feat(music): scrape lyrics during library import

This commit is contained in:
jxxghp
2026-08-09 22:22:31 +08:00
parent d03307b687
commit aa4023e0a5
9 changed files with 1187 additions and 70 deletions

View File

@@ -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,

View File

@@ -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(

View File

@@ -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:
"""标准化音乐元数据信息。"""

View File

@@ -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:

View 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

View File

@@ -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 = "歌词"

View File

@@ -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
View 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

View File

@@ -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"