"""On-disk layout for downloaded media — Plex-friendly tree + .nfo/poster sidecars. The physical filename is system-decided and bound to the source id (never renamed; the user's custom name is display-only, applied at local-download time). Layout is admin-configurable: plex: {Channel}/Season {Year}/{Channel} - {YYYY-MM-DD} - {title} [{id}].{ext} flat: {title} [{id}].{ext} Plus a Kodi/Plex-readable `.nfo` (episodedetails) and `.jpg` thumbnail, so a Plex library using a metadata agent picks up rich info. Channel = "show", video = episode, date-based — the community-standard mapping for YouTube-in-Plex. """ import re import shutil import unicodedata from dataclasses import dataclass from datetime import date from pathlib import Path from xml.sax.saxutils import escape # Reserve room for the " [id].ext" suffix + parent dirs within the 255-byte component limit. _MAX_TITLE = 120 # Filesystem-illegal on Windows/Plex + control chars. _ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f]') # Unicode categories we drop entirely: emoji/pictographs (So), modifier symbols/skin-tones # (Sk), and every control/format/private/surrogate/unassigned char (C*). Letters (incl. # accented á/ö/ü/ß), digits, and ordinary punctuation are KEPT — Siftlode is trilingual, so # ASCII-folding would mangle Hungarian/German titles. _DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"} @dataclass class MediaMeta: video_id: str title: str uploader: str upload_date: date | None duration_s: int | None = None description: str | None = None thumbnail_url: str | None = None def sanitize(name: str, limit: int = _MAX_TITLE) -> str: """Turn an arbitrary (emoji-laden, clickbait) title into a clean, space-free path token. Steps: NFKC-normalize (fold fancy width/compat forms) → drop emoji/symbols/control chars → strip filesystem-illegal chars → collapse whitespace and runs of the same punctuation → join words with underscores → trim junk punctuation from the ends → length-cap. Accented letters and digits survive; the result never contains spaces or path separators.""" text = unicodedata.normalize("NFKC", name or "") text = "".join( ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES ) text = _ILLEGAL.sub("", text) # Collapse repeated punctuation ("!!!", "???", "-----") to a single mark. text = re.sub(r"([^\w\s])\1{1,}", r"\1", text) # Whitespace → single underscore (no spaces in generated names). text = re.sub(r"\s+", "_", text.strip()) # Tidy separator pile-ups and trim junk from the ends. text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ") if len(text) > limit: text = text[:limit].rstrip("_.-") return text or "untitled" def display_filename(name: str) -> str: """User-facing download filename (Content-Disposition): strip emoji/symbols/control + filesystem-illegal chars, but KEEP spaces, accents, and ordinary punctuation — unlike the underscore-joined on-disk name, this is the readable name the user saves to their device.""" text = unicodedata.normalize("NFKC", name or "") text = "".join(ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES) text = _ILLEGAL.sub("", text) text = re.sub(r"\s+", " ", text).strip(" .") return text or "download" def rel_path(meta: MediaMeta, ext: str, layout: str = "plex") -> str: """Path of the media file relative to DOWNLOAD_ROOT (forward slashes).""" title = sanitize(meta.title) vid = meta.video_id if layout == "flat": return f"{title}_[{vid}].{ext}" channel = sanitize(meta.uploader or "Unknown_Channel", 80) year = meta.upload_date.year if meta.upload_date else 0 d = meta.upload_date.isoformat() if meta.upload_date else "0000-00-00" epname = f"{channel}_-_{d}_-_{title}_[{vid}].{ext}" return f"{channel}/Season_{year}/{epname}" def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str: """On-disk path (relative to DOWNLOAD_ROOT) for a phase-2 editor derivative. Kept in a per-user `.edits/` tree — device-downloadable via the file endpoint, but NOT part of the Plex library (these are user clips, not canonical episodes; there are no .nfo/poster sidecars). The name is system-decided and bound to the derivative asset id.""" return f".edits/{user_id}/clip_{asset_id}.{ext}" def abs_path(download_root: str, rel: str) -> Path: return Path(download_root) / rel def place_file(staging_file: Path, download_root: str, rel: str) -> None: """Move a completed staging file into its final location under DOWNLOAD_ROOT.""" dest = abs_path(download_root, rel) dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(staging_file), str(dest)) def _nfo_xml(meta: MediaMeta) -> str: aired = meta.upload_date.isoformat() if meta.upload_date else "" year = meta.upload_date.year if meta.upload_date else "" parts = [ '', "", f" {escape(meta.title or '')}", f" {escape(meta.uploader or '')}", f" {escape(meta.uploader or '')}", f" {aired}", f" {aired}", f" {year}", f" {escape(meta.description or '')}", f" {escape(meta.video_id)}", ] if meta.duration_s: parts.append(f" {round(meta.duration_s / 60)}") parts.append("") return "\n".join(parts) def write_sidecars( download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None ) -> bool: """Write `.nfo` + `.jpg` next to the media file. Returns True if written.""" media = abs_path(download_root, rel) base = media.with_suffix("") try: base.with_suffix(".nfo").write_text(_nfo_xml(meta), encoding="utf-8") if thumb_src and thumb_src.exists(): shutil.copyfile(str(thumb_src), str(base.with_suffix(".jpg"))) return True except OSError: return False def delete_asset_files(download_root: str, rel: str) -> None: """Remove the media file and its sidecars; prune now-empty parent dirs (best effort).""" media = abs_path(download_root, rel) base = media.with_suffix("") for p in (media, base.with_suffix(".nfo"), base.with_suffix(".jpg")): try: p.unlink() except OSError: pass # Prune empty Season/Channel dirs up to (but not including) the root. root = Path(download_root).resolve() parent = media.parent while parent != root and root in parent.parents: try: parent.rmdir() # only succeeds if empty except OSError: break parent = parent.parent