Files
shelfmark/tests/slskd/test_handler_isolated.py
T
bb1655238a feat(slskd): add Soulseek (slskd) release source, building on #1335 (#1428)
## Add Soulseek (slskd) as a release source, building on #1335

This is @dskvr's work from #1335 rebased onto current `main`, with my
changes on top. #1335 has been quiet since 2026-09-13 and I needed the
changes below, so I carried it forward instead of starting over. Their
two commits are the first two in this branch with their authorship
intact, and the source plugin, handler, API client, settings tab, tests,
`ReleaseProtocol.SOULSEEK`, test client compose file and frontend bits
are all theirs. I haven't changed how a search turns into releases
beyond what is listed here.

If @dskvr would rather keep #1335 alive and take these as commits on
their branch, I can do that instead and close this.

### What I changed

**Downloads survive other apps using the same slskd.** The handler found
its files by listing slskd's downloads, and another app on the same
instance (SoulSync here) sends `DELETE
/transfers/downloads/all/completed` after each of its batches. Once that
ran, a finished download no longer appeared in the list and the task
never saw it complete, even though the files were on disk. With the new
`SLSKD_ISOLATE_DOWNLOADS` setting (on by default) each task:

- enqueues one batch (`POST /transfers/downloads/batches`) into its own
folder, `<SLSKD_DESTINATION_PREFIX>/<task id>`
- polls each transfer by id, which slskd still answers after the list
has been cleared
- decides it is done from the files on disk (sizes, not names), with the
`/events` log as a fallback when the transfers are gone and the files
aren't visible yet
- copies out of that folder, and removes it only if
`SLSKD_KEEP_COMPLETED` is off and the import worked

With isolation off the handler behaves as it did in #1335. If the batch
endpoint isn't there (404/405) it falls back to the original per-file
enqueue.

**Source changes.** m4b/m4a files are their own release next to loose
chapter files, so auto-download can prefer one file over a chapter set.
Results from the query variants are merged per peer. The folder text is
exposed as `extra["author"]` and `extra["title_raw"]`, which is what
auto-download's strict match and pack detection read. `is_available()`
also needs an API key now.

**Smaller fixes.** Cancelling while files are still being located now
removes the transfer records it created. In the settings tab, a cleared
form field is no longer replaced by the saved value when testing the
connection. The new settings are in `docs/environment-variables.md`.

### Testing

`tests/slskd` has 192 passing and 6 live tests that skip without an
instance. I wrote the new tests first and checked the handler and source
rules by breaking them and confirming a test failed. Ruff is clean. On
the whole suite the only failures are 4 in `tests/download` that import
`seleniumbase`, which isn't installed in my environment, and they fail
the same on `main`.

I've been running this on my own deployment against slskd 0.26.0 behind
a VPN, with SoulSync active on a second instance. A search returned a
couple hundred results from about 70 peers, an epub came down and was
imported, and auto-download fetched five audiobooks as single m4b files.
That is a day of use, so I don't know how it behaves on other slskd
versions or on Windows path mappings.

---------

Co-authored-by: sandwich <dskvr@users.noreply.github.com>
Co-authored-by: sandwich <299465+dskvr@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-10-20 01:33:12 -04:00

539 lines
20 KiB
Python

"""The handler's isolated mode: one folder per download, polled by id, confirmed on disk.
Several apps can share one slskd, and any of them can clear its list of finished transfers.
A download that watched the list would then "lose" a file that had actually arrived. These
tests pin the behaviour that does not: each download gets its own folder, each transfer is
read by its id, the files on disk decide whether it finished, and the finished files stay in
slskd's folder (still shared) while the orchestrator copies them.
"""
from __future__ import annotations
import json
from threading import Event
import pytest
import shelfmark.release_sources.slskd.handler as mod
from shelfmark.core.models import DownloadTask
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
from shelfmark.release_sources.slskd.api import SlskdBatchUnsupportedError
from shelfmark.release_sources.slskd.handler import SlskdHandler
REMOTE_DIR = "@@share\\Books\\Author"
FILE_A = f"{REMOTE_DIR}\\Book One.epub"
FILE_B = f"{REMOTE_DIR}\\Book Two.epub"
FILES = [{"filename": FILE_A, "size": 5}, {"filename": FILE_B, "size": 7}]
OK = "Completed, Succeeded"
class Recorder:
def __init__(self) -> None:
self.progress: list[float] = []
self.status: list[tuple[str, str | None]] = []
def progress_callback(self, value: float) -> None:
self.progress.append(value)
def status_callback(self, status: str, message: str | None) -> None:
self.status.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 if s != ACTIVITY_GRACE_STATUS]
@property
def statuses(self) -> list[str]:
return [s for s, _ in self.real]
@property
def graces(self) -> list[float]:
return [float(m) for s, m in self.status if s == ACTIVITY_GRACE_STATUS]
class FakeSlskd:
"""slskd as the handler sees it: batches, transfers by id, a list, events."""
def __init__(self, root, *, script=None, write_on_success=True):
self.root = root
# script[filename] = list of states, one per get_transfer call; the last repeats.
# A state of None means slskd no longer has the transfer (cleared from its list).
self.script = script or {}
self.write_on_success = write_on_success
self.batches: list[dict] = []
self.cancelled: list[tuple[str, str, bool]] = []
self.list_calls = 0
self.events: list[dict] = []
self.refuse: list[dict] = []
self.batch_error: Exception | None = None
self._calls: dict[str, int] = {}
self._written: set[str] = set()
self._ids: dict[str, str] = {}
self.on_get = None
def enqueue_batch(self, username, files, destination):
if self.batch_error:
raise self.batch_error
self.batches.append({"username": username, "files": files, "destination": destination})
self._destination = destination
transfers = []
for i, f in enumerate(files):
if any(r["filename"] == f["filename"] for r in self.refuse):
continue
tid = f"t{i}"
self._ids[tid] = f["filename"]
transfers.append({"id": tid, "filename": f["filename"], "state": "Requested"})
return {"batch_id": "b-1", "transfers": transfers, "failures": list(self.refuse)}
def get_transfer(self, username, transfer_id):
filename = self._ids[transfer_id]
count = self._calls.get(filename, 0)
self._calls[filename] = count + 1
states = self.script.get(filename, [OK])
state = states[min(count, len(states) - 1)]
if self.on_get:
self.on_get(self, filename, state)
if state is None:
return None
size = next(f["size"] for f in self.batches[-1]["files"] if f["filename"] == filename)
if state == OK and self.write_on_success and filename not in self._written:
folder = self.root / self._destination
folder.mkdir(parents=True, exist_ok=True)
(folder / filename.rsplit("\\", 1)[-1]).write_bytes(b"x" * size)
self._written.add(filename)
done = size if state == OK else 0
return {
"id": transfer_id,
"filename": filename,
"state": state,
"size": size,
"bytesTransferred": done,
"averageSpeed": 1000.0,
"placeInQueue": 3 if "Remotely" in state else None,
}
def list_downloads(self, username):
self.list_calls += 1
return [
{"id": tid, "filename": fn, "state": "InProgress", "removed": False}
for tid, fn in self._ids.items()
]
def cancel_transfer(self, username, transfer_id, *, remove):
self.cancelled.append((username, transfer_id, remove))
return True
def get_events(self, *, limit=200, offset=0):
return list(self.events)
def get_download_directory(self):
return str(self.root)
@pytest.fixture
def cfg(monkeypatch, tmp_path):
values: dict = {"SLSKD_DOWNLOAD_PATH": str(tmp_path)}
monkeypatch.setattr(mod.config, "get", lambda k, d=None: values.get(k, d))
return values
@pytest.fixture
def handler(monkeypatch):
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)
monkeypatch.setattr(mod, "MAX_MISSING_POLLS", 3)
return SlskdHandler()
def _task(files=None, task_id="slskd:abc"):
files = files if files is not None else FILES
return DownloadTask(
task_id=task_id,
source="slskd",
title="Book",
retry_source_context={"username": "peer", "directory": REMOTE_DIR, "files": files},
)
def _run(handler, fake, task=None, cancel=None):
handler._get_client = lambda: fake # type: ignore[method-assign]
rec = Recorder()
task = task or _task()
result = handler.download(task, cancel or Event(), rec.progress_callback, rec.status_callback)
return result, task, rec
class TestHappyPath:
def test_the_task_gets_its_own_folder_and_it_is_what_the_orchestrator_receives(
self, handler, cfg, tmp_path
):
fake = FakeSlskd(
tmp_path, script={FILE_A: ["Queued, Remotely", "InProgress", OK], FILE_B: [OK]}
)
result, _task_obj, rec = _run(handler, fake)
folder = tmp_path / "shelfmark" / "slskd_abc"
assert result == str(folder)
assert sorted(p.name for p in folder.iterdir()) == ["Book One.epub", "Book Two.epub"]
assert fake.batches[0]["destination"] == "shelfmark/slskd_abc"
assert rec.progress[-1] == 100.0
assert "downloading" in rec.statuses
def test_the_orchestrator_is_told_the_folder_is_the_clients_so_it_copies_not_moves(
self, handler, cfg, tmp_path
):
fake = FakeSlskd(tmp_path)
result, task, _ = _run(handler, fake)
assert task.original_download_path == result
def test_transfers_are_read_by_id_never_by_the_list(self, handler, cfg, tmp_path):
fake = FakeSlskd(tmp_path, script={FILE_A: ["InProgress", OK], FILE_B: ["InProgress", OK]})
_run(handler, fake)
assert fake.list_calls == 0
def test_a_failure_to_name_a_safe_folder_never_escapes_the_downloads_root(
self, handler, cfg, tmp_path
):
fake = FakeSlskd(tmp_path)
_run(handler, fake, task=_task(task_id="../../etc/passwd"))
destination = fake.batches[0]["destination"]
assert ".." not in destination and not destination.startswith("/")
assert destination.startswith("shelfmark/")
def test_the_destination_prefix_is_a_setting_and_a_bad_one_is_ignored(
self, handler, cfg, tmp_path
):
cfg["SLSKD_DESTINATION_PREFIX"] = "books/incoming"
fake = FakeSlskd(tmp_path)
_run(handler, fake)
assert fake.batches[0]["destination"] == "books/incoming/slskd_abc"
cfg["SLSKD_DESTINATION_PREFIX"] = "../../elsewhere"
fake2 = FakeSlskd(tmp_path)
_run(handler, fake2, task=_task(task_id="slskd:def"))
assert fake2.batches[0]["destination"] == "shelfmark/slskd_def"
class TestAnotherAppClearsSlskdsList:
def test_a_transfer_slskd_no_longer_lists_is_confirmed_by_the_files_on_disk(
self, handler, cfg, tmp_path
):
# Both files arrive, then SoulSync (say) clears the finished transfers.
fake = FakeSlskd(tmp_path, script={FILE_A: [OK, None], FILE_B: [OK, None]})
result, _, rec = _run(handler, fake)
assert result == str(tmp_path / "shelfmark" / "slskd_abc")
assert "error" not in rec.statuses
def test_a_file_that_arrived_before_the_clear_is_not_lost(self, handler, cfg, tmp_path):
folder = tmp_path / "shelfmark" / "slskd_abc"
def write_a_then_clear(fake, filename, state):
if filename == FILE_A:
folder.mkdir(parents=True, exist_ok=True)
(folder / "Book One.epub").write_bytes(b"x" * 5)
fake = FakeSlskd(tmp_path, script={FILE_A: [None], FILE_B: ["InProgress", OK]})
fake.on_get = write_a_then_clear
result, _, rec = _run(handler, fake)
assert result == str(folder)
assert "error" not in rec.statuses
def test_a_transfer_that_vanishes_with_no_file_is_an_error_not_a_hang(
self, handler, cfg, tmp_path
):
fake = FakeSlskd(tmp_path, script={FILE_A: [None], FILE_B: [None]}, write_on_success=False)
result, task, rec = _run(handler, fake)
assert result is None
assert rec.real[-1][0] == "error"
assert "slskd" in (rec.real[-1][1] or "").lower()
assert task.original_download_path is None
def test_slskds_events_confirm_a_vanished_transfer_whose_file_is_still_arriving(
self, handler, cfg, tmp_path
):
folder = tmp_path / "shelfmark" / "slskd_abc"
fake = FakeSlskd(tmp_path, script={FILE_A: [None], FILE_B: [None]}, write_on_success=False)
fake.events = [
{"type": "DownloadFileComplete", "data": json.dumps({"remoteFilename": FILE_A})},
{"type": "DownloadFileComplete", "data": json.dumps({"remoteFilename": FILE_B})},
]
# The files land a moment after slskd reported the events.
calls = {"n": 0}
real_get_events = fake.get_events
def events_then_files(**kw):
folder.mkdir(parents=True, exist_ok=True)
(folder / "Book One.epub").write_bytes(b"x" * 5)
(folder / "Book Two.epub").write_bytes(b"x" * 7)
calls["n"] += 1
return real_get_events(**kw)
fake.get_events = events_then_files # type: ignore[method-assign]
result, _, rec = _run(handler, fake)
assert calls["n"] >= 1
assert result == str(folder)
assert "error" not in rec.statuses
class TestFailuresAndCancel:
def test_a_failed_transfer_fails_the_task_and_stops_the_rest(self, handler, cfg, tmp_path):
fake = FakeSlskd(tmp_path, script={FILE_A: ["Completed, Errored"], FILE_B: ["InProgress"]})
result, task, rec = _run(handler, fake)
assert result is None
assert rec.real[-1][0] == "error"
assert "Errored" in (rec.real[-1][1] or "")
assert {tid for _, tid, _ in fake.cancelled} == {"t0", "t1"}
assert task.original_download_path is None
def test_cancelling_cancels_and_removes_every_transfer(self, handler, cfg, tmp_path):
cancel = Event()
fake = FakeSlskd(tmp_path, script={FILE_A: ["InProgress"], FILE_B: ["InProgress"]})
fake.on_get = lambda f, name, state: cancel.set()
result, _, rec = _run(handler, fake, cancel=cancel)
assert result is None
assert rec.real[-1][0] == "cancelled"
assert {(tid, remove) for _, tid, remove in fake.cancelled} == {("t0", True), ("t1", True)}
def test_a_peer_that_never_starts_gives_up_after_the_queue_timeout(
self, handler, cfg, tmp_path, monkeypatch
):
monkeypatch.setattr(SlskdHandler, "_queue_timeout_seconds", lambda self: 600.0)
clock = iter([0.0, 1.0, 2.0, 700.0, 701.0, 702.0, 703.0, 704.0, 705.0])
monkeypatch.setattr(SlskdHandler, "_clock", lambda self: next(clock), raising=False)
fake = FakeSlskd(
tmp_path,
script={FILE_A: ["Queued, Remotely"], FILE_B: ["Queued, Remotely"]},
write_on_success=False,
)
result, _, rec = _run(handler, fake)
assert result is None
assert "minutes" in (rec.real[-1][1] or "")
assert fake.cancelled
class TestPeerQueue:
def test_a_queued_transfer_asks_the_orchestrator_not_to_stall_it(self, handler, cfg, tmp_path):
fake = FakeSlskd(
tmp_path,
script={
FILE_A: ["Queued, Remotely", "Queued, Remotely", OK],
FILE_B: ["Queued, Remotely", "Queued, Remotely", OK],
},
)
_, _, rec = _run(handler, fake)
assert rec.graces, "no activity grace was requested while the peer queued the files"
assert rec.graces[0] == mod.QUEUE_GRACE_SECONDS
# and it is released once the transfers start, so the normal stall timer applies
assert rec.graces[-1] == 0.0
class TestRejoinAfterARestart:
def test_files_already_on_disk_are_not_downloaded_again(self, handler, cfg, tmp_path):
folder = tmp_path / "shelfmark" / "slskd_abc"
folder.mkdir(parents=True)
(folder / "Book One.epub").write_bytes(b"x" * 5)
(folder / "Book Two.epub").write_bytes(b"x" * 7)
fake = FakeSlskd(tmp_path)
result, task, rec = _run(handler, fake)
assert result == str(folder)
assert fake.batches == []
assert task.original_download_path == str(folder)
assert rec.progress[-1] == 100.0
def test_files_slskd_says_are_already_queued_are_found_by_name_and_rejoined(
self, handler, cfg, tmp_path
):
fake = FakeSlskd(tmp_path, script={FILE_A: [OK], FILE_B: [OK]})
fake.refuse = [{"filename": FILE_A, "message": "already queued"}]
fake._ids["t0"] = FILE_A # slskd still lists the earlier transfer for it
result, _, rec = _run(handler, fake)
assert result is not None
assert "error" not in rec.statuses
def test_a_refusal_with_nothing_to_rejoin_is_reported(self, handler, cfg, tmp_path):
fake = FakeSlskd(tmp_path)
fake.refuse = [
{"filename": FILE_A, "message": "denied"},
{"filename": FILE_B, "message": "denied"},
]
result, _, rec = _run(handler, fake)
assert result is None
assert rec.real[-1][0] == "error"
assert "denied" in (rec.real[-1][1] or "")
class TestFallbackAndSettings:
def test_an_old_slskd_without_batches_uses_the_original_enqueue(self, handler, cfg, tmp_path):
class Old(FakeSlskd):
def __init__(self, root):
super().__init__(root)
self.batch_error = SlskdBatchUnsupportedError("no batches")
self.enqueued: list = []
def enqueue_downloads(self, username, files):
self.enqueued.append(files)
return [
{"id": f"o{i}", "filename": f["filename"], "state": "Queued, Locally"}
for i, f in enumerate(files)
]
fake = Old(tmp_path)
# the original path polls the list, so give it finished transfers and the files
fake.list_downloads = lambda username: [ # type: ignore[method-assign]
{
"id": f"o{i}",
"filename": f["filename"],
"state": OK,
"bytesTransferred": f["size"],
"size": f["size"],
"removed": False,
}
for i, f in enumerate(FILES)
]
for name in ("Book One.epub", "Book Two.epub"):
target = tmp_path / "Author" / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"x" * 5)
result, _, rec = _run(handler, fake)
assert fake.enqueued, "the original enqueue was not used"
assert result is not None
assert "error" not in rec.statuses
def test_isolation_can_be_switched_off(self, handler, cfg, tmp_path):
cfg["SLSKD_ISOLATE_DOWNLOADS"] = False
class NoBatch(FakeSlskd):
def enqueue_batch(self, *a, **kw):
raise AssertionError("the batch endpoint was used with isolation off")
def enqueue_downloads(self, username, files):
return []
fake = NoBatch(tmp_path)
fake.list_downloads = lambda username: [] # type: ignore[method-assign]
result, _, rec = _run(handler, fake)
assert result is None # the original path found nothing to wait for
assert rec.real[-1][0] == "error"
class TestCleanup:
def _done(self, handler, cfg, tmp_path):
fake = FakeSlskd(tmp_path)
result, task, _ = _run(handler, fake)
return fake, result, task
def test_by_default_finished_files_and_records_are_left_in_slskd_so_they_stay_shared(
self, handler, cfg, tmp_path
):
fake, _result, task = self._done(handler, cfg, tmp_path)
handler.post_process_cleanup(task, success=True)
assert fake.cancelled == []
assert (tmp_path / "shelfmark" / "slskd_abc").is_dir()
def test_with_keep_off_the_transfers_and_the_folder_are_removed_after_import(
self, handler, cfg, tmp_path
):
cfg["SLSKD_KEEP_COMPLETED"] = False
fake, _result, task = self._done(handler, cfg, tmp_path)
handler.post_process_cleanup(task, success=True)
assert {(tid, remove) for _, tid, remove in fake.cancelled} == {("t0", True), ("t1", True)}
assert not (tmp_path / "shelfmark" / "slskd_abc").exists()
def test_a_failed_import_leaves_everything_for_a_retry(self, handler, cfg, tmp_path):
cfg["SLSKD_KEEP_COMPLETED"] = False
fake, _result, task = self._done(handler, cfg, tmp_path)
handler.post_process_cleanup(task, success=False)
assert fake.cancelled == []
assert (tmp_path / "shelfmark" / "slskd_abc").is_dir()
class TestFolderComplete:
def _folder(self, tmp_path, files):
folder = tmp_path / "f"
folder.mkdir()
for name, size in files.items():
(folder / name).write_bytes(b"x" * size)
return folder
def test_the_files_asked_for_are_complete(self, tmp_path):
folder = self._folder(tmp_path, {"a.epub": 5, "b.epub": 7})
assert mod._folder_complete(folder, [5, 7]) is True
def test_a_file_of_the_wrong_size_is_not_complete(self, tmp_path):
# the right number of files, but one is a partial copy
folder = self._folder(tmp_path, {"a.epub": 5, "b.epub": 3})
assert mod._folder_complete(folder, [5, 7]) is False
def test_names_do_not_matter_because_slskd_may_rename_a_duplicate(self, tmp_path):
folder = self._folder(tmp_path, {"a_1.epub": 5, "totally different.epub": 7})
assert mod._folder_complete(folder, [7, 5]) is True
def test_too_few_or_too_many_files_is_not_complete(self, tmp_path):
assert mod._folder_complete(self._folder(tmp_path, {"a.epub": 5}), [5, 7]) is False
other = tmp_path / "g"
other.mkdir()
for name, size in {"a": 5, "b": 7, "c": 1}.items():
(other / name).write_bytes(b"x" * size)
assert mod._folder_complete(other, [5, 7]) is False
def test_hidden_and_missing_folders_hold_nothing(self, tmp_path):
folder = self._folder(tmp_path, {".part": 5})
assert mod._folder_complete(folder, [5]) is False
assert mod._folder_complete(tmp_path / "nope", [5]) is False
def test_an_unknown_size_is_checked_by_count_only(self, tmp_path):
folder = self._folder(tmp_path, {"a.epub": 5, "b.epub": 9})
assert mod._folder_complete(folder, [0, 0]) is True
assert mod._folder_complete(folder, [0]) is False
def test_nothing_asked_for_is_never_complete(self, tmp_path):
assert mod._folder_complete(self._folder(tmp_path, {"a": 1}), []) is False