mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
feat(music): 完善歌词与专辑目录整理
This commit is contained in:
+95
-13
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user