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>
97 lines
3.0 KiB
Python
97 lines
3.0 KiB
Python
"""`run` orchestration: poll, then download, then reap — under a lock."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import fcntl
|
|
import logging
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
from . import config, discovery, download, reap, util, videos
|
|
from .settings import Settings
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class AlreadyRunning(Exception):
|
|
pass
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def exclusive_lock(path: Path | None = None):
|
|
"""Non-blocking flock. Raises AlreadyRunning if another run holds it.
|
|
|
|
The cron schedule is hourly and a large backfill can outlast that, so
|
|
overlapping runs are expected and must be a silent no-op rather than two
|
|
workers fighting over the same queue.
|
|
"""
|
|
path = path or config.LOCK_PATH
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
handle = path.open("w")
|
|
try:
|
|
try:
|
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError as exc:
|
|
raise AlreadyRunning(f"another run holds {path}") from exc
|
|
yield
|
|
finally:
|
|
with contextlib.suppress(OSError):
|
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
handle.close()
|
|
|
|
|
|
def recover(conn: sqlite3.Connection) -> dict:
|
|
"""Undo the effects of a killed run before doing anything else."""
|
|
requeued = videos.recover_downloading(conn)
|
|
orphans = download.recover_orphans()
|
|
if requeued or orphans:
|
|
log.info(
|
|
"crash recovery: %d row(s) back to pending, %d orphan file(s) cleared",
|
|
requeued,
|
|
orphans,
|
|
)
|
|
return {"requeued": requeued, "orphans": orphans}
|
|
|
|
|
|
def run(
|
|
conn: sqlite3.Connection, settings: Settings, channel_pk: int | None = None
|
|
) -> dict:
|
|
"""One full cycle. Assumes the caller holds the lock."""
|
|
result = {"recovered": recover(conn)}
|
|
|
|
result["poll"] = discovery.poll_all(conn, settings, channel_pk)
|
|
|
|
try:
|
|
result["download"] = download.drain(conn, settings)
|
|
except RuntimeError as exc:
|
|
# The POT provider being down is loud and fatal for this run, but the
|
|
# poll results are still worth keeping.
|
|
log.error("%s", exc)
|
|
result["download"] = {"error": str(exc)}
|
|
return result
|
|
|
|
result["reap"] = reap.run(conn, settings)
|
|
settings.set("last_run_at", util.utcnow_iso())
|
|
return result
|
|
|
|
|
|
def summarise(result: dict) -> str:
|
|
poll = result.get("poll", {})
|
|
down = result.get("download", {})
|
|
reaped = result.get("reap", {})
|
|
parts = [
|
|
f"discovered={poll.get('queued', 0)}",
|
|
f"repaired={poll.get('repaired', 0)}",
|
|
f"poll_failures={poll.get('failed', 0)}",
|
|
f"downloaded={down.get(videos.DOWNLOADED, 0)}",
|
|
f"failed={down.get(videos.FAILED, 0)}",
|
|
f"skipped={down.get(videos.SKIPPED_SHORT, 0) + down.get(videos.SKIPPED_LIVE, 0)}",
|
|
f"deferred={down.get(videos.DEFERRED, 0)}",
|
|
f"reaped={reaped.get('deleted', 0)}",
|
|
f"evicted={reaped.get('evicted', 0)}",
|
|
]
|
|
if "error" in down:
|
|
parts.append(f"ERROR={down['error']}")
|
|
return " ".join(parts)
|