fix: restore popular years and safe Transmission selection

This commit is contained in:
jxxghp
2026-10-19 12:17:56 +08:00
parent bf4ef4214e
commit 4f98486e25
7 changed files with 138 additions and 9 deletions

View File

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

View File

@@ -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}:文件选择设置未确认"

View File

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

View File

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

View File

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

View File

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

View File

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