feat: expand metadata sources and media server sync (#6129)

This commit is contained in:
jxxghp
2026-07-21 11:34:18 +08:00
parent 1d708870c9
commit e3f80af74f
43 changed files with 2581 additions and 152 deletions
+12 -2
View File
@@ -1,4 +1,4 @@
from typing import Any, List, Annotated, Optional
from typing import Any, List, Annotated, Literal, Optional
from fastapi import APIRouter, Depends, Body
@@ -17,6 +17,7 @@ from app.schemas.types import SystemConfigKey
from app.utils.security import SecurityUtils
router = APIRouter()
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"]
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
@@ -97,6 +98,8 @@ def add(
torrent_in: schemas.TorrentInfo,
tmdbid: Annotated[int | None, Body()] = None,
doubanid: Annotated[str | None, Body()] = None,
media_source: Annotated[MediaSource | None, Body()] = None,
media_id: Annotated[str | None, Body()] = None,
downloader: Annotated[str | None, Body()] = None,
# 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等
save_path: Annotated[str | None, Body()] = None,
@@ -108,15 +111,18 @@ def add(
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
# 媒体信息
if tmdbid or doubanid:
if tmdbid or doubanid or media_id:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
source=media_source,
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
source=media_source,
obtain_images=False,
)
if not mediainfo:
@@ -146,6 +152,8 @@ def download_subtitle(
subtitle_in: schemas.SubtitleInfo,
tmdbid: Annotated[int | None, Body()] = None,
doubanid: Annotated[str | None, Body()] = None,
media_source: Annotated[MediaSource | None, Body()] = None,
media_id: Annotated[str | None, Body()] = None,
save_path: Annotated[str | None, Body()] = None,
current_user: User = Depends(get_current_active_user),
) -> Any:
@@ -160,6 +168,8 @@ def download_subtitle(
success, message, saved_files = DownloadChain().download_subtitle(
subtitle=subtitle_info,
media_source=media_source,
media_id=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
save_path=save_path,
+25 -9
View File
@@ -1,5 +1,5 @@
from pathlib import Path
from typing import List, Any, Union, Annotated, Optional
from typing import Annotated, Any, List, Literal, Optional, Union
from fastapi import APIRouter, Depends
@@ -18,6 +18,7 @@ from app.schemas.category import CategoryConfig
from app.schemas.types import ChainEventType
router = APIRouter()
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"]
@router.get(
@@ -27,17 +28,22 @@ async def recognize(
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
source: Optional[MediaSource] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据标题、副标题识别媒体信息
:param custom_words: 临时识别词(每行一条规则),传入时仅在本次识别中生效,不会保存到系统配置
:param source: 请求级识别数据源
"""
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
metainfo = MetaInfo(
title, subtitle, custom_words=custom_words.split("\n") if custom_words else None
)
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
mediainfo = await MediaChain().async_recognize_by_meta(
metainfo,
source=source,
)
if mediainfo:
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
return schemas.Context()
@@ -53,25 +59,28 @@ async def recognize2(
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
source: Optional[MediaSource] = None,
) -> Any:
"""
根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx
"""
# 识别媒体信息
return await recognize(title, subtitle, custom_words)
return await recognize(title, subtitle, custom_words, source)
@router.get(
"/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context
)
async def recognize_file(
path: str, _: schemas.TokenPayload = Depends(verify_token)
path: str,
source: Optional[MediaSource] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据文件路径识别媒体信息
"""
# 识别媒体信息
context = await MediaChain().async_recognize_by_path(path)
context = await MediaChain().async_recognize_by_path(path, source=source)
if context:
return context.to_dict()
return schemas.Context()
@@ -83,13 +92,15 @@ async def recognize_file(
response_model=schemas.Context,
)
async def recognize_file2(
path: str, _: Annotated[str, Depends(verify_apitoken)]
path: str,
_: Annotated[str, Depends(verify_apitoken)],
source: Optional[MediaSource] = None,
) -> Any:
"""
根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx
"""
# 识别媒体信息
return await recognize_file(path)
return await recognize_file(path, source)
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
@@ -98,6 +109,7 @@ async def search(
type: Optional[str] = "media",
page: int = 1,
count: int = 8,
source: Optional[MediaSource] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
@@ -114,7 +126,7 @@ async def search(
media_chain = MediaChain()
if type == "media":
_, medias = await media_chain.async_search(title=title)
_, medias = await media_chain.async_search(title=title, source=source)
result = [media.to_dict() for media in medias] if medias else []
elif type == "collection":
collections = await media_chain.async_search_collections(name=title)
@@ -294,7 +306,7 @@ async def detail(
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
根据带来源前缀的媒体ID查询媒体信息,type_name: 电影/电视剧
"""
mtype = MediaType(type_name)
mediainfo = None
@@ -311,6 +323,10 @@ async def detail(
mediainfo = await mediachain.async_recognize_media(
bangumiid=int(mediaid[8:]), mtype=mtype
)
elif mediaid.startswith("anilist:"):
mediainfo = await mediachain.async_recognize_media(
anilistid=int(mediaid[8:]), mtype=mtype
)
else:
# 广播事件解析媒体信息
event_data = MediaRecognizeConvertEventData(
+12
View File
@@ -292,6 +292,14 @@ def manual_transfer(
transer_item.doubanid = (
str(history.doubanid) if history.doubanid else transer_item.doubanid
)
transer_item.media_source = (
getattr(history, "media_source", None)
or transer_item.media_source
)
transer_item.media_id = (
getattr(history, "media_id", None)
or transer_item.media_id
)
transer_item.season = (
int(str(history.seasons).replace("S", ""))
if history.seasons
@@ -409,6 +417,8 @@ def manual_transfer(
target_path=target_path,
tmdbid=transer_item.tmdbid,
doubanid=transer_item.doubanid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,
@@ -491,6 +501,8 @@ def manual_transfer(
target_path=target_path,
tmdbid=transer_item.tmdbid,
doubanid=transer_item.doubanid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,
+199 -36
View File
@@ -464,14 +464,24 @@ class ChainBase(metaclass=ABCMeta):
)
return result
def run_module(self, method: str, *args, **kwargs) -> Any:
def run_module(
self,
method: str,
*args,
system_only: bool = False,
**kwargs,
) -> Any:
"""
运行包含该方法的所有模块,然后返回结果
当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常
:param method: 模块方法名称
:param system_only: 是否仅执行系统模块
"""
result = None
# 执行插件模块
if not system_only:
result = self.__execute_plugin_modules(method, result, *args, **kwargs)
if not self.__is_valid_empty(result) and not isinstance(result, list):
@@ -481,15 +491,25 @@ class ChainBase(metaclass=ABCMeta):
# 执行系统模块
return self.__execute_system_modules(method, result, *args, **kwargs)
async def async_run_module(self, method: str, *args, **kwargs) -> Any:
async def async_run_module(
self,
method: str,
*args,
system_only: bool = False,
**kwargs,
) -> Any:
"""
异步运行包含该方法的所有模块,然后返回结果
当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常
支持异步和同步方法的混合调用
:param method: 模块方法名称
:param system_only: 是否仅执行系统模块
"""
result = None
# 执行插件模块
if not system_only:
result = await self.__async_execute_plugin_modules(
method, result, *args, **kwargs
)
@@ -560,13 +580,75 @@ class ChainBase(metaclass=ABCMeta):
mediainfo=mediainfo,
)
@staticmethod
def _resolve_media_source_params(
source: Optional[str] = None,
mediaid: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
) -> Tuple[Optional[str], Optional[int], Optional[str], Optional[int], Optional[int]]:
"""
统一请求级数据源ID与兼容字段,并保证同一次识别只携带一个来源ID。
:param source: 数据源名称
:param mediaid: 数据源原生ID
:param tmdbid: TMDB兼容ID
:param doubanid: 豆瓣兼容ID
:param bangumiid: Bangumi兼容ID
:param anilistid: AniList兼容ID
:return: 数据源及四种兼容ID
"""
source_aliases = {
"tmdb": "themoviedb",
"themoviedb": "themoviedb",
"douban": "douban",
"bangumi": "bangumi",
"anilist": "anilist",
}
source = source_aliases.get(str(source).casefold()) if source else None
def to_int(value) -> Optional[int]:
"""将数字ID安全转换为整数。"""
return int(value) if value is not None and str(value).isdigit() else None
if source:
source_ids = {
"themoviedb": to_int(mediaid) if mediaid else to_int(tmdbid),
"douban": str(mediaid) if mediaid else str(doubanid) if doubanid else None,
"bangumi": to_int(mediaid) if mediaid else to_int(bangumiid),
"anilist": to_int(mediaid) if mediaid else to_int(anilistid),
}
selected_id = source_ids.get(source)
return (
source,
selected_id if source == "themoviedb" else None,
selected_id if source == "douban" else None,
selected_id if source == "bangumi" else None,
selected_id if source == "anilist" else None,
)
if tmdbid:
return "themoviedb", int(tmdbid), None, None, None
if doubanid:
return "douban", None, str(doubanid), None, None
if bangumiid:
return "bangumi", None, None, int(bangumiid), None
if anilistid:
return "anilist", None, None, None, int(anilistid)
return source, None, None, None, None
def recognize_media(
self,
meta: MetaBase = None,
mtype: Optional[MediaType] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
episode_group: Optional[str] = None,
cache: bool = True,
share_meta: MetaBase = None,
@@ -576,37 +658,66 @@ class ChainBase(metaclass=ABCMeta):
:param meta: 识别的元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param mtype: 识别的媒体类型,与tmdbid配套
:param source: 请求级识别数据源
:param mediaid: 与source配套的数据源原生ID
:param tmdbid: tmdbid
:param doubanid: 豆瓣ID
:param bangumiid: BangumiID
:param anilistid: AniList ID
:param episode_group: 剧集组
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
"""
# 识别用名中含指定信息情形
requested_source = source
if not tmdbid and hasattr(meta, "tmdbid"):
tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"):
doubanid = meta.doubanid
if not source and hasattr(meta, "media_source"):
source = meta.media_source
if not mediaid and hasattr(meta, "media_id"):
mediaid = meta.media_id
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
# 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定),也不使用其它ID
if tmdbid:
doubanid = None
bangumiid = None
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
with fresh(not cache):
mediainfo = self.run_module(
"recognize_media",
meta=meta,
mtype=mtype,
source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params(
source=source,
mediaid=mediaid,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
episode_group=episode_group,
cache=cache,
anilistid=anilistid,
)
# 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定)
if tmdbid:
source = "themoviedb"
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
system_only = bool(
requested_source
or mediaid
or anilistid
or source in {"bangumi", "anilist"}
)
module_kwargs = {
"meta": meta,
"mtype": mtype,
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"episode_group": episode_group,
"cache": cache,
}
if system_only:
module_kwargs["source"] = source
if anilistid:
module_kwargs["anilistid"] = anilistid
with fresh(not cache):
mediainfo = self.run_module(
"recognize_media",
system_only=system_only,
**module_kwargs,
)
if mediainfo:
if not mediainfo.recognize_cache_hit:
@@ -617,7 +728,7 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if self._can_use_media_recognize_share(
if not source and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
@@ -648,9 +759,12 @@ class ChainBase(metaclass=ABCMeta):
self,
meta: MetaBase = None,
mtype: Optional[MediaType] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
episode_group: Optional[str] = None,
cache: bool = True,
share_meta: MetaBase = None,
@@ -660,37 +774,66 @@ class ChainBase(metaclass=ABCMeta):
:param meta: 识别的元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param mtype: 识别的媒体类型,与tmdbid配套
:param source: 请求级识别数据源
:param mediaid: 与source配套的数据源原生ID
:param tmdbid: tmdbid
:param doubanid: 豆瓣ID
:param bangumiid: BangumiID
:param anilistid: AniList ID
:param episode_group: 剧集组
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
"""
# 识别用名中含指定信息情形
requested_source = source
if not tmdbid and hasattr(meta, "tmdbid"):
tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"):
doubanid = meta.doubanid
if not source and hasattr(meta, "media_source"):
source = meta.media_source
if not mediaid and hasattr(meta, "media_id"):
mediaid = meta.media_id
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
# 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定),也不使用其它ID
if tmdbid:
doubanid = None
bangumiid = None
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
async with async_fresh(not cache):
mediainfo = await self.async_run_module(
"async_recognize_media",
meta=meta,
mtype=mtype,
source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params(
source=source,
mediaid=mediaid,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
episode_group=episode_group,
cache=cache,
anilistid=anilistid,
)
# 有tmdbid时,不使用meta推断的类型(由消歧逻辑决定)
if tmdbid:
source = "themoviedb"
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
system_only = bool(
requested_source
or mediaid
or anilistid
or source in {"bangumi", "anilist"}
)
module_kwargs = {
"meta": meta,
"mtype": mtype,
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"episode_group": episode_group,
"cache": cache,
}
if system_only:
module_kwargs["source"] = source
if anilistid:
module_kwargs["anilistid"] = anilistid
async with async_fresh(not cache):
mediainfo = await self.async_run_module(
"async_recognize_media",
system_only=system_only,
**module_kwargs,
)
if mediainfo:
if not mediainfo.recognize_cache_hit:
@@ -701,7 +844,7 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if self._can_use_media_recognize_share(
if not source and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
@@ -984,20 +1127,40 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("webhook_parser", body=body, form=form, args=args)
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息列表
:param source: 请求级搜索数据源
:return: 媒体信息列表
"""
if source:
return self.run_module(
"search_medias",
meta=meta,
source=source,
system_only=True,
)
return self.run_module("search_medias", meta=meta)
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息(异步版本)
:param meta: 识别的元数据
:reutrn: 媒体信息列表
:param source: 请求级搜索数据源
:return: 媒体信息列表
"""
if source:
return await self.async_run_module(
"async_search_medias",
meta=meta,
source=source,
system_only=True,
)
return await self.async_run_module("async_search_medias", meta=meta)
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
+6
View File
@@ -324,6 +324,8 @@ class DownloadChain(ChainBase):
def download_subtitle(
self,
subtitle: SubtitleInfo,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
save_path: Optional[str] = None,
@@ -333,6 +335,8 @@ class DownloadChain(ChainBase):
下载字幕文件并保存到媒体对应的下载目录。
:param subtitle: 字幕搜索结果
:param media_source: 媒体数据源
:param media_id: 数据源原生ID
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param save_path: 保存路径
@@ -345,6 +349,8 @@ class DownloadChain(ChainBase):
metainfo = MetaInfo(title=subtitle.title, subtitle=subtitle.description)
mediainfo = self.recognize_media(
meta=metainfo,
source=media_source,
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
)
+71 -6
View File
@@ -592,14 +592,21 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def recognize_by_meta(
self,
metainfo: MetaBase,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息
:param metainfo: 标题解析元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
"""
mediainfo = self._recognize_with_fallback_by_meta(
metainfo=metainfo,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
)
@@ -610,11 +617,18 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _recognize_with_fallback_by_meta(
self,
metainfo: MetaBase,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[MediaInfo]:
"""
根据标题识别媒体信息,必要时回退到辅助识别。
:param metainfo: 标题解析元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 统一媒体信息
"""
if not metainfo:
return None
@@ -622,17 +636,21 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
share_meta = deepcopy(metainfo)
def native_recognize() -> Optional[MediaInfo]:
"""使用请求级数据源执行原生识别。"""
return self.recognize_media(
meta=metainfo,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
def plugin_recognize() -> Optional[MediaInfo]:
"""执行辅助识别并保持请求级数据源约束。"""
return self.recognize_help(
title=title,
org_meta=metainfo,
share_meta=share_meta,
source=source,
episode_group=episode_group,
)
@@ -668,6 +686,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
title: str,
org_meta: MetaBase,
share_meta: MetaBase = None,
source: Optional[str] = None,
episode_group: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
@@ -676,6 +695,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param title: 标题
:param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
"""
# 发送请求事件,等待结果
@@ -718,6 +738,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 重新识别
return self.recognize_media(
meta=org_meta,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
@@ -725,11 +746,18 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def recognize_by_path(
self,
path: str,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[Context]:
"""
根据文件路径识别媒体信息
:param path: 文件路径
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
logger.info(f"开始识别媒体信息,文件:{path} ...")
file_path = Path(path)
@@ -737,6 +765,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
file_meta = MetaInfoPath(file_path)
mediainfo = self._recognize_with_fallback_by_meta(
metainfo=file_meta,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
)
@@ -746,11 +775,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 返回上下文
return Context(meta_info=file_meta, media_info=mediainfo)
def search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
def search(
self, title: str, source: Optional[str] = None
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
"""
搜索媒体/人物信息
:param title: 搜索内容
:param source: 请求级搜索数据源
:return: 识别元数据,媒体信息列表
"""
# 提取要素
@@ -772,7 +804,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
meta.year = year
# 开始搜索
logger.info(f"开始搜索媒体信息:{meta.name}")
medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta)
medias: Optional[List[MediaInfo]] = self.search_medias(meta=meta, source=source)
if not medias:
logger.warn(f"{meta.name} 没有找到对应的媒体信息!")
return meta, []
@@ -1553,14 +1585,22 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
async def async_recognize_by_meta(
self,
metainfo: MetaBase,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息(异步版本)
:param metainfo: 标题解析元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 统一媒体信息
"""
mediainfo = await self._async_recognize_with_fallback_by_meta(
metainfo=metainfo,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
)
@@ -1571,29 +1611,40 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
async def _async_recognize_with_fallback_by_meta(
self,
metainfo: MetaBase,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[MediaInfo]:
"""
异步根据标题识别媒体信息,必要时回退到辅助识别。
:param metainfo: 标题解析元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 统一媒体信息
"""
if not metainfo:
return None
title = metainfo.title
share_meta = deepcopy(metainfo)
async def native_recognize():
async def native_recognize() -> Optional[MediaInfo]:
"""异步使用请求级数据源执行原生识别。"""
return await self.async_recognize_media(
meta=metainfo,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
async def plugin_recognize():
async def plugin_recognize() -> Optional[MediaInfo]:
"""异步执行辅助识别并保持请求级数据源约束。"""
return await self.async_recognize_help(
title=title,
org_meta=metainfo,
share_meta=share_meta,
source=source,
episode_group=episode_group,
)
@@ -1618,6 +1669,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
title: str,
org_meta: MetaBase,
share_meta: MetaBase = None,
source: Optional[str] = None,
episode_group: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
@@ -1626,6 +1678,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param title: 标题
:param org_meta: 原始元数据
:param share_meta: 共享识别查询/上报使用的原始元数据
:param source: 请求级识别数据源
:param episode_group: 剧集组
"""
# 发送请求事件,等待结果
@@ -1668,6 +1721,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 重新识别
return await self.async_recognize_media(
meta=org_meta,
source=source,
share_meta=share_meta,
episode_group=episode_group,
)
@@ -1675,11 +1729,18 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
async def async_recognize_by_path(
self,
path: str,
source: Optional[str] = None,
episode_group: Optional[str] = None,
obtain_images: bool = False,
) -> Optional[Context]:
"""
根据文件路径识别媒体信息(异步版本)
:param path: 文件路径
:param source: 请求级识别数据源
:param episode_group: 剧集组
:param obtain_images: 是否补充图片
:return: 识别上下文
"""
logger.info(f"开始识别媒体信息,文件:{path} ...")
file_path = Path(path)
@@ -1687,6 +1748,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
file_meta = MetaInfoPath(file_path)
mediainfo = await self._async_recognize_with_fallback_by_meta(
metainfo=file_meta,
source=source,
episode_group=episode_group,
obtain_images=obtain_images,
)
@@ -1697,12 +1759,13 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return Context(meta_info=file_meta, media_info=mediainfo)
async def async_search(
self, title: str
self, title: str, source: Optional[str] = None
) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
"""
搜索媒体/人物信息(异步版本)
:param title: 搜索内容
:param source: 请求级搜索数据源
:return: 识别元数据,媒体信息列表
"""
# 提取要素
@@ -1724,7 +1787,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
meta.year = year
# 开始搜索
logger.info(f"开始搜索媒体信息:{meta.name}")
medias: Optional[List[MediaInfo]] = await self.async_search_medias(meta=meta)
medias: Optional[List[MediaInfo]] = await self.async_search_medias(
meta=meta, source=source
)
if not medias:
logger.warn(f"{meta.name} 没有找到对应的媒体信息!")
return meta, []
+21 -3
View File
@@ -238,11 +238,16 @@ class MediaServerChain(ChainBase):
"mediaserver_image_cookies", server=server, image_url=image_url
)
def sync(self, progress_callback: Optional[Callable[..., None]] = None) -> None:
def sync(
self,
progress_callback: Optional[Callable[..., None]] = None,
server: Optional[str] = None,
) -> None:
"""
同步媒体库所有数据到本地数据库
同步全部或指定媒体服务器的媒体库数据到本地数据库
:param progress_callback: 定时服务进度更新回调
:param server: 指定媒体服务器名称,为空时同步全部已启用服务器
"""
# 设置的媒体服务器
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
@@ -257,7 +262,14 @@ class MediaServerChain(ChainBase):
enabled_servers = [mediaserver.name for mediaserver in mediaservers
if mediaserver and mediaserver.enabled and mediaserver.name]
dboper.delete_excluded_servers(enabled_servers)
if server:
mediaservers = [
mediaserver for mediaserver in mediaservers
if mediaserver and mediaserver.enabled and mediaserver.name == server
]
total_servers = len(enabled_servers)
if server:
total_servers = len(mediaservers)
if progress_callback:
progress_callback(
value=0,
@@ -266,7 +278,13 @@ class MediaServerChain(ChainBase):
)
if not total_servers:
if progress_callback:
progress_callback(value=100, text="没有已启用的媒体服务器")
progress_callback(
value=100,
text=(
f"媒体服务器 {server} 未启用或不存在"
if server else "没有已启用的媒体服务器"
),
)
return
server_sync_contexts = {}
+42 -8
View File
@@ -141,7 +141,19 @@ class JobManager:
"""
if not media:
return None, season
return media.tmdb_id or media.douban_id, season
media_ids = {
"themoviedb": getattr(media, "tmdb_id", None),
"douban": getattr(media, "douban_id", None),
"bangumi": getattr(media, "bangumi_id", None),
"anilist": getattr(media, "anilist_id", None),
}
source = getattr(media, "source", None)
if not source or media_ids.get(source) is None:
source = next(
(name for name, media_id in media_ids.items() if media_id is not None),
source,
)
return (source, media_ids.get(source)), season
@staticmethod
def __get_file_key(fileitem: FileItem) -> Optional[Tuple[str, str]]:
@@ -1600,23 +1612,30 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
f"{task.fileitem.name} 文件年份 {task.meta.year} 与下载记录年份 "
f"{download_history.year} 不一致,按文件名重新识别"
)
recognize_kwargs = {"obtain_images": True}
if task.media_source:
recognize_kwargs["source"] = task.media_source
mediainfo = MediaChain().recognize_by_meta(
task.meta,
obtain_images=True,
task.meta, **recognize_kwargs
)
if mediainfo and download_history.media_category:
mediainfo.category = download_history.media_category
else:
# 识别媒体信息
recognize_kwargs = {"obtain_images": True}
if task.media_source:
recognize_kwargs["source"] = task.media_source
mediainfo = MediaChain().recognize_by_meta(
task.meta,
obtain_images=True,
task.meta, **recognize_kwargs
)
# 按名称识别时已在识别链路补图,这里只补齐显式ID识别的场景。
if mediainfo and need_obtain_images:
self.obtain_images(mediainfo=mediainfo)
if mediainfo and task.media_source:
mediainfo.scrape_source = task.media_source
if not mediainfo:
if task.preview:
return False, "未识别到媒体信息"
@@ -2572,6 +2591,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
fileitem: FileItem,
meta: MetaBase = None,
mediainfo: MediaInfo = None,
media_source: Optional[str] = None,
target_directory: TransferDirectoryConf = None,
target_storage: Optional[str] = None,
target_path: Path = None,
@@ -2597,6 +2617,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param fileitem: 文件项
:param meta: 元数据
:param mediainfo: 媒体信息
:param media_source: 请求级识别与刮削数据源
:param target_directory: 目标目录配置
:param target_storage: 目标存储器
:param target_path: 目标路径
@@ -3115,6 +3136,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
fileitem=file_item,
meta=file_meta,
mediainfo=task_mediainfo,
media_source=media_source,
target_directory=target_directory,
target_storage=target_storage,
target_path=target_path,
@@ -3474,6 +3496,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
target_path: Path = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
mtype: MediaType = None,
season: Optional[int] = None,
episode_group: Optional[str] = None,
@@ -3498,6 +3522,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param target_path: 目标路径
:param tmdbid: TMDB ID
:param doubanid: 豆瓣ID
:param media_source: 媒体数据源
:param media_id: 数据源原生ID
:param mtype: 媒体类型
:param season: 季度
:param episode_group: 剧集组
@@ -3516,21 +3542,27 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
"""
logger.info(f"手动整理:{fileitem.path} ...")
if tmdbid or doubanid:
# 有输入TMDBID时单个识别
if tmdbid or doubanid or media_id:
# 有输入媒体ID时单个识别
# 识别媒体信息
mediainfo: MediaInfo = MediaChain().recognize_media(
tmdbid=tmdbid,
doubanid=doubanid,
source=media_source,
mediaid=media_id,
mtype=mtype,
episode_group=episode_group,
)
if not mediainfo:
return (
False,
f"媒体信息识别失败,tmdbid{tmdbid}doubanid{doubanid}type: {mtype.value if mtype else None}",
f"媒体信息识别失败,source{media_source}media_id{media_id}"
f"tmdbid{tmdbid}doubanid{doubanid}"
f"type: {mtype.value if mtype else None}",
)
else:
if media_source:
mediainfo.scrape_source = media_source
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
@@ -3540,6 +3572,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
target_storage=target_storage,
target_path=target_path,
mediainfo=mediainfo,
media_source=media_source,
transfer_type=transfer_type,
season=season,
epformat=epformat,
@@ -3567,6 +3600,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
fileitem=fileitem,
target_storage=target_storage,
target_path=target_path,
media_source=media_source,
transfer_type=transfer_type,
season=season,
epformat=epformat,
+5 -3
View File
@@ -38,6 +38,8 @@ class SystemConfModel(BaseModel):
douban: int = 0
# Bangumi请求缓存数量
bangumi: int = 0
# AniList请求缓存数量
anilist: int = 0
# Fanart请求缓存数量
fanart: int = 0
# 元数据缓存过期时间(秒)
@@ -197,11 +199,11 @@ class ConfigModel(BaseModel):
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
# ==================== 媒体元数据配置 ====================
# 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔
# 媒体搜索来源 themoviedb/douban/bangumi/anilist,多个用,分隔
SEARCH_SOURCE: str = "themoviedb"
# 媒体识别来源 themoviedb/douban
# 媒体识别来源 themoviedb/douban/bangumi/anilist
RECOGNIZE_SOURCE: str = "themoviedb"
# 刮削来源 themoviedb/douban
# 刮削来源 themoviedb/douban/bangumi/anilist
SCRAP_SOURCE: str = "themoviedb"
# 电视剧动漫的分类genre_ids
ANIME_GENREIDS: List[int] = Field(default=[16])
+180 -3
View File
@@ -10,6 +10,7 @@ from app.schemas.types import MediaType
from app.utils.string import StringUtils
BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"})
ANILIST_MOVIE_FORMATS = frozenset({"MOVIE"})
@dataclass
@@ -251,8 +252,10 @@ class MediaInfo:
# 内部标记:是否命中本地识别缓存,不参与序列化
recognize_cache_hit = False
# 来源:themoviedb、douban、bangumi
# 来源:themoviedb、douban、bangumi、anilist
source: str = None
# 请求级刮削来源;为空时使用系统设置
scrape_source: str = None
# 类型 电影、电视剧
type: MediaType = None
# 媒体标题
@@ -279,6 +282,10 @@ class MediaInfo:
douban_id: str = None
# Bangumi ID
bangumi_id: int = None
# AniList ID
anilist_id: int = None
# AniDB IDAniList外部映射)
anidb_id: int = None
# 合集ID
collection_id: int = None
# 媒体原语种
@@ -315,6 +322,8 @@ class MediaInfo:
douban_info: dict = field(default_factory=dict)
# Bangumi INFO
bangumi_info: dict = field(default_factory=dict)
# AniList INFO
anilist_info: dict = field(default_factory=dict)
# 导演
directors: List[dict] = field(default_factory=list)
# 演员
@@ -380,6 +389,8 @@ class MediaInfo:
self.set_douban_info(self.douban_info)
if self.bangumi_info:
self.set_bangumi_info(self.bangumi_info)
if self.anilist_info:
self.set_anilist_info(self.anilist_info)
def __setattr__(self, name: str, value: Any):
self.__dict__[name] = value
@@ -750,7 +761,7 @@ class MediaInfo:
self.source = "bangumi"
# 本体
self.bangumi_info = info
# 豆瓣ID
# Bangumi ID
self.bangumi_id = info.get("id")
# 类型
if not self.type:
@@ -804,13 +815,166 @@ class MediaInfo:
if self.type == MediaType.TV and not self.seasons:
meta = MetaInfo(self.title)
season = meta.begin_season if meta.begin_season is not None else 1
episodes_count = info.get("total_episodes")
episodes_count = info.get("total_episodes") or info.get("eps")
if episodes_count:
self.seasons[season] = list(range(1, episodes_count + 1))
self.number_of_episodes = episodes_count
self.number_of_seasons = 1
# 风格
if not self.genres:
self.genres = [
{"id": tag.get("name"), "name": tag.get("name")}
for tag in info.get("tags") or []
if tag.get("name")
]
# 制作公司与导演
if info.get("infobox"):
companies = []
directors = []
for item in info.get("infobox"):
values = item.get("value")
if not isinstance(values, list):
values = [values]
normalized_values = [
value.get("v") if isinstance(value, dict) else value
for value in values
if value
]
if item.get("key") in {"动画制作", "制作"}:
companies.extend({"name": value} for value in normalized_values)
elif item.get("key") == "导演":
directors.extend({"name": value} for value in normalized_values)
if companies and not self.production_companies:
self.production_companies = companies
if directors and not self.directors:
self.directors = directors
# 演员
if not self.actors:
self.actors = info.get("actors") or []
@staticmethod
def get_anilist_media_type(info: dict) -> MediaType:
"""
根据 AniList 发布格式获取标准媒体类型
:param info: AniList 媒体信息
:return: 标准媒体类型
"""
return (
MediaType.MOVIE
if str(info.get("format") or "").upper() in ANILIST_MOVIE_FORMATS
else MediaType.TV
)
@staticmethod
def _anilist_date(date_info: dict) -> Optional[str]:
"""
AniList 模糊日期转换为标准日期文本
:param date_info: AniList FuzzyDate 字段
:return: YYYYYYYY-MM YYYY-MM-DD 日期文本
"""
if not date_info or not date_info.get("year"):
return None
values = [str(date_info.get("year"))]
if date_info.get("month"):
values.append(str(date_info.get("month")).zfill(2))
if date_info.get("day"):
values.append(str(date_info.get("day")).zfill(2))
return "-".join(values)
def set_anilist_info(self, info: dict) -> None:
"""
初始化 AniList 媒体信息
:param info: AniList 媒体详情
"""
if not info:
return
self.source = "anilist"
self.anilist_info = info
self.anilist_id = info.get("id")
self.type = self.type or self.get_anilist_media_type(info)
titles = info.get("title") or {}
self.title = self.title or titles.get("english") or titles.get("romaji") or titles.get("native")
self.en_title = self.en_title or titles.get("english")
self.original_title = self.original_title or titles.get("native") or titles.get("romaji")
self.names = list(
dict.fromkeys(
value
for value in [
titles.get("english"),
titles.get("romaji"),
titles.get("native"),
*(info.get("synonyms") or []),
]
if value and value != self.title
)
)
self.release_date = self.release_date or self._anilist_date(info.get("startDate") or {})
self.first_air_date = self.first_air_date or self.release_date
self.last_air_date = self.last_air_date or self._anilist_date(info.get("endDate") or {})
self.year = self.year or (
str(info.get("startDate", {}).get("year"))
if info.get("startDate", {}).get("year")
else str(info.get("seasonYear")) if info.get("seasonYear") else None
)
cover = info.get("coverImage") or {}
self.poster_path = self.poster_path or cover.get("extraLarge") or cover.get("large")
self.backdrop_path = self.backdrop_path or info.get("bannerImage")
self.overview = self.overview or re.sub(
r"<[^>]+>",
"",
str(info.get("description") or "").replace("<br>", "\n").replace("<br />", "\n"),
).strip()
self.vote_average = self.vote_average or (
round(float(info.get("averageScore")) / 10, 1)
if info.get("averageScore") is not None
else 0
)
self.popularity = self.popularity or info.get("popularity")
self.runtime = self.runtime or info.get("duration")
self.adult = self.adult or bool(info.get("isAdult"))
self.status = self.status or info.get("status")
self.original_language = self.original_language or (
"ja" if info.get("countryOfOrigin") == "JP" else None
)
self.origin_country = self.origin_country or (
[info.get("countryOfOrigin")] if info.get("countryOfOrigin") else []
)
self.production_companies = self.production_companies or [
{"name": studio.get("name")}
for studio in info.get("studios", {}).get("nodes") or []
if studio.get("name")
]
self.genres = self.genres or [
{"id": genre, "name": genre} for genre in info.get("genres") or []
]
self.actors = self.actors or info.get("actors") or []
self.directors = self.directors or info.get("directors") or []
if self.season is None:
self.season = MetaInfo(self.title).begin_season if self.title else None
episodes_count = info.get("episodes")
if self.type == MediaType.TV and episodes_count:
season = self.season if self.season is not None else 1
self.seasons[season] = list(range(1, episodes_count + 1))
self.number_of_episodes = episodes_count
self.number_of_seasons = 1
if self.year:
self.season_years[season] = self.year
for external_link in info.get("externalLinks") or []:
if str(external_link.get("site") or "").casefold() != "anidb":
continue
match = re.search(r"\d+", external_link.get("url") or "")
if match:
self.anidb_id = int(match.group())
break
@property
def title_year(self):
if self.title:
@@ -831,6 +995,8 @@ class MediaInfo:
return "https://movie.douban.com/subject/%s" % self.douban_id
elif self.bangumi_id:
return "http://bgm.tv/subject/%s" % self.bangumi_id
elif self.anilist_id:
return "https://anilist.co/anime/%s" % self.anilist_id
return ""
@property
@@ -895,6 +1061,16 @@ class MediaInfo:
dicts["tmdb_info"] = None
dicts["douban_info"] = None
dicts["bangumi_info"] = None
dicts["anilist_info"] = None
dicts["mediaid_prefix"] = self.source
source_ids = {
"themoviedb": self.tmdb_id,
"douban": self.douban_id,
"bangumi": self.bangumi_id,
"anilist": self.anilist_id,
}
media_id = source_ids.get(self.source)
dicts["media_id"] = str(media_id) if media_id is not None else None
return dicts
def clear(self):
@@ -904,6 +1080,7 @@ class MediaInfo:
self.tmdb_info = {}
self.douban_info = {}
self.bangumi_info = {}
self.anilist_info = {}
self.seasons = {}
self.genres = []
self.season_info = []
+1 -1
View File
@@ -23,7 +23,7 @@ def should_use_parent_title_for_file_stem(
"""
if not file_meta.isfile or not stem or not parent_dir_name:
return False
if file_meta.tmdbid or file_meta.doubanid:
if file_meta.tmdbid or file_meta.doubanid or file_meta.media_id:
return False
if not PARENT_LATIN_TITLE_RE.search(parent_dir_name):
return False
+7
View File
@@ -97,6 +97,8 @@ class MetaBase(object):
# 附加信息
tmdbid: int = None
doubanid: str = None
media_source: Optional[str] = None
media_id: Optional[str] = None
episode_group: Optional[str] = None
# 帧率信息(纯数值)
fps: Optional[int] = None
@@ -683,6 +685,11 @@ class MetaBase(object):
# doubanid
if not self.doubanid and meta.doubanid:
self.doubanid = meta.doubanid
# 通用媒体来源与ID
if not self.media_source and meta.media_source:
self.media_source = meta.media_source
if not self.media_id and meta.media_id:
self.media_id = meta.media_id
# 剧集组
if not self.episode_group and meta.episode_group:
self.episode_group = meta.episode_group
+102 -1
View File
@@ -29,6 +29,8 @@ _ANIME_SQUARE_BRACKET_RE = re.compile(r'\[[+0-9XVPI-]+]\s*\[', re.IGNORECASE)
_BRACED_METAINFO_RE = re.compile(r'(?<={\[)[\W\w]+(?=]})')
_BRACED_TMDBID_RE = re.compile(r'(?<=tmdbid=)\d+')
_BRACED_DOUBANID_RE = re.compile(r'(?<=doubanid=)\d+')
_BRACED_BANGUMIID_RE = re.compile(r'(?<=bangumiid=)\d+')
_BRACED_ANILISTID_RE = re.compile(r'(?<=anilistid=)\d+')
_BRACED_TYPE_RE = re.compile(r'(?<=type=)\w+')
_BRACED_EPISODE_GROUP_RE = re.compile(r'(?:^|;)g=([0-9a-fA-F]+)(?=;|$)')
_BRACED_BEGIN_SEASON_RE = re.compile(r'(?<=s=)\d+')
@@ -41,6 +43,24 @@ _EMBY_TMDB_RE_LIST = (
re.compile(r'\{tmdbid[=\-](\d+)\}'),
re.compile(r'\{tmdb[=\-](\d+)\}'),
)
_EXTENDED_MEDIA_ID_RE_LIST = {
"bangumi": (
re.compile(r'\[bangumiid[=\-](\d+)\]'),
re.compile(r'\[bangumi[=\-](\d+)\]'),
re.compile(r'\{bangumiid[=\-](\d+)\}'),
re.compile(r'\{bangumi[=\-](\d+)\}'),
),
"anilist": (
re.compile(r'\[anilistid[=\-](\d+)\]'),
re.compile(r'\[anilist[=\-](\d+)\]'),
re.compile(r'\{anilistid[=\-](\d+)\}'),
re.compile(r'\{anilist[=\-](\d+)\}'),
),
}
_EXTENDED_MEDIA_ID_TAG_RE = re.compile(
r'(?:bangumi(?:id)?|anilist(?:id)?)[=\-]\d+',
re.IGNORECASE,
)
_RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key"
@@ -51,6 +71,10 @@ def _empty_metainfo() -> dict:
return {
'tmdbid': None,
'doubanid': None,
'bangumiid': None,
'anilistid': None,
'media_source': None,
'media_id': None,
'type': None,
'episode_group': None,
'begin_season': None,
@@ -115,6 +139,14 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
doubanid = _BRACED_DOUBANID_RE.search(result)
if doubanid and doubanid.group(0).isdigit():
metainfo['doubanid'] = doubanid.group(0)
# 查找Bangumi ID信息
bangumiid = _BRACED_BANGUMIID_RE.search(result)
if bangumiid and bangumiid.group(0).isdigit():
metainfo['bangumiid'] = bangumiid.group(0)
# 查找AniList ID信息
anilistid = _BRACED_ANILISTID_RE.search(result)
if anilistid and anilistid.group(0).isdigit():
metainfo['anilistid'] = anilistid.group(0)
# 查找媒体类型
mtype = _BRACED_TYPE_RE.search(result)
if mtype:
@@ -142,7 +174,18 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
if end_episode and end_episode.group(0).isdigit():
metainfo['end_episode'] = int(end_episode.group(0))
# 去除title中该部分
if tmdbid or mtype or episode_group or begin_season or end_season or begin_episode or end_episode:
if (
tmdbid
or doubanid
or bangumiid
or anilistid
or mtype
or episode_group
or begin_season
or end_season
or begin_episode
or end_episode
):
title = title.replace(f"{{[{result}]}}", '')
# 支持Emby格式的ID标签;第一个 [tmdbid] 历史上始终优先处理,用于覆盖前面 {[...]} 中的旧标签。
@@ -159,6 +202,31 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
title = tmdb_re.sub('', title).strip()
break
for source, patterns in _EXTENDED_MEDIA_ID_RE_LIST.items():
key = f"{source}id"
if metainfo.get(key):
continue
for media_id_re in patterns:
media_id_match = media_id_re.search(title)
if not media_id_match:
continue
metainfo[key] = media_id_match.group(1)
title = media_id_re.sub('', title).strip()
break
if metainfo.get('tmdbid'):
metainfo['media_source'] = 'themoviedb'
metainfo['media_id'] = metainfo['tmdbid']
elif metainfo.get('doubanid'):
metainfo['media_source'] = 'douban'
metainfo['media_id'] = metainfo['doubanid']
elif metainfo.get('bangumiid'):
metainfo['media_source'] = 'bangumi'
metainfo['media_id'] = metainfo['bangumiid']
elif metainfo.get('anilistid'):
metainfo['media_source'] = 'anilist'
metainfo['media_id'] = metainfo['anilistid']
# 计算季集总数
_apply_range_total(metainfo, 'begin_season', 'end_season', 'total_season')
_apply_range_total(metainfo, 'begin_episode', 'end_episode', 'total_episode')
@@ -202,6 +270,10 @@ def _build_meta_info(
logger.warn("tmdbid 必须是数字")
if metainfo.get('doubanid'):
meta.doubanid = metainfo['doubanid']
if metainfo.get('media_source'):
meta.media_source = metainfo['media_source']
if metainfo.get('media_id'):
meta.media_id = str(metainfo['media_id'])
if metainfo.get('type'):
meta.type = MediaType(metainfo['type']) if isinstance(metainfo['type'], str) else metainfo['type']
if metainfo.get('episode_group'):
@@ -319,6 +391,8 @@ def _meta_from_rust(parsed: dict) -> Optional[MetaBase]:
"apply_words": parsed.get("apply_words") or [],
"tmdbid": parsed.get("tmdbid"),
"doubanid": parsed.get("doubanid"),
"media_source": parsed.get("media_source"),
"media_id": parsed.get("media_id"),
"episode_group": parsed.get("episode_group"),
"fps": parsed.get("fps"),
}
@@ -327,6 +401,24 @@ def _meta_from_rust(parsed: dict) -> Optional[MetaBase]:
return meta
def _requires_python_metainfo(
title: str,
custom_words: Optional[List[str]] = None,
) -> bool:
"""
判断标题或临时识别词是否包含当前Rust扩展尚未支持的数据源ID标签
:param title: 原始标题
:param custom_words: 临时识别词
:return: 是否必须使用Python解析器
"""
candidates = [title or "", *(custom_words or [])]
contains_extended_id = any(
_EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
)
return contains_extended_id and not rust_accel.supports_extended_media_ids()
def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None) -> MetaBase:
"""
根据标题和副标题识别元数据
@@ -335,6 +427,8 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
:param custom_words: 自定义识别词列表
:return: MetaAnimeMetaVideo
"""
rust_meta = None
if not _requires_python_metainfo(title, custom_words):
rust_meta = _meta_from_rust(
rust_accel.parse_metainfo(title, subtitle, _rust_parse_options(custom_words))
)
@@ -355,6 +449,11 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase:
:param path: 路径
:param custom_words: 自定义识别词列表
"""
path_context = " ".join(
[path.name, path.parent.name, path.parent.parent.name]
)
rust_meta = None
if not _requires_python_metainfo(path_context, custom_words):
rust_meta = _meta_from_rust(
rust_accel.parse_metainfo_path(str(path), _rust_parse_options(custom_words))
)
@@ -400,6 +499,8 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
"""
从标题中提取媒体信息
"""
rust_result = None
if not _requires_python_metainfo(title):
rust_result = rust_accel.find_metainfo(title)
if rust_result:
return rust_result["title"], rust_result["metainfo"]
+3
View File
@@ -48,6 +48,9 @@ class TransferHistory(Base):
imdbid = Column(String)
tvdbid = Column(Integer)
doubanid = Column(String)
# 统一媒体数据源与原生ID
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# Sxx
seasons = Column(String)
# Exx
+8
View File
@@ -198,6 +198,8 @@ class TransferHistoryOper(DbOper):
imdbid=mediainfo.imdb_id,
tvdbid=mediainfo.tvdb_id,
doubanid=mediainfo.douban_id,
media_source=mediainfo.source,
media_id=mediainfo.to_dict().get("media_id"),
seasons=meta.season,
episodes=meta.episode,
image=mediainfo.get_poster_image(),
@@ -229,6 +231,8 @@ class TransferHistoryOper(DbOper):
imdbid=mediainfo.imdb_id,
tvdbid=mediainfo.tvdb_id,
doubanid=mediainfo.douban_id,
media_source=mediainfo.source,
media_id=mediainfo.to_dict().get("media_id"),
seasons=meta.season,
episodes=meta.episode,
image=mediainfo.get_poster_image(),
@@ -243,6 +247,10 @@ class TransferHistoryOper(DbOper):
his = self.add_force(
title=meta.name,
year=meta.year,
tmdbid=meta.tmdbid,
doubanid=meta.doubanid,
media_source=meta.media_source,
media_id=meta.media_id,
src=fileitem.path,
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(),
+179
View File
@@ -0,0 +1,179 @@
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from xml.dom import minidom
from app.core.context import MediaInfo
from app.schemas.types import MediaType
from app.utils.dom import DomUtils
class MediaScraperHelper:
"""
基于统一媒体信息生成通用 NFO 与图片清单供缺少专用刮削格式的数据源复用
"""
@staticmethod
def _media_identity(mediainfo: MediaInfo) -> tuple[Optional[str], Optional[str]]:
"""
获取媒体信息中的来源与来源原生 ID
:param mediainfo: 统一媒体信息
:return: 数据源名称与原生 ID
"""
source_ids = {
"themoviedb": mediainfo.tmdb_id,
"douban": mediainfo.douban_id,
"bangumi": mediainfo.bangumi_id,
"anilist": mediainfo.anilist_id,
}
media_id = source_ids.get(mediainfo.source)
return mediainfo.source, str(media_id) if media_id is not None else None
@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", 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
+20
View File
@@ -1102,6 +1102,10 @@
"source": "数据表 {name} 清理处理完成",
"target": "Data table {name} cleanup completed"
},
{
"source": "同步媒体服务器 - {name} 开始执行 ...",
"target": "Starting media server sync - {name} ..."
},
{
"source": "{name} 开始执行 ...",
"target": "Starting {name_i18n} ..."
@@ -1122,6 +1126,10 @@
"source": "正在同步媒体服务器({index}/{total}{name} ...",
"target": "Syncing media server ({index}/{total}) {name} ..."
},
{
"source": "媒体服务器 {name} 未启用或不存在",
"target": "Media server {name} is disabled or does not exist"
},
{
"source": "媒体服务器 {name} 无可同步媒体库",
"target": "Media server {name} has no libraries to sync"
@@ -1326,6 +1334,18 @@
"source": "工作流 {name} 执行完成",
"target": "Workflow {name} completed"
},
{
"source": "同步媒体服务器 - {name} 执行完成",
"target": "Media server sync - {name} completed"
},
{
"source": "同步媒体服务器 - {name} 执行失败",
"target": "Media server sync - {name} failed"
},
{
"source": "同步媒体服务器 - {name}",
"target": "Sync Media Server - {name}"
},
{
"source": "{name} 执行完成",
"target": "{name_i18n} completed"
+20
View File
@@ -1102,6 +1102,10 @@
"source": "数据表 {name} 清理处理完成",
"target": "資料表 {name} 清理處理完成"
},
{
"source": "同步媒体服务器 - {name} 开始执行 ...",
"target": "開始同步媒體伺服器 - {name} ..."
},
{
"source": "{name} 开始执行 ...",
"target": "{name_i18n} 開始執行 ..."
@@ -1122,6 +1126,10 @@
"source": "正在同步媒体服务器({index}/{total}{name} ...",
"target": "正在同步媒體伺服器({index}/{total}{name} ..."
},
{
"source": "媒体服务器 {name} 未启用或不存在",
"target": "媒體伺服器 {name} 未啟用或不存在"
},
{
"source": "媒体服务器 {name} 无可同步媒体库",
"target": "媒體伺服器 {name} 無可同步媒體庫"
@@ -1326,6 +1334,18 @@
"source": "工作流 {name} 执行完成",
"target": "工作流 {name} 執行完成"
},
{
"source": "同步媒体服务器 - {name} 执行完成",
"target": "同步媒體伺服器 - {name} 執行完成"
},
{
"source": "同步媒体服务器 - {name} 执行失败",
"target": "同步媒體伺服器 - {name} 執行失敗"
},
{
"source": "同步媒体服务器 - {name}",
"target": "同步媒體伺服器 - {name}"
},
{
"source": "{name} 执行完成",
"target": "{name_i18n} 執行完成"
+311
View File
@@ -0,0 +1,311 @@
from typing import List, Optional, Tuple, Union
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.anilist.anilist import AniListApi
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
class AniListModule(_ModuleBase):
"""
AniList 动画媒体识别与刮削模块
"""
CONFIG_WATCH = {"PROXY_HOST"}
anilist_api: AniListApi = None
scraper: MediaScraperHelper = None
def init_module(self) -> None:
"""初始化 AniList 客户端与通用刮削器"""
self.anilist_api = AniListApi()
self.scraper = MediaScraperHelper()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""AniList 模块无需独立开关"""
return None
def stop(self) -> None:
"""关闭 AniList 模块"""
return None
def test(self) -> Tuple[bool, str]:
"""测试 AniList GraphQL API 连通性"""
result = self.anilist_api.search("Cowboy Bebop", count=1)
return (True, "") if result else (False, "AniList网络连接失败")
@staticmethod
def get_name() -> str:
"""获取模块名称"""
return "AniList"
@staticmethod
def get_type() -> ModuleType:
"""获取模块类型"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""获取模块子类型"""
return MediaRecognizeType.AniList
@staticmethod
def get_priority() -> int:
"""获取模块优先级"""
return 4
@staticmethod
def _source_enabled(source: Optional[str]) -> bool:
"""
判断本次识别是否指定 AniList
:param source: 请求级识别数据源
:return: 是否启用 AniList 识别
"""
return (source or settings.RECOGNIZE_SOURCE) == "anilist"
@staticmethod
def _media_type(info: dict) -> MediaType:
"""
AniList 发布格式转换为系统媒体类型
:param info: AniList 媒体信息
:return: 系统媒体类型
"""
return MediaType.MOVIE if info.get("format") == "MOVIE" else MediaType.TV
@classmethod
def _matches_meta(cls, meta: MetaBase, info: dict) -> bool:
"""
判断 AniList 候选项是否符合标题解析出的类型与年份
:param meta: 标题解析元数据
:param info: AniList 候选项
:return: 是否符合筛选条件
"""
if meta.type in {MediaType.MOVIE, MediaType.TV} and cls._media_type(info) != meta.type:
return False
year = info.get("startDate", {}).get("year") or info.get("seasonYear")
return not meta.year or not year or str(year) == str(meta.year)
@staticmethod
def _enrich_people(info: dict) -> dict:
"""
AniList 人物连接转换为统一媒体信息所需的演职员结构
:param info: AniList 媒体详情
:return: 补充演员和导演后的媒体详情
"""
enriched = dict(info)
actors = []
for edge in info.get("characters", {}).get("edges") or []:
character = edge.get("node") or {}
voice_actors = edge.get("voiceActors") or []
actor = voice_actors[0] if voice_actors else {}
actor_name = actor.get("name", {}).get("full")
if not actor_name:
continue
actors.append(
{
"name": actor_name,
"character": character.get("name", {}).get("full")
or character.get("name", {}).get("native"),
"avatar": {"large": actor.get("image", {}).get("large")},
"url": actor.get("siteUrl"),
}
)
enriched["actors"] = actors
directors = []
for edge in info.get("staff", {}).get("edges") or []:
role = edge.get("role") or ""
if "Director" not in role:
continue
staff = edge.get("node") or {}
directors.append(
{
"name": staff.get("name", {}).get("full"),
"job": role,
"avatar": {"large": staff.get("image", {}).get("large")},
"url": staff.get("siteUrl"),
}
)
enriched["directors"] = directors
return enriched
def recognize_media(
self,
meta: MetaBase = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
AniList ID 或标题识别动画媒体信息
:param meta: 标题解析元数据
:param anilistid: AniList 媒体 ID
:param source: 请求级识别数据源
:return: 统一媒体信息
"""
if not anilistid and (not meta or not self._source_enabled(source)):
return None
info = self.anilist_api.detail(anilistid) if anilistid else self._match_by_meta(meta)
if not info:
return None
mediainfo = MediaInfo(anilist_info=self._enrich_people(info))
if meta and meta.begin_season is not None:
mediainfo.season = meta.begin_season
logger.info(
f"{anilistid or meta.name} AniList识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year}"
)
return mediainfo
async def async_recognize_media(
self,
meta: MetaBase = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
异步按 AniList ID 或标题识别动画媒体信息
:param meta: 标题解析元数据
:param anilistid: AniList 媒体 ID
:param source: 请求级识别数据源
:return: 统一媒体信息
"""
if not anilistid and (not meta or not self._source_enabled(source)):
return None
info = (
await self.anilist_api.async_detail(anilistid)
if anilistid
else await self._async_match_by_meta(meta)
)
if not info:
return None
mediainfo = MediaInfo(anilist_info=self._enrich_people(info))
if meta and meta.begin_season is not None:
mediainfo.season = meta.begin_season
logger.info(
f"{anilistid or meta.name} AniList识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year}"
)
return mediainfo
def _match_by_meta(self, meta: MetaBase) -> Optional[dict]:
"""
同步搜索并筛选最符合标题解析结果的 AniList 条目
:param meta: 标题解析元数据
:return: AniList 媒体详情
"""
for info in self.anilist_api.search(meta.name):
if self._matches_meta(meta, info):
return info
return None
async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict]:
"""
异步搜索并筛选最符合标题解析结果的 AniList 条目
:param meta: 标题解析元数据
:return: AniList 媒体详情
"""
for info in await self.anilist_api.async_search(meta.name):
if self._matches_meta(meta, info):
return info
return None
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索 AniList 动画媒体信息
:param meta: 标题解析元数据
:param source: 请求级搜索数据源
:return: 统一媒体信息列表
"""
if source and source != "anilist":
return None
if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE:
return None
if not meta or not meta.name:
return []
return [
MediaInfo(anilist_info=self._enrich_people(info))
for info in self.anilist_api.search(meta.name)
if self._matches_meta(meta, info)
]
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
异步搜索 AniList 动画媒体信息
:param meta: 标题解析元数据
:param source: 请求级搜索数据源
:return: 统一媒体信息列表
"""
if source and source != "anilist":
return None
if not source and settings.SEARCH_SOURCE and "anilist" not in settings.SEARCH_SOURCE:
return None
if not meta or not meta.name:
return []
return [
MediaInfo(anilist_info=self._enrich_people(info))
for info in await self.anilist_api.async_search(meta.name)
if self._matches_meta(meta, info)
]
def metadata_nfo(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
**kwargs,
) -> Optional[str]:
"""
生成 AniList 来源的 NFO 内容
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: NFO XML 文本
"""
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
if scrape_source != "anilist":
return None
return self.scraper.get_metadata_nfo(mediainfo, season=season, episode=episode)
def metadata_img(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> Optional[dict]:
"""
获取 AniList 来源的刮削图片清单
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: 图片文件名与下载地址映射
"""
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
if scrape_source != "anilist":
return None
return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode)
def clear_cache(self) -> None:
"""清理 AniList 接口缓存"""
self.anilist_api.clear_cache()
+185
View File
@@ -0,0 +1,185 @@
from typing import Optional
from app.core.cache import cached
from app.core.config import settings
from app.log import logger
from app.utils.http import AsyncRequestUtils, RequestUtils
class AniListApi:
"""
AniList GraphQL API 客户端
"""
_base_url = "https://graphql.anilist.co"
_media_fields = """
id
idMal
title { romaji english native }
format
status
description(asHtml: false)
startDate { year month day }
endDate { year month day }
seasonYear
episodes
duration
countryOfOrigin
coverImage { extraLarge large }
bannerImage
genres
synonyms
averageScore
popularity
isAdult
siteUrl
studios(isMain: true) { nodes { name } }
staff(perPage: 25, sort: [RELEVANCE]) {
edges { role node { name { full } image { large } siteUrl } }
}
characters(perPage: 25, sort: [ROLE]) {
edges {
role
node { name { full native } image { large } siteUrl }
voiceActors(language: JAPANESE, sort: [RELEVANCE]) {
name { full }
image { large }
siteUrl
}
}
}
externalLinks { site url type }
"""
def __init__(self) -> None:
"""初始化同步与异步请求客户端"""
headers = {
"User-Agent": settings.NORMAL_USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/json",
}
self._request = RequestUtils(
proxies=settings.PROXY,
headers=headers,
)
self._async_request = AsyncRequestUtils(
proxies=settings.PROXY,
headers=headers,
)
@staticmethod
def _extract_response(response) -> Optional[dict]:
"""
提取 GraphQL 响应数据并统一处理上游错误
:param response: HTTP 响应对象
:return: GraphQL data 字段
"""
if response is None or response.status_code != 200:
return None
try:
result = response.json()
except Exception as err:
logger.error(f"解析 AniList 响应失败:{str(err)}")
return None
if result.get("errors"):
logger.warning(f"AniList 接口返回错误:{result.get('errors')}")
return None
return result.get("data")
def _invoke(self, query: str, variables: dict) -> Optional[dict]:
"""
执行同步 GraphQL 请求
:param query: GraphQL 查询
:param variables: 查询变量
:return: GraphQL data 字段
"""
response = self._request.post_res(
self._base_url,
json={"query": query, "variables": variables},
)
return self._extract_response(response)
async def _async_invoke(self, query: str, variables: dict) -> Optional[dict]:
"""
执行异步 GraphQL 请求
:param query: GraphQL 查询
:param variables: 查询变量
:return: GraphQL data 字段
"""
response = await self._async_request.post_res(
self._base_url,
json={"query": query, "variables": variables},
)
return self._extract_response(response)
@cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get")
def detail(self, anilist_id: int) -> Optional[dict]:
"""
根据 AniList ID 获取动画详情
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
query = f"query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {self._media_fields} }} }}"
result = self._invoke(query, {"id": anilist_id})
return result.get("Media") if result else None
@cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get")
async def async_detail(self, anilist_id: int) -> Optional[dict]:
"""
异步根据 AniList ID 获取动画详情
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
query = f"query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {self._media_fields} }} }}"
result = await self._async_invoke(query, {"id": anilist_id})
return result.get("Media") if result else None
@cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get")
def search(self, name: str, count: int = 20) -> list[dict]:
"""
按标题搜索 AniList 动画
:param name: 动画标题
:param count: 返回条数
:return: AniList 媒体列表
"""
query = f"""
query ($search: String!, $count: Int!) {{
Page(page: 1, perPage: $count) {{
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {self._media_fields} }}
}}
}}
"""
result = self._invoke(query, {"search": name, "count": count})
return result.get("Page", {}).get("media") or [] if result else []
@cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get")
async def async_search(self, name: str, count: int = 20) -> list[dict]:
"""
异步按标题搜索 AniList 动画
:param name: 动画标题
:param count: 返回条数
:return: AniList 媒体列表
"""
query = f"""
query ($search: String!, $count: Int!) {{
Page(page: 1, perPage: $count) {{
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {self._media_fields} }}
}}
}}
"""
result = await self._async_invoke(query, {"search": name, "count": count})
return result.get("Page", {}).get("media") or [] if result else []
def clear_cache(self) -> None:
"""清理 AniList 详情与搜索缓存"""
self.detail.cache_clear()
self.async_detail.cache_clear()
self.search.cache_clear()
self.async_search.cache_clear()
+149 -27
View File
@@ -4,10 +4,11 @@ from app import schemas
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.bangumi.bangumi import BangumiApi
from app.schemas.types import ModuleType, MediaRecognizeType
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.http import RequestUtils
@@ -18,12 +19,14 @@ class BangumiModule(_ModuleBase):
CONFIG_WATCH = {"PROXY_HOST"}
bangumiapi: BangumiApi = None
scraper: MediaScraperHelper = None
def init_module(self) -> None:
"""
初始化Bangumi客户端
"""
self.bangumiapi = BangumiApi()
self.scraper = MediaScraperHelper()
def stop(self) -> None:
"""
@@ -44,7 +47,8 @@ class BangumiModule(_ModuleBase):
return False, "Bangumi网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
"""Bangumi模块无需独立开关"""
return None
@staticmethod
def get_name() -> str:
@@ -74,59 +78,133 @@ class BangumiModule(_ModuleBase):
"""
return 3
def recognize_media(self, bangumiid: int = None,
**kwargs) -> Optional[MediaInfo]:
def recognize_media(
self,
meta: MetaBase = None,
bangumiid: int = None,
source: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param bangumiid: 识别的Bangumi ID
:param source: 请求级识别数据源
:return: 识别的媒体信息包括剧集信息
"""
if not bangumiid:
if not bangumiid and (
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
):
return None
# 直接查询详情
info = self.bangumi_info(bangumiid=bangumiid)
info = (
self.bangumi_info(bangumiid=bangumiid)
if bangumiid
else self._match_by_meta(meta)
)
if info:
# 赋值TMDB信息并返回
info["actors"] = self.bangumiapi.credits(info.get("id"))
mediainfo = MediaInfo(bangumi_info=info)
logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} "
if meta and meta.begin_season is not None:
mediainfo.season = meta.begin_season
logger.info(f"{bangumiid or meta.name} Bangumi识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year}")
return mediainfo
else:
logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息")
logger.info(f"{bangumiid or meta.name} 未匹配到Bangumi媒体信息")
return None
async def async_recognize_media(self, bangumiid: int = None,
**kwargs) -> Optional[MediaInfo]:
async def async_recognize_media(
self,
meta: MetaBase = None,
bangumiid: int = None,
source: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
识别媒体信息异步版本
:param meta: 识别的元数据
:param bangumiid: 识别的Bangumi ID
:param source: 请求级识别数据源
:return: 识别的媒体信息包括剧集信息
"""
if not bangumiid:
if not bangumiid and (
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
):
return None
# 直接查询详情
info = await self.async_bangumi_info(bangumiid=bangumiid)
info = (
await self.async_bangumi_info(bangumiid=bangumiid)
if bangumiid
else await self._async_match_by_meta(meta)
)
if info:
# 赋值TMDB信息并返回
info["actors"] = await self.bangumiapi.async_credits(info.get("id"))
mediainfo = MediaInfo(bangumi_info=info)
logger.info(f"{bangumiid} Bangumi识别结果:{mediainfo.type.value} "
if meta and meta.begin_season is not None:
mediainfo.season = meta.begin_season
logger.info(f"{bangumiid or meta.name} Bangumi识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year}")
return mediainfo
else:
logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息")
logger.info(f"{bangumiid or meta.name} 未匹配到Bangumi媒体信息")
return None
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
@staticmethod
def _matches_meta(meta: MetaBase, info: dict) -> bool:
"""
判断Bangumi候选项是否符合标题解析出的类型与年份
:param meta: 标题解析元数据
:param info: Bangumi候选项详情
:return: 是否符合筛选条件
"""
if (
meta.type in {MediaType.MOVIE, MediaType.TV}
and MediaInfo.get_bangumi_media_type(info) != meta.type
):
return False
release_date = info.get("date") or info.get("air_date") or ""
return not meta.year or not release_date or release_date[:4] == str(meta.year)
def _match_by_meta(self, meta: MetaBase) -> Optional[dict]:
"""
搜索并获取最符合标题解析结果的Bangumi详情
:param meta: 标题解析元数据
:return: Bangumi媒体详情
"""
for item in (self.bangumiapi.search(meta.name) or [])[:10]:
info = self.bangumiapi.detail(item.get("id")) if item.get("id") else None
if info and self._matches_meta(meta, info):
return info
return None
async def _async_match_by_meta(self, meta: MetaBase) -> Optional[dict]:
"""
异步搜索并获取最符合标题解析结果的Bangumi详情
:param meta: 标题解析元数据
:return: Bangumi媒体详情
"""
for item in (await self.bangumiapi.async_search(meta.name) or [])[:10]:
info = await self.bangumiapi.async_detail(item.get("id")) if item.get("id") else None
if info and self._matches_meta(meta, info):
return info
return None
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息
:param source: 请求级搜索数据源
:return: 媒体信息
"""
if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
if source and source != "bangumi":
return None
if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
@@ -137,13 +215,18 @@ class BangumiModule(_ModuleBase):
or meta.name.lower() in str(info.get("name_cn")).lower()]
return []
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息异步版本
:param meta: 识别的元数据
:reutrn: 媒体信息
:param source: 请求级搜索数据源
:return: 媒体信息
"""
if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
if source and source != "bangumi":
return None
if not source and settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
@@ -176,6 +259,45 @@ class BangumiModule(_ModuleBase):
logger.info(f"开始获取Bangumi信息:{bangumiid} ...")
return await self.bangumiapi.async_detail(bangumiid)
def metadata_nfo(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
**kwargs,
) -> Optional[str]:
"""
生成Bangumi来源的NFO内容
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: NFO XML文本
"""
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
if scrape_source != "bangumi":
return None
return self.scraper.get_metadata_nfo(mediainfo, season=season, episode=episode)
def metadata_img(
self,
mediainfo: MediaInfo,
season: Optional[int] = None,
episode: Optional[int] = None,
) -> Optional[dict]:
"""
获取Bangumi来源的刮削图片清单
:param mediainfo: 统一媒体信息
:param season: 季号
:param episode: 集号
:return: 图片文件名与下载地址映射
"""
scrape_source = mediainfo.scrape_source or settings.SCRAP_SOURCE
if scrape_source != "bangumi":
return None
return self.scraper.get_metadata_img(mediainfo, season=season, episode=episode)
def bangumi_calendar(self) -> Optional[List[MediaInfo]]:
"""
获取Bangumi每日放送
@@ -319,7 +441,7 @@ class BangumiModule(_ModuleBase):
return [MediaInfo(bangumi_info=info) for info in infos]
return []
def clear_cache(self):
def clear_cache(self) -> None:
"""
清除缓存
"""
+29 -13
View File
@@ -127,8 +127,11 @@ class DoubanModule(_ModuleBase):
if not doubanid and not meta:
return None
if meta and not doubanid \
and settings.RECOGNIZE_SOURCE != "douban":
if (
meta
and not doubanid
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
):
return None
if not meta:
@@ -227,8 +230,11 @@ class DoubanModule(_ModuleBase):
if not doubanid and not meta:
return None
if meta and not doubanid \
and settings.RECOGNIZE_SOURCE != "douban":
if (
meta
and not doubanid
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
):
return None
if not meta:
@@ -927,13 +933,18 @@ class DoubanModule(_ModuleBase):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息
:param source: 请求级搜索数据源
:return: 媒体信息
"""
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
if source and source != "douban":
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
@@ -943,13 +954,18 @@ class DoubanModule(_ModuleBase):
# 返回数据
return self._build_search_medias_result(meta, result.get("items"))
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息异步版本
:param meta: 识别的元数据
:reutrn: 媒体信息
:param source: 请求级搜索数据源
:return: 媒体信息
"""
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
if source and source != "douban":
return None
if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
@@ -1147,7 +1163,7 @@ class DoubanModule(_ModuleBase):
:param mediainfo: 媒体信息
:param season: 季号
"""
if settings.SCRAP_SOURCE != "douban":
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "douban":
return None
return self.scraper.get_metadata_nfo(mediainfo=mediainfo, season=season)
@@ -1158,7 +1174,7 @@ class DoubanModule(_ModuleBase):
:param season: 季号
:param episode: 集号
"""
if settings.SCRAP_SOURCE != "douban":
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "douban":
return None
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)
@@ -1169,7 +1185,7 @@ class DoubanModule(_ModuleBase):
:param mediainfo: 媒体信息
:return: None 表示不处理MediaInfo 表示继续处理
"""
if settings.RECOGNIZE_SOURCE != "douban":
if mediainfo.source != "douban" and settings.RECOGNIZE_SOURCE != "douban":
return None
if not mediainfo.douban_id:
return None
+32 -13
View File
@@ -92,14 +92,23 @@ class TheMovieDbModule(_ModuleBase):
pass
@staticmethod
def _validate_recognize_params(meta: MetaBase, tmdbid: Optional[int]) -> bool:
def _validate_recognize_params(
meta: MetaBase,
tmdbid: Optional[int],
source: Optional[str] = None,
) -> bool:
"""
验证识别参数
:param meta: 标题解析元数据
:param tmdbid: TMDB ID
:param source: 请求级识别数据源
:return: 参数是否可用于TMDB识别
"""
if not tmdbid and not meta:
return False
if meta and not tmdbid and settings.RECOGNIZE_SOURCE != "themoviedb":
if meta and not tmdbid and (source or settings.RECOGNIZE_SOURCE) != "themoviedb":
return False
if meta and not meta.name and not tmdbid:
@@ -467,7 +476,7 @@ class TheMovieDbModule(_ModuleBase):
:return: 识别的媒体信息包括剧集信息
"""
# 验证参数
if not self._validate_recognize_params(meta, tmdbid):
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
return None
if not meta:
@@ -553,7 +562,7 @@ class TheMovieDbModule(_ModuleBase):
:return: 识别的媒体信息包括剧集信息
"""
# 验证参数
if not self._validate_recognize_params(meta, tmdbid):
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
return None
if not meta:
@@ -726,13 +735,18 @@ class TheMovieDbModule(_ModuleBase):
MediaType.TV.value: list(self.category.tv_categorys)
}
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息列表
:param source: 请求级搜索数据源
:return: 媒体信息列表
"""
if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
if source and source != "themoviedb":
return None
if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
@@ -822,7 +836,7 @@ class TheMovieDbModule(_ModuleBase):
:param season: 季号
:param episode: 集号
"""
if settings.SCRAP_SOURCE != "themoviedb":
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "themoviedb":
return None
return self.scraper.get_metadata_nfo(meta=meta, mediainfo=mediainfo, season=season, episode=episode)
@@ -834,7 +848,7 @@ class TheMovieDbModule(_ModuleBase):
:param season: 季号
:param episode: 集号
"""
if settings.SCRAP_SOURCE != "themoviedb":
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "themoviedb":
return None
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)
@@ -955,7 +969,7 @@ class TheMovieDbModule(_ModuleBase):
:param mediainfo: 媒体信息
:return: None 表示不处理MediaInfo 表示继续处理
"""
if settings.RECOGNIZE_SOURCE != "themoviedb":
if mediainfo.source != "themoviedb" and settings.RECOGNIZE_SOURCE != "themoviedb":
return None
if not mediainfo.tmdb_id:
return mediainfo
@@ -1181,13 +1195,18 @@ class TheMovieDbModule(_ModuleBase):
return []
# 异步方法
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息异步版本
:param meta: 识别的元数据
:reutrn: 媒体信息列表
:param source: 请求级搜索数据源
:return: 媒体信息列表
"""
if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
if source and source != "themoviedb":
return None
if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
+74 -10
View File
@@ -1,5 +1,6 @@
import asyncio
import gc
import hashlib
import inspect
import json
import multiprocessing
@@ -38,6 +39,7 @@ from app.helper.image import WallpaperHelper
from app.helper.message import MessageHelper
from app.helper.progress import ProgressHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.service import ServiceConfigHelper
from app.helper.sites import SitesHelper # noqa
from app.log import logger
from app.schemas import Notification, NotificationType, Workflow
@@ -280,6 +282,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"DEV",
"COOKIECLOUD_INTERVAL",
"MEDIASERVER_SYNC_INTERVAL",
SystemConfigKey.MediaServers.value,
"SUBSCRIBE_SEARCH",
"SUBSCRIBE_SEARCH_INTERVAL",
"SUBSCRIBE_MODE",
@@ -323,6 +326,58 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"""
return "定时服务"
@staticmethod
def _get_mediaserver_sync_interval(
mediaserver: schemas.MediaServerConf,
default_interval: Optional[int],
) -> Optional[int]:
"""
获取媒体服务器的有效同步间隔未设置时回退旧全局配置
"""
interval = mediaserver.sync_interval
if interval is None:
interval = default_interval
try:
interval = int(interval)
except (TypeError, ValueError):
return None
return interval if interval > 0 else None
@classmethod
def _build_mediaserver_sync_schedules(
cls,
mediaservers: List[schemas.MediaServerConf],
default_interval: Optional[int],
) -> List[dict]:
"""
构建已启用媒体服务器的独立自动同步任务描述
"""
schedules = []
job_ids = set()
for mediaserver in mediaservers:
if not mediaserver or not mediaserver.enabled or not mediaserver.name:
continue
interval = cls._get_mediaserver_sync_interval(
mediaserver=mediaserver,
default_interval=default_interval,
)
if not interval:
continue
digest = hashlib.sha256(mediaserver.name.encode("utf-8")).hexdigest()[:12]
job_id = f"mediaserver_sync_{digest}"
if job_id in job_ids:
continue
job_ids.add(job_id)
schedules.append(
{
"id": job_id,
"name": f"同步媒体服务器 - {mediaserver.name}",
"server": mediaserver.name,
"interval": interval,
}
)
return schedules
@staticmethod
def _get_progress_key(job_id: str) -> str:
"""
@@ -351,6 +406,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
with lock:
# 各服务的运行状态
mediaserver_chain = MediaServerChain()
self._jobs = {
"cookiecloud": {
"name": "同步CookieCloud站点",
@@ -359,7 +415,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
},
"mediaserver_sync": {
"name": "同步媒体服务器",
"func": MediaServerChain().sync,
"func": mediaserver_chain.sync,
"running": False,
},
"subscribe_tmdb": {
@@ -478,19 +534,27 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
kwargs={"job_id": "cookiecloud"},
)
# 媒体服务器同步
if (
settings.MEDIASERVER_SYNC_INTERVAL
and str(settings.MEDIASERVER_SYNC_INTERVAL).isdigit()
):
# 媒体服务器分别注册自动同步任务
mediaserver_schedules = self._build_mediaserver_sync_schedules(
mediaservers=ServiceConfigHelper.get_mediaserver_configs(),
default_interval=settings.MEDIASERVER_SYNC_INTERVAL,
)
for mediaserver_schedule in mediaserver_schedules:
job_id = mediaserver_schedule["id"]
self._jobs[job_id] = {
"name": mediaserver_schedule["name"],
"func": mediaserver_chain.sync,
"running": False,
"kwargs": {"server": mediaserver_schedule["server"]},
}
self._scheduler.add_job(
self.start,
"interval",
id="mediaserver_sync",
name="同步媒体服务器",
hours=int(settings.MEDIASERVER_SYNC_INTERVAL),
id=job_id,
name=mediaserver_schedule["name"],
hours=mediaserver_schedule["interval"],
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=10),
kwargs={"job_id": "mediaserver_sync"},
kwargs={"job_id": job_id},
)
# 新增订阅时搜索(5分钟检查一次)
+11 -1
View File
@@ -63,14 +63,20 @@ class MetaInfo(BaseModel):
apply_words: Optional[List[str]] = None
# 剧集组
episode_group: Optional[str] = None
# 显式媒体数据源
media_source: Optional[str] = None
# 显式媒体数据源原生ID
media_id: Optional[str] = None
class MediaInfo(BaseModel):
"""
识别媒体信息
"""
# 来源:themoviedb、douban、bangumi
# 来源:themoviedb、douban、bangumi、anilist
source: Optional[str] = None
# 请求级刮削来源
scrape_source: Optional[str] = None
# 类型 电影、电视剧、合集
type: Optional[str] = None
# 媒体标题
@@ -93,6 +99,10 @@ class MediaInfo(BaseModel):
douban_id: Optional[str] = None
# Bangumi ID
bangumi_id: Optional[int] = None
# AniList ID
anilist_id: Optional[int] = None
# AniDB ID
anidb_id: Optional[int] = None
# 合集ID
collection_id: Optional[int] = None
# 其它媒体ID前缀
+8
View File
@@ -55,6 +55,10 @@ class DownloadHistory(BaseModel):
class TransferHistory(BaseModel):
"""
文件整理历史记录
"""
# ID
id: int
# 源目录
@@ -79,6 +83,10 @@ class TransferHistory(BaseModel):
tvdbid: Optional[int] = None
# 豆瓣ID
doubanid: Optional[str] = None
# 媒体数据源
media_source: Optional[str] = None
# 数据源原生ID
media_id: Optional[str] = None
# 季Sxx
seasons: Optional[str] = None
# 集Exx
+2
View File
@@ -37,6 +37,8 @@ class MediaServerConf(BaseModel):
enabled: Optional[bool] = False
# 同步媒体体库列表
sync_libraries: Optional[list] = Field(default_factory=list)
# 自动同步间隔(小时),未设置时使用旧全局配置
sync_interval: Optional[int] = None
class DownloaderConf(BaseModel):
+14 -1
View File
@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Optional, List, Any, Callable
from typing import Any, Callable, List, Literal, Optional
from pydantic import BaseModel, Field
@@ -60,6 +60,9 @@ class TransferTask(BaseModel):
fileitem: FileItem
meta: Optional[Any] = None
mediainfo: Optional[Any] = None
media_source: Optional[
Literal["themoviedb", "douban", "bangumi", "anilist"]
] = None
target_directory: Optional[TransferDirectoryConf] = None
target_storage: Optional[str] = None
target_path: Optional[Path] = None
@@ -192,6 +195,10 @@ class EpisodeFormatRecommendItem(BaseModel):
class ManualTransferItem(BaseModel):
"""
手动整理请求兼容历史数据源ID字段并支持统一来源与原生ID
"""
# 文件项
fileitem: FileItem = None
# 文件项列表(前端多选时传入)
@@ -208,6 +215,12 @@ class ManualTransferItem(BaseModel):
tmdbid: Optional[int] = None
# 豆瓣ID
doubanid: Optional[str] = None
# 媒体数据源
media_source: Optional[
Literal["themoviedb", "douban", "bangumi", "anilist"]
] = None
# 数据源原生ID
media_id: Optional[str] = None
# 类型
type_name: Optional[str] = None
# 季号
+2
View File
@@ -391,6 +391,8 @@ class MediaRecognizeType(Enum):
TVDB = "TheTvDb"
# bangumi
Bangumi = "Bangumi"
# AniList
AniList = "AniList"
# 用户配置Key字典
+24
View File
@@ -1,4 +1,5 @@
import logging
from functools import lru_cache
from typing import List, Optional, Tuple
from app.core.config import settings
@@ -214,6 +215,29 @@ def find_metainfo(title: str) -> Optional[dict]:
return None
@lru_cache(maxsize=1)
def supports_extended_media_ids() -> bool:
"""
判断当前 Rust 扩展是否支持 Bangumi AniList 显式媒体标签
:return: 是否支持扩展数据源ID字段
"""
if not is_enabled():
return False
try:
result = _moviepilot_rust.find_metainfo_fast("test [anilist=1]")
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"检测 Rust 扩展数据源ID能力失败:{err}")
return False
metainfo = result.get("metainfo") if isinstance(result, dict) else None
return bool(
metainfo
and metainfo.get("media_source") == "anilist"
and metainfo.get("media_id") == "1"
)
def _raise_non_rust_panic(err: BaseException) -> None:
"""
只吞掉 Rust 扩展 panic/异常保留用户中断和进程退出语义
+107
View File
@@ -0,0 +1,107 @@
"""2.2.13
为整理历史增加统一媒体数据源与原生ID
Revision ID: e6a1c4b8d2f0
Revises: c4e8f7a1b2d3
Create Date: 2026-07-21
"""
from alembic import op
import sqlalchemy as sa
revision = "e6a1c4b8d2f0"
down_revision = "c4e8f7a1b2d3"
branch_labels = None
depends_on = None
def _has_column(
inspector: sa.Inspector,
table_name: str,
column_name: str,
) -> bool:
"""
检查数据表是否已存在指定字段
:param inspector: SQLAlchemy结构检查器
:param table_name: 数据表名称
:param column_name: 字段名称
:return: 字段是否存在
"""
if table_name not in inspector.get_table_names():
return False
return any(
column["name"] == column_name
for column in inspector.get_columns(table_name)
)
def upgrade() -> None:
"""升级整理历史数据源字段。"""
inspector = sa.inspect(op.get_bind())
if not _has_column(inspector, "transferhistory", "media_source"):
op.add_column(
"transferhistory",
sa.Column("media_source", sa.String(), nullable=True),
)
op.create_index(
"ix_transferhistory_media_source",
"transferhistory",
["media_source"],
)
inspector = sa.inspect(op.get_bind())
if not _has_column(inspector, "transferhistory", "media_id"):
op.add_column(
"transferhistory",
sa.Column("media_id", sa.String(), nullable=True),
)
op.create_index(
"ix_transferhistory_media_id",
"transferhistory",
["media_id"],
)
transfer_history = sa.table(
"transferhistory",
sa.column("tmdbid", sa.Integer()),
sa.column("doubanid", sa.String()),
sa.column("media_source", sa.String()),
sa.column("media_id", sa.String()),
)
connection = op.get_bind()
connection.execute(
transfer_history.update()
.where(transfer_history.c.tmdbid.is_not(None))
.where(transfer_history.c.media_id.is_(None))
.values(
media_source="themoviedb",
media_id=sa.cast(transfer_history.c.tmdbid, sa.String()),
)
)
connection.execute(
transfer_history.update()
.where(transfer_history.c.tmdbid.is_(None))
.where(transfer_history.c.doubanid.is_not(None))
.where(transfer_history.c.media_id.is_(None))
.values(
media_source="douban",
media_id=transfer_history.c.doubanid,
)
)
def downgrade() -> None:
"""回滚整理历史数据源字段。"""
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, "transferhistory", "media_id"):
op.drop_index("ix_transferhistory_media_id", table_name="transferhistory")
op.drop_column("transferhistory", "media_id")
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, "transferhistory", "media_source"):
op.drop_index(
"ix_transferhistory_media_source",
table_name="transferhistory",
)
op.drop_column("transferhistory", "media_source")
+15 -2
View File
@@ -110,6 +110,19 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返回 `detail_i18n`;新版前端优先展示 `detail_i18n`,缺失时回退 `detail`
#### 媒体识别 / 整理
媒体识别、搜索和手动整理支持 `themoviedb``douban``bangumi``anilist` 四种数据源。请求未指定 `source` 时继续使用后台配置;显式指定时仅在该数据源中识别或搜索。
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/media/search` | 按标题搜索媒体,参数:`title``type``page``count`,可选 `source` |
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title``subtitle``custom_words`,可选 `source` |
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` |
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:``douban:``bangumi:``anilist:` 前缀 |
| POST | `/api/v1/transfer/manual/target-path` | 匹配手动整理目标路径;请求体可用 `media_source` + `media_id` 指定数据源原生ID |
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid``doubanid` |
#### 搜索 / 种子 / 字幕
| 方法 | 路径 | 说明 |
@@ -132,8 +145,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
| :--- | :--- | :--- |
| GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name` |
| POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 |
| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `tmdbid``doubanid``downloader``save_path` |
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,可选 `tmdbid``doubanid``save_path` |
| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `media_source` + `media_id`;继续兼容 `tmdbid``doubanid`,并支持 `downloader``save_path` |
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,可选 `media_source` + `media_id`;继续兼容 `tmdbid``doubanid`,并支持 `save_path` |
| GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` |
| GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` |
| GET | `/api/v1/download/clients` | 查询可用下载器 |
+1 -1
View File
@@ -1,4 +1,4 @@
moviepilot-rust~=0.2.3
moviepilot-rust~=0.2.4
pydantic>=2.13.4,<3.0.0
pydantic-settings>=2.14.1,<3.0.0
SQLAlchemy~=2.0.50
+10 -10
View File
@@ -91,11 +91,11 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/media/search` | Search media/person by title. Params: `title` (required), `type`, `page`, `count` |
| GET | `/api/v1/media/recognize` | Recognize media from torrent title. Params: `title` (required), `subtitle` |
| GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle` |
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required) |
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path` |
| GET | `/api/v1/media/search` | Search media/person by title. Params: `title` (required), `type`, `page`, `count`, optional `source` (`themoviedb`, `douban`, `bangumi`, `anilist`) |
| GET | `/api/v1/media/recognize` | Recognize media from torrent title. Params: `title` (required), `subtitle`, `custom_words`, optional `source` |
| GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source` |
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `source` |
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `source` |
| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON |
| GET | `/api/v1/media/category/config` | Get category strategy config |
| POST | `/api/v1/media/category/config` | Save category strategy config. Body: CategoryConfig |
@@ -103,7 +103,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons |
| GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups |
| GET | `/api/v1/media/seasons` | Get media season info. Params: `mediaid`, `title`, `year`, `season` |
| GET | `/api/v1/media/{mediaid}` | Get media detail. Params: `type_name` (required: movie/tv), `title`, `year` |
| GET | `/api/v1/media/{mediaid}` | Get media detail. `mediaid` supports `tmdb:`, `douban:`, `bangumi:`, and `anilist:`. Params: `type_name` (required: movie/tv), `title`, `year` |
### TMDB (8 endpoints)
@@ -160,8 +160,8 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
|--------|------|-------------|
| GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name) |
| POST | `/api/v1/download/` | Add download (with media info). Body: JSON |
| POST | `/api/v1/download/add` | Add download (without media info). Body: JSON with `torrent_url` |
| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, optional `tmdbid`, `doubanid`, `save_path` |
| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional `media_source` + `media_id` (legacy `tmdbid`/`doubanid` remain supported), `downloader`, `save_path` |
| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, optional `media_source` + `media_id` (legacy `tmdbid`/`doubanid` remain supported), `save_path` |
| GET | `/api/v1/download/start/{hashString}` | Resume download task |
| GET | `/api/v1/download/stop/{hashString}` | Pause download task |
| GET | `/api/v1/download/clients` | List available download clients |
@@ -278,8 +278,8 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| GET | `/api/v1/transfer/name` | Preview transfer name. Params: `path` (required), `filetype` (required) |
| GET | `/api/v1/transfer/queue` | Transfer queue |
| DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON |
| POST | `/api/v1/transfer/manual/target-path` | Match manual transfer target path. Body: ManualTransferItem JSON |
| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON |
| POST | `/api/v1/transfer/manual/target-path` | Match manual transfer target path. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select the recognition source |
| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source |
| GET | `/api/v1/transfer/now` | Run immediate transfer |
### Dashboard (19 endpoints)
+162
View File
@@ -0,0 +1,162 @@
import asyncio
from unittest.mock import AsyncMock, Mock
from xml.dom import minidom
import pytest
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.modules.anilist import AniListModule
from app.modules.anilist.anilist import AniListApi
from app.schemas.types import MediaType
@pytest.fixture
def anilist_info() -> dict:
"""构造不依赖网络的AniList媒体详情。"""
return {
"id": 154587,
"title": {
"romaji": "Sousou no Frieren",
"english": "Frieren: Beyond Journey's End",
"native": "葬送のフリーレン",
},
"format": "TV",
"status": "FINISHED",
"description": "A <b>journey</b> after the adventure.",
"startDate": {"year": 2023, "month": 9, "day": 29},
"endDate": {"year": 2024, "month": 3, "day": 22},
"episodes": 28,
"duration": 24,
"countryOfOrigin": "JP",
"coverImage": {"extraLarge": "https://img.example/poster.jpg"},
"bannerImage": "https://img.example/backdrop.png",
"genres": ["Adventure", "Fantasy"],
"synonyms": ["Frieren"],
"averageScore": 91,
"popularity": 300000,
"isAdult": False,
"studios": {"nodes": [{"name": "Madhouse"}]},
"staff": {
"edges": [
{
"role": "Director",
"node": {
"name": {"full": "Keiichiro Saito"},
"image": {"large": "https://img.example/director.jpg"},
"siteUrl": "https://anilist.co/staff/1",
},
}
]
},
"characters": {
"edges": [
{
"node": {"name": {"full": "Frieren"}},
"voiceActors": [
{
"name": {"full": "Atsumi Tanezaki"},
"image": {"large": "https://img.example/actor.jpg"},
"siteUrl": "https://anilist.co/staff/2",
}
],
}
]
},
"externalLinks": [
{"site": "AniDB", "url": "https://anidb.net/anime/17617"}
],
}
def test_anilist_id_recognition_normalizes_media_info(anilist_info: dict) -> None:
"""AniList ID识别应生成可供整理和刮削复用的统一媒体信息。"""
module = AniListModule()
module.anilist_api = Mock()
module.anilist_api.detail.return_value = anilist_info
media = module.recognize_media(anilistid=154587)
assert media is not None
assert media.source == "anilist"
assert media.anilist_id == 154587
assert media.anidb_id == 17617
assert media.type == MediaType.TV
assert media.year == "2023"
assert media.number_of_episodes == 28
assert media.seasons[1] == list(range(1, 29))
assert media.genres == [
{"id": "Adventure", "name": "Adventure"},
{"id": "Fantasy", "name": "Fantasy"},
]
assert media.production_companies == [{"name": "Madhouse"}]
assert media.directors[0]["name"] == "Keiichiro Saito"
assert media.actors[0]["character"] == "Frieren"
module.anilist_api.detail.assert_called_once_with(154587)
def test_anilist_title_recognition_respects_request_source(anilist_info: dict) -> None:
"""标题识别仅在本次请求明确选择AniList时使用AniList候选项。"""
module = AniListModule()
module.anilist_api = Mock()
module.anilist_api.search.return_value = [anilist_info]
meta = MetaBase("Frieren")
meta.cn_name = "Frieren"
meta.type = MediaType.TV
meta.year = "2023"
media = module.recognize_media(meta=meta, source="anilist")
skipped = module.recognize_media(meta=meta, source="douban")
assert media is not None
assert media.anilist_id == 154587
assert skipped is None
module.anilist_api.search.assert_called_once_with("Frieren")
def test_async_anilist_title_recognition(anilist_info: dict) -> None:
"""异步AniList标题识别应与同步结果保持一致。"""
module = AniListModule()
module.anilist_api = Mock()
module.anilist_api.async_search = AsyncMock(return_value=[anilist_info])
meta = MetaBase("Frieren")
meta.cn_name = "Frieren"
meta.type = MediaType.TV
media = asyncio.run(
module.async_recognize_media(meta=meta, source="anilist")
)
assert media is not None
assert media.anilist_id == 154587
module.anilist_api.async_search.assert_awaited_once_with("Frieren")
def test_anilist_scraper_generates_nfo_and_images(anilist_info: dict) -> None:
"""AniList媒体信息应生成带来源ID的NFO以及主海报和背景图。"""
module = AniListModule()
module.scraper = MediaScraperHelper()
media = MediaInfo(anilist_info=anilist_info)
media.scrape_source = "anilist"
nfo = module.metadata_nfo(media)
images = module.metadata_img(media)
document = minidom.parseString(nfo)
unique_id = document.getElementsByTagName("uniqueid")[0]
assert document.documentElement.tagName == "tvshow"
assert unique_id.firstChild.data == "154587"
assert unique_id.getAttribute("type") == "anilist"
assert images == {
"poster.jpg": "https://img.example/poster.jpg",
"backdrop.png": "https://img.example/backdrop.png",
}
def test_anilist_api_extracts_graphql_errors_without_network() -> None:
"""AniList客户端应把GraphQL错误响应统一视为无结果。"""
response = Mock(status_code=200)
response.json.return_value = {"errors": [{"message": "invalid"}]}
assert AniListApi._extract_response(response) is None
+71
View File
@@ -0,0 +1,71 @@
from pathlib import Path
from unittest.mock import patch
import pytest
from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo
from app.schemas.types import MediaType
@pytest.mark.parametrize(
("title", "source", "media_id"),
[
("葬送的芙莉莲 {[bangumiid=400602;type=tv;s=1]}", "bangumi", "400602"),
("Frieren {[anilistid=154587;type=tv;s=1]}", "anilist", "154587"),
("Frieren [anilist=154587] S01E01", "anilist", "154587"),
],
)
def test_find_metainfo_supports_anime_source_ids(
title: str,
source: str,
media_id: str,
) -> None:
"""显式动画来源标签应提取统一来源ID并从标题中移除。"""
parsed_title, metainfo = find_metainfo(title)
assert metainfo["media_source"] == source
assert metainfo["media_id"] == media_id
assert f"{source}id=" not in parsed_title
assert f"{source}=" not in parsed_title
def test_metainfo_custom_words_support_anilist_id() -> None:
"""自定义识别词替换结果中的AniList ID应进入统一元数据字段。"""
meta = MetaInfo(
"Sousou no Frieren 01",
custom_words=[
"Sousou no Frieren => Frieren {[anilistid=154587;type=tv;s=1]}"
],
)
assert meta.media_source == "anilist"
assert meta.media_id == "154587"
assert meta.type == MediaType.TV
assert meta.begin_season == 1
def test_metainfo_path_inherits_bangumi_id_from_parent() -> None:
"""文件路径识别应从父目录继承Bangumi来源ID。"""
meta = MetaInfoPath(
Path("/anime/葬送的芙莉莲 [bangumi=400602]/Frieren.S01E01.mkv")
)
assert meta.media_source == "bangumi"
assert meta.media_id == "400602"
assert meta.begin_season == 1
assert meta.begin_episode == 1
def test_extended_ids_fall_back_when_installed_rust_is_old() -> None:
"""当前Rust扩展缺少新字段时应直接使用Python解析器。"""
with patch(
"app.core.metainfo.rust_accel.supports_extended_media_ids",
return_value=False,
), patch(
"app.core.metainfo.rust_accel.find_metainfo",
side_effect=AssertionError("旧Rust扩展不应处理扩展来源ID"),
):
_, metainfo = find_metainfo("Frieren [anilist=154587]")
assert metainfo["media_source"] == "anilist"
assert metainfo["media_id"] == "154587"
+78
View File
@@ -0,0 +1,78 @@
from unittest.mock import Mock
from xml.dom import minidom
from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.modules.bangumi import BangumiModule
from app.schemas.types import MediaType
def _bangumi_info() -> dict:
"""构造Bangumi识别与刮削测试详情。"""
return {
"id": 400602,
"name": "Sousou no Frieren",
"name_cn": "葬送的芙莉莲",
"platform": "TV",
"date": "2023-09-29",
"eps": 28,
"summary": "勇者一行击败魔王后的故事。",
"images": {"large": "https://lain.example/poster.jpg"},
"rating": {"score": 8.9},
"tags": [{"name": "奇幻"}, {"name": "冒险"}],
"infobox": [
{"key": "动画制作", "value": "MADHOUSE"},
{"key": "导演", "value": [{"v": "斋藤圭一郎"}]},
],
}
def test_bangumi_title_recognition_loads_detail_and_people() -> None:
"""Bangumi标题识别应搜索候选、读取详情并补齐演职员。"""
module = BangumiModule()
module.bangumiapi = Mock()
module.bangumiapi.search.return_value = [
{"id": 400602, "name": "Sousou no Frieren", "name_cn": "葬送的芙莉莲"}
]
module.bangumiapi.detail.return_value = _bangumi_info()
module.bangumiapi.credits.return_value = [
{"name": "种崎敦美", "career": ["芙莉莲"]}
]
meta = MetaBase("葬送的芙莉莲")
meta.cn_name = "葬送的芙莉莲"
meta.type = MediaType.TV
meta.year = "2023"
media = module.recognize_media(meta=meta, source="bangumi")
assert media is not None
assert media.source == "bangumi"
assert media.bangumi_id == 400602
assert media.number_of_episodes == 28
assert media.genres == [
{"id": "奇幻", "name": "奇幻"},
{"id": "冒险", "name": "冒险"},
]
assert media.production_companies == [{"name": "MADHOUSE"}]
assert media.directors == [{"name": "斋藤圭一郎"}]
assert media.actors[0]["name"] == "种崎敦美"
def test_bangumi_scraper_generates_source_nfo() -> None:
"""Bangumi来源应可生成带Bangumi唯一ID的NFO与图片清单。"""
module = BangumiModule()
module.bangumiapi = Mock()
module.scraper = MediaScraperHelper()
module.bangumiapi.detail.return_value = _bangumi_info()
module.bangumiapi.credits.return_value = []
media = module.recognize_media(bangumiid=400602)
media.scrape_source = "bangumi"
nfo = module.metadata_nfo(media)
images = module.metadata_img(media)
document = minidom.parseString(nfo)
unique_id = document.getElementsByTagName("uniqueid")[0]
assert unique_id.firstChild.data == "400602"
assert unique_id.getAttribute("type") == "bangumi"
assert images == {"poster.jpg": "https://lain.example/poster.jpg"}
+118
View File
@@ -0,0 +1,118 @@
from types import SimpleNamespace
from app import schemas
from app.api.endpoints import download as download_endpoint
from app.core.context import MediaInfo
from app.schemas.types import MediaType
def test_download_add_passes_generic_media_source(monkeypatch) -> None:
"""不含媒体信息的下载应按统一来源ID执行精确识别。"""
captured = {}
media = MediaInfo(
anilist_info={
"id": 154587,
"title": {"english": "Frieren"},
"format": "TV",
}
)
class FakeMediaChain:
"""记录下载接口传入的媒体识别参数。"""
def recognize_media(self, **kwargs):
"""返回固定媒体信息并保存识别参数。"""
captured["recognize"] = kwargs
return media
class FakeDownloadChain:
"""模拟下载任务提交。"""
def download_single(self, **kwargs):
"""保存下载上下文并返回任务ID。"""
captured["download"] = kwargs
return "download-1"
monkeypatch.setattr(download_endpoint, "MediaChain", FakeMediaChain)
monkeypatch.setattr(download_endpoint, "DownloadChain", FakeDownloadChain)
response = download_endpoint.add(
torrent_in=schemas.TorrentInfo(title="Frieren S01E01"),
media_source="anilist",
media_id="154587",
current_user=SimpleNamespace(name="tester"),
)
assert response.success is True
assert captured["recognize"]["source"] == "anilist"
assert captured["recognize"]["mediaid"] == "154587"
assert captured["download"]["context"].media_info is media
def test_download_add_uses_selected_source_for_title_recognition(monkeypatch) -> None:
"""只选择来源而未填写ID时应在该来源内按标题识别。"""
captured = {}
media = MediaInfo(title="测试动画", type=MediaType.TV, bangumi_id=1)
class FakeMediaChain:
"""记录按标题识别的请求级来源。"""
def recognize_by_meta(self, metainfo, **kwargs):
"""返回固定媒体信息并保存来源。"""
captured["metainfo"] = metainfo
captured["kwargs"] = kwargs
return media
class FakeDownloadChain:
"""模拟下载任务提交。"""
@staticmethod
def download_single(**kwargs):
"""返回固定下载任务ID。"""
return "download-2"
monkeypatch.setattr(download_endpoint, "MediaChain", FakeMediaChain)
monkeypatch.setattr(download_endpoint, "DownloadChain", FakeDownloadChain)
response = download_endpoint.add(
torrent_in=schemas.TorrentInfo(title="测试动画 S01E01"),
media_source="bangumi",
current_user=SimpleNamespace(name="tester"),
)
assert response.success is True
assert captured["kwargs"]["source"] == "bangumi"
def test_subtitle_download_passes_generic_media_source(monkeypatch) -> None:
"""字幕下载接口应把统一来源ID传递到下载链。"""
captured = {}
class FakeDownloadChain:
"""记录字幕下载参数。"""
def download_subtitle(self, **kwargs):
"""保存参数并返回固定成功结果。"""
captured.update(kwargs)
return True, "字幕下载成功", ["/tmp/subtitle.ass"]
monkeypatch.setattr(
download_endpoint,
"_prepare_subtitle_download",
lambda _subtitle: (True, ""),
)
monkeypatch.setattr(download_endpoint, "DownloadChain", FakeDownloadChain)
response = download_endpoint.download_subtitle(
subtitle_in=schemas.SubtitleInfo(
title="Frieren S01E01",
enclosure="https://example.com/subtitle.ass",
),
media_source="anilist",
media_id="154587",
current_user=SimpleNamespace(name="tester"),
)
assert response.success is True
assert captured["media_source"] == "anilist"
assert captured["media_id"] == "154587"
+12
View File
@@ -317,6 +317,18 @@ def test_schedule_info_auto_fills_i18n_display_fields():
assert schedule.progress_detail.text_i18n == "Media server Emby has no libraries to sync"
assert schedule.progress_detail.error_i18n == "Background service does not exist"
token = LocaleHelper.set_current_locale("en-US")
try:
dynamic_schedule = ScheduleInfo(
id="mediaserver_sync_example",
name="同步媒体服务器 - Plex",
progress_text="同步媒体服务器 - Plex 开始执行 ...",
)
finally:
LocaleHelper.reset_current_locale(token)
assert dynamic_schedule.name_i18n == "Sync Media Server - Plex"
assert dynamic_schedule.progress_text_i18n == "Starting media server sync - Plex ..."
def test_scheduler_progress_patterns_translate_dynamic_texts():
"""定时任务进度动态模板应翻译固定词并保留业务变量。"""
+106
View File
@@ -0,0 +1,106 @@
from unittest.mock import Mock, patch
from app.chain import ChainBase
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.schemas.types import MediaType
def _chain_without_init() -> ChainBase:
"""构造不加载真实模块和外部服务的识别链实例。"""
return object.__new__(ChainBase)
def test_generic_source_id_wins_over_legacy_ids() -> None:
"""显式source与media_id应优先于同一请求残留的兼容ID字段。"""
resolved = ChainBase._resolve_media_source_params(
source="anilist",
mediaid="154587",
tmdbid=999,
doubanid="888",
)
assert resolved == ("anilist", None, None, None, 154587)
def test_explicit_source_recognition_runs_system_modules_only() -> None:
"""显式选择数据源时应跳过插件并只向系统模块传递该来源ID。"""
chain = _chain_without_init()
media = MediaInfo(
anilist_info={
"id": 154587,
"title": {"english": "Frieren"},
"format": "TV",
}
)
chain.run_module = Mock(return_value=media)
with patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=False,
):
result = chain.recognize_media(
source="anilist",
mediaid="154587",
tmdbid=999,
mtype=MediaType.TV,
)
assert result is media
call = chain.run_module.call_args
assert call.kwargs["system_only"] is True
assert call.kwargs["source"] == "anilist"
assert call.kwargs["anilistid"] == 154587
assert call.kwargs["tmdbid"] is None
def test_default_recognition_preserves_plugin_method_contract() -> None:
"""未显式选择来源时不应向既有插件额外传递source参数。"""
chain = _chain_without_init()
media = MediaInfo(title="测试电影", type=MediaType.MOVIE, tmdb_id=1)
chain.run_module = Mock(return_value=media)
meta = MetaBase("测试电影")
meta.cn_name = "测试电影"
meta.type = MediaType.MOVIE
with patch(
"app.chain.MoviePilotServerHelper.report_recognize_share",
return_value=False,
):
result = chain.recognize_media(meta=meta)
assert result is media
call = chain.run_module.call_args
assert call.kwargs["system_only"] is False
assert "source" not in call.kwargs
assert "anilistid" not in call.kwargs
def test_system_only_module_dispatch_skips_plugins() -> None:
"""模块调度的system_only模式不得执行插件模块。"""
chain = _chain_without_init()
chain._ChainBase__execute_plugin_modules = Mock(return_value="plugin")
chain._ChainBase__execute_system_modules = Mock(return_value="system")
result = chain.run_module("search_medias", system_only=True, meta=MetaBase("test"))
assert result == "system"
chain._ChainBase__execute_plugin_modules.assert_not_called()
chain._ChainBase__execute_system_modules.assert_called_once()
def test_explicit_search_source_uses_system_only_dispatch() -> None:
"""请求级搜索来源应进入仅系统模块调度。"""
chain = _chain_without_init()
chain.run_module = Mock(return_value=[])
meta = MetaBase("Frieren")
result = chain.search_medias(meta, source="anilist")
assert result == []
chain.run_module.assert_called_once_with(
"search_medias",
meta=meta,
source="anilist",
system_only=True,
)
@@ -248,3 +248,33 @@ def test_sync_queries_counts_before_items_and_reports_media_progress(database):
assert media_progress[2]["data"]["media_finished"] == 3
progress_values = [snapshot["value"] for snapshot in progress_snapshots]
assert progress_values == sorted(progress_values)
def test_sync_targets_one_server_without_excluding_other_enabled_servers(monkeypatch):
"""定向同步只访问目标服务器,缓存清理仍保留其他已启用服务器。"""
chain = object.__new__(MediaServerChain)
library_calls = []
excluded_server_calls = []
class FakeMediaServerOper:
"""记录媒体服务器缓存清理参数的测试替身。"""
def delete_excluded_servers(self, servers):
"""记录应保留的全部已启用服务器名称。"""
excluded_server_calls.append(servers)
chain.librarys = lambda server: library_calls.append(server) or []
monkeypatch.setattr(MEDIA_SERVER_CHAIN_MODULE, "MediaServerOper", FakeMediaServerOper)
monkeypatch.setattr(
MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper,
"get_mediaserver_configs",
lambda: [
SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]),
SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]),
],
)
chain.sync(server="plex-a")
assert library_calls == ["plex-a"]
assert excluded_server_calls == [["plex-a", "plex-b"]]
+33
View File
@@ -0,0 +1,33 @@
from app.schemas.system import MediaServerConf
from app.scheduler import Scheduler
def test_build_mediaserver_sync_schedules_uses_server_interval_and_legacy_fallback():
"""媒体服务器自动任务应支持独立周期,并在缺省时回退旧全局值。"""
schedules = Scheduler._build_mediaserver_sync_schedules(
mediaservers=[
MediaServerConf(name="default", enabled=True),
MediaServerConf(name="custom", enabled=True, sync_interval=12),
MediaServerConf(name="disabled-sync", enabled=True, sync_interval=0),
MediaServerConf(name="disabled-server", enabled=False, sync_interval=3),
],
default_interval=6,
)
assert [(item["server"], item["interval"]) for item in schedules] == [
("default", 6),
("custom", 12),
]
assert len({item["id"] for item in schedules}) == 2
assert all(item["id"].startswith("mediaserver_sync_") for item in schedules)
def test_build_mediaserver_sync_schedules_keeps_ids_stable():
"""同名媒体服务器重载配置后应生成稳定的自动任务标识。"""
mediaservers = [MediaServerConf(name="My Plex", enabled=True, sync_interval=8)]
first = Scheduler._build_mediaserver_sync_schedules(mediaservers, 6)
second = Scheduler._build_mediaserver_sync_schedules(mediaservers, 24)
assert first[0]["id"] == second[0]["id"]
assert first[0]["interval"] == second[0]["interval"] == 8
@@ -0,0 +1,82 @@
import importlib
from types import SimpleNamespace
from unittest.mock import Mock
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from app.core.meta import MetaBase
from app.db.transferhistory_oper import TransferHistoryOper
from app.schemas import FileItem
def test_transferhistory_migration_backfills_existing_source_ids(monkeypatch) -> None:
"""迁移应把存量TMDB和豆瓣字段回填到统一来源字段。"""
migration = importlib.import_module(
"database.versions.e6a1c4b8d2f0_2_2_13"
)
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
transfer_history = sa.Table(
"transferhistory",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tmdbid", sa.Integer()),
sa.Column("doubanid", sa.String()),
)
with engine.begin() as connection:
metadata.create_all(connection)
connection.execute(
transfer_history.insert(),
[
{"id": 1, "tmdbid": 123, "doubanid": "ignored"},
{"id": 2, "tmdbid": None, "doubanid": "456"},
{"id": 3, "tmdbid": None, "doubanid": None},
],
)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
migration.upgrade()
migrated = sa.Table(
"transferhistory",
sa.MetaData(),
autoload_with=connection,
)
rows = connection.execute(
sa.select(migrated).order_by(migrated.c.id)
).mappings().all()
assert rows[0]["media_source"] == "themoviedb"
assert rows[0]["media_id"] == "123"
assert rows[1]["media_source"] == "douban"
assert rows[1]["media_id"] == "456"
assert rows[2]["media_source"] is None
assert rows[2]["media_id"] is None
def test_failed_transfer_history_preserves_explicit_media_source() -> None:
"""识别失败记录也应保存文件名中显式指定的数据源ID。"""
oper = object.__new__(TransferHistoryOper)
oper.add_force = Mock(return_value=SimpleNamespace(id=1))
meta = MetaBase("Frieren")
meta.cn_name = "Frieren"
meta.media_source = "anilist"
meta.media_id = "154587"
oper.add_fail(
fileitem=FileItem(
storage="local",
path="/downloads/Frieren.mkv",
type="file",
),
mode="copy",
meta=meta,
)
call = oper.add_force.call_args
assert call.kwargs["media_source"] == "anilist"
assert call.kwargs["media_id"] == "154587"