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

This commit is contained in:
jxxghp
2026-07-21 11:34:18 +08:00
parent 1d708870c9
commit e3f80af74f
43 changed files with 2581 additions and 152 deletions

View File

@@ -38,6 +38,8 @@ class SystemConfModel(BaseModel):
douban: int = 0
# Bangumi请求缓存数量
bangumi: int = 0
# AniList请求缓存数量
anilist: int = 0
# Fanart请求缓存数量
fanart: int = 0
# 元数据缓存过期时间(秒)
@@ -197,11 +199,11 @@ class ConfigModel(BaseModel):
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
# ==================== 媒体元数据配置 ====================
# 媒体搜索来源 themoviedb/douban/bangumi多个用,分隔
# 媒体搜索来源 themoviedb/douban/bangumi/anilist,多个用,分隔
SEARCH_SOURCE: str = "themoviedb"
# 媒体识别来源 themoviedb/douban
# 媒体识别来源 themoviedb/douban/bangumi/anilist
RECOGNIZE_SOURCE: str = "themoviedb"
# 刮削来源 themoviedb/douban
# 刮削来源 themoviedb/douban/bangumi/anilist
SCRAP_SOURCE: str = "themoviedb"
# 电视剧动漫的分类genre_ids
ANIME_GENREIDS: List[int] = Field(default=[16])

View File

@@ -10,6 +10,7 @@ from app.schemas.types import MediaType
from app.utils.string import StringUtils
BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"})
ANILIST_MOVIE_FORMATS = frozenset({"MOVIE"})
@dataclass
@@ -251,8 +252,10 @@ class MediaInfo:
# 内部标记:是否命中本地识别缓存,不参与序列化
recognize_cache_hit = False
# 来源themoviedb、douban、bangumi
# 来源themoviedb、douban、bangumi、anilist
source: str = None
# 请求级刮削来源;为空时使用系统设置
scrape_source: str = None
# 类型 电影、电视剧
type: MediaType = None
# 媒体标题
@@ -279,6 +282,10 @@ class MediaInfo:
douban_id: str = None
# Bangumi ID
bangumi_id: int = None
# AniList ID
anilist_id: int = None
# AniDB IDAniList外部映射
anidb_id: int = None
# 合集ID
collection_id: int = None
# 媒体原语种
@@ -315,6 +322,8 @@ class MediaInfo:
douban_info: dict = field(default_factory=dict)
# Bangumi INFO
bangumi_info: dict = field(default_factory=dict)
# AniList INFO
anilist_info: dict = field(default_factory=dict)
# 导演
directors: List[dict] = field(default_factory=list)
# 演员
@@ -380,6 +389,8 @@ class MediaInfo:
self.set_douban_info(self.douban_info)
if self.bangumi_info:
self.set_bangumi_info(self.bangumi_info)
if self.anilist_info:
self.set_anilist_info(self.anilist_info)
def __setattr__(self, name: str, value: Any):
self.__dict__[name] = value
@@ -750,7 +761,7 @@ class MediaInfo:
self.source = "bangumi"
# 本体
self.bangumi_info = info
# 豆瓣ID
# Bangumi ID
self.bangumi_id = info.get("id")
# 类型
if not self.type:
@@ -804,13 +815,166 @@ class MediaInfo:
if self.type == MediaType.TV and not self.seasons:
meta = MetaInfo(self.title)
season = meta.begin_season if meta.begin_season is not None else 1
episodes_count = info.get("total_episodes")
episodes_count = info.get("total_episodes") or info.get("eps")
if episodes_count:
self.seasons[season] = list(range(1, episodes_count + 1))
self.number_of_episodes = episodes_count
self.number_of_seasons = 1
# 风格
if not self.genres:
self.genres = [
{"id": tag.get("name"), "name": tag.get("name")}
for tag in info.get("tags") or []
if tag.get("name")
]
# 制作公司与导演
if info.get("infobox"):
companies = []
directors = []
for item in info.get("infobox"):
values = item.get("value")
if not isinstance(values, list):
values = [values]
normalized_values = [
value.get("v") if isinstance(value, dict) else value
for value in values
if value
]
if item.get("key") in {"动画制作", "制作"}:
companies.extend({"name": value} for value in normalized_values)
elif item.get("key") == "导演":
directors.extend({"name": value} for value in normalized_values)
if companies and not self.production_companies:
self.production_companies = companies
if directors and not self.directors:
self.directors = directors
# 演员
if not self.actors:
self.actors = info.get("actors") or []
@staticmethod
def get_anilist_media_type(info: dict) -> MediaType:
"""
根据 AniList 发布格式获取标准媒体类型。
:param info: AniList 媒体信息
:return: 标准媒体类型
"""
return (
MediaType.MOVIE
if str(info.get("format") or "").upper() in ANILIST_MOVIE_FORMATS
else MediaType.TV
)
@staticmethod
def _anilist_date(date_info: dict) -> Optional[str]:
"""
将 AniList 模糊日期转换为标准日期文本。
:param date_info: AniList FuzzyDate 字段
:return: 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 = []

View File

@@ -23,7 +23,7 @@ def should_use_parent_title_for_file_stem(
"""
if not file_meta.isfile or not stem or not parent_dir_name:
return False
if file_meta.tmdbid or file_meta.doubanid:
if file_meta.tmdbid or file_meta.doubanid or file_meta.media_id:
return False
if not PARENT_LATIN_TITLE_RE.search(parent_dir_name):
return False

View File

@@ -97,6 +97,8 @@ class MetaBase(object):
# 附加信息
tmdbid: int = None
doubanid: str = None
media_source: Optional[str] = None
media_id: Optional[str] = None
episode_group: Optional[str] = None
# 帧率信息(纯数值)
fps: Optional[int] = None
@@ -683,6 +685,11 @@ class MetaBase(object):
# doubanid
if not self.doubanid and meta.doubanid:
self.doubanid = meta.doubanid
# 通用媒体来源与ID
if not self.media_source and meta.media_source:
self.media_source = meta.media_source
if not self.media_id and meta.media_id:
self.media_id = meta.media_id
# 剧集组
if not self.episode_group and meta.episode_group:
self.episode_group = meta.episode_group

View File

@@ -29,6 +29,8 @@ _ANIME_SQUARE_BRACKET_RE = re.compile(r'\[[+0-9XVPI-]+]\s*\[', re.IGNORECASE)
_BRACED_METAINFO_RE = re.compile(r'(?<={\[)[\W\w]+(?=]})')
_BRACED_TMDBID_RE = re.compile(r'(?<=tmdbid=)\d+')
_BRACED_DOUBANID_RE = re.compile(r'(?<=doubanid=)\d+')
_BRACED_BANGUMIID_RE = re.compile(r'(?<=bangumiid=)\d+')
_BRACED_ANILISTID_RE = re.compile(r'(?<=anilistid=)\d+')
_BRACED_TYPE_RE = re.compile(r'(?<=type=)\w+')
_BRACED_EPISODE_GROUP_RE = re.compile(r'(?:^|;)g=([0-9a-fA-F]+)(?=;|$)')
_BRACED_BEGIN_SEASON_RE = re.compile(r'(?<=s=)\d+')
@@ -41,6 +43,24 @@ _EMBY_TMDB_RE_LIST = (
re.compile(r'\{tmdbid[=\-](\d+)\}'),
re.compile(r'\{tmdb[=\-](\d+)\}'),
)
_EXTENDED_MEDIA_ID_RE_LIST = {
"bangumi": (
re.compile(r'\[bangumiid[=\-](\d+)\]'),
re.compile(r'\[bangumi[=\-](\d+)\]'),
re.compile(r'\{bangumiid[=\-](\d+)\}'),
re.compile(r'\{bangumi[=\-](\d+)\}'),
),
"anilist": (
re.compile(r'\[anilistid[=\-](\d+)\]'),
re.compile(r'\[anilist[=\-](\d+)\]'),
re.compile(r'\{anilistid[=\-](\d+)\}'),
re.compile(r'\{anilist[=\-](\d+)\}'),
),
}
_EXTENDED_MEDIA_ID_TAG_RE = re.compile(
r'(?:bangumi(?:id)?|anilist(?:id)?)[=\-]\d+',
re.IGNORECASE,
)
_RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key"
@@ -51,6 +71,10 @@ def _empty_metainfo() -> dict:
return {
'tmdbid': None,
'doubanid': None,
'bangumiid': None,
'anilistid': None,
'media_source': None,
'media_id': None,
'type': None,
'episode_group': None,
'begin_season': None,
@@ -115,6 +139,14 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
doubanid = _BRACED_DOUBANID_RE.search(result)
if doubanid and doubanid.group(0).isdigit():
metainfo['doubanid'] = doubanid.group(0)
# 查找Bangumi ID信息
bangumiid = _BRACED_BANGUMIID_RE.search(result)
if bangumiid and bangumiid.group(0).isdigit():
metainfo['bangumiid'] = bangumiid.group(0)
# 查找AniList ID信息
anilistid = _BRACED_ANILISTID_RE.search(result)
if anilistid and anilistid.group(0).isdigit():
metainfo['anilistid'] = anilistid.group(0)
# 查找媒体类型
mtype = _BRACED_TYPE_RE.search(result)
if mtype:
@@ -142,7 +174,18 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
if end_episode and end_episode.group(0).isdigit():
metainfo['end_episode'] = int(end_episode.group(0))
# 去除title中该部分
if tmdbid or mtype or episode_group or begin_season or end_season or begin_episode or end_episode:
if (
tmdbid
or doubanid
or bangumiid
or anilistid
or mtype
or episode_group
or begin_season
or end_season
or begin_episode
or end_episode
):
title = title.replace(f"{{[{result}]}}", '')
# 支持Emby格式的ID标签第一个 [tmdbid] 历史上始终优先处理,用于覆盖前面 {[...]} 中的旧标签。
@@ -159,6 +202,31 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
title = tmdb_re.sub('', title).strip()
break
for source, patterns in _EXTENDED_MEDIA_ID_RE_LIST.items():
key = f"{source}id"
if metainfo.get(key):
continue
for media_id_re in patterns:
media_id_match = media_id_re.search(title)
if not media_id_match:
continue
metainfo[key] = media_id_match.group(1)
title = media_id_re.sub('', title).strip()
break
if metainfo.get('tmdbid'):
metainfo['media_source'] = 'themoviedb'
metainfo['media_id'] = metainfo['tmdbid']
elif metainfo.get('doubanid'):
metainfo['media_source'] = 'douban'
metainfo['media_id'] = metainfo['doubanid']
elif metainfo.get('bangumiid'):
metainfo['media_source'] = 'bangumi'
metainfo['media_id'] = metainfo['bangumiid']
elif metainfo.get('anilistid'):
metainfo['media_source'] = 'anilist'
metainfo['media_id'] = metainfo['anilistid']
# 计算季集总数
_apply_range_total(metainfo, 'begin_season', 'end_season', 'total_season')
_apply_range_total(metainfo, 'begin_episode', 'end_episode', 'total_episode')
@@ -202,6 +270,10 @@ def _build_meta_info(
logger.warn("tmdbid 必须是数字")
if metainfo.get('doubanid'):
meta.doubanid = metainfo['doubanid']
if metainfo.get('media_source'):
meta.media_source = metainfo['media_source']
if metainfo.get('media_id'):
meta.media_id = str(metainfo['media_id'])
if metainfo.get('type'):
meta.type = MediaType(metainfo['type']) if isinstance(metainfo['type'], str) else metainfo['type']
if metainfo.get('episode_group'):
@@ -319,6 +391,8 @@ def _meta_from_rust(parsed: dict) -> Optional[MetaBase]:
"apply_words": parsed.get("apply_words") or [],
"tmdbid": parsed.get("tmdbid"),
"doubanid": parsed.get("doubanid"),
"media_source": parsed.get("media_source"),
"media_id": parsed.get("media_id"),
"episode_group": parsed.get("episode_group"),
"fps": parsed.get("fps"),
}
@@ -327,6 +401,24 @@ def _meta_from_rust(parsed: dict) -> Optional[MetaBase]:
return meta
def _requires_python_metainfo(
title: str,
custom_words: Optional[List[str]] = None,
) -> bool:
"""
判断标题或临时识别词是否包含当前Rust扩展尚未支持的数据源ID标签。
:param title: 原始标题
:param custom_words: 临时识别词
:return: 是否必须使用Python解析器
"""
candidates = [title or "", *(custom_words or [])]
contains_extended_id = any(
_EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
)
return contains_extended_id and not rust_accel.supports_extended_media_ids()
def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None) -> MetaBase:
"""
根据标题和副标题识别元数据
@@ -335,9 +427,11 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
:param custom_words: 自定义识别词列表
:return: MetaAnime、MetaVideo
"""
rust_meta = _meta_from_rust(
rust_accel.parse_metainfo(title, subtitle, _rust_parse_options(custom_words))
)
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))
)
if rust_meta:
return rust_meta
meta = _build_meta_info(title=title, subtitle=subtitle, custom_words=custom_words)
@@ -355,9 +449,14 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase:
:param path: 路径
:param custom_words: 自定义识别词列表
"""
rust_meta = _meta_from_rust(
rust_accel.parse_metainfo_path(str(path), _rust_parse_options(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))
)
if rust_meta:
return rust_meta
# 文件元数据,不包含后缀
@@ -400,7 +499,9 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
"""
从标题中提取媒体信息
"""
rust_result = rust_accel.find_metainfo(title)
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"]
return _find_metainfo_python(title)