mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 22:59:46 +08:00
fix: restore popular years and safe Transmission selection
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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}:文件选择设置未确认"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user