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>
204 lines
6.0 KiB
Python
204 lines
6.0 KiB
Python
"""Preflight checks.
|
|
|
|
`doctor` is the first acceptance criterion and the thing to run when something
|
|
breaks. Every check returns a row rather than raising, so one failure doesn't
|
|
hide the others.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import grp
|
|
import os
|
|
import stat
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from . import config, jellyfin, ytdlp
|
|
from .settings import Settings
|
|
|
|
MEDIA_GROUP = "mediaserver"
|
|
|
|
|
|
@dataclass
|
|
class Check:
|
|
name: str
|
|
ok: bool
|
|
detail: str
|
|
fatal: bool = True
|
|
|
|
|
|
def _deno() -> Check:
|
|
binary = config.VENV_BIN / "deno"
|
|
if not binary.exists():
|
|
return Check(
|
|
"js runtime",
|
|
False,
|
|
f"deno not found at {binary} — yt-dlp cannot solve n challenges (specs.md §3)",
|
|
)
|
|
try:
|
|
result = subprocess.run(
|
|
[str(binary), "--version"], capture_output=True, text=True, timeout=30
|
|
)
|
|
except OSError as exc:
|
|
return Check("js runtime", False, f"deno unusable: {exc}")
|
|
if result.returncode != 0:
|
|
return Check("js runtime", False, "deno --version failed")
|
|
return Check("js runtime", True, result.stdout.splitlines()[0])
|
|
|
|
|
|
def _ytdlp() -> Check:
|
|
try:
|
|
return Check("yt-dlp", True, ytdlp.version())
|
|
except (OSError, ytdlp.YtdlpError) as exc:
|
|
return Check("yt-dlp", False, str(exc))
|
|
|
|
|
|
def _ejs() -> Check:
|
|
try:
|
|
from importlib.metadata import version as pkg_version
|
|
|
|
return Check("yt-dlp-ejs", True, pkg_version("yt-dlp-ejs"))
|
|
except Exception:
|
|
return Check(
|
|
"yt-dlp-ejs",
|
|
False,
|
|
"not installed — reinstall with the yt-dlp[default] extra (specs.md §3)",
|
|
)
|
|
|
|
|
|
def _pot(settings: Settings) -> Check:
|
|
url = settings.get_str("pot_provider_url")
|
|
try:
|
|
info = ytdlp.pot_provider_ping(url)
|
|
except Exception as exc:
|
|
return Check("pot provider", False, f"{url}/ping unreachable: {exc}")
|
|
|
|
server_version = str(info.get("version", "?"))
|
|
installed = ytdlp.plugin_version()
|
|
if installed and installed != server_version:
|
|
return Check(
|
|
"pot provider",
|
|
False,
|
|
f"version skew: server {server_version} vs plugin {installed}",
|
|
)
|
|
return Check("pot provider", True, f"up, version {server_version}")
|
|
|
|
|
|
def _database() -> Check:
|
|
try:
|
|
from . import db
|
|
|
|
conn = db.connect()
|
|
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
|
conn.close()
|
|
except Exception as exc:
|
|
return Check("database", False, str(exc))
|
|
if str(mode).lower() != "wal":
|
|
return Check("database", False, f"journal_mode is {mode}, expected wal")
|
|
return Check("database", True, f"{config.DB_PATH} (schema v{version}, {mode})")
|
|
|
|
|
|
def _media_root() -> Check:
|
|
root = config.MEDIA_ROOT
|
|
if not root.is_dir():
|
|
return Check("media root", False, f"{root} does not exist")
|
|
if not os.access(root, os.W_OK | os.X_OK):
|
|
return Check("media root", False, f"{root} is not writable")
|
|
|
|
info = root.stat()
|
|
problems = []
|
|
try:
|
|
group = grp.getgrgid(info.st_gid).gr_name
|
|
except KeyError:
|
|
group = str(info.st_gid)
|
|
if group != MEDIA_GROUP:
|
|
problems.append(f"group is {group}, expected {MEDIA_GROUP}")
|
|
if not info.st_mode & stat.S_ISGID:
|
|
problems.append("setgid bit not set (new dirs won't inherit the group)")
|
|
|
|
if problems:
|
|
return Check("media root", False, f"{root}: " + "; ".join(problems))
|
|
return Check("media root", True, f"{root} ({group}, setgid)")
|
|
|
|
|
|
def _work_dir() -> Check:
|
|
work = config.WORK_DIR
|
|
if not work.is_dir():
|
|
return Check("work dir", False, f"{work} does not exist")
|
|
if work.stat().st_dev != config.MEDIA_ROOT.stat().st_dev:
|
|
return Check(
|
|
"work dir",
|
|
False,
|
|
"not on the same filesystem as the media root — moves would be copies",
|
|
)
|
|
if not (work / ".ignore").exists():
|
|
return Check("work dir", False, f"{work}/.ignore missing", fatal=False)
|
|
return Check("work dir", True, f"{work} (same fs, .ignore present)")
|
|
|
|
|
|
def _jellyfin(settings: Settings) -> Check:
|
|
client = jellyfin.from_settings(settings)
|
|
if not client.base_url:
|
|
return Check("jellyfin", False, "jellyfin_url not set", fatal=False)
|
|
try:
|
|
info = client.public_info()
|
|
except jellyfin.JellyfinError as exc:
|
|
return Check("jellyfin", False, str(exc))
|
|
|
|
label = f"{info.get('ServerName', '?')} {info.get('Version', '?')}"
|
|
if not client.api_key:
|
|
return Check(
|
|
"jellyfin",
|
|
False,
|
|
f"{label} reachable but no API key set (run set-jellyfin-key)",
|
|
fatal=False,
|
|
)
|
|
try:
|
|
library = client.find_library(config.MEDIA_ROOT)
|
|
except jellyfin.JellyfinError as exc:
|
|
return Check("jellyfin", False, f"API key rejected: {exc}")
|
|
if library is None:
|
|
return Check(
|
|
"jellyfin",
|
|
False,
|
|
f"{label}, no library for {config.MEDIA_ROOT} (run setup-jellyfin-library)",
|
|
fatal=False,
|
|
)
|
|
return Check("jellyfin", True, f"{label}, library '{library.get('Name')}'")
|
|
|
|
|
|
def run_checks(settings: Settings) -> list[Check]:
|
|
return [
|
|
_ytdlp(),
|
|
_ejs(),
|
|
_deno(),
|
|
_pot(settings),
|
|
_database(),
|
|
_media_root(),
|
|
_work_dir(),
|
|
_jellyfin(settings),
|
|
]
|
|
|
|
|
|
def report(checks: list[Check]) -> tuple[str, int]:
|
|
"""Render the checks and return (text, exit_code)."""
|
|
lines = []
|
|
failed_fatal = 0
|
|
for check in checks:
|
|
if check.ok:
|
|
mark = "ok "
|
|
elif check.fatal:
|
|
mark = "FAIL"
|
|
failed_fatal += 1
|
|
else:
|
|
mark = "warn"
|
|
lines.append(f" [{mark}] {check.name:<14} {check.detail}")
|
|
|
|
if failed_fatal:
|
|
lines.append(f"\n{failed_fatal} fatal problem(s).")
|
|
else:
|
|
lines.append("\nAll fatal checks passed.")
|
|
return "\n".join(lines), (1 if failed_fatal else 0)
|