From 6cdd4dbc664ad78790850d95a2127da800e2a594 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 28 Sep 2026 16:59:30 +0800 Subject: [PATCH] fix(subscribe): resolve cross-source video status without premiere year --- app/api/endpoints/subscribe.py | 4 +- app/application/subscription/query.py | 17 ++-- docs/mcp-api.md | 7 ++ skills/moviepilot-api/api/subscription.md | 2 +- tests/test_subscribe_endpoint.py | 110 +++++++++++++++++++++- 5 files changed, 125 insertions(+), 15 deletions(-) diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 5744232d2..9e4148698 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -415,7 +415,7 @@ async def subscribe_media_identity( current_user: ApiPrincipal = Depends(get_current_active_user_async), ) -> Any: """ - 根据媒体身份查询订阅,视频身份未命中时按类型、标题和年份回退。 + 根据媒体身份查询订阅,影视身份未命中时按类型、标题和可用年份跨源回退。 """ metadata = MetaInfo(title) if title else None normalized_title = metadata.name if metadata else None @@ -427,10 +427,8 @@ async def subscribe_media_identity( result = select_accessible_subscribe(subscribes, current_user) if ( not result - and media_source != MediaSource.TMDB and mtype in (MediaType.MOVIE, MediaType.TV) and normalized_title - and year ): subscribes = await query.list_by_video_metadata( title=normalized_title, diff --git a/app/application/subscription/query.py b/app/application/subscription/query.py index b940fa1fa..e4b0f2ba8 100644 --- a/app/application/subscription/query.py +++ b/app/application/subscription/query.py @@ -127,11 +127,11 @@ class SubscriptionQueryService: self, *, title: str, - year: str, + year: Optional[str], media_type: MediaType, season: Optional[int] = None, ) -> list[SubscribeView]: - """按影视类型、规范标题、年份和可选季号读取跨来源订阅。""" + """按影视类型、规范标题和可用年份读取跨来源订阅,优先精确年份。""" if self._async_repository is None: raise RuntimeError("异步订阅查询端口未注册") if media_type not in (MediaType.MOVIE, MediaType.TV): @@ -140,13 +140,14 @@ class SubscriptionQueryService: title=title, season=season, ) - expected_year = str(year).strip() - return [ - self._to_public_view(record) - for record in records - if record.type == media_type.value - and str(record.year or "").strip() == expected_year + expected_year = str(year or "").strip() + matching_type = [record for record in records if record.type == media_type.value] + exact_year = [ + record for record in matching_type + if expected_year and str(record.year or "").strip() == expected_year ] + unknown_year = [record for record in matching_type if not str(record.year or "").strip()] + return [self._to_public_view(record) for record in exact_year + unknown_year] async def list_history( self, diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 637adb078..6e76e5486 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -604,6 +604,13 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized` 取消是幂等的状态请求,不会撤销已经提交到下载器的任务;批次详情中的状态和任务结果才是最终事实。 +### 跨来源订阅回显 + +`GET /api/v1/subscribe/media/{media_id}` 先按 `media_source`、`media_id` 和可选季号查询当前用户可访问的订阅。 +影视身份未命中且提供 `mtype`、`title` 时,会按类型、规范标题和可选季号跨来源查找; +有年份时优先精确年份,其次匹配订阅年份为空的未定档媒体;没有年份时只匹配年份也为空的订阅。 +音乐订阅始终只按媒体身份查询。 + ### 单条订阅搜索周期 `POST /api/v1/subscribe/` 和 `PUT /api/v1/subscribe/` 支持 `search_interval`: diff --git a/skills/moviepilot-api/api/subscription.md b/skills/moviepilot-api/api/subscription.md index 88b5904cf..a98aecb47 100644 --- a/skills/moviepilot-api/api/subscription.md +++ b/skills/moviepilot-api/api/subscription.md @@ -56,7 +56,7 @@ Purpose: Read local library and transfer-file coverage for one accessible subscr ### `subscription.find` `GET /api/v1/subscribe/media/{media_id}`; policy effect: `safe_read`. -Purpose: Find one accessible subscription by canonical media identity and optional season. +Purpose: Find one accessible subscription by canonical media identity and optional season. For movies and TV, if exact identity misses and mtype/title are supplied, match across sources by type, normalized title, optional season, and year. An exact year ranks first; a subscription with no year may match when the card has a year, while a card with no year only matches a subscription with no year. Music uses exact identity only. - `path_params`: `media_id*` (string): Source-native media ID. Always pair it with the exact media_source returned by search. - `query`: `media_source*` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `mtype` (MediaType|null): MoviePilot media type or subscription-history category required by the operation.; `music_type` (string|null): Music identity level: recording, album, or artist where supported.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `title` (string|null): Media, torrent, subscription, or history title used by the operation.; `year` (string|null): Release or premiere year used to disambiguate the media title. - `body`: none diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 43437e8c3..eb215e178 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -669,8 +669,8 @@ class TestSubscribeEndpoint: season=None, ) - def test_subscribe_media_identity_keeps_tmdb_lookup_strict(self): - """TMDB 身份可用时不得退化为同名年份查询。""" + def test_subscribe_media_identity_falls_back_from_tmdb(self): + """TMDB 身份未命中时也应找到其它来源的同名同年订阅。""" from app.api.endpoints.subscribe import subscribe_media_identity repository = _SubscriptionRepositoryFake( @@ -697,8 +697,112 @@ class TestSubscribeEndpoint: ) ) + assert result.id == 33 + repository.async_list_by_title.assert_awaited_once_with(title="同名电影", season=None) + + @pytest.mark.parametrize( + ("card_source", "subscription_source", "card_year"), + [ + (MediaSource("iqiyidiscover"), MediaSource.TMDB, "2026"), + (MediaSource.TMDB, MediaSource("iqiyidiscover"), None), + (MediaSource.TMDB, MediaSource("iqiyidiscover"), ""), + ], + ) + def test_subscribe_media_identity_matches_unknown_subscription_year( + self, card_source, subscription_source, card_year, + ): + """跨源卡片的年份可有可无,但订阅无年份时仍能回显。""" + from app.api.endpoints.subscribe import subscribe_media_identity + + repository = _SubscriptionRepositoryFake( + _EndpointSubscribe( + id=34, + username="alice", + name="未定档剧", + year=None, + type=MediaType.TV.value, + media_source=subscription_source, + media_id="existing-id", + season=1, + ) + ) + + result = asyncio.run( + subscribe_media_identity( + media_id="card-id", + media_source=card_source, + title="未定档剧 第一季", + year=card_year, + mtype=MediaType.TV, + query=_subscription_query(repository), + current_user=_EndpointUser(name="alice", is_superuser=False), + ) + ) + + assert result.id == 34 + repository.async_list_by_title.assert_awaited_once_with(title="未定档剧", season=1) + + @pytest.mark.parametrize("card_year", [None, "", "2025"]) + def test_subscribe_media_identity_rejects_conflicting_known_year( + self, card_year, + ): + """无年份卡片不能串到已定档剧,已知年份也不能匹配冲突年份。""" + from app.api.endpoints.subscribe import subscribe_media_identity + + repository = _SubscriptionRepositoryFake( + _EndpointSubscribe( + id=35, + username="alice", + name="同名剧", + year="2024", + type=MediaType.TV.value, + media_source=MediaSource.Douban, + media_id="existing-id", + ) + ) + result = asyncio.run( + subscribe_media_identity( + media_id="card-id", + media_source=MediaSource.TMDB, + title="同名剧", + year=card_year, + mtype=MediaType.TV, + query=_subscription_query(repository), + current_user=_EndpointUser(name="alice", is_superuser=False), + ) + ) + assert result.id is None - repository.async_list_by_title.assert_not_awaited() + + def test_subscribe_media_identity_prefers_matching_year_over_unknown_year(self): + """同名订阅同时含已知和未知年份时,优先返回确切年份。""" + from app.api.endpoints.subscribe import subscribe_media_identity + + repository = _SubscriptionRepositoryFake( + _EndpointSubscribe( + id=36, username="alice", name="同名剧", year=None, + type=MediaType.TV.value, media_source=MediaSource.Douban, + media_id="undated", + ), + _EndpointSubscribe( + id=37, username="alice", name="同名剧", year="2026", + type=MediaType.TV.value, media_source=MediaSource.Douban, + media_id="dated", + ), + ) + result = asyncio.run( + subscribe_media_identity( + media_id="card-id", + media_source=MediaSource.TMDB, + title="同名剧", + year="2026", + mtype=MediaType.TV, + query=_subscription_query(repository), + current_user=_EndpointUser(name="alice", is_superuser=False), + ) + ) + + assert result.id == 37 def test_delete_subscribe_by_media_identity_deletes_owner_candidate(self): """