diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index d1269dae0..11bdf024c 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -130,10 +130,12 @@ class SubscriptionSearchSubmission(BaseModel): # type: ignore[misc] class Subscribe(OptionalMediaIdentityMixin, BaseModel): """订阅输入与响应模型,媒体身份必须为空对或完整有效对。""" - # 表单用空字符串表达“全部”时必须保留显式清空语义,更新接口才能覆盖存量规则。 - CLEARABLE_FILTER_FIELDS: ClassVar[frozenset[str]] = frozenset({ + # 表单清空可选设置时保留字段,更新接口才能用 None 覆盖旧值;媒体身份和集数仍单独处理。 + CLEARABLE_OPTIONAL_FIELDS: ClassVar[frozenset[str]] = frozenset({ "filter", "include", "exclude", "quality", "resolution", "effect", "audio_quality", "audio_format", + "custom_words", "keyword", "save_path", "episode_group", "downloader", + "min_bitrate", "min_bit_depth", "min_sample_rate", }) # 公共创建和更新接口不得接收系统字段和运行事实;其余字段默认作为订阅输入透传。 @@ -293,13 +295,13 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel): 在表单中常以空字符串提交,而 Pydantic 不会把空字符串自动转为 None,会直接抛出 校验异常导致接口返回 422。这里把空字符串键移除,等价于该字段未提供,从而复用字段 默认值(如 ``total_episode`` 回退为 0、``sites`` 回退为空列表)。媒体身份键以及可清空 - 的筛选字段保留为 None,以便更新接口区分“未提交”与“显式清空”。 + 的可选设置、搜索周期保留为 None,以便更新接口区分“未提交”与“显式清空”。 """ if isinstance(data, dict): data = dict(data) for key, value in list(data.items()): if isinstance(value, str) and value == "": - if key in {"media_source", "media_id", "search_interval"} or key in cls.CLEARABLE_FILTER_FIELDS: + if key in {"media_source", "media_id", "search_interval"} or key in cls.CLEARABLE_OPTIONAL_FIELDS: data[key] = None else: data.pop(key) diff --git a/docs/mcp-api.md b/docs/mcp-api.md index e9ab8a074..a4cb7d05d 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -799,6 +799,16 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized` 有年份时优先精确年份,其次匹配订阅年份为空的未定档媒体;没有年份时只匹配年份也为空的订阅。 音乐订阅始终只按媒体身份查询。 +### 订阅可选设置清空 + +`PUT /api/v1/subscribe/`(`subscription.update`)只更新显式提交的字段,省略字段保留原值。 +`custom_words`、`keyword`、`save_path`、`episode_group`、`downloader`、 +`min_bitrate`、`min_bit_depth`、`min_sample_rate` 与筛选字段 +`filter`、`include`、`exclude`、`quality`、`resolution`、`effect`、`audio_quality`、`audio_format` +均可通过 `null` 清空,也兼容表单提交的空字符串 `""`;清空会实际覆盖数据库旧值。 +非空字符串不自动裁剪,数值 `0` 保留。此规则不扩展到名称、类型、季号、总集数等字段, +媒体身份仍要求 `media_source` 与 `media_id` 成对提交。 + ### 单条订阅搜索周期 `POST /api/v1/subscribe/` 和 `PUT /api/v1/subscribe/` 支持 `search_interval`: diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index e3cb8de7e..9f3c5595a 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 43 +version: 44 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, @@ -90,6 +90,10 @@ users may update or delete only their own subscriptions. An unbound or inactive channel user must bind an active account before retrying, not switch to an administrator identity. +For `subscription.update`, omit settings that should stay unchanged. Send JSON +`null` to clear a supported optional setting; see [Subscription APIs](api/subscription.md) +for the exact fields and the form empty-string compatibility rule. + This file is intentionally kept as the routing and execution guide. Detailed operation contracts live in the linked category files under `api/`; load only the one category file needed for the selected operation. Do not load every diff --git a/skills/moviepilot-api/api/subscription.md b/skills/moviepilot-api/api/subscription.md index a98aecb47..073daaaaf 100644 --- a/skills/moviepilot-api/api/subscription.md +++ b/skills/moviepilot-api/api/subscription.md @@ -203,6 +203,7 @@ Purpose: Set one accessible subscription to running, paused, or stopped state. ### `subscription.update` `PUT /api/v1/subscribe/`; policy effect: `reversible_write`. Purpose: Update one existing movie, TV, or music subscription. +- Omitted fields keep their stored values. Send JSON `null` to clear `custom_words`, `keyword`, `save_path`, `episode_group`, `downloader`, `min_bitrate`, `min_bit_depth`, `min_sample_rate`, `filter`, `include`, `exclude`, `quality`, `resolution`, `effect`, `audio_quality`, or `audio_format`. Form submissions using `""` for these fields are also accepted as explicit clearing. Nonempty strings are preserved without trimming, and numeric `0` remains a value. Name, type, season, and episode counts retain their existing normalization; submit `media_source` and `media_id` together when changing or clearing media identity. - `path_params`: none - `query`: none - `body`: `audio_format` (string|null): Requested or recorded audio container or codec, such as FLAC or MP3.; `audio_quality` (string|null): Subscription audio-quality rule, such as hires, lossless, or lossy.; `backdrop` (string|null): Backdrop image URL stored with the media or subscription.; `best_version` (integer|null): Enable normal best-version upgrading when set to 1.; `best_version_full` (integer|null): Enable full best-version upgrading when set to 1.; `classification_policy_revision` (integer|null): Policy revision that produced the persisted classification snapshot.; `classification_rule_id` (string|null): Stable rule ID that selected the persisted classification category.; `classification_source` (string|null): Selection source recorded with the persisted classification snapshot.; `completed_episode` (integer|null): Highest episode number already completed for the subscription.; `completed_tracks` (integer|null): Number of distinct album tracks accepted for the subscription so far.; `current_audio_format` (string|null): Audio format of the best version currently held.; `current_bit_depth` (integer|null): Bit depth of the best version currently held.; `current_bitrate` (integer|null): Bitrate of the best version currently held.; `current_priority` (integer|null): Calculated priority of the best version currently held.; `current_sample_rate` (integer|null): Sample rate of the best version currently held.; `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `date` (string|null): Record creation or completion timestamp used by the history item.; `description` (string|null): Human-readable media, torrent, or subscription description.; `downloader` (string|null): Configured downloader instance name.; `effect` (string|null): Video or release-effect filter expression used by the subscription.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_priority` (object|null): Per-episode best-version priority state.; `exclude` (string|null): Regular expression or filter expression that rejects matching releases.; `execution_status` (SubscriptionExecutionStatus|null): Current subscription execution status returned with the subscription snapshot.; `filter` (string|null): Named filter rule or rule expression applied to this site or subscription.; `filter_groups` (array|null): Ordered filter-rule group names applied to the subscription.; `id` (integer|null): Persistent database identifier of the supplied record.; `include` (string|null): Regular expression or filter expression that a release must match.; `keyword` (string|null): Case-insensitive substring used to discover settings or filter storage entries.; `lack_episode` (integer|null; default `0`): Number of episodes still missing from the subscription.; `last_search` (string|null): Read-only UTC timestamp of the most recent subscription search attempt.; `last_update` (string|null): Timestamp of the subscription's most recent update.; `media_category` (string|null): MoviePilot library category assigned to the media.; `media_category_id` (string|null): Stable classification category ID; preserve it separately from the current category path snapshot.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_bit_depth` (integer|null): Minimum acceptable audio bit depth in bits.; `min_bitrate` (integer|null): Minimum acceptable audio bitrate in bits per second.; `min_sample_rate` (integer|null): Minimum acceptable audio sample rate in hertz.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `name` (string|null): Human-readable name of the site, storage item, subscription, or rule group.; `note` (array|null): Structured auxiliary metadata stored with the record.; `poster` (string|null): Poster image URL stored with the media or subscription.; `quality` (string|null): Video or release quality filter expression.; `resolution` (string|null): Video resolution filter expression, such as 1080p or 2160p.; `save_path` (string|null): Configured downloader-side save path for the download or subscription.; `search_imdbid` (integer|null; default `0`): Use IMDb identity during subscription search when set to 1.; `search_interval` (integer|null): Scheduled search interval in whole hours (1-8760); null uses the system interval.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `sites` (array|null): Exact site IDs included in the search or subscription scope.; `start_episode` (integer|null; default `0`): First episode number requested by the subscription.; `state` (string|null): Current site, subscription, marketplace, or transfer state filter.; `total_episode` (integer|null; default `0`): Expected total episode count for the subscription.; `total_tracks` (integer|null): Expected or recorded track count for a music item.; `type` (string|null): MoviePilot media or storage item type required by the selected operation.; `username` (string|null): MoviePilot or site username required by the selected operation.; `vote` (number|null; default `0.0`): Media vote average stored with the subscription.; `year` (string|null): Release or premiere year used to disambiguate the media title. diff --git a/tests/test_subscribe_filter_clear.py b/tests/test_subscribe_filter_clear.py index 781d1ce81..7c0c127ff 100644 --- a/tests/test_subscribe_filter_clear.py +++ b/tests/test_subscribe_filter_clear.py @@ -1,17 +1,47 @@ -"""订阅更新显式清空筛选条件的回归测试。""" +"""订阅更新显式清空可选设置的回归测试。""" from types import SimpleNamespace +from typing import Optional import pytest +from sqlalchemy.ext.asyncio import AsyncSession from app.api.endpoints.subscribe import update_subscribe +from app.application.subscription.mutation import SubscriptionMutationService +from app.db.adapters.outbox import SqlAlchemyAsyncOutboxDispatchStore, SqlAlchemyAsyncOutboxStager +from app.db.adapters.subscription import SessionSubscriptionRepository +from app.db.models.outbox import OutboxMessage +from app.db.models.subscribe import Subscribe as SubscribeModel +from app.db.session import async_session_scope +from app.db.uow import SqlAlchemyAsyncUnitOfWork from app.schemas.subscribe import Subscribe +from app.schemas.types import MediaSource, MediaType + +OPTIONAL_SETTINGS = { + "filter": "custom-rule", + "include": "CHS", + "exclude": "CAM", + "quality": "WEB-DL", + "resolution": "4K", + "effect": "HDR", + "audio_quality": "lossless", + "audio_format": "FLAC", + "custom_words": " ", + "keyword": "测试关键字", + "save_path": "/downloads/custom", + "episode_group": "custom-group", + "downloader": "custom-downloader", + "min_bitrate": 320000, + "min_bit_depth": 24, + "min_sample_rate": 96000, +} class _SubscribeRow: """提供更新端点所需字段的最小订阅替身。""" def __init__(self) -> None: + """构造包含已有分辨率限制的订阅。""" self.id = 1 self.username = "alice" self.type = "电影" @@ -28,6 +58,7 @@ class _MutationService: """记录端点交给订阅写服务的更新 payload。""" def __init__(self, subscribe: _SubscribeRow) -> None: + """保存订阅替身并初始化待观察的更新载荷。""" self.subscribe = subscribe self.payload = None @@ -48,11 +79,15 @@ class _MutationService: @pytest.mark.anyio -async def test_update_subscribe_clears_explicit_empty_resolution() -> None: - """从 4K 切换到全部时,空字符串必须作为显式 None 写入而非被忽略。""" +@pytest.mark.parametrize("field, previous", OPTIONAL_SETTINGS.items()) +@pytest.mark.parametrize("empty", ["", None]) +async def test_update_subscribe_clears_explicit_optional_setting(field, previous, empty) -> None: + """表单空字符串与 JSON null 都应显式清空旧设置,包含历史纯空格识别词。""" subscribe = _SubscribeRow() + setattr(subscribe, field, previous) mutation = _MutationService(subscribe) - subscribe_in = Subscribe(id=1, resolution="") + raw = {"id": 1, field: empty} + subscribe_in = Subscribe.model_validate(raw) response = await update_subscribe( subscribe_in=subscribe_in, @@ -61,6 +96,108 @@ async def test_update_subscribe_clears_explicit_empty_resolution() -> None: ) assert response.success is True - assert "resolution" in subscribe_in.model_fields_set - assert mutation.payload["resolution"] is None - assert subscribe.resolution is None + assert field in subscribe_in.model_fields_set + assert mutation.payload == {field: None, "username": "alice"} + assert getattr(subscribe, field) is None + assert raw == {"id": 1, field: empty} + + +def test_subscribe_omitted_optional_settings_are_not_written() -> None: + """省略可选设置时不能用模型默认值覆盖已有设置。""" + subscribe = Subscribe(id=1, name="新名称") + + assert subscribe.to_public_write_payload(exclude_unset=True) == {"name": "新名称"} + + +@pytest.mark.parametrize("field, value", OPTIONAL_SETTINGS.items()) +def test_subscribe_preserves_nonempty_optional_settings(field, value) -> None: + """非空设置保持原值,识别词中的有意义空白也不得被自动裁剪。""" + subscribe = Subscribe.model_validate({field: value}) + + assert subscribe.to_public_write_payload(exclude_unset=True) == {field: value} + + +@pytest.mark.parametrize("field", ["min_bitrate", "min_bit_depth", "min_sample_rate"]) +@pytest.mark.parametrize("value", [0, "0", "24"]) +def test_subscribe_preserves_numeric_optional_settings(field: str, value) -> None: + """音频下限中的显式零值和数字字符串继续按数值处理。""" + subscribe = Subscribe.model_validate({field: value}) + + assert subscribe.to_public_write_payload(exclude_unset=True) == {field: int(value)} + + +def test_subscribe_empty_metadata_fields_remain_omitted() -> None: + """名称、类型、集数等字段仍沿用空字符串回退默认值的历史行为。""" + subscribe = Subscribe( + name="", type="", season="", total_episode="", start_episode="", + sites="", filter_groups="", + ) + + assert subscribe.to_public_write_payload(exclude_unset=True) == {} + assert subscribe.total_episode == 0 + assert subscribe.sites == [] + + +@pytest.mark.parametrize("media_type", [MediaType.TV, MediaType.MUSIC]) +@pytest.mark.parametrize("empty", ["", None]) +def test_update_subscribe_persists_cleared_optional_settings(db, media_type: MediaType, empty: Optional[str]) -> None: + """真实写服务提交后重新读库,清空结果和修改事件都必须保留,未提交字段不得改变。""" + db.watermark(SubscribeModel, OutboxMessage) + row = db.add(SubscribeModel( + name="清空设置测试", + type=media_type.value, + username="alice", + media_source=MediaSource.TMDB.value, + media_id="6894", + music_type="album" if media_type == MediaType.MUSIC else None, + total_tracks=12 if media_type == MediaType.MUSIC else None, + season=1, + total_episode=12, + lack_episode=7, + manual_total_episode=0, + search_interval=24, + **OPTIONAL_SETTINGS, + )) + subscribe_id = row.id + published = [] + + async def execute(session: AsyncSession): + """通过请求模型、端点、写服务和真实事务执行清空。""" + async def publish(payload: dict) -> None: + """只记录事件,避免向真实插件或外部服务派发。""" + published.append(payload) + + mutation = SubscriptionMutationService( + repository=SessionSubscriptionRepository(session), + unit_of_work=SqlAlchemyAsyncUnitOfWork(session), + outbox=SqlAlchemyAsyncOutboxStager(session), + dispatch_store=SqlAlchemyAsyncOutboxDispatchStore(async_session_scope), + publish_modified=publish, + ) + return await update_subscribe( + subscribe_in=Subscribe.model_validate({ + "id": subscribe_id, **dict.fromkeys(OPTIONAL_SETTINGS, empty), + }), + mutation=mutation, + current_user=SimpleNamespace(name="alice", is_superuser=False), + ) + + response = db.run_async_session(execute) + + db.session.expire_all() + saved = db.session.get(SubscribeModel, subscribe_id) + assert response.success is True + assert all(getattr(saved, field) is None for field in OPTIONAL_SETTINGS) + assert saved.name == "清空设置测试" + assert saved.type == media_type.value + assert saved.media_source == MediaSource.TMDB.value + assert saved.media_id == "6894" + assert (saved.season, saved.total_episode, saved.lack_episode, saved.manual_total_episode) == (1, 12, 7, 0) + assert saved.search_interval == 24 + if media_type == MediaType.MUSIC: + assert (saved.music_type, saved.total_tracks) == ("album", 12) + assert len(published) == 1 + assert set(published[0]["fields"]) == set(OPTIONAL_SETTINGS) + intent = db.session.query(OutboxMessage).filter_by(event_key=published[0]["idempotency_key"]).one() + assert intent.status == "completed" + assert all(intent.payload["subscribe_info"][field] is None for field in OPTIONAL_SETTINGS)