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