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>
190 lines
7.9 KiB
Python
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
|