mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
refactor(chain): 处理链功能域 mixin 化,清理未使用导入并根治兼容层循环导入
- ChainBase 拆分为 RecognitionMixin/MessageProcessingMixin/NotificationMixin - TransferChain 拆分为 7 个功能 mixin(_mixins.py),SubscribeChain 音乐订阅域拆出 _music.py - 斜杠命令交互四件套收敛为 InteractionChainMixin 委托,会话管理器移至 application 层,chain 层不再 re-export - 模块基础类收敛到 app/modules/_base(notification/mediaserver 语义重命名) - 清理 app/chain/__init__.py 24 个未使用导入,修正 49 处测试 patch 目标到实际命名空间 - 兼容层 legacy 符号不再并入 __all__,根治 schemas 初始化反向拉起 application.transfer 的循环导入 - 修复 bangumi 集数为字符串时 set_bangumi_info 抛 TypeError - 新增重复代码等架构门禁测试;capability 清单校验排除下划线内部目录
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
import copy
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from app.application.torrent import TorrentHelper
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
不独立成链。build_subscribe_meta / _subscribe_media_key 等订阅通用辅助仍保留在
|
||||
subscribe.py,方法内延迟导入以避免 _music ↔ subscribe 的模块级循环。
|
||||
"""
|
||||
|
||||
@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]:
|
||||
"""按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
|
||||
# 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环
|
||||
from app.chain.subscribe import build_subscribe_meta
|
||||
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]:
|
||||
"""异步按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。"""
|
||||
# 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环
|
||||
from app.chain.subscribe import build_subscribe_meta
|
||||
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]]:
|
||||
"""识别音乐订阅目标、同步实体快照,并在搜索前处理已完整入库的目标。"""
|
||||
# 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环
|
||||
from app.chain.subscribe import _subscribe_media_key
|
||||
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 SystemConfigOper().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 SystemConfigOper().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)
|
||||
Reference in New Issue
Block a user