feat: unify media recognition and music lifecycle

This commit is contained in:
jxxghp
2026-08-12 06:51:58 +08:00
parent b018646f61
commit 97dba2a8ae
96 changed files with 4677 additions and 719 deletions
+7 -24
View File
@@ -3,39 +3,22 @@
from typing import Any, Optional
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
MUSIC_ENTITY_RECORDING,
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
)
from app.schemas.types import media_type_to_agent
from app.schemas.types import (
MUSIC_ENTITY_TYPES,
MUSIC_SUBSCRIBABLE_TYPES,
media_type_to_agent,
)
from app.utils.media import normalize_music_type
MUSIC_ENTITY_TYPES = frozenset({
MUSIC_ENTITY_RECORDING,
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
})
MUSIC_SUBSCRIBABLE_TYPES = frozenset({
MUSIC_ENTITY_RECORDING,
MUSIC_ENTITY_ALBUM,
})
MUSIC_TRACK_PREVIEW_LIMIT = 100
MUSIC_RELEASE_PREVIEW_LIMIT = 20
def normalize_music_type(
value: Optional[str],
*,
allow_artist: bool = True,
) -> Optional[str]:
"""规范化音乐实体类型,非法值返回 None。"""
normalized = str(value or "").strip().lower()
allowed = MUSIC_ENTITY_TYPES if allow_artist else MUSIC_SUBSCRIBABLE_TYPES
return normalized if normalized in allowed else None
def simplify_music_info(info: MusicInfo) -> dict[str, Any]:
"""精简音乐列表项,同时保留订阅和下载所需的稳定身份。"""
payload = {
+2 -2
View File
@@ -9,7 +9,7 @@ from app.agent.tools.tags import ToolTag
from app.chain.subscribe import SubscribeChain
from app.db.user_oper import UserOper
from app.log import logger
from app.schemas.types import MediaType, MessageChannel
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, MessageChannel
from ._music_utils import normalize_music_type
@@ -290,7 +290,7 @@ class AddSubscribeTool(MoviePilotTool):
if sid:
display_year = f" ({year})" if year else ""
music_label = (
"专辑" if normalized_music_type == "album" else "单曲"
"专辑" if normalized_music_type == MUSIC_ENTITY_ALBUM else "单曲"
) if normalized_music_type else ""
if message and "已存在" in message:
result_msg = f"{music_label}订阅已存在:{title}{display_year}"
+8 -3
View File
@@ -15,7 +15,12 @@ from app.modules.listenbrainz import (
LISTENBRAINZ_FRESH_MAX_DAYS,
LISTENBRAINZ_FRESH_SORTS,
)
from app.schemas.types import MediaType, media_type_to_agent
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaType,
media_type_to_agent,
)
from ._music_utils import normalize_music_type, simplify_music_info
@@ -176,10 +181,10 @@ class GetRecommendationsTool(MoviePilotTool):
sort_by=sort_by,
min_listen_count=max(0, min_listen_count or 0),
with_cover=bool(with_cover),
entity=normalized_music_type or "recording",
entity=normalized_music_type or MUSIC_ENTITY_RECORDING,
)
else:
if normalized_music_type and normalized_music_type != "album":
if normalized_music_type and normalized_music_type != MUSIC_ENTITY_ALBUM:
return "错误:ListenBrainz 新发行结果只支持 music_type='album'"
if fresh_sort not in LISTENBRAINZ_FRESH_SORTS:
return f"错误:无效的新发行排序 '{fresh_sort}'"
+2 -2
View File
@@ -11,7 +11,7 @@ from app.chain.download import DownloadChain
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.log import logger
from app.schemas import DownloaderTorrent
from app.schemas.types import TorrentQueryStatus, media_type_to_agent
from app.schemas.types import MUSIC_ENTITY_RECORDING, TorrentQueryStatus, media_type_to_agent
class QueryDownloadTasksInput(BaseModel):
@@ -124,7 +124,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
music_media = music_note.get("media") or {}
if media_type_to_agent(history.type) == "music":
media_payload.update({
"music_type": music_media.get("music_type") or "recording",
"music_type": music_media.get("music_type") or MUSIC_ENTITY_RECORDING,
"artists": music_media.get("artists") or [],
"album": music_media.get("album"),
"album_id": music_media.get("album_id"),
+1 -2
View File
@@ -10,10 +10,9 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.mediaserver import MediaServerChain
from app.core.context import MUSIC_ENTITY_ALBUM
from app.helper.mediaserver import MediaServerHelper
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, media_type_to_agent
from ._music_utils import normalize_music_type
+4 -3
View File
@@ -10,13 +10,13 @@ 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.core.context import (
from app.log import logger
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
MUSIC_ENTITY_RECORDING,
MediaType,
)
from app.log import logger
from app.schemas.types import MediaType
from ._music_utils import (
normalize_music_type,
simplify_music_album,
@@ -200,6 +200,7 @@ class QueryMediaDetailTool(MoviePilotTool):
source=media_source,
mediaid=media_id,
mtype=MediaType.MUSIC,
music_type=normalized_music_type,
)
if (
not mediainfo
@@ -11,7 +11,7 @@ from app.agent.tools.tags import ToolTag
from app.core.context import MediaInfo
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent
from ._music_utils import normalize_music_type
MAX_PAGE_SIZE = 50
@@ -133,7 +133,7 @@ class QueryPopularSubscribesTool(MoviePilotTool):
raw_type = str(sub.get("type") or "").strip().lower()
if raw_type in ["music", "音乐"]:
sub_music_type = normalize_music_type(
sub.get("music_type") or "recording",
sub.get("music_type") or MUSIC_ENTITY_RECORDING,
allow_artist=False,
)
if not sub_music_type:
@@ -9,7 +9,7 @@ from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.db.subscribehistory_oper import SubscribeHistoryOper
from app.log import logger
from app.schemas.types import MediaType, media_type_to_agent
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent
from ._music_utils import normalize_music_type
PAGE_SIZE = 20
@@ -197,7 +197,7 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
return True
if media_type_to_agent(getattr(record, "type", None)) != "music":
return False
return (getattr(record, "music_type", None) or "recording") == music_type
return (getattr(record, "music_type", None) or MUSIC_ENTITY_RECORDING) == music_type
@staticmethod
def _simplify_records(records) -> list:
@@ -217,7 +217,7 @@ class QuerySubscribeHistoryTool(MoviePilotTool):
"media_source": record.media_source,
"media_id": record.media_id,
"music_type": getattr(record, "music_type", None) or (
"recording" if media_type_to_agent(record.type) == "music" else None
MUSIC_ENTITY_RECORDING if media_type_to_agent(record.type) == "music" else None
),
"total_tracks": getattr(record, "total_tracks", None),
"poster": record.poster,
@@ -9,7 +9,7 @@ from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.schemas.types import media_type_to_agent
from app.schemas.types import MUSIC_ENTITY_RECORDING, media_type_to_agent
from ._music_utils import normalize_music_type
MAX_PAGE_SIZE = 50
@@ -97,7 +97,7 @@ class QuerySubscribeSharesTool(MoviePilotTool):
normalized_music_type = None
if normalized_type == "music":
normalized_music_type = normalize_music_type(
share.get("music_type") or "recording",
share.get("music_type") or MUSIC_ENTITY_RECORDING,
allow_artist=False,
)
if not normalized_music_type:
+3 -3
View File
@@ -10,7 +10,7 @@ from app.agent.tools.tags import ToolTag
from app.db.subscribe_oper import SubscribeOper
from app.log import logger
from app.schemas.subscribe import Subscribe as SubscribeSchema
from app.schemas.types import MediaType, media_type_to_agent
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaType, media_type_to_agent
from ._music_utils import normalize_music_type
PAGE_SIZE = 100
@@ -190,7 +190,7 @@ class QuerySubscribesTool(MoviePilotTool):
if media_id is not None and sub.media_id != media_id:
continue
if normalized_music_type:
sub_music_type = sub.music_type or "recording"
sub_music_type = sub.music_type or MUSIC_ENTITY_RECORDING
if sub_music_type != normalized_music_type:
continue
filtered_subscribes.append(sub)
@@ -217,7 +217,7 @@ class QuerySubscribesTool(MoviePilotTool):
payload["manual_total_episode"] = subscribe.manual_total_episode or 0
payload["type"] = media_type_to_agent(payload.get("type"))
if payload["type"] == "music" and not payload.get("music_type"):
payload["music_type"] = "recording"
payload["music_type"] = MUSIC_ENTITY_RECORDING
full_subscribes.append(payload)
result_json = json.dumps(full_subscribes, ensure_ascii=False, indent=2)
+11 -15
View File
@@ -9,12 +9,10 @@ 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.core.config import settings
from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_ARTIST
from app.log import logger
from app.schemas import FileItem
from app.schemas.types import MediaType, media_type_to_agent
from app.schemas.types import MUSIC_ENTITY_ARTIST, MediaType, media_type_to_agent
from ._music_utils import normalize_music_type, simplify_music_info
@@ -180,18 +178,16 @@ class ScrapeMetadataTool(MoviePilotTool):
mediainfo = None
if media_source and media_id:
if normalized_music_type == MUSIC_ENTITY_ALBUM:
album_info = await MusicChain().async_album(
source=media_source,
media_id=media_id,
)
mediainfo = album_info.to_music_info() if album_info else None
else:
mediainfo = await media_chain.async_recognize_media(
source=media_source,
mediaid=media_id,
mtype=MediaType.MUSIC,
)
recognize_kwargs = {
"source": media_source,
"mediaid": media_id,
"mtype": MediaType.MUSIC,
}
if normalized_music_type:
recognize_kwargs["music_type"] = normalized_music_type
mediainfo = await media_chain.async_recognize_media(
**recognize_kwargs
)
if not mediainfo:
return json.dumps({
"success": False,
+1 -8
View File
@@ -126,18 +126,11 @@ class SearchTorrentsTool(MoviePilotTool):
source=media_source,
mediaid=media_id,
mtype=media_type_enum,
music_type=normalized_music_type,
area=area or "title",
sites=sites,
cache_local=False,
)
if normalized_music_type:
filtered_torrents = [
context
for context in filtered_torrents or []
if getattr(context.media_info, "music_type", None)
== normalized_music_type
]
# 获取站点信息
all_indexers = await SitesHelper().async_get_indexers()
all_sites = [{"id": indexer.get("id"), "name": indexer.get("name")} for indexer in (all_indexers or [])]
+1 -1
View File
@@ -7,9 +7,9 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING
from app.log import logger
from app.schemas import FileItem, MediaType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING
from ._music_utils import normalize_music_type
+35 -2
View File
@@ -14,7 +14,13 @@ from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user
from app.helper.directory import DirectoryHelper
from app.schemas.types import SystemConfigKey
from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaType,
MusicTargetEntityType,
SystemConfigKey,
)
from app.utils.media import is_music_media_source, normalize_music_type
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -114,6 +120,7 @@ def add(
anilistid: Annotated[int | None, Body()] = None,
media_source: Annotated[MediaSource | None, Body()] = None,
media_id: Annotated[str | None, Body()] = None,
music_type: Annotated[MusicTargetEntityType | None, Body()] = None,
downloader: Annotated[str | None, Body()] = None,
# 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等
save_path: Annotated[str | None, Body()] = None,
@@ -122,8 +129,30 @@ def add(
"""
添加下载任务(不含媒体信息)
"""
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if music_type is not None and not normalized_music_type:
return schemas.Response(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
is_music = (
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
or is_music_media_source(media_source)
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
return schemas.Response(
success=False,
message="音乐下载只能使用音乐元数据源",
)
if is_music and not normalized_music_type:
normalized_music_type = MUSIC_ENTITY_RECORDING
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
metainfo = (
MusicChain.parse_query(torrent_in.title)
if is_music
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
)
# 媒体信息
if tmdbid or doubanid or bangumiid or anilistid or media_id:
mediainfo = MediaChain().recognize_media(
@@ -134,12 +163,16 @@ def add(
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
source=media_source,
obtain_images=False,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
if not mediainfo:
return schemas.Response(success=False, message="无法识别媒体信息")
+18 -2
View File
@@ -18,10 +18,11 @@ from app.db.models import User
from app.db.user_oper import get_current_active_user, get_current_active_superuser
from app.schemas import MediaType, MediaRecognizeConvertEventData
from app.schemas.category import CategoryConfig
from app.schemas.types import ChainEventType
from app.schemas.types import ChainEventType, MUSIC_ENTITY_RECORDING
from app.utils.media import (
MEDIA_SOURCE_ID_FIELDS,
is_music_media_source,
normalize_music_type,
parse_media_key,
)
@@ -30,13 +31,16 @@ MediaSource = str
def _is_valid_source_media_id(source: Optional[str], media_id: str) -> bool:
"""按媒体数据源校验原生 IDMusicBrainz 使用 UUID,其它内置来源使用数字 ID。"""
"""按媒体数据源校验原生 ID并兼容豆瓣音乐的曲目复合 ID。"""
if source == "musicbrainz":
try:
UUID(media_id)
return True
except (TypeError, ValueError):
return False
if source == "doubanmusic" and ":" in media_id:
album_id, track_number = media_id.split(":", 1)
return album_id.isdigit() and track_number.isdigit()
return media_id.isdigit()
@@ -267,6 +271,7 @@ def scrape(
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
type_name: Optional[MediaType] = None,
music_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
@@ -277,6 +282,7 @@ def scrape(
:param media_source: 请求级媒体数据源
:param media_id: 数据源原生ID
:param type_name: 媒体类型
:param music_type: 音乐实体类型,支持 recording 和 album
:param _: Token校验
"""
if not fileitem or not fileitem.path:
@@ -299,11 +305,21 @@ def scrape(
return schemas.Response(success=False, message="音乐元数据源只能用于音乐刮削")
music_info: Optional[MusicInfo] = None
if normalized_media_id:
normalized_music_type = normalize_music_type(
music_type or MUSIC_ENTITY_RECORDING,
allow_artist=False,
)
if not normalized_music_type:
return schemas.Response(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
# 音乐与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
music_info = MediaChain().recognize_media(
source=media_source or "musicbrainz",
mediaid=normalized_media_id,
mtype=MediaType.MUSIC,
music_type=normalized_music_type,
)
if not music_info:
return schemas.Response(success=False, message="刮削失败,无法识别音乐信息")
+8 -3
View File
@@ -63,10 +63,15 @@ async def recognize_music(
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MusicInfo:
"""根据音乐元数据来源和媒体 ID 获取标准详情,与影视识别共用统一入口。"""
recognize_kwargs = {
"source": request.source,
"mediaid": request.media_id,
"mtype": MediaType.MUSIC,
}
if request.music_type is not None:
recognize_kwargs["music_type"] = request.music_type
info = await MediaChain().async_recognize_media(
source=request.source,
mediaid=request.media_id,
mtype=MediaType.MUSIC,
**recognize_kwargs,
)
if not info:
raise HTTPException(status_code=404, detail="未识别到音乐信息")
+28 -11
View File
@@ -18,7 +18,7 @@ from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, ChainEventType
from app.utils.media import parse_media_key, resolve_media_identity
from app.utils.media import normalize_music_type, parse_media_key, resolve_media_identity
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -63,14 +63,30 @@ async def _resolve_media_search_params(
title: Optional[str] = None,
year: Optional[str] = None,
media_season: Optional[int] = None,
music_type: Optional[str] = None,
) -> tuple[Optional[dict], str]:
"""将任意来源媒体键解析为 SearchChain 可直接使用的识别参数。"""
normalized_music_type = None
if music_type:
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if not normalized_music_type:
return None, "音乐实体类型无效,仅支持 recording 或 album"
if media_type != MediaType.MUSIC:
return None, "music_type 仅能用于音乐资源搜索"
def build_params(source: str, source_media_id: str) -> dict:
"""构造带可选音乐实体命名空间的精确搜索参数。"""
params = {"source": source, "mediaid": source_media_id}
if normalized_music_type:
params["music_type"] = normalized_music_type
return params
source, source_media_id = parse_media_key(mediaid)
if source and source_media_id:
if source in {"themoviedb", "bangumi", "anilist"} \
and not source_media_id.isdigit():
return None, "媒体ID格式错误"
return {"source": source, "mediaid": source_media_id}, ""
return build_params(source, source_media_id), ""
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
@@ -82,10 +98,7 @@ async def _resolve_media_search_params(
event_data = event.event_data
search_id = event_data.media_dict.get("id")
if search_id is not None:
return {
"source": event_data.convert_type,
"mediaid": str(search_id),
}, ""
return build_params(event_data.convert_type, str(search_id)), ""
if not title:
return None, "未知的媒体ID"
@@ -98,16 +111,16 @@ async def _resolve_media_search_params(
if media_season is not None:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await MediaChain().async_recognize_by_meta(
meta,
obtain_images=False,
)
recognize_kwargs = {"obtain_images": False}
if normalized_music_type:
recognize_kwargs["music_type"] = normalized_music_type
mediainfo = await MediaChain().async_recognize_by_meta(meta, **recognize_kwargs)
if not mediainfo:
return None, "未识别到媒体信息"
source, source_media_id = resolve_media_identity(media=mediainfo)
if not source or not source_media_id:
return None, "媒体信息缺少有效ID"
return {"source": source, "mediaid": source_media_id}, ""
return build_params(source, source_media_id), ""
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
@@ -390,6 +403,7 @@ async def search_by_id_stream(
year: Optional[str] = None,
season: Optional[str] = None,
sites: Optional[str] = None,
music_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
) -> Any:
"""
@@ -409,6 +423,7 @@ async def search_by_id_stream(
title=title,
year=year,
media_season=media_season,
music_type=music_type,
)
if not search_params:
yield {"type": "error", "success": False, "message": message}
@@ -440,6 +455,7 @@ async def search_by_id(
year: Optional[str] = None,
season: Optional[str] = None,
sites: Optional[str] = None,
music_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
@@ -453,6 +469,7 @@ async def search_by_id(
title=title,
year=year,
media_season=media_season,
music_type=music_type,
)
if not search_params:
return schemas.Response(success=False, message=message)
+37 -13
View File
@@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.chain.music import MusicChain
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaInfo
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_apitoken
@@ -22,7 +23,13 @@ from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.scheduler import Scheduler
from app.schemas.event import SubscribeModifiedEventData
from app.schemas.types import MediaType, EventType, SystemConfigKey
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaType,
EventType,
SystemConfigKey,
)
from app.utils.media import normalize_media_source, parse_media_key
router = APIRouter()
@@ -117,6 +124,14 @@ def matches_subscribe_music_type(
or (music_type == MUSIC_ENTITY_RECORDING and subscribe_music_type is None)
def music_subscribe_title_candidates(title: str) -> List[str]:
"""生成音乐订阅标题兜底候选,保留精确标题并追加音乐语义解析结果。"""
parsed_title = MusicChain.parse_query(title).title
return list(dict.fromkeys(
candidate for candidate in (title, parsed_title) if candidate
))
async def list_subscribes_by_media_key(
db: AsyncSession,
media_key: str,
@@ -326,18 +341,27 @@ async def subscribe_mediaid(
title_check = not result and bool(title) and source != "themoviedb"
# 使用名称检查订阅
if title_check and title:
meta = MetaInfo(title)
if season is not None:
meta.begin_season = season
subscribes = await Subscribe.async_list_by_title(
db, title=meta.name, season=meta.begin_season
)
title_season = None
if music_type:
subscribes = [
subscribe for subscribe in subscribes
if matches_subscribe_music_type(subscribe, music_type)
]
result = select_accessible_subscribe(subscribes, current_user)
title_candidates = music_subscribe_title_candidates(title)
else:
title_meta = MetaInfo(title)
if season is not None:
title_meta.begin_season = season
title_season = title_meta.begin_season
title_candidates = [title_meta.name]
for candidate_title in title_candidates:
subscribes = await Subscribe.async_list_by_title(
db, title=candidate_title, season=title_season
)
if music_type:
subscribes = [
subscribe for subscribe in subscribes
if matches_subscribe_music_type(subscribe, music_type)
]
result = select_accessible_subscribe(subscribes, current_user)
if result:
break
return result if result else Subscribe()
+85 -11
View File
@@ -4,16 +4,28 @@ 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
from app.core.context import MediaInfo, MusicInfo
from app.core.meta import MetaMusic
from app.core.metainfo import MetaInfo
from app.db.models import User
from app.db.user_oper import (
get_current_active_superuser,
get_current_active_superuser_async,
)
from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaType,
MusicTargetEntityType,
)
from app.utils.crypto import HashUtils
from app.utils.media import (
is_music_media_source,
normalize_music_type,
resolve_media_identity,
)
router = APIRouter()
@@ -41,6 +53,7 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
torrent_hash = HashUtils.md5(
f"{context.torrent_info.title}{context.torrent_info.description}"
)
media_source, media_id = resolve_media_identity(media=context.media_info)
torrent_data.append(
{
"hash": torrent_hash,
@@ -55,6 +68,9 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
else "",
"media_year": context.media_info.year if context.media_info else "",
"media_type": context.media_info.type if context.media_info else "",
"media_source": media_source,
"media_id": media_id,
"music_type": getattr(context.media_info, "music_type", None),
"season_episode": context.meta_info.season_episode
if context.meta_info
else "",
@@ -181,6 +197,7 @@ async def reidentify_cache(
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
music_type: Optional[MusicTargetEntityType] = None,
_: User = Depends(get_current_active_superuser_async),
):
"""
@@ -193,6 +210,7 @@ async def reidentify_cache(
:param anilistid: 手动指定的 AniList ID
:param media_source: 媒体数据源
:param media_id: 数据源原生 ID
:param music_type: 音乐实体类型,仅支持单曲或专辑
:param _: 当前用户,必须是超级用户
"""
@@ -221,13 +239,53 @@ async def reidentify_cache(
if not target_context:
return schemas.Response(success=False, message="未找到指定的种子")
# 重新识别
meta = MetaInfo(
title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description,
existing_music_type = normalize_music_type(
getattr(target_context.media_info, "music_type", None),
allow_artist=False,
)
if tmdbid or doubanid or bangumiid or anilistid or media_source or media_id:
# 手动指定媒体信息
normalized_music_type = normalize_music_type(
music_type,
allow_artist=False,
)
if music_type is not None and not normalized_music_type:
return schemas.Response(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
is_music = (
getattr(target_context.media_info, "type", None) == MediaType.MUSIC
or isinstance(target_context.meta_info, MetaMusic)
or target_context.torrent_info.category
in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
or is_music_media_source(media_source)
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
return schemas.Response(
success=False,
message="音乐重新识别只能使用音乐元数据源",
)
if is_music and not normalized_music_type:
normalized_music_type = existing_music_type or MUSIC_ENTITY_RECORDING
# 重识别沿用原媒体域;音乐标题必须使用 MetaMusic,避免误入影视模块。
if is_music:
meta = (
target_context.meta_info
if isinstance(target_context.meta_info, MetaMusic)
else MusicChain.parse_query(target_context.torrent_info.title)
)
else:
meta = MetaInfo(
title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description,
)
has_explicit_id = bool(
tmdbid or doubanid or bangumiid or anilistid or media_id
)
if has_explicit_id:
# 手动指定媒体身份时执行精确识别。
mediainfo = await media_chain.async_recognize_media(
meta=meta,
tmdbid=tmdbid,
@@ -236,14 +294,27 @@ async def reidentify_cache(
anilistid=anilistid,
source=media_source,
mediaid=media_id,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
else:
# 自动重新识别
mediainfo = await media_chain.async_recognize_by_meta(meta)
# 未指定 ID 时按标题识别,请求级来源仍用于约束本次识别
mediainfo = await media_chain.async_recognize_by_meta(
meta,
source=media_source,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
if not mediainfo:
# 创建空的媒体信息
mediainfo = MediaInfo()
# 失败占位仍保留原媒体域,避免音乐缓存被误写进影视缓存文件。
mediainfo = (
MusicInfo(
music_type=normalized_music_type or MUSIC_ENTITY_RECORDING
)
if is_music
else MediaInfo()
)
else:
# 清理多余数据
mediainfo.clear()
@@ -266,6 +337,9 @@ async def reidentify_cache(
"media_type": mediainfo.type.value
if mediainfo and mediainfo.type
else "",
"media_source": getattr(mediainfo, "source", None),
"media_id": getattr(mediainfo, "media_id", None),
"music_type": getattr(mediainfo, "music_type", None),
},
)
except Exception as e:
+6
View File
@@ -53,6 +53,7 @@ def query_name(
media_path = DirectoryHelper.get_media_root_path(
rename_format=settings.RENAME_FORMAT(context.media_info.type),
rename_path=Path(new_path),
media_type=context.media_info.type,
)
if media_path:
new_name = media_path.name
@@ -338,6 +339,9 @@ def manual_transfer(
transer_item.media_id = (
history.media_id or transer_item.media_id
)
transer_item.music_type = (
getattr(history, "music_type", None) or transer_item.music_type
)
transer_item.season = (
int(str(history.seasons).replace("S", ""))
if history.seasons
@@ -459,6 +463,7 @@ def manual_transfer(
anilistid=transer_item.anilistid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
music_type=transer_item.music_type,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,
@@ -546,6 +551,7 @@ def manual_transfer(
anilistid=transer_item.anilistid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
music_type=transer_item.music_type,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,
+86 -71
View File
@@ -41,7 +41,7 @@ from app.schemas import (
MessageResponse,
)
from app.utils.identity import normalize_internal_user_id
from app.utils.media import is_music_media_source, normalize_media_source
from app.utils.media import normalize_media_source
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.category import CategoryConfig
from app.schemas.types import (
@@ -640,30 +640,7 @@ class ChainBase(metaclass=ABCMeta):
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""按媒体领域执行同步原生识别,音乐请求只允许进入音乐数据源"""
meta = module_kwargs.get("meta")
mtype = module_kwargs.get("mtype")
source = module_kwargs.get("source")
if (
isinstance(meta, MetaMusic)
or mtype == MediaType.MUSIC
or is_music_media_source(source)
):
# 延迟导入避免 ChainBase 与 MusicChain 形成模块加载环。
from app.chain.music import MusicChain
music_chain = MusicChain()
if source:
with fresh(not cache):
return music_chain.recognize_from_source(
source=source,
meta=meta if isinstance(meta, MetaMusic) else None,
mediaid=module_kwargs.get("mediaid"),
cache=cache,
)
if isinstance(meta, MetaMusic):
return music_chain.recognize_best(meta=meta, cache=cache)
return None
"""执行同步原生媒体模块识别,具体媒体领域可覆写该路由钩子"""
with fresh(not cache):
return self.run_module("recognize_media", **module_kwargs)
@@ -672,30 +649,7 @@ class ChainBase(metaclass=ABCMeta):
module_kwargs: dict,
cache: bool,
) -> Optional[MediaInfo]:
"""按媒体领域执行异步原生识别,音乐请求只允许进入音乐数据源"""
meta = module_kwargs.get("meta")
mtype = module_kwargs.get("mtype")
source = module_kwargs.get("source")
if (
isinstance(meta, MetaMusic)
or mtype == MediaType.MUSIC
or is_music_media_source(source)
):
# 延迟导入避免 ChainBase 与 MusicChain 形成模块加载环。
from app.chain.music import MusicChain
music_chain = MusicChain()
if source:
async with async_fresh(not cache):
return await music_chain.async_recognize_from_source(
source=source,
meta=meta if isinstance(meta, MetaMusic) else None,
mediaid=module_kwargs.get("mediaid"),
cache=cache,
)
if isinstance(meta, MetaMusic):
return await music_chain.async_recognize_best(meta=meta, cache=cache)
return None
"""执行异步原生媒体模块识别,具体媒体领域可覆写该路由钩子"""
async with async_fresh(not cache):
return await self.async_run_module(
"async_recognize_media", **module_kwargs
@@ -714,6 +668,7 @@ class ChainBase(metaclass=ABCMeta):
episode_group: Optional[str] = None,
cache: bool = True,
share_meta: MetaBase = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
识别媒体信息,不含Fanart图片
@@ -728,6 +683,7 @@ class ChainBase(metaclass=ABCMeta):
:param anilistid: AniList ID
:param episode_group: 剧集组
:param cache: 是否使用缓存
:param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑
:return: 识别的媒体信息,包括剧集信息
"""
# 识别用名中含指定信息情形
@@ -768,13 +724,21 @@ class ChainBase(metaclass=ABCMeta):
"episode_group": episode_group,
"cache": cache,
}
if music_type is not None:
module_kwargs["music_type"] = music_type
mediainfo = self._run_native_media_recognize(module_kwargs, cache)
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
mediainfo = self._supplement_media_recognize(
meta=meta, mtype=mtype, source=source,
mediaid=requested_mediaid, mediainfo=mediainfo,
music_type=music_type,
)
if mediainfo:
fallback_mediainfo = (
mediainfo
if mediainfo and not self._media_info_has_identity(mediainfo)
else None
)
if mediainfo and self._media_info_has_identity(mediainfo):
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID
if not getattr(mediainfo, "recognize_cache_hit", False):
MoviePilotServerHelper.report_recognize_share(
@@ -788,15 +752,19 @@ class ChainBase(metaclass=ABCMeta):
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
share_query_kwargs = {
"meta": meta,
"mtype": mtype,
"keyword_meta": share_query_meta,
}
if music_type is not None:
share_query_kwargs["music_type"] = music_type
shared_item = MoviePilotServerHelper.query_recognize_share(
meta=meta,
mtype=mtype,
keyword_meta=share_query_meta,
**share_query_kwargs,
)
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
if shared_params:
mediainfo = self._run_native_media_recognize(
{
shared_module_kwargs = {
"meta": meta,
"mtype": shared_params.get("mtype") or mtype,
"source": shared_params.get("source"),
@@ -807,14 +775,21 @@ class ChainBase(metaclass=ABCMeta):
"anilistid": shared_params.get("anilistid"),
"episode_group": episode_group,
"cache": cache,
},
}
shared_music_type = shared_params.get("music_type") or music_type
if shared_music_type is not None:
shared_module_kwargs["music_type"] = shared_music_type
mediainfo = self._run_native_media_recognize(
shared_module_kwargs,
cache,
)
if mediainfo:
if mediainfo and self._media_info_has_identity(mediainfo):
self._update_local_recognize_cache(shared_cache_meta, mediainfo)
self._record_media_recognize_share_hit()
return mediainfo
return None
if mediainfo and not fallback_mediainfo:
fallback_mediainfo = mediainfo
return fallback_mediainfo
async def async_recognize_media(
self,
@@ -829,6 +804,7 @@ class ChainBase(metaclass=ABCMeta):
episode_group: Optional[str] = None,
cache: bool = True,
share_meta: MetaBase = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
识别媒体信息,不含Fanart图片(异步版本)
@@ -843,6 +819,7 @@ class ChainBase(metaclass=ABCMeta):
:param anilistid: AniList ID
:param episode_group: 剧集组
:param cache: 是否使用缓存
:param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑
:return: 识别的媒体信息,包括剧集信息
"""
# 识别用名中含指定信息情形
@@ -883,13 +860,21 @@ class ChainBase(metaclass=ABCMeta):
"episode_group": episode_group,
"cache": cache,
}
if music_type is not None:
module_kwargs["music_type"] = music_type
mediainfo = await self._async_run_native_media_recognize(module_kwargs, cache)
# 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一)
mediainfo = await self._async_supplement_media_recognize(
meta=meta, mtype=mtype, source=source,
mediaid=requested_mediaid, mediainfo=mediainfo,
music_type=music_type,
)
if mediainfo:
fallback_mediainfo = (
mediainfo
if mediainfo and not self._media_info_has_identity(mediainfo)
else None
)
if mediainfo and self._media_info_has_identity(mediainfo):
# 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID
if not getattr(mediainfo, "recognize_cache_hit", False):
await MoviePilotServerHelper.async_report_recognize_share(
@@ -903,15 +888,19 @@ class ChainBase(metaclass=ABCMeta):
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
share_query_kwargs = {
"meta": meta,
"mtype": mtype,
"keyword_meta": share_query_meta,
}
if music_type is not None:
share_query_kwargs["music_type"] = music_type
shared_item = await MoviePilotServerHelper.async_query_recognize_share(
meta=meta,
mtype=mtype,
keyword_meta=share_query_meta,
**share_query_kwargs,
)
shared_params = MoviePilotServerHelper.to_recognize_params(shared_item)
if shared_params:
mediainfo = await self._async_run_native_media_recognize(
{
shared_module_kwargs = {
"meta": meta,
"mtype": shared_params.get("mtype") or mtype,
"source": shared_params.get("source"),
@@ -922,14 +911,21 @@ class ChainBase(metaclass=ABCMeta):
"anilistid": shared_params.get("anilistid"),
"episode_group": episode_group,
"cache": cache,
},
}
shared_music_type = shared_params.get("music_type") or music_type
if shared_music_type is not None:
shared_module_kwargs["music_type"] = shared_music_type
mediainfo = await self._async_run_native_media_recognize(
shared_module_kwargs,
cache,
)
if mediainfo:
if mediainfo and self._media_info_has_identity(mediainfo):
await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo)
await run_in_threadpool(self._record_media_recognize_share_hit)
return mediainfo
return None
if mediainfo and not fallback_mediainfo:
fallback_mediainfo = mediainfo
return fallback_mediainfo
@staticmethod
def _media_recognize_plugin_payload(
@@ -938,6 +934,7 @@ class ChainBase(metaclass=ABCMeta):
source: Optional[str],
mediaid: Optional[str],
is_music: bool,
music_type: Optional[str] = None,
) -> dict:
"""
构造媒体识别链式事件的已知要素载荷,供插件匹配媒体信息;影视与音乐统一协议,
@@ -952,6 +949,7 @@ class ChainBase(metaclass=ABCMeta):
"isrc": getattr(meta, "isrc", None),
"source": source,
"media_id": mediaid,
"music_type": music_type,
}
return {
"title": getattr(meta, "title", None) or getattr(meta, "name", None),
@@ -968,6 +966,7 @@ class ChainBase(metaclass=ABCMeta):
event_data: dict,
is_music: bool,
mtype: Optional[MediaType] = None,
music_type: Optional[str] = None,
) -> Optional[MediaInfo]:
"""
解析插件返回的媒体信息,缺少数据源或身份字段的结果不采信;
@@ -989,6 +988,12 @@ class ChainBase(metaclass=ABCMeta):
info: MediaInfo = MusicInfo.from_dict(plugin_info)
if not info.source or not info.media_id:
return None
if music_type and info.music_type != music_type:
logger.warn(
f"插件返回的音乐实体类型为 {info.music_type}"
f"与请求的 {music_type} 不一致,忽略 ..."
)
return None
return info
# 影视:插件未提供类型时使用请求推断的类型
if not plugin_info.get("type") and mtype:
@@ -1022,6 +1027,7 @@ class ChainBase(metaclass=ABCMeta):
source: Optional[str],
mediaid: Optional[str],
mediainfo,
music_type: Optional[str] = None,
):
"""
媒体识别插件补充(影视与音乐统一):原生模块未给出带远端身份的结果时,
@@ -1040,11 +1046,15 @@ class ChainBase(metaclass=ABCMeta):
return mediainfo
result: Event = self.eventmanager.send_event(
etype,
self._media_recognize_plugin_payload(meta, mtype, source, mediaid, is_music),
self._media_recognize_plugin_payload(
meta, mtype, source, mediaid, is_music, music_type
),
)
if not result:
return mediainfo
plugin_info = self._media_info_from_plugin(result.event_data or {}, is_music, mtype)
plugin_info = self._media_info_from_plugin(
result.event_data or {}, is_music, mtype, music_type
)
if not plugin_info:
return mediainfo
logger.info(
@@ -1060,6 +1070,7 @@ class ChainBase(metaclass=ABCMeta):
source: Optional[str],
mediaid: Optional[str],
mediainfo,
music_type: Optional[str] = None,
):
"""媒体识别插件补充的异步版本,影视与音乐统一流程"""
is_music = (
@@ -1075,11 +1086,15 @@ class ChainBase(metaclass=ABCMeta):
return mediainfo
result: Event = await self.eventmanager.async_send_event(
etype,
self._media_recognize_plugin_payload(meta, mtype, source, mediaid, is_music),
self._media_recognize_plugin_payload(
meta, mtype, source, mediaid, is_music, music_type
),
)
if not result:
return mediainfo
plugin_info = self._media_info_from_plugin(result.event_data or {}, is_music, mtype)
plugin_info = self._media_info_from_plugin(
result.event_data or {}, is_music, mtype, music_type
)
if not plugin_info:
return mediainfo
logger.info(
+24 -8
View File
@@ -16,7 +16,6 @@ from app.chain.storage import StorageChain
from app.core.cache import FileCache
from app.core.config import settings, global_vars
from app.core.context import (
MUSIC_ENTITY_ALBUM,
Context,
MediaInfo,
MusicInfo,
@@ -24,7 +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 MetaBase, MetaMusic
from app.core.metainfo import MetaInfo
from app.db.downloadfailure_oper import DownloadFailureOper
from app.db.downloadhistory_oper import DownloadHistoryOper
@@ -35,7 +34,7 @@ from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import ExistMediaInfo, FileURI, NotExistMediaInfo, DownloaderTorrent, Notification, ResourceSelectionEventData, \
ResourceDownloadEventData
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \
ChainEventType
from app.utils.http import RequestUtils
from app.utils.media import build_media_key, resolve_media_identity
@@ -112,12 +111,13 @@ class DownloadChain(ChainBase):
if not file_list:
return "专辑资源无法校验:种子未提供文件清单,不能确认整专曲目"
audio_files = {
str(Path(str(file)))
track_identities = {
identity
for file in file_list
if Path(str(file)).suffix.lower() in settings.RMT_AUDIOEXT
and (identity := DownloadChain._music_resource_track_identity(file))
}
actual_tracks = len(audio_files)
actual_tracks = len(track_identities)
if actual_tracks < expected_tracks:
return (
f"专辑资源不完整:专辑共 {expected_tracks} 首,"
@@ -127,6 +127,21 @@ class DownloadChain(ChainBase):
context.confirmed_full_coverage = True
return None
@staticmethod
def _music_resource_track_identity(file: str) -> Optional[Tuple[int, Union[int, str]]]:
"""从资源文件路径提取盘号和曲序,缺少曲序时按归一化曲名去重。"""
file_path = Path(str(file))
file_meta = MetaMusic(
org_string=file_path.name,
title=file_path.stem,
).apply_path_context(file_path)
track_identity: Union[int, str, None] = file_meta.track_number
if track_identity is None:
track_identity = StringUtils.clear_upper(file_meta.title or file_path.stem)
if track_identity in (None, ""):
return None
return file_meta.disc_number or 1, track_identity
@staticmethod
def _normalize_indirect_download_url(url: str, base_url: Optional[str] = None) -> str:
"""
@@ -458,7 +473,7 @@ class DownloadChain(ChainBase):
return False, "字幕下载链接为空", []
metainfo = MetaInfo(title=subtitle.title, subtitle=subtitle.description)
mediainfo = self.recognize_media(
mediainfo = MediaChain().recognize_media(
meta=metainfo,
source=media_source,
mediaid=media_id,
@@ -1026,6 +1041,7 @@ class DownloadChain(ChainBase):
anilistid=_media.anilist_id,
media_source=media_source,
media_id=media_id,
music_type=getattr(_media, "music_type", None),
seasons=_meta.season,
episodes=download_episodes or _meta.episode,
image=_media.get_backdrop_image(),
@@ -1773,7 +1789,7 @@ class DownloadChain(ChainBase):
else:
if not mediainfo.seasons:
# 补充媒体信息
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
mediainfo: MediaInfo = MediaChain().recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
+639 -107
View File
File diff suppressed because it is too large Load Diff
+108 -35
View File
@@ -10,8 +10,6 @@ from app.chain import ChainBase
from app.core.cache import async_fresh, fresh
from app.core.config import settings
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MusicAlbumInfo,
MusicArtistInfo,
MusicInfo,
@@ -20,11 +18,12 @@ from app.core.context import (
from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.schemas.types import MediaType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType
from app.utils.media import (
MUSIC_MEDIA_SOURCE_ORDER,
is_music_media_source,
normalize_media_source,
normalize_music_type,
)
from app.utils.zhconv import convert as zhconv_convert
@@ -32,8 +31,11 @@ from app.utils.zhconv import convert as zhconv_convert
class MusicChain(ChainBase):
"""音乐元数据搜索、探索与站点搜索参数编排链;媒体识别统一入口见 MediaChain。"""
# 专辑目录匹配结果缓存:{目录路径: (音频文件数, 匹配结果)},避免逐文件整理时重复请求远端
_album_dir_cache: dict[str, tuple[int, dict[str, MusicInfo]]] = {}
# 专辑目录匹配结果缓存:目录内相对路径变化时失效,标签写回不触发重复远端匹配。
_album_dir_cache: dict[
str,
tuple[tuple[str, ...], dict[str, MusicInfo]],
] = {}
_album_dir_cache_max = 128
# 目录级匹配至少需要两个音频文件,单文件由单曲搜索链路处理
_album_match_min_files = 2
@@ -66,9 +68,15 @@ class MusicChain(ChainBase):
return cls._unique_texts(keywords)
@classmethod
def matches_site_resource(cls, music: MusicInfo, resource_title: str) -> bool:
"""判断站点资源标题是否包含订阅目标名称,避免宽泛搜索结果串专辑或串单曲。"""
normalized_resource = cls._normalize_match_text(resource_title)
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:
@@ -185,16 +193,29 @@ class MusicChain(ChainBase):
meta: Optional[MetaMusic] = None,
mediaid: Optional[str] = None,
cache: bool = True,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""只调用指定音乐数据源进行识别,拒绝影视未知来源。"""
"""只调用指定音乐数据源识别指定实体,拒绝影视未知来源和跨实体结果"""
normalized_source = normalize_media_source(source)
if not is_music_media_source(normalized_source):
return None
return self._recognize_from_source(
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,
source=normalized_source,
cache=cache,
mediaid=mediaid,
music_type=normalized_music_type,
)
return self._validate_source_recognize_result(
result=result,
source=normalized_source,
mediaid=mediaid,
music_type=normalized_music_type,
)
async def async_recognize_from_source(
@@ -203,16 +224,29 @@ class MusicChain(ChainBase):
meta: Optional[MetaMusic] = None,
mediaid: Optional[str] = None,
cache: bool = True,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步只调用指定音乐数据源进行识别,拒绝影视未知来源。"""
"""异步只调用指定音乐数据源识别指定实体,拒绝影视未知来源和跨实体结果"""
normalized_source = normalize_media_source(source)
if not is_music_media_source(normalized_source):
return None
return await self._async_recognize_from_source(
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,
source=normalized_source,
cache=cache,
mediaid=mediaid,
music_type=normalized_music_type,
)
return self._validate_source_recognize_result(
result=result,
source=normalized_source,
mediaid=mediaid,
music_type=normalized_music_type,
)
async def async_search(
@@ -433,24 +467,52 @@ class MusicChain(ChainBase):
return None
return result
@staticmethod
def _validate_source_recognize_result(
result: Optional[MusicInfo],
source: str,
mediaid: Optional[str],
music_type: Optional[str],
) -> Optional[MusicInfo]:
"""校验指定来源的识别结果,显式 ID 不允许被另一实体或另一身份替代。"""
if not isinstance(result, MusicInfo):
return None
if result.source and result.source != source:
return None
if music_type and result.music_type != music_type:
return None
if mediaid and (
not result.source
or not result.media_id
or str(result.media_id) != str(mediaid)
):
return None
return result
def _recognize_from_source(
self,
meta: Optional[MetaMusic],
source: str,
cache: bool,
mediaid: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""调用声明了指定音乐来源的系统模块,隔离单个来源的查询失败。"""
module = self._music_recognize_module(source)
if not module:
return None
try:
recognize_kwargs = {
"meta": meta,
"mtype": MediaType.MUSIC,
"source": source,
"mediaid": mediaid,
"cache": cache,
}
if music_type is not None:
recognize_kwargs["music_type"] = music_type
return module.recognize_media(
meta=meta,
mtype=MediaType.MUSIC,
source=source,
mediaid=mediaid,
cache=cache,
**recognize_kwargs,
)
except Exception as err:
logger.warning(f"{source} 音乐自动识别失败:{err}")
@@ -462,28 +524,28 @@ class MusicChain(ChainBase):
source: str,
cache: bool,
mediaid: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步调用指定音乐来源模块,单个来源失败不影响其它候选。"""
module = self._music_recognize_module(source)
if not module:
return None
try:
recognize_kwargs = {
"meta": meta,
"mtype": MediaType.MUSIC,
"source": source,
"mediaid": mediaid,
"cache": cache,
}
if music_type is not None:
recognize_kwargs["music_type"] = music_type
async_method = getattr(module, "async_recognize_media", None)
if async_method:
return await async_method(
meta=meta,
mtype=MediaType.MUSIC,
source=source,
mediaid=mediaid,
cache=cache,
)
return await async_method(**recognize_kwargs)
return await run_in_threadpool(
module.recognize_media,
meta=meta,
mtype=MediaType.MUSIC,
source=source,
mediaid=mediaid,
cache=cache,
**recognize_kwargs,
)
except Exception as err:
logger.warning(f"{source} 音乐自动识别失败:{err}")
@@ -621,14 +683,15 @@ class MusicChain(ChainBase):
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] == len(files):
# 新增、删除或重命名音频时重新匹配;标签写回不会改变相对路径签名。
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] = (len(files), matched)
self._album_dir_cache[cache_key] = (signature, matched)
return matched
async def async_recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]:
@@ -662,6 +725,14 @@ class MusicChain(ChainBase):
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:
"""读取本地音频标签,标签缺失时用文件名和目录线索补齐。"""
@@ -669,9 +740,11 @@ class MusicChain(ChainBase):
if file_path.exists() and file_path.is_file():
return AudioMetadataHelper.read(file_path)
meta = MetaMusic(
org_string=file_path.stem, title=file_path.stem, parse_title=True
org_string=file_path.name,
title=file_path.stem,
audio_format=file_path.suffix.lstrip(".").upper() or None,
)
# WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索
# apply_path_context 会先剥离曲序再解析文件名,不能在构造阶段提前解析一次。
return meta.apply_path_context(file_path)
def _match_album_directory(
@@ -680,7 +753,7 @@ class MusicChain(ChainBase):
files: list[Path],
) -> dict[str, MusicInfo]:
"""执行目录级专辑匹配,并把专辑曲目对位到具体音频文件。"""
# 音频标签读取归口 MediaChain,延迟导入避免模块加载阶段双向依赖
# 标签读取属于音乐领域;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):
+33 -11
View File
@@ -12,6 +12,7 @@ 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
@@ -234,6 +235,8 @@ class SearchChain(ChainBase):
"sites": str(params.get("sites") or ""),
"result_type": str(params.get("result_type") or "torrent"),
}
if params.get("music_type"):
normalized["music_type"] = str(params["music_type"])
return normalized if normalized["keyword"] else None
def save_last_search_params(
@@ -247,6 +250,7 @@ class SearchChain(ChainBase):
season: Optional[int] = None,
episode: Optional[int] = None,
sites: Optional[List[int]] = None,
music_type: Optional[str] = None,
result_type: Optional[str] = "torrent",
) -> None:
"""
@@ -262,6 +266,7 @@ class SearchChain(ChainBase):
"season": season,
"episode": episode,
"sites": self._stringify_sites(sites),
"music_type": music_type,
"result_type": result_type or "torrent",
}
)
@@ -279,6 +284,7 @@ class SearchChain(ChainBase):
season: Optional[int] = None,
episode: Optional[int] = None,
sites: Optional[List[int]] = None,
music_type: Optional[str] = None,
result_type: Optional[str] = "torrent",
) -> None:
"""
@@ -294,6 +300,7 @@ class SearchChain(ChainBase):
"season": season,
"episode": episode,
"sites": self._stringify_sites(sites),
"music_type": music_type,
"result_type": result_type or "torrent",
}
)
@@ -521,6 +528,7 @@ class SearchChain(ChainBase):
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
music_type: Optional[str] = None,
) -> List[Context]:
"""
根据数据源媒体 ID 搜索资源精确匹配不过滤本地存在的资源
@@ -530,6 +538,7 @@ class SearchChain(ChainBase):
:param anilistid: AniList ID
:param source: 媒体数据源
:param mediaid: 数据源原生 ID
:param music_type: 音乐实体类型
:param mtype: 媒体电影 or 电视剧
:param area: 搜索范围title or imdbid
:param season: 季数
@@ -546,11 +555,13 @@ class SearchChain(ChainBase):
area=area,
season=season,
sites=sites,
music_type=music_type,
)
# 音乐统一在 recognize_media 内路由到 MusicChain
mediainfo = self.recognize_media(
mediainfo = MediaChain().recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
music_type=music_type,
)
if not mediainfo:
logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!')
@@ -746,7 +757,7 @@ class SearchChain(ChainBase):
sites=sites,
result_type="subtitle",
)
mediainfo = await self.async_recognize_media(
mediainfo = await MediaChain().async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
@@ -802,7 +813,7 @@ class SearchChain(ChainBase):
sites=sites,
result_type="subtitle",
)
mediainfo = await self.async_recognize_media(
mediainfo = await MediaChain().async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
@@ -849,6 +860,7 @@ class SearchChain(ChainBase):
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
music_type: Optional[str] = None,
) -> List[Context]:
"""
根据数据源媒体 ID 异步搜索资源精确匹配不过滤本地存在的资源
@@ -858,6 +870,7 @@ class SearchChain(ChainBase):
:param anilistid: AniList ID
:param source: 媒体数据源
:param mediaid: 数据源原生 ID
:param music_type: 音乐实体类型
:param mtype: 媒体电影 or 电视剧
:param area: 搜索范围title or imdbid
:param season: 季数
@@ -874,11 +887,13 @@ class SearchChain(ChainBase):
area=area,
season=season,
sites=sites,
music_type=music_type,
)
# 音乐统一在 async_recognize_media 内路由到 MusicChain
mediainfo = await self.async_recognize_media(
mediainfo = await MediaChain().async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
music_type=music_type,
)
if not mediainfo:
logger.error(
@@ -1064,6 +1079,7 @@ class SearchChain(ChainBase):
cache_local: bool = False,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
source: Optional[str] = None, mediaid: Optional[str] = None,
music_type: Optional[str] = None,
) -> AsyncIterator[dict]:
"""
根据数据源媒体 ID 渐进式搜索资源先返回站点原始候选再返回过滤匹配后的最终结果
@@ -1078,11 +1094,13 @@ class SearchChain(ChainBase):
area=area,
season=season,
sites=sites,
music_type=music_type,
)
# 音乐统一在 async_recognize_media 内路由到 MusicChain
mediainfo = await self.async_recognize_media(
mediainfo = await MediaChain().async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
music_type=music_type,
)
if not mediainfo:
logger.error(
@@ -1413,7 +1431,11 @@ class SearchChain(ChainBase):
torrent
for torrent in torrents or []
if torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value)
and MusicChain.matches_site_resource(mediainfo, torrent.title)
and MusicChain.matches_site_resource(
mediainfo,
torrent.title,
torrent.description,
)
]
def _process_music(
@@ -1521,7 +1543,7 @@ class SearchChain(ChainBase):
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = self.recognize_media(
mediainfo: MediaInfo = MediaChain().recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
@@ -1614,7 +1636,7 @@ class SearchChain(ChainBase):
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = await self.async_recognize_media(
mediainfo: MediaInfo = await MediaChain().async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
@@ -1714,7 +1736,7 @@ class SearchChain(ChainBase):
# 补充媒体信息
if not mediainfo.names:
mediainfo = await self.async_recognize_media(
mediainfo = await MediaChain().async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
@@ -1969,7 +1991,7 @@ class SearchChain(ChainBase):
logger.info(f'开始精确搜索字幕,关键词:{mediainfo.title} ...')
if not mediainfo.names:
mediainfo = await self.async_recognize_media(
mediainfo = await MediaChain().async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
@@ -2050,7 +2072,7 @@ class SearchChain(ChainBase):
logger.info(f'开始渐进式精确搜索字幕,关键词:{mediainfo.title} ...')
if not mediainfo.names:
mediainfo = await self.async_recognize_media(
mediainfo = await MediaChain().async_recognize_media(
mtype=mediainfo.type,
**self._media_recognize_kwargs(mediainfo),
)
+135 -49
View File
@@ -18,8 +18,6 @@ from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings, global_vars
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
Context,
MediaInfo,
MusicInfo,
@@ -50,9 +48,10 @@ from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import (MediaRecognizeConvertEventData, SubscribeEpisodesRefreshEventData,
SubscribeCompletionCheckEventData)
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \
ContentType
from app.utils.media import (
MUSIC_SUBSCRIBABLE_TYPES,
build_media_key,
normalize_media_source,
parse_media_key,
@@ -62,6 +61,15 @@ from app.utils.media import (
subscribe_interaction_manager = SlashInteractionManager()
def _normalize_music_total_tracks(value: Any) -> Optional[int]:
"""将专辑曲目总数归一为正整数,无效或未知值返回 None。"""
try:
total_tracks = int(value or 0)
except (TypeError, ValueError):
return None
return total_tracks if total_tracks > 0 else None
def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
"""
按订阅对象构造主程序链路共用的媒体元数据
@@ -930,6 +938,7 @@ class SubscribeChain(ChainBase):
logger.info(f'开始添加订阅,标题:{title} ...')
mediainfo = None
requested_music_type = kwargs.get("music_type")
metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
if year:
metainfo.year = year
@@ -950,11 +959,23 @@ class SubscribeChain(ChainBase):
)
if resolved_source and resolved_media_id:
media_source, media_id = resolved_source, resolved_media_id
# 音乐身份落到 meta,由统一 recognize_by_meta 的详情分支处理,不再单独编排 recognize+search
# 音乐身份同步落到 meta;显式来源与 ID 直接走统一识别入口,不允许失败后换目标。
if mtype == MediaType.MUSIC and media_id:
metainfo.media_id = str(media_id)
if mtype != MediaType.MUSIC and any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = self.recognize_media(
has_music_identity = bool(
mtype == MediaType.MUSIC and media_source and media_id
)
if has_music_identity:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
mtype=MediaType.MUSIC,
source=media_source,
mediaid=str(media_id),
music_type=requested_music_type,
cache=False,
)
elif mtype != MediaType.MUSIC and any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = MediaChain().recognize_media(
meta=metainfo,
mtype=mtype,
source=media_source,
@@ -975,14 +996,14 @@ class SubscribeChain(ChainBase):
if season is None:
season = meta.begin_season
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源
# 音乐与影视共用统一 recognize_by_meta 入口,MediaChain 按 MetaMusic 路由到 MusicChain。
if not mediainfo:
# 没有稳定音乐身份时才允许按名称识别;影视保留原有同源兜底行为
if not mediainfo and not has_music_identity:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
source=media_source,
episode_group=episode_group,
obtain_images=False,
music_type=requested_music_type,
)
# 音乐 recognize_by_meta 未命中远端时返回离线兜底,订阅创建要求真实命中
if mtype == MediaType.MUSIC and mediainfo and not mediainfo.source:
@@ -996,7 +1017,7 @@ class SubscribeChain(ChainBase):
if mtype == MediaType.MUSIC:
music_error = self._validate_music_subscribe_target(
mediainfo,
requested_music_type=kwargs.get("music_type"),
requested_music_type=requested_music_type,
)
if music_error:
logger.warning(f"音乐订阅目标校验失败:{title} - {music_error}")
@@ -1010,7 +1031,7 @@ class SubscribeChain(ChainBase):
if not kwargs.get('total_episode'):
if not mediainfo.seasons or episode_group:
# 补充媒体信息
mediainfo = self.recognize_media(mtype=mediainfo.type,
mediainfo = MediaChain().recognize_media(mtype=mediainfo.type,
**_media_recognize_kwargs(mediainfo),
episode_group=episode_group,
cache=False)
@@ -1158,6 +1179,7 @@ class SubscribeChain(ChainBase):
logger.info(f'开始添加订阅,标题:{title} ...')
mediainfo = None
requested_music_type = kwargs.get("music_type")
metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
if year:
metainfo.year = year
@@ -1178,11 +1200,23 @@ class SubscribeChain(ChainBase):
)
if resolved_source and resolved_media_id:
media_source, media_id = resolved_source, resolved_media_id
# 音乐身份落到 meta,由统一 recognize_by_meta 的详情分支处理,不再单独编排 recognize+search
# 音乐身份同步落到 meta;显式来源与 ID 直接走统一识别入口,不允许失败后换目标。
if mtype == MediaType.MUSIC and media_id:
metainfo.media_id = str(media_id)
if mtype != MediaType.MUSIC and any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = await self.async_recognize_media(
has_music_identity = bool(
mtype == MediaType.MUSIC and media_source and media_id
)
if has_music_identity:
mediainfo = await MediaChain().async_recognize_media(
meta=metainfo,
mtype=MediaType.MUSIC,
source=media_source,
mediaid=str(media_id),
music_type=requested_music_type,
cache=False,
)
elif mtype != MediaType.MUSIC and any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
mediainfo = await MediaChain().async_recognize_media(
meta=metainfo,
mtype=mtype,
source=media_source,
@@ -1203,14 +1237,14 @@ class SubscribeChain(ChainBase):
if season is None:
season = meta.begin_season
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源
# 音乐与影视共用统一 recognize_by_meta 入口,MediaChain 按 MetaMusic 路由到 MusicChain。
if not mediainfo:
# 没有稳定音乐身份时才允许按名称识别;影视保留原有同源兜底行为
if not mediainfo and not has_music_identity:
mediainfo = await MediaChain().async_recognize_by_meta(
metainfo,
source=media_source,
episode_group=episode_group,
obtain_images=False,
music_type=requested_music_type,
)
# 音乐 recognize_by_meta 未命中远端时返回离线兜底,订阅创建要求真实命中
if mtype == MediaType.MUSIC and mediainfo and not mediainfo.source:
@@ -1224,7 +1258,7 @@ class SubscribeChain(ChainBase):
if mtype == MediaType.MUSIC:
music_error = self._validate_music_subscribe_target(
mediainfo,
requested_music_type=kwargs.get("music_type"),
requested_music_type=requested_music_type,
)
if music_error:
logger.warning(f"音乐订阅目标校验失败:{title} - {music_error}")
@@ -1238,7 +1272,7 @@ class SubscribeChain(ChainBase):
if not kwargs.get('total_episode'):
if not mediainfo.seasons or episode_group:
# 补充媒体信息
mediainfo = await self.async_recognize_media(mtype=mediainfo.type,
mediainfo = await MediaChain().async_recognize_media(mtype=mediainfo.type,
**_media_recognize_kwargs(mediainfo),
episode_group=episode_group,
cache=False)
@@ -1391,20 +1425,15 @@ class SubscribeChain(ChainBase):
if mediainfo.type != MediaType.MUSIC:
return "识别结果不是音乐"
music_type = getattr(mediainfo, "music_type", None)
supported_types = {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}
if requested_music_type and requested_music_type not in supported_types:
if requested_music_type and requested_music_type not in MUSIC_SUBSCRIBABLE_TYPES:
return "音乐订阅仅支持单曲或专辑"
if music_type not in supported_types:
if music_type not in MUSIC_SUBSCRIBABLE_TYPES:
return "音乐订阅仅支持单曲或专辑"
if requested_music_type and requested_music_type != music_type:
return f"音乐订阅类型不匹配:请求 {requested_music_type},识别为 {music_type}"
if music_type == MUSIC_ENTITY_ALBUM:
try:
total_tracks = int(getattr(mediainfo, "total_tracks", None) or 0)
except (TypeError, ValueError):
total_tracks = 0
if total_tracks <= 0:
return "专辑总曲目数未知,无法校验整张专辑资源"
if music_type == MUSIC_ENTITY_ALBUM \
and _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None)) is None:
return "专辑总曲目数未知,无法校验整张专辑资源"
return None
@staticmethod
@@ -1416,15 +1445,31 @@ class SubscribeChain(ChainBase):
if not mediainfo:
return None
expected_type = getattr(subscribe, "music_type", None)
if expected_type and expected_type not in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
actual_type = getattr(mediainfo, "music_type", None)
if expected_type and expected_type not in MUSIC_SUBSCRIBABLE_TYPES:
logger.warning(f"音乐订阅 {subscribe.name} 的实体类型无效:{expected_type}")
return None
if expected_type and mediainfo.music_type != expected_type:
if actual_type not in MUSIC_SUBSCRIBABLE_TYPES:
logger.warning(
f"音乐订阅 {subscribe.name} 识别为不可订阅实体:{actual_type}"
)
if expected_type in MUSIC_SUBSCRIBABLE_TYPES:
return SubscribeChain._music_info_from_subscribe(subscribe)
return None
if expected_type and actual_type != expected_type:
logger.warning(
f"音乐订阅 {subscribe.name} 实体不匹配:"
f"订阅为 {expected_type},远端识别为 {mediainfo.music_type},使用订阅快照"
f"订阅为 {expected_type},远端识别为 {actual_type},使用订阅快照"
)
return SubscribeChain._music_info_from_subscribe(subscribe)
if actual_type == MUSIC_ENTITY_ALBUM:
remote_total = _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None))
stored_total = _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None))
resolved_total = remote_total or stored_total
if resolved_total is not None and mediainfo.total_tracks != resolved_total:
# 识别模块结果可能来自共享缓存,补齐订阅快照时不得原地修改。
mediainfo = copy.copy(mediainfo)
mediainfo.total_tracks = resolved_total
return mediainfo
@staticmethod
@@ -1436,6 +1481,7 @@ class SubscribeChain(ChainBase):
source=subscribe.media_source,
mediaid=str(subscribe.media_id),
mtype=MediaType.MUSIC,
music_type=getattr(subscribe, "music_type", None),
)
if mediainfo:
return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
@@ -1447,11 +1493,13 @@ class SubscribeChain(ChainBase):
# 缺少远端 ID 的专辑不能退化为单曲识别,使用已保存专辑快照更可靠。
return SubscribeChain._music_info_from_subscribe(subscribe)
# 旧订阅没有实体类型时只允许走 Recording 识别,不能从全局混合搜索中猜成专辑或艺术家。
return MediaChain().recognize_media(
mediainfo = MediaChain().recognize_media(
meta=build_subscribe_meta(subscribe),
mtype=MediaType.MUSIC,
source=subscribe.media_source,
music_type=MUSIC_ENTITY_RECORDING,
)
return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
@staticmethod
async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
@@ -1462,6 +1510,7 @@ class SubscribeChain(ChainBase):
source=subscribe.media_source,
mediaid=str(subscribe.media_id),
mtype=MediaType.MUSIC,
music_type=getattr(subscribe, "music_type", None),
)
if mediainfo:
return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
@@ -1470,11 +1519,13 @@ class SubscribeChain(ChainBase):
return None
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
return SubscribeChain._music_info_from_subscribe(subscribe)
return await MediaChain().async_recognize_media(
mediainfo = await MediaChain().async_recognize_media(
meta=build_subscribe_meta(subscribe),
mtype=MediaType.MUSIC,
source=subscribe.media_source,
music_type=MUSIC_ENTITY_RECORDING,
)
return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo)
@staticmethod
def _music_info_from_subscribe(subscribe: Subscribe) -> MusicInfo:
@@ -1506,7 +1557,12 @@ class SubscribeChain(ChainBase):
update_data = {}
if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type:
update_data["music_type"] = mediainfo.music_type
total_tracks = mediainfo.total_tracks if mediainfo.music_type == MUSIC_ENTITY_ALBUM else None
if mediainfo.music_type == MUSIC_ENTITY_ALBUM:
# 远端详情可能暂时不返回曲目数;已确认的订阅快照不能因此被清空。
total_tracks = _normalize_music_total_tracks(mediainfo.total_tracks) \
or _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None))
else:
total_tracks = None
if getattr(subscribe, "total_tracks", None) != total_tracks:
update_data["total_tracks"] = total_tracks
if not update_data:
@@ -1541,6 +1597,13 @@ class SubscribeChain(ChainBase):
f"媒体源:{subscribe.media_source},媒体ID{subscribe.media_id}"
)
return None
validation_error = self._validate_music_subscribe_target(
mediainfo,
getattr(subscribe, "music_type", None),
)
if validation_error:
logger.warning(f"音乐订阅 {subscribe.name} 无法继续:{validation_error}")
return None
self._sync_music_subscribe_target(subscribe, mediainfo)
meta = MusicChain.to_meta(mediainfo)
exists, _ = self.check_and_handle_existing_media(
@@ -1567,12 +1630,18 @@ class SubscribeChain(ChainBase):
torrent_helper = TorrentHelper()
matched: List[Context] = []
for source_context in contexts or []:
torrent = source_context.torrent_info
if not torrent or torrent.category not in (MediaType.MUSIC, MediaType.MUSIC.value):
source_torrent = source_context.torrent_info
if not source_torrent or source_torrent.category not in (MediaType.MUSIC, MediaType.MUSIC.value):
continue
# 过滤模块会就地写入 pri_order;RSS 缓存会被多个订阅复用,必须隔离候选副本。
torrent = copy.copy(source_torrent)
if sites and torrent.site not in sites:
continue
if not MusicChain.matches_site_resource(mediainfo, torrent.title):
if not MusicChain.matches_site_resource(
mediainfo,
torrent.title,
torrent.description,
):
continue
if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)):
continue
@@ -1581,10 +1650,13 @@ class SubscribeChain(ChainBase):
torrent_list=[torrent],
mediainfo=mediainfo,
)
if filtered is not None and not filtered:
continue
if filtered is not None:
if not filtered:
continue
torrent = filtered[0]
context = copy.copy(source_context)
context.torrent_info = torrent
meta = MusicChain.to_meta(mediainfo)
meta.org_string = torrent.title
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
@@ -1630,8 +1702,14 @@ class SubscribeChain(ChainBase):
context for context in downloads or []
if context and context.meta_info and context.torrent_info
]
if subscribe.best_version and successful:
best_context = max(successful, key=lambda item: item.torrent_info.pri_order)
quality_downloads = successful
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
quality_downloads = [
context for context in successful
if context.confirmed_full_coverage
]
if subscribe.best_version and quality_downloads:
best_context = max(quality_downloads, key=lambda item: item.torrent_info.pri_order)
best_meta = best_context.meta_info
quality_data = {
"current_priority": best_context.torrent_info.pri_order,
@@ -1794,7 +1872,7 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(
mediainfo: MediaInfo = MediaChain().recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
@@ -2034,6 +2112,13 @@ class SubscribeChain(ChainBase):
touch_last_update=bool(downloads),
scene="download",
)
if meta.type == MediaType.MUSIC and not self._is_music_download_complete(
subscribe,
mediainfo,
downloads,
):
logger.info(f'{mediainfo.title_year} 未下载完整,继续洗版 ...')
return
if self.__is_best_version_complete(subscribe):
# 洗版完成
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
@@ -2268,7 +2353,7 @@ class SubscribeChain(ChainBase):
if subscribe.sites:
domains = SiteOper().get_domains_by_ids(subscribe.sites)
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(
mediainfo: MediaInfo = MediaChain().recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
@@ -2597,7 +2682,7 @@ class SubscribeChain(ChainBase):
if meta.type == MediaType.MUSIC:
mediainfo = self._recognize_music_subscribe(subscribe)
else:
mediainfo: MediaInfo = self.recognize_media(
mediainfo: MediaInfo = MediaChain().recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
@@ -2868,7 +2953,7 @@ class SubscribeChain(ChainBase):
if mtype == MediaType.MUSIC:
mediainfo = await self._async_recognize_music_subscribe(subscribe)
else:
mediainfo: MediaInfo = await self.async_recognize_media(
mediainfo: MediaInfo = await MediaChain().async_recognize_media(
mtype=mtype,
**_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
@@ -3064,7 +3149,7 @@ class SubscribeChain(ChainBase):
mediainfo = None
if not subscribe.best_version:
meta = build_subscribe_meta(subscribe)
mediainfo = self.recognize_media(
mediainfo = MediaChain().recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
@@ -4316,6 +4401,7 @@ class SubscribeChain(ChainBase):
anilistid=subscribe.anilistid,
media_source=subscribe.media_source,
media_id=subscribe.media_id,
music_type=getattr(subscribe, "music_type", None),
)
if download_his:
for his in download_his:
@@ -4349,7 +4435,7 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
return subscribe_info
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(
mediainfo: MediaInfo = MediaChain().recognize_media(
meta=meta,
mtype=meta.type,
**_subscribe_recognize_kwargs(subscribe),
@@ -4586,7 +4672,7 @@ class SubscribeChain(ChainBase):
start_episode=subscribe.start_episode,
downloaded_episodes=downloaded
)
if meta.type == MediaType.MOVIE:
if meta.type in (MediaType.MOVIE, MediaType.MUSIC):
return bool(downloaded), no_exists
return False, no_exists
+6
View File
@@ -709,6 +709,12 @@ class TorrentsChain(ChainBase):
else:
# 刷新RSS种子
torrents: List[TorrentInfo] = self.rss(domain=domain)
# 混合站点的 RSS 通常不提供媒体分类;有音乐订阅时补抓专用入口,
# 后续仍按与 spider 相同的独立缓存和去重规则处理。
if include_music and self._music_browse_paths(indexer):
torrents = self.__append_music_browse_torrents(
domain=domain, torrents=torrents
)
# 按pubdate降序排列
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
# 音乐与影视按同一公共参数独立计算刷新配额,并分别写入各自缓存,音乐不会被影视资源挤出
+187 -72
View File
@@ -17,7 +17,7 @@ from app.chain.storage import StorageChain
from app.chain.subscribe import SubscribeChain
from app.chain.tmdb import TmdbChain
from app.core.config import settings, global_vars
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo, MusicInfo
from app.core.context import MediaInfo, MusicInfo
from app.core.event import eventmanager
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfoPath
@@ -55,9 +55,16 @@ from app.schemas.types import (
SystemConfigKey,
ChainEventType,
ContentType,
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
)
from app.utils.mixins import ConfigReloadMixin
from app.utils.media import normalize_media_source, parse_media_key, resolve_media_identity
from app.utils.media import (
normalize_media_source,
normalize_music_type,
parse_media_key,
resolve_media_identity,
)
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
@@ -146,11 +153,43 @@ class JobManager:
@staticmethod
def __get_media_id(media: MediaInfo = None, season: Optional[int] = None) -> Tuple:
"""
获取媒体ID
获取媒体ID音乐额外区分实体类型并为无远端ID的曲目构造稳定身份
"""
if not media:
return None, season
source, media_id = resolve_media_identity(media=media)
if getattr(media, "type", None) == MediaType.MUSIC:
music_type = normalize_music_type(
getattr(media, "music_type", None),
) or MUSIC_ENTITY_RECORDING
if source and media_id:
return "music", source, media_id, music_type
artists = tuple(
StringUtils.clear_upper(artist)
for artist in (getattr(media, "artists", None) or [])
if StringUtils.clear_upper(artist)
)
if music_type == MUSIC_ENTITY_ALBUM:
album_artist = StringUtils.clear_upper(
getattr(media, "album_artist", None)
or (artists[0] if artists else "")
)
album = StringUtils.clear_upper(
getattr(media, "album", None) or getattr(media, "title", None) or ""
)
return "music", "local", music_type, album_artist, album, getattr(media, "year", None)
return (
"music",
"local",
music_type,
artists,
StringUtils.clear_upper(getattr(media, "title", None) or ""),
StringUtils.clear_upper(getattr(media, "album", None) or ""),
getattr(media, "disc_number", None),
getattr(media, "track_number", None),
)
return (source, media_id), season
@staticmethod
@@ -176,6 +215,10 @@ class JobManager:
else:
return self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
def get_job_id(self, task: TransferTask) -> Tuple:
"""返回任务当前所属的稳定作业身份,供作业级附加状态隔离使用。"""
return self.__get_id(task)
@staticmethod
def __get_media(task: TransferTask) -> schemas.MediaInfo:
"""
@@ -954,7 +997,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# Agent重试管理器
self.retry_scheduler = FailedRetryScheduler()
# 转移成功的文件清单
self._success_target_files: Dict[str, List[str]] = {}
self._success_target_files: Dict[Tuple, List[str]] = {}
# 批次级刮削缓冲,避免同一批多文件入库重复触发目录刮削
self._scrape_batches: Dict[str, Dict[str, Any]] = {}
# 整理进度进度
@@ -1023,15 +1066,17 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
判断是否为主要媒体文件
"""
if mtype == MediaType.MUSIC:
if fileitem.type != "file" or not fileitem.extension:
return False
return f".{fileitem.extension.lower()}" in self._audio_exts
if fileitem.type == "dir":
# 蓝光原盘判断
return StorageChain().is_bluray_folder(fileitem)
if not fileitem.extension:
return False
extension = f".{fileitem.extension.lower()}"
if extension in self._media_exts:
return True
return mtype == MediaType.MUSIC and extension in self._audio_exts
return extension in self._media_exts
def _is_primary_media_file(
self,
@@ -1047,22 +1092,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
@staticmethod
def _music_info_from_meta(meta: MetaMusic) -> MusicInfo:
"""将音频文件标签解析结果转换为可整理的最小音乐信息。"""
return MusicInfo(
source=meta.media_source,
media_id=meta.media_id,
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_tracks=meta.total_tracks,
duration=meta.duration,
isrc=meta.isrc,
version=meta.version,
names=[name for name in (meta.title, meta.album) if name],
)
return MusicInfo.from_meta(meta)
@classmethod
def _match_music_album_context(
@@ -1122,6 +1152,27 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
merged_info.detail_link = info.detail_link
return merged_meta, merged_info
@staticmethod
def _download_history_music_type(
download_history: Optional[DownloadHistory],
) -> Optional[str]:
"""从下载历史字段或旧版音乐备注中恢复音乐实体类型。"""
music_type = normalize_music_type(
getattr(download_history, "music_type", None),
allow_artist=False,
)
if music_type:
return music_type
note = getattr(download_history, "note", None)
music_note = note.get("music") if isinstance(note, dict) else None
media_payload = music_note.get("media") if isinstance(music_note, dict) else None
if not isinstance(media_payload, dict):
return None
return normalize_music_type(
media_payload.get("music_type"),
allow_artist=False,
)
@classmethod
def _restore_music_download_context(
cls,
@@ -1139,15 +1190,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
except (TypeError, ValueError):
return None, None
file_tags = (
AudioMetadataHelper.read(file_path)
if file_path.exists()
else MetaMusic(
org_string=file_path.name,
title=file_path.stem,
audio_format=file_path.suffix.lstrip(".").upper() or None,
)
)
file_tags = MediaChain.read_path_meta(file_path)
file_meta = deepcopy(saved_meta)
file_meta.org_string = file_path.name
# 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名),
@@ -1219,10 +1262,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
音乐专辑目录返回 None交由整理链按音频后缀逐文件解析识别
"""
if history.media_source and history.media_id:
retry_info = self.recognize_media(
retry_info = MediaChain().recognize_media(
mtype=MediaType.MUSIC,
source=history.media_source,
mediaid=history.media_id,
music_type=getattr(history, "music_type", None),
)
if retry_info:
return retry_info
@@ -1343,6 +1387,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
transferhis = TransferHistoryOper()
target_dir_path = self.__get_transfer_target_dir_path(transferinfo)
job_id = self.jobview.get_job_id(task)
# 转移失败
if not transferinfo.success:
@@ -1519,12 +1564,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# task登记转移成功文件清单
target_files = transferinfo.file_list_new
if target_dir_path:
if target_files:
with job_lock:
if self._success_target_files.get(target_dir_path):
self._success_target_files[target_dir_path].extend(target_files)
if self._success_target_files.get(job_id):
self._success_target_files[job_id].extend(target_files)
else:
self._success_target_files[target_dir_path] = target_files
self._success_target_files[job_id] = list(target_files)
# 设置任务成功
self.jobview.finish_task(task)
@@ -1536,12 +1581,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if self.jobview.is_finished(task):
# 更新文件清单
with job_lock:
if target_dir_path:
transferinfo.file_list_new = self._success_target_files.pop(
target_dir_path, []
)
else:
transferinfo.file_list_new = transferinfo.file_list_new or []
transferinfo.file_list_new = list(dict.fromkeys(
self._success_target_files.pop(job_id, [])
or transferinfo.file_list_new
or []
))
__notify()
if not task.transfer_batch_id:
self.__send_metadata_scrape_event(task, transferinfo)
@@ -1702,15 +1746,41 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
self.eventmanager.send_event(
EventType.MetadataScrape,
{
"meta": task.meta,
"mediainfo": task.mediainfo,
"fileitem": target_diritem,
"file_list": transferinfo.file_list_new,
"overwrite": False,
},
self.__build_metadata_scrape_payload(
task=task,
fileitem=target_diritem,
file_list=transferinfo.file_list_new,
overwrite=False,
),
)
@staticmethod
def __build_metadata_scrape_payload(
task: TransferTask,
fileitem: FileItem,
file_list: Optional[list[str]],
overwrite: bool,
) -> dict[str, Any]:
"""构造刮削事件载荷,并为音乐批次保留逐文件身份上下文。"""
paths = list(dict.fromkeys(file_list or []))
payload: dict[str, Any] = {
"meta": task.meta,
"mediainfo": task.mediainfo,
"fileitem": fileitem,
"file_list": paths,
"overwrite": overwrite,
}
if isinstance(task.mediainfo, MusicInfo):
payload["file_contexts"] = [
{
"path": path,
"meta": task.meta,
"mediainfo": task.mediainfo,
}
for path in paths
]
return payload
def __register_scrape_batch_task(self, task: TransferTask):
"""
登记批次任务刮削事件只在批次关闭且任务全部完成后统一发送
@@ -1781,6 +1851,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"meta": task.meta,
"mediainfo": task.mediainfo,
"files": [],
"file_contexts": {},
"overwrite": False,
},
)
@@ -1791,6 +1862,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
for target_file in target_files:
if target_file and target_file not in target["files"]:
target["files"].append(target_file)
if target_file and isinstance(task.mediainfo, MusicInfo):
target["file_contexts"][target_file] = {
"path": target_file,
"meta": task.meta,
"mediainfo": task.mediainfo,
}
def __finish_scrape_batch_task(self, task: TransferTask):
"""
@@ -1828,15 +1905,23 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if not fileitem:
continue
file_list = list(dict.fromkeys(target.get("files") or []))
file_contexts = target.get("file_contexts") or {}
payload = {
"meta": target.get("meta"),
"mediainfo": target.get("mediainfo"),
"fileitem": fileitem,
"file_list": file_list,
"overwrite": target.get("overwrite", False),
}
if file_contexts:
payload["file_contexts"] = [
file_contexts[path]
for path in file_list
if path in file_contexts
]
self.eventmanager.send_event(
EventType.MetadataScrape,
{
"meta": target.get("meta"),
"mediainfo": target.get("mediainfo"),
"fileitem": fileitem,
"file_list": file_list,
"overwrite": target.get("overwrite", False),
},
payload,
)
def remove_from_queue(self, fileitem: FileItem):
@@ -2022,7 +2107,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
and not history_year_conflict
):
# 下载记录中已存在识别信息
mediainfo: Optional[MediaInfo] = self.recognize_media(
mediainfo: Optional[MediaInfo] = MediaChain().recognize_media(
mtype=task.mtype or MediaType(download_history.type),
tmdbid=download_history.tmdbid,
doubanid=download_history.doubanid,
@@ -2030,6 +2115,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
anilistid=download_history.anilistid,
source=download_history.media_source,
mediaid=download_history.media_id,
music_type=self._download_history_music_type(download_history),
episode_group=download_history.episode_group,
)
need_obtain_images = True
@@ -2564,7 +2650,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
except ValueError:
mtype = MediaType.TV
# 识别媒体信息
mediainfo = self.recognize_media(
mediainfo = MediaChain().recognize_media(
mtype=mtype,
tmdbid=downloadhis.tmdbid,
doubanid=downloadhis.doubanid,
@@ -2572,6 +2658,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
anilistid=downloadhis.anilistid,
source=downloadhis.media_source,
mediaid=downloadhis.media_id,
music_type=self._download_history_music_type(downloadhis),
episode_group=downloadhis.episode_group,
)
if mediainfo:
@@ -3214,6 +3301,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 是否全部成功
all_success = True
transfer_batch_id = str(uuid.uuid4())
batch_mtype = getattr(mediainfo, "type", None)
if batch_mtype in (None, MediaType.UNKNOWN):
batch_mtype = mtype
if preview:
# 预览模式始终同步执行,避免进入异步队列
background = False
@@ -3261,8 +3351,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
是否存在可靠的影视类型来源存在时音频按附加音轨解析
避免影视场景的音频文件误入音乐识别
"""
if mtype is not None:
return mtype != MediaType.MUSIC
if batch_mtype is not None:
return batch_mtype != MediaType.MUSIC
# 预载媒体信息为非音乐时,整批整理视为影视上下文
return mediainfo is not None and not isinstance(mediainfo, MusicInfo)
@@ -3281,7 +3371,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
and source_path.suffix.lower() in self._audio_exts
and not _has_reliable_video_source()
):
path_meta = AudioMetadataHelper.read(source_path)
path_meta = MediaChain.read_path_meta(source_path)
else:
# 影视场景附加音轨(如评论音轨)强制按视频解析,保留季集归属
path_meta = MetaInfoPath(
@@ -3330,13 +3420,20 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if not formaterHandler.match(item.name):
return False
matched_episode_format_template = True
if batch_mtype == MediaType.MUSIC:
# 明确的音乐批次只接收音频主文件,避免混合下载目录中的视频或字幕
# 被音乐身份和命名模板整理进音乐库。
if not self.__is_media_file(item, batch_mtype):
return False
if not self.__is_allow_filesize(item, min_filesize):
return False
# 过滤后缀和大小(蓝光目录、附加文件不过滤)
if (
elif (
not is_bluray_dir
and not self.__is_subtitle_file(item)
and not self.__is_audio_file(item)
):
if not self.__is_media_file(item):
if not self.__is_media_file(item, batch_mtype):
return False
if not self.__is_allow_filesize(item, min_filesize):
return False
@@ -3433,7 +3530,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if not item or item.type != "file":
continue
dir_key = self.__get_file_parent_key(item)
if not is_bluray_dir and self.__is_media_file(item):
if not is_bluray_dir and self.__is_media_file(item, batch_mtype):
main_items_by_dir.setdefault(dir_key, []).append(item)
elif self.__is_subtitle_file(item) or self.__is_audio_file(item):
extra_items_by_dir.setdefault(dir_key, []).append((item, is_bluray_dir))
@@ -3458,7 +3555,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
for item in storagechain.list_files(parent_item, recursion=False) or []:
if not item or item.type != "file":
continue
if self.__is_media_file(item):
if self.__is_media_file(item, batch_mtype):
main_fileitems.append(item)
continue
if not (self.__is_subtitle_file(item) or self.__is_audio_file(item)):
@@ -3484,7 +3581,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
(item, is_bluray_dir)
for item, is_bluray_dir in items
if item
and (is_bluray_dir or (item.type == "file" and self.__is_media_file(item)))
and (
is_bluray_dir
or (
item.type == "file"
and self.__is_media_file(item, batch_mtype)
)
)
]
single_file_mode = len(items) == 1 and fileitem.type == "file"
@@ -3495,7 +3598,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
current_item
)
current_dir_key = self.__get_file_parent_key(current_item)
if not current_bluray_dir and self.__is_media_file(current_item):
if not current_bluray_dir and self.__is_media_file(
current_item, batch_mtype
):
main_items = [(current_item, current_bluray_dir)]
main_items_by_dir[current_dir_key] = [current_item]
extra_items_by_dir[current_dir_key] = sibling_extra_items
@@ -3550,7 +3655,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
for main_item, main_bluray_dir in main_items:
_append_item(planned_items, seen_file_keys, main_item, main_bluray_dir)
if main_bluray_dir or not self.__is_media_file(main_item):
if main_bluray_dir or not self.__is_media_file(
main_item, batch_mtype
):
continue
main_path = Path(main_item.path)
@@ -3767,7 +3874,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
meta=file_meta,
mediainfo=task_mediainfo,
media_source=media_source,
mtype=mtype,
mtype=batch_mtype,
target_directory=target_directory,
target_storage=target_storage,
target_path=target_path,
@@ -4090,14 +4197,19 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 音乐原生 ID 未带来源前缀时默认按 MusicBrainz ID 处理
media_source, source_media_id = "musicbrainz", str(mediaid)
if media_source and source_media_id:
mediainfo = self.recognize_media(
mediainfo = MediaChain().recognize_media(
mtype=mtype,
source=media_source,
mediaid=source_media_id,
music_type=(
getattr(history, "music_type", None)
if mtype == MediaType.MUSIC
else None
),
episode_group=history.episode_group,
)
else:
mediainfo = self.recognize_media(
mediainfo = MediaChain().recognize_media(
mtype=mtype,
tmdbid=int(mediaid) if str(mediaid).isdigit() else None,
doubanid=mediaid if not str(mediaid).isdigit() else None,
@@ -4174,6 +4286,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
bangumiid: Optional[int] = None,
anilistid: Optional[int] = None,
reorganize: Optional[bool] = False,
music_type: Optional[str] = None,
) -> Tuple[bool, Union[str, dict]]:
"""
手动整理支持复杂条件带进度显示
@@ -4203,6 +4316,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param reorganize: 是否清理已有成功记录后重新整理
:param sync_extra_files: 是否同步整理同媒体附加文件
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
:param music_type: 音乐实体类型为保持位置参数兼容必须追加在签名末尾
"""
logger.info(f"手动整理:{fileitem.path} ...")
if tmdbid or doubanid or bangumiid or anilistid or media_id:
@@ -4214,6 +4328,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
anilistid=anilistid,
source=media_source,
mediaid=media_id,
music_type=music_type,
mtype=mtype,
episode_group=episode_group,
)
+32 -7
View File
@@ -4,7 +4,7 @@ from datetime import datetime
from typing import List, Dict, Any, Tuple, Optional, Set, Union, Self
from app.core.config import settings
from app.core.meta import MetaBase
from app.core.meta import MetaBase, MetaMusic
from app.core.meta.metamusic import (
audio_quality_score,
audio_quality_tier,
@@ -13,7 +13,12 @@ from app.core.meta.metamusic import (
normalize_audio_format,
)
from app.core.metainfo import MetaInfo
from app.schemas.types import MediaType
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
MUSIC_ENTITY_RECORDING,
MediaType,
)
from app.utils.string import StringUtils
BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"})
@@ -21,11 +26,6 @@ ANILIST_MOVIE_FORMATS = frozenset({"MOVIE"})
ANILIST_CHINESE_TITLE_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]")
ANILIST_JAPANESE_KANA_PATTERN = re.compile(r"[\u3040-\u30ff]")
# 音乐可浏览实体类型:单曲(Recording)、专辑(Release Group)、艺术家(Artist
MUSIC_ENTITY_RECORDING = "recording"
MUSIC_ENTITY_ALBUM = "album"
MUSIC_ENTITY_ARTIST = "artist"
def _validate_music_type(value: object) -> None:
"""校验音乐模型类型字段,仅接受音乐或空值。"""
@@ -334,6 +334,31 @@ class MusicInfo:
values[key] = _music_optional_int(values.get(key))
return cls(**values)
@classmethod
def from_meta(cls, meta: MetaMusic) -> Self:
"""将文件名和音频标签解析结果转换为无远端依赖的标准音乐信息。"""
return cls(
source=meta.media_source,
media_id=meta.media_id,
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_tracks=meta.total_tracks,
duration=meta.duration,
isrc=meta.isrc,
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,
names=[name for name in (meta.title, meta.album) if name],
)
@dataclass
class MusicRelease:
+3
View File
@@ -38,6 +38,7 @@ class DownloadHistoryOper(DbOper):
self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
bangumiid: Optional[int] = None, anilistid: Optional[int] = None,
media_source: Optional[str] = None, media_id: Optional[str] = None,
music_type: Optional[str] = None,
) -> List[DownloadHistory]:
"""
按媒体ID查询下载记录
@@ -47,6 +48,7 @@ class DownloadHistoryOper(DbOper):
:param anilistid: AniList ID
:param media_source: 媒体数据源
:param media_id: 数据源原生 ID
:param music_type: 音乐实体类型
"""
return DownloadHistory.get_by_mediaid(
self._db,
@@ -56,6 +58,7 @@ class DownloadHistoryOper(DbOper):
anilistid=anilistid,
media_source=media_source,
media_id=media_id,
music_type=music_type,
)
def add(self, **kwargs):
+8 -2
View File
@@ -35,6 +35,8 @@ class DownloadHistory(Base):
anilistid = Column(Integer, index=True)
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# 音乐实体类型:recording 单曲、album 专辑
music_type = Column(String)
# Sxx
seasons = Column(String)
# Exx
@@ -127,14 +129,18 @@ class DownloadHistory(Base):
doubanid: Optional[str] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None,
music_type: Optional[str] = None,
):
"""按统一媒体身份或兼容 ID 查询下载历史。"""
query = db.query(DownloadHistory)
if media_source and media_id:
return query.filter(
query = query.filter(
DownloadHistory.media_source == media_source,
DownloadHistory.media_id == str(media_id),
).all()
)
if music_type:
query = query.filter(DownloadHistory.music_type == music_type)
return query.all()
if tmdbid is not None:
return query.filter(DownloadHistory.tmdbid == tmdbid).all()
if doubanid:
+2 -1
View File
@@ -6,6 +6,7 @@ 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
class Subscribe(Base):
@@ -136,7 +137,7 @@ class Subscribe(Base):
"""按统一媒体身份优先级构造订阅查询条件。"""
if media_source and media_id:
condition = (cls.media_source == media_source) & (cls.media_id == str(media_id))
if music_type == "recording":
if music_type == MUSIC_ENTITY_RECORDING:
# 旧音乐订阅没有实体字段,历史语义等同单曲,查询时保持向后兼容。
return condition & or_(cls.music_type == music_type, cls.music_type.is_(None))
if music_type:
+2 -1
View File
@@ -5,6 +5,7 @@ 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
class SubscribeHistory(Base):
@@ -169,7 +170,7 @@ class SubscribeHistory(Base):
"""按统一媒体身份优先级构造订阅历史查询条件。"""
if media_source and media_id:
condition = (cls.media_source == media_source) & (cls.media_id == str(media_id))
if music_type == "recording":
if music_type == MUSIC_ENTITY_RECORDING:
# 旧历史记录没有实体字段,历史语义等同单曲。
return condition & or_(cls.music_type == music_type, cls.music_type.is_(None))
if music_type:
+17 -9
View File
@@ -8,7 +8,7 @@ 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 MediaType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType
def _text_like(column, pattern: str, wildcard: bool = False):
@@ -341,7 +341,7 @@ class TransferHistory(Base):
统计当月成功整理的电影电视剧剧集和音乐数量
电影和电视剧按媒体身份去重剧集优先按历史记录中的集数字段计算
缺少集数时按单条成功整理记录计数音乐按曲目计数识别到媒体 ID 的曲目按 ID 去重
缺少集数时按单条成功整理记录计数音乐按曲目身份去重整专记录不能只按专辑 ID 合并
"""
month_prefix = time.strftime("%Y-%m-", time.localtime())
histories = db.query(cls).filter(
@@ -352,16 +352,11 @@ class TransferHistory(Base):
movie_identities = set()
tv_identities = set()
episode_count = 0
music_count = 0
music_identities = set()
for history in histories:
if history.type == MediaType.MUSIC.value:
# 有媒体 ID 的曲目按 ID 去重,避免同一首歌多次整理重复计数
if history.media_id:
music_identities.add(history.media_id)
else:
music_count += 1
music_identities.add(cls._music_history_identity(history))
continue
identity = (history.tmdbid or 0, history.title or "", history.year or "")
@@ -372,7 +367,20 @@ class TransferHistory(Base):
tv_identities.add(identity)
episode_count += cls._history_episode_count(history)
return len(movie_identities), len(tv_identities), episode_count, music_count + len(music_identities)
return len(movie_identities), len(tv_identities), episode_count, len(music_identities)
@staticmethod
def _music_history_identity(history: "TransferHistory") -> tuple:
"""构造曲目级历史身份,避免整专内全部曲目被同一专辑 ID 合并。"""
source = str(history.media_source or "").strip().casefold()
media_id = str(history.media_id or "").strip()
music_type = str(history.music_type or MUSIC_ENTITY_RECORDING).strip().casefold()
path_identity = str(history.dest or history.src or "").replace("\\", "/").casefold()
if music_type == MUSIC_ENTITY_ALBUM:
return source, media_id, music_type, path_identity or history.title or history.id
if media_id:
return source, media_id, music_type
return source, music_type, path_identity or history.title or history.id
@staticmethod
def _history_episode_count(history: "TransferHistory") -> int:
+2 -2
View File
@@ -1,11 +1,11 @@
import time
from typing import Tuple, List, Optional
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo, MusicInfo
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 MediaType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
from app.utils.media import resolve_media_identity
INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode")
+3
View File
@@ -6,6 +6,7 @@ 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
class TransferHistoryOper(DbOper):
@@ -322,6 +323,7 @@ class TransferHistoryOper(DbOper):
)
else:
his = self.add_force(
type=meta.type.value if meta.type else None,
title=self._history_title(meta),
year=meta.year,
tmdbid=meta.tmdbid,
@@ -330,6 +332,7 @@ class TransferHistoryOper(DbOper):
anilistid=meta.anilistid,
media_source=meta.media_source,
media_id=meta.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),
bit_depth=getattr(meta, "bit_depth", None),
+23 -1
View File
@@ -49,7 +49,7 @@ class AudioMetadataHelper:
total_discs=total_discs,
total_tracks=total_tracks,
version=cls._first(tags, "version") or cls._first(tags, "subtitle"),
audio_format=path.suffix.lstrip(".").upper() or None,
audio_format=cls._audio_format(path, info),
bit_depth=cls._optional_int(getattr(info, "bits_per_sample", None)),
sample_rate=cls._optional_int(getattr(info, "sample_rate", None)),
bitrate=cls._optional_int(getattr(info, "bitrate", None)),
@@ -217,6 +217,28 @@ class AudioMetadataHelper:
return None
return AudioMetadataHelper._optional_int(str(value)[:4])
@staticmethod
def _audio_format(path: Path, info: Any) -> Optional[str]:
"""结合扩展名与流编码识别音频格式,区分同为 M4A 容器的 AAC 和 ALAC。"""
codec_text = " ".join(
str(value or "")
for value in (
getattr(info, "codec", None),
getattr(info, "codec_description", None),
)
).casefold()
codec_formats = (
(("alac", "apple lossless"), "ALAC"),
(("aac", "mp4a"), "AAC"),
(("opus",), "OPUS"),
(("vorbis",), "OGG"),
(("flac",), "FLAC"),
)
for markers, audio_format in codec_formats:
if any(marker in codec_text for marker in markers):
return audio_format
return path.suffix.lstrip(".").upper() or None
@staticmethod
def _optional_int(value: Any) -> Optional[int]:
"""将音频技术参数安全转换为整数。"""
+18 -2
View File
@@ -6,7 +6,7 @@ from app import schemas
from app.core.context import MediaInfo
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import StorageSchema, SystemConfigKey
from app.schemas.types import MediaType, StorageSchema, SystemConfigKey
from app.utils.system import SystemUtils
JINJA2_VAR_PATTERN = re.compile(r"\{\{.*?}}", re.DOTALL)
@@ -167,17 +167,33 @@ class DirectoryHelper:
return src_storage == tar_storage
@staticmethod
def get_media_root_path(rename_format: str, rename_path: Path) -> Optional[Path]:
def get_media_root_path(
rename_format: str,
rename_path: Path,
media_type: Optional[MediaType] = None,
) -> Optional[Path]:
"""
获取重命名后的媒体文件根路径
:param rename_format: 重命名格式
:param rename_path: 重命名后的路径
:param media_type: 媒体类型音乐需要避开可选碟片目录并返回专辑目录
:return: 媒体文件根路径
"""
if not rename_format:
logger.error("重命名格式不能为空")
return None
if media_type == MediaType.MUSIC:
# 音乐模板允许按多碟动态增加 Disc 子目录,不能按静态模板层数反推。
# 文件的直接父目录通常就是专辑目录;命中碟片目录时再上移一级。
media_root = rename_path.parent
if re.fullmatch(
r"(?:cd|disc|disk)\s*0*\d+",
media_root.name,
re.IGNORECASE,
):
media_root = media_root.parent
return media_root
# 计算重命名中的文件夹层数
rename_list = rename_format.split("/")
rename_format_level = len(rename_list) - 1
+2 -2
View File
@@ -3,10 +3,10 @@ from collections.abc import Iterable, Mapping
from typing import Any, Optional
from app import schemas
from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.core.context import MusicInfo
from app.helper.service import ServiceBaseHelper
from app.schemas import MediaServerConf, ServiceInfo
from app.schemas.types import SystemConfigKey, ModuleType
from app.schemas.types import MUSIC_ENTITY_ALBUM, SystemConfigKey, ModuleType
class MusicMediaServerHelper:
+2 -2
View File
@@ -15,14 +15,14 @@ from jinja2 import Template
from app.core.cache import TTLCache
from app.core.config import global_vars
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo, MusicInfo, TorrentInfo
from app.core.context import MediaInfo, MusicInfo, TorrentInfo
from app.core.meta import MetaBase, MetaMusic
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.message import Notification
from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import TransferInfo
from app.schemas.types import SystemConfigKey
from app.schemas.types import MUSIC_ENTITY_ALBUM, SystemConfigKey
from app.utils.singleton import Singleton, SingletonClass
from app.utils.string import StringUtils
+23 -2
View File
@@ -13,9 +13,14 @@ from app.db.subscribe_oper import SubscribeOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.workflow_oper import WorkflowOper
from app.log import logger
from app.schemas.types import MediaType, SystemConfigKey, media_type_to_agent
from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaType,
SystemConfigKey,
media_type_to_agent,
)
from app.utils.http import AsyncRequestUtils, RequestUtils
from app.utils.media import resolve_media_identity
from app.utils.media import normalize_music_type, resolve_media_identity
from app.utils.system import SystemUtils
from version import APP_VERSION, FRONTEND_VERSION
@@ -1324,6 +1329,7 @@ class MoviePilotServerHelper:
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
music_type: Optional[str] = None,
) -> Optional[dict]:
"""
查询共享识别结果
@@ -1334,6 +1340,7 @@ class MoviePilotServerHelper:
meta=meta,
mtype=mtype,
keyword_meta=keyword_meta,
music_type=music_type,
)
if not params:
return None
@@ -1346,6 +1353,7 @@ class MoviePilotServerHelper:
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
music_type: Optional[str] = None,
) -> Optional[dict]:
"""
异步查询共享识别结果
@@ -1356,6 +1364,7 @@ class MoviePilotServerHelper:
meta=meta,
mtype=mtype,
keyword_meta=keyword_meta,
music_type=music_type,
)
if not params:
return None
@@ -1433,6 +1442,8 @@ class MoviePilotServerHelper:
"anilistid": anilistid,
"source": media_source,
"mediaid": media_id,
"music_type": normalize_music_type(item.get("music_type"))
or (MUSIC_ENTITY_RECORDING if mtype == MediaType.MUSIC else None),
"season": item.get("season"),
}
@@ -1546,6 +1557,7 @@ class MoviePilotServerHelper:
meta: Optional[MetaBase],
mtype: Optional[MediaType] = None,
keyword_meta: Optional[MetaBase] = None,
music_type: Optional[str] = None,
) -> Optional[dict]:
"""
组装共享识别查询参数
@@ -1558,6 +1570,11 @@ class MoviePilotServerHelper:
params = {"keyword": keyword}
if media_type:
params["type"] = media_type
if media_type == "music":
params["music_type"] = (
normalize_music_type(music_type)
or MUSIC_ENTITY_RECORDING
)
if year := cls._extract_year(meta=meta):
params["year"] = year
season = cls._extract_season(media_type=media_type, meta=meta)
@@ -1602,6 +1619,10 @@ class MoviePilotServerHelper:
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"music_type": (
normalize_music_type(getattr(mediainfo, "music_type", None))
if media_type == "music" else None
),
}
@classmethod
+3
View File
@@ -112,6 +112,9 @@
"未识别到音乐信息": "Unable to recognize music information",
"未识别到专辑信息": "Unable to recognize album information",
"未识别到艺术家信息": "Unable to recognize artist information",
"音乐实体类型无效,仅支持 recording 或 album": "Invalid music entity type; only recording or album is supported",
"音乐下载只能使用音乐元数据源": "Music downloads can only use music metadata sources",
"音乐重新识别只能使用音乐元数据源": "Music re-identification can only use music metadata sources",
"记录不存在": "Record does not exist",
"MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled",
"整理记录不存在": "Organization record does not exist",
+3
View File
@@ -112,6 +112,9 @@
"未识别到音乐信息": "未識別到音樂資訊",
"未识别到专辑信息": "未識別到專輯資訊",
"未识别到艺术家信息": "未識別到藝術家資訊",
"音乐实体类型无效,仅支持 recording 或 album": "音樂實體類型無效,僅支援 recording 或 album",
"音乐下载只能使用音乐元数据源": "音樂下載只能使用音樂中繼資料來源",
"音乐重新识别只能使用音乐元数据源": "音樂重新識別只能使用音樂中繼資料來源",
"记录不存在": "記錄不存在",
"MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用",
"整理记录不存在": "整理記錄不存在",
+45 -5
View File
@@ -5,7 +5,11 @@ import cn2an
from app import schemas
from app.core.config import settings
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo, MusicAlbumInfo, MusicInfo
from app.core.context import (
MediaInfo,
MusicAlbumInfo,
MusicInfo,
)
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo
from app.log import logger
@@ -13,7 +17,13 @@ from app.modules import _ModuleBase
from app.modules.douban.apiv2 import DoubanApi
from app.modules.douban.scraper import DoubanScraper
from app.schemas import MediaPerson, APIRateLimitException
from app.schemas.types import MediaType, ModuleType, MediaRecognizeType
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaType,
ModuleType,
MediaRecognizeType,
)
from app.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.limit import rate_limit_exponential
@@ -92,11 +102,20 @@ class DoubanModule(_ModuleBase):
result = self.doubanapi.music_search(keyword=keyword, count=max(1, min(limit, 100)))
return self._build_music_search_results(result)
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
"""按豆瓣音乐原生 ID 获取专辑或专辑内曲目详情。"""
def recognize_music(
self,
source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""按豆瓣音乐原生 ID 和实体类型获取专辑或专辑内曲目详情。"""
if source != self._music_source or not media_id:
return None
album_id, separator, track_id = str(media_id).partition(":")
if music_type == MUSIC_ENTITY_RECORDING and not separator:
return None
if music_type == MUSIC_ENTITY_ALBUM and separator:
return None
album = self.music_album(source, album_id)
if not album:
return None
@@ -122,13 +141,19 @@ class DoubanModule(_ModuleBase):
meta: Optional[MetaMusic],
source: Optional[str],
mediaid: Optional[str],
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""执行豆瓣音乐详情识别或按专辑名称匹配。"""
if source != self._music_source:
return None
resolved_media_id = mediaid or (meta.media_id if meta else None)
if resolved_media_id:
return self.recognize_music(source, str(resolved_media_id))
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
)
return self.recognize_music(
source, str(resolved_media_id), **detail_kwargs
)
if not meta:
return None
candidates = self.search_music(meta=meta, limit=20, source=source) or []
@@ -142,12 +167,16 @@ class DoubanModule(_ModuleBase):
for actual in candidate.artists
):
continue
if music_type == MUSIC_ENTITY_ALBUM:
return candidate
if meta.album and meta.title:
album = self.music_album(source, candidate.media_id)
matched_track = self._select_douban_music_track(meta, album)
if matched_track:
return matched_track
continue
if music_type == MUSIC_ENTITY_RECORDING:
continue
return candidate
return None
@@ -156,6 +185,7 @@ class DoubanModule(_ModuleBase):
meta: Optional[MetaMusic],
source: Optional[str],
mediaid: Optional[str],
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步执行豆瓣音乐详情识别或按专辑名称匹配。"""
if source != self._music_source:
@@ -163,6 +193,10 @@ class DoubanModule(_ModuleBase):
resolved_media_id = mediaid or (meta.media_id if meta else None)
if resolved_media_id:
album_id, separator, track_id = str(resolved_media_id).partition(":")
if music_type == MUSIC_ENTITY_RECORDING and not separator:
return None
if music_type == MUSIC_ENTITY_ALBUM and separator:
return None
info = await self.doubanapi.async_music_detail(subject_id=album_id)
album = self._douban_music_to_album(info) if info else None
if not album:
@@ -194,6 +228,8 @@ class DoubanModule(_ModuleBase):
for actual in candidate.artists
):
continue
if music_type == MUSIC_ENTITY_ALBUM:
return candidate
if meta.album and meta.title:
info = await self.doubanapi.async_music_detail(
subject_id=str(candidate.media_id)
@@ -203,6 +239,8 @@ class DoubanModule(_ModuleBase):
if matched_track:
return matched_track
continue
if music_type == MUSIC_ENTITY_RECORDING:
continue
return candidate
return None
@@ -690,6 +728,7 @@ class DoubanModule(_ModuleBase):
meta=meta if isinstance(meta, MetaMusic) else None,
source=source,
mediaid=kwargs.get("mediaid"),
music_type=kwargs.get("music_type"),
)
# 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
@@ -720,6 +759,7 @@ class DoubanModule(_ModuleBase):
meta=meta if isinstance(meta, MetaMusic) else None,
source=source,
mediaid=kwargs.get("mediaid"),
music_type=kwargs.get("music_type"),
)
# 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
+15 -11
View File
@@ -670,25 +670,27 @@ class Emby:
# 开始刷新媒体库
if "/" in library_ids:
return self.refresh_root_library()
success = True
for library_id in library_ids:
if library_id != "/":
return self.__refresh_emby_library_by_id(library_id)
refreshed = self.__refresh_emby_library_by_id(library_id)
success = bool(refreshed) and success
logger.info(f"Emby媒体库刷新完成")
return True
return success
def __get_emby_library_id_by_item(self, item: schemas.RefreshMediaItem) -> Optional[str]:
"""
根据媒体信息查询在哪个媒体库返回要刷新的位置的ID
:param item: {title, year, type, category, target_path}
"""
if not item.title or not item.year or not item.type:
if not item.type or not item.target_path:
return None
if item.type != MediaType.MOVIE.value:
if item.type == MediaType.TV and item.title and item.year:
item_id = self.__get_emby_series_id_by_name(item.title, item.year)
if item_id:
# 存在电视剧,则直接刷新这个电视剧就行
return item_id
else:
elif item.type == MediaType.MOVIE and item.title and item.year:
if self.get_movies(item.title, item.year):
# 已存在,不用刷新
return None
@@ -696,7 +698,7 @@ class Emby:
item_path = Path(item.target_path)
# 匹配子目录
for folder in self.folders:
for subfolder in folder.get("SubFolders"):
for subfolder in folder.get("SubFolders") or []:
try:
# 匹配子目录
subfolder_path = Path(subfolder.get("Path"))
@@ -705,11 +707,13 @@ class Emby:
except Exception as err:
logger.debug(f"匹配子目录出错:{err} - {traceback.format_exc()}")
# 如果找不到,只要路径中有分类目录名就命中
for folder in self.folders:
for subfolder in folder.get("SubFolders"):
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
subfolder.get("Path")):
return folder.get("Id")
if item.category:
for folder in self.folders:
for subfolder in folder.get("SubFolders") or []:
if subfolder.get("Path") and re.search(
r"[/\\]%s" % re.escape(item.category), subfolder.get("Path")
):
return folder.get("Id")
# 刷新根目录
return "/"
+132 -17
View File
@@ -3,8 +3,8 @@ from typing import Optional, List, Tuple, Union, Dict, Callable
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.context import MediaInfo, MusicInfo
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo
from app.helper.directory import DirectoryHelper
from app.helper.message import MessageHelper
@@ -14,8 +14,9 @@ from app.modules import _ModuleBase
from app.modules.filemanager.storages import StorageBase
from app.modules.filemanager.transhandler import TransHandler
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage
from app.schemas.types import MediaType, ModuleType, OtherModulesType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, ModuleType, OtherModulesType
from app.utils.system import SystemUtils
from app.utils.string import StringUtils
class FileManagerModule(_ModuleBase):
@@ -549,7 +550,101 @@ class FileManagerModule(_ModuleBase):
source_oper=source_oper,
target_oper=target_oper)
def media_files(self, mediainfo: MediaInfo) -> List[FileItem]:
@staticmethod
def _build_library_lookup_meta(
mediainfo: Union[MediaInfo, MusicInfo],
) -> MetaBase:
"""构造标准媒体库路径反查使用的最小元数据。"""
if mediainfo.type == MediaType.MUSIC:
music_type = getattr(mediainfo, "music_type", None)
album = getattr(mediainfo, "album", None)
if not album and music_type == MUSIC_ENTITY_ALBUM:
album = mediainfo.title
return MetaMusic(
title=mediainfo.title,
artists=list(getattr(mediainfo, "artists", None) or []),
album=album,
album_artist=getattr(mediainfo, "album_artist", None),
year=mediainfo.year,
disc_number=getattr(mediainfo, "disc_number", None),
track_number=getattr(mediainfo, "track_number", None),
total_tracks=getattr(mediainfo, "total_tracks", None),
media_source=getattr(mediainfo, "source", None),
media_id=getattr(mediainfo, "media_id", None),
)
meta = MetaInfo(mediainfo.title)
if meta.type == MediaType.UNKNOWN and mediainfo.type is not None:
meta.type = mediainfo.type
if meta.year is None:
meta.year = mediainfo.year
if meta.begin_season is None:
meta.begin_season = 1
if meta.begin_episode is None:
meta.begin_episode = 1
return meta
@staticmethod
def _music_file_identity(fileitem: FileItem) -> Tuple[Optional[int], Optional[int], str]:
"""从标准音乐文件路径提取碟号、曲序和归一化曲名。"""
file_path = Path(fileitem.path or fileitem.name or "")
file_meta = MetaMusic(
org_string=file_path.name,
title=file_path.stem,
).apply_path_context(file_path)
return (
file_meta.disc_number,
file_meta.track_number,
StringUtils.clear_upper(file_meta.title or file_path.stem),
)
@classmethod
def _music_recording_exists(
cls,
fileitems: List[FileItem],
mediainfo: MusicInfo,
) -> bool:
"""按曲名和可用曲序判断单曲是否存在,避免专辑内任一文件造成误判。"""
target_title = StringUtils.clear_upper(mediainfo.title or "")
target_track = getattr(mediainfo, "track_number", None)
target_disc = getattr(mediainfo, "disc_number", None)
if not target_title:
return False
for fileitem in fileitems:
disc_number, track_number, title = cls._music_file_identity(fileitem)
if title != target_title:
continue
if target_track is not None and track_number not in (None, target_track):
continue
if target_disc is not None and disc_number not in (None, target_disc):
continue
return True
return False
@classmethod
def _music_album_is_complete(
cls,
fileitems: List[FileItem],
total_tracks: Optional[int],
) -> bool:
"""按去重后的曲序或曲名判断本地专辑是否达到目标曲目数。"""
if not fileitems:
return False
if not total_tracks:
return False
track_identities = {
(
disc_number or 1,
track_number if track_number is not None else title,
)
for disc_number, track_number, title in (
cls._music_file_identity(fileitem) for fileitem in fileitems
)
if track_number is not None or title
}
return len(track_identities) >= total_tracks
def media_files(self, mediainfo: Union[MediaInfo, MusicInfo]) -> List[FileItem]:
"""
获取对应媒体的媒体库文件列表
:param mediainfo: 媒体信息
@@ -569,15 +664,7 @@ class FileManagerModule(_ModuleBase):
# 重命名格式
rename_format = settings.RENAME_FORMAT(mediainfo.type)
# 元数据补上常用属性,尽可能确保重命名后的路径不出现空白
meta = MetaInfo(mediainfo.title)
if meta.type == MediaType.UNKNOWN and mediainfo.type is not None:
meta.type = mediainfo.type
if meta.year is None:
meta.year = mediainfo.year
if meta.begin_season is None:
meta.begin_season = 1
if meta.begin_episode is None:
meta.begin_episode = 1
meta = self._build_library_lookup_meta(mediainfo)
# 获取路径(重命名路径)
target_path = handler.get_rename_path(
path=dir_path,
@@ -587,7 +674,9 @@ class FileManagerModule(_ModuleBase):
)
# 获取重命名后的媒体文件根路径
media_path = DirectoryHelper.get_media_root_path(
rename_format, rename_path=target_path
rename_format,
rename_path=target_path,
media_type=mediainfo.type,
)
if not media_path:
# 忽略
@@ -606,13 +695,25 @@ class FileManagerModule(_ModuleBase):
logger.debug(f"获取媒体文件列表失败:{str(e)}")
continue
if media_files:
media_extensions = (
settings.RMT_AUDIOEXT
if mediainfo.type == MediaType.MUSIC
else settings.RMT_MEDIAEXT
)
for media_file in media_files:
if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT:
if (
media_file.extension
and f".{media_file.extension.lower()}" in media_extensions
):
if media_file not in ret_fileitems:
ret_fileitems.append(media_file)
return ret_fileitems
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
def media_exists(
self,
mediainfo: Union[MediaInfo, MusicInfo],
**kwargs,
) -> Optional[ExistMediaInfo]:
"""
判断媒体文件是否存在于文件系统网盘或本地文件只支持标准媒体库结构
:param mediainfo: 识别的媒体信息
@@ -637,7 +738,20 @@ class FileManagerModule(_ModuleBase):
# 电影存在任何文件为存在
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了")
return ExistMediaInfo(type=MediaType.MOVIE)
else:
if mediainfo.type == MediaType.MUSIC:
if getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM:
exists = self._music_album_is_complete(
fileitems,
getattr(mediainfo, "total_tracks", None),
)
else:
exists = self._music_recording_exists(fileitems, mediainfo)
if not exists:
logger.debug(f"{mediainfo.title_year} 在本地音乐库中尚不完整")
return None
logger.info(f"{mediainfo.title_year} 在本地音乐库中找到了")
return ExistMediaInfo(type=MediaType.MUSIC)
if mediainfo.type == MediaType.TV:
# 电视剧检索集数
seasons: Dict[int, list] = {}
for fileitem in fileitems:
@@ -653,3 +767,4 @@ class FileManagerModule(_ModuleBase):
# 返回剧集情况
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}")
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
return None
+78 -21
View File
@@ -5,10 +5,11 @@ from typing import Optional, List, Tuple
from jinja2 import Template
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.context import MediaInfo, MusicInfo
from app.core.event import eventmanager
from app.core.meta import MetaBase
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfoPath
from app.helper.audio import AudioMetadataHelper
from app.helper.directory import DirectoryHelper
from app.helper.message import TemplateHelper
from app.log import logger
@@ -128,11 +129,41 @@ class TransHandler:
size=size if item_type == "file" else None,
)
@staticmethod
def __music_quality_overwrite_decision(
meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
target_item: FileItem,
) -> Optional[bool]:
"""比较新旧音乐的实际音质,无法形成可靠结论时交回原覆盖策略。"""
if getattr(mediainfo, "type", None) != MediaType.MUSIC or not target_item:
return None
source_score = getattr(meta, "audio_quality_score", 0) or getattr(
mediainfo, "audio_quality_score", 0
)
target_path = Path(target_item.path) if target_item.path else None
if (
target_path
and (target_item.storage or "local") == "local"
and target_path.is_file()
):
target_music = AudioMetadataHelper.read(target_path)
else:
target_format = target_item.extension
if not target_format and target_path:
target_format = target_path.suffix.lstrip(".")
target_music = MetaMusic(audio_format=target_format)
target_score = target_music.audio_quality_score
if not source_score or not target_score or source_score == target_score:
return None
return source_score > target_score
def transfer_media(
self,
fileitem: FileItem,
in_meta: MetaBase,
mediainfo: MediaInfo,
mediainfo: MediaInfo | MusicInfo,
target_storage: str,
target_path: Path,
transfer_type: str,
@@ -234,7 +265,9 @@ class TransHandler:
)
else:
new_path = DirectoryHelper.get_media_root_path(
rename_format, rename_path=rendered_path
rename_format,
rename_path=rendered_path,
media_type=mediainfo.type,
)
if not new_path:
self.__update_result(
@@ -369,7 +402,9 @@ class TransHandler:
# 文件目录
folder_path = DirectoryHelper.get_media_root_path(
rename_format, rename_path=new_file
rename_format,
rename_path=new_file,
media_type=mediainfo.type,
)
if not folder_path:
self.__update_result(
@@ -516,27 +551,20 @@ class TransHandler:
# 总是覆盖同名文件
overflag = True
elif overwrite_mode == "size":
# 存在时大覆盖小
source_size = (
plugin_source_size
if plugin_source_size is not None
else fileitem.size
# 音乐先比较真实音质,无法判断时再沿用文件大小策略
music_overwrite = self.__music_quality_overwrite_decision(
meta=in_meta,
mediainfo=mediainfo,
target_item=target_item,
)
target_size = (
plugin_target_size
if plugin_target_size is not None
else target_item.size
)
if target_size < source_size:
logger.info(
f"目标文件文件大小更小,将覆盖:{new_file}"
)
if music_overwrite is True:
logger.info(f"目标音乐音质较低,将覆盖:{new_file}")
overflag = True
else:
elif music_overwrite is False:
self.__update_result(
result=result,
success=False,
message=f"媒体库存在同名文件,且质更好",
message="媒体库存在同名音乐文件,且目标音质更好",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
@@ -545,6 +573,35 @@ class TransHandler:
need_notify=need_notify,
)
return result
else:
source_size = (
plugin_source_size
if plugin_source_size is not None
else fileitem.size
)
target_size = (
plugin_target_size
if plugin_target_size is not None
else target_item.size
)
if target_size < source_size:
logger.info(
f"目标文件文件大小更小,将覆盖:{new_file}"
)
overflag = True
else:
self.__update_result(
result=result,
success=False,
message=f"媒体库存在同名文件,且质量更好",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify,
)
return result
elif overwrite_mode == "never":
# 存在不覆盖
self.__update_result(
+7 -2
View File
@@ -2,10 +2,15 @@ from typing import Any, Optional, Tuple, Union
from app.core.cache import cached
from app.core.config import settings
from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MusicInfo
from app.core.context import MusicInfo
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import ModuleType, OtherModulesType
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
ModuleType,
OtherModulesType,
)
from app.utils.http import RequestUtils
# ListenBrainz 全站统计支持的周期,取值与官方统计页面完全一致
+54 -37
View File
@@ -19,7 +19,13 @@ from app.core.meta import MetaBase, MetaMusic
from app.log import logger
from app.modules import _ModuleBase
from app.modules.musicbrainz.music_cache import MusicBrainzCache
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaRecognizeType,
MediaType,
ModuleType,
)
from app.utils.http import RequestUtils
from app.utils.media import is_media_source_selected
from app.utils.zhconv import convert as zhconv_convert
@@ -635,6 +641,7 @@ class MusicBrainzModule(_ModuleBase):
**kwargs,
) -> Optional[MusicInfo]:
"""跟随统一媒体识别分发,仅在音乐类型请求下返回 MusicBrainz 识别结果。"""
music_type = kwargs.get("music_type")
# 显式选择其它音乐源时必须让出识别管线,且不能复用 MusicBrainz 缓存。
if source and source != self._source:
return None
@@ -644,8 +651,32 @@ class MusicBrainzModule(_ModuleBase):
# 无 MetaMusic 元数据时仅响应本数据源的详情识别请求
if not isinstance(meta, MetaMusic):
if source == self._source and mediaid:
return self.recognize_music(source, str(mediaid))
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
)
return self.recognize_music(
source, str(mediaid), **detail_kwargs
)
return None
# 显式身份只允许按该 ID 和实体类型读取,失败后不能按标题替换成其它目标。
resolved_source = source or meta.media_source
resolved_media_id = mediaid or meta.media_id
if resolved_source and resolved_media_id:
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
)
info = self.recognize_music(
resolved_source,
str(resolved_media_id),
**detail_kwargs,
)
if info:
self._update_recognize_cache(meta, info)
return info
# 专辑名称识别不复用 Recording 缓存,避免同名实体互相覆盖。
if music_type == MUSIC_ENTITY_ALBUM:
albums = self._search_albums(meta, limit=10)
return self._select_album_candidate(meta, albums)
# 识别缓存命中直接响应,避免重复搜索占用 MusicBrainz 限流配额
cache_enabled = bool(kwargs.get("cache", True))
if cache_enabled and self.cache:
@@ -657,13 +688,6 @@ class MusicBrainzModule(_ModuleBase):
logger.info(f"{meta.title} 使用音乐识别缓存:无法识别")
cached_info.recognize_cache_hit = True
return cached_info
# 携带数据源与原生 ID 的请求优先按详情识别
resolved_source = source or meta.media_source
if resolved_source and (mediaid or meta.media_id):
info = self.recognize_music(resolved_source, str(mediaid or meta.media_id))
if info:
self._update_recognize_cache(meta, info)
return info
# 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兑底
# 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。
candidates = self._search_recordings(meta, limit=10)
@@ -671,7 +695,7 @@ class MusicBrainzModule(_ModuleBase):
# 整专/单曲发行类资源在 Recording 检索无果时,回退按专辑实体识别;
# 专辑挑选要求标题与艺术家同时命中,无艺术家线索时回退检索必然无果,
# 直接跳过避免浪费限流配额(批量识别场景可减少约半数请求)
if not matched and meta.artists:
if not matched and meta.artists and music_type != MUSIC_ENTITY_RECORDING:
albums = self._search_albums(meta, limit=10)
matched = self._select_album_candidate(meta, albums)
result = matched or self._info_from_meta(meta)
@@ -885,22 +909,7 @@ class MusicBrainzModule(_ModuleBase):
@classmethod
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
"""音乐识别无候选时,把元数据转换为可展示的最小信息。"""
return MusicInfo(
source=meta.media_source,
media_id=meta.media_id,
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_tracks=meta.total_tracks,
duration=meta.duration,
isrc=meta.isrc,
version=meta.version,
names=[name for name in (meta.title, meta.album) if name],
)
return MusicInfo.from_meta(meta)
@classmethod
def _same_text(cls, left: Optional[str], right: Optional[str]) -> bool:
@@ -981,19 +990,27 @@ class MusicBrainzModule(_ModuleBase):
text = stripped
return cls._normalize_text(text)
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
"""按 MusicBrainz 标准 ID 获取音乐详情,单曲不存在时回退到专辑。"""
def recognize_music(
self,
source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""按 MusicBrainz 标准 ID 和实体类型获取详情;空类型保留旧版探测顺序。"""
if source != self._source or not media_id:
return None
payload = self._request_json(
f"/recording/{media_id}",
params={
"inc": "artists+releases+release-groups+isrcs+genres",
"fmt": "json",
},
)
if payload:
return self._recording_to_info(payload)
if music_type != MUSIC_ENTITY_ALBUM:
payload = self._request_json(
f"/recording/{media_id}",
params={
"inc": "artists+releases+release-groups+isrcs+genres",
"fmt": "json",
},
)
if payload:
return self._recording_to_info(payload)
if music_type == MUSIC_ENTITY_RECORDING:
return None
# MusicBrainz 各实体共用 UUID 形式,统一详情入口在 Recording 未命中后继续探测专辑。
album = self.music_album(source, media_id)
return album.to_music_info() if album else None
+2 -1
View File
@@ -10,6 +10,7 @@ from app.core.config import settings
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.utils.singleton import WeakSingleton
lock = RLock()
@@ -111,7 +112,7 @@ class MusicBrainzCache(metaclass=WeakSingleton):
"artists": value.get("artists") or [],
"album": value.get("album") or "",
"year": value.get("year") or "",
"music_type": value.get("music_type") or "recording",
"music_type": value.get("music_type") or MUSIC_ENTITY_RECORDING,
"cover_url": value.get("cover_url") or "",
})
return sorted(cache_items, key=lambda item: item["key"])
+3 -3
View File
@@ -517,14 +517,14 @@ class Plex:
result_dict[""] = lib_key
if "" in result_dict:
# 如果有匹配失败的,刷新整个库
self._plex.library.update()
refreshed = self._plex.library.update()
return refreshed is not False
else:
# 否则一个一个刷新
for path, lib_key in result_dict.items():
logger.info(f"刷新媒体库:{lib_key} - {path}")
self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(Path(path).parent.as_posix())}')
return None
return None
return True
@staticmethod
def __find_librarie(path: Path, libraries: List[Any]) -> Tuple[str, Optional[Path]]:
+40 -12
View File
@@ -12,7 +12,13 @@ from app.core.context import (
from app.core.meta import MetaBase, MetaMusic
from app.log import logger
from app.modules import _ModuleBase
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaRecognizeType,
MediaType,
ModuleType,
)
from app.utils.http import RequestUtils
from app.utils.media import is_media_source_selected
@@ -93,18 +99,32 @@ class TheAudioDbModule(_ModuleBase):
**kwargs,
) -> Optional[MusicInfo]:
"""仅响应显式 TheAudioDB 音乐请求,并返回带原生 ID 的标准音乐信息。"""
music_type = kwargs.get("music_type")
if source != self._source:
return None
if not isinstance(meta, MetaMusic):
if mtype == MediaType.MUSIC and mediaid:
return self.recognize_music(source, str(mediaid))
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
)
return self.recognize_music(
source, str(mediaid), **detail_kwargs
)
return None
resolved_media_id = mediaid or meta.media_id
if resolved_media_id:
return self.recognize_music(source, str(resolved_media_id))
matched = self._select_track(meta, self._search_tracks(meta))
if matched:
return matched
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
)
return self.recognize_music(
source, str(resolved_media_id), **detail_kwargs
)
if music_type != MUSIC_ENTITY_ALBUM:
matched = self._select_track(meta, self._search_tracks(meta))
if matched:
return matched
if music_type == MUSIC_ENTITY_RECORDING:
return None
album = self._select_album(meta, self._search_albums(meta))
return album.to_music_info() if album else None
@@ -126,14 +146,22 @@ class TheAudioDbModule(_ModuleBase):
**kwargs,
)
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
"""按 TheAudioDB 原生 ID 获取单曲详情,未命中时回退到专辑。"""
def recognize_music(
self,
source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""按 TheAudioDB 原生 ID 和实体类型获取详情;空类型保留旧版探测顺序。"""
if source != self._source or not media_id:
return None
payload = self._request_json("track.php", {"h": media_id})
track = self._first_entity(payload, "track", "tracks")
if track:
return self._track_to_info(track)
if music_type != MUSIC_ENTITY_ALBUM:
payload = self._request_json("track.php", {"h": media_id})
track = self._first_entity(payload, "track", "tracks")
if track:
return self._track_to_info(track)
if music_type == MUSIC_ENTITY_RECORDING:
return None
album = self.music_album(source, media_id)
return album.to_music_info() if album else None
+14 -10
View File
@@ -760,24 +760,26 @@ class ZSpace:
library_ids.append(library_id)
if "/" in library_ids:
return self.refresh_root_library()
success = True
for library_id in library_ids:
if library_id != "/":
return self.__refresh_library_by_id(library_id)
refreshed = self.__refresh_library_by_id(library_id)
success = bool(refreshed) and success
logger.info("极影视媒体库刷新完成")
return True
return success
def __get_library_id_by_item(self, item: schemas.RefreshMediaItem) -> Optional[str]:
"""
根据媒体信息查询在哪个媒体库返回要刷新的位置的ID
:param item: {title, year, type, category, target_path}
"""
if not item.title or not item.year or not item.type:
if not item.type or not item.target_path:
return None
if item.type != MediaType.MOVIE.value:
if item.type == MediaType.TV and item.title and item.year:
item_id = self.__get_series_id_by_name(item.title, item.year)
if item_id:
return item_id
else:
elif item.type == MediaType.MOVIE and item.title and item.year:
if self.get_movies(item.title, item.year):
return None
item_path = Path(item.target_path)
@@ -789,11 +791,13 @@ class ZSpace:
return folder.get("Id")
except Exception as err:
logger.debug(f"匹配子目录出错:{err} - {traceback.format_exc()}")
for folder in self.folders:
for subfolder in folder.get("SubFolders") or []:
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
subfolder.get("Path")):
return folder.get("Id")
if item.category:
for folder in self.folders:
for subfolder in folder.get("SubFolders") or []:
if subfolder.get("Path") and re.search(
r"[/\\]%s" % re.escape(item.category), subfolder.get("Path")
):
return folder.get("Id")
return "/"
@staticmethod
+2
View File
@@ -34,6 +34,8 @@ class DownloadHistory(BaseModel):
media_source: Optional[str] = None
# 数据源原生ID
media_id: Optional[str] = None
# 音乐实体类型:recording 单曲、album 专辑
music_type: Optional[str] = None
# 季Sxx
seasons: Optional[str] = None
# 集Exx
+4 -1
View File
@@ -2,6 +2,8 @@ from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
from app.schemas.types import MusicEntityType, MusicTargetEntityType
class MusicMeta(BaseModel):
"""音乐名称及音频文件解析结果。"""
@@ -38,7 +40,7 @@ class MusicInfo(BaseModel):
type: Literal["音乐"] = "音乐"
# 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
music_type: Literal["recording", "album", "artist"] = "recording"
music_type: MusicEntityType = "recording"
source: Optional[str] = None
media_id: Optional[str] = None
title: Optional[str] = None
@@ -170,3 +172,4 @@ class MusicRecognizeRequest(BaseModel):
source: str
media_id: str
music_type: Optional[MusicTargetEntityType] = None
+4
View File
@@ -3,6 +3,8 @@ from typing import Any, Callable, List, Optional, Union
from pydantic import BaseModel, Field
from app.schemas.types import MusicTargetEntityType
from app.schemas.context import MetaInfo, MediaInfo
from app.schemas.music import MusicInfo, MusicMeta
from app.schemas.file import FileItem
@@ -225,6 +227,8 @@ class ManualTransferItem(BaseModel):
media_source: Optional[str] = None
# 数据源原生ID
media_id: Optional[str] = None
# 音乐实体类型
music_type: Optional[MusicTargetEntityType] = None
# 类型
type_name: Optional[str] = None
# 季号
+18 -1
View File
@@ -1,5 +1,22 @@
from enum import Enum
from typing import Optional
from typing import Literal, Optional
# 音乐实体命名空间由公共类型模块统一持有,避免模型、接口和工具层重复定义。
MUSIC_ENTITY_RECORDING = "recording"
MUSIC_ENTITY_ALBUM = "album"
MUSIC_ENTITY_ARTIST = "artist"
MusicEntityType = Literal["recording", "album", "artist"]
MusicTargetEntityType = Literal["recording", "album"]
MUSIC_ENTITY_TYPES = frozenset({
MUSIC_ENTITY_RECORDING,
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
})
MUSIC_SUBSCRIBABLE_TYPES = frozenset({
MUSIC_ENTITY_RECORDING,
MUSIC_ENTITY_ALBUM,
})
# 媒体类型
+18
View File
@@ -1,6 +1,13 @@
from typing import Any, Optional, Tuple
from app.core.config import settings
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
MUSIC_ENTITY_RECORDING,
MUSIC_ENTITY_TYPES,
MUSIC_SUBSCRIBABLE_TYPES,
)
MEDIA_SOURCE_ALIASES = {
@@ -40,6 +47,17 @@ MUSIC_MEDIA_SOURCE_ORDER = ("musicbrainz", "theaudiodb", "doubanmusic")
MUSIC_MEDIA_SOURCES = frozenset(MUSIC_MEDIA_SOURCE_ORDER)
def normalize_music_type(
value: Optional[object],
*,
allow_artist: bool = True,
) -> Optional[str]:
"""规范化音乐实体类型,非法值返回 None。"""
normalized = str(value or "").strip().lower()
allowed = MUSIC_ENTITY_TYPES if allow_artist else MUSIC_SUBSCRIBABLE_TYPES
return normalized if normalized in allowed else None
def is_music_media_source(source: Optional[str]) -> bool:
"""判断单个请求级来源是否为内置音乐元数据源。"""
return normalize_media_source(source) in MUSIC_MEDIA_SOURCES
+7 -1
View File
@@ -74,11 +74,17 @@ class ScrapeFileAction(BaseAction):
_failed_count += 1
logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削")
continue
mediachain.scrape_metadata(
scrape_result = mediachain.scrape_metadata(
fileitem=fileitem,
meta=media_context.meta_info,
mediainfo=media_context.media_info
)
if isinstance(scrape_result, tuple) and not scrape_result[0]:
_failed_count += 1
logger.info(
f"{fileitem.path} 刮削失败:{scrape_result[1] or '未知错误'}"
)
continue
self._scraped_files.append(fileitem)
# 保存缓存
self.save_cache(workflow_id, cache_key)
+86
View File
@@ -0,0 +1,86 @@
"""3.0.1
为下载历史保存音乐实体类型
Revision ID: 6f9a1c2d3e4b
Revises: 4dadad1d161a
Create Date: 2026-08-12
"""
import json
from alembic import op
import sqlalchemy as sa
revision = "6f9a1c2d3e4b"
down_revision = "4dadad1d161a"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
"""检查数据表是否已存在指定字段。"""
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return False
return any(
column["name"] == column_name
for column in inspector.get_columns(table_name)
)
def _parse_note(value: object) -> dict:
"""将不同数据库驱动返回的 JSON 备注统一转换为字典。"""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _backfill_music_type() -> None:
"""从版本化音乐备注回填旧下载记录的实体类型。"""
download_history = sa.table(
"downloadhistory",
sa.column("id", sa.Integer()),
sa.column("note", sa.JSON()),
sa.column("music_type", sa.String()),
)
connection = op.get_bind()
rows = connection.execute(
sa.select(download_history.c.id, download_history.c.note).where(
download_history.c.music_type.is_(None)
)
).mappings().all()
for row in rows:
note = _parse_note(row["note"])
music_note = note.get("music")
media = music_note.get("media") if isinstance(music_note, dict) else None
music_type = media.get("music_type") if isinstance(media, dict) else None
if music_type not in {"recording", "album"}:
continue
connection.execute(
download_history.update()
.where(download_history.c.id == row["id"])
.values(music_type=music_type)
)
def upgrade() -> None:
"""增加音乐实体字段,并从旧版下载备注幂等回填。"""
if not _has_column("downloadhistory", "music_type"):
op.add_column(
"downloadhistory",
sa.Column("music_type", sa.String(), nullable=True),
)
_backfill_music_type()
def downgrade() -> None:
"""移除下载历史音乐实体字段。"""
if _has_column("downloadhistory", "music_type"):
op.drop_column("downloadhistory", "music_type")
+1 -1
View File
@@ -132,7 +132,7 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶
#### 媒体识别 / 整理
媒体识别、搜索和手动整理内置支持 `themoviedb``douban``bangumi``anilist` 四种数据源,也允许插件处理自定义来源。自动识别仍使用系统默认来源;手动操作可通过请求级 `source``media_source` + `media_id` 临时指定来源,不修改系统默认值。
媒体识别、搜索和手动整理内置支持 `themoviedb``douban``bangumi``anilist` 四种数据源,也允许插件处理自定义来源。影视自动识别在未指定来源时优先使用 TMDB;TMDB 未可靠命中后才并发查询其它内置来源,并按标题、类型、年份和季信息选择最佳兜底结果。音乐自动识别会并发比较全部内置音乐来源。手动操作可通过请求级 `source``media_source` + `media_id` 严格指定单一来源,不修改系统默认值,也不会跨来源兜底
涉及媒体身份的请求统一以 `media_source` + `media_id` 表示本次选定的主身份,同时保留 `tmdbid``doubanid``bangumiid``anilistid` 作为跨数据源映射和旧客户端兼容字段。两者并非两套独立数据流:显式通用主身份优先,专用 ID 用于补全映射和兼容回退。
+2
View File
@@ -111,6 +111,8 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
### Media Search (13 endpoints)
When a video recognition request omits `source`, MoviePilot validates TMDB first and only then queries `douban`, `bangumi`, and `anilist` concurrently to score a fallback. Providing `source` or a source-native ID keeps recognition strict to that source. Music recognition without `source` compares all built-in music sources concurrently.
| 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` |
+73
View File
@@ -18,6 +18,7 @@ from app.agent.tools.impl.query_subscribe_history import QuerySubscribeHistoryTo
from app.agent.tools.impl.recognize_media import RecognizeMediaTool
from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
from app.agent.tools.impl.search_media import SearchMediaTool
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
@@ -46,6 +47,31 @@ def _recording() -> MusicInfo:
)
def test_search_torrents_forwards_album_namespace_before_recognition():
"""Agent 精确搜专辑资源时应在识别阶段绑定 album 命名空间。"""
async_search = AsyncMock(return_value=[])
async_sites = AsyncMock(return_value=[])
tool = SearchTorrentsTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.search_torrents.SearchChain.async_search_by_id",
new=async_search,
), patch(
"app.agent.tools.impl.search_torrents.SitesHelper",
return_value=SimpleNamespace(async_get_indexers=async_sites),
):
asyncio.run(
tool.run(
media_type="music",
music_type="album",
media_source="musicbrainz",
media_id="release-group-1",
)
)
assert async_search.await_args.kwargs["music_type"] == "album"
def _album() -> MusicInfo:
"""构造 Agent 工具测试使用的整张专辑信息。"""
return MusicInfo(
@@ -202,6 +228,53 @@ def test_query_album_detail_exposes_complete_track_contract():
assert payload["tracks"][0]["media_id"] == "recording-1"
def test_query_recording_detail_forwards_recording_namespace():
"""Agent 查询单曲详情时必须把 Recording 实体传给统一识别入口。"""
async_recognize = AsyncMock(return_value=_recording())
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,
):
result = asyncio.run(tool.run(
media_type="music",
music_type="recording",
media_source="musicbrainz",
media_id="recording-1",
))
payload = json.loads(result)
assert payload["music_type"] == "recording"
assert async_recognize.await_args.kwargs["music_type"] == "recording"
def test_scrape_album_uses_unified_entity_recognition(tmp_path):
"""Agent 专辑刮削应通过 MediaChain 识别,不再单独编排 MusicChain 专辑查询。"""
album_dir = tmp_path / "叶惠美"
album_dir.mkdir()
async_recognize = AsyncMock(return_value=_album())
tool = ScrapeMetadataTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.scrape_metadata.MediaChain.async_recognize_media",
new=async_recognize,
), patch(
"app.agent.tools.impl.scrape_metadata.MediaChain.scrape_music_metadata",
return_value=(True, "已刮削专辑"),
):
result = asyncio.run(tool.run(
path=str(album_dir),
media_type="music",
music_type="album",
media_source="musicbrainz",
media_id="release-group-1",
))
assert json.loads(result)["success"] is True
assert async_recognize.await_args.kwargs["music_type"] == "album"
def test_query_artist_detail_marks_entity_as_non_subscribable():
"""艺术家详情应明确标记为不可订阅,避免 Agent 混入获取流程。"""
artist = MusicArtistInfo(
+69
View File
@@ -2,6 +2,8 @@ from pathlib import Path
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,
@@ -90,6 +92,36 @@ def test_music_info_serialization_exposes_derived_audio_quality():
assert payload["audio_specs"] == "FLAC · 24-bit · 96 kHz · 2,304 kbps"
def test_music_info_from_meta_preserves_track_and_audio_evidence():
"""核心元数据转换应保留整理、刮削和通知依赖的曲序与实际音频参数。"""
from app.core.meta import MetaMusic
info = MusicInfo.from_meta(MetaMusic(
title="Get Lucky",
artists=["Daft Punk"],
album="Random Access Memories",
disc_number=1,
track_number=8,
total_tracks=13,
audio_format="FLAC",
bit_depth=24,
sample_rate=96_000,
bitrate=2_304_000,
duration=369,
isrc="USQX91300105",
))
assert info.track_number == 8
assert info.total_tracks == 13
assert info.audio_format == "FLAC"
assert info.audio_lossless is True
assert info.bit_depth == 24
assert info.sample_rate == 96_000
assert info.bitrate == 2_304_000
assert info.duration == 369
assert info.isrc == "USQX91300105"
def test_parse_compact_audio_quality_tokens_without_false_sample_bitrate():
"""紧凑资源命名中的 FLAC24bit 和 320K 应可识别,96kHz 不得误判为码率。"""
lossless = parse_audio_quality("Album.FLAC24bit.96kHz")
@@ -112,6 +144,21 @@ def test_read_audio_metadata_falls_back_to_filename(monkeypatch):
assert meta.audio_format == "MP3"
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)
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):
"""标签不可读时应直接使用完整动态模式解析复杂音乐文件名。"""
audio_path = tmp_path / (
@@ -155,6 +202,28 @@ def test_read_audio_metadata_partial_tags_use_filename_for_missing_fields(
assert meta.sample_rate == 44100
def test_read_audio_metadata_distinguishes_alac_inside_m4a(monkeypatch):
"""M4A 容器应依据实际流编码区分 ALAC 与 AAC,避免把无损音频降级。"""
audio = SimpleNamespace(
tags={"title": ["Lossless Track"]},
info=SimpleNamespace(
codec="alac",
codec_description="Apple Lossless Audio Codec",
length=180,
bitrate=900000,
bits_per_sample=24,
sample_rate=96000,
),
)
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
meta = AudioMetadataHelper.read(Path("/music/Lossless Track.m4a"))
assert meta.audio_format == "ALAC"
assert meta.audio_lossless is True
assert meta.audio_quality == "hires"
def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
"""音乐刮削应把标准歌曲、专辑和曲序字段写回音频标签。"""
class FakeAudio:
+68
View File
@@ -0,0 +1,68 @@
"""处理链模块分层约束测试。"""
import ast
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
CHAIN_ROOT = PROJECT_ROOT / "app" / "chain"
def _imported_modules(path: Path) -> set[str]:
"""解析源码中的导入模块,包含函数内部的延迟导入。"""
tree = ast.parse(path.read_text(encoding="utf-8"))
modules: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
modules.add(node.module)
elif isinstance(node, ast.Import):
modules.update(alias.name for alias in node.names)
return modules
def _inherited_recognize_calls(path: Path) -> list[tuple[int, str]]:
"""查找业务链通过 self 隐式调用媒体识别入口的位置。"""
tree = ast.parse(path.read_text(encoding="utf-8"))
calls: list[tuple[int, str]] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
owner = node.func.value
if (
isinstance(owner, ast.Name)
and owner.id == "self"
and node.func.attr in {"recognize_media", "async_recognize_media"}
):
calls.append((node.lineno, node.func.attr))
return calls
def test_chain_base_does_not_import_concrete_chains() -> None:
"""基础链不得反向导入任何具体处理链。"""
imports = _imported_modules(CHAIN_ROOT / "__init__.py")
assert not {
module for module in imports
if module.startswith("app.chain.")
}
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_business_chains_delegate_recognition_to_media_chain() -> None:
"""搜索、订阅、下载和转移链必须显式委托媒体识别编排层。"""
violations = {
name: calls
for name in ("search.py", "subscribe.py", "download.py", "transfer.py")
if (calls := _inherited_recognize_calls(CHAIN_ROOT / name))
}
assert not violations
+12 -1
View File
@@ -79,13 +79,24 @@ def test_monthly_media_statistics_counts_successful_unique_media():
TransferHistory(status=True, date=f"{month}06 10:00:00", type=MediaType.MUSIC.value, title="晴天", media_id="musicbrainz:r1"),
TransferHistory(status=True, date=f"{month}07 10:00:00", type=MediaType.MUSIC.value, title="未知曲目"),
TransferHistory(status=False, date=f"{month}08 10:00:00", type=MediaType.MUSIC.value, title="失败曲目"),
# 整专内不同目标曲目共享专辑 ID,但应分别计入音乐数量
TransferHistory(
status=True, date=f"{month}09 10:00:00", type=MediaType.MUSIC.value,
title="以父之名", media_source="musicbrainz", media_id="release-group-1",
music_type="album", dest="/music/叶惠美/01 - 以父之名.flac",
),
TransferHistory(
status=True, date=f"{month}10 10:00:00", type=MediaType.MUSIC.value,
title="晴天", media_source="musicbrainz", media_id="release-group-1",
music_type="album", dest="/music/叶惠美/03 - 晴天.flac",
),
]
db = SessionFactory()
try:
db.add_all(histories)
db.commit()
assert TransferHistory.monthly_media_statistics(db) == (1, 1, 3, 2)
assert TransferHistory.monthly_media_statistics(db) == (1, 1, 3, 4)
finally:
history_ids = [history.id for history in histories if history.id is not None]
if history_ids:
+10 -8
View File
@@ -528,16 +528,18 @@ def test_resolve_media_download_dir_rejects_bad_subtitle_save_path():
assert error_msg == "保存路径不在允许的下载目录范围内"
def test_download_subtitle_returns_specific_error_for_bad_save_path():
def test_download_subtitle_returns_specific_error_for_bad_save_path(monkeypatch):
chain = DownloadChain.__new__(DownloadChain)
chain.recognize_media = MagicMock(
return_value=MediaInfo(
type=MediaType.MOVIE,
title="Demo Movie",
year="2026",
tmdb_id=1,
)
mediainfo = MediaInfo(
type=MediaType.MOVIE,
title="Demo Movie",
year="2026",
tmdb_id=1,
)
media_chain = MagicMock()
media_chain.recognize_media.return_value = mediainfo
media_chain.supplement_tmdb_info.return_value = mediainfo
monkeypatch.setattr(download_module, "MediaChain", MagicMock(return_value=media_chain))
subtitle = SubtitleInfo(
title="Demo Movie",
enclosure="https://example.test/subtitle.srt",
@@ -0,0 +1,62 @@
import importlib
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def test_downloadhistory_music_type_migration_backfills_music_notes(monkeypatch) -> None:
"""迁移应新增实体字段,并从版本化音乐备注中回填旧记录。"""
migration = importlib.import_module(
"database.versions.6f9a1c2d3e4b_3_0_1"
)
engine = sa.create_engine("sqlite://")
metadata = sa.MetaData()
download_history = sa.Table(
"downloadhistory",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("note", sa.JSON()),
)
with engine.begin() as connection:
metadata.create_all(connection)
connection.execute(
download_history.insert(),
[
{
"id": 1,
"note": {
"music": {
"version": 1,
"media": {"music_type": "album"},
}
},
},
{"id": 2, "note": {"source": "search"}},
],
)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
migration.upgrade()
migration.upgrade()
migrated = sa.Table(
"downloadhistory",
sa.MetaData(),
autoload_with=connection,
)
rows = connection.execute(
sa.select(migrated).order_by(migrated.c.id)
).mappings().all()
assert rows[0]["music_type"] == "album"
assert rows[1]["music_type"] is None
migration.downgrade()
rolled_back = sa.Table(
"downloadhistory",
sa.MetaData(),
autoload_with=connection,
)
assert "music_type" not in rolled_back.c
+168 -6
View File
@@ -478,6 +478,7 @@ def test_music_report_payload_includes_source_and_media_id():
assert payload["doubanid"] is None
assert payload["media_source"] == "musicbrainz"
assert payload["media_id"] == "recording-1"
assert payload["music_type"] == "recording"
def test_music_report_payload_skips_fallback_without_remote_identity():
@@ -506,12 +507,47 @@ def test_music_query_params_build_for_music_type():
assert params["keyword"] == "晴天"
assert params["type"] == "music"
assert params["year"] == "2003"
assert params["music_type"] == "recording"
assert "season" not in params
album_params = MoviePilotServerHelper._build_recognize_query_params(
meta=meta,
mtype=MediaType.MUSIC,
keyword_meta=keyword_meta,
music_type="album",
)
assert album_params["music_type"] == "album"
def test_music_shared_identity_keeps_entity_type():
"""共享专辑身份转回本地识别参数时必须保留专辑命名空间。"""
params = MoviePilotServerHelper.to_recognize_params({
"type": "music",
"media_source": "musicbrainz",
"media_id": "release-group-1",
"music_type": "album",
})
assert params["mtype"] == MediaType.MUSIC
assert params["source"] == "musicbrainz"
assert params["mediaid"] == "release-group-1"
assert params["music_type"] == "album"
def test_legacy_music_shared_identity_defaults_to_recording():
"""旧共享记录缺少实体字段时只按自动识别的 Recording 语义兼容。"""
params = MoviePilotServerHelper.to_recognize_params({
"type": "music",
"media_source": "musicbrainz",
"media_id": "recording-1",
})
assert params["music_type"] == "recording"
def test_chain_recognize_media_reports_music_share_result():
"""音乐识别成功且有远端身份时,应与影视一样走统一共享上报。"""
chain = ChainBase()
chain = MediaChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"], year=2003)
music = _music_info()
@@ -530,7 +566,7 @@ def test_chain_recognize_media_reports_music_share_result():
def test_chain_recognize_media_queries_music_share_when_local_failed():
"""音乐本地识别失败后应回查共享识别并按数据源原生 ID 二次识别。"""
chain = ChainBase()
chain = MediaChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
music = _music_info()
@@ -542,13 +578,19 @@ def test_chain_recognize_media_queries_music_share_when_local_failed():
return_value=music,
) as recognize_source, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value={"type": "music", "media_source": "musicbrainz", "media_id": "recording-1"},
return_value={
"type": "music",
"media_source": "musicbrainz",
"media_id": "recording-1",
"music_type": "recording",
},
), patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.MUSIC,
"source": "musicbrainz",
"mediaid": "recording-1",
"music_type": "recording",
"tmdbid": None,
"doubanid": None,
"bangumiid": None,
@@ -571,21 +613,141 @@ def test_chain_recognize_media_queries_music_share_when_local_failed():
meta=meta,
mediaid="recording-1",
cache=False,
music_type="recording",
)
def test_chain_recognize_media_queries_music_share_after_local_fallback():
"""本地标签兜底没有远端身份时,仍应通过共享结果补成标准音乐身份。"""
chain = MediaChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
fallback = MusicInfo(title="晴天", artists=["周杰伦"])
music = _music_info()
with patch(
"app.chain.music.MusicChain.recognize_best",
return_value=fallback,
), patch(
"app.chain.music.MusicChain.recognize_from_source",
return_value=music,
) as recognize_source, patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value={
"type": "music",
"media_source": "musicbrainz",
"media_id": "recording-1",
"music_type": "recording",
},
) as query_share, patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.MUSIC,
"source": "musicbrainz",
"mediaid": "recording-1",
"music_type": "recording",
"tmdbid": None,
"doubanid": None,
"bangumiid": None,
"anilistid": None,
"season": None,
},
), patch.object(
chain,
"_update_local_recognize_cache",
), patch(
"app.chain.settings.MEDIA_RECOGNIZE_SHARE",
True,
):
result = chain.recognize_media(meta=meta, cache=False)
assert result is music
query_share.assert_called_once_with(
meta=meta,
mtype=MediaType.MUSIC,
keyword_meta=meta,
)
recognize_source.assert_called_once()
def test_chain_async_recognize_media_queries_music_share_after_local_fallback():
"""异步音乐识别也必须在返回本地兜底前尝试共享身份补全。"""
chain = MediaChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
fallback = MusicInfo(title="晴天", artists=["周杰伦"])
music = _music_info()
async def runner():
with patch(
"app.chain.music.MusicChain.async_recognize_best",
new=AsyncMock(return_value=fallback),
), patch(
"app.chain.music.MusicChain.async_recognize_from_source",
new=AsyncMock(return_value=music),
) as recognize_source, patch(
"app.chain.MoviePilotServerHelper.async_query_recognize_share",
new=AsyncMock(return_value={
"type": "music",
"media_source": "musicbrainz",
"media_id": "recording-1",
"music_type": "recording",
}),
) as query_share, patch(
"app.chain.MoviePilotServerHelper.to_recognize_params",
return_value={
"mtype": MediaType.MUSIC,
"source": "musicbrainz",
"mediaid": "recording-1",
"music_type": "recording",
"tmdbid": None,
"doubanid": None,
"bangumiid": None,
"anilistid": None,
"season": None,
},
), patch.object(
chain,
"_async_update_local_recognize_cache",
new=AsyncMock(),
), patch(
"app.chain.settings.MEDIA_RECOGNIZE_SHARE",
True,
):
result = await chain.async_recognize_media(meta=meta, cache=False)
return result, query_share, recognize_source
result, query_share, recognize_source = asyncio.run(runner())
assert result is music
query_share.assert_awaited_once_with(
meta=meta,
mtype=MediaType.MUSIC,
keyword_meta=meta,
)
recognize_source.assert_awaited_once_with(
source="musicbrainz",
meta=meta,
mediaid="recording-1",
cache=False,
music_type="recording",
)
def test_chain_recognize_media_skips_music_report_for_fallback_result():
"""兜底无远端身份的音乐结果上报共享识别,由载荷构建返回空保证"""
chain = ChainBase()
"""共享也未命中时保留音乐标签兜底,且不把无身份结果上报。"""
chain = MediaChain()
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"])
with patch("app.chain.music.MusicChain.recognize_best", return_value=fallback), patch(
"app.chain.MoviePilotServerHelper.recognize_report"
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value=None,
) as query_mock, patch(
"app.chain.MoviePilotServerHelper.report_recognize_share"
) as report_mock, patch(
"app.chain.settings.MEDIA_RECOGNIZE_SHARE", True
):
result = chain.recognize_media(meta=meta, cache=False)
assert result is fallback
query_mock.assert_called_once()
report_mock.assert_not_called()
+61 -3
View File
@@ -130,9 +130,10 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
result = scrape(
fileitem=fileitem,
storage="local",
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
type_name=MediaType.MUSIC,
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
type_name=MediaType.MUSIC,
music_type="recording",
_=Mock(),
)
@@ -141,6 +142,7 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
source="musicbrainz",
mediaid="977e6978-139d-425c-bb98-6b0c62d1e45e",
mtype=MediaType.MUSIC,
music_type="recording",
)
media_chain.scrape_music_metadata.assert_called_once_with(
fileitem=fileitem,
@@ -171,3 +173,59 @@ def test_scrape_music_without_source_keeps_automatic_recognition() -> None:
overwrite=True,
source=None,
)
def test_scrape_music_album_forwards_album_namespace() -> None:
"""手动专辑刮削必须把 Release Group ID 标记为 album。"""
fileitem = FileItem(storage="local", path="/music/叶惠美", type="dir")
info = MusicInfo(
source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
music_type="album",
title="叶惠美",
)
media_chain = Mock()
media_chain.recognize_media.return_value = info
media_chain.scrape_music_metadata.return_value = (True, "已刮削专辑")
with patch("app.api.endpoints.media.MediaChain", return_value=media_chain):
result = scrape(
fileitem=fileitem,
storage="local",
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
type_name=MediaType.MUSIC,
music_type="album",
_=Mock(),
)
assert result.success is True
assert media_chain.recognize_media.call_args.kwargs["music_type"] == "album"
def test_scrape_music_accepts_douban_recording_composite_id() -> None:
"""豆瓣音乐曲目 ID 使用“专辑ID:曲序”时应通过入口校验。"""
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
info = MusicInfo(
source="doubanmusic",
media_id="1401853:3",
music_type="recording",
title="晴天",
)
media_chain = Mock()
media_chain.recognize_media.return_value = info
media_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件")
with patch("app.api.endpoints.media.MediaChain", return_value=media_chain):
result = scrape(
fileitem=fileitem,
storage="local",
media_source="doubanmusic",
media_id="1401853:3",
type_name=MediaType.MUSIC,
music_type="recording",
_=Mock(),
)
assert result.success is True
assert media_chain.recognize_media.call_args.kwargs["mediaid"] == "1401853:3"
+115
View File
@@ -0,0 +1,115 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, call
from app.modules.emby.emby import Emby
from app.modules.plex.plex import Plex
from app.modules.zspace.zspace import ZSpace
from app.schemas import RefreshMediaItem
from app.schemas.types import MediaType
def _music_item(path: str) -> RefreshMediaItem:
"""构造不依赖发行年份的音乐媒体库刷新项。"""
return RefreshMediaItem(
title="晴天",
type=MediaType.MUSIC,
category="音乐",
target_path=Path(path),
)
def test_emby_music_refresh_resolves_library_by_path_without_year():
"""Emby 音乐刷新应直接按目标路径定位媒体库,不得误查电视剧。"""
service = object.__new__(Emby)
service.folders = [
{"Id": "music-library", "SubFolders": [{"Path": "/library/music"}]},
]
service._Emby__get_emby_series_id_by_name = Mock()
service.get_movies = Mock()
library_id = service._Emby__get_emby_library_id_by_item(
_music_item("/library/music/周杰伦/叶惠美/03 - 晴天.flac")
)
assert library_id == "music-library"
service._Emby__get_emby_series_id_by_name.assert_not_called()
service.get_movies.assert_not_called()
def test_zspace_music_refresh_resolves_library_by_path_without_year():
"""极影视音乐刷新应直接按目标路径定位媒体库,不得误查电视剧。"""
service = object.__new__(ZSpace)
service.folders = [
{"Id": "music-library", "SubFolders": [{"Path": "/library/music"}]},
]
service._ZSpace__get_series_id_by_name = Mock()
service.get_movies = Mock()
library_id = service._ZSpace__get_library_id_by_item(
_music_item("/library/music/周杰伦/叶惠美/03 - 晴天.flac")
)
assert library_id == "music-library"
service._ZSpace__get_series_id_by_name.assert_not_called()
service.get_movies.assert_not_called()
def test_emby_refreshes_all_unique_libraries_and_aggregates_failures():
"""Emby 批量刷新必须处理全部唯一媒体库,并汇总任一刷新失败。"""
service = object.__new__(Emby)
service._Emby__get_emby_library_id_by_item = Mock(
side_effect=["library-a", "library-b", "library-a"]
)
service._Emby__refresh_emby_library_by_id = Mock(side_effect=[True, False])
items = [_music_item(f"/library/music/{index}.flac") for index in range(3)]
assert service.refresh_library_by_items(items) is False
assert service._Emby__refresh_emby_library_by_id.call_args_list == [
call("library-a"),
call("library-b"),
]
def test_zspace_refreshes_all_unique_libraries_and_aggregates_failures():
"""极影视批量刷新必须处理全部唯一媒体库,并汇总任一刷新失败。"""
service = object.__new__(ZSpace)
service._ZSpace__get_library_id_by_item = Mock(
side_effect=["library-a", "library-b", "library-a"]
)
service._ZSpace__refresh_library_by_id = Mock(side_effect=[True, False])
items = [_music_item(f"/library/music/{index}.flac") for index in range(3)]
assert service.refresh_library_by_items(items) is False
assert service._ZSpace__refresh_library_by_id.call_args_list == [
call("library-a"),
call("library-b"),
]
def test_plex_refreshes_every_matched_path():
"""Plex 批量刷新不能在首个路径后提前返回。"""
service = object.__new__(Plex)
service._plex = SimpleNamespace(
library=SimpleNamespace(update=Mock(return_value=True)),
query=Mock(),
)
service._libraries = []
service._Plex__find_librarie = Mock(
side_effect=[
("1", Path("/library/music/Album A/01.flac")),
("2", Path("/library/music/Album B/01.flac")),
]
)
result = service.refresh_library_by_items(
[
_music_item("/library/music/Album A/01.flac"),
_music_item("/library/music/Album B/01.flac"),
]
)
assert result is True
assert service._plex.query.call_count == 2
assert "/library/sections/1/refresh" in service._plex.query.call_args_list[0].args[0]
assert "/library/sections/2/refresh" in service._plex.query.call_args_list[1].args[0]
+31
View File
@@ -178,6 +178,37 @@ def test_recognize_album_directory_skips_single_file(tmp_path, music_chain, monk
assert music_chain.recognize_album_directory(album_dir) == {}
def test_recognize_album_directory_invalidates_cache_after_same_count_rename(
tmp_path,
music_chain,
monkeypatch,
):
"""目录内文件数量不变但文件名变化时,专辑曲目映射缓存必须失效。"""
album_dir = tmp_path / "Album"
album_dir.mkdir()
first = album_dir / "01 - First.wav"
second = album_dir / "02 - Second.wav"
first.write_bytes(b"RIFF")
second.write_bytes(b"RIFF")
calls = []
def fake_match(_dir_path, files):
"""记录目录匹配输入,返回空映射以专注验证缓存签名。"""
calls.append([file.name for file in files])
return {}
monkeypatch.setattr(music_chain, "_match_album_directory", fake_match)
music_chain.recognize_album_directory(album_dir)
first.rename(album_dir / "01 - Renamed.wav")
music_chain.recognize_album_directory(album_dir)
assert calls == [
["01 - First.wav", "02 - Second.wav"],
["01 - Renamed.wav", "02 - Second.wav"],
]
def test_recognize_music_by_path_falls_back_to_album_match(tmp_path, monkeypatch):
"""单曲识别无远端身份时应用目录级匹配结果兜底。"""
album_dir = tmp_path / "周杰伦 - 七里香 (2004)"
+46 -1
View File
@@ -1,9 +1,10 @@
from unittest.mock import Mock, patch
from app.api.endpoints.download import download
from app.api.endpoints.download import add, download
from app.chain.download import DownloadChain
from app.chain.music import MusicChain
from app.core.context import MUSIC_ENTITY_ALBUM, Context, MusicInfo
from app.core.meta import MetaMusic
from app.schemas import ExistMediaInfo
from app.schemas.context import TorrentInfo
from app.schemas.music import MusicInfo as MusicInfoSchema
@@ -96,6 +97,19 @@ def test_album_resource_rejects_incomplete_or_unverifiable_pack():
)
def test_album_resource_dedupes_same_track_in_different_formats():
"""同一盘同一曲序的多种编码不能冒充多首独立曲目。"""
context = Context(media_info=_album_info(total_tracks=2))
error = DownloadChain._validate_music_album_resource(
context,
["叶惠美/01 - 以父之名.flac", "叶惠美/01 - 以父之名.mp3"],
)
assert "仅包含 1 个独立音频文件" in (error or "")
assert context.confirmed_full_coverage is False
def test_download_single_stops_before_client_when_album_pack_is_incomplete():
"""下载入口应在添加任务前拒绝不完整专辑,并记录可供后续候选继续尝试的失败原因。"""
context = Context(
@@ -157,6 +171,37 @@ def test_download_endpoint_builds_music_context():
assert context.meta_info.org_string == "周杰伦 - 叶惠美 FLAC"
def test_download_add_forwards_album_namespace_to_media_chain():
"""无完整媒体上下文的专辑下载必须在精确识别前保留 album 命名空间。"""
media_chain = Mock()
media_chain.recognize_media.return_value = _album_info(total_tracks=11)
download_chain = Mock()
download_chain.download_single.return_value = "hash-album"
with patch("app.api.endpoints.download.MediaChain", return_value=media_chain), patch(
"app.api.endpoints.download.DownloadChain", return_value=download_chain
):
response = add(
torrent_in=TorrentInfo(
title="周杰伦 - 叶惠美 FLAC",
enclosure="https://example.com/album.torrent",
category=MediaType.MUSIC.value,
),
media_source="musicbrainz",
media_id="release-group-1",
music_type="album",
current_user=Mock(name="admin"),
)
assert response.success is True
recognize_kwargs = media_chain.recognize_media.call_args.kwargs
assert isinstance(recognize_kwargs["meta"], MetaMusic)
assert recognize_kwargs["mtype"] == MediaType.MUSIC
assert recognize_kwargs["music_type"] == MUSIC_ENTITY_ALBUM
context = download_chain.download_single.call_args.kwargs["context"]
assert context.media_info.music_type == MUSIC_ENTITY_ALBUM
def test_music_library_exists_uses_atomic_album_lookup():
"""整专存在性检查应按音乐条目判断,不能落入电视剧季集补全分支。"""
album = _album_info(total_tracks=11)
+5 -3
View File
@@ -123,9 +123,10 @@ def test_recognize_music_returns_detail():
with patch("app.api.endpoints.music.MediaChain", return_value=chain):
result = asyncio.run(
recognize_music(
request=MusicRecognizeRequest(
source="musicbrainz",
media_id="recording-1",
request=MusicRecognizeRequest(
source="musicbrainz",
media_id="recording-1",
music_type="recording",
),
_=Mock(),
)
@@ -136,6 +137,7 @@ def test_recognize_music_returns_detail():
source="musicbrainz",
mediaid="recording-1",
mtype=MediaType.MUSIC,
music_type="recording",
)
+209
View File
@@ -0,0 +1,209 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.core.config import settings
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper
from app.helper.directory import DirectoryHelper
from app.modules.filemanager import FileManagerModule
from app.modules.filemanager.transhandler import TransHandler
from app.schemas import FileItem, TransferDirectoryConf
from app.schemas.types import MediaType
def _audio_file(name: str, extension: str = "flac") -> FileItem:
"""构造标准专辑目录下的音频文件项。"""
path = Path("/library/Daft Punk/Random Access Memories (2013)") / name
return FileItem(
storage="local",
path=path.as_posix(),
name=path.name,
basename=path.stem,
type="file",
extension=extension,
)
def _recording(**overrides) -> MusicInfo:
"""构造本地媒体库查重使用的单曲身份。"""
values = {
"source": "musicbrainz",
"media_id": "recording-1",
"title": "Get Lucky",
"artists": ["Daft Punk"],
"album": "Random Access Memories",
"album_artist": "Daft Punk",
"year": 2013,
"disc_number": 1,
"track_number": 8,
}
values.update(overrides)
return MusicInfo(**values)
def test_music_media_root_uses_album_directory_with_or_without_disc_folder():
"""音乐根目录应稳定落在专辑层,不受动态 Disc 子目录是否渲染影响。"""
album_dir = Path("/library/Daft Punk/Random Access Memories (2013)")
assert DirectoryHelper.get_media_root_path(
settings.MUSIC_RENAME_FORMAT,
album_dir / "08 - Get Lucky.flac",
media_type=MediaType.MUSIC,
) == album_dir
assert DirectoryHelper.get_media_root_path(
settings.MUSIC_RENAME_FORMAT,
album_dir / "Disc 1" / "08 - Get Lucky.flac",
media_type=MediaType.MUSIC,
) == album_dir
def test_media_files_uses_music_template_root_and_only_returns_audio():
"""本地音乐查重应扫描目标专辑目录并排除同目录视频文件。"""
module = FileManagerModule()
storage = Mock()
album_dir = Path("/library/Daft Punk/Random Access Memories (2013)")
storage.get_item.return_value = FileItem(
storage="local",
path=album_dir.as_posix(),
name=album_dir.name,
type="dir",
)
audio = _audio_file("08 - Get Lucky.flac")
video = FileItem(
storage="local",
path=(album_dir / "Get Lucky.mkv").as_posix(),
name="Get Lucky.mkv",
basename="Get Lucky",
type="file",
extension="mkv",
)
module._FileManagerModule__get_storage_oper = Mock(return_value=storage)
module.list_files = Mock(return_value=[audio, video])
directory = TransferDirectoryConf(
library_path="/library",
library_storage="local",
)
with patch(
"app.modules.filemanager.DirectoryHelper.get_library_dirs",
return_value=[directory],
), patch(
"app.modules.filemanager.transhandler.eventmanager.send_event",
return_value=None,
):
files = module.media_files(_recording())
storage.get_item.assert_called_once_with(album_dir)
assert files == [audio]
def test_local_music_recording_requires_matching_track_not_any_album_file():
"""单曲查重必须命中目标曲名,不能因同专辑存在其它音轨而误判完成。"""
module = FileManagerModule()
module.media_files = Mock(
return_value=[
_audio_file("01 - Give Life Back to Music.flac"),
_audio_file("08 - Get Lucky.flac"),
]
)
with patch("app.modules.filemanager.settings.LOCAL_EXISTS_SEARCH", True):
exists = module.media_exists(_recording())
missing = module.media_exists(
_recording(title="Instant Crush", media_id="recording-2", track_number=5)
)
assert exists and exists.type == MediaType.MUSIC
assert missing is None
def test_local_music_album_requires_unique_complete_track_coverage():
"""专辑查重应按去重曲目数判定完整,重复格式不能冒充缺失音轨。"""
module = FileManagerModule()
duplicate = _audio_file("01 - Give Life Back to Music.mp3", extension="mp3")
files = [
_audio_file("01 - Give Life Back to Music.flac"),
duplicate,
_audio_file("02 - The Game of Love.flac"),
]
module.media_files = Mock(return_value=files)
album = _recording(
music_type="album",
media_id="release-group-1",
title="Random Access Memories",
track_number=None,
total_tracks=3,
)
with patch("app.modules.filemanager.settings.LOCAL_EXISTS_SEARCH", True):
assert module.media_exists(album) is None
files.append(_audio_file("03 - Giorgio by Moroder.flac"))
exists = module.media_exists(album)
assert exists and exists.type == MediaType.MUSIC
def test_local_music_album_with_unknown_total_is_not_assumed_complete():
"""未知专辑总曲目数时不能仅凭目录中存在音频就判定订阅完成。"""
assert FileManagerModule._music_album_is_complete(
[_audio_file("01 - Intro.flac")],
total_tracks=None,
) is False
def test_local_music_album_counts_same_track_number_on_different_discs():
"""多碟专辑中相同曲序应通过盘号区分为不同曲目。"""
files = [
_audio_file("Disc 1/01 - Intro.flac"),
_audio_file("Disc 2/01 - Finale.flac"),
]
assert FileManagerModule._music_album_is_complete(files, total_tracks=2) is True
def test_music_size_overwrite_prefers_actual_audio_quality(tmp_path, monkeypatch):
"""音乐的 size 覆盖判断应优先使用音频参数,而不是把字节数当作音质。"""
target_path = tmp_path / "Track.flac"
target_path.write_bytes(b"target")
target_item = FileItem(
storage="local",
path=target_path.as_posix(),
name=target_path.name,
basename=target_path.stem,
type="file",
extension="flac",
)
music = _recording()
monkeypatch.setattr(
AudioMetadataHelper,
"read",
lambda path: MetaMusic(audio_format="MP3", bitrate=320000),
)
upgrade = TransHandler._TransHandler__music_quality_overwrite_decision(
MetaMusic(audio_format="FLAC", bit_depth=24, sample_rate=192000),
music,
target_item,
)
monkeypatch.setattr(
AudioMetadataHelper,
"read",
lambda path: MetaMusic(audio_format="FLAC", bit_depth=24, sample_rate=192000),
)
downgrade = TransHandler._TransHandler__music_quality_overwrite_decision(
MetaMusic(audio_format="MP3", bitrate=320000),
music,
target_item,
)
non_music = TransHandler._TransHandler__music_quality_overwrite_decision(
MetaMusic(audio_format="FLAC"),
SimpleNamespace(type=MediaType.MOVIE),
target_item,
)
assert upgrade is True
assert downgrade is False
assert non_music is None
+31
View File
@@ -65,6 +65,37 @@ def test_theaudiodb_module_ignores_other_sources(monkeypatch):
request.assert_not_called()
def test_theaudiodb_detail_respects_requested_entity(monkeypatch):
"""TheAudioDB 数值 ID 必须按显式实体调用对应接口,不能跨表探测。"""
module = TheAudioDbModule()
request = Mock(return_value={"album": []})
monkeypatch.setattr(module, "_request_json", request)
result = module.recognize_music(
"theaudiodb",
"2109619",
music_type=MUSIC_ENTITY_ALBUM,
)
assert result is None
request.assert_called_once_with("album.php", {"m": "2109619"})
def test_douban_detail_rejects_album_id_as_recording(monkeypatch):
"""豆瓣单曲使用专辑加曲序复合 ID,纯专辑 ID 不能作为 Recording。"""
module = DoubanModule()
module.doubanapi = Mock()
result = module.recognize_music(
"doubanmusic",
"1401853",
music_type="recording",
)
assert result is None
module.doubanapi.music_detail.assert_not_called()
def test_douban_music_search_and_album_mapping(monkeypatch):
"""豆瓣模块应把音乐条目映射为专辑,并生成可用于曲目识别的复合 ID。"""
module = DoubanModule()
+64 -4
View File
@@ -34,7 +34,20 @@ def _remote_music() -> MusicInfo:
def test_music_recognize_help_sends_event_and_rematches(monkeypatch):
"""原生识别无远端身份时应发送音乐名称识别事件,并按修正要素重新匹配。"""
chain = MediaChain()
meta = MetaMusic(title="周杰伦 晴天 FLAC 24bit 48kHz")
meta = MetaMusic(
title="周杰伦 晴天 FLAC 24bit 48kHz",
disc_number=1,
track_number=3,
total_discs=1,
total_tracks=11,
version="原版",
audio_format="FLAC",
bit_depth=24,
sample_rate=48_000,
bitrate=2_304_000,
duration=269,
isrc="TW-A53-03-00003",
)
remote = _remote_music()
recognize_calls = []
@@ -66,6 +79,18 @@ def test_music_recognize_help_sends_event_and_rematches(monkeypatch):
assert rematch_meta.artists == ["周杰伦"]
assert rematch_meta.album == "叶惠美"
assert rematch_meta.year == 2003
assert rematch_meta.disc_number == 1
assert rematch_meta.track_number == 3
assert rematch_meta.total_discs == 1
assert rematch_meta.total_tracks == 11
assert rematch_meta.version == "原版"
assert rematch_meta.audio_format == "FLAC"
assert rematch_meta.audio_lossless is True
assert rematch_meta.bit_depth == 24
assert rematch_meta.sample_rate == 48_000
assert rematch_meta.bitrate == 2_304_000
assert rematch_meta.duration == 269
assert rematch_meta.isrc == "TW-A53-03-00003"
def test_music_recognize_keeps_fallback_without_plugin(monkeypatch):
@@ -128,7 +153,7 @@ def test_music_recognize_help_keeps_fallback_when_rematch_fails(monkeypatch):
def test_async_music_recognize_help(monkeypatch):
"""异步音乐识别同样应走插件辅助识别并重匹配。"""
chain = MediaChain()
meta = MetaMusic(title="周杰伦-晴天")
meta = MetaMusic(title="周杰伦-晴天", track_number=3, duration=269)
remote = _remote_music()
recognize_calls = []
@@ -151,6 +176,8 @@ def test_async_music_recognize_help(monkeypatch):
assert result is remote
assert recognize_calls[-1].title == "晴天"
assert recognize_calls[-1].artists == ["周杰伦"]
assert recognize_calls[-1].track_number == 3
assert recognize_calls[-1].duration == 269
def test_plugin_first_keeps_fallback_when_help_unidentified(monkeypatch):
@@ -193,7 +220,12 @@ def test_chain_supplement_music_recognize_uses_plugin_result():
with patch.object(chain.eventmanager, "check", return_value=True), \
patch.object(chain.eventmanager, "send_event", return_value=event) as sender:
result = chain._supplement_media_recognize(
meta=meta, mtype=None, source=None, mediaid=None, mediainfo=None
meta=meta,
mtype=None,
source=None,
mediaid=None,
mediainfo=None,
music_type="recording",
)
assert isinstance(result, MusicInfo)
@@ -205,6 +237,34 @@ def test_chain_supplement_music_recognize_uses_plugin_result():
assert payload["title"] == "晴天"
assert payload["artists"] == ["周杰伦"]
assert payload["album"] == "叶惠美"
assert payload["music_type"] == "recording"
def test_chain_supplement_music_rejects_cross_entity_plugin_result():
"""媒体识别插件返回的音乐实体与请求不一致时应保留原结果。"""
chain = ChainBase()
fallback = _fallback_music(title="叶惠美")
event = Event(ChainEventType.MusicMediaRecognize, {
"mediainfo": {
"source": "qqmusic",
"media_id": "song-123",
"music_type": "recording",
"title": "叶惠美",
},
})
with patch.object(chain.eventmanager, "check", return_value=True), \
patch.object(chain.eventmanager, "send_event", return_value=event):
result = chain._supplement_media_recognize(
meta=MetaMusic(title="叶惠美"),
mtype=MediaType.MUSIC,
source="qqmusic",
mediaid="album-123",
mediainfo=fallback,
music_type="album",
)
assert result is fallback
def test_chain_supplement_video_recognize_uses_plugin_result():
@@ -281,7 +341,7 @@ def test_chain_supplement_media_recognize_skips_identified_result():
def test_chain_recognize_media_music_plugin_supplement():
"""统一识别入口应在原生音乐识别无身份时采信插件补充结果并统一上报。"""
chain = ChainBase()
chain = MediaChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
fallback = _fallback_music(title="晴天")
plugin_music = MusicInfo(
+36 -5
View File
@@ -2,16 +2,15 @@
覆盖 MediaChain 同步/异步 ``recognize_by_meta`` ``recognize_by_path``
``MetaMusic`` 路由到音乐模块以及 MusicBrainz 模块 ``recognize_media`` /
``async_recognize_media`` 对音乐请求的详情搜索匹配与兜底分支ChainBase
``async_recognize_media`` 对音乐请求的详情搜索匹配与兜底分支MediaChain
MusicInfo 结果与影视统一走共享识别上报
"""
import asyncio
from unittest.mock import AsyncMock, Mock, patch
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import MusicInfo
from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.core.meta import MetaMusic
from app.modules.anilist import AniListModule
from app.modules.bangumi import BangumiModule
@@ -143,7 +142,7 @@ def test_musicbrainz_module_recognize_media_ignores_non_music():
def test_chain_explicit_music_source_bypasses_generic_module_dispatch(monkeypatch):
"""显式音乐类型和来源应只调用对应音乐模块,不遍历通用影视模块。"""
expected = _music_info()
chain = ChainBase()
chain = MediaChain()
recognize_source = Mock(return_value=expected)
generic_dispatch = Mock()
monkeypatch.setattr(MusicChain, "recognize_from_source", recognize_source)
@@ -169,9 +168,41 @@ def test_chain_explicit_music_source_bypasses_generic_module_dispatch(monkeypatc
generic_dispatch.assert_not_called()
def test_music_chain_rejects_cross_entity_detail_result(monkeypatch):
"""指定专辑实体时,即使来源返回同 ID 的单曲也不得采信。"""
chain = MusicChain()
recognize_source = Mock(return_value=_music_info())
monkeypatch.setattr(chain, "_recognize_from_source", recognize_source)
result = chain.recognize_from_source(
source="musicbrainz",
mediaid="recording-1",
music_type=MUSIC_ENTITY_ALBUM,
)
assert result is None
assert recognize_source.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
def test_music_chain_rejects_replaced_explicit_identity(monkeypatch):
"""显式 ID 识别不得用标题搜索得到的另一 ID 替换请求目标。"""
chain = MusicChain()
replaced = _music_info()
replaced.media_id = "recording-other"
monkeypatch.setattr(chain, "_recognize_from_source", Mock(return_value=replaced))
result = chain.recognize_from_source(
source="musicbrainz",
mediaid="recording-requested",
music_type="recording",
)
assert result is None
def test_chain_music_type_rejects_video_source_before_module_dispatch(monkeypatch):
"""音乐状态即使携带错误影视来源,也不得调用 TMDB 等通用识别模块。"""
chain = ChainBase()
chain = MediaChain()
generic_dispatch = Mock()
async_generic_dispatch = AsyncMock()
monkeypatch.setattr(chain, "run_module", generic_dispatch)
+114 -1
View File
@@ -135,6 +135,39 @@ def test_recording_identity_rejects_multi_track_directory_scrape() -> None:
assert message == "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑"
def test_generic_scrape_dispatches_music_without_entering_video_handlers() -> None:
"""统一刮削入口应直接分派音乐,不能因音频后缀过滤或落入电视剧分支。"""
chain = _media_chain()
chain.scrape_music_metadata = Mock(return_value=(True, "已刮削 1 个音频文件"))
music = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
fileitem = FileItem(
storage="local",
path="/music/晴天.flac",
type="file",
name="晴天.flac",
extension="flac",
)
result = chain.scrape_metadata(
fileitem=fileitem,
meta=MetaMusic(title="晴天", artists=["周杰伦"]),
mediainfo=music,
overwrite=False,
)
assert result == (True, "已刮削 1 个音频文件")
chain.scrape_music_metadata.assert_called_once_with(
fileitem=fileitem,
mediainfo=music,
overwrite=False,
)
def test_default_scraping_config_enables_missing_only_music_lyrics() -> None:
"""新安装和未保存过该字段的用户应默认仅在缺失时下载歌词。"""
assert ScrapingConfig.get_default_config()["music_lyrics"] == ScrapingPolicy.MISSINGONLY
@@ -204,7 +237,7 @@ def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None:
)
music_chain = Mock()
with patch("app.chain.music.MusicChain", return_value=music_chain):
with patch("app.chain.media.MusicChain", return_value=music_chain):
success, message = chain.scrape_music_metadata(
FileItem(
storage="local",
@@ -338,6 +371,86 @@ def test_music_scrape_event_preserves_independent_policy_overwrite() -> None:
)
def test_music_scrape_event_uses_only_batch_files_and_per_track_contexts() -> None:
"""自动刮削必须按成功文件清单处理,并保留批次内每首歌各自的身份。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.scrape_music_metadata = Mock(return_value=(True, "done"))
root = FileItem(storage="local", path="/music/叶惠美", type="dir")
paths = ["/music/叶惠美/01 - 以父之名.flac", "/music/叶惠美/03 - 晴天.flac"]
audio_files = [
FileItem(
storage="local",
path=path,
type="file",
name=path.rsplit("/", 1)[-1],
extension="flac",
)
for path in paths
]
chain.storagechain.get_file_item.side_effect = audio_files
recordings = [
MusicInfo(source="musicbrainz", media_id="recording-1", title="以父之名"),
MusicInfo(source="musicbrainz", media_id="recording-3", title="晴天"),
]
chain.scrape_metadata_event(Event(
event_type=EventType.MetadataScrape,
event_data={
"fileitem": root,
"file_list": paths,
"mediainfo": recordings[0],
"file_contexts": [
{"path": path, "mediainfo": recording}
for path, recording in zip(paths, recordings)
],
"overwrite": False,
},
))
chain.scrape_music_metadata.assert_called_once_with(
fileitem=root,
mediainfo=recordings[0],
overwrite=False,
audio_files=audio_files,
media_by_path=dict(zip(paths, recordings)),
)
def test_music_scrape_batch_applies_each_recording_to_its_own_file() -> None:
"""多首单曲批次应逐文件使用对应身份,不触发单曲覆盖整目录保护。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.scraping_policies = Mock()
chain.scraping_policies.option.side_effect = lambda _target, metadata: SimpleNamespace(
is_skip=metadata == "lyrics",
is_overwrite=False,
)
chain._scrape_music_file = Mock(
return_value=_MusicScrapeFileResult(metadata_success=True)
)
files = [
FileItem(storage="local", path="/music/01.flac", type="file", name="01.flac"),
FileItem(storage="local", path="/music/02.flac", type="file", name="02.flac"),
]
recordings = [
MusicInfo(source="musicbrainz", media_id="recording-1", title="Track 1"),
MusicInfo(source="musicbrainz", media_id="recording-2", title="Track 2"),
]
success, message = chain.scrape_music_metadata(
fileitem=FileItem(storage="local", path="/music", type="dir"),
mediainfo=recordings[0],
overwrite=False,
audio_files=files,
media_by_path=dict(zip((item.path for item in files), recordings)),
)
assert success is True
assert message == "已刮削 2 个音频文件"
assert [call.args[1] for call in chain._scrape_music_file.call_args_list] == recordings
def test_music_download_failure_is_attributed_only_to_enabled_outputs() -> None:
"""音频下载失败时只标记实际启用的标签、封面或歌词任务。"""
chain = _media_chain()
+23 -2
View File
@@ -82,6 +82,23 @@ def test_music_search_continues_after_unrelated_first_keyword_results():
assert contexts[0].torrent_info.title == matched.title
def test_music_search_matches_artist_from_resource_description():
"""精确音乐搜索应使用副标题中的艺术家,兼容主标题只有曲名的站点。"""
music = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
torrent = TorrentInfo(
title="晴天 FLAC",
description="周杰伦 - 叶惠美 2003",
category=MediaType.MUSIC.value,
)
assert SearchChain._matching_music_torrents([torrent], music) == [torrent]
def test_search_by_id_routes_music_identity_to_recognize_and_process():
"""MusicBrainz 精确身份搜索应经统一识别入口识别后进入现有搜索处理链。"""
chain = SearchChain()
@@ -92,20 +109,23 @@ def test_search_by_id_routes_music_identity_to_recognize_and_process():
artists=["周杰伦"],
)
expected = [Mock()]
media_chain = Mock()
media_chain.recognize_media.return_value = music
with (
patch.object(chain, "recognize_media", return_value=music) as recognize,
patch("app.chain.search.MediaChain", return_value=media_chain),
patch.object(chain, "process", return_value=expected) as process,
):
result = chain.search_by_id(
source="musicbrainz",
mediaid="recording-1",
mtype=MediaType.MUSIC,
music_type="recording",
sites=[1],
)
assert result == expected
recognize.assert_called_once_with(
media_chain.recognize_media.assert_called_once_with(
source="musicbrainz",
mediaid="recording-1",
tmdbid=None,
@@ -113,6 +133,7 @@ def test_search_by_id_routes_music_identity_to_recognize_and_process():
bangumiid=None,
anilistid=None,
mtype=MediaType.MUSIC,
music_type="recording",
)
process.assert_called_once_with(
mediainfo=music,
+232 -9
View File
@@ -1,8 +1,15 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.chain.music import MusicChain
from app.chain.subscribe import SubscribeChain, build_subscribe_meta
from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, Context, TorrentInfo
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
MUSIC_ENTITY_RECORDING,
Context,
TorrentInfo,
)
from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.schemas.types import MediaType
@@ -30,6 +37,7 @@ def _subscribe(**overrides) -> SimpleNamespace:
keyword=None,
media_source="musicbrainz",
media_id="recording-1",
mediaid=None,
music_type="recording",
total_tracks=None,
season=None,
@@ -84,6 +92,25 @@ def test_build_subscribe_meta_returns_music_meta():
assert meta.original_name == "晴天"
def test_music_subscribe_recovers_completion_from_persisted_download_note():
"""进程重启后音乐订阅应像电影一样用已确认下载备注恢复完成状态。"""
subscribe = _subscribe(note=[1])
subscribe.total_episode = 0
subscribe.manual_total_episode = False
download_chain = Mock()
download_chain.get_no_exists_info.return_value = (False, {})
with patch("app.chain.subscribe.DownloadChain", return_value=download_chain):
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
subscribe=subscribe,
meta=build_subscribe_meta(subscribe),
mediainfo=_music_info(),
)
assert satisfied is True
assert no_exists == {}
def test_music_subscribe_reuses_search_download_and_finish_flow():
"""音乐订阅应复用站点搜索、批量下载和订阅完成主流程。"""
subscribe = _subscribe()
@@ -232,6 +259,101 @@ def test_music_best_version_persists_downloaded_rule_priority():
chain.finish_subscribe_or_not.assert_called_once()
def test_music_filter_keeps_cached_torrent_priority_isolated():
"""音乐订阅规则写入优先级时不得污染供其它订阅复用的 RSS 缓存。"""
subscribe = _subscribe(best_version=1, current_priority=90)
source_torrent = TorrentInfo(
title="周杰伦 - 晴天 MP3 320kbps",
category=MediaType.MUSIC.value,
pri_order=0,
)
source_context = Context(torrent_info=source_torrent)
chain = SubscribeChain()
def apply_rule_priority(**kwargs):
"""模拟过滤模块在传入对象上写入规则优先级。"""
kwargs["torrent_list"][0].pri_order = 100
return kwargs["torrent_list"]
chain.filter_torrents = Mock(side_effect=apply_rule_priority)
matched = chain._filter_music_subscribe_contexts(
subscribe,
_music_info(),
[source_context],
)
assert len(matched) == 1
assert matched[0].torrent_info is not source_torrent
assert matched[0].torrent_info.pri_order == 100
assert source_torrent.pri_order == 0
def test_music_subscribe_matches_artist_from_resource_description():
"""站点主标题只有曲名时,应允许使用副标题中的艺术家完成目标复核。"""
subscribe = _subscribe()
context = Context(torrent_info=TorrentInfo(
title="晴天 FLAC",
description="周杰伦 - 叶惠美 2003",
category=MediaType.MUSIC.value,
))
chain = SubscribeChain()
chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"])
matched = chain._filter_music_subscribe_contexts(
subscribe,
_music_info(),
[context],
)
assert len(matched) == 1
def test_album_best_version_requires_confirmed_full_coverage():
"""最高优先级的非完整专辑不得写入洗版基线或完成订阅。"""
subscribe = _subscribe(
name="叶惠美",
music_type=MUSIC_ENTITY_ALBUM,
total_tracks=11,
best_version=1,
current_priority=90,
)
album = MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
album="叶惠美",
artists=["周杰伦"],
total_tracks=11,
)
meta = MusicChain.to_meta(album)
downloaded = Context(
torrent_info=TorrentInfo(
title="周杰伦 - 叶惠美 FLAC",
category=MediaType.MUSIC.value,
pri_order=100,
),
meta_info=meta,
media_info=album,
confirmed_full_coverage=False,
)
download_chain = Mock()
download_chain.batch_download.return_value = ([downloaded], None)
subscribe_oper = Mock()
subscribe_oper.get.return_value = subscribe
chain = SubscribeChain()
with patch("app.chain.subscribe.DownloadChain", return_value=download_chain), \
patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \
patch.object(chain, "_SubscribeChain__finish_subscribe") as finish:
chain._download_music_subscribe(subscribe, album, [downloaded])
subscribe_oper.update.assert_not_called()
assert subscribe.current_priority == 90
finish.assert_not_called()
def test_music_subscribe_ignores_non_music_category():
"""音乐订阅不得自动下载未被站点分类为音乐的资源。"""
subscribe = _subscribe()
@@ -415,6 +537,53 @@ def test_legacy_music_without_identity_uses_recording_recognition_boundary():
assert call.kwargs["mtype"] == MediaType.MUSIC
def test_legacy_music_subscription_rejects_artist_recognition_result():
"""旧订阅缺少实体类型时不得把艺术家识别结果迁移成可下载订阅。"""
subscribe = _subscribe(music_type=None)
artist = MusicInfo(
source="musicbrainz",
media_id="artist-1",
music_type=MUSIC_ENTITY_ARTIST,
title="周杰伦",
artists=["周杰伦"],
)
media_chain = Mock()
media_chain.recognize_media.return_value = artist
with patch("app.chain.subscribe.MediaChain", return_value=media_chain):
restored = SubscribeChain._recognize_music_subscribe(subscribe)
assert restored is None
def test_album_subscription_preserves_track_count_snapshot_when_remote_omits_it():
"""专辑远端详情暂缺曲目数时应复用持久化快照,且不得修改共享识别对象。"""
subscribe = _subscribe(
name="叶惠美",
music_type=MUSIC_ENTITY_ALBUM,
total_tracks=11,
media_id="release-group-1",
)
remote = MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
album="叶惠美",
artists=["周杰伦"],
total_tracks=None,
)
media_chain = Mock()
media_chain.recognize_media.return_value = remote
with patch("app.chain.subscribe.MediaChain", return_value=media_chain):
restored = SubscribeChain._recognize_music_subscribe(subscribe)
assert restored is not remote
assert restored.total_tracks == 11
assert remote.total_tracks is None
def test_album_subscription_finishes_only_after_confirmed_full_pack():
"""专辑与电视剧全集相同,必须确认整专覆盖;单曲仍在任一成功下载后完成。"""
album_subscribe = _subscribe(music_type=MUSIC_ENTITY_ALBUM, total_tracks=11)
@@ -483,11 +652,62 @@ def test_recording_target_sync_clears_stale_album_track_count():
assert subscribe.total_tracks is None
def test_subscribe_add_music_uses_unified_recognize_by_meta():
"""音乐订阅新增应走统一 recognize_by_meta,并把媒体身份落到 MetaMusic 上"""
def test_album_target_sync_does_not_clear_stable_track_count():
"""专辑详情缺少曲目数时,同步逻辑不得清空已确认的完整性快照"""
subscribe = _subscribe(
name="叶惠美",
music_type=MUSIC_ENTITY_ALBUM,
total_tracks=11,
)
album = MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
album="叶惠美",
total_tracks=None,
)
subscribe_oper = Mock()
with patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper):
SubscribeChain._sync_music_subscribe_target(subscribe, album)
subscribe_oper.update.assert_not_called()
assert subscribe.total_tracks == 11
def test_prepare_music_subscription_rejects_album_without_track_count():
"""搜索入口必须在同步和查库前拒绝无法验证完整覆盖的专辑。"""
subscribe = _subscribe(
name="未知专辑",
music_type=None,
total_tracks=None,
)
album = MusicInfo(
source="musicbrainz",
media_id="release-group-unknown",
music_type=MUSIC_ENTITY_ALBUM,
title="未知专辑",
album="未知专辑",
total_tracks=None,
)
chain = SubscribeChain()
chain.check_and_handle_existing_media = Mock()
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=album), \
patch.object(SubscribeChain, "_sync_music_subscribe_target") as sync_target:
prepared = chain._prepare_music_subscribe(subscribe)
assert prepared is None
sync_target.assert_not_called()
chain.check_and_handle_existing_media.assert_not_called()
def test_subscribe_add_music_uses_explicit_entity_recognize():
"""带稳定身份的音乐订阅应按来源、ID 和实体直查,不再按标题换目标。"""
target = _music_info()
media_chain = Mock()
media_chain.recognize_by_meta = Mock(return_value=target)
media_chain.recognize_media = Mock(return_value=target)
subscribe_oper = Mock()
subscribe_oper.add.return_value = (1, "")
@@ -501,23 +721,25 @@ def test_subscribe_add_music_uses_unified_recognize_by_meta():
mtype=MediaType.MUSIC,
media_source="musicbrainz",
media_id="recording-1",
music_type=MUSIC_ENTITY_RECORDING,
message=False,
)
assert sid == 1
assert err_msg == ""
media_chain.recognize_by_meta.assert_called_once()
routed_meta = media_chain.recognize_by_meta.call_args.args[0]
media_chain.recognize_media.assert_called_once()
routed_meta = media_chain.recognize_media.call_args.kwargs["meta"]
assert isinstance(routed_meta, MetaMusic)
# 媒体身份落到 meta,供统一识别的详情分支复用
assert routed_meta.media_id == "recording-1"
assert media_chain.recognize_by_meta.call_args.kwargs["source"] == "musicbrainz"
assert media_chain.recognize_media.call_args.kwargs["source"] == "musicbrainz"
assert media_chain.recognize_media.call_args.kwargs["music_type"] == MUSIC_ENTITY_RECORDING
media_chain.recognize_by_meta.assert_not_called()
def test_subscribe_add_rejects_music_entity_mismatch_before_database_write():
"""请求专辑却识别为单曲时必须中止,不能创建完成语义错误的订阅。"""
media_chain = Mock()
media_chain.recognize_by_meta.return_value = _music_info()
media_chain.recognize_media.return_value = _music_info()
subscribe_oper = Mock()
with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
@@ -534,6 +756,7 @@ def test_subscribe_add_rejects_music_entity_mismatch_before_database_write():
assert sid is None
assert "类型不匹配" in err_msg
media_chain.recognize_by_meta.assert_not_called()
subscribe_oper.add.assert_not_called()
+63
View File
@@ -275,6 +275,69 @@ def test_refresh_include_music_fetches_music_entry():
assert music_titles == {"Daft Punk - Get Lucky [FLAC]"}
def test_rss_refresh_include_music_fetches_dedicated_entry():
"""RSS 模式有音乐订阅时也应补抓独立音乐入口,并写入音乐缓存。"""
chain = TorrentsChain()
video_torrent = TorrentInfo(
site=1,
site_name="Test",
title="Some.Movie.2026.1080p",
enclosure="https://example.com/download?id=1",
category=MediaType.MOVIE.value,
pubdate="2026-08-07 00:00:00",
)
music_torrent = TorrentInfo(
site=1,
site_name="Test",
title="Daft Punk - Get Lucky [FLAC]",
enclosure="https://example.com/download?id=2",
category=MediaType.MUSIC.value,
pubdate="2026-08-08 00:00:00",
)
def _fake_browse(domain, keyword=None, cat=None, page=None, mtype=None):
"""模拟音乐专用入口的两页抓取结果。"""
assert mtype == MediaType.MUSIC
return [music_torrent] if not page else []
sites_helper = Mock()
sites_helper.get_indexers.return_value = [{
"id": 1,
"name": "Test",
"domain": "https://example.com",
"search": {
"paths": [
{"path": "torrents.php", "type": "all"},
{"path": "music.php", "type": "music"},
]
},
}]
saved = {}
with (
patch.object(chain, "load_cache", return_value=None),
patch.object(chain, "rss", return_value=[video_torrent]),
patch.object(chain, "browse", side_effect=_fake_browse) as browse,
patch.object(
chain,
"save_cache",
side_effect=lambda data, filename: saved.__setitem__(filename, copy.deepcopy(data)),
),
patch("app.chain.torrents.SitesHelper", return_value=sites_helper),
patch("app.chain.torrents.MediaChain"),
):
result = chain.refresh(stype="rss", sites=[1], include_music=True)
browse.assert_called()
assert {
context.torrent_info.title for context in result["example.com"]
} == {video_torrent.title, music_torrent.title}
assert {
context.torrent_info.title
for context in saved[TorrentsChain._music_rss_file]["example.com"]
} == {music_torrent.title}
def test_music_cache_not_evicted_by_video_torrents():
"""影视缓存按配额裁剪时,音乐独立缓存中的资源不应被挤出。"""
chain = TorrentsChain()
+355 -6
View File
@@ -14,7 +14,7 @@ from app.helper.message import TemplateHelper
from app.schemas.file import FileItem
from app.schemas.system import TransferDirectoryConf
from app.schemas.transfer import TransferInfo, TransferTask, TransferTorrent
from app.schemas.types import MediaType
from app.schemas.types import EventType, MediaType
def _music_context() -> tuple[MetaMusic, MusicInfo]:
@@ -34,6 +34,50 @@ def _music_context() -> tuple[MetaMusic, MusicInfo]:
return MusicChain.to_meta(info), info
def _music_task(path: str, info: MusicInfo) -> TransferTask:
"""构造带完整音乐上下文的整理作业任务。"""
file_path = Path(path)
return TransferTask(
fileitem=FileItem(
storage="local",
path=file_path.as_posix(),
name=file_path.name,
basename=file_path.stem,
type="file",
extension=file_path.suffix.lstrip("."),
),
meta=MusicChain.to_meta(info),
mediainfo=info,
mtype=MediaType.MUSIC,
)
def test_music_retry_restores_history_entity_namespace(tmp_path, monkeypatch):
"""重新整理按历史身份恢复音乐时必须保留单曲或专辑实体类型。"""
history = SimpleNamespace(
type=MediaType.MUSIC.value,
media_source="musicbrainz",
media_id="release-group-1",
music_type="album",
)
media_chain = Mock()
media_chain.recognize_media.return_value = MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type="album",
title="叶惠美",
)
monkeypatch.setattr("app.chain.transfer.MediaChain", lambda: media_chain)
result = TransferChain()._recognize_music_retry_media(
history,
tmp_path / "叶惠美",
)
assert result and result.music_type == "album"
assert media_chain.recognize_media.call_args.kwargs["music_type"] == "album"
def test_music_rename_context_contains_audio_fields():
"""重命名模板上下文应提供艺术家、专辑、盘号和曲序字段。"""
meta, info = _music_context()
@@ -110,6 +154,68 @@ def test_audio_is_primary_only_in_music_context():
assert chain._is_primary_media_file(audio, None) is False
def test_music_scrape_batch_event_preserves_each_track_context():
"""同一专辑目录的批次刮削事件应保留每个目标音轨自己的识别身份。"""
chain = object.__new__(TransferChain)
chain._audio_exts = settings.RMT_AUDIOEXT
chain._media_exts = settings.RMT_MEDIAEXT
chain._scrape_batches = {}
chain.eventmanager = Mock()
batch_id = "music-scrape-batch"
target_dir = FileItem(storage="local", path="/library/Album", type="dir")
tasks = []
target_paths = []
for number, title in ((1, "Track One"), (2, "Track Two")):
source = FileItem(
storage="local",
path=f"/downloads/{number:02d}.flac",
type="file",
name=f"{number:02d}.flac",
extension="flac",
)
target_path = f"/library/Album/{number:02d} - {title}.flac"
task = TransferTask(
fileitem=source,
meta=MetaMusic(title=title, track_number=number),
mediainfo=MusicInfo(
source="musicbrainz",
media_id=f"recording-{number}",
title=title,
track_number=number,
),
transfer_batch_id=batch_id,
)
tasks.append(task)
target_paths.append(target_path)
chain._TransferChain__register_scrape_batch_task(task)
chain._TransferChain__record_scrape_target(
task,
TransferInfo(
success=True,
target_diritem=target_dir,
file_list_new=[target_path],
need_scrape=True,
),
)
chain._TransferChain__close_scrape_batch(batch_id)
for task in tasks:
chain._TransferChain__finish_scrape_batch_task(task)
scrape_calls = [
call
for call in chain.eventmanager.send_event.call_args_list
if call.args[0] == EventType.MetadataScrape
]
assert len(scrape_calls) == 1
payload = scrape_calls[0].args[1]
assert payload["file_list"] == target_paths
assert [
context["mediainfo"].media_id for context in payload["file_contexts"]
] == ["recording-1", "recording-2"]
def test_restore_music_context_from_download_history():
"""自动整理应从下载历史备注恢复标准音乐身份。"""
meta, info = _music_context()
@@ -136,6 +242,21 @@ def test_restore_music_context_from_download_history():
assert restored_info.album == "Random Access Memories"
def test_download_history_music_type_falls_back_to_versioned_note():
"""旧下载记录缺少独立字段时应从版本化备注恢复实体类型。"""
history = SimpleNamespace(
music_type=None,
note={
"music": {
"version": 1,
"media": {"music_type": "album"},
}
},
)
assert TransferChain._download_history_music_type(history) == "album"
def test_restore_album_context_keeps_album_identity_and_track_specific_tags(tmp_path, monkeypatch):
"""整专整理应保留选中的专辑身份,同时使用每个文件自己的曲名、艺术家和曲序。"""
album = MusicInfo(
@@ -254,8 +375,10 @@ def test_restore_music_context_uses_filename_when_source_is_not_locally_accessib
assert restored_meta is not None
assert restored_info is not None
assert restored_meta.title == "10. 明天晴天"
assert restored_info.title == "10. 明天晴天"
assert restored_meta.title == "明天晴天"
assert restored_meta.track_number == 10
assert restored_info.title == "明天晴天"
assert restored_info.track_number == 10
context = TemplateHelper().builder.build(
meta=restored_meta,
mediainfo=restored_info,
@@ -263,7 +386,7 @@ def test_restore_music_context_uses_filename_when_source_is_not_locally_accessib
include_raw_objects=False,
)
rendered = Template(settings.MUSIC_RENAME_FORMAT).render(context)
assert rendered == "孙燕姿/完美的一天 (2005)/10. 明天晴天.m4a"
assert rendered == "孙燕姿/完美的一天 (2005)/10 - 明天晴天.m4a"
def test_job_manager_serializes_music_queue_models():
@@ -292,6 +415,146 @@ def test_job_manager_serializes_music_queue_models():
assert task.mtype == MediaType.MUSIC
def test_job_manager_separates_unidentified_music_recordings():
"""缺少远端 ID 的不同曲目必须进入不同作业,避免通知和完成状态串组。"""
manager = JobManager()
first = MusicInfo(title="Intro", artists=["Artist"], album="Album", track_number=1)
second = MusicInfo(title="Finale", artists=["Artist"], album="Album", track_number=2)
assert manager.add_task(_music_task("/music/01 - Intro.flac", first)) is True
assert manager.add_task(_music_task("/music/02 - Finale.flac", second)) is True
assert len(manager.list_jobs()) == 2
def test_job_manager_groups_unidentified_album_tracks_by_album_identity():
"""无远端 ID 的整专曲目应按专辑身份聚合成一个作业。"""
manager = JobManager()
first = MusicInfo(
music_type="album",
title="Intro",
artists=["Artist"],
album="Album",
album_artist="Artist",
year=2026,
track_number=1,
)
second = MusicInfo(
music_type="album",
title="Finale",
artists=["Artist"],
album="Album",
album_artist="Artist",
year=2026,
track_number=2,
)
assert manager.add_task(_music_task("/music/01 - Intro.flac", first)) is True
assert manager.add_task(_music_task("/music/02 - Finale.flac", second)) is True
jobs = manager.list_jobs()
assert len(jobs) == 1
assert len(jobs[0].tasks) == 2
def test_job_manager_separates_music_entity_namespaces_for_same_provider_id():
"""数据源 ID 相同但实体类型不同的单曲和专辑不能共享作业。"""
manager = JobManager()
recording = MusicInfo(
source="musicbrainz",
media_id="shared-id",
music_type="recording",
title="Track",
artists=["Artist"],
)
album = MusicInfo(
source="musicbrainz",
media_id="shared-id",
music_type="album",
title="Album",
album="Album",
artists=["Artist"],
)
assert manager.add_task(_music_task("/music/Track.flac", recording)) is True
assert manager.add_task(_music_task("/music/Album/01.flac", album)) is True
assert len(manager.list_jobs()) == 2
def test_success_file_aggregation_is_isolated_between_music_jobs_in_same_directory(monkeypatch):
"""同目录内交错完成的整专与单曲作业不能互相取走通知文件清单。"""
chain = object.__new__(TransferChain)
chain.jobview = JobManager()
chain._success_target_files = {}
chain._scrape_batches = {}
chain._media_exts = settings.RMT_MEDIAEXT
chain._audio_exts = settings.RMT_AUDIOEXT
chain.eventmanager = Mock()
chain.transfer_completed = Mock()
chain.send_transfer_message = Mock()
album_infos = [
MusicInfo(
music_type="album",
title=title,
artists=["Artist"],
album="Album",
album_artist="Artist",
year=2026,
track_number=number,
)
for number, title in ((1, "Intro"), (2, "Finale"))
]
album_tasks = [
_music_task(f"/downloads/{number:02d}.flac", info)
for number, info in enumerate(album_infos, start=1)
]
recording_task = _music_task(
"/downloads/Single.flac",
MusicInfo(title="Single", artists=["Artist"], album="Album"),
)
tasks = [album_tasks[0], recording_task, album_tasks[1]]
for task in tasks:
task.background = True
assert chain.jobview.add_task(task) is True
target_dir = FileItem(storage="local", path="/library/Artist/Album", type="dir")
def transfer_info(task: TransferTask) -> TransferInfo:
"""为指定任务构造同目录下的成功整理结果。"""
target = f"/library/Artist/Album/{task.fileitem.name}"
return TransferInfo(
success=True,
fileitem=task.fileitem,
target_diritem=target_dir,
target_item=FileItem(storage="local", path=target, type="file"),
file_list_new=[target],
transfer_type="copy",
need_notify=True,
)
monkeypatch.setattr(
"app.chain.transfer.TransferHistoryOper",
lambda: SimpleNamespace(add_success=lambda **kwargs: SimpleNamespace(id=1)),
)
for task in tasks:
chain._TransferChain__default_callback(task, transfer_info(task))
notified_lists = [
call.kwargs["transferinfo"].file_list_new
for call in chain.send_transfer_message.call_args_list
]
assert notified_lists == [
["/library/Artist/Album/Single.flac"],
[
"/library/Artist/Album/01.flac",
"/library/Artist/Album/02.flac",
],
]
assert chain._success_target_files == {}
def test_automatic_audio_transfer_runs_music_recognition(tmp_path, monkeypatch):
"""无下载身份的音频应先走音乐识别,远端失败后再使用本地标签兜底。"""
audio_path = tmp_path / "周杰伦 - 晴天.flac"
@@ -379,6 +642,88 @@ def test_automatic_audio_transfer_runs_music_recognition(tmp_path, monkeypatch):
assert preview["items"][0]["type"] == MediaType.MUSIC.value
def test_explicit_music_batch_excludes_video_from_mixed_directory(tmp_path, monkeypatch):
"""明确音乐上下文时只规划音频主文件,混合目录中的视频不得套用音乐身份。"""
audio_path = tmp_path / "08 - Get Lucky.flac"
video_path = tmp_path / "Get Lucky.mkv"
audio_path.write_bytes(b"fake-flac")
video_path.write_bytes(b"fake-video")
audio_item = FileItem(
storage="local",
path=audio_path.as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
size=audio_path.stat().st_size,
)
video_item = FileItem(
storage="local",
path=video_path.as_posix(),
name=video_path.name,
basename=video_path.stem,
type="file",
extension="mkv",
size=video_path.stat().st_size,
)
source_dir = FileItem(
storage="local",
path=tmp_path.as_posix(),
name=tmp_path.name,
type="dir",
)
target_item = FileItem(
storage="local",
path=(tmp_path / "library" / audio_path.name).as_posix(),
name=audio_path.name,
basename=audio_path.stem,
type="file",
extension="flac",
)
recognized = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="Get Lucky",
artists=["Daft Punk"],
album="Random Access Memories",
track_number=8,
)
chain = TransferChain()
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
Mock(return_value=[(audio_item, False), (video_item, False)]),
)
monkeypatch.setattr(chain, "_resolve_download_history", Mock(return_value=None))
monkeypatch.setattr(MediaChain, "recognize_by_meta", Mock(return_value=recognized))
monkeypatch.setattr(
chain,
"transfer",
Mock(
return_value=TransferInfo(
success=True,
fileitem=audio_item,
target_item=target_item,
)
),
)
state, preview = chain.do_transfer(
fileitem=source_dir,
target_directory=TransferDirectoryConf(
library_path=(tmp_path / "library").as_posix(),
library_storage="local",
),
mtype=MediaType.MUSIC,
force=True,
preview=True,
)
assert state is True
assert [item["source"] for item in preview["items"]] == [audio_item.path]
assert chain.transfer.call_count == 1
def test_downloader_process_forwards_music_history_type(tmp_path, monkeypatch):
"""下载器自动整理应把下载历史中的音乐类型传入文件规划,且不调用影视补图模块。"""
audio_path = tmp_path / "晴天.flac"
@@ -397,10 +742,13 @@ def test_downloader_process_forwards_music_history_type(tmp_path, monkeypatch):
anilistid=None,
media_source="musicbrainz",
media_id="recording-1",
music_type="recording",
episode_group=None,
media_category=None,
)
chain = TransferChain()
media_chain = Mock()
media_chain.recognize_media.return_value = recognized
run_module = Mock()
monkeypatch.setattr(
"app.chain.transfer.DirectoryHelper.get_download_dirs",
@@ -429,14 +777,15 @@ def test_downloader_process_forwards_music_history_type(tmp_path, monkeypatch):
]
),
)
monkeypatch.setattr(chain, "recognize_media", Mock(return_value=recognized))
monkeypatch.setattr("app.chain.transfer.MediaChain", lambda: media_chain)
monkeypatch.setattr(chain, "do_transfer", Mock(return_value=(True, "")))
monkeypatch.setattr(chain, "run_module", run_module)
state = chain.process()
assert state is True
assert chain.recognize_media.call_args.kwargs["mtype"] == MediaType.MUSIC
assert media_chain.recognize_media.call_args.kwargs["mtype"] == MediaType.MUSIC
assert media_chain.recognize_media.call_args.kwargs["music_type"] == "recording"
assert chain.do_transfer.call_args.kwargs["mtype"] == MediaType.MUSIC
assert chain.do_transfer.call_args.kwargs["mediainfo"] is recognized
run_module.assert_not_called()
+52 -2
View File
@@ -1,5 +1,5 @@
from app.core.config import settings
from app.core.context import MusicInfo
from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MusicInfo
from app.core.meta import MetaMusic
from app.modules.musicbrainz import MusicBrainzModule
@@ -226,7 +226,7 @@ def test_recognize_music_fetches_recording_detail(monkeypatch):
def test_recognize_music_falls_back_to_album(monkeypatch):
"""单曲 ID 不存在时应按专辑再识别一次,保证专辑订阅可恢复目标"""
"""旧调用未给实体类型时保留先单曲后专辑的兼容探测"""
module = MusicBrainzModule()
requested = []
@@ -259,6 +259,56 @@ def test_recognize_music_falls_back_to_album(monkeypatch):
assert requested[0].startswith("/recording/")
def test_recognize_music_recording_does_not_probe_album(monkeypatch):
"""显式 Recording ID 未命中时不得继续请求同 ID 的专辑实体。"""
module = MusicBrainzModule()
requested = []
monkeypatch.setattr(
module,
"_request_json",
lambda path, params=None: requested.append(path),
)
result = module.recognize_music(
"musicbrainz",
"recording-missing",
music_type=MUSIC_ENTITY_RECORDING,
)
assert result is None
assert requested == ["/recording/recording-missing"]
def test_recognize_music_album_skips_recording_namespace(monkeypatch):
"""显式 Album ID 应直接读取 Release Group,不先探测 Recording。"""
module = MusicBrainzModule()
requested = []
def fake_request(path, params=None):
"""记录请求路径并返回最小专辑详情。"""
requested.append(path)
if path.startswith("/release-group/"):
return {
"id": "release-group-1",
"title": "叶惠美",
"artist-credit": [],
"releases": [],
}
return None
monkeypatch.setattr(module, "_request_json", fake_request)
result = module.recognize_music(
"musicbrainz",
"release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
)
assert result and result.music_type == MUSIC_ENTITY_ALBUM
assert requested[0] == "/release-group/release-group-1"
assert not any(path.startswith("/recording/") for path in requested)
def test_music_album_builds_tracks_and_release_variants(monkeypatch):
"""专辑详情应带上曲目列表、发行版本和 10 分制评分。"""
module = MusicBrainzModule()
+14 -9
View File
@@ -416,17 +416,22 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
chain = self._make_chain()
cached = []
chain.save_cache = lambda cache, filename: cached.append((filename, cache))
chain.recognize_media = lambda **_kwargs: SimpleNamespace(title="Test")
chain.process = lambda **_kwargs: [SimpleNamespace(title="Result")]
chain.search_by_id(
tmdbid=123,
mtype=MediaType.MOVIE,
area="title",
season=2,
sites=[1, 3],
cache_local=True,
)
with patch(
"app.chain.search.MediaChain",
return_value=SimpleNamespace(
recognize_media=lambda **_kwargs: SimpleNamespace(title="Test")
),
):
chain.search_by_id(
tmdbid=123,
mtype=MediaType.MOVIE,
area="title",
season=2,
sites=[1, 3],
cache_local=True,
)
self.assertIn(
(
+46 -11
View File
@@ -57,6 +57,44 @@ def test_resource_search_forwards_custom_plugin_source(monkeypatch) -> None:
assert captured["mtype"] == MediaType.TV
def test_resource_search_forwards_music_entity_namespace(monkeypatch) -> None:
"""音乐资源搜索 API 应在识别前传递单曲或专辑实体类型。"""
captured = {}
class FakeTorrent:
"""提供音乐资源搜索响应需要的最小种子对象。"""
@staticmethod
def to_dict() -> dict:
"""返回可序列化的测试种子。"""
return {"title": "Album result"}
class FakeSearchChain:
"""记录音乐资源搜索链收到的实体命名空间。"""
async def async_search_by_id(self, **kwargs):
"""保存搜索参数并返回单条测试结果。"""
captured.update(kwargs)
return [FakeTorrent()]
monkeypatch.setattr(search_endpoint, "SearchChain", FakeSearchChain)
response = asyncio.run(
search_endpoint.search_by_id(
mediaid="musicbrainz:release-group-1",
mtype="music",
music_type="album",
_=None,
)
)
assert response.success
assert captured["source"] == "musicbrainz"
assert captured["mediaid"] == "release-group-1"
assert captured["mtype"] == MediaType.MUSIC
assert captured["music_type"] == "album"
def test_subtitle_search_forwards_anilist_identity(monkeypatch) -> None:
"""字幕搜索 API 应把 AniList 身份传给字幕搜索链。"""
captured = {}
@@ -376,11 +414,11 @@ def test_media_seasons_does_not_fallback_to_default_source_for_explicit_identity
def test_subscribe_add_keeps_inferred_anilist_source_during_title_fallback() -> None:
"""同步新增订阅按兼容 ID 推导来源后,标题兜底仍应限定 AniList。"""
media_chain = Mock()
media_chain.recognize_media.return_value = None
media_chain.recognize_by_meta.return_value = None
chain = object.__new__(SubscribeChain)
with patch.object(SubscribeChain, "recognize_media", return_value=None) as recognize, \
patch.object(subscribe_module, "MediaChain", return_value=media_chain):
with patch.object(subscribe_module, "MediaChain", return_value=media_chain):
sid, message = chain.add(
title="AniList 同步订阅",
year="2026",
@@ -392,22 +430,19 @@ def test_subscribe_add_keeps_inferred_anilist_source_during_title_fallback() ->
assert sid is None
assert message == "未识别到媒体信息"
assert recognize.call_args.kwargs["source"] == "anilist"
assert recognize.call_args.kwargs["mediaid"] == "154587"
assert media_chain.recognize_media.call_args.kwargs["source"] == "anilist"
assert media_chain.recognize_media.call_args.kwargs["mediaid"] == "154587"
assert media_chain.recognize_by_meta.call_args.kwargs["source"] == "anilist"
def test_subscribe_async_add_keeps_inferred_anilist_source_during_title_fallback() -> None:
"""异步新增订阅按兼容 ID 推导来源后,标题兜底仍应限定 AniList。"""
media_chain = Mock()
media_chain.async_recognize_media = AsyncMock(return_value=None)
media_chain.async_recognize_by_meta = AsyncMock(return_value=None)
chain = object.__new__(SubscribeChain)
with patch.object(
SubscribeChain, "async_recognize_media", new=AsyncMock(return_value=None)
) as recognize, patch.object(
subscribe_module, "MediaChain", return_value=media_chain
):
with patch.object(subscribe_module, "MediaChain", return_value=media_chain):
sid, message = asyncio.run(
chain.async_add(
title="AniList 异步订阅",
@@ -421,6 +456,6 @@ def test_subscribe_async_add_keeps_inferred_anilist_source_during_title_fallback
assert sid is None
assert message == "未识别到媒体信息"
assert recognize.await_args.kwargs["source"] == "anilist"
assert recognize.await_args.kwargs["mediaid"] == "154587"
assert media_chain.async_recognize_media.await_args.kwargs["source"] == "anilist"
assert media_chain.async_recognize_media.await_args.kwargs["mediaid"] == "154587"
assert media_chain.async_recognize_by_meta.await_args.kwargs["source"] == "anilist"
+43 -20
View File
@@ -43,6 +43,25 @@ def _load_subscribe_chain_class():
chain_module.ChainBase = _ChainBase
class _MediaChain:
"""提供订阅链隔离测试所需的统一媒体识别接口。"""
def recognize_media(self, *args, **kwargs):
"""同步识别默认返回空结果,由具体用例显式替换。"""
return None
async def async_recognize_media(self, *args, **kwargs):
"""异步识别默认返回空结果,由具体用例显式替换。"""
return None
def recognize_by_meta(self, *args, **kwargs):
"""同步按元数据识别默认返回空结果。"""
return None
async def async_recognize_by_meta(self, *args, **kwargs):
"""异步按元数据识别默认返回空结果。"""
return None
interaction_module = ensure_module("app.helper.interaction", types.ModuleType("app.helper.interaction"))
class _SlashInteractionManager:
@@ -355,7 +374,6 @@ def _load_subscribe_chain_class():
chain_dependencies = {
"app.chain.download": "DownloadChain",
"app.chain.media": "MediaChain",
"app.chain.mediaserver": "MediaServerChain",
"app.chain.music": "MusicChain",
"app.chain.search": "SearchChain",
@@ -366,6 +384,9 @@ def _load_subscribe_chain_class():
module = ensure_module(module_name_key, types.ModuleType(module_name_key))
setattr(module, class_name, type(class_name, (), {}))
media_chain_module = ensure_module("app.chain.media", types.ModuleType("app.chain.media"))
media_chain_module.MediaChain = _MediaChain
subscribe_path = Path(__file__).resolve().parents[1] / "app" / "chain" / "subscribe.py"
spec = importlib.util.spec_from_file_location(module_name, subscribe_path)
module = importlib.util.module_from_spec(spec)
@@ -381,6 +402,13 @@ def _load_subscribe_chain_class():
SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _load_subscribe_chain_class()
def _patch_media_recognize(module, result):
"""将隔离测试中的统一媒体识别入口替换为指定结果或回调。"""
recognizer = result if callable(result) else lambda **_kwargs: result
media_chain = SimpleNamespace(recognize_media=recognizer)
return patch.object(module, "MediaChain", return_value=media_chain)
class SubscribeChainTest(TestCase):
def _build_subscribe(self, **overrides):
data = {
@@ -522,14 +550,13 @@ class SubscribeChainTest(TestCase):
return [subscribe]
chain = SubscribeChain()
chain.recognize_media = lambda **kwargs: mediainfo
chain.check_and_handle_existing_media = lambda **kwargs: (False, {})
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper", _SubscribeOper), patch.object(
SUBSCRIBE_CHAIN_MODULE,
"TorrentHelper",
_PlainTorrentHelper,
), self.assertRaises(_ReachedTitleMatch):
), _patch_media_recognize(SUBSCRIBE_CHAIN_MODULE, mediainfo), self.assertRaises(_ReachedTitleMatch):
chain.match({"test.example": [context]})
def test_match_accepts_special_season_zero_candidate(self):
@@ -604,7 +631,6 @@ class SubscribeChainTest(TestCase):
return [context], {}
chain = SubscribeChain()
chain.recognize_media = lambda **kwargs: mediainfo
chain.check_and_handle_existing_media = lambda **kwargs: (False, {})
chain.get_sub_sites = lambda *_args, **_kwargs: []
chain.get_params = lambda *_args, **_kwargs: {}
@@ -619,7 +645,7 @@ class SubscribeChainTest(TestCase):
SubscribeChain,
"_SubscribeChain__download_best_version_with_full_pack_first",
_download,
):
), _patch_media_recognize(SUBSCRIBE_CHAIN_MODULE, mediainfo):
chain.match({"test.example": [context]})
self.assertEqual(len(download_calls), 1)
@@ -1793,7 +1819,7 @@ class SubscribeChainTest(TestCase):
lack_episode=0,
)
chain = SubscribeChain()
chain.recognize_media = lambda **kwargs: SimpleNamespace(
mediainfo = SimpleNamespace(
seasons={1: [1, 2, 3, 4, 5]},
title="Test Show",
year="2026",
@@ -1810,7 +1836,8 @@ class SubscribeChainTest(TestCase):
get_backdrop_image=lambda: "backdrop",
)
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls:
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls, \
_patch_media_recognize(SUBSCRIBE_CHAIN_MODULE, mediainfo):
subscribe_oper = subscribe_oper_cls.return_value
subscribe_oper.list.return_value = [subscribe]
subscribe_oper.update.return_value = None
@@ -2487,7 +2514,7 @@ class SubscribeProgressEntrypointTest(TestCase):
updates.append(payload)
with patch.object(self.module, "SubscribeOper", return_value=_SubscribeOper()), \
patch.object(self.SubscribeChain, "recognize_media", return_value=mediainfo), \
_patch_media_recognize(self.module, mediainfo), \
patch.object(self.SubscribeChain, "resolve_subscribe_missing", return_value=(False, no_exists)) as resolve_missing:
summary = self.SubscribeChain().refresh_subscribe_progress(subscribe, scene="unit")
@@ -2520,7 +2547,7 @@ class SubscribeProgressEntrypointTest(TestCase):
raise AssertionError("resolve failure must not write progress")
with patch.object(self.module, "SubscribeOper", return_value=_SubscribeOper()), \
patch.object(self.SubscribeChain, "recognize_media", return_value=mediainfo), \
_patch_media_recognize(self.module, mediainfo), \
patch.object(self.SubscribeChain, "resolve_subscribe_missing", return_value=(False, {})):
summary = self.SubscribeChain().refresh_subscribe_progress(subscribe, scene="unit")
@@ -2536,7 +2563,7 @@ class SubscribeProgressEntrypointTest(TestCase):
raise AssertionError("recognition failure must not write progress")
with patch.object(self.module, "SubscribeOper", return_value=_SubscribeOper()), \
patch.object(self.SubscribeChain, "recognize_media", return_value=None):
_patch_media_recognize(self.module, None):
summary = self.SubscribeChain().refresh_subscribe_progress(subscribe, scene="unit")
self.assertFalse(summary["updated"])
@@ -3136,9 +3163,9 @@ class SubscribeProgressConsolidationTest(TestCase):
updates.append((subscribe_id, payload))
chain = SubscribeChain()
chain.recognize_media = lambda **kwargs: self._mediainfo(total_episode=5)
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()):
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), \
_patch_media_recognize(module, lambda **_kwargs: self._mediainfo(total_episode=5)):
chain.check()
payload = updates[-1][1]
@@ -3182,13 +3209,12 @@ class SubscribeProgressConsolidationTest(TestCase):
updates.append((subscribe_id, payload))
chain = SubscribeChain()
chain.recognize_media = lambda **kwargs: self._mediainfo(total_episode=10)
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
module,
"eventmanager",
eventmanager,
):
), _patch_media_recognize(module, lambda **_kwargs: self._mediainfo(total_episode=10)):
chain.check()
payload = updates[-1][1]
@@ -3228,7 +3254,6 @@ class SubscribeProgressConsolidationTest(TestCase):
updates.append((subscribe_id, payload))
chain = SubscribeChain()
chain.recognize_media = lambda **kwargs: self._mediainfo(total_episode=1)
chain.resolve_subscribe_missing = lambda **kwargs: (
False,
{
@@ -3248,7 +3273,7 @@ class SubscribeProgressConsolidationTest(TestCase):
module,
"eventmanager",
eventmanager,
):
), _patch_media_recognize(module, lambda **_kwargs: self._mediainfo(total_episode=1)):
chain.check()
payload = updates[-1][1]
@@ -3293,13 +3318,12 @@ class SubscribeProgressConsolidationTest(TestCase):
raise AssertionError("non-tv subscribe must not ask external refresh")
chain = SubscribeChain()
chain.recognize_media = lambda **kwargs: mediainfo
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
module,
"eventmanager",
_EventManager(),
):
), _patch_media_recognize(module, mediainfo):
chain.check()
self.assertEqual(updates[-1][1]["total_episode"], 100)
@@ -3311,7 +3335,6 @@ class SubscribeProgressConsolidationTest(TestCase):
eventmanager, captured = self._event_manager(5)
mediainfo = self._mediainfo(total_episode=10)
chain = SubscribeChain()
chain.recognize_media = lambda **_kwargs: mediainfo
chain.obtain_images = lambda **_kwargs: None
class _SubscribeOper:
@@ -3323,7 +3346,7 @@ class SubscribeProgressConsolidationTest(TestCase):
module,
"eventmanager",
eventmanager,
):
), _patch_media_recognize(module, mediainfo):
sid, err_msg = chain.add(
title="总集创建剧",
year="2026",
+39
View File
@@ -395,6 +395,45 @@ class SubscribeEndpointTest(TestCase):
self.assertEqual(result.id, 22)
self.assertEqual(list_by_identity.await_args.kwargs["music_type"], "album")
def test_subscribe_mediaid_uses_music_title_parser_for_fallback(self):
"""音乐身份未命中时应先查原题,再按艺术家与曲名语义查询,不能套影视解析。"""
from app.api.endpoints.subscribe import subscribe_mediaid
recording = _EndpointSubscribe(
id=23,
username="alice",
type=MediaType.MUSIC.value,
music_type="recording",
name="晴天",
)
title_lookup = AsyncMock(side_effect=[[], [recording]])
with patch(
"app.api.endpoints.subscribe.list_subscribes_by_media_key",
new=AsyncMock(return_value=[]),
), patch(
"app.api.endpoints.subscribe.Subscribe.async_list_by_title",
new=title_lookup,
):
result = asyncio.run(
subscribe_mediaid(
mediaid="musicbrainz:legacy-recording",
title="周杰伦 - 晴天",
music_type="recording",
db=object(),
current_user=_EndpointUser(name="alice", is_superuser=False),
)
)
self.assertEqual(result.id, 23)
self.assertEqual(
[call.kwargs for call in title_lookup.await_args_list],
[
{"title": "周杰伦 - 晴天", "season": None},
{"title": "晴天", "season": None},
],
)
def test_delete_subscribe_by_mediaid_deletes_owner_when_other_douban_match_first(self):
"""
按媒体删除订阅时应在候选集合中删除当前用户自己的订阅
+6 -2
View File
@@ -89,10 +89,12 @@ def test_subscribe_files_info_merges_multiple_mediaservers():
mediaserver_chain = MagicMock()
mediaserver_chain.get_play_url.side_effect = lambda server, item_id: f"https://{server}/item/{item_id}"
mediaserver_chain.get_season_episode_ids.side_effect = lambda server, item_id, season: {1: f"{item_id}-ep1"}
media_chain = MagicMock()
media_chain.recognize_media.return_value = mediainfo
chain = SubscribeChain()
with patch("app.chain.subscribe.DownloadHistoryOper") as download_oper, \
patch.object(chain, "recognize_media", return_value=mediainfo), \
patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
patch.object(chain, "media_files", return_value=None), \
patch.object(chain, "media_exists", side_effect=_media_exists_side_effect), \
patch("app.chain.subscribe.MediaServerHelper", return_value=helper), \
@@ -133,10 +135,12 @@ def test_subscribe_files_info_uses_season_zero_for_tv():
mediaserver_chain = MagicMock()
mediaserver_chain.get_play_url.return_value = "https://emby/item/1"
mediaserver_chain.get_season_episode_ids.side_effect = _get_season_episode_ids
media_chain = MagicMock()
media_chain.recognize_media.return_value = mediainfo
chain = SubscribeChain()
with patch("app.chain.subscribe.DownloadHistoryOper") as download_oper, \
patch.object(chain, "recognize_media", return_value=mediainfo), \
patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
patch.object(chain, "media_files", return_value=None), \
patch.object(chain, "media_exists", side_effect=_media_exists_side_effect), \
patch("app.chain.subscribe.MediaServerHelper", return_value=helper), \
+8 -5
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import Mock, patch
from app.chain import subscribe as subscribe_module
from app.chain.subscribe import SubscribeChain
@@ -65,11 +65,12 @@ def test_new_subscribe_search_keeps_state_when_recently_created(monkeypatch) ->
_SubscribeOper.updates = []
monkeypatch.setattr(subscribe_module, "SubscribeOper", _SubscribeOper)
with patch.object(SubscribeChain, "recognize_media", return_value=None) as recognize:
media_chain_class = Mock()
with patch.object(subscribe_module, "MediaChain", media_chain_class):
chain = object.__new__(SubscribeChain)
chain.search(state="N", manual=False)
recognize.assert_not_called()
media_chain_class.assert_not_called()
assert _SubscribeOper.updates == []
@@ -81,9 +82,11 @@ def test_new_subscribe_search_marks_state_after_attempt(monkeypatch) -> None:
_SubscribeOper.updates = []
monkeypatch.setattr(subscribe_module, "SubscribeOper", _SubscribeOper)
with patch.object(SubscribeChain, "recognize_media", return_value=None) as recognize:
media_chain = Mock()
media_chain.recognize_media.return_value = None
with patch.object(subscribe_module, "MediaChain", return_value=media_chain):
chain = object.__new__(SubscribeChain)
chain.search(state="N", manual=False)
recognize.assert_called_once()
media_chain.recognize_media.assert_called_once()
assert _SubscribeOper.updates == [(31, {"state": "R"})]
+120
View File
@@ -0,0 +1,120 @@
"""种子缓存音乐实体识别测试。"""
import asyncio
from unittest.mock import AsyncMock, Mock, patch
from app.api.endpoints.torrent import reidentify_cache, torrents_cache
from app.chain.music import MusicChain
from app.core.context import MUSIC_ENTITY_ALBUM, Context, MusicInfo, TorrentInfo
from app.schemas.types import MediaType
from app.utils.crypto import HashUtils
def _album_context() -> Context:
"""构造带完整专辑身份的缓存上下文。"""
album = MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
artists=["周杰伦"],
)
return Context(
meta_info=MusicChain.to_meta(album),
media_info=album,
torrent_info=TorrentInfo(
title="周杰伦 - 叶惠美 FLAC",
description="11 tracks",
category=MediaType.MUSIC.value,
site_name="Music Site",
),
)
def _mock_torrents_chain(context: Context) -> Mock:
"""构造可记录缓存读写的种子链替身。"""
chain = Mock()
chain.async_get_torrents = AsyncMock(return_value={"music.example": [context]})
chain.split_cache_contexts.return_value = ({}, {"music.example": [context]})
chain.cache_files.return_value = ("video-cache", "music-cache")
chain.async_save_cache = AsyncMock()
return chain
def test_torrent_cache_exposes_music_identity_namespace():
"""缓存列表应返回重识别所需的数据源、原生 ID 和实体类型。"""
context = _album_context()
torrents_chain = _mock_torrents_chain(context)
with patch(
"app.api.endpoints.torrent.TorrentsChain",
return_value=torrents_chain,
):
response = asyncio.run(torrents_cache(_=Mock()))
item = response.data["data"][0]
assert item["media_source"] == "musicbrainz"
assert item["media_id"] == "release-group-1"
assert item["music_type"] == MUSIC_ENTITY_ALBUM
def test_torrent_cache_exact_reidentify_forwards_album_namespace():
"""缓存按专辑 ID 重识别时必须在进入 MediaChain 前绑定 album。"""
context = _album_context()
torrents_chain = _mock_torrents_chain(context)
media_chain = Mock()
media_chain.async_recognize_media = AsyncMock(return_value=context.media_info)
torrent_hash = HashUtils.md5(
f"{context.torrent_info.title}{context.torrent_info.description}"
)
with patch(
"app.api.endpoints.torrent.TorrentsChain",
return_value=torrents_chain,
), patch("app.api.endpoints.torrent.MediaChain", return_value=media_chain):
response = asyncio.run(
reidentify_cache(
domain="music.example",
torrent_hash=torrent_hash,
media_source="musicbrainz",
media_id="release-group-1",
music_type="album",
_=Mock(),
)
)
assert response.success is True
recognize_kwargs = media_chain.async_recognize_media.await_args.kwargs
assert recognize_kwargs["mtype"] == MediaType.MUSIC
assert recognize_kwargs["music_type"] == MUSIC_ENTITY_ALBUM
assert recognize_kwargs["mediaid"] == "release-group-1"
def test_torrent_cache_auto_reidentify_keeps_music_meta_and_entity():
"""不指定 ID 的专辑重识别应沿用 MetaMusic 和原有 album 命名空间。"""
context = _album_context()
torrents_chain = _mock_torrents_chain(context)
media_chain = Mock()
media_chain.async_recognize_by_meta = AsyncMock(return_value=context.media_info)
torrent_hash = HashUtils.md5(
f"{context.torrent_info.title}{context.torrent_info.description}"
)
with patch(
"app.api.endpoints.torrent.TorrentsChain",
return_value=torrents_chain,
), patch("app.api.endpoints.torrent.MediaChain", return_value=media_chain):
response = asyncio.run(
reidentify_cache(
domain="music.example",
torrent_hash=torrent_hash,
_=Mock(),
)
)
assert response.success is True
meta = media_chain.async_recognize_by_meta.await_args.args[0]
recognize_kwargs = media_chain.async_recognize_by_meta.await_args.kwargs
assert meta.type == MediaType.MUSIC
assert recognize_kwargs["mtype"] == MediaType.MUSIC
assert recognize_kwargs["music_type"] == MUSIC_ENTITY_ALBUM
+42 -1
View File
@@ -6,7 +6,48 @@ from app.api.endpoints.transfer import (
match_manual_transfer_target_path,
recommend_episode_format,
)
from app.schemas import EpisodeFormatRecommendItem, ManualTransferItem, TransferDirectoryConf
from app.schemas import (
EpisodeFormatRecommendItem,
FileItem,
ManualTransferItem,
TransferDirectoryConf,
)
def test_manual_music_transfer_forwards_entity_namespace(monkeypatch):
"""手动音乐整理应把请求选择的单曲或专辑命名空间传入整理链。"""
captured = {}
class FakeTransferChain:
"""记录手动整理 API 向整理链传入的参数。"""
def manual_transfer(self, **kwargs):
"""保存整理参数并模拟成功。"""
captured.update(kwargs)
return True, ""
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
response = manual_transfer(
transer_item=ManualTransferItem(
fileitem=FileItem(
storage="local",
path="/downloads/叶惠美",
name="叶惠美",
type="dir",
),
type_name="音乐",
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
music_type="album",
),
background=True,
db=object(),
_="token",
)
assert response.success is True
assert captured["music_type"] == "album"
def test_manual_transfer_from_history_preserves_download_context(monkeypatch):
+1 -1
View File
@@ -84,7 +84,6 @@ def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch)
tmdb_id=101299,
)
recognized_meta = []
chain.recognize_media = lambda **kwargs: pytest.fail("不应按合集历史 ID 识别")
chain.jobview = SimpleNamespace(
migrate_task=lambda task: False,
try_remove_job=lambda task: None,
@@ -96,6 +95,7 @@ def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch)
monkeypatch.setattr(
"app.chain.transfer.MediaChain",
lambda: SimpleNamespace(
recognize_media=lambda **kwargs: pytest.fail("不应按合集历史 ID 识别"),
recognize_by_meta=lambda meta, obtain_images: (
recognized_meta.append(meta) or fallback_media
),
@@ -83,6 +83,33 @@ def test_failed_transfer_history_preserves_explicit_media_source() -> None:
assert call.kwargs["media_id"] == "154587"
def test_failed_music_history_preserves_media_type_and_entity_namespace() -> None:
"""未识别音乐的失败记录也应保留音乐类型和单曲实体命名空间。"""
oper = object.__new__(TransferHistoryOper)
oper.add_force = Mock(return_value=SimpleNamespace(id=1))
meta = MetaMusic(
title="晴天",
artists=["周杰伦"],
album="叶惠美",
media_source="musicbrainz",
media_id="recording-1",
)
oper.add_fail(
fileitem=FileItem(
storage="local",
path="/downloads/周杰伦 - 晴天.flac",
type="file",
),
mode="copy",
meta=meta,
)
call = oper.add_force.call_args
assert call.kwargs["type"] == "音乐"
assert call.kwargs["music_type"] == "recording"
def test_transferhistory_music_migration_is_idempotent(monkeypatch) -> None:
"""整理历史音乐字段迁移应支持重复执行。"""
migration = importlib.import_module(
+222
View File
@@ -0,0 +1,222 @@
"""影视自动识别的 TMDB 优先和多源兜底测试。"""
import asyncio
from threading import Barrier
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.schemas.types import MediaType
def _video_meta(
title: str = "流浪地球",
year: str = "2019",
mtype: MediaType = MediaType.MOVIE,
) -> MetaInfo:
"""构造包含明确标题、年份和类型的影视解析信息。"""
meta = MetaInfo(title)
meta.year = year
meta.type = mtype
return meta
def _video_info(
source: str,
media_id: int | str,
title: str = "流浪地球",
year: str = "2019",
mtype: MediaType = MediaType.MOVIE,
**kwargs,
) -> MediaInfo:
"""构造带指定内置来源原生身份的标准影视信息。"""
identity_fields = {
"themoviedb": {"tmdb_id": int(media_id)},
"douban": {"douban_id": str(media_id)},
"bangumi": {"bangumi_id": int(media_id)},
"anilist": {"anilist_id": int(media_id)},
}
return MediaInfo(
source=source,
type=mtype,
title=title,
year=year,
**identity_fields[source],
**kwargs,
)
def _module_kwargs(meta: MetaInfo, source: str | None = None) -> dict:
"""构造原生识别路由需要的最小参数。"""
return {
"meta": meta,
"mtype": meta.type,
"source": source,
"mediaid": None,
"tmdbid": None,
"doubanid": None,
"bangumiid": None,
"anilistid": None,
"episode_group": None,
"cache": True,
}
def test_video_auto_recognize_stops_after_reliable_tmdb(monkeypatch) -> None:
"""TMDB 可靠命中时不得查询任何影视副源。"""
chain = object.__new__(MediaChain)
meta = _video_meta()
tmdb = _video_info("themoviedb", 550, names=["The Wandering Earth"])
calls = []
def recognize_source(module_kwargs, source, cache):
"""记录单源调用并仅返回 TMDB 测试结果。"""
calls.append((module_kwargs, source, cache))
return tmdb if source == "themoviedb" else None
monkeypatch.setattr(chain, "_recognize_video_from_source", recognize_source)
result = chain._run_native_media_recognize(_module_kwargs(meta), cache=True)
assert result is tmdb
assert [call[1] for call in calls] == ["themoviedb"]
def test_video_auto_recognize_concurrently_scores_fallback_sources(monkeypatch) -> None:
"""TMDB 低置信时应并发查询全部副源,并返回评分最高的候选。"""
chain = object.__new__(MediaChain)
meta = _video_meta()
fallback_barrier = Barrier(3, timeout=2)
candidates = {
"douban": _video_info("douban", "26266893"),
"bangumi": _video_info(
"bangumi", 302875, mtype=MediaType.TV
),
"anilist": _video_info(
"anilist", 105333, title="流浪星球"
),
}
called_sources = []
def recognize_source(module_kwargs, source, cache):
"""使用线程屏障证明三个副源不是串行执行。"""
del module_kwargs, cache
called_sources.append(source)
if source == "themoviedb":
return _video_info(
"themoviedb", 999, title="完全不同的电影"
)
fallback_barrier.wait()
return candidates[source]
monkeypatch.setattr(chain, "_recognize_video_from_source", recognize_source)
result = chain._run_native_media_recognize(_module_kwargs(meta), cache=True)
assert result is candidates["douban"]
assert called_sources[0] == "themoviedb"
assert set(called_sources[1:]) == {"douban", "bangumi", "anilist"}
def test_async_video_auto_recognize_concurrently_scores_fallback_sources(
monkeypatch,
) -> None:
"""异步自动识别应在 TMDB 失败后并发等待全部影视副源。"""
chain = object.__new__(MediaChain)
meta = _video_meta()
candidates = {
source: _video_info(source, index)
for index, source in enumerate(("douban", "bangumi", "anilist"), start=1)
}
started_sources = set()
all_started = asyncio.Event()
async def recognize_source(module_kwargs, source, cache):
"""等三个副源均开始后再放行,串行实现会触发超时。"""
del module_kwargs, cache
if source == "themoviedb":
return None
started_sources.add(source)
if len(started_sources) == 3:
all_started.set()
await asyncio.wait_for(all_started.wait(), timeout=1)
return candidates[source]
monkeypatch.setattr(
chain,
"_async_recognize_video_from_source",
recognize_source,
)
result = asyncio.run(
chain._async_run_native_media_recognize(_module_kwargs(meta), cache=True)
)
assert result is candidates["douban"]
assert started_sources == {"douban", "bangumi", "anilist"}
def test_video_candidate_score_uses_requested_season_year() -> None:
"""电视剧应使用请求季年份,而不是整部剧首播年份进行可信度判断。"""
meta = _video_meta("测试剧", "2024", MediaType.TV)
meta.begin_season = 2
candidate = _video_info(
"themoviedb",
42,
title="测试剧",
year="2020",
mtype=MediaType.TV,
seasons={1: [1], 2: [1]},
season_years={1: "2020", 2: "2024"},
)
score = MediaChain._video_candidate_score(meta, candidate, MediaType.TV)
assert score == 100
def test_video_candidate_score_rejects_type_and_year_conflicts() -> None:
"""类型冲突或明显年份冲突必须在进入副源排名前淘汰。"""
meta = _video_meta()
wrong_type = _video_info(
"bangumi", 1, mtype=MediaType.TV
)
wrong_year = _video_info(
"douban", 2, year="2023"
)
assert MediaChain._video_candidate_score(meta, wrong_type) is None
assert MediaChain._video_candidate_score(meta, wrong_year) is None
def test_video_explicit_source_bypasses_automatic_fallback(monkeypatch) -> None:
"""显式影视来源应继续走通用严格单源分发,不进入自动策略。"""
chain = object.__new__(MediaChain)
meta = _video_meta()
expected = _video_info("douban", 26266893)
generic_calls = []
def generic_recognize(_self, module_kwargs, cache):
"""记录父类通用分发调用并返回显式来源结果。"""
generic_calls.append((module_kwargs, cache))
return expected
def unexpected_auto(*_args, **_kwargs):
"""显式来源误入自动策略时立即使测试失败。"""
raise AssertionError("显式来源不应进入自动影视识别")
monkeypatch.setattr(
ChainBase,
"_run_native_media_recognize",
generic_recognize,
)
monkeypatch.setattr(chain, "_recognize_video_best", unexpected_auto)
result = chain._run_native_media_recognize(
_module_kwargs(meta, source="douban"),
cache=True,
)
assert result is expected
assert generic_calls[0][0]["source"] == "douban"
+53
View File
@@ -120,6 +120,59 @@ def test_scrape_file_keeps_workflow_action_context(monkeypatch):
assert scraped == [("/library/movie.mkv", "meta", "media")]
def test_scrape_file_does_not_cache_failed_music_scrape(monkeypatch):
"""音乐刮削失败时工作流必须计为失败,不能写入成功缓存。"""
saved_cache = []
class FakeStorageChain:
"""模拟存在的音乐文件。"""
def exists(self, fileitem):
return True
class FakeMediaChain:
"""模拟识别成功但音乐产物写入失败。"""
def recognize_by_path(self, path, obtain_images=False):
return SimpleNamespace(meta_info="music-meta", media_info="music")
def scrape_metadata(self, fileitem, meta=None, mediainfo=None):
return False, "歌词保存失败"
monkeypatch.setattr(scrape_file_module, "StorageChain", FakeStorageChain)
monkeypatch.setattr(scrape_file_module, "MediaChain", FakeMediaChain)
monkeypatch.setattr(
scrape_file_module.global_vars,
"is_workflow_stopped",
lambda workflow_id: False,
)
monkeypatch.setattr(
ScrapeFileAction,
"check_cache",
lambda self, workflow_id, key: False,
)
monkeypatch.setattr(
ScrapeFileAction,
"save_cache",
lambda self, workflow_id, data: saved_cache.append(data),
)
action = ScrapeFileAction("scrape-music")
action.execute(
workflow_id=1,
params={},
context=ActionContext(
fileitems=[
FileItem(path="/library/晴天.flac", storage="local", type="file")
]
),
)
assert action.success is False
assert action._scraped_files == []
assert saved_cache == []
def test_execute_with_inputs_maps_contract_inputs_outputs_and_runtime(monkeypatch):
"""新版动作桥接方法应按契约映射输入、输出和运行期信息。"""