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
+12
View File
@@ -141,6 +141,12 @@ class FileFilterMixin:
return False
return True if f".{fileitem.extension.lower()}" in self._audio_exts else False
@staticmethod
def _is_music_lyrics_file(fileitem: FileItem) -> bool:
"""判断文件是否为可随同名音乐音轨迁移的歌词旁挂文件。"""
path = str(fileitem.path or fileitem.name or "").casefold()
return path.endswith((".lrc", ".txt", ".lyricsfile.yaml"))
def _is_media_file(
self,
fileitem: FileItem,
@@ -1068,6 +1074,11 @@ class FileKeyMixin:
"""
if self._is_subtitle_file(extra_fileitem):
return self._get_subtitle_media_stem(extra_fileitem)
if self._is_music_lyrics_file(extra_fileitem):
file_name = extra_fileitem.name or Path(extra_fileitem.path).name
lowered = file_name.casefold()
suffix = ".lyricsfile.yaml" if lowered.endswith(".lyricsfile.yaml") else Path(file_name).suffix
return file_name[:-len(suffix)].casefold() if suffix else lowered
return self._get_file_stem(extra_fileitem)
def _get_related_main_file_key(
@@ -1081,6 +1092,7 @@ class FileKeyMixin:
if not (
self._is_subtitle_file(extra_fileitem)
or self._is_audio_file(extra_fileitem)
or self._is_music_lyrics_file(extra_fileitem)
):
return None
-29
View File
@@ -1,29 +0,0 @@
from typing import Optional, Union
from app.chain import ChainBase
from app.domain.context import MusicInfo, MusicLyrics
from app.domain.meta.metamusic import MetaMusic
class LrclibChain(ChainBase):
"""LRCLIB 音乐歌词来源链。"""
def get_music_lyrics(
self,
music: Union[MetaMusic, MusicInfo],
) -> Optional[MusicLyrics]:
"""按单曲元数据获取标准化歌词。"""
result = self.run_module("music_lyrics", music=music)
if isinstance(result, MusicLyrics):
return result
return MusicLyrics.from_dict(result) if isinstance(result, dict) else None
async def async_get_music_lyrics(
self,
music: Union[MetaMusic, MusicInfo],
) -> Optional[MusicLyrics]:
"""异步按单曲元数据获取标准化歌词。"""
result = await self.async_run_module("music_lyrics", music=music)
if isinstance(result, MusicLyrics):
return result
return MusicLyrics.from_dict(result) if isinstance(result, dict) else None
+128
View File
@@ -0,0 +1,128 @@
import time
from typing import Any, Optional, Union
from app.chain import ChainBase
from app.domain.context import MusicInfo, MusicLyrics
from app.domain.meta.metamusic import MetaMusic
class LyricsChain(ChainBase):
"""聚合本地、插件和宿主歌词候选,并按匹配度与内容质量择优。"""
def get_music_lyrics_candidates(
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
) -> list[MusicLyrics]:
"""获取所有标准化歌词候选,同时兼容旧版单结果插件接口。"""
candidates = list(local_candidates or [])
if self.deadline is not None and time.monotonic() >= self.deadline:
self.budget_exceeded = True
return self._deduplicate(candidates)
legacy = self.run_module("music_lyrics", music=music)
candidates.extend(self._normalize_results(legacy))
results = self.run_module("music_lyrics_candidates", music=music)
candidates.extend(self._normalize_results(results))
fallback = str(getattr(music, "lyrics", None) or "").strip()
if fallback:
candidates.append(MusicLyrics(
provider="theaudiodb",
plain_lyrics=fallback,
match_score=85,
provider_priority=10,
))
return self._deduplicate(candidates)
def get_music_lyrics(
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
) -> Optional[MusicLyrics]:
"""返回匹配可信且质量最高的歌词候选。"""
candidates = self.get_music_lyrics_candidates(music, local_candidates)
return self._select_best(candidates)
async def async_get_music_lyrics_candidates(
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
) -> list[MusicLyrics]:
"""异步聚合新旧模块候选,并复用同步路径的本地兜底规则。"""
candidates = list(local_candidates or [])
if self.deadline is not None and time.monotonic() >= self.deadline:
self.budget_exceeded = True
return self._deduplicate(candidates)
legacy = await self.async_run_module("music_lyrics", music=music)
candidates.extend(self._normalize_results(legacy))
results = await self.async_run_module("music_lyrics_candidates", music=music)
candidates.extend(self._normalize_results(results))
fallback = str(getattr(music, "lyrics", None) or "").strip()
if fallback:
candidates.append(MusicLyrics(
provider="theaudiodb",
plain_lyrics=fallback,
match_score=85,
provider_priority=10,
))
return self._deduplicate(candidates)
async def async_get_music_lyrics(
self,
music: Union[MetaMusic, MusicInfo],
local_candidates: Optional[list[MusicLyrics]] = None,
) -> Optional[MusicLyrics]:
"""异步返回质量最高的标准歌词候选。"""
candidates = await self.async_get_music_lyrics_candidates(music, local_candidates)
return self._select_best(candidates)
@staticmethod
def _select_best(candidates: list[MusicLyrics]) -> Optional[MusicLyrics]:
"""在可信候选中按内容质量、匹配度和来源优先级择优。"""
if not candidates:
return None
return max(
candidates,
key=lambda item: (
item.quality_rank,
item.match_score,
item.provider_priority,
),
)
@classmethod
def _normalize_results(cls, value: Any) -> list[MusicLyrics]:
"""把模块的单结果、列表或字典结果统一转换为候选列表。"""
values = value if isinstance(value, list) else [value]
normalized = []
for item in values:
if isinstance(item, MusicLyrics):
normalized.append(item)
elif isinstance(item, dict):
normalized.append(MusicLyrics.from_dict(item))
return normalized
@staticmethod
def _deduplicate(candidates: list[MusicLyrics]) -> list[MusicLyrics]:
"""按来源身份和内容去重,重复项保留质量与匹配度更高的一条。"""
selected: dict[tuple[str, str, str], MusicLyrics] = {}
for candidate in candidates:
if not candidate or not (candidate.content or candidate.instrumental):
continue
current = selected.get(candidate.identity_key)
if current is None or (
candidate.match_score,
candidate.quality_rank,
candidate.provider_priority,
) > (
current.match_score,
current.quality_rank,
current.provider_priority,
):
selected[candidate.identity_key] = candidate
return list(selected.values())
def __init__(self, deadline: Optional[float] = None) -> None:
"""保存批次查询截止时间,防止单张专辑长期占用刮削任务。"""
super().__init__()
self.deadline = deadline
self.budget_exceeded = False
+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}")
+36 -6
View File
@@ -155,8 +155,10 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
self._subtitle_exts = self.runtime_config.subtitle_extensions
# 音频文件后缀
self._audio_exts = self.runtime_config.audio_extensions
# 可处理的文件后缀(视频文件、字幕、音频文件)
self._allowed_exts = self._media_exts + self._audio_exts + self._subtitle_exts
# 可处理的文件后缀(视频文件、字幕、音频文件和音乐歌词
self._allowed_exts = self._media_exts + self._audio_exts + self._subtitle_exts + (
".lrc", ".txt", ".yaml",
)
# 待整理任务队列
self._queue = queue.Queue()
# 文件整理线程
@@ -1997,6 +1999,8 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
return False
matched_template = True
if batch_mtype == MediaType.MUSIC:
if self._is_music_lyrics_file(item):
return not self._is_blocked_by_exclude_words(item.path, exclude_words)
if not self._is_media_file(item, batch_mtype):
return False
if not self._is_allow_filesize(item, min_filesize):
@@ -2332,7 +2336,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
dir_key = self._get_file_parent_key(item)
if not is_bluray_dir and self._is_media_file(item, batch_mtype):
main_items_by_dir.setdefault(dir_key, []).append(item)
elif self._is_subtitle_file(item) or self._is_audio_file(item):
elif (
self._is_subtitle_file(item)
or self._is_audio_file(item)
or self._is_music_lyrics_file(item)
):
extra_items_by_dir.setdefault(dir_key, []).append((item, is_bluray_dir))
return main_items_by_dir, extra_items_by_dir
@@ -2358,7 +2366,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
if self._is_media_file(item, batch_mtype):
main_fileitems.append(item)
continue
if not (self._is_subtitle_file(item) or self._is_audio_file(item)):
if not (
self._is_subtitle_file(item)
or self._is_audio_file(item)
or self._is_music_lyrics_file(item)
):
continue
if not _is_allowed_transfer_item(item, False):
continue
@@ -2404,7 +2416,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
main_items = [(current_item, current_bluray_dir)]
main_items_by_dir[current_dir_key] = [current_item]
extra_items_by_dir[current_dir_key] = sibling_extra_items
elif self._is_subtitle_file(current_item) or self._is_audio_file(current_item):
elif (
self._is_subtitle_file(current_item)
or self._is_audio_file(current_item)
or self._is_music_lyrics_file(current_item)
):
related_main_file_key = self._get_related_main_file_key(
extra_fileitem=current_item,
main_fileitems=sibling_main_items,
@@ -2428,7 +2444,15 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
return list(items), inherited_map
if not main_items:
return list(items), inherited_map
remaining = [
item
for item in items
if not (
batch_mtype == MediaType.MUSIC
and self._is_music_lyrics_file(item[0])
)
]
return remaining, inherited_map
planned_items: List[Tuple[FileItem, bool]] = []
seen_file_keys: set[Tuple[str, str]] = set()
@@ -2516,6 +2540,12 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
inherited_map[self._get_file_key(extra_item)] = deepcopy(extra_meta)
for item, is_bluray_dir in items:
if (
batch_mtype == MediaType.MUSIC
and self._is_music_lyrics_file(item)
and self._get_file_key(item) not in inherited_map
):
continue
_append_item(planned_items, seen_file_keys, item, is_bluray_dir)
return planned_items, inherited_map