"""Channel subscribe/unsubscribe, and the API-only sync path.""" from __future__ import annotations import pytest from ytstream import api, channels, config from conftest import CHANNEL_ID, FakeApi, add_channel, add_video def test_normalise_url_accepts_every_reference_form(): assert channels.normalise_url(CHANNEL_ID).endswith("/channel/" + CHANNEL_ID) assert channels.normalise_url("@clabretro") == "https://www.youtube.com/@clabretro" assert channels.normalise_url("clabretro") == "https://www.youtube.com/@clabretro" assert channels.normalise_url("https://youtube.com/x") == "https://youtube.com/x" assert channels.normalise_url("youtube.com/x") == "https://youtube.com/x" def test_normalise_url_rejects_nonsense(): with pytest.raises(channels.ResolutionError): channels.normalise_url("") with pytest.raises(channels.ResolutionError): channels.normalise_url("not a handle!!") def test_uulf_playlist_id(): assert channels.uulf_playlist_id(CHANNEL_ID) == "UULF" + CHANNEL_ID[2:] # ---------------------------------------------------------- subscribe_from_sync @pytest.fixture() def api_channel(monkeypatch): """Patch channels' lazily-imported api module.""" fake = FakeApi(channel={"channel_id": "UC" + "a" * 22, "title": "Alpha Channel", "description": "About alpha", "handle": "@alpha", "avatar_url": None}) monkeypatch.setattr(api, "Api", lambda *a, **kw: fake) return fake def test_subscribe_from_sync_never_calls_yt_dlp(conn, settings, media_root, api_channel, monkeypatch): """119 channels' worth of yt-dlp resolution is the request burst this design exists to avoid.""" monkeypatch.setattr(channels.ytdlp, "run_json", lambda *a, **k: pytest.fail("yt-dlp must not be called")) row = channels.subscribe_from_sync(conn, settings, "UC" + "a" * 22, "Alpha") assert row["title"] == "Alpha Channel" assert row["source"] == "youtube" def test_subscribe_from_sync_uses_the_api_description(conn, settings, media_root, api_channel): row = channels.subscribe_from_sync(conn, settings, "UC" + "a" * 22, "Alpha") assert row["description"] == "About alpha" def test_subscribe_from_sync_is_idempotent(conn, settings, media_root, api_channel): first = channels.subscribe_from_sync(conn, settings, "UC" + "a" * 22, "Alpha") second = channels.subscribe_from_sync(conn, settings, "UC" + "a" * 22, "Alpha") assert first["id"] == second["id"] assert conn.execute("SELECT COUNT(*) FROM channel").fetchone()[0] == 1 def test_subscribe_from_sync_survives_a_metadata_failure(conn, settings, media_root, monkeypatch): """A channel we cannot describe is still a channel we can mirror.""" class Failing(FakeApi): def channel(self, channel_id): raise api.ApiError(500, "backendError", "boom") monkeypatch.setattr(api, "Api", lambda *a, **kw: Failing()) row = channels.subscribe_from_sync(conn, settings, "UC" + "a" * 22, "Fallback Name") assert row["title"] == "Fallback Name" def test_subscribe_from_sync_makes_no_directory(conn, settings, media_root, monkeypatch): """The tree appears with the first episode, so a channel with nothing inside the window leaves no empty series behind.""" class NoArt(FakeApi): def channel(self, channel_id): return {"channel_id": channel_id, "title": "Quiet", "description": "", "handle": None, "avatar_url": None} monkeypatch.setattr(api, "Api", lambda *a, **kw: NoArt()) row = channels.subscribe_from_sync(conn, settings, "UC" + "a" * 22, "Quiet") assert not (media_root / row["dir_name"]).exists() def test_directory_names_are_deduplicated(conn, settings, media_root, monkeypatch): """Two channels can legitimately share a title; dir_name is UNIQUE.""" class Same(FakeApi): def channel(self, channel_id): return {"channel_id": channel_id, "title": "Same Name", "description": "", "handle": None, "avatar_url": None} monkeypatch.setattr(api, "Api", lambda *a, **kw: Same()) first = channels.subscribe_from_sync(conn, settings, "UC" + "a" * 22, "Same Name") second = channels.subscribe_from_sync(conn, settings, "UC" + "b" * 22, "Same Name") assert first["dir_name"] == "Same Name" assert second["dir_name"] == "Same Name (2)" # ------------------------------------------------------------------ unsubscribe def test_unsubscribe_removes_the_tree_and_the_rows(conn, media_root, channel): tree = media_root / "clabretro" (tree / "Season 2026").mkdir(parents=True) (tree / "tvshow.nfo").write_text("") add_video(conn, channel["id"], "vid00000001") title = channels.unsubscribe(conn, channel["id"]) assert title == "clabretro" assert not tree.exists() assert conn.execute("SELECT COUNT(*) FROM channel").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM video").fetchone()[0] == 0 def test_unsubscribe_rejects_an_unknown_id(conn): with pytest.raises(LookupError): channels.unsubscribe(conn, 999) def test_lookup_helpers(conn, channel): assert channels.get(conn, channel["id"])["title"] == "clabretro" assert channels.get_by_channel_id(conn, CHANNEL_ID)["id"] == channel["id"] assert channels.get(conn, 999) is None assert channels.get_by_channel_id(conn, "UCnope") is None def test_all_channels_is_sorted_case_insensitively(conn): add_channel(conn, "UC" + "a" * 22, "zebra", "zebra") add_channel(conn, "UC" + "b" * 22, "Apple", "Apple") titles = [row["title"] for row in channels.all_channels(conn)] assert titles == ["Apple", "zebra"]