feat(music): 完善歌词与专辑目录整理

This commit is contained in:
jxxghp
2026-08-25 21:02:26 +08:00
parent f28e8b1538
commit 574c50586e
31 changed files with 1228 additions and 105 deletions
+95 -13
View File
@@ -1,5 +1,6 @@
import os
import re
import time
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
@@ -14,7 +15,7 @@ from app.application.configuration import (
get_configured_system_config,
)
from app.chain import ChainBase
from app.chain.lrclib import LrclibChain
from app.chain.lyrics import LyricsChain
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.domain.context import (
@@ -102,6 +103,11 @@ class ScrapingOption:
"""是否覆盖模式"""
return self.policy == ScrapingPolicy.OVERWRITE
@property
def is_upgrade(self) -> bool:
"""是否只在歌词等产物质量更高时替换。"""
return self.policy == ScrapingPolicy.UPGRADE
class ScrapingConfig:
"""媒体刮削配置"""
@@ -165,7 +171,9 @@ class ScrapingConfig:
]
for md in mds
]
return {item: ScrapingPolicy.MISSINGONLY for item in config_items}
defaults = {item: ScrapingPolicy.MISSINGONLY for item in config_items}
defaults["music_lyrics"] = ScrapingPolicy.UPGRADE
return defaults
class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
@@ -194,7 +202,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"landscape": ["thumb"],
}
MUSIC_LYRICS_EXTENSIONS = (".lrc", ".txt")
MUSIC_LYRICS_EXTENSIONS = (".lyricsfile.yaml", ".lrc", ".txt")
_music_track_prefix_pattern = re.compile(
r"^\s*(?:(?:cd|disc)\s*\d+\s*[-_. ]+)?(?:\d+\s*[-_. ]+)+",
flags=re.IGNORECASE,
@@ -1045,7 +1053,9 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
with_cover = not poster_option.is_skip
lyrics_chain = None
if not lyrics_option.is_skip:
lyrics_chain = LrclibChain()
lyrics_chain = LyricsChain(
deadline=time.monotonic() + max(self.runtime_config.lyrics_batch_timeout, 0)
)
cover_cache: dict[str, tuple[Optional[bytes], str]] = {}
album_cache: dict[tuple[str, str], Optional[MusicAlbumInfo]] = {}
@@ -1054,6 +1064,9 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"saved": 0,
"existing": 0,
"missing": 0,
"upgraded": 0,
"protected": 0,
"budget_exceeded": 0,
"failed": 0,
}
metadata_failure_label = (
@@ -1109,11 +1122,15 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if not lyrics_option.is_skip:
message += (
f",歌词新增 {lyrics_counts['saved']}"
f"、升级 {lyrics_counts['upgraded']}"
f"、已存在 {lyrics_counts['existing']}"
f"、防降级保护 {lyrics_counts['protected']}"
f"、未匹配 {lyrics_counts['missing']}"
)
if lyrics_counts["failed"]:
message += f"、失败 {lyrics_counts['failed']}"
if lyrics_counts["budget_exceeded"]:
message += f"、预算耗尽 {lyrics_counts['budget_exceeded']}"
if failures:
return False, f"{message}{''.join(failures[:3])}"
return True, message
@@ -1240,7 +1257,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
cover: Optional[tuple[Optional[bytes], str]] = None,
lyrics_option: Optional[ScrapingOption] = None,
lyrics_overwrite: bool = False,
lyrics_chain: Optional[LrclibChain] = None,
lyrics_chain: Optional[LyricsChain] = None,
album_info: Optional[MusicAlbumInfo] = None,
media_source: Optional[MediaSource] = None,
) -> _MusicScrapeFileResult:
@@ -1306,7 +1323,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
cover: Optional[tuple[Optional[bytes], str]],
lyrics_option: Optional[ScrapingOption],
lyrics_overwrite: bool,
lyrics_chain: Optional[LrclibChain],
lyrics_chain: Optional[LyricsChain],
album_info: Optional[MusicAlbumInfo],
media_source: Optional[MediaSource],
) -> _MusicScrapeFileResult:
@@ -1495,14 +1512,14 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
scrape_info: Optional[MetaMusic | MusicInfo],
lyrics_option: Optional[ScrapingOption],
overwrite: bool,
lyrics_chain: Optional[LrclibChain],
lyrics_chain: Optional[LyricsChain],
album_info: Optional[MusicAlbumInfo],
) -> str:
"""按歌词策略查询单个音轨并保存同名旁挂歌词文件。"""
if not lyrics_option or lyrics_option.is_skip or not lyrics_chain:
return "disabled"
existing = self._find_music_lyrics_sidecar(fileitem)
if existing and not overwrite:
if existing and not overwrite and not getattr(lyrics_option, "is_upgrade", False):
return "existing"
if not scrape_info:
return "missing"
@@ -1511,11 +1528,23 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if album_info:
local_meta = AudioMetadataHelper.read(local_path)
lookup_info = self._match_music_album_track(local_meta, album_info) or scrape_info
lyrics = lyrics_chain.get_music_lyrics(lookup_info)
embedded = AudioMetadataHelper.read_lyrics(local_path)
lyrics = lyrics_chain.get_music_lyrics(
lookup_info,
local_candidates=[embedded] if embedded else None,
)
if lyrics_chain.budget_exceeded and not lyrics:
return "budget_exceeded"
if not lyrics or lyrics.instrumental or not lyrics.content or not lyrics.extension:
return "missing"
existing_quality = self._music_lyrics_sidecar_quality(existing)
if existing_quality > lyrics.quality_rank:
return "protected"
if existing and existing_quality == lyrics.quality_rank and not overwrite:
return "existing"
status = "upgraded" if existing and lyrics.quality_rank > existing_quality else "saved"
return (
"saved"
status
if self._write_music_lyrics_sidecar(
fileitem=fileitem,
local_path=local_path,
@@ -1532,14 +1561,34 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""查找音轨旁已存在的同步或纯文本歌词文件。"""
audio_path = Path(fileitem.path)
for extension in self.MUSIC_LYRICS_EXTENSIONS:
target_path = self._music_lyrics_path(audio_path, extension)
item = self.storagechain.get_file_item(
storage=fileitem.storage,
path=audio_path.with_suffix(extension),
path=target_path,
)
if item:
return item
return None
@classmethod
def _music_lyrics_path(cls, audio_path: Path, extension: str) -> Path:
"""构造普通歌词和双扩展名 Lyricsfile 的同名旁挂路径。"""
return audio_path.with_suffix(extension)
@staticmethod
def _music_lyrics_sidecar_quality(fileitem: Optional[_SchemaFileItem]) -> int:
"""按旁挂扩展名估算质量,用于写入前执行防降级保护。"""
if not fileitem:
return 0
path = str(fileitem.path or "").casefold()
if path.endswith(".lyricsfile.yaml"):
return 4
if path.endswith(".lrc"):
return 3
if path.endswith(".txt"):
return 1
return 0
def _write_music_lyrics_sidecar(
self,
fileitem: _SchemaFileItem,
@@ -1581,6 +1630,13 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
):
return False
if lyrics.lyricsfile and not self._write_music_lyricsfile_sidecar(
fileitem=fileitem,
local_path=local_path,
content=lyrics.lyricsfile,
):
return False
if overwrite:
self._remove_alternate_music_lyrics(fileitem, keep_extension=extension)
return True
@@ -1591,6 +1647,31 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if temp_path and temp_path.exists() and temp_path != target_path:
self._cleanup_temp_file(temp_path)
def _write_music_lyricsfile_sidecar(
self,
fileitem: _SchemaFileItem,
local_path: Path,
content: str,
) -> bool:
"""保留来源返回的标准 Lyricsfile,同时由主写入流程生成播放器兼容歌词。"""
target_path = self._music_lyrics_path(Path(fileitem.path), ".lyricsfile.yaml")
try:
if fileitem.storage == "local":
target_path.write_text(f"{content.rstrip()}\n", encoding="utf-8")
return True
parent = self.storagechain.get_parent_item(fileitem)
if not parent:
return False
temp_path = local_path.with_suffix(".lyricsfile.yaml")
temp_path.write_text(f"{content.rstrip()}\n", encoding="utf-8")
try:
return bool(self.storagechain.upload_file(parent, temp_path, new_name=target_path.name))
finally:
self._cleanup_temp_file(temp_path)
except OSError as err:
logger.warning(f"保存 Lyricsfile 失败:{target_path} - {err}")
return False
def _remove_alternate_music_lyrics(
self,
fileitem: _SchemaFileItem,
@@ -1599,11 +1680,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""覆盖歌词格式后删除同音轨的旧扩展名文件,避免播放器优先读取过期内容。"""
audio_path = Path(fileitem.path)
for extension in self.MUSIC_LYRICS_EXTENSIONS:
if extension == keep_extension:
if extension in (keep_extension, ".lyricsfile.yaml"):
continue
target_path = self._music_lyrics_path(audio_path, extension)
item = self.storagechain.get_file_item(
storage=fileitem.storage,
path=audio_path.with_suffix(extension),
path=target_path,
)
if item and not self.storagechain.delete_file(item):
logger.warning(f"删除旧歌词文件失败:{item.path}")