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>
151 lines
4.8 KiB
Python
151 lines
4.8 KiB
Python
"""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}
|