mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(media): aggregate aliases from enabled metadata sources
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.media import is_media_source_enabled
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.scraper import MediaScraperHelper
|
||||
from app.runtime.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.anilist.anilist import AniListApi
|
||||
from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import (
|
||||
MediaRecognizeType,
|
||||
MediaSource,
|
||||
@@ -17,14 +19,14 @@ from app.schemas.types import (
|
||||
MediaType,
|
||||
ModuleType,
|
||||
)
|
||||
from app.domain.media import is_media_source_enabled
|
||||
|
||||
|
||||
class AniListModule(_ModuleBase):
|
||||
class AniListModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
"""
|
||||
AniList 动画媒体识别与刮削模块
|
||||
"""
|
||||
|
||||
auxiliary_media_source = MediaSource.AniList
|
||||
CONFIG_WATCH = {"PROXY_HOST"}
|
||||
|
||||
anilist_api: AniListApi = None
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.media import is_media_source_enabled
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.scraper import MediaScraperHelper
|
||||
from app.runtime.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.bangumi.bangumi import BangumiApi
|
||||
from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import (
|
||||
MediaRecognizeType,
|
||||
MediaSource,
|
||||
@@ -18,8 +21,6 @@ from app.schemas.types import (
|
||||
MediaType,
|
||||
ModuleType,
|
||||
)
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.domain.media import is_media_source_enabled
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -29,10 +30,11 @@ class BangumiConfigSnapshot:
|
||||
proxy: Any
|
||||
|
||||
|
||||
class BangumiModule(_ModuleBase):
|
||||
class BangumiModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
"""
|
||||
Bangumi媒体信息匹配
|
||||
"""
|
||||
auxiliary_media_source = MediaSource.Bangumi
|
||||
CONFIG_WATCH = {"PROXY_HOST"}
|
||||
|
||||
bangumiapi: BangumiApi = None
|
||||
|
||||
@@ -3,43 +3,45 @@ from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
import cn2an
|
||||
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.domain.context import (
|
||||
MediaInfo,
|
||||
MusicAlbumInfo,
|
||||
MusicInfo,
|
||||
)
|
||||
from app.domain.media import is_media_source_enabled, is_media_source_selected
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.runtime.log import logger
|
||||
from app.foundation.text import convert as zhconv_convert
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.douban.apiv2 import DoubanApi
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin
|
||||
from app.runtime.execution import retry
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.rate import rate_limit_exponential
|
||||
from app.schemas.context import MediaPerson
|
||||
from app.schemas.exception import APIRateLimitException
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaRecognizeType,
|
||||
MediaSource,
|
||||
MediaSourceSelection,
|
||||
MediaType,
|
||||
ModuleType,
|
||||
MediaRecognizeType,
|
||||
)
|
||||
from app.runtime.execution import retry
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.runtime.rate import rate_limit_exponential
|
||||
from app.domain.media import is_media_source_enabled, is_media_source_selected
|
||||
from app.foundation.text import convert as zhconv_convert
|
||||
|
||||
|
||||
class DoubanModule(_ModuleBase):
|
||||
class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
"""提供豆瓣影视与豆瓣音乐元数据识别能力。"""
|
||||
|
||||
auxiliary_media_source = MediaSource.Douban
|
||||
_music_source = MediaSource.DoubanMusic
|
||||
doubanapi: DoubanApi = None
|
||||
scraper: DoubanScraper = None
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.scraper import MediaScraperHelper
|
||||
from app.foundation.text import convert as zhconv_convert
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
@@ -43,9 +44,10 @@ class ImdbConfigSnapshot:
|
||||
proxy: Any
|
||||
|
||||
|
||||
class ImdbModule(_ModuleBase):
|
||||
class ImdbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
"""提供 IMDb 搜索、识别、详情补全与刮削能力。"""
|
||||
|
||||
auxiliary_media_source = MediaSource.IMDb
|
||||
CONFIG_WATCH = {"PROXY_HOST"}
|
||||
_IMDB_ID_PATTERN = re.compile(r"^tt\d+$", re.IGNORECASE)
|
||||
_MOVIE_TYPES = frozenset({"movie", "tvMovie"})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""影视识别模块共享的附加信息能力实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Optional
|
||||
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.media import is_media_source_enabled
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.schemas.types import MediaSource, MediaSourceSelection, MediaType
|
||||
|
||||
|
||||
class MediaAuxiliaryProviderMixin:
|
||||
"""把现有识别接口适配为可聚合的影视附加信息能力。"""
|
||||
|
||||
auxiliary_media_source: MediaSource
|
||||
|
||||
@staticmethod
|
||||
def _build_auxiliary_meta(
|
||||
mediainfo: MediaInfo,
|
||||
metainfo: Optional[MetaBase] = None,
|
||||
) -> MetaInfo:
|
||||
"""根据主识别结果构造不携带原来源身份的标题查询参数。"""
|
||||
meta = MetaInfo(mediainfo.title or getattr(metainfo, "name", None) or "")
|
||||
if not meta.en_name:
|
||||
meta.en_name = mediainfo.en_title or getattr(metainfo, "en_name", None)
|
||||
meta.type = mediainfo.type or getattr(metainfo, "type", None) or MediaType.UNKNOWN
|
||||
meta.begin_season = (
|
||||
mediainfo.season
|
||||
if mediainfo.season is not None
|
||||
else getattr(metainfo, "begin_season", None)
|
||||
)
|
||||
season_year = None
|
||||
if meta.begin_season is not None and mediainfo.season_years:
|
||||
season_year = (
|
||||
mediainfo.season_years.get(meta.begin_season)
|
||||
or mediainfo.season_years.get(str(meta.begin_season))
|
||||
)
|
||||
meta.year = season_year or mediainfo.year or getattr(metainfo, "year", None)
|
||||
return meta
|
||||
|
||||
def _auxiliary_recognize_kwargs(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
metainfo: Optional[MetaBase] = None,
|
||||
) -> dict[str, object]:
|
||||
"""构造当前来源的识别参数,同源媒体优先使用来源原生 ID。"""
|
||||
same_source = mediainfo.media_source == self.auxiliary_media_source
|
||||
return {
|
||||
"meta": self._build_auxiliary_meta(mediainfo, metainfo),
|
||||
"mtype": mediainfo.type,
|
||||
"media_source": self.auxiliary_media_source,
|
||||
"media_id": mediainfo.media_id if same_source else None,
|
||||
"episode_group": mediainfo.episode_group,
|
||||
"cache": True,
|
||||
}
|
||||
|
||||
def get_media_auxiliary_info(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
media_source: Optional[MediaSourceSelection] = None,
|
||||
metainfo: Optional[MetaBase] = None,
|
||||
) -> list[MediaInfo]:
|
||||
"""使用当前来源同步获取影视别名等附加信息。"""
|
||||
if (
|
||||
not mediainfo
|
||||
or mediainfo.type not in (MediaType.MOVIE, MediaType.TV)
|
||||
or not is_media_source_enabled(media_source, self.auxiliary_media_source)
|
||||
):
|
||||
return []
|
||||
recognize: Callable[..., Optional[MediaInfo]] = getattr(self, "recognize_media")
|
||||
result = recognize(**self._auxiliary_recognize_kwargs(mediainfo, metainfo))
|
||||
return [result] if result else []
|
||||
|
||||
async def async_get_media_auxiliary_info(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
media_source: Optional[MediaSourceSelection] = None,
|
||||
metainfo: Optional[MetaBase] = None,
|
||||
) -> list[MediaInfo]:
|
||||
"""使用当前来源异步获取影视别名等附加信息。"""
|
||||
if (
|
||||
not mediainfo
|
||||
or mediainfo.type not in (MediaType.MOVIE, MediaType.TV)
|
||||
or not is_media_source_enabled(media_source, self.auxiliary_media_source)
|
||||
):
|
||||
return []
|
||||
recognize: Callable[..., Awaitable[Optional[MediaInfo]]] = getattr(
|
||||
self,
|
||||
"async_recognize_media",
|
||||
)
|
||||
result = await recognize(**self._auxiliary_recognize_kwargs(mediainfo, metainfo))
|
||||
return [result] if result else []
|
||||
@@ -1,24 +1,29 @@
|
||||
import re
|
||||
from typing import Optional, List, Tuple, Union, Dict
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import cn2an
|
||||
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason
|
||||
from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode
|
||||
from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.media import is_media_source_enabled, is_media_source_selected
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.runtime.log import logger
|
||||
from app.foundation.text import convert as zhconv_convert
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.media_auxiliary import MediaAuxiliaryProviderMixin
|
||||
from app.modules.themoviedb.category import CategoryHelper
|
||||
from app.modules.themoviedb.scraper import TmdbScraper
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
from app.modules.themoviedb.tmdbapi import TmdbApi
|
||||
from app.modules.themoviedb.tmdbv3api.exceptions import TMDbConnectionError
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.category import CategoryConfig
|
||||
from app.schemas.media import normalize_media_source
|
||||
from app.schemas.types import (
|
||||
MediaImageType,
|
||||
MediaRecognizeType,
|
||||
@@ -27,20 +32,16 @@ from app.schemas.types import (
|
||||
MediaType,
|
||||
ModuleType,
|
||||
)
|
||||
from app.adapters.network.http import RequestUtils
|
||||
from app.domain.media import is_media_source_enabled, is_media_source_selected
|
||||
from app.schemas.media import normalize_media_source
|
||||
from app.foundation.text import convert as zhconv_convert
|
||||
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
class TheMovieDbModule(_ModuleBase):
|
||||
class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
"""
|
||||
TMDB媒体信息匹配
|
||||
"""
|
||||
CONFIG_WATCH = {"PROXY_HOST", "TMDB_API_DOMAIN", "TMDB_API_KEY", "TMDB_LOCALE"}
|
||||
auxiliary_media_source = MediaSource.TMDB
|
||||
|
||||
# 元数据缓存
|
||||
cache: TmdbCache = None
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
from threading import Lock
|
||||
from typing import Optional, Tuple, Union
|
||||
from typing import Optional, Tuple, Union, cast
|
||||
|
||||
from app.runtime.settings import RuntimeSettingsCompat
|
||||
|
||||
settings = RuntimeSettingsCompat()
|
||||
from app.runtime.log import logger
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.media import is_media_source_enabled
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.thetvdb import tvdb_v4_official
|
||||
from app.schemas.types import ModuleType, MediaRecognizeType
|
||||
from app.runtime.execution import run_in_threadpool
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.types import (
|
||||
MediaRecognizeType,
|
||||
MediaSource,
|
||||
MediaSourceSelection,
|
||||
MediaType,
|
||||
ModuleType,
|
||||
)
|
||||
|
||||
|
||||
class TheTvDbModule(_ModuleBase):
|
||||
@@ -179,6 +189,111 @@ class TheTvDbModule(_ModuleBase):
|
||||
logger.error(f"用标题搜索TVDB剧集失败 ({title}): {str(err)}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _tvdb_aliases(info: dict[str, object]) -> list[str]:
|
||||
"""从 TVDB 搜索或详情响应中提取名称与翻译别名。"""
|
||||
values: list[object] = [info.get("name")]
|
||||
for field in ("aliases", "translations", "nameTranslations"):
|
||||
raw_value = info.get(field) or []
|
||||
if isinstance(raw_value, dict):
|
||||
raw_value = list(raw_value.values())
|
||||
if not isinstance(raw_value, (list, tuple, set)):
|
||||
raw_value = [raw_value]
|
||||
for item in raw_value:
|
||||
values.append(
|
||||
item.get("name") or item.get("value")
|
||||
if isinstance(item, dict)
|
||||
else item
|
||||
)
|
||||
aliases: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in values:
|
||||
alias = str(value or "").strip()
|
||||
normalized = " ".join(alias.casefold().split())
|
||||
if alias and normalized not in seen:
|
||||
aliases.append(alias)
|
||||
seen.add(normalized)
|
||||
return aliases
|
||||
|
||||
@staticmethod
|
||||
def _tvdb_media_id(info: dict[str, object]) -> Optional[str]:
|
||||
"""从不同形态的 TVDB 响应中提取纯数字来源 ID。"""
|
||||
raw_id = info.get("tvdb_id") or info.get("id")
|
||||
if raw_id is None:
|
||||
return None
|
||||
value = str(raw_id).rsplit("-", 1)[-1]
|
||||
return value if value.isdigit() else None
|
||||
|
||||
def get_media_auxiliary_info(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
media_source: Optional[MediaSourceSelection] = None,
|
||||
metainfo: Optional[MetaBase] = None,
|
||||
) -> list[MediaInfo]:
|
||||
"""从 TVDB 补充电视剧别名,不向主媒体写入 TVDB 专用字段。"""
|
||||
if (
|
||||
not mediainfo
|
||||
or mediainfo.type != MediaType.TV
|
||||
or not is_media_source_enabled(media_source, MediaSource.TVDB)
|
||||
):
|
||||
return []
|
||||
del metainfo
|
||||
if (
|
||||
mediainfo.media_source == MediaSource.TVDB
|
||||
and str(mediainfo.media_id or "").isdigit()
|
||||
):
|
||||
info = self.tvdb_info(int(mediainfo.media_id))
|
||||
candidates = [info] if info else []
|
||||
else:
|
||||
candidates = self.search_tvdb(mediainfo.title)
|
||||
target_names = {
|
||||
" ".join(str(name).casefold().split())
|
||||
for name in [mediainfo.title, *(mediainfo.names or [])]
|
||||
if name
|
||||
}
|
||||
for info in candidates:
|
||||
candidate_year = str(
|
||||
info.get("year")
|
||||
or info.get("firstAired")
|
||||
or info.get("first_air_time")
|
||||
or ""
|
||||
)[:4]
|
||||
if mediainfo.year and candidate_year and str(mediainfo.year) != candidate_year:
|
||||
continue
|
||||
aliases = self._tvdb_aliases(info)
|
||||
normalized_aliases = {" ".join(alias.casefold().split()) for alias in aliases}
|
||||
if not target_names.intersection(normalized_aliases):
|
||||
continue
|
||||
media_id = self._tvdb_media_id(info)
|
||||
if not media_id or not aliases:
|
||||
continue
|
||||
return [MediaInfo(
|
||||
media_source=MediaSource.TVDB,
|
||||
media_id=media_id,
|
||||
type=MediaType.TV,
|
||||
title=aliases[0],
|
||||
year=str(info.get("year")) if info.get("year") else mediainfo.year,
|
||||
names=aliases,
|
||||
)]
|
||||
return []
|
||||
|
||||
async def async_get_media_auxiliary_info(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
media_source: Optional[MediaSourceSelection] = None,
|
||||
metainfo: Optional[MetaBase] = None,
|
||||
) -> list[MediaInfo]:
|
||||
"""在线程池中执行 TVDB 同步附加信息查询。"""
|
||||
return cast(
|
||||
list[MediaInfo],
|
||||
await run_in_threadpool(
|
||||
self.get_media_auxiliary_info,
|
||||
mediainfo=mediainfo,
|
||||
media_source=media_source,
|
||||
metainfo=metainfo,
|
||||
),
|
||||
)
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清除缓存
|
||||
|
||||
Reference in New Issue
Block a user