mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
refactor(media): unify media identity and chain responsibilities
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 +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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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), ""
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
搜索集合信息(异步版本)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
|
||||
@@ -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 []
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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,
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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,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,
|
||||
):
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
@@ -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:
|
||||
"""异步按规范媒体身份删除订阅。"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
"""按规范媒体身份和类型查询整理记录。"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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_FLAG(v1)时默认全部兼容;否则需声明当前 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
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
搜索种子信息
|
||||
"""
|
||||
|
||||
@@ -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 事件的数据模型
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
文件整理历史记录
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
将前端清空输入框后残留的空字符串视为未提供,移除该键由字段默认值兜底。
|
||||
将前端清空输入框后残留的空字符串视为空值。
|
||||
|
||||
音乐等媒体类型的 season、total_episode、episode_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,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。"""
|
||||
|
||||
# 文件项
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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/异常,保留用户中断和进程退出语义。
|
||||
|
||||
@@ -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
|
||||
|
||||
+115
-17
@@ -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
@@ -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
@@ -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")` 会按策略写音频标签、封面和歌词,并返回歌词新增、已存在、未匹配和失败数量。
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 []),
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–24 and there is a multi-month gap between episode 12 and 13, then episodes 13–24 correspond to the user's "Season 2". If no such gap exists, tell user content is unavailable. Otherwise confirm the episode range with user.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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!
|
||||
```
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]})
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user