refactor(media): unify media identity and chain responsibilities

This commit is contained in:
jxxghp
2026-08-12 18:31:09 +08:00
parent cbb969e0ff
commit ca32922a7f
155 changed files with 8371 additions and 4526 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
<core_workflow>
1. Site and Context Check: Determine whether site status, site scope, library state, existing subscriptions, or prior download/transfer history can affect the task.
2. Media Identity Resolution: Confirm an exact source-native identity. Video normally uses TMDB/Douban/Bangumi/AniList IDs plus title, year, type, season, or episode. Music uses `media_type=music`, MusicBrainz `media_source` + `media_id`, and `music_type=recording|album|artist`, plus artist/title/album when available. Use `search_media`, `query_media_detail`, or `recognize_media` as needed.
2. Media Identity Resolution: Confirm an exact source-native identity and pass it only as the fixed `media_source` enum plus `media_id`. Video and music share this pair; music also uses `media_type=music` and `music_type=recording|album|artist`. Use `search_media`, `query_media_detail`, or `recognize_media` as needed.
3. Resource Discovery: Use the appropriate search path for the task. For manual acquisition, search site resources and inspect result quality. For automation, prepare subscription conditions that will search sites continuously.
4. Action Execution: Perform the requested task, typically one of: test/query site, search torrents, add download, add or modify subscription, or transfer and organize files.
5. Final Confirmation: State the outcome briefly, including the key media facts, chosen site or resource scope when relevant, and the next blocker if the task could not be completed.
+3 -3
View File
@@ -42,7 +42,7 @@ task_types:
- "Delete the failed history record using `delete_transfer_history` with history_id={history_id}."
- "Re-identify the media using `recognize_media` with the source file path. For audio files, set media_type='music' and preserve artist/title/album context."
- "If recognition fails, try `search_media` with keywords from the filename. For music, distinguish recording, album, and browse-only artist results."
- "Re-transfer using `transfer_file` with the source path and exact identity fields. Reuse media_source + media_id + media_type + music_type for music, or the applicable video IDs."
- "Re-transfer using `transfer_file` with the source path and exact identity fields. Reuse media_source + media_id for every media type, plus media_type + music_type for music."
- "Report the final result."
batch_transfer_failed_retry:
header: "[System Task - Batch Transfer Failed Retry]"
@@ -92,7 +92,7 @@ task_types:
- "Only continue when you have high confidence in the target media."
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, media_type, and music_type. For an album, retry the album directory once with the album identity when the records share that directory."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. For an album, retry the album directory once with the album identity when the records share that directory."
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
task_rules:
- "Do NOT rely on previous chat context. Work only from the record above."
@@ -118,7 +118,7 @@ task_types:
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, media_type, and music_type. Prefer one directory transfer for a verified complete album instead of treating each track as an unrelated media item."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. Prefer one directory transfer for a verified complete album instead of treating each track as an unrelated media item."
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
task_rules:
+4 -5
View File
@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.music import MusicChain
from app.chain.recommend import RecommendChain
from app.log import logger
from app.modules.listenbrainz import (
@@ -168,13 +167,13 @@ class GetRecommendationsTool(MoviePilotTool):
f"错误:无效的音乐实体类型 '{music_type}'"
"支持的类型:'recording', 'album'"
)
music_chain = MusicChain()
recommend_chain = RecommendChain()
if source == "listenbrainz_chart":
if range_name not in LISTENBRAINZ_CHART_RANGES:
return f"错误:无效的榜单周期 '{range_name}'"
if sort_by not in {"listen_count.desc", "listen_count.asc"}:
return f"错误:无效的榜单排序 '{sort_by}'"
results = await music_chain.async_chart(
results = await recommend_chain.async_music_chart(
range_name=range_name,
page=page,
count=page_size,
@@ -191,7 +190,7 @@ class GetRecommendationsTool(MoviePilotTool):
if not past and not future:
return "错误:past 和 future 不能同时为 false"
normalized_days = max(1, min(days or 14, LISTENBRAINZ_FRESH_MAX_DAYS))
results = await music_chain.async_fresh_releases(
results = await recommend_chain.async_music_fresh_releases(
days=normalized_days,
sort=fresh_sort,
past=bool(past),
@@ -324,7 +323,7 @@ class GetRecommendationsTool(MoviePilotTool):
"douban_id": r.get("douban_id"),
"bangumi_id": r.get("bangumi_id"),
"anilist_id": r.get("anilist_id"),
"media_source": r.get("source"),
"media_source": r.get("media_source"),
"media_id": r.get("media_id"),
"vote_average": r.get("vote_average"),
"poster_path": r.get("poster_path"),
+9 -7
View File
@@ -9,7 +9,6 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.log import logger
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
@@ -125,9 +124,11 @@ class QueryMediaDetailTool(MoviePilotTool):
"message": "查询音乐详情必须同时提供 media_source 和 media_id",
}, ensure_ascii=False)
music_chain = MusicChain()
media_chain = MediaChain()
if normalized_music_type == MUSIC_ENTITY_ALBUM:
album_info = await music_chain.async_album(media_source, media_id)
album_info = await media_chain.async_get_music_album(
media_source, media_id
)
if not album_info:
return json.dumps({
"success": False,
@@ -140,7 +141,9 @@ class QueryMediaDetailTool(MoviePilotTool):
)
if normalized_music_type == MUSIC_ENTITY_ARTIST:
artist_info = await music_chain.async_artist(media_source, media_id)
artist_info = await media_chain.async_get_music_artist(
media_source, media_id
)
if not artist_info:
return json.dumps({
"success": False,
@@ -153,7 +156,7 @@ class QueryMediaDetailTool(MoviePilotTool):
if include_artist_albums:
pending.append((
"albums",
music_chain.async_artist_albums(
media_chain.async_get_music_artist_albums(
media_source=media_source,
media_id=media_id,
page=normalized_page,
@@ -164,7 +167,7 @@ class QueryMediaDetailTool(MoviePilotTool):
if include_related_artists:
pending.append((
"related_artists",
music_chain.async_artist_related(
media_chain.async_get_music_artist_related(
media_source=media_source,
media_id=media_id,
count=normalized_count,
@@ -181,7 +184,6 @@ class QueryMediaDetailTool(MoviePilotTool):
]
return json.dumps(result, ensure_ascii=False, indent=2)
media_chain = MediaChain()
mediainfo = await media_chain.async_recognize_media(
media_source=media_source,
media_id=media_id,
@@ -170,23 +170,17 @@ class QueryPopularSubscribesTool(MoviePilotTool):
# 跳过无法识别类型的数据,避免单条脏数据导致整批失败
logger.warning(f"跳过未知媒体类型: {sub.get('type')}")
continue
media.tmdb_id = sub.get("tmdbid")
# 处理标题
title = sub.get("name")
season = sub.get("season")
if season not in (None, "") and int(season) != 1 and media.tmdb_id:
if season not in (None, "") and int(season) != 1:
# 小写数据转大写
season_str = cn2an.an2cn(season, "low")
title = f"{title}{season_str}"
media.title = title
media.year = sub.get("year")
media.douban_id = sub.get("doubanid")
media.bangumi_id = sub.get("bangumiid")
media.anilist_id = sub.get("anilistid")
media.media_source = sub.get("media_source")
media.media_id = sub.get("media_id")
media.tvdb_id = sub.get("tvdbid")
media.imdb_id = sub.get("imdbid")
media.season = sub.get("season")
media.vote_average = sub.get("vote")
media.poster_path = sub.get("poster")
@@ -208,14 +202,8 @@ class QueryPopularSubscribesTool(MoviePilotTool):
"type": media_type_to_agent(media_dict.get("type")),
"title": media_dict.get("title"),
"year": media_dict.get("year"),
"tmdb_id": media_dict.get("tmdb_id"),
"douban_id": media_dict.get("douban_id"),
"bangumi_id": media_dict.get("bangumi_id"),
"anilist_id": media_dict.get("anilist_id"),
"media_source": media_dict.get("source"),
"media_source": media_dict.get("media_source"),
"media_id": media_dict.get("media_id"),
"tvdb_id": media_dict.get("tvdb_id"),
"imdb_id": media_dict.get("imdb_id"),
"season": media_dict.get("season"),
"vote_average": media_dict.get("vote_average"),
"poster_path": media_dict.get("poster_path"),
@@ -111,10 +111,6 @@ class QuerySubscribeSharesTool(MoviePilotTool):
"year": share.get("year"),
"type": normalized_type,
"season": share.get("season"),
"tmdbid": share.get("tmdbid"),
"doubanid": share.get("doubanid"),
"bangumiid": share.get("bangumiid"),
"anilistid": share.get("anilistid"),
"media_source": share.get("media_source"),
"media_id": share.get("media_id"),
"music_type": normalized_music_type,
+2 -3
View File
@@ -8,10 +8,10 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.music import MusicChain
from app.chain.media import MediaChain
from app.core.config import settings
from app.core.context import Context
from app.core.meta import MetaMusic
from app.core.metainfo import MetaInfo
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
@@ -108,7 +108,6 @@ class RecognizeMediaTool(MoviePilotTool):
media_type_enum is None and is_audio_path
)
if recognize_music:
music_chain = MusicChain()
if path:
if not is_audio_path:
return json.dumps({
@@ -129,7 +128,7 @@ class RecognizeMediaTool(MoviePilotTool):
"path": path,
}, ensure_ascii=False)
if title:
metainfo = music_chain.parse_query(title)
metainfo = MetaMusic.parse_query(title)
if artist:
metainfo.artists = [artist]
if album:
+27 -11
View File
@@ -9,10 +9,17 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain
from app.core.config import settings
from app.log import logger
from app.schemas import FileItem
from app.schemas.types import MUSIC_ENTITY_ARTIST, MediaType, media_type_to_agent
from app.schemas.types import (
MUSIC_ENTITY_ARTIST,
MediaSource,
MediaType,
media_type_to_agent,
)
from app.utils.media import normalize_media_source
from ._music_utils import normalize_music_type, simplify_music_info
@@ -39,7 +46,7 @@ class ScrapeMetadataInput(BaseModel):
None,
description="For an explicit music ID: recording for one file or album for a complete album directory",
)
media_source: Optional[str] = Field(
media_source: Optional[MediaSource] = Field(
None,
description=(
"Music metadata source: musicbrainz, theaudiodb, or doubanmusic. "
@@ -97,7 +104,7 @@ class ScrapeMetadataTool(MoviePilotTool):
overwrite: Optional[bool] = False,
media_type: Optional[str] = None,
music_type: Optional[str] = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs,
) -> str:
@@ -127,11 +134,18 @@ class ScrapeMetadataTool(MoviePilotTool):
"支持的类型:'movie', 'tv', 'music'"
),
}, ensure_ascii=False)
if bool(media_source) != bool(media_id):
explicit_identity = media_source is not None or media_id is not None
normalized_source = normalize_media_source(media_source)
normalized_media_id = str(media_id).strip() if media_id is not None else ""
if explicit_identity and (
not normalized_source or not normalized_media_id
):
return json.dumps({
"success": False,
"message": "media_source 和 media_id 必须同时提供",
"message": "必须同时提供有效的 media_source 和 media_id",
}, ensure_ascii=False)
media_source = normalized_source
media_id = normalized_media_id or None
local_path = Path(path)
is_local_directory = (storage or "local") == "local" and local_path.is_dir()
@@ -151,6 +165,7 @@ class ScrapeMetadataTool(MoviePilotTool):
)
media_chain = MediaChain()
scraping_chain = ScrapingChain()
is_audio_file = (
fileitem.type == "file"
and Path(path).suffix.lower() in settings.RMT_AUDIOEXT
@@ -179,8 +194,8 @@ class ScrapeMetadataTool(MoviePilotTool):
mediainfo = None
if media_source and media_id:
recognize_kwargs = {
"source": media_source,
"mediaid": media_id,
"media_source": media_source,
"media_id": media_id,
"mtype": MediaType.MUSIC,
}
if normalized_music_type:
@@ -205,11 +220,11 @@ class ScrapeMetadataTool(MoviePilotTool):
success, message = await self.run_blocking(
"storage",
media_chain.scrape_music_metadata,
scraping_chain.scrape_music_metadata,
fileitem=fileitem,
mediainfo=mediainfo,
overwrite=bool(overwrite),
source=media_source,
media_source=media_source,
)
result = {
"success": success,
@@ -246,7 +261,7 @@ class ScrapeMetadataTool(MoviePilotTool):
# 刮削会包含磁盘写入和外部图片/元数据访问,统一放到 storage 线程池。
await self.run_blocking(
"storage",
media_chain.scrape_metadata,
scraping_chain.scrape_metadata,
fileitem=fileitem,
meta=context.meta_info,
mediainfo=context.media_info,
@@ -262,7 +277,8 @@ class ScrapeMetadataTool(MoviePilotTool):
"title": context.media_info.title,
"year": context.media_info.year,
"type": media_type_to_agent(context.media_info.type),
"tmdb_id": context.media_info.tmdb_id,
"media_source": context.media_info.media_source,
"media_id": context.media_info.media_id,
"season": context.media_info.season,
},
},
+1 -2
View File
@@ -8,7 +8,6 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
from app.utils.media import resolve_media_identity
@@ -93,7 +92,7 @@ class SearchMediaTool(MoviePilotTool):
f"错误:无效的音乐实体类型 '{music_type}'"
"支持的类型:'recording', 'album', 'artist'"
)
results = await MusicChain().async_search(query=title, limit=100)
results = await MediaChain().async_search_music(query=title, limit=100)
filtered_music = [
item
for item in results or []
+3 -3
View File
@@ -5,8 +5,8 @@ from fastapi import APIRouter, Depends, Body
from app import schemas
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
from app.core.meta import MetaMusic
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db.models.user import User
@@ -75,7 +75,7 @@ def download(
"""
if isinstance(media_in, schemas.MusicInfo):
mediainfo = MusicInfo.from_dict(media_in.model_dump())
metainfo = MusicChain.to_meta(mediainfo)
metainfo = MetaMusic.from_music_info(mediainfo)
metainfo.org_string = torrent_in.title
else:
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
@@ -142,7 +142,7 @@ def add(
normalized_music_type = MUSIC_ENTITY_RECORDING
# 元数据
metainfo = (
MusicChain.parse_query(torrent_in.title)
MetaMusic.parse_query(torrent_in.title)
if is_music
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
)
+113 -38
View File
@@ -2,11 +2,12 @@ from pathlib import Path
from typing import Annotated, Any, List, Optional, Union
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import BeforeValidator
from app import schemas
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.chain.scraping import ScrapingChain
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import Context, MusicInfo
@@ -20,28 +21,64 @@ from app.schemas.category import CategoryConfig
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
from app.utils.media import (
is_music_media_source,
normalize_media_source,
normalize_music_type,
parse_media_source_selection,
resolve_media_identity,
)
router = APIRouter()
def _split_media_source_query(value: object) -> tuple[str, ...]:
"""展开重复或逗号分隔的来源参数,并在枚举校验前规范历史别名。"""
if value in (None, ""):
return ()
values = value if isinstance(value, (list, tuple)) else (value,)
sources = tuple(
source.strip()
for item in values
for source in str(item).split(",")
if source.strip()
)
return tuple(
normalized.value if (normalized := normalize_media_source(source)) else source
for source in sources
)
MediaSourceQuery = Annotated[
tuple[MediaSource, ...],
BeforeValidator(_split_media_source_query),
Query(),
]
def _is_valid_source_media_id(
media_source: Optional[MediaSource], media_id: str,
) -> bool:
"""按媒体数据源校验原生 ID,并兼容豆瓣音乐的曲目复合 ID。"""
if media_source == MediaSource.MusicBrainz:
normalized_source, normalized_media_id = resolve_media_identity(
media_source=media_source,
media_id=media_id,
)
if not normalized_source or not normalized_media_id:
return False
if normalized_source == MediaSource.MusicBrainz:
try:
UUID(media_id)
UUID(normalized_media_id)
return True
except (TypeError, ValueError):
return False
if media_source == MediaSource.DoubanMusic and ":" in media_id:
album_id, track_number = media_id.split(":", 1)
if normalized_source == MediaSource.DoubanMusic and ":" in normalized_media_id:
album_id, track_number = normalized_media_id.split(":", 1)
return album_id.isdigit() and track_number.isdigit()
if media_source == MediaSource.IMDb:
return media_id.startswith("tt") and media_id[2:].isdigit()
return bool(media_id.strip())
if normalized_source == MediaSource.IMDb:
return (
normalized_media_id.startswith("tt")
and normalized_media_id[2:].isdigit()
)
return True
def _build_recognize_metainfo(
@@ -132,7 +169,7 @@ async def recognize(
metainfo = _build_recognize_metainfo(title, subtitle, custom_words)
# 显式音乐来源需要按音乐元数据解析,避免名称测试误入影视识别。
if is_music_media_source(media_source) and not isinstance(metainfo, MetaMusic):
metainfo = MusicChain.parse_query(title)
metainfo = MetaMusic.parse_query(title)
mediainfo = await MediaChain().async_recognize_by_meta(
metainfo,
media_source=media_source,
@@ -204,7 +241,7 @@ async def search(
type: Optional[str] = "media",
page: int = 1,
count: int = 8,
media_source: Optional[str] = None,
media_source: MediaSourceQuery = (),
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
@@ -214,7 +251,7 @@ async def search(
:param type: 搜索类型,支持 media、music、collection、person
:param page: 页码
:param count: 每页数量
:param media_source: 请求级搜索数据源,支持逗号分隔
:param media_source: 请求级搜索数据源枚举;可重复传入,逗号格式仅用于兼容旧客户端
:param _: Token校验
:return: 搜索结果列表
"""
@@ -227,33 +264,43 @@ async def search(
return obj.get("media_source")
return obj.media_source
# 直接函数调用也可能绕过 FastAPI/Pydantic,仅在该测试与内部兼容边界补一次规范化。
selected_sources = (
media_source
if isinstance(media_source, tuple)
and all(isinstance(source, MediaSource) for source in media_source)
else parse_media_source_selection(",".join(_split_media_source_query(media_source)))
)
selected_sources = tuple(dict.fromkeys(selected_sources))
source_selection = selected_sources or None
media_chain = MediaChain()
if type == "music" or is_music_media_source(media_source):
if type == "music" or any(is_music_media_source(source) for source in selected_sources):
# 音乐搜索统一入口,与影视搜索共用 /media/search
music_search_params = {"query": title, "limit": count}
# 未指定来源时保留既有调用契约,由 MusicChain 选择默认音乐源。
if media_source:
music_search_params["media_source"] = media_source
music_infos = await MusicChain().async_search(**music_search_params)
# 未指定来源时由 MediaChain 使用默认 MusicBrainz 来源。
if source_selection:
music_search_params["media_source"] = source_selection
music_infos = await media_chain.async_search_music(**music_search_params)
return [
info.to_dict()
for info in music_infos
] if music_infos else []
if type == "media":
_, medias = await media_chain.async_search(
title=title, media_source=media_source
title=title, media_source=source_selection
)
result = [media.to_dict() for media in medias] if medias else []
elif type == "collection":
collections = await media_chain.async_search_collections(
name=title, media_source=media_source
name=title, media_source=source_selection
)
result = (
[collection.to_dict() for collection in collections] if collections else []
)
else: # person
persons = await media_chain.async_search_persons(
name=title, media_source=media_source
name=title, media_source=source_selection
)
result = [person.model_dump() for person in persons] if persons else []
@@ -293,7 +340,10 @@ def scrape(
"""
if not fileitem or not fileitem.path:
return schemas.Response(success=False, message="刮削路径无效")
normalized_media_id = media_id.strip() if media_id else None
has_explicit_media_id = media_id is not None
normalized_media_id = str(media_id).strip() if has_explicit_media_id else None
if has_explicit_media_id and not normalized_media_id:
return schemas.Response(success=False, message="媒体ID格式无效")
if normalized_media_id and not media_source:
return schemas.Response(
success=False, message="指定媒体ID时必须同时指定媒体数据源"
@@ -329,7 +379,7 @@ def scrape(
)
if not music_info:
return schemas.Response(success=False, message="刮削失败,无法识别音乐信息")
success, message = MediaChain().scrape_music_metadata(
success, message = ScrapingChain().scrape_music_metadata(
fileitem=fileitem,
mediainfo=music_info,
overwrite=True,
@@ -366,7 +416,7 @@ def scrape(
if not Path(fileitem.path).exists():
return schemas.Response(success=False, message="刮削路径不存在")
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
chain.scrape_metadata(
ScrapingChain().scrape_metadata(
fileitem=fileitem,
meta=meta_info,
mediainfo=media_info,
@@ -420,7 +470,13 @@ async def group_seasons(
"""
查询剧集组季信息(themoviedb)
"""
return await TmdbChain().async_tmdb_group_seasons(group_id=episode_group)
_, normalized_group_id = resolve_media_identity(
media_source=MediaSource.TMDB,
media_id=episode_group,
)
if not normalized_group_id:
return []
return await TmdbChain().async_tmdb_group_seasons(group_id=normalized_group_id)
@router.get("/groups/{tmdbid}", summary="查询媒体剧集组", response_model=List[dict])
@@ -428,9 +484,15 @@ async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -
"""
查询媒体剧集组列表(themoviedb)
"""
mediainfo = await MediaChain().async_recognize_media(
media_source, media_id = resolve_media_identity(
media_source=MediaSource.TMDB,
media_id=str(tmdbid),
media_id=tmdbid,
)
if not media_source or not media_id:
return []
mediainfo = await MediaChain().async_recognize_media(
media_source=media_source,
media_id=media_id,
mtype=MediaType.TV,
)
if not mediainfo:
@@ -452,11 +514,15 @@ async def seasons(
"""
查询媒体季信息
"""
if media_source or media_id:
if not media_source or not media_id:
if media_source is not None or media_id is not None:
normalized_source, normalized_media_id = resolve_media_identity(
media_source=media_source,
media_id=media_id,
)
if not normalized_source or not normalized_media_id:
return []
if media_source == MediaSource.TMDB and media_id.isdigit():
tmdbid = int(media_id)
if normalized_source == MediaSource.TMDB and normalized_media_id.isdigit():
tmdbid = int(normalized_media_id)
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
if seasons_info:
if season is not None:
@@ -464,8 +530,8 @@ async def seasons(
return seasons_info
else:
mediainfo = await MediaChain().async_recognize_media(
media_source=media_source,
media_id=media_id,
media_source=normalized_source,
media_id=normalized_media_id,
mtype=MediaType.TV,
cache=False,
)
@@ -483,13 +549,16 @@ async def seasons(
obtain_images=False,
)
if mediainfo:
recognized_source, recognized_media_id = resolve_media_identity(
media=mediainfo
)
if (
mediainfo.media_source == MediaSource.TMDB
and mediainfo.media_id
and mediainfo.media_id.isdigit()
recognized_source == MediaSource.TMDB
and recognized_media_id
and recognized_media_id.isdigit()
):
seasons_info = await TmdbChain().async_tmdb_seasons(
tmdbid=int(mediainfo.media_id)
tmdbid=int(recognized_media_id)
)
if seasons_info:
if season is not None:
@@ -512,10 +581,16 @@ async def detail(
根据媒体来源和原生 ID 查询媒体信息,type_name: 电影/电视剧
"""
mtype = MediaType(type_name)
mediachain = MediaChain()
mediainfo = await mediachain.async_recognize_media(
normalized_source, normalized_media_id = resolve_media_identity(
media_source=media_source,
media_id=media_id,
)
if not normalized_source or not normalized_media_id:
return schemas.MediaInfo()
mediachain = MediaChain()
mediainfo = await mediachain.async_recognize_media(
media_source=normalized_source,
media_id=normalized_media_id,
mtype=mtype,
)
# 识别
+56 -23
View File
@@ -4,8 +4,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from app import schemas
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.schemas.types import MediaType
from app.chain.recommend import RecommendChain
from app.schemas.types import MediaSource, MediaType
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.core.security import verify_token
from app.db.models.user import User
@@ -22,12 +22,12 @@ router = APIRouter()
CountParam = Annotated[int, Query(ge=1, le=100)]
PageParam = Annotated[int, Query(ge=1)]
MusicSourceParam = Annotated[
str,
Query(pattern="^(musicbrainz|theaudiodb|doubanmusic)$"),
MediaSource,
Query(),
]
MusicExploreSourceParam = Annotated[
str,
Query(pattern="^(musicbrainz|doubanmusic)$"),
MediaSource,
Query(),
]
MusicModeParam = Annotated[str, Query(pattern="^(chart|fresh)$")]
MusicEntityParam = Annotated[str, Query(pattern="^(recording|album)$")]
@@ -41,6 +41,29 @@ MusicAlbumTypeParam = Annotated[
Optional[str],
Query(pattern="^(album|single|ep|broadcast|other|compilation|soundtrack|live|remix)$"),
]
_MUSIC_DETAIL_SOURCES = frozenset({
MediaSource.MusicBrainz,
MediaSource.TheAudioDB,
MediaSource.DoubanMusic,
})
_MUSIC_EXPLORE_SOURCES = frozenset({
MediaSource.MusicBrainz,
MediaSource.DoubanMusic,
})
def _validate_music_source(
media_source: MediaSource,
allowed_sources: frozenset[MediaSource],
) -> MediaSource:
"""将 HTTP 或直接调用参数规范为音乐来源枚举,并拒绝不支持的来源。"""
try:
normalized_source = MediaSource(media_source)
except (TypeError, ValueError) as err:
raise HTTPException(status_code=422, detail="无效的媒体来源") from err
if normalized_source not in allowed_sources:
raise HTTPException(status_code=422, detail="该媒体来源不支持此音乐接口")
return normalized_source
def _serialize_music(info: MusicInfo) -> schemas.MusicInfo:
@@ -138,7 +161,7 @@ async def clear_music_recognition_cache(
async def explore_music(
page: PageParam = 1,
count: CountParam = 30,
media_source: MusicExploreSourceParam = "musicbrainz",
media_source: MusicExploreSourceParam = MediaSource.MusicBrainz,
mode: MusicModeParam = "chart",
entity: MusicEntityParam = "recording",
range_name: MusicRangeParam = "this_month",
@@ -154,9 +177,10 @@ async def explore_music(
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
"""MusicBrainz 返回榜单或新发行,豆瓣音乐固定按官方标签分类浏览。"""
chain = MusicChain()
if media_source != "musicbrainz":
results = await chain.async_discover(
media_source = _validate_music_source(media_source, _MUSIC_EXPLORE_SOURCES)
chain = RecommendChain()
if media_source != MediaSource.MusicBrainz:
results = await chain.async_music_discover(
media_source=media_source,
page=page,
count=count,
@@ -166,7 +190,7 @@ async def explore_music(
sort=douban_sort,
)
elif mode == "fresh":
results = await chain.async_fresh_releases(
results = await chain.async_music_fresh_releases(
days=days,
sort=sort,
past=past,
@@ -176,7 +200,7 @@ async def explore_music(
with_cover=with_cover,
)
else:
results = await chain.async_chart(
results = await chain.async_music_chart(
range_name=range_name,
page=page,
count=count,
@@ -185,7 +209,7 @@ async def explore_music(
with_cover=with_cover,
entity=entity,
)
if media_source != "musicbrainz" and with_cover:
if media_source != MediaSource.MusicBrainz and with_cover:
results = [info for info in results if info.cover_url or info.poster_path]
return [_serialize_music(info) for info in results]
@@ -197,11 +221,14 @@ async def explore_music(
)
async def music_album(
album_id: str,
media_source: MusicSourceParam = "musicbrainz",
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MusicAlbumInfo:
"""按专辑标准 ID 返回专辑详情、曲目列表和发行版本。"""
info = await MusicChain().async_album(media_source=media_source, media_id=album_id)
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
info = await MediaChain().async_get_music_album(
media_source=media_source, media_id=album_id
)
if not info:
raise HTTPException(status_code=404, detail="未识别到专辑信息")
return _serialize_album(info)
@@ -215,11 +242,12 @@ async def music_album(
async def music_album_related(
album_id: str,
count: CountParam = 24,
media_source: MusicSourceParam = "musicbrainz",
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
"""按来源和专辑 ID 返回可继续浏览的关联专辑。"""
results = await MusicChain().async_album_related(
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
results = await MediaChain().async_get_music_album_related(
media_source=media_source,
media_id=album_id,
count=count,
@@ -237,11 +265,12 @@ async def music_artist_albums(
page: PageParam = 1,
count: CountParam = 30,
album_type: MusicAlbumTypeParam = None,
media_source: MusicSourceParam = "musicbrainz",
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
"""按艺术家标准 ID 分页返回其专辑、EP 和单曲。"""
results = await MusicChain().async_artist_albums(
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
results = await MediaChain().async_get_music_artist_albums(
media_source=media_source,
media_id=artist_id,
page=page,
@@ -259,11 +288,12 @@ async def music_artist_albums(
async def music_artist_related(
artist_id: str,
count: CountParam = 24,
media_source: MusicSourceParam = "musicbrainz",
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicArtistInfo]:
"""按艺术家关系返回可继续浏览的关联艺术家。"""
results = await MusicChain().async_artist_related(
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
results = await MediaChain().async_get_music_artist_related(
media_source=media_source,
media_id=artist_id,
count=count,
@@ -278,11 +308,14 @@ async def music_artist_related(
)
async def music_artist(
artist_id: str,
media_source: MusicSourceParam = "musicbrainz",
media_source: MusicSourceParam = MediaSource.MusicBrainz,
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MusicArtistInfo:
"""按艺术家标准 ID 返回艺术家详情。"""
info = await MusicChain().async_artist(media_source=media_source, media_id=artist_id)
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
info = await MediaChain().async_get_music_artist(
media_source=media_source, media_id=artist_id
)
if not info:
raise HTTPException(status_code=404, detail="未识别到艺术家信息")
return _serialize_artist(info)
+10 -8
View File
@@ -13,7 +13,7 @@ from app.core.security import verify_resource_token, verify_token
from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas.types import MediaSource, MediaType
from app.utils.media import normalize_music_type
from app.utils.media import normalize_music_type, resolve_media_identity
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -59,9 +59,12 @@ async def _resolve_media_search_params(
music_type: Optional[str] = None,
) -> tuple[Optional[dict], str]:
"""校验统一媒体身份并构造 SearchChain 精确搜索参数。"""
normalized_media_id = str(media_id or "").strip()
if not normalized_media_id:
return None, "媒体 ID 不能为空"
normalized_source, normalized_media_id = resolve_media_identity(
media_source=media_source,
media_id=media_id,
)
if not normalized_source or not normalized_media_id:
return None, "媒体ID格式无效"
normalized_music_type = None
if music_type:
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
@@ -71,7 +74,7 @@ async def _resolve_media_search_params(
return None, "music_type 仅能用于音乐资源搜索"
params = {
"media_source": media_source,
"media_source": normalized_source,
"media_id": normalized_media_id,
}
if normalized_music_type:
@@ -368,7 +371,6 @@ async def search_by_id_stream(
media_type = _parse_media_type(mtype)
media_season = int(season) if season else None
site_list = _parse_site_list(sites)
search_chain = SearchChain()
async def event_source():
"""解析媒体身份并输出精确搜索流事件。"""
@@ -381,7 +383,7 @@ async def search_by_id_stream(
if not search_params:
yield {"type": "error", "success": False, "message": message}
return
torrents = search_chain.async_search_by_id_stream(
torrents = SearchChain().async_search_by_id_stream(
**search_params,
mtype=media_type,
area=area,
@@ -551,7 +553,6 @@ async def _build_subtitle_search_source(
media_season = int(season) if season else None
media_episode = int(episode) if episode else None
site_list = _parse_site_list(sites)
search_chain = SearchChain()
def call_search(**kwargs):
"""
@@ -576,6 +577,7 @@ async def _build_subtitle_search_source(
)
if not search_params:
return None, message
search_chain = SearchChain()
return call_search(**search_params), ""
+49 -4
View File
@@ -30,7 +30,7 @@ from app.schemas.types import (
EventType,
SystemConfigKey,
)
from app.utils.media import normalize_media_source
from app.utils.media import normalize_media_source, resolve_media_identity
router = APIRouter()
@@ -211,6 +211,26 @@ async def create_subscribe(
else:
title = None
subscribe_dict = subscribe_in.to_public_write_payload()
identity_fields = {"media_source", "media_id"}.intersection(
subscribe_in.model_fields_set
)
if identity_fields:
media_source, media_id = resolve_media_identity(
media_source=subscribe_in.media_source,
media_id=subscribe_in.media_id,
)
if media_source and media_id:
subscribe_dict["media_source"] = media_source
subscribe_dict["media_id"] = media_id
elif subscribe_in.media_source is None and subscribe_in.media_id is None:
# 完整空对表示订阅暂无可用身份,与只提交其中一个字段语义不同。
subscribe_dict["media_source"] = None
subscribe_dict["media_id"] = None
else:
return schemas.Response(
success=False,
message="新增订阅时必须同时提供有效的 media_source 和 media_id",
)
subscribe_dict["username"] = current_user.name
sid, message = await SubscribeChain().async_add(
mtype=mtype,
@@ -236,7 +256,27 @@ async def update_subscribe(
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
old_subscribe_dict = subscribe.to_dict()
subscribe_dict = subscribe_in.to_public_write_payload()
subscribe_dict = subscribe_in.to_public_write_payload(exclude_unset=True)
identity_fields = {"media_source", "media_id"}.intersection(
subscribe_in.model_fields_set
)
if identity_fields:
media_source, media_id = resolve_media_identity(
media_source=subscribe_in.media_source,
media_id=subscribe_in.media_id,
)
if media_source and media_id:
subscribe_dict["media_source"] = media_source
subscribe_dict["media_id"] = media_id
elif subscribe_in.media_source is None and subscribe_in.media_id is None:
# 只有两个身份键都显式为空时才清空;全部省略则保留存量身份。
subscribe_dict["media_source"] = None
subscribe_dict["media_id"] = None
else:
return schemas.Response(
success=False,
message="更新媒体身份时必须同时提供有效的 media_source 和 media_id",
)
subscribe_dict["username"] = subscribe.username
if getattr(subscribe, "type", None) == MediaType.MUSIC.value:
# 音乐实体与曲目总数来自识别链,编辑接口不得把专辑改成单曲而提前完成订阅。
@@ -244,13 +284,18 @@ async def update_subscribe(
subscribe_dict["music_type"] = subscribe.music_type
subscribe_dict["total_tracks"] = subscribe.total_tracks \
if subscribe.music_type == MUSIC_ENTITY_ALBUM else None
if subscribe_in.total_episode and subscribe_in.total_episode > (subscribe.total_episode or 0):
total_episode_updated = "total_episode" in subscribe_in.model_fields_set
if (
total_episode_updated
and subscribe_in.total_episode
and subscribe_in.total_episode > (subscribe.total_episode or 0)
):
# 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。
subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + (
subscribe_in.total_episode - (subscribe.total_episode or 0)
)
# 是否手动修改过总集数
if subscribe_in.total_episode != subscribe.total_episode:
if total_episode_updated and subscribe_in.total_episode != subscribe.total_episode:
subscribe_dict["manual_total_episode"] = 1
# 更新到数据库
await subscribe.async_update(db, subscribe_dict)
+1 -2
View File
@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends
from app import schemas
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings
from app.core.context import MediaInfo, MusicInfo
@@ -266,7 +265,7 @@ async def reidentify_cache(
meta = (
target_context.meta_info
if isinstance(target_context.meta_info, MetaMusic)
else MusicChain.parse_query(target_context.torrent_info.title)
else MetaMusic.parse_query(target_context.torrent_info.title)
)
else:
meta = MetaInfo(
+9 -8
View File
@@ -47,6 +47,7 @@ from app.schemas.category import CategoryConfig
from app.schemas.types import (
TorrentStatus,
MediaType,
MediaSourceSelection,
MediaImageType,
EventType,
ChainEventType,
@@ -637,7 +638,7 @@ class ChainBase(metaclass=ABCMeta):
return None
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
if not mtype and meta and meta.type in [
if not mtype and not (media_source and media_id) and meta and meta.type in [
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
]:
mtype = meta.type
@@ -747,7 +748,7 @@ class ChainBase(metaclass=ABCMeta):
return None
if not episode_group and hasattr(meta, "episode_group"):
episode_group = meta.episode_group
if not mtype and meta and meta.type in [
if not mtype and not (media_source and media_id) and meta and meta.type in [
MediaType.TV, MediaType.MOVIE, MediaType.MUSIC
]:
mtype = meta.type
@@ -1263,7 +1264,7 @@ class ChainBase(metaclass=ABCMeta):
return self.run_module("webhook_parser", body=body, form=form, args=args)
def search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
@@ -1276,7 +1277,7 @@ class ChainBase(metaclass=ABCMeta):
)
async def async_search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息(异步版本)
@@ -1289,7 +1290,7 @@ class ChainBase(metaclass=ABCMeta):
)
def search_persons(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaPerson]]:
"""
搜索人物信息
@@ -1302,7 +1303,7 @@ class ChainBase(metaclass=ABCMeta):
)
async def async_search_persons(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaPerson]]:
"""
搜索人物信息(异步版本)
@@ -1315,7 +1316,7 @@ class ChainBase(metaclass=ABCMeta):
)
def search_collections(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索集合信息
@@ -1328,7 +1329,7 @@ class ChainBase(metaclass=ABCMeta):
)
async def async_search_collections(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索集合信息(异步版本)
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from typing import Optional, Union
from app.chain import ChainBase
class AcoustIdChain(ChainBase):
"""AcoustID 音频指纹识别来源链。"""
def identify_music_by_fingerprint(
self,
path: Union[str, Path],
) -> Optional[str]:
"""根据本地音频指纹返回 MusicBrainz Recording ID。"""
result = self.run_module(
"identify_music_by_fingerprint",
path=Path(path),
)
return str(result).strip() if result else None
async def async_identify_music_by_fingerprint(
self,
path: Union[str, Path],
) -> Optional[str]:
"""异步根据本地音频指纹返回 MusicBrainz Recording ID。"""
result = await self.async_run_module(
"async_identify_music_by_fingerprint",
path=Path(path),
)
return str(result).strip() if result else None
+214 -2
View File
@@ -1,9 +1,11 @@
from typing import Optional, List
from typing import Any, List, Optional
from app import schemas
from app.chain import ChainBase
from app.core.context import MediaInfo
from app.core.context import MediaInfo, MusicAlbumInfo, MusicInfo
from app.core.meta import MetaMusic
from app.schemas import MediaType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource
class DoubanChain(ChainBase):
@@ -11,6 +13,216 @@ class DoubanChain(ChainBase):
豆瓣处理链
"""
music_source = MediaSource.DoubanMusic
def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]:
"""按音乐元数据搜索豆瓣音乐候选。"""
result = self.run_module(
"search_music",
meta=meta,
limit=limit,
media_source=self.music_source,
)
return self._music_infos(result, limit=limit)
async def async_search_music(
self,
meta: MetaMusic,
limit: int = 20,
) -> list[MusicInfo]:
"""异步按音乐元数据搜索豆瓣音乐候选。"""
result = await self.async_run_module(
"search_music",
meta=meta,
limit=limit,
media_source=self.music_source,
)
return self._music_infos(result, limit=limit)
def recognize_music(
self,
meta: Optional[MetaMusic] = None,
media_id: Optional[str] = None,
cache: bool = True,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""按豆瓣音乐身份或音乐元数据识别标准音乐信息。"""
normalized_id = self._normalize_music_id(media_id)
result = self.run_module(
"recognize_media",
meta=meta,
mtype=MediaType.MUSIC,
media_source=self.music_source,
media_id=normalized_id,
cache=cache,
music_type=music_type,
)
return self._music_info(result, media_id=normalized_id)
async def async_recognize_music(
self,
meta: Optional[MetaMusic] = None,
media_id: Optional[str] = None,
cache: bool = True,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步按豆瓣音乐身份或音乐元数据识别标准音乐信息。"""
normalized_id = self._normalize_music_id(media_id)
result = await self.async_run_module(
"async_recognize_media",
meta=meta,
mtype=MediaType.MUSIC,
media_source=self.music_source,
media_id=normalized_id,
cache=cache,
music_type=music_type,
)
return self._music_info(result, media_id=normalized_id)
def get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]:
"""按豆瓣音乐专辑 ID 获取标准化专辑详情。"""
normalized_id = self._normalize_music_id(media_id)
if not normalized_id:
return None
result = self.run_module(
"music_album",
media_source=self.music_source,
media_id=normalized_id,
)
return self._music_album(result, media_id=normalized_id)
async def async_get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]:
"""异步按豆瓣音乐专辑 ID 获取标准化专辑详情。"""
normalized_id = self._normalize_music_id(media_id)
if not normalized_id:
return None
result = await self.async_run_module(
"music_album",
media_source=self.music_source,
media_id=normalized_id,
)
return self._music_album(result, media_id=normalized_id)
async def async_get_music_album_related(
self,
media_id: str,
count: int = 24,
) -> list[MusicInfo]:
"""异步按豆瓣音乐专辑 ID 获取相关推荐。"""
normalized_id = self._normalize_music_id(media_id)
if not normalized_id:
return []
result = await self.async_run_module(
"music_album_related",
media_source=self.music_source,
media_id=normalized_id,
count=count,
)
return self._music_infos(result, limit=count)
def music_discover(
self,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
mode: str = "chart",
tags: str = "",
sort: str = "U",
) -> list[MusicInfo]:
"""按豆瓣音乐官方榜单或标签浏览标准音乐条目。"""
result = self.run_module(
"music_discover",
media_source=self.music_source,
page=page,
count=count,
entity=entity,
mode=mode,
tags=tags,
sort=sort,
)
return self._music_infos(result, limit=count)
async def async_music_discover(
self,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
mode: str = "chart",
tags: str = "",
sort: str = "U",
) -> list[MusicInfo]:
"""异步按豆瓣音乐官方榜单或标签浏览标准音乐条目。"""
result = await self.async_run_module(
"music_discover",
media_source=self.music_source,
page=page,
count=count,
entity=entity,
mode=mode,
tags=tags,
sort=sort,
)
return self._music_infos(result, limit=count)
@staticmethod
def _normalize_music_id(media_id: Optional[str]) -> Optional[str]:
"""清理豆瓣音乐原生 ID,空值和历史零哨兵按无身份处理。"""
normalized = str(media_id).strip() if media_id is not None else ""
return normalized if normalized and normalized != "0" else None
@classmethod
def _music_infos(cls, result: Any, limit: Optional[int] = None) -> list[MusicInfo]:
"""将模块或插件结果转换为豆瓣音乐候选列表。"""
candidates = result if isinstance(result, list) else []
infos = [
item if isinstance(item, MusicInfo) else MusicInfo.from_dict(item)
for item in candidates
if isinstance(item, (MusicInfo, dict))
]
infos = [info for info in infos if info.media_source == cls.music_source]
return infos[:limit] if limit else infos
@classmethod
def _music_info(
cls,
result: Any,
media_id: Optional[str] = None,
) -> Optional[MusicInfo]:
"""校验豆瓣音乐识别结果的来源与显式身份。"""
if isinstance(result, MusicInfo):
info = result
elif isinstance(result, dict):
info = MusicInfo.from_dict(result)
else:
return None
if info.media_source and info.media_source != cls.music_source:
return None
if media_id and (
info.media_source != cls.music_source
or info.media_id != media_id
):
return None
return info
@classmethod
def _music_album(
cls,
result: Any,
media_id: Optional[str] = None,
) -> Optional[MusicAlbumInfo]:
"""将模块或插件结果转换为豆瓣音乐专辑详情。"""
if isinstance(result, MusicAlbumInfo):
album = result
elif isinstance(result, dict):
album = MusicAlbumInfo.from_dict(result)
else:
return None
if album.media_source != cls.music_source:
return None
if media_id and album.media_id != media_id:
return None
return album
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
根据人物ID查询豆瓣人物详情
+24 -28
View File
@@ -166,19 +166,16 @@ class DownloadChain(ChainBase):
@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)}
media_key = build_media_key(source, media_id)
return {media_key} if media_key else set()
@classmethod
def _matches_media_identity(cls, media: Optional[MediaInfo], media_key: object) -> bool:
"""判断媒体是否命中统一身份键或任一兼容 ID"""
"""判断媒体是否命中来源与原生 ID 组成的统一身份键。"""
return media_key is not None and str(media_key) in cls._media_identity_keys(media)
@staticmethod
@@ -603,12 +600,8 @@ class DownloadChain(ChainBase):
media_type = getattr(getattr(media, "type", None), "value", getattr(media, "type", None))
media_source, media_id = resolve_media_identity(media=media)
media_key = (
f"{media_source}:{media_id}"
if media_source and media_id
else getattr(media, "imdb_id", None)
or getattr(media, "tvdb_id", None)
or f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}"
media_key = build_media_key(media_source, media_id) or (
f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}"
)
meta = getattr(context, "meta_info", None)
site = getattr(torrent, "site", None) or getattr(torrent, "site_name", None)
@@ -1126,7 +1119,7 @@ class DownloadChain(ChainBase):
def batch_download(self,
contexts: List[Context],
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None,
no_exists: Dict[str, Dict[int, NotExistMediaInfo]] = None,
save_path: Optional[str] = None,
channel: MessageChannel = None,
source: Optional[str] = None,
@@ -1134,7 +1127,7 @@ class DownloadChain(ChainBase):
username: Optional[str] = None,
downloader: Optional[str] = None,
custom_words: Optional[str] = None
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
) -> Tuple[List[Context], Dict[str, Dict[int, NotExistMediaInfo]]]:
"""
根据缺失数据自动种子列表中组合择优下载
:param contexts: 资源上下文列表
@@ -1146,16 +1139,16 @@ class DownloadChain(ChainBase):
:param username: 调用下载的用户名/插件名
:param downloader: 下载器
:param custom_words: 下载来源如订阅的完整自定义识别词文本随下载记录存档供整理时原样复现识别
:return: 下载资源列表剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
:return: 已下载资源列表及剩余缺集键格式为 no_exists[source:id]
"""
# 已下载的项目
downloaded_list: List[Context] = []
custom_word_list = custom_words.splitlines() if custom_words else None
def __update_seasons(_mid: Union[int, str], _need: list, _current: list) -> list:
def __update_seasons(_mid: str, _need: list, _current: list) -> list:
"""
更新need_tvs季数返回剩余季数
:param _mid: TMDBID
:param _mid: 统一媒体身份键
:param _need: 需要下载的季数
:param _current: 已经下载的季数
"""
@@ -1172,10 +1165,10 @@ class DownloadChain(ChainBase):
break
return need
def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list:
def __update_episodes(_mid: str, _sea: int, _need: list, _current: set) -> list:
"""
更新need_tvs集数返回剩余集数
:param _mid: TMDBID
:param _mid: 统一媒体身份键
:param _sea: 季数
:param _need: 需要下载的集数
:param _current: 已经下载的集数
@@ -1197,7 +1190,7 @@ class DownloadChain(ChainBase):
no_exists.pop(_mid)
return need
def __get_season_episodes(_mid: Union[int, str], season: int) -> int:
def __get_season_episodes(_mid: str, season: int) -> int:
"""
获取需要的季的集数
"""
@@ -1208,7 +1201,7 @@ class DownloadChain(ChainBase):
return 9999
return no_exist[season].total_episode
def __get_no_exist_media(_mid: Union[int, str], season: int) -> Optional[NotExistMediaInfo]:
def __get_no_exist_media(_mid: str, season: int) -> Optional[NotExistMediaInfo]:
"""
获取指定媒体和季的缺失信息
"""
@@ -1216,7 +1209,7 @@ class DownloadChain(ChainBase):
return None
return no_exists.get(_mid).get(season)
def __get_required_episodes(_mid: Union[int, str], season: int) -> Set[int]:
def __get_required_episodes(_mid: str, season: int) -> Set[int]:
"""
获取整季候选必须覆盖的目标集范围
"""
@@ -1349,8 +1342,8 @@ class DownloadChain(ChainBase):
# 电视剧整季匹配
if no_exists:
logger.info(f"开始匹配电视剧整季:{no_exists}")
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
need_seasons: Dict[int, list] = {}
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {source:id: [seasons]}
need_seasons: Dict[str, list] = {}
for need_mid, need_tv in no_exists.items():
for tv in need_tv.values():
if not tv:
@@ -1691,9 +1684,9 @@ class DownloadChain(ChainBase):
def get_no_exists_info(self, meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
no_exists: Dict[str, Dict[int, NotExistMediaInfo]] = None,
totals: Dict[int, int] = None
) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
) -> Tuple[bool, Dict[str, Dict[int, NotExistMediaInfo]]]:
"""
检查媒体库查询电影或音乐是否存在对于剧集同时返回不存在的季集信息
:param meta: 元数据
@@ -1703,11 +1696,14 @@ class DownloadChain(ChainBase):
:return: 当前媒体是否缺失各标题总的季集和缺失的季集
"""
media_source, media_id = resolve_media_identity(media=mediainfo)
if mediainfo.type == MediaType.TV and not build_media_key(media_source, media_id):
logger.error("电视剧缺集检查需要有效的 media_source 和 media_id")
return False, no_exists or {}
def __append_no_exists(_season: int, _episodes: list, _total: int, _start: int):
"""
添加不存在的季集信息
{tmdbid: [
{source:id: [
"season": int,
"episodes": list,
"total_episode": int,
+77
View File
@@ -0,0 +1,77 @@
from typing import Any
from app.chain import ChainBase
from app.core.context import MusicInfo
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
class ListenBrainzChain(ChainBase):
"""ListenBrainz 音乐榜单与新发行来源链。"""
result_source = MediaSource.MusicBrainz
def music_chart(
self,
range_name: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_RECORDING,
) -> list[MusicInfo]:
"""分页读取 ListenBrainz 全站音乐榜单。"""
result = self.run_module(
"music_chart",
range_name=range_name,
offset=max(page - 1, 0) * max(1, count),
count=count,
entity=entity,
)
return self._music_infos(result, limit=count)
async def async_music_chart(
self,
range_name: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_RECORDING,
) -> list[MusicInfo]:
"""异步分页读取 ListenBrainz 全站音乐榜单。"""
result = await self.async_run_module(
"music_chart",
range_name=range_name,
offset=max(page - 1, 0) * max(1, count),
count=count,
entity=entity,
)
return self._music_infos(result, limit=count)
async def async_music_fresh_releases(
self,
days: int = 14,
sort: str = "release_date",
past: bool = True,
future: bool = True,
page: int = 1,
count: int = 30,
) -> list[MusicInfo]:
"""异步分页读取 ListenBrainz 官方新发行专辑。"""
result = await self.async_run_module(
"music_fresh_releases",
days=days,
sort=sort,
past=past,
future=future,
offset=max(page - 1, 0) * max(1, count),
count=count,
)
return self._music_infos(result, limit=count)
@classmethod
def _music_infos(cls, result: Any, limit: int) -> list[MusicInfo]:
"""将榜单模块结果转换为带 MusicBrainz 身份的音乐列表。"""
candidates = result if isinstance(result, list) else []
infos = [
item if isinstance(item, MusicInfo) else MusicInfo.from_dict(item)
for item in candidates
if isinstance(item, (MusicInfo, dict))
]
return [info for info in infos if info.media_source == cls.result_source][:limit]
+29
View File
@@ -0,0 +1,29 @@
from typing import Optional, Union
from app.chain import ChainBase
from app.core.context import MusicInfo, MusicLyrics
from app.core.meta import MetaMusic
class LrclibChain(ChainBase):
"""LRCLIB 音乐歌词来源链。"""
def get_music_lyrics(
self,
music: Union[MetaMusic, MusicInfo],
) -> Optional[MusicLyrics]:
"""按单曲元数据获取标准化歌词。"""
result = self.run_module("music_lyrics", music=music)
if isinstance(result, MusicLyrics):
return result
return MusicLyrics.from_dict(result) if isinstance(result, dict) else None
async def async_get_music_lyrics(
self,
music: Union[MetaMusic, MusicInfo],
) -> Optional[MusicLyrics]:
"""异步按单曲元数据获取标准化歌词。"""
result = await self.async_run_module("music_lyrics", music=music)
if isinstance(result, MusicLyrics):
return result
return MusicLyrics.from_dict(result) if isinstance(result, dict) else None
+670 -2045
View File
File diff suppressed because it is too large Load Diff
-883
View File
@@ -1,883 +0,0 @@
import re
from pathlib import Path
from typing import Any, Iterable, Optional, Union
from fastapi.concurrency import run_in_threadpool
from app.chain import ChainBase
from app.core.cache import async_fresh, fresh
from app.core.config import settings
from app.core.context import (
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
MusicLyrics,
)
from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType
from app.utils.media import (
is_music_media_source,
normalize_media_source,
normalize_music_type,
)
class MusicChain(ChainBase):
"""音乐元数据搜索、探索与站点搜索参数编排链;媒体识别统一入口见 MediaChain。"""
# 专辑目录匹配结果缓存:目录内相对路径变化时失效,标签写回不触发重复远端匹配。
_album_dir_cache: dict[
str,
tuple[tuple[str, ...], dict[str, MusicInfo]],
] = {}
_album_dir_cache_max = 128
# 目录级匹配至少需要两个音频文件,单文件由单曲搜索链路处理
_album_match_min_files = 2
# 自动识别只使用 MusicBrainz;其它来源仅响应显式来源请求。
_primary_recognize_source = "musicbrainz"
@classmethod
def parse_query(cls, query: str) -> MetaMusic:
"""将用户输入的搜索关键词解析为音乐元数据,解析核心在 MetaMusic.apply_title。"""
return MetaMusic(org_string=query, title=query, parse_title=True)
@classmethod
def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]:
"""按单曲或专辑实体生成站点关键词,避免单曲订阅优先搜到所属整专。"""
artists = music.artists or []
artist = artists[0] if artists else music.album_artist
keywords = []
if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM:
album = music.album or music.title
if artist and album:
keywords.append(f"{artist} {album}")
if album:
keywords.append(album)
else:
if artist and music.title:
keywords.append(f"{artist} {music.title}")
if music.title:
keywords.append(music.title)
return cls._unique_texts(keywords)
@classmethod
def matches_site_resource(
cls,
music: MusicInfo,
resource_title: str,
resource_description: Optional[str] = None,
) -> bool:
"""判断站点资源标题与副标题是否包含订阅目标,避免串专辑或串单曲。"""
resource_text = f"{resource_title or ''} {resource_description or ''}"
normalized_resource = cls._normalize_match_text(resource_text)
if not normalized_resource:
return False
if music.music_type == MUSIC_ENTITY_ALBUM:
candidates = cls._unique_texts([
music.album or music.title,
*(music.names or []),
])
else:
# Recording 的 names 兼容字段会包含所属专辑名;单曲匹配只能使用曲名,
# 否则整专资源会被当成单曲下载并在首个任务后误销订阅。
candidates = cls._unique_texts([music.title])
title_matches = any(
normalized_target and normalized_target in normalized_resource
for normalized_target in (
cls._normalize_match_text(candidate) for candidate in candidates
)
)
if not title_matches:
return False
artists = cls._unique_texts([
music.artist,
music.album_artist,
*(music.artists or []),
])
if not artists:
return True
# 同名歌曲和专辑十分常见,已知艺术家时必须同时出现在资源标题中。
return any(
normalized_artist and normalized_artist in normalized_resource
for normalized_artist in (
cls._normalize_match_text(artist) for artist in artists
)
)
@classmethod
def normalize_candidates(
cls,
candidates: Optional[Iterable[MusicInfo | dict[str, Any]]],
limit: Optional[int] = None,
) -> list[MusicInfo]:
"""标准化并去重来自一个或多个音乐元数据模块的候选。"""
results: list[MusicInfo] = []
identities: set[tuple[str, ...]] = set()
for candidate in candidates or []:
info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate)
identity = cls._candidate_identity(info)
if identity in identities:
continue
identities.add(identity)
results.append(info)
if limit and len(results) >= limit:
break
return results
def search(
self,
query: str,
limit: int = 20,
media_source: Optional[str] = None,
) -> list[MusicInfo]:
"""按请求来源调用音乐元数据模块搜索候选,未指定时默认使用 MusicBrainz。"""
meta = self.parse_query(query)
candidates = self.run_module(
"search_music",
meta=meta,
limit=limit,
media_source=media_source or "musicbrainz",
)
return self.normalize_candidates(candidates, limit=limit)
def recognize_best(
self,
meta: MetaMusic,
cache: bool = True,
) -> Optional[MusicInfo]:
"""执行自动音乐识别,仅调用 MusicBrainz 主数据源。"""
with fresh(not cache):
return self.recognize_from_source(
media_source=self._primary_recognize_source,
meta=meta,
cache=cache,
music_type=MUSIC_ENTITY_RECORDING,
)
async def async_recognize_best(
self,
meta: MetaMusic,
cache: bool = True,
) -> Optional[MusicInfo]:
"""异步执行自动音乐识别,仅调用 MusicBrainz 主数据源。"""
async with async_fresh(not cache):
return await self.async_recognize_from_source(
media_source=self._primary_recognize_source,
meta=meta,
cache=cache,
music_type=MUSIC_ENTITY_RECORDING,
)
def recognize_from_source(
self,
media_source: str,
meta: Optional[MetaMusic] = None,
media_id: Optional[str] = None,
cache: bool = True,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""只调用指定音乐数据源识别指定实体,拒绝影视、未知来源和跨实体结果。"""
normalized_source = normalize_media_source(media_source)
if not is_music_media_source(normalized_source):
return None
normalized_music_type = normalize_music_type(
music_type, allow_artist=False
)
if music_type is not None and not normalized_music_type:
return None
result = self._recognize_from_source(
meta=meta,
media_source=normalized_source,
cache=cache,
media_id=media_id,
music_type=normalized_music_type,
)
return self._validate_source_recognize_result(
result=result,
media_source=normalized_source,
media_id=media_id,
music_type=normalized_music_type,
)
async def async_recognize_from_source(
self,
media_source: str,
meta: Optional[MetaMusic] = None,
media_id: Optional[str] = None,
cache: bool = True,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步只调用指定音乐数据源识别指定实体,拒绝影视、未知来源和跨实体结果。"""
normalized_source = normalize_media_source(media_source)
if not is_music_media_source(normalized_source):
return None
normalized_music_type = normalize_music_type(
music_type, allow_artist=False
)
if music_type is not None and not normalized_music_type:
return None
result = await self._async_recognize_from_source(
meta=meta,
media_source=normalized_source,
cache=cache,
media_id=media_id,
music_type=normalized_music_type,
)
return self._validate_source_recognize_result(
result=result,
media_source=normalized_source,
media_id=media_id,
music_type=normalized_music_type,
)
async def async_search(
self,
query: str,
limit: int = 20,
media_source: Optional[str] = None,
) -> list[MusicInfo]:
"""异步按请求来源搜索音乐候选,未指定时默认使用 MusicBrainz。"""
meta = self.parse_query(query)
candidates = await self.async_run_module(
"search_music",
meta=meta,
limit=limit,
media_source=media_source or "musicbrainz",
)
return self.normalize_candidates(candidates, limit=limit)
def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]:
"""读取 ListenBrainz 全站音乐榜单并标准化分页结果。"""
candidates = self.run_module(
"music_chart",
range_name=range_name,
offset=max(page - 1, 0) * count,
count=count,
)
return self.normalize_candidates(candidates, limit=count)
async def async_chart(
self,
range_name: str,
page: int = 1,
count: int = 30,
sort_by: str = "listen_count.desc",
min_listen_count: int = 0,
with_cover: bool = False,
entity: str = MUSIC_ENTITY_RECORDING,
) -> list[MusicInfo]:
"""异步读取 ListenBrainz 热门榜单,并应用音乐探索筛选和排序。"""
candidates = await self.async_run_module(
"music_chart",
range_name=range_name,
offset=max(page - 1, 0) * count,
count=count,
entity=entity,
)
results = self._filter_candidates(
self.normalize_candidates(candidates),
min_listen_count=min_listen_count,
with_cover=with_cover,
)
results.sort(
key=lambda info: info.listen_count or 0,
reverse=sort_by != "listen_count.asc",
)
return results[:count]
async def async_fresh_releases(
self,
days: int = 14,
sort: str = "release_date",
past: bool = True,
future: bool = True,
page: int = 1,
count: int = 30,
with_cover: bool = False,
) -> list[MusicInfo]:
"""异步读取 ListenBrainz 官方新发行专辑,排序由官方接口决定。"""
candidates = await self.async_run_module(
"music_fresh_releases",
days=days,
sort=sort,
past=past,
future=future,
offset=max(page - 1, 0) * count,
count=count,
)
results = self._filter_candidates(
self.normalize_candidates(candidates),
min_listen_count=0,
with_cover=with_cover,
)
return results[:count]
def discover(
self,
media_source: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
mode: str = "chart",
tags: str = "",
sort: str = "U",
) -> list[MusicInfo]:
"""按指定音乐源读取推荐榜单,并统一分页候选结构。"""
candidates = self.run_module(
"music_discover",
media_source=media_source,
page=page,
count=count,
entity=entity,
mode=mode,
tags=tags,
sort=sort,
)
return self.normalize_candidates(candidates, limit=count)
async def async_discover(
self,
media_source: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
mode: str = "chart",
tags: str = "",
sort: str = "U",
) -> list[MusicInfo]:
"""异步按指定音乐源读取推荐榜单,并统一分页候选结构。"""
candidates = await self.async_run_module(
"music_discover",
media_source=media_source,
page=page,
count=count,
entity=entity,
mode=mode,
tags=tags,
sort=sort,
)
return self.normalize_candidates(candidates, limit=count)
async def async_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""异步按来源和专辑 ID 获取标准化专辑详情及曲目。"""
result = await self.async_run_module(
"music_album",
media_source=media_source,
media_id=media_id,
)
if isinstance(result, MusicAlbumInfo):
return result
if isinstance(result, dict):
return MusicAlbumInfo.from_dict(result)
return None
def album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""同步按来源和专辑 ID 获取标准化专辑详情及曲目。"""
result = self.run_module(
"music_album",
media_source=media_source,
media_id=media_id,
)
if isinstance(result, MusicAlbumInfo):
return result
if isinstance(result, dict):
return MusicAlbumInfo.from_dict(result)
return None
async def async_album_related(
self,
media_source: str,
media_id: str,
count: int = 24,
) -> list[MusicInfo]:
"""异步读取指定来源的关联专辑,供专辑详情继续浏览。"""
candidates = await self.async_run_module(
"music_album_related",
media_source=media_source,
media_id=media_id,
count=count,
)
return self.normalize_candidates(candidates, limit=count)
def lyrics(self, music: MetaMusic | MusicInfo) -> Optional[MusicLyrics]:
"""按单曲元数据调用已启用的歌词模块并返回标准歌词。"""
result = self.run_module("music_lyrics", music=music)
if isinstance(result, MusicLyrics):
return result
if isinstance(result, dict):
return MusicLyrics.from_dict(result)
return None
async def async_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]:
"""异步按来源和艺术家 ID 获取标准化艺术家详情。"""
result = await self.async_run_module(
"music_artist",
media_source=media_source,
media_id=media_id,
)
if isinstance(result, MusicArtistInfo):
return result
if isinstance(result, dict):
return MusicArtistInfo.from_dict(result)
return None
async def async_artist_albums(
self,
media_source: str,
media_id: str,
page: int = 1,
count: int = 30,
album_type: Optional[str] = None,
) -> list[MusicInfo]:
"""异步分页读取艺术家名下的专辑、EP 和单曲。"""
candidates = await self.async_run_module(
"music_artist_albums",
media_source=media_source,
media_id=media_id,
page=page,
count=count,
album_type=album_type,
)
return self.normalize_candidates(candidates, limit=count)
async def async_artist_related(
self,
media_source: str,
media_id: str,
count: int = 24,
) -> list[MusicArtistInfo]:
"""异步读取关联艺术家,供详情页继续浏览。"""
candidates = await self.async_run_module(
"music_artist_related",
media_source=media_source,
media_id=media_id,
count=count,
)
results: list[MusicArtistInfo] = []
identities: set[str] = set()
for candidate in candidates or []:
info = (
candidate
if isinstance(candidate, MusicArtistInfo)
else MusicArtistInfo.from_dict(candidate)
)
identity = (info.media_id or info.name or "").casefold()
if not identity or identity in identities:
continue
identities.add(identity)
results.append(info)
return results[:count]
@staticmethod
def _filter_candidates(
candidates: list[MusicInfo],
min_listen_count: int,
with_cover: bool,
) -> list[MusicInfo]:
"""按热度和封面条件过滤音乐探索候选。"""
results = candidates
if min_listen_count > 0:
results = [
info for info in results
if (info.listen_count or 0) >= min_listen_count
]
if with_cover:
results = [info for info in results if info.cover_url]
return list(results)
@staticmethod
def _normalize_match_text(value: Optional[str]) -> str:
"""移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。"""
return MetaMusic.compact_text(value)
@staticmethod
def _validate_source_recognize_result(
result: Optional[MusicInfo],
media_source: str,
media_id: Optional[str],
music_type: Optional[str],
) -> Optional[MusicInfo]:
"""校验指定来源的识别结果,显式 ID 不允许被另一实体或另一身份替代。"""
if not isinstance(result, MusicInfo):
return None
if result.media_source and result.media_source != media_source:
return None
if music_type and result.music_type != music_type:
return None
if media_id and (
not result.media_source
or not result.media_id
or str(result.media_id) != str(media_id)
):
return None
return result
def _recognize_from_source(
self,
meta: Optional[MetaMusic],
media_source: str,
cache: bool,
media_id: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""调用声明了指定音乐来源的系统模块,隔离单个来源的查询失败。"""
module = self._music_recognize_module(media_source)
if not module:
return None
try:
recognize_kwargs = {
"meta": meta,
"mtype": MediaType.MUSIC,
"media_source": media_source,
"media_id": media_id,
"cache": cache,
}
if music_type is not None:
recognize_kwargs["music_type"] = music_type
return module.recognize_media(
**recognize_kwargs,
)
except Exception as err:
logger.warning(f"{media_source} 音乐自动识别失败:{err}")
return None
async def _async_recognize_from_source(
self,
meta: Optional[MetaMusic],
media_source: str,
cache: bool,
media_id: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步调用指定音乐来源模块,单个来源失败不影响其它候选。"""
module = self._music_recognize_module(media_source)
if not module:
return None
try:
recognize_kwargs = {
"meta": meta,
"mtype": MediaType.MUSIC,
"media_source": media_source,
"media_id": media_id,
"cache": cache,
}
if music_type is not None:
recognize_kwargs["music_type"] = music_type
return await module.async_recognize_media(**recognize_kwargs)
except Exception as err:
logger.warning(f"{media_source} 音乐自动识别失败:{err}")
return None
def _music_recognize_module(self, media_source: str) -> Optional[Any]:
"""枚举运行中的系统模块并返回声明了指定音乐来源的实现。"""
for module in self.modulemanager.get_running_modules("recognize_media"):
get_music_source = getattr(module, "get_music_source", None)
if get_music_source and get_music_source() == media_source:
return module
return None
def recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]:
"""按目录级线索批量识别整目录音频,返回 文件路径 到标准音乐信息的映射。
适用于 WAV 无标签或标签不全的整专目录先用目录名和文件标签构造专辑线索
再交给音乐元数据模块用曲目数时长等特征对位到具体发行版本
"""
dir_path = Path(path)
if not dir_path.is_dir():
return {}
files = self._directory_audio_files(dir_path)
if len(files) < self._album_match_min_files:
return {}
cache_key = str(dir_path)
signature = self._album_directory_signature(dir_path, files)
cached = self._album_dir_cache.get(cache_key)
# 新增、删除或重命名音频时重新匹配;标签写回不会改变相对路径签名。
if cached and cached[0] == signature:
return cached[1]
matched = self._match_album_directory(dir_path, files)
if len(self._album_dir_cache) >= self._album_dir_cache_max:
self._album_dir_cache.clear()
self._album_dir_cache[cache_key] = (signature, matched)
return matched
async def async_recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]:
"""异步按目录级线索批量识别整目录音频。"""
dir_path = Path(path)
if not dir_path.is_dir():
return {}
files = await run_in_threadpool(self._directory_audio_files, dir_path)
if len(files) < self._album_match_min_files:
return {}
cache_key = str(dir_path)
signature = self._album_directory_signature(dir_path, files)
cached = self._album_dir_cache.get(cache_key)
if cached and cached[0] == signature:
return cached[1]
matched = await self._async_match_album_directory(dir_path, files)
if len(self._album_dir_cache) >= self._album_dir_cache_max:
self._album_dir_cache.clear()
self._album_dir_cache[cache_key] = (signature, matched)
return matched
@classmethod
def _directory_audio_files(cls, dir_path: Path) -> list[Path]:
"""收集目录及其一级子目录(如 CD1/CD2)内的音频文件。"""
audio_exts = settings.RMT_AUDIOEXT
files: list[Path] = []
def collect(current: Path) -> None:
try:
entries = sorted(current.iterdir())
except OSError:
return
for entry in entries:
if entry.name.startswith("."):
continue
if entry.is_file() and entry.suffix.lower() in audio_exts:
files.append(entry)
collect(dir_path)
try:
subdirs = sorted(entry for entry in dir_path.iterdir()
if entry.is_dir() and not entry.name.startswith("."))
except OSError:
subdirs = []
for subdir in subdirs:
collect(subdir)
return files
@staticmethod
def _album_directory_signature(dir_path: Path, files: list[Path]) -> tuple[str, ...]:
"""按相对文件路径生成专辑目录缓存签名,兼容多碟子目录。"""
return tuple(
str(file.relative_to(dir_path)).casefold()
for file in files
)
@classmethod
def read_path_meta(cls, path: Union[str, Path]) -> MetaMusic:
"""读取本地音频标签,标签缺失时用文件名和目录线索补齐。"""
file_path = Path(path)
if file_path.exists() and file_path.is_file():
return AudioMetadataHelper.read(file_path)
return AudioMetadataHelper.read_filename(file_path)
@classmethod
def read_path_evidence(
cls,
path: Union[str, Path],
) -> tuple[MetaMusic, Optional[MetaMusic], MetaMusic]:
"""分别返回合并元数据、纯标签元数据和纯文件名元数据。"""
file_path = Path(path)
filename_meta = AudioMetadataHelper.read_filename(file_path)
tag_meta = None
if file_path.exists() and file_path.is_file():
tag_meta = AudioMetadataHelper.read_tags(file_path)
if not tag_meta:
return filename_meta, None, filename_meta
merged_meta = MetaMusic.from_dict(tag_meta.to_dict()).apply_path_context(file_path)
return merged_meta, tag_meta, filename_meta
def identify_by_fingerprint(self, path: Union[str, Path]) -> Optional[str]:
"""调用音频指纹模块识别 MusicBrainz Recording ID。"""
result = self.run_module(
"identify_music_by_fingerprint",
path=Path(path),
)
return str(result) if result else None
async def async_identify_by_fingerprint(
self,
path: Union[str, Path],
) -> Optional[str]:
"""异步调用音频指纹模块识别 MusicBrainz Recording ID。"""
result = await self.async_run_module(
"async_identify_music_by_fingerprint",
path=Path(path),
)
return str(result) if result else None
def _match_album_directory(
self,
dir_path: Path,
files: list[Path],
) -> dict[str, MusicInfo]:
"""执行目录级专辑匹配,并把专辑曲目对位到具体音频文件。"""
# 标签读取属于音乐领域;MediaChain 只负责编排识别、刮削等跨领域流程。
metas = [self.read_path_meta(file) for file in files]
album_meta = self._album_meta_from_context(dir_path, metas)
if not (album_meta.album or album_meta.title or album_meta.artists):
logger.debug(f"目录缺少专辑识别线索,跳过专辑匹配:{dir_path}")
return {}
candidates = self.run_module("match_music_album", meta=album_meta, tracks=metas)
candidate_items = candidates if isinstance(candidates, list) else [candidates]
album = next(
(item for item in candidate_items if isinstance(item, MusicAlbumInfo) and item.tracks),
None,
)
if not album:
return {}
logger.info(f"目录 {dir_path.name} 匹配到专辑:{album.title_year}{album.media_source}")
matched: dict[str, MusicInfo] = {}
for file, info in self._align_album_tracks(files, metas, album.tracks).items():
matched[str(file.resolve())] = info
return matched
async def _async_match_album_directory(
self,
dir_path: Path,
files: list[Path],
) -> dict[str, MusicInfo]:
"""异步执行目录级专辑匹配,本地标签读取保持在线程池中。"""
metas = await run_in_threadpool(self._read_album_path_metas, files)
album_meta = self._album_meta_from_context(dir_path, metas)
if not (album_meta.album or album_meta.title or album_meta.artists):
logger.debug(f"目录缺少专辑识别线索,跳过专辑匹配:{dir_path}")
return {}
candidates = await self.async_run_module(
"async_match_music_album",
meta=album_meta,
tracks=metas,
)
candidate_items = candidates if isinstance(candidates, list) else [candidates]
album = next(
(item for item in candidate_items if isinstance(item, MusicAlbumInfo) and item.tracks),
None,
)
if not album:
return {}
logger.info(f"目录 {dir_path.name} 匹配到专辑:{album.title_year}{album.media_source}")
matched: dict[str, MusicInfo] = {}
for file, info in self._align_album_tracks(files, metas, album.tracks).items():
matched[str(file.resolve())] = info
return matched
@classmethod
def _read_album_path_metas(cls, files: list[Path]) -> list[MetaMusic]:
"""批量读取专辑目录中的本地音频元数据。"""
return [cls.read_path_meta(file) for file in files]
@classmethod
def _album_meta_from_context(cls, dir_path: Path, metas: list[MetaMusic]) -> MetaMusic:
"""汇总目录名和文件标签中的专辑线索,作为专辑搜索条件。"""
dir_info = MetaMusic.parse_album_dir(dir_path.name)
# 文件标签中的专辑信息比目录名更可靠,多数文件一致时优先采用
album_votes: dict[str, int] = {}
artist_votes: dict[str, int] = {}
for meta in metas:
if meta.album:
album_votes[meta.album] = album_votes.get(meta.album, 0) + 1
if meta.album_artist:
artist_votes[meta.album_artist] = artist_votes.get(meta.album_artist, 0) + 1
elif meta.artists:
artist_votes[meta.artists[0]] = artist_votes.get(meta.artists[0], 0) + 1
majority_album = max(album_votes, key=album_votes.get) if album_votes else None
majority_artist = max(artist_votes, key=artist_votes.get) if artist_votes else None
# 多数文件共享同一专辑标签才可信,避免杂集目录的个别错误标签带偏搜索
album = majority_album if majority_album and album_votes[majority_album] >= max(2, len(metas) // 2) else None
artist = majority_artist if majority_artist and artist_votes[majority_artist] >= max(2,
len(metas) // 2) else None
return MetaMusic(
org_string=dir_path.name,
title=album or dir_info.get("album") or dir_path.name,
album=album or dir_info.get("album"),
artists=[artist or dir_info.get("artist")] if (artist or dir_info.get("artist")) else [],
album_artist=artist or dir_info.get("artist"),
year=dir_info.get("year"),
)
@classmethod
def _align_album_tracks(
cls,
files: list[Path],
metas: list[MetaMusic],
tracks: list[MusicInfo],
) -> dict[Path, MusicInfo]:
"""把专辑曲目对位到目录内的音频文件。
带曲序标签的文件按碟号, 曲序精确对位其余文件按排序顺序依次补齐
"""
matched: dict[Path, MusicInfo] = {}
used_keys: set[tuple[int, int]] = set()
by_position: dict[tuple[int, int], MusicInfo] = {}
for track in tracks:
if track.track_number:
by_position[(track.disc_number or 1, track.track_number)] = track
pending: list[tuple[Path, MetaMusic]] = []
for file, meta in zip(files, metas):
key = (meta.disc_number or 1, meta.track_number)
track = by_position.get(key) if meta.track_number else None
if track and key not in used_keys:
matched[file] = track
used_keys.add(key)
else:
pending.append((file, meta))
if not pending:
return matched
remaining = [
track for track in tracks
if (track.disc_number or 1, track.track_number or 0) not in used_keys
]
# 无曲序线索的文件按碟号和文件名排序,与剩余曲目顺序对位
pending.sort(key=lambda item: (item[1].disc_number or 1, item[0].name.casefold()))
for (file, _meta), track in zip(pending, remaining):
matched[file] = track
return matched
@classmethod
def to_meta(cls, info: MusicInfo) -> MetaMusic:
"""将用户选中的标准音乐信息转换为下载和整理上下文元数据。"""
return MetaMusic(
title=info.title,
artists=list(info.artists),
album=info.album,
album_artist=info.album_artist,
year=info.year,
disc_number=info.disc_number,
track_number=info.track_number,
total_tracks=info.total_tracks,
version=info.version,
audio_format=info.audio_format,
audio_lossless=info.audio_lossless,
bit_depth=info.bit_depth,
sample_rate=info.sample_rate,
bitrate=info.bitrate,
duration=info.duration,
isrc=info.isrc,
media_source=info.media_source,
media_id=info.media_id,
)
@classmethod
def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]:
"""构造跨来源稳定的候选去重键。"""
if info.media_source and info.media_id:
return "id", info.media_source.casefold(), info.music_type.casefold(), info.media_id.casefold()
return (
"metadata",
info.music_type.casefold(),
cls._normalize_text(info.title).casefold(),
cls._normalize_text(info.artist).casefold(),
cls._normalize_text(info.album).casefold(),
)
@classmethod
def _unique_texts(cls, values: Iterable[Optional[str]]) -> list[str]:
"""按规范化文本去重并保留原始顺序。"""
results = []
seen = set()
for value in values:
normalized = cls._normalize_text(value)
identity = normalized.casefold()
if not normalized or identity in seen:
continue
seen.add(identity)
results.append(normalized)
return results
@classmethod
def _normalize_text(cls, value: Optional[str]) -> str:
"""清理音乐检索文本中的多余空白。"""
return re.sub(r"\s+", " ", str(value or "")).strip()
+290
View File
@@ -0,0 +1,290 @@
from typing import Any, Optional
from app.chain import ChainBase
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.core.meta import MetaMusic
from app.schemas.types import MediaSource, MediaType
class _MusicMetadataSourceChain(ChainBase):
"""固定音乐元数据来源链的共用端口适配。"""
source: MediaSource
def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]:
"""按音乐元数据搜索当前来源候选。"""
result = self.run_module(
"search_music",
meta=meta,
limit=limit,
media_source=self.source,
)
return self._music_infos(result, limit=limit)
async def async_search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]:
"""异步按音乐元数据搜索当前来源候选。"""
result = await self.async_run_module(
"search_music",
meta=meta,
limit=limit,
media_source=self.source,
)
return self._music_infos(result, limit=limit)
def recognize_music(
self,
meta: Optional[MetaMusic] = None,
media_id: Optional[str] = None,
cache: bool = True,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""按当前来源身份或音乐元数据识别标准音乐信息。"""
normalized_id = self._normalize_media_id(media_id)
result = self.run_module(
"recognize_media",
meta=meta,
mtype=MediaType.MUSIC,
media_source=self.source,
media_id=normalized_id,
cache=cache,
music_type=music_type,
)
return self._music_info(result, media_id=normalized_id)
async def async_recognize_music(
self,
meta: Optional[MetaMusic] = None,
media_id: Optional[str] = None,
cache: bool = True,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步按当前来源身份或音乐元数据识别标准音乐信息。"""
normalized_id = self._normalize_media_id(media_id)
result = await self.async_run_module(
"async_recognize_media",
meta=meta,
mtype=MediaType.MUSIC,
media_source=self.source,
media_id=normalized_id,
cache=cache,
music_type=music_type,
)
return self._music_info(result, media_id=normalized_id)
def get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]:
"""按当前来源原生 ID 获取专辑详情。"""
normalized_id = self._normalize_media_id(media_id)
if not normalized_id:
return None
result = self.run_module(
"music_album",
media_source=self.source,
media_id=normalized_id,
)
return self._music_album(result, media_id=normalized_id)
async def async_get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]:
"""异步按当前来源原生 ID 获取专辑详情。"""
normalized_id = self._normalize_media_id(media_id)
if not normalized_id:
return None
result = await self.async_run_module(
"music_album",
media_source=self.source,
media_id=normalized_id,
)
return self._music_album(result, media_id=normalized_id)
async def async_get_music_album_related(
self,
media_id: str,
count: int = 24,
) -> list[MusicInfo]:
"""异步获取当前来源的专辑关联条目。"""
normalized_id = self._normalize_media_id(media_id)
if not normalized_id:
return []
result = await self.async_run_module(
"music_album_related",
media_source=self.source,
media_id=normalized_id,
count=count,
)
return self._music_infos(result, limit=count)
async def async_get_music_artist(self, media_id: str) -> Optional[MusicArtistInfo]:
"""异步按当前来源原生 ID 获取艺术家详情。"""
normalized_id = self._normalize_media_id(media_id)
if not normalized_id:
return None
result = await self.async_run_module(
"music_artist",
media_source=self.source,
media_id=normalized_id,
)
return self._music_artist(result, media_id=normalized_id)
async def async_get_music_artist_albums(
self,
media_id: str,
page: int = 1,
count: int = 30,
album_type: Optional[str] = None,
) -> list[MusicInfo]:
"""异步分页获取当前来源艺术家的专辑目录。"""
normalized_id = self._normalize_media_id(media_id)
if not normalized_id:
return []
result = await self.async_run_module(
"music_artist_albums",
media_source=self.source,
media_id=normalized_id,
page=page,
count=count,
album_type=album_type,
)
return self._music_infos(result, limit=count)
async def async_get_music_artist_related(
self,
media_id: str,
count: int = 24,
) -> list[MusicArtistInfo]:
"""异步获取当前来源艺术家的关联艺术家。"""
normalized_id = self._normalize_media_id(media_id)
if not normalized_id:
return []
result = await self.async_run_module(
"music_artist_related",
media_source=self.source,
media_id=normalized_id,
count=count,
)
return self._music_artists(result, limit=count)
@staticmethod
def _normalize_media_id(media_id: Optional[str]) -> Optional[str]:
"""清理来源原生 ID,空值和历史零哨兵按无身份处理。"""
normalized = str(media_id).strip() if media_id is not None else ""
return normalized if normalized and normalized != "0" else None
@classmethod
def _music_infos(cls, result: Any, limit: Optional[int] = None) -> list[MusicInfo]:
"""将模块或插件结果统一转换为音乐候选列表。"""
candidates = result if isinstance(result, list) else []
infos = [
item if isinstance(item, MusicInfo) else MusicInfo.from_dict(item)
for item in candidates
if isinstance(item, (MusicInfo, dict))
]
infos = [info for info in infos if info.media_source == cls.source]
return infos[:limit] if limit else infos
@classmethod
def _music_info(
cls,
result: Any,
media_id: Optional[str] = None,
) -> Optional[MusicInfo]:
"""校验单条识别结果的来源与显式身份。"""
if isinstance(result, MusicInfo):
info = result
elif isinstance(result, dict):
info = MusicInfo.from_dict(result)
else:
return None
if info.media_source and info.media_source != cls.source:
return None
if media_id and (info.media_source != cls.source or info.media_id != media_id):
return None
return info
@classmethod
def _music_album(
cls,
result: Any,
media_id: Optional[str] = None,
) -> Optional[MusicAlbumInfo]:
"""将模块或插件结果统一转换为专辑详情。"""
if isinstance(result, MusicAlbumInfo):
album = result
elif isinstance(result, dict):
album = MusicAlbumInfo.from_dict(result)
else:
return None
if album.media_source != cls.source:
return None
if media_id and album.media_id != media_id:
return None
return album
@classmethod
def _music_artist(
cls,
result: Any,
media_id: Optional[str] = None,
) -> Optional[MusicArtistInfo]:
"""将模块或插件结果统一转换为艺术家详情。"""
if isinstance(result, MusicArtistInfo):
artist = result
elif isinstance(result, dict):
artist = MusicArtistInfo.from_dict(result)
else:
return None
if artist.media_source != cls.source:
return None
if media_id and artist.media_id != media_id:
return None
return artist
@classmethod
def _music_artists(
cls,
result: Any,
limit: Optional[int] = None,
) -> list[MusicArtistInfo]:
"""将模块或插件结果统一转换为艺术家列表。"""
candidates = result if isinstance(result, list) else []
artists = [
item if isinstance(item, MusicArtistInfo) else MusicArtistInfo.from_dict(item)
for item in candidates
if isinstance(item, (MusicArtistInfo, dict))
]
artists = [artist for artist in artists if artist.media_source == cls.source]
return artists[:limit] if limit else artists
class MusicBrainzChain(_MusicMetadataSourceChain):
"""MusicBrainz 音乐搜索、识别与详情来源链。"""
source = MediaSource.MusicBrainz
def match_music_album(
self,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int = 5,
) -> Optional[MusicAlbumInfo]:
"""按目录元数据与曲目证据匹配 MusicBrainz 发行版本。"""
result = self.run_module(
"match_music_album",
meta=meta,
tracks=tracks,
limit=limit,
)
return self._music_album(result)
async def async_match_music_album(
self,
meta: MetaMusic,
tracks: list[MetaMusic],
limit: int = 5,
) -> Optional[MusicAlbumInfo]:
"""异步按目录元数据与曲目证据匹配 MusicBrainz 发行版本。"""
result = await self.async_run_module(
"async_match_music_album",
meta=meta,
tracks=tracks,
limit=limit,
)
return self._music_album(result)
+154 -8
View File
@@ -5,15 +5,21 @@ import pillow_avif # noqa 用于自动注册AVIF支持
from app.chain import ChainBase
from app.chain.bangumi import BangumiChain
from app.chain.douban import DoubanChain
from app.chain.music import MusicChain
from app.chain.listenbrainz import ListenBrainzChain
from app.chain.tmdb import TmdbChain
from app.core.cache import cached, fresh
from app.core.config import settings, global_vars
from app.core.context import MusicInfo
from app.helper.image import ImageHelper
from app.log import logger
from app.schemas import MediaType
from app.schemas.types import MUSIC_ENTITY_ALBUM
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaSource,
)
from app.utils.common import log_execution_time
from app.utils.media import normalize_media_source
from app.utils.singleton import Singleton
@@ -29,6 +35,146 @@ class RecommendChain(ChainBase, metaclass=Singleton):
# 推荐缓存区域
recommend_cache_region = "recommend"
def music_chart(
self,
range_name: str,
page: int = 1,
count: int = 30,
sort_by: str = "listen_count.desc",
min_listen_count: int = 0,
with_cover: bool = False,
entity: str = MUSIC_ENTITY_RECORDING,
) -> list[MusicInfo]:
"""读取 ListenBrainz 音乐榜单并应用推荐筛选与排序。"""
results = ListenBrainzChain().music_chart(
range_name=range_name,
page=page,
count=count,
entity=entity,
)
return self._filter_music_candidates(
results,
count=count,
sort_by=sort_by,
min_listen_count=min_listen_count,
with_cover=with_cover,
)
async def async_music_chart(
self,
range_name: str,
page: int = 1,
count: int = 30,
sort_by: str = "listen_count.desc",
min_listen_count: int = 0,
with_cover: bool = False,
entity: str = MUSIC_ENTITY_RECORDING,
) -> list[MusicInfo]:
"""异步读取 ListenBrainz 音乐榜单并应用推荐筛选与排序。"""
results = await ListenBrainzChain().async_music_chart(
range_name=range_name,
page=page,
count=count,
entity=entity,
)
return self._filter_music_candidates(
results,
count=count,
sort_by=sort_by,
min_listen_count=min_listen_count,
with_cover=with_cover,
)
async def async_music_fresh_releases(
self,
days: int = 14,
sort: str = "release_date",
past: bool = True,
future: bool = True,
page: int = 1,
count: int = 30,
with_cover: bool = False,
) -> list[MusicInfo]:
"""异步读取 ListenBrainz 新发行专辑并应用封面筛选。"""
results = await ListenBrainzChain().async_music_fresh_releases(
days=days,
sort=sort,
past=past,
future=future,
page=page,
count=count,
)
return self._filter_music_candidates(
results,
count=count,
with_cover=with_cover,
)
def music_discover(
self,
media_source: MediaSource,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
mode: str = "chart",
tags: str = "",
sort: str = "U",
) -> list[MusicInfo]:
"""按固定音乐来源读取发现内容,当前支持豆瓣音乐。"""
if normalize_media_source(media_source) != MediaSource.DoubanMusic:
return []
return DoubanChain().music_discover(
page=page,
count=count,
entity=entity,
mode=mode,
tags=tags,
sort=sort,
)
async def async_music_discover(
self,
media_source: MediaSource,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
mode: str = "chart",
tags: str = "",
sort: str = "U",
) -> list[MusicInfo]:
"""异步按固定音乐来源读取发现内容,当前支持豆瓣音乐。"""
if normalize_media_source(media_source) != MediaSource.DoubanMusic:
return []
return await DoubanChain().async_music_discover(
page=page,
count=count,
entity=entity,
mode=mode,
tags=tags,
sort=sort,
)
@staticmethod
def _filter_music_candidates(
candidates: list[MusicInfo],
count: int,
sort_by: Optional[str] = None,
min_listen_count: int = 0,
with_cover: bool = False,
) -> list[MusicInfo]:
"""按热度和封面条件筛选音乐推荐,并限制返回数量。"""
results = [
info for info in candidates
if (info.listen_count or 0) >= max(0, min_listen_count)
and (not with_cover or bool(info.cover_url))
]
if sort_by:
results.sort(
key=lambda info: info.listen_count or 0,
reverse=sort_by != "listen_count.asc",
)
return results[:max(1, count)]
def refresh_recommend(
self,
manual: bool = False,
@@ -182,7 +328,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
def music_weekly(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""返回 ListenBrainz 本周全站热门音乐。"""
medias = MusicChain().chart(
medias = self.music_chart(
range_name="this_week",
page=page or 1,
count=count or 30,
@@ -197,8 +343,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
count: Optional[int] = 30,
) -> List[dict]:
"""返回豆瓣音乐官方新碟榜。"""
medias = MusicChain().discover(
media_source="doubanmusic",
medias = self.music_discover(
media_source=MediaSource.DoubanMusic,
page=page or 1,
count=count or 30,
entity=MUSIC_ENTITY_ALBUM,
@@ -427,7 +573,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
async def async_music_weekly(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""异步返回 ListenBrainz 本周全站热门音乐。"""
medias = await MusicChain().async_chart(
medias = await self.async_music_chart(
range_name="this_week",
page=page or 1,
count=count or 30,
@@ -442,8 +588,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
count: Optional[int] = 30,
) -> List[dict]:
"""异步返回豆瓣音乐官方新碟榜。"""
medias = await MusicChain().async_discover(
media_source="doubanmusic",
medias = await self.async_music_discover(
media_source=MediaSource.DoubanMusic,
page=page or 1,
count=count or 30,
entity=MUSIC_ENTITY_ALBUM,
File diff suppressed because it is too large Load Diff
+119 -32
View File
@@ -6,14 +6,13 @@ import re
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait
from datetime import datetime
from typing import AsyncIterator, Any, Dict, Tuple
from typing import AsyncIterator, Any, Dict, Iterable, Tuple
from typing import List, Optional
from fastapi.concurrency import run_in_threadpool
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.config import global_vars, settings
from app.core.context import Context
from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo
@@ -28,13 +27,18 @@ from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import NotExistMediaInfo
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
EventType,
MediaSource,
MediaType,
ProgressKey,
SystemConfigKey,
)
from app.utils.media import build_media_key, resolve_media_identity
from app.utils.media import (
build_media_key,
parse_media_key,
resolve_media_identity,
)
from app.utils.string import StringUtils
@@ -54,6 +58,73 @@ class SearchChain(ChainBase):
_ai_recommend_result: Optional[List[int]] = None
_ai_recommend_error: Optional[str] = None
@classmethod
def music_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]:
"""按单曲或专辑实体生成站点关键词,避免单曲优先命中所属整专。"""
artists = music.artists or []
artist = artists[0] if artists else music.album_artist
values: list[Optional[str]] = []
if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM:
album = music.album or music.title
values.extend([f"{artist} {album}" if artist and album else None, album])
else:
values.extend([
f"{artist} {music.title}" if artist and music.title else None,
music.title,
])
return cls._unique_music_texts(values)
@classmethod
def matches_music_resource(
cls,
music: MusicInfo,
resource_title: str,
resource_description: Optional[str] = None,
) -> bool:
"""校验站点资源标题同时包含目标音乐名称和已知艺术家。"""
normalized_resource = MetaMusic.compact_text(
f"{resource_title or ''} {resource_description or ''}"
)
if not normalized_resource:
return False
if music.music_type == MUSIC_ENTITY_ALBUM:
candidates = cls._unique_music_texts([
music.album or music.title,
*(music.names or []),
])
else:
candidates = cls._unique_music_texts([music.title])
if not any(
MetaMusic.compact_text(candidate) in normalized_resource
for candidate in candidates
if MetaMusic.compact_text(candidate)
):
return False
artists = cls._unique_music_texts([
music.artist,
music.album_artist,
*(music.artists or []),
])
return not artists or any(
MetaMusic.compact_text(artist) in normalized_resource
for artist in artists
if MetaMusic.compact_text(artist)
)
@staticmethod
def _unique_music_texts(values: Iterable[Optional[str]]) -> list[str]:
"""按清理后的文本去重,并保留站点搜索词原始顺序。"""
results: list[str] = []
seen: set[str] = set()
for value in values:
normalized = re.sub(r"\s+", " ", str(value or "")).strip()
identity = normalized.casefold()
if not normalized or identity in seen:
continue
seen.add(identity)
results.append(normalized)
return results
@staticmethod
def _get_search_resource_pages() -> int:
"""
@@ -207,13 +278,26 @@ class SearchChain(ChainBase):
@staticmethod
def _normalize_search_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
"""
规范化上次搜索参数供前端结果页重新搜索使用
规范化上次搜索参数供前端结果页重新搜索使用旧复合关键字仅在
缓存读取边界转换为独立的媒体来源和原生 ID
"""
if not isinstance(params, dict):
return None
media_source, media_id = resolve_media_identity(
media_source=params.get("media_source"),
media_id=params.get("media_id"),
)
keyword = str(params.get("keyword") or "")
if not media_source and keyword:
media_source, media_id = parse_media_key(keyword)
if media_source and media_id:
keyword = ""
normalized = {
"keyword": str(params.get("keyword") or ""),
"keyword": keyword,
"media_source": str(media_source) if media_source else "",
"media_id": media_id or "",
"type": str(params.get("type") or ""),
"area": str(params.get("area") or ""),
"title": str(params.get("title") or ""),
@@ -225,12 +309,14 @@ class SearchChain(ChainBase):
}
if params.get("music_type"):
normalized["music_type"] = str(params["music_type"])
return normalized if normalized["keyword"] else None
return normalized if normalized["keyword"] or media_id else None
def save_last_search_params(
self,
*,
keyword: Optional[str],
keyword: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
mtype: Optional[MediaType] = None,
area: Optional[str] = "title",
title: Optional[str] = None,
@@ -242,11 +328,13 @@ class SearchChain(ChainBase):
result_type: Optional[str] = "torrent",
) -> None:
"""
保存最后一次资源搜索参数
保存最后一次资源搜索参数标题搜索与精确身份搜索使用互斥字段
"""
params = self._normalize_search_params(
{
"keyword": keyword,
"media_source": media_source,
"media_id": media_id,
"type": mtype.value if isinstance(mtype, MediaType) else mtype,
"area": area,
"title": title,
@@ -264,7 +352,9 @@ class SearchChain(ChainBase):
async def async_save_last_search_params(
self,
*,
keyword: Optional[str],
keyword: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
mtype: Optional[MediaType] = None,
area: Optional[str] = "title",
title: Optional[str] = None,
@@ -276,11 +366,13 @@ class SearchChain(ChainBase):
result_type: Optional[str] = "torrent",
) -> None:
"""
异步保存最后一次资源搜索参数
异步保存最后一次资源搜索参数标题搜索与精确身份搜索使用互斥字段
"""
params = self._normalize_search_params(
{
"keyword": keyword,
"media_source": media_source,
"media_id": media_id,
"type": mtype.value if isinstance(mtype, MediaType) else mtype,
"area": area,
"title": title,
@@ -530,16 +622,15 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
self.save_last_search_params(
keyword=self._build_search_keyword(
media_source, media_id
),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
season=season,
sites=sites,
music_type=music_type,
)
# 音乐统一在 recognize_media 内路由到 MusicChain
# 音乐统一在 MediaChain.recognize_media 内按固定来源路由
mediainfo = MediaChain().recognize_media(
media_source=media_source, media_id=media_id, mtype=mtype,
music_type=music_type,
@@ -724,9 +815,8 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(
media_source, media_id
),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area="title",
season=season,
@@ -771,9 +861,8 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(
media_source, media_id
),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area="title",
season=season,
@@ -837,16 +926,15 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(
media_source, media_id
),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
season=season,
sites=sites,
music_type=music_type,
)
# 音乐统一在 async_recognize_media 内路由到 MusicChain
# 音乐统一在 MediaChain.async_recognize_media 内按固定来源路由
mediainfo = await MediaChain().async_recognize_media(
media_source=media_source, media_id=media_id, mtype=mtype,
music_type=music_type,
@@ -1041,16 +1129,15 @@ class SearchChain(ChainBase):
if cache_local:
self.cancel_ai_recommend()
await self.async_save_last_search_params(
keyword=self._build_search_keyword(
media_source, media_id
),
media_source=media_source,
media_id=media_id,
mtype=mtype,
area=area,
season=season,
sites=sites,
music_type=music_type,
)
# 音乐统一在 async_recognize_media 内路由到 MusicChain
# 音乐统一在 MediaChain.async_recognize_media 内按固定来源路由
mediainfo = await MediaChain().async_recognize_media(
media_source=media_source, media_id=media_id, mtype=mtype,
music_type=music_type,
@@ -1361,7 +1448,7 @@ class SearchChain(ChainBase):
contexts = []
for torrent in torrents:
meta = MusicChain.to_meta(mediainfo)
meta = MetaMusic.from_music_info(mediainfo)
meta.org_string = torrent.title
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
contexts.append(
@@ -1387,7 +1474,7 @@ class SearchChain(ChainBase):
torrent
for torrent in torrents or []
if torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value)
and MusicChain.matches_site_resource(
and SearchChain.matches_music_resource(
mediainfo,
torrent.title,
torrent.description,
@@ -1403,7 +1490,7 @@ class SearchChain(ChainBase):
filter_params: Optional[Dict[str, str]] = None,
) -> List[Context]:
"""按音乐元数据生成站点关键词并执行同步资源搜索。"""
keywords = [keyword] if keyword else MusicChain.build_site_keywords(mediainfo)
keywords = [keyword] if keyword else SearchChain.music_site_keywords(mediainfo)
torrents: List[TorrentInfo] = []
for index, search_word in enumerate(keywords or [mediainfo.title]):
if index:
@@ -1436,7 +1523,7 @@ class SearchChain(ChainBase):
filter_params: Optional[Dict[str, str]] = None,
) -> List[Context]:
"""按音乐元数据生成站点关键词并执行异步资源搜索。"""
keywords = [keyword] if keyword else MusicChain.build_site_keywords(mediainfo)
keywords = [keyword] if keyword else SearchChain.music_site_keywords(mediainfo)
torrents: List[TorrentInfo] = []
for index, search_word in enumerate(keywords or [mediainfo.title]):
if index:
+8 -10
View File
@@ -12,7 +12,6 @@ from app.chain import ChainBase
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain
from app.chain.music import MusicChain
from app.chain.search import SearchChain
from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
@@ -24,8 +23,7 @@ from app.core.context import (
TorrentInfo,
)
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.meta import MetaMusic
from app.core.meta import MetaBase, MetaMusic
from app.core.meta.words import WordsMatcher
from app.core.metainfo import MetaInfo
from app.db.downloadhistory_oper import DownloadHistoryOper
@@ -877,7 +875,7 @@ class SubscribeChain(ChainBase):
mediainfo = None
requested_music_type = kwargs.get("music_type")
metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
metainfo = MetaMusic.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
if year:
metainfo.year = year
if mtype:
@@ -1081,7 +1079,7 @@ class SubscribeChain(ChainBase):
mediainfo = None
requested_music_type = kwargs.get("music_type")
metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
metainfo = MetaMusic.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
if year:
metainfo.year = year
if mtype:
@@ -1462,7 +1460,7 @@ class SubscribeChain(ChainBase):
logger.warning(f"音乐订阅 {subscribe.name} 无法继续:{validation_error}")
return None
self._sync_music_subscribe_target(subscribe, mediainfo)
meta = MusicChain.to_meta(mediainfo)
meta = MetaMusic.from_music_info(mediainfo)
exists, _ = self.check_and_handle_existing_media(
subscribe=subscribe,
meta=meta,
@@ -1494,7 +1492,7 @@ class SubscribeChain(ChainBase):
torrent = copy.copy(source_torrent)
if sites and torrent.site not in sites:
continue
if not MusicChain.matches_site_resource(
if not SearchChain.matches_music_resource(
mediainfo,
torrent.title,
torrent.description,
@@ -1514,7 +1512,7 @@ class SubscribeChain(ChainBase):
context = copy.copy(source_context)
context.torrent_info = torrent
meta = MusicChain.to_meta(mediainfo)
meta = MetaMusic.from_music_info(mediainfo)
meta.org_string = torrent.title
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
if subscribe.best_version:
@@ -1582,7 +1580,7 @@ class SubscribeChain(ChainBase):
if current_subscribe:
self.finish_subscribe_or_not(
subscribe=current_subscribe,
meta=MusicChain.to_meta(mediainfo),
meta=MetaMusic.from_music_info(mediainfo),
mediainfo=mediainfo,
downloads=downloads,
)
@@ -1598,7 +1596,7 @@ class SubscribeChain(ChainBase):
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or []
keywords = [subscribe.keyword] if subscribe.keyword else MusicChain.build_site_keywords(mediainfo)
keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo)
if not keywords:
keywords = [subscribe.name]
+17
View File
@@ -0,0 +1,17 @@
from app.chain.musicbrainz import _MusicMetadataSourceChain
from app.schemas.types import MediaSource
class TheAudioDbChain(_MusicMetadataSourceChain):
"""TheAudioDB 音乐搜索、识别与详情来源链。"""
source = MediaSource.TheAudioDB
async def async_get_music_artist_related(
self,
media_id: str,
count: int = 24,
) -> list:
"""返回 TheAudioDB 关联艺术家;当前来源模块未提供该能力。"""
del media_id, count
return []
+2 -2
View File
@@ -7,10 +7,10 @@ from app.helper.sites import SitesHelper # noqa
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.config import settings, global_vars
from app.core.context import TorrentInfo, Context, MediaInfo
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.core.metainfo import MetaInfo
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
@@ -670,7 +670,7 @@ class TorrentsChain(ChainBase):
continue
logger.info(f'处理资源:{torrent.title} ...')
if torrent.category == MediaType.MUSIC.value:
meta = MusicChain.parse_query(torrent.title)
meta = MetaMusic.parse_query(torrent.title)
mediainfo = MusicInfo(
title=meta.title,
artists=list(meta.artists),
+34 -7
View File
@@ -1103,15 +1103,14 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
) -> tuple[MetaMusic, Optional[MusicInfo]]:
"""为缺少远端身份的本地音频尝试目录级专辑匹配,命中后回填文件元数据。
WAV 等无标签文件只能依靠目录结构和曲目特征识别匹配结果 MusicChain
按目录缓存同一专辑目录内的后续文件不会重复请求远端
WAV 等无标签文件只能依靠目录结构和曲目特征识别匹配结果 MediaChain
按目录缓存同一专辑目录内的后续文件不会重复请求远端
"""
# 目录级匹配需要读取本地音频时长,远端存储文件无法参与
if file_meta.media_id or getattr(file_item, "storage", "local") != "local":
return file_meta, None
try:
from app.chain.music import MusicChain
matched = MusicChain().recognize_album_directory(file_path.parent)
matched = MediaChain().recognize_music_album_directory(file_path.parent)
except Exception as err:
logger.debug(f"音乐专辑目录匹配失败:{file_path} - {err}")
return file_meta, None
@@ -2122,7 +2121,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
)
recognize_kwargs = {"obtain_images": True}
if task.media_source:
recognize_kwargs["source"] = task.media_source
recognize_kwargs["media_source"] = task.media_source
if task.mtype:
recognize_kwargs["mtype"] = task.mtype
mediainfo = MediaChain().recognize_by_meta(
@@ -2134,7 +2133,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 识别媒体信息
recognize_kwargs = {"obtain_images": True}
if task.media_source:
recognize_kwargs["source"] = task.media_source
recognize_kwargs["media_source"] = task.media_source
if task.mtype:
recognize_kwargs["mtype"] = task.mtype
mediainfo = MediaChain().recognize_by_meta(
@@ -3237,7 +3236,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
meta: MetaBase = None,
mediainfo: MediaInfo = None,
mtype: Optional[MediaType] = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
target_directory: TransferDirectoryConf = None,
target_storage: Optional[str] = None,
@@ -3290,6 +3289,34 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param continue_callback: 继续处理回调
返回成功标识错误信息
"""
explicit_identity = media_source is not None or media_id is not None
normalized_source, normalized_media_id = resolve_media_identity(
media_source=media_source,
media_id=media_id,
)
if explicit_identity and (
not normalized_source or not normalized_media_id
):
return False, "整理任务需要同时提供有效的 media_source 和 media_id"
if not explicit_identity and mediainfo:
normalized_source, normalized_media_id = resolve_media_identity(
media=mediainfo
)
media_source = normalized_source
media_id = normalized_media_id
if explicit_identity and not mediainfo:
mediainfo = MediaChain().recognize_media(
mtype=mtype,
media_source=media_source,
media_id=media_id,
music_type=getattr(meta, "music_type", None),
)
if not mediainfo:
return False, (
"未识别到媒体信息,"
f"media_source{media_source}media_id{media_id}"
)
# 是否全部成功
all_success = True
transfer_batch_id = str(uuid.uuid4())
+15 -13
View File
@@ -20,7 +20,7 @@ from app.schemas.types import (
MediaSource,
MediaType,
)
from app.utils.media import normalize_media_source
from app.utils.media import normalize_media_source, resolve_media_identity
from app.utils.string import StringUtils
BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"})
@@ -168,8 +168,8 @@ class MusicInfo:
raw_data: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""将构造参数中的数据源规范化为统一枚举"""
self.media_source = normalize_media_source(self.media_source)
"""将构造参数中的媒体身份规范化为统一成对字段"""
self.media_source, self.media_id = resolve_media_identity(media=self)
@property
def artist(self) -> str:
@@ -394,8 +394,8 @@ class MusicAlbumInfo:
raw_data: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""将构造参数中的数据源规范化为统一枚举"""
self.media_source = normalize_media_source(self.media_source)
"""将构造参数中的媒体身份规范化为统一成对字段"""
self.media_source, self.media_id = resolve_media_identity(media=self)
@property
def artist(self) -> str:
@@ -558,8 +558,8 @@ class MusicArtistInfo:
raw_data: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""将构造参数中的数据源规范化为统一枚举"""
self.media_source = normalize_media_source(self.media_source)
"""将构造参数中的媒体身份规范化为统一成对字段"""
self.media_source, self.media_id = resolve_media_identity(media=self)
@property
def title(self) -> str | None:
@@ -703,6 +703,10 @@ class TorrentInfo:
# 种子分类 电影/电视剧/音乐
category: str = None
def __post_init__(self) -> None:
"""将种子声明的媒体身份规范化为统一成对字段。"""
self.media_source, self.media_id = resolve_media_identity(media=self)
def __setattr__(self, name: str, value: Any):
self.__dict__[name] = value
@@ -726,9 +730,7 @@ class TorrentInfo:
if key in properties:
continue
setattr(self, key, value)
self.media_source = normalize_media_source(self.media_source)
if self.media_id is not None:
self.media_id = str(self.media_id)
self.media_source, self.media_id = resolve_media_identity(media=self)
@staticmethod
def get_free_string(upload_volume_factor: float, download_volume_factor: float) -> str:
@@ -1022,7 +1024,7 @@ class MediaInfo:
def __post_init__(self):
"""规范化媒体来源,并从各来源原始数据初始化统一字段。"""
self.media_source = normalize_media_source(self.media_source)
self.media_source, self.media_id = resolve_media_identity(media=self)
# 设置媒体信息
if self.tmdb_info:
self.set_tmdb_info(self.tmdb_info)
@@ -1032,7 +1034,7 @@ class MediaInfo:
self.set_bangumi_info(self.bangumi_info)
if self.anilist_info:
self.set_anilist_info(self.anilist_info)
self.media_source = normalize_media_source(self.media_source)
self.media_source, self.media_id = resolve_media_identity(media=self)
def __setattr__(self, name: str, value: Any):
self.__dict__[name] = value
@@ -1057,7 +1059,7 @@ class MediaInfo:
if key in properties:
continue
setattr(self, key, value)
self.media_source = normalize_media_source(self.media_source)
self.media_source, self.media_id = resolve_media_identity(media=self)
if isinstance(self.type, str):
self.type = MediaType(self.type)
+6 -6
View File
@@ -7,6 +7,7 @@ import regex as re
from app.log import logger
from app.schemas.types import MediaSource, MediaType
from app.utils.media import resolve_media_identity
from app.utils.string import StringUtils
@@ -678,12 +679,11 @@ class MetaBase(object):
if not self.part:
self.part = meta.part
# 媒体身份必须原子合并,不能将不同目录层级的来源和ID拼成一对
if not (self.media_source and self.media_id) and meta.media_source and meta.media_id:
try:
self.media_source = MediaSource(meta.media_source)
self.media_id = str(meta.media_id)
except ValueError:
pass
current_source, current_id = resolve_media_identity(media=self)
if current_source and current_id:
self.media_source, self.media_id = current_source, current_id
else:
self.media_source, self.media_id = resolve_media_identity(media=meta)
# 剧集组
if not self.episode_group and meta.episode_group:
self.episode_group = meta.episode_group
+130 -4
View File
@@ -5,7 +5,9 @@ from threading import RLock
from typing import Any, Callable, Optional
from app.core.meta.metabase import MetaBase
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
from app.utils import rust_accel
from app.utils.media import resolve_media_identity
_AUDIO_FORMAT_PATTERN = re.compile(
@@ -539,6 +541,8 @@ class MusicNameRegistry:
_patterns: dict[str, MusicNamePattern] = {}
_parsers: dict[str, MusicNameParser] = {}
_default_patterns: dict[str, MusicNamePattern] = {}
_default_parsers: dict[str, MusicNameParser] = {}
_lock = RLock()
@classmethod
@@ -613,6 +617,32 @@ class MusicNameRegistry:
return None
return parser.handler(context, matched)
@classmethod
def _capture_default_components(cls) -> None:
"""保存内置命名组件的对象快照,供 Rust 快路判断兼容性。"""
with cls._lock:
cls._default_patterns = dict(cls._patterns)
cls._default_parsers = dict(cls._parsers)
@classmethod
def _uses_default_components(cls) -> bool:
"""判断当前注册表是否仍为未替换的内置命名组件。"""
with cls._lock:
if not cls._default_patterns or not cls._default_parsers:
return False
if (
cls._patterns.keys() != cls._default_patterns.keys()
or cls._parsers.keys() != cls._default_parsers.keys()
):
return False
return all(
component is cls._default_patterns[name]
for name, component in cls._patterns.items()
) and all(
component is cls._default_parsers[name]
for name, component in cls._parsers.items()
)
class MetaMusic(MetaBase):
"""音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。"""
@@ -637,7 +667,7 @@ class MetaMusic(MetaBase):
bitrate: Optional[int] = None,
duration: Optional[int] = None,
isrc: Optional[str] = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
parse_title: bool = False,
):
@@ -662,12 +692,75 @@ class MetaMusic(MetaBase):
self.bitrate = bitrate
self.duration = duration
self.isrc = isrc
self.media_source = media_source
self.media_id = media_id
self.media_source, self.media_id = resolve_media_identity(
media_source=media_source,
media_id=media_id,
)
if parse_title:
# 种子/文件名字符串场景:解析艺术家、曲名、年份并补充音质参数
self.apply_title(self.title or org_string or "")
@classmethod
def parse_query(cls, query: str) -> "MetaMusic":
"""把用户输入或资源标题解析为音乐元数据。"""
return cls(org_string=query, title=query, parse_title=True)
@classmethod
def from_music_info(cls, info: Any) -> "MetaMusic":
"""把标准音乐信息转换为下载、整理和站点搜索使用的元数据。"""
return cls(
title=info.title,
artists=list(info.artists),
album=info.album,
album_artist=info.album_artist,
year=info.year,
disc_number=info.disc_number,
track_number=info.track_number,
total_discs=getattr(info, "total_discs", None),
total_tracks=info.total_tracks,
version=info.version,
audio_format=info.audio_format,
audio_lossless=info.audio_lossless,
bit_depth=info.bit_depth,
sample_rate=info.sample_rate,
bitrate=info.bitrate,
duration=info.duration,
isrc=info.isrc,
media_source=info.media_source,
media_id=info.media_id,
)
@classmethod
def from_album_context(
cls,
directory_name: str,
tracks: list["MetaMusic"],
) -> "MetaMusic":
"""按目录名和多数音轨标签汇总专辑识别条件。"""
directory = cls.parse_album_dir(directory_name)
album_votes: dict[str, int] = {}
artist_votes: dict[str, int] = {}
for track in tracks:
if track.album:
album_votes[track.album] = album_votes.get(track.album, 0) + 1
artist = track.album_artist or (track.artists[0] if track.artists else None)
if artist:
artist_votes[artist] = artist_votes.get(artist, 0) + 1
majority_album = max(album_votes, key=album_votes.get) if album_votes else None
majority_artist = max(artist_votes, key=artist_votes.get) if artist_votes else None
threshold = max(2, len(tracks) // 2)
album = majority_album if majority_album and album_votes[majority_album] >= threshold else None
artist = majority_artist if majority_artist and artist_votes[majority_artist] >= threshold else None
return cls(
org_string=directory_name,
title=album or directory.get("album") or directory_name,
album=album or directory.get("album"),
artists=[artist or directory.get("artist")]
if artist or directory.get("artist") else [],
album_artist=artist or directory.get("artist"),
year=directory.get("year"),
)
@property
def name(self) -> str:
"""返回搜索和展示使用的音乐名称,优先专辑名其次标题。"""
@@ -725,6 +818,14 @@ class MetaMusic(MetaBase):
命名模式和对应解析器最后统一回填结构化字段并提取曲序前缀
"""
raw = str(value or "")
if MusicNameRegistry._uses_default_components():
rust_result = rust_accel.parse_metamusic(
raw,
artists=list(self.artists) or None,
year=self.year,
)
if rust_result and self._apply_rust_title_result(rust_result):
return
self.apply_audio_quality(raw)
context = self._prepare_name_context(
raw=raw,
@@ -743,6 +844,30 @@ class MetaMusic(MetaBase):
self._apply_name_result(context, parsed)
self._apply_track_prefix()
def _apply_rust_title_result(self, parsed: dict[str, Any]) -> bool:
"""回填 Rust 音乐解析结果,并保留调用方已有的高可信字段。"""
if "title" not in parsed:
return False
parsed_meta = type(self).from_dict(parsed)
self.title = parsed_meta.title
for field_name in (
"artists",
"album",
"year",
"disc_number",
"track_number",
"audio_format",
"audio_lossless",
"bit_depth",
"sample_rate",
"bitrate",
):
current_value = getattr(self, field_name, None)
parsed_value = getattr(parsed_meta, field_name, None)
if current_value in (None, "", []) and parsed_value not in (None, "", []):
setattr(self, field_name, parsed_value)
return True
@classmethod
def _prepare_name_context(
cls,
@@ -1778,6 +1903,7 @@ def _register_default_name_components() -> None:
MusicNameRegistry.register_pattern(pattern)
for parser in parsers:
MusicNameRegistry.register_parser(parser)
MusicNameRegistry._capture_default_components()
_register_default_name_components()
+20 -14
View File
@@ -110,8 +110,9 @@ def _normalize_metainfo_identity(metainfo: dict) -> dict:
if not media_source:
for source, key in _LEGACY_ID_KEYS:
value = normalized.get(key)
if value is not None and str(value).strip():
media_source, media_id = source, str(value).strip()
normalized_id = str(value).strip() if value is not None else ""
if normalized_id and normalized_id != "0":
media_source, media_id = source, normalized_id
break
for _, key in _LEGACY_ID_KEYS:
normalized.pop(key, None)
@@ -177,9 +178,11 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
legacy_matches = []
for source, pattern in _LEGACY_BRACED_ID_PATTERNS:
legacy_match = pattern.search(result)
if legacy_match and legacy_match.group(0).isdigit():
legacy_identities[source] = legacy_match.group(0)
if legacy_match:
legacy_matches.append(legacy_match)
normalized_id = legacy_match.group(0)
if normalized_id.isdigit() and normalized_id != "0":
legacy_identities[source] = normalized_id
# 查找媒体类型
mtype = _BRACED_TYPE_RE.search(result)
if mtype:
@@ -223,14 +226,16 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
# 支持Emby格式的ID标签;第一个 [tmdbid] 历史上始终优先处理,用于覆盖前面 {[...]} 中的旧标签。
tmdb_match = _EMBY_TMDB_RE_LIST[0].search(title)
if tmdb_match:
legacy_identities[MediaSource.TMDB] = tmdb_match.group(1)
if tmdb_match.group(1) != "0":
legacy_identities[MediaSource.TMDB] = tmdb_match.group(1)
title = _EMBY_TMDB_RE_LIST[0].sub('', title).strip()
elif MediaSource.TMDB not in legacy_identities:
# 保持原有优先级:[tmdbid] > [tmdb] > {tmdbid} > {tmdb}
for tmdb_re in _EMBY_TMDB_RE_LIST[1:]:
tmdb_match = tmdb_re.search(title)
if tmdb_match:
legacy_identities[MediaSource.TMDB] = tmdb_match.group(1)
if tmdb_match.group(1) != "0":
legacy_identities[MediaSource.TMDB] = tmdb_match.group(1)
title = tmdb_re.sub('', title).strip()
break
@@ -242,7 +247,8 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
media_id_match = media_id_re.search(title)
if not media_id_match:
continue
legacy_identities[source] = media_id_match.group(1)
if media_id_match.group(1) != "0":
legacy_identities[source] = media_id_match.group(1)
title = media_id_re.sub('', title).strip()
break
@@ -426,14 +432,17 @@ def _requires_python_metainfo(
custom_words: Optional[List[str]] = None,
) -> bool:
"""
判断标题或临时识别词是否包含当前Rust扩展尚未支持的数据源ID标签
判断标题或临时识别词是否包含当前 Rust 扩展尚未支持的媒体身份标签
:param title: 原始标题
:param custom_words: 临时识别词
:return: 是否必须使用Python解析器
"""
candidates = [title or "", *(custom_words or [])]
if any(_GENERIC_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates):
contains_generic_id = any(
_GENERIC_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
)
if contains_generic_id and not rust_accel.supports_unified_media_identity():
return True
contains_extended_id = any(
_EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
@@ -486,14 +495,11 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None, force_video: bool =
# 音频文件直接构造音乐元数据,不参与父目录季集合并,影视附加音轨强制走视频解析
audio_suffix = path.suffix.lower()
if not force_video and audio_suffix in settings.RMT_AUDIOEXT:
music_meta = MetaMusic(
return MetaMusic(
org_string=path.name,
title=path.stem,
audio_format=audio_suffix.lstrip(".").upper() or None,
parse_title=True,
)
# 无标签音频只能依靠文件名和目录结构,补充曲序、碟号、歌手和专辑线索
return music_meta.apply_path_context(path)
).apply_path_context(path)
path_context = " ".join(
[path.name, path.parent.name, path.parent.parent.name]
)
+7 -6
View File
@@ -1336,7 +1336,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
if not settings.PLUGIN_MARKET:
return []
# 当前版本及向后兼容的低版本标识,按优先级降序,均作为高版本来源拉取
# 拉取当前索引及可扫描的旧索引;旧条目可用当前版本 false 显式排除。
compatible_flags = (
[settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, [])
if settings.VERSION_FLAG else []
@@ -1348,10 +1348,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
# future -> (market_index, is_higher, flag_priority)
futures_meta: Dict[concurrent.futures.Future, Tuple[int, bool, int]] = {}
for market_index, m in enumerate(markets):
# 提交任务获取 v1 版本插件
# 默认索引只展示声明 V2 或当前版本兼容的共享实现。
base_future = executor.submit(self.get_plugins_from_market, m, None, force)
futures_meta[base_future] = (market_index, False, 0)
# 提交任务获取高版本插件(如 v3)及向后兼容版本(如 v2
# 提交当前专用索引(如 v3)及可扫描的旧索引(如 v2
for flag_priority, flag in enumerate(compatible_flags):
higher_future = executor.submit(self.get_plugins_from_market, m, flag, force)
futures_meta[higher_future] = (market_index, True, flag_priority)
@@ -1628,8 +1628,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
return None
plugin_info = PluginHelper.annotate_plugin_system_version(plugin_info.copy())
# 如 package_version 为空(package.json 来源),则需要判断插件是否兼容当前版本或任一向后兼容版本
if not package_version and not PluginHelper.is_plugin_info_compatible(plugin_info):
if not PluginHelper.is_package_plugin_compatible(
plugin_info, package_version or ""
):
# 插件当前版本不兼容
return None
@@ -1762,7 +1763,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
base_version_plugins = []
tasks = []
# 当前版本及向后兼容的低版本标识,按优先级降序,均作为高版本来源拉取
# 拉取当前索引及可扫描的旧索引;旧条目可用当前版本 false 显式排除。
compatible_flags = (
[settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, [])
if settings.VERSION_FLAG else []
+2
View File
@@ -2,6 +2,7 @@ from typing import Dict, List, Optional
from app.db import DbOper
from app.db.models.downloadfailure import DownloadFailure
from app.utils.media import normalize_media_identity_payload
class DownloadFailureOper(DbOper):
@@ -38,6 +39,7 @@ class DownloadFailureOper(DbOper):
"""
新增或更新资源失败记录
"""
kwargs = normalize_media_identity_payload(kwargs)
return DownloadFailure.record_failure(
self._db,
fingerprint=fingerprint,
+6 -3
View File
@@ -2,6 +2,8 @@ from typing import Dict, List, Optional
from app.db import DbOper
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
from app.schemas.types import MediaSource
from app.utils.media import normalize_media_identity_payload
class DownloadHistoryOper(DbOper):
@@ -35,7 +37,7 @@ class DownloadHistoryOper(DbOper):
}
def get_by_media_identity(
self, media_source: str, media_id: str,
self, media_source: MediaSource, media_id: str,
music_type: Optional[str] = None,
) -> List[DownloadHistory]:
"""
@@ -55,6 +57,7 @@ class DownloadHistoryOper(DbOper):
"""
新增下载历史
"""
kwargs = normalize_media_identity_payload(kwargs)
DownloadHistory(**kwargs).create(self._db)
def add_files(self, file_items: List[dict]):
@@ -137,7 +140,7 @@ class DownloadHistoryOper(DbOper):
def get_last_by(self, mtype=None, title: Optional[str] = None, year: Optional[str] = None,
season: Optional[str] = None, episode: Optional[str] = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None) -> List[DownloadHistory]:
"""
按类型标题年份季集查询下载记录
@@ -161,7 +164,7 @@ class DownloadHistoryOper(DbOper):
username=username)
def list_by_date(
self, date: str, type: str, media_source: str, media_id: str,
self, date: str, type: str, media_source: MediaSource, media_id: str,
seasons: Optional[str] = None,
) -> List[DownloadHistory]:
"""
+3 -1
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from app.db import DbOper
from app.db.models.mediaserver import MediaServerItem
from app.utils.media import normalize_media_identity_payload
class MediaServerOper(DbOper):
@@ -19,10 +20,11 @@ class MediaServerOper(DbOper):
"""
过滤数据库模型不存在或不应由远端覆盖的字段
"""
return {
payload = {
k: v for k, v in kwargs.items()
if hasattr(MediaServerItem, k) and k != "id"
}
return normalize_media_identity_payload(payload)
def add(self, **kwargs) -> bool:
"""
+2
View File
@@ -4,6 +4,7 @@ from sqlalchemy import Column, Float, Index, Integer, String
from sqlalchemy.orm import Session
from app.db import Base, db_query, db_update, get_id_column
from app.db.models.media_identity import media_identity_constraint
class DownloadFailure(Base):
@@ -53,6 +54,7 @@ class DownloadFailure(Base):
next_retry_at = Column(String)
__table_args__ = (
media_identity_constraint("downloadfailure"),
Index("ux_downloadfailure_fingerprint", "fingerprint", unique=True),
Index("ix_downloadfailure_next_retry_at", "next_retry_at"),
Index("ix_downloadfailure_media_identity_site", "type", "media_source", "media_id", "site"),
+6 -3
View File
@@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db import db_query, db_update, get_id_column, Base, async_db_query
from app.db.models.media_identity import media_identity_constraint
from app.schemas.types import MediaSource
def _title_like(column, title: str):
@@ -67,6 +69,7 @@ class DownloadHistory(Base):
custom_words = Column(String)
__table_args__ = (
media_identity_constraint("downloadhistory"),
Index('ix_downloadhistory_download_hash_date', 'download_hash', 'date'),
Index('ix_downloadhistory_date_id', 'date', 'id'),
Index('ix_downloadhistory_media_identity', 'media_source', 'media_id'),
@@ -119,7 +122,7 @@ class DownloadHistory(Base):
@classmethod
@db_query
def get_by_media_identity(
cls, db: Session, media_source: str, media_id: str,
cls, db: Session, media_source: MediaSource, media_id: str,
music_type: Optional[str] = None,
):
"""按规范媒体身份查询下载历史。"""
@@ -205,7 +208,7 @@ class DownloadHistory(Base):
year: Optional[str] = None,
season: Optional[str] = None,
episode: Optional[str] = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
):
"""
@@ -321,7 +324,7 @@ class DownloadHistory(Base):
db: Session,
date: str,
type: str,
media_source: str,
media_source: MediaSource,
media_id: str,
seasons: Optional[str] = None,
):
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import CheckConstraint
from app.schemas.types import MediaSource
MEDIA_SOURCE_SQL_VALUES = ", ".join(
f"'{media_source.value}'" for media_source in MediaSource
)
MEDIA_IDENTITY_CHECK_SQL = (
"(media_source IS NULL AND media_id IS NULL) OR "
"(media_source IS NOT NULL AND "
f"media_source IN ({MEDIA_SOURCE_SQL_VALUES}) AND "
"media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')"
)
def media_identity_constraint(table_name: str) -> CheckConstraint:
"""构造通用媒体表使用的来源枚举与身份成对数据库约束。"""
return CheckConstraint(
MEDIA_IDENTITY_CHECK_SQL,
name=f"ck_{table_name}_media_identity",
)
+5 -2
View File
@@ -7,6 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db import db_query, db_update, get_id_column, async_db_query, Base
from app.db.models.media_identity import media_identity_constraint
from app.schemas.types import MediaSource
class MediaServerItem(Base):
@@ -41,6 +43,7 @@ class MediaServerItem(Base):
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
__table_args__ = (
media_identity_constraint("mediaserveritem"),
Index('ux_mediaserveritem_server_item_id', 'server', 'item_id', unique=True),
Index(
'ix_mediaserveritem_media_identity_type',
@@ -85,7 +88,7 @@ class MediaServerItem(Base):
@classmethod
@db_query
def exist_by_media_identity(
cls, db: Session, media_source: str, media_id: str, mtype: str,
cls, db: Session, media_source: MediaSource, media_id: str, mtype: str,
):
"""按规范媒体身份和类型查询媒体服务器条目。"""
return db.query(cls).filter(
@@ -118,7 +121,7 @@ class MediaServerItem(Base):
@classmethod
@async_db_query
async def async_exist_by_media_identity(
cls, db: AsyncSession, media_source: str, media_id: str, mtype: str,
cls, db: AsyncSession, media_source: MediaSource, media_id: str, mtype: str,
):
"""异步按规范媒体身份和类型查询媒体服务器条目。"""
result = await db.execute(select(cls).filter(
+14 -12
View File
@@ -6,7 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db import db_query, db_update, get_id_column, Base, async_db_query, async_db_update
from app.schemas.types import MUSIC_ENTITY_RECORDING
from app.db.models.media_identity import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
class Subscribe(Base):
@@ -112,6 +113,7 @@ class Subscribe(Base):
episode_group = Column(String)
__table_args__ = (
media_identity_constraint("subscribe"),
Index('ix_subscribe_type_date', 'type', 'date'),
Index('ix_subscribe_media_identity', 'media_source', 'media_id'),
)
@@ -119,7 +121,7 @@ class Subscribe(Base):
@classmethod
def _identity_condition(
cls,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
music_type: Optional[str] = None,
):
@@ -139,7 +141,7 @@ class Subscribe(Base):
@classmethod
@db_query
def exists(
cls, db: Session, media_source: str, media_id: str,
cls, db: Session, media_source: MediaSource, media_id: str,
season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
@@ -159,7 +161,7 @@ class Subscribe(Base):
@classmethod
@async_db_query
async def async_exists(
cls, db: AsyncSession, media_source: str, media_id: str,
cls, db: AsyncSession, media_source: MediaSource, media_id: str,
season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
@@ -180,7 +182,7 @@ class Subscribe(Base):
@classmethod
@db_query
def exists_by_username(
cls, db: Session, username: str, media_source: str, media_id: str,
cls, db: Session, username: str, media_source: MediaSource, media_id: str,
season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
@@ -204,7 +206,7 @@ class Subscribe(Base):
@classmethod
@async_db_query
async def async_exists_by_username(
cls, db: AsyncSession, username: str, media_source: str,
cls, db: AsyncSession, username: str, media_source: MediaSource,
media_id: str, season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
@@ -289,7 +291,7 @@ class Subscribe(Base):
@classmethod
@db_query
def list_by_media_identity(
cls, db: Session, media_source: str, media_id: str,
cls, db: Session, media_source: MediaSource, media_id: str,
music_type: Optional[str] = None,
):
"""同步按统一媒体身份查询候选订阅列表。"""
@@ -305,7 +307,7 @@ class Subscribe(Base):
@classmethod
@async_db_query
async def async_list_by_media_identity(
cls, db: AsyncSession, media_source: str, media_id: str,
cls, db: AsyncSession, media_source: MediaSource, media_id: str,
music_type: Optional[str] = None,
):
"""异步按统一媒体身份查询候选订阅列表。"""
@@ -322,7 +324,7 @@ class Subscribe(Base):
@classmethod
@db_query
def get_by(
cls, db: Session, type: str, media_source: str, media_id: str,
cls, db: Session, type: str, media_source: MediaSource, media_id: str,
season: Optional[str] = None,
music_type: Optional[str] = None,
):
@@ -342,7 +344,7 @@ class Subscribe(Base):
@classmethod
@async_db_query
async def async_get_by(
cls, db: AsyncSession, type: str, media_source: str, media_id: str,
cls, db: AsyncSession, type: str, media_source: MediaSource, media_id: str,
season: Optional[str] = None,
music_type: Optional[str] = None,
):
@@ -362,7 +364,7 @@ class Subscribe(Base):
@db_update
def delete_by_media_identity(
self, db: Session, media_source: str, media_id: str,
self, db: Session, media_source: MediaSource, media_id: str,
season: Optional[int] = None,
) -> bool:
"""按规范媒体身份删除订阅。"""
@@ -377,7 +379,7 @@ class Subscribe(Base):
@async_db_update
async def async_delete_by_media_identity(
self, db: AsyncSession, media_source: str, media_id: str,
self, db: AsyncSession, media_source: MediaSource, media_id: str,
season: Optional[int] = None,
) -> bool:
"""异步按规范媒体身份删除订阅。"""
+6 -4
View File
@@ -5,7 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db import db_query, Base, get_id_column, async_db_query
from app.schemas.types import MUSIC_ENTITY_RECORDING
from app.db.models.media_identity import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
class SubscribeHistory(Base):
@@ -99,6 +100,7 @@ class SubscribeHistory(Base):
episode_group = Column(String)
__table_args__ = (
media_identity_constraint("subscribehistory"),
Index('ix_subscribehistory_type_date', 'type', 'date'),
Index('ix_subscribehistory_media_identity', 'media_source', 'media_id'),
)
@@ -152,7 +154,7 @@ class SubscribeHistory(Base):
@classmethod
def _identity_condition(
cls,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
music_type: Optional[str] = None,
):
@@ -172,7 +174,7 @@ class SubscribeHistory(Base):
@classmethod
@db_query
def exists(
cls, db: Session, media_source: str, media_id: str,
cls, db: Session, media_source: MediaSource, media_id: str,
season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
@@ -192,7 +194,7 @@ class SubscribeHistory(Base):
@classmethod
@async_db_query
async def async_exists(
cls, db: AsyncSession, media_source: str, media_id: str,
cls, db: AsyncSession, media_source: MediaSource, media_id: str,
season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
+5 -3
View File
@@ -8,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db import db_query, db_update, get_id_column, Base, async_db_query
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType
from app.db.models.media_identity import media_identity_constraint
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType
def _text_like(column, pattern: str, wildcard: bool = False):
@@ -84,6 +85,7 @@ class TransferHistory(Base):
episode_group = Column(String)
__table_args__ = (
media_identity_constraint("transferhistory"),
Index('ix_transferhistory_status_date', 'status', 'date'),
Index('ix_transferhistory_date_id', 'date', 'id'),
Index('ix_transferhistory_media_identity', 'media_source', 'media_id'),
@@ -476,7 +478,7 @@ class TransferHistory(Base):
def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None,
season: Optional[str] = None,
episode: Optional[str] = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
dest: Optional[str] = None):
"""
@@ -544,7 +546,7 @@ class TransferHistory(Base):
@classmethod
@db_query
def get_by_media_identity(
cls, db: Session, media_source: str, media_id: str,
cls, db: Session, media_source: MediaSource, media_id: str,
mtype: Optional[str] = None,
):
"""按规范媒体身份和类型查询整理记录。"""
+7 -6
View File
@@ -5,8 +5,8 @@ from app.core.context import MediaInfo, MusicInfo
from app.db import DbOper
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
from app.utils.media import resolve_media_identity
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType
from app.utils.media import normalize_media_identity_payload, resolve_media_identity
INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode")
@@ -164,7 +164,7 @@ class SubscribeOper(DbOper):
return subscribe.id, "订阅已存在"
def exists(
self, media_source: str, media_id: str,
self, media_source: MediaSource, media_id: str,
season: Optional[int] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
) -> bool:
@@ -193,7 +193,7 @@ class SubscribeOper(DbOper):
return await Subscribe.async_get(self._db, rid=sid)
def get_by(
self, type: str, media_source: str, media_id: str,
self, type: str, media_source: MediaSource, media_id: str,
season: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[Subscribe]:
@@ -205,7 +205,7 @@ class SubscribeOper(DbOper):
)
async def async_get_by(
self, type: str, media_source: str, media_id: str,
self, type: str, media_source: MediaSource, media_id: str,
season: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[Subscribe]:
@@ -289,6 +289,7 @@ class SubscribeOper(DbOper):
"""
# 去除kwargs中 SubscribeHistory 没有的字段
kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)}
kwargs = normalize_media_identity_payload(kwargs)
kwargs = _normalize_integer_flags(kwargs)
# 更新完成订阅时间
kwargs.update({"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())})
@@ -299,7 +300,7 @@ class SubscribeOper(DbOper):
subscribe.create(self._db)
def exist_history(
self, media_source: str, media_id: str,
self, media_source: MediaSource, media_id: str,
season: Optional[int] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
) -> bool:
+15 -9
View File
@@ -6,7 +6,8 @@ from app.core.meta import MetaBase, MetaMusic
from app.db import DbOper
from app.db.models.transferhistory import TransferHistory
from app.schemas import TransferInfo, FileItem
from app.schemas.types import MUSIC_ENTITY_RECORDING
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
from app.utils.media import normalize_media_identity_payload, resolve_media_identity
class TransferHistoryOper(DbOper):
@@ -153,6 +154,7 @@ class TransferHistoryOper(DbOper):
"""
新增转移历史
"""
kwargs = normalize_media_identity_payload(kwargs)
kwargs.update({
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
@@ -166,7 +168,7 @@ class TransferHistoryOper(DbOper):
def get_by(self, title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None,
season: Optional[str] = None, episode: Optional[str] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
media_source: Optional[MediaSource] = None, media_id: Optional[str] = None,
dest: Optional[str] = None) -> List[TransferHistory]:
"""
按类型标题年份季集查询转移记录
@@ -182,7 +184,7 @@ class TransferHistoryOper(DbOper):
media_id=media_id)
def get_by_media_identity(
self, media_source: str, media_id: str,
self, media_source: MediaSource, media_id: str,
mtype: Optional[str] = None,
) -> TransferHistory:
"""按规范媒体身份和类型查询整理记录。"""
@@ -215,6 +217,7 @@ class TransferHistoryOper(DbOper):
"""
新增转移历史相同源目录的记录会被删除
"""
kwargs = normalize_media_identity_payload(kwargs)
if kwargs.get("src"):
transferhistory = TransferHistory.get_by_src(self._db, kwargs.get("src"))
if transferhistory:
@@ -248,6 +251,7 @@ class TransferHistoryOper(DbOper):
"""
新增转移成功历史记录
"""
media_source, media_id = resolve_media_identity(media=mediainfo)
return self.add_force(
src=fileitem.path,
src_storage=fileitem.storage,
@@ -260,8 +264,8 @@ class TransferHistoryOper(DbOper):
category=mediainfo.category,
title=self._history_title(meta, mediainfo),
year=mediainfo.year,
media_source=str(mediainfo.media_source),
media_id=mediainfo.media_id,
media_source=media_source,
media_id=media_id,
music_type=getattr(mediainfo, "music_type", None),
total_tracks=getattr(mediainfo, "total_tracks", None),
audio_format=getattr(meta, "audio_format", None),
@@ -284,6 +288,7 @@ class TransferHistoryOper(DbOper):
新增转移失败历史记录
"""
if mediainfo and transferinfo:
media_source, media_id = resolve_media_identity(media=mediainfo)
his = self.add_force(
src=fileitem.path,
src_storage=fileitem.storage,
@@ -296,8 +301,8 @@ class TransferHistoryOper(DbOper):
category=mediainfo.category,
title=self._history_title(meta, mediainfo),
year=mediainfo.year or meta.year,
media_source=str(mediainfo.media_source),
media_id=mediainfo.media_id,
media_source=media_source,
media_id=media_id,
music_type=getattr(mediainfo, "music_type", None),
total_tracks=getattr(mediainfo, "total_tracks", None),
audio_format=getattr(meta, "audio_format", None),
@@ -316,12 +321,13 @@ class TransferHistoryOper(DbOper):
files=transferinfo.file_list
)
else:
media_source, media_id = resolve_media_identity(media=meta)
his = self.add_force(
type=meta.type.value if meta.type else None,
title=self._history_title(meta),
year=meta.year,
media_source=str(meta.media_source) if meta.media_source else None,
media_id=meta.media_id,
media_source=media_source,
media_id=media_id,
music_type=MUSIC_ENTITY_RECORDING if isinstance(meta, MetaMusic) else None,
audio_format=getattr(meta, "audio_format", None),
audio_lossless=getattr(meta, "audio_lossless", None),
+21 -6
View File
@@ -10,7 +10,7 @@ from mutagen.mp4 import MP4, MP4Cover
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.log import logger
from app.schemas.types import MUSIC_ENTITY_RECORDING
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
class AudioMetadataHelper:
@@ -24,6 +24,24 @@ class AudioMetadataHelper:
return tag_meta.apply_path_context(path)
return cls.read_filename(path)
@classmethod
def read_evidence(
cls,
path: Path,
) -> tuple[MetaMusic, Optional[MetaMusic], MetaMusic]:
"""分别返回合并元数据、纯标签元数据和纯文件名元数据。"""
filename_meta = cls.read_filename(path)
tag_meta = cls.read_tags(path) if path.exists() and path.is_file() else None
if not tag_meta:
return filename_meta, None, filename_meta
merged_meta = MetaMusic.from_dict(tag_meta.to_dict()).apply_path_context(path)
return merged_meta, tag_meta, filename_meta
@classmethod
def read_many(cls, paths: list[Path]) -> list[MetaMusic]:
"""批量读取一组音频路径的标签与文件名元数据。"""
return [cls.read(path) for path in paths]
@classmethod
def read_tags(cls, path: Path) -> Optional[MetaMusic]:
"""只读取本地音频标签和流参数,不使用文件名或目录补齐。"""
@@ -64,7 +82,7 @@ class AudioMetadataHelper:
bitrate=cls._optional_int(getattr(info, "bitrate", None)),
duration=round(info.length) if info and getattr(info, "length", None) else None,
isrc=cls._first(tags, "isrc"),
media_source="musicbrainz" if musicbrainz_id else None,
media_source=MediaSource.MusicBrainz if musicbrainz_id else None,
media_id=musicbrainz_id,
)
@@ -151,11 +169,8 @@ class AudioMetadataHelper:
music: Union[MetaMusic, MusicInfo],
) -> Optional[str]:
"""仅将 MusicBrainz 单曲身份写入 recording 标签,避免误写专辑 ID。"""
if getattr(music, "media_source", None) == "musicbrainz":
media_id = getattr(music, "media_id", None)
return str(media_id) if media_id else None
if (
getattr(music, "media_source", None) == "musicbrainz"
getattr(music, "media_source", None) == MediaSource.MusicBrainz
and getattr(music, "music_type", MUSIC_ENTITY_RECORDING)
== MUSIC_ENTITY_RECORDING
):
+74 -27
View File
@@ -43,8 +43,7 @@ from version import APP_VERSION
PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins"
LOCAL_REPO_PREFIX = "local://"
PLUGIN_SYSTEM_VERSION_FIELD = "system_version"
# 主程序重大版本向后兼容声明:键为当前 VERSION_FLAG,值为该版本可向下兼容的更低版本标识列表(按优先级降序)
# 例如 v3 兼容 v2,则 package.json 中声明 "v2": true 的插件、package.v2.json 中的插件均视为可用。
# 主程序重大版本可扫描的旧索引;V3 临时默认兼容 V2,条目可用 v3:false 排除
VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = {
"v3": ["v2"],
}
@@ -178,18 +177,50 @@ class PluginHelper(metaclass=WeakSingleton):
"""
判断 package.json 中的插件元数据是否兼容当前主程序版本
兼容条件未启用 VERSION_FLAGv1时默认全部兼容否则需声明当前 VERSION_FLAG True
或声明任一向后兼容的低版本标识为 True
默认索引需要声明当前版本V3 临时兼容已声明 V2 的共享实现
但显式 ``v3: false`` 始终优先拒绝
"""
if not isinstance(plugin_info, dict):
return False
if not settings.VERSION_FLAG:
return True
if plugin_info.get(settings.VERSION_FLAG) is True:
current_flag = settings.VERSION_FLAG
if plugin_info.get(current_flag) is False:
return False
if plugin_info.get(current_flag) is True:
return True
for flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []):
if plugin_info.get(flag) is True:
return True
return any(
plugin_info.get(flag) is True
for flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(
current_flag, []
)
)
@classmethod
def is_package_plugin_compatible(
cls,
plugin_info: Optional[dict],
package_version: Optional[str],
) -> bool:
"""
判断指定索引中的插件条目能否在当前主程序版本使用
当前代专用索引直接兼容V3 临时默认兼容 V2 专用索引
除非条目显式声明 ``v3: false``默认索引仍需先声明 ``v2: true``
"""
if not isinstance(plugin_info, dict):
return False
current_flag = settings.VERSION_FLAG
if not current_flag:
return not package_version
if package_version == current_flag:
return True
if package_version in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(
current_flag, []
):
return plugin_info.get(current_flag) is not False
if not package_version:
return cls.is_plugin_info_compatible(plugin_info)
return False
@classmethod
@@ -310,8 +341,9 @@ class PluginHelper(metaclass=WeakSingleton):
for pid, plugin_info in local_plugins.items():
if not isinstance(plugin_info, dict):
continue
# package.json 中的旧结构需要声明兼容当前版本或任一向后兼容版本。
if not package_version and not self.is_plugin_info_compatible(plugin_info):
if not self.is_package_plugin_compatible(
plugin_info, package_version
):
continue
plugin_dir = self.__get_local_plugin_dir(repo_path, pid, package_version)
@@ -374,10 +406,9 @@ class PluginHelper(metaclass=WeakSingleton):
for candidate_pid, plugin_info in local_plugins.items():
if candidate_pid.lower() != pid.lower() or not isinstance(plugin_info, dict):
continue
# 指定版本 package 文件视为可用;package.json 需声明当前版本或任一向后兼容版本。
is_compatible = (
bool(current_package_version)
or self.is_plugin_info_compatible(plugin_info)
is_compatible = self.is_package_plugin_compatible(
plugin_info,
current_package_version or "",
)
if not is_compatible and strict_compat:
continue
@@ -394,7 +425,7 @@ class PluginHelper(metaclass=WeakSingleton):
if not is_compatible:
candidate["compatible"] = False
candidate["skip_reason"] = (
f"package.json 未声明 {settings.VERSION_FLAG} 或向后兼容版本"
f"插件索引条目不兼容 {settings.VERSION_FLAG}"
)
self.annotate_plugin_system_version(candidate)
if strict_system_version and candidate.get("system_version_compatible") is False:
@@ -613,8 +644,8 @@ class PluginHelper(metaclass=WeakSingleton):
检查并获取指定插件的可用版本支持多版本优先级加载和版本兼容性检测
1. 如果未指定版本则使用系统配置的默认版本通过 settings.VERSION_FLAG 设置
2. 优先检查指定版本的插件 `package.v2.json`
3. 向后兼容检查更低版本的 package 文件安装对应版本代码
4. 检查 `package.json` 文件插件声明当前版本或任一向后兼容版本均视为可用
3. 检查更低版本的 package 文件并应用版本兼容标志
4. 检查 `package.json` 文件并应用共享实现兼容标志
5. 如果插件不存在或不兼容指定版本返回 `None`
:param pid: 插件 ID用于在插件列表中查找
:param repo_url: 插件仓库的 URL指定用于获取插件信息的 GitHub 仓库地址
@@ -625,18 +656,24 @@ class PluginHelper(metaclass=WeakSingleton):
if not package_version:
package_version = settings.VERSION_FLAG
# 优先检查指定版本的插件,即 package.v(x).json 文件中是否存在该插件,如果存在,返回该版本号
if pid in (self.get_plugins(repo_url, package_version) or []):
# 优先检查指定索引;即使显式指定 V2,也必须尊重 v3:false 排除标志。
plugin = (self.get_plugins(repo_url, package_version) or {}).get(pid)
if plugin and self.is_package_plugin_compatible(
plugin, package_version
):
return package_version
# 向后兼容:检查更低版本的 package 文件,命中则安装对应版本代码
# V3 临时默认接纳 V2 专用索引,v3:false 的 V3 专用副本旧条目除外。
for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(package_version, []):
if pid in (self.get_plugins(repo_url, backward_flag) or []):
plugin = (self.get_plugins(repo_url, backward_flag) or {}).get(pid)
if plugin and self.is_package_plugin_compatible(
plugin, backward_flag
):
return backward_flag
# 检查全局 package.json 文件,插件声明当前版本或任一向后兼容版本均视为可用,安装基础代码
# 默认索引只接纳声明 v2:true 或当前版本兼容的共享实现。
plugin = (self.get_plugins(repo_url) or {}).get(pid, None)
if plugin and self.is_plugin_info_compatible(plugin):
if plugin and self.is_package_plugin_compatible(plugin, ""):
return ""
# 如果所有版本都不存在或插件不兼容,返回 None,表示插件不可用
@@ -2177,16 +2214,26 @@ class PluginHelper(metaclass=WeakSingleton):
if not package_version:
package_version = settings.VERSION_FLAG
if pid in (await self.async_get_plugins(repo_url, package_version) or []):
plugin = (
await self.async_get_plugins(repo_url, package_version) or {}
).get(pid)
if plugin and self.is_package_plugin_compatible(
plugin, package_version
):
return package_version
# 向后兼容:检查更低版本的 package 文件,命中则安装对应版本代码
# 异步安装链路与同步链路使用相同的 V2 默认兼容规则。
for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(package_version, []):
if pid in (await self.async_get_plugins(repo_url, backward_flag) or []):
plugin = (
await self.async_get_plugins(repo_url, backward_flag) or {}
).get(pid)
if plugin and self.is_package_plugin_compatible(
plugin, backward_flag
):
return backward_flag
plugin = (await self.async_get_plugins(repo_url) or {}).get(pid, None)
if plugin and self.is_plugin_info_compatible(plugin):
if plugin and self.is_package_plugin_compatible(plugin, ""):
return ""
return None
+81 -14
View File
@@ -50,6 +50,19 @@ class MoviePilotServerHelper:
_RECOGNIZE_SHARE_PATH = "/recognize/share"
_USER_PERMISSIONS_PATH = "/user/permissions"
_LOCAL_REPO_PREFIX = "local://"
_SUBSCRIBE_STATISTIC_FIELDS = frozenset({
"name", "year", "type", "media_source", "media_id", "music_type",
"total_tracks", "genre_ids", "season", "poster", "backdrop", "vote",
"description",
})
_SUBSCRIBE_SHARE_FIELDS = frozenset({
"share_title", "share_comment", "share_user", "share_uid", "name",
"year", "type", "keyword", "media_source", "media_id", "music_type",
"total_tracks", "season", "poster", "backdrop", "vote", "description",
"genre_ids", "include", "exclude", "quality", "resolution", "effect",
"total_episode", "custom_words", "media_category", "episode_group",
"date",
})
_user_uid: Optional[str] = None
_github_user: Optional[str] = None
@@ -821,7 +834,10 @@ class MoviePilotServerHelper:
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
res = cls.subscribe_add(sub)
payload = cls._build_subscribe_statistic_payload(sub)
if not payload:
return False
res = cls.subscribe_add(payload)
return bool(res is not None and res.status_code == 200)
@classmethod
@@ -831,7 +847,10 @@ class MoviePilotServerHelper:
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
res = await cls.async_subscribe_add(sub)
payload = cls._build_subscribe_statistic_payload(sub)
if not payload:
return False
res = await cls.async_subscribe_add(payload)
return bool(res is not None and res.status_code == 200)
@classmethod
@@ -841,7 +860,10 @@ class MoviePilotServerHelper:
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
res = cls.subscribe_done(sub)
payload = cls._build_subscribe_statistic_payload(sub)
if not payload:
return False
res = cls.subscribe_done(payload)
return bool(res is not None and res.status_code == 200)
@classmethod
@@ -870,7 +892,14 @@ class MoviePilotServerHelper:
subscribes = SubscribeOper().list()
if not subscribes:
return True
res = cls.subscribe_report([sub.to_dict() for sub in subscribes])
payloads = [
payload
for sub in subscribes
if (payload := cls._build_subscribe_statistic_payload(sub.to_dict()))
]
if not payloads:
return True
res = cls.subscribe_report(payloads)
return bool(res is not None and res.status_code == 200)
@classmethod
@@ -889,15 +918,15 @@ class MoviePilotServerHelper:
subscribe = SubscribeOper().get(subscribe_id)
if not subscribe:
return False, "订阅不存在"
subscribe_dict = subscribe.to_dict()
subscribe_dict.pop("id", None)
payload = {
payload = cls._build_subscribe_share_payload({
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**subscribe_dict,
}
**subscribe.to_dict(),
})
if not payload:
return False, "订阅媒体身份不完整"
return cls._handle_response(cls.subscribe_share(payload), cls._clear_subscribe_share_cache)
@classmethod
@@ -916,20 +945,58 @@ class MoviePilotServerHelper:
subscribe = await SubscribeOper().async_get(subscribe_id)
if not subscribe:
return False, "订阅不存在"
subscribe_dict = subscribe.to_dict()
subscribe_dict.pop("id", None)
payload = {
payload = cls._build_subscribe_share_payload({
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": cls.get_user_uuid(),
**subscribe_dict,
}
**subscribe.to_dict(),
})
if not payload:
return False, "订阅媒体身份不完整"
return cls._handle_response(
await cls.async_subscribe_share(payload),
cls._clear_subscribe_share_cache,
)
@classmethod
def _build_subscribe_statistic_payload(
cls, item: Optional[dict]
) -> Optional[dict]:
"""构造中心服务订阅统计载荷,只保留统一身份和公开统计字段。"""
if not isinstance(item, dict):
return None
media_source, media_id = resolve_media_identity(media=item)
if not media_source or not media_id:
return None
payload = {
key: value
for key, value in item.items()
if key in cls._SUBSCRIBE_STATISTIC_FIELDS
}
payload["media_source"] = str(media_source)
payload["media_id"] = media_id
return payload
@classmethod
def _build_subscribe_share_payload(
cls, item: Optional[dict]
) -> Optional[dict]:
"""构造中心服务订阅分享载荷,隔离本地运行字段和旧专用 ID。"""
if not isinstance(item, dict):
return None
media_source, media_id = resolve_media_identity(media=item)
if not media_source or not media_id:
return None
payload = {
key: value
for key, value in item.items()
if key in cls._SUBSCRIBE_SHARE_FIELDS
}
payload["media_source"] = str(media_source)
payload["media_id"] = media_id
return payload
@classmethod
def share_delete(cls, share_id: int) -> Tuple[bool, str]:
"""
+4
View File
@@ -97,8 +97,12 @@
}
},
"messages": {
"无效的媒体来源": "Invalid media source",
"该媒体来源不支持此音乐接口": "This media source is not supported by this music endpoint",
"媒体来源和媒体 ID 必须同时提供": "Media source and media ID must be provided together",
"media_source 和 media_id 必须同时提供": "media_source and media_id must be provided together",
"新增订阅时必须同时提供有效的 media_source 和 media_id": "A valid media_source and media_id must be provided together when creating a subscription",
"更新媒体身份时必须同时提供有效的 media_source 和 media_id": "A valid media_source and media_id must be provided together when updating media identity",
"模块不支持测试": "Module does not support testing",
"网络请求失败": "Network request failed",
"TMDB请求失败": "TMDB request failed",
+11 -5
View File
@@ -8,7 +8,13 @@ from app.helper.scraper import MediaScraperHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.anilist.anilist import AniListApi
from app.schemas.types import MediaRecognizeType, MediaSource, MediaType, ModuleType
from app.schemas.types import (
MediaRecognizeType,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
)
from app.utils.media import is_media_source_enabled
@@ -327,7 +333,7 @@ class AniListModule(_ModuleBase):
return None
def search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索 AniList 动画媒体信息
@@ -336,7 +342,7 @@ class AniListModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 统一媒体信息列表
"""
if not is_media_source_enabled(media_source, "anilist"):
if not is_media_source_enabled(media_source, MediaSource.AniList):
return None
if not meta or not meta.name:
return []
@@ -347,7 +353,7 @@ class AniListModule(_ModuleBase):
]
async def async_search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
异步搜索 AniList 动画媒体信息
@@ -356,7 +362,7 @@ class AniListModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 统一媒体信息列表
"""
if not is_media_source_enabled(media_source, "anilist"):
if not is_media_source_enabled(media_source, MediaSource.AniList):
return None
if not meta or not meta.name:
return []
+11 -5
View File
@@ -8,7 +8,13 @@ from app.helper.scraper import MediaScraperHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.bangumi.bangumi import BangumiApi
from app.schemas.types import MediaRecognizeType, MediaSource, MediaType, ModuleType
from app.schemas.types import (
MediaRecognizeType,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
)
from app.utils.http import RequestUtils
from app.utils.media import is_media_source_enabled
@@ -221,7 +227,7 @@ class BangumiModule(_ModuleBase):
return None
def search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
@@ -229,7 +235,7 @@ class BangumiModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 媒体信息
"""
if not is_media_source_enabled(media_source, "bangumi"):
if not is_media_source_enabled(media_source, MediaSource.Bangumi):
return None
if not meta.name:
return []
@@ -241,7 +247,7 @@ class BangumiModule(_ModuleBase):
return []
async def async_search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息异步版本
@@ -249,7 +255,7 @@ class BangumiModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 媒体信息
"""
if not is_media_source_enabled(media_source, "bangumi"):
if not is_media_source_enabled(media_source, MediaSource.Bangumi):
return None
if not meta.name:
return []
+36 -17
View File
@@ -21,6 +21,7 @@ from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
MediaRecognizeType,
@@ -63,7 +64,7 @@ class DoubanModule(_ModuleBase):
return "豆瓣"
@staticmethod
def get_music_source() -> str:
def get_music_source() -> MediaSource:
"""返回音乐识别使用的数据源标识。"""
return DoubanModule._music_source
@@ -92,7 +93,7 @@ class DoubanModule(_ModuleBase):
self,
meta: MetaMusic,
limit: int = 20,
media_source: Optional[str] = None,
media_source: Optional[MediaSourceSelection] = None,
) -> Optional[List[MusicInfo]]:
"""按请求来源搜索豆瓣音乐专辑,并转换为统一音乐候选。"""
if not is_media_source_selected(media_source, self._music_source):
@@ -105,7 +106,7 @@ class DoubanModule(_ModuleBase):
def recognize_music(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
@@ -130,7 +131,11 @@ class DoubanModule(_ModuleBase):
)
return album.to_music_info()
def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
def music_album(
self,
media_source: MediaSource,
media_id: str,
) -> Optional[MusicAlbumInfo]:
"""按豆瓣音乐专辑 ID 获取标准化专辑详情和曲目。"""
if media_source != self._music_source or not media_id:
return None
@@ -139,7 +144,7 @@ class DoubanModule(_ModuleBase):
def music_discover(
self,
media_source: str,
media_source: MediaSource,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
@@ -187,7 +192,7 @@ class DoubanModule(_ModuleBase):
def music_album_related(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
count: int = 24,
) -> Optional[List[MusicInfo]]:
@@ -204,7 +209,7 @@ class DoubanModule(_ModuleBase):
def _recognize_music_media(
self,
meta: Optional[MetaMusic],
media_source: Optional[str],
media_source: Optional[MediaSource],
media_id: Optional[str],
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
@@ -248,7 +253,7 @@ class DoubanModule(_ModuleBase):
async def _async_recognize_music_media(
self,
meta: Optional[MetaMusic],
media_source: Optional[str],
media_source: Optional[MediaSource],
media_id: Optional[str],
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
@@ -385,7 +390,7 @@ class DoubanModule(_ModuleBase):
title = cls._douban_music_text(target.get("title") or target.get("name"))
if not media_id or not title:
continue
artists = cls._douban_music_artists(target)
artists = cls._douban_music_search_artists(target)
release_date = cls._douban_music_date(target)
cover_url = cls._douban_music_cover(target)
candidate = MusicInfo(
@@ -551,6 +556,20 @@ class DoubanModule(_ModuleBase):
artists.append(text)
return artists
@classmethod
def _douban_music_search_artists(cls, info: dict[str, Any]) -> List[str]:
"""提取搜索候选艺术家,缺少结构化字段时回退到卡片副标题首段。"""
artists = cls._douban_music_artists(info)
if artists:
return artists
subtitle = cls._douban_music_text(info.get("card_subtitle"))
if not subtitle:
return []
artist = re.split(r"\s+/\s+", subtitle, maxsplit=1)[0].strip()
if not artist or re.fullmatch(r"\d{4}(?:-\d{1,2}(?:-\d{1,2})?)?", artist):
return []
return [artist]
@classmethod
def _douban_music_cover(cls, info: dict[str, Any]) -> Optional[str]:
"""从豆瓣多种图片字段中提取清晰封面。"""
@@ -1425,7 +1444,7 @@ class DoubanModule(_ModuleBase):
return []
def search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
@@ -1433,7 +1452,7 @@ class DoubanModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 媒体信息
"""
if not is_media_source_enabled(media_source, "douban"):
if not is_media_source_enabled(media_source, MediaSource.Douban):
return None
if not meta.name:
return []
@@ -1444,7 +1463,7 @@ class DoubanModule(_ModuleBase):
return self._build_search_medias_result(meta, result.get("items"))
async def async_search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息异步版本
@@ -1452,7 +1471,7 @@ class DoubanModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 媒体信息
"""
if not is_media_source_enabled(media_source, "douban"):
if not is_media_source_enabled(media_source, MediaSource.Douban):
return None
if not meta.name:
return []
@@ -1463,7 +1482,7 @@ class DoubanModule(_ModuleBase):
return self._build_search_medias_result(meta, result.get("items"))
def search_persons(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaPerson]]:
"""
搜索人物信息
@@ -1471,7 +1490,7 @@ class DoubanModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 人物信息列表
"""
if not is_media_source_enabled(media_source, "douban"):
if not is_media_source_enabled(media_source, MediaSource.Douban):
return None
if not name:
return []
@@ -1488,7 +1507,7 @@ class DoubanModule(_ModuleBase):
return []
async def async_search_persons(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaPerson]]:
"""
搜索人物信息异步版本
@@ -1496,7 +1515,7 @@ class DoubanModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 人物信息列表
"""
if not is_media_source_enabled(media_source, "douban"):
if not is_media_source_enabled(media_source, MediaSource.Douban):
return None
if not name:
return []
+6 -2
View File
@@ -1150,7 +1150,9 @@ class Emby:
eventItem.item_id = message.get('Item', {}).get('Id')
eventItem.item_path = message.get('Item', {}).get('Path')
eventItem.tmdb_id = message.get('Item', {}).get('ProviderIds', {}).get('Tmdb')
eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids(
message.get('Item', {}).get('ProviderIds')
)
if message.get('Item', {}).get('Overview') and len(message.get('Item', {}).get('Overview')) > 100:
eventItem.overview = str(message.get('Item', {}).get('Overview'))[:100] + "..."
else:
@@ -1171,7 +1173,9 @@ class Emby:
eventItem.item_type = message.get("item_type")
eventItem.item_name = message.get("item_name")
eventItem.item_path = message.get("item_path")
eventItem.tmdb_id = message.get("tmdb_id")
eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids({
"tmdb_id": message.get("tmdb_id"),
})
eventItem.season_id = message.get("season_id")
eventItem.episode_id = message.get("episode_id")
+5 -1
View File
@@ -825,7 +825,11 @@ class Jellyfin:
channel="jellyfin"
)
eventItem.item_id = message.get('ItemId')
eventItem.tmdb_id = message.get('Provider_tmdb')
eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids({
key.removeprefix("Provider_"): value
for key, value in message.items()
if key.startswith("Provider_")
})
eventItem.overview = message.get('Overview')
eventItem.item_favorite = message.get('Favorite')
eventItem.save_reason = message.get('SaveReason')
+26 -12
View File
@@ -24,6 +24,7 @@ from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaRecognizeType,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
)
@@ -123,7 +124,7 @@ class MusicBrainzModule(_ModuleBase):
return "MusicBrainz"
@staticmethod
def get_music_source() -> str:
def get_music_source() -> MediaSource:
"""返回音乐识别使用的数据源标识。"""
return MusicBrainzModule._source
@@ -146,7 +147,7 @@ class MusicBrainzModule(_ModuleBase):
self,
meta: MetaMusic,
limit: int = 20,
media_source: Optional[str] = None,
media_source: Optional[MediaSourceSelection] = None,
) -> Optional[list[MusicInfo]]:
"""搜索单曲、专辑和艺术家,并交错返回可浏览的 MusicBrainz 候选。"""
if not is_media_source_selected(media_source, self._source):
@@ -747,7 +748,7 @@ class MusicBrainzModule(_ModuleBase):
self,
meta: MetaBase = None,
mtype: MediaType = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
@@ -846,7 +847,7 @@ class MusicBrainzModule(_ModuleBase):
self,
meta: MetaBase = None,
mtype: MediaType = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
@@ -902,7 +903,12 @@ class MusicBrainzModule(_ModuleBase):
return result
@classmethod
def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], media_source: str) -> Optional[MusicInfo]:
def _select_candidate(
cls,
meta: MetaMusic,
candidates: Iterable[MusicInfo],
media_source: MediaSource,
) -> Optional[MusicInfo]:
"""按标题、艺术家和专辑匹配度选择最可信的搜索候选。"""
normalized_source = cls._normalize_text(media_source).casefold()
# 资源标题携带的音质标记先剥离,再与候选曲名比对;
@@ -1144,7 +1150,7 @@ class MusicBrainzModule(_ModuleBase):
def recognize_music(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
@@ -1169,7 +1175,7 @@ class MusicBrainzModule(_ModuleBase):
async def async_recognize_music(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
@@ -1193,7 +1199,7 @@ class MusicBrainzModule(_ModuleBase):
async def _async_music_album(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
) -> Optional[MusicAlbumInfo]:
"""异步按 MusicBrainz Release Group ID 获取专辑详情及曲目。"""
@@ -1218,7 +1224,11 @@ class MusicBrainzModule(_ModuleBase):
)
return album
def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
def music_album(
self,
media_source: MediaSource,
media_id: str,
) -> Optional[MusicAlbumInfo]:
"""按 MusicBrainz Release Group ID 获取标准化专辑详情及曲目。"""
if media_source != self._source or not media_id:
return None
@@ -1238,7 +1248,11 @@ class MusicBrainzModule(_ModuleBase):
album.tracks = self._album_tracks(album, payload.get("releases") or [])
return album
def music_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]:
def music_artist(
self,
media_source: MediaSource,
media_id: str,
) -> Optional[MusicArtistInfo]:
"""按 MusicBrainz Artist ID 获取标准化艺术家详情。"""
if media_source != self._source or not media_id:
return None
@@ -1250,7 +1264,7 @@ class MusicBrainzModule(_ModuleBase):
def music_artist_albums(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
page: int = 1,
count: int = 30,
@@ -1281,7 +1295,7 @@ class MusicBrainzModule(_ModuleBase):
def music_artist_related(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
count: int = 24,
) -> list[MusicArtistInfo]:
+72 -38
View File
@@ -15,6 +15,7 @@ from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaRecognizeType,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
)
@@ -50,7 +51,7 @@ class TheAudioDbModule(_ModuleBase):
return "TheAudioDB"
@staticmethod
def get_music_source() -> str:
def get_music_source() -> MediaSource:
"""返回音乐识别使用的数据源标识。"""
return TheAudioDbModule._source
@@ -73,7 +74,7 @@ class TheAudioDbModule(_ModuleBase):
self,
meta: MetaMusic,
limit: int = 20,
media_source: Optional[str] = None,
media_source: Optional[MediaSourceSelection] = None,
) -> Optional[list[MusicInfo]]:
"""按请求来源搜索 TheAudioDB 单曲、专辑和艺术家。"""
if not is_media_source_selected(media_source, self._source):
@@ -93,7 +94,7 @@ class TheAudioDbModule(_ModuleBase):
self,
meta: MetaBase = None,
mtype: MediaType = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
@@ -131,7 +132,7 @@ class TheAudioDbModule(_ModuleBase):
self,
meta: MetaBase = None,
mtype: MediaType = None,
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
@@ -171,7 +172,7 @@ class TheAudioDbModule(_ModuleBase):
def recognize_music(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
@@ -190,7 +191,7 @@ class TheAudioDbModule(_ModuleBase):
async def async_recognize_music(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
@@ -209,7 +210,7 @@ class TheAudioDbModule(_ModuleBase):
async def _async_music_album(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
) -> Optional[MusicAlbumInfo]:
"""异步按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
@@ -228,7 +229,11 @@ class TheAudioDbModule(_ModuleBase):
]
return album
def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
def music_album(
self,
media_source: MediaSource,
media_id: str,
) -> Optional[MusicAlbumInfo]:
"""按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
if media_source != self._source or not media_id:
return None
@@ -245,7 +250,11 @@ class TheAudioDbModule(_ModuleBase):
]
return album
def music_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]:
def music_artist(
self,
media_source: MediaSource,
media_id: str,
) -> Optional[MusicArtistInfo]:
"""按 TheAudioDB 艺术家 ID 获取标准化艺术家详情。"""
if media_source != self._source or not media_id:
return None
@@ -255,7 +264,7 @@ class TheAudioDbModule(_ModuleBase):
def music_artist_albums(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
page: int = 1,
count: int = 30,
@@ -277,7 +286,7 @@ class TheAudioDbModule(_ModuleBase):
def music_album_related(
self,
media_source: str,
media_source: MediaSource,
media_id: str,
count: int = 24,
) -> Optional[list[MusicInfo]]:
@@ -304,11 +313,10 @@ class TheAudioDbModule(_ModuleBase):
def _search_tracks(self, meta: MetaMusic) -> list[MusicInfo]:
"""使用曲名和艺术家搜索 TheAudioDB 单曲。"""
title = meta.title
if not title:
artist = meta.artists[0] if meta.artists else meta.album_artist
if not title or not artist:
return []
params = {"t": title}
if meta.artists:
params["s"] = meta.artists[0]
params = {"t": title, "s": artist}
payload = self._request_json("searchtrack.php", params)
return [
info
@@ -319,11 +327,10 @@ class TheAudioDbModule(_ModuleBase):
async def _async_search_tracks(self, meta: MetaMusic) -> list[MusicInfo]:
"""异步使用曲名和艺术家搜索 TheAudioDB 单曲。"""
title = meta.title
if not title:
artist = meta.artists[0] if meta.artists else meta.album_artist
if not title or not artist:
return []
params = {"t": title}
if meta.artists:
params["s"] = meta.artists[0]
params = {"t": title, "s": artist}
payload = await self._async_request_json("searchtrack.php", params)
return [
info
@@ -334,11 +341,10 @@ class TheAudioDbModule(_ModuleBase):
def _search_albums(self, meta: MetaMusic) -> list[MusicAlbumInfo]:
"""使用专辑名和艺术家搜索 TheAudioDB 专辑。"""
album_name = meta.album or meta.title
if not album_name:
artist = meta.artists[0] if meta.artists else meta.album_artist
if not album_name or not artist:
return []
params = {"a": album_name}
if meta.artists:
params["s"] = meta.artists[0]
params = {"a": album_name, "s": artist}
payload = self._request_json("searchalbum.php", params)
return [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
@@ -348,11 +354,10 @@ class TheAudioDbModule(_ModuleBase):
) -> list[MusicAlbumInfo]:
"""异步使用专辑名和艺术家搜索 TheAudioDB 专辑。"""
album_name = meta.album or meta.title
if not album_name:
artist = meta.artists[0] if meta.artists else meta.album_artist
if not album_name or not artist:
return []
params = {"a": album_name}
if meta.artists:
params["s"] = meta.artists[0]
params = {"a": album_name, "s": artist}
payload = await self._async_request_json("searchalbum.php", params)
return [
self._album_to_info(item)
@@ -564,14 +569,25 @@ class TheAudioDbModule(_ModuleBase):
url=f"{cls._base_url}/{api_key}/{endpoint}",
params=params or {},
)
if not response or response.status_code != 200:
if response is None:
return None
try:
payload = response.json()
except ValueError as err:
logger.error(f"TheAudioDB 响应解析失败:{str(err)}")
return None
return payload if isinstance(payload, dict) else None
if response.status_code != 200:
return None
diagnostic = cls._response_diagnostic(response, endpoint)
if getattr(response, "content", None) in (b"", ""):
logger.warning(f"TheAudioDB 返回空响应:{diagnostic}")
return None
try:
payload = response.json()
except (TypeError, ValueError) as err:
logger.warning(
f"TheAudioDB 响应解析失败:{diagnostic},错误:{str(err)}"
)
return None
return payload if isinstance(payload, dict) else None
finally:
response.close()
@classmethod
@cached(
@@ -603,13 +619,31 @@ class TheAudioDbModule(_ModuleBase):
try:
if response.status_code != 200:
return None
payload = response.json()
except ValueError as err:
logger.error(f"TheAudioDB 响应解析失败:{str(err)}")
return None
diagnostic = cls._response_diagnostic(response, endpoint)
if getattr(response, "content", None) in (b"", ""):
logger.warning(f"TheAudioDB 返回空响应:{diagnostic}")
return None
try:
payload = response.json()
except (TypeError, ValueError) as err:
logger.warning(
f"TheAudioDB 响应解析失败:{diagnostic},错误:{str(err)}"
)
return None
return payload if isinstance(payload, dict) else None
finally:
await response.aclose()
return payload if isinstance(payload, dict) else None
@staticmethod
def _response_diagnostic(response: Any, endpoint: str) -> str:
"""生成不包含 API Key 的 TheAudioDB 响应诊断摘要。"""
headers = getattr(response, "headers", {}) or {}
content_type = headers.get("Content-Type", "") if hasattr(headers, "get") else ""
body = str(getattr(response, "text", "") or "").replace("\n", " ")[:200]
return (
f"endpoint={endpoint}, HTTP={getattr(response, 'status_code', '')}, "
f"Content-Type={content_type}, body={body!r}"
)
@staticmethod
def _entities(
+21 -15
View File
@@ -18,11 +18,16 @@ from app.schemas.types import (
MediaImageType,
MediaRecognizeType,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
)
from app.utils.http import RequestUtils
from app.utils.media import is_media_source_enabled, is_media_source_selected
from app.utils.media import (
is_media_source_enabled,
is_media_source_selected,
normalize_media_source,
)
from app.utils.zhconv import convert as zhconv_convert
@@ -102,7 +107,7 @@ class TheMovieDbModule(_ModuleBase):
def _validate_recognize_params(
meta: MetaBase,
tmdbid: Optional[int],
media_source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
) -> bool:
"""
验证识别参数
@@ -115,7 +120,8 @@ class TheMovieDbModule(_ModuleBase):
if not tmdbid and not meta:
return False
if meta and not tmdbid and (media_source or settings.RECOGNIZE_SOURCE) != "themoviedb":
selected_source = normalize_media_source(media_source or settings.RECOGNIZE_SOURCE)
if meta and not tmdbid and selected_source != MediaSource.TMDB:
return False
if meta and not meta.name and not tmdbid:
@@ -769,7 +775,7 @@ class TheMovieDbModule(_ModuleBase):
}
def search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
@@ -777,7 +783,7 @@ class TheMovieDbModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 媒体信息列表
"""
if not is_media_source_enabled(media_source, "themoviedb"):
if not is_media_source_enabled(media_source, MediaSource.TMDB):
return None
if not meta.name:
return []
@@ -801,7 +807,7 @@ class TheMovieDbModule(_ModuleBase):
return self._build_search_medias_result(meta, results)
def search_persons(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[schemas.MediaPerson]]:
"""
搜索人物信息
@@ -809,7 +815,7 @@ class TheMovieDbModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 人物信息列表
"""
if not is_media_source_enabled(media_source, "themoviedb"):
if not is_media_source_enabled(media_source, MediaSource.TMDB):
return None
if not name:
return []
@@ -819,7 +825,7 @@ class TheMovieDbModule(_ModuleBase):
return []
async def async_search_persons(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[schemas.MediaPerson]]:
"""
异步搜索人物信息
@@ -827,7 +833,7 @@ class TheMovieDbModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 人物信息列表
"""
if not is_media_source_enabled(media_source, "themoviedb"):
if not is_media_source_enabled(media_source, MediaSource.TMDB):
return None
if not name:
return []
@@ -837,7 +843,7 @@ class TheMovieDbModule(_ModuleBase):
return []
def search_collections(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索集合信息
@@ -845,7 +851,7 @@ class TheMovieDbModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 合集信息列表
"""
if media_source and not is_media_source_selected(media_source, "themoviedb"):
if media_source and not is_media_source_selected(media_source, MediaSource.TMDB):
return None
if not name:
return []
@@ -855,7 +861,7 @@ class TheMovieDbModule(_ModuleBase):
return []
async def async_search_collections(
self, name: str, media_source: Optional[MediaSource] = None
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
异步搜索集合信息
@@ -863,7 +869,7 @@ class TheMovieDbModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 合集信息列表
"""
if media_source and not is_media_source_selected(media_source, "themoviedb"):
if media_source and not is_media_source_selected(media_source, MediaSource.TMDB):
return None
if not name:
return []
@@ -1251,7 +1257,7 @@ class TheMovieDbModule(_ModuleBase):
# 异步方法
async def async_search_medias(
self, meta: MetaBase, media_source: Optional[MediaSource] = None
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息异步版本
@@ -1259,7 +1265,7 @@ class TheMovieDbModule(_ModuleBase):
:param media_source: 请求级搜索数据源
:return: 媒体信息列表
"""
if not is_media_source_enabled(media_source, "themoviedb"):
if not is_media_source_enabled(media_source, MediaSource.TMDB):
return None
if not meta.name:
return []
+6 -2
View File
@@ -1026,7 +1026,9 @@ class ZSpace:
event_item.item_id = message.get('Item', {}).get('Id')
event_item.item_path = message.get('Item', {}).get('Path')
event_item.tmdb_id = message.get('Item', {}).get('ProviderIds', {}).get('Tmdb')
event_item.media_source, event_item.media_id = MediaServerIdentityHelper.from_provider_ids(
message.get('Item', {}).get('ProviderIds')
)
if message.get('Item', {}).get('Overview') and len(message.get('Item', {}).get('Overview')) > 100:
event_item.overview = str(message.get('Item', {}).get('Overview'))[:100] + "..."
else:
@@ -1047,7 +1049,9 @@ class ZSpace:
event_item.item_type = message.get("item_type")
event_item.item_name = message.get("item_name")
event_item.item_path = message.get("item_path")
event_item.tmdb_id = message.get("tmdb_id")
event_item.media_source, event_item.media_id = MediaServerIdentityHelper.from_provider_ids({
"tmdb_id": message.get("tmdb_id"),
})
event_item.season_id = message.get("season_id")
event_item.episode_id = message.get("episode_id")
+6 -5
View File
@@ -3,10 +3,11 @@ from typing import Optional, Dict, List, Union, Any
from pydantic import BaseModel, Field
from app.schemas.music import MusicInfo, MusicMeta
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource
class MetaInfo(BaseModel):
class MetaInfo(OptionalMediaIdentityMixin, BaseModel):
"""
识别元数据
"""
@@ -67,17 +68,17 @@ class MetaInfo(BaseModel):
# 剧集组
episode_group: Optional[str] = None
# 显式媒体数据源
media_source: Optional[Union[MediaSource, str]] = None
media_source: Optional[MediaSource] = None
# 显式媒体数据源原生ID
media_id: Optional[str] = None
class MediaInfo(BaseModel):
class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
"""
识别媒体信息
"""
# 媒体主身份来源
media_source: Optional[Union[MediaSource, str]] = None
media_source: Optional[MediaSource] = None
# 请求级刮削来源
scrape_source: Optional[str] = None
# 类型 电影、电视剧、合集
@@ -188,7 +189,7 @@ class MediaInfo(BaseModel):
episode_group: Optional[str] = None
class TorrentInfo(BaseModel):
class TorrentInfo(OptionalMediaIdentityMixin, BaseModel):
"""
搜索种子信息
"""
+3 -2
View File
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator
from app.schemas.message import MessageChannel
from app.schemas.file import FileItem
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
from app.schemas.types import MediaSource
@@ -532,7 +533,7 @@ class RecommendSourceEventData(ChainEventData):
)
class MediaRecognizeConvertEventData(ChainEventData):
class MediaRecognizeConvertEventData(RequiredMediaIdentityMixin, ChainEventData):
"""
MediaRecognizeConvert 事件的数据模型
@@ -576,7 +577,7 @@ class StorageOperSelectionEventData(ChainEventData):
storage_oper: Optional[Callable] = Field(default=None, description="存储操作对象")
class SubscribeEpisodesRefreshEventData(ChainEventData):
class SubscribeEpisodesRefreshEventData(OptionalMediaIdentityMixin, ChainEventData):
"""
SubscribeEpisodesRefresh 事件的数据模型
+3 -2
View File
@@ -2,10 +2,11 @@ from typing import Optional, Any
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource
class DownloadHistory(BaseModel):
class DownloadHistory(OptionalMediaIdentityMixin, BaseModel):
"""
下载历史记录
"""
@@ -60,7 +61,7 @@ class DownloadHistory(BaseModel):
model_config = ConfigDict(from_attributes=True)
class TransferHistory(BaseModel):
class TransferHistory(OptionalMediaIdentityMixin, BaseModel):
"""
文件整理历史记录
"""
+38
View File
@@ -0,0 +1,38 @@
from pydantic import model_validator
class OptionalMediaIdentityMixin:
"""为可选媒体身份模型统一校验来源枚举与原生 ID 的成对约束。"""
@model_validator(mode="after")
def _validate_optional_media_identity(self):
"""规范化 ID,并拒绝显式半对、空白或零值身份。"""
source_provided = "media_source" in self.model_fields_set
id_provided = "media_id" in self.model_fields_set
if source_provided != id_provided:
raise ValueError("media_source 和 media_id 必须同时提供")
normalized_id = (
str(self.media_id).strip()
if self.media_id is not None
else None
)
if bool(self.media_source) != bool(normalized_id):
raise ValueError("media_source 和 media_id 必须同时提供")
if normalized_id == "0":
raise ValueError("media_id 不能为 0")
# 校验器内部的规范化不能伪装成请求显式提交字段,否则 PATCH 会误清空存量身份。
object.__setattr__(self, "media_id", normalized_id)
return self
class RequiredMediaIdentityMixin:
"""为必填媒体身份模型统一校验来源枚举与原生 ID。"""
@model_validator(mode="after")
def _validate_required_media_identity(self):
"""去除 ID 两端空白,并拒绝空白或零值身份。"""
normalized_id = str(self.media_id).strip()
if not normalized_id or normalized_id == "0":
raise ValueError("media_id 必须是非零的来源原生 ID")
self.media_id = normalized_id
return self
+53 -3
View File
@@ -1,8 +1,9 @@
from pathlib import Path
from typing import Optional, Dict, Union, List, Any
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, model_validator
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource, MediaType
@@ -99,7 +100,7 @@ class MediaServerItemUserState(BaseModel):
percentage: Optional[float] = None
class MediaServerItem(BaseModel):
class MediaServerItem(OptionalMediaIdentityMixin, BaseModel):
"""
媒体服务器媒体信息
"""
@@ -158,7 +159,8 @@ class WebhookEventInfo(BaseModel):
item_path: Optional[str] = None
season_id: Optional[str] = None
episode_id: Optional[str] = None
tmdb_id: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
overview: Optional[str] = None
percentage: Optional[float] = None
ip: Optional[str] = None
@@ -172,6 +174,54 @@ class WebhookEventInfo(BaseModel):
media_type: Optional[str] = None
json_object: Optional[dict] = Field(default_factory=dict)
@model_validator(mode="before")
@classmethod
def _migrate_legacy_tmdb_identity(cls, data: Any) -> Any:
"""在旧事件输入边界把 tmdb_id 迁移为统一媒体身份。"""
if not isinstance(data, dict):
return data
if data.get("media_source") is not None or data.get("media_id") is not None:
migrated = dict(data)
migrated.pop("tmdb_id", None)
return migrated
legacy_tmdb_id = data.get("tmdb_id")
if legacy_tmdb_id in (None, ""):
return data
migrated = dict(data)
migrated["media_source"] = MediaSource.TMDB
migrated["media_id"] = str(legacy_tmdb_id)
migrated.pop("tmdb_id", None)
return migrated
@model_validator(mode="after")
def _validate_media_identity(self) -> "WebhookEventInfo":
"""确保 webhook 媒体身份始终完整成对且不接受零值。"""
normalized_id = str(self.media_id).strip() if self.media_id is not None else None
if bool(self.media_source) != bool(normalized_id):
raise ValueError("media_source 和 media_id 必须同时提供")
if normalized_id == "0":
raise ValueError("media_id 不能为 0")
self.media_id = normalized_id
return self
@property
def tmdb_id(self) -> Optional[str]:
"""兼容旧插件读取 TMDB 身份;新事件输出不再包含该字段。"""
if self.media_source == MediaSource.TMDB:
return self.media_id
return None
@tmdb_id.setter
def tmdb_id(self, value: Optional[Union[str, int]]) -> None:
"""兼容旧插件写入 TMDB 身份,并同步为统一字段。"""
if value in (None, ""):
if self.media_source == MediaSource.TMDB:
self.media_source = None
self.media_id = None
return
self.media_source = MediaSource.TMDB
self.media_id = str(value)
class MediaServerPlayItem(BaseModel):
"""
+8 -7
View File
@@ -2,10 +2,11 @@ from typing import Any, Literal, Optional, Union
from pydantic import BaseModel, Field
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
from app.schemas.types import MediaSource, MusicEntityType, MusicTargetEntityType
class MusicMeta(BaseModel):
class MusicMeta(OptionalMediaIdentityMixin, BaseModel):
"""音乐名称及音频文件解析结果。"""
type: Literal["音乐"] = "音乐"
@@ -31,17 +32,17 @@ class MusicMeta(BaseModel):
bitrate: Optional[int] = None
duration: Optional[int] = None
isrc: Optional[str] = None
media_source: Optional[Union[MediaSource, str]] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
class MusicInfo(BaseModel):
class MusicInfo(OptionalMediaIdentityMixin, BaseModel):
"""标准化音乐元数据信息。"""
type: Literal["音乐"] = "音乐"
# 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
music_type: MusicEntityType = "recording"
media_source: Optional[Union[MediaSource, str]] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
title: Optional[str] = None
artists: list[str] = Field(default_factory=list)
@@ -97,7 +98,7 @@ class MusicRelease(BaseModel):
cover_url: Optional[str] = None
class MusicAlbumInfo(BaseModel):
class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel):
"""标准化音乐专辑信息。"""
type: Literal["音乐"] = "音乐"
@@ -132,7 +133,7 @@ class MusicAlbumInfo(BaseModel):
vote_average: float = 0.0
class MusicArtistInfo(BaseModel):
class MusicArtistInfo(OptionalMediaIdentityMixin, BaseModel):
"""标准化音乐艺术家信息。"""
type: Literal["音乐"] = "音乐"
@@ -164,7 +165,7 @@ class MusicArtistInfo(BaseModel):
overview: Optional[str] = None
class MusicRecognizeRequest(BaseModel):
class MusicRecognizeRequest(RequiredMediaIdentityMixin, BaseModel):
"""音乐元数据详情识别请求。"""
media_source: MediaSource
+19 -8
View File
@@ -2,6 +2,7 @@ from typing import Optional, List, Dict, Any, ClassVar
from pydantic import BaseModel, Field, ConfigDict, model_validator
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource, MediaType
@@ -43,7 +44,9 @@ def compute_subscribe_completed_episode(subscribe: "Subscribe") -> Optional[int]
return min(max(start_episode - 1, 0), total_episode) + priority_completed
class Subscribe(BaseModel):
class Subscribe(OptionalMediaIdentityMixin, BaseModel):
"""订阅输入与响应模型,媒体身份必须为空对或完整有效对。"""
# 公共创建和更新接口不得接收系统字段和运行事实;其余字段默认作为订阅输入透传。
PUBLIC_WRITE_EXCLUDED_FIELDS: ClassVar[frozenset[str]] = frozenset({
"id", "poster", "backdrop", "vote", "description", "lack_episode", "completed_episode",
@@ -155,17 +158,22 @@ class Subscribe(BaseModel):
@classmethod
def _normalize_empty_strings(cls, data: Any) -> Any:
"""
将前端清空输入框后残留的空字符串视为未提供移除该键由字段默认值兜底
将前端清空输入框后残留的空字符串视为空值
音乐等媒体类型的 seasontotal_episodeepisode_priority 等数值或容器字段
在表单中常以空字符串提交 Pydantic 不会把空字符串自动转为 None会直接抛出
校验异常导致接口返回 422这里把空字符串键移除等价于该字段未提供从而复用字段
默认值 ``total_episode`` 回退为 0``sites`` 回退为空列表
默认值 ``total_episode`` 回退为 0``sites`` 回退为空列表媒体身份键保留为
None以便更新接口区分未提交显式清空完整身份对
"""
if isinstance(data, dict):
data = dict(data)
for key, value in list(data.items()):
if isinstance(value, str) and value == "":
data.pop(key)
if key in {"media_source", "media_id"}:
data[key] = None
else:
data.pop(key)
return data
@model_validator(mode="after")
@@ -180,12 +188,15 @@ class Subscribe(BaseModel):
self.completed_episode = compute_subscribe_completed_episode(self)
return self
def to_public_write_payload(self) -> Dict[str, Any]:
"""裁剪公共订阅写入字段,避免请求体覆盖下载事实和运行状态"""
return self.model_dump(exclude=self.PUBLIC_WRITE_EXCLUDED_FIELDS)
def to_public_write_payload(self, *, exclude_unset: bool = False) -> Dict[str, Any]:
"""裁剪公共订阅写入字段,可仅保留更新请求显式提交的字段"""
return self.model_dump(
exclude=self.PUBLIC_WRITE_EXCLUDED_FIELDS,
exclude_unset=exclude_unset,
)
class SubscribeShare(BaseModel):
class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):
# 分享ID
id: Optional[int] = None
# 订阅ID
+3 -2
View File
@@ -3,6 +3,7 @@ from typing import Any, Callable, List, Optional, Union
from pydantic import BaseModel, Field
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource, MusicTargetEntityType
from app.schemas.context import MetaInfo, MediaInfo
@@ -58,7 +59,7 @@ class DownloadingTorrent(DownloaderTorrent):
"""
class TransferTask(BaseModel):
class TransferTask(OptionalMediaIdentityMixin, BaseModel):
"""
文件整理任务
"""
@@ -199,7 +200,7 @@ class EpisodeFormatRecommendItem(BaseModel):
fileitems: Optional[List[FileItem]] = None
class ManualTransferItem(BaseModel):
class ManualTransferItem(OptionalMediaIdentityMixin, BaseModel):
"""手动整理请求,媒体身份只接受来源枚举与原生 ID。"""
# 文件项
+5 -1
View File
@@ -1,5 +1,5 @@
from enum import Enum
from typing import Literal, Optional
from typing import Literal, Optional, Tuple, Union
# 音乐实体命名空间由公共类型模块统一持有,避免模型、接口和工具层重复定义。
@@ -68,6 +68,10 @@ class MediaSource(str, Enum):
return self.value
# 搜索可以选择一个或多个来源,但集合中的每一项都必须是固定枚举。
MediaSourceSelection = Union[MediaSource, Tuple[MediaSource, ...]]
def media_type_to_agent(value) -> Optional[str]:
"""将枚举、Agent 键或数据库枚举值统一转换为 Agent 媒体类型。"""
if isinstance(value, MediaType):
+17 -3
View File
@@ -182,6 +182,18 @@ def prepare_v2_backend(plugins_repo: Path) -> None:
_prepend_sys_path(Path(plugins_repo) / "plugins.v2")
def prepare_v3_backend(plugins_repo: Path) -> None:
"""v3 插件单测引导:``prepare_backend`` + 把 ``<repo>/plugins.v3`` 注入 ``sys.path``。
v3 插件与旧代插件可能存在同名包必须在独立 pytest 会话中加载避免 Python 模块
缓存把其它代际实现复用到当前测试进程
:param plugins_repo: 插件仓根目录由调用方 shim 传入
"""
prepare_backend()
_prepend_sys_path(Path(plugins_repo) / "plugins.v3")
def prepare_v1_backend(plugins_repo: Path) -> None:
"""v1 插件单测引导:``prepare_backend`` + 把 ``<repo>/plugins`` 注入 ``sys.path``(与 v2 互斥)。
@@ -192,10 +204,10 @@ def prepare_v1_backend(plugins_repo: Path) -> None:
def mark_plugin_generation(items, pytest_module) -> None:
"""按用例所在目录自动给其打 ``v1`` / ``v2`` marker,供按代筛选与分会话运行。
"""按用例所在目录自动给其打 ``v1`` / ``v2`` / ``v3`` marker,供按代筛选与分会话运行。
优先读取 pytest 7+ ``item.path``旧版 pytest 缺失该属性时回退到 ``item.fspath``
不带前导斜杠的子串匹配``tests/v2/`` / ``tests/v1/``兼容相对路径与绝对路径两种
不带前导斜杠的子串匹配兼容相对路径与绝对路径两种
运行方式 ``pytest tests/v2`` 等相对路径运行时收集路径可能不含前导斜杠
``pytest`` 模块由各仓 conftest 传入避免本模块在非测试态强依赖 pytest
@@ -205,7 +217,9 @@ def mark_plugin_generation(items, pytest_module) -> None:
for item in items:
item_path = getattr(item, "path", None)
path = str(item_path if item_path is not None else item.fspath).replace("\\", "/")
if "tests/v2/" in path:
if "tests/v3/" in path:
item.add_marker(pytest_module.mark.v3)
elif "tests/v2/" in path:
item.add_marker(pytest_module.mark.v2)
elif "tests/v1/" in path:
item.add_marker(pytest_module.mark.v1)
+73 -20
View File
@@ -5,6 +5,7 @@ from app.schemas.types import (
MUSIC_ENTITY_TYPES,
MUSIC_SUBSCRIBABLE_TYPES,
MediaSource,
MediaSourceSelection,
)
MEDIA_SOURCE_ALIASES = {
@@ -83,48 +84,73 @@ def normalize_media_source(
return MEDIA_SOURCE_ALIASES.get(normalized)
def parse_media_source_selection(value: Optional[str]) -> Tuple[MediaSource, ...]:
"""
解析 HTTP 查询参数中的逗号分隔来源并转换为有序枚举集合
:param value: 逗号分隔的来源值空值表示未显式选择来源
:return: 去重后的媒体来源枚举元组
:raises ValueError: 包含固定枚举之外的来源
"""
if not value:
return ()
sources: list[MediaSource] = []
invalid_sources: list[str] = []
for item in str(value).split(","):
raw_source = item.strip()
if not raw_source:
continue
source = normalize_media_source(raw_source)
if not source:
invalid_sources.append(raw_source)
elif source not in sources:
sources.append(source)
if invalid_sources:
raise ValueError(f"不支持的媒体数据源:{', '.join(invalid_sources)}")
return tuple(sources)
def is_media_source_selected(
media_source: Optional[Union[MediaSource, str]],
source_key: Union[MediaSource, str],
media_source: Optional[MediaSourceSelection],
source_key: MediaSource,
) -> bool:
"""
判断请求级媒体数据源集合是否包含当前模块
:param media_source: 请求级媒体数据源支持逗号分隔空表示不作限制
:param media_source: 请求级媒体数据源枚举或枚举元组空表示不作限制
:param source_key: 当前模块对应的数据源标识
:return: 是否包含
"""
if not media_source:
return True
normalized_key = normalize_media_source(source_key)
selected_sources = {
normalize_media_source(item)
for item in str(media_source).split(",")
}
return bool(normalized_key and normalized_key in selected_sources)
selected_sources = (
(media_source,)
if isinstance(media_source, MediaSource)
else media_source
)
return source_key in selected_sources
def is_media_source_enabled(
media_source: Optional[Union[MediaSource, str]],
source_key: Union[MediaSource, str],
media_source: Optional[MediaSourceSelection],
source_key: MediaSource,
) -> bool:
"""
判断媒体搜索时数据源是否启用请求级来源集合优先未指定时回退到
全局 SEARCH_SOURCE 多来源配置两者均未配置时全部启用
:param media_source: 请求级媒体数据源支持逗号分隔
:param media_source: 请求级媒体数据源枚举或枚举元组
:param source_key: 当前模块对应的数据源标识
:return: 是否启用
"""
if media_source:
return is_media_source_selected(media_source, source_key)
if settings.SEARCH_SOURCE:
normalized_key = normalize_media_source(source_key)
configured_sources = {
normalize_media_source(item)
for item in str(settings.SEARCH_SOURCE).split(",")
}
return normalized_key in configured_sources
return source_key in configured_sources
return True
@@ -137,7 +163,7 @@ def parse_media_key(
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:
if not source or not media_id or media_id == "0":
return None, None
return source, media_id
@@ -157,8 +183,9 @@ def resolve_media_identity(
"""
normalized_source = normalize_media_source(media_source)
if media_source is not None or media_id is not None:
if normalized_source and media_id is not None and str(media_id).strip():
return normalized_source, str(media_id).strip()
normalized_id = str(media_id).strip() if media_id is not None else ""
if normalized_source and normalized_id and normalized_id != "0":
return normalized_source, normalized_id
return None, None
if media is None:
@@ -175,18 +202,44 @@ def resolve_media_identity(
)
if normalized_source and object_media_id is not None:
normalized_id = str(object_media_id).strip()
if normalized_id:
if normalized_id and normalized_id != "0":
return normalized_source, normalized_id
return None, None
def normalize_media_identity_payload(
payload: dict[str, Any],
*,
include_empty: bool = False,
) -> dict[str, Any]:
"""
规范化字典中的媒体身份保证来源与 ID 始终成对写入
:param payload: 待写入或传输的字段字典
:param include_empty: 字典未声明身份字段时是否仍补充空身份
:return: 复制后的规范字典非法半对或零值身份会被清空
"""
normalized = dict(payload)
has_identity = "media_source" in normalized or "media_id" in normalized
if not has_identity and not include_empty:
return normalized
media_source, media_id = resolve_media_identity(
media_source=normalized.get("media_source"),
media_id=normalized.get("media_id"),
)
normalized["media_source"] = media_source.value if media_source else None
normalized["media_id"] = media_id
return normalized
def build_media_key(
media_source: Optional[Union[MediaSource, str]],
media_id: Optional[Any],
) -> str:
"""构造 API 使用的带来源前缀媒体键。"""
normalized_source = normalize_media_source(media_source)
if not normalized_source or media_id is None or not str(media_id).strip():
normalized_id = str(media_id).strip() if media_id is not None else ""
if not normalized_source or not normalized_id or normalized_id == "0":
return ""
prefix = MEDIA_SOURCE_PREFIXES[normalized_source]
return f"{prefix}:{str(media_id).strip()}"
return f"{prefix}:{normalized_id}"
+47
View File
@@ -201,6 +201,32 @@ def parse_metainfo_path(path: str, options: Optional[dict] = None) -> Optional[d
return None
def parse_metamusic(
title: str,
artists: Optional[List[str]] = None,
year: Optional[int] = None,
) -> Optional[dict]:
"""使用 Rust 解析音乐资源标题,旧扩展不支持时返回 None。
:param title: 音乐资源标题或文件主干名
:param artists: 调用方已有的高可信艺术家列表
:param year: 调用方已有的高可信发行年份
:return: Rust 解析字段不可用不支持或异常时返回 None
"""
if not is_enabled():
return None
parser = getattr(_moviepilot_rust, "parse_metamusic_fast", None)
if not callable(parser):
return None
try:
result = parser(title, artists, year)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"Rust MetaMusic解析失败,使用 Python 解析兜底:{err}")
return None
return result if isinstance(result, dict) and result else None
def find_metainfo(title: str) -> Optional[dict]:
"""
使用 Rust 提取标题中的显式媒体标签不可用或异常时返回 None
@@ -238,6 +264,27 @@ def supports_extended_media_ids() -> bool:
)
@lru_cache(maxsize=1)
def supports_unified_media_identity() -> bool:
"""判断当前 Rust 扩展是否支持固定来源的通用媒体身份标签。"""
if not is_enabled():
return False
try:
result = _moviepilot_rust.find_metainfo_fast(
"test {[media_source=musicbrainz;media_id=recording-1]}"
)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"检测 Rust 通用媒体身份能力失败:{err}")
return False
metainfo = result.get("metainfo") if isinstance(result, dict) else None
return bool(
metainfo
and metainfo.get("media_source") == "musicbrainz"
and metainfo.get("media_id") == "recording-1"
)
def _raise_non_rust_panic(err: BaseException) -> None:
"""
只吞掉 Rust 扩展 panic/异常保留用户中断和进程退出语义
+2 -1
View File
@@ -1,4 +1,5 @@
from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain
from app.chain.storage import StorageChain
from app.core.config import global_vars
from app.log import logger
@@ -74,7 +75,7 @@ class ScrapeFileAction(BaseAction):
_failed_count += 1
logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削")
continue
scrape_result = mediachain.scrape_metadata(
scrape_result = ScrapingChain().scrape_metadata(
fileitem=fileitem,
meta=media_context.meta_info,
mediainfo=media_context.media_info
@@ -1,4 +1,4 @@
"""3.0.1
"""3.0.0
统一通用媒体表的来源与原生 ID
Revision ID: 8a4c7e1d2f90
@@ -61,7 +61,24 @@ SOURCE_ALIASES = {
"audio_db": "theaudiodb",
"doubanmusic": "doubanmusic",
"douban_music": "doubanmusic",
"bilibili": "bilibili",
"mangguodiscover": "mangguodiscover",
"mango_tv": "mangguodiscover",
"migu": "migu",
"migu_video": "migu",
"tencentvideodiscover": "tencentvideodiscover",
"tencent_video": "tencentvideodiscover",
}
MEDIA_SOURCE_VALUES = frozenset(SOURCE_ALIASES.values())
MEDIA_SOURCE_SQL_VALUES = ", ".join(
f"'{source}'" for source in sorted(MEDIA_SOURCE_VALUES)
)
MEDIA_IDENTITY_CHECK_SQL = (
"(media_source IS NULL AND media_id IS NULL) OR "
"(media_source IS NOT NULL AND "
f"media_source IN ({MEDIA_SOURCE_SQL_VALUES}) AND "
"media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')"
)
def _inspector() -> sa.Inspector:
@@ -108,13 +125,45 @@ def _normalize_existing_sources(table_name: str) -> None:
)
connection = op.get_bind()
for alias, source in SOURCE_ALIASES.items():
if alias == source:
continue
connection.execute(
table.update()
.where(sa.func.lower(table.c.media_source) == alias)
.where(sa.func.lower(sa.func.trim(table.c.media_source)) == alias)
.values(media_source=source)
)
connection.execute(
table.update()
.where(table.c.media_source.is_not(None))
.values(media_source=sa.func.trim(table.c.media_source))
)
def _clear_invalid_or_partial_identity(table_name: str) -> None:
"""清空无效或仅有一半的身份,允许后续从旧字段重新回填。"""
table = sa.table(
table_name,
sa.column("media_source", sa.String()),
sa.column("media_id", sa.String()),
)
invalid_identity = sa.or_(
table.c.media_source.is_(None),
sa.func.trim(table.c.media_source) == "",
sa.func.lower(sa.func.trim(table.c.media_source)).not_in(
MEDIA_SOURCE_VALUES
),
table.c.media_id.is_(None),
sa.func.trim(table.c.media_id) == "",
sa.func.trim(table.c.media_id) == "0",
)
op.get_bind().execute(
table.update()
.where(invalid_identity)
.values(media_source=None, media_id=None)
)
op.get_bind().execute(
table.update()
.where(table.c.media_id.is_not(None))
.values(media_id=sa.func.trim(table.c.media_id))
)
def _backfill_prefixed_media_id(table_name: str, columns: set[str]) -> None:
@@ -127,22 +176,32 @@ def _backfill_prefixed_media_id(table_name: str, columns: set[str]) -> None:
sa.column("media_source", sa.String()),
sa.column("media_id", sa.String()),
)
for prefix, source in (
("tmdb", "themoviedb"),
("themoviedb", "themoviedb"),
("douban", "douban"),
("bangumi", "bangumi"),
("anilist", "anilist"),
("imdb", "imdb"),
("tvdb", "tvdb"),
):
for prefix, source in SOURCE_ALIASES.items():
op.get_bind().execute(
table.update()
.where(_identity_missing(table))
.where(table.c.mediaid.like(f"{prefix}:%"))
.where(
sa.func.lower(
sa.func.substr(
sa.func.trim(table.c.mediaid), 1, len(prefix) + 1
)
) == f"{prefix}:"
)
.where(
sa.func.trim(
sa.func.substr(table.c.mediaid, len(prefix) + 2)
) != ""
)
.where(
sa.func.trim(
sa.func.substr(table.c.mediaid, len(prefix) + 2)
) != "0"
)
.values(
media_source=source,
media_id=sa.func.substr(table.c.mediaid, len(prefix) + 2),
media_id=sa.func.trim(
sa.func.substr(table.c.mediaid, len(prefix) + 2)
),
)
)
@@ -166,10 +225,11 @@ def _backfill_source_columns(table_name: str, columns: set[str]) -> None:
table.update()
.where(_identity_missing(table))
.where(identity_column.is_not(None))
.where(sa.cast(identity_column, sa.String()) != "")
.where(sa.func.trim(sa.cast(identity_column, sa.String())) != "")
.where(sa.func.trim(sa.cast(identity_column, sa.String())) != "0")
.values(
media_source=source,
media_id=sa.cast(identity_column, sa.String()),
media_id=sa.func.trim(sa.cast(identity_column, sa.String())),
)
)
@@ -241,6 +301,41 @@ def _ensure_identity_indexes() -> None:
)
def _ensure_identity_constraints() -> None:
"""为六张通用媒体表建立来源枚举与身份成对数据库约束。"""
for table_name in LEGACY_COLUMNS:
if not _has_table(table_name):
continue
constraint_name = f"ck_{table_name}_media_identity"
existing = {
constraint.get("name")
for constraint in _inspector().get_check_constraints(table_name)
}
if constraint_name in existing:
continue
with op.batch_alter_table(table_name) as batch_op:
batch_op.create_check_constraint(
constraint_name,
MEDIA_IDENTITY_CHECK_SQL,
)
def _drop_identity_constraints() -> None:
"""降级时移除本次迁移新增的媒体身份数据库约束。"""
for table_name in LEGACY_COLUMNS:
if not _has_table(table_name):
continue
constraint_name = f"ck_{table_name}_media_identity"
existing = {
constraint.get("name")
for constraint in _inspector().get_check_constraints(table_name)
}
if constraint_name not in existing:
continue
with op.batch_alter_table(table_name) as batch_op:
batch_op.drop_constraint(constraint_name, type_="check")
def upgrade() -> None:
"""回填规范媒体身份,并删除通用表中的全部来源专用 ID 字段。"""
for table_name, legacy_columns in LEGACY_COLUMNS.items():
@@ -248,11 +343,13 @@ def upgrade() -> None:
continue
_ensure_identity_columns(table_name)
_normalize_existing_sources(table_name)
_clear_invalid_or_partial_identity(table_name)
columns = _column_names(table_name)
_backfill_prefixed_media_id(table_name, columns)
_backfill_source_columns(table_name, columns)
_drop_legacy_columns(table_name, legacy_columns)
_ensure_identity_indexes()
_ensure_identity_constraints()
def _restore_legacy_columns(table_name: str, columns: Iterable[str]) -> None:
@@ -291,6 +388,7 @@ def _restore_legacy_columns(table_name: str, columns: Iterable[str]) -> None:
def downgrade() -> None:
"""恢复旧列;已被规范身份舍弃的辅助来源 ID 无法无损恢复。"""
_drop_identity_constraints()
for table_name, legacy_columns in LEGACY_COLUMNS.items():
_restore_legacy_columns(table_name, legacy_columns)
if _has_table("mediaserveritem"):
+2 -1
View File
@@ -481,7 +481,7 @@ moviepilot tool show search_torrents
```shell
moviepilot tool run query_schedulers
moviepilot tool run search_torrents media_type=movie tmdb_id=12345
moviepilot tool run search_torrents media_type=movie media_source=themoviedb media_id=12345
```
说明:
@@ -489,6 +489,7 @@ moviepilot tool run search_torrents media_type=movie tmdb_id=12345
- `tool list` 用于动态发现当前服务可调用的工具
- `tool show` 会输出参数名、类型和描述
- `tool run` 参数格式固定为 `key=value`
- 涉及精确媒体身份的通用工具统一使用 `media_source` + `media_id``media_source` 必须是工具 Schema 列出的 `MediaSource` 枚举值,两个字段必须成对传递并复用搜索结果。TMDB 等单数据源专属工具按各自 Schema 保留原生 ID 参数
- `read_file``write_file``edit_file``execute_command`
属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时
由 Agent 按当前用户权限直接调用这些工具。
+16 -16
View File
@@ -132,20 +132,20 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶
#### 媒体识别 / 整理
媒体识别、搜索和手动整理内置支持 `themoviedb``douban``bangumi``anilist` 四种影视数据源,也允许插件处理自定义来源。影视自动识别在未指定来源时只使用 TMDB,未命中时不会继续查询其它影视源。音乐路径识别严格按 AcoustID 音频指纹、文件标签、文件名三级依次执行;指纹或标签直接提供 MusicBrainz Recording ID 时,会直接查询 MusicBrainz 详情,标签和文件名标题识别也只使用 MusicBrainz。其它元数据源仅在手动操作通过请求级 `source``media_source` + `media_id` 明确指定时使用,不修改系统默认值,也不会跨来源兜底
媒体识别、搜索和手动整理统一使用 `media_source` + `media_id` 表示媒体主身份。`media_source` 必须是 `MediaSource` 枚举值:`themoviedb``douban``bangumi``anilist``imdb``tvdb``musicbrainz``theaudiodb``doubanmusic``bilibili``mangguodiscover``migu``tencentvideodiscover``media_id` 是该来源的原生 ID,不添加 `tmdb:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个
涉及媒体身份的请求统一以 `media_source` + `media_id` 表示本次选定的主身份,同时保留 `tmdbid``doubanid``bangumiid``anilistid` 作为跨数据源映射和旧客户端兼容字段。两者并非两套独立数据流:显式通用主身份优先,专用 ID 用于补全映射和兼容回退
影视自动识别在未指定来源时只使用 TMDB,未命中时不会继续查询其它影视源。音乐路径识别严格按 AcoustID 音频指纹、文件标签、文件名三级依次执行;指纹或标签直接提供 MusicBrainz Recording ID 时,会直接查询 MusicBrainz 详情,标签和文件名标题识别也只使用 MusicBrainz。其它元数据源仅在手动操作通过请求级 `media_source`,或通过完整的 `media_source` + `media_id` 精确指定时使用,不修改系统默认值,也不会跨来源兜底。`MediaInfo` 响应仍可能包含 `tmdb_id``douban_id``bangumi_id``anilist_id` 等跨源映射辅助字段,但这些字段不是通用请求入口。明确归属 `/tmdb``/douban``/bangumi``/anilist` 的接口,以及固定使用 TMDB 的剧集组和排期接口,仍可按其单数据源契约接收原生 ID
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/media/search` | 按标题搜索媒体、合集人物,参数:`title``type``page``count`,可`source``media` 支持 `themoviedb``douban``bangumi``anilist``collection` 支持 `themoviedb``person` 支持 `themoviedb``douban` |
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title``subtitle``custom_words`,可选 `source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 |
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` |
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:``douban:``bangumi:``anilist:` 及插件自定义来源前缀 |
| GET | `/api/v1/media/search` | 按标题搜索媒体、合集人物或音乐,参数:`title``type``page``count`,可重复传入可选 `media_source`;不同搜索类型仅接受其支持 `MediaSource` 枚举值,旧客户端的逗号格式仅在输入边界兼容 |
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title``subtitle``custom_words`,可选 `media_source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 |
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `media_source` |
| GET | `/api/v1/media/{media_id}` | 按原生 ID 查询媒体详情;必填参数:`media_source``type_name`,其中 `media_source` 与路径中的 `media_id` 组成统一媒体身份 |
| 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` | 按源文件与目录配置匹配手动整理目标路径;请求体 `ManualTransferItem`,该接口不执行媒体识别 |
| POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 |
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid``doubanid``bangumiid``anilistid`;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
#### 站点
@@ -157,14 +157,14 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源`mediaid` 支持 `tmdb:123``douban:123``bangumi:123``anilist:123``musicbrainz:<recording_mbid>` 及插件来源前缀,参数:`mtype``area``title``year``season``sites` |
| GET | `/api/v1/search/media/{mediaid}/stream` | 按媒体 ID 渐进式搜索站点种子资源,返回 SSE,参数同上 |
| GET | `/api/v1/search/media/{media_id}` | 按统一媒体身份搜索站点种子资源;必填参数:`media_source`,其它参数:`mtype``area``season``sites``music_type` |
| GET | `/api/v1/search/media/{media_id}/stream` | 按统一媒体身份渐进式搜索站点种子资源,返回 SSE,参数同上 |
| GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword``page``sites`,可选 `mtype=音乐` 仅搜索音乐分类 |
| GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword``page``sites`,可选 `mtype=音乐` |
| GET | `/api/v1/search/subtitle/title` | 按关键字搜索站点字幕资源,参数:`keyword``page``sites` |
| GET | `/api/v1/search/subtitle/title/stream` | 按关键字渐进式搜索站点字幕资源,返回 SSE,参数:`keyword``page``sites` |
| GET | `/api/v1/search/subtitle/media/{mediaid}` | 按媒体 ID 精确搜索站点字幕资源`mediaid` 支持四种内置来源及插件来源前缀,参数:`mtype``title``year``season``episode``sites` |
| GET | `/api/v1/search/subtitle/media/{mediaid}/stream` | 按媒体 ID 渐进式精确搜索站点字幕资源,返回 SSE,参数同上 |
| GET | `/api/v1/search/subtitle/media/{media_id}` | 按统一媒体身份精确搜索站点字幕资源;必填参数:`media_source`,其它参数:`mtype``season``episode``sites` |
| GET | `/api/v1/search/subtitle/media/{media_id}/stream` | 按统一媒体身份渐进式精确搜索站点字幕资源,返回 SSE,参数同上 |
| GET | `/api/v1/search/last` | 获取上一次种子搜索结果 |
| GET | `/api/v1/search/last/context` | 获取上一次搜索结果及可复用搜索参数,`params.result_type``torrent``subtitle` |
| POST | `/api/v1/search/recommend` | 获取 AI 推荐资源,请求体:`filtered_indices``check_only``force` |
@@ -194,7 +194,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/media/search` | 当 `type=music` 或指定音乐 `media_source` 时按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`title``type``count``media_source` |
| GET | `/api/v1/media/search` | 当 `type=music` 或指定音乐 `media_source` 时按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`title``type``count`可重复的 `media_source` 枚举 |
| POST | `/api/v1/music/recognize` | 按 `media_source` + `media_id` 识别音乐详情,请求体:`MusicRecognizeRequest` |
| GET | `/api/v1/music/explore` | 按来源浏览音乐;`media_source=musicbrainz` 支持 `mode=chart|fresh` 榜单与新发行,`media_source=doubanmusic` 固定按官方标签分类浏览,使用 `tags``douban_sort=U|S|R|O` 筛选。其它参数:`entity=recording|album``range_name``sort_by``sort``days``past``future``min_listen_count``with_cover``page``count` |
| GET | `/api/v1/music/album/{album_id}` | 按来源专辑 ID 查询专辑详情、完整曲目和发行版本,参数:`media_source` |
@@ -215,8 +215,8 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
| :--- | :--- | :--- |
| GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name`;关联下载历史时返回媒体类型、来源站点 `site_name`,以及 `media.poster` 海报和 `media.backdrop` 背景图;兼容字段 `media.image``media.poster` 相同 |
| POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 |
| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `media_source` + `media_id`;继续兼容四种专用 ID,并支持 `downloader``save_path` |
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`可选 `media_source` + `media_id`继续兼容四种专用 ID,并支持 `save_path` |
| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选且必须成对提供 `media_source` + `media_id`,并支持 `music_type``downloader``save_path` |
| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`并必须提供 `media_source` + `media_id`可选 `save_path` |
| GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` |
| GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` |
| GET | `/api/v1/download/clients` | 查询可用下载器 |
@@ -297,7 +297,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用
`start_line``end_line` 指定更小的行号范围继续读取。
媒体相关 MCP 工具(如 `search_media``query_media_detail``search_torrents``query_library_exists``add_subscribe``transfer_file``scrape_metadata`)接受 `tmdb_id`/`tmdbid``douban_id`/`doubanid``bangumi_id`/`bangumiid``anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。音乐调用还使用 `media_type=music``music_type=recording|album|artist`;其中艺术家只允许搜索和详情浏览。工具返回的媒体、订阅、下载和整理记录会带回可复用的专用 ID、通用主身份以及音乐实体字段
媒体相关 MCP 工具 `MediaSource` 枚举 `media_source` + 来源原生 `media_id` 传递精确身份。`query_media_detail``search_torrents``query_library_exists` 必须提供完整字段对;`add_subscribe``transfer_file``scrape_metadata` 在显式指定身份时也必须成对提供。`search_media``recognize_media` 是按标题或路径发现身份的入口,其结果中的字段对可直接用于后续工具。音乐调用还使用 `media_type=music``music_type=recording|album|artist`;其中艺术家只允许搜索和详情浏览。工具响应中的专用 ID 仅是跨源映射辅助输出,不应再作为上述通用工具的输入。TMDB 专用的 `query_episode_schedule` 仍使用 `tmdb_id`,因为它直接调用单一 TMDB 剧集接口
Agent 音乐流程与影视共用同一采集管线,但实体边界不同:单曲通过 `music_type=recording` 按一个文件处理;专辑通过 `music_type=album` 类似电视剧整季包,按一个目录/资源处理并校验总曲目数;艺术家不是采集目标。`add_subscribe` / `update_subscribe` 支持音乐音质筛选字段和 `best_version` 音质洗版;`query_subscribes` 会返回筛选条件及当前音质快照。`scrape_metadata(media_type="music")` 会按策略写音频标签、封面和歌词,并返回歌词新增、已存在、未匹配和失败数量。
+6 -1
View File
@@ -219,7 +219,7 @@ moviepilot tool show search_torrents
# Run a tool directly
moviepilot tool run query_schedulers
moviepilot tool run search_torrents media_type=movie tmdb_id=12345
moviepilot tool run search_torrents media_type=movie media_source=themoviedb media_id=12345
# List scheduled tasks
moviepilot scheduler list
@@ -228,6 +228,11 @@ moviepilot scheduler list
moviepilot scheduler run subscribe_refresh
```
**Media identity rule:** Generic media tools use the complete `media_source` +
`media_id` pair returned by media search. `media_source` must be a `MediaSource`
enum value. A source-owned tool such as `query_episode_schedule` may retain its
native ID parameter because its schema and implementation are single-source.
---
## Local CLI — Agent
+11 -4
View File
@@ -75,16 +75,23 @@ with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)):
```python
import pytest
from app.schemas.types import MediaSource
@pytest.fixture
def sample_meta():
"""构造一条可复用的识别元数据。"""
return MetaInfo(title="示例 (2020)")
def test_recognize_prefers_explicit_id(sample_meta, monkeypatch):
"""显式 tmdbid 时应优先按 ID 识别,而非回退标题搜索。"""
def test_recognize_prefers_explicit_identity(sample_meta, monkeypatch):
"""显式媒体来源与原生 ID 时应优先精确识别,而非回退标题搜索。"""
monkeypatch.setattr(SomeClient, "fetch", lambda *a, **k: FAKE_MOVIE)
result = recognize(sample_meta, tmdbid=123)
assert result.tmdb_id == 123
result = recognize(
sample_meta,
media_source=MediaSource.TMDB,
media_id="123",
)
assert result.media_source == MediaSource.TMDB
assert result.media_id == "123"
```
## `unittest → pytest` 演进路线:改到即转
+1 -1
View File
@@ -1,4 +1,4 @@
moviepilot-rust~=0.2.6
moviepilot-rust~=0.2.7
pydantic>=2.13.4,<3.0.0
pydantic-settings>=2.14.2,<3.0.0
SQLAlchemy~=2.0.50
+290 -63
View File
@@ -4,20 +4,58 @@ import sys
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Callable, Optional
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from app.core import metainfo as metainfo_module
from app.core.meta import MetaAnime, MetaMusic
from app.core.metainfo import MetaInfo, MetaInfoPath
from tests.cases.meta import meta_cases
def build_inputs(repeat: int):
"""
构造覆盖 MetaInfo MetaInfoPath 的基准输入
"""
inputs = []
BenchmarkInput = tuple[str, str, Optional[str]]
ResultProjector = Callable[[Any], dict[str, Any]]
_MUSIC_CASES: tuple[BenchmarkInput, ...] = (
("music_query", "毛阿敏 - 永遠是朋友(2000) - ALAC [16B-44.1kHz]", None),
(
"music_query",
"VA-Once.Upon.a.Time.in.Hollywood.Original.Motion.Picture.Soundtrack."
"2019.FLAC.24bit.96kHz",
None,
),
("music_query", "李宗盛《理性与感性作品音乐会-CD2》2006-FLAC-分轨", None),
("music_query", "天国的情人-邓丽君作品全集1967-1995", None),
(
"music_query",
"S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv",
None,
),
("title", "周杰伦 - 晴天.flac", None),
("title", "01.我的地盘.wav", None),
(
"path",
"/benchmark/music/周杰伦 - 七里香 (2004) [FLAC 24bit-96kHz]/01.我的地盘.flac",
None,
),
(
"path",
"/benchmark/music/Daft Punk - Discovery (2001)/CD1/01 - One More Time.flac",
None,
),
(
"path",
"/benchmark/music/喜多郎 - 古事记 (1990) [SACD]/1-02 古事记.dsf",
None,
),
)
def build_video_inputs(repeat: int) -> list[BenchmarkInput]:
"""构造覆盖影视 MetaInfo 和 MetaInfoPath 生产入口的基准输入。"""
inputs: list[BenchmarkInput] = []
for _ in range(repeat):
for item in meta_cases:
if item.get("path"):
@@ -27,88 +65,277 @@ def build_inputs(repeat: int):
return inputs
def build_music_inputs(repeat: int) -> list[BenchmarkInput]:
"""构造覆盖音乐查询、音频文件名和目录路径生产入口的基准输入。"""
return list(_MUSIC_CASES) * repeat
def disabled_rust_parse(*_args, **_kwargs):
"""
关闭 Rust MetaInfo 快路径用于测量旧 Python 链路
"""
"""关闭一个 Rust 快路径,使生产入口自然回退到 Python 实现。"""
return None
@contextmanager
def selected_meta_parser(use_rust: bool):
"""
Rust 入口和 Python 旧实现之间切换
"""
original_parse = metainfo_module.rust_accel.parse_metainfo
original_parse_path = metainfo_module.rust_accel.parse_metainfo_path
original_find = metainfo_module.rust_accel.find_metainfo
"""在 Rust 入口和 Python 回退链路之间切换,并在退出时恢复适配器。"""
parser_names = (
"parse_metainfo",
"parse_metainfo_path",
"find_metainfo",
"parse_metamusic",
)
rust_accel = metainfo_module.rust_accel
original_parsers = {
name: getattr(rust_accel, name)
for name in parser_names
}
if not use_rust:
metainfo_module.rust_accel.parse_metainfo = disabled_rust_parse
metainfo_module.rust_accel.parse_metainfo_path = disabled_rust_parse
metainfo_module.rust_accel.find_metainfo = disabled_rust_parse
for name in parser_names:
setattr(rust_accel, name, disabled_rust_parse)
try:
yield
finally:
metainfo_module.rust_accel.parse_metainfo = original_parse
metainfo_module.rust_accel.parse_metainfo_path = original_parse_path
metainfo_module.rust_accel.find_metainfo = original_find
for name, parser in original_parsers.items():
setattr(rust_accel, name, parser)
def parse_all(inputs, use_rust: bool):
"""
执行一轮完整 MetaInfo/MetaInfoPath 入口解析
"""
with selected_meta_parser(use_rust):
parsed = []
for kind, value, subtitle in inputs:
if kind == "path":
parsed.append(MetaInfoPath(Path(value)))
else:
parsed.append(MetaInfo(title=value, subtitle=subtitle, custom_words=["#"]))
return parsed
def parse_input(item: BenchmarkInput):
"""按输入类型调用应用实际使用的公开识别入口。"""
kind, value, subtitle = item
if kind == "path":
return MetaInfoPath(Path(value))
if kind == "music_query":
return MetaMusic.parse_query(value)
if kind == "title":
return MetaInfo(title=value, subtitle=subtitle, custom_words=["#"])
raise ValueError(f"未知基准输入类型:{kind}")
def measure(inputs, use_rust: bool, loops: int, repeats: int):
"""
多轮测量 MetaInfo 入口解析耗时
"""
def parse_all(inputs: list[BenchmarkInput]) -> list[Any]:
"""通过生产入口解析一轮完整输入。"""
return [parse_input(item) for item in inputs]
def _enum_value(value: Any) -> Any:
"""把枚举值归一为稳定的可比较值。"""
return getattr(value, "value", value)
def project_video_result(meta: Any) -> dict[str, Any]:
"""提取影视识别对外契约字段,排除 Python 解析器的临时内部状态。"""
return {
"kind": "anime" if isinstance(meta, MetaAnime) else "video",
"type": _enum_value(meta.type),
"cn_name": meta.cn_name or "",
"en_name": meta.en_name or "",
"year": meta.year or "",
"part": meta.part or "",
"season": meta.season,
"episode": meta.episode,
"resource_type": meta.edition,
"resource_pix": meta.resource_pix or "",
"video_encode": meta.video_encode or "",
"audio_encode": meta.audio_encode or "",
"fps": meta.fps or None,
"media_source": _enum_value(meta.media_source),
"media_id": meta.media_id,
}
def project_music_result(meta: Any) -> dict[str, Any]:
"""提取音乐识别持久字段和派生音质字段,用于 Rust/Python 等价校验。"""
return {
"type": _enum_value(meta.type),
"org_string": meta.org_string,
"title": meta.title,
"artists": list(meta.artists),
"album": meta.album,
"album_artist": meta.album_artist,
"year": meta.year,
"disc_number": meta.disc_number,
"track_number": meta.track_number,
"total_discs": meta.total_discs,
"total_tracks": meta.total_tracks,
"version": meta.version,
"audio_format": meta.audio_format,
"audio_lossless": meta.audio_lossless,
"bit_depth": meta.bit_depth,
"sample_rate": meta.sample_rate,
"bitrate": meta.bitrate,
"duration": meta.duration,
"isrc": meta.isrc,
"media_source": _enum_value(meta.media_source),
"media_id": meta.media_id,
"audio_quality": meta.audio_quality,
"audio_quality_score": meta.audio_quality_score,
"audio_specs": meta.audio_specs,
}
def assert_projected_results_equal(
inputs: list[BenchmarkInput],
rust_results: list[Any],
python_results: list[Any],
projector: ResultProjector,
) -> None:
"""逐项校验 Rust/Python 稳定输出,首个差异携带输入和字段明细。"""
if len(rust_results) != len(python_results):
raise AssertionError(
f"Rust/Python 结果数量不一致:{len(rust_results)} != {len(python_results)}"
)
for index, (rust_result, python_result) in enumerate(zip(rust_results, python_results)):
rust_projection = projector(rust_result)
python_projection = projector(python_result)
if rust_projection == python_projection:
continue
differences = {
key: (rust_projection.get(key), python_projection.get(key))
for key in sorted(set(rust_projection) | set(python_projection))
if rust_projection.get(key) != python_projection.get(key)
}
raise AssertionError(
f"Rust/Python 输出不等价:index={index} input={inputs[index]!r} "
f"differences={differences!r}"
)
def validate_equivalent_results(
inputs: list[BenchmarkInput],
projector: ResultProjector,
) -> None:
"""分别通过 Rust/Python 生产链路解析并校验稳定输出等价。"""
with selected_meta_parser(use_rust=True):
rust_results = parse_all(inputs)
with selected_meta_parser(use_rust=False):
python_results = parse_all(inputs)
assert_projected_results_equal(inputs, rust_results, python_results, projector)
def measure(
inputs: list[BenchmarkInput],
use_rust: bool,
loops: int,
repeats: int,
) -> tuple[float, int]:
"""在一次解析器切换上下文中预热并多轮测量生产入口耗时。"""
samples = []
parsed_count = 0
for _ in range(repeats):
start = time.perf_counter()
for _ in range(loops):
parsed = parse_all(inputs, use_rust)
parsed_count = len(parsed)
samples.append((time.perf_counter() - start) * 1000 / loops)
with selected_meta_parser(use_rust):
parse_all(inputs)
for _ in range(repeats):
start = time.perf_counter()
for _ in range(loops):
parsed_count = len(parse_all(inputs))
samples.append((time.perf_counter() - start) * 1000 / loops)
return statistics.median(samples), parsed_count
def benchmark_suite(
inputs: list[BenchmarkInput],
projector: ResultProjector,
loops: int,
repeats: int,
) -> dict[str, float | int]:
"""先校验一个媒体域的结果等价,再返回 Rust/Python 独立性能指标。"""
validate_equivalent_results(inputs, projector)
rust_ms, rust_count = measure(inputs, use_rust=True, loops=loops, repeats=repeats)
python_ms, python_count = measure(inputs, use_rust=False, loops=loops, repeats=repeats)
return {
"rust_ms": rust_ms,
"python_ms": python_ms,
"rust_count": rust_count,
"python_count": python_count,
"speedup": python_ms / rust_ms if rust_ms else 0,
}
def validate_rust_runtime() -> None:
"""确认 Rust 总开关和音乐扩展入口可用,拒绝静默回退形成伪基准。"""
rust_accel = metainfo_module.rust_accel
if not rust_accel.is_enabled():
raise RuntimeError("Rust 加速未启用或 moviepilot-rust 扩展不可用")
if not callable(getattr(rust_accel, "parse_metamusic", None)):
raise RuntimeError("MoviePilot 后端缺少 rust_accel.parse_metamusic 适配器")
extension = getattr(rust_accel, "_moviepilot_rust", None)
if not callable(getattr(extension, "parse_metamusic_fast", None)):
raise RuntimeError("moviepilot-rust 版本过旧,缺少 parse_metamusic_fast")
probe = rust_accel.parse_metamusic("Daft Punk - Get Lucky 2013 FLAC")
if not isinstance(probe, dict):
raise RuntimeError("Rust 音乐解析探针未返回有效结果,拒绝测量 Python 回退")
def positive_int(value: str) -> int:
"""解析命令行正整数,拒绝空循环和空样本配置。"""
parsed = int(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("必须为正整数")
return parsed
def parse_args():
"""
解析命令行参数
"""
parser = argparse.ArgumentParser(description="Benchmark MetaInfo parsing through public entries")
parser.add_argument("--repeat-inputs", type=int, default=20, help="Repeat meta cases per loop")
parser.add_argument("--loops", type=int, default=10, help="Loops per repeat")
parser.add_argument("--repeats", type=int, default=5, help="Repeat count")
"""解析命令行参数。"""
parser = argparse.ArgumentParser(
description="Benchmark video and music metadata through public entries"
)
parser.add_argument(
"--repeat-inputs",
type=positive_int,
default=20,
help="Repeat video and music cases per loop",
)
parser.add_argument("--loops", type=positive_int, default=10, help="Loops per repeat")
parser.add_argument("--repeats", type=positive_int, default=5, help="Repeat count")
return parser.parse_args()
def main() -> int:
"""
运行 MetaInfo Rust Python 入口链路基准测试
"""
args = parse_args()
inputs = build_inputs(args.repeat_inputs)
rust_ms, rust_count = measure(inputs, use_rust=True, loops=args.loops, repeats=args.repeats)
python_ms, python_count = measure(inputs, use_rust=False, loops=args.loops, repeats=args.repeats)
speedup = python_ms / rust_ms if rust_ms else 0
def print_suite_result(
name: str,
inputs: list[BenchmarkInput],
result: dict[str, float | int],
loops: int,
repeats: int,
) -> None:
"""按媒体域输出等价状态、耗时、单项耗时和性能提升倍数。"""
rust_ms = float(result["rust_ms"])
python_ms = float(result["python_ms"])
print(f"{name}_items_per_loop={len(inputs)} loops={loops} repeats={repeats}")
print(
f"{name}_rust_items={result['rust_count']} "
f"{name}_python_items={result['python_count']}"
)
print(f"{name}_equivalent=true")
print(f"{name}_rust_ms_per_loop={rust_ms:.3f}")
print(f"{name}_python_ms_per_loop={python_ms:.3f}")
print(f"{name}_rust_us_per_item={rust_ms * 1000 / len(inputs):.3f}")
print(f"{name}_python_us_per_item={python_ms * 1000 / len(inputs):.3f}")
print(f"{name}_speedup={float(result['speedup']):.2f}x")
print(f"items_per_loop={len(inputs)} loops={args.loops} repeats={args.repeats}")
print(f"rust_items={rust_count} python_items={python_count}")
print(f"rust_chain_ms_per_loop={rust_ms:.3f}")
print(f"python_chain_ms_per_loop={python_ms:.3f}")
print(f"speedup={speedup:.2f}x")
def main() -> int:
"""运行影视与音乐 Rust/Python 生产入口基准测试。"""
args = parse_args()
try:
validate_rust_runtime()
video_inputs = build_video_inputs(args.repeat_inputs)
music_inputs = build_music_inputs(args.repeat_inputs)
video_result = benchmark_suite(
video_inputs,
project_video_result,
loops=args.loops,
repeats=args.repeats,
)
music_result = benchmark_suite(
music_inputs,
project_music_result,
loops=args.loops,
repeats=args.repeats,
)
except (AssertionError, RuntimeError) as err:
print(f"benchmark_error={err}", file=sys.stderr)
return 2
print_suite_result("video", video_inputs, video_result, args.loops, args.repeats)
print_suite_result("music", music_inputs, music_result, args.loops, args.repeats)
return 0
+2 -2
View File
@@ -148,11 +148,11 @@ def fetch_titles(site_keys: list[str], max_pages: int) -> list[tuple[str, str]]:
def recognize_one(module, title: str) -> dict:
"""对单条标题执行与识别测试页相同的解析+识别链路,并给出失败归因。"""
from app.chain.music import MusicChain
from app.core.meta import MetaMusic
row = {"title": title}
try:
meta = MusicChain.parse_query(title)
meta = MetaMusic.parse_query(title)
row.update({
"parsed_title": meta.title,
"parsed_artists": " / ".join(meta.artists or []),
+15 -6
View File
@@ -1,6 +1,6 @@
---
name: database-operation
version: 3
version: 4
description: >-
Use this skill when you need to inspect, query, maintain, or carefully modify
the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper,
@@ -100,7 +100,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
## Core Tables
### downloadhistory
Key columns: `id`, `path`, `type`, `title`, `year`, `tmdbid`, `imdbid`, `doubanid`, `seasons`, `episodes`, `downloader`, `download_hash`, `torrent_name`, `torrent_site`, `userid`, `username`, `date`, `media_category`
Key columns: `id`, `path`, `type`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `downloader`, `download_hash`, `torrent_name`, `torrent_site`, `userid`, `username`, `date`, `media_category`
### downloadfiles
Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filepath`, `torrentname`, `state`
@@ -108,17 +108,21 @@ Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filep
### transferhistory
Music rows persist actual `audio_format`, `audio_lossless`, `bit_depth`, `sample_rate`, and `bitrate` values read during organization. Bitrate uses bps and sample rate uses Hz.
Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `tmdbid`, `seasons`, `episodes`, `download_hash`, `status`, `errmsg`, `date`
Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `download_hash`, `status`, `errmsg`, `date`
### downloadfailure
Key columns: `id`, `fingerprint`, `type`, `title`, `year`, `media_source`, `media_id`, `seasons`, `episodes`, `site`, `torrent_id`, `downloader`, `error_message`, `retry_count`, `next_retry_at`
### subscribe
Music filters use `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, and `min_sample_rate`. Quality upgrades reuse `current_priority` and persist the current exact values in `current_audio_format`, `current_bitrate`, `current_bit_depth`, and `current_sample_rate`.
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state`, `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `best_version_full`, `date`, `username`
Key columns: `id`, `name`, `year`, `type`, `media_source`, `media_id`, `music_type`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state`, `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `best_version_full`, `date`, `username`
### subscribehistory
Completed music subscriptions retain both audio filters and the final current-quality snapshot for auditing.
Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `date`, `username`
Key columns: `id`, `name`, `year`, `type`, `media_source`, `media_id`, `music_type`, `season`, `total_episode`, `start_episode`, `date`, `username`
### user
Key columns: `id`, `name`, `email`, `is_active`, `is_superuser`, `permissions`, `settings`
@@ -133,7 +137,12 @@ Key columns: `id`, `domain`, `name`, `username`, `user_level`, `bonus`, `upload`
Key columns: `id`, `domain`, `success`, `fail`, `seconds`, `lst_state`, `lst_mod_date`
### mediaserveritem
Key columns: `id`, `server`, `library`, `item_id`, `item_type`, `title`, `original_title`, `year`, `tmdbid`, `imdbid`, `tvdbid`, `path`
Key columns: `id`, `server`, `library`, `item_id`, `item_type`, `title`, `original_title`, `year`, `media_source`, `media_id`, `path`
The media-bearing tables above store one primary identity only. Treat
`media_source` and `media_id` as an atomic pair: both are null for an unknown
identity, or both contain a valid source enum value and its native ID. Do not
write source-specific identity columns back into these tables.
### systemconfig
Key columns: `id`, `key`, `value`
+13 -11
View File
@@ -1,6 +1,6 @@
---
name: generate-identifiers
version: 2
version: 3
description: >-
Use this skill when a user provides a torrent name or file name and wants to fix recognition issues,
or asks to add/manage custom identifiers (自定义识别词).
@@ -12,7 +12,7 @@ description: >-
1) A torrent or file name is incorrectly recognized (wrong title, season, episode, etc.);
2) The user wants to block unwanted keywords from torrent names;
3) The user needs episode offset rules for series with non-standard numbering;
4) The user wants to force recognition of a specific media by TMDB/Douban ID;
4) The user wants to force recognition of a specific media by source-native ID;
5) The user wants TV recognition to use a specific TMDB episode group.
allowed-tools: query_custom_identifiers update_custom_identifiers recognize_media
---
@@ -52,13 +52,15 @@ Regex substitution. The left side is a regex pattern, the right side is the repl
**Special replacement for direct ID specification:**
```
被替换词 => {[tmdbid=xxx;type=movie/tv;s=xxx;e=xxx]}
被替换词 => {[doubanid=xxx;type=movie/tv;s=xxx;e=xxx]}
被替换词 => {[media_source=themoviedb;media_id=xxx;type=movie/tv;s=xxx;e=xxx]}
被替换词 => {[media_source=douban;media_id=xxx;type=movie/tv;s=xxx;e=xxx]}
```
Where `s` (season) and `e` (episode) are optional. For TMDB TV recognition, add `g=xxx` to specify an episode group:
`media_source` must use a `MediaSource` enum value and `media_id` must be that
source's native ID. Where `s` (season) and `e` (episode) are optional. For TMDB
TV recognition, add `g=xxx` to specify an episode group:
```
被替换词 => {[tmdbid=xxx;type=tv;g=xxx;s=xxx;e=xxx]}
被替换词 => {[media_source=themoviedb;media_id=xxx;type=tv;g=xxx;s=xxx;e=xxx]}
```
### 3. Episode Offset (集偏移)
@@ -105,7 +107,7 @@ When generating a new rule, default to **the narrowest regex that still fixes th
- Avoid generic global rules such as bare `1080p`, `WEB-DL`, `中字`, `国配`, `REPACK`, `S01E01`, or pure numbers unless the user explicitly wants a global cleanup rule.
- If the rule only needs to fix one specific naming pattern, prefer a **contextual replacement** with capture groups/backreferences over a bare block word.
- For episode offset rules, the `前定位词` and `后定位词` should use sample-specific context so the offset only runs on the intended naming pattern.
- For direct TMDB/Douban binding, the left side should match the user's specific wrong alias or naming pattern, not a broad season/episode pattern that could hit other media.
- For direct media binding, the left side should match the user's specific wrong alias or naming pattern, not a broad season/episode pattern that could hit other media.
### Narrow vs Broad Examples
@@ -113,13 +115,13 @@ Bad (too broad for a global rule):
```
REPACK
1080p
S01E01 => {[tmdbid=12345;type=tv;s=1;e=1]}
S01E01 => {[media_source=themoviedb;media_id=12345;type=tv;s=1;e=1]}
```
Better (scoped to the user's sample pattern):
```
(\[SubGroup\].*?My\.Show.*?2024.*?)REPACK => \1
Some\.Weird\.Name(?:\.2024)?(?:\.S01E\d+)? => {[tmdbid=12345;type=tv;s=1]}
Some\.Weird\.Name(?:\.2024)?(?:\.S01E\d+)? => {[media_source=themoviedb;media_id=12345;type=tv;s=1]}
\[Baha\] <> \[1080P\] >> EP-12
```
@@ -226,7 +228,7 @@ Tell the user:
**Solution**: Direct ID specification with a sample-specific alias pattern:
```
# 仅在 Some.Weird.Name 这一命名模式下强制绑定 TMDB ID 12345
Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[tmdbid=12345;type=tv;s=1]}
Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[media_source=themoviedb;media_id=12345;type=tv;s=1]}
```
### Force TMDB Episode Group Recognition
@@ -236,7 +238,7 @@ Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[tmdbid=12345;type=tv;s=1]}
**Solution**: Direct TMDB ID specification with `g=...`:
```
# 仅在 Some.Weird.Name 命名模式下绑定 TMDB ID 12345 并指定剧集组
Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[tmdbid=12345;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]}
Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[media_source=themoviedb;media_id=12345;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]}
```
### Combined Fix
+47 -39
View File
@@ -1,6 +1,6 @@
---
name: moviepilot-api
version: 11
version: 12
description: >-
Use this skill when you need to call MoviePilot REST API endpoints directly
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
@@ -17,6 +17,14 @@ description: >-
Use `scripts/mp-api.py` to call any MoviePilot REST API endpoint directly.
Generic media requests use one stable identity contract: `media_source` is a
`MediaSource` enum value and `media_id` is that source's native ID. Supply the
pair together and keep it unchanged across detail, search, subscription,
download, transfer, scraping, and library checks. Source-specific IDs exposed
by `MediaInfo` are mapping metadata, not alternate generic request parameters.
Native IDs remain valid on explicitly source-owned endpoints under `/tmdb`,
`/douban`, `/bangumi`, and `/anilist`.
## Scope And Boundaries
This skill is the REST API bridge. It is implemented as a Python script and is
@@ -88,10 +96,10 @@ changing its method, parameters, request body, or authentication.
```bash
# GET with query params
python scripts/mp-api.py GET /api/v1/media/search title="Avatar" type="movie"
python scripts/mp-api.py GET /api/v1/media/search title="Avatar" type="media"
# POST with JSON body
python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_url":"abc1234:1"}'
python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_in":{"title":"Avatar.2009","enclosure":"abc1234:1"},"media_source":"themoviedb","media_id":"19995"}'
# DELETE
python scripts/mp-api.py DELETE /api/v1/subscribe/123
@@ -111,23 +119,23 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
### Media Search (13 endpoints)
When recognition omits `source`, MoviePilot uses TMDB exclusively for video and MusicBrainz exclusively for music. A miss does not trigger another metadata source. Providing `source` or a source-native ID keeps recognition strict to that manually selected source.
When recognition omits `media_source`, MoviePilot uses TMDB exclusively for video and MusicBrainz exclusively for music. A miss does not trigger another metadata source. Providing `media_source`, or the complete `media_source` + `media_id` pair, keeps recognition strict to that manually selected source.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/media/search` | Search media, collections, or people by title. Params: `title` (required), `type`, `page`, `count`, optional `source`. Supported sources: `media` = `themoviedb`, `douban`, `bangumi`, `anilist`; `collection` = `themoviedb`; `person` = `themoviedb`, `douban` |
| GET | `/api/v1/media/recognize` | Recognize media from a torrent title or a media file path. Params: `title` (required), `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata such as title and year |
| GET | `/api/v1/media/recognize2` | Recognize media from a torrent title or media file path (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata |
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `source` |
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `source` |
| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: `media_source`, `media_id`, `type_name` (`电影`/`电视剧`) |
| GET | `/api/v1/media/search` | Search by title. Params: `title` (required), `type=media|music|collection|person`, `page`, `count`, optional repeated `media_source`; each type accepts only its supported `MediaSource` values. Comma-separated input is legacy compatibility only |
| GET | `/api/v1/media/recognize` | Recognize media from a torrent title or a media file path. Params: `title` (required), `subtitle`, `custom_words`, optional `media_source`; media file paths also use parent-directory metadata such as title and year |
| GET | `/api/v1/media/recognize2` | Recognize media from a torrent title or media file path (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `media_source`; media file paths also use parent-directory metadata |
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `media_source` |
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `media_source` |
| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: paired `media_source` + `media_id`, `type_name` (`电影`/`电视剧`/`音乐`), `music_type` |
| GET | `/api/v1/media/category/config` | Get category strategy config |
| POST | `/api/v1/media/category/config` | Save category strategy config. Body: CategoryConfig |
| GET | `/api/v1/media/category` | Get auto-categorization config |
| GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons |
| GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups |
| GET | `/api/v1/media/seasons` | Get media season info. Params: `mediaid`, `title`, `year`, `season` |
| GET | `/api/v1/media/{mediaid}` | Get media detail. `mediaid` supports `tmdb:`, `douban:`, `bangumi:`, `anilist:`, and plugin-defined source prefixes. Params: `type_name` (required: movie/tv), `title`, `year` |
| GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons. TMDB-only endpoint |
| GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups. TMDB-only endpoint, so the native ID parameter is intentional |
| GET | `/api/v1/media/seasons` | Get media season info. Use `media_source` + `media_id`, or title discovery with `title` and optional `year`; optional `season` narrows the result |
| GET | `/api/v1/media/{media_id}` | Get media detail by native ID. Required params: `media_source`, `type_name` (`电影`/`电视剧`) |
### TMDB (8 endpoints)
@@ -187,7 +195,7 @@ music on configured music-capable media servers; it does not manage playlists.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/media/search` | Search tracks, albums, or artists with `type=music` or a music `media_source`. Params: `title`, `type`, `count`, `media_source` |
| GET | `/api/v1/media/search` | Search tracks, albums, or artists with `type=music` or a music `media_source`. Params: `title`, `type`, `count`, repeated enum `media_source` |
| POST | `/api/v1/music/recognize` | Resolve music metadata. Body: `media_source`, `media_id` |
| GET | `/api/v1/music/explore` | Explore by `media_source`: MusicBrainz supports `mode=chart|fresh`; Douban Music always uses official tag categories with `tags` and `douban_sort=U|S|R|O`. Other params: `entity`, `range_name`, `sort_by`, `sort`, `days`, `past`, `future`, `min_listen_count`, `with_cover`, `page`, `count` |
| GET | `/api/v1/music/album/{album_id}` | Album detail with tracks and releases. Params: `media_source` |
@@ -208,14 +216,14 @@ Music acquisition rules:
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (video prefixes, `musicbrainz:<recording_mbid>`, 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/{media_id}` | Search torrents by native ID. Required param: `media_source`; other params: `mtype`, `area`, `season`, `sites`, `music_type` |
| GET | `/api/v1/search/media/{media_id}/stream` | Stream torrent search by native ID with SSE. Required param: `media_source`; other params match the non-streaming endpoint |
| GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` |
| GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` |
| GET | `/api/v1/search/subtitle/title` | Fuzzy search site subtitles by keyword. Params: `keyword`, `page`, `sites` |
| GET | `/api/v1/search/subtitle/title/stream` | Stream fuzzy site subtitle search with SSE. Params: `keyword`, `page`, `sites` |
| GET | `/api/v1/search/subtitle/media/{mediaid}` | Exact subtitle search by media ID (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/{media_id}` | Exact subtitle search by native ID. Required param: `media_source`; other params: `mtype`, `season`, `episode`, `sites` |
| GET | `/api/v1/search/subtitle/media/{media_id}/stream` | Stream exact subtitle search by native ID with SSE. Required param: `media_source`; other params match the non-streaming endpoint |
| 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` |
| POST | `/api/v1/search/recommend` | AI recommended resources. Body: `filtered_indices`, `check_only`, `force` |
@@ -228,8 +236,8 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
|--------|------|-------------|
| GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name); linked history adds media type and source `site_name` |
| POST | `/api/v1/download/` | Add download (with media info). Body: JSON |
| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional `media_source` + `media_id` (all four dedicated IDs remain supported), `downloader`, `save_path` |
| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, optional `media_source` + `media_id` (all four dedicated IDs remain supported), `save_path` |
| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional paired `media_source` + `media_id`, `music_type`, `downloader`, `save_path` |
| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, required `media_source` + `media_id`, optional `save_path` |
| GET | `/api/v1/download/start/{hashString}` | Resume download task |
| GET | `/api/v1/download/stop/{hashString}` | Pause download task |
| GET | `/api/v1/download/clients` | List available download clients |
@@ -240,14 +248,14 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/subscribe/` | List all subscriptions |
| POST | `/api/v1/subscribe/` | Add subscription. Music requires `type=music`, `music_type=recording|album`, and exact `media_source` + `media_id`; video also accepts compatible dedicated IDs |
| POST | `/api/v1/subscribe/` | Add subscription. An explicit identity is always `media_source` + `media_id`; music also requires `type=音乐` and `music_type=recording|album` |
| PUT | `/api/v1/subscribe/` | Update subscription. Body: Subscribe JSON |
| GET | `/api/v1/subscribe/list` | List subscriptions (API_TOKEN auth, use `--token-param`) |
| GET | `/api/v1/subscribe/{subscribe_id}` | Subscription detail |
| DELETE | `/api/v1/subscribe/{subscribe_id}` | Delete subscription |
| PUT | `/api/v1/subscribe/status/{subid}` | Update subscription status. Params: `state` (required) |
| GET | `/api/v1/subscribe/media/{mediaid}` | Query subscription by a built-in or plugin-prefixed media ID. Params: `season`, `title` |
| DELETE | `/api/v1/subscribe/media/{mediaid}` | Delete subscription by a built-in or plugin-prefixed media ID. Params: `season` |
| GET | `/api/v1/subscribe/media/{media_id}` | Query subscription by native ID. Required param: `media_source`; optional params: `season`, `title`, `music_type` |
| DELETE | `/api/v1/subscribe/media/{media_id}` | Delete subscription by native ID. Required param: `media_source`; optional params: `season`, `music_type` |
| GET | `/api/v1/subscribe/refresh` | Refresh all subscriptions |
| GET | `/api/v1/subscribe/reset/{subid}` | Reset subscription |
| GET | `/api/v1/subscribe/check` | Refresh subscription TMDB info |
@@ -314,7 +322,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/mediaserver/play/{itemid}` | Play media online |
| GET | `/api/v1/mediaserver/exists` | Check if media exists in library. Params: `title`, `year`, `mtype`, `tmdbid`, `season` |
| GET | `/api/v1/mediaserver/exists` | Check if media exists in the local library database. Params: `media_source` + `media_id`, or `title` discovery; optional `year`, `mtype`, `season` |
| POST | `/api/v1/mediaserver/exists_remote` | Check existing episodes (remote). Body: MediaInfo JSON |
| POST | `/api/v1/mediaserver/notexists` | Check missing episodes (remote). Body: MediaInfo JSON |
| GET | `/api/v1/mediaserver/latest` | Latest library items. Params: `server` (required), `count` |
@@ -347,7 +355,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| GET | `/api/v1/transfer/name` | Preview transfer name. Params: `path` (required), `filetype` (required) |
| GET | `/api/v1/transfer/queue` | Transfer queue |
| DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON |
| POST | `/api/v1/transfer/manual/target-path` | Match manual transfer target path. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select the recognition source |
| POST | `/api/v1/transfer/manual/target-path` | Match the manual transfer target from source path and directory configuration. Body: ManualTransferItem JSON; this endpoint does not recognize media |
| POST | `/api/v1/transfer/manual/history` | Query successful transfer-history summary for selected files or directories. Body: ManualTransferItem JSON |
| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source; matching failed history is cleared automatically, while `reorganize=true` removes matched successful history and old non-move targets before retrying |
| GET | `/api/v1/transfer/now` | Run immediate transfer |
@@ -496,7 +504,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| DELETE | `/api/v1/torrent/cache` | Clear torrent cache |
| DELETE | `/api/v1/torrent/cache/{domain}/{torrent_hash}` | Delete specific torrent cache |
| POST | `/api/v1/torrent/cache/refresh` | Refresh torrent cache |
| POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Params: `tmdbid`, `doubanid` |
| POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Optional paired params: `media_source`, `media_id`; music may also pass `music_type` |
### Recognition Cache (3 endpoints)
@@ -617,19 +625,19 @@ Radarr/Sonarr compatible API for integration with external tools.
```bash
# 1. Search TMDB for the movie
python scripts/mp-api.py GET /api/v1/media/search title="Inception" type="movie"
python scripts/mp-api.py GET /api/v1/media/search title="Inception" type="media"
# 2. Get media detail (replace {tmdbid} with actual ID)
python scripts/mp-api.py GET /api/v1/media/27205 type_name="movie"
# 2. Get media detail with the exact identity returned by search
python scripts/mp-api.py GET /api/v1/media/27205 media_source="themoviedb" type_name="电影"
# 3. Search torrents
python scripts/mp-api.py GET /api/v1/search/media/tmdb:27205 mtype="movie"
python scripts/mp-api.py GET /api/v1/search/media/27205 media_source="themoviedb" mtype="movie"
# 4. Get latest search results
python scripts/mp-api.py GET /api/v1/search/last
# 5. Add download
python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_url":"<url_from_search>"}'
python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_in":{"title":"<title_from_search>","enclosure":"<url_from_search>"},"media_source":"themoviedb","media_id":"27205"}'
```
### Search and subscribe to one recording or complete album
@@ -639,10 +647,10 @@ python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_url":"<url_
python scripts/mp-api.py GET /api/v1/media/search title="Artist - Title" type="music" count=20
# 2a. For an album, inspect its complete track list before subscribing
python scripts/mp-api.py GET /api/v1/music/album/<album_mbid> source="musicbrainz"
python scripts/mp-api.py GET /api/v1/music/album/<album_mbid> media_source="musicbrainz"
# 2b. Check the exact entity subscription separately; music_type prevents recording/album ambiguity
python scripts/mp-api.py GET /api/v1/subscribe/media/musicbrainz:<mbid> music_type="album"
python scripts/mp-api.py GET /api/v1/subscribe/media/<mbid> media_source="musicbrainz" music_type="album"
# 3. Add one exact album subscription. REST enum values use the localized MediaType value.
python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Album Title","type":"音乐","music_type":"album","media_source":"musicbrainz","media_id":"<album_mbid>"}'
@@ -662,23 +670,23 @@ python scripts/mp-api.py GET /api/v1/search/subtitle/title keyword="Inception" s
python scripts/mp-api.py GET /api/v1/search/last/context
# 3. Download a subtitle result to the recognized media directory
python scripts/mp-api.py POST /api/v1/download/subtitle --json '{"subtitle_in":{"title":"Inception.2010.1080p.chs","enclosure":"https://example.com/downloadsubs.php?torrentid=1&subid=2","site_name":"Example"},"tmdbid":27205}'
python scripts/mp-api.py POST /api/v1/download/subtitle --json '{"subtitle_in":{"title":"Inception.2010.1080p.chs","enclosure":"https://example.com/downloadsubs.php?torrentid=1&subid=2","site_name":"Example"},"media_source":"themoviedb","media_id":"27205"}'
```
### Add a subscription
```bash
# 1. Search for the show
python scripts/mp-api.py GET /api/v1/media/search title="Breaking Bad" type="tv"
python scripts/mp-api.py GET /api/v1/media/search title="Breaking Bad" type="media"
# 2. Check if already subscribed
python scripts/mp-api.py GET /api/v1/subscribe/media/tmdb:1396
python scripts/mp-api.py GET /api/v1/subscribe/media/1396 media_source="themoviedb"
# 3. Check if already in library
python scripts/mp-api.py GET /api/v1/mediaserver/exists tmdbid=1396 mtype="tv"
python scripts/mp-api.py GET /api/v1/mediaserver/exists media_source="themoviedb" media_id=1396 mtype="tv"
# 4. Add subscription
python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Breaking Bad","year":"2008","type":"tv","tmdbid":1396}'
python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Breaking Bad","year":"2008","type":"电视剧","media_source":"themoviedb","media_id":"1396"}'
```
### System monitoring
+16 -13
View File
@@ -1,6 +1,6 @@
---
name: moviepilot-cli
version: 6
version: 7
description: >-
Use this skill when the user asks to operate MoviePilot through the local
`moviepilot tool` MCP CLI for normal product workflows: media search, torrent
@@ -77,16 +77,18 @@ If the user specifies a TV season, run Season Validation step first — the seas
#### 2. Search torrents
Prefer `tmdb_id`; use `douban_id` only when `tmdb_id` is unavailable.
Reuse the exact `media_source` and `media_id` returned by `search_media`. Do not
replace the selected primary identity with an auxiliary TMDB, Douban, Bangumi,
or AniList mapping ID.
Omitting `sites=` uses the user's default sites. If the user specifies sites, first retrieve site IDs:
`moviepilot tool run query_sites`
Search torrents using default sites:
`moviepilot tool run search_torrents tmdb_id=791373 media_type="movie"`
`moviepilot tool run search_torrents media_source="themoviedb" media_id=791373 media_type="movie"`
Search torrents using user-specified sites (pass site IDs from `query_sites`):
`moviepilot tool run search_torrents tmdb_id=791373 media_type="movie" sites='1,3'`
`moviepilot tool run search_torrents media_source="themoviedb" media_id=791373 media_type="movie" sites='1,3'`
When `search_torrents` returns:
1. **Stop** — do not call `get_search_results` yet.
@@ -137,25 +139,25 @@ Download one or more torrents (`torrent_url` comes from `get_search_results` out
| Step | Action |
|---|---|
| `search_media` empty | Retry with alternative title (English/original), inform user. Still empty → ask for title or TMDB ID. |
| `search_media` empty | Retry with an alternative title (English/original), then ask for the title or exact `media_source` + `media_id`. |
| `search_torrents` empty | Inform user, ask whether to retry with different sites. |
| `get_search_results` empty | Do not silently broaden filters. Suggest which filter to relax, ask before retrying. |
| `add_download_tasks` fails | Run `query_downloaders` + `query_download_tasks` to diagnose, then report to user. |
### Add Subscription
1. Search for the media to get `tmdb_id`: Run `search_media`.
1. Run `search_media` and keep the returned `media_source` + `media_id` pair.
2. Run **Check Library and Subscriptions** step, if media already exists or is subscribed, **stop** and report to user.
3. If the user specifies a TV season, run Season Validation step first.
Subscribe to a movie or TV show:
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009`
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" media_source="themoviedb" media_id=42009`
Subscribe to a specific season:
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009 season=4`
`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" media_source="themoviedb" media_id=42009 season=4`
Subscribe starting from a specific episode:
`moviepilot tool run add_subscribe title="..." year="2024" media_type="tv" tmdb_id=12345 season=1 start_episode=13`
`moviepilot tool run add_subscribe title="..." year="2024" media_type="tv" media_source="themoviedb" media_id=12345 season=1 start_episode=13`
Subscribe to a complete lossless album and keep upgrading its audio quality:
`moviepilot tool run add_subscribe title="..." media_type="music" music_type="album" media_source="musicbrainz" media_id="<release-group-id>" audio_quality="hires|lossless" audio_format="DSD|FLAC|ALAC" min_bit_depth=24 best_version=1`
@@ -244,10 +246,10 @@ Delete a task only after confirming permanent removal with the user:
Run before any download or subscription to avoid duplicates.
Check if the media already exists in the library:
`moviepilot tool run query_library_exists tmdb_id=123456 media_type="movie"`
`moviepilot tool run query_library_exists media_source="themoviedb" media_id=123456 media_type="movie"`
Check if the media is already subscribed:
`moviepilot tool run query_subscribes tmdb_id=123456`
`moviepilot tool run query_subscribes media_source="themoviedb" media_id=123456`
### Season Validation
@@ -256,7 +258,7 @@ Mandatory when user specifies a season. Productions sometimes release a show in
#### 1. Verify season exists
Fetch media detail to check available seasons:
`moviepilot tool run query_media_detail tmdb_id=<id> media_type="tv"`
`moviepilot tool run query_media_detail media_source="themoviedb" media_id=<id> media_type="tv"`
Compare `season_info` with the user's requested season:
1. If the season exists in `season_info` → use that season number directly and return to the calling workflow.
@@ -264,7 +266,8 @@ Compare `season_info` with the user's requested season:
#### 2. Identify the correct episode range
Fetch episode schedule for the latest season from `season_info`:
Fetch the episode schedule for the latest season from `season_info`. This is a
TMDB-only tool, so its native `tmdb_id` parameter is intentional:
`moviepilot tool run query_episode_schedule tmdb_id=<id> season=<latest_season_number>`
Use `air_date` to find a block of recently-aired episodes that likely corresponds to what the user calls the missing season. Look for a gap in `air_date` between episodes — the gap indicates a part break, and the episodes after the gap are what the user likely refers to as the next "season". For example, if TMDB Season 1 has episodes 124 and there is a multi-month gap between episode 12 and 13, then episodes 1324 correspond to the user's "Season 2". If no such gap exists, tell user content is unavailable. Otherwise confirm the episode range with user.
+10 -9
View File
@@ -1,6 +1,6 @@
---
name: organize-files
version: 2
version: 3
description: >-
Use this skill when the user asks the MoviePilot agent to identify and organize downloaded/local video or music files that automatic transfer cannot handle. Typical triggers include manually organizing a file or folder, a TV season pack, one music recording, or a complete album directory. If the user gives failed transfer history IDs, prefer transfer-failed-retry instead.
allowed-tools: list_directory query_directory_settings query_download_tasks query_transfer_history delete_transfer_history recognize_media search_media query_media_detail query_library_exists transfer_file scrape_metadata ask_user_choice send_message
@@ -59,8 +59,8 @@ If recognition fails or looks wrong:
1. Extract likely title, year, media type, season/episode range, or music artist/track/album from filenames and audio tags.
2. For video, call `search_media(title="...", year="...", media_type="movie|tv")`. For music, call `search_media(title="<artist> - <title>", media_type="music", music_type="recording|album")`.
3. If several results are plausible, use `ask_user_choice` when available, or ask the user directly to choose the correct title/TMDB ID.
4. For TV season confusion, use `query_media_detail(tmdb_id=<id>, media_type="tv")` before deciding the season number. For an album, use `query_media_detail(media_type="music", music_type="album", media_source="musicbrainz", media_id="<album_id>")` and verify `total_tracks` before treating the directory as complete.
3. If several results are plausible, use `ask_user_choice` when available, or ask the user directly to choose the correct title and `media_source` + `media_id` pair.
4. For TV season confusion, use `query_media_detail(media_source="themoviedb", media_id="<id>", media_type="tv")` before deciding the season number. For an album, use `query_media_detail(media_type="music", music_type="album", media_source="musicbrainz", media_id="<album_id>")` and verify `total_tracks` before treating the directory as complete.
Never invent an ID. Preserve the exact source-native entity returned by search: a recording is one track, an album is a multi-track collection, and an artist is browse-only and cannot be organized.
@@ -83,7 +83,8 @@ transfer_file(
file_path="<source path>",
storage="local",
media_type="movie|tv",
tmdbid=<tmdb_id>,
media_source="<source>",
media_id="<native_id>",
season=<season_number_if_tv>
)
```
@@ -107,7 +108,7 @@ Rules:
- Set `target_path` or `transfer_type` only when the user explicitly asks or the default directory configuration cannot handle the file.
- For a single movie or a single TV season folder, transfer the folder once with the shared identity.
- For mixed folders, split by media and transfer each file/subfolder separately.
- For episode packs, identify the media once, then reuse `tmdbid`, `media_type="tv"`, and the confirmed `season` for each item.
- For episode packs, identify the media once, then reuse the exact `media_source` + `media_id`, `media_type="tv"`, and the confirmed `season` for each item.
- For one recording, transfer only that audio file with the recording ID.
- For one album, verify the directory belongs to the selected album, then transfer the directory once with the album ID. Do not submit every track as an unrelated recording.
- Never transfer an artist search result. Select a recording or album first.
@@ -118,7 +119,7 @@ Rules:
After each transfer batch, report:
- source path(s) processed;
- recognized media title, type, TMDB/Douban ID, season/episode range when relevant;
- recognized media title, type, `media_source` + `media_id`, season/episode range when relevant;
- success/failure count;
- any failed message exactly enough for the user to act, such as missing media library directory, unsupported storage, existing history, or no media recognized.
@@ -130,14 +131,14 @@ If the result creates failed history records, tell the user they can retry with
1. `recognize_media(path=...)`
2. If needed, `search_media(...)` and confirm the result.
3. `transfer_file(file_path=..., media_type=..., tmdbid=..., season=...)`
3. `transfer_file(file_path=..., media_type=..., media_source=..., media_id=..., season=...)`
### User Gives A Season Folder
1. `list_directory(path=...)`
2. Pick a representative episode and run `recognize_media(path=...)`.
3. Confirm `tmdbid`, `media_type="tv"`, and season.
4. `transfer_file(file_path="<folder>/", media_type="tv", tmdbid=<id>, season=<season>)`
3. Confirm `media_source`, `media_id`, `media_type="tv"`, and season.
4. `transfer_file(file_path="<folder>/", media_type="tv", media_source="<source>", media_id="<native_id>", season=<season>)`
### User Gives One Music Track
+12 -13
View File
@@ -1,6 +1,6 @@
---
name: transfer-failed-retry
version: 3
version: 4
description: Use this skill when you need to retry failed video or music transfers/organizations. Given failed transfer history IDs, query the exact records, group by trustworthy movie/series/recording/album identity, delete only the old records being retried, then re-identify and re-organize through MoviePilot. This skill is automatically triggered when transfer failures occur and AI retry is enabled.
allowed-tools: query_transfer_history delete_transfer_history recognize_media transfer_file search_media
---
@@ -36,11 +36,10 @@ From each record, extract the following key information:
- **title**: The recognized title (may be incorrect)
- **errmsg**: The error message explaining why the transfer failed
- **type**: Media type (movie/tv/music)
- **tmdbid**: TMDB ID (if available)
- **media_source/media_id**: Exact source-native identity; preserve the pair together for every retry
- **seasons/episodes**: Season/episode info (if TV show)
- **downloader**: Which downloader was used
- **download_hash**: The torrent hash
- **media_source/media_id**: Exact source-native identity; required to preserve selected music entities
### Step 2: Analyze the Failure Reason
@@ -48,7 +47,7 @@ Common failure reasons and how to handle them:
| Error Message | Cause | Solution |
|---------------|-------|----------|
| 未识别到媒体信息 | File name or audio tags could not be matched | Use `search_media` to find the exact video ID or music recording/album identity, then transfer with explicit IDs |
| 未识别到媒体信息 | File name or audio tags could not be matched | Use `search_media` to find the exact `media_source` + `media_id`, then transfer with that pair |
| 源目录不存在 | Source file was moved or deleted | Cannot retry - skip this record |
| 目标路径不存在 | Target directory issue | Retry transfer - the directory config may have been fixed |
| 文件已存在 | Target file already exists | May need to use `force` mode or skip |
@@ -83,7 +82,7 @@ Based on the failure analysis in Step 2:
3. Once you have the exact identity, re-transfer with explicit identification:
```
transfer_file(file_path="<source_path>", tmdbid=<tmdb_id>, media_type="movie" or "tv")
transfer_file(file_path="<source_path>", media_source="<source>", media_id="<native_id>", media_type="movie" or "tv")
# or for music
transfer_file(file_path="<source_path>", media_type="music", music_type="recording" or "album", media_source="musicbrainz", media_id="<recording_or_album_id>")
```
@@ -101,7 +100,7 @@ For TV shows where episode info couldn't be determined:
1. Use `recognize_media` to get better metadata
2. Re-transfer with explicit season info:
```
transfer_file(file_path="<source_path>", tmdbid=<tmdb_id>, media_type="tv", season=<season_number>)
transfer_file(file_path="<source_path>", media_source="<source>", media_id="<native_id>", media_type="tv", season=<season_number>)
```
#### Case D: Music Recording Or Album
@@ -145,20 +144,20 @@ query_transfer_history(status="failed")
# 2. Identify media ONCE using the first file
recognize_media(path="/downloads/Show.Name.S01E01.1080p.mkv")
# Found: tmdb_id=789, media_type="tv"
# Found: media_source="themoviedb", media_id="789", media_type="tv"
# 3. For each record: delete history, then re-transfer
delete_transfer_history(history_id=42)
transfer_file(file_path="/downloads/Show.Name.S01E01.1080p.mkv", tmdbid=789, media_type="tv")
transfer_file(file_path="/downloads/Show.Name.S01E01.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv")
delete_transfer_history(history_id=43)
transfer_file(file_path="/downloads/Show.Name.S01E02.1080p.mkv", tmdbid=789, media_type="tv")
transfer_file(file_path="/downloads/Show.Name.S01E02.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv")
delete_transfer_history(history_id=44)
transfer_file(file_path="/downloads/Show.Name.S01E03.1080p.mkv", tmdbid=789, media_type="tv")
transfer_file(file_path="/downloads/Show.Name.S01E03.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv")
delete_transfer_history(history_id=45)
transfer_file(file_path="/downloads/Show.Name.S01E04.1080p.mkv", tmdbid=789, media_type="tv")
transfer_file(file_path="/downloads/Show.Name.S01E04.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv")
# 4. Report summary: "重试完成:4/4 成功"
```
@@ -187,12 +186,12 @@ recognize_media(path="/downloads/Movie.Name.2024.1080p.mkv")
# 3. Search TMDB
search_media(title="Movie Name", year="2024", media_type="movie")
# Found: tmdb_id=123456
# Found: media_source="themoviedb", media_id="123456"
# 4. Delete old history record
delete_transfer_history(history_id=42)
# 5. Re-transfer with correct identification
transfer_file(file_path="/downloads/Movie.Name.2024.1080p.mkv", tmdbid=123456, media_type="movie")
transfer_file(file_path="/downloads/Movie.Name.2024.1080p.mkv", media_source="themoviedb", media_id="123456", media_type="movie")
# Success!
```
+71 -14
View File
@@ -3,7 +3,7 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -126,7 +126,7 @@ def test_search_media_filters_music_entities_and_returns_stable_identity():
tool = SearchMediaTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.search_media.MusicChain.async_search",
"app.agent.tools.impl.search_media.MediaChain.async_search_music",
new=async_search,
):
result = asyncio.run(
@@ -215,7 +215,7 @@ def test_query_album_detail_exposes_complete_track_contract():
tool = QueryMediaDetailTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.query_media_detail.MusicChain.async_album",
"app.agent.tools.impl.query_media_detail.MediaChain.async_get_music_album",
new=async_album,
):
result = asyncio.run(
@@ -237,11 +237,13 @@ def test_query_album_detail_exposes_complete_track_contract():
def test_query_recording_detail_forwards_recording_namespace():
"""Agent 查询单曲详情时必须把 Recording 实体传给统一识别入口。"""
async_recognize = AsyncMock(return_value=_recording())
media_chain = Mock()
media_chain.async_recognize_media = async_recognize
tool = QueryMediaDetailTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.query_media_detail.MediaChain.async_recognize_media",
new=async_recognize,
"app.agent.tools.impl.query_media_detail.MediaChain",
return_value=media_chain,
):
result = asyncio.run(tool.run(
media_type="music",
@@ -256,18 +258,22 @@ def test_query_recording_detail_forwards_recording_namespace():
def test_scrape_album_uses_unified_entity_recognition(tmp_path):
"""Agent 专辑刮削应通过 MediaChain 识别,不再单独编排 MusicChain 专辑查询"""
"""Agent 专辑刮削应通过 MediaChain 识别,再交给 ScrapingChain 执行"""
album_dir = tmp_path / "叶惠美"
album_dir.mkdir()
async_recognize = AsyncMock(return_value=_album())
media_chain = Mock()
media_chain.async_recognize_media = async_recognize
scraping_chain = Mock()
scraping_chain.scrape_music_metadata.return_value = (True, "已刮削专辑")
tool = ScrapeMetadataTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.scrape_metadata.MediaChain.async_recognize_media",
new=async_recognize,
"app.agent.tools.impl.scrape_metadata.MediaChain",
return_value=media_chain,
), patch(
"app.agent.tools.impl.scrape_metadata.MediaChain.scrape_music_metadata",
return_value=(True, "已刮削专辑"),
"app.agent.tools.impl.scrape_metadata.ScrapingChain",
return_value=scraping_chain,
):
result = asyncio.run(tool.run(
path=str(album_dir),
@@ -278,9 +284,31 @@ def test_scrape_album_uses_unified_entity_recognition(tmp_path):
))
assert json.loads(result)["success"] is True
assert async_recognize.await_args.kwargs["media_source"] == MediaSource.MusicBrainz
assert async_recognize.await_args.kwargs["media_id"] == "release-group-1"
assert "source" not in async_recognize.await_args.kwargs
assert "mediaid" not in async_recognize.await_args.kwargs
assert async_recognize.await_args.kwargs["music_type"] == "album"
def test_scrape_metadata_rejects_unknown_media_source_before_file_access(tmp_path):
"""Agent 直接调用工具时也必须拒绝固定枚举之外的媒体来源。"""
audio_file = tmp_path / "unknown-source.flac"
audio_file.write_bytes(b"audio")
tool = ScrapeMetadataTool(session_id="session-1", user_id="10001")
result = asyncio.run(tool.run(
path=str(audio_file),
media_type="music",
media_source="plugin-source",
media_id="recording-1",
))
payload = json.loads(result)
assert payload["success"] is False
assert "media_source" in payload["message"]
def test_query_artist_detail_marks_entity_as_non_subscribable():
"""艺术家详情应明确标记为不可订阅,避免 Agent 混入获取流程。"""
artist = MusicArtistInfo(
@@ -293,7 +321,7 @@ def test_query_artist_detail_marks_entity_as_non_subscribable():
tool = QueryMediaDetailTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.query_media_detail.MusicChain.async_artist",
"app.agent.tools.impl.query_media_detail.MediaChain.async_get_music_artist",
new=async_artist,
):
result = asyncio.run(
@@ -479,7 +507,7 @@ def test_music_scrape_routes_audio_to_tag_cover_and_lyrics_pipeline(tmp_path):
tool = ScrapeMetadataTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.scrape_metadata.MediaChain.scrape_music_metadata",
"app.agent.tools.impl.scrape_metadata.ScrapingChain.scrape_music_metadata",
return_value=(True, "音乐刮削完成,歌词新增 1 首"),
) as scrape_music:
result = asyncio.run(
@@ -543,12 +571,12 @@ def test_agent_identity_schemas_only_expose_media_source_and_media_id():
def test_listenbrainz_album_chart_preserves_entity_and_bounded_page_size():
"""音乐榜单应把专辑实体与有界分页参数传递给缓存后的 MusicChain"""
"""音乐榜单应把专辑实体与有界分页参数传递给推荐链"""
async_chart = AsyncMock(return_value=[_album()])
tool = GetRecommendationsTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.get_recommendations.MusicChain.async_chart",
"app.agent.tools.impl.get_recommendations.RecommendChain.async_music_chart",
new=async_chart,
):
result = asyncio.run(
@@ -562,6 +590,8 @@ def test_listenbrainz_album_chart_preserves_entity_and_bounded_page_size():
payload = json.loads(result)
assert payload[0]["music_type"] == "album"
assert payload[0]["media_source"] == "musicbrainz"
assert payload[0]["media_id"] == "release-group-1"
assert async_chart.await_args.kwargs["entity"] == "album"
assert async_chart.await_args.kwargs["page"] == 2
assert async_chart.await_args.kwargs["count"] == 20
@@ -589,3 +619,30 @@ def test_subscribe_shares_normalize_legacy_music_type():
payload = json.loads(result.split("\n\n", 1)[1])
assert payload[0]["type"] == "music"
assert payload[0]["music_type"] == "recording"
def test_subscribe_shares_hide_server_legacy_identity_fields():
"""V3 Agent 只输出统一身份,中心服务为旧客户端合成的字段不得继续传播。"""
async_shares = AsyncMock(return_value=[{
"id": 2,
"name": "测试电影",
"type": MediaType.MOVIE.value,
"media_source": "themoviedb",
"media_id": "123",
"tmdbid": 123,
"doubanid": "legacy-douban",
}])
tool = QuerySubscribeSharesTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.query_subscribe_shares."
"MoviePilotServerHelper.async_get_subscribe_shares",
new=async_shares,
):
result = asyncio.run(tool.run())
payload = json.loads(result.split("\n\n", 1)[1])
assert payload[0]["media_source"] == "themoviedb"
assert payload[0]["media_id"] == "123"
assert "tmdbid" not in payload[0]
assert "doubanid" not in payload[0]
@@ -11,7 +11,8 @@ def test_popular_subscribe_title_distinguishes_special_season_zero(monkeypatch):
"type": "tv",
"name": "Demo Show",
"season": 0,
"tmdbid": 1,
"media_source": "themoviedb",
"media_id": "1",
"count": 5,
}]
@@ -27,6 +28,10 @@ def test_popular_subscribe_title_distinguishes_special_season_zero(monkeypatch):
assert payload[0]["title"] == "Demo Show 第零季"
assert payload[0]["season"] == 0
assert payload[0]["media_source"] == "themoviedb"
assert payload[0]["media_id"] == "1"
assert "tmdb_id" not in payload[0]
assert "tmdbid" not in payload[0]
def test_popular_music_subscribes_can_filter_complete_albums(monkeypatch):
+3 -2
View File
@@ -4,7 +4,7 @@ from unittest.mock import patch
from app.agent.tools.impl.query_subscribes import QuerySubscribesTool
from app.db.models.subscribe import Subscribe
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
def test_agent_query_subscribes_returns_manual_total_episode():
@@ -13,7 +13,8 @@ def test_agent_query_subscribes_returns_manual_total_episode():
id=160,
name="测试剧集",
type=MediaType.TV.value,
tmdbid=224839,
media_source=MediaSource.TMDB.value,
media_id="224839",
season=1,
total_episode=175,
manual_total_episode=1,
+9 -6
View File
@@ -9,7 +9,7 @@ from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.modules.anilist import AniListModule
from app.modules.anilist.anilist import AniListApi
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
@pytest.fixture
@@ -77,10 +77,13 @@ def test_anilist_id_recognition_normalizes_media_info(anilist_info: dict) -> Non
module.anilist_api = Mock()
module.anilist_api.detail.return_value = anilist_info
media = module.recognize_media(anilistid=154587)
media = module.recognize_media(
media_source=MediaSource.AniList, media_id="154587"
)
assert media is not None
assert media.source == "anilist"
assert media.media_source == MediaSource.AniList
assert media.media_id == "154587"
assert media.anilist_id == 154587
assert media.title == "葬送的芙莉莲"
assert media.anidb_id == 17617
@@ -108,8 +111,8 @@ def test_anilist_title_recognition_respects_request_source(anilist_info: dict) -
meta.type = MediaType.TV
meta.year = "2023"
media = module.recognize_media(meta=meta, source="anilist")
skipped = module.recognize_media(meta=meta, source="douban")
media = module.recognize_media(meta=meta, media_source=MediaSource.AniList)
skipped = module.recognize_media(meta=meta, media_source=MediaSource.Douban)
assert media is not None
assert media.anilist_id == 154587
@@ -127,7 +130,7 @@ def test_async_anilist_title_recognition(anilist_info: dict) -> None:
meta.type = MediaType.TV
media = asyncio.run(
module.async_recognize_media(meta=meta, source="anilist")
module.async_recognize_media(meta=meta, media_source=MediaSource.AniList)
)
assert media is not None
+58
View File
@@ -69,3 +69,61 @@ def test_extended_ids_fall_back_when_installed_rust_is_old() -> None:
assert metainfo["media_source"] == "anilist"
assert metainfo["media_id"] == "154587"
def test_generic_identity_falls_back_when_installed_rust_is_old() -> None:
"""旧 Rust 扩展缺少通用字段时应直接使用 Python 解析器。"""
with patch(
"app.core.metainfo.rust_accel.supports_unified_media_identity",
return_value=False,
), patch(
"app.core.metainfo.rust_accel.find_metainfo",
side_effect=AssertionError("旧 Rust 扩展不应处理通用媒体身份"),
):
_, metainfo = find_metainfo(
"Frieren {[media_source=anilist;media_id=154587]}"
)
assert metainfo["media_source"] == "anilist"
assert metainfo["media_id"] == "154587"
@pytest.mark.parametrize(
"title",
[
"Movie {[media_source=themoviedb;media_id=0;type=movies]}",
"Movie {[tmdbid=0;type=movies]}",
"Movie [tmdbid=0]",
"Anime [anilist=0]",
],
)
def test_python_metainfo_rejects_zero_identity_and_removes_tag(title: str) -> None:
"""Python 标签解析器应移除零值标签,但不得生成媒体身份。"""
with patch("app.core.metainfo.rust_accel.find_metainfo", return_value=None):
parsed_title, metainfo = find_metainfo(title)
assert metainfo["media_source"] is None
assert metainfo["media_id"] is None
assert "=0" not in parsed_title
def test_metainfo_normalizes_zero_identity_from_old_rust_extension() -> None:
"""旧 Rust 扩展返回零值身份时,主程序边界仍应将统一对清空。"""
rust_result = {
"title": "Movie",
"metainfo": {
"media_source": "themoviedb",
"media_id": "0",
"tmdbid": 0,
},
}
with patch(
"app.core.metainfo.rust_accel.find_metainfo",
return_value=rust_result,
):
parsed_title, metainfo = find_metainfo("Movie [tmdbid=0]")
assert parsed_title == "Movie"
assert metainfo["media_source"] is None
assert metainfo["media_id"] is None
assert "tmdbid" not in metainfo
+35 -4
View File
@@ -3,7 +3,6 @@ from types import SimpleNamespace
from unittest.mock import Mock
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import MusicInfo
from app.core.meta.metamusic import (
audio_quality_score,
@@ -12,6 +11,7 @@ from app.core.meta.metamusic import (
parse_audio_quality,
)
from app.helper.audio import AudioMetadataHelper
from app.schemas.types import MUSIC_ENTITY_ALBUM
RECORDING_ID = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
@@ -186,15 +186,13 @@ def test_remote_path_meta_parses_track_prefix_once(tmp_path):
"""远程或尚未落盘的音频路径应先剥离曲序,不能把 08 误识别成艺术家。"""
audio_path = tmp_path / "Daft Punk - Random Access Memories (2013)" / "08 - Get Lucky.flac"
music_meta = MusicChain.read_path_meta(audio_path)
media_meta = MediaChain.read_path_meta(audio_path)
music_meta = MediaChain.read_path_meta(audio_path)
assert music_meta.title == "Get Lucky"
assert music_meta.artists == ["Daft Punk"]
assert music_meta.album == "Random Access Memories"
assert music_meta.track_number == 8
assert music_meta.audio_format == "FLAC"
assert media_meta.to_dict() == music_meta.to_dict()
def test_read_audio_metadata_fallback_uses_dynamic_filename_parser(tmp_path, monkeypatch):
@@ -304,6 +302,39 @@ def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
assert audio.tags["musicbrainz_trackid"] == [RECORDING_ID]
def test_write_audio_metadata_does_not_write_album_id_as_recording_tag(monkeypatch):
"""MusicBrainz 专辑身份不得写入只接受 recording ID 的曲目标签。"""
class FakeAudio:
"""记录专辑元数据写入结果。"""
def __init__(self):
"""初始化空标签容器。"""
self.tags = {}
def __setitem__(self, key, value):
"""记录 Easy 标签赋值。"""
self.tags[key] = value
def save(self):
"""模拟 Mutagen 保存。"""
audio = FakeAudio()
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
success = AudioMetadataHelper.write(
Path("/music/Random Access Memories.flac"),
MusicInfo(
media_source="musicbrainz",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
title="Random Access Memories",
),
)
assert success is True
assert "musicbrainz_trackid" not in audio.tags
def test_write_audio_metadata_can_embed_cover_without_rewriting_tags(monkeypatch):
"""音乐封面策略应能在标签策略关闭时独立执行。"""
audio = SimpleNamespace(tags={"title": ["Original"]})
+49 -5
View File
@@ -5,7 +5,7 @@ import pytest
from app.chain.media import MediaChain
from app.core.context import MediaInfo
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
@pytest.mark.parametrize(
@@ -78,8 +78,18 @@ def test_bangumi_movie_conversion_uses_movie_type() -> None:
"""Bangumi剧场版转TMDB和豆瓣时均应按电影匹配。"""
chain = _SyncBangumiMediaChain()
tmdb_info = MediaChain.get_tmdbinfo_by_bangumiid(chain, 1)
douban_info = MediaChain.get_doubaninfo_by_bangumiid(chain, 1)
tmdb_info = MediaChain.convert_media_identity(
chain,
target_source=MediaSource.TMDB,
media_source=MediaSource.Bangumi,
media_id="1",
)
douban_info = MediaChain.convert_media_identity(
chain,
target_source=MediaSource.Douban,
media_source=MediaSource.Bangumi,
media_id="1",
)
assert tmdb_info == {"id": 100}
assert douban_info == {"id": "200"}
@@ -87,6 +97,26 @@ def test_bangumi_movie_conversion_uses_movie_type() -> None:
assert chain.douban_mtype == MediaType.MOVIE
def test_media_identity_conversion_rejects_invalid_or_unsupported_pairs() -> None:
"""跨源转换只接受完整非零 pair 和受支持的来源组合。"""
chain = _SyncBangumiMediaChain()
assert MediaChain.convert_media_identity(
chain,
target_source=MediaSource.TMDB,
media_source=MediaSource.Bangumi,
media_id="0",
) is None
assert MediaChain.convert_media_identity(
chain,
target_source=MediaSource.TheAudioDB,
media_source=MediaSource.Bangumi,
media_id="1",
) is None
assert chain.tmdb_mtype is None
assert chain.douban_mtype is None
class _AsyncBangumiMediaChain:
"""异步Bangumi跨数据源转换测试桩。"""
@@ -125,8 +155,22 @@ def test_async_bangumi_movie_conversion_uses_movie_type() -> None:
"""异步Bangumi电影转换应向TMDB和豆瓣传递电影类型。"""
chain = _AsyncBangumiMediaChain()
tmdb_info = asyncio.run(MediaChain.async_get_tmdbinfo_by_bangumiid(chain, 1))
douban_info = asyncio.run(MediaChain.async_get_doubaninfo_by_bangumiid(chain, 1))
tmdb_info = asyncio.run(
MediaChain.async_convert_media_identity(
chain,
target_source=MediaSource.TMDB,
media_source=MediaSource.Bangumi,
media_id="1",
)
)
douban_info = asyncio.run(
MediaChain.async_convert_media_identity(
chain,
target_source=MediaSource.Douban,
media_source=MediaSource.Bangumi,
media_id="1",
)
)
assert tmdb_info == {"id": 100}
assert douban_info == {"id": "200"}
+7 -4
View File
@@ -4,7 +4,7 @@ from xml.dom import minidom
from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.modules.bangumi import BangumiModule
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
def _bangumi_info() -> dict:
@@ -43,10 +43,11 @@ def test_bangumi_title_recognition_loads_detail_and_people() -> None:
meta.type = MediaType.TV
meta.year = "2023"
media = module.recognize_media(meta=meta, source="bangumi")
media = module.recognize_media(meta=meta, media_source=MediaSource.Bangumi)
assert media is not None
assert media.source == "bangumi"
assert media.media_source == MediaSource.Bangumi
assert media.media_id == "400602"
assert media.bangumi_id == 400602
assert media.number_of_episodes == 28
assert media.genres == [
@@ -65,7 +66,9 @@ def test_bangumi_scraper_generates_source_nfo() -> None:
module.scraper = MediaScraperHelper()
module.bangumiapi.detail.return_value = _bangumi_info()
module.bangumiapi.credits.return_value = []
media = module.recognize_media(bangumiid=400602)
media = module.recognize_media(
media_source=MediaSource.Bangumi, media_id="400602"
)
media.scrape_source = "bangumi"
nfo = module.metadata_nfo(media)
+223
View File
@@ -0,0 +1,223 @@
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from scripts import benchmark_metainfo_rust as benchmark
def test_build_inputs_separates_video_and_music_domains():
"""影视与音乐输入应独立扩展,并按 repeat 稳定重复。"""
video_once = benchmark.build_video_inputs(1)
music_once = benchmark.build_music_inputs(1)
assert benchmark.build_video_inputs(2) == video_once * 2
assert benchmark.build_music_inputs(2) == music_once * 2
assert {kind for kind, _value, _subtitle in music_once} == {
"music_query",
"title",
"path",
}
assert all(not value.lower().endswith(tuple(benchmark.metainfo_module.settings.RMT_AUDIOEXT))
for kind, value, _subtitle in music_once if kind == "music_query")
def test_parse_input_uses_public_production_entries(monkeypatch):
"""输入分发应调用 MetaInfo、MetaInfoPath 和 MetaMusic.parse_query 公开入口。"""
title_result = object()
path_result = object()
music_result = object()
title_parser = Mock(return_value=title_result)
path_parser = Mock(return_value=path_result)
music_parser = Mock(return_value=music_result)
monkeypatch.setattr(benchmark, "MetaInfo", title_parser)
monkeypatch.setattr(benchmark, "MetaInfoPath", path_parser)
monkeypatch.setattr(benchmark.MetaMusic, "parse_query", music_parser)
assert benchmark.parse_input(("title", "Movie 2026", "subtitle")) is title_result
assert benchmark.parse_input(("path", "/media/Movie 2026/movie.mkv", None)) is path_result
assert benchmark.parse_input(("music_query", "Artist - Track", None)) is music_result
title_parser.assert_called_once_with(
title="Movie 2026",
subtitle="subtitle",
custom_words=["#"],
)
path_parser.assert_called_once_with(benchmark.Path("/media/Movie 2026/movie.mkv"))
music_parser.assert_called_once_with("Artist - Track")
def test_selected_meta_parser_disables_and_restores_all_fast_paths(monkeypatch):
"""Python 对照上下文应屏蔽影视和音乐 Rust 入口,并完整恢复原函数。"""
rust_accel = benchmark.metainfo_module.rust_accel
parser_names = (
"parse_metainfo",
"parse_metainfo_path",
"find_metainfo",
"parse_metamusic",
)
originals = {}
for name in parser_names:
parser = Mock(name=name)
monkeypatch.setattr(rust_accel, name, parser, raising=False)
originals[name] = parser
with benchmark.selected_meta_parser(use_rust=False):
for name in parser_names:
assert getattr(rust_accel, name)("sample") is None
for name, parser in originals.items():
assert getattr(rust_accel, name) is parser
def test_measure_switches_once_and_warms_up_outside_samples(monkeypatch):
"""一次测量只应切换一次解析器,并额外执行一轮不计时预热。"""
context_calls = []
parse_calls = []
@contextmanager
def fake_selected_meta_parser(use_rust: bool):
"""记录测试中的解析器上下文进入次数。"""
context_calls.append(use_rust)
yield
def fake_parse_all(inputs):
"""记录测试中的每轮解析调用。"""
parse_calls.append(inputs)
return [object()] * len(inputs)
monkeypatch.setattr(benchmark, "selected_meta_parser", fake_selected_meta_parser)
monkeypatch.setattr(benchmark, "parse_all", fake_parse_all)
elapsed, parsed_count = benchmark.measure(
[("title", "Movie", None)],
use_rust=False,
loops=2,
repeats=3,
)
assert context_calls == [False]
assert len(parse_calls) == 7
assert parsed_count == 1
assert elapsed >= 0
def test_assert_projected_results_equal_reports_first_field_difference():
"""等价校验失败时应报告首个输入及稳定字段差异。"""
inputs = [("music_query", "Artist - Track", None)]
rust_result = SimpleNamespace(title="Track", artists=["Artist"])
python_result = SimpleNamespace(title="Other", artists=["Artist"])
with pytest.raises(AssertionError) as error:
benchmark.assert_projected_results_equal(
inputs,
[rust_result],
[python_result],
lambda result: {
"title": result.title,
"artists": list(result.artists),
},
)
message = str(error.value)
assert "Artist - Track" in message
assert "title" in message
assert "Track" in message
assert "Other" in message
def test_video_projection_ignores_python_parser_internal_state():
"""影视等价投影不应纳入 Python 解析器的临时私有字段。"""
rust_result = benchmark.MetaInfo("Marty Supreme 2025 2160p WEB-DL")
python_result = benchmark.MetaInfo("Marty Supreme 2025 2160p WEB-DL")
rust_result._index = 1
python_result._index = 99
rust_result._effect = []
python_result._effect = ["temporary"]
assert benchmark.project_video_result(rust_result) == benchmark.project_video_result(
python_result
)
def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch):
"""运行前检查应拒绝关闭的 Rust 和缺少音乐入口的旧扩展。"""
rust_accel = benchmark.metainfo_module.rust_accel
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=False))
with pytest.raises(RuntimeError, match="未启用"):
benchmark.validate_rust_runtime()
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True))
monkeypatch.setattr(rust_accel, "parse_metamusic", Mock(return_value={}), raising=False)
monkeypatch.setattr(rust_accel, "_moviepilot_rust", SimpleNamespace())
with pytest.raises(RuntimeError, match="版本过旧"):
benchmark.validate_rust_runtime()
def test_validate_rust_runtime_requires_successful_music_probe(monkeypatch):
"""音乐 Rust 入口存在但实际回退 Python 时也必须拒绝执行基准。"""
rust_accel = benchmark.metainfo_module.rust_accel
extension = SimpleNamespace(parse_metamusic_fast=Mock())
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True))
monkeypatch.setattr(rust_accel, "_moviepilot_rust", extension)
monkeypatch.setattr(rust_accel, "parse_metamusic", Mock(return_value=None), raising=False)
with pytest.raises(RuntimeError, match="探针"):
benchmark.validate_rust_runtime()
def test_main_outputs_independent_video_and_music_metrics(monkeypatch, capsys):
"""主程序应分别输出影视和音乐等价状态、耗时及性能提升。"""
monkeypatch.setattr(benchmark, "validate_rust_runtime", Mock())
monkeypatch.setattr(benchmark, "build_video_inputs", Mock(return_value=[("title", "V", None)]))
monkeypatch.setattr(
benchmark,
"build_music_inputs",
Mock(return_value=[("music_query", "M", None), ("title", "M.flac", None)]),
)
results = [
{
"rust_ms": 1.0,
"python_ms": 2.0,
"rust_count": 1,
"python_count": 1,
"speedup": 2.0,
},
{
"rust_ms": 2.0,
"python_ms": 6.0,
"rust_count": 2,
"python_count": 2,
"speedup": 3.0,
},
]
monkeypatch.setattr(benchmark, "benchmark_suite", Mock(side_effect=results))
monkeypatch.setattr(
benchmark.sys,
"argv",
["benchmark_metainfo_rust.py", "--repeat-inputs", "1", "--loops", "1", "--repeats", "1"],
)
assert benchmark.main() == 0
output = capsys.readouterr().out
assert "video_equivalent=true" in output
assert "video_speedup=2.00x" in output
assert "music_equivalent=true" in output
assert "music_speedup=3.00x" in output
assert "video_rust_us_per_item=1000.000" in output
assert "music_rust_us_per_item=1000.000" in output
def test_main_reports_runtime_failure_with_nonzero_exit(monkeypatch, capsys):
"""Rust 未就绪时主程序应明确报错并返回非零状态。"""
monkeypatch.setattr(
benchmark,
"validate_rust_runtime",
Mock(side_effect=RuntimeError("old extension")),
)
monkeypatch.setattr(benchmark.sys, "argv", ["benchmark_metainfo_rust.py"])
assert benchmark.main() == 2
assert "benchmark_error=old extension" in capsys.readouterr().err
+5 -5
View File
@@ -6,7 +6,7 @@ from unittest import TestCase
from unittest.mock import patch
from app import schemas
from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.core.context import MediaInfo
@@ -143,7 +143,7 @@ class BluRayTest(TestCase):
# 测试手动刮削
logger.debug(f"测试手动刮削 {path}")
mock_metadata_nfo.call_count = 0
MediaChain().scrape_metadata(
ScrapingChain().scrape_metadata(
fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True
)
# 确保调用了指定次数的metadata_nfo
@@ -152,7 +152,7 @@ class BluRayTest(TestCase):
# 测试自动刮削
logger.debug(f"测试自动刮削 {path}")
mock_metadata_nfo.call_count = 0
MediaChain().scrape_metadata_event(
ScrapingChain().scrape_metadata_event(
Event(
event_type=EventType.MetadataScrape,
event_data={
@@ -174,7 +174,7 @@ class BluRayTest(TestCase):
# 刮削电影目录
__test_scrape_metadata("/FOLDER", excepted_nfo_count=2)
@patch("app.chain.media.MediaChain.metadata_img", return_value=None) # 避免获取图片
@patch("app.chain.scraping.ScrapingChain.metadata_img", return_value=None) # 避免获取图片
@patch("app.chain.ChainBase.__init__", return_value=None) # 避免不必要的模块初始化
@patch("app.db.transferhistory_oper.TransferHistoryOper.get_by_src")
@patch("app.chain.storage.StorageChain.list_files")
@@ -222,6 +222,6 @@ class BluRayTest(TestCase):
self._test_do_transfer()
with patch(
"app.chain.media.MediaChain.metadata_nfo", return_value=None
"app.chain.scraping.ScrapingChain.metadata_nfo", return_value=None
) as mock:
self._test_scrape_metadata(mock_metadata_nfo=mock)
+6 -5
View File
@@ -22,12 +22,13 @@ def _frontmatter_value(content: str, key: str) -> str:
def test_modified_builtin_skills_have_incremented_versions() -> None:
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
expected_versions = {
"database-operation": "3",
"moviepilot-api": "11",
"moviepilot-cli": "6",
"database-operation": "4",
"moviepilot-api": "12",
"moviepilot-cli": "7",
"moviepilot-update": "3",
"organize-files": "2",
"transfer-failed-retry": "3",
"organize-files": "3",
"transfer-failed-retry": "4",
"generate-identifiers": "3",
"create-moviepilot-plugin": "3",
}
+74 -8
View File
@@ -6,11 +6,23 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
CHAIN_ROOT = PROJECT_ROOT / "app" / "chain"
LEGACY_MUSIC_SCAN_ROOTS = (
PROJECT_ROOT / "app",
PROJECT_ROOT / "scripts",
)
MUSIC_SOURCE_CHAIN_FILES = (
"acoustid.py",
"douban.py",
"listenbrainz.py",
"lrclib.py",
"musicbrainz.py",
"theaudiodb.py",
)
def _imported_modules(path: Path) -> set[str]:
"""解析源码中的导入模块,包含函数内部的延迟导入。"""
tree = ast.parse(path.read_text(encoding="utf-8"))
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
modules: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
@@ -47,14 +59,68 @@ def test_chain_base_does_not_import_concrete_chains() -> None:
}
def test_music_chain_only_depends_on_chain_base() -> None:
"""音乐领域链不得反向依赖媒体编排链或其他业务链"""
imports = _imported_modules(CHAIN_ROOT / "music.py")
assert not {
module for module in imports
if module.startswith("app.chain.")
def test_legacy_music_chain_is_removed() -> None:
"""聚合全部音乐职责的旧 MusicChain 文件和导入不得重新出现"""
assert not (CHAIN_ROOT / "music.py").exists()
violations = {
str(path.relative_to(PROJECT_ROOT)): sorted(
module for module in _imported_modules(path) if module == "app.chain.music"
)
for root in LEGACY_MUSIC_SCAN_ROOTS
for path in root.rglob("*.py")
if "app.chain.music" in _imported_modules(path)
}
assert not violations
def test_music_source_chains_do_not_depend_on_public_orchestration_chains() -> None:
"""音乐数据源链不得反向依赖识别、刮削、搜索或推荐编排链。"""
forbidden = {
"app.chain.media",
"app.chain.recommend",
"app.chain.scraping",
"app.chain.search",
}
violations = {
filename: sorted(_imported_modules(CHAIN_ROOT / filename).intersection(forbidden))
for filename in MUSIC_SOURCE_CHAIN_FILES
if _imported_modules(CHAIN_ROOT / filename).intersection(forbidden)
}
assert not violations
def test_media_chain_excludes_scraping_and_music_exploration_methods() -> None:
"""MediaChain 只保留公共识别与详情路由,不得重新承接刮削或音乐探索职责。"""
path = CHAIN_ROOT / "media.py"
tree = ast.parse(path.read_text(encoding="utf-8"))
method_names = {
node.name
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
forbidden_methods = {
"async_get_doubaninfo_by_bangumiid",
"async_get_doubaninfo_by_tmdbid",
"async_get_tmdbinfo_by_bangumiid",
"async_get_tmdbinfo_by_doubanid",
"scrape_metadata",
"scrape_metadata_event",
"scrape_music_metadata",
"get_doubaninfo_by_bangumiid",
"get_doubaninfo_by_tmdbid",
"get_music_lyrics",
"get_tmdbinfo_by_bangumiid",
"get_tmdbinfo_by_doubanid",
"async_get_music_lyrics",
"music_chart",
"async_music_chart",
"music_discover",
"async_music_discover",
"async_music_fresh_releases",
}
assert not method_names.intersection(forbidden_methods)
assert "app.chain.scraping" not in _imported_modules(path)
def test_business_chains_delegate_recognition_to_media_chain() -> None:

Some files were not shown because too many files have changed in this diff Show More