fix(subscribe): resolve cross-source video status without premiere year

This commit is contained in:
jxxghp
2026-09-28 16:59:30 +08:00
parent 9d37ee4687
commit 6cdd4dbc66
5 changed files with 125 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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