From 791bcc00c0659c7e8f3a9da89b0451efdc911c10 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 5 Oct 2026 17:54:29 +0800 Subject: [PATCH] fix(subscribe): persist cleared optional settings Preserve explicit empty values for recognition words, keywords, paths, episode groups, downloaders, and audio thresholds. Add endpoint and persisted transaction regression coverage and document the update contract. Refs #6894 --- app/schemas/subscribe.py | 10 +- docs/mcp-api.md | 10 ++ skills/moviepilot-api/SKILL.md | 6 +- skills/moviepilot-api/api/subscription.md | 1 + tests/test_subscribe_filter_clear.py | 151 +++++++++++++++++++++- 5 files changed, 166 insertions(+), 12 deletions(-) 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)