mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
feat: expand metadata sources and media server sync (#6129)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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]]:
|
||||
|
||||
@@ -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
@@ -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, []
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ID(AniList外部映射)
|
||||
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: YYYY、YYYY-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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: MetaAnime、MetaVideo
|
||||
"""
|
||||
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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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} 執行完成"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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:
|
||||
"""
|
||||
清除缓存
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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前缀
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
# 季号
|
||||
|
||||
@@ -391,6 +391,8 @@ class MediaRecognizeType(Enum):
|
||||
TVDB = "TheTvDb"
|
||||
# bangumi
|
||||
Bangumi = "Bangumi"
|
||||
# AniList
|
||||
AniList = "AniList"
|
||||
|
||||
|
||||
# 用户配置Key字典
|
||||
|
||||
@@ -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/异常,保留用户中断和进程退出语义。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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():
|
||||
"""定时任务进度动态模板应翻译固定词并保留业务变量。"""
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user