"""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