"""Kodi/Jellyfin/Emby compatible NFO generation.""" import math import os import re import xml.etree.ElementTree as ET from typing import List, Optional from module.download_record import DownloadRecord HASHTAG_RE = re.compile(r"(? str: """Return the sidecar NFO path for a video path.""" return os.path.splitext(video_path)[0] + ".nfo" def clean_caption_line(line: str) -> str: """Remove hashtags and surrounding whitespace from one caption line.""" return HASHTAG_RE.sub("", line).strip() def extract_genres(caption: Optional[str]) -> List[str]: """Extract ordered unique genres from caption hashtags.""" if not caption: return [] genres: List[str] = [] seen = set() for tag in HASHTAG_RE.findall(caption): genre = tag.lstrip("#").strip() if genre and genre not in seen: genres.append(genre) seen.add(genre) return genres def is_promotional_line(line: str) -> bool: """Return True when a caption line is likely channel promotion text.""" return any(keyword in line for keyword in PROMOTION_KEYWORDS) def clean_caption(caption: Optional[str]) -> str: """Return media-library friendly caption text without tags or promo lines.""" if not caption: return "" lines: List[str] = [] for raw_line in caption.splitlines(): line = clean_caption_line(raw_line) if not line or is_promotional_line(line): continue lines.append(line) paragraphs: List[str] = [] current: List[str] = [] for line in lines: if line: current.append(line) elif current: paragraphs.append("\n".join(current)) current = [] if current: paragraphs.append("\n".join(current)) return "\n\n".join(paragraphs).strip() def title_from_record(record: DownloadRecord) -> str: """Choose a human-readable title for the media library.""" cleaned_caption = clean_caption(record.caption) if cleaned_caption: return cleaned_caption.splitlines()[0] if record.original_file_name: return os.path.splitext(record.original_file_name)[0] if record.chat_title: return f"{record.chat_title} {record.message_id}" return f"{record.chat_id} {record.message_id}" def date_part(value: Optional[str]) -> Optional[str]: """Return YYYY-MM-DD from an ISO-like timestamp.""" if not value: return None return value[:10] def year_part(value: Optional[str]) -> Optional[str]: """Return YYYY from an ISO-like timestamp.""" if not value: return None return value[:4] def runtime_minutes(duration: Optional[int]) -> Optional[str]: """Convert seconds to rounded-up minutes for NFO runtime.""" if not duration: return None return str(int(math.ceil(duration / 60.0))) def add_text(parent, tag: str, text: Optional[str]): """Add an XML child only when text is meaningful.""" if text is None or text == "": return child = ET.SubElement(parent, tag) child.text = str(text) def build_video_nfo_xml(record: DownloadRecord) -> str: """Build Kodi/Jellyfin/Emby-compatible movie XML.""" root = ET.Element("movie") title = title_from_record(record) plot = clean_caption(record.caption) add_text(root, "title", title) add_text(root, "plot", plot) unique_id = ET.SubElement( root, "uniqueid", {"type": "telegram", "default": "true"}, ) unique_id.text = f"{record.chat_id}_{record.message_id}" add_text(root, "premiered", date_part(record.message_date)) add_text(root, "year", year_part(record.message_date)) add_text(root, "runtime", runtime_minutes(record.duration)) add_text(root, "studio", record.chat_title) for genre in extract_genres(record.caption): add_text(root, "genre", genre) return ET.tostring(root, encoding="unicode") def write_video_nfo(record: DownloadRecord) -> str: """Write a sidecar NFO file for successful video records.""" if ( record.media_type != "video" or record.download_status != "success" or not record.local_path ): return "" nfo_path = get_nfo_path(record.local_path) nfo_dir = os.path.dirname(nfo_path) if nfo_dir: os.makedirs(nfo_dir, exist_ok=True) root = ET.fromstring(build_video_nfo_xml(record)) tree = ET.ElementTree(root) tree.write(nfo_path, encoding="utf-8", xml_declaration=True) return nfo_path