Files
shelfmark/tests/prowlarr/test_remote_path_mappings.py
T
splitsec2 525b094b57 fix(clients): hand a finished wrong-type directory on instead of polling out the timeout (#1573)
A torrent that finishes with the wrong kind of files in it holds the
download for the whole completed-path timeout (60 seconds by default, up
to an hour) and then fails with "Completed download has no stable
supported files yet." An ebook task whose torrent holds only an `.m4b`,
an audiobook task that got an `.epub`, or a `.pdf`-only release with the
default format list all land here. In v1.4.0 these failed with the
specific format error from post-processing, but the wait for the sync to
settle (#1464) only counts files of the task's own type, so it keeps
polling for files that are never going to appear.

If the directory has no supported files, `_wait_for_completed_path` now
looks for finished book or audio files that this task does not accept
(the audiobook formats, the default ebook formats and pdf). When it
finds some and they are the same on two polls in a row, it returns the
path so post-processing can say what is wrong with the formats. Cover
art and notes alone do not count, because that is what a directory looks
like while the real files are still syncing over a remote mount, so
those keep waiting as before. A wrong-type file that is still growing
keeps waiting too. With no wait configured there is only one sample, and
a wrong-type file is handed on straight away.

The existing test that pins "no book files, no wait configured" uses a
jpg, so it stays as it is. I extended the test helper to take a content
type, which the wait tests never passed before. Four new cases fail
without the change (a wrong-type directory for ebook, audiobook and pdf
tasks), and two more check that sidecar-only and still-growing
directories keep waiting. Full suite passes.
2026-10-24 20:41:21 -06:00

943 lines
32 KiB
Python

"""Tests for remote path mappings.
This focuses on integration of mapping logic into the Prowlarr handler.
"""
import errno
import tempfile
from pathlib import Path
from threading import Event
from unittest.mock import MagicMock, patch
import pytest
from shelfmark.core.models import DownloadTask
from shelfmark.download.clients import DownloadState, DownloadStatus
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
class ProgressRecorder:
def __init__(self):
self.progress_values = []
self.status_updates = []
def progress_callback(self, progress: float):
self.progress_values.append(progress)
def status_callback(self, status: str, message: str | None):
self.status_updates.append((status, message))
def test_remaps_completed_path_when_remote_path_missing():
with tempfile.TemporaryDirectory() as tmp_dir:
local_file = Path(tmp_dir) / "local" / "book.epub"
local_file.parent.mkdir(parents=True)
local_file.write_text("test content")
remote_path = "/remote/downloads/book.epub"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=remote_path,
)
mock_client.get_download_path.return_value = remote_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": "/remote/downloads",
"localPath": str(local_file.parent),
}
]
return default
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
),
patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
),
patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
),
patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
),
patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
),
):
handler = ProwlarrHandler()
task = DownloadTask(task_id="poll-mapping-test", source="prowlarr", title="Test Book")
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result == str(local_file)
assert task.original_download_path == str(local_file)
def test_remap_prefers_mapping_when_original_exists():
with tempfile.TemporaryDirectory() as tmp_dir:
remote_dir = Path(tmp_dir) / "remote" / "downloads"
remote_dir.mkdir(parents=True)
remote_file = remote_dir / "book.epub"
remote_file.write_text("remote content")
local_dir = Path(tmp_dir) / "local" / "downloads"
local_dir.mkdir(parents=True)
local_file = local_dir / "book.epub"
local_file.write_text("local content")
remote_path = str(remote_file)
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=remote_path,
)
mock_client.get_download_path.return_value = remote_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": str(remote_dir),
"localPath": str(local_dir),
}
]
return default
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
),
patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
),
patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
),
patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
),
patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
),
):
handler = ProwlarrHandler()
task = DownloadTask(task_id="poll-mapping-prefer", source="prowlarr", title="Test Book")
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result == str(local_file)
assert task.original_download_path == str(local_file)
def test_remap_fails_when_mapping_exists_but_path_missing():
with tempfile.TemporaryDirectory() as tmp_dir:
remote_dir = Path(tmp_dir) / "remote" / "downloads"
remote_dir.mkdir(parents=True)
remote_file = remote_dir / "book.epub"
remote_file.write_text("remote content")
local_dir = Path(tmp_dir) / "local" / "downloads"
local_dir.mkdir(parents=True)
local_file = local_dir / "book.epub"
remote_path = str(remote_file)
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=remote_path,
)
mock_client.get_download_path.return_value = remote_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": str(remote_dir),
"localPath": str(local_dir),
}
]
if key == "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT":
return 0
return default
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
),
patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
),
patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
),
patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
),
patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
),
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="poll-mapping-missing", source="prowlarr", title="Test Book"
)
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert any(status == "error" for status, _ in recorder.status_updates)
assert not local_file.exists()
def test_wait_for_completed_path_allows_delayed_remapped_file(monkeypatch, tmp_path):
import shelfmark.download.clients.base_handler as base_handler
local_file = tmp_path / "local" / "downloads" / "book.epub"
remote_path = "/remote/downloads/book.epub"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.get_download_path.return_value = remote_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": "/remote/downloads",
"localPath": str(local_file.parent),
}
]
if key == "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT":
return 1
return default
probe_calls = 0
original_probe = base_handler._probe_completed_path
def delayed_probe(path: Path):
nonlocal probe_calls
probe_calls += 1
if path == local_file and probe_calls == 2:
local_file.parent.mkdir(parents=True)
local_file.write_text("synced")
return original_probe(path)
handler = ProwlarrHandler()
recorder = ProgressRecorder()
monkeypatch.setattr(base_handler.config, "get", config_get)
monkeypatch.setattr(base_handler, "_probe_completed_path", delayed_probe)
monkeypatch.setattr(handler, "_completed_path_retry_interval", lambda: 0.01)
resolved_path, error = handler._wait_for_completed_path(
mock_client,
"download_id",
cancel_flag=Event(),
status_callback=recorder.status_callback,
)
assert resolved_path == local_file
assert error is None
assert ("locating", "Waiting for completed files...") in recorder.status_updates
def test_waits_for_supported_file_to_stop_growing_in_remapped_directory(monkeypatch, tmp_path):
import shelfmark.download.clients.base_handler as base_handler
local_dir = tmp_path / "local" / "downloads" / "book"
local_dir.mkdir(parents=True)
(local_dir / "book.cue").write_text("cue")
(local_dir / "cover.jpg").write_text("cover")
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.get_download_path.return_value = "/remote/downloads/book"
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": "/remote/downloads",
"localPath": str(local_dir.parent),
}
]
if key == "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT":
return 1
return default
media_file = local_dir / "book.m4b"
sleep_calls = 0
def advance_sync(_seconds: float) -> None:
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls == 1:
media_file.write_bytes(b"a")
elif sleep_calls == 2:
media_file.write_bytes(b"audio")
handler = ProwlarrHandler()
recorder = ProgressRecorder()
monkeypatch.setattr(base_handler.config, "get", config_get)
monkeypatch.setattr(handler, "_completed_path_retry_interval", lambda: 0.01)
monkeypatch.setattr(base_handler.time, "sleep", advance_sync)
resolved_path, error = handler._wait_for_completed_path(
mock_client,
"download_id",
cancel_flag=None,
status_callback=recorder.status_callback,
content_type="audiobook",
)
assert resolved_path == local_dir
assert error is None
assert sleep_calls == 3
assert recorder.status_updates.count(("locating", "Waiting for completed files...")) == 3
def _wait_for_local_directory(
monkeypatch,
directory: Path,
*,
timeout: object,
content_type: str | None = None,
on_sleep=None,
expected=None,
):
import shelfmark.download.clients.base_handler as base_handler
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.get_download_path.return_value = str(directory)
mock_client.get_expected_files.return_value = expected
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return []
if key == "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT":
return timeout
return default
handler = ProwlarrHandler()
monkeypatch.setattr(base_handler.config, "get", config_get)
monkeypatch.setattr(handler, "_completed_path_retry_interval", lambda: 0.01)
monkeypatch.setattr(base_handler.time, "sleep", on_sleep or (lambda _seconds: None))
return handler._wait_for_completed_path(
mock_client,
"download_id",
cancel_flag=None,
status_callback=ProgressRecorder().status_callback,
content_type=content_type,
)
def test_a_directory_holding_only_an_archive_is_returned_for_extraction(monkeypatch, tmp_path):
# A scene release: the book is inside the .rar, which post-processing extracts.
release = tmp_path / "Book.Title-GROUP"
release.mkdir()
(release / "book.rar").write_bytes(b"Rar!archive")
(release / "book.nfo").write_text("nfo")
(release / "book.sfv").write_text("sfv")
resolved_path, error = _wait_for_local_directory(monkeypatch, release, timeout=1)
assert resolved_path == release
assert error is None
def test_a_completed_directory_is_returned_when_no_wait_is_configured(monkeypatch, tmp_path):
release = tmp_path / "Book"
release.mkdir()
(release / "book.epub").write_bytes(b"epub")
resolved_path, error = _wait_for_local_directory(monkeypatch, release, timeout=0)
assert resolved_path == release
assert error is None
def test_a_directory_without_book_files_still_fails_when_no_wait_is_configured(
monkeypatch, tmp_path
):
release = tmp_path / "Book"
release.mkdir()
(release / "cover.jpg").write_bytes(b"jpg")
resolved_path, error = _wait_for_local_directory(monkeypatch, release, timeout=0)
assert resolved_path is None
assert error == "Completed download has no stable supported files yet."
def _expected(*files: tuple[str, int]):
from shelfmark.download.clients import ExpectedFile
return [ExpectedFile(name, size) for name, size in files]
def _batch_release(tmp_path, count: int, *, present: int):
"""A 23-file style book folder that a sync has copied `present` files of."""
release = tmp_path / "Release"
release.mkdir()
for number in range(1, present + 1):
(release / f"{number:02d}.epub").write_bytes(b"x" * (100 + number))
expected = _expected(*((f"Release/{n:02d}.epub", 100 + n) for n in range(1, count + 1)))
return release, expected
def test_a_folder_with_only_some_of_the_clients_files_keeps_waiting(monkeypatch, tmp_path):
"""The sync paused after 10 of 23 files and the folder stopped changing (#1561)."""
release, expected = _batch_release(tmp_path, 23, present=10)
resolved_path, error = _wait_for_local_directory(
monkeypatch, release, timeout=1, expected=expected
)
assert resolved_path is None
assert error == "Completed download is not fully on disk yet (10 of 23 files)."
def test_a_truncated_file_does_not_count_as_present(monkeypatch, tmp_path):
release, expected = _batch_release(tmp_path, 3, present=3)
(release / "02.epub").write_bytes(b"x" * 5)
resolved_path, error = _wait_for_local_directory(
monkeypatch, release, timeout=1, expected=expected
)
assert resolved_path is None
assert error == "Completed download is not fully on disk yet (2 of 3 files)."
def test_the_import_goes_ahead_once_the_rest_of_the_files_arrive(monkeypatch, tmp_path):
release, expected = _batch_release(tmp_path, 5, present=2)
sleeps = 0
def sync_more(_seconds: float) -> None:
nonlocal sleeps
sleeps += 1
arriving = release / f"{2 + sleeps:02d}.epub"
if 2 + sleeps <= 5:
arriving.write_bytes(b"x" * (100 + 2 + sleeps))
resolved_path, error = _wait_for_local_directory(
monkeypatch, release, timeout=1, expected=expected, on_sleep=sync_more
)
assert resolved_path == release
assert error is None
assert sleeps >= 3
def test_a_complete_folder_is_accepted(monkeypatch, tmp_path):
release, expected = _batch_release(tmp_path, 4, present=4)
resolved_path, error = _wait_for_local_directory(
monkeypatch, release, timeout=1, expected=expected
)
assert resolved_path == release
assert error is None
def test_names_without_the_top_level_folder_also_match(monkeypatch, tmp_path):
"""A torrent saved without a subfolder is listed without one."""
release, _ = _batch_release(tmp_path, 3, present=3)
expected = _expected(*((f"{n:02d}.epub", 100 + n) for n in range(1, 4)))
resolved_path, error = _wait_for_local_directory(
monkeypatch, release, timeout=1, expected=expected
)
assert resolved_path == release
assert error is None
def test_a_single_file_still_syncing_is_not_accepted(monkeypatch, tmp_path):
book = tmp_path / "book.epub"
book.write_bytes(b"part")
resolved_path, error = _wait_for_local_directory(
monkeypatch, book, timeout=1, expected=_expected(("book.epub", 4000))
)
assert resolved_path is None
assert error == "Completed download is not fully on disk yet (0 of 1 files)."
def test_a_single_file_at_its_final_size_is_accepted(monkeypatch, tmp_path):
book = tmp_path / "book.epub"
book.write_bytes(b"x" * 4000)
resolved_path, error = _wait_for_local_directory(
monkeypatch, book, timeout=1, expected=_expected(("book.epub", 4000))
)
assert resolved_path == book
assert error is None
def test_a_client_without_a_file_list_keeps_the_old_behaviour(monkeypatch, tmp_path):
release, _ = _batch_release(tmp_path, 23, present=10)
resolved_path, error = _wait_for_local_directory(monkeypatch, release, timeout=1, expected=None)
assert resolved_path == release
assert error is None
@pytest.mark.parametrize(
("content_type", "filename"),
[
(None, "book.m4b"),
("book (fiction)", "recording.mp3"),
("audiobook", "book.epub"),
(None, "book.pdf"),
],
)
def test_a_finished_directory_of_the_wrong_kind_is_returned_without_waiting_out_the_timeout(
monkeypatch, tmp_path, content_type, filename
):
"""Post-processing can name the format problem; polling for 60 s to say it later cannot (F3)."""
release = tmp_path / "Release"
release.mkdir()
(release / filename).write_bytes(b"finished")
resolved_path, error = _wait_for_local_directory(
monkeypatch, release, timeout=1, content_type=content_type
)
assert resolved_path == release
assert error is None
def test_a_directory_with_only_cover_art_and_notes_keeps_waiting(monkeypatch, tmp_path):
"""The book may still be syncing: sidecar files alone are not a finished wrong-type result."""
release = tmp_path / "Release"
release.mkdir()
(release / "cover.jpg").write_bytes(b"jpg")
(release / "book.nfo").write_text("nfo")
resolved_path, error = _wait_for_local_directory(monkeypatch, release, timeout=1)
assert resolved_path is None
assert error == "Completed download has no stable supported files yet."
def test_a_wrong_kind_file_that_is_still_growing_keeps_waiting(monkeypatch, tmp_path):
release = tmp_path / "Release"
release.mkdir()
growing = release / "book.m4b"
growing.write_bytes(b"a")
sleeps = 0
def grow(_seconds: float) -> None:
nonlocal sleeps
sleeps += 1
growing.write_bytes(b"a" * (sleeps + 1))
resolved_path, error = _wait_for_local_directory(
monkeypatch, release, timeout=0.05, on_sleep=grow
)
# The file changes on every poll, so it is never stable and the wait runs out.
assert resolved_path is None
assert error == "Completed download has no stable supported files yet."
assert sleeps >= 2
def test_wait_for_completed_path_fails_fast_for_unsafe_mapping(monkeypatch, tmp_path):
import shelfmark.download.clients.base_handler as base_handler
remote_dir = tmp_path / "remote" / "downloads"
local_dir = tmp_path / "local" / "downloads"
raw_path = f"{remote_dir}/../escape/book.epub"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.get_download_path.return_value = raw_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": str(remote_dir),
"localPath": str(local_dir),
}
]
if key == "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT":
return 900
return default
handler = ProwlarrHandler()
recorder = ProgressRecorder()
monkeypatch.setattr(base_handler.config, "get", config_get)
monkeypatch.setattr(handler, "_completed_path_retry_interval", lambda: 999)
resolved_path, error = handler._wait_for_completed_path(
mock_client,
"download_id",
cancel_flag=Event(),
status_callback=recorder.status_callback,
)
assert resolved_path is None
assert error is not None
assert "rejected unsafe path" in error
assert mock_client.get_download_path.call_count == 1
assert recorder.status_updates == []
def test_wait_for_completed_path_fails_fast_for_permission_error(monkeypatch):
import shelfmark.download.clients.base_handler as base_handler
raw_path = "/downloads/book.epub"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.get_download_path.return_value = raw_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return []
if key == "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT":
return 900
return default
probe_calls = 0
def permission_denied_probe(_path: Path):
nonlocal probe_calls
probe_calls += 1
return False, PermissionError(errno.EACCES, "Permission denied", raw_path)
handler = ProwlarrHandler()
recorder = ProgressRecorder()
monkeypatch.setattr(base_handler.config, "get", config_get)
monkeypatch.setattr(base_handler, "_probe_completed_path", permission_denied_probe)
monkeypatch.setattr(handler, "_completed_path_retry_interval", lambda: 999)
resolved_path, error = handler._wait_for_completed_path(
mock_client,
"download_id",
cancel_flag=Event(),
status_callback=recorder.status_callback,
)
assert resolved_path is None
assert error is not None
assert "not accessible" in error
assert probe_calls == 1
assert recorder.status_updates == []
def test_remaps_windows_path_to_linux():
"""Test that Windows paths from external download clients are correctly remapped."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create a local file that represents the mounted path
local_file = Path(tmp_dir) / "downloads" / "Le Fay" / "book.epub"
local_file.parent.mkdir(parents=True)
local_file.write_text("test content")
# Windows path as reported by qBittorrent running on Windows
windows_path = r"D:\Torrents\Le Fay\book.epub"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=windows_path,
)
mock_client.get_download_path.return_value = windows_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
# User enters Windows path in settings (with backslashes)
"remotePath": r"D:\Torrents",
"localPath": str(Path(tmp_dir) / "downloads"),
}
]
return default
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
),
patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
),
patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
),
patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
),
patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
),
):
handler = ProwlarrHandler()
task = DownloadTask(task_id="windows-path-test", source="prowlarr", title="Test Book")
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result == str(local_file)
assert task.original_download_path == str(local_file)
def test_windows_path_case_insensitive_matching():
"""Test that Windows path matching is case-insensitive.
Users may enter paths in different case than what the download client reports.
For example, user enters 'd:\\torrents' but qBittorrent reports 'D:\\Torrents'.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create a local file that represents the mounted path
local_file = Path(tmp_dir) / "downloads" / "Le Fay" / "book.epub"
local_file.parent.mkdir(parents=True)
local_file.write_text("test content")
# qBittorrent reports path with different case than user's setting
windows_path = r"D:\Torrents\Le Fay\book.epub" # Mixed case
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=windows_path,
)
mock_client.get_download_path.return_value = windows_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
# User enters lowercase (as shown in UI screenshot)
"remotePath": r"d:\torrents",
"localPath": str(Path(tmp_dir) / "downloads"),
}
]
return default
with (
patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
),
patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
),
patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
),
patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
),
patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
),
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="case-insensitive-test", source="prowlarr", title="Test Book"
)
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result == str(local_file)
assert task.original_download_path == str(local_file)
def test_resolve_download_path_rejects_unsafe_mapping_remainder_even_if_original_exists(
tmp_path,
):
remote_dir = tmp_path / "remote" / "downloads"
local_dir = tmp_path / "local" / "downloads"
escaped_file = tmp_path / "escape" / "book.epub"
remote_dir.mkdir(parents=True)
local_dir.mkdir(parents=True)
escaped_file.parent.mkdir(parents=True)
escaped_file.write_text("escaped content")
raw_path = f"{remote_dir}/../../escape/book.epub"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.get_download_path.return_value = raw_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": str(remote_dir),
"localPath": str(local_dir),
}
]
return default
with patch("shelfmark.download.clients.base_handler.config.get", side_effect=config_get):
handler = ProwlarrHandler()
resolved_path, error = handler._resolve_download_path_once(
mock_client,
"download_id",
log_details=False,
)
assert Path(raw_path).exists()
assert resolved_path is None
assert error is not None
assert "rejected unsafe path" in error
def test_delete_local_download_data_skips_unsafe_mapping_remainder_even_if_original_exists(
tmp_path,
):
remote_dir = tmp_path / "remote" / "downloads"
local_dir = tmp_path / "local" / "downloads"
escaped_file = tmp_path / "escape" / "book.epub"
remote_dir.mkdir(parents=True)
local_dir.mkdir(parents=True)
escaped_file.parent.mkdir(parents=True)
escaped_file.write_text("escaped content")
raw_path = f"{remote_dir}/../../escape/book.epub"
mock_client = MagicMock()
mock_client.name = "nzbget"
mock_client.get_download_path.return_value = raw_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "nzbget",
"remotePath": str(remote_dir),
"localPath": str(local_dir),
}
]
return default
with patch("shelfmark.download.clients.base_handler.config.get", side_effect=config_get):
handler = ProwlarrHandler()
handler._delete_local_download_data(mock_client, "download_id")
assert Path(raw_path).exists()
assert escaped_file.exists()
assert escaped_file.read_text() == "escaped content"