mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 02:05:13 +08:00
feat(music): scrape lyrics during library import
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import os
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from threading import Lock
|
||||
from typing import Optional, List, Tuple, Union
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
@@ -14,7 +16,9 @@ from app.core.context import (
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
Context,
|
||||
MediaInfo,
|
||||
MusicAlbumInfo,
|
||||
MusicInfo,
|
||||
MusicLyrics,
|
||||
)
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
@@ -37,6 +41,9 @@ from app.utils.mixins import ConfigReloadMixin
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
recognize_lock = Lock()
|
||||
scraping_lock = Lock()
|
||||
|
||||
@@ -44,6 +51,14 @@ current_umask = os.umask(0)
|
||||
os.umask(current_umask)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MusicScrapeFileResult:
|
||||
"""记录单个音轨的标签刮削结果和歌词处理状态。"""
|
||||
|
||||
metadata_success: bool = True
|
||||
lyrics_status: str = "disabled"
|
||||
|
||||
|
||||
class ScrapingOption:
|
||||
"""刮削选项"""
|
||||
|
||||
@@ -147,7 +162,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"]),
|
||||
("music", ["nfo", "poster", "lyrics"]),
|
||||
]
|
||||
for md in mds
|
||||
]
|
||||
@@ -182,6 +197,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"landscape": ["thumb"],
|
||||
}
|
||||
|
||||
MUSIC_LYRICS_EXTENSIONS = (".lrc", ".txt")
|
||||
_music_track_prefix_pattern = re.compile(
|
||||
r"^\s*(?:(?:cd|disc)\s*\d+\s*[-_. ]+)?(?:\d+\s*[-_. ]+)+",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.storagechain = StorageChain()
|
||||
@@ -1076,7 +1097,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
_, message = self.scrape_music_metadata(
|
||||
fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
overwrite=overwrite or self.scraping_policies.option("music", "nfo").is_overwrite,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
if message:
|
||||
logger.info(f"音乐刮削:{message}")
|
||||
@@ -1420,11 +1441,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
):
|
||||
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, "音乐标签刮削策略为跳过,请先在高级设置中开启"
|
||||
lyrics_option = self.scraping_policies.option("music", "lyrics")
|
||||
if nfo_option.is_skip and poster_option.is_skip and lyrics_option.is_skip:
|
||||
return False, "音乐标签、封面和歌词刮削策略均为跳过"
|
||||
|
||||
with_cover = not poster_option.is_skip
|
||||
shared_cover = (
|
||||
@@ -1432,19 +1454,71 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
if mediainfo and with_cover
|
||||
else None
|
||||
)
|
||||
failures: list[str] = []
|
||||
for audio_item in files:
|
||||
if not self._scrape_music_file(
|
||||
audio_item,
|
||||
mediainfo,
|
||||
overwrite=overwrite or nfo_option.is_overwrite,
|
||||
with_cover=with_cover,
|
||||
cover=shared_cover,
|
||||
music_chain = None
|
||||
album_info = None
|
||||
if not lyrics_option.is_skip:
|
||||
# 延迟导入避免 MediaChain 与 MusicChain 在模块加载阶段形成双向依赖。
|
||||
from app.chain.music import MusicChain
|
||||
|
||||
music_chain = MusicChain()
|
||||
if (
|
||||
mediainfo
|
||||
and mediainfo.music_type == MUSIC_ENTITY_ALBUM
|
||||
and mediainfo.source
|
||||
and mediainfo.media_id
|
||||
):
|
||||
failures.append(f"{audio_item.name or audio_item.path} 标签写入失败")
|
||||
album_info = music_chain.album(
|
||||
source=mediainfo.source,
|
||||
media_id=mediainfo.media_id,
|
||||
)
|
||||
|
||||
failures: list[str] = []
|
||||
lyrics_counts = {
|
||||
"saved": 0,
|
||||
"existing": 0,
|
||||
"missing": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
metadata_failure_label = (
|
||||
"音乐标签和封面"
|
||||
if not nfo_option.is_skip and with_cover
|
||||
else "音乐标签" if not nfo_option.is_skip else "封面"
|
||||
)
|
||||
for audio_item in files:
|
||||
result = self._scrape_music_file(
|
||||
audio_item,
|
||||
mediainfo,
|
||||
write_tags=not nfo_option.is_skip,
|
||||
tag_overwrite=overwrite or nfo_option.is_overwrite,
|
||||
with_cover=with_cover,
|
||||
cover_overwrite=overwrite or poster_option.is_overwrite,
|
||||
cover=shared_cover,
|
||||
lyrics_option=lyrics_option,
|
||||
lyrics_overwrite=overwrite or lyrics_option.is_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
)
|
||||
if not result.metadata_success:
|
||||
failures.append(
|
||||
f"{audio_item.name or audio_item.path} {metadata_failure_label}写入失败"
|
||||
)
|
||||
if result.lyrics_status in lyrics_counts:
|
||||
lyrics_counts[result.lyrics_status] += 1
|
||||
if result.lyrics_status == "failed":
|
||||
failures.append(f"{audio_item.name or audio_item.path} 歌词保存失败")
|
||||
|
||||
message = f"已刮削 {len(files)} 个音频文件"
|
||||
if not lyrics_option.is_skip:
|
||||
message += (
|
||||
f",歌词新增 {lyrics_counts['saved']} 首"
|
||||
f"、已存在 {lyrics_counts['existing']} 首"
|
||||
f"、未匹配 {lyrics_counts['missing']} 首"
|
||||
)
|
||||
if lyrics_counts["failed"]:
|
||||
message += f"、失败 {lyrics_counts['failed']} 首"
|
||||
if failures:
|
||||
return False, ";".join(failures[:3])
|
||||
return True, f"已刮削 {len(files)} 个音频文件"
|
||||
return False, f"{message};{';'.join(failures[:3])}"
|
||||
return True, message
|
||||
|
||||
@staticmethod
|
||||
def _download_music_cover(url: Optional[str]) -> tuple[Optional[bytes], str]:
|
||||
@@ -1486,46 +1560,120 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
mediainfo: Optional[MusicInfo],
|
||||
overwrite: bool,
|
||||
write_tags: bool,
|
||||
tag_overwrite: bool,
|
||||
with_cover: bool = True,
|
||||
cover_overwrite: bool = True,
|
||||
cover: Optional[tuple[Optional[bytes], str]] = None,
|
||||
) -> bool:
|
||||
"""下载单个音频文件、写入标签,并在远端存储场景上传覆盖原文件。"""
|
||||
storage = StorageChain()
|
||||
lyrics_option: Optional[ScrapingOption] = None,
|
||||
lyrics_overwrite: bool = False,
|
||||
music_chain: Optional["MusicChain"] = None,
|
||||
album_info: Optional[MusicAlbumInfo] = None,
|
||||
) -> _MusicScrapeFileResult:
|
||||
"""下载单个音轨并执行标签、封面和歌词刮削,远端产物写回原目录。"""
|
||||
storage = self.storagechain
|
||||
download_failure = _MusicScrapeFileResult(
|
||||
metadata_success=not (write_tags or with_cover),
|
||||
lyrics_status=(
|
||||
"failed"
|
||||
if lyrics_option and not lyrics_option.is_skip and music_chain
|
||||
else "disabled"
|
||||
),
|
||||
)
|
||||
if fileitem.storage == "local":
|
||||
local_path = storage.download_file(fileitem)
|
||||
return bool(
|
||||
local_path
|
||||
and self._write_music_metadata(
|
||||
local_path,
|
||||
mediainfo,
|
||||
overwrite=overwrite,
|
||||
with_cover=with_cover,
|
||||
cover=cover,
|
||||
)
|
||||
if not local_path:
|
||||
return download_failure
|
||||
return self._apply_music_file_scrape(
|
||||
fileitem=fileitem,
|
||||
local_path=local_path,
|
||||
mediainfo=mediainfo,
|
||||
write_tags=write_tags,
|
||||
tag_overwrite=tag_overwrite,
|
||||
with_cover=with_cover,
|
||||
cover_overwrite=cover_overwrite,
|
||||
cover=cover,
|
||||
lyrics_option=lyrics_option,
|
||||
lyrics_overwrite=lyrics_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
)
|
||||
|
||||
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 self._write_music_metadata(
|
||||
local_path,
|
||||
mediainfo,
|
||||
overwrite=overwrite,
|
||||
with_cover=with_cover,
|
||||
cover=cover,
|
||||
):
|
||||
return False
|
||||
parent = storage.get_parent_item(fileitem)
|
||||
if not local_path:
|
||||
return download_failure
|
||||
return self._apply_music_file_scrape(
|
||||
fileitem=fileitem,
|
||||
local_path=local_path,
|
||||
mediainfo=mediainfo,
|
||||
write_tags=write_tags,
|
||||
tag_overwrite=tag_overwrite,
|
||||
with_cover=with_cover,
|
||||
cover_overwrite=cover_overwrite,
|
||||
cover=cover,
|
||||
lyrics_option=lyrics_option,
|
||||
lyrics_overwrite=lyrics_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
)
|
||||
|
||||
def _apply_music_file_scrape(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
local_path: Path,
|
||||
mediainfo: Optional[MusicInfo],
|
||||
write_tags: bool,
|
||||
tag_overwrite: bool,
|
||||
with_cover: bool,
|
||||
cover_overwrite: bool,
|
||||
cover: Optional[tuple[Optional[bytes], str]],
|
||||
lyrics_option: Optional[ScrapingOption],
|
||||
lyrics_overwrite: bool,
|
||||
music_chain: Optional["MusicChain"],
|
||||
album_info: Optional[MusicAlbumInfo],
|
||||
) -> _MusicScrapeFileResult:
|
||||
"""在本地音轨副本上执行刮削,并将变更后的音频和歌词写回目标存储。"""
|
||||
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo)
|
||||
metadata_requested = write_tags or with_cover
|
||||
metadata_success = True
|
||||
if metadata_requested:
|
||||
metadata_success = self._write_music_metadata(
|
||||
local_path=local_path,
|
||||
mediainfo=mediainfo,
|
||||
tag_overwrite=tag_overwrite,
|
||||
write_tags=write_tags,
|
||||
with_cover=with_cover,
|
||||
cover_overwrite=cover_overwrite,
|
||||
cover=cover,
|
||||
scrape_info=scrape_info,
|
||||
)
|
||||
|
||||
lyrics_status = self._scrape_music_lyrics(
|
||||
fileitem=fileitem,
|
||||
local_path=local_path,
|
||||
scrape_info=scrape_info,
|
||||
lyrics_option=lyrics_option,
|
||||
overwrite=lyrics_overwrite,
|
||||
music_chain=music_chain,
|
||||
album_info=album_info,
|
||||
)
|
||||
|
||||
if fileitem.storage != "local" and metadata_requested and metadata_success:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
if not parent:
|
||||
logger.warning(f"无法获取远端音频父目录:{fileitem.path}")
|
||||
return False
|
||||
return bool(
|
||||
storage.upload_file(
|
||||
metadata_success = False
|
||||
elif not self.storagechain.upload_file(
|
||||
parent,
|
||||
local_path,
|
||||
new_name=fileitem.name or local_path.name,
|
||||
)
|
||||
)
|
||||
):
|
||||
metadata_success = False
|
||||
return _MusicScrapeFileResult(
|
||||
metadata_success=metadata_success,
|
||||
lyrics_status=lyrics_status,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _merge_music_album_metadata(local_meta: MetaMusic, album: MusicInfo) -> MetaMusic:
|
||||
@@ -1540,6 +1688,66 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
merged.media_id = album.media_id or local_meta.media_id
|
||||
return merged
|
||||
|
||||
@classmethod
|
||||
def _match_music_album_track(
|
||||
cls,
|
||||
local_meta: MetaMusic,
|
||||
album_info: Optional[MusicAlbumInfo],
|
||||
) -> Optional[MusicInfo]:
|
||||
"""按碟号、曲序、标题、艺术家和时长为本地文件匹配专辑中的单个音轨。"""
|
||||
if not album_info or not album_info.tracks:
|
||||
return None
|
||||
local_title = cls._normalize_music_track_title(local_meta.title)
|
||||
local_artists = {
|
||||
cls._normalize_music_track_title(artist)
|
||||
for artist in local_meta.artists
|
||||
if artist
|
||||
}
|
||||
ranked: list[tuple[int, MusicInfo]] = []
|
||||
for track in album_info.tracks:
|
||||
score = 0
|
||||
if local_meta.track_number and track.track_number:
|
||||
if local_meta.track_number == track.track_number:
|
||||
score += 6
|
||||
else:
|
||||
continue
|
||||
if local_meta.disc_number and track.disc_number:
|
||||
if local_meta.disc_number == track.disc_number:
|
||||
score += 3
|
||||
else:
|
||||
continue
|
||||
if local_title and local_title == cls._normalize_music_track_title(track.title):
|
||||
score += 8
|
||||
track_artists = {
|
||||
cls._normalize_music_track_title(artist)
|
||||
for artist in track.artists
|
||||
if artist
|
||||
}
|
||||
if local_artists and track_artists and local_artists.intersection(track_artists):
|
||||
score += 3
|
||||
if local_meta.duration and track.duration:
|
||||
duration_delta = abs(local_meta.duration - track.duration)
|
||||
if duration_delta <= 2:
|
||||
score += 4
|
||||
elif duration_delta > 5:
|
||||
score -= 3
|
||||
if score >= 8:
|
||||
ranked.append((score, track))
|
||||
if not ranked:
|
||||
return None
|
||||
ranked.sort(key=lambda pair: pair[0], reverse=True)
|
||||
if len(ranked) > 1 and ranked[0][0] == ranked[1][0]:
|
||||
return None
|
||||
matched = deepcopy(ranked[0][1])
|
||||
matched.duration = matched.duration or local_meta.duration
|
||||
return matched
|
||||
|
||||
@classmethod
|
||||
def _normalize_music_track_title(cls, value: Optional[str]) -> str:
|
||||
"""规范化音轨标题并移除常见文件名前置碟号和曲序。"""
|
||||
title = cls._music_track_prefix_pattern.sub("", str(value or "").strip())
|
||||
return re.sub(r"[^\w]+", "", title.casefold(), flags=re.UNICODE)
|
||||
|
||||
@classmethod
|
||||
def _resolve_music_scrape_info(
|
||||
cls,
|
||||
@@ -1566,12 +1774,15 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
self,
|
||||
local_path: Path,
|
||||
mediainfo: Optional[MusicInfo],
|
||||
overwrite: bool,
|
||||
tag_overwrite: bool,
|
||||
write_tags: bool,
|
||||
with_cover: bool,
|
||||
cover_overwrite: bool,
|
||||
cover: Optional[tuple[Optional[bytes], str]] = None,
|
||||
scrape_info: Optional[MetaMusic | MusicInfo] = None,
|
||||
) -> bool:
|
||||
"""解析单个本地音轨并写入标签,显式专辑刮削可复用一次下载的封面。"""
|
||||
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo)
|
||||
"""解析单个本地音轨并按独立策略写入标签和封面。"""
|
||||
scrape_info = scrape_info or self._resolve_music_scrape_info(local_path, mediainfo)
|
||||
if not scrape_info or not scrape_info.title:
|
||||
logger.warning(f"无法识别音乐信息:{local_path}")
|
||||
return False
|
||||
@@ -1587,9 +1798,131 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
scrape_info,
|
||||
cover_data=cover_data,
|
||||
cover_mime=cover_mime,
|
||||
overwrite=overwrite,
|
||||
overwrite=tag_overwrite,
|
||||
write_tags=write_tags,
|
||||
cover_overwrite=cover_overwrite,
|
||||
)
|
||||
|
||||
def _scrape_music_lyrics(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
local_path: Path,
|
||||
scrape_info: Optional[MetaMusic | MusicInfo],
|
||||
lyrics_option: Optional[ScrapingOption],
|
||||
overwrite: bool,
|
||||
music_chain: Optional["MusicChain"],
|
||||
album_info: Optional[MusicAlbumInfo],
|
||||
) -> str:
|
||||
"""按歌词策略查询单个音轨并保存同名旁挂歌词文件。"""
|
||||
if not lyrics_option or lyrics_option.is_skip or not music_chain:
|
||||
return "disabled"
|
||||
existing = self._find_music_lyrics_sidecar(fileitem)
|
||||
if existing and not overwrite:
|
||||
return "existing"
|
||||
if not scrape_info:
|
||||
return "missing"
|
||||
|
||||
lookup_info: MetaMusic | MusicInfo = scrape_info
|
||||
if album_info:
|
||||
local_meta = AudioMetadataHelper.read(local_path)
|
||||
lookup_info = self._match_music_album_track(local_meta, album_info) or scrape_info
|
||||
lyrics = music_chain.lyrics(lookup_info)
|
||||
if not lyrics or lyrics.instrumental or not lyrics.content or not lyrics.extension:
|
||||
return "missing"
|
||||
return (
|
||||
"saved"
|
||||
if self._write_music_lyrics_sidecar(
|
||||
fileitem=fileitem,
|
||||
local_path=local_path,
|
||||
lyrics=lyrics,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
else "failed"
|
||||
)
|
||||
|
||||
def _find_music_lyrics_sidecar(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
) -> Optional[schemas.FileItem]:
|
||||
"""查找音轨旁已存在的同步或纯文本歌词文件。"""
|
||||
audio_path = Path(fileitem.path)
|
||||
for extension in self.MUSIC_LYRICS_EXTENSIONS:
|
||||
item = self.storagechain.get_file_item(
|
||||
storage=fileitem.storage,
|
||||
path=audio_path.with_suffix(extension),
|
||||
)
|
||||
if item:
|
||||
return item
|
||||
return None
|
||||
|
||||
def _write_music_lyrics_sidecar(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
local_path: Path,
|
||||
lyrics: MusicLyrics,
|
||||
overwrite: bool,
|
||||
) -> bool:
|
||||
"""原子写入本地歌词或上传远端歌词,并在覆盖时清理旧格式旁挂文件。"""
|
||||
extension = lyrics.extension
|
||||
content = lyrics.content
|
||||
if not extension or not content:
|
||||
return False
|
||||
target_path = Path(fileitem.path).with_suffix(extension)
|
||||
target_name = target_path.name
|
||||
temp_path: Optional[Path] = None
|
||||
try:
|
||||
if fileitem.storage == "local":
|
||||
with NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=target_path.parent,
|
||||
prefix=f".{target_name}.",
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_file.write(f"{content.rstrip()}\n")
|
||||
temp_path = Path(temp_file.name)
|
||||
temp_path.replace(target_path)
|
||||
else:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
if not parent:
|
||||
logger.warning(f"无法获取远端歌词父目录:{fileitem.path}")
|
||||
return False
|
||||
temp_path = local_path.with_suffix(extension)
|
||||
temp_path.write_text(f"{content.rstrip()}\n", encoding="utf-8")
|
||||
if not self.storagechain.upload_file(
|
||||
parent,
|
||||
temp_path,
|
||||
new_name=target_name,
|
||||
):
|
||||
return False
|
||||
|
||||
if overwrite:
|
||||
self._remove_alternate_music_lyrics(fileitem, keep_extension=extension)
|
||||
return True
|
||||
except OSError as err:
|
||||
logger.warning(f"保存音乐歌词失败:{target_path} - {err}")
|
||||
return False
|
||||
finally:
|
||||
if temp_path and temp_path.exists() and temp_path != target_path:
|
||||
self._cleanup_temp_file(temp_path)
|
||||
|
||||
def _remove_alternate_music_lyrics(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
keep_extension: str,
|
||||
) -> None:
|
||||
"""覆盖歌词格式后删除同音轨的旧扩展名文件,避免播放器优先读取过期内容。"""
|
||||
audio_path = Path(fileitem.path)
|
||||
for extension in self.MUSIC_LYRICS_EXTENSIONS:
|
||||
if extension == keep_extension:
|
||||
continue
|
||||
item = self.storagechain.get_file_item(
|
||||
storage=fileitem.storage,
|
||||
path=audio_path.with_suffix(extension),
|
||||
)
|
||||
if item and not self.storagechain.delete_file(item):
|
||||
logger.warning(f"删除旧歌词文件失败:{item.path}")
|
||||
|
||||
def _handle_movie_scraping(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.core.context import (
|
||||
MusicAlbumInfo,
|
||||
MusicArtistInfo,
|
||||
MusicInfo,
|
||||
MusicLyrics,
|
||||
)
|
||||
from app.core.meta import MetaMusic
|
||||
from app.helper.audio import AudioMetadataHelper
|
||||
@@ -185,6 +186,28 @@ class MusicChain(ChainBase):
|
||||
return MusicAlbumInfo.from_dict(result)
|
||||
return None
|
||||
|
||||
def album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
|
||||
"""同步按来源和专辑 ID 获取标准化专辑详情及曲目。"""
|
||||
result = self.run_module(
|
||||
"music_album",
|
||||
source=source,
|
||||
media_id=media_id,
|
||||
)
|
||||
if isinstance(result, MusicAlbumInfo):
|
||||
return result
|
||||
if isinstance(result, dict):
|
||||
return MusicAlbumInfo.from_dict(result)
|
||||
return None
|
||||
|
||||
def lyrics(self, music: MetaMusic | MusicInfo) -> Optional[MusicLyrics]:
|
||||
"""按单曲元数据调用已启用的歌词模块并返回标准歌词。"""
|
||||
result = self.run_module("music_lyrics", music=music)
|
||||
if isinstance(result, MusicLyrics):
|
||||
return result
|
||||
if isinstance(result, dict):
|
||||
return MusicLyrics.from_dict(result)
|
||||
return None
|
||||
|
||||
async def async_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
|
||||
"""异步按来源和艺术家 ID 获取标准化艺术家详情。"""
|
||||
result = await self.async_run_module(
|
||||
|
||||
@@ -79,6 +79,44 @@ def _music_init_values(model: type, data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: value for key, value in data.items() if key in init_names}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MusicLyrics:
|
||||
"""标准化单曲歌词,区分同步歌词、纯文本歌词和纯音乐结果。"""
|
||||
|
||||
provider: str
|
||||
provider_id: str | None = None
|
||||
instrumental: bool = False
|
||||
plain_lyrics: str | None = None
|
||||
synced_lyrics: str | None = None
|
||||
|
||||
@property
|
||||
def content(self) -> str | None:
|
||||
"""优先返回同步歌词,不存在时回退到纯文本歌词。"""
|
||||
return self.synced_lyrics or self.plain_lyrics
|
||||
|
||||
@property
|
||||
def extension(self) -> str | None:
|
||||
"""根据歌词内容返回适合播放器扫描的旁挂文件扩展名。"""
|
||||
if self.synced_lyrics:
|
||||
return ".lrc"
|
||||
if self.plain_lyrics:
|
||||
return ".txt"
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> Self:
|
||||
"""从模块或插件返回字典恢复标准歌词对象。"""
|
||||
values = _music_init_values(cls, data)
|
||||
values["provider"] = str(values.get("provider") or "")
|
||||
values["provider_id"] = (
|
||||
str(values["provider_id"])
|
||||
if values.get("provider_id") is not None
|
||||
else None
|
||||
)
|
||||
values["instrumental"] = bool(values.get("instrumental"))
|
||||
return cls(**values)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MusicInfo:
|
||||
"""标准化音乐元数据信息。"""
|
||||
|
||||
@@ -62,31 +62,38 @@ class AudioMetadataHelper:
|
||||
cover_data: Optional[bytes] = None,
|
||||
cover_mime: str = "image/jpeg",
|
||||
overwrite: bool = True,
|
||||
write_tags: bool = True,
|
||||
cover_overwrite: Optional[bool] = None,
|
||||
) -> 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 write_tags:
|
||||
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,
|
||||
overwrite=(
|
||||
overwrite
|
||||
if cover_overwrite is None
|
||||
else cover_overwrite
|
||||
),
|
||||
)
|
||||
return True
|
||||
except Exception as err:
|
||||
|
||||
234
app/modules/lrclib/__init__.py
Normal file
234
app/modules/lrclib/__init__.py
Normal file
@@ -0,0 +1,234 @@
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.core.context import MusicInfo, MusicLyrics
|
||||
from app.core.meta import MetaMusic
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas.types import ModuleType, OtherModulesType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class LrclibModule(_ModuleBase):
|
||||
"""通过 LRCLIB 获取与单个音轨匹配的同步歌词或纯文本歌词。"""
|
||||
|
||||
_base_url = "https://lrclib.net"
|
||||
_source = "lrclib"
|
||||
_request_interval = 0.3
|
||||
_request_lock = threading.Lock()
|
||||
_last_request_at = 0.0
|
||||
_match_pattern = re.compile(r"[^\w]+", flags=re.UNICODE)
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""初始化无状态的 LRCLIB 歌词模块。"""
|
||||
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""LRCLIB 无需密钥,是否请求由音乐歌词刮削策略控制。"""
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块;当前实现没有需要释放的持久资源。"""
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""测试 LRCLIB 搜索接口连通性。"""
|
||||
result = self._request_json("/api/search", params={"track_name": "test"})
|
||||
return (True, "") if result is not None else (False, "LRCLIB 网络连接失败")
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""返回模块展示名称。"""
|
||||
return "LRCLIB"
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""返回模块所属的其它能力类型。"""
|
||||
return ModuleType.Other
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> OtherModulesType:
|
||||
"""返回 LRCLIB 模块子类型。"""
|
||||
return OtherModulesType.Lrclib
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""返回歌词模块执行优先级。"""
|
||||
return 5
|
||||
|
||||
def music_lyrics(self, music: Union[MetaMusic, MusicInfo]) -> Optional[MusicLyrics]:
|
||||
"""按标题、艺术家、专辑和时长查询单曲歌词,并对搜索回退结果严格匹配。"""
|
||||
title = str(getattr(music, "title", None) or "").strip()
|
||||
artists = list(getattr(music, "artists", None) or [])
|
||||
artist = str((artists[0] if artists else getattr(music, "album_artist", None)) or "").strip()
|
||||
album = str(getattr(music, "album", None) or "").strip()
|
||||
duration = self._optional_int(getattr(music, "duration", None))
|
||||
if not title or not artist:
|
||||
return None
|
||||
|
||||
exact_params: dict[str, Any] = {
|
||||
"track_name": title,
|
||||
"artist_name": artist,
|
||||
}
|
||||
if album:
|
||||
exact_params["album_name"] = album
|
||||
if duration:
|
||||
exact_params["duration"] = duration
|
||||
payload = self._request_json("/api/get", params=exact_params)
|
||||
if not payload:
|
||||
results = self._request_json(
|
||||
"/api/search",
|
||||
params={
|
||||
"track_name": title,
|
||||
"artist_name": artist,
|
||||
**({"album_name": album} if album else {}),
|
||||
},
|
||||
)
|
||||
payload = self._select_result(
|
||||
results if isinstance(results, list) else [],
|
||||
title=title,
|
||||
artist=artist,
|
||||
album=album,
|
||||
duration=duration,
|
||||
)
|
||||
return self._to_lyrics(payload)
|
||||
|
||||
@classmethod
|
||||
def _select_result(
|
||||
cls,
|
||||
results: list[dict[str, Any]],
|
||||
title: str,
|
||||
artist: str,
|
||||
album: str,
|
||||
duration: Optional[int],
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""从模糊搜索结果中选择标题和艺术家一致且时长可信的歌词。"""
|
||||
expected_title = cls._normalize_text(title)
|
||||
expected_artist = cls._normalize_text(artist)
|
||||
expected_album = cls._normalize_text(album)
|
||||
ranked: list[tuple[int, dict[str, Any]]] = []
|
||||
for item in results:
|
||||
if cls._normalize_text(item.get("trackName")) != expected_title:
|
||||
continue
|
||||
candidate_artist = cls._normalize_text(item.get("artistName"))
|
||||
if not cls._compatible_text(expected_artist, candidate_artist):
|
||||
continue
|
||||
candidate_duration = cls._optional_int(item.get("duration"))
|
||||
if duration and candidate_duration and abs(duration - candidate_duration) > 2:
|
||||
continue
|
||||
score = 4
|
||||
if candidate_artist == expected_artist:
|
||||
score += 3
|
||||
if expected_album and cls._normalize_text(item.get("albumName")) == expected_album:
|
||||
score += 2
|
||||
if duration and candidate_duration and abs(duration - candidate_duration) <= 2:
|
||||
score += 3
|
||||
ranked.append((score, item))
|
||||
if not ranked:
|
||||
return None
|
||||
ranked.sort(key=lambda pair: pair[0], reverse=True)
|
||||
return ranked[0][1]
|
||||
|
||||
@classmethod
|
||||
def _normalize_text(cls, value: Any) -> str:
|
||||
"""移除大小写、标点和空白差异,生成歌词匹配文本。"""
|
||||
return cls._match_pattern.sub("", str(value or "").casefold())
|
||||
|
||||
@staticmethod
|
||||
def _compatible_text(expected: str, candidate: str) -> bool:
|
||||
"""允许合作艺人字符串互相包含,同时拒绝完全无关的艺术家。"""
|
||||
return bool(expected and candidate and (expected in candidate or candidate in expected))
|
||||
|
||||
@staticmethod
|
||||
def _optional_int(value: Any) -> Optional[int]:
|
||||
"""把歌词源返回的时长安全转换为整数秒。"""
|
||||
try:
|
||||
return round(float(value)) if value not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _to_lyrics(cls, payload: Any) -> Optional[MusicLyrics]:
|
||||
"""把 LRCLIB 响应转换为标准歌词对象。"""
|
||||
if not isinstance(payload, dict) or payload.get("id") is None:
|
||||
return None
|
||||
plain_lyrics = str(payload.get("plainLyrics") or "").strip() or None
|
||||
synced_lyrics = str(payload.get("syncedLyrics") or "").strip() or None
|
||||
instrumental = bool(payload.get("instrumental"))
|
||||
if not instrumental and not plain_lyrics and not synced_lyrics:
|
||||
return None
|
||||
return MusicLyrics(
|
||||
provider=cls._source,
|
||||
provider_id=str(payload["id"]),
|
||||
instrumental=instrumental,
|
||||
plain_lyrics=plain_lyrics,
|
||||
synced_lyrics=synced_lyrics,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _request_once(
|
||||
cls,
|
||||
path: str,
|
||||
params: Optional[dict[str, Any]],
|
||||
) -> Any:
|
||||
"""串行执行一次 LRCLIB 请求,确保批量专辑刮削遵守最小请求间隔。"""
|
||||
with cls._request_lock:
|
||||
delay = cls._request_interval - (time.monotonic() - cls._last_request_at)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
response = RequestUtils(
|
||||
headers={
|
||||
"User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
proxies=settings.PROXY,
|
||||
timeout=20,
|
||||
).get_res(f"{cls._base_url}{path}", params=params)
|
||||
cls._last_request_at = time.monotonic()
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
@cached(maxsize=1024, ttl=7 * 24 * 60 * 60, skip_none=True)
|
||||
def _request_json(
|
||||
cls,
|
||||
path: str,
|
||||
params: Optional[dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""请求 LRCLIB JSON 接口,缓存命中与未命中结果并按 Retry-After 重试一次。"""
|
||||
response = cls._request_once(path, params)
|
||||
if response is None:
|
||||
return None
|
||||
try:
|
||||
if response.status_code == 404:
|
||||
return {}
|
||||
if response.status_code in (429, 503):
|
||||
retry_after = cls._retry_after_seconds(response.headers.get("Retry-After"))
|
||||
response.close()
|
||||
time.sleep(retry_after)
|
||||
response = cls._request_once(path, params)
|
||||
if response is None:
|
||||
return None
|
||||
if response.status_code == 404:
|
||||
return {}
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
f"LRCLIB 请求失败:{response.status_code} {response.text[:200]}"
|
||||
)
|
||||
return None
|
||||
return response.json()
|
||||
except (TypeError, ValueError) as err:
|
||||
logger.warning(f"LRCLIB 响应解析失败:{err}")
|
||||
return None
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
|
||||
@staticmethod
|
||||
def _retry_after_seconds(value: Any) -> float:
|
||||
"""解析 LRCLIB 限流等待秒数,异常值回退到一秒。"""
|
||||
try:
|
||||
return max(float(value), 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
@@ -464,6 +464,8 @@ class OtherModulesType(Enum):
|
||||
Redis = "Redis"
|
||||
# ListenBrainz
|
||||
ListenBrainz = "ListenBrainz"
|
||||
# LRCLIB 歌词
|
||||
Lrclib = "LRCLIB"
|
||||
|
||||
|
||||
class NameValueEnum(Enum):
|
||||
@@ -505,3 +507,4 @@ class ScrapingMetadata(NameValueEnum):
|
||||
DISC = "光盘图"
|
||||
CLEARART = "透明艺术图"
|
||||
LANDSCAPE = "横版缩略图"
|
||||
LYRICS = "歌词"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.core.context import MusicInfo
|
||||
from app.helper.audio import AudioMetadataHelper
|
||||
@@ -86,3 +87,28 @@ def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
|
||||
assert audio.tags["title"] == ["Get Lucky"]
|
||||
assert audio.tags["artist"] == ["Daft Punk", "Pharrell Williams"]
|
||||
assert audio.tags["tracknumber"] == ["8/13"]
|
||||
|
||||
|
||||
def test_write_audio_metadata_can_embed_cover_without_rewriting_tags(monkeypatch):
|
||||
"""音乐封面策略应能在标签策略关闭时独立执行。"""
|
||||
audio = SimpleNamespace(tags={"title": ["Original"]})
|
||||
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
|
||||
write_cover = Mock()
|
||||
monkeypatch.setattr(AudioMetadataHelper, "_write_cover", write_cover)
|
||||
|
||||
success = AudioMetadataHelper.write(
|
||||
Path("/music/track.flac"),
|
||||
MusicInfo(title="Changed"),
|
||||
cover_data=b"cover",
|
||||
write_tags=False,
|
||||
cover_overwrite=False,
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert audio.tags == {"title": ["Original"]}
|
||||
write_cover.assert_called_once_with(
|
||||
path=Path("/music/track.flac"),
|
||||
cover_data=b"cover",
|
||||
cover_mime="image/jpeg",
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
203
tests/test_lrclib_module.py
Normal file
203
tests/test_lrclib_module.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from app.core.context import MusicInfo
|
||||
from app.modules.lrclib import LrclibModule
|
||||
|
||||
|
||||
class _FakeLrclibResponse:
|
||||
"""模拟 LRCLIB HTTP 响应,供解析、缓存和限流测试复用。"""
|
||||
|
||||
def __init__(self, payload, status_code=200, headers=None):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.headers = headers or {}
|
||||
self.text = ""
|
||||
self.closed = False
|
||||
|
||||
def json(self):
|
||||
"""返回预设 JSON 负载。"""
|
||||
return self._payload
|
||||
|
||||
def close(self):
|
||||
"""记录响应已关闭。"""
|
||||
self.closed = True
|
||||
|
||||
def __bool__(self):
|
||||
"""复现 requests.Response 对 4xx/5xx 响应返回 False 的行为。"""
|
||||
return self.status_code < 400
|
||||
|
||||
|
||||
def test_music_lyrics_prefers_exact_signature_and_synced_lyrics(monkeypatch) -> None:
|
||||
"""元数据完整时应调用精确接口并优先返回同步歌词。"""
|
||||
module = LrclibModule()
|
||||
requested = []
|
||||
|
||||
def fake_request(path, params=None):
|
||||
"""记录精确查询参数并返回同步歌词。"""
|
||||
requested.append((path, params))
|
||||
return {
|
||||
"id": 3396226,
|
||||
"instrumental": False,
|
||||
"plainLyrics": "plain",
|
||||
"syncedLyrics": "[00:01.00]synced",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module, "_request_json", fake_request)
|
||||
|
||||
lyrics = module.music_lyrics(
|
||||
MusicInfo(
|
||||
title="I Want to Live",
|
||||
artists=["Borislav Slavov"],
|
||||
album="Baldur's Gate 3",
|
||||
duration=233,
|
||||
)
|
||||
)
|
||||
|
||||
assert lyrics is not None
|
||||
assert lyrics.provider == "lrclib"
|
||||
assert lyrics.provider_id == "3396226"
|
||||
assert lyrics.content == "[00:01.00]synced"
|
||||
assert lyrics.extension == ".lrc"
|
||||
assert requested == [
|
||||
(
|
||||
"/api/get",
|
||||
{
|
||||
"track_name": "I Want to Live",
|
||||
"artist_name": "Borislav Slavov",
|
||||
"album_name": "Baldur's Gate 3",
|
||||
"duration": 233,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_music_lyrics_search_fallback_rejects_wrong_duration(monkeypatch) -> None:
|
||||
"""精确接口未命中后只能选取标题、艺术家和时长均可信的搜索结果。"""
|
||||
module = LrclibModule()
|
||||
|
||||
def fake_request(path, params=None):
|
||||
"""精确查询返回未命中,搜索返回一条错误版本和一条正确版本。"""
|
||||
if path == "/api/get":
|
||||
return {}
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"trackName": "晴天",
|
||||
"artistName": "周杰伦",
|
||||
"albumName": "演唱会",
|
||||
"duration": 310,
|
||||
"plainLyrics": "wrong",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"trackName": "晴天",
|
||||
"artistName": "周杰伦",
|
||||
"albumName": "叶惠美",
|
||||
"duration": 269,
|
||||
"plainLyrics": "correct",
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(module, "_request_json", fake_request)
|
||||
|
||||
lyrics = module.music_lyrics(
|
||||
MusicInfo(
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
album="叶惠美",
|
||||
duration=270,
|
||||
)
|
||||
)
|
||||
|
||||
assert lyrics is not None
|
||||
assert lyrics.provider_id == "2"
|
||||
assert lyrics.content == "correct"
|
||||
assert lyrics.extension == ".txt"
|
||||
|
||||
|
||||
def test_request_json_honors_retry_after_once(monkeypatch) -> None:
|
||||
"""LRCLIB 返回带 Retry-After 的过载响应时应等待并串行重试一次。"""
|
||||
responses = iter(
|
||||
[
|
||||
_FakeLrclibResponse(None, status_code=503, headers={"Retry-After": "2"}),
|
||||
_FakeLrclibResponse([{"id": 1}], status_code=200),
|
||||
]
|
||||
)
|
||||
sleeps = []
|
||||
monkeypatch.setattr(LrclibModule, "_request_once", lambda *_args, **_kwargs: next(responses))
|
||||
monkeypatch.setattr("app.modules.lrclib.time.sleep", lambda seconds: sleeps.append(seconds))
|
||||
LrclibModule._request_json.cache_clear()
|
||||
|
||||
result = LrclibModule._request_json(
|
||||
"/api/search",
|
||||
params={"track_name": "晴天", "artist_name": "周杰伦"},
|
||||
)
|
||||
|
||||
assert result == [{"id": 1}]
|
||||
assert sleeps == [2.0]
|
||||
|
||||
|
||||
def test_request_json_retry_network_failure_is_not_cached(monkeypatch) -> None:
|
||||
"""过载重试遇到网络失败时应安全返回且不把失败写入缓存。"""
|
||||
responses = iter(
|
||||
[
|
||||
_FakeLrclibResponse(None, status_code=429, headers={"Retry-After": "0"}),
|
||||
None,
|
||||
_FakeLrclibResponse([{"id": 3}], status_code=200),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(LrclibModule, "_request_once", lambda *_args, **_kwargs: next(responses))
|
||||
monkeypatch.setattr("app.modules.lrclib.time.sleep", lambda _seconds: None)
|
||||
LrclibModule._request_json.cache_clear()
|
||||
params = {"track_name": "retry"}
|
||||
|
||||
first = LrclibModule._request_json("/api/search", params=params)
|
||||
second = LrclibModule._request_json("/api/search", params=params)
|
||||
|
||||
assert first is None
|
||||
assert second == [{"id": 3}]
|
||||
|
||||
|
||||
def test_request_json_caches_not_found_response(monkeypatch) -> None:
|
||||
"""未匹配结果也应缓存,避免整库重复刮削持续请求同一首歌。"""
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_request(*_args, **_kwargs):
|
||||
"""记录请求次数并返回 404。"""
|
||||
calls["count"] += 1
|
||||
return _FakeLrclibResponse(None, status_code=404)
|
||||
|
||||
monkeypatch.setattr(LrclibModule, "_request_once", fake_request)
|
||||
LrclibModule._request_json.cache_clear()
|
||||
|
||||
first = LrclibModule._request_json("/api/get", params={"track_name": "missing"})
|
||||
second = LrclibModule._request_json("/api/get", params={"track_name": "missing"})
|
||||
|
||||
assert first == second == {}
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_request_json_caches_success_response(monkeypatch) -> None:
|
||||
"""成功歌词响应应进入有界 TLRU 缓存,相同签名只访问一次外部 API。"""
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_request(*_args, **_kwargs):
|
||||
"""记录请求次数并返回固定歌词。"""
|
||||
calls["count"] += 1
|
||||
return _FakeLrclibResponse(
|
||||
{"id": 2, "syncedLyrics": "[00:01.00]晴天"},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(LrclibModule, "_request_once", fake_request)
|
||||
LrclibModule._request_json.cache_clear()
|
||||
params = {
|
||||
"track_name": "晴天",
|
||||
"artist_name": "周杰伦",
|
||||
"album_name": "叶惠美",
|
||||
"duration": 269,
|
||||
}
|
||||
|
||||
first = LrclibModule._request_json("/api/get", params=params)
|
||||
second = LrclibModule._request_json("/api/get", params=params)
|
||||
|
||||
assert first == second == {"id": 2, "syncedLyrics": "[00:01.00]晴天"}
|
||||
assert calls["count"] == 1
|
||||
@@ -1,10 +1,12 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo
|
||||
from app.chain.media import MediaChain, ScrapingConfig, _MusicScrapeFileResult
|
||||
from app.core.context import MUSIC_ENTITY_ALBUM, MusicAlbumInfo, MusicInfo, MusicLyrics
|
||||
from app.core.event import Event
|
||||
from app.core.meta import MetaMusic
|
||||
from app.schemas import FileItem
|
||||
from app.schemas.types import EventType, ScrapingPolicy
|
||||
|
||||
|
||||
def _media_chain() -> MediaChain:
|
||||
@@ -58,17 +60,24 @@ def test_album_directory_scrape_processes_each_track_and_reuses_cover() -> None:
|
||||
chain = _media_chain()
|
||||
chain.storagechain = Mock()
|
||||
chain.scraping_policies = Mock()
|
||||
chain.scraping_policies.option.return_value = SimpleNamespace(
|
||||
is_skip=False,
|
||||
is_overwrite=False,
|
||||
)
|
||||
|
||||
def scraping_option(_target, metadata):
|
||||
"""保持原测试只验证标签和封面,歌词由独立用例覆盖。"""
|
||||
return SimpleNamespace(
|
||||
is_skip=metadata == "lyrics",
|
||||
is_overwrite=False,
|
||||
)
|
||||
|
||||
chain.scraping_policies.option.side_effect = scraping_option
|
||||
audio_files = [
|
||||
FileItem(storage="local", path="/music/叶惠美/01.flac", type="file", name="01.flac"),
|
||||
FileItem(storage="local", path="/music/叶惠美/02.m4a", type="file", name="02.m4a"),
|
||||
]
|
||||
chain.storagechain.list_files.return_value = audio_files
|
||||
chain._download_music_cover = Mock(return_value=(b"cover", "image/jpeg"))
|
||||
chain._scrape_music_file = Mock(return_value=True)
|
||||
chain._scrape_music_file = Mock(
|
||||
return_value=_MusicScrapeFileResult(metadata_success=True)
|
||||
)
|
||||
album = _album_info()
|
||||
|
||||
success, message = chain.scrape_music_metadata(
|
||||
@@ -102,3 +111,244 @@ def test_recording_identity_rejects_multi_track_directory_scrape() -> None:
|
||||
|
||||
assert success is False
|
||||
assert message == "单曲 MusicBrainz ID 仅支持刮削单个音频文件,整目录请选择专辑"
|
||||
|
||||
|
||||
def test_default_scraping_config_enables_missing_only_music_lyrics() -> None:
|
||||
"""新安装和未保存过该字段的用户应默认仅在缺失时下载歌词。"""
|
||||
assert ScrapingConfig.get_default_config()["music_lyrics"] == ScrapingPolicy.MISSINGONLY
|
||||
|
||||
|
||||
def test_album_track_match_uses_disc_track_title_and_duration() -> None:
|
||||
"""整张专辑刮削时应把本地音轨绑定到对应 Recording,不能复用专辑级身份。"""
|
||||
album = MusicAlbumInfo(
|
||||
source="musicbrainz",
|
||||
media_id="album-1",
|
||||
title="叶惠美",
|
||||
tracks=[
|
||||
MusicInfo(
|
||||
media_id="recording-1",
|
||||
title="以父之名",
|
||||
artists=["周杰伦"],
|
||||
disc_number=1,
|
||||
track_number=1,
|
||||
duration=342,
|
||||
),
|
||||
MusicInfo(
|
||||
media_id="recording-3",
|
||||
title="晴天",
|
||||
artists=["周杰伦"],
|
||||
disc_number=1,
|
||||
track_number=3,
|
||||
duration=269,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
matched = MediaChain._match_music_album_track(
|
||||
MetaMusic(
|
||||
title="03 - 晴天",
|
||||
artists=["周杰伦"],
|
||||
disc_number=1,
|
||||
track_number=3,
|
||||
duration=270,
|
||||
),
|
||||
album,
|
||||
)
|
||||
|
||||
assert matched is not None
|
||||
assert matched.media_id == "recording-3"
|
||||
assert matched.title == "晴天"
|
||||
|
||||
|
||||
def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None:
|
||||
"""标签和封面关闭时,歌词开关仍应独立驱动逐曲处理。"""
|
||||
chain = _media_chain()
|
||||
chain.storagechain = Mock()
|
||||
chain.scraping_policies = Mock()
|
||||
|
||||
def scraping_option(_target, metadata):
|
||||
"""仅开启歌词的缺失刮削策略。"""
|
||||
return SimpleNamespace(
|
||||
is_skip=metadata != "lyrics",
|
||||
is_overwrite=False,
|
||||
)
|
||||
|
||||
chain.scraping_policies.option.side_effect = scraping_option
|
||||
chain._scrape_music_file = Mock(
|
||||
return_value=_MusicScrapeFileResult(
|
||||
metadata_success=True,
|
||||
lyrics_status="saved",
|
||||
)
|
||||
)
|
||||
music_chain = Mock()
|
||||
|
||||
with patch("app.chain.music.MusicChain", return_value=music_chain):
|
||||
success, message = chain.scrape_music_metadata(
|
||||
FileItem(
|
||||
storage="local",
|
||||
path="/music/晴天.flac",
|
||||
type="file",
|
||||
name="晴天.flac",
|
||||
),
|
||||
mediainfo=MusicInfo(title="晴天", artists=["周杰伦"]),
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert message == "已刮削 1 个音频文件,歌词新增 1 首、已存在 0 首、未匹配 0 首"
|
||||
call = chain._scrape_music_file.call_args
|
||||
assert call.kwargs["write_tags"] is False
|
||||
assert call.kwargs["with_cover"] is False
|
||||
assert call.kwargs["music_chain"] is music_chain
|
||||
|
||||
|
||||
def test_write_music_lyrics_sidecar_creates_same_name_lrc(tmp_path) -> None:
|
||||
"""同步歌词应以 UTF-8 同名 LRC 文件写入音轨所在目录。"""
|
||||
chain = _media_chain()
|
||||
chain.storagechain = Mock()
|
||||
audio_path = tmp_path / "晴天.flac"
|
||||
audio_path.write_bytes(b"audio")
|
||||
|
||||
success = chain._write_music_lyrics_sidecar(
|
||||
fileitem=FileItem(
|
||||
storage="local",
|
||||
path=audio_path.as_posix(),
|
||||
type="file",
|
||||
name=audio_path.name,
|
||||
),
|
||||
local_path=audio_path,
|
||||
lyrics=MusicLyrics(
|
||||
provider="lrclib",
|
||||
provider_id="1",
|
||||
synced_lyrics="[00:01.00]故事的小黄花",
|
||||
),
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
lyric_path = audio_path.with_suffix(".lrc")
|
||||
assert success is True
|
||||
assert lyric_path.read_text(encoding="utf-8") == "[00:01.00]故事的小黄花\n"
|
||||
|
||||
|
||||
def test_missing_only_lyrics_skips_existing_sidecar(tmp_path) -> None:
|
||||
"""仅缺失策略发现同名歌词后不得再次请求歌词源。"""
|
||||
chain = _media_chain()
|
||||
existing = FileItem(storage="local", path=(tmp_path / "晴天.lrc").as_posix(), type="file")
|
||||
chain.storagechain = Mock()
|
||||
chain.storagechain.get_file_item.return_value = existing
|
||||
music_chain = Mock()
|
||||
lyrics_option = SimpleNamespace(is_skip=False)
|
||||
|
||||
status = chain._scrape_music_lyrics(
|
||||
fileitem=FileItem(storage="local", path=(tmp_path / "晴天.flac").as_posix(), type="file"),
|
||||
local_path=tmp_path / "晴天.flac",
|
||||
scrape_info=MetaMusic(title="晴天", artists=["周杰伦"]),
|
||||
lyrics_option=lyrics_option,
|
||||
overwrite=False,
|
||||
music_chain=music_chain,
|
||||
album_info=None,
|
||||
)
|
||||
|
||||
assert status == "existing"
|
||||
music_chain.lyrics.assert_not_called()
|
||||
|
||||
|
||||
def test_write_music_lyrics_sidecar_uploads_to_remote_audio_directory(tmp_path) -> None:
|
||||
"""远端存储歌词应使用音轨父目录和同名文件上传。"""
|
||||
chain = _media_chain()
|
||||
chain.storagechain = Mock()
|
||||
parent = FileItem(storage="u115", path="/Music/叶惠美", type="dir")
|
||||
chain.storagechain.get_parent_item.return_value = parent
|
||||
chain.storagechain.upload_file.return_value = FileItem(
|
||||
storage="u115",
|
||||
path="/Music/叶惠美/晴天.lrc",
|
||||
type="file",
|
||||
)
|
||||
local_path = tmp_path / "晴天.flac"
|
||||
local_path.write_bytes(b"audio")
|
||||
fileitem = FileItem(
|
||||
storage="u115",
|
||||
path="/Music/叶惠美/晴天.flac",
|
||||
type="file",
|
||||
name="晴天.flac",
|
||||
)
|
||||
|
||||
success = chain._write_music_lyrics_sidecar(
|
||||
fileitem=fileitem,
|
||||
local_path=local_path,
|
||||
lyrics=MusicLyrics(provider="lrclib", synced_lyrics="[00:01.00]晴天"),
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
assert success is True
|
||||
upload = chain.storagechain.upload_file.call_args
|
||||
assert upload.args[0] is parent
|
||||
assert upload.kwargs["new_name"] == "晴天.lrc"
|
||||
|
||||
|
||||
def test_music_scrape_event_preserves_independent_policy_overwrite() -> None:
|
||||
"""标签覆盖策略不得升级为全局覆盖并误覆盖缺失模式下的歌词。"""
|
||||
chain = _media_chain()
|
||||
chain.scrape_music_metadata = Mock(return_value=(True, "done"))
|
||||
fileitem = FileItem(storage="local", path="/music/叶惠美", type="dir")
|
||||
mediainfo = MusicInfo(
|
||||
source="musicbrainz",
|
||||
media_id="album-1",
|
||||
music_type=MUSIC_ENTITY_ALBUM,
|
||||
title="叶惠美",
|
||||
)
|
||||
|
||||
chain.scrape_metadata_event(
|
||||
Event(
|
||||
event_type=EventType.MetadataScrape,
|
||||
event_data={
|
||||
"fileitem": fileitem,
|
||||
"mediainfo": mediainfo,
|
||||
"overwrite": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
chain.scrape_music_metadata.assert_called_once_with(
|
||||
fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
|
||||
def test_music_download_failure_is_attributed_only_to_enabled_outputs() -> None:
|
||||
"""音频下载失败时只标记实际启用的标签、封面或歌词任务。"""
|
||||
chain = _media_chain()
|
||||
chain.storagechain = Mock()
|
||||
chain.storagechain.download_file.return_value = None
|
||||
fileitem = FileItem(
|
||||
storage="local",
|
||||
path="/music/晴天.flac",
|
||||
type="file",
|
||||
name="晴天.flac",
|
||||
)
|
||||
lyrics_option = SimpleNamespace(is_skip=False)
|
||||
|
||||
lyrics_only = chain._scrape_music_file(
|
||||
fileitem=fileitem,
|
||||
mediainfo=MusicInfo(title="晴天"),
|
||||
write_tags=False,
|
||||
tag_overwrite=False,
|
||||
with_cover=False,
|
||||
lyrics_option=lyrics_option,
|
||||
music_chain=Mock(),
|
||||
)
|
||||
metadata_only = chain._scrape_music_file(
|
||||
fileitem=fileitem,
|
||||
mediainfo=MusicInfo(title="晴天"),
|
||||
write_tags=True,
|
||||
tag_overwrite=False,
|
||||
with_cover=False,
|
||||
lyrics_option=SimpleNamespace(is_skip=True),
|
||||
music_chain=None,
|
||||
)
|
||||
|
||||
assert lyrics_only.metadata_success is True
|
||||
assert lyrics_only.lyrics_status == "failed"
|
||||
assert metadata_only.metadata_success is False
|
||||
assert metadata_only.lyrics_status == "disabled"
|
||||
|
||||
Reference in New Issue
Block a user