feat: unify media source identity flow (#6129)

This commit is contained in:
jxxghp
2026-07-21 20:20:09 +08:00
parent 6f8b6cfbc9
commit 3b709b7f2e
78 changed files with 2519 additions and 1121 deletions
+6 -2
View File
@@ -79,6 +79,10 @@ task_types:
- "- Transfer mode: {transfer_mode}"
- "- Current TMDB ID: {tmdbid}"
- "- Current Douban ID: {doubanid}"
- "- Current Bangumi ID: {bangumiid}"
- "- Current AniList ID: {anilistid}"
- "- Current media source: {media_source}"
- "- Current source-native ID: {media_id}"
- "- Error message: {error_message}"
steps_title: "Required workflow"
steps:
@@ -90,7 +94,7 @@ task_types:
- "Only continue when you have high confidence in the target media."
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
task_rules:
- "Do NOT rely on previous chat context. Work only from the record above."
@@ -116,7 +120,7 @@ task_types:
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, and media_type."
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
task_rules:
+8
View File
@@ -32,6 +32,10 @@ def build_manual_redo_template_context(history: Any) -> dict[str, int | str]:
"transfer_mode": history.mode or "unknown",
"tmdbid": history.tmdbid or "none",
"doubanid": history.doubanid or "none",
"bangumiid": history.bangumiid or "none",
"anilistid": history.anilistid or "none",
"media_source": history.media_source or "none",
"media_id": history.media_id or "none",
"error_message": history.errmsg or "none",
}
@@ -55,6 +59,10 @@ def format_manual_redo_record_context(history: Any) -> str:
f"- Transfer mode: {context['transfer_mode']}",
f"- Current TMDB ID: {context['tmdbid']}",
f"- Current Douban ID: {context['doubanid']}",
f"- Current Bangumi ID: {context['bangumiid']}",
f"- Current AniList ID: {context['anilistid']}",
f"- Current media source: {context['media_source']}",
f"- Current source-native ID: {context['media_id']}",
f"- Error message: {context['error_message']}",
]
)
+12
View File
@@ -39,6 +39,10 @@ class AddSubscribeInput(BaseModel):
None,
description="Douban ID for precise media identification (optional, alternative to tmdb_id)",
)
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
start_episode: Optional[int] = Field(
None,
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)",
@@ -144,6 +148,10 @@ class AddSubscribeTool(MoviePilotTool):
season: Optional[int] = None,
tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None,
anilist_id: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
start_episode: Optional[int] = None,
total_episode: Optional[int] = None,
quality: Optional[str] = None,
@@ -197,6 +205,10 @@ class AddSubscribeTool(MoviePilotTool):
year=year,
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
media_source=media_source,
media_id=media_id,
season=season,
username=subscribe_username,
**subscribe_kwargs,
+8 -1
View File
@@ -54,7 +54,14 @@ class DeleteSubscribeTool(MoviePilotTool):
await subscribe_oper.async_delete(subscribe_id)
# 分享订阅统计刷新本身已异步化,这里只需要在删除后触发即可。
MoviePilotServerHelper.sub_done_async(
{"tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid}
{
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid,
"bangumiid": subscribe.bangumiid,
"anilistid": subscribe.anilistid,
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
}
)
# 发送事件
@@ -210,6 +210,10 @@ class GetRecommendationsTool(MoviePilotTool):
"tmdb_id": r.get("tmdb_id"),
"imdb_id": r.get("imdb_id"),
"douban_id": r.get("douban_id"),
"bangumi_id": r.get("bangumi_id"),
"anilist_id": r.get("anilist_id"),
"media_source": r.get("source"),
"media_id": r.get("media_id"),
"vote_average": r.get("vote_average"),
"poster_path": r.get("poster_path"),
"detail_link": r.get("detail_link"),
+29 -16
View File
@@ -77,8 +77,12 @@ def _build_tv_server_result(existing_seasons: OrderedDict, total_seasons: Ordere
class QueryLibraryExistsInput(BaseModel):
"""查询媒体库工具的输入参数模型"""
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
tmdb_id: Optional[int] = Field(None, description="TMDB media ID")
douban_id: Optional[str] = Field(None, description="Douban media ID")
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
@@ -89,21 +93,24 @@ class QueryLibraryExistsTool(MoviePilotTool):
ToolTag.Library,
ToolTag.Media,
]
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons. Requires tmdb_id or douban_id from search_media."
description: str = "Check whether media already exists in Plex, Emby, or Jellyfin by a TMDB, Douban, Bangumi, AniList, or source-native media ID. Results are grouped by media server; TV results include existing episodes, total episodes, and missing episodes/seasons."
args_schema: Type[BaseModel] = QueryLibraryExistsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
tmdb_id = kwargs.get("tmdb_id")
douban_id = kwargs.get("douban_id")
media_type = kwargs.get("media_type")
if tmdb_id:
message = f"查询媒体库: TMDB={tmdb_id}"
elif douban_id:
message = f"查询媒体库: 豆瓣={douban_id}"
else:
message = "查询媒体库"
identities = (
("TMDB", kwargs.get("tmdb_id")),
("豆瓣", kwargs.get("douban_id")),
("Bangumi", kwargs.get("bangumi_id")),
("AniList", kwargs.get("anilist_id")),
(kwargs.get("media_source") or "媒体源", kwargs.get("media_id")),
)
label, identity = next(
((label, identity) for label, identity in identities if identity is not None),
(None, None),
)
message = f"查询媒体库: {label}={identity}" if label else "查询媒体库"
if media_type:
message += f" [{media_type}]"
return message
@@ -119,11 +126,13 @@ class QueryLibraryExistsTool(MoviePilotTool):
return MediaServerChain().media_exists(mediainfo=mediainfo, server=server)
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
media_type: Optional[str] = None, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
try:
if not tmdb_id and not douban_id:
return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID"
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
return "参数错误:至少需要提供一个媒体 ID,请先使用 search_media 工具获取媒体信息"
media_type_enum = None
if media_type:
@@ -135,11 +144,15 @@ class QueryLibraryExistsTool(MoviePilotTool):
mediainfo = await media_chain.async_recognize_media(
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
)
if not mediainfo:
media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}"
return f"未识别到媒体信息: {media_id}"
identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id
return f"未识别到媒体信息: {identity}"
# 2. 遍历所有媒体服务器,分别查询存在性信息
server_results = OrderedDict()
+45 -12
View File
@@ -20,6 +20,10 @@ class QueryMediaDetailInput(BaseModel):
"""查询媒体详情工具的输入参数模型"""
tmdb_id: Optional[int] = Field(None, description="TMDB ID of the media (movie or TV series, can be obtained from search_media tool)")
douban_id: Optional[str] = Field(None, description="Douban ID of the media (alternative to tmdb_id)")
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
media_type: str = Field(..., description="Allowed values: movie, tv")
@@ -29,24 +33,37 @@ class QueryMediaDetailTool(MoviePilotTool):
ToolTag.Read,
ToolTag.Media,
]
description: str = "Query supplementary media details from TMDB by ID and media_type. Accepts tmdb_id or douban_id (at least one required). media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
description: str = "Query supplementary media details from a metadata source by ID and media_type. Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID. media_type accepts 'movie' or 'tv'. Returns non-duplicated detail fields such as status, genres, directors, actors, and season info for TV series."
args_schema: Type[BaseModel] = QueryMediaDetailInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
tmdb_id = kwargs.get("tmdb_id")
douban_id = kwargs.get("douban_id")
if tmdb_id:
return f"查询媒体详情: TMDB ID {tmdb_id}"
return f"查询媒体详情: 豆瓣 ID {douban_id}"
identities = (
("TMDB", kwargs.get("tmdb_id")),
("豆瓣", kwargs.get("douban_id")),
("Bangumi", kwargs.get("bangumi_id")),
("AniList", kwargs.get("anilist_id")),
)
for label, identity in identities:
if identity is not None:
return f"查询媒体详情: {label} ID {identity}"
return (
f"查询媒体详情: {kwargs.get('media_source') or '媒体源'} "
f"ID {kwargs.get('media_id')}"
)
async def run(self, media_type: str, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None, **kwargs) -> str:
async def run(
self, media_type: str, tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None, bangumi_id: Optional[int] = None,
anilist_id: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, **kwargs,
) -> str:
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}")
if tmdb_id is None and douban_id is None:
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
return json.dumps({
"success": False,
"message": "必须提供 tmdb_id 或 douban_id 之一"
"message": "必须提供至少一个媒体 ID"
}, ensure_ascii=False)
try:
@@ -59,10 +76,22 @@ class QueryMediaDetailTool(MoviePilotTool):
"message": f"无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
}, ensure_ascii=False)
mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, doubanid=douban_id, mtype=media_type_enum)
mediainfo = await media_chain.async_recognize_media(
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
)
if not mediainfo:
id_info = f"TMDB ID {tmdb_id}" if tmdb_id else f"豆瓣 ID {douban_id}"
id_info = (
f"{media_source or '媒体源'} ID {media_id}"
if media_id else
f"媒体 ID {tmdb_id or douban_id or bangumi_id or anilist_id}"
)
return json.dumps({
"success": False,
"message": f"未找到 {id_info} 的媒体信息"
@@ -139,5 +168,9 @@ class QueryMediaDetailTool(MoviePilotTool):
"success": False,
"message": error_message,
"tmdb_id": tmdb_id,
"douban_id": douban_id
"douban_id": douban_id,
"bangumi_id": bangumi_id,
"anilist_id": anilist_id,
"media_source": media_source,
"media_id": media_id,
}, ensure_ascii=False)
@@ -126,6 +126,8 @@ class QueryPopularSubscribesTool(MoviePilotTool):
media.year = sub.get("year")
media.douban_id = sub.get("doubanid")
media.bangumi_id = sub.get("bangumiid")
media.anilist_id = sub.get("anilistid")
media.source = sub.get("media_source")
media.tvdb_id = sub.get("tvdbid")
media.imdb_id = sub.get("imdbid")
media.season = sub.get("season")
@@ -149,6 +151,9 @@ class QueryPopularSubscribesTool(MoviePilotTool):
"tmdb_id": media_dict.get("tmdb_id"),
"douban_id": media_dict.get("douban_id"),
"bangumi_id": media_dict.get("bangumi_id"),
"anilist_id": media_dict.get("anilist_id"),
"media_source": media_dict.get("source"),
"media_id": media_dict.get("media_id"),
"tvdb_id": media_dict.get("tvdb_id"),
"imdb_id": media_dict.get("imdb_id"),
"season": media_dict.get("season"),
@@ -170,6 +170,9 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
"tmdbid": record.tmdbid,
"doubanid": record.doubanid,
"bangumiid": record.bangumiid,
"anilistid": record.anilistid,
"media_source": record.media_source,
"media_id": record.media_id,
"poster": record.poster,
"vote": record.vote,
"total_episode": record.total_episode,
@@ -97,6 +97,9 @@ class QuerySubscribeSharesTool(MoviePilotTool):
"tmdbid": share.get("tmdbid"),
"doubanid": share.get("doubanid"),
"bangumiid": share.get("bangumiid"),
"anilistid": share.get("anilistid"),
"media_source": share.get("media_source"),
"media_id": share.get("media_id"),
"poster": share.get("poster"),
"vote": share.get("vote"),
"share_title": share.get("share_title"),
+16
View File
@@ -63,6 +63,10 @@ class QuerySubscribesInput(BaseModel):
None,
description="Filter by Douban ID to check if a specific media is already subscribed",
)
bangumi_id: Optional[int] = Field(None, description="Filter by Bangumi ID")
anilist_id: Optional[int] = Field(None, description="Filter by AniList ID")
media_source: Optional[str] = Field(None, description="Filter by media source")
media_id: Optional[str] = Field(None, description="Filter by source-native media ID")
page: Optional[int] = Field(
1, description="Page number for pagination (default: 1, 100 items per page)"
)
@@ -104,6 +108,10 @@ class QuerySubscribesTool(MoviePilotTool):
media_type: Optional[str] = "all",
tmdb_id: Optional[int] = None,
douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None,
anilist_id: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
page: Optional[int] = 1,
**kwargs,
) -> str:
@@ -130,6 +138,14 @@ class QuerySubscribesTool(MoviePilotTool):
continue
if douban_id is not None and sub.doubanid != douban_id:
continue
if bangumi_id is not None and sub.bangumiid != bangumi_id:
continue
if anilist_id is not None and sub.anilistid != anilist_id:
continue
if media_source is not None and sub.media_source != media_source:
continue
if media_id is not None and sub.media_id != media_id:
continue
filtered_subscribes.append(sub)
if filtered_subscribes:
total_count = len(filtered_subscribes)
@@ -120,6 +120,14 @@ class QueryTransferHistoryTool(MoviePilotTool):
simplified["imdbid"] = record.imdbid
if record.doubanid:
simplified["doubanid"] = record.doubanid
if record.bangumiid:
simplified["bangumiid"] = record.bangumiid
if record.anilistid:
simplified["anilistid"] = record.anilistid
if record.media_source:
simplified["media_source"] = record.media_source
if record.media_id:
simplified["media_id"] = record.media_id
simplified_records.append(simplified)
result_json = json.dumps(simplified_records, ensure_ascii=False, indent=2)
+8 -1
View File
@@ -142,6 +142,9 @@ class RecognizeMediaTool(MoviePilotTool):
"imdb_id": media_info.get("imdb_id"),
"douban_id": media_info.get("douban_id"),
"bangumi_id": media_info.get("bangumi_id"),
"anilist_id": media_info.get("anilist_id"),
"media_source": media_info.get("source"),
"media_id": media_info.get("media_id"),
"overview": media_info.get("overview"),
"vote_average": media_info.get("vote_average"),
"poster_path": media_info.get("poster_path"),
@@ -167,7 +170,11 @@ class RecognizeMediaTool(MoviePilotTool):
"season_episode": meta_info.get("season_episode"),
"episode_list": meta_info.get("episode_list"),
"tmdbid": meta_info.get("tmdbid"),
"doubanid": meta_info.get("doubanid")
"doubanid": meta_info.get("doubanid"),
"bangumiid": meta_info.get("bangumiid"),
"anilistid": meta_info.get("anilistid"),
"media_source": meta_info.get("media_source"),
"media_id": meta_info.get("media_id"),
}
return json.dumps(result, ensure_ascii=False, indent=2)
+6
View File
@@ -10,6 +10,7 @@ from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
from app.utils.media import resolve_media_identity
class SearchMediaInput(BaseModel):
@@ -83,6 +84,7 @@ class SearchMediaTool(MoviePilotTool):
# 精简字段,只保留关键信息
simplified_results = []
for r in limited_results:
media_source, media_id = resolve_media_identity(media=r)
simplified = {
"title": r.title,
"en_title": r.en_title,
@@ -92,6 +94,10 @@ class SearchMediaTool(MoviePilotTool):
"tmdb_id": r.tmdb_id,
"imdb_id": r.imdb_id,
"douban_id": r.douban_id,
"bangumi_id": r.bangumi_id,
"anilist_id": r.anilist_id,
"media_source": media_source,
"media_id": media_id,
"overview": r.overview[:200] + "..." if r.overview and len(r.overview) > 200 else r.overview,
"vote_average": r.vote_average,
"poster_path": r.poster_path,
@@ -11,6 +11,7 @@ from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
from app.chain.bangumi import BangumiChain
from app.log import logger
from app.utils.media import resolve_media_identity
class SearchPersonCreditsInput(BaseModel):
@@ -59,6 +60,7 @@ class SearchPersonCreditsTool(MoviePilotTool):
# 精简字段,只保留关键信息
simplified_results = []
for media in limited_medias:
media_source, media_id = resolve_media_identity(media=media)
simplified = {
"title": media.title,
"en_title": media.en_title,
@@ -68,6 +70,10 @@ class SearchPersonCreditsTool(MoviePilotTool):
"tmdb_id": media.tmdb_id,
"imdb_id": media.imdb_id,
"douban_id": media.douban_id,
"bangumi_id": media.bangumi_id,
"anilist_id": media.anilist_id,
"media_source": media_source,
"media_id": media_id,
"overview": media.overview[:200] + "..." if media.overview and len(media.overview) > 200 else media.overview,
"vote_average": media.vote_average,
"poster_path": media.poster_path,
+5 -1
View File
@@ -70,7 +70,11 @@ class SearchSubscribeTool(MoviePilotTool):
"total_episode": subscribe.total_episode,
"lack_episode": subscribe.lack_episode,
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
"doubanid": subscribe.doubanid,
"bangumiid": subscribe.bangumiid,
"anilistid": subscribe.anilistid,
"media_source": subscribe.media_source,
"media_id": subscribe.media_id,
}
# 检查订阅状态
+32 -17
View File
@@ -20,13 +20,18 @@ from ._torrent_search_utils import (
class SearchTorrentsInput(BaseModel):
"""搜索种子工具的输入参数模型"""
tmdb_id: Optional[int] = Field(None, description="TMDB ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
douban_id: Optional[str] = Field(None, description="Douban ID (can be obtained from search_media tool). Either tmdb_id or douban_id must be provided.")
tmdb_id: Optional[int] = Field(None, description="TMDB media ID")
douban_id: Optional[str] = Field(None, description="Douban media ID")
bangumi_id: Optional[int] = Field(None, description="Bangumi media ID")
anilist_id: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
media_type: Optional[str] = Field(None, description="Allowed values: movie, tv")
area: Optional[str] = Field(None, description="Search scope: 'title' (default) or 'imdbid'")
sites: Optional[List[int]] = Field(None,
description="Array of specific site IDs to search on (optional, if not provided searches all configured sites)")
class SearchTorrentsTool(MoviePilotTool):
name: str = "search_torrents"
tags: list[str] = [
@@ -35,23 +40,27 @@ class SearchTorrentsTool(MoviePilotTool):
ToolTag.Site,
ToolTag.Media,
]
description: str = ("Search for torrent files by media ID across configured indexer sites, cache the matched results, "
description: str = (
"Search for torrent files by media ID across configured indexer sites, cache the matched results, "
"and return available filter options for follow-up selection. "
"Requires tmdb_id or douban_id (can be obtained from search_media tool) for accurate matching.")
"Accepts a TMDB, Douban, Bangumi, AniList, or source-native media ID for accurate matching.")
args_schema: Type[BaseModel] = SearchTorrentsInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据搜索参数生成友好的提示消息"""
tmdb_id = kwargs.get("tmdb_id")
douban_id = kwargs.get("douban_id")
media_type = kwargs.get("media_type")
if tmdb_id:
message = f"搜索种子: TMDB={tmdb_id}"
elif douban_id:
message = f"搜索种子: 豆瓣={douban_id}"
else:
message = "搜索种子"
identities = (
("TMDB", kwargs.get("tmdb_id")),
("豆瓣", kwargs.get("douban_id")),
("Bangumi", kwargs.get("bangumi_id")),
("AniList", kwargs.get("anilist_id")),
(kwargs.get("media_source") or "媒体源", kwargs.get("media_id")),
)
label, identity = next(
((label, identity) for label, identity in identities if identity is not None),
(None, None),
)
message = f"搜索种子: {label}={identity}" if label else "搜索种子"
if media_type:
message += f" [{media_type}]"
return message
@@ -62,13 +71,15 @@ class SearchTorrentsTool(MoviePilotTool):
return SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
async def run(self, tmdb_id: Optional[int] = None, douban_id: Optional[str] = None,
bangumi_id: Optional[int] = None, anilist_id: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
media_type: Optional[str] = None, area: Optional[str] = None,
sites: Optional[List[int]] = None, **kwargs) -> str:
logger.info(
f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, douban_id={douban_id}, media_type={media_type}, area={area}, sites={sites}")
if not tmdb_id and not douban_id:
return "参数错误:tmdb_id 和 douban_id 至少需要提供一个,请先使用 search_media 工具获取媒体 ID"
if not any((tmdb_id, douban_id, bangumi_id, anilist_id, media_id)):
return "参数错误:至少需要提供一个媒体 ID,请先使用 search_media 工具获取媒体信息"
try:
search_chain = SearchChain()
@@ -81,6 +92,10 @@ class SearchTorrentsTool(MoviePilotTool):
filtered_torrents = await search_chain.async_search_by_id(
tmdbid=tmdb_id,
doubanid=douban_id,
bangumiid=bangumi_id,
anilistid=anilist_id,
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
area=area or "title",
sites=sites,
@@ -107,9 +122,9 @@ class SearchTorrentsTool(MoviePilotTool):
}, ensure_ascii=False, indent=2)
return result_json
else:
media_id = f"TMDB={tmdb_id}" if tmdb_id else f"豆瓣={douban_id}"
identity = media_id or tmdb_id or douban_id or bangumi_id or anilist_id
result_json = json.dumps({
"message": f"未找到相关种子资源: {media_id}",
"message": f"未找到相关种子资源: {identity}",
"all_sites": all_sites,
"search_site_ids": search_site_ids,
}, ensure_ascii=False, indent=2)
+20
View File
@@ -38,6 +38,10 @@ class TransferFileInput(BaseModel):
doubanid: Optional[str] = Field(
None, description="Douban ID for media identification (optional)"
)
bangumiid: Optional[int] = Field(None, description="Bangumi media ID")
anilistid: Optional[int] = Field(None, description="AniList media ID")
media_source: Optional[str] = Field(None, description="Media metadata source")
media_id: Optional[str] = Field(None, description="Native ID for media_source")
season: Optional[int] = Field(
None, description="Season number for TV shows (optional)"
)
@@ -109,6 +113,10 @@ class TransferFileTool(MoviePilotTool):
media_type: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
transfer_type: Optional[str] = None,
background: Optional[bool] = False,
@@ -148,6 +156,10 @@ class TransferFileTool(MoviePilotTool):
target_path=target_path_obj,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
mtype=media_type_enum,
season=season,
transfer_type=transfer_type,
@@ -178,6 +190,10 @@ class TransferFileTool(MoviePilotTool):
media_type: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
transfer_type: Optional[str] = None,
background: Optional[bool] = False,
@@ -200,6 +216,10 @@ class TransferFileTool(MoviePilotTool):
media_type,
tmdbid,
doubanid,
bangumiid,
anilistid,
media_source,
media_id,
season,
transfer_type,
background,
+9 -1
View File
@@ -98,6 +98,8 @@ def add(
torrent_in: schemas.TorrentInfo,
tmdbid: Annotated[int | None, Body()] = None,
doubanid: Annotated[str | None, Body()] = None,
bangumiid: Annotated[int | None, Body()] = None,
anilistid: Annotated[int | None, Body()] = None,
media_source: Annotated[MediaSource | None, Body()] = None,
media_id: Annotated[str | None, Body()] = None,
downloader: Annotated[str | None, Body()] = None,
@@ -111,13 +113,15 @@ def add(
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
# 媒体信息
if tmdbid or doubanid or media_id:
if tmdbid or doubanid or bangumiid or anilistid or media_id:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
source=media_source,
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
else:
mediainfo = MediaChain().recognize_by_meta(
@@ -152,6 +156,8 @@ def download_subtitle(
subtitle_in: schemas.SubtitleInfo,
tmdbid: Annotated[int | None, Body()] = None,
doubanid: Annotated[str | None, Body()] = None,
bangumiid: Annotated[int | None, Body()] = None,
anilistid: Annotated[int | None, Body()] = None,
media_source: Annotated[MediaSource | None, Body()] = None,
media_id: Annotated[str | None, Body()] = None,
save_path: Annotated[str | None, Body()] = None,
@@ -172,6 +178,8 @@ def download_subtitle(
media_id=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
save_path=save_path,
username=current_user.name,
)
+72 -38
View File
@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Annotated, Any, List, Literal, Optional, Union
from typing import Annotated, Any, List, Optional, Union
from fastapi import APIRouter, Depends
@@ -16,9 +16,53 @@ from app.db.user_oper import get_current_active_user, get_current_active_superus
from app.schemas import MediaType, MediaRecognizeConvertEventData
from app.schemas.category import CategoryConfig
from app.schemas.types import ChainEventType
from app.utils.media import parse_media_key
router = APIRouter()
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"]
MediaSource = str
def _build_media_seasons(
mediainfo: Any, season: Optional[int] = None,
) -> List[schemas.MediaSeason]:
"""将任意数据源的统一媒体信息转换为季信息响应。"""
seasons_info = []
for item in mediainfo.season_info or []:
season_number = item.get("season_number")
if season is not None and season_number != season:
continue
seasons_info.append(schemas.MediaSeason(
air_date=item.get("air_date"),
episode_count=item.get("episode_count"),
name=item.get("name"),
overview=item.get("overview"),
poster_path=item.get("poster_path"),
season_number=season_number,
vote_average=item.get("vote_average"),
))
if seasons_info:
return seasons_info
season_numbers = sorted((mediainfo.seasons or {}).keys())
if season is not None:
season_numbers = [season]
elif not season_numbers:
season_numbers = [mediainfo.season or 1]
return [
schemas.MediaSeason(
season_number=season_number,
poster_path=mediainfo.poster_path,
name=f"{season_number}",
air_date=mediainfo.release_date,
overview=mediainfo.overview,
vote_average=mediainfo.vote_average,
episode_count=(
len((mediainfo.seasons or {}).get(season_number) or [])
or mediainfo.number_of_episodes
),
)
for season_number in season_numbers
]
@router.get(
@@ -33,8 +77,11 @@ async def recognize(
) -> Any:
"""
根据标题、副标题识别媒体信息
:param title: 标题
:param subtitle: 副标题
:param custom_words: 临时识别词(每行一条规则),传入时仅在本次识别中生效,不会保存到系统配置
:param source: 请求级识别数据源
:param _:
"""
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
metainfo = MetaInfo(
@@ -292,13 +339,23 @@ async def seasons(
查询媒体季信息
"""
if mediaid:
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid[5:])
media_source, source_media_id = parse_media_key(mediaid)
if media_source == "themoviedb":
tmdbid = int(source_media_id)
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
if seasons_info:
if season is not None:
return [sea for sea in seasons_info if sea.season_number == season]
return seasons_info
elif media_source and source_media_id:
mediainfo = await MediaChain().async_recognize_media(
source=media_source,
mediaid=source_media_id,
mtype=MediaType.TV,
cache=False,
)
if mediainfo:
return _build_media_seasons(mediainfo, season)
if title:
meta = MetaInfo(title)
if year:
@@ -309,7 +366,7 @@ async def seasons(
obtain_images=False,
)
if mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb":
if mediainfo.source == "themoviedb" and mediainfo.tmdb_id:
seasons_info = await TmdbChain().async_tmdb_seasons(
tmdbid=mediainfo.tmdb_id
)
@@ -319,19 +376,7 @@ async def seasons(
sea for sea in seasons_info if sea.season_number == season
]
return seasons_info
else:
sea = season if season is not None else 1
return [
schemas.MediaSeason(
season_number=sea,
poster_path=mediainfo.poster_path,
name=f"{sea}",
air_date=mediainfo.release_date,
overview=mediainfo.overview,
vote_average=mediainfo.vote_average,
episode_count=mediainfo.number_of_episodes,
)
]
return _build_media_seasons(mediainfo, season)
return []
@@ -349,21 +394,12 @@ async def detail(
mtype = MediaType(type_name)
mediainfo = None
mediachain = MediaChain()
if mediaid.startswith("tmdb:"):
media_source, source_media_id = parse_media_key(mediaid)
if media_source and source_media_id:
mediainfo = await mediachain.async_recognize_media(
tmdbid=int(mediaid[5:]), mtype=mtype
)
elif mediaid.startswith("douban:"):
mediainfo = await mediachain.async_recognize_media(
doubanid=mediaid[7:], mtype=mtype
)
elif mediaid.startswith("bangumi:"):
mediainfo = await mediachain.async_recognize_media(
bangumiid=int(mediaid[8:]), mtype=mtype
)
elif mediaid.startswith("anilist:"):
mediainfo = await mediachain.async_recognize_media(
anilistid=int(mediaid[8:]), mtype=mtype
source=media_source,
mediaid=source_media_id,
mtype=mtype,
)
else:
# 广播事件解析媒体信息
@@ -377,13 +413,11 @@ async def detail(
if event and event.event_data and event.event_data.media_dict:
event_data: MediaRecognizeConvertEventData = event.event_data
new_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
if new_id is not None and event_data.convert_type:
mediainfo = await mediachain.async_recognize_media(
tmdbid=new_id, mtype=mtype
)
elif event_data.convert_type == "douban":
mediainfo = await mediachain.async_recognize_media(
doubanid=new_id, mtype=mtype
source=event_data.convert_type,
mediaid=str(new_id),
mtype=mtype,
)
elif title:
# 使用名称识别兜底
+3 -1
View File
@@ -16,6 +16,7 @@ from app.db.systemconfig_oper import SystemConfigOper
from app.helper.mediaserver import MediaServerHelper
from app.schemas import MediaType, NotExistMediaInfo
from app.schemas.types import SystemConfigKey
from app.utils.media import build_media_key, resolve_media_identity
router = APIRouter()
@@ -130,7 +131,8 @@ def not_exists(
exist_flag, no_exists = DownloadChain().get_no_exists_info(
meta=meta, mediainfo=mediainfo
)
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
media_source, media_id = resolve_media_identity(media=mediainfo)
mediakey = build_media_key(media_source, media_id)
if mediainfo.type == MediaType.MOVIE:
# 电影已存在时返回空列表,不存在时返回空对像列表
return [] if exist_flag else [NotExistMediaInfo()]
+88 -415
View File
@@ -16,6 +16,7 @@ from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, ChainEventType
from app.utils.media import parse_media_key, resolve_media_identity
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -44,12 +45,63 @@ def _resolve_media_season(
explicit_season: Optional[int],
recognized_season: Optional[int],
) -> Optional[int]:
"""
合并显式季号与识别结果显式值优先且季 0 属于有效业务值
"""
"""合并显式季号与识别结果,显式值优先且季 0 属于有效业务值。"""
return explicit_season if explicit_season is not None else recognized_season
async def _resolve_media_search_params(
mediaid: str,
media_type: Optional[MediaType] = None,
title: Optional[str] = None,
year: Optional[str] = None,
media_season: Optional[int] = None,
) -> tuple[Optional[dict], str]:
"""将任意来源媒体键解析为 SearchChain 可直接使用的识别参数。"""
source, source_media_id = parse_media_key(mediaid)
if source and source_media_id:
if source in {"themoviedb", "bangumi", "anilist"} \
and not source_media_id.isdigit():
return None, "媒体ID格式错误"
return {"source": source, "mediaid": source_media_id}, ""
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(
ChainEventType.MediaRecognizeConvert, event_data
)
if event and event.event_data and event.event_data.media_dict:
event_data = event.event_data
search_id = event_data.media_dict.get("id")
if search_id is not None:
return {
"source": event_data.convert_type,
"mediaid": str(search_id),
}, ""
if not title:
return None, "未知的媒体ID"
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season is not None:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await MediaChain().async_recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo:
return None, "未识别到媒体信息"
source, source_media_id = resolve_media_identity(media=mediainfo)
if not source or not source_media_id:
return None, "媒体信息缺少有效ID"
return {"source": source, "mediaid": source_media_id}, ""
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
"""
转换为SSE事件
@@ -264,189 +316,27 @@ async def search_by_id_stream(
media_type = _parse_media_type(mtype)
media_season = int(season) if season else None
site_list = _parse_site_list(sites)
media_chain = MediaChain()
search_chain = SearchChain()
async def event_source():
nonlocal media_season
torrents = None
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", ""))
if settings.RECOGNIZE_SOURCE == "douban":
doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(
tmdbid=tmdbid, mtype=media_type
search_params, message = await _resolve_media_search_params(
mediaid=mediaid,
media_type=media_type,
title=title,
year=year,
media_season=media_season,
)
if doubaninfo:
torrents = search_chain.async_search_by_id_stream(
doubanid=doubaninfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
yield {
"type": "error",
"success": False,
"message": "未识别到豆瓣媒体信息",
}
if not search_params:
yield {"type": "error", "success": False, "message": message}
return
else:
torrents = search_chain.async_search_by_id_stream(
tmdbid=tmdbid,
**search_params,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "")
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(
doubanid=doubanid, mtype=media_type
)
if tmdbinfo:
media_season = _resolve_media_season(
explicit_season=media_season,
recognized_season=tmdbinfo.get("season"),
)
torrents = search_chain.async_search_by_id_stream(
tmdbid=tmdbinfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
yield {
"type": "error",
"success": False,
"message": "未识别到TMDB媒体信息",
}
return
else:
torrents = search_chain.async_search_by_id_stream(
doubanid=doubanid,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif mediaid.startswith("bangumi:"):
bangumiid = int(mediaid.replace("bangumi:", ""))
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
bangumiid=bangumiid
)
if tmdbinfo:
torrents = search_chain.async_search_by_id_stream(
tmdbid=tmdbinfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
yield {
"type": "error",
"success": False,
"message": "未识别到TMDB媒体信息",
}
return
else:
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
bangumiid=bangumiid
)
if doubaninfo:
torrents = search_chain.async_search_by_id_stream(
doubanid=doubaninfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
yield {
"type": "error",
"success": False,
"message": "未识别到豆瓣媒体信息",
}
return
else:
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(
ChainEventType.MediaRecognizeConvert, event_data
)
if event and event.event_data:
event_data = event.event_data
if event_data.media_dict:
search_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
torrents = search_chain.async_search_by_id_stream(
tmdbid=search_id,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif event_data.convert_type == "douban":
torrents = search_chain.async_search_by_id_stream(
doubanid=search_id,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
if not title:
yield {"type": "error", "success": False, "message": "未知的媒体ID"}
return
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season is not None:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await media_chain.async_recognize_by_meta(
meta,
obtain_images=False,
)
if mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb":
torrents = search_chain.async_search_by_id_stream(
tmdbid=mediainfo.tmdb_id,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
torrents = search_chain.async_search_by_id_stream(
doubanid=mediainfo.douban_id,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
if not torrents:
yield {"type": "error", "success": False, "message": "未搜索到任何资源"}
return
async for event in torrents:
yield event
@@ -467,179 +357,29 @@ async def search_by_id(
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
根据带来源前缀的媒体 ID 精确搜索站点资源
"""
media_type = _parse_media_type(mtype)
if season:
media_season = int(season)
else:
media_season = None
if sites:
site_list = [int(site) for site in sites.split(",") if site]
else:
site_list = None
torrents = None
media_chain = MediaChain()
search_chain = SearchChain()
# 根据前缀识别媒体ID
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", ""))
if settings.RECOGNIZE_SOURCE == "douban":
# 通过TMDBID识别豆瓣ID
doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(
tmdbid=tmdbid, mtype=media_type
media_season = int(season) if season else None
search_params, message = await _resolve_media_search_params(
mediaid=mediaid,
media_type=media_type,
title=title,
year=year,
media_season=media_season,
)
if doubaninfo:
torrents = await search_chain.async_search_by_id(
doubanid=doubaninfo.get("id"),
if not search_params:
return schemas.Response(success=False, message=message)
torrents = await SearchChain().async_search_by_id(
**search_params,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
sites=_parse_site_list(sites),
cache_local=True,
)
else:
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
else:
torrents = await search_chain.async_search_by_id(
tmdbid=tmdbid,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "")
if settings.RECOGNIZE_SOURCE == "themoviedb":
# 通过豆瓣ID识别TMDBID
tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(
doubanid=doubanid, mtype=media_type
)
if tmdbinfo:
media_season = _resolve_media_season(
explicit_season=media_season,
recognized_season=tmdbinfo.get("season"),
)
torrents = await search_chain.async_search_by_id(
tmdbid=tmdbinfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
else:
torrents = await search_chain.async_search_by_id(
doubanid=doubanid,
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
elif mediaid.startswith("bangumi:"):
bangumiid = int(mediaid.replace("bangumi:", ""))
if settings.RECOGNIZE_SOURCE == "themoviedb":
# 通过BangumiID识别TMDBID
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
bangumiid=bangumiid
)
if tmdbinfo:
torrents = await search_chain.async_search_by_id(
tmdbid=tmdbinfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
else:
# 通过BangumiID识别豆瓣ID
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
bangumiid=bangumiid
)
if doubaninfo:
torrents = await search_chain.async_search_by_id(
doubanid=doubaninfo.get("id"),
mtype=media_type,
area=area,
season=media_season,
sites=site_list,
cache_local=True,
)
else:
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
else:
# 未知前缀,广播事件解析媒体信息
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(
ChainEventType.MediaRecognizeConvert, event_data
)
# 使用事件返回的上下文数据
if event and event.event_data:
event_data: MediaRecognizeConvertEventData = event.event_data
if event_data.media_dict:
search_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
torrents = await search_chain.async_search_by_id(
tmdbid=search_id,
mtype=media_type,
area=area,
season=media_season,
cache_local=True,
)
elif event_data.convert_type == "douban":
torrents = await search_chain.async_search_by_id(
doubanid=search_id,
mtype=media_type,
area=area,
season=media_season,
cache_local=True,
)
else:
if not title:
return schemas.Response(success=False, message="未知的媒体ID")
# 使用名称识别兜底
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season is not None:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await media_chain.async_recognize_by_meta(
meta,
obtain_images=False,
)
if mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb":
torrents = await search_chain.async_search_by_id(
tmdbid=mediainfo.tmdb_id,
mtype=media_type,
area=area,
season=media_season,
cache_local=True,
)
else:
torrents = await search_chain.async_search_by_id(
doubanid=mediainfo.douban_id,
mtype=media_type,
area=area,
season=media_season,
cache_local=True,
)
# 返回搜索结果
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
else:
return schemas.Response(
success=True, data=[torrent.to_dict() for torrent in torrents]
)
@@ -746,7 +486,6 @@ async def _build_subtitle_search_source(
media_season = int(season) if season else None
media_episode = int(episode) if episode else None
site_list = _parse_site_list(sites)
media_chain = MediaChain()
search_chain = SearchChain()
def call_search(**kwargs):
@@ -765,82 +504,16 @@ async def _build_subtitle_search_source(
return search_chain.async_search_subtitles_by_id_stream(**params)
return search_chain.async_search_subtitles_by_id(**params)
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", ""))
if settings.RECOGNIZE_SOURCE == "douban":
doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(
tmdbid=tmdbid, mtype=media_type
search_params, message = await _resolve_media_search_params(
mediaid=mediaid,
media_type=media_type,
title=title,
year=year,
media_season=media_season,
)
if not doubaninfo:
return None, "未识别到豆瓣媒体信息"
return call_search(doubanid=doubaninfo.get("id")), ""
return call_search(tmdbid=tmdbid), ""
if mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "")
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(
doubanid=doubanid, mtype=media_type
)
if not tmdbinfo:
return None, "未识别到TMDB媒体信息"
media_season = _resolve_media_season(
explicit_season=media_season,
recognized_season=tmdbinfo.get("season"),
)
return call_search(tmdbid=tmdbinfo.get("id")), ""
return call_search(doubanid=doubanid), ""
if mediaid.startswith("bangumi:"):
bangumiid = int(mediaid.replace("bangumi:", ""))
if settings.RECOGNIZE_SOURCE == "themoviedb":
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(
bangumiid=bangumiid
)
if not tmdbinfo:
return None, "未识别到TMDB媒体信息"
return call_search(tmdbid=tmdbinfo.get("id")), ""
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(
bangumiid=bangumiid
)
if not doubaninfo:
return None, "未识别到豆瓣媒体信息"
return call_search(doubanid=doubaninfo.get("id")), ""
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(
ChainEventType.MediaRecognizeConvert, event_data
)
if event and event.event_data and event.event_data.media_dict:
event_data = event.event_data
search_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
return call_search(tmdbid=search_id), ""
if event_data.convert_type == "douban":
return call_search(doubanid=search_id), ""
if not title:
return None, "未知的媒体ID"
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season is not None:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await media_chain.async_recognize_by_meta(
meta,
obtain_images=False,
)
if not mediainfo:
return None, "未识别到媒体信息"
if settings.RECOGNIZE_SOURCE == "themoviedb":
return call_search(tmdbid=mediainfo.tmdb_id), ""
return call_search(doubanid=mediainfo.douban_id), ""
if not search_params:
return None, message
return call_search(**search_params), ""
@router.get("/subtitle/media/{mediaid}/stream", summary="渐进式精确搜索字幕")
@@ -856,7 +529,7 @@ async def search_subtitle_by_id_stream(
_: schemas.TokenPayload = Depends(verify_resource_token),
) -> Any:
"""
根据TMDBID/豆瓣ID渐进式精确搜索站点字幕资源返回格式为SSE
根据带来源前缀的媒体 ID 渐进式精确搜索站点字幕资源返回格式为SSE
"""
subtitles, message = await _build_subtitle_search_source(
mediaid=mediaid,
@@ -900,7 +573,7 @@ async def search_subtitle_by_id(
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
根据TMDBID/豆瓣ID精确搜索站点字幕资源
根据带来源前缀的媒体 ID 精确搜索站点字幕资源
"""
subtitles, message = await _build_subtitle_search_source(
mediaid=mediaid,
+53 -48
View File
@@ -23,6 +23,7 @@ from app.log import logger
from app.scheduler import Scheduler
from app.schemas.event import SubscribeModifiedEventData
from app.schemas.types import MediaType, EventType, SystemConfigKey
from app.utils.media import normalize_media_source, parse_media_key
router = APIRouter()
@@ -104,6 +105,35 @@ def select_accessible_subscribe(
return None
async def list_subscribes_by_media_key(
db: AsyncSession, media_key: str, season: Optional[int] = None,
) -> List[Subscribe]:
"""按统一媒体键查询订阅,并兼容迁移前的专用 ID 字段。"""
source, media_id = parse_media_key(media_key)
if not source or not media_id:
return await Subscribe.async_list_by_mediaid(db, media_key)
subscribes = list(await Subscribe.async_list_by_media_identity(
db, media_source=source, media_id=media_id
))
if source == "themoviedb" and media_id.isdigit():
subscribes.extend(await Subscribe.async_get_by_tmdbid(db, int(media_id), season))
elif source == "douban":
subscribes.extend(await Subscribe.async_list_by_doubanid(db, media_id))
elif source == "bangumi" and media_id.isdigit():
subscribes.extend(await Subscribe.async_list_by_bangumiid(db, int(media_id)))
elif source == "anilist" and media_id.isdigit():
subscribes.extend(await Subscribe.async_list_by_anilistid(db, int(media_id)))
unique_subscribes = {subscribe.id: subscribe for subscribe in subscribes}
if season is not None:
return [
subscribe for subscribe in unique_subscribes.values()
if subscribe.season == season
]
return list(unique_subscribes.values())
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
async def read_subscribes(
db: AsyncSession = Depends(get_async_db),
@@ -141,8 +171,13 @@ async def create_subscribe(
mtype = MediaType(subscribe_in.type)
else:
mtype = None
# 豆瓣标理
if subscribe_in.doubanid or subscribe_in.bangumiid:
# 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。
if (
subscribe_in.doubanid
or subscribe_in.bangumiid
or subscribe_in.anilistid
or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb")
):
meta = MetaInfo(subscribe_in.name)
subscribe_in.name = meta.name
if subscribe_in.season is None:
@@ -247,36 +282,12 @@ async def subscribe_mediaid(
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
根据 TMDB豆瓣BangumiAniList 或插件媒体键查询订阅
"""
title_check = False
if mediaid.startswith("tmdb:"):
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return Subscribe()
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
subscribes = await list_subscribes_by_media_key(db, mediaid, season)
result = select_accessible_subscribe(subscribes, current_user)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return Subscribe()
subscribes = await Subscribe.async_list_by_doubanid(db, doubanid)
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
elif mediaid.startswith("bangumi:"):
bangumiid = mediaid[8:]
if not bangumiid or not str(bangumiid).isdigit():
return Subscribe()
subscribes = await Subscribe.async_list_by_bangumiid(db, int(bangumiid))
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
else:
subscribes = await Subscribe.async_list_by_mediaid(db, mediaid)
result = select_accessible_subscribe(subscribes, current_user)
if not result and title:
title_check = True
source, _ = parse_media_key(mediaid)
title_check = not result and bool(title) and source != "themoviedb"
# 使用名称检查订阅
if title_check and title:
meta = MetaInfo(title)
@@ -419,24 +430,9 @@ async def delete_subscribe_by_mediaid(
current_user: User = Depends(get_current_active_user_async),
) -> Any:
"""
根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:
根据任意媒体数据源 ID 删除订阅
"""
delete_subscribes = []
if mediaid.startswith("tmdb:"):
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return schemas.Response(success=False)
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
delete_subscribes.extend(subscribes)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return schemas.Response(success=False)
subscribes = await Subscribe.async_list_by_doubanid(db, doubanid)
delete_subscribes.extend(subscribes)
else:
subscribes = await Subscribe.async_list_by_mediaid(db, mediaid)
delete_subscribes.extend(subscribes)
delete_subscribes = await list_subscribes_by_media_key(db, mediaid, season)
delete_events = []
for subscribe in [
subscribe
@@ -632,6 +628,8 @@ async def popular_subscribes(
media.year = sub.get("year")
media.douban_id = sub.get("doubanid")
media.bangumi_id = sub.get("bangumiid")
media.anilist_id = sub.get("anilistid")
media.source = sub.get("media_source")
media.tvdb_id = sub.get("tvdbid")
media.imdb_id = sub.get("imdbid")
media.season = sub.get("season")
@@ -858,6 +856,13 @@ async def delete_subscribe(
)
# 统计订阅
MoviePilotServerHelper.sub_done_async(
{"tmdbid": subscribe_info.get("tmdbid"), "doubanid": subscribe_info.get("doubanid")}
{
"tmdbid": subscribe_info.get("tmdbid"),
"doubanid": subscribe_info.get("doubanid"),
"bangumiid": subscribe_info.get("bangumiid"),
"anilistid": subscribe_info.get("anilistid"),
"media_source": subscribe_info.get("media_source"),
"media_id": subscribe_info.get("media_id"),
}
)
return schemas.Response(success=True)
+16 -2
View File
@@ -174,6 +174,10 @@ async def reidentify_cache(
torrent_hash: str,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
_: User = Depends(get_current_active_superuser_async),
):
"""
@@ -182,6 +186,10 @@ async def reidentify_cache(
:param torrent_hash: 种子hash使用title+description的md5
:param tmdbid: 手动指定的TMDB ID
:param doubanid: 手动指定的豆瓣ID
:param bangumiid: 手动指定的 Bangumi ID
:param anilistid: 手动指定的 AniList ID
:param media_source: 媒体数据源
:param media_id: 数据源原生 ID
:param _: 当前用户必须是超级用户
"""
@@ -215,10 +223,16 @@ async def reidentify_cache(
title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description,
)
if tmdbid or doubanid:
if tmdbid or doubanid or bangumiid or anilistid or media_source or media_id:
# 手动指定媒体信息
mediainfo = await media_chain.async_recognize_media(
meta=meta, tmdbid=tmdbid, doubanid=doubanid
meta=meta,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=media_source,
mediaid=media_id,
)
else:
# 自动重新识别
+8 -4
View File
@@ -292,13 +292,13 @@ def manual_transfer(
transer_item.doubanid = (
str(history.doubanid) if history.doubanid else transer_item.doubanid
)
transer_item.bangumiid = history.bangumiid or transer_item.bangumiid
transer_item.anilistid = history.anilistid or transer_item.anilistid
transer_item.media_source = (
getattr(history, "media_source", None)
or transer_item.media_source
history.media_source or transer_item.media_source
)
transer_item.media_id = (
getattr(history, "media_id", None)
or transer_item.media_id
history.media_id or transer_item.media_id
)
transer_item.season = (
int(str(history.seasons).replace("S", ""))
@@ -417,6 +417,8 @@ def manual_transfer(
target_path=target_path,
tmdbid=transer_item.tmdbid,
doubanid=transer_item.doubanid,
bangumiid=transer_item.bangumiid,
anilistid=transer_item.anilistid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
mtype=mtype,
@@ -501,6 +503,8 @@ def manual_transfer(
target_path=target_path,
tmdbid=transer_item.tmdbid,
doubanid=transer_item.doubanid,
bangumiid=transer_item.bangumiid,
anilistid=transer_item.anilistid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
mtype=mtype,
+28 -69
View File
@@ -40,6 +40,7 @@ from app.schemas import (
MessageResponse,
)
from app.utils.identity import normalize_internal_user_id
from app.utils.media import normalize_media_source
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.category import CategoryConfig
from app.schemas.types import (
@@ -468,7 +469,6 @@ class ChainBase(metaclass=ABCMeta):
self,
method: str,
*args,
system_only: bool = False,
**kwargs,
) -> Any:
"""
@@ -476,13 +476,9 @@ class ChainBase(metaclass=ABCMeta):
当kwargs包含命名参数raise_exception时如模块方法抛出异常且raise_exception为True则同步抛出异常
:param method: 模块方法名称
:param system_only: 是否仅执行系统模块
"""
result = None
# 执行插件模块
if not system_only:
result = self.__execute_plugin_modules(method, result, *args, **kwargs)
result = self.__execute_plugin_modules(method, None, *args, **kwargs)
if not self.__is_valid_empty(result) and not isinstance(result, list):
# 插件模块返回结果不为空且不是列表,直接返回
@@ -495,7 +491,6 @@ class ChainBase(metaclass=ABCMeta):
self,
method: str,
*args,
system_only: bool = False,
**kwargs,
) -> Any:
"""
@@ -504,14 +499,10 @@ class ChainBase(metaclass=ABCMeta):
支持异步和同步方法的混合调用
:param method: 模块方法名称
:param system_only: 是否仅执行系统模块
"""
result = None
# 执行插件模块
if not system_only:
result = await self.__async_execute_plugin_modules(
method, result, *args, **kwargs
method, None, *args, **kwargs
)
if not self.__is_valid_empty(result) and not isinstance(result, list):
@@ -529,6 +520,7 @@ class ChainBase(metaclass=ABCMeta):
tmdbid: Optional[int],
doubanid: Optional[str],
bangumiid: Optional[int],
anilistid: Optional[int],
) -> bool:
"""
仅在名称识别场景下使用共享识别显式ID识别不再重复回查
@@ -536,7 +528,7 @@ class ChainBase(metaclass=ABCMeta):
return bool(
settings.MEDIA_RECOGNIZE_SHARE
and meta
and not any([tmdbid, doubanid, bangumiid])
and not any([tmdbid, doubanid, bangumiid, anilistid])
)
@staticmethod
@@ -600,14 +592,7 @@ class ChainBase(metaclass=ABCMeta):
:param anilistid: AniList兼容ID
:return: 数据源及四种兼容ID
"""
source_aliases = {
"tmdb": "themoviedb",
"themoviedb": "themoviedb",
"douban": "douban",
"bangumi": "bangumi",
"anilist": "anilist",
}
source = source_aliases.get(str(source).casefold()) if source else None
source = normalize_media_source(source)
def to_int(value) -> Optional[int]:
"""将数字ID安全转换为整数。"""
@@ -669,7 +654,6 @@ class ChainBase(metaclass=ABCMeta):
:return: 识别的媒体信息包括剧集信息
"""
# 识别用名中含指定信息情形
requested_source = source
if not tmdbid and hasattr(meta, "tmdbid"):
tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"):
@@ -678,6 +662,7 @@ class ChainBase(metaclass=ABCMeta):
source = meta.media_source
if not mediaid and hasattr(meta, "media_id"):
mediaid = meta.media_id
requested_mediaid = mediaid
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params(
@@ -688,35 +673,25 @@ class ChainBase(metaclass=ABCMeta):
bangumiid=bangumiid,
anilistid=anilistid,
)
# 有tmdbid时,不使用meta推断类型(由消歧逻辑决定)
if tmdbid:
source = "themoviedb"
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
# 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
system_only = bool(
requested_source
or mediaid
or anilistid
or source in {"bangumi", "anilist"}
)
module_kwargs = {
"meta": meta,
"mtype": mtype,
"source": source,
"mediaid": requested_mediaid,
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"anilistid": anilistid,
"episode_group": episode_group,
"cache": cache,
}
if system_only:
module_kwargs["source"] = source
if anilistid:
module_kwargs["anilistid"] = anilistid
with fresh(not cache):
mediainfo = self.run_module(
"recognize_media",
system_only=system_only,
**module_kwargs,
)
if mediainfo:
@@ -729,7 +704,7 @@ class ChainBase(metaclass=ABCMeta):
return mediainfo
if not source and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
shared_item = MoviePilotServerHelper.query_recognize_share(
@@ -744,9 +719,12 @@ class ChainBase(metaclass=ABCMeta):
"recognize_media",
meta=meta,
mtype=shared_params.get("mtype") or mtype,
source=shared_params.get("source"),
mediaid=shared_params.get("mediaid"),
tmdbid=shared_params.get("tmdbid"),
doubanid=shared_params.get("doubanid"),
bangumiid=shared_params.get("bangumiid"),
anilistid=shared_params.get("anilistid"),
episode_group=episode_group,
cache=cache,
)
@@ -785,7 +763,6 @@ class ChainBase(metaclass=ABCMeta):
:return: 识别的媒体信息包括剧集信息
"""
# 识别用名中含指定信息情形
requested_source = source
if not tmdbid and hasattr(meta, "tmdbid"):
tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"):
@@ -794,6 +771,7 @@ class ChainBase(metaclass=ABCMeta):
source = meta.media_source
if not mediaid and hasattr(meta, "media_id"):
mediaid = meta.media_id
requested_mediaid = mediaid
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
source, tmdbid, doubanid, bangumiid, anilistid = self._resolve_media_source_params(
@@ -804,35 +782,25 @@ class ChainBase(metaclass=ABCMeta):
bangumiid=bangumiid,
anilistid=anilistid,
)
# 有tmdbid时,不使用meta推断类型(由消歧逻辑决定)
if tmdbid:
source = "themoviedb"
elif not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
# 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
system_only = bool(
requested_source
or mediaid
or anilistid
or source in {"bangumi", "anilist"}
)
module_kwargs = {
"meta": meta,
"mtype": mtype,
"source": source,
"mediaid": requested_mediaid,
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"anilistid": anilistid,
"episode_group": episode_group,
"cache": cache,
}
if system_only:
module_kwargs["source"] = source
if anilistid:
module_kwargs["anilistid"] = anilistid
async with async_fresh(not cache):
mediainfo = await self.async_run_module(
"async_recognize_media",
system_only=system_only,
**module_kwargs,
)
if mediainfo:
@@ -845,7 +813,7 @@ class ChainBase(metaclass=ABCMeta):
return mediainfo
if not source and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
shared_item = await MoviePilotServerHelper.async_query_recognize_share(
@@ -860,9 +828,12 @@ class ChainBase(metaclass=ABCMeta):
"async_recognize_media",
meta=meta,
mtype=shared_params.get("mtype") or mtype,
source=shared_params.get("source"),
mediaid=shared_params.get("mediaid"),
tmdbid=shared_params.get("tmdbid"),
doubanid=shared_params.get("doubanid"),
bangumiid=shared_params.get("bangumiid"),
anilistid=shared_params.get("anilistid"),
episode_group=episode_group,
cache=cache,
)
@@ -1136,14 +1107,7 @@ class ChainBase(metaclass=ABCMeta):
:param source: 请求级搜索数据源
:return: 媒体信息列表
"""
if source:
return self.run_module(
"search_medias",
meta=meta,
source=source,
system_only=True,
)
return self.run_module("search_medias", meta=meta)
return self.run_module("search_medias", meta=meta, source=source)
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
@@ -1154,14 +1118,9 @@ class ChainBase(metaclass=ABCMeta):
:param source: 请求级搜索数据源
:return: 媒体信息列表
"""
if source:
return await self.async_run_module(
"async_search_medias",
meta=meta,
source=source,
system_only=True,
"async_search_medias", meta=meta, source=source
)
return await self.async_run_module("async_search_medias", meta=meta)
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
"""
+48 -7
View File
@@ -30,6 +30,7 @@ from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloaderTo
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \
ChainEventType
from app.utils.http import RequestUtils
from app.utils.media import build_media_key, resolve_media_identity
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
@@ -59,6 +60,23 @@ class DownloadChain(ChainBase):
".rar": "rar",
}
@staticmethod
def _media_identity_keys(media: Optional[MediaInfo]) -> Set[str]:
"""返回媒体的统一身份键及全部兼容 ID,用于临时缺失集映射匹配。"""
if not media:
return set()
source, media_id = resolve_media_identity(media=media)
values = {
media.tmdb_id, media.douban_id, media.bangumi_id, media.anilist_id,
build_media_key(source, media_id),
}
return {str(value) for value in values if value is not None and str(value)}
@classmethod
def _matches_media_identity(cls, media: Optional[MediaInfo], media_key: object) -> bool:
"""判断媒体是否命中统一身份键或任一兼容 ID。"""
return media_key is not None and str(media_key) in cls._media_identity_keys(media)
@staticmethod
def _safe_subtitle_file_name(file_name: str, fallback_name: str) -> str:
"""
@@ -330,6 +348,8 @@ class DownloadChain(ChainBase):
doubanid: Optional[str] = None,
save_path: Optional[str] = None,
username: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
) -> Tuple[bool, str, List[str]]:
"""
下载字幕文件并保存到媒体对应的下载目录
@@ -339,6 +359,8 @@ class DownloadChain(ChainBase):
:param media_id: 数据源原生ID
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param save_path: 保存路径
:param username: 调用下载的用户名
:return: 成功状态提示消息保存文件列表
@@ -353,6 +375,8 @@ class DownloadChain(ChainBase):
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
if not mediainfo:
return False, "无法识别媒体信息", []
@@ -485,10 +509,11 @@ class DownloadChain(ChainBase):
return None
media_type = getattr(getattr(media, "type", None), "value", getattr(media, "type", None))
media_source, media_id = resolve_media_identity(media=media)
media_key = (
getattr(media, "tmdb_id", None)
or getattr(media, "douban_id", None)
or getattr(media, "imdb_id", None)
f"{media_source}:{media_id}"
if media_source and media_id
else getattr(media, "imdb_id", None)
or getattr(media, "tvdb_id", None)
or f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}"
)
@@ -542,6 +567,7 @@ class DownloadChain(ChainBase):
time.localtime(now_timestamp + self._download_failure_ttl(error_msg)),
)
media = context.media_info
media_source, media_id = resolve_media_identity(media=media)
meta = context.meta_info
torrent = context.torrent_info
site = getattr(torrent, "site", None)
@@ -555,6 +581,10 @@ class DownloadChain(ChainBase):
year=getattr(media, "year", None),
tmdbid=getattr(media, "tmdb_id", None),
doubanid=getattr(media, "douban_id", None),
bangumiid=media.bangumi_id,
anilistid=media.anilist_id,
media_source=media_source,
media_id=media_id,
seasons=getattr(meta, "season", None),
episodes=StringUtils.format_ep(list(episodes)) if episodes else self._format_failure_episodes(meta),
site=site if isinstance(site, int) else None,
@@ -785,6 +815,7 @@ class DownloadChain(ChainBase):
if not _media.genre_ids:
new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id,
doubanid=_media.douban_id, bangumiid=_media.bangumi_id,
anilistid=_media.anilist_id,
episode_group=_media.episode_group)
if new_media:
_media = new_media
@@ -867,6 +898,7 @@ class DownloadChain(ChainBase):
# 登记下载记录
downloadhis = DownloadHistoryOper()
media_source, media_id = resolve_media_identity(media=_media)
downloadhis.add(
path=download_path.as_posix(),
type=_media.type.value,
@@ -876,6 +908,10 @@ class DownloadChain(ChainBase):
imdbid=_media.imdb_id,
tvdbid=_media.tvdb_id,
doubanid=_media.douban_id,
bangumiid=_media.bangumi_id,
anilistid=_media.anilist_id,
media_source=media_source,
media_id=media_id,
seasons=_meta.season,
episodes=download_episodes or _meta.episode,
image=_media.get_backdrop_image(),
@@ -1212,7 +1248,7 @@ class DownloadChain(ChainBase):
if meta.episode_list:
continue
# 匹配TMDBID
if need_mid == media.tmdb_id or need_mid == media.douban_id:
if self._matches_media_identity(media, need_mid):
# 不重复添加
if context in downloaded_list:
continue
@@ -1343,7 +1379,7 @@ class DownloadChain(ChainBase):
if media.type != MediaType.TV:
continue
# 匹配TMDB
if media.tmdb_id == need_mid or media.douban_id == need_mid:
if self._matches_media_identity(media, need_mid):
# 不重复添加
if context in downloaded_list:
continue
@@ -1445,7 +1481,7 @@ class DownloadChain(ChainBase):
if not effective_need:
continue
# 选中一个单季整季的或单季包括需要的所有集的
if (media.tmdb_id == need_mid or media.douban_id == need_mid) \
if self._matches_media_identity(media, need_mid) \
and (not meta.episode_list
or set(meta.episode_list).intersection(effective_need)) \
and len(meta.season_list) == 1 \
@@ -1523,6 +1559,7 @@ class DownloadChain(ChainBase):
:param totals: 电视剧每季的总集数
:return: 当前媒体是否缺失各标题总的季集和缺失的季集
"""
media_source, media_id = resolve_media_identity(media=mediainfo)
def __append_no_exists(_season: int, _episodes: list, _total: int, _start: int):
"""
@@ -1534,7 +1571,7 @@ class DownloadChain(ChainBase):
"start_episode": int
]}
"""
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
mediakey = build_media_key(media_source, media_id)
if not no_exists.get(mediakey):
no_exists[mediakey] = {
_season: NotExistMediaInfo(
@@ -1575,6 +1612,10 @@ class DownloadChain(ChainBase):
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
source=media_source,
mediaid=media_id,
episode_group=mediainfo.episode_group)
if not mediainfo:
logger.error(f"媒体信息识别失败!")
+9 -2
View File
@@ -42,6 +42,7 @@ from app.schemas.message import ChannelCapabilityManager, ChannelCapability
from app.schemas.system import TransferDirectoryConf
from app.schemas.types import EventType, MessageChannel, MediaType
from app.utils.http import RequestUtils
from app.utils.media import build_media_key, resolve_media_identity
from app.utils.string import StringUtils
@@ -2071,6 +2072,10 @@ class MediaInteractionChain(ChainBase):
mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
source=resolve_media_identity(media=mediainfo)[0],
mediaid=resolve_media_identity(media=mediainfo)[1],
cache=False,
)
if not mediainfo:
@@ -2085,7 +2090,8 @@ class MediaInteractionChain(ChainBase):
)
return {}
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
media_source, media_id = resolve_media_identity(media=mediainfo)
mediakey = build_media_key(media_source, media_id)
no_exists = {mediakey: {}}
if meta.begin_season is not None:
episodes = mediainfo.seasons.get(meta.begin_season)
@@ -3528,7 +3534,8 @@ class MediaInteractionChain(ChainBase):
"""
if not no_exists:
return []
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
media_source, media_id = resolve_media_identity(media=mediainfo)
mediakey = build_media_key(media_source, media_id)
season_map = no_exists.get(mediakey) or {}
if show_missing_only:
return [
+186 -57
View File
@@ -24,6 +24,7 @@ from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import NotExistMediaInfo
from app.schemas.types import MediaType, ProgressKey, SystemConfigKey, EventType
from app.utils.media import build_media_key, resolve_media_identity
from app.utils.string import StringUtils
@@ -171,16 +172,38 @@ class SearchChain(ChainBase):
@staticmethod
def _build_search_keyword(
tmdbid: Optional[int] = None, doubanid: Optional[str] = None
source: Optional[str] = None,
mediaid: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
) -> str:
"""
根据媒体ID生成可重放的搜索关键字
"""
if tmdbid is not None:
return f"tmdb:{tmdbid}"
if doubanid:
return f"douban:{doubanid}"
return ""
media_source, media_id = resolve_media_identity(
source=source,
media_id=mediaid,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
)
return build_media_key(media_source, media_id)
@staticmethod
def _media_recognize_kwargs(mediainfo: MediaInfo) -> dict:
"""从统一媒体信息构造完整的识别 ID 参数。"""
media_source, media_id = resolve_media_identity(media=mediainfo)
return {
"source": media_source,
"mediaid": media_id,
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
}
@staticmethod
def _stringify_sites(sites: Optional[List[int]]) -> str:
@@ -488,13 +511,22 @@ class SearchChain(ChainBase):
state._ai_recommend_task = asyncio.create_task(run_recommend())
def search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
def search_by_id(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title",
season: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
) -> List[Context]:
"""
根据TMDBID/豆瓣ID搜索资源精确匹配不过滤本地存在的资源
根据数据源媒体 ID 搜索资源精确匹配不过滤本地存在的资源
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param source: 媒体数据源
:param mediaid: 数据源原生 ID
:param mtype: 媒体电影 or 电视剧
:param area: 搜索范围title or imdbid
:param season: 季数
@@ -504,20 +536,26 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
self.save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area=area,
season=season,
sites=sites,
)
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = self.recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!')
return []
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(media=mediainfo)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[])
}
}
@@ -658,14 +696,22 @@ class SearchChain(ChainBase):
"total_items": len(subtitles)
}
async def async_search_subtitles_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
async def async_search_subtitles_by_id(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, season: Optional[int] = None,
episode: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False) -> List[SubtitleInfo]:
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
) -> List[SubtitleInfo]:
"""
根据TMDBID/豆瓣ID异步精确搜索字幕不应用过滤规则
根据数据源媒体 ID 异步精确搜索字幕不应用过滤规则
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param source: 媒体数据源
:param mediaid: 数据源原生 ID
:param mtype: 媒体电影 or 电视剧
:param season: 季数
:param episode: 集数
@@ -675,7 +721,9 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area="title",
season=season,
@@ -683,14 +731,24 @@ class SearchChain(ChainBase):
sites=sites,
result_type="subtitle",
)
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
'媒体信息识别失败!'
)
return []
subtitles = await self.__async_search_subtitles_for_media(
mediainfo=mediainfo,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=source,
mediaid=mediaid,
season=season,
episode=episode,
sites=sites,
@@ -708,14 +766,20 @@ class SearchChain(ChainBase):
episode: Optional[int] = None,
sites: List[int] = None,
cache_local: bool = False,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
) -> AsyncIterator[dict]:
"""
根据TMDBID/豆瓣ID渐进式精确搜索字幕先返回站点候选再返回标题和剧集匹配后的结果
根据数据源媒体 ID 渐进式精确搜索字幕先返回站点候选再返回标题和剧集匹配后的结果
"""
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area="title",
season=season,
@@ -723,9 +787,15 @@ class SearchChain(ChainBase):
sites=sites,
result_type="subtitle",
)
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
'媒体信息识别失败!'
)
yield {
"type": "error",
"success": False,
@@ -738,6 +808,10 @@ class SearchChain(ChainBase):
mediainfo=mediainfo,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=source,
mediaid=mediaid,
season=season,
episode=episode,
sites=sites):
@@ -753,13 +827,22 @@ class SearchChain(ChainBase):
if cache_local:
await self.async_save_cache(subtitles, self.__subtitle_result_temp_file)
async def async_search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
async def async_search_by_id(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title",
season: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
) -> List[Context]:
"""
根据TMDBID/豆瓣ID异步搜索资源精确匹配不过滤本地存在的资源
根据数据源媒体 ID 异步搜索资源精确匹配不过滤本地存在的资源
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param source: 媒体数据源
:param mediaid: 数据源原生 ID
:param mtype: 媒体电影 or 电视剧
:param area: 搜索范围title or imdbid
:param season: 季数
@@ -769,20 +852,29 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area=area,
season=season,
sites=sites,
)
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
'媒体信息识别失败!'
)
return []
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(media=mediainfo)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[])
}
}
@@ -913,25 +1005,37 @@ class SearchChain(ChainBase):
logger.info(f'标题搜索过滤完成,剩余 {len(filtered_torrents)} 个资源')
return filtered_torrents
async def async_search_by_id_stream(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
async def async_search_by_id_stream(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title",
season: Optional[int] = None, sites: List[int] = None,
cache_local: bool = False) -> AsyncIterator[dict]:
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
) -> AsyncIterator[dict]:
"""
根据TMDBID/豆瓣ID渐进式搜索资源先返回站点原始候选再返回过滤匹配后的最终结果
根据数据源媒体 ID 渐进式搜索资源先返回站点原始候选再返回过滤匹配后的最终结果
"""
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(tmdbid=tmdbid, doubanid=doubanid),
keyword=self._build_search_keyword(
source, mediaid, tmdbid, doubanid, bangumiid, anilistid
),
mtype=mtype,
area=area,
season=season,
sites=sites,
)
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
'媒体信息识别失败!'
)
yield {
"type": "error",
"success": False,
@@ -941,8 +1045,9 @@ class SearchChain(ChainBase):
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(media=mediainfo)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[])
}
}
@@ -970,7 +1075,8 @@ class SearchChain(ChainBase):
准备搜索参数
"""
# 缺失的季集
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
media_source, media_id = resolve_media_identity(media=mediainfo)
mediakey = build_media_key(media_source, media_id)
if no_exists and no_exists.get(mediakey):
# 过滤剧集
season_episodes = {sea: info.episodes
@@ -1230,9 +1336,10 @@ class SearchChain(ChainBase):
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo: MediaInfo = self.recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
@@ -1313,9 +1420,10 @@ class SearchChain(ChainBase):
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo: MediaInfo = await self.async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
@@ -1385,9 +1493,10 @@ class SearchChain(ChainBase):
# 补充媒体信息
if not mediainfo.names:
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo = await self.async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
yield {
@@ -1619,6 +1728,10 @@ class SearchChain(ChainBase):
mediainfo: MediaInfo,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
sites: List[int] = None,
@@ -1633,17 +1746,23 @@ class SearchChain(ChainBase):
logger.info(f'开始精确搜索字幕,关键词:{mediainfo.title} ...')
if not mediainfo.names:
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo = await self.async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error('媒体信息识别失败!')
return []
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(
media=mediainfo, source=source, media_id=mediaid,
tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid,
)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[episode] if episode is not None else [])
}
}
@@ -1689,6 +1808,10 @@ class SearchChain(ChainBase):
mediainfo: MediaInfo,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
sites: List[int] = None,
@@ -1704,9 +1827,10 @@ class SearchChain(ChainBase):
logger.info(f'开始渐进式精确搜索字幕,关键词:{mediainfo.title} ...')
if not mediainfo.names:
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
mediainfo = await self.async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
if not mediainfo:
logger.error('媒体信息识别失败!')
yield {
@@ -1718,8 +1842,13 @@ class SearchChain(ChainBase):
no_exists = None
if season is not None:
media_source, media_id = resolve_media_identity(
media=mediainfo, source=source, media_id=mediaid,
tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid,
)
no_exists = {
tmdbid or doubanid: {
build_media_key(media_source, media_id): {
season: NotExistMediaInfo(episodes=[episode] if episode is not None else [])
}
}
+309 -121
View File
@@ -43,6 +43,12 @@ from app.schemas import (MediaRecognizeConvertEventData, SubscribeEpisodesRefres
SubscribeCompletionCheckEventData)
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \
ContentType
from app.utils.media import (
build_media_key,
normalize_media_source,
parse_media_key,
resolve_media_identity,
)
subscribe_interaction_manager = SlashInteractionManager()
@@ -55,9 +61,61 @@ def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
meta.year = subscribe.year
meta.begin_season = subscribe.season
meta.type = MediaType(subscribe.type)
meta.tmdbid = subscribe.tmdbid
meta.doubanid = subscribe.doubanid
meta.bangumiid = subscribe.bangumiid
meta.anilistid = subscribe.anilistid
meta.media_source = subscribe.media_source
meta.media_id = subscribe.media_id
return meta
def _media_recognize_kwargs(mediainfo: MediaInfo) -> dict:
"""从统一媒体信息构造完整的识别 ID 参数。"""
media_source, media_id = resolve_media_identity(media=mediainfo)
return {
"source": media_source,
"mediaid": media_id,
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
}
def _subscribe_recognize_kwargs(subscribe: Subscribe) -> dict:
"""从订阅记录构造完整的识别 ID 参数。"""
media_source, media_id = resolve_media_identity(media=subscribe)
return {
"source": media_source,
"mediaid": media_id,
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid,
"bangumiid": subscribe.bangumiid,
"anilistid": subscribe.anilistid,
}
def _subscribe_media_key(subscribe: Subscribe) -> Union[str, int, None]:
"""返回订阅缺失集映射使用的稳定媒体键。"""
media_source, media_id = resolve_media_identity(media=subscribe)
return build_media_key(media_source, media_id) or media_id
def _subscribe_media_keys(subscribe: Subscribe) -> List[Union[str, int]]:
"""返回新旧缺失集缓存均可识别的订阅媒体键。"""
media_source, media_id = resolve_media_identity(media=subscribe)
candidates = [
build_media_key(media_source, media_id),
subscribe.mediaid,
subscribe.tmdbid,
subscribe.doubanid,
subscribe.bangumiid,
subscribe.anilistid,
]
return [candidate for candidate in candidates if candidate not in (None, "")]
class SubscribeChain(ChainBase):
"""
订阅管理处理链
@@ -227,8 +285,14 @@ class SubscribeChain(ChainBase):
if not subscribe.best_version:
no_exists = no_exists or {}
mediakey = subscribe.tmdbid or subscribe.doubanid
left_seasons = no_exists.get(mediakey) or {}
left_seasons = next(
(
no_exists.get(media_key)
for media_key in _subscribe_media_keys(subscribe)
if no_exists.get(media_key) is not None
),
{},
)
for season_info in left_seasons.values():
if season_info.season != subscribe.season:
continue
@@ -739,10 +803,12 @@ class SubscribeChain(ChainBase):
if event_data.media_dict:
mediachain = MediaChain()
new_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
return mediachain.recognize_media(meta=_meta, tmdbid=new_id)
elif event_data.convert_type == "douban":
return mediachain.recognize_media(meta=_meta, doubanid=new_id)
if new_id is not None and event_data.convert_type:
return mediachain.recognize_media(
meta=_meta,
source=event_data.convert_type,
mediaid=str(new_id),
)
return None
@staticmethod
@@ -761,10 +827,12 @@ class SubscribeChain(ChainBase):
if event_data.media_dict:
mediachain = MediaChain()
new_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
return await mediachain.async_recognize_media(meta=_meta, tmdbid=new_id)
elif event_data.convert_type == "douban":
return await mediachain.async_recognize_media(meta=_meta, doubanid=new_id)
if new_id is not None and event_data.convert_type:
return await mediachain.async_recognize_media(
meta=_meta,
source=event_data.convert_type,
mediaid=str(new_id),
)
return None
def __get_default_kwargs(self, mtype: MediaType, **kwargs) -> dict:
@@ -815,6 +883,9 @@ class SubscribeChain(ChainBase):
username: Optional[str] = None,
message: Optional[bool] = True,
exist_ok: Optional[bool] = False,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
**kwargs) -> Tuple[Optional[int], str]:
"""
识别媒体信息并添加订阅
@@ -831,31 +902,25 @@ class SubscribeChain(ChainBase):
if season is not None:
metainfo.type = MediaType.TV
metainfo.begin_season = season
# 识别媒体信息
if settings.RECOGNIZE_SOURCE == "themoviedb":
# TMDB识别模式
if not tmdbid:
if doubanid:
# 将豆瓣信息转换为TMDB信息
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo:
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
if not media_source and not media_id and mediaid:
media_source, media_id = parse_media_key(mediaid)
if any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = self.recognize_media(
meta=metainfo,
mtype=mtype,
source=media_source,
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
episode_group=episode_group,
cache=False,
)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = self.__get_event_media(mediaid, metainfo)
else:
# 使用TMDBID识别
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid,
episode_group=episode_group, cache=False)
else:
if doubanid:
# 豆瓣识别模式,不使用缓存
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = self.__get_event_media(mediaid, metainfo)
if mediainfo:
# 豆瓣标题处理
if mediainfo and mediainfo.source != "themoviedb":
meta = MetaInfo(mediainfo.title)
mediainfo.title = meta.name
if season is None:
@@ -883,9 +948,7 @@ class SubscribeChain(ChainBase):
if not mediainfo.seasons or episode_group:
# 补充媒体信息
mediainfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
**_media_recognize_kwargs(mediainfo),
episode_group=episode_group,
cache=False)
if not mediainfo:
@@ -898,7 +961,10 @@ class SubscribeChain(ChainBase):
# 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。
total_episode = self.__apply_episodes_refresh(
current_total_episode, season=season, mediainfo=mediainfo,
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create")
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id, anilistid=mediainfo.anilist_id,
media_source=resolve_media_identity(media=mediainfo)[0],
media_id=resolve_media_identity(media=mediainfo)[1], scene="create")
if current_total_episode and total_episode < current_total_episode:
total_episode = current_total_episode
if not total_episode:
@@ -923,6 +989,13 @@ class SubscribeChain(ChainBase):
mediainfo.douban_id = doubanid
if bangumiid:
mediainfo.bangumi_id = bangumiid
if anilistid:
mediainfo.anilist_id = anilistid
media_source, media_id = resolve_media_identity(
media=mediainfo, source=media_source, media_id=media_id
)
kwargs.update({"media_source": media_source, "media_id": media_id})
# 添加订阅
kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs))
@@ -979,6 +1052,9 @@ class SubscribeChain(ChainBase):
"tvdbid": mediainfo.tvdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"season": metainfo.begin_season,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -1002,6 +1078,9 @@ class SubscribeChain(ChainBase):
username: Optional[str] = None,
message: Optional[bool] = True,
exist_ok: Optional[bool] = False,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
**kwargs) -> Tuple[Optional[int], str]:
"""
异步识别媒体信息并添加订阅
@@ -1018,31 +1097,25 @@ class SubscribeChain(ChainBase):
if season is not None:
metainfo.type = MediaType.TV
metainfo.begin_season = season
# 识别媒体信息
if settings.RECOGNIZE_SOURCE == "themoviedb":
# TMDB识别模式
if not tmdbid:
if doubanid:
# 将豆瓣信息转换为TMDB信息
tmdbinfo = await MediaChain().async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo:
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
if not media_source and not media_id and mediaid:
media_source, media_id = parse_media_key(mediaid)
if any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = await self.async_recognize_media(
meta=metainfo,
mtype=mtype,
source=media_source,
mediaid=media_id,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
episode_group=episode_group,
cache=False,
)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = await self.__async_get_event_meida(mediaid, metainfo)
else:
# 使用TMDBID识别
mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid,
episode_group=episode_group, cache=False)
else:
if doubanid:
# 豆瓣识别模式,不使用缓存
mediainfo = await self.async_recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = await self.__async_get_event_meida(mediaid, metainfo)
if mediainfo:
# 豆瓣标题处理
if mediainfo and mediainfo.source != "themoviedb":
meta = MetaInfo(mediainfo.title)
mediainfo.title = meta.name
if season is None:
@@ -1070,9 +1143,7 @@ class SubscribeChain(ChainBase):
if not mediainfo.seasons or episode_group:
# 补充媒体信息
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
**_media_recognize_kwargs(mediainfo),
episode_group=episode_group,
cache=False)
if not mediainfo:
@@ -1085,7 +1156,10 @@ class SubscribeChain(ChainBase):
# 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。
total_episode = await self.__async_apply_episodes_refresh(
current_total_episode, season=season, mediainfo=mediainfo,
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create")
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id, anilistid=mediainfo.anilist_id,
media_source=resolve_media_identity(media=mediainfo)[0],
media_id=resolve_media_identity(media=mediainfo)[1], scene="create")
if current_total_episode and total_episode < current_total_episode:
total_episode = current_total_episode
if not total_episode:
@@ -1110,6 +1184,13 @@ class SubscribeChain(ChainBase):
mediainfo.douban_id = doubanid
if bangumiid:
mediainfo.bangumi_id = bangumiid
if anilistid:
mediainfo.anilist_id = anilistid
media_source, media_id = resolve_media_identity(
media=mediainfo, source=media_source, media_id=media_id
)
kwargs.update({"media_source": media_source, "media_id": media_id})
# 列新默认参数
kwargs.update(self.__get_default_kwargs(mediainfo.type, **kwargs))
@@ -1166,6 +1247,9 @@ class SubscribeChain(ChainBase):
"tvdbid": mediainfo.tvdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"season": metainfo.begin_season,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -1180,9 +1264,16 @@ class SubscribeChain(ChainBase):
"""
判断订阅是否已存在
"""
if SubscribeOper().exists(tmdbid=mediainfo.tmdb_id,
media_source, media_id = resolve_media_identity(media=mediainfo)
if SubscribeOper().exists(
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=meta.begin_season if meta else None):
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
media_source=media_source,
media_id=media_id,
season=meta.begin_season if meta else None,
):
return True
return False
@@ -1239,7 +1330,7 @@ class SubscribeChain(ChainBase):
"current": subscribe.id,
},
)
mediakey = subscribe.tmdbid or subscribe.doubanid
mediakey = _subscribe_media_key(subscribe)
custom_word_list = subscribe.custom_words.split("\n") if subscribe.custom_words else None
search_attempted = False
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
@@ -1267,11 +1358,13 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
mediainfo: MediaInfo = self.recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False)
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -1463,9 +1556,9 @@ class SubscribeChain(ChainBase):
"""
判断是否应完成订阅
"""
mediakey = subscribe.tmdbid or subscribe.doubanid
media_keys = _subscribe_media_keys(subscribe)
# 是否有剩余集
no_lefts = not lefts or not lefts.get(mediakey)
no_lefts = not lefts or not any(lefts.get(media_key) for media_key in media_keys)
if downloads and meta.type == MediaType.TV:
self.__record_subscribe_download_facts(subscribe=subscribe, mediainfo=mediainfo, downloads=downloads)
elif downloads:
@@ -1638,8 +1731,10 @@ class SubscribeChain(ChainBase):
if global_vars.is_system_stopped:
break
# 如果种子未识别且失败次数未超过3次,尝试识别
if (not context.media_info or (not context.media_info.tmdb_id
and not context.media_info.douban_id)) and context.media_recognize_fail_count < 3:
if (
not context.media_info
or not resolve_media_identity(media=context.media_info)[1]
) and context.media_recognize_fail_count < 3:
logger.debug(
f'尝试重新识别种子:{context.torrent_info.title},当前失败次数:{context.media_recognize_fail_count}/3')
re_mediainfo = MediaChain().recognize_by_meta(
@@ -1653,7 +1748,7 @@ class SubscribeChain(ChainBase):
context.media_info = re_mediainfo
context.match_source = self.__get_media_id_match_source(re_mediainfo)
context.candidate_recognized = bool(
re_mediainfo.tmdb_id or re_mediainfo.douban_id
resolve_media_identity(media=re_mediainfo)[1]
)
context.media_info_is_target = False
# 重置失败次数
@@ -1698,7 +1793,7 @@ class SubscribeChain(ChainBase):
},
)
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
mediakey = subscribe.tmdbid or subscribe.doubanid
mediakey = _subscribe_media_key(subscribe)
try:
meta = build_subscribe_meta(subscribe)
except ValueError:
@@ -1709,11 +1804,13 @@ class SubscribeChain(ChainBase):
if subscribe.sites:
domains = SiteOper().get_domains_by_ids(subscribe.sites)
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
mediainfo: MediaInfo = self.recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False)
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -1787,13 +1884,14 @@ class SubscribeChain(ChainBase):
_context.media_info = torrent_mediainfo
_context.match_source = self.__get_media_id_match_source(torrent_mediainfo)
_context.candidate_recognized = bool(
torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id
resolve_media_identity(media=torrent_mediainfo)[1]
)
_context.media_info_is_target = False
# 如果仍然没有识别到媒体信息,尝试标题匹配
if not torrent_mediainfo or (
not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
if not torrent_mediainfo or not resolve_media_identity(
media=torrent_mediainfo
)[1]:
logger.debug(
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
if TorrentHelper.match_torrent(mediainfo=mediainfo,
@@ -1812,7 +1910,9 @@ class SubscribeChain(ChainBase):
continue
# 直接比对媒体信息
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
if torrent_mediainfo and resolve_media_identity(
media=torrent_mediainfo
)[1]:
if torrent_mediainfo.type != mediainfo.type:
continue
if torrent_mediainfo.tmdb_id \
@@ -2022,11 +2122,13 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
mediainfo: MediaInfo = self.recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False)
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -2040,6 +2142,10 @@ class SubscribeChain(ChainBase):
total_episode = self.__apply_episodes_refresh(
current_total_episode, season=subscribe.season, mediainfo=mediainfo,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
anilistid=subscribe.anilistid,
media_source=subscribe.media_source,
media_id=subscribe.media_id,
subscribe_id=subscribe.id, scene="refresh")
old_total_episode = subscribe.total_episode or 0
if total_episode and total_episode < old_total_episode:
@@ -2048,7 +2154,7 @@ class SubscribeChain(ChainBase):
candidate_total=total_episode,
meta=meta,
mediainfo=mediainfo,
mediakey=subscribe.tmdbid or subscribe.doubanid,
mediakey=_subscribe_media_key(subscribe),
)
if total_episode and total_episode != old_total_episode:
progress_update = self.__prepare_total_episode_change_fields(
@@ -2079,6 +2185,12 @@ class SubscribeChain(ChainBase):
"description": mediainfo.overview,
"imdbid": mediainfo.imdb_id,
"tvdbid": mediainfo.tvdb_id,
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": resolve_media_identity(media=mediainfo)[0],
"media_id": resolve_media_identity(media=mediainfo)[1],
"total_episode": total_episode,
}
update_data.update(progress_update)
@@ -2103,8 +2215,13 @@ class SubscribeChain(ChainBase):
if not source_keyword:
return None
# 只保留需要的字段动态获取订阅
valid_fields = {k: v for k, v in source_keyword.items()
if k in ["type", "season", "tmdbid", "doubanid", "bangumiid"]}
valid_fields = {
k: v for k, v in source_keyword.items()
if k in [
"type", "season", "tmdbid", "doubanid", "bangumiid",
"anilistid", "media_source", "media_id",
]
}
# 暂时不考虑订阅历史, 若有必要再添加
return SubscribeOper().get_by(**valid_fields)
@@ -2145,11 +2262,19 @@ class SubscribeChain(ChainBase):
# 订阅已存在则跳过
if subscribeoper.exists(tmdbid=share_sub.get("tmdbid"),
doubanid=share_sub.get("doubanid"),
bangumiid=share_sub.get("bangumiid"),
anilistid=share_sub.get("anilistid"),
media_source=share_sub.get("media_source"),
media_id=share_sub.get("media_id"),
season=share_sub.get("season")):
continue
# 已经订阅过跳过
if subscribeoper.exist_history(tmdbid=share_sub.get("tmdbid"),
doubanid=share_sub.get("doubanid"),
bangumiid=share_sub.get("bangumiid"),
anilistid=share_sub.get("anilistid"),
media_source=share_sub.get("media_source"),
media_id=share_sub.get("media_id"),
season=share_sub.get("season")):
continue
# 去除无效属性
@@ -2160,7 +2285,13 @@ class SubscribeChain(ChainBase):
subscribe_in = schemas.Subscribe(**share_sub)
mtype = MediaType(subscribe_in.type)
# 豆瓣标题处理
if subscribe_in.doubanid or subscribe_in.bangumiid:
if (
subscribe_in.doubanid
or subscribe_in.bangumiid
or subscribe_in.anilistid
or normalize_media_source(subscribe_in.media_source)
not in (None, "themoviedb")
):
meta = MetaInfo(subscribe_in.name)
subscribe_in.name = meta.name
if subscribe_in.season is None:
@@ -2177,6 +2308,9 @@ class SubscribeChain(ChainBase):
season=subscribe_in.season,
doubanid=subscribe_in.doubanid,
bangumiid=subscribe_in.bangumiid,
anilistid=subscribe_in.anilistid,
media_source=subscribe_in.media_source,
media_id=subscribe_in.media_id,
username="订阅分享",
best_version=subscribe_in.best_version,
save_path=subscribe_in.save_path,
@@ -2239,25 +2373,25 @@ class SubscribeChain(ChainBase):
except ValueError:
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
if mtype == MediaType.MOVIE:
mediainfo: MediaInfo = await self.async_recognize_media(mtype=mtype,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
# 先按订阅的主媒体身份预热对应数据源,再对 TMDB 额外预热分集接口。
mediainfo: MediaInfo = await self.async_recognize_media(
mtype=mtype,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False)
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
f'未识别到媒体信息,标题:{subscribe.name}'
f'媒体源:{subscribe.media_source},媒体ID{subscribe.media_id}')
continue
else:
episodes = await TmdbChain().async_tmdb_episodes(tmdbid=subscribe.tmdbid,
if mtype == MediaType.TV and mediainfo.source == "themoviedb" and mediainfo.tmdb_id:
episodes = await TmdbChain().async_tmdb_episodes(tmdbid=mediainfo.tmdb_id,
season=subscribe.season,
episode_group=subscribe.episode_group)
if not episodes:
logger.warn(
f'未识别到季集信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid},豆瓣ID{subscribe.doubanid},季:{subscribe.season}')
f'未识别到季集信息,标题:{subscribe.name}tmdbid{mediainfo.tmdb_id},季:{subscribe.season}')
continue
if progress_callback:
progress_callback(
@@ -2289,6 +2423,21 @@ class SubscribeChain(ChainBase):
if subscribe.doubanid and mediainfo.douban_id \
and mediainfo.douban_id != subscribe.doubanid:
continue
subscribe_bangumiid = subscribe.bangumiid
media_bangumiid = mediainfo.bangumi_id
if subscribe_bangumiid and media_bangumiid \
and media_bangumiid != subscribe_bangumiid:
continue
subscribe_anilistid = subscribe.anilistid
media_anilistid = mediainfo.anilist_id
if subscribe_anilistid and media_anilistid \
and media_anilistid != subscribe_anilistid:
continue
subscribe_source, subscribe_media_id = resolve_media_identity(media=subscribe)
media_source, media_id = resolve_media_identity(media=mediainfo)
if subscribe_source == media_source and subscribe_media_id and media_id \
and subscribe_media_id != media_id:
continue
items = []
if mediainfo.type == MediaType.TV:
# 电视剧有集数,使用 episode_list
@@ -2419,15 +2568,13 @@ class SubscribeChain(ChainBase):
mediainfo = self.recognize_media(
meta=meta,
mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo:
return {"scene": scene, "updated": False, "fields": [], "reason": "recognize_failed"}
mediakey = subscribe.tmdbid or subscribe.doubanid
mediakey = _subscribe_media_key(subscribe)
exist_flag, no_exists = self.resolve_subscribe_missing(
subscribe=subscribe,
meta=meta,
@@ -3636,7 +3783,14 @@ class SubscribeChain(ChainBase):
# 所有下载记录
downloadhis = DownloadHistoryOper()
download_his = downloadhis.get_by_mediaid(tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
download_his = downloadhis.get_by_mediaid(
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
anilistid=subscribe.anilistid,
media_source=subscribe.media_source,
media_id=subscribe.media_id,
)
if download_his:
for his in download_his:
# 查询下载文件
@@ -3669,11 +3823,13 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
return subscribe_info
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
mediainfo: MediaInfo = self.recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False)
cache=False,
)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -3837,7 +3993,7 @@ class SubscribeChain(ChainBase):
priority>0 的目标集视为已满足默认 False 保持主程序洗版完成需 priority==100
的搜索/完成口径
"""
mediakey = mediakey or subscribe.tmdbid or subscribe.doubanid
mediakey = mediakey or _subscribe_media_key(subscribe)
effective_total_episode = self.__resolve_effective_total_episode(subscribe, mediainfo)
if not subscribe.best_version:
@@ -3923,7 +4079,7 @@ class SubscribeChain(ChainBase):
if subscribe.type != MediaType.TV.value or self.__is_full_best_version_enabled(subscribe):
return candidate_total
target_key = mediakey or subscribe.tmdbid or subscribe.doubanid
target_key = mediakey or _subscribe_media_key(subscribe)
target_season = subscribe.season
target_start = subscribe.start_episode or 1
snapshot = copy.copy(subscribe)
@@ -3944,7 +4100,14 @@ class SubscribeChain(ChainBase):
return old_total
if not isinstance(no_exists, dict):
return candidate_total
seasons = no_exists.get(target_key)
seasons = next(
(
no_exists.get(media_key)
for media_key in [target_key, *_subscribe_media_keys(subscribe)]
if no_exists.get(media_key) is not None
),
None,
)
if not isinstance(seasons, dict):
return candidate_total
missing_info = seasons.get(target_season)
@@ -3994,19 +4157,25 @@ class SubscribeChain(ChainBase):
mediainfo: Optional[MediaInfo] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
subscribe_id: Optional[int] = None,
scene: Optional[str] = None) -> int:
"""
发送订阅总集数推算事件允许外部把主程序本次识别到的 TMDB 当前季总集数向上覆盖
发送订阅总集数推算事件允许外部把当前数据源识别到的季总集数向上覆盖
用途插件在"待定集数"等场景经事件注入 total_episode
无监听者或外部未覆盖时返回入参原值保证零行为变更
:param current_total: 主程序本次识别到的 TMDB 当前季总集数
:param current_total: 主程序本次识别到的当前季总集数
:param season: 季号
:return: 最终采用的总集数
"""
event_data = SubscribeEpisodesRefreshEventData(
tmdbid=tmdbid, doubanid=doubanid, season=season, mediainfo=mediainfo,
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid,
anilistid=anilistid, media_source=media_source, media_id=media_id,
season=season, mediainfo=mediainfo,
current_total_episode=current_total, subscribe_id=subscribe_id, scene=scene)
event = eventmanager.send_event(ChainEventType.SubscribeEpisodesRefresh, event_data)
if event and event.event_data:
@@ -4021,13 +4190,19 @@ class SubscribeChain(ChainBase):
mediainfo: Optional[MediaInfo] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
subscribe_id: Optional[int] = None,
scene: Optional[str] = None) -> int:
"""
__apply_episodes_refresh 的异步版本
"""
event_data = SubscribeEpisodesRefreshEventData(
tmdbid=tmdbid, doubanid=doubanid, season=season, mediainfo=mediainfo,
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid,
anilistid=anilistid, media_source=media_source, media_id=media_id,
season=season, mediainfo=mediainfo,
current_total_episode=current_total, subscribe_id=subscribe_id, scene=scene)
event = await eventmanager.async_send_event(ChainEventType.SubscribeEpisodesRefresh, event_data)
if event and event.event_data:
@@ -4059,6 +4234,10 @@ class SubscribeChain(ChainBase):
new_total_episode = self.__apply_episodes_refresh(
current_total_episode, season=subscribe.season, mediainfo=mediainfo,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid,
bangumiid=subscribe.bangumiid,
anilistid=subscribe.anilistid,
media_source=subscribe.media_source,
media_id=subscribe.media_id,
subscribe_id=subscribe.id, scene="precheck")
old_total_episode = subscribe.total_episode or 0
if meta is not None and new_total_episode and new_total_episode < old_total_episode:
@@ -4113,6 +4292,12 @@ class SubscribeChain(ChainBase):
return "tmdbid"
if mediainfo and mediainfo.douban_id:
return "doubanid"
if mediainfo and mediainfo.bangumi_id:
return "bangumiid"
if mediainfo and mediainfo.anilist_id:
return "anilistid"
if mediainfo and all(resolve_media_identity(media=mediainfo)):
return "plugin"
return "unknown"
@staticmethod
@@ -4150,7 +4335,10 @@ class SubscribeChain(ChainBase):
'imdbid': subscribe.imdbid,
'tvdbid': subscribe.tvdbid,
'doubanid': subscribe.doubanid,
'bangumiid': subscribe.bangumiid
'bangumiid': subscribe.bangumiid,
'anilistid': subscribe.anilistid,
'media_source': subscribe.media_source,
'media_id': subscribe.media_id,
}
return f"Subscribe|{json.dumps(source_keyword, ensure_ascii=False)}"
+72 -6
View File
@@ -17,6 +17,7 @@ from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import Notification
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType
from app.utils.media import resolve_media_identity
from app.utils.string import StringUtils
@@ -131,12 +132,21 @@ class TorrentsChain(ChainBase):
subscribe_tmdbid = cls._normalize_id(getattr(subscribe, "tmdbid", None))
subscribe_doubanid = cls._normalize_id(getattr(subscribe, "doubanid", None))
subscribe_bangumiid = cls._normalize_id(subscribe.bangumiid)
subscribe_anilistid = cls._normalize_id(subscribe.anilistid)
context_tmdbids = cls._context_tmdb_ids(context)
context_doubanids = cls._context_douban_ids(context)
context_bangumiids = cls._context_bangumi_ids(context)
context_anilistids = cls._context_anilist_ids(context)
subscribe_identity = resolve_media_identity(media=subscribe)
context_identities = cls._context_media_identities(context)
return bool(
subscribe_tmdbid and subscribe_tmdbid in context_tmdbids
or subscribe_doubanid and subscribe_doubanid in context_doubanids
or subscribe_bangumiid and subscribe_bangumiid in context_bangumiids
or subscribe_anilistid and subscribe_anilistid in context_anilistids
or all(subscribe_identity) and subscribe_identity in context_identities
)
@classmethod
@@ -181,6 +191,9 @@ class TorrentsChain(ChainBase):
title=getattr(subscribe, "name", None),
tmdb_id=getattr(subscribe, "tmdbid", None),
douban_id=getattr(subscribe, "doubanid", None),
bangumi_id=subscribe.bangumiid,
anilist_id=subscribe.anilistid,
source=subscribe.media_source,
season=getattr(subscribe, "season", None),
)
@@ -255,7 +268,26 @@ class TorrentsChain(ChainBase):
"""
判断候选是否已经带有明确媒体 ID
"""
return bool(TorrentsChain._context_tmdb_ids(context) or TorrentsChain._context_douban_ids(context))
return bool(
TorrentsChain._context_tmdb_ids(context)
or TorrentsChain._context_douban_ids(context)
or TorrentsChain._context_bangumi_ids(context)
or TorrentsChain._context_anilist_ids(context)
or TorrentsChain._context_media_identities(context)
)
@staticmethod
def _context_media_identities(context: Context) -> set[tuple[str, str]]:
"""提取候选媒体信息与标题标签中的通用媒体身份。"""
identities = {
resolve_media_identity(media=getattr(context, "media_info", None)),
resolve_media_identity(media=getattr(context, "meta_info", None)),
}
return {
(source, media_id)
for source, media_id in identities
if source and media_id
}
@staticmethod
def _context_tmdb_ids(context: Context) -> set[str]:
@@ -285,6 +317,30 @@ class TorrentsChain(ChainBase):
) if value
}
@staticmethod
def _context_bangumi_ids(context: Context) -> set[str]:
"""提取候选已有 Bangumi ID,兼容媒体信息与标题显式标签。"""
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
return {
value for value in (
TorrentsChain._normalize_id(media_info.bangumi_id if media_info else None),
TorrentsChain._normalize_id(meta_info.bangumiid if meta_info else None),
) if value
}
@staticmethod
def _context_anilist_ids(context: Context) -> set[str]:
"""提取候选已有 AniList ID,兼容媒体信息与标题显式标签。"""
media_info = getattr(context, "media_info", None)
meta_info = getattr(context, "meta_info", None)
return {
value for value in (
TorrentsChain._normalize_id(media_info.anilist_id if media_info else None),
TorrentsChain._normalize_id(meta_info.anilistid if meta_info else None),
) if value
}
@staticmethod
def _normalize_id(value) -> Optional[str]:
"""
@@ -556,7 +612,9 @@ class TorrentsChain(ChainBase):
mediainfo = MediaInfo()
# 清理多余数据,减少内存占用
mediainfo.clear()
candidate_recognized = bool(mediainfo and (mediainfo.tmdb_id or mediainfo.douban_id))
candidate_recognized = bool(
mediainfo and all(resolve_media_identity(media=mediainfo))
)
match_source = self._get_media_id_match_source(mediainfo)
# 上下文
context = Context(
@@ -569,7 +627,7 @@ class TorrentsChain(ChainBase):
media_info_is_target=False,
)
# 如果未识别到媒体信息,设置初始失败次数为1
if not mediainfo or (not mediainfo.tmdb_id and not mediainfo.douban_id):
if not mediainfo or not all(resolve_media_identity(media=mediainfo)):
context.media_recognize_fail_count = 1
# 添加到缓存
if not torrents_cache.get(domain):
@@ -616,14 +674,16 @@ class TorrentsChain(ChainBase):
if "media_recognize_fail_count" not in context_fields:
context.media_recognize_fail_count = 0
# 如果媒体信息未识别,设置初始失败次数
if (not context.media_info or
(not context.media_info.tmdb_id and not context.media_info.douban_id)):
if not context.media_info or not all(
resolve_media_identity(media=context.media_info)
):
context.media_recognize_fail_count = 1
if "resource_source" not in context_fields:
context.resource_source = "spider" if stype == "spider" else "rss"
if "candidate_recognized" not in context_fields:
context.candidate_recognized = bool(
context.media_info and (context.media_info.tmdb_id or context.media_info.douban_id)
context.media_info
and all(resolve_media_identity(media=context.media_info))
)
if "match_source" not in context_fields:
context.match_source = (
@@ -642,6 +702,12 @@ class TorrentsChain(ChainBase):
return "tmdbid"
if mediainfo and mediainfo.douban_id:
return "doubanid"
if mediainfo and mediainfo.bangumi_id:
return "bangumiid"
if mediainfo and mediainfo.anilist_id:
return "anilistid"
if mediainfo and all(resolve_media_identity(media=mediainfo)):
return "plugin"
return "unknown"
def __renew_rss_url(self, domain: str, site: dict):
+42 -12
View File
@@ -55,6 +55,7 @@ from app.schemas.types import (
ContentType,
)
from app.utils.mixins import ConfigReloadMixin
from app.utils.media import parse_media_key
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
@@ -142,12 +143,12 @@ class JobManager:
if not media:
return None, season
media_ids = {
"themoviedb": getattr(media, "tmdb_id", None),
"douban": getattr(media, "douban_id", None),
"bangumi": getattr(media, "bangumi_id", None),
"anilist": getattr(media, "anilist_id", None),
"themoviedb": media.tmdb_id,
"douban": media.douban_id,
"bangumi": media.bangumi_id,
"anilist": media.anilist_id,
}
source = getattr(media, "source", None)
source = media.source
if not source or media_ids.get(source) is None:
source = next(
(name for name, media_id in media_ids.items() if media_id is not None),
@@ -1591,7 +1592,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
task.meta, download_history
)
if (
(download_history.tmdbid or download_history.doubanid)
(
download_history.media_id
or download_history.tmdbid
or download_history.doubanid
or download_history.bangumiid
or download_history.anilistid
)
and not history_year_conflict
):
# 下载记录中已存在识别信息
@@ -1599,6 +1606,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
mtype=MediaType(download_history.type),
tmdbid=download_history.tmdbid,
doubanid=download_history.doubanid,
bangumiid=download_history.bangumiid,
anilistid=download_history.anilistid,
source=download_history.media_source,
mediaid=download_history.media_id,
episode_group=download_history.episode_group,
)
need_obtain_images = True
@@ -2096,6 +2107,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
mtype=mtype,
tmdbid=downloadhis.tmdbid,
doubanid=downloadhis.doubanid,
bangumiid=downloadhis.bangumiid,
anilistid=downloadhis.anilistid,
source=downloadhis.media_source,
mediaid=downloadhis.media_id,
episode_group=downloadhis.episode_group,
)
if mediainfo:
@@ -3332,7 +3347,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
source: Optional[str] = None,
):
"""
远程重新整理参数 历史记录ID TMDBID|类型
远程重新整理参数 历史记录ID 来源前缀:媒体ID|类型
"""
def args_error():
@@ -3340,7 +3355,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
Notification(
channel=channel,
source=source,
title="请输入正确的命令格式:/redo [id] 或 /redo [id] [tmdbid/豆瓣id]|[类型]"
title="请输入正确的命令格式:/redo [id] 或 /redo [id] [来源前缀:媒体ID]|[类型]"
"[id] 为整理记录编号",
userid=userid,
save_history=False,
@@ -3374,7 +3389,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
)
)
return
# TMDBID/豆瓣ID
# 带来源前缀的媒体 ID;旧格式继续兼容纯数字 TMDB ID 和非数字豆瓣 ID
id_strs = arg_strs[1].split("|")
media_id = id_strs[0]
if not logid.isdigit():
@@ -3434,7 +3449,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
根据历史记录重新识别整理只支持简单条件
:param logid: 历史记录ID
:param mtype: 媒体类型
:param mediaid: TMDB ID/豆瓣ID
:param mediaid: 带来源前缀的媒体 ID或旧格式 TMDB/豆瓣 ID
"""
# 查询历史记录
history: TransferHistory = TransferHistoryOper().get(logid)
@@ -3447,10 +3462,19 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return False, f"源目录不存在:{src_path}"
# 查询媒体信息
if mtype and mediaid:
media_source, source_media_id = parse_media_key(mediaid)
if media_source and source_media_id:
mediainfo = self.recognize_media(
mtype=mtype,
source=media_source,
mediaid=source_media_id,
episode_group=history.episode_group,
)
else:
mediainfo = self.recognize_media(
mtype=mtype,
tmdbid=int(mediaid) if str(mediaid).isdigit() else None,
doubanid=mediaid,
doubanid=mediaid if not str(mediaid).isdigit() else None,
episode_group=history.episode_group,
)
if mediainfo:
@@ -3514,6 +3538,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
preview: Optional[bool] = False,
sync_extra_files: Optional[bool] = True,
cleanup_dest_fileitem: Optional[FileItem] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
) -> Tuple[bool, Union[str, dict]]:
"""
手动整理支持复杂条件带进度显示
@@ -3522,6 +3548,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param target_path: 目标路径
:param tmdbid: TMDB ID
:param doubanid: 豆瓣ID
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param media_source: 媒体数据源
:param media_id: 数据源原生ID
:param mtype: 媒体类型
@@ -3542,12 +3570,14 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
"""
logger.info(f"手动整理:{fileitem.path} ...")
if tmdbid or doubanid or media_id:
if tmdbid or doubanid or bangumiid or anilistid or media_id:
# 有输入媒体ID时单个识别
# 识别媒体信息
mediainfo: MediaInfo = MediaChain().recognize_media(
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=media_source,
mediaid=media_id,
mtype=mtype,
+10 -3
View File
@@ -254,6 +254,8 @@ class MediaInfo:
recognize_cache_hit = False
# 来源:themoviedb、douban、bangumi、anilist
source: str = None
# 当前数据源原生ID,主要用于保留插件自定义数据源身份
media_id: str = None
# 请求级刮削来源;为空时使用系统设置
scrape_source: str = None
# 类型 电影、电视剧
@@ -1062,14 +1064,19 @@ class MediaInfo:
dicts["douban_info"] = None
dicts["bangumi_info"] = None
dicts["anilist_info"] = None
dicts["mediaid_prefix"] = self.source
source_ids = {
"themoviedb": self.tmdb_id,
"douban": self.douban_id,
"bangumi": self.bangumi_id,
"anilist": self.anilist_id,
}
media_id = source_ids.get(self.source)
media_source = self.source or next(
(source for source, media_id in source_ids.items() if media_id is not None),
None,
)
dicts["source"] = media_source
dicts["mediaid_prefix"] = media_source
media_id = self.media_id or source_ids.get(media_source)
dicts["media_id"] = str(media_id) if media_id is not None else None
return dicts
@@ -1111,7 +1118,7 @@ class Context:
media_recognize_fail_count: int = 0
# 候选资源来源:rss、spider、search、unknown。
resource_source: str = "unknown"
# 候选匹配来源:tmdbid、doubanid、imdbid、title、plugin、unknown。
# 候选匹配来源:tmdbid、doubanid、bangumiid、anilistid、imdbid、title、plugin、unknown。
match_source: str = "unknown"
# 候选自身是否已经识别出有效媒体 ID。
candidate_recognized: bool = False
+4 -1
View File
@@ -23,7 +23,10 @@ def should_use_parent_title_for_file_stem(
"""
if not file_meta.isfile or not stem or not parent_dir_name:
return False
if file_meta.tmdbid or file_meta.doubanid or file_meta.media_id:
if any((
file_meta.tmdbid, file_meta.doubanid,
file_meta.bangumiid, file_meta.anilistid, file_meta.media_id,
)):
return False
if not PARENT_LATIN_TITLE_RE.search(parent_dir_name):
return False
+2
View File
@@ -97,6 +97,8 @@ class MetaBase(object):
# 附加信息
tmdbid: int = None
doubanid: str = None
bangumiid: int = None
anilistid: int = None
media_source: Optional[str] = None
media_id: Optional[str] = None
episode_group: Optional[str] = None
+18 -2
View File
@@ -34,13 +34,29 @@ class DownloadHistoryOper(DbOper):
if history and history.download_hash
}
def get_by_mediaid(self, tmdbid: int, doubanid: str) -> List[DownloadHistory]:
def get_by_mediaid(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
) -> List[DownloadHistory]:
"""
按媒体ID查询下载记录
:param tmdbid: tmdbid
:param doubanid: doubanid
:param bangumiid: Bangumi ID
:param anilistid: AniList ID
:param media_source: 媒体数据源
:param media_id: 数据源原生 ID
"""
return DownloadHistory.get_by_mediaid(self._db, tmdbid=tmdbid, doubanid=doubanid)
return DownloadHistory.get_by_mediaid(
self._db,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
)
def add(self, **kwargs):
"""
+8
View File
@@ -24,6 +24,13 @@ class DownloadFailure(Base):
tmdbid = Column(Integer)
# 豆瓣ID
doubanid = Column(String)
# Bangumi ID
bangumiid = Column(Integer)
# AniList ID
anilistid = Column(Integer)
# 统一媒体数据源与原生ID
media_source = Column(String)
media_id = Column(String)
# Sxx
seasons = Column(String)
# Exx
@@ -57,6 +64,7 @@ class DownloadFailure(Base):
Index("ux_downloadfailure_fingerprint", "fingerprint", unique=True),
Index("ix_downloadfailure_next_retry_at", "next_retry_at"),
Index("ix_downloadfailure_media_site", "type", "tmdbid", "doubanid", "site"),
Index("ix_downloadfailure_media_identity_site", "type", "media_source", "media_id", "site"),
)
@classmethod
+26 -11
View File
@@ -31,6 +31,10 @@ class DownloadHistory(Base):
imdbid = Column(String)
tvdbid = Column(Integer)
doubanid = Column(String)
bangumiid = Column(Integer, index=True)
anilistid = Column(Integer, index=True)
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# Sxx
seasons = Column(String)
# Exx
@@ -67,6 +71,7 @@ class DownloadHistory(Base):
__table_args__ = (
Index('ix_downloadhistory_download_hash_date', 'download_hash', 'date'),
Index('ix_downloadhistory_date_id', 'date', 'id'),
Index('ix_downloadhistory_media_identity', 'media_source', 'media_id'),
)
@classmethod
@@ -115,17 +120,27 @@ class DownloadHistory(Base):
@classmethod
@db_query
def get_by_mediaid(cls, db: Session, tmdbid: int, doubanid: str):
if tmdbid:
return (
db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()
)
elif doubanid:
return (
db.query(DownloadHistory)
.filter(DownloadHistory.doubanid == doubanid)
.all()
)
def get_by_mediaid(
cls, db: Session, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None,
):
"""按统一媒体身份或兼容 ID 查询下载历史。"""
query = db.query(DownloadHistory)
if media_source and media_id:
return query.filter(
DownloadHistory.media_source == media_source,
DownloadHistory.media_id == str(media_id),
).all()
if tmdbid is not None:
return query.filter(DownloadHistory.tmdbid == tmdbid).all()
if doubanid:
return query.filter(DownloadHistory.doubanid == doubanid).all()
if bangumiid is not None:
return query.filter(DownloadHistory.bangumiid == bangumiid).all()
if anilistid is not None:
return query.filter(DownloadHistory.anilistid == anilistid).all()
return []
@classmethod
+129 -84
View File
@@ -26,7 +26,10 @@ class Subscribe(Base):
tvdbid = Column(Integer)
doubanid = Column(String, index=True)
bangumiid = Column(Integer, index=True)
anilistid = Column(Integer, index=True)
mediaid = Column(String, index=True)
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# 季号
season = Column(Integer)
# 海报
@@ -94,80 +97,116 @@ class Subscribe(Base):
__table_args__ = (
Index('ix_subscribe_type_date', 'type', 'date'),
Index('ix_subscribe_media_identity', 'media_source', 'media_id'),
)
@classmethod
def _identity_condition(
cls,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
):
"""按统一媒体身份优先级构造订阅查询条件。"""
if media_source and media_id:
return (cls.media_source == media_source) & (cls.media_id == str(media_id))
if tmdbid is not None:
return cls.tmdbid == tmdbid
if doubanid:
return cls.doubanid == doubanid
if bangumiid is not None:
return cls.bangumiid == bangumiid
if anilistid is not None:
return cls.anilistid == anilistid
return None
@classmethod
@db_query
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None):
if tmdbid:
if season is not None:
return db.query(cls).filter(cls.tmdbid == tmdbid,
cls.season == season).first()
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
elif doubanid:
return db.query(cls).filter(cls.doubanid == doubanid).first()
def exists(
cls, db: Session, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""按媒体身份与季号查询已有订阅。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = db.query(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
@classmethod
@async_db_query
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None):
if tmdbid:
if season is not None:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
async def async_exists(
cls, db: AsyncSession, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""异步按媒体身份与季号查询已有订阅。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
else:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid)
)
elif doubanid:
result = await db.execute(
select(cls).filter(cls.doubanid == doubanid)
)
else:
if condition is None:
return None
query = select(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
return result.scalars().first()
@classmethod
@db_query
def exists_by_username(cls, db: Session, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, season: Optional[int] = None):
def exists_by_username(
cls, db: Session, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""
按订阅 owner 查询同一媒体的订阅行
"""
if not username:
return None
if tmdbid:
query = db.query(cls).filter(cls.username == username, cls.tmdbid == tmdbid)
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = db.query(cls).filter(cls.username == username, condition)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
elif doubanid:
return db.query(cls).filter(cls.username == username, cls.doubanid == doubanid).first()
return None
@classmethod
@async_db_query
async def async_exists_by_username(cls, db: AsyncSession, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, season: Optional[int] = None):
async def async_exists_by_username(
cls, db: AsyncSession, username: str, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""
异步按订阅 owner 查询同一媒体的订阅行
"""
if not username:
return None
if tmdbid:
query = select(cls).filter(cls.username == username, cls.tmdbid == tmdbid)
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = select(cls).filter(cls.username == username, condition)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
elif doubanid:
result = await db.execute(
select(cls).filter(cls.username == username, cls.doubanid == doubanid)
)
else:
return None
return result.scalars().first()
@classmethod
@@ -300,6 +339,29 @@ class Subscribe(Base):
)
return result.scalars().all()
@classmethod
@async_db_query
async def async_list_by_anilistid(cls, db: AsyncSession, anilistid: int):
"""异步按 AniList ID 查询候选订阅列表。"""
result = await db.execute(
select(cls).filter(cls.anilistid == anilistid)
)
return result.scalars().all()
@classmethod
@async_db_query
async def async_list_by_media_identity(
cls, db: AsyncSession, media_source: str, media_id: str,
):
"""异步按统一媒体身份查询候选订阅列表。"""
result = await db.execute(
select(cls).filter(
cls.media_source == media_source,
cls.media_id == str(media_id),
)
)
return result.scalars().all()
@classmethod
@db_query
def get_by_mediaid(cls, db: Session, mediaid: str):
@@ -326,62 +388,45 @@ class Subscribe(Base):
@classmethod
@db_query
def get_by(cls, db: Session, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None):
def get_by(
cls, db: Session, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
):
"""
根据条件查询订阅
"""
# TMDBID
if tmdbid:
if season is not None:
result = db.query(cls).filter(
cls.tmdbid == tmdbid, cls.type == type, cls.season == season
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
else:
result = db.query(cls).filter(cls.tmdbid == tmdbid, cls.type == type)
# 豆瓣ID
elif doubanid:
result = db.query(cls).filter(cls.doubanid == doubanid, cls.type == type)
# BangumiID
elif bangumiid:
result = db.query(cls).filter(cls.bangumiid == bangumiid, cls.type == type)
else:
if condition is None:
return None
return result.first()
query = db.query(cls).filter(condition, cls.type == type)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
@classmethod
@async_db_query
async def async_get_by(cls, db: AsyncSession, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None, bangumiid: Optional[str] = None):
async def async_get_by(
cls, db: AsyncSession, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
):
"""
根据条件查询订阅
"""
# TMDBID
if tmdbid:
if season is not None:
result = await db.execute(
select(cls).filter(
cls.tmdbid == tmdbid, cls.type == type, cls.season == season
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
)
else:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid, cls.type == type)
)
# 豆瓣ID
elif doubanid:
result = await db.execute(
select(cls).filter(cls.doubanid == doubanid, cls.type == type)
)
# BangumiID
elif bangumiid:
result = await db.execute(
select(cls).filter(cls.bangumiid == bangumiid, cls.type == type)
)
else:
if condition is None:
return None
query = select(cls).filter(condition, cls.type == type)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
return result.scalars().first()
@db_update
+58 -26
View File
@@ -25,7 +25,10 @@ class SubscribeHistory(Base):
tvdbid = Column(Integer)
doubanid = Column(String, index=True)
bangumiid = Column(Integer, index=True)
anilistid = Column(Integer, index=True)
mediaid = Column(String, index=True)
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# 季号
season = Column(Integer)
# 海报
@@ -79,6 +82,7 @@ class SubscribeHistory(Base):
__table_args__ = (
Index('ix_subscribehistory_type_date', 'type', 'date'),
Index('ix_subscribehistory_media_identity', 'media_source', 'media_id'),
)
@classmethod
@@ -128,35 +132,63 @@ class SubscribeHistory(Base):
return result.scalars().all()
@classmethod
@db_query
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None):
if tmdbid:
if season is not None:
return db.query(cls).filter(cls.tmdbid == tmdbid,
cls.season == season).first()
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
elif doubanid:
return db.query(cls).filter(cls.doubanid == doubanid).first()
def _identity_condition(
cls,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
tmdbid: Optional[int] = None,
doubanid: Optional[str] = None,
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
):
"""按统一媒体身份优先级构造订阅历史查询条件。"""
if media_source and media_id:
return (cls.media_source == media_source) & (cls.media_id == str(media_id))
if tmdbid is not None:
return cls.tmdbid == tmdbid
if doubanid:
return cls.doubanid == doubanid
if bangumiid is not None:
return cls.bangumiid == bangumiid
if anilistid is not None:
return cls.anilistid == anilistid
return None
@classmethod
@async_db_query
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None):
if tmdbid:
if season is not None:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
@db_query
def exists(
cls, db: Session, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""按媒体身份与季号查询订阅历史。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
else:
result = await db.execute(
select(cls).filter(cls.tmdbid == tmdbid)
)
elif doubanid:
result = await db.execute(
select(cls).filter(cls.doubanid == doubanid)
)
else:
if condition is None:
return None
query = db.query(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
return query.first()
@classmethod
@async_db_query
async def async_exists(
cls, db: AsyncSession, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, season: Optional[int] = None,
):
"""异步按媒体身份与季号查询订阅历史。"""
condition = cls._identity_condition(
media_source, media_id, tmdbid, doubanid, bangumiid, anilistid
)
if condition is None:
return None
query = select(cls).filter(condition)
if season is not None:
query = query.filter(cls.season == season)
result = await db.execute(query)
return result.scalars().first()
+3
View File
@@ -48,6 +48,8 @@ class TransferHistory(Base):
imdbid = Column(String)
tvdbid = Column(Integer)
doubanid = Column(String)
bangumiid = Column(Integer, index=True)
anilistid = Column(Integer, index=True)
# 统一媒体数据源与原生ID
media_source = Column(String, index=True)
media_id = Column(String, index=True)
@@ -75,6 +77,7 @@ class TransferHistory(Base):
__table_args__ = (
Index('ix_transferhistory_status_date', 'status', 'date'),
Index('ix_transferhistory_date_id', 'date', 'id'),
Index('ix_transferhistory_media_identity', 'media_source', 'media_id'),
)
@classmethod
+95 -53
View File
@@ -5,6 +5,7 @@ from app.core.context import MediaInfo
from app.db import DbOper
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.utils.media import resolve_media_identity
INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode")
@@ -31,17 +32,26 @@ class SubscribeOper(DbOper):
"""
owner_scope = bool(kwargs.pop("owner_scope", False))
username = kwargs.get("username") if owner_scope else None
media_source, media_id = resolve_media_identity(
media=mediainfo,
source=kwargs.get("media_source"),
media_id=kwargs.get("media_id"),
)
identity_params = {
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"season": kwargs.get("season"),
}
if username:
subscribe = Subscribe.exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
**identity_params)
else:
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
subscribe = Subscribe.exists(self._db, **identity_params)
kwargs.update({
"name": mediainfo.title,
"year": mediainfo.year,
@@ -51,6 +61,9 @@ class SubscribeOper(DbOper):
"tvdbid": mediainfo.tvdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"episode_group": mediainfo.episode_group,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -67,14 +80,9 @@ class SubscribeOper(DbOper):
if username:
subscribe = Subscribe.exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
**identity_params)
else:
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
subscribe = Subscribe.exists(self._db, **identity_params)
return subscribe.id, "新增订阅成功"
else:
return subscribe.id, "订阅已存在"
@@ -85,17 +93,26 @@ class SubscribeOper(DbOper):
"""
owner_scope = bool(kwargs.pop("owner_scope", False))
username = kwargs.get("username") if owner_scope else None
media_source, media_id = resolve_media_identity(
media=mediainfo,
source=kwargs.get("media_source"),
media_id=kwargs.get("media_id"),
)
identity_params = {
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"season": kwargs.get("season"),
}
if username:
subscribe = await Subscribe.async_exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
**identity_params)
else:
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
subscribe = await Subscribe.async_exists(self._db, **identity_params)
kwargs.update({
"name": mediainfo.title,
"year": mediainfo.year,
@@ -105,6 +122,9 @@ class SubscribeOper(DbOper):
"tvdbid": mediainfo.tvdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"episode_group": mediainfo.episode_group,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -121,31 +141,32 @@ class SubscribeOper(DbOper):
if username:
subscribe = await Subscribe.async_exists_by_username(self._db,
username=username,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
**identity_params)
else:
subscribe = await Subscribe.async_exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
subscribe = await Subscribe.async_exists(self._db, **identity_params)
return subscribe.id, "新增订阅成功"
else:
return subscribe.id, "订阅已存在"
def exists(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
season: Optional[int] = None) -> bool:
def exists(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
season: Optional[int] = None,
) -> bool:
"""
判断是否存在
"""
if tmdbid:
if season is not None:
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
else:
return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False
elif doubanid:
return True if Subscribe.exists(self._db, doubanid=doubanid) else False
return False
return bool(Subscribe.exists(
self._db,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
season=season,
))
def get(self, sid: int) -> Subscribe:
"""
@@ -159,19 +180,33 @@ class SubscribeOper(DbOper):
"""
return await Subscribe.async_get(self._db, rid=sid)
def get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]:
def get_by(
self, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
) -> Optional[Subscribe]:
"""
根据条件查询订阅
"""
return Subscribe.get_by(self._db, type, season, tmdbid, doubanid, bangumiid)
return Subscribe.get_by(
self._db, type, season, tmdbid, doubanid, bangumiid, anilistid,
media_source, media_id,
)
async def async_get_by(self, type: str, season: Optional[str] = None, tmdbid: Optional[int] = None,
doubanid: Optional[str] = None, bangumiid: Optional[str] = None) -> Optional[Subscribe]:
async def async_get_by(
self, type: str, season: Optional[str] = None,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
) -> Optional[Subscribe]:
"""
根据条件查询订阅
"""
return await Subscribe.async_get_by(self._db, type, season, tmdbid, doubanid, bangumiid)
return await Subscribe.async_get_by(
self._db, type, season, tmdbid, doubanid, bangumiid, anilistid,
media_source, media_id,
)
def list(self, state: Optional[str] = None) -> List[Subscribe]:
"""
@@ -261,15 +296,22 @@ class SubscribeOper(DbOper):
subscribe = SubscribeHistory(**kwargs)
subscribe.create(self._db)
def exist_history(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None, season: Optional[int] = None):
def exist_history(
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
season: Optional[int] = None,
) -> bool:
"""
判断是否存在订阅历史
"""
if tmdbid:
if season is not None:
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid, season=season) else False
else:
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid) else False
elif doubanid:
return True if SubscribeHistory.exists(self._db, doubanid=doubanid) else False
return False
return bool(SubscribeHistory.exists(
self._db,
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
season=season,
))
+6
View File
@@ -198,6 +198,8 @@ class TransferHistoryOper(DbOper):
imdbid=mediainfo.imdb_id,
tvdbid=mediainfo.tvdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
media_source=mediainfo.source,
media_id=mediainfo.to_dict().get("media_id"),
seasons=meta.season,
@@ -231,6 +233,8 @@ class TransferHistoryOper(DbOper):
imdbid=mediainfo.imdb_id,
tvdbid=mediainfo.tvdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
media_source=mediainfo.source,
media_id=mediainfo.to_dict().get("media_id"),
seasons=meta.season,
@@ -249,6 +253,8 @@ class TransferHistoryOper(DbOper):
year=meta.year,
tmdbid=meta.tmdbid,
doubanid=meta.doubanid,
bangumiid=meta.bangumiid,
anilistid=meta.anilistid,
media_source=meta.media_source,
media_id=meta.media_id,
src=fileitem.path,
+19
View File
@@ -92,6 +92,17 @@ class TemplateContextBuilder:
if not mediainfo:
return
season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None
source_ids = {
"themoviedb": mediainfo.tmdb_id,
"douban": mediainfo.douban_id,
"bangumi": mediainfo.bangumi_id,
"anilist": mediainfo.anilist_id,
}
media_source = mediainfo.source or next(
(source for source, media_id in source_ids.items() if media_id is not None),
None,
)
media_id = mediainfo.media_id or source_ids.get(media_source)
base_info = {
# 标题
"title": cls.__convert_invalid_characters(mediainfo.title),
@@ -135,6 +146,14 @@ class TemplateContextBuilder:
"imdbid": mediainfo.imdb_id,
# 豆瓣ID
"doubanid": mediainfo.douban_id,
# Bangumi ID
"bangumiid": mediainfo.bangumi_id,
# AniList ID
"anilistid": mediainfo.anilist_id,
# 当前媒体数据源
"media_source": media_source,
# 当前数据源原生ID
"media_id": str(media_id) if media_id is not None else None,
}
context.update({**base_info, **media_info})
+13 -2
View File
@@ -15,6 +15,7 @@ from app.db.workflow_oper import WorkflowOper
from app.log import logger
from app.schemas.types import MediaType, SystemConfigKey, media_type_to_agent
from app.utils.http import AsyncRequestUtils, RequestUtils
from app.utils.media import resolve_media_identity
from app.utils.system import SystemUtils
from version import APP_VERSION, FRONTEND_VERSION
@@ -1332,7 +1333,10 @@ class MoviePilotServerHelper:
tmdbid = item.get("tmdbid")
doubanid = item.get("doubanid")
bangumiid = item.get("bangumiid")
if not any([tmdbid, doubanid, bangumiid]):
anilistid = item.get("anilistid")
media_source = item.get("media_source")
media_id = item.get("media_id")
if not any([tmdbid, doubanid, bangumiid, anilistid, media_id]):
return None
return {
@@ -1340,6 +1344,9 @@ class MoviePilotServerHelper:
"tmdbid": tmdbid,
"doubanid": doubanid,
"bangumiid": bangumiid,
"anilistid": anilistid,
"source": media_source,
"mediaid": media_id,
"season": item.get("season"),
}
@@ -1486,7 +1493,8 @@ class MoviePilotServerHelper:
media_type = cls._extract_media_type(meta=meta, mediainfo=mediainfo)
if not keyword or not media_type:
return None
if not any([mediainfo.tmdb_id, mediainfo.douban_id, mediainfo.bangumi_id]):
media_source, media_id = resolve_media_identity(media=mediainfo)
if not media_id:
return None
return {
@@ -1502,6 +1510,9 @@ class MoviePilotServerHelper:
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
}
@classmethod
+12 -1
View File
@@ -397,7 +397,10 @@ class TorrentHelper:
:param torrent: 种子信息
"""
# 比对词条指定的tmdbid
if torrent_meta.tmdbid or torrent_meta.doubanid:
if any((
torrent_meta.tmdbid, torrent_meta.doubanid,
torrent_meta.bangumiid, torrent_meta.anilistid,
)):
if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:
logger.info(
f'{mediainfo.title} 通过词表指定TMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
@@ -406,6 +409,14 @@ class TorrentHelper:
logger.info(
f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源:{torrent.site_name} - {torrent.title}')
return True
if torrent_meta.bangumiid and torrent_meta.bangumiid == mediainfo.bangumi_id:
logger.info(
f'{mediainfo.title} 通过词表指定 Bangumi ID 匹配到资源:{torrent.site_name} - {torrent.title}')
return True
if torrent_meta.anilistid and torrent_meta.anilistid == mediainfo.anilist_id:
logger.info(
f'{mediainfo.title} 通过词表指定 AniList ID 匹配到资源:{torrent.site_name} - {torrent.title}')
return True
# 要匹配的媒体标题、原标题
media_titles = {
StringUtils.clear_upper(mediainfo.title),
+2
View File
@@ -118,6 +118,8 @@
"模型响应为空": "Model response is empty",
"LLM 调用超时": "LLM call timed out",
"刮削路径无效": "Scraping path is invalid",
"指定媒体ID时必须同时指定媒体数据源": "The media source must be specified together with the media ID",
"媒体ID格式无效": "Invalid media ID format",
"刮削失败,无法识别媒体信息": "Scraping failed: unable to recognize media information",
"刮削路径不存在": "Scraping path does not exist",
"保存成功": "Saved successfully",
+2
View File
@@ -118,6 +118,8 @@
"模型响应为空": "模型回應為空",
"LLM 调用超时": "LLM 呼叫逾時",
"刮削路径无效": "刮削路徑無效",
"指定媒体ID时必须同时指定媒体数据源": "指定媒體ID時必須同時指定媒體資料源",
"媒体ID格式无效": "媒體ID格式無效",
"刮削失败,无法识别媒体信息": "刮削失敗,無法識別媒體資訊",
"刮削路径不存在": "刮削路徑不存在",
"保存成功": "儲存成功",
+9 -1
View File
@@ -67,6 +67,14 @@ class MetaInfo(BaseModel):
media_source: Optional[str] = None
# 显式媒体数据源原生ID
media_id: Optional[str] = None
# TMDB ID
tmdbid: Optional[int] = None
# 豆瓣 ID
doubanid: Optional[str] = None
# Bangumi ID
bangumiid: Optional[int] = None
# AniList ID
anilistid: Optional[int] = None
class MediaInfo(BaseModel):
@@ -318,7 +326,7 @@ class Context(BaseModel):
torrent_info: Optional[TorrentInfo] = None
# 候选资源来源:rss、spider、search、unknown
resource_source: Optional[str] = "unknown"
# 候选匹配来源:tmdbid、doubanid、imdbid、title、plugin、unknown
# 候选匹配来源:tmdbid、doubanid、bangumiid、anilistid、imdbid、title、plugin、unknown
match_source: Optional[str] = "unknown"
# 候选自身是否已经识别出有效媒体 ID
candidate_recognized: Optional[bool] = False
+4
View File
@@ -593,6 +593,10 @@ class SubscribeEpisodesRefreshEventData(ChainEventData):
# 输入参数
tmdbid: Optional[int] = Field(default=None, description="TMDB ID")
doubanid: Optional[str] = Field(default=None, description="豆瓣 ID")
bangumiid: Optional[int] = Field(default=None, description="Bangumi ID")
anilistid: Optional[int] = Field(default=None, description="AniList ID")
media_source: Optional[str] = Field(default=None, description="媒体数据源")
media_id: Optional[str] = Field(default=None, description="数据源原生 ID")
season: Optional[int] = Field(default=None, description="季号")
mediainfo: Any = Field(default=None, description="媒体信息")
current_total_episode: int = Field(default=0, description="主程序本次识别到的 TMDB 当前季总集数")
+12
View File
@@ -22,6 +22,14 @@ class DownloadHistory(BaseModel):
tvdbid: Optional[int] = None
# 豆瓣ID
doubanid: Optional[str] = None
# Bangumi ID
bangumiid: Optional[int] = None
# AniList ID
anilistid: Optional[int] = None
# 媒体数据源
media_source: Optional[str] = None
# 数据源原生ID
media_id: Optional[str] = None
# 季Sxx
seasons: Optional[str] = None
# 集Exx
@@ -83,6 +91,10 @@ class TransferHistory(BaseModel):
tvdbid: Optional[int] = None
# 豆瓣ID
doubanid: Optional[str] = None
# Bangumi ID
bangumiid: Optional[int] = None
# AniList ID
anilistid: Optional[int] = None
# 媒体数据源
media_source: Optional[str] = None
# 数据源原生ID
+6
View File
@@ -62,7 +62,10 @@ class Subscribe(BaseModel):
tmdbid: Optional[int] = None
doubanid: Optional[str] = None
bangumiid: Optional[int] = None
anilistid: Optional[int] = None
mediaid: Optional[str] = None
media_source: Optional[str] = None
media_id: Optional[str] = None
# 季号
season: Optional[int] = None
# 海报
@@ -171,6 +174,9 @@ class SubscribeShare(BaseModel):
tmdbid: Optional[int] = None
doubanid: Optional[str] = None
bangumiid: Optional[int] = None
anilistid: Optional[int] = None
media_source: Optional[str] = None
media_id: Optional[str] = None
# 季号
season: Optional[int] = None
# 海报
+4
View File
@@ -215,6 +215,10 @@ class ManualTransferItem(BaseModel):
tmdbid: Optional[int] = None
# 豆瓣ID
doubanid: Optional[str] = None
# Bangumi ID
bangumiid: Optional[int] = None
# AniList ID
anilistid: Optional[int] = None
# 媒体数据源
media_source: Optional[
Literal["themoviedb", "douban", "bangumi", "anilist"]
+112
View File
@@ -0,0 +1,112 @@
from typing import Any, Optional, Tuple
MEDIA_SOURCE_ALIASES = {
"tmdb": "themoviedb",
"themoviedb": "themoviedb",
"douban": "douban",
"bangumi": "bangumi",
"anilist": "anilist",
}
MEDIA_SOURCE_PREFIXES = {
"themoviedb": "tmdb",
"douban": "douban",
"bangumi": "bangumi",
"anilist": "anilist",
}
MEDIA_SOURCE_ID_FIELDS = {
"themoviedb": ("tmdb_id", "tmdbid"),
"douban": ("douban_id", "doubanid"),
"bangumi": ("bangumi_id", "bangumiid"),
"anilist": ("anilist_id", "anilistid"),
}
def normalize_media_source(source: Optional[str]) -> Optional[str]:
"""规范化媒体数据源名称,兼容外部使用的 ``tmdb`` 前缀。"""
if not source:
return None
normalized = str(source).strip().casefold()
return MEDIA_SOURCE_ALIASES.get(normalized, normalized or None)
def parse_media_key(media_key: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
"""解析带来源前缀的媒体键,返回规范化数据源与原生 ID。"""
if not media_key or ":" not in str(media_key):
return None, None
prefix, media_id = str(media_key).split(":", 1)
source = normalize_media_source(prefix)
media_id = media_id.strip()
if not source or not media_id:
return None, None
return source, media_id
def resolve_media_identity(
media: Any = None,
source: Optional[str] = None,
media_id: Optional[Any] = None,
tmdbid: Optional[Any] = None,
doubanid: Optional[Any] = None,
bangumiid: Optional[Any] = None,
anilistid: Optional[Any] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""
从统一媒体对象通用身份或兼容 ID 中解析主媒体身份
显式 ``source/media_id`` 优先未指定来源时按 TMDB豆瓣Bangumi
AniList 的兼容顺序选择首个有效 ID
"""
normalized_source = normalize_media_source(source)
if normalized_source and media_id is not None and str(media_id).strip():
return normalized_source, str(media_id).strip()
values = {
"themoviedb": tmdbid,
"douban": doubanid,
"bangumi": bangumiid,
"anilist": anilistid,
}
if media is not None:
normalized_source = normalized_source or normalize_media_source(
getattr(media, "source", None) or getattr(media, "media_source", None)
)
object_media_id = getattr(media, "media_id", None)
if normalized_source and object_media_id is not None and str(object_media_id).strip():
return normalized_source, str(object_media_id).strip()
for media_source, fields in MEDIA_SOURCE_ID_FIELDS.items():
for field in fields:
value = getattr(media, field, None)
if value is not None and str(value).strip():
values[media_source] = value
break
legacy_source, legacy_media_id = parse_media_key(
getattr(media, "mediaid", None)
)
if not normalized_source and legacy_source and legacy_media_id:
return legacy_source, legacy_media_id
if normalized_source:
value = values.get(normalized_source)
return (
normalized_source,
str(value).strip() if value is not None and str(value).strip() else None,
)
for media_source in MEDIA_SOURCE_ID_FIELDS:
value = values.get(media_source)
if value is not None and str(value).strip():
return media_source, str(value).strip()
return None, None
def build_media_key(source: Optional[str], media_id: Optional[Any]) -> str:
"""构造 API 使用的带来源前缀媒体键。"""
normalized_source = normalize_media_source(source)
if not normalized_source or media_id is None or not str(media_id).strip():
return ""
prefix = MEDIA_SOURCE_PREFIXES.get(normalized_source, normalized_source)
return f"{prefix}:{str(media_id).strip()}"
+3
View File
@@ -76,6 +76,9 @@ class AddSubscribeAction(BaseAction):
season=mediainfo.season,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
anilistid=mediainfo.anilist_id,
media_source=mediainfo.source,
media_id=mediainfo.to_dict().get("media_id"),
username=settings.SUPERUSER)
if sid:
self._added_subscribes.append(sid)
+4
View File
@@ -93,6 +93,10 @@ class FetchTorrentsAction(BaseAction):
break
torrents = searchchain.search_by_id(tmdbid=media.tmdb_id,
doubanid=media.douban_id,
bangumiid=media.bangumi_id,
anilistid=media.anilist_id,
source=media.source,
mediaid=media.media_id,
mtype=MediaType(media.type),
sites=params.sites)
for torrent in torrents:
+209
View File
@@ -0,0 +1,209 @@
"""2.2.14
统一媒体身份字段并补齐 Bangumi/AniList ID
Revision ID: f7b2d5c9a301
Revises: e6a1c4b8d2f0
Create Date: 2026-07-21
"""
from typing import Iterable
from alembic import op
import sqlalchemy as sa
revision = "f7b2d5c9a301"
down_revision = "e6a1c4b8d2f0"
branch_labels = None
depends_on = None
def _has_column(
inspector: sa.Inspector,
table_name: str,
column_name: str,
) -> bool:
"""检查数据表是否已存在指定字段。"""
if table_name not in inspector.get_table_names():
return False
return any(
column["name"] == column_name
for column in inspector.get_columns(table_name)
)
def _has_index(
inspector: sa.Inspector,
table_name: str,
index_name: str,
) -> bool:
"""检查数据表是否已存在指定索引。"""
if table_name not in inspector.get_table_names():
return False
return any(
index["name"] == index_name
for index in inspector.get_indexes(table_name)
)
def _add_columns(table_name: str, columns: Iterable[sa.Column]) -> None:
"""为指定表补充尚不存在的字段。"""
for column in columns:
inspector = sa.inspect(op.get_bind())
if not _has_column(inspector, table_name, column.name):
op.add_column(table_name, column)
def _create_index(table_name: str, index_name: str, columns: list[str]) -> None:
"""为指定表创建尚不存在的索引。"""
inspector = sa.inspect(op.get_bind())
if not _has_index(inspector, table_name, index_name):
op.create_index(index_name, table_name, columns)
def _backfill_media_identity(table_name: str, has_mediaid: bool = False) -> None:
"""使用兼容 ID 幂等回填统一媒体身份。"""
columns = [
sa.column("tmdbid", sa.Integer()),
sa.column("doubanid", sa.String()),
sa.column("bangumiid", sa.Integer()),
sa.column("anilistid", sa.Integer()),
sa.column("media_source", sa.String()),
sa.column("media_id", sa.String()),
]
if has_mediaid:
columns.append(sa.column("mediaid", sa.String()))
table = sa.table(table_name, *columns)
connection = op.get_bind()
if has_mediaid:
for prefix, source in (
("tmdb", "themoviedb"),
("themoviedb", "themoviedb"),
("douban", "douban"),
("bangumi", "bangumi"),
("anilist", "anilist"),
):
connection.execute(
table.update()
.where(table.c.media_id.is_(None))
.where(table.c.mediaid.like(f"{prefix}:%"))
.values(
media_source=source,
media_id=sa.func.substr(table.c.mediaid, len(prefix) + 2),
)
)
for source, field in (
("themoviedb", "tmdbid"),
("douban", "doubanid"),
("bangumi", "bangumiid"),
("anilist", "anilistid"),
):
identity_column = table.c[field]
connection.execute(
table.update()
.where(table.c.media_id.is_(None))
.where(identity_column.is_not(None))
.values(
media_source=source,
media_id=sa.cast(identity_column, sa.String()),
)
)
def upgrade() -> None:
"""升级媒体身份字段并迁移存量数据。"""
_add_columns("subscribe", (
sa.Column("anilistid", sa.Integer(), nullable=True),
sa.Column("media_source", sa.String(), nullable=True),
sa.Column("media_id", sa.String(), nullable=True),
))
_add_columns("subscribehistory", (
sa.Column("anilistid", sa.Integer(), nullable=True),
sa.Column("media_source", sa.String(), nullable=True),
sa.Column("media_id", sa.String(), nullable=True),
))
_add_columns("downloadhistory", (
sa.Column("bangumiid", sa.Integer(), nullable=True),
sa.Column("anilistid", sa.Integer(), nullable=True),
sa.Column("media_source", sa.String(), nullable=True),
sa.Column("media_id", sa.String(), nullable=True),
))
_add_columns("transferhistory", (
sa.Column("bangumiid", sa.Integer(), nullable=True),
sa.Column("anilistid", sa.Integer(), nullable=True),
))
_add_columns("downloadfailure", (
sa.Column("bangumiid", sa.Integer(), nullable=True),
sa.Column("anilistid", sa.Integer(), nullable=True),
sa.Column("media_source", sa.String(), nullable=True),
sa.Column("media_id", sa.String(), nullable=True),
))
for table_name, fields in {
"subscribe": ("anilistid", "media_source", "media_id"),
"subscribehistory": ("anilistid", "media_source", "media_id"),
"downloadhistory": ("bangumiid", "anilistid", "media_source", "media_id"),
"transferhistory": ("bangumiid", "anilistid"),
}.items():
for field in fields:
_create_index(table_name, f"ix_{table_name}_{field}", [field])
for table_name in ("subscribe", "subscribehistory", "downloadhistory", "transferhistory"):
_create_index(
table_name,
f"ix_{table_name}_media_identity",
["media_source", "media_id"],
)
_create_index(
"downloadfailure",
"ix_downloadfailure_media_identity_site",
["type", "media_source", "media_id", "site"],
)
_backfill_media_identity("subscribe", has_mediaid=True)
_backfill_media_identity("subscribehistory", has_mediaid=True)
_backfill_media_identity("downloadhistory")
_backfill_media_identity("transferhistory")
_backfill_media_identity("downloadfailure")
def downgrade() -> None:
"""回滚统一媒体身份及 Bangumi/AniList 字段。"""
for table_name, index_names in {
"subscribe": (
"ix_subscribe_media_identity", "ix_subscribe_media_id",
"ix_subscribe_media_source", "ix_subscribe_anilistid",
),
"subscribehistory": (
"ix_subscribehistory_media_identity", "ix_subscribehistory_media_id",
"ix_subscribehistory_media_source", "ix_subscribehistory_anilistid",
),
"downloadhistory": (
"ix_downloadhistory_media_identity", "ix_downloadhistory_media_id",
"ix_downloadhistory_media_source", "ix_downloadhistory_anilistid",
"ix_downloadhistory_bangumiid",
),
"transferhistory": (
"ix_transferhistory_media_identity", "ix_transferhistory_anilistid",
"ix_transferhistory_bangumiid",
),
"downloadfailure": ("ix_downloadfailure_media_identity_site",),
}.items():
for index_name in index_names:
inspector = sa.inspect(op.get_bind())
if _has_index(inspector, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
for table_name, fields in {
"subscribe": ("media_id", "media_source", "anilistid"),
"subscribehistory": ("media_id", "media_source", "anilistid"),
"downloadhistory": ("media_id", "media_source", "anilistid", "bangumiid"),
"transferhistory": ("anilistid", "bangumiid"),
"downloadfailure": ("media_id", "media_source", "anilistid", "bangumiid"),
}.items():
for field in fields:
inspector = sa.inspect(op.get_bind())
if _has_column(inspector, table_name, field):
op.drop_column(table_name, field)
+11 -7
View File
@@ -112,29 +112,31 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
#### 媒体识别 / 整理
媒体识别、搜索和手动整理支持 `themoviedb``douban``bangumi``anilist` 四种数据源。请求未指定 `source` 时继续使用后台配置;显式指定时仅在该数据源中识别或搜索
媒体识别、搜索和手动整理内置支持 `themoviedb``douban``bangumi``anilist` 四种数据源,也允许插件处理自定义来源。自动识别仍使用系统默认来源;手动操作可通过请求级 `source``media_source` + `media_id` 临时指定来源,不修改系统默认值
涉及媒体身份的请求统一以 `media_source` + `media_id` 表示本次选定的主身份,同时保留 `tmdbid``doubanid``bangumiid``anilistid` 作为跨数据源映射和旧客户端兼容字段。两者并非两套独立数据流:显式通用主身份优先,专用 ID 用于补全映射和兼容回退。
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/media/search` | 按标题搜索媒体,参数:`title``type``page``count`,可选 `source` |
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title``subtitle``custom_words`,可选 `source` |
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` |
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:``douban:``bangumi:``anilist:` 前缀 |
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:``douban:``bangumi:``anilist:` 及插件自定义来源前缀 |
| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source``media_id``type_name`(电影/电视剧)可指定本次刮削媒体 |
| POST | `/api/v1/transfer/manual/target-path` | 匹配手动整理目标路径;请求体可用 `media_source` + `media_id` 指定数据源原生ID |
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid``doubanid` |
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid``doubanid``bangumiid``anilistid` |
#### 搜索 / 种子 / 字幕
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源,`mediaid` 支持 `tmdb:123``douban:123``bangumi:123`,参数:`mtype``area``title``year``season``sites` |
| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源,`mediaid` 支持 `tmdb:123``douban:123``bangumi:123``anilist:123` 及插件来源前缀,参数:`mtype``area``title``year``season``sites` |
| GET | `/api/v1/search/media/{mediaid}/stream` | 按媒体 ID 渐进式搜索站点种子资源,返回 SSE,参数同上 |
| GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword``page``sites` |
| GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword``page``sites` |
| GET | `/api/v1/search/subtitle/title` | 按关键字搜索站点字幕资源,参数:`keyword``page``sites` |
| GET | `/api/v1/search/subtitle/title/stream` | 按关键字渐进式搜索站点字幕资源,返回 SSE,参数:`keyword``page``sites` |
| GET | `/api/v1/search/subtitle/media/{mediaid}` | 按媒体 ID 精确搜索站点字幕资源,`mediaid` 支持 `tmdb:123``douban:123``bangumi:123`,参数:`mtype``title``year``season``episode``sites` |
| GET | `/api/v1/search/subtitle/media/{mediaid}` | 按媒体 ID 精确搜索站点字幕资源,`mediaid` 支持四种内置来源及插件来源前缀,参数:`mtype``title``year``season``episode``sites` |
| GET | `/api/v1/search/subtitle/media/{mediaid}/stream` | 按媒体 ID 渐进式精确搜索站点字幕资源,返回 SSE,参数同上 |
| GET | `/api/v1/search/last` | 获取上一次种子搜索结果 |
| GET | `/api/v1/search/last/context` | 获取上一次搜索结果及可复用搜索参数,`params.result_type``torrent``subtitle` |
@@ -146,8 +148,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
| :--- | :--- | :--- |
| GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name` |
| POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 |
| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `media_source` + `media_id`;继续兼容 `tmdbid``doubanid`,并支持 `downloader``save_path` |
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,可选 `media_source` + `media_id`;继续兼容 `tmdbid``doubanid`,并支持 `save_path` |
| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `media_source` + `media_id`;继续兼容四种专用 ID,并支持 `downloader``save_path` |
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,可选 `media_source` + `media_id`;继续兼容四种专用 ID,并支持 `save_path` |
| GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` |
| GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` |
| GET | `/api/v1/download/clients` | 查询可用下载器 |
@@ -195,6 +197,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。
媒体相关 MCP 工具(如 `query_media_detail``search_torrents``query_library_exists``add_subscribe``transfer_file`)接受 `tmdb_id`/`tmdbid``douban_id`/`doubanid``bangumi_id`/`bangumiid``anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。工具返回的媒体、订阅、下载和整理记录会同步带回可用的四种专用 ID 及通用主身份。
#### Agent 自主定时任务工具
以下工具用于管理会在指定时间重新唤醒 Agent 的持久化任务,均为管理员级工具:
+8 -8
View File
@@ -103,7 +103,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons |
| GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups |
| GET | `/api/v1/media/seasons` | Get media season info. Params: `mediaid`, `title`, `year`, `season` |
| GET | `/api/v1/media/{mediaid}` | Get media detail. `mediaid` supports `tmdb:`, `douban:`, `bangumi:`, and `anilist:`. Params: `type_name` (required: movie/tv), `title`, `year` |
| GET | `/api/v1/media/{mediaid}` | Get media detail. `mediaid` supports `tmdb:`, `douban:`, `bangumi:`, `anilist:`, and plugin-defined source prefixes. Params: `type_name` (required: movie/tv), `title`, `year` |
### TMDB (8 endpoints)
@@ -142,13 +142,13 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (format: `tmdb:123` / `douban:123` / `bangumi:123`). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` |
| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (four built-in prefixes or a plugin-defined source prefix). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` |
| GET | `/api/v1/search/media/{mediaid}/stream` | Stream torrent search by media ID with SSE. Params: `mtype`, `area`, `title`, `year`, `season`, `sites` |
| GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites` |
| GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites` |
| GET | `/api/v1/search/subtitle/title` | Fuzzy search site subtitles by keyword. Params: `keyword`, `page`, `sites` |
| GET | `/api/v1/search/subtitle/title/stream` | Stream fuzzy site subtitle search with SSE. Params: `keyword`, `page`, `sites` |
| GET | `/api/v1/search/subtitle/media/{mediaid}` | Exact subtitle search by media ID (format: `tmdb:123` / `douban:123` / `bangumi:123`). Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` |
| GET | `/api/v1/search/subtitle/media/{mediaid}` | Exact subtitle search by media ID (four built-in prefixes or a plugin-defined source prefix). Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` |
| GET | `/api/v1/search/subtitle/media/{mediaid}/stream` | Stream exact subtitle search by media ID with SSE. Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` |
| GET | `/api/v1/search/last` | Get latest search results |
| GET | `/api/v1/search/last/context` | Get latest search results with replayable params. `params.result_type` is `torrent` or `subtitle` |
@@ -160,8 +160,8 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
|--------|------|-------------|
| GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name) |
| POST | `/api/v1/download/` | Add download (with media info). Body: JSON |
| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional `media_source` + `media_id` (legacy `tmdbid`/`doubanid` remain supported), `downloader`, `save_path` |
| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, optional `media_source` + `media_id` (legacy `tmdbid`/`doubanid` remain supported), `save_path` |
| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional `media_source` + `media_id` (all four dedicated IDs remain supported), `downloader`, `save_path` |
| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, optional `media_source` + `media_id` (all four dedicated IDs remain supported), `save_path` |
| GET | `/api/v1/download/start/{hashString}` | Resume download task |
| GET | `/api/v1/download/stop/{hashString}` | Pause download task |
| GET | `/api/v1/download/clients` | List available download clients |
@@ -172,14 +172,14 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/subscribe/` | List all subscriptions |
| POST | `/api/v1/subscribe/` | Add subscription. Body: Subscribe JSON |
| POST | `/api/v1/subscribe/` | Add subscription. Body accepts `media_source` + `media_id` and compatible `tmdbid`, `doubanid`, `bangumiid`, `anilistid` fields |
| PUT | `/api/v1/subscribe/` | Update subscription. Body: Subscribe JSON |
| GET | `/api/v1/subscribe/list` | List subscriptions (API_TOKEN auth, use `--token-param`) |
| GET | `/api/v1/subscribe/{subscribe_id}` | Subscription detail |
| DELETE | `/api/v1/subscribe/{subscribe_id}` | Delete subscription |
| PUT | `/api/v1/subscribe/status/{subid}` | Update subscription status. Params: `state` (required) |
| GET | `/api/v1/subscribe/media/{mediaid}` | Query subscription by media ID. Params: `season`, `title` |
| DELETE | `/api/v1/subscribe/media/{mediaid}` | Delete subscription by media ID. Params: `season` |
| GET | `/api/v1/subscribe/media/{mediaid}` | Query subscription by a built-in or plugin-prefixed media ID. Params: `season`, `title` |
| DELETE | `/api/v1/subscribe/media/{mediaid}` | Delete subscription by a built-in or plugin-prefixed media ID. Params: `season` |
| GET | `/api/v1/subscribe/refresh` | Refresh all subscriptions |
| GET | `/api/v1/subscribe/reset/{subid}` | Reset subscription |
| GET | `/api/v1/subscribe/check` | Refresh subscription TMDB info |
@@ -307,6 +307,10 @@ def test_manual_redo_context_uses_dest_path_for_successful_move_record():
mode="move",
tmdbid=100,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="themoviedb",
media_id="100",
errmsg=None,
)
@@ -346,6 +350,10 @@ def test_manual_redo_context_only_treats_exact_move_as_dest_source():
mode="not-move",
tmdbid=100,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="themoviedb",
media_id="100",
errmsg=None,
)
+2
View File
@@ -396,6 +396,8 @@ def _build_tv_context(episode_list=None):
title_year="Test Show (2026)",
tmdb_id=1,
douban_id=None,
bangumi_id=None,
anilist_id=None,
),
meta_info=SimpleNamespace(
season_list=[1],
@@ -24,6 +24,10 @@ def test_batch_manual_redo_prompt_requires_plain_text_result():
mode="copy",
tmdbid=123,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="themoviedb",
media_id="123",
errmsg="识别失败",
)
+116
View File
@@ -0,0 +1,116 @@
import importlib
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def _create_legacy_tables(connection) -> dict[str, sa.Table]:
"""创建执行 2.2.14 迁移前的最小历史表结构。"""
metadata = sa.MetaData()
common_identity_columns = (
sa.Column("tmdbid", sa.Integer()),
sa.Column("doubanid", sa.String()),
)
tables = {
"subscribe": sa.Table(
"subscribe", metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("mediaid", sa.String()),
*common_identity_columns,
sa.Column("bangumiid", sa.Integer()),
),
"subscribehistory": sa.Table(
"subscribehistory", metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("mediaid", sa.String()),
sa.Column("tmdbid", sa.Integer()),
sa.Column("doubanid", sa.String()),
sa.Column("bangumiid", sa.Integer()),
),
"downloadhistory": sa.Table(
"downloadhistory", metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tmdbid", sa.Integer()),
sa.Column("doubanid", sa.String()),
),
"transferhistory": sa.Table(
"transferhistory", metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tmdbid", sa.Integer()),
sa.Column("doubanid", sa.String()),
sa.Column("media_source", sa.String()),
sa.Column("media_id", sa.String()),
),
"downloadfailure": sa.Table(
"downloadfailure", metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("type", sa.String()),
sa.Column("site", sa.Integer()),
sa.Column("tmdbid", sa.Integer()),
sa.Column("doubanid", sa.String()),
),
}
metadata.create_all(connection)
return tables
def test_media_identity_migration_adds_fields_and_backfills_rows(monkeypatch) -> None:
"""迁移应补齐五张表的媒体 ID 字段并幂等迁移存量身份。"""
migration = importlib.import_module(
"database.versions.f7b2d5c9a301_2_2_14"
)
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
tables = _create_legacy_tables(connection)
connection.execute(tables["subscribe"].insert(), {
"id": 1, "mediaid": "anilist:154587",
})
connection.execute(tables["subscribehistory"].insert(), {
"id": 1, "bangumiid": 29648,
})
connection.execute(tables["downloadhistory"].insert(), {
"id": 1, "doubanid": "35209731",
})
connection.execute(tables["transferhistory"].insert(), {
"id": 1, "tmdbid": 209867,
"media_source": "plugin_source", "media_id": "custom-1",
})
connection.execute(tables["downloadfailure"].insert(), {
"id": 1, "type": "电视剧", "site": 1, "tmdbid": 209867,
})
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
migration.upgrade()
migration.upgrade()
migrated = {
table_name: sa.Table(
table_name, sa.MetaData(), autoload_with=connection,
)
for table_name in tables
}
rows = {
table_name: connection.execute(
sa.select(table).where(table.c.id == 1)
).mappings().one()
for table_name, table in migrated.items()
}
for table_name in migrated:
assert "bangumiid" in migrated[table_name].c
assert "anilistid" in migrated[table_name].c
assert "media_source" in migrated[table_name].c
assert "media_id" in migrated[table_name].c
assert rows["subscribe"]["media_source"] == "anilist"
assert rows["subscribe"]["media_id"] == "154587"
assert rows["subscribehistory"]["media_source"] == "bangumi"
assert rows["subscribehistory"]["media_id"] == "29648"
assert rows["downloadhistory"]["media_source"] == "douban"
assert rows["downloadhistory"]["media_id"] == "35209731"
assert rows["transferhistory"]["media_source"] == "plugin_source"
assert rows["transferhistory"]["media_id"] == "custom-1"
assert rows["downloadfailure"]["media_source"] == "themoviedb"
assert rows["downloadfailure"]["media_id"] == "209867"
+2 -2
View File
@@ -152,8 +152,8 @@ def test_rebuild_download_scope_keeps_special_season_zero():
no_exists = MediaInteractionChain._get_noexits_info(meta, mediainfo)
assert list(no_exists[1]) == [0]
assert no_exists[1][0].total_episode == 2
assert list(no_exists["tmdb:1"]) == [0]
assert no_exists["tmdb:1"][0].total_episode == 2
def test_message_routes_text_reply_to_media_interaction_before_ai():
+26 -17
View File
@@ -23,8 +23,18 @@ def test_generic_source_id_wins_over_legacy_ids() -> None:
assert resolved == ("anilist", None, None, None, 154587)
def test_explicit_source_recognition_runs_system_modules_only() -> None:
"""显式选择数据源时应跳过插件并只向系统模块传递该来源ID"""
def test_custom_plugin_source_is_not_discarded() -> None:
"""插件自定义来源应保留来源名,并通过通用原生 ID 交给插件"""
resolved = ChainBase._resolve_media_source_params(
source="plugin_source",
mediaid="custom-1",
)
assert resolved == ("plugin_source", None, None, None, None)
def test_explicit_source_recognition_reaches_plugins_with_all_ids() -> None:
"""显式选择数据源时应保留通用参数并进入完整模块调度。"""
chain = _chain_without_init()
media = MediaInfo(
anilist_info={
@@ -48,14 +58,14 @@ def test_explicit_source_recognition_runs_system_modules_only() -> None:
assert result is media
call = chain.run_module.call_args
assert call.kwargs["system_only"] is True
assert call.kwargs["source"] == "anilist"
assert call.kwargs["mediaid"] == "154587"
assert call.kwargs["anilistid"] == 154587
assert call.kwargs["tmdbid"] is None
def test_default_recognition_preserves_plugin_method_contract() -> None:
"""未显式选择来源时不应向既有插件额外传递source参数。"""
def test_default_recognition_passes_empty_generic_identity() -> None:
"""默认识别也应向插件传递完整但为空的通用媒体身份参数。"""
chain = _chain_without_init()
media = MediaInfo(title="测试电影", type=MediaType.MOVIE, tmdb_id=1)
chain.run_module = Mock(return_value=media)
@@ -71,26 +81,26 @@ def test_default_recognition_preserves_plugin_method_contract() -> None:
assert result is media
call = chain.run_module.call_args
assert call.kwargs["system_only"] is False
assert "source" not in call.kwargs
assert "anilistid" not in call.kwargs
assert call.kwargs["source"] is None
assert call.kwargs["mediaid"] is None
assert call.kwargs["anilistid"] is None
def test_system_only_module_dispatch_skips_plugins() -> None:
"""模块调度的system_only模式不得执行插件模块。"""
def test_module_dispatch_always_reaches_plugins() -> None:
"""模块调度必须始终先执行插件模块。"""
chain = _chain_without_init()
chain._ChainBase__execute_plugin_modules = Mock(return_value="plugin")
chain._ChainBase__execute_system_modules = Mock(return_value="system")
result = chain.run_module("search_medias", system_only=True, meta=MetaBase("test"))
result = chain.run_module("search_medias", meta=MetaBase("test"))
assert result == "system"
chain._ChainBase__execute_plugin_modules.assert_not_called()
chain._ChainBase__execute_system_modules.assert_called_once()
assert result == "plugin"
chain._ChainBase__execute_plugin_modules.assert_called_once()
chain._ChainBase__execute_system_modules.assert_not_called()
def test_explicit_search_source_uses_system_only_dispatch() -> None:
"""请求级搜索来源应进入仅系统模块调度。"""
def test_explicit_search_source_reaches_plugins() -> None:
"""请求级搜索来源应进入包含插件的完整模块调度。"""
chain = _chain_without_init()
chain.run_module = Mock(return_value=[])
meta = MetaBase("Frieren")
@@ -102,5 +112,4 @@ def test_explicit_search_source_uses_system_only_dispatch() -> None:
"search_medias",
meta=meta,
source="anilist",
system_only=True,
)
@@ -0,0 +1,58 @@
import inspect
from app.chain.download import DownloadChain
from app.chain.search import SearchChain
from app.chain.subscribe import SubscribeChain
from app.chain.transfer import TransferChain
def _assert_parameter_prefix(method, expected: list[str]) -> None:
"""断言新增媒体源参数未改变已有位置参数顺序。"""
parameters = list(inspect.signature(method).parameters)
assert parameters[:len(expected)] == expected
def test_search_chain_media_source_parameters_preserve_old_order() -> None:
"""搜索链新增媒体源参数必须追加在原有位置参数之后。"""
resource_parameters = [
"self", "tmdbid", "doubanid", "mtype", "area", "season", "sites", "cache_local"
]
subtitle_parameters = [
"self", "tmdbid", "doubanid", "mtype", "season", "episode", "sites", "cache_local"
]
_assert_parameter_prefix(SearchChain.search_by_id, resource_parameters)
_assert_parameter_prefix(SearchChain.async_search_by_id, resource_parameters)
_assert_parameter_prefix(SearchChain.async_search_by_id_stream, resource_parameters)
_assert_parameter_prefix(SearchChain.async_search_subtitles_by_id, subtitle_parameters)
_assert_parameter_prefix(SearchChain.async_search_subtitles_by_id_stream, subtitle_parameters)
def test_download_chain_media_source_parameters_preserve_old_order() -> None:
"""字幕下载链新增媒体源参数必须追加在原有位置参数之后。"""
_assert_parameter_prefix(DownloadChain.download_subtitle, [
"self", "subtitle", "media_source", "media_id", "tmdbid", "doubanid",
"save_path", "username",
])
def test_transfer_chain_media_source_parameters_preserve_old_order() -> None:
"""整理链新增媒体源参数必须追加在原有位置参数之后。"""
_assert_parameter_prefix(TransferChain.manual_transfer, [
"self", "fileitem", "target_storage", "target_path", "tmdbid", "doubanid",
"media_source", "media_id", "mtype", "season", "episode_group", "transfer_type",
"epformat", "min_filesize", "scrape", "library_type_folder",
"library_category_folder", "force", "background", "downloader", "download_hash",
"preview", "sync_extra_files", "cleanup_dest_fileitem",
])
def test_subscribe_chain_media_source_parameters_preserve_old_order() -> None:
"""订阅链新增媒体源参数必须追加在原有位置参数之后。"""
parameters = [
"self", "title", "year", "mtype", "tmdbid", "doubanid", "bangumiid", "mediaid",
"episode_group", "season", "channel", "source", "userid", "username", "message", "exist_ok",
]
_assert_parameter_prefix(SubscribeChain.add, parameters)
_assert_parameter_prefix(SubscribeChain.async_add, parameters)
+41
View File
@@ -0,0 +1,41 @@
from app.core.context import MediaInfo
from app.helper.message import TemplateContextBuilder
from app.schemas.types import MediaType
def test_message_context_contains_all_media_identity_fields() -> None:
"""消息模板上下文应向插件暴露全部媒体源 ID 和当前主身份。"""
context = {}
media = MediaInfo(
source="anilist",
type=MediaType.TV,
title="测试动画",
tmdb_id=24680,
douban_id="35000000",
bangumi_id=499390,
anilist_id=170942,
)
TemplateContextBuilder._add_media_info(context, media)
assert context["tmdbid"] == 24680
assert context["doubanid"] == "35000000"
assert context["bangumiid"] == 499390
assert context["anilistid"] == 170942
assert context["media_source"] == "anilist"
assert context["media_id"] == "170942"
assert media.to_dict()["mediaid_prefix"] == "anilist"
assert media.to_dict()["media_id"] == "170942"
def test_media_info_preserves_plugin_source_identity() -> None:
"""核心媒体对象应原样保留插件自定义数据源的原生 ID。"""
media = MediaInfo(
source="plugin_source",
media_id="custom-100",
type=MediaType.MOVIE,
title="插件电影",
)
assert media.media_id == "custom-100"
assert media.to_dict()["media_id"] == "custom-100"
+2
View File
@@ -128,6 +128,8 @@ def test_torrent_title_match_ignores_question_mark_variants():
torrent_meta = SimpleNamespace(
tmdbid=None,
doubanid=None,
bangumiid=None,
anilistid=None,
cn_name=None,
en_name="Otaku ni Yasashii Gal wa Inai",
type=MediaType.TV,
+150
View File
@@ -0,0 +1,150 @@
import asyncio
from app.api.endpoints import media as media_endpoint
from app.api.endpoints import search as search_endpoint
from app.core.context import MediaInfo
from app.schemas.types import MediaType
def test_resolve_anilist_search_params_preserves_source_identity() -> None:
"""AniList 媒体键应直接解析为统一搜索身份。"""
params, message = asyncio.run(
search_endpoint._resolve_media_search_params("anilist:154587")
)
assert message == ""
assert params == {"source": "anilist", "mediaid": "154587"}
def test_resource_search_forwards_custom_plugin_source(monkeypatch) -> None:
"""资源搜索 API 应把自定义插件来源原样传给搜索链。"""
captured = {}
class FakeTorrent:
"""提供资源搜索响应需要的最小种子对象。"""
@staticmethod
def to_dict() -> dict:
"""返回可序列化的测试种子。"""
return {"title": "Plugin result"}
class FakeSearchChain:
"""记录资源搜索链收到的统一身份。"""
async def async_search_by_id(self, **kwargs):
"""保存搜索参数并返回单条测试结果。"""
captured.update(kwargs)
return [FakeTorrent()]
monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain)
response = asyncio.run(
search_endpoint.search_by_id(
mediaid="plugin_source:custom-1",
mtype="tv",
_=None,
)
)
assert response.success
assert captured["source"] == "plugin_source"
assert captured["mediaid"] == "custom-1"
assert captured["mtype"] == MediaType.TV
def test_subtitle_search_forwards_anilist_identity(monkeypatch) -> None:
"""字幕搜索 API 应把 AniList 身份传给字幕搜索链。"""
captured = {}
class FakeSearchChain:
"""记录字幕搜索链收到的统一身份。"""
async def async_search_subtitles_by_id(self, **kwargs):
"""保存字幕搜索参数并返回空结果。"""
captured.update(kwargs)
return []
monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain)
source, message = asyncio.run(
search_endpoint._build_subtitle_search_source(
mediaid="anilist:154587",
mtype="tv",
)
)
assert message == ""
assert asyncio.run(source) == []
assert captured["source"] == "anilist"
assert captured["mediaid"] == "154587"
def test_media_detail_forwards_custom_plugin_source(monkeypatch) -> None:
"""媒体详情 API 应允许插件自定义来源处理原生 ID。"""
captured = {}
media = MediaInfo(
source="plugin_source",
type=MediaType.MOVIE,
title="Plugin movie",
)
class FakeMediaChain:
"""记录详情识别链收到的统一身份。"""
async def async_recognize_media(self, **kwargs):
"""保存识别参数并返回插件媒体信息。"""
captured.update(kwargs)
return media
async def async_obtain_images(self, _media):
"""跳过测试中的真实图片获取。"""
return None
monkeypatch.setattr(media_endpoint, "MediaChain", FakeMediaChain)
result = asyncio.run(
media_endpoint.detail(
mediaid="plugin_source:custom-1",
type_name=MediaType.MOVIE.value,
_=None,
)
)
assert result["title"] == "Plugin movie"
assert captured["source"] == "plugin_source"
assert captured["mediaid"] == "custom-1"
def test_media_seasons_builds_anilist_season_response(monkeypatch) -> None:
"""AniList 详情应能通过统一季信息接口返回剧集季。"""
captured = {}
media = MediaInfo(
source="anilist",
type=MediaType.TV,
title="Frieren",
anilist_id=154587,
season_info=[{
"season_number": 1,
"name": "Season 1",
"episode_count": 28,
}],
)
class FakeMediaChain:
"""记录季信息识别链收到的 AniList 身份。"""
async def async_recognize_media(self, **kwargs):
"""保存识别参数并返回 AniList 媒体信息。"""
captured.update(kwargs)
return media
monkeypatch.setattr(media_endpoint, "MediaChain", FakeMediaChain)
result = asyncio.run(
media_endpoint.seasons(mediaid="anilist:154587", _=None)
)
assert len(result) == 1
assert result[0].season_number == 1
assert result[0].episode_count == 28
assert captured["source"] == "anilist"
assert captured["mediaid"] == "154587"
+41 -6
View File
@@ -293,6 +293,10 @@ def _load_subscribe_chain_class():
def __init__(self, **kwargs):
self.best_version_full = 0
self.bangumiid = None
self.anilistid = None
self.media_source = None
self.media_id = None
self.mediaid = None
self.episode_group = None
for key, value in kwargs.items():
setattr(self, key, value)
@@ -391,6 +395,10 @@ class SubscribeChainTest(TestCase):
"imdbid": None,
"tvdbid": None,
"bangumiid": None,
"anilistid": None,
"media_source": "themoviedb",
"media_id": "1",
"mediaid": "tmdb:1",
"episode_group": None,
"poster": None,
"backdrop": None,
@@ -1776,6 +1784,11 @@ class SubscribeChainTest(TestCase):
overview="overview",
imdb_id="tt1234567",
tvdb_id=99,
source="themoviedb",
tmdb_id=1,
douban_id=None,
bangumi_id=None,
anilist_id=None,
get_poster_image=lambda: "poster",
get_backdrop_image=lambda: "backdrop",
)
@@ -1970,6 +1983,8 @@ class SubscribeNoteTrackingTest(TestCase):
type=MediaType.TV,
tmdb_id=1,
douban_id=None,
bangumi_id=None,
anilist_id=None,
),
torrent_info=SimpleNamespace(pri_order=99, title="fake-torrent"),
selected_episodes=list(episodes),
@@ -2467,7 +2482,7 @@ class SubscribeProgressEntrypointTest(TestCase):
self.assertEqual(kwargs["meta"].name, subscribe.name)
self.assertEqual(kwargs["meta"].season_seq, "1")
self.assertIs(kwargs["mediainfo"], mediainfo)
self.assertEqual(kwargs["mediakey"], 10001)
self.assertEqual(kwargs["mediakey"], "tmdb:10001")
self.assertTrue(summary["updated"])
self.assertEqual(summary["lack_episode"], 2)
self.assertEqual(subscribe.lack_episode, 2)
@@ -2550,6 +2565,8 @@ class SubscribeProgressConsolidationTest(TestCase):
tmdb_id=31000,
douban_id=None,
bangumi_id=None,
anilist_id=None,
source="themoviedb",
vote_average=9.5,
overview="overview",
imdb_id="tt1234567",
@@ -3395,7 +3412,13 @@ class SubscribeDownloadFactsTest(TestCase):
confirmed_full_coverage=confirmed_full_coverage,
torrent_info=SimpleNamespace(pri_order=pri_order),
meta_info=SimpleNamespace(episode_list=episodes or [], season_list=[1]),
media_info=SimpleNamespace(type=MediaType.TV, tmdb_id=30003, douban_id=None),
media_info=SimpleNamespace(
type=MediaType.TV,
tmdb_id=30003,
douban_id=None,
bangumi_id=None,
anilist_id=None,
),
)
def test_normal_tv_download_records_note_and_episode_priority_without_current_priority(self):
@@ -3553,7 +3576,10 @@ class SubscribeDownloadFactsTest(TestCase):
lack_episode=1,
)
download = self._download(episodes=[], pri_order=90)
download.media_info = SimpleNamespace(type=MediaType.MOVIE, tmdb_id=30003, douban_id=None)
download.media_info = SimpleNamespace(
type=MediaType.MOVIE, tmdb_id=30003, douban_id=None,
bangumi_id=None, anilist_id=None,
)
download.meta_info = SimpleNamespace(episode_list=[], season_list=[])
updates = []
@@ -3589,7 +3615,10 @@ class SubscribeDownloadFactsTest(TestCase):
lack_episode=1,
)
download = self._download(episodes=[], pri_order=90)
download.media_info = SimpleNamespace(type=MediaType.MOVIE, tmdb_id=30003, douban_id=None)
download.media_info = SimpleNamespace(
type=MediaType.MOVIE, tmdb_id=30003, douban_id=None,
bangumi_id=None, anilist_id=None,
)
download.meta_info = SimpleNamespace(episode_list=[], season_list=[])
chain = self.SubscribeChain()
@@ -3622,7 +3651,10 @@ class SubscribeDownloadFactsTest(TestCase):
lack_episode=1,
)
download = self._download(episodes=[], pri_order=90)
download.media_info = SimpleNamespace(type=MediaType.MOVIE, tmdb_id=30003, douban_id=None)
download.media_info = SimpleNamespace(
type=MediaType.MOVIE, tmdb_id=30003, douban_id=None,
bangumi_id=None, anilist_id=None,
)
download.meta_info = SimpleNamespace(episode_list=[], season_list=[])
chain = self.SubscribeChain()
@@ -3655,7 +3687,10 @@ class SubscribeDownloadFactsTest(TestCase):
lack_episode=1,
)
download = self._download(episodes=[], pri_order=90)
download.media_info = SimpleNamespace(type=MediaType.MOVIE, tmdb_id=30003, douban_id=None)
download.media_info = SimpleNamespace(
type=MediaType.MOVIE, tmdb_id=30003, douban_id=None,
bangumi_id=None, anilist_id=None,
)
download.meta_info = SimpleNamespace(episode_list=[], season_list=[])
updates = []
finished = []
+1
View File
@@ -1025,6 +1025,7 @@ class _EndpointMediaInfo:
tvdb_id = 456
douban_id = "douban-1"
bangumi_id = 789
anilist_id = 154587
episode_group = None
vote_average = 8.0
overview = "测试简介"
+7
View File
@@ -21,6 +21,9 @@ def _build_subscribe(**overrides):
"imdbid": None,
"tvdbid": None,
"bangumiid": None,
"anilistid": None,
"media_source": None,
"media_id": None,
"episode_group": None,
"start_episode": 1,
"total_episode": 2,
@@ -39,6 +42,10 @@ def _build_mediainfo():
year="2026",
tmdb_id=None,
douban_id=None,
bangumi_id=None,
anilist_id=None,
source=None,
media_id=None,
)
+4
View File
@@ -45,6 +45,10 @@ def _new_subscribe(created_at: datetime) -> SimpleNamespace:
type=MediaType.MOVIE.value,
tmdbid=12345,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="themoviedb",
media_id="12345",
season=None,
custom_words=None,
date=created_at.strftime("%Y-%m-%d %H:%M:%S"),
+3
View File
@@ -18,6 +18,9 @@ def test_subscribe_source_keyword_includes_episode_group():
tvdbid=None,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="themoviedb",
media_id="12345",
)
source = SubscribeChain.get_subscribe_source_keyword(subscribe)
@@ -14,6 +14,10 @@ def _subscribe(**kwargs):
defaults = {
"tmdbid": 100,
"doubanid": None,
"bangumiid": None,
"anilistid": None,
"media_source": None,
"media_id": None,
"season": 1,
"name": "测试剧",
"type": MediaType.TV.value,
@@ -41,6 +45,10 @@ def _ctx(
type=meta_type,
tmdbid=meta_tmdbid,
doubanid=meta_doubanid,
bangumiid=None,
anilistid=None,
media_source=None,
media_id=None,
begin_season=begin_season,
end_season=end_season,
begin_episode=5,
@@ -107,6 +115,12 @@ def test_cache_candidates_ignore_default_meta_season_list_when_no_explicit_meta_
title = "测试剧 E05"
name = "测试剧"
type = MediaType.TV
tmdbid = None
doubanid = None
bangumiid = None
anilistid = None
media_source = None
media_id = None
begin_season = None
end_season = None
@@ -94,6 +94,10 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
mode="copy",
tmdbid=123,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="themoviedb",
media_id="123",
errmsg="未识别到媒体信息",
)
@@ -151,6 +155,10 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
mode="move",
tmdbid=123,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="themoviedb",
media_id="123",
errmsg=None,
)
@@ -21,6 +21,10 @@ def test_manual_transfer_from_history_preserves_download_context(monkeypatch):
type="电视剧",
tmdbid="100",
doubanid="200",
bangumiid=None,
anilistid=None,
media_source="themoviedb",
media_id="100",
seasons="S01",
episodes="E01-E02",
episode_group="WEB-DL",
+8
View File
@@ -56,15 +56,21 @@ class FakeMeta:
class FakeMedia:
def __init__(self, tmdb_id: int = 12345):
"""构造与正式 MediaInfo 身份字段一致的测试媒体对象。"""
self.tmdb_id = tmdb_id
self.douban_id = None
self.bangumi_id = None
self.anilist_id = None
self.source = "themoviedb"
self.type = MediaType.TV
self.title_year = "Test Show (2026)"
def clear(self):
"""模拟正式媒体对象的清理接口。"""
pass
def to_dict(self):
"""返回测试媒体对象的序列化字段。"""
return {
"type": MediaType.TV.value,
"title": "Test Show",
@@ -72,6 +78,8 @@ class FakeMedia:
"title_year": "Test Show (2026)",
"tmdb_id": self.tmdb_id,
"douban_id": self.douban_id,
"bangumi_id": self.bangumi_id,
"anilist_id": self.anilist_id,
}