diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 75d995b7d..f8e2043fd 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -664,7 +664,8 @@ async def popular_subscribes( media.title = title raw_year = sub.get("year") if isinstance(raw_year, (int, float, str)) and str(raw_year).strip(): - media.year = int(raw_year) + # 共享数据可能使用数值年份,公开响应仍遵循字符串年份契约 + media.year = str(int(float(raw_year))) if season is not None: media.season = season raw_description = sub.get("description") diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index dadb5da6a..db1728ff0 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -254,6 +254,12 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): ) -> Tuple[bool, str]: """ 先确认任务暂停再按来源集数映射选集,兼容 RPC 返回正在运行的重复任务。 + + :param server: 已配置的 Transmission 客户端 + :param torrent_hash: 需要暂停、选集并恢复的任务哈希 + :param episodes: 需要完整匹配的目标集数;空集合不启动任务 + :param custom_words: 来源识别词,按行参与物理文件名识别 + :return: 是否确认选集及启动成功,以及失败时的下载器核查说明 """ if not server.stop_torrents(torrent_hash): return False, f"选集失败,任务 {torrent_hash} 无法确认暂停,未执行选集,请在下载器核实" @@ -275,7 +281,7 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): unwanted.append(item.id) finally: torrent_files.clear() - if not set(episodes).issubset(matched): + if not wanted or not set(episodes).issubset(matched): return False, f"{failure}:未找到目标集数 {sorted(set(episodes) - matched)}" if not server.set_files(torrent_hash, wanted) or not server.set_unwanted_files(torrent_hash, unwanted): return False, f"{failure}:文件选择设置未确认" diff --git a/app/modules/transmission/transmission.py b/app/modules/transmission/transmission.py index a318b014a..25ff17c45 100755 --- a/app/modules/transmission/transmission.py +++ b/app/modules/transmission/transmission.py @@ -346,11 +346,15 @@ class Transmission: 将选定文件设为需要下载。 :param tid: 种子哈希 - :param file_ids: 种子内部的文件索引列表 - :return: 请求完成时返回 True;未连接或请求失败时返回 False,不代表任务已完成该动作 + :param file_ids: 种子内部的文件索引列表;空列表不修改任何文件 + :return: 已连接且无需修改或请求完成时返回 True;未连接或请求失败时返回 False, + 不代表任务已完成该动作 """ if not self.trc: return False + # Transmission 把空数组解释为全部文件,空选择不能直接传入 RPC + if not file_ids: + return True try: self.trc.change_torrent(ids=tid, files_wanted=file_ids) return True @@ -363,11 +367,15 @@ class Transmission: 将选定文件设为不下载。 :param tid: 种子哈希 - :param file_ids: 种子内部的文件索引列表 - :return: 请求完成时返回 True;未连接或请求失败时返回 False,不代表任务已完成该动作 + :param file_ids: 种子内部的文件索引列表;空列表不修改任何文件 + :return: 已连接且无需修改或请求完成时返回 True;未连接或请求失败时返回 False, + 不代表任务已完成该动作 """ if not self.trc: return False + # 空的不下载列表表示无需排除文件,不能触发 RPC 的全部文件语义 + if not file_ids: + return True try: self.trc.change_torrent(ids=tid, files_unwanted=file_ids) return True diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 56ae4e945..e44558bb9 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -292,7 +292,6 @@ }, "app/api/endpoints/subscribe.py": { "arg-type": 5, - "assignment": 1, "no-any-return": 1, "no-untyped-def": 1, "type-arg": 2 diff --git a/tests/test_api_media_types.py b/tests/test_api_media_types.py index 7d69f30da..00acc0a65 100644 --- a/tests/test_api_media_types.py +++ b/tests/test_api_media_types.py @@ -7,6 +7,8 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient from app.api.endpoints import douban, media, mediaserver, subscribe, tmdb, transfer from app.schemas.file import FileItem @@ -116,7 +118,9 @@ def test_popular_subscriptions_normalize_record_type(monkeypatch, type_name, exp def test_popular_subscriptions_preserves_optional_fields_and_filters_invalid_records(monkeypatch) -> None: - """热门订阅应转换可选字段,并跳过类型或人数无效的记录。""" + """ + 热门订阅应转换可选字段,并跳过类型或人数无效的记录。 + """ statistic = AsyncMock(return_value=[ { "name": "测试媒体", @@ -144,11 +148,38 @@ def test_popular_subscriptions_preserves_optional_fields_and_filters_invalid_rec assert result[0]["type"] == MediaType.TV.value assert result[0]["media_id"] == "123" assert result[0]["season"] == 2 - assert result[0]["year"] == 2024 + assert result[0]["year"] == "2024" assert result[0]["vote_average"] == 8.5 assert result[1]["type"] == MediaType.MOVIE.value +@pytest.mark.parametrize("year,expected", [ + ("2023", "2023"), (2023, "2023"), (2023.0, "2023"), + ("2023.0", "2023"), (" 2023 ", "2023"), (None, None), ("", None), (" ", None), +]) +def test_popular_subscriptions_validates_year_in_http_response(monkeypatch, year, expected) -> None: + """ + 真实响应路由应把共享年份输出为字符串或空值,避免响应校验导致 500。 + """ + statistic = AsyncMock(return_value=[{ + "name": "测试媒体", "type": "movie", "count": 1, "year": year, + }]) + monkeypatch.setattr( + subscribe.MoviePilotServerHelper, "async_get_subscribe_statistic", statistic, + ) + app = FastAPI() + app.dependency_overrides[subscribe.verify_token] = lambda: None + app.include_router(subscribe.router, prefix="/api/v1/subscribe") + + with TestClient(app) as client: + response = client.get("/api/v1/subscribe/popular", params={"stype": "电影", "count": 5}) + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["data"][0]["year"] == expected + + def test_share_subscribe_rejects_incomplete_payload() -> None: """分享订阅缺少必要字段时应直接返回参数错误。""" from app.api.endpoints.subscribe import subscribe_share diff --git a/tests/test_downloader_submission.py b/tests/test_downloader_submission.py index 5599d1da9..c626f790b 100644 --- a/tests/test_downloader_submission.py +++ b/tests/test_downloader_submission.py @@ -14,6 +14,7 @@ from app.chain.download.facade import DownloadChain from app.domain.context import Context from app.modules.qbittorrent import QbittorrentModule from app.modules.transmission import TransmissionModule +from app.modules.transmission.transmission import Transmission _CUSTOM_WORDS = ( "A.Will.Eternal.S04 => 一念永恒{[tmdbid=107371;type=tv]}S01 " @@ -112,6 +113,58 @@ def test_subscription_mapping_reaches_real_downloader_selection(downloader): chain._record_rejected_download.assert_not_called() +@pytest.mark.parametrize("file_count", [1, 2]) +def test_transmission_all_files_wanted_starts_without_unwanted_rpc(downloader, file_count) -> None: + """ + 单集和多集文件全部命中时,经真实客户端封装仅发送需要下载的索引后启动。 + """ + module, server, content, torrent_hash = downloader + if not isinstance(module, TransmissionModule): + pytest.skip("Transmission 空文件数组的 RPC 语义") + list_files = server.get_files.side_effect + server.get_files.side_effect = lambda *args: list_files(*args)[:file_count] + client = Transmission.__new__(Transmission) + client.trc = Mock() + server.set_files = Mock(wraps=client.set_files) + server.set_unwanted_files = Mock(wraps=client.set_unwanted_files) + + def start_selected_task(_torrent_hash) -> bool: + """ + 启动之前确认所有目标文件已选中,且未发送覆盖全部文件的不下载请求。 + """ + client.trc.change_torrent.assert_called_once_with( + ids=torrent_hash, files_wanted=list(range(file_count)), + ) + return True + + server.start_torrents.side_effect = start_selected_task + result = module.download( + content, Path("/downloads"), "", episodes=set(range(170, 170 + file_count)), + custom_words=_CUSTOM_WORDS, + ) + + assert result[1] == torrent_hash + client.trc.change_torrent.assert_called_once_with(ids=torrent_hash, files_wanted=list(range(file_count))) + server.start_torrents.assert_called_once_with(torrent_hash) + + +def test_transmission_empty_episode_selection_never_starts_task() -> None: + """ + 选集入口没有目标集数时应保留暂停状态,不把空数组转换为全部文件。 + """ + server = Mock() + server.stop_torrents.return_value = True + server.get_files.return_value = [SimpleNamespace(id=0, name="Show.S01E01.mkv")] + + selected, message = TransmissionModule._select_download_files(server, "hash", set(), None) + + assert selected is False + assert "保持暂停" in message + server.set_files.assert_not_called() + server.set_unwanted_files.assert_not_called() + server.start_torrents.assert_not_called() + + @pytest.mark.parametrize("failure", ["unmatched", "missing_files", "priority", "start"]) def test_selection_failure_keeps_paused_task_and_never_settles_success(downloader, failure): """ diff --git a/tests/test_transmission_client_state.py b/tests/test_transmission_client_state.py index 6fe545b03..53d30fe64 100644 --- a/tests/test_transmission_client_state.py +++ b/tests/test_transmission_client_state.py @@ -82,3 +82,34 @@ def test_explicit_speed_limits_preserve_integer_conversion(download_limit, uploa speed_limit_down_enabled=bool(download_limit), speed_limit_up_enabled=bool(upload_limit), ) + + +@pytest.mark.parametrize("method,field", [ + ("set_files", "files_wanted"), ("set_unwanted_files", "files_unwanted"), +]) +@pytest.mark.parametrize("file_ids", [[], [0], [0, 2]]) +def test_file_selection_never_sends_empty_rpc_arrays(method, field, file_ids) -> None: + """ + 空选择不得发送代表全部文件的 RPC 数组,非空索引应原样转发。 + """ + client = Transmission.__new__(Transmission) + client.trc = Mock() + + assert getattr(client, method)("hash", file_ids) is True + + if file_ids: + client.trc.change_torrent.assert_called_once_with(ids="hash", **{field: file_ids}) + else: + client.trc.change_torrent.assert_not_called() + + +@pytest.mark.parametrize("method", ["set_files", "set_unwanted_files"]) +@pytest.mark.parametrize("file_ids", [[], [0]]) +def test_file_selection_without_connection_keeps_failure(method, file_ids) -> None: + """ + 未连接时仍返回失败,空选择不能掩盖客户端不可用状态。 + """ + client = Transmission.__new__(Transmission) + client.trc = None + + assert getattr(client, method)("hash", file_ids) is False