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
This commit is contained in:
jxxghp
2026-10-05 17:54:29 +08:00
parent 84e3787b57
commit 791bcc00c0
5 changed files with 166 additions and 12 deletions

View File

@@ -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)

View File

@@ -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`:

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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)