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,109 @@
|
||||
"""Thin wrapper around the venv's yt-dlp binary.
|
||||
|
||||
Everything that shells out to yt-dlp goes through here so the PATH handling (Deno
|
||||
must be discoverable — see specs.md §3) and the shared extractor args live in one
|
||||
place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from . import config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YtdlpError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def binary() -> Path:
|
||||
return config.VENV_BIN / "yt-dlp"
|
||||
|
||||
|
||||
def environment() -> dict[str, str]:
|
||||
"""Env for a yt-dlp subprocess.
|
||||
|
||||
yt-dlp locates the JS runtime by searching PATH, so the venv's bin directory
|
||||
must come first — that is where Deno lives.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = f"{config.VENV_BIN}:{env.get('PATH', '')}"
|
||||
return env
|
||||
|
||||
|
||||
def version() -> str:
|
||||
result = subprocess.run(
|
||||
[str(binary()), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment(),
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise YtdlpError(result.stderr.strip() or "yt-dlp --version failed")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def extractor_args(pot_provider_url: str) -> list[str]:
|
||||
return [
|
||||
"--extractor-args",
|
||||
"youtube:player_client=default,mweb",
|
||||
"--extractor-args",
|
||||
f"youtubepot-bgutilhttp:base_url={pot_provider_url}",
|
||||
]
|
||||
|
||||
|
||||
def run_json(args: list[str], timeout: int = 300) -> dict:
|
||||
"""Run yt-dlp with -J and parse the single JSON document it prints."""
|
||||
cmd = [str(binary()), *args]
|
||||
log.debug("yt-dlp %s", " ".join(args))
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, env=environment(), timeout=timeout
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise YtdlpError(first_error(result.stderr) or "yt-dlp failed")
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise YtdlpError(f"could not parse yt-dlp output: {exc}") from exc
|
||||
|
||||
|
||||
def run(args: list[str], timeout: int = 7200) -> subprocess.CompletedProcess:
|
||||
"""Run yt-dlp for its side effects, returning the completed process."""
|
||||
cmd = [str(binary()), *args]
|
||||
log.debug("yt-dlp %s", " ".join(args))
|
||||
return subprocess.run(
|
||||
cmd, capture_output=True, text=True, env=environment(), timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
def first_error(stderr: str) -> str:
|
||||
for line in (stderr or "").splitlines():
|
||||
if line.startswith("ERROR:"):
|
||||
return line[len("ERROR:") :].strip()
|
||||
return (stderr or "").strip().splitlines()[-1] if stderr.strip() else ""
|
||||
|
||||
|
||||
def pot_provider_ping(base_url: str, timeout: float = 5.0) -> dict:
|
||||
"""GET /ping on the bgutil provider. Raises on any failure."""
|
||||
url = base_url.rstrip("/") + "/ping"
|
||||
request = urllib.request.Request(url, headers={"User-Agent": config.USER_AGENT})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def plugin_version() -> str | None:
|
||||
"""Installed bgutil plugin version, for comparison against the server's."""
|
||||
try:
|
||||
from importlib.metadata import version as pkg_version
|
||||
|
||||
return pkg_version("bgutil-ytdlp-pot-provider")
|
||||
except Exception: # pragma: no cover - only when the plugin is absent
|
||||
return None
|
||||
Reference in New Issue
Block a user