OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / c4eabde

Web UI: find a title in a browser and add it to the library (0.20.0)
labs#12. Until now a download could only start from Kodi -- a play, an action row, or a title already on /library. You could not start from a title. GET / search + trending/popular rows (TMDB) GET /web/search?q=&type= poster grid GET /web/title/movie/<id> state + Download (1080/720/480) GET /web/title/series/<id> season tabs, per-episode state + Download, "Get all N missing" for the season POST /web/download _ensure_ingest / _start_season_job -> 303 back Same pipeline and ident (tmdb:<id>) as Kodi's fxl rows, so a web start and a Kodi play share one job key. No new state: held = _library_has (completeness gate included), running/failed = _mp4_jobs, progress = .part size. Unaired episodes (no air date, or in the future) get no button -- _released_episodes' rule, so the page and the season job agree. POST-only actions, strict input validation, LAN-only like /library. Auto-refresh only while a job is running. 28 tests, TMDB and job starters stubbed. Exercised against live TMDB on a spare port: all pages 200, season page ~2s cold (one TMDB call per episode), GET on /web/download = 405. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c4eabde1080ecac302fc7d406941acf19638dcaa
by plankatron <fleeky@gmail.com> · 2026-10-04T23:58:58.000Z

Changed files (2) +576 -6

addon.py +319 -6
……@@ -25,6 +25,7 @@ from urllib.parse import urlparse, unquote, urljoin, parse_qs
2525
2626import requests
2727from flask import Flask, jsonify, request, redirect, abort, Response, send_file
28from werkzeug.exceptions import HTTPException
2829
2930NODE_HELPER = os.path.join(os.path.dirname(os.path.abspath(__file__)),
3031 "node_helper", "fmovies_resolve.mjs")
……@@ -618,7 +619,7 @@ TV_GENRES = [
618619
619620MANIFEST = {
620621 "id": "org.tabertron.vsembed",
621 "version": "0.19.1",
622 "version": "0.20.0",
622623 "name": "Tabertron VsEmbed",
623624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
624625 "types": ["movie", "series"],
……@@ -3705,13 +3706,325 @@ def library_season():
37053706 return redirect("/library", code=303)
37063707
37073708
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
3726WEB_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}
3734body{font:15px/1.5 system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)}
3735header,main{max-width:64rem;margin:0 auto;padding:.75rem 1rem}
3736header{display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:center;
3737border-bottom:1px solid var(--line)}
3738header a{color:inherit;text-decoration:none;font-weight:600}
3739header form{display:flex;gap:.4rem;flex:1;min-width:16rem}
3740input[type=search]{flex:1;min-width:0;padding:.45rem .6rem;font:inherit;
3741border:1px solid var(--line);border-radius:.3rem;background:var(--card);color:inherit}
3742select,button{font:inherit;padding:.4rem .7rem;border:1px solid var(--line);
3743border-radius:.3rem;background:var(--card);color:inherit;cursor:pointer}
3744button.go{background:var(--run);border-color:var(--run);color:#fff}
3745h1{font-size:1.4rem;margin:.5rem 0}
3746h2{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;
3750border-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;
3758color:inherit;text-decoration:none}
3759.tabs a.on{background:var(--run);border-color:var(--run);color:#fff}
3760table{border-collapse:collapse;width:100%}
3761td{padding:.45rem .4rem;border-bottom:1px solid var(--line);vertical-align:middle}
3762td.n{width:3rem;color:var(--mute);font-variant-numeric:tabular-nums}
3763td.act{text-align:right;white-space:nowrap}
3764form.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)}
3767p.warn{color:var(--bad);font-weight:600}
3768"""
3769
3770
3771def _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
3789def _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
3802def _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
3825def _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
3838def _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
3851def _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
37083857@app.route("/")
37093858def 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")
3883def 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
3903def _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
3914def _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
3925def _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>")
3976def 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"])
3993def 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)
37154028
37164029
37174030# The sources this addon knows about. Declared here, at the end, so each entry
test_web_ui.py +257 -0
……@@ -0,0 +1,257 @@
1"""The web UI: find a title in a browser and add it to the library (labs#12).
2
3Until this existed a download could only start from Kodi -- a play, an action
4row, or a title already on /library. This page starts from the TITLE, and feeds
5the same pipeline: _ensure_ingest / _start_season_job, ident `tmdb:<id>` (what
6Kodi's fxl rows use, so both share one job key).
7
8Every action is POST. Kodi fires speculative GETs at this service, and a GET
9must never be able to start a download.
10
11Run: .venv/bin/python3 -m pytest test_web_ui.py -q
12"""
13
14import os
15
16import pytest
17
18import addon
19
20
21TMDB = {
22 "trending/tv/week": {"results": [{"id": 1, "name": "Show",
23 "first_air_date": "2020-01-01"}]},
24 "trending/movie/week": {"results": [{"id": 2, "title": "Film",
25 "release_date": "2021-05-05"}]},
26 "tv/popular": {"results": []},
27 "movie/popular": {"results": []},
28 "search/tv": {"results": [{"id": 1, "name": "Show <b>bold</b>",
29 "first_air_date": "2020-01-01"}]},
30 "search/movie": {"results": [{"id": 2, "title": "Film",
31 "release_date": "2021-05-05"}]},
32 "movie/2": {"title": "Film", "release_date": "2021-05-05",
33 "overview": "A film.", "runtime": 100},
34 "tv/1": {"name": "Show", "first_air_date": "2020-01-01", "overview": "A show.",
35 "seasons": [{"season_number": 0}, {"season_number": 1},
36 {"season_number": 2}]},
37 "tv/1/season/1": {"episodes": [
38 {"episode_number": 1, "name": "Pilot", "air_date": "2020-01-01"},
39 {"episode_number": 2, "name": "Future", "air_date": "2999-01-01"},
40 {"episode_number": 3, "name": "Undated"}]},
41 "tv/1/season/2": {"episodes": [
42 {"episode_number": 1, "name": "Two", "air_date": "2021-01-01"}]},
43 "tv/1/season/1/episode/1": {"name": "Pilot", "runtime": 50},
44 "tv/1/season/1/episode/2": {"name": "Future", "runtime": 50},
45 "tv/1/season/1/episode/3": {"name": "Undated", "runtime": 50},
46 "tv/1/season/2/episode/1": {"name": "Two", "runtime": 50},
47}
48
49
50@pytest.fixture(autouse=True)
51def isolated(tmp_path, monkeypatch):
52 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path))
53 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE",
54 os.path.join(str(tmp_path), "queue.json"))
55 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE",
56 os.path.join(str(tmp_path), "verified.json"))
57 monkeypatch.setattr(addon, "_duration_cache", {})
58 monkeypatch.setattr(addon, "_library_meta_cache", {})
59 monkeypatch.setattr(addon, "_mp4_jobs", {})
60 monkeypatch.setattr(addon, "_season_jobs", {})
61 monkeypatch.setattr(addon, "_enough_space", lambda: True)
62 monkeypatch.setattr(addon, "_tmdb_get",
63 lambda path, params=None: TMDB[path])
64 yield
65
66
67@pytest.fixture
68def started(monkeypatch):
69 """Record what would have been started, without starting a thread -- a
70 real job here would reach vidcore."""
71 calls = {"mp4": [], "season": []}
72 monkeypatch.setattr(addon, "_start_mp4_job",
73 lambda *a, **k: calls["mp4"].append(a) or "running")
74 monkeypatch.setattr(addon, "_start_season_job",
75 lambda *a, **k: calls["season"].append(a) or "running")
76 return calls
77
78
79@pytest.fixture
80def client():
81 return addon.app.test_client()
82
83
84def _hold(kind, season=0, episode=0, tmdb="1"):
85 """Put a complete copy in the library."""
86 meta = addon._library_meta(kind, f"tmdb:{tmdb}", season, episode)
87 path = addon._library_path(kind, meta["title"], meta["year"],
88 meta["tmdb_id"], season, episode,
89 meta["episode_title"])
90 os.makedirs(os.path.dirname(path), exist_ok=True)
91 with open(path, "wb") as f:
92 f.write(b"x" * 1024)
93 return path
94
95
96# --- discovery --------------------------------------------------------------
97
98def test_home_has_a_search_form_and_trending_titles(client):
99 body = client.get("/").get_data(as_text=True)
100 assert "action='/web/search'" in body
101 assert "/web/title/series/1" in body
102 assert "/web/title/movie/2" in body
103
104
105def test_home_keeps_the_install_url_and_the_library_link(client):
106 body = client.get("/").get_data(as_text=True)
107 assert "/manifest.json" in body
108 assert "href='/library'" in body
109
110
111def test_search_lists_matches_linking_to_their_title_page(client):
112 body = client.get("/web/search?q=show&type=series").get_data(as_text=True)
113 assert "/web/title/series/1" in body
114 assert "/web/title/movie/2" not in body
115
116
117def test_search_without_a_type_searches_both(client):
118 body = client.get("/web/search?q=x").get_data(as_text=True)
119 assert "/web/title/series/1" in body and "/web/title/movie/2" in body
120
121
122def test_tmdb_text_is_escaped(client):
123 body = client.get("/web/search?q=show&type=series").get_data(as_text=True)
124 assert "<b>bold</b>" not in body
125 assert "&lt;b&gt;bold&lt;/b&gt;" in body
126
127
128def test_a_tmdb_failure_is_a_message_not_a_crash(client, monkeypatch):
129 def down(path, params=None):
130 raise RuntimeError("tmdb down")
131 monkeypatch.setattr(addon, "_tmdb_get", down)
132 assert client.get("/").status_code == 200
133 r = client.get("/web/title/movie/2")
134 assert r.status_code == 502
135 assert "TMDB" in r.get_data(as_text=True)
136
137
138# --- title pages ------------------------------------------------------------
139
140def test_a_movie_not_held_offers_download(client):
141 body = client.get("/web/title/movie/2").get_data(as_text=True)
142 assert "action='/web/download'" in body
143 assert "name='tmdb' value='2'" in body
144
145
146def test_a_held_movie_says_so_and_offers_no_download(client):
147 _hold("movie", tmdb="2")
148 body = client.get("/web/title/movie/2").get_data(as_text=True)
149 assert "in library" in body
150 assert "action='/web/download'" not in body
151
152
153def test_an_aired_episode_offers_download_and_unaired_ones_do_not(client):
154 body = client.get("/web/title/series/1?s=1").get_data(as_text=True)
155 assert "name='episode' value='1'" in body
156 assert "name='episode' value='2'" not in body # airs 2999
157 assert "name='episode' value='3'" not in body # no air date = unreleased
158 assert body.count("not aired") == 2
159
160
161def test_series_lists_real_seasons_not_specials(client):
162 body = client.get("/web/title/series/1").get_data(as_text=True)
163 assert "?s=1" in body and "?s=2" in body
164 assert "?s=0" not in body
165
166
167def test_season_parameter_selects_the_season(client):
168 body = client.get("/web/title/series/1?s=2").get_data(as_text=True)
169 assert "Two" in body and "Pilot" not in body
170
171
172def test_a_held_episode_says_in_library(client):
173 _hold("series", 1, 1)
174 body = client.get("/web/title/series/1?s=1").get_data(as_text=True)
175 assert "in library" in body
176 assert "name='episode' value='1'" not in body
177
178
179def test_a_running_ingest_shows_progress_and_refreshes(client):
180 addon._mp4_jobs["series:tmdb:1:1:1:1080"] = "running"
181 path = _hold("series", 1, 1)
182 os.rename(path, addon._part_path(path)) # in flight, not finished
183 body = client.get("/web/title/series/1?s=1").get_data(as_text=True)
184 assert "downloading" in body
185 assert "MB" in body
186 assert "http-equiv=refresh" in body
187
188
189def test_a_page_with_nothing_running_does_not_refresh(client):
190 body = client.get("/web/title/series/1?s=1").get_data(as_text=True)
191 assert "http-equiv=refresh" not in body
192
193
194def test_a_failed_ingest_says_why_and_can_be_retried(client):
195 addon._mp4_jobs["movie:tmdb:2:0:0:1080"] = "failed: could not resolve title"
196 body = client.get("/web/title/movie/2").get_data(as_text=True)
197 assert "could not resolve title" in body
198 assert "action='/web/download'" in body
199
200
201def test_an_unknown_kind_is_404(client):
202 assert client.get("/web/title/anime/1").status_code == 404
203
204
205# --- the action -------------------------------------------------------------
206
207def test_download_an_episode_starts_one_job_with_the_tmdb_ident(client, started):
208 r = client.post("/web/download", data={
209 "kind": "series", "tmdb": "1", "season": "1", "episode": "1",
210 "height": "720"})
211 assert r.status_code == 303
212 assert r.headers["Location"].endswith("/web/title/series/1?s=1")
213 assert started["mp4"] == [("series", "tmdb:1", 1, 1, 720)]
214
215
216def test_download_a_movie(client, started):
217 r = client.post("/web/download", data={"kind": "movie", "tmdb": "2"})
218 assert r.status_code == 303
219 assert r.headers["Location"].endswith("/web/title/movie/2")
220 assert started["mp4"] == [("movie", "tmdb:2", 0, 0, 1080)]
221
222
223def test_download_of_a_held_title_starts_nothing(client, started):
224 _hold("movie", tmdb="2")
225 client.post("/web/download", data={"kind": "movie", "tmdb": "2"})
226 assert started["mp4"] == []
227
228
229def test_download_a_season(client, started):
230 client.post("/web/download", data={
231 "kind": "series", "tmdb": "1", "season": "1", "scope": "season"})
232 assert started["season"] == [("tmdb:1", 1, 1080)]
233 assert started["mp4"] == []
234
235
236def test_a_get_can_never_start_a_download(client, started):
237 r = client.get("/web/download?kind=movie&tmdb=2")
238 assert r.status_code == 405
239 assert started["mp4"] == [] and started["season"] == []
240
241
242@pytest.mark.parametrize("data", [
243 {"kind": "movie", "tmdb": "2x"},
244 {"kind": "movie", "tmdb": "../2"},
245 {"kind": "anime", "tmdb": "2"},
246 {"kind": "movie", "tmdb": "2", "height": "2160"},
247 {"kind": "series", "tmdb": "1", "season": "one", "episode": "1"},
248 {"kind": "series", "tmdb": "1", "season": "0", "episode": "0"},
249])
250def test_bad_input_is_400_and_starts_nothing(client, started, data):
251 assert client.post("/web/download", data=data).status_code == 400
252 assert started["mp4"] == [] and started["season"] == []
253
254
255def test_a_bad_season_parameter_is_400_not_a_tmdb_error(client):
256 """abort() inside the page must not be swallowed into the 502 path."""
257 assert client.get("/web/title/series/1?s=abc").status_code == 400