162 lines
4.6 KiB
Python
Executable File
162 lines
4.6 KiB
Python
Executable File
"""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"(?<!\w)#\S+")
|
|
PROMOTION_KEYWORDS = (
|
|
"点击下方",
|
|
"评论区",
|
|
"置顶",
|
|
"预览视频",
|
|
"支持一下",
|
|
"谢谢大家",
|
|
)
|
|
|
|
|
|
def get_nfo_path(video_path: str) -> 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
|