Files
MoviePilot/app/modules/_base/mediaserver.py

259 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
媒体服务器模块业务样板基类。
沉淀各媒体服务器模块逐字复制的样板:用户辅助认证、媒体存在性检查、
定时重连与连接测试。服务器差异(认证 API、存在性检查端点、连接探测方式)
通过类属性与钩子方法保留在各模块。
"""
from typing import Any, Iterable, Mapping, Optional, Tuple, cast
from app.application.mediaserver import MusicMediaServerHelper
from app.domain.context import MediaInfo, MusicInfo
from app.modules import TService, _MediaServerBase, _ModuleBase
from app.runtime.events import eventmanager
from app.runtime.log import logger
from app.schemas.event import AuthCredentials as _SchemaAuthCredentials
from app.schemas.event import AuthInterceptCredentials as _SchemaAuthInterceptCredentials
from app.schemas.mediaserver import ExistMediaInfo as _SchemaExistMediaInfo
from app.schemas.mediaserver import MediaServerField, MediaServerQueryOptions
from app.schemas.types import ChainEventType, MediaType
def emby_episode_numbers(item: Mapping[str, Any]) -> range:
"""
展开 Emby 系分集条目的起止集号,缺少结束集号或倒序时按单集处理。
:param item: 含 IndexNumber 与可空 IndexNumberEnd 的分集响应
:return: 包含合并文件所覆盖全部集号的区间,未提供起始集号时为空
"""
number = item.get("IndexNumber")
if number is None:
return range(0)
start = int(number)
end = item.get("IndexNumberEnd")
return range(start, max(start, int(end) if end is not None else start) + 1)
def emby_query_params(query: Optional[MediaServerQueryOptions]) -> dict[str, str]:
"""
把显式投影翻译为 Emby/Jellyfin 已公开的列表查询控制参数。
名称、类型由服务端默认返回;年份必须通过 Fields 显式请求,避免精简查询丢失匹配和同步依据。
Fields 只追加当前投影所需的字段。
音乐完整性匹配需要 ChildCount,即使未请求额外统计也不能删除它。
:param query: 调用方投影;None 或 fields=None 时不改变旧请求
:return: 字段追加、图片信息和用户数据参数;不添加未公开的总量开关
"""
if query is None or query.fields is None:
return {}
fields = {
MediaServerField.LIBRARY: "ParentId",
MediaServerField.ORIGINAL_TITLE: "OriginalTitle",
MediaServerField.YEAR: "ProductionYear",
MediaServerField.IDENTITY: "ProviderIds",
MediaServerField.PATH: "Path",
MediaServerField.MUSIC: "ChildCount",
}
return {
"Fields": ",".join(value for field, value in fields.items() if query.includes(field)),
"EnableImages": str(query.includes(MediaServerField.IMAGES)).lower(),
"EnableUserData": str(query.includes(MediaServerField.USER_STATE)).lower(),
}
class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]):
"""
媒体服务器模块业务样板基类。
"""
# 媒体库标识(用于 ExistMediaInfo.server_type,如 "emby"),子类覆写
_server_type_value: str = ""
# 仅已实现查询投影的适配器向原生客户端透传新参数。
_query_options_supported: bool = False
def user_authenticate(
self,
credentials: _SchemaAuthCredentials,
service_name: Optional[str] = None,
) -> Optional[_SchemaAuthCredentials]:
"""
使用媒体服务器用户辅助完成用户认证。
:param credentials: 认证数据
:param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务器
:return: 成功时返回已写入渠道、服务名和令牌的原凭据;不适用或所有服务认证失败时返回 None
"""
if not credentials or credentials.grant_type != "password":
return None
# 确定要认证的服务器列表
servers: Iterable[tuple[Optional[str], TService]]
if service_name:
# 如果指定了服务名,获取该服务实例
servers = (
[(service_name, server)]
if (server := self.get_instance(service_name))
else []
)
else:
# 如果没有指定服务名,遍历所有服务
servers = self.get_instances().items()
# 遍历要认证的服务器
for name, server in servers:
service_name = name or ""
channel_name = self.get_name() or ""
# 触发认证拦截事件
intercept_event = eventmanager.send_event(
etype=ChainEventType.AuthIntercept,
data=_SchemaAuthInterceptCredentials(
username=credentials.username,
channel=channel_name,
service=service_name,
status="triggered",
),
)
if intercept_event and intercept_event.event_data:
intercept_data = (
intercept_event.event_data
if isinstance(intercept_event.event_data, _SchemaAuthInterceptCredentials)
else _SchemaAuthInterceptCredentials.model_validate(intercept_event.event_data)
)
if intercept_data.cancel:
continue
token = cast(Any, server).authenticate(credentials.username, credentials.password)
if token:
credentials.channel = channel_name
credentials.service = name
credentials.token = token
return credentials
return None
def media_exists(
self,
mediainfo: MediaInfo,
itemid: Optional[str] = None,
server: Optional[str] = None,
*,
query: Optional[MediaServerQueryOptions] = None,
) -> Optional[_SchemaExistMediaInfo]:
"""
判断媒体文件是否存在。
:param mediainfo: 识别的媒体信息
:param itemid: 媒体服务器ItemID
:param server: 媒体服务器名称
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
if not self._query_options_supported:
query = None
servers: Iterable[tuple[Optional[str], Optional[TService]]]
if server:
servers = [(server, self.get_instance(server))]
else:
servers = self.get_instances().items()
for name, s in servers:
if not s:
continue
if mediainfo.type == MediaType.MUSIC:
# 部分服务器未实现音乐查询,退化为空列表
if not isinstance(mediainfo, MusicInfo):
continue
matches: list[Any] = getattr(s, "get_music", lambda **_: [])(
**MusicMediaServerHelper.search_params(mediainfo),
**({"query": query.with_fields(MediaServerField.MUSIC)} if query is not None else {}),
)
match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return _SchemaExistMediaInfo(
type=MediaType.MUSIC,
server_type=self._server_type_value,
server=name,
itemid=match.item_id,
)
continue
service = cast(Any, s)
if mediainfo.type == MediaType.MOVIE:
if itemid:
movie = service.get_iteminfo(itemid, **({"query": query} if query is not None else {}))
if movie:
logger.info(f"媒体库 {name} 中找到了 {movie}")
return _SchemaExistMediaInfo(
type=MediaType.MOVIE, server_type=self._server_type_value, server=name, itemid=movie.item_id
)
movies = service.get_movies(
title=mediainfo.title,
year=mediainfo.year,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
**({"query": query} if query is not None else {}),
)
if not movies:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
continue
else:
logger.info(f"媒体库 {name} 中找到了 {movies}")
return _SchemaExistMediaInfo(
type=MediaType.MOVIE, server_type=self._server_type_value, server=name, itemid=movies[0].item_id
)
else:
itemid, tvs = service.get_tv_episodes(
title=mediainfo.title,
year=mediainfo.year,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
item_id=itemid,
**({"query": query} if query is not None else {}),
)
if not tvs:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
continue
else:
logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到 了这些季集:{tvs}")
return _SchemaExistMediaInfo(
type=MediaType.TV, seasons=tvs, server_type=self._server_type_value, server=name, itemid=itemid
)
return None
def scheduler_job(self) -> None:
"""
巡检已配置媒体服务器,对失活连接发起重连。
由模块调度器每十分钟调用一次。
"""
# 定时重连
for name, server in self.get_instances().items():
if self._is_inactive(server):
logger.info(f"{self.get_name()}服务器 {name} 连接断开,尝试重连 ...")
cast(Any, server).reconnect()
def _is_inactive(self, server: Any) -> bool:
"""
定时重连的失活判断钩子,子类可覆写(如增加配置完整性检查)。
"""
return bool(server.is_inactive())
def test(self) -> Optional[Tuple[bool, str]]:
"""
检查已配置媒体服务器,遇到首个不可用实例即返回失败。
:return: 是否就绪与失败说明;没有实例时返回 None
"""
if not self.get_instances():
return None
for name, server in self.get_instances().items():
error = self._test_server(server, name)
if error:
return False, error
return True, ""
def _test_server(self, server: Any, name: Optional[str]) -> Optional[str]:
"""
连接测试钩子,返回失败信息,None 表示就绪,子类可覆写。
"""
if server.is_inactive():
server.reconnect()
if not server.get_user():
return f"无法连接{self.get_name()}服务器:{name}"
return None