fix(v3): complete music recognition and scraping

This commit is contained in:
jxxghp
2026-08-07 23:46:54 +08:00
parent 4f22ceb1a6
commit c356d47807
11 changed files with 630 additions and 15 deletions
+44 -1
View File
@@ -1,13 +1,16 @@
from pathlib import Path
from typing import Annotated, Any, List, Optional, Union
from uuid import UUID
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import Context
from app.core.music import MusicInfo
from app.core.event import eventmanager
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath
@@ -23,6 +26,17 @@ router = APIRouter()
MediaSource = str
def _is_valid_source_media_id(source: Optional[str], media_id: str) -> bool:
"""按媒体数据源校验原生 IDMusicBrainz 使用 UUID,其它现有来源使用数字 ID。"""
if source == "musicbrainz":
try:
UUID(media_id)
return True
except (TypeError, ValueError):
return False
return media_id.isdigit()
def _build_recognize_metainfo(
title: str,
subtitle: Optional[str] = None,
@@ -148,6 +162,12 @@ async def recognize_file(
"""
根据文件路径识别媒体信息
"""
if MusicChain.is_audio_path(path) or source == "musicbrainz":
meta_info, media_info = await MusicChain().async_recognize_by_path(
path=path,
source=source or "musicbrainz",
)
return Context(meta_info=meta_info, media_info=media_info).to_dict()
# 识别媒体信息
context = await MediaChain().async_recognize_by_path(path, source=source)
if context:
@@ -255,9 +275,32 @@ def scrape(
return schemas.Response(
success=False, message="指定媒体ID时必须同时指定媒体数据源"
)
if normalized_media_id and not normalized_media_id.isdigit():
if normalized_media_id and not _is_valid_source_media_id(media_source, normalized_media_id):
return schemas.Response(success=False, message="媒体ID格式无效")
is_music = (
type_name == MediaType.MUSIC
or media_source == "musicbrainz"
or MusicChain.is_audio_path(fileitem.path)
)
if is_music:
if type_name not in (None, MediaType.MUSIC):
return schemas.Response(success=False, message="MusicBrainz 只能用于音乐刮削")
music_info: Optional[MusicInfo] = None
if normalized_media_id:
music_info = MusicChain().recognize(
source=media_source or "musicbrainz",
media_id=normalized_media_id,
)
if not music_info:
return schemas.Response(success=False, message="刮削失败,无法识别音乐信息")
success, message = MusicChain().scrape_metadata(
fileitem=fileitem,
mediainfo=music_info,
overwrite=True,
)
return schemas.Response(success=success, message=message)
chain = MediaChain()
if normalized_media_id:
meta_info = MetaInfoPath(Path(fileitem.path))
+17 -2
View File
@@ -10,6 +10,14 @@ from app.core.security import verify_token
router = APIRouter()
CountParam = Annotated[int, Query(ge=1, le=100)]
MusicRangeParam = Annotated[
str,
Query(pattern="^(this_week|this_month|this_year|all_time)$"),
]
MusicSortParam = Annotated[
str,
Query(pattern="^listen_count\\.(desc|asc)$"),
]
def _serialize_music(info: MusicInfo) -> schemas.MusicInfo:
@@ -59,12 +67,19 @@ async def recognize_music(
async def explore_music(
page: Annotated[int, Query(ge=1)] = 1,
count: CountParam = 30,
range_name: MusicRangeParam = "this_month",
sort_by: MusicSortParam = "listen_count.desc",
min_listen_count: Annotated[int, Query(ge=0)] = 0,
with_cover: bool = False,
_: schemas.TokenPayload = Depends(verify_token),
) -> list[schemas.MusicInfo]:
"""月度全站收听榜单分页返回可搜索和订阅的音乐候选。"""
"""周期、热度和封面条件返回可搜索和订阅的音乐候选。"""
results = await MusicChain().async_chart(
range_name="this_month",
range_name=range_name,
page=page,
count=count,
sort_by=sort_by,
min_listen_count=min_listen_count,
with_cover=with_cover,
)
return [_serialize_music(info) for info in results]
+226 -2
View File
@@ -1,8 +1,16 @@
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.music import MusicInfo, MusicMeta
from app.helper.audio import AudioMetadataHelper
from app.log import logger
from app.utils.http import RequestUtils
class MusicChain(ChainBase):
@@ -119,15 +127,175 @@ class MusicChain(ChainBase):
range_name: str,
page: int = 1,
count: int = 30,
sort_by: str = "listen_count.desc",
min_listen_count: int = 0,
with_cover: bool = False,
) -> list[MusicInfo]:
"""异步读取 ListenBrainz 全站音乐榜单并标准化分页结果"""
"""异步读取 ListenBrainz 榜单,并应用音乐探索筛选和排序"""
candidates = await self.async_run_module(
"music_chart",
range_name=range_name,
offset=max(page - 1, 0) * count,
count=count,
)
return self.normalize_candidates(candidates, limit=count)
results = self.normalize_candidates(candidates)
if min_listen_count > 0:
results = [
info for info in results
if (info.listen_count or 0) >= min_listen_count
]
if with_cover:
results = [info for info in results if info.cover_url]
results.sort(
key=lambda info: info.listen_count or 0,
reverse=sort_by != "listen_count.asc",
)
return results[:count]
@classmethod
def is_audio_path(cls, path: str | Path) -> bool:
"""判断路径是否指向系统支持的音频文件。"""
return Path(path).suffix.lower() in settings.RMT_AUDIOEXT
@classmethod
def read_path_meta(cls, path: str | Path) -> MusicMeta:
"""读取本地音频标签,不可访问时按文件名构造最小音乐元数据。"""
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)
async def async_recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MusicMeta, 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)
def recognize_by_path(
self,
path: str | Path,
source: str = "musicbrainz",
) -> tuple[MusicMeta, 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,
)
)
@classmethod
def to_meta(cls, info: MusicInfo) -> MusicMeta:
@@ -148,6 +316,62 @@ class MusicChain(ChainBase):
media_id=info.media_id,
)
@classmethod
def _select_path_candidate(
cls,
meta: MusicMeta,
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: MusicMeta) -> 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],
)
@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, ...]:
"""构造跨来源稳定的候选去重键。"""
+2
View File
@@ -517,6 +517,8 @@ class ConfigModel(BaseModel):
"ykimg.com",
"qpic.cn",
"anilist.co",
"coverartarchive.org",
"archive.org",
]
)
# 图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果
+126 -3
View File
@@ -1,14 +1,17 @@
from pathlib import Path
from typing import Any, Optional
from typing import Any, Optional, Union
from mutagen import File as MutagenFile
from mutagen.flac import FLAC, Picture
from mutagen.id3 import APIC
from mutagen.mp4 import MP4, MP4Cover
from app.core.music import MusicMeta
from app.core.music import MusicInfo, MusicMeta
from app.log import logger
class AudioMetadataHelper:
"""读取音频标签和技术参数并转换为标准 MusicMeta"""
"""读取和写入音频标签并转换为标准音乐元数据"""
@classmethod
def read(cls, path: Path) -> MusicMeta:
@@ -50,6 +53,126 @@ class AudioMetadataHelper:
isrc=cls._first(tags, "isrc"),
)
@classmethod
def write(
cls,
path: Path,
music: Union[MusicMeta, MusicInfo],
cover_data: Optional[bytes] = None,
cover_mime: str = "image/jpeg",
overwrite: bool = True,
) -> bool:
"""把标准音乐字段写入音频标签,并为常见格式嵌入专辑封面。"""
try:
audio = MutagenFile(path, easy=True)
if not audio:
logger.warning(f"无法写入音频标签:{path}")
return False
if audio.tags is None:
audio.add_tags()
for key, value in cls._tag_values(music).items():
if value in (None, "", []):
continue
if not overwrite and audio.tags.get(key):
continue
try:
audio[key] = value if isinstance(value, list) else [str(value)]
except (KeyError, TypeError, ValueError) as err:
logger.debug(f"音频格式不支持标签 {key}{path} - {err}")
audio.save()
if cover_data:
cls._write_cover(
path=path,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
)
return True
except Exception as err:
logger.warning(f"写入音频标签失败:{path} - {err}")
return False
@classmethod
def _tag_values(cls, music: Union[MusicMeta, MusicInfo]) -> dict[str, Any]:
"""把标准音乐对象转换为 Mutagen Easy 标签字典。"""
track_number = cls._number_text(
getattr(music, "track_number", None),
getattr(music, "total_tracks", None),
)
disc_number = cls._number_text(
getattr(music, "disc_number", None),
getattr(music, "total_discs", None),
)
return {
"title": getattr(music, "title", None),
"artist": list(getattr(music, "artists", None) or []),
"album": getattr(music, "album", None),
"albumartist": getattr(music, "album_artist", None),
"date": getattr(music, "year", None),
"tracknumber": track_number,
"discnumber": disc_number,
"isrc": getattr(music, "isrc", None),
}
@staticmethod
def _number_text(current: Optional[int], total: Optional[int]) -> Optional[str]:
"""把曲序或碟号转换为常见的 current/total 标签文本。"""
if current is None:
return None
return f"{current}/{total}" if total else str(current)
@staticmethod
def _write_cover(
path: Path,
cover_data: bytes,
cover_mime: str,
overwrite: bool,
) -> None:
"""为 MP3、FLAC 和 MP4/M4A 写入内嵌封面,其它格式保留标签写入结果。"""
audio = MutagenFile(path)
if isinstance(audio, FLAC):
if audio.pictures and not overwrite:
return
picture = Picture()
picture.type = 3
picture.mime = cover_mime
picture.desc = "Cover"
picture.data = cover_data
if overwrite:
audio.clear_pictures()
audio.add_picture(picture)
audio.save()
return
if isinstance(audio, MP4):
if audio.tags is None:
audio.add_tags()
if audio.tags.get("covr") and not overwrite:
return
image_format = (
MP4Cover.FORMAT_PNG
if cover_mime == "image/png"
else MP4Cover.FORMAT_JPEG
)
audio.tags["covr"] = [MP4Cover(cover_data, imageformat=image_format)]
audio.save()
return
tags = getattr(audio, "tags", None)
if tags is not None and hasattr(tags, "add"):
if tags.getall("APIC") and not overwrite:
return
if overwrite:
tags.delall("APIC")
tags.add(
APIC(
encoding=3,
mime=cover_mime,
type=3,
desc="Cover",
data=cover_data,
)
)
audio.save()
@staticmethod
def _values(tags: Any, key: str) -> list[str]:
"""从 Mutagen Easy 标签中提取非空字符串列表。"""