mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
259 lines
11 KiB
Python
259 lines
11 KiB
Python
"""
|
||
媒体服务器模块业务样板基类。
|
||
|
||
沉淀各媒体服务器模块逐字复制的样板:用户辅助认证、媒体存在性检查、
|
||
定时重连与连接测试。服务器差异(认证 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
|