Files
Tom FluxandClaude Opus 5 18bb2e420b 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>
2026-08-11 21:42:48 +01:00

190 lines
7.9 KiB
Python

"""The video state machine and episode assignment."""
from datetime import date
from conftest import add_video
from youtube_automate import videos
class TestNextEpisode:
def test_first_video_of_the_day_gets_the_base_number(self, conn, channel):
add_video(conn, channel["id"], "a")
season, episode = videos.next_episode(
conn, channel["id"], date(2026, 8, 11), "a"
)
assert (season, episode) == (2026, 8110)
def test_second_video_increments_the_ordinal(self, conn, channel):
add_video(conn, channel["id"], "a")
add_video(conn, channel["id"], "b")
videos.mark_downloaded(
conn, "a", rel_path="x", size_bytes=1, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
season, episode = videos.next_episode(
conn, channel["id"], date(2026, 8, 11), "b"
)
assert episode == 8111
def test_ordinal_is_computed_from_the_database_not_the_batch(self, conn, channel):
"""Stability across runs: a video keeps its slot even if others are
assigned in a different order later."""
for name, ep in (("a", 8110), ("b", 8111)):
add_video(conn, channel["id"], name)
videos.mark_downloaded(
conn, name, rel_path="x", size_bytes=1, season=2026, episode=ep,
upload_date="2026-08-11", duration=1, title="t",
)
add_video(conn, channel["id"], "c")
_, episode = videos.next_episode(conn, channel["id"], date(2026, 8, 11), "c")
assert episode == 8112
def test_a_different_day_starts_fresh(self, conn, channel):
add_video(conn, channel["id"], "a")
videos.mark_downloaded(
conn, "a", rel_path="x", size_bytes=1, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
add_video(conn, channel["id"], "b")
_, episode = videos.next_episode(conn, channel["id"], date(2026, 8, 12), "b")
assert episode == 8120
def test_another_channel_does_not_share_the_numbering(self, conn, channel):
with conn:
conn.execute(
"INSERT INTO channel (channel_id, title, dir_name, added_at) "
"VALUES ('UCother', 'Other', 'Other', '2026-01-01')"
)
other = conn.execute(
"SELECT id FROM channel WHERE dir_name = 'Other'"
).fetchone()["id"]
add_video(conn, channel["id"], "a")
videos.mark_downloaded(
conn, "a", rel_path="x", size_bytes=1, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
add_video(conn, other, "b")
_, episode = videos.next_episode(conn, other, date(2026, 8, 11), "b")
assert episode == 8110
def test_clamps_at_the_tenth_upload_of_a_day(self, conn, channel):
for index in range(10):
name = f"v{index}"
add_video(conn, channel["id"], name)
videos.mark_downloaded(
conn, name, rel_path="x", size_bytes=1, season=2026,
episode=8110 + index, upload_date="2026-08-11", duration=1, title="t",
)
add_video(conn, channel["id"], "overflow")
_, episode = videos.next_episode(
conn, channel["id"], date(2026, 8, 11), "overflow"
)
assert episode == 8119
def test_reassigning_the_same_video_is_stable(self, conn, channel):
add_video(conn, channel["id"], "a")
videos.mark_downloaded(
conn, "a", rel_path="x", size_bytes=1, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
# Its own row must be excluded, so it gets the same slot back.
_, episode = videos.next_episode(conn, channel["id"], date(2026, 8, 11), "a")
assert episode == 8110
class TestQueue:
def test_claim_returns_pending(self, conn, channel):
add_video(conn, channel["id"], "a")
assert [row["video_id"] for row in videos.claim_pending(conn, 5)] == ["a"]
def test_claim_includes_failed_with_attempts_left(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.FAILED)
with conn:
conn.execute("UPDATE video SET attempts = 2 WHERE video_id = 'a'")
assert len(videos.claim_pending(conn, 5)) == 1
def test_claim_excludes_exhausted_failures(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.FAILED)
with conn:
conn.execute("UPDATE video SET attempts = 5 WHERE video_id = 'a'")
assert videos.claim_pending(conn, 5) == []
def test_claim_excludes_terminal_states(self, conn, channel):
for index, state in enumerate(
(videos.DELETED, videos.SKIPPED_LIVE, videos.SKIPPED_OLD,
videos.SKIPPED_SHORT, videos.DOWNLOADED)
):
add_video(conn, channel["id"], f"v{index}", state=state)
assert videos.claim_pending(conn, 5) == []
def test_claim_is_oldest_first(self, conn, channel):
add_video(conn, channel["id"], "new", upload_date="2026-08-10")
add_video(conn, channel["id"], "old", upload_date="2026-08-01")
assert [row["video_id"] for row in videos.claim_pending(conn, 5)] == ["old", "new"]
def test_limit_is_respected(self, conn, channel):
for index in range(5):
add_video(conn, channel["id"], f"v{index}")
assert len(videos.claim_pending(conn, 5, limit=2)) == 2
class TestCrashRecovery:
def test_downloading_rows_return_to_pending(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.DOWNLOADING)
assert videos.recover_downloading(conn) == 1
assert videos.get(conn, "a")["state"] == videos.PENDING
def test_other_states_are_untouched(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.DOWNLOADED)
add_video(conn, channel["id"], "b", state=videos.DELETED)
assert videos.recover_downloading(conn) == 0
assert videos.get(conn, "a")["state"] == videos.DOWNLOADED
assert videos.get(conn, "b")["state"] == videos.DELETED
class TestFailures:
def test_attempts_accumulate(self, conn, channel):
add_video(conn, channel["id"], "a")
videos.record_failure(conn, "a", "boom", 5)
videos.record_failure(conn, "a", "boom", 5)
row = videos.get(conn, "a")
assert row["attempts"] == 2
assert row["state"] == videos.FAILED
assert row["last_error"] == "boom"
def test_exhaustion_is_reported(self, conn, channel):
add_video(conn, channel["id"], "a")
for _ in range(4):
videos.record_failure(conn, "a", "boom", 5)
assert videos.record_failure(conn, "a", "boom", 5) == "exhausted"
class TestTombstone:
def test_mark_deleted_clears_the_path_but_keeps_the_row(self, conn, channel):
add_video(conn, channel["id"], "a")
videos.mark_downloaded(
conn, "a", rel_path="x/y.mp4", size_bytes=10, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
videos.mark_deleted(conn, "a")
row = videos.get(conn, "a")
assert row is not None
assert row["state"] == videos.DELETED
assert row["rel_path"] is None
assert row["deleted_at"]
def test_insert_never_overwrites_an_existing_row(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.DELETED)
add_video(conn, channel["id"], "a", state=videos.PENDING)
assert videos.get(conn, "a")["state"] == videos.DELETED
class TestQueueDepth:
def test_counts_only_work_in_progress(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.PENDING)
add_video(conn, channel["id"], "b", state=videos.DOWNLOADING)
add_video(conn, channel["id"], "c", state=videos.FAILED)
add_video(conn, channel["id"], "d", state=videos.DOWNLOADED)
assert videos.queue_depth(conn) == 3