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>
95 lines
3.4 KiB
Python
95 lines
3.4 KiB
Python
import xml.etree.ElementTree as ET
|
|
|
|
from youtube_automate import nfo
|
|
|
|
HOSTILE = (
|
|
"Ampersands & angle <brackets> and \"quotes\"\n"
|
|
"control chars: \x00\x07\x1b\n"
|
|
"emoji 🎬 and em-dash — and links https://example.com/?a=1&b=2"
|
|
)
|
|
|
|
|
|
class TestEpisodeNfo:
|
|
def build(self, **overrides):
|
|
kwargs = dict(
|
|
title="Video Title",
|
|
show_title="Some Channel",
|
|
season=2026,
|
|
episode=8110,
|
|
plot="A plot.",
|
|
aired="2026-08-11",
|
|
duration_seconds=762,
|
|
video_id="dQw4w9WgXcQ",
|
|
)
|
|
kwargs.update(overrides)
|
|
return nfo.episode_nfo(**kwargs)
|
|
|
|
def test_is_well_formed_xml(self):
|
|
root = ET.fromstring(self.build())
|
|
assert root.tag == "episodedetails"
|
|
|
|
def test_hostile_description_still_parses(self):
|
|
root = ET.fromstring(self.build(plot=HOSTILE))
|
|
plot = root.findtext("plot")
|
|
assert "&" in plot and "<brackets>" in plot
|
|
assert "🎬" in plot
|
|
|
|
def test_control_characters_are_stripped(self):
|
|
plot = ET.fromstring(self.build(plot=HOSTILE)).findtext("plot")
|
|
for bad in ("\x00", "\x07", "\x1b"):
|
|
assert bad not in plot
|
|
|
|
def test_newlines_are_preserved(self):
|
|
plot = ET.fromstring(self.build(plot="one\ntwo")).findtext("plot")
|
|
assert plot == "one\ntwo"
|
|
|
|
def test_runtime_is_rounded_minutes(self):
|
|
assert ET.fromstring(self.build(duration_seconds=762)).findtext("runtime") == "13"
|
|
|
|
def test_short_video_still_gets_at_least_one_minute(self):
|
|
assert ET.fromstring(self.build(duration_seconds=20)).findtext("runtime") == "1"
|
|
|
|
def test_runtime_omitted_when_duration_unknown(self):
|
|
assert ET.fromstring(self.build(duration_seconds=None)).find("runtime") is None
|
|
|
|
def test_unique_id_marks_youtube_as_default(self):
|
|
unique = ET.fromstring(self.build()).find("uniqueid")
|
|
assert unique.get("type") == "youtube"
|
|
assert unique.get("default") == "true"
|
|
assert unique.text == "dQw4w9WgXcQ"
|
|
|
|
def test_season_and_episode_are_present(self):
|
|
root = ET.fromstring(self.build())
|
|
assert root.findtext("season") == "2026"
|
|
assert root.findtext("episode") == "8110"
|
|
|
|
def test_title_keeps_characters_that_the_filename_strips(self):
|
|
root = ET.fromstring(self.build(title="Hermitcraft S11#11: Expanding Business"))
|
|
assert root.findtext("title") == "Hermitcraft S11#11: Expanding Business"
|
|
|
|
def test_empty_plot_does_not_break(self):
|
|
assert ET.fromstring(self.build(plot=None)).find("plot") is not None
|
|
|
|
|
|
class TestTvshowNfo:
|
|
def test_well_formed_and_carries_channel_id(self):
|
|
root = ET.fromstring(nfo.tvshow_nfo("clabretro", HOSTILE, "UCabc123"))
|
|
assert root.tag == "tvshow"
|
|
assert root.findtext("title") == "clabretro"
|
|
assert root.findtext("studio") == "YouTube"
|
|
assert root.find("uniqueid").text == "UCabc123"
|
|
|
|
|
|
class TestWrite:
|
|
def test_write_is_atomic_and_leaves_no_temp_file(self, tmp_path):
|
|
target = tmp_path / "sub" / "tvshow.nfo"
|
|
nfo.write(target, b"<tvshow/>")
|
|
assert target.read_bytes() == b"<tvshow/>"
|
|
assert list(tmp_path.rglob("*.tmp")) == []
|
|
|
|
def test_overwrites_existing(self, tmp_path):
|
|
target = tmp_path / "tvshow.nfo"
|
|
nfo.write(target, b"<a/>")
|
|
nfo.write(target, b"<b/>")
|
|
assert target.read_bytes() == b"<b/>"
|