feat: unify media recognition and music lifecycle

This commit is contained in:
jxxghp
2026-08-12 06:51:58 +08:00
parent b018646f61
commit 97dba2a8ae
96 changed files with 4677 additions and 719 deletions

View File

@@ -3,8 +3,8 @@ from typing import Optional, List, Tuple, Union, Dict, Callable
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.context import MediaInfo, MusicInfo
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo
from app.helper.directory import DirectoryHelper
from app.helper.message import MessageHelper
@@ -14,8 +14,9 @@ from app.modules import _ModuleBase
from app.modules.filemanager.storages import StorageBase
from app.modules.filemanager.transhandler import TransHandler
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage
from app.schemas.types import MediaType, ModuleType, OtherModulesType
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, ModuleType, OtherModulesType
from app.utils.system import SystemUtils
from app.utils.string import StringUtils
class FileManagerModule(_ModuleBase):
@@ -549,7 +550,101 @@ class FileManagerModule(_ModuleBase):
source_oper=source_oper,
target_oper=target_oper)
def media_files(self, mediainfo: MediaInfo) -> List[FileItem]:
@staticmethod
def _build_library_lookup_meta(
mediainfo: Union[MediaInfo, MusicInfo],
) -> MetaBase:
"""构造标准媒体库路径反查使用的最小元数据。"""
if mediainfo.type == MediaType.MUSIC:
music_type = getattr(mediainfo, "music_type", None)
album = getattr(mediainfo, "album", None)
if not album and music_type == MUSIC_ENTITY_ALBUM:
album = mediainfo.title
return MetaMusic(
title=mediainfo.title,
artists=list(getattr(mediainfo, "artists", None) or []),
album=album,
album_artist=getattr(mediainfo, "album_artist", None),
year=mediainfo.year,
disc_number=getattr(mediainfo, "disc_number", None),
track_number=getattr(mediainfo, "track_number", None),
total_tracks=getattr(mediainfo, "total_tracks", None),
media_source=getattr(mediainfo, "source", None),
media_id=getattr(mediainfo, "media_id", None),
)
meta = MetaInfo(mediainfo.title)
if meta.type == MediaType.UNKNOWN and mediainfo.type is not None:
meta.type = mediainfo.type
if meta.year is None:
meta.year = mediainfo.year
if meta.begin_season is None:
meta.begin_season = 1
if meta.begin_episode is None:
meta.begin_episode = 1
return meta
@staticmethod
def _music_file_identity(fileitem: FileItem) -> Tuple[Optional[int], Optional[int], str]:
"""从标准音乐文件路径提取碟号、曲序和归一化曲名。"""
file_path = Path(fileitem.path or fileitem.name or "")
file_meta = MetaMusic(
org_string=file_path.name,
title=file_path.stem,
).apply_path_context(file_path)
return (
file_meta.disc_number,
file_meta.track_number,
StringUtils.clear_upper(file_meta.title or file_path.stem),
)
@classmethod
def _music_recording_exists(
cls,
fileitems: List[FileItem],
mediainfo: MusicInfo,
) -> bool:
"""按曲名和可用曲序判断单曲是否存在,避免专辑内任一文件造成误判。"""
target_title = StringUtils.clear_upper(mediainfo.title or "")
target_track = getattr(mediainfo, "track_number", None)
target_disc = getattr(mediainfo, "disc_number", None)
if not target_title:
return False
for fileitem in fileitems:
disc_number, track_number, title = cls._music_file_identity(fileitem)
if title != target_title:
continue
if target_track is not None and track_number not in (None, target_track):
continue
if target_disc is not None and disc_number not in (None, target_disc):
continue
return True
return False
@classmethod
def _music_album_is_complete(
cls,
fileitems: List[FileItem],
total_tracks: Optional[int],
) -> bool:
"""按去重后的曲序或曲名判断本地专辑是否达到目标曲目数。"""
if not fileitems:
return False
if not total_tracks:
return False
track_identities = {
(
disc_number or 1,
track_number if track_number is not None else title,
)
for disc_number, track_number, title in (
cls._music_file_identity(fileitem) for fileitem in fileitems
)
if track_number is not None or title
}
return len(track_identities) >= total_tracks
def media_files(self, mediainfo: Union[MediaInfo, MusicInfo]) -> List[FileItem]:
"""
获取对应媒体的媒体库文件列表
:param mediainfo: 媒体信息
@@ -569,15 +664,7 @@ class FileManagerModule(_ModuleBase):
# 重命名格式
rename_format = settings.RENAME_FORMAT(mediainfo.type)
# 元数据补上常用属性,尽可能确保重命名后的路径不出现空白
meta = MetaInfo(mediainfo.title)
if meta.type == MediaType.UNKNOWN and mediainfo.type is not None:
meta.type = mediainfo.type
if meta.year is None:
meta.year = mediainfo.year
if meta.begin_season is None:
meta.begin_season = 1
if meta.begin_episode is None:
meta.begin_episode = 1
meta = self._build_library_lookup_meta(mediainfo)
# 获取路径(重命名路径)
target_path = handler.get_rename_path(
path=dir_path,
@@ -587,7 +674,9 @@ class FileManagerModule(_ModuleBase):
)
# 获取重命名后的媒体文件根路径
media_path = DirectoryHelper.get_media_root_path(
rename_format, rename_path=target_path
rename_format,
rename_path=target_path,
media_type=mediainfo.type,
)
if not media_path:
# 忽略
@@ -606,13 +695,25 @@ class FileManagerModule(_ModuleBase):
logger.debug(f"获取媒体文件列表失败:{str(e)}")
continue
if media_files:
media_extensions = (
settings.RMT_AUDIOEXT
if mediainfo.type == MediaType.MUSIC
else settings.RMT_MEDIAEXT
)
for media_file in media_files:
if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT:
if (
media_file.extension
and f".{media_file.extension.lower()}" in media_extensions
):
if media_file not in ret_fileitems:
ret_fileitems.append(media_file)
return ret_fileitems
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
def media_exists(
self,
mediainfo: Union[MediaInfo, MusicInfo],
**kwargs,
) -> Optional[ExistMediaInfo]:
"""
判断媒体文件是否存在于文件系统(网盘或本地文件),只支持标准媒体库结构
:param mediainfo: 识别的媒体信息
@@ -637,7 +738,20 @@ class FileManagerModule(_ModuleBase):
# 电影存在任何文件为存在
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了")
return ExistMediaInfo(type=MediaType.MOVIE)
else:
if mediainfo.type == MediaType.MUSIC:
if getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM:
exists = self._music_album_is_complete(
fileitems,
getattr(mediainfo, "total_tracks", None),
)
else:
exists = self._music_recording_exists(fileitems, mediainfo)
if not exists:
logger.debug(f"{mediainfo.title_year} 在本地音乐库中尚不完整")
return None
logger.info(f"{mediainfo.title_year} 在本地音乐库中找到了")
return ExistMediaInfo(type=MediaType.MUSIC)
if mediainfo.type == MediaType.TV:
# 电视剧检索集数
seasons: Dict[int, list] = {}
for fileitem in fileitems:
@@ -653,3 +767,4 @@ class FileManagerModule(_ModuleBase):
# 返回剧集情况
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}")
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
return None

View File

@@ -5,10 +5,11 @@ from typing import Optional, List, Tuple
from jinja2 import Template
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.context import MediaInfo, MusicInfo
from app.core.event import eventmanager
from app.core.meta import MetaBase
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfoPath
from app.helper.audio import AudioMetadataHelper
from app.helper.directory import DirectoryHelper
from app.helper.message import TemplateHelper
from app.log import logger
@@ -128,11 +129,41 @@ class TransHandler:
size=size if item_type == "file" else None,
)
@staticmethod
def __music_quality_overwrite_decision(
meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
target_item: FileItem,
) -> Optional[bool]:
"""比较新旧音乐的实际音质,无法形成可靠结论时交回原覆盖策略。"""
if getattr(mediainfo, "type", None) != MediaType.MUSIC or not target_item:
return None
source_score = getattr(meta, "audio_quality_score", 0) or getattr(
mediainfo, "audio_quality_score", 0
)
target_path = Path(target_item.path) if target_item.path else None
if (
target_path
and (target_item.storage or "local") == "local"
and target_path.is_file()
):
target_music = AudioMetadataHelper.read(target_path)
else:
target_format = target_item.extension
if not target_format and target_path:
target_format = target_path.suffix.lstrip(".")
target_music = MetaMusic(audio_format=target_format)
target_score = target_music.audio_quality_score
if not source_score or not target_score or source_score == target_score:
return None
return source_score > target_score
def transfer_media(
self,
fileitem: FileItem,
in_meta: MetaBase,
mediainfo: MediaInfo,
mediainfo: MediaInfo | MusicInfo,
target_storage: str,
target_path: Path,
transfer_type: str,
@@ -234,7 +265,9 @@ class TransHandler:
)
else:
new_path = DirectoryHelper.get_media_root_path(
rename_format, rename_path=rendered_path
rename_format,
rename_path=rendered_path,
media_type=mediainfo.type,
)
if not new_path:
self.__update_result(
@@ -369,7 +402,9 @@ class TransHandler:
# 文件目录
folder_path = DirectoryHelper.get_media_root_path(
rename_format, rename_path=new_file
rename_format,
rename_path=new_file,
media_type=mediainfo.type,
)
if not folder_path:
self.__update_result(
@@ -516,27 +551,20 @@ class TransHandler:
# 总是覆盖同名文件
overflag = True
elif overwrite_mode == "size":
# 存在时大覆盖小
source_size = (
plugin_source_size
if plugin_source_size is not None
else fileitem.size
# 音乐先比较真实音质,无法判断时再沿用文件大小策略
music_overwrite = self.__music_quality_overwrite_decision(
meta=in_meta,
mediainfo=mediainfo,
target_item=target_item,
)
target_size = (
plugin_target_size
if plugin_target_size is not None
else target_item.size
)
if target_size < source_size:
logger.info(
f"目标文件文件大小更小,将覆盖:{new_file}"
)
if music_overwrite is True:
logger.info(f"目标音乐音质较低,将覆盖:{new_file}")
overflag = True
else:
elif music_overwrite is False:
self.__update_result(
result=result,
success=False,
message=f"媒体库存在同名文件,且质更好",
message="媒体库存在同名音乐文件,且目标音质更好",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
@@ -545,6 +573,35 @@ class TransHandler:
need_notify=need_notify,
)
return result
else:
source_size = (
plugin_source_size
if plugin_source_size is not None
else fileitem.size
)
target_size = (
plugin_target_size
if plugin_target_size is not None
else target_item.size
)
if target_size < source_size:
logger.info(
f"目标文件文件大小更小,将覆盖:{new_file}"
)
overflag = True
else:
self.__update_result(
result=result,
success=False,
message=f"媒体库存在同名文件,且质量更好",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify,
)
return result
elif overwrite_mode == "never":
# 存在不覆盖
self.__update_result(