"""Retention: delete videos that have aged out. Reap is a purely local operation. Jellyfin watch-state protection was considered and declined (specs.md §15), so nothing here needs the Jellyfin API except the refresh at the end — and that is best effort. """ from __future__ import annotations import logging import sqlite3 from datetime import timedelta from pathlib import Path from . import config, jellyfin, util, videos from .settings import Settings log = logging.getLogger(__name__) def effective_retention(settings: Settings, channel_override: int | None) -> int: return int(channel_override) if channel_override else settings.get_int("retention_days") def candidates(conn: sqlite3.Connection, settings: Settings) -> list[sqlite3.Row]: """Downloaded videos past their channel's effective retention window.""" rows = conn.execute( "SELECT v.*, c.dir_name, c.retention_days AS channel_retention, " "c.title AS channel_title " "FROM video v JOIN channel c ON c.id = v.channel_pk " "WHERE v.state = ? ORDER BY v.upload_date ASC", (videos.DOWNLOADED,), ).fetchall() today = util.today() due = [] for row in rows: days = effective_retention(settings, row["channel_retention"]) if row["upload_date"] and row["upload_date"] < (today - timedelta(days=days)).isoformat(): due.append(row) return due def _delete_artefacts(rel_path: str) -> int: """Remove the media file and every sidecar sharing its stem.""" media = config.MEDIA_ROOT / rel_path season_dir = media.parent stem = media.stem # includes the [videoid], so it cannot collide removed = 0 if season_dir.is_dir(): for path in season_dir.iterdir(): if path.name.startswith(stem): try: path.unlink() removed += 1 except OSError as exc: log.warning("could not delete %s: %s", path, exc) return removed def _prune_empty_season(season_dir: Path) -> None: """Remove the season directory once nothing is left in it. The channel directory is deliberately kept even when it holds no seasons: it still carries tvshow.nfo and the artwork, and deleting it would make an active subscription vanish from Jellyfin and come back later. """ if not season_dir.is_dir() or season_dir == config.MEDIA_ROOT: return try: next(season_dir.iterdir()) except StopIteration: try: season_dir.rmdir() log.info("pruned empty season directory %s", season_dir) except OSError as exc: # pragma: no cover log.warning("could not prune %s: %s", season_dir, exc) except OSError: # pragma: no cover pass def delete_video(conn: sqlite3.Connection, video: sqlite3.Row) -> bool: """Delete one video's files and leave a tombstone row.""" rel_path = video["rel_path"] if not rel_path: videos.mark_deleted(conn, video["video_id"]) return False season_dir = (config.MEDIA_ROOT / rel_path).parent removed = _delete_artefacts(rel_path) _prune_empty_season(season_dir) videos.mark_deleted(conn, video["video_id"]) log.info( "reaped %s (%s, uploaded %s): %d file(s)", video["video_id"], video["channel_title"], video["upload_date"], removed, ) return True def disk_cap_evictions( conn: sqlite3.Connection, settings: Settings ) -> list[sqlite3.Row]: """Oldest-first list of videos to evict to get back under the cap.""" cap_gb = settings.get_int("disk_cap_gb") if cap_gb <= 0: return [] cap_bytes = cap_gb * 1024**3 rows = conn.execute( "SELECT v.*, c.dir_name, c.title AS channel_title " "FROM video v JOIN channel c ON c.id = v.channel_pk " "WHERE v.state = ? ORDER BY v.upload_date ASC", (videos.DOWNLOADED,), ).fetchall() total = sum(row["size_bytes"] or 0 for row in rows) if total <= cap_bytes: return [] evict = [] for row in rows: if total <= cap_bytes: break evict.append(row) total -= row["size_bytes"] or 0 log.info("disk cap %d GB exceeded; evicting %d video(s)", cap_gb, len(evict)) return evict def run(conn: sqlite3.Connection, settings: Settings) -> dict: """Age-out pass plus optional disk-cap eviction.""" deleted = 0 for video in candidates(conn, settings): if delete_video(conn, video): deleted += 1 evicted = 0 for video in disk_cap_evictions(conn, settings): if delete_video(conn, video): evicted += 1 if deleted or evicted: # Without this, Jellyfin shows ghost episodes until its own scheduled scan. jellyfin.from_settings(settings).refresh() return {"deleted": deleted, "evicted": evicted}