diff --git a/app/chain/subscribe/query.py b/app/chain/subscribe/query.py index 48c877f16..0e998eb2c 100644 --- a/app/chain/subscribe/query.py +++ b/app/chain/subscribe/query.py @@ -544,5 +544,13 @@ class SubscribeQueryOwner(_SubscribeOwnerBase): self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True) return True, no_exists + # 手动下载不会写入订阅 note,检查到部分入库时也需同步卡片进度。 + if subscribe.type == MediaType.TV.value and not subscribe.best_version and no_exists: + self._SubscribeChain__refresh_subscribe_progress_with_no_exists( + subscribe=subscribe, + no_exists=no_exists, + scene="existing_media", + ) + # 返回结果,表示媒体未完全下载或存在 return False, no_exists diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 61fd437ac..cb68d7588 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -1535,10 +1535,12 @@ class TestSubscribeChain: calls = [] def fake_refresh(_self, subscribe, mediainfo, meta=None, mediakey=None): + """隔离总集数写入并记录执行顺序。""" calls.append(("refresh", subscribe.total_episode)) return replace(subscribe, total_episode=20) def fake_resolve(_self, subscribe, meta, mediainfo, mediakey=None): + """返回尚未完成的缺集结果。""" calls.append(("resolve", subscribe.total_episode)) return False, {"media-key": {1: SimpleNamespace(episodes=[11], total_episode=20, start_episode=1)}} @@ -1554,6 +1556,10 @@ class TestSubscribeChain: "resolve_subscribe_missing", fake_resolve, ), + patch.object( + chain, + "_SubscribeChain__refresh_subscribe_progress_with_no_exists", + ) as refresh_progress, ): exist_flag, no_exists = chain.check_and_handle_existing_media( subscribe=subscribe, @@ -1565,6 +1571,11 @@ class TestSubscribeChain: assert not (exist_flag) assert calls == [("refresh", 10), ("resolve", 20)] assert no_exists["media-key"][1].episodes == [11] + refresh_progress.assert_called_once_with( + subscribe=replace(subscribe, total_episode=20), + no_exists=no_exists, + scene="existing_media", + ) def test_best_version_full_pack_first_keeps_whole_missing_for_custom_start_episode(self): """分集洗版优先全集时,空集列表仍表示下载链按整季资源处理。""" diff --git a/tests/test_subscribe_existing_progress.py b/tests/test_subscribe_existing_progress.py new file mode 100644 index 000000000..c676632c1 --- /dev/null +++ b/tests/test_subscribe_existing_progress.py @@ -0,0 +1,127 @@ +"""手动下载后,订阅检查应把部分入库事实同步到卡片进度。""" + +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import app.chain.subscribe.refresh as subscribe_refresh +from app.application.subscription.contract import SubscriptionSnapshot, build_subscribe_meta +from app.chain.download import DownloadChain +from app.chain.subscribe.facade import SubscribeChain +from app.domain.context import MediaInfo +from app.schemas.mediaserver import ExistMediaInfo +from app.schemas.types import MediaSource, MediaType + + +@pytest.fixture +def progress_scope(monkeypatch): + """保留缺集计算和进度派生,仅隔离媒体库与持久化边界。""" + subscribe = SubscriptionSnapshot( + id=6966, name="Doctor X", type=MediaType.TV.value, + media_source=MediaSource.TMDB, media_id="6966", season=1, + total_episode=12, lack_episode=11, note=[1], state="R", + last_update="2026-10-01 12:00:00", + ) + media = MediaInfo( + title="Doctor X", type=MediaType.TV, + media_source=MediaSource.TMDB, media_id="6966", + seasons={1: list(range(1, 13))}, + ) + download = object.__new__(DownloadChain) + download._media_exists_with_server_cache = Mock( + return_value=ExistMediaInfo(seasons={1: [1, 2]}) + ) + monkeypatch.setattr(subscribe_refresh, "DownloadChain", lambda: download) + chain = object.__new__(SubscribeChain) + chain.finish_subscribe_or_not = Mock() + chain._SubscribeChain__apply_episodes_refresh = Mock(return_value=12) + state = SimpleNamespace(subscribe=subscribe, media=media, chain=chain, download=download) + + def apply_update(current, fields, **_kwargs): + """保存派生快照,供重复检查读取同一持久化状态。""" + state.subscribe = replace(current, **fields) + return state.subscribe + + chain._SubscribeChain__apply_subscribe_update = Mock(side_effect=apply_update) + return state + + +def _check(scope): + """使用当前订阅快照执行真实的现存媒体检查。""" + return scope.chain.check_and_handle_existing_media( + subscribe=scope.subscribe, meta=build_subscribe_meta(scope.subscribe), + mediainfo=scope.media, mediakey="tmdb:6966", + ) + + +def test_partial_library_updates_stale_card_progress(progress_scope): + """前两集已入库且 note 只含第一集时,卡片应从 1/12 校正为 2/12。""" + for _ in range(2): + completed, missing = _check(progress_scope) + assert not completed + assert missing["tmdb:6966"][1].episodes == list(range(3, 13)) + assert progress_scope.subscribe.lack_episode == 10 + assert progress_scope.subscribe.note == [1] + assert progress_scope.subscribe.last_update == "2026-10-01 12:00:00" + progress_scope.chain.finish_subscribe_or_not.assert_not_called() + + +def test_partial_library_respects_start_and_downloaded_facts(progress_scope): + """进度复用订阅范围和已下载事实,不重复计数或包含其他季。""" + progress_scope.subscribe = replace(progress_scope.subscribe, start_episode=3, note=[3, 5]) + progress_scope.download._media_exists_with_server_cache.return_value = ExistMediaInfo( + seasons={1: [1, 2, 3, 4, 4], 2: list(range(1, 13))} + ) + + completed, missing = _check(progress_scope) + + assert not completed + assert missing["tmdb:6966"][1].episodes == list(range(6, 13)) + assert progress_scope.subscribe.lack_episode == 7 + progress_scope.chain.finish_subscribe_or_not.assert_not_called() + + +def test_failed_missing_query_preserves_progress(progress_scope): + """无法取得缺集事实时,不能把未知状态当成全部完成。""" + progress_scope.chain.resolve_subscribe_missing = Mock(return_value=(False, {})) + + completed, missing = _check(progress_scope) + + assert not completed + assert missing == {} + assert progress_scope.subscribe.lack_episode == 11 + progress_scope.chain._SubscribeChain__apply_subscribe_update.assert_not_called() + progress_scope.chain.finish_subscribe_or_not.assert_not_called() + + +def test_best_version_check_preserves_quality_progress(progress_scope): + """洗版订阅继续按下载质量事实处理,不用部分入库覆盖其进度。""" + progress_scope.subscribe = replace( + progress_scope.subscribe, best_version=1, episode_priority={"1": 80}, + ) + + completed, missing = _check(progress_scope) + + assert not completed + assert missing + assert progress_scope.subscribe.lack_episode == 11 + assert progress_scope.subscribe.episode_priority == {"1": 80} + progress_scope.chain._SubscribeChain__apply_subscribe_update.assert_not_called() + progress_scope.chain.finish_subscribe_or_not.assert_not_called() + + +def test_complete_library_keeps_completion_path(progress_scope): + """整季入库仍交给原有完成流程,避免在检查入口重复结算。""" + progress_scope.download._media_exists_with_server_cache.return_value = ExistMediaInfo( + seasons={1: list(range(1, 13))} + ) + + completed, missing = _check(progress_scope) + + assert completed + assert missing == {} + progress_scope.chain.finish_subscribe_or_not.assert_called_once() + assert progress_scope.chain.finish_subscribe_or_not.call_args.kwargs["force"] + progress_scope.chain._SubscribeChain__apply_subscribe_update.assert_not_called() diff --git a/tests/test_subscribe_missing_scope.py b/tests/test_subscribe_missing_scope.py index 9b40a745e..b5893009e 100644 --- a/tests/test_subscribe_missing_scope.py +++ b/tests/test_subscribe_missing_scope.py @@ -50,6 +50,9 @@ def scope(monkeypatch): chain = object.__new__(SubscribeChain) chain.subscription_repository = SimpleNamespace(get=Mock(return_value=subscribe)) chain.finish_subscribe_or_not = Mock() + chain._SubscribeChain__apply_subscribe_update = Mock( + side_effect=lambda current, fields, **_kwargs: replace(current, **fields) + ) monkeypatch.setattr(subscribe_refresh, "DownloadChain", lambda: download) monkeypatch.setattr(subscribe_policy, "DownloadChain", lambda: download) return SimpleNamespace(subscribe=subscribe, media=media, download=download, chain=chain) diff --git a/tests/test_subscribe_paged_search.py b/tests/test_subscribe_paged_search.py index c06dc61c7..9b0a19e61 100644 --- a/tests/test_subscribe_paged_search.py +++ b/tests/test_subscribe_paged_search.py @@ -66,6 +66,7 @@ def _missing(scope, item): def test_paged_search_keeps_new_missing_episodes_in_completion(subscription, monkeypatch): + """搜索中扩展总集数后,下载结算保留新缺集,检查阶段可先同步旧范围。""" scope = subscription target = SubscriptionSearchTarget(scope.initial, build_subscribe_meta(scope.initial), scope.media, _missing(scope, scope.initial), "tmdb:1") @@ -76,6 +77,7 @@ def test_paged_search_keeps_new_missing_episodes_in_completion(subscription, mon monkeypatch.setattr(scope.chain, "get_sub_sites", lambda _subscribe: [1]) calls = [] def request(**params): + """搜索首轮后模拟用户扩展订阅范围。""" calls.append(params["page"]) # 开始搜索后用户将总集数从 1 增加到 2。 scope.current["value"] = replace(scope.current["value"], total_episode=2) @@ -90,7 +92,12 @@ def test_paged_search_keeps_new_missing_episodes_in_completion(subscription, mon scope.download.download_single.assert_called_once() scope.finished.assert_not_called() assert _missing(scope, scope.current["value"])["tmdb:1"][1].episodes == [2] - for call in scope.chain._SubscribeChain__refresh_subscribe_progress_with_no_exists.call_args_list: + completion_calls = [ + call for call in scope.chain._SubscribeChain__refresh_subscribe_progress_with_no_exists.call_args_list + if call.kwargs.get("scene") == "download" + ] + assert completion_calls + for call in completion_calls: assert call.kwargs["no_exists"]["tmdb:1"][1].episodes == [2]