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)