"""Retention: candidate selection, artefact deletion, pruning, disk cap.""" from datetime import timedelta from conftest import add_video from youtube_automate import config, reap, util, videos def _place(media_root, channel_dir, season, stem): """Create a downloaded video's full set of artefacts on disk.""" season_dir = media_root / channel_dir / f"Season {season}" season_dir.mkdir(parents=True, exist_ok=True) (season_dir / f"{stem}.mp4").write_bytes(b"video-bytes") (season_dir / f"{stem}.nfo").write_text("") (season_dir / f"{stem}.info.json").write_text("{}") (season_dir / f"{stem}-thumb.jpg").write_bytes(b"jpg") (season_dir / f"{stem}.en.srt").write_text("1\n") return f"{channel_dir}/Season {season}/{stem}.mp4" def _downloaded(conn, channel, media_root, video_id, upload_date, size=1024): """Place artefacts and record the row. `size` is only ever read back out of the database (the disk-cap arithmetic uses `size_bytes`), so the files on disk stay tiny no matter how large a size the test claims. """ stem = f"clabretro - S2026E8110 - Title [{video_id}]" rel = _place(media_root, channel["dir_name"], 2026, stem) add_video(conn, channel["id"], video_id, upload_date=upload_date) videos.mark_downloaded( conn, video_id, rel_path=rel, size_bytes=size, season=2026, episode=8110, upload_date=upload_date, duration=100, title="Title", ) return rel class TestCandidates: def test_selects_only_videos_past_the_window(self, conn, settings, channel, media_root): fresh = (util.today() - timedelta(days=3)).isoformat() stale = (util.today() - timedelta(days=30)).isoformat() _downloaded(conn, channel, media_root, "fresh", fresh) _downloaded(conn, channel, media_root, "stale", stale) due = [row["video_id"] for row in reap.candidates(conn, settings)] assert due == ["stale"] def test_per_channel_override_widens_the_window( self, conn, settings, channel, media_root ): age = (util.today() - timedelta(days=30)).isoformat() _downloaded(conn, channel, media_root, "v1", age) assert len(reap.candidates(conn, settings)) == 1 with conn: conn.execute( "UPDATE channel SET retention_days = 60 WHERE id = ?", (channel["id"],) ) assert reap.candidates(conn, settings) == [] def test_per_channel_override_narrows_the_window( self, conn, settings, channel, media_root ): age = (util.today() - timedelta(days=5)).isoformat() _downloaded(conn, channel, media_root, "v1", age) assert reap.candidates(conn, settings) == [] with conn: conn.execute( "UPDATE channel SET retention_days = 2 WHERE id = ?", (channel["id"],) ) assert len(reap.candidates(conn, settings)) == 1 def test_ignores_videos_that_are_not_downloaded(self, conn, settings, channel): old = (util.today() - timedelta(days=99)).isoformat() add_video(conn, channel["id"], "p", upload_date=old, state=videos.PENDING) add_video(conn, channel["id"], "d", upload_date=old, state=videos.DELETED) assert reap.candidates(conn, settings) == [] class TestDeletion: def test_removes_every_sidecar(self, conn, settings, channel, media_root, monkeypatch): monkeypatch.setattr(config, "MEDIA_ROOT", media_root) old = (util.today() - timedelta(days=30)).isoformat() rel = _downloaded(conn, channel, media_root, "v1", old) season_dir = (media_root / rel).parent reap.run(conn, settings) assert not season_dir.exists() def test_leaves_a_tombstone(self, conn, settings, channel, media_root, monkeypatch): monkeypatch.setattr(config, "MEDIA_ROOT", media_root) old = (util.today() - timedelta(days=30)).isoformat() _downloaded(conn, channel, media_root, "v1", old) reap.run(conn, settings) row = videos.get(conn, "v1") assert row["state"] == videos.DELETED assert row["rel_path"] is None def test_keeps_the_channel_directory_and_its_artwork( self, conn, settings, channel, media_root, monkeypatch ): """Deleting the channel dir would make an active subscription vanish from Jellyfin and reappear later.""" monkeypatch.setattr(config, "MEDIA_ROOT", media_root) channel_dir = media_root / channel["dir_name"] channel_dir.mkdir(parents=True, exist_ok=True) (channel_dir / "tvshow.nfo").write_text("") (channel_dir / "poster.jpg").write_bytes(b"jpg") old = (util.today() - timedelta(days=30)).isoformat() _downloaded(conn, channel, media_root, "v1", old) reap.run(conn, settings) assert channel_dir.is_dir() assert (channel_dir / "tvshow.nfo").exists() assert (channel_dir / "poster.jpg").exists() def test_does_not_prune_a_season_that_still_has_videos( self, conn, settings, channel, media_root, monkeypatch ): monkeypatch.setattr(config, "MEDIA_ROOT", media_root) old = (util.today() - timedelta(days=30)).isoformat() fresh = (util.today() - timedelta(days=1)).isoformat() rel = _downloaded(conn, channel, media_root, "old1", old) _downloaded(conn, channel, media_root, "new1", fresh) reap.run(conn, settings) season_dir = (media_root / rel).parent assert season_dir.is_dir() assert list(season_dir.glob("*new1*")) assert not list(season_dir.glob("*old1*")) def test_only_deletes_files_matching_the_stem( self, conn, settings, channel, media_root, monkeypatch ): monkeypatch.setattr(config, "MEDIA_ROOT", media_root) old = (util.today() - timedelta(days=30)).isoformat() rel = _downloaded(conn, channel, media_root, "v1", old) season_dir = (media_root / rel).parent bystander = season_dir / "unrelated file.txt" bystander.write_text("keep me") reap.run(conn, settings) assert bystander.exists() class TestDiskCap: def test_disabled_by_default(self, conn, settings, channel, media_root): _downloaded(conn, channel, media_root, "v1", util.today().isoformat(), size=10**6) assert reap.disk_cap_evictions(conn, settings) == [] def test_evicts_oldest_first_until_under_the_cap( self, conn, settings, channel, media_root ): gigabyte = 1024**3 for index, day in enumerate((10, 5, 1)): _downloaded( conn, channel, media_root, f"v{index}", (util.today() - timedelta(days=day)).isoformat(), size=gigabyte, ) settings.set("disk_cap_gb", "2") evicted = [row["video_id"] for row in reap.disk_cap_evictions(conn, settings)] assert evicted == ["v0"] def test_nothing_evicted_when_under_the_cap(self, conn, settings, channel, media_root): _downloaded(conn, channel, media_root, "v1", util.today().isoformat(), size=1024) settings.set("disk_cap_gb", "5") assert reap.disk_cap_evictions(conn, settings) == [] class TestEffectiveRetention: def test_override_wins(self, settings): assert reap.effective_retention(settings, 60) == 60 def test_none_falls_back_to_global(self, settings): assert reap.effective_retention(settings, None) == 14 def test_zero_falls_back_to_global(self, settings): assert reap.effective_retention(settings, 0) == 14