Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,12 +382,15 @@ Docker users: the provided `docker-compose.yml` includes an example `LISTENARR_L

### Download Clients

Native Slskd (Soulseek) setup, portable path mapping, and troubleshooting are documented in [docs/download-clients/slskd.md](docs/download-clients/slskd.md). A reusable deployment example is available at [docker-compose.slskd.example.yml](docker-compose.slskd.example.yml).

Supported download clients:

- **qBittorrent** - Popular torrent client with web UI
- **Transmission** - Cross-platform torrent client
- **SABnzbd** - Usenet downloader
- **NZBGet** - Efficient usenet client
- **Slskd** - Native Soulseek search, batch monitoring, and safe staged import

### Application Settings

Expand Down
28 changes: 28 additions & 0 deletions docker-compose.slskd.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
services:
listenarr:
image: ghcr.io/listenarrs/listenarr:canary
ports:
- "4545:4545"
volumes:
- ./data/listenarr:/config
- ./data/library:/audiobooks
- ./data/slskd/complete:/slskd-downloads
depends_on:
- slskd

slskd:
image: slskd/slskd:latest
ports:
- "5030:5030"
environment:
SLSKD_REMOTE_CONFIGURATION: "true"
volumes:
- ./data/slskd/app:/app
- ./data/slskd/complete:/downloads/complete
- ./data/slskd/incomplete:/downloads/incomplete

# Configure slskd's completed-download directory as /downloads/complete.
# Listenarr submits isolated destinations under listenarr/<reservation-id> and
# sees the same files beneath /slskd-downloads/listenarr/<reservation-id>.
# Supply credentials/API keys through your secret-management mechanism rather
# than committing them to this file.
58 changes: 58 additions & 0 deletions docs/download-clients/slskd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Native Slskd download client

Listenarr can submit audiobook searches directly to [slskd](https://github.com/slskd/slskd), monitor the returned batch, import every successful audio transfer into the canonical library directory, and remove only the verified isolated staging batch after import.

## Path contract

Use an isolated completion destination outside every Listenarr library root:

| Namespace | Example |
| --- | --- |
| slskd batch destination | `listenarr/<reservation-id>` |
| slskd completion root | `/downloads/complete` |
| Listenarr-visible completion root | `/slskd-downloads` |

The value entered as **Listenarr source root** must be an absolute path in the Listenarr runtime namespace. `/slskd-downloads` is the portable default, not a required host path. Listenarr rejects relative roots and validates reconstructed files beneath the configured root and isolated batch destination.

## Docker Compose

See [`docker-compose.slskd.example.yml`](../../docker-compose.slskd.example.yml). Create `./data/slskd/complete` on the host and mount it read/write into both containers under their respective namespaces. Do not mount this directory inside a library directory.

On Linux, the example relative bind paths work directly. On Windows Docker Desktop, replace `./data/slskd/complete` with a generic Docker Desktop shared path such as `D:/containers/listenarr/slskd-complete`; keep the container destinations unchanged.

For native Listenarr, set **Listenarr source root** to the absolute local directory where the same slskd completion files are visible. If slskd and Listenarr run on separate hosts, use a shared filesystem and point each application at its own absolute mount path.

## Configure

1. Create an API key in slskd and keep it in an environment secret or secret manager.
2. In Listenarr, open **Settings → Download Clients → Add → Slskd**.
3. Set the slskd host, port, TLS option, and API key.
4. Set **Listenarr source root** (default `/slskd-downloads`).
5. Choose priority and optionally mark the client as default.
6. Leave protocol fallback disabled unless torrent/Usenet fallback is explicitly desired.
7. Test the connection, then save.

API keys are carried in the standard secret settings dictionary and are redacted by Listenarr's existing API response redactor. Avoid putting keys in Compose files or logs. Non-loopback deployments should expose slskd through HTTPS.

## Semantics

- The configured default enabled client wins; otherwise lower priority wins, with creation time as a stable tie-break.
- Native Slskd routing runs before torrent/NZB indexer search.
- One durable Listenarr download is reserved before external HTTP effects; active or imported duplicates are rejected and never fall back.
- A batch completes only when every non-removed transfer reports both `Completed` and `Succeeded`.
- Multi-chapter transfers remain one batch and import into one canonical author/title directory.
- Missing author metadata imports beneath `Unknown Author/<Title>`.
- Cleanup runs only after successful import. Partial, failed, unsafe, or unverifiable staging is preserved.

## Troubleshooting

- **401/403:** recreate or re-enter the slskd API key and test again.
- **429:** Slskd is rate limiting. Listenarr honors retry timing within its bounded search deadline; retry later if it expires.
- **Search timeout/no safe result:** verify Soulseek connectivity and query metadata. Only safe audio filenames from one coherent response are selected.
- **Queued remotely:** confirm the batch UUID is visible in both slskd and Listenarr activity.
- **Unsafe path:** ensure the source root is absolute and the shared completion mount matches it. Do not use `..`, drive-qualified remote filenames, or a library root as staging.
- **Import/finalization preserved staging:** inspect failed transfers and unexpected files. Listenarr deliberately refuses broad deletion.

Before upgrading, back up Listenarr's database/config and slskd configuration. To roll back, stop both services, restore those backups, and use the previous image digest; preserve staging until imports are reconciled.

Automated tests use deterministic HTTP fixtures. CI and normal tests do not contact public Soulseek peers.
48 changes: 43 additions & 5 deletions fe/src/components/domain/download/DownloadClientFormModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
<option value="transmission">Transmission</option>
<option value="sabnzbd">SABnzbd</option>
<option value="nzbget">NZBGet</option>
<option value="slskd">Slskd (Soulseek)</option>
</select>
</div>

Expand Down Expand Up @@ -208,6 +209,27 @@

<!-- Priority -->
<FormSection title="Priority" :icon="PhSortAscending">
<div class="form-group">
<label for="clientPriority">Client Priority</label>
<input id="clientPriority" v-model.number="formData.priority" type="number" min="0" />
<small>Lower numbers are preferred for normal audiobook downloads.</small>
</div>
<div class="checkbox-group">
<Checkbox v-model="formData.isDefault">
<strong>Default download client</strong>
<small>Prefer this client ahead of priority ordering.</small>
</Checkbox>
</div>
<div class="checkbox-group" v-if="formData.type === 'slskd'">
<Checkbox v-model="formData.allowProtocolFallback">
<strong>Allow torrent/NZB fallback</strong>
<small>Off by default; native Slskd failures will not silently grab a torrent.</small>
</Checkbox>
</div>
<div class="form-group" v-if="formData.type === 'slskd'">
<label for="listenarrSourceRoot">Listenarr-visible source root *</label>
<input id="listenarrSourceRoot" v-model="formData.listenarrSourceRoot" type="text" required placeholder="/slskd-downloads" />
</div>
<div class="form-group">
<label for="recentPriority">Recent Priority</label>
<select id="recentPriority" v-model="formData.recentPriority">
Expand Down Expand Up @@ -404,7 +426,7 @@ const testing = ref(false)

const defaultFormData = {
name: '',
type: 'qbittorrent' as 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget',
type: 'qbittorrent' as 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget' | 'slskd',
host: '',
port: 8080,
username: '',
Expand All @@ -427,6 +449,10 @@ const defaultFormData = {
urlBase: '',
settings: {},
remotePathMappingIds: [] as number[],
priority: 50,
isDefault: false,
allowProtocolFallback: false,
listenarrSourceRoot: '/slskd-downloads',
}

const formData = ref({ ...defaultFormData })
Expand Down Expand Up @@ -462,7 +488,7 @@ const requiresAuth = computed(() => {
})

const requiresApiKey = computed(() => {
return formData.value.type === 'sabnzbd'
return formData.value.type === 'sabnzbd' || formData.value.type === 'slskd'
})

const getHostPlaceholder = () => {
Expand Down Expand Up @@ -513,7 +539,7 @@ const onTypeChange = () => {
}
formData.value.port = defaultPorts[formData.value.type] || 8080

if (formData.value.type === 'sabnzbd') {
if (formData.value.type === 'sabnzbd' || formData.value.type === 'slskd') {
formData.value.username = ''
formData.value.password = ''
} else {
Expand Down Expand Up @@ -556,6 +582,10 @@ watch(
settings: newClient.settings || {},
remotePathMappingIds:
settings && settings.remotePathMappingIds ? settings.remotePathMappingIds : [],
priority: Number(settings?.priority ?? 50),
isDefault: Boolean(settings?.isDefault ?? false),
allowProtocolFallback: Boolean(settings?.allowProtocolFallback ?? false),
listenarrSourceRoot: (settings?.listenarrSourceRoot as string) || '/slskd-downloads',
}
// Load available mappings when editing a client so the dropdown can show options
void loadRemotePathMappings()
Expand Down Expand Up @@ -590,7 +620,7 @@ const testConnection = async () => {
isEnabled: formData.value.isEnabled,
removeCompletedDownloads: formData.value.removeCompletedDownloads,
settings: {
...(formData.value.type === 'sabnzbd' && formData.value.apiKey
...((formData.value.type === 'sabnzbd' || formData.value.type === 'slskd') && formData.value.apiKey
? { apiKey: formData.value.apiKey }
: {}),
...(formData.value.type === 'transmission' && formData.value.urlBase
Expand All @@ -606,6 +636,10 @@ const testConnection = async () => {
sequentialOrder: formData.value.sequentialOrder,
firstAndLastFirst: formData.value.firstAndLastFirst,
contentLayout: formData.value.contentLayout,
priority: formData.value.priority,
isDefault: formData.value.isDefault,
allowProtocolFallback: formData.value.allowProtocolFallback,
...(formData.value.type === 'slskd' ? { listenarrSourceRoot: formData.value.listenarrSourceRoot } : {}),
...(formData.value.remotePathMappingIds && formData.value.remotePathMappingIds.length > 0
? { remotePathMappingIds: formData.value.remotePathMappingIds }
: {}),
Expand Down Expand Up @@ -650,7 +684,7 @@ const handleSubmit = async () => {
isEnabled: formData.value.isEnabled,
removeCompletedDownloads: formData.value.removeCompletedDownloads,
settings: {
...(formData.value.type === 'sabnzbd' && formData.value.apiKey
...((formData.value.type === 'sabnzbd' || formData.value.type === 'slskd') && formData.value.apiKey
? { apiKey: formData.value.apiKey }
: {}),
...(formData.value.type === 'transmission' && formData.value.urlBase
Expand All @@ -666,6 +700,10 @@ const handleSubmit = async () => {
sequentialOrder: formData.value.sequentialOrder,
firstAndLastFirst: formData.value.firstAndLastFirst,
contentLayout: formData.value.contentLayout,
priority: formData.value.priority,
isDefault: formData.value.isDefault,
allowProtocolFallback: formData.value.allowProtocolFallback,
...(formData.value.type === 'slskd' ? { listenarrSourceRoot: formData.value.listenarrSourceRoot } : {}),
...(formData.value.remotePathMappingIds && formData.value.remotePathMappingIds.length > 0
? { remotePathMappingIds: formData.value.remotePathMappingIds }
: {}),
Expand Down
6 changes: 5 additions & 1 deletion fe/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export interface ApiConfiguration {
export interface DownloadClientConfiguration {
id: string
name: string
type: 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget'
type: 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget' | 'slskd'
host: string
port: number
username: string
Expand All @@ -268,6 +268,10 @@ export interface DownloadClientSettings {
initialState?: string
sequentialOrder?: boolean
firstAndLastFirst?: boolean
priority?: number
isDefault?: boolean
allowProtocolFallback?: boolean
listenarrSourceRoot?: string
contentLayout?: string
// Optional mapping to one or more remote path mapping IDs
remotePathMappingIds?: number[]
Expand Down
12 changes: 12 additions & 0 deletions listenarr.api/Features/Downloads/DownloadController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class DownloadController : ControllerBase
private readonly ILogger<DownloadController> _logger;
private readonly IDownloadReferenceService? _downloadReferenceService;


public DownloadController(
IDownloadService downloadService,
IDownloadQueueService downloadQueueService,
Expand All @@ -44,6 +45,7 @@ public DownloadController(
_downloadProcessingJobService = downloadProcessingJobService;
_logger = logger;
_downloadReferenceService = downloadReferenceService;

}

/// <summary>
Expand All @@ -65,6 +67,7 @@ public async Task<ActionResult<SearchAndDownloadResult>> SearchAndDownload([From
}
}


/// <summary>
/// Send a specific search result to a download client (torrent or NZB).
/// </summary>
Expand Down Expand Up @@ -338,6 +341,15 @@ public class SearchAndDownloadRequest
public int AudiobookId { get; set; }
}

public class SlskdDownloadRequest
{
public int AudiobookId { get; set; }
public string DownloadClientId { get; set; } = string.Empty;
public string SearchQuery { get; set; } = string.Empty;
public string? Title { get; set; }
public string? Author { get; set; }
}

public class SendDownloadRequest
{
public string DownloadReference { get; set; } = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Listenarr - Audiobook Management System
* Copyright (C) 2024-2026 Listenarr Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/

namespace Listenarr.Application.Common
{
/// <summary>
/// Represents an expected rejection because the audiobook already has an active or imported download.
/// Callers must not retry through another protocol because the submission guard runs before side effects.
/// </summary>
public sealed class DuplicateDownloadSubmissionException : Exception
{
public DuplicateDownloadSubmissionException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Listenarr.Application.Downloads.Contracts;

/// <summary>
/// Executes the native slskd search, exact-file batch submission, and batch-status poll.
/// This is intentionally separate from torrent/NZB submission contracts.
/// </summary>
public interface ISlskdDownloadService
{
Task<SlskdSubmissionResult> SearchSubmitAndPollAsync(
DownloadClientConfiguration client,
SlskdSubmissionRequest request,
CancellationToken ct = default);
}

public sealed record SlskdSubmissionRequest(int AudiobookId, string SearchQuery, string? Title = null, string? Author = null);
public sealed record SlskdSubmissionResult(string BatchId, string Destination, IReadOnlyList<string> CompletedFiles, string? State);
Loading