Files
MoviePilot/app/domain/scraper.py
2026-08-14 15:45:38 +08:00

195 lines
7.1 KiB
Python

import xml.etree.ElementTree as ET
from pathlib import Path
from typing import List, Optional
from urllib.parse import urlparse
from xml.dom import minidom
from app.domain.context import MediaInfo
from app.schemas.types import MediaSource, MediaType
from app.foundation.dom import DomUtils
class NfoReader:
"""读取 Kodi 风格 NFO XML 中的元素值。"""
def __init__(self, xml_file_path: Path):
"""解析指定 NFO 文件并保存根元素。"""
self.xml_file_path = xml_file_path
self.tree = ET.parse(xml_file_path)
self.root = self.tree.getroot()
def get_element_value(self, element_path) -> Optional[str]:
"""返回首个匹配元素的文本。"""
element = self.root.find(element_path)
return element.text if element is not None else None
def get_elements(self, element_path) -> List[ET.Element]:
"""返回路径匹配的全部 XML 元素。"""
return self.root.findall(element_path)
class MediaScraperHelper:
"""
基于统一媒体信息生成通用 NFO 与图片清单,供缺少专用刮削格式的数据源复用
"""
@staticmethod
def _media_identity(
mediainfo: MediaInfo,
) -> tuple[Optional[MediaSource], Optional[str]]:
"""
获取媒体信息中的来源与来源原生 ID。
:param mediainfo: 统一媒体信息
:return: 数据源名称与原生 ID
"""
return mediainfo.media_source, mediainfo.media_id
@staticmethod
def _image_extension(url: str) -> str:
"""
从图片 URL 中提取可用于本地文件名的扩展名。
:param url: 图片地址
:return: 图片扩展名,无法确定时返回 .jpg
"""
extension = Path(urlparse(url).path).suffix.lower()
return extension if extension in {".jpg", ".jpeg", ".png", ".webp"} else ".jpg"
@classmethod
def _append_common_nodes(
cls,
mediainfo: MediaInfo,
doc: minidom.Document,
root: minidom.Node,
) -> None:
"""
向 NFO 根节点写入各媒体类型共享的标准字段。
:param mediainfo: 统一媒体信息
:param doc: XML 文档
:param root: NFO 根节点
"""
DomUtils.add_node(doc, root, "title", mediainfo.title or "")
DomUtils.add_node(doc, root, "originaltitle", mediainfo.original_title or "")
DomUtils.add_node(doc, root, "year", mediainfo.year or "")
DomUtils.add_node(doc, root, "premiered", mediainfo.release_date or "")
DomUtils.add_node(doc, root, "rating", mediainfo.vote_average or "0")
plot = DomUtils.add_node(doc, root, "plot")
plot.appendChild(doc.createCDATASection(mediainfo.overview or ""))
outline = DomUtils.add_node(doc, root, "outline")
outline.appendChild(doc.createCDATASection(mediainfo.overview or ""))
source, media_id = cls._media_identity(mediainfo)
if source and media_id:
unique_id = DomUtils.add_node(doc, root, "uniqueid", media_id)
unique_id.setAttribute("type", str(source))
unique_id.setAttribute("default", "true")
for genre in mediainfo.genres or []:
genre_name = genre.get("name") if isinstance(genre, dict) else str(genre)
if genre_name:
DomUtils.add_node(doc, root, "genre", genre_name)
for company in mediainfo.production_companies or []:
company_name = company.get("name") if isinstance(company, dict) else str(company)
if company_name:
DomUtils.add_node(doc, root, "studio", company_name)
for director in mediainfo.directors or []:
director_name = director.get("name") if isinstance(director, dict) else str(director)
if director_name:
DomUtils.add_node(doc, root, "director", director_name)
for actor in mediainfo.actors or []:
if not isinstance(actor, dict):
continue
actor_node = DomUtils.add_node(doc, root, "actor")
DomUtils.add_node(doc, actor_node, "name", actor.get("name") or "")
DomUtils.add_node(
doc,
actor_node,
"role",
actor.get("character") or actor.get("role") or "",
)
avatar = actor.get("avatar") or actor.get("images") or {}
if isinstance(avatar, dict):
DomUtils.add_node(
doc,
actor_node,
"thumb",
avatar.get("large") or avatar.get("medium") or avatar.get("normal") or "",
)
@classmethod
def get_metadata_nfo(
cls,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> Optional[str]:
"""
根据统一媒体信息生成电影、剧集、季或单集 NFO。
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: NFO XML 文本
"""
if not mediainfo:
return None
doc = minidom.Document()
if mediainfo.type == MediaType.MOVIE:
root = DomUtils.add_node(doc, doc, "movie")
cls._append_common_nodes(mediainfo, doc, root)
elif season is not None and episode is not None:
root = DomUtils.add_node(doc, doc, "episodedetails")
cls._append_common_nodes(mediainfo, doc, root)
DomUtils.add_node(doc, root, "season", str(season))
DomUtils.add_node(doc, root, "episode", str(episode))
DomUtils.add_node(
doc,
root,
"showtitle",
mediainfo.title or "",
)
elif season is not None:
root = DomUtils.add_node(doc, doc, "season")
cls._append_common_nodes(mediainfo, doc, root)
DomUtils.add_node(doc, root, "seasonnumber", str(season))
else:
root = DomUtils.add_node(doc, doc, "tvshow")
cls._append_common_nodes(mediainfo, doc, root)
DomUtils.add_node(doc, root, "season", "-1")
DomUtils.add_node(doc, root, "episode", "-1")
return doc.toprettyxml(indent=" ", encoding="utf-8")
@classmethod
def get_metadata_img(
cls,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> dict:
"""
根据统一媒体信息生成主海报和背景图下载清单。
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: 图片文件名与下载地址映射
"""
if not mediainfo or season is not None or episode is not None:
return {}
images = {}
if mediainfo.poster_path:
extension = cls._image_extension(mediainfo.poster_path)
images[f"poster{extension}"] = mediainfo.poster_path
if mediainfo.backdrop_path:
extension = cls._image_extension(mediainfo.backdrop_path)
images[f"backdrop{extension}"] = mediainfo.backdrop_path
return images