mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 20:54:32 +08:00
420 lines
20 KiB
Python
420 lines
20 KiB
Python
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)
|