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>
105 lines
3.4 KiB
Python
105 lines
3.4 KiB
Python
"""Filename sanitisation and season/episode numbering.
|
|
|
|
Season is the upload year; episode is ``MMDD * 10 + ordinal_within_day``. That
|
|
scheme sorts correctly across a whole year (1 Jan is 1010, 31 Dec is 12310) and
|
|
leaves room for ten uploads per channel per day.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from datetime import date
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Characters that are illegal or awkward in filenames on the platforms Jellyfin
|
|
# clients run on. Replaced with a space rather than deleted so that "A/B" reads
|
|
# as "A B" instead of collapsing into "AB".
|
|
FORBIDDEN = '/\\:*?"<>|'
|
|
|
|
MAX_TITLE_LEN = 120
|
|
MAX_ORDINAL = 9
|
|
|
|
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
_WHITESPACE = re.compile(r"\s+")
|
|
|
|
|
|
def sanitize_component(text: str, max_len: int = MAX_TITLE_LEN) -> str:
|
|
"""Make one path component safe, collapsing whitespace and truncating."""
|
|
text = _CONTROL.sub(" ", text or "")
|
|
text = "".join(" " if char in FORBIDDEN else char for char in text)
|
|
text = _WHITESPACE.sub(" ", text).strip()
|
|
text = truncate_on_word_boundary(text, max_len)
|
|
# A component may not begin or end with a dot or space: leading dots hide the
|
|
# file from Jellyfin, trailing ones confuse some clients.
|
|
text = text.strip(" .")
|
|
return text
|
|
|
|
|
|
def truncate_on_word_boundary(text: str, max_len: int) -> str:
|
|
if len(text) <= max_len:
|
|
return text
|
|
cut = text[:max_len]
|
|
space = cut.rfind(" ")
|
|
# Only honour the word boundary if it doesn't throw away most of the name.
|
|
if space > max_len * 0.6:
|
|
cut = cut[:space]
|
|
return cut.rstrip()
|
|
|
|
|
|
def channel_dir_name(title: str, channel_id: str) -> str:
|
|
"""Directory name for a channel. Stored once and never recomputed."""
|
|
name = sanitize_component(title)
|
|
return name or channel_id
|
|
|
|
|
|
def parse_upload_date(value: str | date) -> date:
|
|
"""Accept yt-dlp's YYYYMMDD, ISO YYYY-MM-DD, or a date."""
|
|
if isinstance(value, date):
|
|
return value
|
|
text = str(value).strip()
|
|
if len(text) == 8 and text.isdigit():
|
|
return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
|
|
return date.fromisoformat(text[:10])
|
|
|
|
|
|
def season_for(upload_date: date) -> int:
|
|
return upload_date.year
|
|
|
|
|
|
def episode_base(upload_date: date) -> int:
|
|
"""First episode number available on this date."""
|
|
return (upload_date.month * 100 + upload_date.day) * 10
|
|
|
|
|
|
def episode_number(upload_date: date, ordinal: int) -> int:
|
|
"""Episode number for the nth upload on a given date (n starting at 0)."""
|
|
if ordinal > MAX_ORDINAL:
|
|
log.warning(
|
|
"more than %d uploads on %s; clamping ordinal %d",
|
|
MAX_ORDINAL + 1,
|
|
upload_date.isoformat(),
|
|
ordinal,
|
|
)
|
|
return episode_base(upload_date) + min(max(ordinal, 0), MAX_ORDINAL)
|
|
|
|
|
|
def episode_range(upload_date: date) -> tuple[int, int]:
|
|
"""Inclusive (low, high) episode numbers belonging to this date."""
|
|
base = episode_base(upload_date)
|
|
return base, base + MAX_ORDINAL
|
|
|
|
|
|
def season_dir_name(season: int) -> str:
|
|
return f"Season {season}"
|
|
|
|
|
|
def basename(channel_dir: str, season: int, episode: int, title: str, video_id: str) -> str:
|
|
"""Filename stem shared by the media file and every sidecar.
|
|
|
|
The [video_id] suffix guarantees uniqueness regardless of title collisions.
|
|
"""
|
|
safe_title = sanitize_component(title) or video_id
|
|
return f"{channel_dir} - S{season}E{episode} - {safe_title} [{video_id}]"
|