mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 00:46:57 +08:00
fix(v3): complete music recognition and scraping
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, List, Optional, Union
|
from typing import Annotated, Any, List, Optional, Union
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
|
from app.chain.music import MusicChain
|
||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import Context
|
from app.core.context import Context
|
||||||
|
from app.core.music import MusicInfo
|
||||||
from app.core.event import eventmanager
|
from app.core.event import eventmanager
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||||
@@ -23,6 +26,17 @@ router = APIRouter()
|
|||||||
MediaSource = str
|
MediaSource = str
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_source_media_id(source: Optional[str], media_id: str) -> bool:
|
||||||
|
"""按媒体数据源校验原生 ID,MusicBrainz 使用 UUID,其它现有来源使用数字 ID。"""
|
||||||
|
if source == "musicbrainz":
|
||||||
|
try:
|
||||||
|
UUID(media_id)
|
||||||
|
return True
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return media_id.isdigit()
|
||||||
|
|
||||||
|
|
||||||
def _build_recognize_metainfo(
|
def _build_recognize_metainfo(
|
||||||
title: str,
|
title: str,
|
||||||
subtitle: Optional[str] = None,
|
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)
|
context = await MediaChain().async_recognize_by_path(path, source=source)
|
||||||
if context:
|
if context:
|
||||||
@@ -255,9 +275,32 @@ def scrape(
|
|||||||
return schemas.Response(
|
return schemas.Response(
|
||||||
success=False, message="指定媒体ID时必须同时指定媒体数据源"
|
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格式无效")
|
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()
|
chain = MediaChain()
|
||||||
if normalized_media_id:
|
if normalized_media_id:
|
||||||
meta_info = MetaInfoPath(Path(fileitem.path))
|
meta_info = MetaInfoPath(Path(fileitem.path))
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ from app.core.security import verify_token
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
CountParam = Annotated[int, Query(ge=1, le=100)]
|
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:
|
def _serialize_music(info: MusicInfo) -> schemas.MusicInfo:
|
||||||
@@ -59,12 +67,19 @@ async def recognize_music(
|
|||||||
async def explore_music(
|
async def explore_music(
|
||||||
page: Annotated[int, Query(ge=1)] = 1,
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
count: CountParam = 30,
|
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),
|
_: schemas.TokenPayload = Depends(verify_token),
|
||||||
) -> list[schemas.MusicInfo]:
|
) -> list[schemas.MusicInfo]:
|
||||||
"""按月度全站收听榜单分页返回可搜索和订阅的音乐候选。"""
|
"""按周期、热度和封面条件返回可搜索和订阅的音乐候选。"""
|
||||||
results = await MusicChain().async_chart(
|
results = await MusicChain().async_chart(
|
||||||
range_name="this_month",
|
range_name=range_name,
|
||||||
page=page,
|
page=page,
|
||||||
count=count,
|
count=count,
|
||||||
|
sort_by=sort_by,
|
||||||
|
min_listen_count=min_listen_count,
|
||||||
|
with_cover=with_cover,
|
||||||
)
|
)
|
||||||
return [_serialize_music(info) for info in results]
|
return [_serialize_music(info) for info in results]
|
||||||
|
|||||||
+226
-2
@@ -1,8 +1,16 @@
|
|||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
from typing import Any, Iterable, Optional
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
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.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):
|
class MusicChain(ChainBase):
|
||||||
@@ -119,15 +127,175 @@ class MusicChain(ChainBase):
|
|||||||
range_name: str,
|
range_name: str,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
count: int = 30,
|
count: int = 30,
|
||||||
|
sort_by: str = "listen_count.desc",
|
||||||
|
min_listen_count: int = 0,
|
||||||
|
with_cover: bool = False,
|
||||||
) -> list[MusicInfo]:
|
) -> list[MusicInfo]:
|
||||||
"""异步读取 ListenBrainz 全站音乐榜单并标准化分页结果。"""
|
"""异步读取 ListenBrainz 榜单,并应用音乐探索筛选和排序。"""
|
||||||
candidates = await self.async_run_module(
|
candidates = await self.async_run_module(
|
||||||
"music_chart",
|
"music_chart",
|
||||||
range_name=range_name,
|
range_name=range_name,
|
||||||
offset=max(page - 1, 0) * count,
|
offset=max(page - 1, 0) * count,
|
||||||
count=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
|
@classmethod
|
||||||
def to_meta(cls, info: MusicInfo) -> MusicMeta:
|
def to_meta(cls, info: MusicInfo) -> MusicMeta:
|
||||||
@@ -148,6 +316,62 @@ class MusicChain(ChainBase):
|
|||||||
media_id=info.media_id,
|
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
|
@classmethod
|
||||||
def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]:
|
def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]:
|
||||||
"""构造跨来源稳定的候选去重键。"""
|
"""构造跨来源稳定的候选去重键。"""
|
||||||
|
|||||||
@@ -517,6 +517,8 @@ class ConfigModel(BaseModel):
|
|||||||
"ykimg.com",
|
"ykimg.com",
|
||||||
"qpic.cn",
|
"qpic.cn",
|
||||||
"anilist.co",
|
"anilist.co",
|
||||||
|
"coverartarchive.org",
|
||||||
|
"archive.org",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
# 图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果
|
# 图片代理允许访问的非公网 IP/CIDR,默认不放行任何非公网解析结果
|
||||||
|
|||||||
+126
-3
@@ -1,14 +1,17 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
from mutagen import File as MutagenFile
|
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
|
from app.log import logger
|
||||||
|
|
||||||
|
|
||||||
class AudioMetadataHelper:
|
class AudioMetadataHelper:
|
||||||
"""读取音频标签和技术参数并转换为标准 MusicMeta。"""
|
"""读取和写入音频标签,并转换为标准音乐元数据。"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def read(cls, path: Path) -> MusicMeta:
|
def read(cls, path: Path) -> MusicMeta:
|
||||||
@@ -50,6 +53,126 @@ class AudioMetadataHelper:
|
|||||||
isrc=cls._first(tags, "isrc"),
|
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
|
@staticmethod
|
||||||
def _values(tags: Any, key: str) -> list[str]:
|
def _values(tags: Any, key: str) -> list[str]:
|
||||||
"""从 Mutagen Easy 标签中提取非空字符串列表。"""
|
"""从 Mutagen Easy 标签中提取非空字符串列表。"""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.core.music import MusicInfo
|
||||||
from app.helper.audio import AudioMetadataHelper
|
from app.helper.audio import AudioMetadataHelper
|
||||||
|
|
||||||
|
|
||||||
@@ -46,3 +47,42 @@ def test_read_audio_metadata_falls_back_to_filename(monkeypatch):
|
|||||||
|
|
||||||
assert meta.title == "Unknown Track"
|
assert meta.title == "Unknown Track"
|
||||||
assert meta.audio_format == "MP3"
|
assert meta.audio_format == "MP3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
|
||||||
|
"""音乐刮削应把标准歌曲、专辑和曲序字段写回音频标签。"""
|
||||||
|
class FakeAudio:
|
||||||
|
"""记录 Mutagen Easy 标签写入结果。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tags = {}
|
||||||
|
self.saved = False
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
self.tags[key] = value
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
self.saved = True
|
||||||
|
|
||||||
|
audio = FakeAudio()
|
||||||
|
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||||
|
|
||||||
|
success = AudioMetadataHelper.write(
|
||||||
|
Path("/music/08 - Get Lucky.flac"),
|
||||||
|
MusicInfo(
|
||||||
|
title="Get Lucky",
|
||||||
|
artists=["Daft Punk", "Pharrell Williams"],
|
||||||
|
album="Random Access Memories",
|
||||||
|
album_artist="Daft Punk",
|
||||||
|
year=2013,
|
||||||
|
track_number=8,
|
||||||
|
total_tracks=13,
|
||||||
|
isrc="USQX91300105",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert audio.saved is True
|
||||||
|
assert audio.tags["title"] == ["Get Lucky"]
|
||||||
|
assert audio.tags["artist"] == ["Daft Punk", "Pharrell Williams"]
|
||||||
|
assert audio.tags["tracknumber"] == ["8/13"]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from unittest.mock import Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
from app.api.endpoints.media import scrape
|
from app.api.endpoints.media import recognize_file, scrape
|
||||||
from app.core.context import Context, MediaInfo
|
from app.core.context import Context, MediaInfo
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
|
from app.core.music import MusicInfo, MusicMeta
|
||||||
from app.schemas import FileItem, MediaType
|
from app.schemas import FileItem, MediaType
|
||||||
|
|
||||||
|
|
||||||
@@ -80,3 +81,65 @@ def test_scrape_rejects_media_id_without_source() -> None:
|
|||||||
|
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert result.message == "指定媒体ID时必须同时指定媒体数据源"
|
assert result.message == "指定媒体ID时必须同时指定媒体数据源"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recognize_file_routes_audio_to_music_chain() -> None:
|
||||||
|
"""文件管理识别音频文件时应返回音乐专属上下文。"""
|
||||||
|
music_chain = Mock()
|
||||||
|
music_chain.async_recognize_by_path = AsyncMock(
|
||||||
|
return_value=(
|
||||||
|
MusicMeta(title="晴天", artists=["周杰伦"]),
|
||||||
|
MusicInfo(
|
||||||
|
source="musicbrainz",
|
||||||
|
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||||
|
title="晴天",
|
||||||
|
artists=["周杰伦"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
with patch("app.api.endpoints.media.MusicChain", return_value=music_chain):
|
||||||
|
result = asyncio.run(recognize_file(path="/music/晴天.flac", _=Mock()))
|
||||||
|
|
||||||
|
assert result["meta_info"]["type"] == "音乐"
|
||||||
|
assert result["media_info"]["title"] == "晴天"
|
||||||
|
music_chain.async_recognize_by_path.assert_awaited_once_with(
|
||||||
|
path="/music/晴天.flac",
|
||||||
|
source="musicbrainz",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None:
|
||||||
|
"""手动音乐刮削应接受 MusicBrainz UUID 并进入音乐标签写入流程。"""
|
||||||
|
fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file")
|
||||||
|
info = MusicInfo(
|
||||||
|
source="musicbrainz",
|
||||||
|
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||||
|
title="晴天",
|
||||||
|
)
|
||||||
|
chain = Mock()
|
||||||
|
chain.recognize.return_value = info
|
||||||
|
chain.scrape_metadata.return_value = (True, "已刮削 1 个音频文件")
|
||||||
|
|
||||||
|
with patch("app.api.endpoints.media.MusicChain", return_value=chain):
|
||||||
|
result = scrape(
|
||||||
|
fileitem=fileitem,
|
||||||
|
storage="local",
|
||||||
|
media_source="musicbrainz",
|
||||||
|
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||||
|
type_name=MediaType.MUSIC,
|
||||||
|
_=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
chain.recognize.assert_called_once_with(
|
||||||
|
source="musicbrainz",
|
||||||
|
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||||
|
)
|
||||||
|
chain.scrape_metadata.assert_called_once_with(
|
||||||
|
fileitem=fileitem,
|
||||||
|
mediainfo=info,
|
||||||
|
overwrite=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from app.chain.music import MusicChain
|
from app.chain.music import MusicChain
|
||||||
from app.core.music import MusicInfo
|
from app.core.music import MusicInfo, MusicMeta
|
||||||
|
|
||||||
|
|
||||||
def test_parse_query_supports_artist_title_format():
|
def test_parse_query_supports_artist_title_format():
|
||||||
@@ -110,3 +110,64 @@ def test_chart_converts_page_to_listenbrainz_offset(monkeypatch):
|
|||||||
"count": 30,
|
"count": 30,
|
||||||
}
|
}
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_chart_applies_music_explore_filters(monkeypatch):
|
||||||
|
"""音乐探索应按收听次数、封面条件和升序设置筛选榜单。"""
|
||||||
|
chain = MusicChain()
|
||||||
|
|
||||||
|
async def fake_async_run_module(method, **kwargs):
|
||||||
|
"""返回包含不同热度和封面状态的榜单候选。"""
|
||||||
|
assert method == "music_chart"
|
||||||
|
return [
|
||||||
|
MusicInfo(media_id="1", source="musicbrainz", title="A", listen_count=300),
|
||||||
|
MusicInfo(
|
||||||
|
media_id="2",
|
||||||
|
source="musicbrainz",
|
||||||
|
title="B",
|
||||||
|
listen_count=120,
|
||||||
|
cover_url="https://coverartarchive.org/release/2/front-500",
|
||||||
|
),
|
||||||
|
MusicInfo(
|
||||||
|
media_id="3",
|
||||||
|
source="musicbrainz",
|
||||||
|
title="C",
|
||||||
|
listen_count=240,
|
||||||
|
cover_url="https://coverartarchive.org/release/3/front-500",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
results = asyncio.run(
|
||||||
|
chain.async_chart(
|
||||||
|
range_name="this_month",
|
||||||
|
count=30,
|
||||||
|
sort_by="listen_count.asc",
|
||||||
|
min_listen_count=100,
|
||||||
|
with_cover=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [item.title for item in results] == ["B", "C"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_path_candidate_prefers_matching_audio_tags():
|
||||||
|
"""文件识别应优先选择标题、艺术家和专辑均匹配的 MusicBrainz 候选。"""
|
||||||
|
meta = MusicMeta(title="晴天", artists=["周杰伦"], album="叶惠美")
|
||||||
|
candidates = [
|
||||||
|
MusicInfo(source="musicbrainz", media_id="1", title="晴天", artists=["其他歌手"]),
|
||||||
|
MusicInfo(
|
||||||
|
source="musicbrainz",
|
||||||
|
media_id="2",
|
||||||
|
title="晴天",
|
||||||
|
artists=["周杰伦"],
|
||||||
|
album="叶惠美",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
selected = MusicChain._select_path_candidate(meta, candidates, source="musicbrainz")
|
||||||
|
|
||||||
|
assert selected is candidates[1]
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ def test_recognize_music_returns_404_for_unknown_item():
|
|||||||
assert error.value.status_code == 404
|
assert error.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_explore_music_serializes_monthly_chart():
|
def test_explore_music_forwards_filters_and_serializes_chart():
|
||||||
"""音乐探索接口应按月度榜单分页并保留收听统计。"""
|
"""音乐探索接口应传递周期、排序、热度和封面筛选条件。"""
|
||||||
chain = Mock()
|
chain = Mock()
|
||||||
chain.async_chart = AsyncMock(
|
chain.async_chart = AsyncMock(
|
||||||
return_value=[
|
return_value=[
|
||||||
@@ -106,11 +106,24 @@ def test_explore_music_serializes_monthly_chart():
|
|||||||
)
|
)
|
||||||
|
|
||||||
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
|
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
|
||||||
result = asyncio.run(explore_music(page=2, count=20, _=Mock()))
|
result = asyncio.run(
|
||||||
|
explore_music(
|
||||||
|
page=2,
|
||||||
|
count=20,
|
||||||
|
range_name="this_week",
|
||||||
|
sort_by="listen_count.asc",
|
||||||
|
min_listen_count=100,
|
||||||
|
with_cover=True,
|
||||||
|
_=Mock(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assert result[0].listen_count == 123
|
assert result[0].listen_count == 123
|
||||||
chain.async_chart.assert_awaited_once_with(
|
chain.async_chart.assert_awaited_once_with(
|
||||||
range_name="this_month",
|
range_name="this_week",
|
||||||
page=2,
|
page=2,
|
||||||
count=20,
|
count=20,
|
||||||
|
sort_by="listen_count.asc",
|
||||||
|
min_listen_count=100,
|
||||||
|
with_cover=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
from app.core.music import MusicMeta
|
from app.core.music import MusicMeta
|
||||||
from app.modules.musicbrainz import MusicBrainzModule
|
from app.modules.musicbrainz import MusicBrainzModule
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_musicbrainz_cover_domains_are_allowed_by_image_proxy():
|
||||||
|
"""MusicBrainz 封面及其归档重定向域名应进入图片代理安全列表。"""
|
||||||
|
assert "coverartarchive.org" in settings.SECURITY_IMAGE_DOMAINS
|
||||||
|
assert "archive.org" in settings.SECURITY_IMAGE_DOMAINS
|
||||||
|
|
||||||
|
|
||||||
def test_build_query_uses_structured_music_fields():
|
def test_build_query_uses_structured_music_fields():
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import pytest
|
|||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.db.models.subscribehistory import SubscribeHistory
|
from app.db.models.subscribehistory import SubscribeHistory
|
||||||
from app.db.subscribe_oper import SubscribeOper
|
from app.db.subscribe_oper import SubscribeOper
|
||||||
|
from app.core.music import MusicInfo
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +97,29 @@ def test_add_scopes_duplicate_lookup_by_episode_group(episode_group):
|
|||||||
created.create.assert_called_once()
|
created.create.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_subscribe_persists_release_cover_as_poster_and_backdrop():
|
||||||
|
"""音乐订阅应把 MusicBrainz 发行封面写入订阅海报和背景字段。"""
|
||||||
|
persisted = SimpleNamespace(id=92)
|
||||||
|
created = SimpleNamespace(create=MagicMock())
|
||||||
|
media = MusicInfo(
|
||||||
|
source="musicbrainz",
|
||||||
|
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
|
||||||
|
title="晴天",
|
||||||
|
cover_url="https://coverartarchive.org/release-group/example/front-500",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
|
||||||
|
subscribe_model.exists.side_effect = [None, persisted]
|
||||||
|
subscribe_model.return_value = created
|
||||||
|
|
||||||
|
sid, _ = SubscribeOper(db=object()).add(mediainfo=media, season=None)
|
||||||
|
|
||||||
|
assert sid == 92
|
||||||
|
payload = subscribe_model.call_args.kwargs
|
||||||
|
assert payload["poster"] == media.cover_url
|
||||||
|
assert payload["backdrop"] == media.cover_url
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("episode_group", [None, "eg-1"])
|
@pytest.mark.parametrize("episode_group", [None, "eg-1"])
|
||||||
def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group):
|
def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group):
|
||||||
"""异步新增与同步路径使用相同的剧集组身份契约。"""
|
"""异步新增与同步路径使用相同的剧集组身份契约。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user