diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index c00814309..2a2950900 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -271,6 +271,7 @@ FIELD_DESCRIPTIONS = { "code": "Two-factor verification code or site-specific authentication secret.", "command": "Complete slash command, including the leading slash and all arguments.", "completed_episode": "Highest episode number already completed for the subscription.", + "completed_tracks": "Number of distinct album tracks accepted for the subscription so far.", "current_site_id": "Configured site ID currently handling the subscription execution.", "cookie": "Site authentication cookie. Treat this value as a secret.", "configured_level": "Plugin instance's own configured log-level override, null when unset or expired.", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 6d581d8cd..0992f38bd 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -3833,6 +3833,18 @@ "description": "Highest episode number already completed for the subscription.", "title": "Completed Episode" }, + "completed_tracks": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Number of distinct album tracks accepted for the subscription so far.", + "title": "Completed Tracks" + }, "current_audio_format": { "anyOf": [ { diff --git a/app/agent/prompt/System Core Prompt.txt b/app/agent/prompt/System Core Prompt.txt index 3e7c62ff9..2636a4ceb 100644 --- a/app/agent/prompt/System Core Prompt.txt +++ b/app/agent/prompt/System Core Prompt.txt @@ -97,7 +97,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel 9. Music Entity Rule: A recording is one track, an album is a collection of tracks, and an artist is browse-only. Never subscribe, search torrents for, download, transfer, or library-check an artist entity. 10. Music Identity Rule: Reuse the exact `media_source`, `media_id`, and `music_type` returned by music search/detail. Do not substitute a same-name track, album, or artist, and do not send TV-only season/episode arguments for music. 11. Recording Rule: A recording subscription or download targets one track. Use the recording ID and organize/scrape one audio file unless the user explicitly selected a different recording. -12. Album Rule: An album subscription or download targets the complete album, similar to a TV season pack. Use the album ID, prefer one album resource/directory, require the resource file list to cover the expected `total_tracks`, and do not mark the subscription complete or report the album as present when only some tracks exist. +12. Album Rule: An album subscription or download targets the complete album, similar to a TV season pack. Use the album ID and prefer one album resource/directory. Partial resources may be downloaded and their distinct track facts are accumulated across subscription runs; only mark the subscription complete, or report the album as present, after the accumulated count reaches `total_tracks` and the local album is complete. 13. Music Library Rule: Before a music download or subscription, use `library.exists` with the exact recording/album identity when duplicate risk matters. For albums, a negative result can mean either absent or incomplete and should remain eligible for acquisition. 14. Music Organization Rule: Organize one recording as one audio file with `music_type=recording`. Organize a complete album by passing its album directory once with `media_type=music`, `music_type=album`, and the album source-native identity; do not repeatedly identify every track as an unrelated media item. 15. Music Scraping Rule: `media.scrape` with music type applies configured audio-tag, cover, and lyrics policies. For an album directory, pass the album identity when known; without one, allow per-file tag recognition. Report the actual lyrics saved/existing/missing counts returned by the API. diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 35fbbed54..dd83297a4 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -338,6 +338,15 @@ async def update_subscribe( subscribe_dict["type"] = subscribe.type subscribe_dict["music_type"] = subscribe.music_type subscribe_dict["total_tracks"] = subscribe.total_tracks if subscribe.music_type == MUSIC_ENTITY_ALBUM else None + if identity_fields and subscribe.music_type == MUSIC_ENTITY_ALBUM: + old_identity = resolve_media_identity(media=subscribe) + new_identity = resolve_media_identity( + media_source=subscribe_dict.get("media_source"), + media_id=subscribe_dict.get("media_id"), + ) + if old_identity != new_identity: + # 媒体身份变化后,旧专辑的音轨事实不能迁移到新专辑。 + subscribe_dict["downloaded_tracks"] = [] total_episode_updated = "total_episode" in subscribe_in.model_fields_set if ( total_episode_updated diff --git a/app/application/chain/events.py b/app/application/chain/events.py index 372eab9da..ea0a44683 100644 --- a/app/application/chain/events.py +++ b/app/application/chain/events.py @@ -299,6 +299,7 @@ def _restore_context(payload: dict[str, Any]) -> Context: media_payload = payload.get("media_info") torrent_payload = payload.get("torrent_info") allowed_episodes = payload.get("allowed_episodes") + music_track_keys = payload.get("music_track_keys") return Context( meta_info=( _restore_meta(meta_payload) if isinstance(meta_payload, dict) else None @@ -322,6 +323,10 @@ def _restore_context(payload: dict[str, Any]) -> Context: set(allowed_episodes) if allowed_episodes is not None else None ), confirmed_full_coverage=bool(payload.get("confirmed_full_coverage")), + music_track_keys=[ + item for item in music_track_keys + if isinstance(item, str) and item + ] if isinstance(music_track_keys, (list, tuple)) else None, ) diff --git a/app/application/messaging/subscribe.py b/app/application/messaging/subscribe.py index e79a09902..e21c2f09c 100644 --- a/app/application/messaging/subscribe.py +++ b/app/application/messaging/subscribe.py @@ -16,7 +16,8 @@ from app.application.subscription.contract import ( SubscriptionSnapshot, ) from app.schemas.message import Message -from app.schemas.types import MediaType, NotificationChannel +from app.schemas.subscribe import compute_subscribe_completed_tracks +from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, NotificationChannel subscribe_interaction_manager = SlashInteractionManager() @@ -544,6 +545,13 @@ class SubscribeInteractionHandler: """ if subscribe.type == MediaType.MOVIE.value: return "电影" + if ( + subscribe.type == MediaType.MUSIC.value + and subscribe.music_type == MUSIC_ENTITY_ALBUM + and subscribe.total_tracks + ): + completed_tracks = compute_subscribe_completed_tracks(subscribe) or 0 + return f"专辑 [{completed_tracks}/{subscribe.total_tracks}]" season = subscribe.season if subscribe.season is not None else 1 if subscribe.total_episode: lack_episode = ( diff --git a/app/application/subscription/contract.py b/app/application/subscription/contract.py index d59810263..c56301629 100644 --- a/app/application/subscription/contract.py +++ b/app/application/subscription/contract.py @@ -27,6 +27,7 @@ _SUBSCRIPTION_FIELDS = frozenset( "media_id", "music_type", "total_tracks", + "downloaded_tracks", "season", "poster", "backdrop", @@ -83,6 +84,7 @@ _CLASSIFICATION_HISTORY_FIELDS = frozenset( _SUBSCRIPTION_HISTORY_FIELDS = (_SUBSCRIPTION_FIELDS - { "lack_episode", "note", + "downloaded_tracks", "state", "last_update", "last_search", @@ -161,6 +163,8 @@ class SubscriptionSnapshot: media_id: Optional[str] = None music_type: Optional[str] = None total_tracks: Optional[int] = None + # 专辑已接受资源中可识别音轨的稳定键;只存在于活动订阅,完成后不写入历史。 + downloaded_tracks: Optional[builtins.list[str]] = None season: Optional[int] = None poster: Optional[str] = None backdrop: Optional[str] = None @@ -207,7 +211,7 @@ class SubscriptionSnapshot: def __post_init__(self) -> None: """冻结订阅中的全部 JSON 列。""" - for name in ("note", "sites", "episode_priority", "filter_groups"): + for name in ("note", "downloaded_tracks", "sites", "episode_priority", "filter_groups"): object.__setattr__(self, name, _freeze_json(getattr(self, name))) def to_dict(self) -> dict[str, JsonData]: diff --git a/app/application/subscription/mutation.py b/app/application/subscription/mutation.py index 83adc9724..9348b5eeb 100644 --- a/app/application/subscription/mutation.py +++ b/app/application/subscription/mutation.py @@ -31,6 +31,7 @@ from app.application.subscription.contract import ( from app.application.subscription.delete import AsyncUnitOfWork from app.schemas.common import JsonData from app.schemas.event import SubscribeModifiedEventData +from app.schemas.types import MUSIC_ENTITY_ALBUM SubscribeModifiedPublisher = Callable[[dict[str, JsonData]], Awaitable[None]] SyncSubscribeModifiedPublisher = Callable[[dict[str, JsonData]], None] @@ -93,6 +94,7 @@ def _reset_payload(subscribe: SubscriptionSnapshot) -> dict[str, JsonData]: """构造同步与异步重置共享的订阅字段补丁。""" return { "note": [], + "downloaded_tracks": [] if subscribe.music_type == MUSIC_ENTITY_ALBUM else None, "lack_episode": subscribe.total_episode, "current_priority": None, "current_audio_format": None, diff --git a/app/application/subscription/query.py b/app/application/subscription/query.py index 9442ab028..42c31353c 100644 --- a/app/application/subscription/query.py +++ b/app/application/subscription/query.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from datetime import datetime, timedelta -from typing import Optional +from typing import Any, Optional from app.application.subscription.contract import ( SubscriptionHistoryQueryPort, @@ -16,6 +16,7 @@ from app.domain.context import MediaInfo from app.domain.meta.metabase import MetaBase from app.schemas.common import JsonData from app.schemas.media import resolve_media_identity +from app.schemas.subscribe import compute_subscribe_completed_tracks from app.schemas.types import MediaSource, MediaType from app.schemas.workflow import Subscribe as SubscribeView @@ -51,6 +52,14 @@ class SubscriptionQueryService: self._async_repository = async_repository self._history_repository = history_repository + @staticmethod + def _to_public_view(record: Any) -> SubscribeView: + """把活动订阅内部快照投影为公开 DTO,并派生专辑曲目进度。""" + payload = record.to_dict() + payload["completed_tracks"] = compute_subscribe_completed_tracks(record) + payload.pop("downloaded_tracks", None) + return SubscribeView.model_validate(payload) + async def list_public( self, username: Optional[str] = None, @@ -79,7 +88,7 @@ class SubscriptionQueryService: page=page, count=count, ) - return [SubscribeView.model_validate(record) for record in records] + return [self._to_public_view(record) for record in records] async def count_public(self, username: Optional[str] = None) -> int: """按 owner 范围返回公开订阅精确总数。""" @@ -92,7 +101,7 @@ class SubscriptionQueryService: if self._async_repository is None: raise RuntimeError("异步订阅查询端口未注册") record = await self._async_repository.async_get(subscribe_id) - return SubscribeView.model_validate(record) if record else None + return self._to_public_view(record) if record else None async def list_by_media_identity( self, @@ -109,7 +118,9 @@ class SubscriptionQueryService: music_type=music_type, ) return [ - SubscribeView.model_validate(record) for record in records if self._matches_music_type(record, music_type) + self._to_public_view(record) + for record in records + if self._matches_music_type(record, music_type) ] async def list_history( @@ -138,7 +149,7 @@ class SubscriptionQueryService: ) result = [] for record in records: - item = SubscribeView.model_validate(record) + item = self._to_public_view(record) if item.type == MediaType.TV.value: item.total_episode = 0 item.lack_episode = 0 diff --git a/app/application/subscription/write.py b/app/application/subscription/write.py index 029f693ad..6873221ff 100644 --- a/app/application/subscription/write.py +++ b/app/application/subscription/write.py @@ -404,6 +404,8 @@ def _translate( "vote": mediainfo.vote_average, "description": mediainfo.overview, "music_type": music_type, + # 专辑进度从空集合开始累计;单曲不使用专辑曲目事实。 + "downloaded_tracks": [] if music_type == MUSIC_ENTITY_ALBUM else None, # 整专完成判定拿 total_tracks 当分母,单曲带着专辑的曲目数会永远判不到完成 "total_tracks": getattr(mediainfo, "total_tracks", None) if music_type == MUSIC_ENTITY_ALBUM else None, } diff --git a/app/chain/_music.py b/app/chain/_music.py index da64a93b2..e3ac64f00 100644 --- a/app/chain/_music.py +++ b/app/chain/_music.py @@ -137,7 +137,7 @@ class MusicSubscribeMixin: mediainfo: MediaInfo, requested_music_type: Optional[str] = None, ) -> Optional[str]: - """校验音乐订阅实体一致性,并确保专辑具备可验证的曲目总数。""" + """校验音乐订阅实体一致性,并确保专辑具备可累计的曲目总数。""" if mediainfo.type != MediaType.MUSIC: return "识别结果不是音乐" music_type = getattr(mediainfo, "music_type", None) @@ -149,7 +149,7 @@ class MusicSubscribeMixin: 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 "专辑总曲目数未知,无法累计专辑下载进度" return None @staticmethod @@ -328,13 +328,49 @@ class MusicSubscribeMixin: 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) + return bool(downloads) + total_tracks = _normalize_music_total_tracks( + getattr(subscribe, "total_tracks", None) or mediainfo.total_tracks + ) + if total_tracks is None: + return False + downloaded_tracks = { + item + for item in (getattr(subscribe, "downloaded_tracks", None) or []) + if isinstance(item, str) and item + } + return len(downloaded_tracks) >= total_tracks + + @staticmethod + def _music_downloads_complete_after_merge( + subscribe: SubscriptionSnapshot, + mediainfo: MusicInfo, + downloads: Optional[List[Context]], + ) -> bool: + """判断本轮下载事实并入订阅后是否达到整专曲目总数。""" + music_type = getattr(subscribe, "music_type", None) or mediainfo.music_type + if music_type != MUSIC_ENTITY_ALBUM: + return bool(downloads) + total_tracks = _normalize_music_total_tracks( + getattr(subscribe, "total_tracks", None) or mediainfo.total_tracks + ) + if total_tracks is None: + return False + downloaded_tracks = { + item + for item in (getattr(subscribe, "downloaded_tracks", None) or []) + if isinstance(item, str) and item + } + for context in downloads or []: + downloaded_tracks.update( + item + for item in (getattr(context, "music_track_keys", None) or []) + if isinstance(item, str) and item + ) + return len(downloaded_tracks) >= total_tracks def _prepare_music_subscribe( self, @@ -485,10 +521,11 @@ class MusicSubscribeMixin: ] quality_downloads = successful if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: - quality_downloads = [ - context for context in successful - if context.confirmed_full_coverage - ] + quality_downloads = successful if self._music_downloads_complete_after_merge( + subscribe, + mediainfo, + successful, + ) else [] current_subscribe = None if subscribe.best_version and quality_downloads: best_context = max(quality_downloads, key=lambda item: item.torrent_info.pri_order) diff --git a/app/chain/download/contract.py b/app/chain/download/contract.py index c0c3b99a4..6d2d3b80e 100644 --- a/app/chain/download/contract.py +++ b/app/chain/download/contract.py @@ -26,11 +26,11 @@ if TYPE_CHECKING: _matches_media_identity: Callable[..., bool] _prepare_batch_download_contexts: Callable[..., Any] _record_download_failure: Callable[..., Any] + _record_music_album_track_keys: Callable[..., Any] _resolve_media_download_dir: Callable[..., Any] _settle_download_success: Callable[..., None] _submit_download_added_task: Callable[..., None] _subscription_download_cancelled: Callable[..., bool] - _validate_music_album_resource: Callable[..., Any] batch_download: Callable[..., Any] download_single: Callable[..., Any] download_torrent: Callable[..., Any] diff --git a/app/chain/download/selection.py b/app/chain/download/selection.py index 06eab83c2..549849125 100644 --- a/app/chain/download/selection.py +++ b/app/chain/download/selection.py @@ -1,5 +1,6 @@ """下载候选规范化、排序和媒体选择 owner。""" +import json from pathlib import Path from typing import Callable, Dict, List, Optional, Set, Tuple, Union, cast @@ -46,46 +47,36 @@ class DownloadSelectionOwner(_DownloadOwnerBase): """下载候选规范化、排序和媒体选择 owner。""" @classmethod - def _validate_music_album_resource( + def _record_music_album_track_keys( cls, context: Context, file_list: Optional[List[str]], - ) -> Optional[str]: - """校验专辑种子是否包含预期数量的独立音轨,并标记已确认的整专覆盖。""" + ) -> None: + """记录种子中可识别的音轨键,允许专辑订阅跨多次资源下载累计完成。""" media = context.media_info if ( not media or media.type != MediaType.MUSIC or getattr(media, "music_type", None) != MUSIC_ENTITY_ALBUM ): - return None + return - context.confirmed_full_coverage = False - try: - expected_tracks = int(getattr(media, "total_tracks", None) or 0) - except (TypeError, ValueError): - expected_tracks = 0 - if expected_tracks <= 0: - return "专辑资源无法校验:专辑总曲目数未知" - if not file_list: - return "专辑资源无法校验:种子未提供文件清单,不能确认整专曲目" - - track_identities = { - identity - for file in file_list - if Path(str(file)).suffix.lower() - in get_chain_runtime_config_snapshot().audio_extensions - and (identity := cls._music_resource_track_identity(file)) + audio_extensions = get_chain_runtime_config_snapshot().audio_extensions + track_keys = { + track_key + for file in file_list or [] + if Path(str(file)).suffix.lower() in audio_extensions + and (track_key := cls._music_resource_track_key(file)) } - actual_tracks = len(track_identities) - if actual_tracks < expected_tracks: - return ( - f"专辑资源不完整:专辑共 {expected_tracks} 首," - f"种子仅包含 {actual_tracks} 个独立音频文件" - ) + context.music_track_keys = sorted(track_keys) or None - context.confirmed_full_coverage = True - return None + @staticmethod + def _music_resource_track_key(file: str) -> Optional[str]: + """把音轨身份编码成跨下载资源稳定保存的 JSON 键。""" + identity = DownloadSelectionOwner._music_resource_track_identity(file) + if identity is None: + return None + return json.dumps(identity, ensure_ascii=False, separators=(",", ":")) @staticmethod def _music_resource_track_identity(file: str) -> Optional[Tuple[int, Union[int, str]]]: diff --git a/app/chain/download/submission.py b/app/chain/download/submission.py index 9ff756998..e12c484cf 100644 --- a/app/chain/download/submission.py +++ b/app/chain/download/submission.py @@ -429,17 +429,7 @@ class DownloadSubmissionOwner(_DownloadResourceOwner): folder_name, file_list = cast(Any, TorrentHelper)().get_fileinfo_from_torrent_content( torrent_content ) - album_error = self._validate_music_album_resource(context, file_list) - if album_error: - logger.info(f"{torrent.title} {album_error},跳过该资源") - self._record_download_failure( - context=context, - error_msg=album_error, - downloader=downloader or site_downloader, - source=source, - episodes=episodes, - ) - return None, album_error + self._record_music_album_track_keys(context, file_list) storage, download_dir, error_msg = self._resolve_media_download_dir( media_info=media, save_path=save_path, diff --git a/app/chain/subscribe/completion.py b/app/chain/subscribe/completion.py index cca04b55b..744aa8cec 100644 --- a/app/chain/subscribe/completion.py +++ b/app/chain/subscribe/completion.py @@ -128,8 +128,16 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): downloads=downloads, ) subscribe = facts["subscribe"] + elif downloads and meta.type == MediaType.MUSIC: + subscribe = self._SubscribeChain__record_music_download_facts( + subscribe=subscribe, + downloads=downloads, + ) elif downloads: - self._SubscribeChain__update_subscribe_note(subscribe=subscribe, downloads=downloads) + subscribe = self._SubscribeChain__update_subscribe_note( + subscribe=subscribe, + downloads=downloads, + ) if downloads and meta.type == MediaType.MOVIE: subscribe = self._SubscribeChain__update_movie_download_priority( subscribe=subscribe, @@ -197,13 +205,13 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): self, subscribe: SubscriptionSnapshot, downloads: Optional[List[Context]], - ) -> None: + ) -> SubscriptionSnapshot: """ 更新已下载信息到note字段 """ # 查询现有Note if not downloads: - return + return subscribe note = [] if subscribe.note: note = subscribe.note or [] @@ -227,8 +235,8 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): # 电影只有一个条目,设置为 [1] items = [1] elif mediainfo.type == MediaType.MUSIC: - # 专辑只能记录已由下载层确认的整专资源;单曲任一成功任务即可完成。 - if getattr(subscribe, "music_type", None) != MUSIC_ENTITY_ALBUM or context.confirmed_full_coverage: + # 单曲任一成功任务即可完成;专辑由独立音轨事实记录,不写入 note。 + if getattr(subscribe, "music_type", None) != MUSIC_ENTITY_ALBUM: items = [1] if not items: continue @@ -236,11 +244,61 @@ class SubscribeCompletionOwner(_SubscribeOwnerBase): note = list(set(note).union(set(items))) # 更新订阅 if note: - self._SubscribeChain__apply_subscribe_update( + updated = self._SubscribeChain__apply_subscribe_update( subscribe, {"note": note}, scene="download_note", ) + return updated or subscribe + return subscribe + + def _SubscribeChain__record_music_download_facts( + self, + subscribe: SubscriptionSnapshot, + downloads: Optional[List[Context]], + ) -> SubscriptionSnapshot: + """合并本轮专辑音轨事实,按统一媒体身份去重后返回最新订阅快照。""" + if not downloads: + return subscribe + if getattr(subscribe, "music_type", None) != MUSIC_ENTITY_ALBUM: + return self._SubscribeChain__update_subscribe_note( + subscribe=subscribe, + downloads=downloads, + ) + + downloaded_tracks = { + item + for item in (getattr(subscribe, "downloaded_tracks", None) or []) + if isinstance(item, str) and item + } + subscribe_identity = resolve_media_identity(media=subscribe) + for context in downloads: + media = context.media_info + if not media or media.type != MediaType.MUSIC: + continue + if subscribe_identity != resolve_media_identity(media=media): + continue + downloaded_tracks.update( + item + for item in (getattr(context, "music_track_keys", None) or []) + if isinstance(item, str) and item + ) + if not downloaded_tracks: + return subscribe + + updated = self._SubscribeChain__apply_subscribe_update( + subscribe, + { + "downloaded_tracks": sorted(downloaded_tracks), + "last_update": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }, + scene="music_download_tracks", + ) + logger.info( + f"订阅 {subscribe.name} 记录已下载专辑曲目:" + f"{len(downloaded_tracks)}/{subscribe.total_tracks or 0}" + ) + return updated or subscribe @staticmethod def _SubscribeChain__get_downloaded(subscribe: SubscriptionSnapshot) -> List[int]: diff --git a/app/chain/subscribe/query.py b/app/chain/subscribe/query.py index ec4c38a7a..31345aeee 100644 --- a/app/chain/subscribe/query.py +++ b/app/chain/subscribe/query.py @@ -30,6 +30,7 @@ from app.schemas.subscribe import Subscribe as _SchemaSubscribe from app.schemas.subscribe import SubscribeDownloadFileInfo as _SchemaSubscribeDownloadFileInfo from app.schemas.subscribe import SubscribeEpisodeInfo as _SchemaSubscribeEpisodeInfo from app.schemas.subscribe import SubscribeLibraryFileInfo as _SchemaSubscribeLibraryFileInfo +from app.schemas.subscribe import compute_subscribe_completed_tracks from app.schemas.types import ( MediaSource, MediaType, @@ -400,7 +401,10 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): self._append_subscribe_media_servers(subscribe, mediainfo, episodes) # 更新订阅信息 - subscribe_info.subscribe = _SchemaSubscribe(**subscribe.to_dict()) + subscribe_payload = subscribe.to_dict() + subscribe_payload["completed_tracks"] = compute_subscribe_completed_tracks(subscribe) + subscribe_payload.pop("downloaded_tracks", None) + subscribe_info.subscribe = _SchemaSubscribe(**subscribe_payload) subscribe_info.episodes = episodes return subscribe_info diff --git a/app/db/adapters/subscription.py b/app/db/adapters/subscription.py index 214b53f96..f01a5c4a4 100644 --- a/app/db/adapters/subscription.py +++ b/app/db/adapters/subscription.py @@ -82,6 +82,7 @@ def _project_subscription(record: Subscribe) -> SubscriptionSnapshot: media_id=record.media_id, music_type=record.music_type, total_tracks=record.total_tracks, + downloaded_tracks=cast(Optional[builtins.list[str]], record.downloaded_tracks), season=record.season, poster=record.poster, backdrop=record.backdrop, diff --git a/app/db/models/subscribe.py b/app/db/models/subscribe.py index 27da6026d..e2183fa5d 100644 --- a/app/db/models/subscribe.py +++ b/app/db/models/subscribe.py @@ -30,8 +30,10 @@ class Subscribe(Base): media_id: Mapped[Optional[str]] = mapped_column(String, index=True) # 音乐实体类型:recording 单曲、album 专辑 music_type: Mapped[Optional[str]] = mapped_column(String) - # 专辑预期总曲目数,供整专资源完整性判断 + # 专辑预期总曲目数,作为分批下载完成判定的分母 total_tracks: Mapped[Optional[int]] = mapped_column(Integer) + # 已接受资源中可识别音轨的稳定键集合,供专辑订阅跨轮次累计进度 + downloaded_tracks: Mapped[Optional[Any]] = mapped_column(JSON) # 季号 season: Mapped[Optional[int]] = mapped_column(Integer) # 海报 diff --git a/app/domain/context.py b/app/domain/context.py index 1ff234f3b..9544aa54a 100644 --- a/app/domain/context.py +++ b/app/domain/context.py @@ -1949,6 +1949,8 @@ class Context: selected_episodes: Optional[List[int]] = None # 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源。 confirmed_full_coverage: bool = False + # 下载层从种子文件清单提取的稳定音轨键,供专辑订阅跨轮次累计下载进度。 + music_track_keys: Optional[List[str]] = None def to_dict(self) -> dict[str, Any]: """ @@ -1969,4 +1971,5 @@ class Context: "allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else None, "selected_episodes": self.selected_episodes, "confirmed_full_coverage": self.confirmed_full_coverage, + "music_track_keys": self.music_track_keys, } diff --git a/app/schemas/context.py b/app/schemas/context.py index 66e87f771..efccb9056 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -559,6 +559,8 @@ class Context(BaseModel): match_reason: Optional[str] = None # 下载层确认候选资源覆盖完整目标范围,供订阅事实写入判断整包资源 confirmed_full_coverage: Optional[bool] = False + # 下载层从种子文件清单提取的稳定音轨键,供专辑订阅跨轮次累计下载进度 + music_track_keys: Optional[List[str]] = None class MediaPerson(BaseModel): diff --git a/app/schemas/exports.py b/app/schemas/exports.py index db66889d8..a81b124a8 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -568,6 +568,7 @@ SCHEMA_EXPORTS = { 'WorkflowRuntimeState': ('app.schemas.workflow', 'WorkflowRuntimeState'), 'WorkflowShare': ('app.schemas.workflow', 'WorkflowShare'), 'compute_subscribe_completed_episode': ('app.schemas.subscribe', 'compute_subscribe_completed_episode'), + 'compute_subscribe_completed_tracks': ('app.schemas.subscribe', 'compute_subscribe_completed_tracks'), 'dataclass': ('app.schemas.system', 'dataclass'), 'datetime': ('app.schemas.monitoring', 'datetime'), 'field_serializer': ('app.schemas.agent', 'field_serializer'), diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index c04b8e4f6..d1269dae0 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -4,6 +4,7 @@ from typing import Any, ClassVar, Dict, List, Literal, Optional from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.schemas.media import OptionalMediaIdentityMixin +from app.schemas.types import MUSIC_ENTITY_ALBUM as _MUSIC_ENTITY_ALBUM from app.schemas.types import MediaSource, MediaType @@ -45,6 +46,36 @@ def compute_subscribe_completed_episode(subscribe: "Subscribe") -> Optional[int] return min(max(start_episode - 1, 0), total_episode) + priority_completed +def compute_subscribe_completed_tracks(subscribe: Any) -> Optional[int]: + """计算专辑订阅已累计曲目数,仅用于响应派生,不把内部音轨键暴露给客户端。""" + def value(name: str) -> Any: + """从快照、ORM 行或字典读取订阅字段。""" + if isinstance(subscribe, dict): + return subscribe.get(name) + return getattr(subscribe, name, None) + + if isinstance(subscribe, dict): + has_track_facts = "downloaded_tracks" in subscribe + else: + has_track_facts = hasattr(subscribe, "downloaded_tracks") + if not has_track_facts: + return None + + if value("type") != MediaType.MUSIC.value or value("music_type") != _MUSIC_ENTITY_ALBUM: + return None + try: + total_tracks = int(value("total_tracks") or 0) + except (TypeError, ValueError): + return None + if total_tracks <= 0: + return None + downloaded_tracks = value("downloaded_tracks") or [] + if not isinstance(downloaded_tracks, (list, tuple, set, frozenset)): + return 0 + completed_tracks = len({item for item in downloaded_tracks if isinstance(item, str) and item}) + return min(completed_tracks, total_tracks) + + class SubscriptionExecutionStatus(BaseModel): # type: ignore[misc] """订阅列表可见的当前业务执行状态。""" @@ -107,7 +138,7 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel): # 公共创建和更新接口不得接收系统字段和运行事实;其余字段默认作为订阅输入透传。 PUBLIC_WRITE_EXCLUDED_FIELDS: ClassVar[frozenset[str]] = frozenset({ - "id", "poster", "backdrop", "vote", "description", "lack_episode", "completed_episode", + "id", "poster", "backdrop", "vote", "description", "lack_episode", "completed_episode", "completed_tracks", "note", "state", "last_update", "username", "current_priority", "episode_priority", "date", "current_audio_format", "current_bitrate", "current_bit_depth", "current_sample_rate", "classification_rule_id", "classification_policy_revision", "classification_source", @@ -133,6 +164,8 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel): music_type: Optional[str] = None # 专辑预期总曲目数 total_tracks: Optional[int] = None + # 已累计下载曲目数,仅由后端根据专辑音轨事实派生 + completed_tracks: Optional[int] = None # 季号 season: Optional[int] = None # 海报 diff --git a/database/versions/c8e4a1f7b2d6_3_0_39.py b/database/versions/c8e4a1f7b2d6_3_0_39.py new file mode 100644 index 000000000..ff922d2cf --- /dev/null +++ b/database/versions/c8e4a1f7b2d6_3_0_39.py @@ -0,0 +1,39 @@ +"""3.0.39 专辑订阅记录已接受资源中的音轨事实。""" + +import sqlalchemy as sa +from alembic import op + +revision = "c8e4a1f7b2d6" +down_revision = "b7d1e4a9c206" +branch_labels = None +depends_on = None + +_TABLE = "subscribe" +_COLUMN = "downloaded_tracks" + + +def _table_exists(connection) -> bool: + """判断订阅表是否已经由基础模型创建。""" + return _TABLE in sa.inspect(connection).get_table_names() + + +def _column_names(connection) -> set[str]: + """读取订阅表现有列名,兼容表尚未创建或模型已包含当前列的场景。""" + inspector = sa.inspect(connection) + if not _table_exists(connection): + return set() + return {column["name"] for column in inspector.get_columns(_TABLE)} + + +def upgrade() -> None: + """增加专辑分批下载所需的音轨事实列,旧订阅从空事实开始累计。""" + connection = op.get_bind() + if _table_exists(connection) and _COLUMN not in _column_names(connection): + op.add_column(_TABLE, sa.Column(_COLUMN, sa.JSON(), nullable=True)) + + +def downgrade() -> None: + """删除专辑音轨事实列,回退后活动订阅进度将丢失。""" + connection = op.get_bind() + if _table_exists(connection) and _COLUMN in _column_names(connection): + op.drop_column(_TABLE, _COLUMN) diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 08c2815cf..f1f8fc518 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -456,7 +456,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch 音乐资源搜索及对应 SSE 接口默认只返回精确匹配。手动调用可传 `include_candidates=true`, 额外返回待确认资源及关联专辑:`match_status=candidate`、`match_reason` 描述原因, 且 `media_info` 为空、不绑定目标 ID;精确结果为 `match_status=exact`。自动订阅和批量下载 -不采用待确认项。单曲的关联专辑不代表已经确认包含该单曲,专辑下载仍需检查曲目覆盖。 +不采用待确认项。单曲的关联专辑不代表已经确认包含该单曲;专辑资源可以先下载,订阅会累计其中可识别的独立曲目并在全部曲目收齐后完成。 SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身份、分类及规则淘汰原因。 只有完整过滤后还有精确结果时才会按多名称设置提前停止;音乐元数据多来源结果先各自去重再公平合并。 @@ -476,7 +476,7 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身 | GET | `/api/v1/recommend/music_weekly` | 浏览本周热门音乐,参数:`page`、`count` | | GET | `/api/v1/recommend/music_douban` | 浏览豆瓣音乐新碟榜,参数:`page`、`count` | -专辑下载与订阅按“整包”处理:下载层会读取种子文件清单并以专辑 `total_tracks` 校验独立音频文件数量;未确认完整覆盖时不会把专辑订阅销订,也不会把部分曲目报告为完整专辑已入库。音乐整理会迁移与音轨同目录、同主干名的 `.lrc`、`.txt` 和 `.lyricsfile.yaml` 旁挂歌词。音乐刮削默认使用“质量升级”策略:先读取已有旁挂和 MP3/FLAC/Ogg/MP4 内嵌歌词,再聚合插件、LRCLIB、AMLL TTML 和 TheAudioDB 纯文本候选;逐字 Lyricsfile、逐行同步 LRC、纯文本依次降级,任何覆盖入口都不会用低质量结果替换高质量歌词。Lyricsfile 会保留为 `.lyricsfile.yaml`,同时生成播放器兼容的 `.lrc`。 +专辑下载与订阅按“累计曲目”处理:下载层读取种子文件清单并记录可识别的独立音轨,允许不完整资源先进入下载;活动订阅跨轮次合并去重,API 返回 `completed_tracks`,达到 `total_tracks` 后才完成订阅。媒体库完整性检查仍要求本地专辑具备完整曲目。音乐整理会迁移与音轨同目录、同主干名的 `.lrc`、`.txt` 和 `.lyricsfile.yaml` 旁挂歌词。音乐刮削默认使用“质量升级”策略:先读取已有旁挂和 MP3/FLAC/Ogg/MP4 内嵌歌词,再聚合插件、LRCLIB、AMLL TTML 和 TheAudioDB 纯文本候选;逐字 Lyricsfile、逐行同步 LRC、纯文本依次降级,任何覆盖入口都不会用低质量结果替换高质量歌词。Lyricsfile 会保留为 `.lyricsfile.yaml`,同时生成播放器兼容的 `.lrc`。 AMLL 使用无需鉴权的原生搜索与获取接口,先尝试 ISRC,再核对完整曲名、艺术家和已有专辑;搜索最多读取 20 项并下载 3 个匹配候选。TTML 只转换主唱内容,排除翻译、音译和背景人声;可靠的逐词或逐行时轴会保留,缺乏完整行时轴时降为纯文本。`AMLL_BASE_URL` 可配置兼容实例地址,网络超时为 10 秒,限流时进入最多 300 秒的有界冷却。动态搜索和 ISRC 查询缓存 1 小时,固定 ID 歌词缓存 7 天,未命中缓存 5 分钟。接口与格式依据见 [AMLL HTTP API](https://amll.dev/reference/http-api/overview)。 diff --git a/docs/refactor/music-lifecycle-audit.md b/docs/refactor/music-lifecycle-audit.md index 60d102f3d..9978067e3 100644 --- a/docs/refactor/music-lifecycle-audit.md +++ b/docs/refactor/music-lifecycle-audit.md @@ -19,8 +19,8 @@ | 元数据搜索 | 前端专辑、单曲入口和 `useMediaSubscribe` 传递统一来源、ID、实体类型 | Recording 和 Release Group 不能互换;艺术家不能直接订阅 | | 站点搜索 | `SearchMusicOwner` 生成标题、艺人及可信别名关键词;`match_music_resource` 核对名称、署名、分类和版本 | 缺艺术家、不同版本、关联专辑只能作为候选;单曲在专辑中的存在不是独立单曲资源 | | 订阅 | 主动搜索与 RSS 均保留目标身份、暂停/取消状态和音质规则 | 专辑需有总曲目数;远端暂不可用时恢复已保存的订阅快照 | -| 下载 | 下载前解析种子文件清单,按盘号/曲序或曲名去重;专辑达到预期数量后才标记覆盖 | 这是文件清单层的覆盖确认,不是逐曲声纹验证;没有文件清单或曲目数未知不能确认整专 | -| 完成判定 | 专辑订阅消费下载层的 `confirmed_full_coverage`,单曲按成功添加下载任务处理 | “订阅完成”代表已提交符合条件的下载,不代表下载完成、整理成功和媒体库刷新完成 | +| 下载 | 下载前解析种子文件清单,按盘号/曲序或曲名去重;专辑允许部分资源先提交并记录音轨事实 | 音轨键是跨资源累计的文件清单事实,不是逐曲声纹验证;没有文件清单的资源仍可下载,但不会增加专辑进度 | +| 完成判定 | 专辑订阅消费活动订阅中累计的 `downloaded_tracks`,达到 `total_tracks` 后完成;单曲按成功添加下载任务处理 | “订阅完成”代表已提交符合条件的下载,不代表下载完成、整理成功和媒体库刷新完成 | | 整理 | 恢复下载历史中的音乐上下文,保留逐曲标签/文件名;本地目录可按曲序、曲名、时长匹配发行版 | 路径不可读时不能假定读到了标签;不能把整张专辑的标题和单曲 ID 复制给每个文件 | | 文件识别 | 指纹、标签、文件名分层识别,目录级匹配作为补充 | 需要可读取的音频、可用的指纹工具/服务及上游条目;失败时的本地展示对象不是远端识别成功 | | 刮削 | 各文件独立处理标签、封面和歌词,沿用统一存储与事件流程 | 单曲 ID 不适用于整目录;歌词缺失或请求预算耗尽不代表音频整理失败 | diff --git a/docs/subscribe-lifecycle.md b/docs/subscribe-lifecycle.md index 11936b12f..b0e007f7f 100644 --- a/docs/subscribe-lifecycle.md +++ b/docs/subscribe-lifecycle.md @@ -11,8 +11,8 @@ | 状态类别 | 主要字段 | 含义 | 写入入口 | | --- | --- | --- | --- | | 目标范围 | `type`、`season`、`start_episode`、`total_episode` | 订阅需要覆盖的媒体范围。电影视为单个目标;电视剧按季和集范围处理。 | 新增订阅、订阅编辑、剧集刷新 | -| 下载事实 | `note`、`episode_priority` | 主程序或可信回填入口确认已经存在或已经下载的目标。`note` 表示存在事实,`episode_priority` 表示电视剧每集已知下载质量。 | 下载事实入口、backfill 入口、reset 入口 | -| 进度摘要 | `lack_episode`、`current_priority`、`completed_episode` | 面向搜索、完成判定和展示的进度。`current_priority` 是当前洗版模式的资源准入基线;`completed_episode` 由响应层按当前事实计算。 | progress 刷新入口、确认整包下载事实入口、响应构造 | +| 下载事实 | `note`、`downloaded_tracks`、`episode_priority` | 主程序或可信回填入口确认已经存在或已经下载的目标。`note` 表示影视/单曲事实,`downloaded_tracks` 保存专辑已接受资源中的去重音轨键,`episode_priority` 表示电视剧每集已知下载质量。 | 下载事实入口、backfill 入口、reset 入口 | +| 进度摘要 | `lack_episode`、`current_priority`、`completed_episode`、`completed_tracks` | 面向搜索、完成判定和展示的进度。`current_priority` 是当前洗版模式的资源准入基线;`completed_episode` 和 `completed_tracks` 由响应层按当前事实计算。 | progress 刷新入口、下载事实入口、响应构造 | | 生命周期状态 | `state`、`last_update` | 订阅是否继续搜索,以及最近一次系统写入时间。 | 搜索下载流程、完成订阅、reset、状态更新 | 字段所有权应保持集中:下载事实入口写事实,progress 刷新入口写派生进度,完成入口写完成状态,普通更新入口不应绕过这些语义直接改派生字段。 @@ -23,8 +23,8 @@ | --- | --- | --- | --- | | 创建订阅 | 用户、API、Agent、插件 | 记录媒体目标、订阅模式、季集范围、保存路径和下载策略。 | 初始订阅记录 | | 搜索匹配 | 定时任务、手动搜索、RSS/站点刷新 | 按订阅目标和模式过滤候选资源,计算需要下载或升级的目标。 | 匹配候选、待下载上下文 | -| 下载选择 | 下载链路 | 将候选资源交给下载器,下载层可返回明确集数或完整覆盖确认。 | 下载上下文、剩余缺口 | -| 事实记录 | 下载任务添加成功或可信 backfill | 写入 `note`,电视剧同步写入或提升 `episode_priority`;全集洗版仅在下载层确认完整覆盖后提升 `current_priority`。 | 订阅下载事实快照 | +| 下载选择 | 下载链路 | 将候选资源交给下载器;专辑下载层从文件清单提取可识别音轨键,不再按当前种子文件数拦截。 | 下载上下文、剩余缺口 | +| 事实记录 | 下载任务添加成功或可信 backfill | 写入 `note`,专辑合并 `downloaded_tracks`,电视剧同步写入或提升 `episode_priority`;全集洗版仅在下载层确认完整覆盖后提升 `current_priority`。 | 订阅下载事实快照 | | 进度刷新 | 下载流程、backfill、剧集范围变化、显式刷新 | 普通电视剧按媒体库缺失结果刷新 `lack_episode`;电视剧洗版按目标范围和按集事实刷新 `lack_episode`,仅分集洗版从按集事实派生 `current_priority`。 | 进度字段 | | 完成判定 | 下载流程末端、强制完成 | 根据媒体类型和订阅模式判断是否完成,完成前允许完成检查事件否决。 | 订阅历史、完成状态 | | 重置/回填 | 用户操作、插件、维护工具 | reset 清空事实和进度;backfill 写入调用方确认的外部存在事实。 | 新事实快照和进度摘要 | @@ -40,6 +40,7 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普 | 普通电视剧订阅 | 电视剧 | 季内目标集范围 | `note` 记录已存在或已下载集;`episode_priority` 记录每集已知下载质量。 | `lack_episode` 来自媒体库缺失结果。 | 目标范围无缺集时完成。 | 追剧、补缺集 | | 分集洗版 | 电视剧 | 季内目标集范围 | `note` 记录存在事实;`episode_priority` 记录每集质量。 | `lack_episode` 统计从未下载过任何版本的集;`current_priority` 是目标范围最低已知质量。 | 目标范围内每集达到顶级质量时完成洗版。 | 对单集逐步升级质量 | | 全集洗版 | 电视剧 | 完整目标集范围的整包质量 | 整包下载确认完整覆盖后,按目标范围写 `note` 和 `episode_priority`,并提升全集准入基线 `current_priority`。 | `current_priority` 表示全集模式的资源准入基线;保留的按集事实不参与候选过滤。 | `current_priority == 100` 或完成策略满足时完成。 | 用完整季包替换已有剧集 | +| 普通专辑订阅 | 音乐专辑 | 专辑总曲目范围 | 每次成功添加资源后合并去重的 `downloaded_tracks`。 | `completed_tracks` 为已累计曲目数,不能超过 `total_tracks`。 | 累计曲目数达到 `total_tracks` 后完成;单个不完整资源也可继续累计。 | 分批收集不同资源中的专辑曲目 | ## 按集订阅与全集洗版 @@ -50,6 +51,8 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普 - `lack_episode` 只表达“还缺多少集没有任何版本”,不表达“还有多少集需要升级质量”。 - `current_priority` 对分集洗版是目标范围内最低已知质量摘要,缺失质量事实按 `0` 参与计算。 +普通专辑订阅使用独立音轨事实:下载层从种子文件清单提取盘号、曲序或归一化曲名组成的稳定键,订阅事实入口跨轮次合并并去重。接口只返回派生的 `completed_tracks`,不暴露内部音轨键;达到 `total_tracks` 前保持订阅,达到后才进入完成流程。 + 因此普通电视剧订阅和分集洗版可以互相转换。普通订阅下载产生的按集质量事实可被分集洗版继续使用;分集洗版产生的 `note` 也可被普通订阅继续作为已下载事实。 全集洗版关注完整目标范围的整体资源质量。整包候选必须完整覆盖 `[start_episode, total_episode]`,不是只覆盖当前缺口集。下载层确认完整覆盖后,主程序才可提升全集 `current_priority`,并在资源缺少显式集数时按目标范围补写按集事实。`episode_priority` 在全集模式中继续只升不降,供以后切回分集洗版使用,但不参与全集候选过滤和完成判断。 @@ -61,10 +64,12 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普 | 字段 | 语义 | 关键约束 | | --- | --- | --- | | `note` | 已存在或已下载的电影项/电视剧集。 | 电影使用 `[1]`;电视剧使用集数列表。 | +| `downloaded_tracks` | 专辑已接受资源中的去重音轨稳定键。 | 只存在于活动订阅内部事实;API 通过 `completed_tracks` 暴露数量。 | | `episode_priority` | 电视剧按集质量事实,键为集数,值为下载优先级。 | 只升不降;普通订阅和洗版订阅都可以写入。 | | `lack_episode` | 电视剧目标范围内仍缺少任意版本的集数。 | 普通订阅来自媒体库缺失结果;洗版订阅按 `note` 和 `episode_priority > 0` 计算。 | | `current_priority` | 当前洗版模式的资源准入基线。 | 电影和全集洗版由对应模式的受控入口维护;分集洗版按目标范围内最低已知质量刷新。 | | `completed_episode` | 展示用完成集数。 | 分集洗版按顶档剧集数计算,全集洗版按整包准入基线是否达到顶档计算;不作为主要事实源。 | +| `completed_tracks` | 展示用已完成专辑曲目数。 | 由 `downloaded_tracks` 去重计数并截断到 `total_tracks`;不作为写入字段。 | | `state` | 订阅运行状态。 | 完成、暂停、重置等入口按业务状态维护。 | ## 用户视角 diff --git a/scripts/evaluation/world.py b/scripts/evaluation/world.py index 22a5297db..05429cdf9 100644 --- a/scripts/evaluation/world.py +++ b/scripts/evaluation/world.py @@ -41,7 +41,7 @@ _TORRENT_FIELDS = frozenset({ }) _SUBSCRIPTION_FIELDS = frozenset({ "audio_format", "audio_quality", "backdrop", "best_version", "best_version_full", "classification_policy_revision", - "classification_rule_id", "classification_source", "completed_episode", "current_audio_format", "current_bit_depth", + "classification_rule_id", "classification_source", "completed_episode", "completed_tracks", "current_audio_format", "current_bit_depth", "current_bitrate", "current_priority", "current_sample_rate", "custom_words", "date", "description", "downloader", "effect", "episode_group", "episode_priority", "exclude", "execution_status", "filter", "filter_groups", "id", "include", "keyword", "lack_episode", "last_search", "last_update", "media_category", "media_category_id", "media_id", diff --git a/tests/test_music_download.py b/tests/test_music_download.py index 9b7acf01b..74e5bad2d 100644 --- a/tests/test_music_download.py +++ b/tests/test_music_download.py @@ -76,54 +76,52 @@ def test_download_note_keeps_versioned_music_context(): assert "raw_data" not in note["music"]["media"] -def test_album_resource_requires_all_independent_audio_tracks(): - """整专资源只有在独立音频文件数覆盖专辑曲目数时才可标记完整。""" +def test_album_resource_records_independent_audio_track_keys(): + """整专资源记录可识别的独立音轨键,不再因数量不足而拒绝下载。""" context = Context(media_info=_album_info(total_tracks=3)) - error = DownloadChain._validate_music_album_resource( + DownloadChain._record_music_album_track_keys( context, ["叶惠美/01.flac", "叶惠美/02.flac", "叶惠美/03.m4a", "叶惠美/cover.jpg"], ) - assert error is None - assert context.confirmed_full_coverage is True + assert context.music_track_keys is not None + assert len(context.music_track_keys) == 3 + assert context.confirmed_full_coverage is False -def test_album_resource_rejects_incomplete_or_unverifiable_pack(): - """曲目不足、未知曲目总数或无文件清单时不得把专辑订阅判定为完成。""" +def test_album_resource_accepts_partial_or_unverifiable_pack(): + """曲目不足、未知总数或无文件清单都允许进入下载,完成交给订阅累计进度判断。""" incomplete = Context(media_info=_album_info(total_tracks=3)) unknown = Context(media_info=_album_info(total_tracks=None)) - assert "仅包含 1 个独立音频文件" in ( - DownloadChain._validate_music_album_resource(incomplete, ["叶惠美/disc.flac"]) or "" - ) + DownloadChain._record_music_album_track_keys(incomplete, ["叶惠美/disc.flac"]) assert incomplete.confirmed_full_coverage is False - assert "总曲目数未知" in ( - DownloadChain._validate_music_album_resource(unknown, ["叶惠美/01.flac"]) or "" - ) - assert "未提供文件清单" in ( - DownloadChain._validate_music_album_resource( - Context(media_info=_album_info(total_tracks=3)), - [], - ) or "" - ) + assert len(incomplete.music_track_keys or []) == 1 + + DownloadChain._record_music_album_track_keys(unknown, ["叶惠美/01.flac"]) + assert len(unknown.music_track_keys or []) == 1 + + empty = Context(media_info=_album_info(total_tracks=3)) + DownloadChain._record_music_album_track_keys(empty, []) + assert empty.music_track_keys is None def test_album_resource_dedupes_same_track_in_different_formats(): """同一盘同一曲序的多种编码不能冒充多首独立曲目。""" context = Context(media_info=_album_info(total_tracks=2)) - error = DownloadChain._validate_music_album_resource( + DownloadChain._record_music_album_track_keys( context, ["叶惠美/01 - 以父之名.flac", "叶惠美/01 - 以父之名.mp3"], ) - assert "仅包含 1 个独立音频文件" in (error or "") + assert len(context.music_track_keys or []) == 1 assert context.confirmed_full_coverage is False -def test_download_single_stops_before_client_when_album_pack_is_incomplete(): - """下载入口应在添加任务前拒绝不完整专辑,并记录可供后续候选继续尝试的失败原因。""" +def test_download_single_prepares_partial_album_for_client_submission(): + """下载入口允许不完整专辑进入下载器,并把本轮音轨事实附着到上下文。""" context = Context( media_info=_album_info(total_tracks=3), meta_info=MetaMusic.from_music_info(_album_info(total_tracks=3)), @@ -134,6 +132,9 @@ def test_download_single_stops_before_client_when_album_pack_is_incomplete(): ) chain = DownloadChain() chain._record_download_failure = Mock() + chain._resolve_media_download_dir = Mock( + return_value=("local", Path("/downloads"), None) + ) media_chain = Mock() media_chain.supplement_tmdb_info.return_value = context.media_info torrent_helper = Mock() @@ -145,15 +146,23 @@ def test_download_single_stops_before_client_when_album_pack_is_incomplete(): with patch.object(download_submission, "MediaChain", return_value=media_chain), \ patch.object(download_submission, "TorrentHelper", return_value=torrent_helper), \ patch.object(download_submission.eventmanager, "send_event", return_value=None): - task_id, error = chain.download_single( - context, + prepared, error = chain._prepare_download_single( + context=context, torrent_content=b"torrent", - return_detail=True, + torrent_file=None, + episodes=None, + channel=None, + source=None, + downloader=None, + save_path=None, + userid=None, + username=None, ) - assert task_id is None - assert "专辑资源不完整" in error - chain._record_download_failure.assert_called_once() + assert prepared is not None + assert error is None + assert len(context.music_track_keys or []) == 1 + chain._record_download_failure.assert_not_called() def test_download_endpoint_builds_music_context(): diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py index ad1618e1b..85f019f19 100644 --- a/tests/test_music_subscribe.py +++ b/tests/test_music_subscribe.py @@ -67,6 +67,7 @@ def _subscribe(**overrides) -> SimpleNamespace: mediaid=None, music_type="recording", total_tracks=None, + downloaded_tracks=None, season=None, episode_group=None, tmdbid=None, @@ -545,8 +546,8 @@ def test_music_rss_rejects_subtitle_version_or_partial_album(album_target): assert chain._filter_music_subscribe_contexts(_subscribe(), target, [context]) == [] -def test_album_best_version_requires_confirmed_full_coverage(): - """最高优先级的非完整专辑不得写入洗版基线或完成订阅。""" +def test_album_best_version_waits_for_accumulated_full_coverage(): + """专辑洗版在累计曲目达到总数前不更新质量基线,达到后才允许更新。""" subscribe = _subscribe( name="叶惠美", music_type=MUSIC_ENTITY_ALBUM, @@ -572,7 +573,7 @@ def test_album_best_version_requires_confirmed_full_coverage(): ), meta_info=meta, media_info=album, - confirmed_full_coverage=False, + music_track_keys=["[1,1]"], ) download_chain = Mock() download_chain.batch_download.return_value = ([downloaded], None) @@ -581,13 +582,41 @@ def test_album_best_version_requires_confirmed_full_coverage(): chain = SubscribeChain() _configure_subscription_write(chain, subscribe_oper) - with patch("app.chain._music.DownloadChain", return_value=download_chain), \ - patch.object(chain, "_SubscribeChain__finish_subscribe") as finish: + chain.finish_subscribe_or_not = Mock() + with patch("app.chain._music.DownloadChain", return_value=download_chain): chain._download_music_subscribe(subscribe, album, [downloaded]) subscribe_oper.update.assert_not_called() assert subscribe.current_priority == 90 - finish.assert_not_called() + chain.finish_subscribe_or_not.assert_called_once() + + full_subscribe = _subscribe( + name="叶惠美", + music_type=MUSIC_ENTITY_ALBUM, + total_tracks=11, + downloaded_tracks=[f"[{index},1]" for index in range(1, 11)], + best_version=1, + current_priority=90, + ) + updated = _subscribe( + name="叶惠美", + music_type=MUSIC_ENTITY_ALBUM, + total_tracks=11, + downloaded_tracks=full_subscribe.downloaded_tracks, + best_version=1, + current_priority=100, + ) + subscribe_oper.reset_mock() + subscribe_oper.get.return_value = full_subscribe + subscribe_oper.update.return_value = updated + chain.finish_subscribe_or_not.reset_mock() + downloaded.music_track_keys = ["[11,1]"] + + with patch("app.chain._music.DownloadChain", return_value=download_chain): + chain._download_music_subscribe(full_subscribe, album, [downloaded]) + + subscribe_oper.update.assert_called_once() + assert subscribe_oper.update.call_args.args[1].to_payload()["current_priority"] == 100 def test_music_subscribe_ignores_non_music_category(): @@ -911,9 +940,13 @@ def test_async_music_subscription_fallback_snapshot_is_finalized() -> None: ) -def test_album_subscription_finishes_only_after_confirmed_full_pack(): - """专辑与电视剧全集相同,必须确认整专覆盖;单曲仍在任一成功下载后完成。""" - album_subscribe = _subscribe(music_type=MUSIC_ENTITY_ALBUM, total_tracks=11) +def test_album_subscription_finishes_only_after_accumulated_tracks(): + """专辑按跨资源累计的独立曲目数完成,单曲仍在任一成功下载后完成。""" + album_subscribe = _subscribe( + music_type=MUSIC_ENTITY_ALBUM, + total_tracks=11, + downloaded_tracks=[f"[{index},1]" for index in range(1, 12)], + ) album = MusicInfo( music_type=MUSIC_ENTITY_ALBUM, title="叶惠美", @@ -924,13 +957,17 @@ def test_album_subscription_finishes_only_after_confirmed_full_pack(): assert SubscribeChain._is_music_download_complete( album_subscribe, album, - [Context(confirmed_full_coverage=False)], - ) is False - assert SubscribeChain._is_music_download_complete( - album_subscribe, - album, - [Context(confirmed_full_coverage=True)], + [], ) is True + assert SubscribeChain._is_music_download_complete( + _subscribe( + music_type=MUSIC_ENTITY_ALBUM, + total_tracks=11, + downloaded_tracks=["[1,1]"], + ), + album, + [Context(music_track_keys=["[1,2]"])], + ) is False assert SubscribeChain._is_music_download_complete( _subscribe(), _music_info(), @@ -938,6 +975,48 @@ def test_album_subscription_finishes_only_after_confirmed_full_pack(): ) is True +def test_album_download_facts_accumulate_and_finish_at_total_tracks(): + """专辑下载事实跨轮次合并去重,达到总曲目数后才触发完成。""" + album_subscribe = _subscribe( + name="叶惠美", + media_id="release-group-1", + music_type=MUSIC_ENTITY_ALBUM, + total_tracks=3, + downloaded_tracks=["[1,1]"], + ) + album = MusicInfo( + media_source="musicbrainz", + media_id="release-group-1", + music_type=MUSIC_ENTITY_ALBUM, + title="叶惠美", + album="叶惠美", + total_tracks=3, + ) + download = Context( + torrent_info=TorrentInfo(title="叶惠美 FLAC", category=MediaType.MUSIC.value), + meta_info=MetaMusic.from_music_info(album), + media_info=album, + music_track_keys=["[1,2]", "[1,2]", "[1,3]"], + ) + subscribe_oper = Mock() + chain = SubscribeChain() + _configure_subscription_write(chain, subscribe_oper) + + with patch.object(SubscribeChain, "_SubscribeChain__finish_subscribe") as finish: + chain.finish_subscribe_or_not( + subscribe=album_subscribe, + meta=MetaMusic.from_music_info(album), + mediainfo=album, + downloads=[download], + ) + + payload = subscribe_oper.update.call_args.args[1].to_payload() + assert payload["downloaded_tracks"] == ["[1,1]", "[1,2]", "[1,3]"] + finish.assert_called_once() + completed = finish.call_args.kwargs["subscribe"] + assert completed.downloaded_tracks == ["[1,1]", "[1,2]", "[1,3]"] + + def test_music_subscribe_target_validation_enforces_entity_semantics(): """单曲无需专辑曲目数,专辑必须有总曲目数,艺术家和实体错配均不可订阅。""" recording = _music_info() diff --git a/tests/test_music_subscription_tracks_migration.py b/tests/test_music_subscription_tracks_migration.py new file mode 100644 index 000000000..ba5059025 --- /dev/null +++ b/tests/test_music_subscription_tracks_migration.py @@ -0,0 +1,55 @@ +"""专辑订阅音轨事实列迁移测试。""" + +import importlib + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +MIGRATION_MODULE = "database.versions.c8e4a1f7b2d6_3_0_39" + + +def test_music_subscription_tracks_migration_is_idempotent_and_reversible(monkeypatch) -> None: + """迁移应为既有订阅表幂等增加 JSON 列,并可安全回退。""" + engine = sa.create_engine("sqlite://") + metadata = sa.MetaData() + sa.Table( + "subscribe", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String()), + ) + + with engine.begin() as connection: + metadata.create_all(connection) + migration = importlib.import_module(MIGRATION_MODULE) + monkeypatch.setattr( + migration, + "op", + Operations(MigrationContext.configure(connection)), + ) + + migration.upgrade() + migration.upgrade() + columns = {column["name"] for column in sa.inspect(connection).get_columns("subscribe")} + assert "downloaded_tracks" in columns + + migration.downgrade() + columns = {column["name"] for column in sa.inspect(connection).get_columns("subscribe")} + assert "downloaded_tracks" not in columns + + +def test_music_subscription_tracks_migration_ignores_missing_table(monkeypatch) -> None: + """基础表尚未创建时,迁移应等待初始化建表而不是失败。""" + engine = sa.create_engine("sqlite://") + + with engine.begin() as connection: + migration = importlib.import_module(MIGRATION_MODULE) + monkeypatch.setattr( + migration, + "op", + Operations(MigrationContext.configure(connection)), + ) + + migration.upgrade() + migration.downgrade() diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index e8a57bcac..24b9141e2 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -15,7 +15,7 @@ from app.application.subscription.contract import ( ) from app.application.subscription.mutation import SubscriptionMutation from app.schemas.mediaserver import NotExistMediaInfo -from app.schemas.types import MediaSource, MediaType +from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType def _load_subscribe_chain_class(): @@ -699,6 +699,19 @@ class TestSubscribeChain: assert progress == "第0季" + def test_format_subscribe_progress_shows_accumulated_album_tracks(self): + """消息订阅列表应像电视剧一样显示专辑已累计曲目进度。""" + subscribe = self._build_subscribe( + type=MediaType.MUSIC.value, + music_type=MUSIC_ENTITY_ALBUM, + total_tracks=11, + downloaded_tracks=["[1,1]", "[1,2]", "[1,2]"], + ) + + progress = SubscribeInteractionHandler._format_subscribe_progress(subscribe) + + assert progress == "专辑 [2/11]" + def test_match_title_fallback_calls_torrent_match_from_class(self): """确保标题兜底匹配不依赖 TorrentHelper 实例绑定。""" reached = [] diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 984dbd8db..9f1f3c69f 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -937,6 +937,7 @@ class TestSubscribeEndpoint: total_episode=10, lack_episode=3, note=[1, 2, 3], + completed_tracks=3, state="S", last_update="2026-07-20 12:00:00", username="forged-user", @@ -968,6 +969,7 @@ class TestSubscribeEndpoint: "episode_priority", "date", "completed_episode", + "completed_tracks", ): assert field not in payload diff --git a/tests/test_subscribe_write_path.py b/tests/test_subscribe_write_path.py index 6b6f84b05..6f3181b63 100644 --- a/tests/test_subscribe_write_path.py +++ b/tests/test_subscribe_write_path.py @@ -320,6 +320,7 @@ def test_add_persists_album_entity_and_track_count(db, is_async): assert row.type == MediaType.MUSIC.value assert row.music_type == "album" assert row.total_tracks == 11 + assert row.downloaded_tracks == [] @_BOTH_PATHS @@ -338,6 +339,7 @@ def test_add_drops_track_count_for_single_recording(db, is_async): row = _row(db, sid) assert row.music_type == "recording" assert row.total_tracks is None + assert row.downloaded_tracks is None @_BOTH_PATHS diff --git a/tests/test_subscription_query_service.py b/tests/test_subscription_query_service.py index 4d297c763..8190c74d6 100644 --- a/tests/test_subscription_query_service.py +++ b/tests/test_subscription_query_service.py @@ -3,7 +3,11 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from app.application.subscription.contract import SubscriptionIdentity +from app.application.subscription.contract import ( + SubscriptionHistorySnapshot, + SubscriptionIdentity, + SubscriptionSnapshot, +) from app.application.subscription.query import SubscriptionQueryService from app.chain.subscribe.facade import SubscribeChain from app.domain.context import MediaInfo @@ -166,3 +170,49 @@ def test_subscribe_chain_facade_delegates_three_query_slices() -> None: "media_id": "123", }) service.has_music.assert_called_once_with("R,P") + + +@pytest.mark.asyncio +async def test_public_subscription_view_derives_album_progress_without_track_keys() -> None: + """公开订阅响应应返回累计曲目数,但不能泄漏内部音轨稳定键。""" + record = SubscriptionSnapshot( + id=31, + name="叶惠美", + type=MediaType.MUSIC.value, + media_source=MediaSource.MusicBrainz, + media_id="release-group-31", + music_type="album", + total_tracks=3, + downloaded_tracks=["[1,1]", "[1,1]", "[1,2]", ""], + ) + repository = Mock() + repository.async_list = AsyncMock(return_value=[record]) + service = SubscriptionQueryService(repository, async_repository=repository) + + [result] = await service.list_public() + + assert result.completed_tracks == 2 + assert "downloaded_tracks" not in result.model_dump() + + +@pytest.mark.asyncio +async def test_public_subscription_history_does_not_invent_album_progress() -> None: + """不含活动音轨事实的订阅历史不能伪造为零首进度。""" + record = SubscriptionHistorySnapshot( + id=32, + name="叶惠美", + type=MediaType.MUSIC.value, + music_type="album", + total_tracks=3, + ) + repository = Mock() + history_repository = Mock() + history_repository.async_list_by_type = AsyncMock(return_value=[record]) + service = SubscriptionQueryService( + repository, + history_repository=history_repository, + ) + + [result] = await service.list_history(MediaType.MUSIC.value) + + assert result.completed_tracks is None