mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 00:46:57 +08:00
feat: unify media recognition and music lifecycle
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}'"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [])]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,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:
|
||||
"""按媒体数据源校验原生 ID,MusicBrainz 使用 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="刮削失败,无法识别音乐信息")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+108
-35
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,6 +112,9 @@
|
||||
"未识别到音乐信息": "未識別到音樂資訊",
|
||||
"未识别到专辑信息": "未識別到專輯資訊",
|
||||
"未识别到艺术家信息": "未識別到藝術家資訊",
|
||||
"音乐实体类型无效,仅支持 recording 或 album": "音樂實體類型無效,僅支援 recording 或 album",
|
||||
"音乐下载只能使用音乐元数据源": "音樂下載只能使用音樂中繼資料來源",
|
||||
"音乐重新识别只能使用音乐元数据源": "音樂重新識別只能使用音樂中繼資料來源",
|
||||
"记录不存在": "記錄不存在",
|
||||
"MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用",
|
||||
"整理记录不存在": "整理記錄不存在",
|
||||
|
||||
@@ -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
@@ -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 "/"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 全站统计支持的周期,取值与官方统计页面完全一致
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
# 媒体类型
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 用于补全映射和兼容回退。
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
按媒体删除订阅时,应在候选集合中删除当前用户自己的订阅。
|
||||
|
||||
@@ -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), \
|
||||
|
||||
@@ -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"})]
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
"""新版动作桥接方法应按契约映射输入、输出和运行期信息。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user