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:
Tom Flux
2026-08-11 21:42:48 +01:00
co-authored by Claude Opus 5
commit 18bb2e420b
44 changed files with 7188 additions and 0 deletions
+150
View File
@@ -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}