| … | … | @@ -25,6 +25,7 @@ from urllib.parse import urlparse, unquote, urljoin, parse_qs |
| 25 | 25 | |
| 26 | 26 | import requests |
| 27 | 27 | from flask import Flask, jsonify, request, redirect, abort, Response, send_file |
| 28 | from werkzeug.exceptions import HTTPException |
| 28 | 29 | |
| 29 | 30 | NODE_HELPER = os.path.join(os.path.dirname(os.path.abspath(__file__)), |
| 30 | 31 | "node_helper", "fmovies_resolve.mjs") |
| … | … | @@ -618,7 +619,7 @@ TV_GENRES = [ |
| 618 | 619 | |
| 619 | 620 | MANIFEST = { |
| 620 | 621 | "id": "org.tabertron.vsembed", |
| 621 | | "version": "0.19.1", |
| 622 | "version": "0.20.0", |
| 622 | 623 | "name": "Tabertron VsEmbed", |
| 623 | 624 | "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub", |
| 624 | 625 | "types": ["movie", "series"], |
| … | … | @@ -3705,13 +3706,325 @@ def library_season(): |
| 3705 | 3706 | return redirect("/library", code=303) |
| 3706 | 3707 | |
| 3707 | 3708 | |
| 3709 | # --------------------------------- web UI ----------------------------------- |
| 3710 | # Add a title to the library from a browser, separately from Kodi (labs#12). |
| 3711 | # Until this existed a download could only start from Kodi -- a play, an action |
| 3712 | # row, or a title already listed on /library. You could not start from a title. |
| 3713 | # |
| 3714 | # It feeds the same pipeline as everything else -- _ensure_ingest and |
| 3715 | # _start_season_job -- with ident `tmdb:<id>`, which is what Kodi's fxl rows |
| 3716 | # send, so a download started here and a play started there share a job key. |
| 3717 | # |
| 3718 | # Nothing here stores state. What a page shows is read from the same places the |
| 3719 | # rest of the addon already trusts: _library_has for "in library", _mp4_jobs for |
| 3720 | # running / failed, the .part size for progress. |
| 3721 | # |
| 3722 | # Every action is POST, like /library: Kodi fires speculative GETs at this |
| 3723 | # service, and a GET must never be able to start a download. LAN only, no auth |
| 3724 | # -- the same exposure /library has always had. |
| 3725 | |
| 3726 | WEB_REFRESH_SECONDS = 10 |
| 3727 | |
| 3728 | _WEB_CSS = """ |
| 3729 | :root{color-scheme:light dark;--bg:#fafafa;--fg:#1d1d1f;--mute:#666;--line:#ddd; |
| 3730 | --card:#fff;--ok:#2e7d32;--run:#1565c0;--bad:#c62828} |
| 3731 | @media(prefers-color-scheme:dark){:root{--bg:#121212;--fg:#e8e8e8;--mute:#9a9a9a; |
| 3732 | --line:#333;--card:#1c1c1c;--ok:#66bb6a;--run:#64b5f6;--bad:#ef5350}} |
| 3733 | *{box-sizing:border-box} |
| 3734 | body{font:15px/1.5 system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)} |
| 3735 | header,main{max-width:64rem;margin:0 auto;padding:.75rem 1rem} |
| 3736 | header{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:center; |
| 3737 | border-bottom:1px solid var(--line)} |
| 3738 | header a{color:inherit;text-decoration:none;font-weight:600} |
| 3739 | header form{display:flex;gap:.4rem;flex:1;min-width:16rem} |
| 3740 | input[type=search]{flex:1;min-width:0;padding:.45rem .6rem;font:inherit; |
| 3741 | border:1px solid var(--line);border-radius:.3rem;background:var(--card);color:inherit} |
| 3742 | select,button{font:inherit;padding:.4rem .7rem;border:1px solid var(--line); |
| 3743 | border-radius:.3rem;background:var(--card);color:inherit;cursor:pointer} |
| 3744 | button.go{background:var(--run);border-color:var(--run);color:#fff} |
| 3745 | h1{font-size:1.4rem;margin:.5rem 0} |
| 3746 | h2{font-size:1.05rem;margin:1.2rem 0 .5rem} |
| 3747 | .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.8rem} |
| 3748 | .card{display:block;color:inherit;text-decoration:none} |
| 3749 | .card img,.card .noimg{width:100%;aspect-ratio:2/3;object-fit:cover; |
| 3750 | border-radius:.3rem;background:var(--line);display:block} |
| 3751 | .card span{display:block;font-size:.85rem;margin-top:.3rem} |
| 3752 | .title{display:flex;gap:1rem;flex-wrap:wrap} |
| 3753 | .title img{width:10rem;border-radius:.3rem} |
| 3754 | .title .about{flex:1;min-width:16rem} |
| 3755 | .mute{color:var(--mute)} |
| 3756 | .tabs{display:flex;flex-wrap:wrap;gap:.3rem;margin:.8rem 0} |
| 3757 | .tabs a{padding:.2rem .6rem;border:1px solid var(--line);border-radius:.3rem; |
| 3758 | color:inherit;text-decoration:none} |
| 3759 | .tabs a.on{background:var(--run);border-color:var(--run);color:#fff} |
| 3760 | table{border-collapse:collapse;width:100%} |
| 3761 | td{padding:.45rem .4rem;border-bottom:1px solid var(--line);vertical-align:middle} |
| 3762 | td.n{width:3rem;color:var(--mute);font-variant-numeric:tabular-nums} |
| 3763 | td.act{text-align:right;white-space:nowrap} |
| 3764 | form.dl{display:inline-flex;gap:.3rem;margin:0} |
| 3765 | .st{font-size:.85rem}.st.held{color:var(--ok)}.st.running{color:var(--run)} |
| 3766 | .st.failed{color:var(--bad)} |
| 3767 | p.warn{color:var(--bad);font-weight:600} |
| 3768 | """ |
| 3769 | |
| 3770 | |
| 3771 | def _web_page(title: str, body: str, refresh: bool = False, |
| 3772 | status: int = 200) -> Response: |
| 3773 | head = (f"<!doctype html><meta charset=utf-8>" |
| 3774 | f"<meta name=viewport content='width=device-width,initial-scale=1'>" |
| 3775 | + (f"<meta http-equiv=refresh content={WEB_REFRESH_SECONDS}>" |
| 3776 | if refresh else "") |
| 3777 | + f"<title>{html.escape(title)}</title><style>{_WEB_CSS}</style>") |
| 3778 | nav = ("<header><a href='/'>vsembed</a><a href='/library'>Library</a>" |
| 3779 | "<form method=get action='/web/search'>" |
| 3780 | "<input type=search name=q placeholder='Find a film or show' required>" |
| 3781 | "<select name=type><option value=''>All</option>" |
| 3782 | "<option value=series>Series</option>" |
| 3783 | "<option value=movie>Movies</option></select>" |
| 3784 | "<button>Search</button></form></header>") |
| 3785 | return Response(head + nav + f"<main>{body}</main>", status=status, |
| 3786 | mimetype="text/html") |
| 3787 | |
| 3788 | |
| 3789 | def _web_cards(ctype: str, metas: list[dict]) -> str: |
| 3790 | out = [] |
| 3791 | for m in metas: |
| 3792 | tmdb = str(m.get("id") or "").removeprefix("fxl:") |
| 3793 | if not tmdb.isdigit(): |
| 3794 | continue |
| 3795 | img = (f"<img loading=lazy alt='' src='{html.escape(m['poster'])}'>" |
| 3796 | if m.get("poster") else "<div class=noimg></div>") |
| 3797 | out.append(f"<a class=card href='/web/title/{ctype}/{tmdb}'>{img}" |
| 3798 | f"<span>{html.escape(m.get('name') or tmdb)}</span></a>") |
| 3799 | return "<div class=grid>" + "".join(out) + "</div>" |
| 3800 | |
| 3801 | |
| 3802 | def _web_state(kind: str, tmdb: str, season: int = 0, |
| 3803 | episode: int = 0) -> dict: |
| 3804 | """What the library knows about one file: held, running, failed or none. |
| 3805 | |
| 3806 | A "done" job whose file is gone (or short -- _library_has applies the |
| 3807 | completeness gate) reads as "none", so it can simply be asked for again. |
| 3808 | Jobs are looked up at every height: the library holds one copy per episode |
| 3809 | whatever height fetched it. |
| 3810 | """ |
| 3811 | ident = f"tmdb:{tmdb}" |
| 3812 | if _library_has(kind, ident, season, episode): |
| 3813 | return {"status": "held"} |
| 3814 | states = [_mp4_jobs.get(_mp4_key(kind, ident, season, episode, h)) |
| 3815 | for h in VIDCORE_HEIGHTS] |
| 3816 | if "running" in states: |
| 3817 | return {"status": "running", |
| 3818 | "bytes": _ingest_progress(kind, ident, season, episode)} |
| 3819 | failed = next((s for s in states if s and s.startswith("failed")), None) |
| 3820 | if failed: |
| 3821 | return {"status": "failed", "why": failed} |
| 3822 | return {"status": "none"} |
| 3823 | |
| 3824 | |
| 3825 | def _web_state_html(state: dict) -> str: |
| 3826 | s = state["status"] |
| 3827 | if s == "held": |
| 3828 | return "<span class='st held'>in library</span>" |
| 3829 | if s == "running": |
| 3830 | n = state.get("bytes") |
| 3831 | got = f" · {n / 1e6:.0f} MB" if n else " · starting" |
| 3832 | return f"<span class='st running'>downloading{got}</span>" |
| 3833 | if s == "failed": |
| 3834 | return f"<span class='st failed'>{html.escape(state['why'])}</span>" |
| 3835 | return "" |
| 3836 | |
| 3837 | |
| 3838 | def _web_download_form(kind: str, tmdb: str, season: int = 0, episode: int = 0, |
| 3839 | scope: str = "one", label: str = "Download") -> str: |
| 3840 | heights = "".join(f"<option value={h}>{h}p</option>" for h in VIDCORE_HEIGHTS) |
| 3841 | return ("<form class=dl method=post action='/web/download'>" |
| 3842 | f"<input type=hidden name='kind' value='{kind}'>" |
| 3843 | f"<input type=hidden name='tmdb' value='{tmdb}'>" |
| 3844 | f"<input type=hidden name='season' value='{season}'>" |
| 3845 | f"<input type=hidden name='episode' value='{episode}'>" |
| 3846 | f"<input type=hidden name='scope' value='{scope}'>" |
| 3847 | f"<select name=height aria-label=Quality>{heights}</select>" |
| 3848 | f"<button class=go>{label}</button></form>") |
| 3849 | |
| 3850 | |
| 3851 | def _web_low_space() -> str: |
| 3852 | return ("" if _enough_space() else |
| 3853 | "<p class=warn>The library volume is under its free-space floor — " |
| 3854 | "downloads will decline until space is cleared.</p>") |
| 3855 | |
| 3856 | |
| 3708 | 3857 | @app.route("/") |
| 3709 | 3858 | def index(): |
| 3710 | | return ( |
| 3711 | | "Tabertron VsEmbed Stremio addon.<br>" |
| 3712 | | "Install URL: <code>/manifest.json</code><br>" |
| 3713 | | "Fetched copies: <a href='/library'>/library</a>" |
| 3714 | | ) |
| 3859 | rows = [] |
| 3860 | for label, ctype, kind in (("Trending series", "series", "trending"), |
| 3861 | ("Trending films", "movie", "trending"), |
| 3862 | ("Popular series", "series", "popular"), |
| 3863 | ("Popular films", "movie", "popular")): |
| 3864 | try: |
| 3865 | metas = _tmdb_browse(ctype, kind) |
| 3866 | except Exception as exc: # one dead row, not a dead page |
| 3867 | log.warning("web: %s browse failed: %s", label, exc) |
| 3868 | continue |
| 3869 | if metas: |
| 3870 | rows.append(f"<h2>{label}</h2>" + _web_cards(ctype, metas[:12])) |
| 3871 | if not rows: |
| 3872 | rows.append("<p class=mute>TMDB did not answer — search still works " |
| 3873 | "once it does.</p>") |
| 3874 | body = ("<p class=mute>Find a title and download it into the library on " |
| 3875 | "stortron; Jellyfin and Kodi pick it up from there. " |
| 3876 | "Fetched so far: <a href='/library'>/library</a> · " |
| 3877 | "Stremio install URL: <code>/manifest.json</code></p>" |
| 3878 | + "".join(rows)) |
| 3879 | return _web_page("vsembed", body) |
| 3880 | |
| 3881 | |
| 3882 | @app.route("/web/search") |
| 3883 | def web_search(): |
| 3884 | query = (request.args.get("q") or "").strip() |
| 3885 | ctype = request.args.get("type") or "" |
| 3886 | types = [ctype] if ctype in ("series", "movie") else ["series", "movie"] |
| 3887 | if not query: |
| 3888 | return redirect("/", code=303) |
| 3889 | parts = [f"<h1>Results for “{html.escape(query)}”</h1>"] |
| 3890 | for t in types: |
| 3891 | try: |
| 3892 | metas = _tmdb_search(t, query) |
| 3893 | except Exception as exc: |
| 3894 | log.warning("web: search %r (%s) failed: %s", query, t, exc) |
| 3895 | parts.append(f"<p class=warn>TMDB search failed for {t}.</p>") |
| 3896 | continue |
| 3897 | heading = "Series" if t == "series" else "Films" |
| 3898 | parts.append(f"<h2>{heading}</h2>" + (_web_cards(t, metas) if metas else |
| 3899 | "<p class=mute>Nothing found.</p>")) |
| 3900 | return _web_page(f"{query} — vsembed", "".join(parts)) |
| 3901 | |
| 3902 | |
| 3903 | def _web_header(show: dict) -> str: |
| 3904 | name = show.get("name") or show.get("title") or "" |
| 3905 | year = (show.get("first_air_date") or show.get("release_date") or "")[:4] |
| 3906 | poster = _tmdb_poster(show.get("poster_path")) |
| 3907 | img = f"<img alt='' src='{html.escape(poster)}'>" if poster else "" |
| 3908 | return (f"<div class=title>{img}<div class=about>" |
| 3909 | f"<h1>{html.escape(name)}" |
| 3910 | + (f" <span class=mute>({year})</span>" if year else "") + "</h1>" |
| 3911 | f"<p>{html.escape(show.get('overview') or '')}</p>") |
| 3912 | |
| 3913 | |
| 3914 | def _web_movie(tmdb: str) -> Response: |
| 3915 | film = _tmdb_get(f"movie/{tmdb}") |
| 3916 | state = _web_state("movie", tmdb) |
| 3917 | action = ("" if state["status"] in ("held", "running") |
| 3918 | else _web_download_form("movie", tmdb)) |
| 3919 | body = (_web_header(film) + _web_low_space() |
| 3920 | + f"<p>{_web_state_html(state)} {action}</p></div></div>") |
| 3921 | return _web_page(film.get("title") or tmdb, body, |
| 3922 | refresh=state["status"] == "running") |
| 3923 | |
| 3924 | |
| 3925 | def _web_series(tmdb: str) -> Response: |
| 3926 | show = _tmdb_get(f"tv/{tmdb}") |
| 3927 | seasons = [s["season_number"] for s in show.get("seasons") or [] |
| 3928 | if isinstance(s.get("season_number"), int) |
| 3929 | and s["season_number"] >= 1] |
| 3930 | try: |
| 3931 | season = int(request.args.get("s") or (seasons[0] if seasons else 1)) |
| 3932 | except ValueError: |
| 3933 | abort(400) |
| 3934 | tabs = "".join( |
| 3935 | f"<a href='/web/title/series/{tmdb}?s={n}'" |
| 3936 | + (" class=on" if n == season else "") + f">Season {n}</a>" |
| 3937 | for n in seasons) |
| 3938 | episodes = _tmdb_get(f"tv/{tmdb}/season/{season}").get("episodes") or [] |
| 3939 | today = time.strftime("%Y-%m-%d") |
| 3940 | ident = f"tmdb:{tmdb}" |
| 3941 | running = any(_season_jobs.get(f"season:{ident}:{season}:{h}") == "running" |
| 3942 | for h in VIDCORE_HEIGHTS) |
| 3943 | rows, aired_missing = [], 0 |
| 3944 | for ep in episodes: |
| 3945 | num = ep.get("episode_number") |
| 3946 | if not isinstance(num, int): |
| 3947 | continue |
| 3948 | air = (ep.get("air_date") or "").strip() |
| 3949 | name = html.escape(ep.get("name") or f"Episode {num}") |
| 3950 | # By air date, never by count -- the rule _released_episodes uses, so |
| 3951 | # this page and the season job agree on what exists. |
| 3952 | if not air or air > today: |
| 3953 | rows.append(f"<tr><td class=n>{num}</td><td>{name}</td>" |
| 3954 | "<td class=act><span class='st mute'>not aired</span>" |
| 3955 | "</td></tr>") |
| 3956 | continue |
| 3957 | state = _web_state("series", tmdb, season, num) |
| 3958 | running = running or state["status"] == "running" |
| 3959 | if state["status"] not in ("held", "running"): |
| 3960 | aired_missing += 1 |
| 3961 | action = ("" if state["status"] in ("held", "running") |
| 3962 | else _web_download_form("series", tmdb, season, num)) |
| 3963 | rows.append(f"<tr><td class=n>{num}</td><td>{name}<br>" |
| 3964 | f"<span class=mute>{air}</span></td>" |
| 3965 | f"<td class=act>{_web_state_html(state)} {action}</td></tr>") |
| 3966 | season_action = ( |
| 3967 | f"<p>{_web_download_form('series', tmdb, season, 0, 'season', f'Get all {aired_missing} missing')}</p>" |
| 3968 | if aired_missing > 1 else "") |
| 3969 | body = (_web_header(show) + _web_low_space() + "</div></div>" |
| 3970 | + f"<div class=tabs>{tabs}</div>" + season_action |
| 3971 | + "<table>" + "".join(rows) + "</table>") |
| 3972 | return _web_page(show.get("name") or tmdb, body, refresh=running) |
| 3973 | |
| 3974 | |
| 3975 | @app.route("/web/title/<kind>/<tmdb>") |
| 3976 | def web_title(kind: str, tmdb: str): |
| 3977 | if kind not in ("movie", "series") or not tmdb.isdigit(): |
| 3978 | abort(404) |
| 3979 | try: |
| 3980 | return _web_movie(tmdb) if kind == "movie" else _web_series(tmdb) |
| 3981 | except HTTPException: |
| 3982 | raise # our own abort(), not TMDB's |
| 3983 | except Exception: |
| 3984 | # Broad like the catalog routes: every failure here is a TMDB call. |
| 3985 | # The traceback goes to the journal so a real bug is not filed as one. |
| 3986 | log.exception("web: title page failed for %s/%s", kind, tmdb) |
| 3987 | return _web_page("TMDB unavailable", |
| 3988 | "<p class=warn>TMDB did not answer for this title. " |
| 3989 | "Reload in a moment.</p>", status=502) |
| 3990 | |
| 3991 | |
| 3992 | @app.route("/web/download", methods=["POST"]) |
| 3993 | def web_download(): |
| 3994 | """POST only -- see the section comment. Validated strictly: these values |
| 3995 | become a job key, a TMDB path and, via _library_path, a filename.""" |
| 3996 | f = request.form |
| 3997 | kind, tmdb = f.get("kind") or "", f.get("tmdb") or "" |
| 3998 | if kind not in ("movie", "series") or not tmdb.isdigit(): |
| 3999 | abort(400) |
| 4000 | try: |
| 4001 | season = int(f.get("season") or 0) |
| 4002 | episode = int(f.get("episode") or 0) |
| 4003 | height = int(f.get("height") or VIDCORE_MAX_HEIGHT) |
| 4004 | except ValueError: |
| 4005 | abort(400) |
| 4006 | if height not in VIDCORE_HEIGHTS: |
| 4007 | abort(400) |
| 4008 | ident = f"tmdb:{tmdb}" |
| 4009 | scope = f.get("scope") or "one" |
| 4010 | back = f"/web/title/{kind}/{tmdb}" |
| 4011 | if kind == "movie": |
| 4012 | season = episode = 0 |
| 4013 | _ensure_ingest("movie", ident, 0, 0, height) |
| 4014 | elif scope == "season": |
| 4015 | if season < 1: |
| 4016 | abort(400) |
| 4017 | state = _start_season_job(ident, season, height) |
| 4018 | log.info("web: season fetch %s S%02d -> %s", ident, season, state) |
| 4019 | else: |
| 4020 | if season < 1 or episode < 1: |
| 4021 | abort(400) |
| 4022 | _ensure_ingest("series", ident, season, episode, height) |
| 4023 | log.info("web: download requested %s:%s:%s:%s @%sp", |
| 4024 | kind, ident, season, episode, height) |
| 4025 | if kind == "series": |
| 4026 | back += f"?s={season}" |
| 4027 | return redirect(back, code=303) |
| 3715 | 4028 | |
| 3716 | 4029 | |
| 3717 | 4030 | # The sources this addon knows about. Declared here, at the end, so each entry |