Initial implementation of youtube-automate
A DVR for YouTube subscriptions, laid out so Jellyfin presents each channel as a show and each video as an episode. Cron-driven, idempotent, with a public admin UI for a non-operator. Verified end to end on susan against three real channels: PO tokens, h264 downloads, Jellyfin resolution from local NFOs with all providers disabled, retention and tombstones. Corrections to the original design handover (specs.md documents each with the evidence, and specs.handover-original.md preserves the original): - The format sort selected 360p. Ranking acodec above res makes `bv*` prefer the combined 360p stream, which carries AAC, over the 720p video-only stream whose acodec is none. vcodec now leads, so a video without h264 at 720p yields h264 lower down rather than VP9 this hardware cannot transcode. - yt-dlp now requires a JS runtime and the yt-dlp-ejs solver scripts, which only ship with the [default] extra. Without them the n challenge fails and the mweb formats disappear entirely. - --flat-playlist carries no upload dates, so the specced client-side date filter for backfill was impossible. Backfill is RSS-first. - skipped_old was terminal, so raising a channel's retention appeared to do nothing. Added an explicit rescan. - is_upcoming premieres now defer and retry instead of being skipped forever. - TubeArchivist is gone, so the media root and the tube.jihakuz.xyz vhost were both reclaimed; the latter still pointed at its dead port. 240 offline tests, no network and no real yt-dlp invocation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Channel URL resolution and the subscribe/unsubscribe lifecycle."""
|
||||
|
||||
import pytest
|
||||
|
||||
from youtube_automate import channels, config
|
||||
|
||||
|
||||
class TestNormaliseUrl:
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
("https://www.youtube.com/@clabretro", "https://www.youtube.com/@clabretro"),
|
||||
("http://youtube.com/c/name", "http://youtube.com/c/name"),
|
||||
("@clabretro", "https://www.youtube.com/@clabretro"),
|
||||
("clabretro", "https://www.youtube.com/@clabretro"),
|
||||
(
|
||||
"UCW7jUEpYT_t0Gsf632d6_wQ",
|
||||
"https://www.youtube.com/channel/UCW7jUEpYT_t0Gsf632d6_wQ",
|
||||
),
|
||||
("www.youtube.com/@x", "https://www.youtube.com/@x"),
|
||||
(" @spaced ", "https://www.youtube.com/@spaced"),
|
||||
],
|
||||
)
|
||||
def test_accepted_forms(self, raw, expected):
|
||||
assert channels.normalise_url(raw) == expected
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", " ", "not a channel!!", "@@@"])
|
||||
def test_rejected_forms(self, raw):
|
||||
with pytest.raises(channels.ResolutionError):
|
||||
channels.normalise_url(raw)
|
||||
|
||||
def test_channel_id_must_be_the_right_shape(self):
|
||||
# Too short to be a real UC id, so it is treated as a handle instead.
|
||||
assert channels.normalise_url("UCshort") == "https://www.youtube.com/@UCshort"
|
||||
|
||||
|
||||
class TestUulfPlaylistId:
|
||||
def test_swaps_the_uc_prefix_for_uulf(self):
|
||||
assert (
|
||||
channels.uulf_playlist_id("UCW7jUEpYT_t0Gsf632d6_wQ")
|
||||
== "UULFW7jUEpYT_t0Gsf632d6_wQ"
|
||||
)
|
||||
|
||||
def test_length_is_preserved(self):
|
||||
channel_id = "UCW7jUEpYT_t0Gsf632d6_wQ"
|
||||
assert len(channels.uulf_playlist_id(channel_id)) == len(channel_id) + 2
|
||||
|
||||
|
||||
class TestThumbnailPicking:
|
||||
def test_finds_the_requested_id(self):
|
||||
thumbs = [
|
||||
{"id": "avatar_uncropped", "url": "http://a/avatar.jpg"},
|
||||
{"id": "banner_uncropped", "url": "http://a/banner.jpg"},
|
||||
]
|
||||
assert channels._pick_thumbnail(thumbs, "banner_uncropped") == "http://a/banner.jpg"
|
||||
|
||||
def test_returns_none_when_absent(self):
|
||||
assert channels._pick_thumbnail([{"id": "other", "url": "u"}], "avatar_uncropped") is None
|
||||
|
||||
def test_ignores_entries_without_a_url(self):
|
||||
assert channels._pick_thumbnail([{"id": "avatar_uncropped"}], "avatar_uncropped") is None
|
||||
|
||||
def test_empty_list(self):
|
||||
assert channels._pick_thumbnail([], "avatar_uncropped") is None
|
||||
|
||||
|
||||
class TestUniqueDirName:
|
||||
def test_first_use_is_unchanged(self, conn):
|
||||
assert channels._unique_dir_name(conn, "clabretro") == "clabretro"
|
||||
|
||||
def test_collision_gets_a_suffix(self, conn, channel):
|
||||
assert channels._unique_dir_name(conn, "clabretro") == "clabretro (2)"
|
||||
|
||||
def test_repeated_collisions_keep_counting(self, conn, channel):
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO channel (channel_id, title, dir_name, added_at) "
|
||||
"VALUES ('UCx', 'clabretro', 'clabretro (2)', '2026-01-01')"
|
||||
)
|
||||
assert channels._unique_dir_name(conn, "clabretro") == "clabretro (3)"
|
||||
|
||||
|
||||
class TestUnsubscribe:
|
||||
def test_removes_the_directory_and_the_rows(self, conn, channel, media_root, monkeypatch):
|
||||
monkeypatch.setattr(config, "MEDIA_ROOT", media_root)
|
||||
channel_dir = media_root / channel["dir_name"]
|
||||
(channel_dir / "Season 2026").mkdir(parents=True)
|
||||
(channel_dir / "tvshow.nfo").write_text("<tvshow/>")
|
||||
|
||||
from conftest import add_video
|
||||
|
||||
add_video(conn, channel["id"], "v1")
|
||||
|
||||
title = channels.unsubscribe(conn, channel["id"])
|
||||
|
||||
assert title == "clabretro"
|
||||
assert not channel_dir.exists()
|
||||
assert conn.execute("SELECT COUNT(*) FROM channel").fetchone()[0] == 0
|
||||
# ON DELETE CASCADE must take the videos with it.
|
||||
assert conn.execute("SELECT COUNT(*) FROM video").fetchone()[0] == 0
|
||||
|
||||
def test_missing_channel_raises(self, conn):
|
||||
with pytest.raises(LookupError):
|
||||
channels.unsubscribe(conn, 999)
|
||||
|
||||
def test_tolerates_a_missing_directory(self, conn, channel, media_root, monkeypatch):
|
||||
monkeypatch.setattr(config, "MEDIA_ROOT", media_root)
|
||||
channels.unsubscribe(conn, channel["id"])
|
||||
assert conn.execute("SELECT COUNT(*) FROM channel").fetchone()[0] == 0
|
||||
Reference in New Issue
Block a user