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