feat(media): aggregate aliases from enabled metadata sources

This commit is contained in:
jxxghp
2026-08-26 13:45:03 +08:00
parent ac2a2b1129
commit e02418bace
19 changed files with 727 additions and 125 deletions
+6 -4
View File
@@ -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
+7 -5
View File
@@ -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
+11 -9
View File
@@ -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
+3 -1
View File
@@ -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"})
+95
View File
@@ -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 []
+12 -11
View File
@@ -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
+118 -3
View File
@@ -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):
"""
清除缓存