"""Seam tests for the isolated /api/video blueprint (experimental branch). Verifies the blueprint builds with its route, the dashboard endpoint returns real (zeroed) JSON against an empty video.db, and that the video API package imports nothing from the music side. """ from __future__ import annotations from pathlib import Path from flask import Flask def _make_client(tmp_path): # Inject a tmp-backed DB directly so the endpoint never falls back to the # real default path (no stray database/video_library.db in the repo). import api.video as videoapi from database.video_database import VideoDatabase videoapi._video_db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) app = Flask(__name__) app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") return app.test_client(), videoapi def test_blueprint_exposes_dashboard_route(): from api.video import create_video_blueprint app = Flask(__name__) app.register_blueprint(create_video_blueprint(), url_prefix="/api/video") rules = {r.rule for r in app.url_map.iter_rules()} assert "/api/video/dashboard" in rules assert "/api/video/scan/request" in rules assert "/api/video/scan/status" in rules assert "/api/video/scan/stop" in rules assert "/api/video/library" in rules assert "/api/video/libraries" in rules assert "/api/video/server" in rules assert any(r.startswith("/api/video/poster/") for r in rules) assert "/api/video/enrichment/services" in rules assert "/api/video/enrichment//status" in rules assert "/api/video/enrichment//unmatched" in rules assert "/api/video/enrichment/config" in rules assert "/api/video/enrichment//test" in rules assert "/api/video/detail/show/" in rules assert "/api/video/detail/movie/" in rules assert "/api/video/detail/show//refresh-art" in rules assert "/api/video/detail/movie//refresh-art" in rules assert "/api/video/detail///extras" in rules assert "/api/video/search" in rules assert "/api/video/trending" in rules assert "/api/video/tmdb//" in rules assert "/api/video/tmdb/show//season/" in rules assert "/api/video/person/" in rules assert "/api/video/episode///" in rules assert any(r.startswith("/api/video/backdrop/") for r in rules) assert "/api/video/img" in rules assert "/api/video/discover/hero" in rules assert "/api/video/discover/genres" in rules assert "/api/video/discover/taste" in rules assert "/api/video/discover/list" in rules assert "/api/video/discover/morelike" in rules assert "/api/video/discover/trailer" in rules def test_discover_trailer_returns_key(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) import core.video.enrichment.engine as eng_mod class FakeEng: def trailer(self, kind, tmdb_id): return {"key": "abc123", "name": "Official"} monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng()) assert client.get("/api/video/discover/trailer?kind=movie&tmdb_id=5").get_json()["trailer"]["key"] == "abc123" # a non-numeric id is rejected without touching the engine assert client.get("/api/video/discover/trailer?kind=movie").get_json() == {"trailer": None} def test_discover_morelike_builds_seeded_rails(tmp_path, monkeypatch): client, vapi = _make_client(tmp_path) db = vapi._video_db db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "tmdb_id": 1, "file": {"relative_path": "a.mkv"}}) import core.video.enrichment.engine as eng_mod class FakeEng: def recommendations(self, kind, tmdb_id, page=1): return [{"kind": "movie", "tmdb_id": 100 + i} for i in range(6)] monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng()) rails = client.get("/api/video/discover/morelike").get_json()["rails"] assert rails and rails[0]["title"] == "More like Dune" assert len(rails[0]["items"]) == 6 def test_discover_list_pages_concatenates_and_dedupes(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) import core.video.enrichment.engine as eng_mod class FakeEng: def discover_curated(self, key, page=1): # page 1 → ids 1,2 ; page 2 → ids 2,3 (overlap on 2) return ([{"kind": "movie", "tmdb_id": 1}, {"kind": "movie", "tmdb_id": 2}] if page == 1 else [{"kind": "movie", "tmdb_id": 2}, {"kind": "movie", "tmdb_id": 3}]) monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng()) r = client.get("/api/video/discover/list?key=popular_movies&pages=2") assert [it["tmdb_id"] for it in r.get_json()["items"]] == [1, 2, 3] # concatenated + deduped def test_discover_list_trending_fetches_once_despite_pages(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) import core.video.enrichment.engine as eng_mod calls = {"n": 0} class FakeEng: def trending(self): calls["n"] += 1 return [{"kind": "movie", "tmdb_id": 9}] monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng()) r = client.get("/api/video/discover/list?key=trending&pages=3") assert calls["n"] == 1 # fixed list — not refetched per page assert [it["tmdb_id"] for it in r.get_json()["items"]] == [9] def test_img_proxy_rejects_non_tmdb(tmp_path): client, _ = _make_client(tmp_path) assert client.get("/api/video/img?u=https://evil.example.com/x.jpg").status_code == 404 assert client.get("/api/video/img").status_code == 404 def test_search_endpoint_empty_query(tmp_path): client, _ = _make_client(tmp_path) resp = client.get("/api/video/search?q=") assert resp.status_code == 200 assert resp.get_json() == {"results": [], "query": ""} def test_search_endpoint_uses_engine(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) class FakeEngine: def search(self, q): return [{"kind": "movie", "tmdb_id": 1, "title": "Dune", "library_id": None}] monkeypatch.setattr("core.video.enrichment.engine.get_video_enrichment_engine", lambda: FakeEngine()) body = client.get("/api/video/search?q=dune").get_json() assert body["query"] == "dune" and body["results"][0]["title"] == "Dune" def test_tmdb_detail_endpoint(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) class FakeEngine: def tmdb_detail(self, kind, tid): return {"source": "tmdb", "kind": kind, "id": tid, "title": "X"} monkeypatch.setattr("core.video.enrichment.engine.get_video_enrichment_engine", lambda: FakeEngine()) resp = client.get("/api/video/tmdb/movie/438631") assert resp.status_code == 200 and resp.get_json()["source"] == "tmdb" assert client.get("/api/video/tmdb/bogus/1").status_code == 400 def test_omdb_key_change_retries_unrated(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) db = videoapi._video_db mid = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"}) db.apply_ratings("movie", mid, {}) # burned: synced, but no rating assert db.ratings_next() is None # not pending monkeypatch.setattr("core.video.enrichment.engine.rebuild_video_enrichment_engine", lambda: None) resp = client.post("/api/video/enrichment/config", json={"omdb_api_key": "NEWKEY"}) assert resp.status_code == 200 assert db.ratings_next() is not None # new key → re-queued for rating def test_show_detail_endpoint(tmp_path): client, videoapi = _make_client(tmp_path) try: sid = videoapi._video_db.upsert_show_tree("plex", { "server_id": "s1", "title": "Show", "seasons": [ {"season_number": 1, "episodes": [ {"episode_number": 1, "title": "Pilot", "file": {"relative_path": "e1.mkv", "size_bytes": 5}}]}]}) resp = client.get("/api/video/detail/show/%d" % sid) assert resp.status_code == 200 d = resp.get_json() assert d["kind"] == "show" and d["episode_total"] == 1 and d["episode_owned"] == 1 assert d["seasons"][0]["episodes"][0]["title"] == "Pilot" assert client.get("/api/video/detail/show/999999").status_code == 404 finally: videoapi._video_db = None def test_enrichment_priority_endpoint(tmp_path): client, videoapi = _make_client(tmp_path) try: assert client.get("/api/video/enrichment/priority").get_json()["priority"] == "" r = client.post("/api/video/enrichment/priority", json={"priority": "show"}) assert r.status_code == 200 and r.get_json()["priority"] == "show" assert client.get("/api/video/enrichment/priority").get_json()["priority"] == "show" assert client.post("/api/video/enrichment/priority", json={"priority": "bogus"}).status_code == 400 finally: videoapi._video_db = None def test_monitor_toggle_endpoint(tmp_path): client, videoapi = _make_client(tmp_path) try: sid = videoapi._video_db.upsert_show_tree("plex", {"server_id": "s1", "title": "S"}) r = client.post("/api/video/monitor", json={"kind": "show", "id": sid, "monitored": False}) assert r.status_code == 200 and r.get_json()["monitored"] is False assert videoapi._video_db.show_detail(sid)["monitored"] is False r2 = client.post("/api/video/monitor", json={"kind": "show", "id": sid, "monitored": True}) assert r2.status_code == 200 and videoapi._video_db.show_detail(sid)["monitored"] is True # bad inputs assert client.post("/api/video/monitor", json={"kind": "bogus", "id": sid}).status_code == 400 assert client.post("/api/video/monitor", json={"kind": "show", "id": 999999, "monitored": True}).status_code == 404 finally: videoapi._video_db = None def test_movie_detail_endpoint(tmp_path): client, videoapi = _make_client(tmp_path) try: mid = videoapi._video_db.upsert_movie("plex", {"server_id": "m1", "title": "Dune"}) resp = client.get("/api/video/detail/movie/%d" % mid) assert resp.status_code == 200 assert resp.get_json()["title"] == "Dune" assert client.get("/api/video/detail/movie/999999").status_code == 404 finally: videoapi._video_db = None def test_dashboard_endpoint_returns_zeroed_json(tmp_path): client, videoapi = _make_client(tmp_path) try: resp = client.get("/api/video/dashboard") assert resp.status_code == 200 data = resp.get_json() assert data["library"]["movies"] == 0 assert data["downloads"]["active"] == 0 assert data["watchlist"] == 0 and data["wishlist"] == 0 finally: videoapi._video_db = None # don't leak the tmp DB to other tests def test_library_endpoint_lists_content(tmp_path): client, videoapi = _make_client(tmp_path) try: videoapi._video_db.upsert_movie("plex", {"server_id": "m1", "title": "A", "poster_url": "/library/metadata/1/thumb/9"}) resp = client.get("/api/video/library?kind=movies") assert resp.status_code == 200 data = resp.get_json() assert [m["title"] for m in data["items"]] == ["A"] assert data["items"][0]["has_poster"] is True # flag, not the raw path assert "poster_url" not in data["items"][0] # don't leak server paths assert data["pagination"]["total_count"] == 1 finally: videoapi._video_db = None def test_libraries_endpoint_lists_and_saves(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) try: import core.video.sources as vs monkeypatch.setattr(vs, "list_video_libraries", lambda: { "server": "plex", "movies": [{"title": "Movies"}], "tv": [{"title": "TV"}]}) import config.settings as cs monkeypatch.setattr(cs.config_manager, "get_active_media_server", lambda: "plex") data = client.get("/api/video/libraries").get_json() assert data["server"] == "plex" assert [m["title"] for m in data["movies"]] == ["Movies"] assert data["selected"]["movies"] is None assert client.post("/api/video/libraries", json={"movies": "Movies", "tv": "TV"}).status_code == 200 data2 = client.get("/api/video/libraries").get_json() assert data2["selected"] == {"movies": "Movies", "tv": "TV"} finally: videoapi._video_db = None def test_enrichment_endpoints(tmp_path): import api.video as videoapi from database.video_database import VideoDatabase import core.video.enrichment.engine as eng_mod from core.video.enrichment.engine import VideoEnrichmentEngine class FakeClient: enabled = True def match(self, *a, **k): return None def test(self): return (True, "ok") db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) videoapi._video_db = db eng_mod._engine = VideoEnrichmentEngine(db, {"tmdb": FakeClient(), "tvdb": FakeClient()}) app = Flask(__name__) app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") client = app.test_client() try: svc = client.get("/api/video/enrichment/services").get_json() ids = {s["id"] for s in svc["services"]} assert {"tmdb", "tvdb"} <= ids # matcher workers assert {"ryd", "sponsorblock", "fanart", "opensubtitles"} <= ids # backfill workers mid = db.upsert_movie("plex", {"server_id": "m1", "title": "X"}) st = client.get("/api/video/enrichment/tmdb/status").get_json() assert st["enabled"] is True and st["stats"]["pending"] == 1 db.enrichment_apply("tmdb", "movie", mid, matched=False) bd = client.get("/api/video/enrichment/tmdb/breakdown").get_json() assert bd["breakdown"]["movie"]["not_found"] == 1 un = client.get("/api/video/enrichment/tmdb/unmatched?kind=movie&status=not_found").get_json() assert un["total"] == 1 and un["kind"] == "movie" assert client.post("/api/video/enrichment/tmdb/pause").get_json()["status"] == "paused" assert client.post("/api/video/enrichment/tmdb/resume").get_json()["status"] == "running" assert client.post("/api/video/enrichment/tmdb/retry", json={"kind": "movie", "scope": "failed"}).get_json()["reset"] == 1 assert client.post("/api/video/enrichment/tmdb/test").get_json()["success"] is True assert client.post("/api/video/enrichment/nope/test").status_code == 404 assert client.get("/api/video/enrichment/nope/status").status_code == 404 finally: videoapi._video_db = None eng_mod._engine = None def test_enrichment_config_save_load(tmp_path, monkeypatch): import api.video as videoapi from database.video_database import VideoDatabase import core.video.enrichment.engine as eng_mod # Don't build a real engine (would open the default-path DB + start threads). monkeypatch.setattr(eng_mod, "rebuild_video_enrichment_engine", lambda: None) db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) videoapi._video_db = db app = Flask(__name__) app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") client = app.test_client() try: assert client.get("/api/video/enrichment/config").get_json() == { "tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": "", "fanart_api_key": "", "opensubtitles_api_key": "", "trakt_api_key": "", "ryd_enabled": True, "sponsorblock_enabled": True, "dearrow_enabled": True, "tvmaze_enabled": True, "anilist_enabled": False, "wikidata_enabled": True, "billboard_autoplay": True, "watch_region": "US"} client.post("/api/video/enrichment/config", json={"tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om", "fanart_api_key": "fa", "opensubtitles_api_key": "os", "ryd_enabled": False, "sponsorblock_enabled": True, "billboard_autoplay": False, "watch_region": "gb"}) assert client.get("/api/video/enrichment/config").get_json() == { "tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om", "fanart_api_key": "fa", "opensubtitles_api_key": "os", "trakt_api_key": "", "ryd_enabled": False, "sponsorblock_enabled": True, "dearrow_enabled": True, "tvmaze_enabled": True, "anilist_enabled": False, "wikidata_enabled": True, "billboard_autoplay": False, "watch_region": "GB"} assert db.get_setting("tmdb_api_key") == "abc" and db.get_setting("omdb_api_key") == "om" assert client.get("/api/video/prefs").get_json() == { "billboard_autoplay": False, "watch_region": "GB"} finally: videoapi._video_db = None def test_downloads_config_save_load(tmp_path): import api.video as videoapi from database.video_database import VideoDatabase db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) videoapi._video_db = db app = Flask(__name__) app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") client = app.test_client() try: # Defaults are empty. assert client.get("/api/video/downloads/config").get_json() == { "download_path": "", "transfer_path": ""} # Round-trips + persists to video.db (separate from any music config). client.post("/api/video/downloads/config", json={"download_path": " /mnt/v/dl ", "transfer_path": "/mnt/v/lib"}) assert client.get("/api/video/downloads/config").get_json() == { "download_path": "/mnt/v/dl", "transfer_path": "/mnt/v/lib"} # trimmed assert db.get_setting("download_path") == "/mnt/v/dl" finally: videoapi._video_db = None def test_video_api_imports_nothing_from_music(): base = Path(__file__).resolve().parent.parent / "api" / "video" for py in base.glob("*.py"): for line in py.read_text(encoding="utf-8").splitlines(): s = line.strip() if s.startswith("import ") or s.startswith("from "): assert "music" not in s.lower(), f"{py.name}: music import leaked: {s!r}" # ── Watchlist endpoints (shows + people) ──────────────────────────────── def test_watchlist_routes_registered(): from api.video import create_video_blueprint app = Flask(__name__) app.register_blueprint(create_video_blueprint(), url_prefix="/api/video") rules = {r.rule for r in app.url_map.iter_rules()} for r in ("/api/video/watchlist", "/api/video/watchlist/add", "/api/video/watchlist/remove", "/api/video/watchlist/check", "/api/video/watchlist/counts"): assert r in rules, r def test_watchlist_add_check_list_remove_roundtrip(tmp_path): client, _ = _make_client(tmp_path) # empty to start assert client.get("/api/video/watchlist").get_json() == { "success": True, "shows": [], "people": [], "counts": {"show": 0, "person": 0, "total": 0}} # add a show + a person r = client.post("/api/video/watchlist/add", json={ "kind": "show", "tmdb_id": 1399, "title": "Game of Thrones", "poster_url": "/p.jpg", "library_id": 7}) assert r.get_json() == {"success": True, "watched": True} client.post("/api/video/watchlist/add", json={ "kind": "person", "tmdb_id": 287, "title": "Brad Pitt"}) # list groups by kind data = client.get("/api/video/watchlist").get_json() assert data["counts"] == {"show": 1, "person": 1, "total": 2} assert data["shows"][0]["title"] == "Game of Thrones" assert data["people"][0]["tmdb_id"] == 287 # check (hydration) — only watched ids come back, keys are strings chk = client.post("/api/video/watchlist/check", json={"kind": "show", "tmdb_ids": [1399, 9999]}).get_json() assert chk == {"success": True, "results": {"1399": True}} # counts endpoint assert client.get("/api/video/watchlist/counts").get_json() == { "success": True, "show": 1, "person": 1, "total": 2} # remove rem = client.post("/api/video/watchlist/remove", json={"kind": "show", "tmdb_id": 1399}).get_json() assert rem["success"] is True and rem["watched"] is False and rem["removed"] is True assert client.get("/api/video/watchlist/counts").get_json()["show"] == 0 def test_watchlist_add_validates_input(tmp_path): client, _ = _make_client(tmp_path) assert client.post("/api/video/watchlist/add", json={"kind": "movie", "tmdb_id": 1, "title": "x"}).status_code == 400 assert client.post("/api/video/watchlist/add", json={"kind": "show", "title": "no id"}).status_code == 400 assert client.post("/api/video/watchlist/add", json={"kind": "show", "tmdb_id": 1}).status_code == 400 # no title assert client.post("/api/video/watchlist/remove", json={"kind": "person"}).status_code == 400 assert client.post("/api/video/watchlist/check", json={"tmdb_ids": [1]}).status_code == 400 # no kind def test_watchlist_endpoint_paginates_and_searches(tmp_path): client, _ = _make_client(tmp_path) for i in range(1, 6): client.post("/api/video/watchlist/add", json={"kind": "person", "tmdb_id": 300 + i, "title": "P%d" % i}) d = client.get("/api/video/watchlist?kind=person&page=1&limit=2").get_json() assert d["success"] and len(d["items"]) == 2 assert d["pagination"]["total_count"] == 5 and d["pagination"]["total_pages"] == 3 assert d["counts"]["person"] == 5 s = client.get("/api/video/watchlist?kind=person&search=P3").get_json() assert len(s["items"]) == 1 and s["items"][0]["title"] == "P3" # ── wishlist endpoints ──────────────────────────────────────────────────────── def test_wishlist_add_movie_then_list(tmp_path): client, _ = _make_client(tmp_path) r = client.post("/api/video/wishlist/add", json={"movie": {"tmdb_id": 603, "title": "The Matrix", "year": 1999}}) assert r.get_json() == {"success": True, "added": 1, "counts": {"movie": 1, "show": 0, "episode": 0, "total": 1}} lst = client.get("/api/video/wishlist?kind=movie").get_json() assert lst["success"] and lst["items"][0]["tmdb_id"] == 603 and lst["counts"]["movie"] == 1 def test_wishlist_add_episodes_groups_into_show(tmp_path): client, _ = _make_client(tmp_path) r = client.post("/api/video/wishlist/add", json={ "show": {"tmdb_id": 1396, "title": "Breaking Bad", "poster_url": "/bb.jpg"}, "episodes": [{"season_number": 1, "episode_number": 1, "title": "Pilot"}, {"season_number": 1, "episode_number": 2}]}) assert r.get_json()["added"] == 2 show = client.get("/api/video/wishlist?kind=show").get_json()["items"][0] assert show["tmdb_id"] == 1396 and show["wanted"] == 2 assert show["seasons"][0]["season_number"] == 1 def test_wishlist_add_requires_valid_body(tmp_path): client, _ = _make_client(tmp_path) assert client.post("/api/video/wishlist/add", json={}).status_code == 400 # show with no episodes is rejected (episodes are the atomic unit) assert client.post("/api/video/wishlist/add", json={"show": {"tmdb_id": 1, "title": "S"}}).status_code == 400 def test_wishlist_remove_scopes_via_api(tmp_path): client, _ = _make_client(tmp_path) client.post("/api/video/wishlist/add", json={ "show": {"tmdb_id": 1396, "title": "Breaking Bad"}, "episodes": [{"season_number": 1, "episode_number": 1}, {"season_number": 1, "episode_number": 2}]}) r = client.post("/api/video/wishlist/remove", json={"scope": "episode", "tmdb_id": 1396, "season_number": 1, "episode_number": 2}) assert r.get_json()["removed"] == 1 and r.get_json()["counts"]["episode"] == 1 assert client.post("/api/video/wishlist/remove", json={"scope": "show", "tmdb_id": 1396}).get_json()["removed"] == 1 assert client.post("/api/video/wishlist/remove", json={"scope": "bogus", "tmdb_id": 1}).status_code == 400 def test_wishlist_check_hydration(tmp_path): client, _ = _make_client(tmp_path) client.post("/api/video/wishlist/add", json={"movie": {"tmdb_id": 603, "title": "The Matrix"}}) client.post("/api/video/wishlist/add", json={ "show": {"tmdb_id": 1396, "title": "Breaking Bad"}, "episodes": [{"season_number": 2, "episode_number": 3}]}) res = client.post("/api/video/wishlist/check", json={"movie_ids": [603, 700], "show_tmdb_id": 1396}).get_json() assert res["movies"] == [603] and res["episodes"] == ["2_3"] def test_wishlist_routes_registered(): from flask import Flask from api.video import create_video_blueprint app = Flask(__name__) app.register_blueprint(create_video_blueprint(), url_prefix="/api/video") rules = {r.rule for r in app.url_map.iter_rules()} for r in ("/api/video/wishlist", "/api/video/wishlist/add", "/api/video/wishlist/remove", "/api/video/wishlist/check", "/api/video/wishlist/counts"): assert r in rules def test_wishlist_check_by_show(tmp_path): client, _ = _make_client(tmp_path) client.post("/api/video/wishlist/add", json={ "show": {"tmdb_id": 1396, "title": "BB"}, "episodes": [{"season_number": 1, "episode_number": 1}]}) res = client.post("/api/video/wishlist/check", json={"shows": [1396, 1399]}).get_json() assert res["by_show"]["1396"] == ["1_1"] and "1399" not in res["by_show"] def test_wishlist_backfill_art_endpoint(tmp_path, monkeypatch): client, vapi = _make_client(tmp_path) db = vapi._video_db db.add_episodes_to_wishlist(1396, "BB", [{"season_number": 1, "episode_number": 1}]) # no art import core.video.enrichment.engine as eng_mod class FakeEng: def tmdb_season(self, tv, sn): return {"poster_url": "/s1.jpg", "episodes": [{"episode_number": 1, "still_url": "/s.jpg"}]} monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng()) assert client.post("/api/video/wishlist/backfill-art").get_json()["success"] is True season = db.query_wishlist("show")["items"][0]["seasons"][0] assert season["poster_url"] == "/s1.jpg" and season["episodes"][0]["still_url"] == "/s.jpg" # ── YouTube channels ───────────────────────────────────────────────────────── def test_youtube_routes_registered(): from api.video import create_video_blueprint app = Flask(__name__) app.register_blueprint(create_video_blueprint(), url_prefix="/api/video") rules = {r.rule for r in app.url_map.iter_rules()} for r in ("/api/video/youtube/resolve", "/api/video/youtube/follow", "/api/video/youtube/unfollow", "/api/video/youtube/channels", "/api/video/youtube/wishlist", "/api/video/youtube/wishlist/remove"): assert r in rules _CHANNEL = { "youtube_id": "UCPlay", "title": "PlayStation", "avatar_url": "http://a/p.jpg", "handle": "@PlayStation", "videos": [ {"youtube_id": "v1", "title": "State of Play", "published_at": "2024-06-01", "thumbnail_url": "http://t/1.jpg", "description": "the latest"}, {"youtube_id": "v2", "title": "Trailer", "published_at": "2024-01-01"}, ]} def test_youtube_resolve_previews_without_committing(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) import core.video.youtube as ytmod monkeypatch.setattr(ytmod, "resolve_channel", lambda url, limit=24: dict(_CHANNEL)) r = client.get("/api/video/youtube/resolve?url=https://youtube.com/@PlayStation") data = r.get_json() assert data["success"] is True assert data["channel"]["youtube_id"] == "UCPlay" assert data["following"] is False # not committed yet # nothing was written assert videoapi._video_db.youtube_wishlist_counts() == {"channel": 0, "video": 0} def test_youtube_resolve_rejects_non_channel(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) import core.video.youtube as ytmod monkeypatch.setattr(ytmod, "resolve_channel", lambda url, limit=24: None) r = client.get("/api/video/youtube/resolve?url=https://youtube.com/watch?v=x") assert r.status_code == 404 and r.get_json()["success"] is False def test_youtube_follow_then_channels_and_wishlist(tmp_path): client, _ = _make_client(tmp_path) # pre-resolved channel in the body → no network/yt-dlp needed r = client.post("/api/video/youtube/follow", json={"channel": _CHANNEL}) data = r.get_json() assert data["success"] is True and data["following"] is True assert data["added_videos"] == 2 assert data["counts"] == {"channel": 1, "video": 2} # appears on the watchlist channels list: wished_count = the 2 wished videos, # video_count = the remembered catalog (0 here — nothing cached in this test). chans = client.get("/api/video/youtube/channels").get_json() assert chans["channels"][0]["youtube_id"] == "UCPlay" assert chans["channels"][0]["wished_count"] == 2 assert chans["channels"][0]["video_count"] == 0 # appears in the youtube wishlist as a nebula channel (year=season, video=episode) wl = client.get("/api/video/youtube/wishlist").get_json() grp = wl["items"][0] assert grp["youtube_id"] == "UCPlay" and grp["wanted"] == 2 and grp["source"] == "youtube" vids = [e["source_id"] for se in grp["seasons"] for e in se["episodes"]] assert set(vids) == {"v1", "v2"} # resolve now reports following=True (hydration) — stub resolve to avoid network import core.video.youtube as ytmod ytmod_resolve = ytmod.resolve_channel try: ytmod.resolve_channel = lambda url, limit=24: dict(_CHANNEL) rr = client.get("/api/video/youtube/resolve?url=@PlayStation").get_json() assert rr["following"] is True finally: ytmod.resolve_channel = ytmod_resolve def test_youtube_unfollow_and_remove_scopes(tmp_path): client, db_api = _make_client(tmp_path) client.post("/api/video/youtube/follow", json={"channel": _CHANNEL}) # unfollow removes the watchlist row but keeps wished videos r = client.post("/api/video/youtube/unfollow", json={"youtube_id": "UCPlay"}) assert r.get_json() == {"success": True, "following": False} assert client.get("/api/video/youtube/channels").get_json()["channels"] == [] assert db_api._video_db.youtube_wishlist_counts() == {"channel": 1, "video": 2} # remove a single video r = client.post("/api/video/youtube/wishlist/remove", json={"scope": "video", "source_id": "v1"}) assert r.get_json()["removed"] == 1 assert r.get_json()["counts"] == {"channel": 1, "video": 1} # remove the whole channel's videos r = client.post("/api/video/youtube/wishlist/remove", json={"scope": "channel", "source_id": "UCPlay"}) assert r.get_json()["counts"] == {"channel": 0, "video": 0} def test_youtube_follow_requires_url_or_channel(tmp_path): client, _ = _make_client(tmp_path) r = client.post("/api/video/youtube/follow", json={}) assert r.status_code == 400 def test_youtube_channel_detail_hydrates_follow_and_wished(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) import core.video.youtube as ytmod monkeypatch.setattr(ytmod, "resolve_channel", lambda url, limit=60: dict(_CHANNEL)) monkeypatch.setattr(ytmod, "channel_recent_dates", lambda cid: {}) # no network in tests # follow first so detail reports following + one wished video client.post("/api/video/youtube/follow", json={"channel": _CHANNEL}) videoapi._video_db.remove_one_video_from_wishlist("v2") # leave only v1 wished d = client.get("/api/video/youtube/channel/UCPlay").get_json() assert d["success"] is True and d["kind"] == "channel" and d["following"] is True wished = {v["youtube_id"]: v["wished"] for v in d["channel"]["videos"]} assert wished == {"v1": True, "v2": False} # the videos' dates got cached for year-seasons assert videoapi._video_db.get_video_dates(["v1"]) == {"v1": "2024-06-01"} def test_youtube_channel_detail_is_cache_first(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) import core.video.youtube as ytmod db = videoapi._video_db # Pre-remember the channel (as the enricher / a prior open would have). db.cache_channel_meta("UCmem", {"title": "Remembered", "avatar_url": "av", "subscriber_count": 9}) db.cache_channel_videos("UCmem", [{"youtube_id": "m1", "title": "M1", "thumbnail_url": "t1"}]) db.cache_video_dates([{"youtube_id": "m1", "published_at": "2021-07-07"}]) # A cache HIT must NOT touch the network (yt-dlp / RSS). monkeypatch.setattr(ytmod, "resolve_channel", lambda *a, **k: (_ for _ in ()).throw(AssertionError("resolved on a cache hit"))) monkeypatch.setattr(ytmod, "channel_recent_dates", lambda *a, **k: (_ for _ in ()).throw(AssertionError("RSS on a cache hit"))) d = client.get("/api/video/youtube/channel/UCmem").get_json() assert d["success"] is True and d["from_cache"] is True assert d["channel"]["title"] == "Remembered" and d["channel"]["subscriber_count"] == 9 vids = d["channel"]["videos"] assert len(vids) == 1 and vids[0]["youtube_id"] == "m1" and vids[0]["published_at"] == "2021-07-07" def test_youtube_channel_detail_404_on_unresolvable(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) import core.video.youtube as ytmod monkeypatch.setattr(ytmod, "resolve_channel", lambda url, limit=60: None) assert client.get("/api/video/youtube/channel/UCnope").status_code == 404 def test_youtube_channel_videos_batch_streams_and_merges(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) import core.video.youtube as ytmod # One POST = one InnerTube page; the token rides in the body (not the URL). calls = [] def fake_page(cid, continuation=None, now=None, post=None): calls.append(continuation) return {"videos": [{"youtube_id": "a1", "title": "A1", "thumbnail_url": "ta", "published_at": "2020-01-01"}, {"youtube_id": "a2", "title": "A2", "thumbnail_url": "tb", "published_at": "2019-01-01"}], "continuation": "NEXT" if continuation is None else None} monkeypatch.setattr(ytmod, "innertube_channel_videos_page", fake_page) # Seed a cached (exact) date for a1 + wish a2 so the merge is observable. videoapi._video_db.cache_video_dates([{"youtube_id": "a1", "published_at": "2020-03-15"}]) videoapi._video_db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "X"}, [{"youtube_id": "a2", "title": "A2"}]) r = client.post("/api/video/youtube/channel/UCx/videos", json={}).get_json() assert r["success"] is True assert r["continuation"] == "NEXT" # token passed back for the next batch vids = {v["youtube_id"]: v for v in r["videos"]} assert vids["a1"]["published_at"] == "2020-03-15" # cached date wins over InnerTube's assert vids["a1"]["wished"] is False and vids["a2"]["wished"] is True assert calls[0] is None # first batch started from page 1 # the streamed list is remembered for instant re-open assert {v["youtube_id"] for v in videoapi._video_db.get_channel_videos("UCx")} == {"a1", "a2"} # the next page is fetched by POSTing the returned token (kept out of the URL) r2 = client.post("/api/video/youtube/channel/UCx/videos", json={"continuation": "NEXT"}).get_json() assert r2["continuation"] is None and calls[1] == "NEXT" def test_youtube_playlist_follow_detail_and_watchlist(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) import core.video.youtube as ytmod _PL = {"playlist_id": "PLx", "title": "Mix", "channel_title": "Lex", "video_count": 2, "thumbnail_url": "t", "videos": [{"youtube_id": "a", "title": "A"}, {"youtube_id": "b", "title": "B"}]} monkeypatch.setattr(ytmod, "parse_playlist_id", lambda u: "PLx" if "list=" in (u or "") else None) monkeypatch.setattr(ytmod, "resolve_playlist", lambda *a, **k: dict(_PL)) monkeypatch.setattr(ytmod, "innertube_playlist_catalog", lambda *a, **k: {"videos": [], "total": None}) # resolve detects a playlist link → returns a playlist (not a channel) r = client.get("/api/video/youtube/resolve?url=https://youtube.com/playlist?list=PLx").get_json() assert r["success"] and r["playlist"]["playlist_id"] == "PLx" and r["following"] is False # follow → appears under the watchlist's playlists assert client.post("/api/video/youtube/playlist/follow", json={"playlist": _PL}).get_json()["following"] is True assert [p["playlist_id"] for p in client.get("/api/video/youtube/channels").get_json()["playlists"]] == ["PLx"] # detail (kept in curator order; flagged following) d = client.get("/api/video/youtube/playlist/PLx").get_json() assert d["kind"] == "playlist" and d["following"] is True assert [v["youtube_id"] for v in d["playlist"]["videos"]] == ["a", "b"] # unfollow assert client.post("/api/video/youtube/playlist/unfollow", json={"playlist_id": "PLx"}).get_json()["following"] is False assert client.get("/api/video/youtube/channels").get_json()["playlists"] == [] def test_youtube_wishlist_add_single_video(tmp_path): client, _ = _make_client(tmp_path) r = client.post("/api/video/youtube/wishlist/add", json={ "channel": {"youtube_id": "UCPlay", "title": "PlayStation"}, "videos": [{"youtube_id": "solo1", "title": "One Video", "published_at": "2024-03-03"}]}) assert r.get_json()["added"] == 1 assert r.get_json()["counts"] == {"channel": 1, "video": 1} r = client.post("/api/video/youtube/wishlist/remove", json={"scope": "video", "source_id": "solo1"}) assert r.get_json()["counts"] == {"channel": 0, "video": 0} def test_youtube_video_detail_endpoint_persists_description(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) import core.video.youtube as ytmod full = {"youtube_id": "v1", "title": "State of Play", "description": "Full synopsis here.", "duration_seconds": 600, "view_count": 5000, "like_count": 100, "published_at": "2024-06-01", "webpage_url": "https://youtu.be/v1", "tags": []} monkeypatch.setattr(ytmod, "video_detail", lambda vid: dict(full)) # wish the video first (empty overview), then the detail call should backfill it videoapi._video_db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "X"}, [{"youtube_id": "v1", "title": "SoP"}]) d = client.get("/api/video/youtube/video/v1").get_json() assert d["success"] is True and d["video"]["description"] == "Full synopsis here." # persisted onto the wishlist row grp = videoapi._video_db.query_youtube_wishlist()["items"][0] ov = grp["seasons"][0]["episodes"][0]["overview"] assert ov == "Full synopsis here." def test_youtube_video_detail_404(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) import core.video.youtube as ytmod monkeypatch.setattr(ytmod, "video_detail", lambda vid: None) assert client.get("/api/video/youtube/video/nope").status_code == 404 def test_img_proxy_allows_youtube_cdn_only(tmp_path, monkeypatch): client, _ = _make_client(tmp_path) import requests class FakeResp: status_code = 200 headers = {"Content-Type": "image/jpeg"} def iter_content(self, n): yield b"x" monkeypatch.setattr(requests, "get", lambda *a, **k: FakeResp()) assert client.get("/api/video/img?u=https://yt3.googleusercontent.com/abc=s900").status_code == 200 assert client.get("/api/video/img?u=https://i.ytimg.com/vi/x/hq.jpg").status_code == 200 assert client.get("/api/video/img?u=https://image.tmdb.org/t/p/w500/x.jpg").status_code == 200 # still SSRF-safe: arbitrary hosts rejected assert client.get("/api/video/img?u=https://evil.example.com/x.jpg").status_code == 404 assert client.get("/api/video/img?u=http://yt3.googleusercontent.com/x").status_code == 404 # http def test_youtube_playlists_and_playlist_videos(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) import core.video.youtube as ytmod monkeypatch.setattr(ytmod, "channel_playlists", lambda cid: [{"playlist_id": "PL1", "title": "Trailers", "video_count": 3, "thumbnail_url": None}]) pls = client.get("/api/video/youtube/playlists/UCx").get_json() assert pls["success"] is True and pls["playlists"][0]["playlist_id"] == "PL1" monkeypatch.setattr(ytmod, "resolve_playlist", lambda *a, **k: {"playlist_id": "PL1", "title": "Trailers", "videos": [{"youtube_id": "a", "title": "A"}, {"youtube_id": "b", "title": "B"}]}) monkeypatch.setattr(ytmod, "innertube_playlist_catalog", lambda *a, **k: {"videos": [], "total": None}) # 'a' is wished → should hydrate wished=True videoapi._video_db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "X"}, [{"youtube_id": "a", "title": "A"}]) pv = client.get("/api/video/youtube/playlist/PL1").get_json() wished = {v["youtube_id"]: v["wished"] for v in pv["videos"]} # still top-level `videos` for the expansion assert wished == {"a": True, "b": False} def test_youtube_search_endpoint_hydrates_following(tmp_path, monkeypatch): client, videoapi = _make_client(tmp_path) import core.video.youtube as ytmod monkeypatch.setattr(ytmod, "search_channels", lambda q: [ {"youtube_id": "UCaaa", "title": "GMM"}, {"youtube_id": "UCbbb", "title": "Other"}]) videoapi._video_db.add_channel_to_watchlist({"youtube_id": "UCaaa", "title": "GMM"}) d = client.get("/api/video/youtube/search?q=gmm").get_json() flags = {c["youtube_id"]: c["following"] for c in d["channels"]} assert flags == {"UCaaa": True, "UCbbb": False} assert client.get("/api/video/youtube/search?q=").get_json()["channels"] == [] def test_enrichment_youtube_status_route(tmp_path): client, _ = _make_client(tmp_path) d = client.get("/api/video/enrichment/youtube/status").get_json() assert d["enabled"] is True and "running" in d and "progress" in d assert client.post("/api/video/enrichment/youtube/pause").get_json()["status"] == "paused" assert client.post("/api/video/enrichment/youtube/resume").get_json()["status"] == "running" # unknown service still 404s assert client.get("/api/video/enrichment/bogus/status").status_code == 404