Skip to content

feat(slskd): add Soulseek (slskd) release source, building on #1335 - #1428

Open
splitsec2 wants to merge 11 commits into
calibrain:mainfrom
splitsec2:pr/slskd-source
Open

splitsec2 wants to merge 11 commits into
calibrain:mainfrom
splitsec2:pr/slskd-source

Conversation

@splitsec2

Copy link
Copy Markdown
Contributor

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.

dskvr and others added 10 commits October 4, 2026 04:00
from it through a slskd instance, added as a self-contained source plugin
with a full test suite.

Soulseek is different from the torrent and usenet sources Shelfmark already
supports: a search result is a file sitting on a specific peer, and only
the slskd daemon that found it can fetch it, so search and download ship
together as one plugin package under shelfmark/release_sources/slskd/
rather than as a new download-client protocol. The API layer wraps just
the slice of slskd's REST interface Shelfmark needs and deals with its
quirks (asynchronous searches that need polling and cleanup, transfer
records that must be cancelled before they can be removed, older versions
that return an empty body on enqueue). The source turns peer responses
into releases that fit the existing release list, and the handler drives
a download through slskd's transfer queue and then hands the finished files
to Shelfmark's normal post-processing pipeline, so naming templates, library
delivery and multi-user requests all work unchanged. Configuration is a
new settings tab, and the completed-files location can be given either as
a direct path or through the existing Remote Path Mappings mechanism. The
main pieces:

- API client — SlskdClient with API-key auth, connection test (verifies
the key and that slskd is logged in to Soulseek), search
create/poll/collect/delete, transfer enqueue/list/get/cancel, and lookup
of slskd's downloads directory.
- Release source (indexer) — runs title+author then bare-title queries;
ebooks become one release per file, audiobooks group a peer's folder of
audio files into one multi-file release (archives kept separate); locked
files are skipped, results are filtered by your Supported Formats and ranked
free slot → shortest queue → fastest upload; custom columns show peer, slot
availability, format and size.
- Download handler — enqueues every file of a release, polls progress with
speed and remote queue position, fails fast on peer errors, honours cancel
and a configurable queue timeout, locates completed files at slskd's
<downloads>/<last remote folder>/<file> layout (via the Downloads Path setting or a path mapping for the new slskd client), stages multi-file releases into TMP_DIR so stranger files in a shared folder are never imported, and removes the transfer record and empty folder after a successful import; the download spec is persisted on the task so retries survive a restart.
- Settings — "Soulseek (slskd)" tab: enable, URL, API key, Test Connection,
downloads path, search wait, max peer responses, queue timeout, remove-completed
toggle.
- Supporting changes — ReleaseProtocol.SOULSEEK, source registration, slskd
in the path-mapping client dropdown, frontend protocol type and dot colour,
readme and environment-variable docs.
- Test tooling — an slskd service in docker-compose.test-clients.yml and
scripts/test_clients.py --write-slskd-config to bootstrap a config with a
throwaway Soulseek login and API key.
- Tests — 148 new tests covering the API client, release building, the full
handler lifecycle against a scripted fake slskd (success, multi-file staging,
peer failure, cancel, queue timeout, missing and unsafe paths, cleanup), the
settings tab, and the queue → task → retry round-trip through the orchestrator,
plus live tests that run against a real instance when SLSKD_TEST_URL/SLSKD_TEST_API_KEY are set.

Everything was verified end to end against a running slskd 0.26.0 logged
in to Soulseek — a real search returned 37 releases, one was downloaded
and its size confirmed on disk, and cancelling a 15-file audiobook mid-transfer
left no records behind — and the full suite of 3172 tests, ruff, basedpyright,
vulture and the frontend typecheck all pass.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The repo's formatter wants the unparenthesised exception list that Python 3.14
allows, which is what failed the Python Quality job on the original PR.
…, events

The client gains the three calls an isolated download needs. enqueue_batch queues
files from one peer into a destination relative to slskd's downloads folder and
returns the transfers it created and the files it refused; a slskd that predates
batches (404/405) is reported as SlskdBatchUnsupportedError so the caller can fall
back. get_batch reads a batch with all its transfers, removed ones included.
get_events reads slskd's stored events, newest first; they survive anything
clearing the transfer list.
…d's list

Several apps can share one slskd and any of them can clear its list of finished
transfers. The handler polled that list, so a book that finished while another
app cleared it was reported as 'Transfer disappeared from slskd' though the file
had arrived. It also let slskd lay files out in its shared download folder by the
peer's folder name, and moved them out and deleted the record afterwards, which
ended the sharing.

A new isolated mode (on by default, SLSKD_ISOLATE_DOWNLOADS) fixes that:
- each download is a batch into its own folder, <prefix>/<task id>
  (SLSKD_DESTINATION_PREFIX, default 'shelfmark'), so nothing else shares it;
- every transfer is read by its id, never through the list;
- the files on disk decide completion (sizes are compared, not names); slskd's
  stored events confirm a vanished transfer whose file is still arriving;
- files already on disk from an earlier run are not downloaded again, and files
  slskd says are already queued are found by name and joined;
- while a peer queues the files the orchestrator is asked for a stall grace,
  renewed and released, as the torrent handler does;
- the folder is handed over as the client's own (original_download_path), so the
  orchestrator copies or hardlinks instead of moving, and the finished files stay
  in slskd (still shared) unless SLSKD_KEEP_COMPLETED is turned off.

A slskd without the batch endpoint, or SLSKD_ISOLATE_DOWNLOADS off, uses the
original path unchanged.
… not replaced by the saved value

Adds Give each download its own folder, Download Folder and Keep finished files
in slskd to the Soulseek tab, and scopes the older Remove completed option to the
non-isolated path. Test Connection now treats a key the form sent as empty as
empty, instead of falling back to the saved value (a review point on the
original PR).
…folder text to auto-download

- An m4b/m4a next to loose chapters is a different copy of the book, so it is its
  own release (as archives already are).
- Results of several query variants are merged per peer before releases are built,
  so a folder's chapters found by different queries are one release instead of the
  first partial folder winning (a review point on the original PR).
- Releases carry the remote folder path as extra['author'] and the folder name as
  extra['title_raw']: a Soulseek result has no author field, and these are what
  auto-download's author and bundle checks read.
- The source is unavailable without an API key as well as without a URL.
…es are being located

A review point on the original PR: _poll had already returned, so the cancel path
only reported 'Cancelled' and the finished transfers stayed in slskd's list.
…do not group bare filenames into one audiobook
@splitsec2

Copy link
Copy Markdown
Contributor Author

I pushed one more commit to this branch. Copilot's two reviews on #1335 raised several points, and I went back through them against this code. Most were already handled here: the cleared form field in the settings test, is_available() needing an API key, cleanup when a cancel lands while files are being located, and merging query variants per peer. The except A, B SyntaxError it reported is valid on Python 3.14.

Two were still open, so this commit covers them:

  • With isolation off, or when the batch endpoint isn't there, a file that disappears from a multi-file release made the handler report an error and return without cancelling the other transfers, which could keep downloading in slskd. It now cancels and removes them. The default isolated path already waited until no transfer was active, so it wasn't affected.
  • A bare filename with no folder was grouped with every other bare file from the same peer into one audiobook. Each of those files is now its own release.

Both have tests that fail without the change. I left the search timeout alone. Copilot says slskd reads SearchTimeout in seconds, but against slskd 0.26.0 a value of 20 returned no results and 15000 worked, so it is milliseconds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants