mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-21 16:23:34 +08:00
refactor(media): unify source identity and music browsing
This commit is contained in:
@@ -8,7 +8,7 @@ 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
|
||||
from app.schemas.types import MediaRecognizeType, MediaSource, MediaType, ModuleType
|
||||
from app.utils.media import is_media_source_enabled
|
||||
|
||||
|
||||
@@ -61,14 +61,14 @@ class AniListModule(_ModuleBase):
|
||||
return 4
|
||||
|
||||
@staticmethod
|
||||
def _source_enabled(source: Optional[str]) -> bool:
|
||||
def _source_enabled(media_source: Optional[MediaSource]) -> bool:
|
||||
"""
|
||||
判断本次识别是否指定 AniList。
|
||||
|
||||
:param source: 请求级识别数据源
|
||||
:param media_source: 请求级识别数据源
|
||||
:return: 是否启用 AniList 识别
|
||||
"""
|
||||
return (source or settings.RECOGNIZE_SOURCE) == "anilist"
|
||||
return (media_source or settings.RECOGNIZE_SOURCE) == MediaSource.AniList
|
||||
|
||||
@staticmethod
|
||||
def _media_type(info: dict) -> MediaType:
|
||||
@@ -217,16 +217,16 @@ class AniListModule(_ModuleBase):
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
按 AniList ID 或标题识别动画媒体信息。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param anilistid: AniList 媒体 ID
|
||||
:param source: 请求级识别数据源
|
||||
:param media_source: 请求级识别数据源
|
||||
:param media_id: 数据源原生ID
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
# AniList 只处理动画影视,不能在音乐模块未响应时接管音乐请求。
|
||||
@@ -235,7 +235,14 @@ class AniListModule(_ModuleBase):
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not anilistid and (not meta or not self._source_enabled(source)):
|
||||
if media_source and media_source != MediaSource.AniList:
|
||||
return None
|
||||
if media_id is not None and (
|
||||
media_source != MediaSource.AniList or not str(media_id).isdigit()
|
||||
):
|
||||
return None
|
||||
anilistid = int(media_id) if media_id is not None else None
|
||||
if not anilistid and (not meta or not self._source_enabled(media_source)):
|
||||
return None
|
||||
info = self.anilist_api.detail(anilistid) if anilistid else self._match_by_meta(meta)
|
||||
if not info:
|
||||
@@ -252,16 +259,16 @@ class AniListModule(_ModuleBase):
|
||||
async def async_recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
anilistid: Optional[int] = None,
|
||||
source: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
异步按 AniList ID 或标题识别动画媒体信息。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param anilistid: AniList 媒体 ID
|
||||
:param source: 请求级识别数据源
|
||||
:param media_source: 请求级识别数据源
|
||||
:param media_id: 数据源原生ID
|
||||
:return: 统一媒体信息
|
||||
"""
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 AniList。
|
||||
@@ -270,7 +277,14 @@ class AniListModule(_ModuleBase):
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if not anilistid and (not meta or not self._source_enabled(source)):
|
||||
if media_source and media_source != MediaSource.AniList:
|
||||
return None
|
||||
if media_id is not None and (
|
||||
media_source != MediaSource.AniList or not str(media_id).isdigit()
|
||||
):
|
||||
return None
|
||||
anilistid = int(media_id) if media_id is not None else None
|
||||
if not anilistid and (not meta or not self._source_enabled(media_source)):
|
||||
return None
|
||||
info = (
|
||||
await self.anilist_api.async_detail(anilistid)
|
||||
@@ -313,16 +327,16 @@ class AniListModule(_ModuleBase):
|
||||
return None
|
||||
|
||||
def search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
self, meta: MetaBase, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索 AniList 动画媒体信息。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 统一媒体信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(source, "anilist"):
|
||||
if not is_media_source_enabled(media_source, "anilist"):
|
||||
return None
|
||||
if not meta or not meta.name:
|
||||
return []
|
||||
@@ -333,16 +347,16 @@ class AniListModule(_ModuleBase):
|
||||
]
|
||||
|
||||
async def async_search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
self, meta: MetaBase, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
异步搜索 AniList 动画媒体信息。
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 统一媒体信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(source, "anilist"):
|
||||
if not is_media_source_enabled(media_source, "anilist"):
|
||||
return None
|
||||
if not meta or not meta.name:
|
||||
return []
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 MediaRecognizeType, MediaType, ModuleType
|
||||
from app.schemas.types import MediaRecognizeType, MediaSource, MediaType, ModuleType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.media import is_media_source_enabled
|
||||
|
||||
@@ -82,15 +82,15 @@ class BangumiModule(_ModuleBase):
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
bangumiid: int = None,
|
||||
source: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param bangumiid: 识别的Bangumi ID
|
||||
:param source: 请求级识别数据源
|
||||
:param media_source: 请求级识别数据源
|
||||
:param media_id: 数据源原生ID
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# Bangumi 只处理影视,不能在音乐模块未响应时接管音乐请求。
|
||||
@@ -99,8 +99,15 @@ class BangumiModule(_ModuleBase):
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if media_source and media_source != MediaSource.Bangumi:
|
||||
return None
|
||||
if media_id is not None and (
|
||||
media_source != MediaSource.Bangumi or not str(media_id).isdigit()
|
||||
):
|
||||
return None
|
||||
bangumiid = int(media_id) if media_id is not None else None
|
||||
if not bangumiid and (
|
||||
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
|
||||
not meta or (media_source or settings.RECOGNIZE_SOURCE) != MediaSource.Bangumi
|
||||
):
|
||||
return None
|
||||
|
||||
@@ -124,15 +131,15 @@ class BangumiModule(_ModuleBase):
|
||||
async def async_recognize_media(
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
bangumiid: int = None,
|
||||
source: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param bangumiid: 识别的Bangumi ID
|
||||
:param source: 请求级识别数据源
|
||||
:param media_source: 请求级识别数据源
|
||||
:param media_id: 数据源原生ID
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 Bangumi。
|
||||
@@ -141,8 +148,15 @@ class BangumiModule(_ModuleBase):
|
||||
or getattr(meta, "type", None) == MediaType.MUSIC
|
||||
):
|
||||
return None
|
||||
if media_source and media_source != MediaSource.Bangumi:
|
||||
return None
|
||||
if media_id is not None and (
|
||||
media_source != MediaSource.Bangumi or not str(media_id).isdigit()
|
||||
):
|
||||
return None
|
||||
bangumiid = int(media_id) if media_id is not None else None
|
||||
if not bangumiid and (
|
||||
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
|
||||
not meta or (media_source or settings.RECOGNIZE_SOURCE) != MediaSource.Bangumi
|
||||
):
|
||||
return None
|
||||
|
||||
@@ -207,15 +221,15 @@ class BangumiModule(_ModuleBase):
|
||||
return None
|
||||
|
||||
def search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
self, meta: MetaBase, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 媒体信息
|
||||
"""
|
||||
if not is_media_source_enabled(source, "bangumi"):
|
||||
if not is_media_source_enabled(media_source, "bangumi"):
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
@@ -227,15 +241,15 @@ class BangumiModule(_ModuleBase):
|
||||
return []
|
||||
|
||||
async def async_search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
self, meta: MetaBase, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 媒体信息
|
||||
"""
|
||||
if not is_media_source_enabled(source, "bangumi"):
|
||||
if not is_media_source_enabled(media_source, "bangumi"):
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.schemas import MediaPerson, APIRateLimitException
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaSource,
|
||||
MediaType,
|
||||
ModuleType,
|
||||
MediaRecognizeType,
|
||||
@@ -34,7 +35,7 @@ from app.utils.zhconv import convert as zhconv_convert
|
||||
class DoubanModule(_ModuleBase):
|
||||
"""提供豆瓣影视与豆瓣音乐元数据识别能力。"""
|
||||
|
||||
_music_source = "doubanmusic"
|
||||
_music_source = MediaSource.DoubanMusic
|
||||
doubanapi: DoubanApi = None
|
||||
scraper: DoubanScraper = None
|
||||
|
||||
@@ -91,10 +92,10 @@ class DoubanModule(_ModuleBase):
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
) -> Optional[List[MusicInfo]]:
|
||||
"""按请求来源搜索豆瓣音乐专辑,并转换为统一音乐候选。"""
|
||||
if not is_media_source_selected(source, self._music_source):
|
||||
if not is_media_source_selected(media_source, self._music_source):
|
||||
return None
|
||||
keyword = meta.album or meta.title
|
||||
if not keyword:
|
||||
@@ -104,19 +105,19 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
def recognize_music(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""按豆瓣音乐原生 ID 和实体类型获取专辑或专辑内曲目详情。"""
|
||||
if source != self._music_source or not media_id:
|
||||
if media_source != self._music_source or not media_id:
|
||||
return None
|
||||
album_id, separator, track_id = str(media_id).partition(":")
|
||||
if music_type == MUSIC_ENTITY_RECORDING and not separator:
|
||||
return None
|
||||
if music_type == MUSIC_ENTITY_ALBUM and separator:
|
||||
return None
|
||||
album = self.music_album(source, album_id)
|
||||
album = self.music_album(media_source, album_id)
|
||||
if not album:
|
||||
return None
|
||||
if separator and track_id:
|
||||
@@ -129,39 +130,69 @@ class DoubanModule(_ModuleBase):
|
||||
)
|
||||
return album.to_music_info()
|
||||
|
||||
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
"""按豆瓣音乐专辑 ID 获取标准化专辑详情和曲目。"""
|
||||
if source != self._music_source or not media_id:
|
||||
if media_source != self._music_source or not media_id:
|
||||
return None
|
||||
info = self.doubanapi.music_detail(subject_id=str(media_id))
|
||||
return self._douban_music_to_album(info) if info else None
|
||||
|
||||
def music_discover(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
entity: str = MUSIC_ENTITY_ALBUM,
|
||||
country: str = "us",
|
||||
mode: str = "chart",
|
||||
tags: str = "",
|
||||
sort: str = "U",
|
||||
) -> Optional[List[MusicInfo]]:
|
||||
"""分页读取豆瓣音乐推荐合集,并保留豆瓣条目原生身份。"""
|
||||
if source != self._music_source:
|
||||
"""按官方新碟榜或标签交集浏览豆瓣音乐,并保留豆瓣条目原生身份。"""
|
||||
if media_source != self._music_source:
|
||||
return None
|
||||
del entity, country
|
||||
result = self.doubanapi.music_single(
|
||||
start=max(page - 1, 0) * max(1, count),
|
||||
count=max(1, count),
|
||||
)
|
||||
return self._build_music_search_results(result)
|
||||
del entity
|
||||
if mode == "chart":
|
||||
chart_items = self._build_music_search_results(self.doubanapi.music_chart())
|
||||
start = max(page - 1, 0) * max(1, count)
|
||||
return chart_items[start:start + max(1, count)]
|
||||
selected_tags = [tag.strip() for tag in str(tags or "").split(",") if tag.strip()]
|
||||
if not selected_tags:
|
||||
selected_tags = ["流行"]
|
||||
if len(selected_tags) == 1:
|
||||
result = self.doubanapi.music_tag(
|
||||
tag=selected_tags[0],
|
||||
start=max(page - 1, 0) * max(1, count),
|
||||
count=max(1, count),
|
||||
sort=sort,
|
||||
)
|
||||
return self._build_music_search_results(result)
|
||||
|
||||
# 豆瓣官网的多标签 URL 会按完整文本标签匹配;分别读取后按原生 ID
|
||||
# 求交集,才能实现风格与地区的真实组合筛选。
|
||||
scan_count = min(max(page * count * 4, 100), 300)
|
||||
tag_results = [
|
||||
self._build_music_search_results(
|
||||
self.doubanapi.music_tag(tag=tag, start=0, count=scan_count, sort=sort)
|
||||
)
|
||||
for tag in selected_tags
|
||||
]
|
||||
if not tag_results:
|
||||
return []
|
||||
shared_ids = set(item.media_id for item in tag_results[0])
|
||||
for items in tag_results[1:]:
|
||||
shared_ids.intersection_update(item.media_id for item in items)
|
||||
matched = [item for item in tag_results[0] if item.media_id in shared_ids]
|
||||
start = max(page - 1, 0) * max(1, count)
|
||||
return matched[start:start + max(1, count)]
|
||||
|
||||
def music_album_related(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
count: int = 24,
|
||||
) -> Optional[List[MusicInfo]]:
|
||||
"""按豆瓣音乐专辑 ID 返回相关推荐条目。"""
|
||||
if source != self._music_source or not media_id:
|
||||
if media_source != self._music_source or not media_id:
|
||||
return None
|
||||
result = self.doubanapi.music_recommendations(
|
||||
subject_id=str(media_id),
|
||||
@@ -173,24 +204,24 @@ class DoubanModule(_ModuleBase):
|
||||
def _recognize_music_media(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: Optional[str],
|
||||
mediaid: Optional[str],
|
||||
media_source: Optional[str],
|
||||
media_id: Optional[str],
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""执行豆瓣音乐详情识别或按专辑名称匹配。"""
|
||||
if source != self._music_source:
|
||||
if media_source != self._music_source:
|
||||
return None
|
||||
resolved_media_id = mediaid or (meta.media_id if meta else None)
|
||||
resolved_media_id = media_id or (meta.media_id if meta else None)
|
||||
if resolved_media_id:
|
||||
detail_kwargs = (
|
||||
{"music_type": music_type} if music_type is not None else {}
|
||||
)
|
||||
return self.recognize_music(
|
||||
source, str(resolved_media_id), **detail_kwargs
|
||||
media_source, str(resolved_media_id), **detail_kwargs
|
||||
)
|
||||
if not meta:
|
||||
return None
|
||||
candidates = self.search_music(meta=meta, limit=20, source=source) or []
|
||||
candidates = self.search_music(meta=meta, limit=20, media_source=media_source) or []
|
||||
expected_title = meta.album or meta.title
|
||||
for candidate in candidates:
|
||||
if not self._same_music_text(expected_title, candidate.title):
|
||||
@@ -204,7 +235,7 @@ class DoubanModule(_ModuleBase):
|
||||
if music_type == MUSIC_ENTITY_ALBUM:
|
||||
return candidate
|
||||
if meta.album and meta.title:
|
||||
album = self.music_album(source, candidate.media_id)
|
||||
album = self.music_album(media_source, candidate.media_id)
|
||||
matched_track = self._select_douban_music_track(meta, album)
|
||||
if matched_track:
|
||||
return matched_track
|
||||
@@ -217,14 +248,14 @@ class DoubanModule(_ModuleBase):
|
||||
async def _async_recognize_music_media(
|
||||
self,
|
||||
meta: Optional[MetaMusic],
|
||||
source: Optional[str],
|
||||
mediaid: Optional[str],
|
||||
media_source: Optional[str],
|
||||
media_id: Optional[str],
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步执行豆瓣音乐详情识别或按专辑名称匹配。"""
|
||||
if source != self._music_source:
|
||||
if media_source != self._music_source:
|
||||
return None
|
||||
resolved_media_id = mediaid or (meta.media_id if meta else None)
|
||||
resolved_media_id = media_id or (meta.media_id if meta else None)
|
||||
if resolved_media_id:
|
||||
album_id, separator, track_id = str(resolved_media_id).partition(":")
|
||||
if music_type == MUSIC_ENTITY_RECORDING and not separator:
|
||||
@@ -358,7 +389,7 @@ class DoubanModule(_ModuleBase):
|
||||
release_date = cls._douban_music_date(target)
|
||||
cover_url = cls._douban_music_cover(target)
|
||||
candidate = MusicInfo(
|
||||
source=cls._music_source,
|
||||
media_source=cls._music_source,
|
||||
media_id=media_id,
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title=title,
|
||||
@@ -371,6 +402,10 @@ class DoubanModule(_ModuleBase):
|
||||
cover_url=cover_url,
|
||||
names=[title],
|
||||
detail_link=f"https://music.douban.com/subject/{media_id}/",
|
||||
raw_data={
|
||||
"rating": cls._douban_music_float(target["rating"].get("value")),
|
||||
"rating_votes": cls._douban_music_int(target["rating"].get("count")),
|
||||
} if isinstance(target.get("rating"), dict) else {},
|
||||
)
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
@@ -392,7 +427,7 @@ class DoubanModule(_ModuleBase):
|
||||
genres = [str(item) for item in info.get("genres") or [] if item]
|
||||
rating = info.get("rating") if isinstance(info.get("rating"), dict) else {}
|
||||
album = MusicAlbumInfo(
|
||||
source=cls._music_source,
|
||||
media_source=cls._music_source,
|
||||
media_id=media_id,
|
||||
title=title,
|
||||
artists=artists,
|
||||
@@ -466,7 +501,7 @@ class DoubanModule(_ModuleBase):
|
||||
if not title:
|
||||
continue
|
||||
results.append(MusicInfo(
|
||||
source=cls._music_source,
|
||||
media_source=cls._music_source,
|
||||
# 豆瓣歌曲没有独立 subject ID,使用专辑内绝对顺序避免多碟曲序重复。
|
||||
media_id=f"{album.media_id}:{index}",
|
||||
title=title,
|
||||
@@ -647,7 +682,7 @@ class DoubanModule(_ModuleBase):
|
||||
if (
|
||||
meta
|
||||
and not doubanid
|
||||
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
|
||||
and (kwargs.get("media_source") or settings.RECOGNIZE_SOURCE) != "douban"
|
||||
):
|
||||
return None
|
||||
|
||||
@@ -718,7 +753,7 @@ class DoubanModule(_ModuleBase):
|
||||
if (
|
||||
meta
|
||||
and not doubanid
|
||||
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
|
||||
and (kwargs.get("media_source") or settings.RECOGNIZE_SOURCE) != "douban"
|
||||
):
|
||||
return None
|
||||
|
||||
@@ -770,63 +805,73 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
doubanid: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与doubanid配套
|
||||
:param doubanid: 豆瓣ID
|
||||
:param mtype: 识别的媒体类型
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
source = kwargs.get("source")
|
||||
if source == self._music_source:
|
||||
if media_source == self._music_source:
|
||||
return self._recognize_music_media(
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
source=source,
|
||||
mediaid=kwargs.get("mediaid"),
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=kwargs.get("music_type"),
|
||||
)
|
||||
# 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。
|
||||
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
|
||||
return None
|
||||
if media_source and media_source != MediaSource.Douban:
|
||||
return None
|
||||
doubanid = str(media_id) if media_id is not None else None
|
||||
return self._recognize_media_core(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
doubanid=doubanid,
|
||||
douban_info_func=self.douban_info,
|
||||
match_doubaninfo_func=self.match_doubaninfo,
|
||||
media_source=media_source,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
async def async_recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
doubanid: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与doubanid配套
|
||||
:param doubanid: 豆瓣ID
|
||||
:param mtype: 识别的媒体类型
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
source = kwargs.get("source")
|
||||
if source == self._music_source:
|
||||
if media_source == self._music_source:
|
||||
return await self._async_recognize_music_media(
|
||||
meta=meta if isinstance(meta, MetaMusic) else None,
|
||||
source=source,
|
||||
mediaid=kwargs.get("mediaid"),
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=kwargs.get("music_type"),
|
||||
)
|
||||
# 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。
|
||||
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
|
||||
return None
|
||||
if media_source and media_source != MediaSource.Douban:
|
||||
return None
|
||||
doubanid = str(media_id) if media_id is not None else None
|
||||
return await self._async_recognize_media_core(
|
||||
meta=meta,
|
||||
mtype=mtype,
|
||||
doubanid=doubanid,
|
||||
async_douban_info_func=self.async_douban_info,
|
||||
async_match_doubaninfo_func=self.async_match_doubaninfo,
|
||||
media_source=media_source,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -1380,15 +1425,15 @@ class DoubanModule(_ModuleBase):
|
||||
return []
|
||||
|
||||
def search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
self, meta: MetaBase, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 媒体信息
|
||||
"""
|
||||
if not is_media_source_enabled(source, "douban"):
|
||||
if not is_media_source_enabled(media_source, "douban"):
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
@@ -1399,15 +1444,15 @@ class DoubanModule(_ModuleBase):
|
||||
return self._build_search_medias_result(meta, result.get("items"))
|
||||
|
||||
async def async_search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
self, meta: MetaBase, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 媒体信息
|
||||
"""
|
||||
if not is_media_source_enabled(source, "douban"):
|
||||
if not is_media_source_enabled(media_source, "douban"):
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
@@ -1418,15 +1463,15 @@ class DoubanModule(_ModuleBase):
|
||||
return self._build_search_medias_result(meta, result.get("items"))
|
||||
|
||||
def search_persons(
|
||||
self, name: str, source: Optional[str] = None
|
||||
self, name: str, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaPerson]]:
|
||||
"""
|
||||
搜索人物信息
|
||||
:param name: 人物名称
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 人物信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(source, "douban"):
|
||||
if not is_media_source_enabled(media_source, "douban"):
|
||||
return None
|
||||
if not name:
|
||||
return []
|
||||
@@ -1443,15 +1488,15 @@ class DoubanModule(_ModuleBase):
|
||||
return []
|
||||
|
||||
async def async_search_persons(
|
||||
self, name: str, source: Optional[str] = None
|
||||
self, name: str, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaPerson]]:
|
||||
"""
|
||||
搜索人物信息(异步版本)
|
||||
:param name: 人物名称
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 人物信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(source, "douban"):
|
||||
if not is_media_source_enabled(media_source, "douban"):
|
||||
return None
|
||||
if not name:
|
||||
return []
|
||||
@@ -1637,7 +1682,7 @@ class DoubanModule(_ModuleBase):
|
||||
:param mediainfo: 媒体信息
|
||||
:return: None 表示不处理,MediaInfo 表示继续处理
|
||||
"""
|
||||
if mediainfo.source != "douban" and settings.RECOGNIZE_SOURCE != "douban":
|
||||
if mediainfo.media_source != MediaSource.Douban and settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
if not mediainfo.douban_id:
|
||||
return None
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
from datetime import datetime
|
||||
from random import choice
|
||||
from typing import Optional, Union
|
||||
@@ -9,6 +10,7 @@ from urllib import parse
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
@@ -17,6 +19,8 @@ from app.utils.singleton import WeakSingleton
|
||||
|
||||
|
||||
class DoubanApi(metaclass=WeakSingleton):
|
||||
"""封装豆瓣 Frodo API 与音乐官网公开浏览页面。"""
|
||||
|
||||
_urls = {
|
||||
# 搜索类
|
||||
# sort=U:近期热门 T:标记最多 S:评分最高 R:最新上映
|
||||
@@ -152,6 +156,7 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
_api_key2 = "0ab215a8b1977939201640fa14c66bab"
|
||||
_base_url = "https://frodo.douban.com/api/v2"
|
||||
_api_url = "https://api.douban.com/v2"
|
||||
_music_web_url = "https://music.douban.com"
|
||||
|
||||
def __init__(self):
|
||||
self._session = requests.Session()
|
||||
@@ -635,6 +640,133 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
self._urls["music_single"], start=start, count=count
|
||||
)
|
||||
|
||||
@cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True)
|
||||
def music_tag(
|
||||
self,
|
||||
tag: str,
|
||||
start: int = 0,
|
||||
count: int = 20,
|
||||
sort: str = "U",
|
||||
) -> dict:
|
||||
"""从豆瓣音乐官方标签页读取专辑,并适配为统一的音乐条目列表。"""
|
||||
normalized_tag = str(tag or "").strip()
|
||||
if not normalized_tag:
|
||||
return {"items": []}
|
||||
page_size = 20
|
||||
first_page = max(start, 0) // page_size
|
||||
first_offset = max(start, 0) % page_size
|
||||
required = first_offset + max(count, 1)
|
||||
items = []
|
||||
page = first_page
|
||||
while len(items) < required:
|
||||
url = f"{self._music_web_url}/tag/{parse.quote(normalized_tag, safe='')}"
|
||||
response = RequestUtils(
|
||||
ua=settings.NORMAL_USER_AGENT,
|
||||
proxies=settings.PROXY,
|
||||
timeout=20,
|
||||
accept_type="text/html,application/xhtml+xml",
|
||||
).get_res(url=url, params={"start": page * page_size, "type": sort})
|
||||
page_items = self._parse_music_tag_page(response.content if response else b"")
|
||||
if not page_items:
|
||||
break
|
||||
items.extend(page_items)
|
||||
if len(page_items) < page_size:
|
||||
break
|
||||
page += 1
|
||||
return {"items": items[first_offset:first_offset + max(count, 1)]}
|
||||
|
||||
@cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True)
|
||||
def music_chart(self) -> dict:
|
||||
"""从豆瓣音乐官方榜单页读取新碟榜,并补充专辑详情供卡片展示。"""
|
||||
response = RequestUtils(
|
||||
ua=settings.NORMAL_USER_AGENT,
|
||||
proxies=settings.PROXY,
|
||||
timeout=20,
|
||||
accept_type="text/html,application/xhtml+xml",
|
||||
).get_res(url=f"{self._music_web_url}/chart")
|
||||
chart_items = self._parse_music_chart_page(response.content if response else b"")
|
||||
items = []
|
||||
for chart_item in chart_items:
|
||||
detail = self.music_detail(subject_id=chart_item["id"])
|
||||
if isinstance(detail, dict) and detail:
|
||||
detail.setdefault("id", chart_item["id"])
|
||||
detail.setdefault("title", chart_item["title"])
|
||||
if chart_item.get("artists") and not detail.get("artists"):
|
||||
detail["artists"] = chart_item["artists"]
|
||||
items.append(detail)
|
||||
else:
|
||||
items.append(chart_item)
|
||||
return {"items": items}
|
||||
|
||||
@staticmethod
|
||||
def _parse_music_tag_page(content: bytes) -> list[dict]:
|
||||
"""解析豆瓣音乐标签页中的专辑 ID、封面、发行信息和评分。"""
|
||||
if not content:
|
||||
return []
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
items = []
|
||||
for row in soup.select("tr.item[id]"):
|
||||
media_id = str(row.get("id") or "").strip()
|
||||
title_link = row.select_one("div.pl2 > a[href*='/subject/']")
|
||||
if not media_id or not title_link:
|
||||
continue
|
||||
title = next(title_link.stripped_strings, "").strip()
|
||||
metadata = row.select_one("div.pl2 > p.pl")
|
||||
metadata_text = metadata.get_text(" ", strip=True) if metadata else ""
|
||||
parts = [part.strip() for part in metadata_text.split("/") if part.strip()]
|
||||
release_date = next(
|
||||
(part for part in parts if re.fullmatch(r"\d{4}(?:-\d{1,2}(?:-\d{1,2})?)?", part)),
|
||||
None,
|
||||
)
|
||||
image = row.select_one("a.nbg img")
|
||||
rating_node = row.select_one("span.rating_nums")
|
||||
votes_node = row.select_one("div.star span.pl")
|
||||
votes_match = re.search(r"(\d+)\s*人评价", votes_node.get_text(" ", strip=True) if votes_node else "")
|
||||
item = {
|
||||
"id": media_id,
|
||||
"type": "music",
|
||||
"title": title,
|
||||
"artists": [{"name": parts[0]}] if parts else [],
|
||||
"pubdate": [release_date] if release_date else [],
|
||||
"year": release_date[:4] if release_date else None,
|
||||
"cover_url": image.get("src") if image else None,
|
||||
"rating": {
|
||||
"value": rating_node.get_text(strip=True) if rating_node else None,
|
||||
"count": votes_match.group(1) if votes_match else None,
|
||||
},
|
||||
}
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _parse_music_chart_page(content: bytes) -> list[dict]:
|
||||
"""解析豆瓣新碟榜的专辑 ID、标题和艺术家。"""
|
||||
if not content:
|
||||
return []
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
heading = next(
|
||||
(node for node in soup.select("h2") if "豆瓣新碟榜" in node.get_text()),
|
||||
None,
|
||||
)
|
||||
container = heading.parent if heading else None
|
||||
items = []
|
||||
for row in container.select("ul.col3 li") if container else []:
|
||||
link = row.select_one("p.entry a[href*='/subject/']")
|
||||
if not link:
|
||||
continue
|
||||
match = re.search(r"/subject/(\d+)", str(link.get("href") or ""))
|
||||
if not match:
|
||||
continue
|
||||
entry_text = row.select_one("p.entry").get_text(" ", strip=True)
|
||||
artist = entry_text.split("/", 1)[1].strip() if "/" in entry_text else ""
|
||||
items.append({
|
||||
"id": match.group(1),
|
||||
"type": "music",
|
||||
"title": link.get_text(" ", strip=True),
|
||||
"artists": [{"name": artist}] if artist else [],
|
||||
})
|
||||
return items
|
||||
|
||||
def music_recommendations(
|
||||
self,
|
||||
subject_id: str,
|
||||
|
||||
@@ -176,7 +176,8 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
)
|
||||
movies = s.get_movies(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id)
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
continue
|
||||
@@ -191,7 +192,8 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
else:
|
||||
itemid, tvs = s.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id,
|
||||
item_id=itemid)
|
||||
if not tvs:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
|
||||
@@ -9,10 +9,10 @@ from requests import Response
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.helper.mediaserver import MusicMediaServerHelper
|
||||
from app.helper.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaServerItem
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
@@ -380,12 +380,14 @@ class Emby:
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
"""
|
||||
根据标题和年份,检查电影是否在Emby中存在,存在则返回列表
|
||||
:param title: 标题
|
||||
:param year: 年份,可以为空,为空时不按年份过滤
|
||||
:param tmdb_id: TMDB ID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:return: 含title、year属性的字典列表
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
@@ -412,7 +414,9 @@ class Emby:
|
||||
continue
|
||||
mediaserver_item = self.__format_item_info(item)
|
||||
if mediaserver_item:
|
||||
if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \
|
||||
if MediaServerIdentityHelper.is_compatible(
|
||||
mediaserver_item, media_source, media_id
|
||||
) and \
|
||||
mediaserver_item.title == title and \
|
||||
(not year or str(mediaserver_item.year) == str(year)):
|
||||
ret_movies.append(mediaserver_item)
|
||||
@@ -454,7 +458,8 @@ class Emby:
|
||||
item_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
|
||||
"""
|
||||
@@ -462,7 +467,8 @@ class Emby:
|
||||
:param item_id: Emby中的ID
|
||||
:param title: 标题
|
||||
:param year: 年份
|
||||
:param tmdb_id: TMDBID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:param season: 季
|
||||
:return: 每一季的已有集数
|
||||
"""
|
||||
@@ -476,7 +482,7 @@ class Emby:
|
||||
return None, None
|
||||
if not item_id:
|
||||
return None, {}
|
||||
# 验证tmdbid是否相同
|
||||
# 校验媒体服务器返回的主身份是否与目标冲突
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if not item_info and cached_item_id and title:
|
||||
# 媒体删除后重新入库会导致缓存ID失效,回退到标题搜索避免误判整部剧缺失。
|
||||
@@ -489,10 +495,8 @@ class Emby:
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if not item_info:
|
||||
return None, {}
|
||||
if item_info:
|
||||
if tmdb_id and item_info.tmdbid:
|
||||
if str(tmdb_id) != str(item_info.tmdbid):
|
||||
return None, {}
|
||||
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
|
||||
return None, {}
|
||||
# 查集的信息
|
||||
if season is None:
|
||||
season = None
|
||||
@@ -740,7 +744,9 @@ class Emby:
|
||||
play_count=item.get("UserData", {}).get("PlayCount"),
|
||||
percentage=item.get("UserData", {}).get("PlayedPercentage"),
|
||||
)
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
media_source, media_id = MediaServerIdentityHelper.from_provider_ids(
|
||||
item.get("ProviderIds")
|
||||
)
|
||||
return schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=item.get("ParentId"),
|
||||
@@ -750,9 +756,8 @@ class Emby:
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
path=item.get("Path"),
|
||||
note=MusicMediaServerHelper.build_note(item)
|
||||
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
|
||||
|
||||
@@ -16,6 +16,8 @@ from app.modules.indexer.spider.rousi import RousiSpider
|
||||
from app.modules.indexer.spider.sunnypt import SunnyPTSpider
|
||||
from app.modules.indexer.spider.tnode import TNodeSpider
|
||||
from app.modules.indexer.spider.torrentleech import TorrentLeech
|
||||
from app.schemas.types import MediaSource
|
||||
from app.utils.media import resolve_media_identity
|
||||
from app.modules.indexer.spider.yema import YemaSpider
|
||||
from app.schemas import SiteUserData
|
||||
from app.schemas.types import MediaType, ModuleType, OtherModulesType
|
||||
@@ -156,14 +158,26 @@ class IndexerModule(_ModuleBase):
|
||||
return []
|
||||
logger.info(
|
||||
f"{site.get('name')} 搜索完成,耗时 {seconds} 秒,返回数据:{len(result_array)}")
|
||||
return [TorrentInfo(site=site.get("id"),
|
||||
site_name=site.get("name"),
|
||||
site_cookie=site.get("cookie"),
|
||||
site_ua=site.get("ua"),
|
||||
site_proxy=site.get("proxy"),
|
||||
site_order=site.get("pri"),
|
||||
site_downloader=site.get("downloader"),
|
||||
**result) for result in result_array]
|
||||
torrents = []
|
||||
for result in result_array:
|
||||
result = dict(result)
|
||||
legacy_imdb_id = result.pop("imdbid", None)
|
||||
media_source, media_id = resolve_media_identity(media=result)
|
||||
if not media_source and legacy_imdb_id:
|
||||
media_source, media_id = MediaSource.IMDb, str(legacy_imdb_id)
|
||||
result["media_source"] = media_source
|
||||
result["media_id"] = media_id
|
||||
torrents.append(TorrentInfo(
|
||||
site=site.get("id"),
|
||||
site_name=site.get("name"),
|
||||
site_cookie=site.get("cookie"),
|
||||
site_ua=site.get("ua"),
|
||||
site_proxy=site.get("proxy"),
|
||||
site_order=site.get("pri"),
|
||||
site_downloader=site.get("downloader"),
|
||||
**result,
|
||||
))
|
||||
return torrents
|
||||
|
||||
@staticmethod
|
||||
def __parse_subtitle_result(site: dict, result_array: list, seconds: int) -> List[SubtitleInfo]:
|
||||
|
||||
@@ -175,7 +175,10 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
|
||||
server=name,
|
||||
itemid=movie.item_id
|
||||
)
|
||||
movies = s.get_movies(title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id)
|
||||
movies = s.get_movies(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
continue
|
||||
@@ -190,7 +193,8 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
|
||||
else:
|
||||
itemid, tvs = s.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id,
|
||||
item_id=itemid)
|
||||
if not tvs:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
|
||||
@@ -7,9 +7,10 @@ from requests import Response
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.helper.mediaserver import MusicMediaServerHelper
|
||||
from app.helper.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.types import MediaSource
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from app.schemas import MediaServerItem
|
||||
@@ -437,12 +438,14 @@ class Jellyfin:
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
"""
|
||||
根据标题和年份,检查电影是否在Jellyfin中存在,存在则返回列表
|
||||
:param title: 标题
|
||||
:param year: 年份,为空则不过滤
|
||||
:param tmdb_id: TMDB ID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:return: 含title、year属性的字典列表
|
||||
"""
|
||||
if not self._host or not self._apikey or not self.user:
|
||||
@@ -468,7 +471,9 @@ class Jellyfin:
|
||||
continue
|
||||
mediaserver_item = self.__format_item_info(item)
|
||||
if mediaserver_item:
|
||||
if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \
|
||||
if MediaServerIdentityHelper.is_compatible(
|
||||
mediaserver_item, media_source, media_id
|
||||
) and \
|
||||
mediaserver_item.title == title and \
|
||||
(not year or str(mediaserver_item.year) == str(year)):
|
||||
ret_movies.append(mediaserver_item)
|
||||
@@ -510,14 +515,16 @@ class Jellyfin:
|
||||
item_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]:
|
||||
"""
|
||||
根据标题和年份和季,返回Jellyfin中的剧集列表
|
||||
:param item_id: Jellyfin中的Id
|
||||
:param title: 标题
|
||||
:param year: 年份
|
||||
:param tmdb_id: TMDBID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:param season: 季
|
||||
:return: 集号的列表
|
||||
"""
|
||||
@@ -531,7 +538,7 @@ class Jellyfin:
|
||||
return None, None
|
||||
if not item_id:
|
||||
return None, {}
|
||||
# 验证tmdbid是否相同
|
||||
# 校验媒体服务器返回的主身份是否与目标冲突
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if not item_info and cached_item_id and title:
|
||||
# 媒体删除后重新入库会导致缓存ID失效,回退到标题搜索避免误判整部剧缺失。
|
||||
@@ -544,10 +551,8 @@ class Jellyfin:
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if not item_info:
|
||||
return None, {}
|
||||
if item_info:
|
||||
if tmdb_id and item_info.tmdbid:
|
||||
if str(tmdb_id) != str(item_info.tmdbid):
|
||||
return None, {}
|
||||
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
|
||||
return None, {}
|
||||
if season is None:
|
||||
season = None
|
||||
url = f"{self._host}Shows/{item_id}/Episodes"
|
||||
@@ -894,7 +899,9 @@ class Jellyfin:
|
||||
play_count=item.get("UserData", {}).get("PlayCount"),
|
||||
percentage=item.get("UserData", {}).get("PlayedPercentage"),
|
||||
)
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
media_source, media_id = MediaServerIdentityHelper.from_provider_ids(
|
||||
item.get("ProviderIds")
|
||||
)
|
||||
return schemas.MediaServerItem(
|
||||
server="jellyfin",
|
||||
library=item.get("ParentId"),
|
||||
@@ -903,9 +910,8 @@ class Jellyfin:
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
path=item.get("Path"),
|
||||
note=MusicMediaServerHelper.build_note(item)
|
||||
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.modules import _ModuleBase
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaSource,
|
||||
ModuleType,
|
||||
OtherModulesType,
|
||||
)
|
||||
@@ -43,7 +44,7 @@ class ListenBrainzModule(_ModuleBase):
|
||||
_album_detail_url = "https://musicbrainz.org/release-group"
|
||||
_release_cover_url = "https://coverartarchive.org/release"
|
||||
_release_group_cover_url = "https://coverartarchive.org/release-group"
|
||||
_source = "musicbrainz"
|
||||
_source = MediaSource.MusicBrainz
|
||||
# 全站统计按实体分为不同接口,键为音乐实体类型,值为接口路径与数据字段
|
||||
_chart_entities = {
|
||||
MUSIC_ENTITY_RECORDING: ("recordings", "recordings"),
|
||||
@@ -215,7 +216,7 @@ class ListenBrainzModule(_ModuleBase):
|
||||
release_name = str(recording.get("release_name") or "").strip()
|
||||
release_mbid = recording.get("caa_release_mbid") or recording.get("release_mbid")
|
||||
return MusicInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=str(media_id),
|
||||
music_type=MUSIC_ENTITY_RECORDING,
|
||||
title=str(title),
|
||||
@@ -238,7 +239,7 @@ class ListenBrainzModule(_ModuleBase):
|
||||
return None
|
||||
artist_name = str(release_group.get("artist_name") or "").strip()
|
||||
return MusicInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=str(media_id),
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title=str(title),
|
||||
@@ -269,7 +270,7 @@ class ListenBrainzModule(_ModuleBase):
|
||||
release.get("release_group_secondary_type"),
|
||||
]
|
||||
return MusicInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=str(media_id),
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title=str(title),
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.schemas.types import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaRecognizeType,
|
||||
MediaSource,
|
||||
MediaType,
|
||||
ModuleType,
|
||||
)
|
||||
@@ -34,7 +35,7 @@ from app.utils.zhconv import convert as zhconv_convert
|
||||
class MusicBrainzModule(_ModuleBase):
|
||||
"""通过 MusicBrainz 提供音乐元数据搜索和详情识别。"""
|
||||
|
||||
_source = "musicbrainz"
|
||||
_source = MediaSource.MusicBrainz
|
||||
_base_url = "https://musicbrainz.org/ws/2"
|
||||
_detail_url = "https://musicbrainz.org/recording"
|
||||
_album_detail_url = "https://musicbrainz.org/release-group"
|
||||
@@ -145,10 +146,10 @@ class MusicBrainzModule(_ModuleBase):
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
) -> Optional[list[MusicInfo]]:
|
||||
"""搜索单曲、专辑和艺术家,并交错返回可浏览的 MusicBrainz 候选。"""
|
||||
if not is_media_source_selected(source, self._source):
|
||||
if not is_media_source_selected(media_source, self._source):
|
||||
return None
|
||||
normalized_limit = max(1, min(limit, 100))
|
||||
recordings = self._search_recordings(meta, limit=normalized_limit)
|
||||
@@ -720,7 +721,7 @@ class MusicBrainzModule(_ModuleBase):
|
||||
group_id = release_group.get("id")
|
||||
artists, artist_ids = cls._artist_credits(detail.get("artist-credit"))
|
||||
album = MusicAlbumInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
# 优先使用 Release Group ID,与专辑详情和封面入口保持一致
|
||||
media_id=str(group_id or release_id),
|
||||
title=str(title),
|
||||
@@ -746,31 +747,31 @@ class MusicBrainzModule(_ModuleBase):
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""跟随统一媒体识别分发,仅在音乐类型请求下返回 MusicBrainz 识别结果。"""
|
||||
music_type = kwargs.get("music_type")
|
||||
# 显式选择其它音乐源时必须让出识别管线,且不能复用 MusicBrainz 缓存。
|
||||
if source and source != self._source:
|
||||
if media_source and media_source != self._source:
|
||||
return None
|
||||
# 非音乐请求交给影视识别模块,不占用识别管线
|
||||
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
|
||||
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and media_source != self._source:
|
||||
return None
|
||||
# 无 MetaMusic 元数据时仅响应本数据源的详情识别请求
|
||||
if not isinstance(meta, MetaMusic):
|
||||
if source == self._source and mediaid:
|
||||
if media_source == self._source and media_id:
|
||||
detail_kwargs = (
|
||||
{"music_type": music_type} if music_type is not None else {}
|
||||
)
|
||||
return self.recognize_music(
|
||||
source, str(mediaid), **detail_kwargs
|
||||
media_source, str(media_id), **detail_kwargs
|
||||
)
|
||||
return None
|
||||
# 显式身份只允许按该 ID 和实体类型读取,失败后不能按标题替换成其它目标。
|
||||
resolved_source = source or meta.media_source
|
||||
resolved_media_id = mediaid or meta.media_id
|
||||
resolved_source = media_source or meta.media_source
|
||||
resolved_media_id = media_id or meta.media_id
|
||||
if resolved_source and resolved_media_id:
|
||||
detail_kwargs = (
|
||||
{"music_type": music_type} if music_type is not None else {}
|
||||
@@ -801,7 +802,7 @@ class MusicBrainzModule(_ModuleBase):
|
||||
# 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兑底
|
||||
# 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。
|
||||
candidates = self._search_recordings(meta, limit=10)
|
||||
matched = self._select_candidate(meta, candidates, source=resolved_source or self._source)
|
||||
matched = self._select_candidate(meta, candidates, media_source=resolved_source or self._source)
|
||||
# 整专/单曲发行类资源在 Recording 检索无果时,回退按专辑实体识别;
|
||||
# 专辑挑选要求标题与艺术家同时命中,无艺术家线索时回退检索必然无果,
|
||||
# 直接跳过避免浪费限流配额(批量识别场景可减少约半数请求)
|
||||
@@ -828,7 +829,7 @@ class MusicBrainzModule(_ModuleBase):
|
||||
return None
|
||||
if not isinstance(meta, MetaMusic) or not isinstance(mediainfo, MusicInfo):
|
||||
return None
|
||||
if mediainfo.source != self._source:
|
||||
if mediainfo.media_source != self._source:
|
||||
return None
|
||||
self._update_recognize_cache(meta, mediainfo)
|
||||
return True
|
||||
@@ -845,26 +846,26 @@ class MusicBrainzModule(_ModuleBase):
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步识别 MusicBrainz 音乐详情或按元数据匹配单曲。"""
|
||||
music_type = kwargs.get("music_type")
|
||||
if source and source != self._source:
|
||||
if media_source and media_source != self._source:
|
||||
return None
|
||||
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
|
||||
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and media_source != self._source:
|
||||
return None
|
||||
if not isinstance(meta, MetaMusic):
|
||||
if source == self._source and mediaid:
|
||||
if media_source == self._source and media_id:
|
||||
return await self.async_recognize_music(
|
||||
source,
|
||||
str(mediaid),
|
||||
media_source,
|
||||
str(media_id),
|
||||
music_type=music_type,
|
||||
)
|
||||
return None
|
||||
resolved_source = source or meta.media_source
|
||||
resolved_media_id = mediaid or meta.media_id
|
||||
resolved_source = media_source or meta.media_source
|
||||
resolved_media_id = media_id or meta.media_id
|
||||
if resolved_source and resolved_media_id:
|
||||
info = await self.async_recognize_music(
|
||||
resolved_source,
|
||||
@@ -891,7 +892,7 @@ class MusicBrainzModule(_ModuleBase):
|
||||
matched = self._select_candidate(
|
||||
meta,
|
||||
candidates,
|
||||
source=resolved_source or self._source,
|
||||
media_source=resolved_source or self._source,
|
||||
)
|
||||
if not matched and meta.artists and music_type != MUSIC_ENTITY_RECORDING:
|
||||
albums = await self._async_search_albums(meta, limit=10)
|
||||
@@ -901,9 +902,9 @@ class MusicBrainzModule(_ModuleBase):
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], source: str) -> Optional[MusicInfo]:
|
||||
def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], media_source: str) -> Optional[MusicInfo]:
|
||||
"""按标题、艺术家和专辑匹配度选择最可信的搜索候选。"""
|
||||
normalized_source = cls._normalize_text(source).casefold()
|
||||
normalized_source = cls._normalize_text(media_source).casefold()
|
||||
# 资源标题携带的音质标记先剥离,再与候选曲名比对;
|
||||
# 曲名开头的艺术家署名前缀是命名习惯,用主体名比对
|
||||
clean_title = cls._strip_artist_prefix(cls._search_title(meta.title), meta.artists)
|
||||
@@ -913,7 +914,7 @@ class MusicBrainzModule(_ModuleBase):
|
||||
bare_title = cls._strip_volume_suffix(cls._strip_parenthetical(clean_title))
|
||||
ranked: list[tuple[int, MusicInfo]] = []
|
||||
for candidate in candidates:
|
||||
if normalized_source and (candidate.source or "").casefold() != normalized_source:
|
||||
if normalized_source and str(candidate.media_source or "").casefold() != normalized_source:
|
||||
continue
|
||||
score = 0
|
||||
title_match = False
|
||||
@@ -1143,12 +1144,12 @@ class MusicBrainzModule(_ModuleBase):
|
||||
|
||||
def recognize_music(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""按 MusicBrainz 标准 ID 和实体类型获取详情;空类型保留旧版探测顺序。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
if music_type != MUSIC_ENTITY_ALBUM:
|
||||
payload = self._request_json(
|
||||
@@ -1163,17 +1164,17 @@ class MusicBrainzModule(_ModuleBase):
|
||||
if music_type == MUSIC_ENTITY_RECORDING:
|
||||
return None
|
||||
# MusicBrainz 各实体共用 UUID 形式,统一详情入口在 Recording 未命中后继续探测专辑。
|
||||
album = self.music_album(source, media_id)
|
||||
album = self.music_album(media_source, media_id)
|
||||
return album.to_music_info() if album else None
|
||||
|
||||
async def async_recognize_music(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步按 MusicBrainz 标准 ID 和实体类型获取详情。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
if music_type != MUSIC_ENTITY_ALBUM:
|
||||
payload = await self._async_request_json(
|
||||
@@ -1187,16 +1188,16 @@ class MusicBrainzModule(_ModuleBase):
|
||||
return self._recording_to_info(payload)
|
||||
if music_type == MUSIC_ENTITY_RECORDING:
|
||||
return None
|
||||
album = await self._async_music_album(source, media_id)
|
||||
album = await self._async_music_album(media_source, media_id)
|
||||
return album.to_music_info() if album else None
|
||||
|
||||
async def _async_music_album(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
) -> Optional[MusicAlbumInfo]:
|
||||
"""异步按 MusicBrainz Release Group ID 获取专辑详情及曲目。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
payload = await self._async_request_json(
|
||||
f"/release-group/{media_id}",
|
||||
@@ -1217,9 +1218,9 @@ class MusicBrainzModule(_ModuleBase):
|
||||
)
|
||||
return album
|
||||
|
||||
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
"""按 MusicBrainz Release Group ID 获取标准化专辑详情及曲目。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json(
|
||||
f"/release-group/{media_id}",
|
||||
@@ -1237,9 +1238,9 @@ class MusicBrainzModule(_ModuleBase):
|
||||
album.tracks = self._album_tracks(album, payload.get("releases") or [])
|
||||
return album
|
||||
|
||||
def music_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
|
||||
def music_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]:
|
||||
"""按 MusicBrainz Artist ID 获取标准化艺术家详情。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json(
|
||||
f"/artist/{media_id}",
|
||||
@@ -1249,14 +1250,14 @@ class MusicBrainzModule(_ModuleBase):
|
||||
|
||||
def music_artist_albums(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
album_type: Optional[str] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""按 MusicBrainz Artist ID 分页浏览该艺术家的专辑、EP 和单曲。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return []
|
||||
limit = max(1, min(count, 100))
|
||||
params: dict[str, Any] = {
|
||||
@@ -1280,12 +1281,12 @@ class MusicBrainzModule(_ModuleBase):
|
||||
|
||||
def music_artist_related(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
count: int = 24,
|
||||
) -> list[MusicArtistInfo]:
|
||||
"""按 MusicBrainz 艺术家关系返回可继续浏览的关联艺术家。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return []
|
||||
payload = self._request_json(
|
||||
f"/artist/{media_id}",
|
||||
@@ -1374,7 +1375,7 @@ class MusicBrainzModule(_ModuleBase):
|
||||
category_parts = [release_group.get("primary-type")]
|
||||
category_parts.extend(release_group.get("secondary-types") or [])
|
||||
return MusicInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=str(media_id),
|
||||
title=str(title),
|
||||
artists=artists,
|
||||
@@ -1406,7 +1407,7 @@ class MusicBrainzModule(_ModuleBase):
|
||||
artists, artist_ids = cls._artist_credits(release_group.get("artist-credit"))
|
||||
rating = release_group.get("rating") or {}
|
||||
return MusicAlbumInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=str(media_id),
|
||||
title=str(title),
|
||||
artists=artists,
|
||||
@@ -1549,7 +1550,7 @@ class MusicBrainzModule(_ModuleBase):
|
||||
track.get("artist-credit") or recording.get("artist-credit")
|
||||
)
|
||||
return MusicInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=str(media_id),
|
||||
title=str(title),
|
||||
artists=artists or list(album.artists),
|
||||
@@ -1636,7 +1637,7 @@ class MusicBrainzModule(_ModuleBase):
|
||||
begin_area = artist.get("begin-area") or {}
|
||||
relations = artist.get("relations") or []
|
||||
return MusicArtistInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=str(media_id),
|
||||
name=str(name),
|
||||
sort_name=artist.get("sort-name") or None,
|
||||
|
||||
@@ -187,7 +187,8 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
|
||||
movies = s.get_movies(title=mediainfo.title,
|
||||
original_title=mediainfo.original_title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id)
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
continue
|
||||
@@ -203,7 +204,8 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
|
||||
item_id, tvs = s.get_tv_episodes(title=mediainfo.title,
|
||||
original_title=mediainfo.original_title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id,
|
||||
item_id=itemid)
|
||||
if not tvs:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
|
||||
@@ -10,8 +10,10 @@ from requests import Response, Session
|
||||
|
||||
from app import schemas
|
||||
from app.core.cache import cached
|
||||
from app.helper.mediaserver import MediaServerIdentityHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.types import MediaSource
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from app.schemas import MediaServerItem
|
||||
@@ -196,13 +198,15 @@ class Plex:
|
||||
title: str,
|
||||
original_title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
"""
|
||||
根据标题和年份,检查电影是否在Plex中存在,存在则返回列表
|
||||
:param title: 标题
|
||||
:param original_title: 原产地标题
|
||||
:param year: 年份,为空则不过滤
|
||||
:param tmdb_id: TMDB ID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:return: 含title、year属性的字典列表
|
||||
"""
|
||||
if not self._plex:
|
||||
@@ -225,9 +229,11 @@ class Plex:
|
||||
libtype="movie"))
|
||||
for item in set(movies):
|
||||
ids = self.__get_ids(item.guids)
|
||||
if tmdb_id and ids['tmdb_id']:
|
||||
if str(ids['tmdb_id']) != str(tmdb_id):
|
||||
continue
|
||||
item_source, item_media_id = MediaServerIdentityHelper.from_provider_ids(ids)
|
||||
if not MediaServerIdentityHelper.are_compatible(
|
||||
item_source, item_media_id, media_source, media_id
|
||||
):
|
||||
continue
|
||||
path = None
|
||||
if item.locations:
|
||||
path = item.locations[0]
|
||||
@@ -240,9 +246,8 @@ class Plex:
|
||||
title=item.title,
|
||||
original_title=item.originalTitle,
|
||||
year=item.year,
|
||||
tmdbid=ids['tmdb_id'],
|
||||
imdbid=ids['imdb_id'],
|
||||
tvdbid=ids['tvdb_id'],
|
||||
media_source=item_source,
|
||||
media_id=item_media_id,
|
||||
path=path,
|
||||
)
|
||||
)
|
||||
@@ -295,7 +300,8 @@ class Plex:
|
||||
title: Optional[str] = None,
|
||||
original_title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]:
|
||||
"""
|
||||
根据标题、年份、季查询电视剧所有集信息
|
||||
@@ -303,7 +309,8 @@ class Plex:
|
||||
:param title: 标题
|
||||
:param original_title: 原产地标题
|
||||
:param year: 年份,可以为空,为空时不按年份过滤
|
||||
:param tmdb_id: TMDB ID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:param season: 季号,数字
|
||||
:return: 所有集的列表
|
||||
"""
|
||||
@@ -327,10 +334,13 @@ class Plex:
|
||||
return None, {}
|
||||
if isinstance(videos, list):
|
||||
videos = videos[0]
|
||||
video_tmdbid = self.__get_ids(videos.guids).get('tmdb_id')
|
||||
if tmdb_id and video_tmdbid:
|
||||
if str(video_tmdbid) != str(tmdb_id):
|
||||
return None, {}
|
||||
video_source, video_media_id = MediaServerIdentityHelper.from_provider_ids(
|
||||
self.__get_ids(videos.guids)
|
||||
)
|
||||
if not MediaServerIdentityHelper.are_compatible(
|
||||
video_source, video_media_id, media_source, media_id
|
||||
):
|
||||
return None, {}
|
||||
episodes = videos.episodes()
|
||||
season_episodes = {}
|
||||
for episode in episodes:
|
||||
@@ -642,9 +652,8 @@ class Plex:
|
||||
title=item.title,
|
||||
original_title=item.originalTitle,
|
||||
year=item.year,
|
||||
tmdbid=ids.get("tmdb_id"),
|
||||
imdbid=ids.get("imdb_id"),
|
||||
tvdbid=ids.get("tvdb_id"),
|
||||
media_source=MediaServerIdentityHelper.from_provider_ids(ids)[0],
|
||||
media_id=MediaServerIdentityHelper.from_provider_ids(ids)[1],
|
||||
path=path,
|
||||
user_state=user_state,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.schemas.types import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaRecognizeType,
|
||||
MediaSource,
|
||||
MediaType,
|
||||
ModuleType,
|
||||
)
|
||||
@@ -24,7 +25,7 @@ from app.utils.media import is_media_source_selected
|
||||
class TheAudioDbModule(_ModuleBase):
|
||||
"""通过 TheAudioDB V1 API 提供音乐搜索、详情和手动识别能力。"""
|
||||
|
||||
_source = "theaudiodb"
|
||||
_source = MediaSource.TheAudioDB
|
||||
_base_url = "https://www.theaudiodb.com/api/v1/json"
|
||||
_detail_url = "https://www.theaudiodb.com"
|
||||
|
||||
@@ -72,10 +73,10 @@ class TheAudioDbModule(_ModuleBase):
|
||||
self,
|
||||
meta: MetaMusic,
|
||||
limit: int = 20,
|
||||
source: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
) -> Optional[list[MusicInfo]]:
|
||||
"""按请求来源搜索 TheAudioDB 单曲、专辑和艺术家。"""
|
||||
if not is_media_source_selected(source, self._source):
|
||||
if not is_media_source_selected(media_source, self._source):
|
||||
return None
|
||||
normalized_limit = max(1, min(limit, 100))
|
||||
tracks = self._search_tracks(meta)
|
||||
@@ -92,30 +93,30 @@ class TheAudioDbModule(_ModuleBase):
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""仅响应显式 TheAudioDB 音乐请求,并返回带原生 ID 的标准音乐信息。"""
|
||||
music_type = kwargs.get("music_type")
|
||||
if source != self._source:
|
||||
if media_source != self._source:
|
||||
return None
|
||||
if not isinstance(meta, MetaMusic):
|
||||
if mtype == MediaType.MUSIC and mediaid:
|
||||
if mtype == MediaType.MUSIC and media_id:
|
||||
detail_kwargs = (
|
||||
{"music_type": music_type} if music_type is not None else {}
|
||||
)
|
||||
return self.recognize_music(
|
||||
source, str(mediaid), **detail_kwargs
|
||||
media_source, str(media_id), **detail_kwargs
|
||||
)
|
||||
return None
|
||||
resolved_media_id = mediaid or meta.media_id
|
||||
resolved_media_id = media_id or meta.media_id
|
||||
if resolved_media_id:
|
||||
detail_kwargs = (
|
||||
{"music_type": music_type} if music_type is not None else {}
|
||||
)
|
||||
return self.recognize_music(
|
||||
source, str(resolved_media_id), **detail_kwargs
|
||||
media_source, str(resolved_media_id), **detail_kwargs
|
||||
)
|
||||
if music_type != MUSIC_ENTITY_ALBUM:
|
||||
matched = self._select_track(meta, self._search_tracks(meta))
|
||||
@@ -130,26 +131,26 @@ class TheAudioDbModule(_ModuleBase):
|
||||
self,
|
||||
meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
source: Optional[str] = None,
|
||||
mediaid: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步识别 TheAudioDB 音乐详情或按元数据匹配单曲。"""
|
||||
music_type = kwargs.get("music_type")
|
||||
if source != self._source:
|
||||
if media_source != self._source:
|
||||
return None
|
||||
if not isinstance(meta, MetaMusic):
|
||||
if mtype == MediaType.MUSIC and mediaid:
|
||||
if mtype == MediaType.MUSIC and media_id:
|
||||
return await self.async_recognize_music(
|
||||
source,
|
||||
str(mediaid),
|
||||
media_source,
|
||||
str(media_id),
|
||||
music_type=music_type,
|
||||
)
|
||||
return None
|
||||
resolved_media_id = mediaid or meta.media_id
|
||||
resolved_media_id = media_id or meta.media_id
|
||||
if resolved_media_id:
|
||||
return await self.async_recognize_music(
|
||||
source,
|
||||
media_source,
|
||||
str(resolved_media_id),
|
||||
music_type=music_type,
|
||||
)
|
||||
@@ -170,12 +171,12 @@ class TheAudioDbModule(_ModuleBase):
|
||||
|
||||
def recognize_music(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""按 TheAudioDB 原生 ID 和实体类型获取详情;空类型保留旧版探测顺序。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
if music_type != MUSIC_ENTITY_ALBUM:
|
||||
payload = self._request_json("track.php", {"h": media_id})
|
||||
@@ -184,17 +185,17 @@ class TheAudioDbModule(_ModuleBase):
|
||||
return self._track_to_info(track)
|
||||
if music_type == MUSIC_ENTITY_RECORDING:
|
||||
return None
|
||||
album = self.music_album(source, media_id)
|
||||
album = self.music_album(media_source, media_id)
|
||||
return album.to_music_info() if album else None
|
||||
|
||||
async def async_recognize_music(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> Optional[MusicInfo]:
|
||||
"""异步按 TheAudioDB 原生 ID 和实体类型获取详情。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
if music_type != MUSIC_ENTITY_ALBUM:
|
||||
payload = await self._async_request_json("track.php", {"h": media_id})
|
||||
@@ -203,16 +204,16 @@ class TheAudioDbModule(_ModuleBase):
|
||||
return self._track_to_info(track)
|
||||
if music_type == MUSIC_ENTITY_RECORDING:
|
||||
return None
|
||||
album = await self._async_music_album(source, media_id)
|
||||
album = await self._async_music_album(media_source, media_id)
|
||||
return album.to_music_info() if album else None
|
||||
|
||||
async def _async_music_album(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
) -> Optional[MusicAlbumInfo]:
|
||||
"""异步按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
payload = await self._async_request_json("album.php", {"m": media_id})
|
||||
item = self._first_entity(payload, "album", "albums")
|
||||
@@ -227,9 +228,9 @@ class TheAudioDbModule(_ModuleBase):
|
||||
]
|
||||
return album
|
||||
|
||||
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
"""按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json("album.php", {"m": media_id})
|
||||
item = self._first_entity(payload, "album", "albums")
|
||||
@@ -244,9 +245,9 @@ class TheAudioDbModule(_ModuleBase):
|
||||
]
|
||||
return album
|
||||
|
||||
def music_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
|
||||
def music_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]:
|
||||
"""按 TheAudioDB 艺术家 ID 获取标准化艺术家详情。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json("artist.php", {"i": media_id})
|
||||
item = self._first_entity(payload, "artists", "artist")
|
||||
@@ -254,14 +255,14 @@ class TheAudioDbModule(_ModuleBase):
|
||||
|
||||
def music_artist_albums(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
album_type: Optional[str] = None,
|
||||
) -> list[MusicInfo]:
|
||||
"""按 TheAudioDB 艺术家 ID 分页返回专辑列表。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return []
|
||||
payload = self._request_json("album.php", {"i": media_id})
|
||||
albums = [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
|
||||
@@ -274,56 +275,14 @@ class TheAudioDbModule(_ModuleBase):
|
||||
start = max(page - 1, 0) * max(1, count)
|
||||
return [album.to_music_info() for album in albums[start:start + max(1, count)]]
|
||||
|
||||
def music_discover(
|
||||
self,
|
||||
source: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
entity: str = MUSIC_ENTITY_ALBUM,
|
||||
country: str = "us",
|
||||
) -> Optional[list[MusicInfo]]:
|
||||
"""读取 TheAudioDB iTunes 趋势榜并转换为可继续浏览的音乐实体。"""
|
||||
if source != self._source:
|
||||
return None
|
||||
payload = self._request_json(
|
||||
"trending.php",
|
||||
{
|
||||
"country": country.casefold(),
|
||||
"type": "itunes",
|
||||
"format": "singles" if entity == MUSIC_ENTITY_RECORDING else "albums",
|
||||
},
|
||||
)
|
||||
items = self._entities(payload, "trending")
|
||||
items.sort(
|
||||
key=lambda item: self._optional_int(item.get("intChartPlace")) or 10_000
|
||||
)
|
||||
candidates = []
|
||||
for item in items:
|
||||
if entity == MUSIC_ENTITY_RECORDING:
|
||||
info = self._track_to_info(item)
|
||||
else:
|
||||
info = self._album_to_info(item).to_music_info()
|
||||
if not info.media_id or not info.title:
|
||||
continue
|
||||
info.category = self._text(item.get("strType")) or "iTunes"
|
||||
info.raw_data.update(
|
||||
{
|
||||
"chart_position": self._optional_int(item.get("intChartPlace")),
|
||||
"chart_country": self._text(item.get("strCountry")),
|
||||
}
|
||||
)
|
||||
candidates.append(info)
|
||||
start = max(page - 1, 0) * max(1, count)
|
||||
return candidates[start:start + max(1, count)]
|
||||
|
||||
def music_album_related(
|
||||
self,
|
||||
source: str,
|
||||
media_source: str,
|
||||
media_id: str,
|
||||
count: int = 24,
|
||||
) -> Optional[list[MusicInfo]]:
|
||||
"""按专辑主艺术家返回 TheAudioDB 同艺人专辑,供详情页关联浏览。"""
|
||||
if source != self._source or not media_id:
|
||||
if media_source != self._source or not media_id:
|
||||
return None
|
||||
payload = self._request_json("album.php", {"m": media_id})
|
||||
album_item = self._first_entity(payload, "album", "albums")
|
||||
@@ -466,7 +425,7 @@ class TheAudioDbModule(_ModuleBase):
|
||||
duration_ms = cls._optional_int(item.get("intDuration"))
|
||||
genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")])
|
||||
return MusicInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=media_id,
|
||||
title=title,
|
||||
artists=[artist] if artist else list(album.artists if album else []),
|
||||
@@ -508,7 +467,7 @@ class TheAudioDbModule(_ModuleBase):
|
||||
if not release_date:
|
||||
release_date = cls._text(item.get("intYearReleased"))
|
||||
return MusicAlbumInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=media_id,
|
||||
title=title,
|
||||
artists=[artist] if artist else [],
|
||||
@@ -540,7 +499,7 @@ class TheAudioDbModule(_ModuleBase):
|
||||
if website:
|
||||
links["official homepage"] = website
|
||||
return MusicArtistInfo(
|
||||
source=cls._source,
|
||||
media_source=cls._source,
|
||||
media_id=media_id,
|
||||
name=name,
|
||||
disambiguation=cls._text(item.get("strArtistAlternate")),
|
||||
|
||||
@@ -14,7 +14,13 @@ from app.modules.themoviedb.scraper import TmdbScraper
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
from app.modules.themoviedb.tmdbapi import TmdbApi
|
||||
from app.schemas.category import CategoryConfig
|
||||
from app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecognizeType
|
||||
from app.schemas.types import (
|
||||
MediaImageType,
|
||||
MediaRecognizeType,
|
||||
MediaSource,
|
||||
MediaType,
|
||||
ModuleType,
|
||||
)
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.media import is_media_source_enabled, is_media_source_selected
|
||||
from app.utils.zhconv import convert as zhconv_convert
|
||||
@@ -96,20 +102,20 @@ class TheMovieDbModule(_ModuleBase):
|
||||
def _validate_recognize_params(
|
||||
meta: MetaBase,
|
||||
tmdbid: Optional[int],
|
||||
source: Optional[str] = None,
|
||||
media_source: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
验证识别参数
|
||||
|
||||
:param meta: 标题解析元数据
|
||||
:param tmdbid: TMDB ID
|
||||
:param source: 请求级识别数据源
|
||||
:param media_source: 请求级识别数据源
|
||||
:return: 参数是否可用于TMDB识别
|
||||
"""
|
||||
if not tmdbid and not meta:
|
||||
return False
|
||||
|
||||
if meta and not tmdbid and (source or settings.RECOGNIZE_SOURCE) != "themoviedb":
|
||||
if meta and not tmdbid and (media_source or settings.RECOGNIZE_SOURCE) != "themoviedb":
|
||||
return False
|
||||
|
||||
if meta and not meta.name and not tmdbid:
|
||||
@@ -463,15 +469,17 @@ class TheMovieDbModule(_ModuleBase):
|
||||
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
cache: Optional[bool] = True,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与tmdbid配套
|
||||
:param tmdbid: tmdbid
|
||||
:param mtype: 识别的媒体类型
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:param episode_group: 剧集组
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
@@ -479,8 +487,15 @@ class TheMovieDbModule(_ModuleBase):
|
||||
# TMDB 只处理影视;音乐识别模块异常时也不能把音乐请求回退成电视剧搜索。
|
||||
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
|
||||
return None
|
||||
if media_source and media_source != MediaSource.TMDB:
|
||||
return None
|
||||
if media_id is not None and (
|
||||
media_source != MediaSource.TMDB or not str(media_id).isdigit()
|
||||
):
|
||||
return None
|
||||
tmdbid = int(media_id) if media_id is not None else None
|
||||
# 验证参数
|
||||
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
|
||||
if not self._validate_recognize_params(meta, tmdbid, media_source):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
@@ -491,7 +506,8 @@ class TheMovieDbModule(_ModuleBase):
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if tmdbid:
|
||||
meta.tmdbid = tmdbid
|
||||
meta.media_source = MediaSource.TMDB
|
||||
meta.media_id = str(tmdbid)
|
||||
cache_info = self.cache.get(meta) if cache else {}
|
||||
|
||||
# 查询剧集组
|
||||
@@ -552,15 +568,17 @@ class TheMovieDbModule(_ModuleBase):
|
||||
|
||||
async def async_recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
cache: Optional[bool] = True,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与tmdbid配套
|
||||
:param tmdbid: tmdbid
|
||||
:param mtype: 识别的媒体类型
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:param episode_group: 剧集组
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
@@ -568,8 +586,15 @@ class TheMovieDbModule(_ModuleBase):
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 TMDB。
|
||||
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
|
||||
return None
|
||||
if media_source and media_source != MediaSource.TMDB:
|
||||
return None
|
||||
if media_id is not None and (
|
||||
media_source != MediaSource.TMDB or not str(media_id).isdigit()
|
||||
):
|
||||
return None
|
||||
tmdbid = int(media_id) if media_id is not None else None
|
||||
# 验证参数
|
||||
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
|
||||
if not self._validate_recognize_params(meta, tmdbid, media_source):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
@@ -580,7 +605,8 @@ class TheMovieDbModule(_ModuleBase):
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if tmdbid:
|
||||
meta.tmdbid = tmdbid
|
||||
meta.media_source = MediaSource.TMDB
|
||||
meta.media_id = str(tmdbid)
|
||||
cache_info = self.cache.get(meta) if cache else {}
|
||||
|
||||
# 查询剧集组
|
||||
@@ -717,7 +743,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
"""
|
||||
if not meta or not mediainfo:
|
||||
return None
|
||||
if mediainfo.source != "themoviedb" or not mediainfo.tmdb_info:
|
||||
if mediainfo.media_source != "themoviedb" or not mediainfo.tmdb_info:
|
||||
return None
|
||||
self.cache.update(meta, mediainfo.tmdb_info)
|
||||
return True
|
||||
@@ -743,15 +769,15 @@ class TheMovieDbModule(_ModuleBase):
|
||||
}
|
||||
|
||||
def search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
self, meta: MetaBase, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(source, "themoviedb"):
|
||||
if not is_media_source_enabled(media_source, "themoviedb"):
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
@@ -775,15 +801,15 @@ class TheMovieDbModule(_ModuleBase):
|
||||
return self._build_search_medias_result(meta, results)
|
||||
|
||||
def search_persons(
|
||||
self, name: str, source: Optional[str] = None
|
||||
self, name: str, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
搜索人物信息
|
||||
:param name: 人物名称
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 人物信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(source, "themoviedb"):
|
||||
if not is_media_source_enabled(media_source, "themoviedb"):
|
||||
return None
|
||||
if not name:
|
||||
return []
|
||||
@@ -793,15 +819,15 @@ class TheMovieDbModule(_ModuleBase):
|
||||
return []
|
||||
|
||||
async def async_search_persons(
|
||||
self, name: str, source: Optional[str] = None
|
||||
self, name: str, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
异步搜索人物信息
|
||||
:param name: 人物名称
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 人物信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(source, "themoviedb"):
|
||||
if not is_media_source_enabled(media_source, "themoviedb"):
|
||||
return None
|
||||
if not name:
|
||||
return []
|
||||
@@ -811,15 +837,15 @@ class TheMovieDbModule(_ModuleBase):
|
||||
return []
|
||||
|
||||
def search_collections(
|
||||
self, name: str, source: Optional[str] = None
|
||||
self, name: str, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索集合信息
|
||||
:param name: 合集名称
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 合集信息列表
|
||||
"""
|
||||
if source and not is_media_source_selected(source, "themoviedb"):
|
||||
if media_source and not is_media_source_selected(media_source, "themoviedb"):
|
||||
return None
|
||||
if not name:
|
||||
return []
|
||||
@@ -829,15 +855,15 @@ class TheMovieDbModule(_ModuleBase):
|
||||
return []
|
||||
|
||||
async def async_search_collections(
|
||||
self, name: str, source: Optional[str] = None
|
||||
self, name: str, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
异步搜索集合信息
|
||||
:param name: 合集名称
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 合集信息列表
|
||||
"""
|
||||
if source and not is_media_source_selected(source, "themoviedb"):
|
||||
if media_source and not is_media_source_selected(media_source, "themoviedb"):
|
||||
return None
|
||||
if not name:
|
||||
return []
|
||||
@@ -998,7 +1024,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param mediainfo: 媒体信息
|
||||
:return: None 表示不处理,MediaInfo 表示继续处理
|
||||
"""
|
||||
if mediainfo.source != "themoviedb" and settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
if mediainfo.media_source != "themoviedb" and settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
return None
|
||||
if not mediainfo.tmdb_id:
|
||||
return mediainfo
|
||||
@@ -1225,15 +1251,15 @@ class TheMovieDbModule(_ModuleBase):
|
||||
|
||||
# 异步方法
|
||||
async def async_search_medias(
|
||||
self, meta: MetaBase, source: Optional[str] = None
|
||||
self, meta: MetaBase, media_source: Optional[MediaSource] = None
|
||||
) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息(异步版本)
|
||||
:param meta: 识别的元数据
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(source, "themoviedb"):
|
||||
if not is_media_source_enabled(media_source, "themoviedb"):
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.core.cache import FileCache, TTLCache
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils.singleton import WeakSingleton
|
||||
|
||||
lock = RLock()
|
||||
@@ -141,7 +141,8 @@ class TmdbCache(metaclass=WeakSingleton):
|
||||
"""
|
||||
获取缓存KEY
|
||||
"""
|
||||
return f"[{meta.type.value if meta.type else '未知'}][{settings.TMDB_LOCALE}]{meta.tmdbid or meta.name}-{meta.year}-{meta.begin_season}"
|
||||
media_id = meta.media_id if meta.media_source == MediaSource.TMDB else None
|
||||
return f"[{meta.type.value if meta.type else '未知'}][{settings.TMDB_LOCALE}]{media_id or meta.name}-{meta.year}-{meta.begin_season}"
|
||||
|
||||
def get(self, meta: MetaBase):
|
||||
"""
|
||||
|
||||
@@ -208,7 +208,8 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
|
||||
movies = s.get_movies(
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id,
|
||||
)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
@@ -225,7 +226,8 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
|
||||
itemid, tvs = s.get_tv_episodes(
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id,
|
||||
item_id=itemid,
|
||||
)
|
||||
if not tvs:
|
||||
|
||||
@@ -3,8 +3,10 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
||||
|
||||
import app.modules.trimemedia.api as fnapi
|
||||
from app import schemas
|
||||
from app.helper.mediaserver import MediaServerIdentityHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.types import MediaSource
|
||||
from app.utils.security import SecurityUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
@@ -265,14 +267,17 @@ class TrimeMedia:
|
||||
result.api.close()
|
||||
|
||||
def get_movies(
|
||||
self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None
|
||||
self, title: str, year: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
) -> Optional[List[schemas.MediaServerItem]]:
|
||||
"""
|
||||
根据标题和年份,检查电影是否在飞牛中存在,存在则返回列表
|
||||
|
||||
:param title: 标题
|
||||
:param year: 年份,为空则不过滤
|
||||
:param tmdb_id: TMDB ID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:return: 含title、year属性的字典列表
|
||||
"""
|
||||
if not self.is_authenticated():
|
||||
@@ -282,8 +287,13 @@ class TrimeMedia:
|
||||
for item in items:
|
||||
if item.type != fnapi.Type.MOVIE:
|
||||
continue
|
||||
item_source, item_media_id = MediaServerIdentityHelper.from_provider_ids(
|
||||
{"tmdb_id": item.tmdb_id, "imdb_id": item.imdb_id}
|
||||
)
|
||||
if (
|
||||
(not tmdb_id or tmdb_id == item.tmdb_id)
|
||||
MediaServerIdentityHelper.are_compatible(
|
||||
item_source, item_media_id, media_source, media_id
|
||||
)
|
||||
and title in [item.title, item.original_title]
|
||||
and (not year or (item.release_date and item.release_date[:4] == year))
|
||||
):
|
||||
@@ -306,7 +316,8 @@ class TrimeMedia:
|
||||
item_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, list]]]:
|
||||
"""
|
||||
@@ -315,7 +326,8 @@ class TrimeMedia:
|
||||
:param item_id: 飞牛影视中的guid
|
||||
:param title: 标题
|
||||
:param year: 年份
|
||||
:param tmdb_id: TMDBID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:param season: 季
|
||||
:return: 集号的列表
|
||||
"""
|
||||
@@ -339,9 +351,8 @@ class TrimeMedia:
|
||||
if not item_info:
|
||||
return None, {}
|
||||
|
||||
if tmdb_id and item_info.tmdbid:
|
||||
if tmdb_id != item_info.tmdbid:
|
||||
return None, {}
|
||||
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
|
||||
return None, {}
|
||||
|
||||
seasons = self._api.season_list(item_id)
|
||||
if not seasons:
|
||||
@@ -465,6 +476,11 @@ class TrimeMedia:
|
||||
else:
|
||||
# 将飞牛的媒体类型转为MP能识别的
|
||||
item_type = "Series" if item.type == fnapi.Type.TV else item.type.value
|
||||
media_source, media_id = MediaServerIdentityHelper.from_provider_ids({
|
||||
"tmdb_id": item.tmdb_id,
|
||||
"imdb_id": item.imdb_id,
|
||||
"douban_id": item.douban_id,
|
||||
})
|
||||
return schemas.MediaServerItem(
|
||||
server="trimemedia",
|
||||
library=item.ancestor_guid,
|
||||
@@ -473,8 +489,8 @@ class TrimeMedia:
|
||||
title=item.title,
|
||||
original_title=item.original_title,
|
||||
year=year,
|
||||
tmdbid=item.tmdb_id,
|
||||
imdbid=item.imdb_id,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
user_state=user_state,
|
||||
)
|
||||
|
||||
|
||||
@@ -190,7 +190,8 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
|
||||
movies = s.get_movies(
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id,
|
||||
)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
@@ -206,7 +207,8 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
|
||||
itemid, tvs = s.get_tv_episodes(
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id,
|
||||
item_id=itemid,
|
||||
)
|
||||
if not tvs:
|
||||
|
||||
@@ -7,11 +7,11 @@ from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from app import schemas
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.mediaserver import MusicMediaServerHelper
|
||||
from app.helper.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper
|
||||
from app.log import logger
|
||||
from app.modules.ugreen.api import Api
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.schemas.types import MediaSource, SystemConfigKey
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
|
||||
@@ -354,7 +354,8 @@ class Ugreen:
|
||||
title=video_info.get("name"),
|
||||
original_title=video_info.get("original_name"),
|
||||
year=Ugreen.__parse_year(video_info),
|
||||
tmdbid=tmdb_id,
|
||||
media_source=MediaSource.TMDB if tmdb_id else None,
|
||||
media_id=str(tmdb_id) if tmdb_id else None,
|
||||
user_state=user_state,
|
||||
)
|
||||
|
||||
@@ -635,7 +636,9 @@ class Ugreen:
|
||||
return result
|
||||
|
||||
def get_movies(
|
||||
self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None
|
||||
self, title: str, year: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
) -> Optional[List[schemas.MediaServerItem]]:
|
||||
if not self.is_authenticated() or not self._api or not title:
|
||||
return None
|
||||
@@ -647,7 +650,12 @@ class Ugreen:
|
||||
movies = []
|
||||
for info in self.__extract_video_info_list(data.get("movies_list")):
|
||||
info_tmdb = info.get("tmdb_id")
|
||||
if tmdb_id and tmdb_id != info_tmdb:
|
||||
if not MediaServerIdentityHelper.are_compatible(
|
||||
MediaSource.TMDB if info_tmdb else None,
|
||||
str(info_tmdb) if info_tmdb else None,
|
||||
media_source,
|
||||
media_id,
|
||||
):
|
||||
continue
|
||||
if title not in [info.get("name"), info.get("original_name")]:
|
||||
continue
|
||||
@@ -687,7 +695,12 @@ class Ugreen:
|
||||
results.append(media_item)
|
||||
return results
|
||||
|
||||
def __search_tv_item(self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None) -> Optional[dict]:
|
||||
def __search_tv_item(
|
||||
self, title: str, year: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""按标题、年份与统一媒体身份查找电视剧条目。"""
|
||||
if not self._api:
|
||||
return None
|
||||
data = self._api.search(title)
|
||||
@@ -695,7 +708,13 @@ class Ugreen:
|
||||
return None
|
||||
|
||||
for info in self.__extract_video_info_list(data.get("tv_list")):
|
||||
if tmdb_id and tmdb_id != info.get("tmdb_id"):
|
||||
info_tmdb = info.get("tmdb_id")
|
||||
if not MediaServerIdentityHelper.are_compatible(
|
||||
MediaSource.TMDB if info_tmdb else None,
|
||||
str(info_tmdb) if info_tmdb else None,
|
||||
media_source,
|
||||
media_id,
|
||||
):
|
||||
continue
|
||||
if title not in [info.get("name"), info.get("original_name")]:
|
||||
continue
|
||||
@@ -710,15 +729,17 @@ class Ugreen:
|
||||
item_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
) -> tuple[Optional[str], Optional[Dict[int, list]]]:
|
||||
"""
|
||||
根据标题、年份、TMDB ID和季号查询绿联媒体库中的电视剧已入库集数。
|
||||
根据标题、年份、媒体身份和季号查询绿联媒体库中的电视剧已入库集数。
|
||||
:param item_id: 绿联媒体库中的剧集ID,存在缓存ID时优先使用
|
||||
:param title: 标题
|
||||
:param year: 年份
|
||||
:param tmdb_id: TMDB ID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:param season: 季号
|
||||
:return: 命中的剧集ID及每季已入库集数
|
||||
"""
|
||||
@@ -729,7 +750,9 @@ class Ugreen:
|
||||
if not item_id:
|
||||
if not title:
|
||||
return None, None
|
||||
if not (tv_info := self.__search_tv_item(title, year, tmdb_id)):
|
||||
if not (tv_info := self.__search_tv_item(
|
||||
title, year, media_source, media_id
|
||||
)):
|
||||
return None, None
|
||||
found_item_id = tv_info.get("ug_video_info_id")
|
||||
if found_item_id is None:
|
||||
@@ -742,7 +765,9 @@ class Ugreen:
|
||||
if not item_info and cached_item_id and title:
|
||||
# 媒体删除后重新入库会导致缓存ID失效,回退到标题搜索避免误判整部剧缺失。
|
||||
logger.warning(f"绿联缓存的电视剧媒体ID {cached_item_id} 已失效,尝试按标题重新搜索:{title}")
|
||||
if not (tv_info := self.__search_tv_item(title, year, tmdb_id)):
|
||||
if not (tv_info := self.__search_tv_item(
|
||||
title, year, media_source, media_id
|
||||
)):
|
||||
return None, {}
|
||||
found_item_id = tv_info.get("ug_video_info_id")
|
||||
if found_item_id is None:
|
||||
@@ -751,7 +776,7 @@ class Ugreen:
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if not item_info:
|
||||
return None, {}
|
||||
if tmdb_id and item_info.tmdbid and tmdb_id != item_info.tmdbid:
|
||||
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
|
||||
return None, {}
|
||||
|
||||
tv_detail = self._api.get_tv(item_id, folder_path="ALL")
|
||||
|
||||
@@ -172,7 +172,8 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]):
|
||||
)
|
||||
movies = s.get_movies(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id)
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
continue
|
||||
@@ -187,7 +188,8 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]):
|
||||
else:
|
||||
itemid, tvs = s.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
media_source=mediainfo.media_source,
|
||||
media_id=mediainfo.media_id,
|
||||
item_id=itemid)
|
||||
if not tvs:
|
||||
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中")
|
||||
|
||||
@@ -8,10 +8,10 @@ from typing import List, Optional, Union, Dict, Generator, Tuple, Any
|
||||
from requests import Response
|
||||
|
||||
from app import schemas
|
||||
from app.helper.mediaserver import MusicMediaServerHelper
|
||||
from app.helper.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaServerItem
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
@@ -514,12 +514,14 @@ class ZSpace:
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
"""
|
||||
根据标题和年份,检查电影是否在极影视中存在,存在则返回列表
|
||||
:param title: 标题
|
||||
:param year: 年份,可以为空,为空时不按年份过滤
|
||||
:param tmdb_id: TMDB ID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:return: 含title、year属性的字典列表
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
@@ -545,7 +547,9 @@ class ZSpace:
|
||||
continue
|
||||
mediaserver_item = self.__format_item_info(item)
|
||||
if mediaserver_item:
|
||||
if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \
|
||||
if MediaServerIdentityHelper.is_compatible(
|
||||
mediaserver_item, media_source, media_id
|
||||
) and \
|
||||
mediaserver_item.title == title and \
|
||||
(not year or str(mediaserver_item.year) == str(year)):
|
||||
ret_movies.append(mediaserver_item)
|
||||
@@ -588,7 +592,8 @@ class ZSpace:
|
||||
item_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
tmdb_id: Optional[int] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
|
||||
"""
|
||||
@@ -596,7 +601,8 @@ class ZSpace:
|
||||
:param item_id: 极影视中的ID
|
||||
:param title: 标题
|
||||
:param year: 年份
|
||||
:param tmdb_id: TMDBID
|
||||
:param media_source: 媒体来源
|
||||
:param media_id: 媒体来源原生ID
|
||||
:param season: 季
|
||||
:return: 每一季的已有集数
|
||||
"""
|
||||
@@ -620,9 +626,8 @@ class ZSpace:
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if not item_info:
|
||||
return None, {}
|
||||
if item_info and tmdb_id and item_info.tmdbid:
|
||||
if str(tmdb_id) != str(item_info.tmdbid):
|
||||
return None, {}
|
||||
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
|
||||
return None, {}
|
||||
if season is None:
|
||||
season = None
|
||||
try:
|
||||
@@ -823,7 +828,9 @@ class ZSpace:
|
||||
play_count=item.get("UserData", {}).get("PlayCount"),
|
||||
percentage=item.get("UserData", {}).get("PlayedPercentage"),
|
||||
)
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
media_source, media_id = MediaServerIdentityHelper.from_provider_ids(
|
||||
item.get("ProviderIds")
|
||||
)
|
||||
return schemas.MediaServerItem(
|
||||
server="zspace",
|
||||
library=item.get("ParentId"),
|
||||
@@ -832,9 +839,8 @@ class ZSpace:
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
path=item.get("Path"),
|
||||
note=MusicMediaServerHelper.build_note(item)
|
||||
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
|
||||
|
||||
Reference in New Issue
Block a user