mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 08:57:09 +08:00
feat: unify media source identity flow (#6129)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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']}",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
# 检查订阅状态
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
# 使用名称识别兜底
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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、豆瓣、Bangumi、AniList 或插件媒体键查询订阅。
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
# 自动重新识别
|
||||
|
||||
@@ -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
@@ -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
@@ -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"媒体信息识别失败!")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
"模型响应为空": "模型回應為空",
|
||||
"LLM 调用超时": "LLM 呼叫逾時",
|
||||
"刮削路径无效": "刮削路徑無效",
|
||||
"指定媒体ID时必须同时指定媒体数据源": "指定媒體ID時必須同時指定媒體資料源",
|
||||
"媒体ID格式无效": "媒體ID格式無效",
|
||||
"刮削失败,无法识别媒体信息": "刮削失敗,無法識別媒體資訊",
|
||||
"刮削路径不存在": "刮削路徑不存在",
|
||||
"保存成功": "儲存成功",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 当前季总集数")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# 海报
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 的持久化任务,均为管理员级工具:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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="识别失败",
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 = "测试简介"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user