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:
Tom Flux
2026-08-11 21:42:48 +01:00
co-authored by Claude Opus 5
commit 18bb2e420b
44 changed files with 7188 additions and 0 deletions
+109
View File
@@ -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