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>
82 lines
2.5 KiB
Python
82 lines
2.5 KiB
Python
"""Kodi-style NFO sidecars.
|
|
|
|
Video descriptions are hostile input — they contain ampersands, angle brackets,
|
|
emoji, ASCII art and control characters — so these are always built with
|
|
ElementTree's serialiser and never by string formatting.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
# XML 1.0 forbids most control characters outright; ElementTree will happily
|
|
# serialise them and produce a document no parser will read back. Written as a
|
|
# raw string so `re` interprets the escapes, not Python.
|
|
_ILLEGAL_XML = re.compile(
|
|
r"[^\x09\x0a\x0d\x20--�\U00010000-\U0010ffff]"
|
|
)
|
|
|
|
|
|
def clean_text(value: str | None) -> str:
|
|
return _ILLEGAL_XML.sub("", value or "")
|
|
|
|
|
|
def _child(parent: ET.Element, tag: str, text: str | None) -> ET.Element:
|
|
element = ET.SubElement(parent, tag)
|
|
element.text = clean_text(text)
|
|
return element
|
|
|
|
|
|
def _serialise(root: ET.Element) -> bytes:
|
|
ET.indent(root, space=" ")
|
|
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
|
|
|
|
def tvshow_nfo(title: str, plot: str | None, channel_id: str) -> bytes:
|
|
root = ET.Element("tvshow")
|
|
_child(root, "title", title)
|
|
_child(root, "plot", plot)
|
|
_child(root, "studio", "YouTube")
|
|
unique = _child(root, "uniqueid", channel_id)
|
|
unique.set("type", "youtube")
|
|
unique.set("default", "true")
|
|
return _serialise(root)
|
|
|
|
|
|
def episode_nfo(
|
|
*,
|
|
title: str,
|
|
show_title: str,
|
|
season: int,
|
|
episode: int,
|
|
plot: str | None,
|
|
aired: str,
|
|
duration_seconds: int | None,
|
|
video_id: str,
|
|
) -> bytes:
|
|
root = ET.Element("episodedetails")
|
|
_child(root, "title", title)
|
|
_child(root, "showtitle", show_title)
|
|
_child(root, "season", str(season))
|
|
_child(root, "episode", str(episode))
|
|
_child(root, "plot", plot)
|
|
_child(root, "aired", aired)
|
|
if duration_seconds:
|
|
# Kodi/Jellyfin expect <runtime> in whole minutes.
|
|
_child(root, "runtime", str(max(1, round(duration_seconds / 60))))
|
|
_child(root, "studio", "YouTube")
|
|
unique = _child(root, "uniqueid", video_id)
|
|
unique.set("type", "youtube")
|
|
unique.set("default", "true")
|
|
return _serialise(root)
|
|
|
|
|
|
def write(path: Path, payload: bytes) -> None:
|
|
"""Write atomically so a crash never leaves Jellyfin a half-written NFO."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(path.name + ".tmp")
|
|
temporary.write_bytes(payload)
|
|
temporary.replace(path)
|