feat: 音乐识别与刮削统一入口整合

- 音乐识别统一走 recognize_media,移除 MusicChain.recognize/async_recognize 及 /music/search 独立端点
- 音乐刮削收拢到 MediaChain.scrape_music_metadata,消除 MusicChain 对 MediaChain 的嵌套引用
- MetaInfo/MetaInfoPath 增加 force_video 参数,影视附加音轨保留季集归属
- 整理、订阅、搜索、API 层音乐识别入口全部统一
This commit is contained in:
jxxghp
2026-08-09 10:06:54 +08:00
parent 7797d34c48
commit 60aa762ca1
19 changed files with 510 additions and 447 deletions
+10 -4
View File
@@ -14,7 +14,7 @@ from transmission_rpc import File
from app.core.cache import FileCache, AsyncFileCache, fresh, async_fresh
from app.core.config import settings
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
from app.core.event import EventManager
from app.core.meta import MetaBase
from app.core.module import ModuleManager
@@ -683,7 +683,7 @@ class ChainBase(metaclass=ABCMeta):
bangumiid=bangumiid,
anilistid=anilistid,
)
# 显式 TMDB ID 由模块自行消歧,不能被标题推断类型误导。
# 检索显式 TMDB ID 由请求方自行消歧,不能被标题推断类型误导。
if not mtype and not tmdbid and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
share_query_meta = share_meta or meta
@@ -705,6 +705,9 @@ class ChainBase(metaclass=ABCMeta):
**module_kwargs,
)
if mediainfo:
# 音乐识别结果不参与影视共享上报
if isinstance(mediainfo, MusicInfo):
return mediainfo
if not mediainfo.recognize_cache_hit:
MoviePilotServerHelper.report_recognize_share(
meta=meta,
@@ -713,7 +716,7 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if not source and self._can_use_media_recognize_share(
if not source and mtype != MediaType.MUSIC and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
@@ -815,6 +818,9 @@ class ChainBase(metaclass=ABCMeta):
**module_kwargs,
)
if mediainfo:
# 音乐识别结果不参与影视共享上报
if isinstance(mediainfo, MusicInfo):
return mediainfo
if not mediainfo.recognize_cache_hit:
await MoviePilotServerHelper.async_report_recognize_share(
meta=meta,
@@ -823,7 +829,7 @@ class ChainBase(metaclass=ABCMeta):
)
return mediainfo
if not source and self._can_use_media_recognize_share(
if not source and mtype != MediaType.MUSIC and self._can_use_media_recognize_share(
share_query_meta, tmdbid, doubanid, bangumiid, anilistid
):
shared_cache_meta = self._snapshot_recognize_cache_meta(meta)
+150 -8
View File
@@ -1,7 +1,7 @@
import os
from copy import deepcopy
from pathlib import Path
from tempfile import NamedTemporaryFile
from tempfile import NamedTemporaryFile, TemporaryDirectory
from threading import Lock
from typing import Optional, List, Tuple, Union
@@ -9,11 +9,12 @@ from app import schemas
from app.chain import ChainBase
from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.context import Context, MediaInfo
from app.core.context import Context, MediaInfo, MusicInfo
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.schemas import FileItem
from app.schemas.types import (
@@ -140,6 +141,7 @@ class ScrapingConfig:
("tv", ["nfo", "poster", "backdrop", "logo", "banner", "thumb", "clearart", "landscape"]),
("season", ["nfo", "poster", "backdrop", "banner", "thumb", "landscape"]),
("episode", ["nfo", "thumb"]),
("music", ["nfo", "poster"]),
]
for md in mds
]
@@ -741,10 +743,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if not metainfo:
return None
# 音乐走独立识别链,不参与影视季集识别与辅助识别事件
# 音乐不经影视季集识别与辅助识别,直接走统一模块分发
if isinstance(metainfo, MetaMusic):
from app.chain.music import MusicChain
return MusicChain().recognize_by_meta(metainfo, source=source)
return self.recognize_media(
meta=metainfo,
source=source,
)
title = metainfo.title
share_meta = deepcopy(metainfo)
@@ -1061,6 +1065,16 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 刮削锁
with scraping_lock:
# 音乐刮削与影视共用 MediaChain 入口,按 ScrapingConfig 的音乐项写入标签与封面
if getattr(mediainfo, "type", None) == MediaType.MUSIC:
_, message = self.scrape_music_metadata(
fileitem=fileitem,
mediainfo=mediainfo,
overwrite=overwrite or self.scraping_policies.option("music", "nfo").is_overwrite,
)
if message:
logger.info(f"音乐刮削:{message}")
return
# 检查文件项是否存在
if not self.storagechain.get_item(fileitem):
logger.warn(f"文件项不存在:{fileitem.path}")
@@ -1380,6 +1394,132 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.info(f"{filepath.name} 刮削完成")
def scrape_music_metadata(
self,
fileitem: schemas.FileItem,
mediainfo: Optional[MusicInfo] = None,
overwrite: bool = True,
) -> tuple[bool, str]:
"""为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。
音乐刮削被收拢到 MediaChain 统一分发,避免音乐链反向依赖媒体链造成嵌套。
"""
files = self._music_audio_fileitems(fileitem)
if not files:
return False, "刮削路径中没有支持的音频文件"
if mediainfo and len(files) > 1:
return False, "指定 MusicBrainz ID 时仅支持刮削单个音频文件"
# 读取音乐刮削策略:music_nfo 控制标签写入,music_poster 控制封面嵌入
nfo_option = self.scraping_policies.option("music", "nfo")
poster_option = self.scraping_policies.option("music", "poster")
if nfo_option.is_skip:
return False, "音乐标签刮削策略为跳过,请先在高级设置中开启"
failures: list[str] = []
for audio_item in files:
info = mediainfo
if not info:
# 未指定识别信息时按文件名识别音乐,识别阶段不读取文件标签
meta = MetaInfoPath(Path(audio_item.path))
info = self.recognize_media(meta=meta, mtype=MediaType.MUSIC, source="musicbrainz")
if not info or not info.title:
failures.append(f"{audio_item.name or audio_item.path} 无法识别音乐信息")
continue
if not self._scrape_music_file(
audio_item,
info,
overwrite=overwrite or nfo_option.is_overwrite,
with_cover=not poster_option.is_skip,
):
failures.append(f"{audio_item.name or audio_item.path} 标签写入失败")
if failures:
return False, "".join(failures[:3])
return True, f"已刮削 {len(files)} 个音频文件"
@staticmethod
def _download_music_cover(url: Optional[str]) -> tuple[Optional[bytes], str]:
"""通过统一请求封装下载音乐封面,并返回图片内容与 MIME 类型。"""
if not url:
return None, "image/jpeg"
response = RequestUtils(
proxies=settings.PROXY,
ua=settings.NORMAL_USER_AGENT,
timeout=20,
).get_res(url)
if not response:
return None, "image/jpeg"
try:
if response.status_code != 200:
logger.warning(f"音乐封面下载失败:{response.status_code} {url}")
return None, "image/jpeg"
mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0]
return response.content, mime
finally:
response.close()
@staticmethod
def _is_music_audio_file(path: str) -> bool:
"""判断路径是否指向系统支持的音频文件。"""
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
def _music_audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]:
"""展开待刮削目录并过滤系统支持的音频文件。"""
if fileitem.type != "dir":
return [fileitem] if self._is_music_audio_file(fileitem.path or "") else []
return [
item
for item in self.storagechain.list_files(fileitem, recursion=True) or []
if item.type == "file" and self._is_music_audio_file(item.path or "")
]
def _scrape_music_file(
self,
fileitem: schemas.FileItem,
mediainfo: MusicInfo,
overwrite: bool,
with_cover: bool = True,
) -> bool:
"""下载单个音频文件、写入标签,并在远端存储场景上传覆盖原文件。"""
cover_data, cover_mime = (
self._download_music_cover(mediainfo.cover_url) if with_cover else (None, "image/jpeg")
)
storage = StorageChain()
if fileitem.storage == "local":
local_path = storage.download_file(fileitem)
return bool(
local_path
and AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
)
)
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
local_path = storage.download_file(fileitem, path=Path(temp_dir))
if not local_path or not AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
):
return False
parent = storage.get_parent_item(fileitem)
if not parent:
logger.warning(f"无法获取远端音频父目录:{fileitem.path}")
return False
return bool(
storage.upload_file(
parent,
local_path,
new_name=fileitem.name or local_path.name,
)
)
def _handle_movie_scraping(
self,
fileitem: schemas.FileItem,
@@ -1739,10 +1879,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if not metainfo:
return None
# 音乐走独立识别链,不参与影视季集识别与辅助识别事件
# 音乐不经影视季集识别与辅助识别,直接走统一模块分发
if isinstance(metainfo, MetaMusic):
from app.chain.music import MusicChain
return await MusicChain().async_recognize_by_meta(metainfo, source=source)
return await self.async_recognize_media(
meta=metainfo,
source=source,
)
title = metainfo.title
share_meta = deepcopy(metainfo)
+13 -236
View File
@@ -1,11 +1,9 @@
import re
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Iterable, Optional
from app import schemas
from app.chain import ChainBase
from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.context import (
MUSIC_ENTITY_RECORDING,
@@ -14,9 +12,7 @@ from app.core.context import (
MusicInfo,
)
from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.utils.http import RequestUtils
class MusicChain(ChainBase):
@@ -84,76 +80,6 @@ class MusicChain(ChainBase):
candidates = await self.async_run_module("search_music", meta=meta, limit=limit)
return self.normalize_candidates(candidates, limit=limit)
def recognize(
self,
source: str,
media_id: str,
) -> Optional[MusicInfo]:
"""按音乐元数据源和媒体 ID 获取标准化详情。"""
result = self.run_module(
"recognize_music",
source=source,
media_id=media_id,
)
if isinstance(result, MusicInfo):
return result
if isinstance(result, dict):
return MusicInfo.from_dict(result)
return None
async def async_recognize(
self,
source: str,
media_id: str,
) -> Optional[MusicInfo]:
"""异步按音乐元数据源和媒体 ID 获取标准化详情。"""
result = await self.async_run_module(
"recognize_music",
source=source,
media_id=media_id,
)
if isinstance(result, MusicInfo):
return result
if isinstance(result, dict):
return MusicInfo.from_dict(result)
return None
def recognize_by_meta(
self,
meta: MetaMusic,
source: Optional[str] = None,
) -> Optional[MusicInfo]:
"""根据音乐元数据识别媒体信息,有 source+media_id 走详情,否则按标题搜索匹配。"""
resolved_source = source or meta.media_source
if resolved_source and meta.media_id:
info = self.recognize(resolved_source, str(meta.media_id))
if info:
return info
candidates = self.run_module("search_music", meta=meta, limit=10)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(
meta, results, source=resolved_source or "musicbrainz",
)
return matched or self._info_from_meta(meta)
async def async_recognize_by_meta(
self,
meta: MetaMusic,
source: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步根据音乐元数据识别媒体信息,有 source+media_id 走详情,否则按标题搜索匹配。"""
resolved_source = source or meta.media_source
if resolved_source and meta.media_id:
info = await self.async_recognize(resolved_source, str(meta.media_id))
if info:
return info
candidates = await self.async_run_module("search_music", meta=meta, limit=10)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(
meta, results, source=resolved_source or "musicbrainz",
)
return matched or self._info_from_meta(meta)
def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]:
"""读取 ListenBrainz 全站音乐榜单并标准化分页结果。"""
candidates = self.run_module(
@@ -316,144 +242,31 @@ class MusicChain(ChainBase):
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
@classmethod
def read_path_meta(cls, path: str | Path) -> MetaMusic:
"""读取本地音频标签,不可访问时按文件名构造最小音乐元数据。"""
file_path = Path(path)
if file_path.exists() and file_path.is_file():
return AudioMetadataHelper.read(file_path)
return cls.parse_query(file_path.stem)
def parse_path_meta(cls, path: str | Path) -> MetaMusic:
"""音频文件名解析最小音乐元数据,识别阶段不读取文件标签"""
return cls.parse_query(Path(path).stem)
async def async_recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MetaMusic, MusicInfo]:
"""根据音频标签和文件名识别音乐,远端不可用时仍返回最小音乐信息。"""
meta = self.read_path_meta(path)
candidates = await self.async_run_module(
"search_music",
meta=meta,
limit=10,
)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(meta, results, source=source)
if matched:
return meta, matched
return meta, self._info_from_meta(meta)
"""根据文件名的歌曲信息识别音乐,远端不可用时仍返回最小音乐信息。"""
meta = self.parse_path_meta(path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兜底
info = await self.async_recognize_media(meta=meta, source=source)
return meta, info or self._info_from_meta(meta)
def recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MetaMusic, MusicInfo]:
"""同步根据音频标签和文件名识别音乐,并保留离线最小结果。"""
meta = self.read_path_meta(path)
candidates = self.run_module("search_music", meta=meta, limit=10)
results = self.normalize_candidates(candidates, limit=10)
matched = self._select_path_candidate(meta, results, source=source)
return meta, matched or self._info_from_meta(meta)
def scrape_metadata(
self,
fileitem: schemas.FileItem,
mediainfo: Optional[MusicInfo] = None,
overwrite: bool = True,
) -> tuple[bool, str]:
"""为音频文件或目录写入音乐标签和封面,复用现有存储下载上传能力。"""
files = self._audio_fileitems(fileitem)
if not files:
return False, "刮削路径中没有支持的音频文件"
if mediainfo and len(files) > 1:
return False, "指定 MusicBrainz ID 时仅支持刮削单个音频文件"
failures: list[str] = []
for audio_item in files:
info = mediainfo
if not info:
_, info = self.recognize_by_path(audio_item.path)
if not info or not info.title:
failures.append(f"{audio_item.name or audio_item.path} 无法识别音乐信息")
continue
if not self._scrape_audio_file(audio_item, info, overwrite=overwrite):
failures.append(f"{audio_item.name or audio_item.path} 标签写入失败")
if failures:
return False, "".join(failures[:3])
return True, f"已刮削 {len(files)} 个音频文件"
@staticmethod
def _download_cover(url: Optional[str]) -> tuple[Optional[bytes], str]:
"""通过统一请求封装下载封面,并返回图片内容与 MIME 类型。"""
if not url:
return None, "image/jpeg"
response = RequestUtils(
proxies=settings.PROXY,
ua=settings.NORMAL_USER_AGENT,
timeout=20,
).get_res(url)
if not response:
return None, "image/jpeg"
try:
if response.status_code != 200:
logger.warning(f"音乐封面下载失败:{response.status_code} {url}")
return None, "image/jpeg"
mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0]
return response.content, mime
finally:
response.close()
def _audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]:
"""展开待刮削目录并过滤系统支持的音频文件。"""
if fileitem.type != "dir":
return [fileitem] if self.is_audio_path(fileitem.path or "") else []
return [
item
for item in StorageChain().list_files(fileitem, recursion=True) or []
if item.type == "file" and self.is_audio_path(item.path or "")
]
def _scrape_audio_file(
self,
fileitem: schemas.FileItem,
mediainfo: MusicInfo,
overwrite: bool,
) -> bool:
"""下载单个音频文件、写入标签,并在远端存储场景上传覆盖原文件。"""
cover_data, cover_mime = self._download_cover(mediainfo.cover_url)
storage = StorageChain()
if fileitem.storage == "local":
local_path = storage.download_file(fileitem)
return bool(
local_path
and AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
)
)
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
local_path = storage.download_file(fileitem, path=Path(temp_dir))
if not local_path or not AudioMetadataHelper.write(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
):
return False
parent = storage.get_parent_item(fileitem)
if not parent:
logger.warning(f"无法获取远端音频父目录:{fileitem.path}")
return False
return bool(
storage.upload_file(
parent,
local_path,
new_name=fileitem.name or local_path.name,
)
)
"""同步根据文件名识别音乐,并保留离线最小结果。"""
meta = self.parse_path_meta(path)
# 统一识别入口分发到音乐模块,模块负责详情/搜索/匹配/兜底
info = self.recognize_media(meta=meta, source=source)
return meta, info or self._info_from_meta(meta)
@classmethod
def to_meta(cls, info: MusicInfo) -> MetaMusic:
@@ -474,37 +287,6 @@ class MusicChain(ChainBase):
media_id=info.media_id,
)
@classmethod
def _select_path_candidate(
cls,
meta: MetaMusic,
candidates: Iterable[MusicInfo],
source: str,
) -> Optional[MusicInfo]:
"""按标题、艺术家和专辑匹配度选择最可信的文件识别候选。"""
normalized_source = cls._normalize_text(source).casefold()
ranked: list[tuple[int, MusicInfo]] = []
for candidate in candidates:
if normalized_source and (candidate.source or "").casefold() != normalized_source:
continue
score = 0
if cls._same_text(meta.title, candidate.title):
score += 4
if meta.artists and any(
cls._same_text(meta.artists[0], artist)
for artist in candidate.artists
):
score += 3
if meta.album and cls._same_text(meta.album, candidate.album):
score += 2
if meta.isrc and cls._same_text(meta.isrc, candidate.isrc):
score += 5
ranked.append((score, candidate))
if not ranked:
return None
ranked.sort(key=lambda item: item[0], reverse=True)
return ranked[0][1] if ranked[0][0] > 0 else None
@classmethod
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
"""把音频标签转换为文件管理可展示的最小音乐信息。"""
@@ -525,11 +307,6 @@ class MusicChain(ChainBase):
names=[name for name in (meta.title, meta.album) if name],
)
@classmethod
def _same_text(cls, left: Optional[str], right: Optional[str]) -> bool:
"""忽略空白和大小写比较两个音乐文本字段。"""
return cls._normalize_text(left).casefold() == cls._normalize_text(right).casefold()
@classmethod
def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]:
"""构造跨来源稳定的候选去重键。"""
+15 -30
View File
@@ -547,16 +547,11 @@ class SearchChain(ChainBase):
season=season,
sites=sites,
)
if mtype == MediaType.MUSIC:
mediainfo = MusicChain().recognize(
source=source,
media_id=str(mediaid) if mediaid is not None else "",
)
else:
mediainfo = self.recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
# 音乐统一在 recognize_media 内路由到 MusicChain
mediainfo = self.recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!')
return []
@@ -880,16 +875,11 @@ class SearchChain(ChainBase):
season=season,
sites=sites,
)
if mtype == MediaType.MUSIC:
mediainfo = await MusicChain().async_recognize(
source=source,
media_id=str(mediaid) if mediaid is not None else "",
)
else:
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
# 音乐统一在 async_recognize_media 内路由到 MusicChain
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
@@ -1083,16 +1073,11 @@ class SearchChain(ChainBase):
season=season,
sites=sites,
)
if mtype == MediaType.MUSIC:
mediainfo = await MusicChain().async_recognize(
source=source,
media_id=str(mediaid) if mediaid is not None else "",
)
else:
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
# 音乐统一在 async_recognize_media 内路由到 MusicChain
mediainfo = await self.async_recognize_media(
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
)
if not mediainfo:
logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
+10 -8
View File
@@ -1340,29 +1340,31 @@ class SubscribeChain(ChainBase):
@staticmethod
def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选。"""
musicchain = MusicChain()
if subscribe.media_source and subscribe.media_id:
mediainfo = musicchain.recognize(
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = MediaChain().recognize_media(
source=subscribe.media_source,
media_id=str(subscribe.media_id),
mediaid=str(subscribe.media_id),
mtype=MediaType.MUSIC,
)
if mediainfo:
return mediainfo
candidates = musicchain.search(subscribe.name, limit=1)
candidates = MusicChain().search(subscribe.name, limit=1)
return candidates[0] if candidates else None
@staticmethod
async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""异步按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选。"""
musicchain = MusicChain()
if subscribe.media_source and subscribe.media_id:
mediainfo = await musicchain.async_recognize(
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = await MediaChain().async_recognize_media(
source=subscribe.media_source,
media_id=str(subscribe.media_id),
mediaid=str(subscribe.media_id),
mtype=MediaType.MUSIC,
)
if mediainfo:
return mediainfo
candidates = await musicchain.async_search(subscribe.name, limit=1)
candidates = await MusicChain().async_search(subscribe.name, limit=1)
return candidates[0] if candidates else None
def _search_music_subscribe(self, subscribe: Subscribe) -> None:
+18 -28
View File
@@ -13,7 +13,6 @@ from app import schemas
from app.agent import ReplyMode, prompt_manager, agent_manager
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.chain.storage import StorageChain
from app.chain.subscribe import SubscribeChain
from app.chain.tmdb import TmdbChain
@@ -1591,7 +1590,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
or not transferinfo
or not transferinfo.need_scrape
or not self._is_primary_media_file(task.fileitem, task.mediainfo)
or task.mediainfo.type == MediaType.MUSIC
):
return
@@ -1655,7 +1653,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
or not transferinfo
or not transferinfo.need_scrape
or not self._is_primary_media_file(task.fileitem, task.mediainfo)
or task.mediainfo.type == MediaType.MUSIC
):
return
@@ -3154,8 +3151,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if mtype == MediaType.MUSIC and source_path.suffix.lower() in self._audio_exts:
path_meta = AudioMetadataHelper.read(source_path)
else:
# 影视场景附加音轨(如评论音轨)强制按视频解析,保留季集归属
path_meta = MetaInfoPath(
source_path, custom_words=custom_word_list
source_path, custom_words=custom_word_list, force_video=True
)
if not path_meta:
return None
@@ -4055,24 +4053,17 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
logger.info(f"手动整理:{fileitem.path} ...")
if tmdbid or doubanid or bangumiid or anilistid or media_id:
# 有输入媒体ID时单个识别
# 识别媒体信息
if mtype == MediaType.MUSIC and media_source and media_id:
mediainfo = MusicChain().recognize(
source=media_source,
media_id=media_id,
)
else:
mediainfo = MediaChain().recognize_media(
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=media_source,
mediaid=media_id,
mtype=mtype,
episode_group=episode_group,
)
# 有输入媒体ID时预先识别,音乐与影视统一走 recognize_media 按类型分发
mediainfo = MediaChain().recognize_media(
tmdbid=tmdbid,
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
source=media_source,
mediaid=media_id,
mtype=mtype,
episode_group=episode_group,
)
if not mediainfo:
return (
False,
@@ -4080,11 +4071,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
f"tmdbid{tmdbid}doubanid{doubanid}"
f"type: {mtype.value if mtype else None}",
)
else:
if media_source and not isinstance(mediainfo, MusicInfo):
mediainfo.scrape_source = media_source
if not isinstance(mediainfo, MusicInfo):
self.obtain_images(mediainfo=mediainfo)
if media_source and not isinstance(mediainfo, MusicInfo):
mediainfo.scrape_source = media_source
if not isinstance(mediainfo, MusicInfo):
self.obtain_images(mediainfo=mediainfo)
# 开始整理
state, errmsg = self.do_transfer(
@@ -4117,7 +4107,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.info(f"{fileitem.path} 整理完成")
return True, errmsg if preview else ""
else:
# 没有输入TMDBID时,按文件识别
# 没有输入媒体ID时,按文件识别
state, errmsg = self.do_transfer(
fileitem=fileitem,
target_storage=target_storage,