Initial implementation of youtube-automate
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>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user