Files
MoviePilot/app/chain/_music.py
T

420 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import copy
from typing import Any, List, Optional, Tuple
from app.application.torrent import TorrentHelper
from app.application.subscription.contract import (
build_subscribe_meta,
subscribe_media_key,
)
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.search import SearchChain
from app.application.chain.data import SubscribePortProxy as SubscribeOper
from app.application.configuration import get_configured_system_config
from app.domain.context import Context, MediaInfo, MusicInfo
from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES
from app.domain.meta.metamusic import MetaMusic
from app.runtime.log import logger
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaType,
SystemConfigKey,
)
Subscribe = Any
def _normalize_music_total_tracks(value: Any) -> Optional[int]:
"""将专辑曲目总数归一为正整数,无效或未知值返回 None。"""
try:
total_tracks = int(value or 0)
except (TypeError, ValueError):
return None
return total_tracks if total_tracks > 0 else None
class MusicSubscribeMixin:
"""
音乐订阅功能域 mixin:单曲/专辑目标识别、实体快照同步、候选筛选、
择优下载与完成推进。
该域方法通过 self 复用 SubscribeChain 主体的 get_sub_sites / get_params /
filter_torrents / check_and_handle_existing_media / finish_subscribe_or_not /
get_subscribe_source_keyword 等编排能力,因此仅作为 mixin 混入 SubscribeChain
不独立成链。订阅元数据与媒体键由 Application 共享契约提供,避免 mixin 与
SubscribeChain 主体形成双向模块依赖。
"""
@staticmethod
def _validate_music_subscribe_target(
mediainfo: MediaInfo,
requested_music_type: Optional[str] = None,
) -> Optional[str]:
"""校验音乐订阅实体一致性,并确保专辑具备可验证的曲目总数。"""
if mediainfo.type != MediaType.MUSIC:
return "识别结果不是音乐"
music_type = getattr(mediainfo, "music_type", None)
if requested_music_type and requested_music_type not in MUSIC_SUBSCRIBABLE_TYPES:
return "音乐订阅仅支持单曲或专辑"
if music_type not in MUSIC_SUBSCRIBABLE_TYPES:
return "音乐订阅仅支持单曲或专辑"
if requested_music_type and requested_music_type != music_type:
return f"音乐订阅类型不匹配:请求 {requested_music_type},识别为 {music_type}"
if music_type == MUSIC_ENTITY_ALBUM \
and _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None)) is None:
return "专辑总曲目数未知,无法校验整张专辑资源"
return None
@staticmethod
def _ensure_music_subscribe_entity(
subscribe: Subscribe,
mediainfo: Optional[MusicInfo],
) -> Optional[MusicInfo]:
"""保持已持久化的单曲/专辑实体边界,拒绝远端详情把订阅类型改写。"""
if not mediainfo:
return None
expected_type = getattr(subscribe, "music_type", None)
actual_type = getattr(mediainfo, "music_type", None)
if expected_type and expected_type not in MUSIC_SUBSCRIBABLE_TYPES:
logger.warning(f"音乐订阅 {subscribe.name} 的实体类型无效:{expected_type}")
return None
if actual_type not in MUSIC_SUBSCRIBABLE_TYPES:
logger.warning(
f"音乐订阅 {subscribe.name} 识别为不可订阅实体:{actual_type}"
)
if expected_type in MUSIC_SUBSCRIBABLE_TYPES:
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
return None
if expected_type and actual_type != expected_type:
logger.warning(
f"音乐订阅 {subscribe.name} 实体不匹配:"
f"订阅为 {expected_type},远端识别为 {actual_type},使用订阅快照"
)
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
if actual_type == MUSIC_ENTITY_ALBUM:
remote_total = _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None))
stored_total = _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None))
resolved_total = remote_total or stored_total
if resolved_total is not None and mediainfo.total_tracks != resolved_total:
# 识别模块结果可能来自共享缓存,补齐订阅快照时不得原地修改。
mediainfo = copy.copy(mediainfo)
mediainfo.total_tracks = resolved_total
return mediainfo
@staticmethod
def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
if subscribe.media_source and subscribe.media_id:
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = MediaChain().recognize_media(
media_source=subscribe.media_source,
media_id=str(subscribe.media_id),
mtype=MediaType.MUSIC,
music_type=getattr(subscribe, "music_type", None),
)
if mediainfo:
return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo)
if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
# 旧订阅没有保存实体类型时不能猜测为单曲,否则可能误把专辑按单曲完成。
return None
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
# 缺少远端 ID 的专辑不能退化为单曲识别,使用已保存专辑快照更可靠。
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
# 旧订阅没有实体类型时只允许走 Recording 识别,不能从全局混合搜索中猜成专辑或艺术家。
mediainfo = MediaChain().recognize_media(
meta=build_subscribe_meta(subscribe),
mtype=MediaType.MUSIC,
media_source=subscribe.media_source,
music_type=MUSIC_ENTITY_RECORDING,
)
return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo)
@staticmethod
async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""异步按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
if subscribe.media_source and subscribe.media_id:
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = await MediaChain().async_recognize_media(
media_source=subscribe.media_source,
media_id=str(subscribe.media_id),
mtype=MediaType.MUSIC,
music_type=getattr(subscribe, "music_type", None),
)
if mediainfo:
return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo)
if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}:
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
return None
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
return MusicSubscribeMixin._music_info_from_subscribe(subscribe)
mediainfo = await MediaChain().async_recognize_media(
meta=build_subscribe_meta(subscribe),
mtype=MediaType.MUSIC,
media_source=subscribe.media_source,
music_type=MUSIC_ENTITY_RECORDING,
)
return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo)
@staticmethod
def _music_info_from_subscribe(subscribe: Subscribe) -> MusicInfo:
"""从订阅行恢复不依赖远端请求的最小音乐目标,保留专辑完成判断所需字段。"""
year_text = str(subscribe.year or "")[:4]
music_type = getattr(subscribe, "music_type", None)
# 音乐订阅的 description 由标准 MusicInfo.overview 生成,首段固定为艺术家。
artist_text = str(getattr(subscribe, "description", None) or "") \
.split(" · ", maxsplit=1)[0].strip()
artists = [
artist.strip() for artist in artist_text.split(" / ") if artist.strip()
]
return MusicInfo(
media_source=subscribe.media_source,
media_id=str(subscribe.media_id) if subscribe.media_id is not None else None,
music_type=music_type,
title=subscribe.name,
artists=artists,
album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None,
year=int(year_text) if year_text.isdigit() else None,
total_tracks=getattr(subscribe, "total_tracks", None)
if music_type == MUSIC_ENTITY_ALBUM else None,
cover_url=getattr(subscribe, "poster", None) or getattr(subscribe, "backdrop", None),
)
@staticmethod
def _sync_music_subscribe_target(subscribe: Subscribe, mediainfo: MusicInfo) -> None:
"""把远端识别得到的专辑类型和总曲目数同步到订阅,供搜索失败与完成历史复用。"""
update_data = {}
if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type:
update_data["music_type"] = mediainfo.music_type
if mediainfo.music_type == MUSIC_ENTITY_ALBUM:
# 远端详情可能暂时不返回曲目数;已确认的订阅快照不能因此被清空。
total_tracks = _normalize_music_total_tracks(mediainfo.total_tracks) \
or _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None))
else:
total_tracks = None
if getattr(subscribe, "total_tracks", None) != total_tracks:
update_data["total_tracks"] = total_tracks
if not update_data:
return
SubscribeOper().update(subscribe.id, update_data)
for key, value in update_data.items():
setattr(subscribe, key, value)
@staticmethod
def _is_music_download_complete(
subscribe: Subscribe,
mediainfo: MusicInfo,
downloads: Optional[List[Context]],
) -> bool:
"""判断音乐下载是否满足订阅完成条件;专辑必须由下载层确认整专曲目覆盖。"""
if not downloads:
return False
music_type = getattr(subscribe, "music_type", None) or mediainfo.music_type
if music_type != MUSIC_ENTITY_ALBUM:
return True
return any(context.confirmed_full_coverage for context in downloads)
def _prepare_music_subscribe(
self,
subscribe: Subscribe,
) -> Optional[Tuple[MusicInfo, MetaMusic]]:
"""识别音乐订阅目标、同步实体快照,并在搜索前处理已完整入库的目标。"""
mediainfo = self._recognize_music_subscribe(subscribe)
if not mediainfo:
logger.warning(
f"未识别到音乐订阅目标:{subscribe.name}"
f"媒体源:{subscribe.media_source},媒体ID{subscribe.media_id}"
)
return None
validation_error = self._validate_music_subscribe_target(
mediainfo,
getattr(subscribe, "music_type", None),
)
if validation_error:
logger.warning(f"音乐订阅 {subscribe.name} 无法继续:{validation_error}")
return None
self._sync_music_subscribe_target(subscribe, mediainfo)
meta = MetaMusic.from_music_info(mediainfo)
exists, _ = self.check_and_handle_existing_media(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=subscribe_media_key(subscribe),
)
if exists:
return None
return mediainfo, meta
def _filter_music_subscribe_contexts(
self,
subscribe: Subscribe,
mediainfo: MusicInfo,
contexts: List[Context],
) -> List[Context]:
"""按站点、音乐实体、订阅参数和优先级规则筛选并绑定下载上下文。"""
sites = self.get_sub_sites(subscribe)
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
rule_groups = subscribe.filter_groups or get_configured_system_config().get(default_rule_key) or []
torrent_helper = TorrentHelper()
matched: List[Context] = []
for source_context in contexts or []:
source_torrent = source_context.torrent_info
if not source_torrent or source_torrent.category not in (MediaType.MUSIC, MediaType.MUSIC.value):
continue
# 过滤模块会就地写入 pri_order;RSS 缓存会被多个订阅复用,必须隔离候选副本。
torrent = copy.copy(source_torrent)
if sites and torrent.site not in sites:
continue
if not SearchChain.matches_music_resource(
mediainfo,
torrent.title,
torrent.description,
):
continue
if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)):
continue
filtered = self.filter_torrents(
rule_groups=rule_groups,
torrent_list=[torrent],
mediainfo=mediainfo,
)
if filtered is not None:
if not filtered:
continue
torrent = filtered[0]
context = copy.copy(source_context)
context.torrent_info = torrent
meta = MetaMusic.from_music_info(mediainfo)
meta.org_string = torrent.title
meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True)
if subscribe.best_version:
# 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则
# 优先级时再回退到规范化音质分数,确保零配置也能自动升级。
music_priority = torrent.pri_order or meta.audio_quality_score
if music_priority <= (subscribe.current_priority or 0):
logger.info(
f"{torrent.title} 音质优先级 {music_priority} "
f"未高于当前版本 {subscribe.current_priority or 0}"
)
continue
torrent.pri_order = music_priority
context.meta_info = meta
context.media_info = mediainfo
context.match_source = str(mediainfo.media_source or "title")
context.candidate_recognized = False
context.media_info_is_target = True
if subscribe.media_category:
context.media_info.category = subscribe.media_category
matched.append(context)
return matched
def _download_music_subscribe(
self,
subscribe: Subscribe,
mediainfo: MusicInfo,
contexts: List[Context],
) -> None:
"""批量择优下载音乐候选,并按单曲或整专完成语义推进订阅。"""
if not contexts:
return
downloads, _ = DownloadChain().batch_download(
contexts=contexts,
username=subscribe.username,
save_path=subscribe.save_path,
downloader=subscribe.downloader,
source=self.get_subscribe_source_keyword(subscribe),
custom_words=subscribe.custom_words,
)
successful = [
context for context in downloads or []
if context and context.meta_info and context.torrent_info
]
quality_downloads = successful
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
quality_downloads = [
context for context in successful
if context.confirmed_full_coverage
]
if subscribe.best_version and quality_downloads:
best_context = max(quality_downloads, key=lambda item: item.torrent_info.pri_order)
best_meta = best_context.meta_info
quality_data = {
"current_priority": best_context.torrent_info.pri_order,
"current_audio_format": best_meta.audio_format,
"current_bitrate": best_meta.bitrate,
"current_bit_depth": best_meta.bit_depth,
"current_sample_rate": best_meta.sample_rate,
}
SubscribeOper().update(subscribe.id, quality_data)
for key, value in quality_data.items():
setattr(subscribe, key, value)
current_subscribe = SubscribeOper().get(subscribe.id)
if current_subscribe:
self.finish_subscribe_or_not(
subscribe=current_subscribe,
meta=MetaMusic.from_music_info(mediainfo),
mediainfo=mediainfo,
downloads=downloads,
)
def _search_music_subscribe(self, subscribe: Subscribe) -> None:
"""复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。"""
target = self._prepare_music_subscribe(subscribe)
if not target:
return
mediainfo, _ = target
sites = self.get_sub_sites(subscribe)
default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \
if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups
rule_groups = subscribe.filter_groups or get_configured_system_config().get(default_rule_key) or []
keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo)
if not keywords:
keywords = [subscribe.name]
searchchain = SearchChain()
contexts: List[Context] = []
for keyword in keywords:
contexts = searchchain.search_by_title(
title=keyword,
sites=sites,
mtype=MediaType.MUSIC,
rule_groups=rule_groups,
)
contexts = self._filter_music_subscribe_contexts(
subscribe=subscribe,
mediainfo=mediainfo,
contexts=contexts,
)
if contexts:
break
if not contexts:
logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源")
return
self._download_music_subscribe(subscribe, mediainfo, contexts)
def _match_music_subscribe(
self,
subscribe: Subscribe,
contexts: List[Context],
) -> None:
"""直接匹配本轮 RSS 缓存中的音乐资源,避免再次调用站点搜索接口。"""
target = self._prepare_music_subscribe(subscribe)
if not target:
return
mediainfo, _ = target
matched = self._filter_music_subscribe_contexts(
subscribe=subscribe,
mediainfo=mediainfo,
contexts=contexts,
)
if not matched:
logger.info(f"音乐订阅 {subscribe.name} 未匹配到符合条件的 RSS 资源")
return
self._download_music_subscribe(subscribe, mediainfo, matched)