Files
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

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}]"