Files
youtube-automate/youtube_automate/download.py
T
Tom FluxandClaude Opus 5 18bb2e420b 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>
2026-08-11 21:42:48 +01:00

331 lines
11 KiB
Python

"""The download worker.
One video at a time, into `.work/`, then everything moves into the season
directory with `os.rename()` — which is atomic because the work dir shares a
filesystem with the media root.
"""
from __future__ import annotations
import json
import logging
import shutil
import sqlite3
from pathlib import Path
from . import config, jellyfin, naming, nfo, videos, ytdlp
from .settings import Settings
log = logging.getLogger(__name__)
# yt-dlp exit code / message fragments that mean "the match filter rejected it",
# which is a decision rather than a failure.
_REJECT_MARKERS = (
"does not pass filter",
"skipping ..",
)
class DownloadOutcome:
QUEUED = "queued"
DONE = "downloaded"
SKIPPED_SHORT = videos.SKIPPED_SHORT
SKIPPED_LIVE = videos.SKIPPED_LIVE
DEFERRED = videos.DEFERRED
FAILED = videos.FAILED
def build_args(settings: Settings, video: sqlite3.Row) -> list[str]:
"""The yt-dlp invocation from specs.md §6."""
max_height = settings.get_int("max_height")
args = [
*ytdlp.extractor_args(settings.get_str("pot_provider_url")),
"-f",
f"bv*[height<={max_height}]+ba/b[height<={max_height}]",
# vcodec must outrank res: on hardware that cannot transcode, h264 at a
# lower resolution beats VP9 at 720p. And acodec must NOT outrank res,
# or `bv*` picks the combined 360p stream because it carries AAC.
"-S",
f"vcodec:h264,res:{max_height},acodec:aac",
"--merge-output-format",
"mp4",
"--no-playlist",
"--ignore-config",
"--write-info-json",
"--write-thumbnail",
"--convert-thumbnails",
"jpg",
"--retries",
"3",
"--fragment-retries",
"10",
"--sleep-requests",
"2",
"--sleep-interval",
"5",
"--max-sleep-interval",
"15",
"-P",
str(config.WORK_DIR),
"-o",
"%(id)s.%(ext)s",
]
if settings.get_bool("write_subs"):
args += [
"--write-subs",
"--write-auto-subs",
"--sub-langs",
settings.get_str("sub_langs"),
"--convert-subs",
"srt",
]
if settings.get_bool("sponsorblock_mark"):
args += ["--sponsorblock-mark", "all", "--embed-chapters"]
# The match filter only applies to rows the fallback feed produced. UULF has
# already excluded Shorts and livestreams for everything else.
if video["discovery_source"] == videos.SOURCE_UC:
minimum = settings.get_int("min_duration_seconds")
args += [
"--match-filter",
f"duration>?{minimum} & live_status!=?is_live "
f"& live_status!=?is_upcoming & !was_live",
]
args.append(f"https://www.youtube.com/watch?v={video['video_id']}")
return args
def _work_files(video_id: str) -> list[Path]:
return sorted(config.WORK_DIR.glob(f"{video_id}.*"))
def cleanup_work(video_id: str) -> None:
for path in _work_files(video_id):
try:
path.unlink()
except OSError as exc: # pragma: no cover - unusual fs state
log.warning("could not remove %s: %s", path, exc)
def _classify_rejection(info: dict | None, stdout: str, stderr: str) -> str | None:
"""Decide why the match filter rejected a video, if it did."""
blob = f"{stdout}\n{stderr}".lower()
if not any(marker in blob for marker in _REJECT_MARKERS):
return None
live_status = (info or {}).get("live_status")
if live_status == "is_upcoming":
return DownloadOutcome.DEFERRED
if live_status in ("is_live", "was_live") or (info or {}).get("was_live"):
return DownloadOutcome.SKIPPED_LIVE
# Duration is the only other condition in our filter.
return DownloadOutcome.SKIPPED_SHORT
def _read_info_json(video_id: str) -> dict | None:
path = config.WORK_DIR / f"{video_id}.info.json"
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
log.warning("unreadable info.json for %s: %s", video_id, exc)
return None
def _choose_subtitle(video_id: str) -> Path | None:
"""`--sub-langs en.*` matches both `en` and `en-orig`, yielding two identical
English tracks. Prefer `.en.srt`; otherwise promote `en-orig`."""
preferred = config.WORK_DIR / f"{video_id}.en.srt"
if preferred.exists():
return preferred
candidates = sorted(config.WORK_DIR.glob(f"{video_id}.en*.srt"))
return candidates[0] if candidates else None
def _move_into_place(
conn: sqlite3.Connection, video: sqlite3.Row, info: dict
) -> tuple[str, int]:
"""Rename every artefact into the season directory. Returns (rel_path, size)."""
upload_date = naming.parse_upload_date(
info.get("upload_date") or video["upload_date"]
)
season, episode = videos.next_episode(
conn, video["channel_pk"], upload_date, video["video_id"]
)
title = (info.get("title") or video["title"] or video["video_id"]).strip()
stem = naming.basename(
video["dir_name"], season, episode, title, video["video_id"]
)
season_dir = config.MEDIA_ROOT / video["dir_name"] / naming.season_dir_name(season)
season_dir.mkdir(parents=True, exist_ok=True)
media_source = config.WORK_DIR / f"{video['video_id']}.mp4"
if not media_source.exists():
raise FileNotFoundError(f"no mp4 produced for {video['video_id']}")
# The episode NFO is generated into the work dir first so that a failure
# here never leaves a half-populated season directory.
nfo.write(
config.WORK_DIR / f"{video['video_id']}.nfo",
nfo.episode_nfo(
title=title,
show_title=video["channel_title"],
season=season,
episode=episode,
plot=info.get("description"),
aired=upload_date.isoformat(),
duration_seconds=info.get("duration"),
video_id=video["video_id"],
),
)
moves: list[tuple[Path, Path]] = [(media_source, season_dir / f"{stem}.mp4")]
for suffix, target in (
(".nfo", f"{stem}.nfo"),
(".info.json", f"{stem}.info.json"),
(".jpg", f"{stem}-thumb.jpg"),
):
source = config.WORK_DIR / f"{video['video_id']}{suffix}"
if source.exists():
moves.append((source, season_dir / target))
subtitle = _choose_subtitle(video["video_id"])
if subtitle:
moves.append((subtitle, season_dir / f"{stem}.en.srt"))
for source, destination in moves:
source.replace(destination) # same filesystem: atomic rename
size = (season_dir / f"{stem}.mp4").stat().st_size
rel_path = str(
(season_dir / f"{stem}.mp4").relative_to(config.MEDIA_ROOT)
)
videos.mark_downloaded(
conn,
video["video_id"],
rel_path=rel_path,
size_bytes=size,
season=season,
episode=episode,
upload_date=upload_date.isoformat(),
duration=info.get("duration"),
title=title,
)
return rel_path, size
def download_one(conn: sqlite3.Connection, settings: Settings, video: sqlite3.Row) -> str:
"""Download a single video. Returns the resulting state."""
video_id = video["video_id"]
config.WORK_DIR.mkdir(parents=True, exist_ok=True)
cleanup_work(video_id)
videos.set_state(conn, video_id, videos.DOWNLOADING)
result = ytdlp.run(build_args(settings, video))
info = _read_info_json(video_id)
rejection = _classify_rejection(info, result.stdout, result.stderr)
if rejection is not None:
cleanup_work(video_id)
videos.set_state(conn, video_id, rejection)
log.info("%s rejected by match filter -> %s", video_id, rejection)
return rejection
if result.returncode != 0:
error = ytdlp.first_error(result.stderr) or f"exit {result.returncode}"
cleanup_work(video_id)
outcome = videos.record_failure(
conn, video_id, error, settings.get_int("max_attempts")
)
log.error("%s failed: %s (%s)", video_id, error, outcome)
return videos.FAILED
if info is None:
cleanup_work(video_id)
videos.record_failure(
conn, video_id, "no info.json produced", settings.get_int("max_attempts")
)
return videos.FAILED
try:
rel_path, size = _move_into_place(conn, video, info)
except Exception as exc: # noqa: BLE001 - any failure must clean up
cleanup_work(video_id)
videos.record_failure(
conn, video_id, str(exc), settings.get_int("max_attempts")
)
log.exception("moving %s into place failed", video_id)
return videos.FAILED
cleanup_work(video_id)
_warn_if_not_h264(video_id, info)
log.info("downloaded %s -> %s (%.1f MB)", video_id, rel_path, size / 1e6)
return videos.DOWNLOADED
def _warn_if_not_h264(video_id: str, info: dict) -> None:
"""§6: log the cases where no h264 rendition existed, since Jellyfin will
have to transcode them and this box cannot."""
vcodec = str(info.get("vcodec") or "")
acodec = str(info.get("acodec") or "")
if vcodec and not vcodec.startswith(("avc1", "h264")):
log.warning("%s has no h264 rendition (got %s) — will transcode", video_id, vcodec)
if acodec and not acodec.startswith(("mp4a", "aac")):
log.warning("%s has no aac audio (got %s) — will transcode", video_id, acodec)
def drain(
conn: sqlite3.Connection, settings: Settings, limit: int | None = None
) -> dict:
"""Work the queue, one video at a time. Returns per-outcome counts."""
if not _provider_healthy(settings):
raise RuntimeError(
"PO token provider is not reachable — refusing to download and "
"accumulate 403s. Check the bgutil-provider container."
)
counts: dict[str, int] = {}
queue = videos.claim_pending(conn, settings.get_int("max_attempts"), limit)
log.info("%d video(s) in the queue", len(queue))
for video in queue:
state = download_one(conn, settings, video)
counts[state] = counts.get(state, 0) + 1
if counts.get(videos.DOWNLOADED):
jellyfin.from_settings(settings).refresh()
return counts
def _provider_healthy(settings: Settings) -> bool:
try:
ytdlp.pot_provider_ping(settings.get_str("pot_provider_url"))
return True
except Exception as exc: # noqa: BLE001
log.error("POT provider health check failed: %s", exc)
return False
def recover_orphans() -> int:
"""Remove anything left in the work dir by a killed run."""
if not config.WORK_DIR.is_dir():
return 0
removed = 0
for path in config.WORK_DIR.iterdir():
if path.name == ".ignore":
continue
try:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
path.unlink()
removed += 1
except OSError: # pragma: no cover
pass
return removed