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