mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-14 16:58:24 +00:00
Some Soulseek uploaders run a bot that blocks your downloads until you
type a word to them in a private message ("please type 'download' in
this chat"). The files sit queued until someone answers, and nobody
looking at Shelfmark would know why.
While a slskd download is polling, this checks slskd's unacknowledged
private messages about once a minute, replies with the word the message
asks for and marks the conversation read. It only matches three known
phrasings, the word has to fit `[A-Za-z0-9_-]{1,24}`, and each user gets
at most one reply per 12 hours (recorded after a successful send). Every
reply is logged at INFO.
The challenge patterns and the one-reply-per-12-hours idea come from
@Nezreka's [SoulSync](https://github.com/Nezreka/SoulSync)
(`core/chat_autoprove.py`, commit 445473b, MIT). I reworked it for
slskd's client and this download handler. The credit is in the module
docstring and the commit message too.
This sends messages to other people on your behalf without asking, so
the setting `SLSKD_CHAT_AUTOPROVE` is off by default here. Say if you'd
rather it not exist at all, or want it somewhere other than the slskd
settings.
It has run on my instance since October 22 without seeing a real
challenge, so the reply path is covered by tests but not by a live one.
The same code works in nezreka/soulsync with the same slskd client so I
am confident in it. Ruff, basedpyright and the slskd tests pass, and
`docs/environment-variables.md` and the readme line for slskd are
updated.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **New Features**
* Added an optional setting to automatically answer supported Soulseek
private-message challenges while a download is running. When enabled,
Shelfmark checks unread messages, replies to each person at most once
every 12 hours, logs replies, and marks answered conversations as read.
The setting is off by default.
* **Documentation**
* Updated the environment-variable and Soulseek setup documentation with
details about the setting and its behavior, including a reminder to
leave it off if you read slskd messages yourself.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
870 lines
34 KiB
Python
870 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_conversations(self):
|
|
return []
|
|
|
|
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
|