mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 05:38:26 +00:00
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.
943 lines
32 KiB
Python
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"
|