mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 20:18:13 +00:00
Fixes four defects in the Soulseek (slskd) A peer names its own files, and Shelfmark works out where slskd stored one from the last folder and the file name. `remote_directory_name` returned the whole directory when its last segment was empty. So `../../../etc//passwd.epub` or `/abs/path//book.epub` resolved outside the downloads folder, and Shelfmark imported whatever was there. For a multi-file release it moved those files into staging (`STAGE_MOVE`). - New `safe_remote_segments` refuses a name with an empty, `.` or `..` segment (which covers absolute and UNC paths), a NUL, or a drive anywhere but a Windows share root (`C:\Books\…`, which never reaches a local path). - Search (`iter_peer_files`, so `build_releases`) drops such files. - `download` refuses them before anything is queued, in both modes. The spec arrives with the queued release payload, so search filtering alone is not enough. - `local_path_for` also refuses a path that resolves outside the root (`resolve()` + `is_relative_to`), for example through a symlinked folder. `_poll`, used when isolation is off and as the fallback for slskd < 0.26, never asked for an activity grace. A file "Queued, Remotely" sent the same status and progress on every poll, so the orchestrator cancelled it after 300s and `SLSKD_QUEUE_TIMEOUT_MINUTES` never applied. It now requests, renews and releases the grace the same way isolated mode does. `search` caught every exception per query, so slskd being down or rejecting the API key showed as "no releases". When no query gets an answer it now raises `SourceUnavailableError`, as Prowlarr does, and the error reaches the user. One failed query among answered ones is still not an outage. A retry reuses the task's folder, and slskd renames a new file when one is already there. Files from a failed attempt therefore meant `_folder_complete`'s exact count could never match again. Now: - a failed or cancelled attempt removes its folder; - a new attempt removes any finished files an earlier one left (for example after a restart) before it queues the batch. `tests/slskd/conftest.py` stubbed `flask_socketio` whenever it had not been imported yet, even though it is installed. Running `pytest tests/slskd tests/` therefore broke the Flask app tests in the same worker (285 errors, also on10710ab). It now stubs only when the module is missing. - 25 new regression tests. All of them fail on10710ab. - `make python-checks` passes. - `uv run pytest tests/slskd tests/ -m "not integration and not e2e"` passes: 4047 tests one83bab9.
867 lines
34 KiB
Python
867 lines
34 KiB
Python
"""Unit tests for the slskd download handler."""
|
|
|
|
from pathlib import Path
|
|
from threading import Event
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from shelfmark.core.models import DownloadTask
|
|
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
|
|
from shelfmark.release_sources import get_handler
|
|
from shelfmark.release_sources.slskd.api import SlskdError
|
|
from shelfmark.release_sources.slskd.handler import (
|
|
SlskdDownloadSpec,
|
|
SlskdHandler,
|
|
_snapshot,
|
|
local_path_for,
|
|
resolve_local_download_root,
|
|
spec_from_release_data,
|
|
spec_from_task,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _original_enqueue_path(monkeypatch):
|
|
"""These tests cover the original enqueue-and-poll-the-list path; isolated mode has its own
|
|
tests in test_handler_isolated.py."""
|
|
from shelfmark.release_sources.slskd.handler import SlskdHandler
|
|
|
|
monkeypatch.setattr(SlskdHandler, "_isolate_enabled", lambda self: False)
|
|
|
|
|
|
# ── helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
REMOTE_DIR = "@@share\\Books\\Author"
|
|
FILE_A = f"{REMOTE_DIR}\\Book.epub"
|
|
FILE_B = f"{REMOTE_DIR}\\Book.mobi"
|
|
|
|
|
|
class ProgressRecorder:
|
|
def __init__(self):
|
|
self.progress_values: list[float] = []
|
|
self.status_updates: list[tuple[str, str | None]] = []
|
|
|
|
def progress_callback(self, v: float):
|
|
self.progress_values.append(v)
|
|
|
|
def status_callback(self, status: str, message: str | None):
|
|
self.status_updates.append((status, message))
|
|
|
|
@property
|
|
def real(self) -> list[tuple[str, str | None]]:
|
|
"""Statuses a user would see, without the stall-grace signals."""
|
|
return [(s, m) for s, m in self.status_updates if s != ACTIVITY_GRACE_STATUS]
|
|
|
|
@property
|
|
def graces(self) -> list[float]:
|
|
return [float(m) for s, m in self.status_updates if s == ACTIVITY_GRACE_STATUS]
|
|
|
|
@property
|
|
def last_status(self) -> str | None:
|
|
return self.real[-1][0] if self.real else None
|
|
|
|
@property
|
|
def last_message(self) -> str | None:
|
|
return self.real[-1][1] if self.real else None
|
|
|
|
@property
|
|
def statuses(self) -> list[str]:
|
|
return [s[0] for s in self.real]
|
|
|
|
|
|
class FakeSlskd:
|
|
"""In-memory stand-in for SlskdClient that scripts transfer state per poll."""
|
|
|
|
def __init__(self, *, download_dir="/downloads/complete", polls=None):
|
|
self.download_dir = download_dir
|
|
# Each poll returns the next list of transfer dicts; the last repeats forever.
|
|
self.polls = list(polls or [])
|
|
self.enqueued: list[tuple[str, list[dict]]] = []
|
|
self.cancelled: list[tuple[str, str, bool]] = []
|
|
self.enqueue_error: Exception | None = None
|
|
self.poll_count = 0
|
|
|
|
def enqueue_downloads(self, username, files):
|
|
if self.enqueue_error:
|
|
raise self.enqueue_error
|
|
self.enqueued.append((username, files))
|
|
return [
|
|
{"id": f"t{i}", "filename": f["filename"], "state": "Queued, Locally"}
|
|
for i, f in enumerate(files)
|
|
]
|
|
|
|
def list_downloads(self, username):
|
|
self.poll_count += 1
|
|
if not self.polls:
|
|
return []
|
|
index = min(self.poll_count - 1, len(self.polls) - 1)
|
|
return list(self.polls[index])
|
|
|
|
def cancel_transfer(self, username, transfer_id, *, remove):
|
|
"""Mimic slskd: a live transfer is cancelled (record kept), a complete one removed."""
|
|
self.cancelled.append((username, transfer_id, remove))
|
|
for poll in self.polls:
|
|
for transfer in poll:
|
|
if transfer.get("id") != transfer_id:
|
|
continue
|
|
if transfer["state"].startswith("Completed"):
|
|
if remove:
|
|
transfer["removed"] = True
|
|
else:
|
|
transfer["state"] = "Completed, Cancelled"
|
|
return True
|
|
|
|
def get_download_directory(self):
|
|
return self.download_dir
|
|
|
|
|
|
def _transfer(filename, state, *, transferred=0, size=1000, tid=None, speed=0, **extra):
|
|
return {
|
|
"id": tid or f"id-{filename}",
|
|
"filename": filename,
|
|
"state": state,
|
|
"bytesTransferred": transferred,
|
|
"size": size,
|
|
"averageSpeed": speed,
|
|
"removed": False,
|
|
**extra,
|
|
}
|
|
|
|
|
|
def _release_data(files=None, username="peer", directory=REMOTE_DIR):
|
|
files = files if files is not None else [{"filename": FILE_A, "size": 1000}]
|
|
return {
|
|
"source": "slskd",
|
|
"source_id": "slskd:abc",
|
|
"title": "Book",
|
|
"extra": {"username": username, "directory": directory, "files": files},
|
|
}
|
|
|
|
|
|
def _task(files=None, task_id="slskd:abc", username="peer"):
|
|
files = files if files is not None else [{"filename": FILE_A, "size": 1000}]
|
|
return DownloadTask(
|
|
task_id=task_id,
|
|
source="slskd",
|
|
title="Book",
|
|
retry_source_context={"username": username, "directory": REMOTE_DIR, "files": files},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def fast_handler(monkeypatch):
|
|
"""Handler with no sleeps between polls / path checks."""
|
|
monkeypatch.setattr(SlskdHandler, "_poll_interval", lambda self: 0)
|
|
monkeypatch.setattr(SlskdHandler, "_completed_path_retry_interval", lambda self: 0)
|
|
monkeypatch.setattr(SlskdHandler, "_completed_path_timeout_seconds", lambda self: 0)
|
|
monkeypatch.setattr(SlskdHandler, "_queue_timeout_seconds", lambda self: 0)
|
|
monkeypatch.setattr(SlskdHandler, "_cancel_remove_wait", lambda self: None)
|
|
return SlskdHandler()
|
|
|
|
|
|
@pytest.fixture
|
|
def config_values(monkeypatch):
|
|
import shelfmark.release_sources.slskd.handler as mod
|
|
|
|
values: dict = {}
|
|
monkeypatch.setattr(mod.config, "get", lambda k, d=None: values.get(k, d))
|
|
return values
|
|
|
|
|
|
def _mount(fast_handler, fake):
|
|
fast_handler._get_client = lambda: fake # type: ignore[method-assign]
|
|
|
|
|
|
def _write_completed(root: Path, *filenames: str) -> list[Path]:
|
|
paths = []
|
|
for filename in filenames:
|
|
path = local_path_for(root, filename)
|
|
assert path is not None
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"x" * 10)
|
|
paths.append(path)
|
|
return paths
|
|
|
|
|
|
# ── spec parsing ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSpec:
|
|
def test_from_release_data(self):
|
|
spec = spec_from_release_data(_release_data())
|
|
assert spec == SlskdDownloadSpec(
|
|
username="peer", directory=REMOTE_DIR, files=[{"filename": FILE_A, "size": 1000}]
|
|
)
|
|
assert spec.filenames == [FILE_A]
|
|
assert spec.total_size == 1000
|
|
|
|
def test_from_release_data_without_extra(self):
|
|
assert spec_from_release_data({"source": "slskd"}) is None
|
|
assert spec_from_release_data({"extra": {"username": "peer", "files": []}}) is None
|
|
assert (
|
|
spec_from_release_data({"extra": {"username": "", "files": [{"filename": "x"}]}})
|
|
is None
|
|
)
|
|
|
|
def test_directory_derived_when_missing(self):
|
|
spec = spec_from_release_data(_release_data(directory=""))
|
|
assert spec is not None
|
|
assert spec.directory == REMOTE_DIR
|
|
|
|
def test_from_task_round_trip(self):
|
|
handler = SlskdHandler()
|
|
fields = handler.build_retry_resolution_fields(_release_data())
|
|
assert fields["retry_download_protocol"] == "soulseek"
|
|
task = DownloadTask(task_id="x", source="slskd", title="t", **fields)
|
|
assert spec_from_task(task) == spec_from_release_data(_release_data())
|
|
|
|
def test_from_task_without_context(self):
|
|
assert spec_from_task(DownloadTask(task_id="x", source="slskd", title="t")) is None
|
|
|
|
def test_bad_sizes_become_zero(self):
|
|
spec = spec_from_release_data(_release_data(files=[{"filename": FILE_A, "size": "n/a"}]))
|
|
assert spec is not None
|
|
assert spec.files[0]["size"] == 0
|
|
|
|
def test_build_retry_fields_empty_without_spec(self):
|
|
assert SlskdHandler().build_retry_resolution_fields({"extra": {}}) == {}
|
|
|
|
def test_list_files(self):
|
|
files = SlskdHandler().list_files(
|
|
_release_data(files=[{"filename": FILE_A, "size": 10}, {"filename": FILE_B, "size": 0}])
|
|
)
|
|
assert files is not None
|
|
assert [(f.path, f.size) for f in files] == [("Book.epub", 10), ("Book.mobi", None)]
|
|
|
|
def test_list_files_none_without_spec(self):
|
|
assert SlskdHandler().list_files({"extra": {}}) is None
|
|
|
|
|
|
# ── local path resolution ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestLocalPaths:
|
|
def test_local_path_uses_last_folder_only(self):
|
|
assert local_path_for(Path("/dl"), FILE_A) == Path("/dl/Author/Book.epub")
|
|
|
|
def test_local_path_forward_slashes(self):
|
|
assert local_path_for(Path("/dl"), "share/Music/Album/01.mp3") == Path("/dl/Album/01.mp3")
|
|
|
|
def test_local_path_bare_file(self):
|
|
assert local_path_for(Path("/dl"), "Book.epub") == Path("/dl/Book.epub")
|
|
|
|
def test_local_path_refuses_traversal(self):
|
|
assert local_path_for(Path("/dl"), "a\\..\\Book.epub") is None
|
|
assert local_path_for(Path("/dl"), "a\\Author\\..") is None
|
|
assert local_path_for(Path("/dl"), "a\\Author\\") is None
|
|
|
|
@pytest.mark.parametrize(
|
|
"filename",
|
|
[
|
|
"../../../etc//passwd.epub", # an empty last folder used to keep the whole path
|
|
"/abs/path//book.epub",
|
|
"/abs/path/book.epub",
|
|
"\\\\server\\share\\book.epub",
|
|
"a//Book.epub",
|
|
"a\\.\\Book.epub",
|
|
"C:\\Book.epub",
|
|
"C:Book.epub",
|
|
"a\\D:\\Book.epub",
|
|
"a\\Author\\Book\0.epub",
|
|
],
|
|
)
|
|
def test_local_path_refuses_names_that_could_leave_the_root(self, tmp_path, filename):
|
|
assert local_path_for(tmp_path / "dl", filename) is None
|
|
|
|
def test_a_windows_peers_drive_is_only_its_share_root(self):
|
|
assert local_path_for(Path("/dl"), "C:\\Books\\Author\\x.epub") == Path("/dl/Author/x.epub")
|
|
|
|
def test_local_path_refuses_a_folder_that_links_outside_the_root(self, tmp_path):
|
|
root = tmp_path / "dl"
|
|
root.mkdir()
|
|
(tmp_path / "elsewhere").mkdir()
|
|
(root / "Author").symlink_to(tmp_path / "elsewhere", target_is_directory=True)
|
|
|
|
assert local_path_for(root, FILE_A) is None
|
|
|
|
def test_root_override_wins(self, config_values):
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = "/mnt/slskd "
|
|
client = MagicMock()
|
|
root, error = resolve_local_download_root(client)
|
|
assert (root, error) == (Path("/mnt/slskd"), None)
|
|
client.get_download_directory.assert_not_called()
|
|
|
|
def test_root_from_slskd_options(self, config_values):
|
|
client = MagicMock()
|
|
client.get_download_directory.return_value = "/downloads/complete"
|
|
assert resolve_local_download_root(client) == (Path("/downloads/complete"), None)
|
|
|
|
def test_root_remapped_by_path_mapping(self, config_values):
|
|
config_values["PROWLARR_REMOTE_PATH_MAPPINGS"] = [
|
|
{"host": "slskd", "remotePath": "/downloads", "localPath": "/mnt/slskd"},
|
|
{"host": "qbittorrent", "remotePath": "/downloads", "localPath": "/mnt/qb"},
|
|
]
|
|
client = MagicMock()
|
|
client.get_download_directory.return_value = "/downloads/complete"
|
|
assert resolve_local_download_root(client) == (Path("/mnt/slskd/complete"), None)
|
|
|
|
def test_root_error_when_slskd_reports_nothing(self, config_values):
|
|
client = MagicMock()
|
|
client.get_download_directory.return_value = None
|
|
root, error = resolve_local_download_root(client)
|
|
assert root is None
|
|
assert error and "Downloads Path" in error
|
|
|
|
def test_root_error_when_slskd_unreachable(self, config_values):
|
|
client = MagicMock()
|
|
client.get_download_directory.side_effect = SlskdError("down")
|
|
root, error = resolve_local_download_root(client)
|
|
assert root is None
|
|
assert error and "down" in error
|
|
|
|
|
|
# ── snapshot aggregation ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSnapshot:
|
|
def _spec(self):
|
|
return SlskdDownloadSpec(
|
|
username="peer",
|
|
directory=REMOTE_DIR,
|
|
files=[{"filename": FILE_A, "size": 1000}, {"filename": FILE_B, "size": 1000}],
|
|
)
|
|
|
|
def test_all_succeeded(self):
|
|
snap = _snapshot(
|
|
[
|
|
_transfer(FILE_A, "Completed, Succeeded", transferred=1000),
|
|
_transfer(FILE_B, "Completed, Succeeded", transferred=1000),
|
|
],
|
|
self._spec(),
|
|
)
|
|
assert snap.all_succeeded
|
|
assert snap.bytes_transferred == 2000
|
|
assert snap.failed_state is None
|
|
|
|
def test_partial_progress(self):
|
|
snap = _snapshot(
|
|
[
|
|
_transfer(FILE_A, "InProgress", transferred=500, speed=100.0),
|
|
_transfer(FILE_B, "Queued, Remotely", placeInQueue=3),
|
|
],
|
|
self._spec(),
|
|
)
|
|
assert not snap.all_succeeded
|
|
assert snap.bytes_transferred == 500
|
|
assert snap.in_progress == 1
|
|
assert snap.queued_remotely == 1
|
|
assert snap.queue_positions == [3]
|
|
assert snap.speed == 100.0
|
|
|
|
def test_failure_detected(self):
|
|
snap = _snapshot(
|
|
[
|
|
_transfer(FILE_A, "Completed, Succeeded", transferred=1000),
|
|
_transfer(FILE_B, "Completed, Errored"),
|
|
],
|
|
self._spec(),
|
|
)
|
|
assert snap.failed_state == "Completed, Errored"
|
|
assert snap.failed_filename == FILE_B
|
|
|
|
def test_ignores_unrelated_and_removed(self):
|
|
snap = _snapshot(
|
|
[
|
|
_transfer("other\\thing.epub", "InProgress"),
|
|
_transfer(FILE_A, "InProgress", removed=True),
|
|
],
|
|
self._spec(),
|
|
)
|
|
assert snap.found == 0
|
|
|
|
def test_prefers_live_record_over_old_failure(self):
|
|
# slskd keeps a failed attempt around after a retry re-enqueues the file.
|
|
snap = _snapshot(
|
|
[
|
|
_transfer(FILE_A, "Completed, Errored", requestedAt="2026-01-01T00:00:00"),
|
|
_transfer(FILE_A, "InProgress", transferred=10, requestedAt="2026-01-01T00:01:00"),
|
|
_transfer(FILE_B, "InProgress"),
|
|
],
|
|
self._spec(),
|
|
)
|
|
assert snap.failed_state is None
|
|
assert snap.in_progress == 2
|
|
|
|
|
|
# ── download lifecycle ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestDownload:
|
|
def test_registered(self):
|
|
assert isinstance(get_handler("slskd"), SlskdHandler)
|
|
|
|
def test_single_file_success_returns_client_path(self, fast_handler, config_values, tmp_path):
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path)
|
|
fake = FakeSlskd(
|
|
polls=[
|
|
[_transfer(FILE_A, "Queued, Remotely")],
|
|
[_transfer(FILE_A, "InProgress", transferred=400, speed=2 * 1024 * 1024)],
|
|
[_transfer(FILE_A, "Completed, Succeeded", transferred=1000, tid="done")],
|
|
]
|
|
)
|
|
_mount(fast_handler, fake)
|
|
(expected,) = _write_completed(tmp_path, FILE_A)
|
|
rec = ProgressRecorder()
|
|
|
|
result = fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback)
|
|
|
|
assert result == str(expected)
|
|
assert fake.enqueued == [("peer", [{"filename": FILE_A, "size": 1000}])]
|
|
assert rec.statuses[0] == "resolving"
|
|
assert ("downloading", "Queued at peer") in rec.status_updates
|
|
assert any(m and m.startswith("40% (2.0 MB/s)") for _, m in rec.status_updates)
|
|
assert rec.progress_values[-1] == 100.0
|
|
assert "error" not in rec.statuses
|
|
assert fast_handler._cleanup_refs[_task().task_id][2] == ["done"]
|
|
|
|
def test_multi_file_success_stages_into_tmp(
|
|
self, fast_handler, config_values, tmp_path, monkeypatch
|
|
):
|
|
import shelfmark.download.staging as staging
|
|
|
|
root = tmp_path / "slskd"
|
|
staging_root = tmp_path / "staging"
|
|
staging_root.mkdir()
|
|
monkeypatch.setattr(staging, "get_staging_dir", lambda: staging_root)
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(root)
|
|
files = [{"filename": FILE_A, "size": 10}, {"filename": FILE_B, "size": 10}]
|
|
fake = FakeSlskd(
|
|
polls=[
|
|
[
|
|
_transfer(FILE_A, "Completed, Succeeded", transferred=10),
|
|
_transfer(FILE_B, "Completed, Succeeded", transferred=10),
|
|
]
|
|
]
|
|
)
|
|
_mount(fast_handler, fake)
|
|
originals = _write_completed(root, FILE_A, FILE_B)
|
|
# A stranger's file in the same per-folder directory must not come along.
|
|
stranger = originals[0].parent / "stranger.epub"
|
|
stranger.write_bytes(b"nope")
|
|
rec = ProgressRecorder()
|
|
|
|
result = fast_handler.download(
|
|
_task(files=files), Event(), rec.progress_callback, rec.status_callback
|
|
)
|
|
|
|
assert result is not None
|
|
staged = Path(result)
|
|
assert staged.parent == staging_root
|
|
assert sorted(p.name for p in staged.iterdir()) == ["Book.epub", "Book.mobi"]
|
|
assert all(not p.exists() for p in originals)
|
|
assert stranger.exists()
|
|
|
|
def test_failed_transfer_reports_outcome_and_removes(self, fast_handler, config_values):
|
|
fake = FakeSlskd(polls=[[_transfer(FILE_A, "Completed, Rejected", tid="x")]])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
result = fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback)
|
|
|
|
assert result is None
|
|
assert rec.last_status == "error"
|
|
assert rec.last_message == "Peer transfer failed: Rejected"
|
|
assert fake.cancelled == [("peer", "x", True)]
|
|
|
|
def test_failed_file_named_in_multi_file_release(self, fast_handler, config_values):
|
|
files = [{"filename": FILE_A, "size": 10}, {"filename": FILE_B, "size": 10}]
|
|
fake = FakeSlskd(
|
|
polls=[
|
|
[
|
|
_transfer(FILE_A, "Completed, Succeeded", transferred=10),
|
|
_transfer(FILE_B, "Completed, TimedOut"),
|
|
]
|
|
]
|
|
)
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
result = fast_handler.download(
|
|
_task(files=files), Event(), rec.progress_callback, rec.status_callback
|
|
)
|
|
|
|
assert result is None
|
|
assert rec.last_message == "Peer transfer failed: TimedOut (Book.mobi)"
|
|
|
|
def test_cancel_flag_cancels_transfers(self, fast_handler, config_values):
|
|
fake = FakeSlskd(polls=[[_transfer(FILE_A, "InProgress", transferred=10, tid="live")]])
|
|
_mount(fast_handler, fake)
|
|
cancel = Event()
|
|
rec = ProgressRecorder()
|
|
|
|
def status_callback(status, message):
|
|
rec.status_callback(status, message)
|
|
if status == "downloading":
|
|
cancel.set()
|
|
|
|
result = fast_handler.download(_task(), cancel, rec.progress_callback, status_callback)
|
|
|
|
assert result is None
|
|
assert rec.last_status == "cancelled"
|
|
# First pass cancels the live transfer, second pass removes the leftover record.
|
|
assert fake.cancelled == [("peer", "live", True), ("peer", "live", True)]
|
|
assert all(t["removed"] for poll in fake.polls for t in poll)
|
|
|
|
def test_cancelled_before_start(self, fast_handler, config_values):
|
|
fake = FakeSlskd()
|
|
_mount(fast_handler, fake)
|
|
cancel = Event()
|
|
cancel.set()
|
|
rec = ProgressRecorder()
|
|
|
|
assert (
|
|
fast_handler.download(_task(), cancel, rec.progress_callback, rec.status_callback)
|
|
is None
|
|
)
|
|
assert rec.last_status == "cancelled"
|
|
assert fake.enqueued == []
|
|
|
|
def test_missing_spec_errors(self, fast_handler, config_values):
|
|
_mount(fast_handler, FakeSlskd())
|
|
rec = ProgressRecorder()
|
|
task = DownloadTask(task_id="slskd:none", source="slskd", title="t")
|
|
|
|
assert (
|
|
fast_handler.download(task, Event(), rec.progress_callback, rec.status_callback) is None
|
|
)
|
|
assert rec.last_status == "error"
|
|
assert "missing" in (rec.last_message or "")
|
|
|
|
def test_unconfigured_client_errors(self, fast_handler, config_values):
|
|
fast_handler._get_client = lambda: None # type: ignore[method-assign]
|
|
rec = ProgressRecorder()
|
|
|
|
assert (
|
|
fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback)
|
|
is None
|
|
)
|
|
assert rec.last_status == "error"
|
|
assert rec.last_message == "slskd is not configured"
|
|
|
|
def test_enqueue_failure_errors(self, fast_handler, config_values):
|
|
fake = FakeSlskd()
|
|
fake.enqueue_error = SlskdError("slskd refused 1 file(s): Not shared")
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
assert (
|
|
fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback)
|
|
is None
|
|
)
|
|
assert rec.last_status == "error"
|
|
assert "Not shared" in (rec.last_message or "")
|
|
|
|
def test_transfer_vanishing_errors_after_grace(self, fast_handler, config_values, monkeypatch):
|
|
import shelfmark.release_sources.slskd.handler as mod
|
|
|
|
monkeypatch.setattr(mod, "MAX_MISSING_POLLS", 3)
|
|
fake = FakeSlskd(polls=[[]])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
assert (
|
|
fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback)
|
|
is None
|
|
)
|
|
assert rec.last_status == "error"
|
|
assert rec.last_message == "Transfer disappeared from slskd"
|
|
# Three polls to give up, then the cleanup pass looks the transfers up again.
|
|
assert fake.poll_count >= 3
|
|
|
|
def test_a_file_vanishing_from_a_multi_file_release_cancels_the_others(
|
|
self, fast_handler, config_values, monkeypatch
|
|
):
|
|
import shelfmark.release_sources.slskd.handler as mod
|
|
|
|
monkeypatch.setattr(mod, "MAX_MISSING_POLLS", 3)
|
|
files = [{"filename": FILE_A, "size": 1000}, {"filename": FILE_B, "size": 1000}]
|
|
# FILE_B is never listed again; FILE_A keeps downloading.
|
|
fake = FakeSlskd(polls=[[_transfer(FILE_A, "InProgress", transferred=10, tid="live")]])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
result = fast_handler.download(
|
|
_task(files=files), Event(), rec.progress_callback, rec.status_callback
|
|
)
|
|
|
|
assert result is None
|
|
assert rec.last_message == "Transfer disappeared from slskd"
|
|
assert ("peer", "live", True) in fake.cancelled
|
|
|
|
def test_queue_timeout_errors(self, fast_handler, config_values, monkeypatch):
|
|
import shelfmark.release_sources.slskd.handler as mod
|
|
|
|
monkeypatch.setattr(SlskdHandler, "_queue_timeout_seconds", lambda self: 120)
|
|
clock = iter([0, 0, 200, 200, 200])
|
|
monkeypatch.setattr(mod.time, "monotonic", lambda: next(clock))
|
|
fake = FakeSlskd(polls=[[_transfer(FILE_A, "Queued, Remotely", tid="q")]])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
assert (
|
|
fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback)
|
|
is None
|
|
)
|
|
assert rec.last_status == "error"
|
|
assert rec.last_message == "Peer did not start the transfer within 2 minutes"
|
|
assert fake.cancelled == [("peer", "q", True), ("peer", "q", True)]
|
|
assert all(t["removed"] for poll in fake.polls for t in poll)
|
|
|
|
def test_completed_file_missing_errors(self, fast_handler, config_values, tmp_path):
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path)
|
|
fake = FakeSlskd(polls=[[_transfer(FILE_A, "Completed, Succeeded", transferred=1000)]])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
assert (
|
|
fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback)
|
|
is None
|
|
)
|
|
assert rec.last_status == "error"
|
|
assert "Completed file not found" in (rec.last_message or "")
|
|
assert str(tmp_path / "Author" / "Book.epub") in (rec.last_message or "")
|
|
|
|
def test_completed_file_appears_after_wait(
|
|
self, fast_handler, config_values, tmp_path, monkeypatch
|
|
):
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path)
|
|
monkeypatch.setattr(SlskdHandler, "_completed_path_retry_interval", lambda self: 1)
|
|
monkeypatch.setattr(SlskdHandler, "_completed_path_timeout_seconds", lambda self: 2)
|
|
fake = FakeSlskd(polls=[[_transfer(FILE_A, "Completed, Succeeded", transferred=1000)]])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
expected = local_path_for(tmp_path, FILE_A)
|
|
assert expected is not None
|
|
|
|
cancel = MagicMock(spec=Event)
|
|
cancel.is_set.return_value = False
|
|
|
|
def wait(timeout=None):
|
|
_write_completed(tmp_path, FILE_A)
|
|
return False
|
|
|
|
cancel.wait.side_effect = wait
|
|
|
|
result = fast_handler.download(_task(), cancel, rec.progress_callback, rec.status_callback)
|
|
|
|
assert result == str(expected)
|
|
assert ("locating", "Waiting for completed files...") in rec.status_updates
|
|
|
|
def test_unsafe_remote_name_errors(self, fast_handler, config_values, tmp_path):
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path)
|
|
evil = "a\\..\\Book.epub"
|
|
fake = FakeSlskd(polls=[[_transfer(evil, "Completed, Succeeded", transferred=10)]])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
task = _task(files=[{"filename": evil, "size": 10}])
|
|
assert (
|
|
fast_handler.download(task, Event(), rec.progress_callback, rec.status_callback) is None
|
|
)
|
|
assert rec.last_status == "error"
|
|
assert "unsafe" in (rec.last_message or "")
|
|
|
|
@pytest.mark.parametrize("folder", ["..\\outside\\", "../outside/", "{outside}/"])
|
|
def test_a_peer_file_name_cannot_reach_files_outside_the_root(
|
|
self, fast_handler, config_values, tmp_path, folder
|
|
):
|
|
root = tmp_path / "dl"
|
|
outside = tmp_path / "outside" # where "../outside" leads from the root
|
|
root.mkdir()
|
|
outside.mkdir()
|
|
names = ["01.mp3", "02.mp3"]
|
|
for name in names:
|
|
(outside / name).write_bytes(b"secret")
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(root)
|
|
# The doubled separator leaves an empty last folder name, which used to let the
|
|
# whole directory through as the local folder.
|
|
sep = folder[-1]
|
|
files = [
|
|
{"filename": f"{folder.format(outside=outside)}{sep}{name}", "size": 6}
|
|
for name in names
|
|
]
|
|
done = [_transfer(f["filename"], "Completed, Succeeded", transferred=6) for f in files]
|
|
fake = FakeSlskd(polls=[done])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
result = fast_handler.download(
|
|
_task(files=files), Event(), rec.progress_callback, rec.status_callback
|
|
)
|
|
|
|
assert result is None
|
|
assert rec.last_status == "error"
|
|
assert "unsafe" in (rec.last_message or "")
|
|
assert fake.enqueued == []
|
|
# A multi-file release is staged with a move: the files must still be there.
|
|
assert sorted(p.name for p in outside.iterdir()) == names
|
|
|
|
def test_a_peer_queue_asks_the_orchestrator_not_to_stall_the_download(
|
|
self, fast_handler, config_values, tmp_path
|
|
):
|
|
"""Queued at the peer, every poll repeats the same status and progress. Without a
|
|
grace the orchestrator cancels after 5 minutes, long before the queue timeout."""
|
|
import shelfmark.release_sources.slskd.handler as mod
|
|
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path)
|
|
queued = _transfer(FILE_A, "Queued, Remotely", placeInQueue=4)
|
|
fake = FakeSlskd(
|
|
polls=[
|
|
[queued],
|
|
[queued],
|
|
[_transfer(FILE_A, "InProgress", transferred=500)],
|
|
[_transfer(FILE_A, "Completed, Succeeded", transferred=1000)],
|
|
]
|
|
)
|
|
_mount(fast_handler, fake)
|
|
_write_completed(tmp_path, FILE_A)
|
|
rec = ProgressRecorder()
|
|
|
|
result = fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback)
|
|
|
|
assert result is not None
|
|
assert rec.graces, "no activity grace was requested while the peer queued the file"
|
|
assert rec.graces[0] == mod.QUEUE_GRACE_SECONDS
|
|
# released once the transfer starts, so the normal stall timer applies again
|
|
assert rec.graces[-1] == 0.0
|
|
|
|
def test_a_long_peer_queue_renews_the_grace_until_the_queue_timeout(
|
|
self, fast_handler, config_values, monkeypatch
|
|
):
|
|
import shelfmark.release_sources.slskd.handler as mod
|
|
|
|
monkeypatch.setattr(SlskdHandler, "_queue_timeout_seconds", lambda self: 3600)
|
|
clock = iter([0, 0, 700, 1400, 3700])
|
|
monkeypatch.setattr(SlskdHandler, "_clock", lambda self: next(clock, 3700))
|
|
fake = FakeSlskd(polls=[[_transfer(FILE_A, "Queued, Remotely", tid="q")]])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
cancel = MagicMock(spec=Event)
|
|
cancel.is_set.side_effect = lambda: fake.poll_count > 10 # never hang
|
|
cancel.wait.return_value = False
|
|
|
|
result = fast_handler.download(_task(), cancel, rec.progress_callback, rec.status_callback)
|
|
|
|
assert result is None
|
|
assert rec.last_message == "Peer did not start the transfer within 60 minutes"
|
|
assert rec.graces == [mod.QUEUE_GRACE_SECONDS] * 4 + [0.0]
|
|
|
|
def test_status_check_errors_are_retried(self, fast_handler, config_values, tmp_path):
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path)
|
|
fake = FakeSlskd(polls=[[_transfer(FILE_A, "Completed, Succeeded", transferred=1000)]])
|
|
calls = {"n": 0}
|
|
original = fake.list_downloads
|
|
|
|
def flaky(username):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
raise SlskdError("hiccup")
|
|
return original(username)
|
|
|
|
fake.list_downloads = flaky
|
|
_mount(fast_handler, fake)
|
|
_write_completed(tmp_path, FILE_A)
|
|
rec = ProgressRecorder()
|
|
|
|
result = fast_handler.download(_task(), Event(), rec.progress_callback, rec.status_callback)
|
|
|
|
assert result is not None
|
|
assert ("resolving", "Waiting for slskd...") in rec.status_updates
|
|
|
|
|
|
# ── post-processing cleanup ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCleanup:
|
|
def _completed(self, fast_handler, config_values, tmp_path):
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path)
|
|
fake = FakeSlskd(
|
|
polls=[[_transfer(FILE_A, "Completed, Succeeded", transferred=1000, tid="t")]]
|
|
)
|
|
_mount(fast_handler, fake)
|
|
(path,) = _write_completed(tmp_path, FILE_A)
|
|
rec = ProgressRecorder()
|
|
task = _task()
|
|
assert fast_handler.download(task, Event(), rec.progress_callback, rec.status_callback)
|
|
return fake, task, path
|
|
|
|
def test_success_removes_transfer_and_empty_folder(self, fast_handler, config_values, tmp_path):
|
|
fake, task, path = self._completed(fast_handler, config_values, tmp_path)
|
|
path.unlink() # orchestrator moved it into the library
|
|
|
|
fast_handler.post_process_cleanup(task, success=True)
|
|
|
|
assert fake.cancelled == [("peer", "t", True)]
|
|
assert not path.parent.exists()
|
|
assert task.task_id not in fast_handler._cleanup_refs
|
|
|
|
def test_success_keeps_folder_with_other_files(self, fast_handler, config_values, tmp_path):
|
|
_fake, task, path = self._completed(fast_handler, config_values, tmp_path)
|
|
path.unlink()
|
|
(path.parent / "other.epub").write_bytes(b"x")
|
|
|
|
fast_handler.post_process_cleanup(task, success=True)
|
|
|
|
assert path.parent.exists()
|
|
|
|
def test_success_respects_remove_setting(self, fast_handler, config_values, tmp_path):
|
|
fake, task, _path = self._completed(fast_handler, config_values, tmp_path)
|
|
config_values["SLSKD_REMOVE_COMPLETED"] = False
|
|
|
|
fast_handler.post_process_cleanup(task, success=True)
|
|
|
|
assert fake.cancelled == []
|
|
|
|
def test_failure_leaves_everything(self, fast_handler, config_values, tmp_path):
|
|
fake, task, path = self._completed(fast_handler, config_values, tmp_path)
|
|
|
|
fast_handler.post_process_cleanup(task, success=False)
|
|
|
|
assert fake.cancelled == []
|
|
assert path.exists()
|
|
assert task.task_id not in fast_handler._cleanup_refs
|
|
|
|
def test_cleanup_without_refs_is_noop(self, fast_handler):
|
|
fast_handler.post_process_cleanup(_task(), success=True)
|
|
|
|
def test_cancel_drops_refs(self, fast_handler, config_values, tmp_path):
|
|
_fake, task, _path = self._completed(fast_handler, config_values, tmp_path)
|
|
assert fast_handler.cancel(task.task_id) is True
|
|
assert task.task_id not in fast_handler._cleanup_refs
|
|
|
|
|
|
class TestCancelWhileLocatingFiles:
|
|
def test_a_cancel_after_the_transfers_finished_still_removes_their_records(
|
|
self, fast_handler, config_values, tmp_path
|
|
):
|
|
config_values["SLSKD_DOWNLOAD_PATH"] = str(tmp_path)
|
|
cancel = Event()
|
|
finished = [_transfer(FILE_A, "Completed, Succeeded", transferred=1000, tid="t0")]
|
|
|
|
class Fake(FakeSlskd):
|
|
def list_downloads(self, username):
|
|
result = super().list_downloads(username)
|
|
cancel.set() # the cancel lands as the last transfer is reported finished
|
|
return result
|
|
|
|
fake = Fake(polls=[finished])
|
|
_mount(fast_handler, fake)
|
|
rec = ProgressRecorder()
|
|
|
|
result = fast_handler.download(_task(), cancel, rec.progress_callback, rec.status_callback)
|
|
|
|
assert result is None
|
|
assert rec.last_status == "cancelled"
|
|
assert ("peer", "t0", True) in fake.cancelled
|