mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
481 lines
18 KiB
Python
481 lines
18 KiB
Python
import json
|
||
import re
|
||
from collections.abc import Iterable, Mapping
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from typing import Any, Optional, Protocol, Union
|
||
|
||
from app.application.service import ServiceBaseHelper
|
||
from app.domain.context import MusicInfo
|
||
from app.runtime.log import logger
|
||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
|
||
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
|
||
from app.schemas.system import MediaServerConf, ServiceInfo
|
||
from app.schemas.types import (
|
||
MUSIC_ENTITY_ALBUM,
|
||
MediaSource,
|
||
ModuleType,
|
||
SystemConfigKey,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class MediaServerSyncItem:
|
||
"""媒体库同步写端使用的脱离远端响应与数据库会话的冻结条目。"""
|
||
|
||
server: str
|
||
library: Optional[str]
|
||
item_id: str
|
||
item_type: Optional[str]
|
||
title: Optional[str]
|
||
original_title: Optional[str]
|
||
year: Optional[Union[str, int]]
|
||
media_source: Optional[MediaSource]
|
||
media_id: Optional[str]
|
||
path: Optional[str]
|
||
seasoninfo: tuple[tuple[int, tuple[int, ...]], ...]
|
||
note_json: Optional[str]
|
||
lst_mod_date: str
|
||
|
||
@classmethod
|
||
def from_item(
|
||
cls,
|
||
item: _SchemaMediaServerItem,
|
||
*,
|
||
item_type: Optional[str],
|
||
seasoninfo: Mapping[int, Optional[list[int]]],
|
||
sync_time: str,
|
||
server: Optional[str] = None,
|
||
) -> "MediaServerSyncItem":
|
||
"""
|
||
冻结远端媒体条目和本轮剧集快照,供短事务持久化。
|
||
|
||
:param server: 媒体服务器配置名称;模块条目只携带 emby/jellyfin 等类型名,
|
||
陈旧清理、排除清理与存在性检查均按配置名称隔离缓存,同步写入必须使用同一键。
|
||
"""
|
||
return cls(
|
||
server=str(server or item.server or ""),
|
||
library=str(item.library) if item.library is not None else None,
|
||
item_id=str(item.item_id or ""),
|
||
item_type=item_type,
|
||
title=item.title,
|
||
original_title=item.original_title,
|
||
year=item.year,
|
||
media_source=item.media_source,
|
||
media_id=item.media_id,
|
||
path=item.path,
|
||
seasoninfo=tuple(
|
||
(season, tuple(episodes or ()))
|
||
for season, episodes in seasoninfo.items()
|
||
),
|
||
note_json=(
|
||
json.dumps(item.note, ensure_ascii=False)
|
||
if item.note is not None
|
||
else None
|
||
),
|
||
lst_mod_date=sync_time,
|
||
)
|
||
|
||
|
||
class MediaServerRepository(Protocol):
|
||
"""Chain 查询与同步媒体服务器本地缓存所需的最小持久化端口。"""
|
||
|
||
def get_item_id(
|
||
self,
|
||
*,
|
||
server: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
year: Optional[Union[str, int]] = None,
|
||
mtype: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
season: Optional[int] = None,
|
||
) -> Optional[str]:
|
||
"""返回匹配条目的服务器 item_id,未命中时返回 None。"""
|
||
...
|
||
|
||
def upsert(self, item: MediaServerSyncItem) -> bool:
|
||
"""在短事务中新增或更新一个冻结媒体条目。"""
|
||
...
|
||
|
||
def delete_stale(self, *, server: str, sync_time: str) -> int:
|
||
"""删除指定服务器本轮同步未更新的本地条目。"""
|
||
...
|
||
|
||
def delete_excluded_servers(self, servers: list[str]) -> int:
|
||
"""删除已停用或已移除服务器的本地条目。"""
|
||
...
|
||
|
||
|
||
class AsyncMediaServerQueryRepository(Protocol):
|
||
"""媒体服务器本地条目查询所需的异步持久化端口。"""
|
||
|
||
async def async_get_item_id(
|
||
self,
|
||
*,
|
||
server: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
year: Optional[Union[str, int]] = None,
|
||
mtype: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
season: Optional[int] = None,
|
||
) -> Optional[str]:
|
||
"""按标题或统一媒体身份返回已同步条目的标量 ID。"""
|
||
...
|
||
|
||
|
||
class MediaServerQueryService:
|
||
"""封装媒体服务器本地存在性标量查询。"""
|
||
|
||
def __init__(self, repository: AsyncMediaServerQueryRepository):
|
||
"""使用显式媒体服务器查询端口初始化服务。"""
|
||
self._repository = repository
|
||
|
||
async def find_item_id(
|
||
self,
|
||
*,
|
||
server: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
year: Optional[Union[str, int]] = None,
|
||
mtype: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
season: Optional[int] = None,
|
||
) -> Optional[str]:
|
||
"""返回匹配条目的服务器 item_id,未命中时返回 None。"""
|
||
if server is None:
|
||
return await self._repository.async_get_item_id(
|
||
title=title,
|
||
year=year,
|
||
mtype=mtype,
|
||
media_source=media_source,
|
||
media_id=media_id,
|
||
season=season,
|
||
)
|
||
return await self._repository.async_get_item_id(
|
||
server=server,
|
||
title=title,
|
||
year=year,
|
||
mtype=mtype,
|
||
media_source=media_source,
|
||
media_id=media_id,
|
||
season=season,
|
||
)
|
||
|
||
|
||
class MediaServerIdentityHelper:
|
||
"""将媒体服务器专有 ProviderIds 适配为统一媒体身份。"""
|
||
|
||
_provider_keys = (
|
||
(MediaSource.TMDB, ("Tmdb", "TMDB", "tmdb", "tmdb_id")),
|
||
(MediaSource.Douban, ("Douban", "douban", "douban_id")),
|
||
(MediaSource.Bangumi, ("Bangumi", "bangumi", "bangumi_id")),
|
||
(MediaSource.AniList, ("AniList", "Anilist", "anilist", "anilist_id")),
|
||
(MediaSource.IMDb, ("Imdb", "IMDb", "imdb", "imdb_id")),
|
||
(MediaSource.TVDB, ("Tvdb", "TVDB", "tvdb", "tvdb_id")),
|
||
(MediaSource.MusicBrainz, ("MusicBrainz", "musicbrainz", "musicbrainz_id")),
|
||
)
|
||
|
||
@classmethod
|
||
def from_provider_ids(
|
||
cls,
|
||
provider_ids: Optional[Mapping[str, Any]],
|
||
) -> tuple[Optional[MediaSource], Optional[str]]:
|
||
"""按固定优先级从外部 ProviderIds 选择一个规范媒体身份。"""
|
||
if not isinstance(provider_ids, Mapping):
|
||
return None, None
|
||
for media_source, keys in cls._provider_keys:
|
||
for key in keys:
|
||
value = provider_ids.get(key)
|
||
if value is not None and str(value).strip():
|
||
return media_source, str(value).strip()
|
||
return None, None
|
||
|
||
@staticmethod
|
||
def are_compatible(
|
||
left_source: Optional[MediaSource | str],
|
||
left_id: Optional[str],
|
||
right_source: Optional[MediaSource | str],
|
||
right_id: Optional[str],
|
||
) -> bool:
|
||
"""判断两组身份是否没有可证实的同来源 ID 冲突。"""
|
||
left_source, left_id = resolve_media_identity(
|
||
media_source=left_source,
|
||
media_id=left_id,
|
||
)
|
||
right_source, right_id = resolve_media_identity(
|
||
media_source=right_source,
|
||
media_id=right_id,
|
||
)
|
||
if not left_source or not right_source:
|
||
return True
|
||
if normalize_media_source(left_source) != normalize_media_source(right_source):
|
||
return True
|
||
return left_id == right_id
|
||
|
||
@classmethod
|
||
def is_compatible(
|
||
cls,
|
||
item: _SchemaMediaServerItem,
|
||
media_source: Optional[MediaSource | str],
|
||
media_id: Optional[str],
|
||
) -> bool:
|
||
"""判断目标与媒体库条目是否无明确身份冲突。"""
|
||
item_source, item_id = resolve_media_identity(media=item)
|
||
return cls.are_compatible(item_source, item_id, media_source, media_id)
|
||
|
||
|
||
class MusicMediaServerHelper:
|
||
"""统一音乐媒体库条目的字段转换、精确匹配和整专完整性判断。"""
|
||
|
||
_name_pattern = re.compile(r"[\W_]+", re.UNICODE)
|
||
|
||
@classmethod
|
||
def normalize_name(cls, value: Optional[str]) -> str:
|
||
"""忽略大小写、空白和标点,生成用于音乐名称精确比较的稳定文本。"""
|
||
return cls._name_pattern.sub("", str(value or "").casefold())
|
||
|
||
@classmethod
|
||
def same_name(cls, left: Optional[str], right: Optional[str]) -> bool:
|
||
"""判断两个非空音乐名称在规范化后是否完全一致。"""
|
||
normalized_left = cls.normalize_name(left)
|
||
normalized_right = cls.normalize_name(right)
|
||
return bool(normalized_left) and normalized_left == normalized_right
|
||
|
||
@staticmethod
|
||
def _first_value(data: Mapping[str, Any], *keys: str) -> Any:
|
||
"""按候选键顺序返回第一个非空字段,兼容不同媒体服务器命名。"""
|
||
for key in keys:
|
||
value = data.get(key)
|
||
if value not in (None, "", []):
|
||
return value
|
||
return None
|
||
|
||
@classmethod
|
||
def _extract_names(cls, value: Any) -> list[str]:
|
||
"""从字符串、对象列表或名称列表中提取非空名称。"""
|
||
if isinstance(value, str):
|
||
return [value] if value.strip() else []
|
||
if isinstance(value, Mapping):
|
||
name = cls._first_value(value, "Name", "name", "Title", "title")
|
||
return [str(name)] if name and str(name).strip() else []
|
||
if not isinstance(value, Iterable) or isinstance(value, bytes):
|
||
return []
|
||
names: list[str] = []
|
||
for item in value:
|
||
if isinstance(item, Mapping):
|
||
name = cls._first_value(item, "Name", "name", "Title", "title")
|
||
else:
|
||
name = item
|
||
if name and str(name).strip():
|
||
names.append(str(name))
|
||
return names
|
||
|
||
@classmethod
|
||
def build_note(cls, item: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""把 Emby 系和 NAS 搜索结果中的音乐字段转换为统一备注结构。"""
|
||
artists = cls._extract_names(
|
||
cls._first_value(item, "Artists", "artists", "ArtistItems", "artist_items")
|
||
)
|
||
album_artists = cls._extract_names(
|
||
cls._first_value(item, "AlbumArtists", "album_artists")
|
||
)
|
||
artist = cls._first_value(
|
||
item,
|
||
"AlbumArtist",
|
||
"album_artist",
|
||
"Artist",
|
||
"artist",
|
||
"artist_name",
|
||
"singer",
|
||
)
|
||
explicit_artists = cls._extract_names(artist)
|
||
if explicit_artists:
|
||
artist = explicit_artists[0]
|
||
if not artist:
|
||
artist = next(iter(album_artists or artists), None)
|
||
|
||
item_type = cls.normalize_name(
|
||
cls._first_value(item, "Type", "type", "item_type")
|
||
)
|
||
album = cls._first_value(item, "Album", "album", "album_name")
|
||
if not album and item_type in {"musicalbum", "album"}:
|
||
album = cls._first_value(item, "Name", "name", "Title", "title")
|
||
|
||
song_count = cls._first_value(
|
||
item,
|
||
"ChildCount",
|
||
"child_count",
|
||
"SongCount",
|
||
"songCount",
|
||
"song_count",
|
||
"TrackCount",
|
||
"trackCount",
|
||
"track_count",
|
||
"LeafCount",
|
||
"leafCount",
|
||
)
|
||
return {
|
||
"artist": str(artist) if artist is not None else None,
|
||
"artists": artists or album_artists,
|
||
"album": str(album) if album is not None else None,
|
||
"song_count": song_count,
|
||
}
|
||
|
||
@staticmethod
|
||
def search_params(mediainfo: MusicInfo) -> dict[str, Optional[str]]:
|
||
"""按单曲或专辑实体构造媒体服务器音乐搜索参数。"""
|
||
is_album = getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
|
||
artists = getattr(mediainfo, "artists", None) or []
|
||
artist = (
|
||
getattr(mediainfo, "album_artist", None)
|
||
or next(iter(artists), None)
|
||
or getattr(mediainfo, "artist", None)
|
||
)
|
||
title = getattr(mediainfo, "title", None)
|
||
album = getattr(mediainfo, "album", None) or title
|
||
return {
|
||
"title": None if is_album else title,
|
||
"artist": artist,
|
||
"album": album if is_album else None,
|
||
}
|
||
|
||
@classmethod
|
||
def item_matches(
|
||
cls,
|
||
mediainfo: MusicInfo,
|
||
item: _SchemaMediaServerItem,
|
||
) -> bool:
|
||
"""校验媒体库条目是否精确对应单曲,或完整覆盖目标专辑。"""
|
||
note = item.note if isinstance(item.note, Mapping) else {}
|
||
is_album = getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
|
||
target_title = getattr(mediainfo, "title", None)
|
||
actual_title = item.title
|
||
if is_album:
|
||
target_title = getattr(mediainfo, "album", None) or target_title
|
||
actual_title = note.get("album") or actual_title
|
||
if not cls.same_name(actual_title, target_title):
|
||
return False
|
||
|
||
target_artists = [
|
||
getattr(mediainfo, "artist", None),
|
||
getattr(mediainfo, "album_artist", None),
|
||
*(getattr(mediainfo, "artists", None) or []),
|
||
]
|
||
target_artists = [artist for artist in target_artists if artist]
|
||
actual_artists = [note.get("artist"), *cls._extract_names(note.get("artists"))]
|
||
actual_artists = [artist for artist in actual_artists if artist]
|
||
if target_artists and not any(
|
||
cls.same_name(actual, target)
|
||
for actual in actual_artists
|
||
for target in target_artists
|
||
):
|
||
return False
|
||
|
||
if not is_album:
|
||
return True
|
||
try:
|
||
expected_tracks = int(getattr(mediainfo, "total_tracks", None) or 0)
|
||
actual_tracks = int(note.get("song_count") or 0)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return expected_tracks > 0 and actual_tracks >= expected_tracks
|
||
|
||
@classmethod
|
||
def find_match(
|
||
cls,
|
||
mediainfo: MusicInfo,
|
||
items: Optional[Iterable[_SchemaMediaServerItem]],
|
||
) -> Optional[_SchemaMediaServerItem]:
|
||
"""返回首个满足单曲精确匹配或整专完整性要求的媒体库条目。"""
|
||
return next(
|
||
(item for item in items or [] if item and cls.item_matches(mediainfo, item)),
|
||
None,
|
||
)
|
||
|
||
|
||
def format_emby_family_item(
|
||
item: Mapping[str, Any],
|
||
*,
|
||
server: str,
|
||
include_server_id: bool = False,
|
||
) -> Optional[_SchemaMediaServerItem]:
|
||
"""把 Emby 系服务条目转换为统一媒体服务器模型,并保留各服务既有字段差异。"""
|
||
try:
|
||
user_data = item.get("UserData") or {}
|
||
if not user_data:
|
||
user_state = None
|
||
else:
|
||
playback_position_ticks = user_data.get("PlaybackPositionTicks")
|
||
resume = playback_position_ticks and playback_position_ticks > 0
|
||
last_played_date = user_data.get("LastPlayedDate")
|
||
if last_played_date is not None and "." in last_played_date:
|
||
last_played_date = last_played_date.split(".")[0]
|
||
user_state = _SchemaMediaServerItemUserState(
|
||
played=user_data.get("Played"),
|
||
resume=resume,
|
||
last_played_date=datetime.strptime(
|
||
last_played_date,
|
||
"%Y-%m-%dT%H:%M:%S",
|
||
).strftime("%Y-%m-%d %H:%M:%S") if last_played_date else None,
|
||
play_count=user_data.get("PlayCount"),
|
||
percentage=user_data.get("PlayedPercentage"),
|
||
)
|
||
media_source, media_id = MediaServerIdentityHelper.from_provider_ids(
|
||
item.get("ProviderIds")
|
||
)
|
||
fields = {
|
||
"server": server,
|
||
"library": item.get("ParentId"),
|
||
"item_id": item.get("Id"),
|
||
"item_type": item.get("Type"),
|
||
"title": item.get("Name"),
|
||
"original_title": item.get("OriginalTitle"),
|
||
"year": item.get("ProductionYear"),
|
||
"media_source": media_source,
|
||
"media_id": media_id,
|
||
"path": item.get("Path"),
|
||
"note": MusicMediaServerHelper.build_note(item)
|
||
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
|
||
"user_state": user_state,
|
||
}
|
||
if include_server_id:
|
||
fields["server_id"] = item.get("ServerId")
|
||
return _SchemaMediaServerItem(**fields)
|
||
except Exception as error:
|
||
logger.error(error)
|
||
return None
|
||
|
||
|
||
class MediaServerHelper(ServiceBaseHelper[MediaServerConf]):
|
||
"""管理媒体服务器配置,并按类型发现已启用的服务实例。"""
|
||
|
||
def __init__(self) -> None:
|
||
"""绑定媒体服务器配置键、配置模型和模块类型。"""
|
||
super().__init__(
|
||
config_key=SystemConfigKey.MediaServers,
|
||
conf_type=MediaServerConf,
|
||
module_type=ModuleType.MediaServer,
|
||
)
|
||
|
||
def is_media_server(
|
||
self,
|
||
service_type: Optional[str] = None,
|
||
service: Optional[ServiceInfo] = None,
|
||
name: Optional[str] = None,
|
||
) -> bool:
|
||
"""判断给定服务或服务名称是否属于指定媒体服务器类型。"""
|
||
service = service or self.get_service(name=name)
|
||
return bool(service and service.type == service_type)
|
||
|
||
|
||
def get_mediaserver_configs(
|
||
include_disabled: bool = False,
|
||
) -> list[MediaServerConf]:
|
||
"""返回媒体服务器配置列表,并按调用方需要决定是否包含禁用项。"""
|
||
return list(
|
||
MediaServerHelper().get_configs(include_disabled=include_disabled).values()
|
||
)
|