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,104 @@
|
||||
"""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}]"
|
||||
Reference in New Issue
Block a user