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: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Listenarr is a fast, feature-rich, cross-platform audiobook management server. B
- [x] **First-class responsive web interface** that works great on any device (phone, tablet, desktop)
- [x] **Rich metadata support** with automatic enrichment from Audible and Amazon
- [x] **External API integration** for searching across multiple torrent and NZB indexers simultaneously
- [x] **Download management** with support for popular clients (qBittorrent, Transmission, SABnzbd, NZBGet)
- [x] **Download management** with support for popular clients (qBittorrent, Transmission, Deluge, SABnzbd, NZBGet)
- [x] **Real-time monitoring** of download progress and status
- [x] **Intelligent file organization** with customizable naming patterns
- [ ] **Full localization support** Soon™️
Expand Down Expand Up @@ -386,6 +386,7 @@ Supported download clients:

- **qBittorrent** - Popular torrent client with web UI
- **Transmission** - Cross-platform torrent client
- **Deluge** - Torrent client via Deluge Web JSON-RPC
- **SABnzbd** - Usenet downloader
- **NZBGet** - Efficient usenet client

Expand Down
34 changes: 25 additions & 9 deletions fe/src/components/domain/download/DownloadClientFormModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
<select id="type" v-model="formData.type" required @change="onTypeChange">
<option value="qbittorrent">qBittorrent</option>
<option value="transmission">Transmission</option>
<option value="deluge">Deluge</option>
<option value="sabnzbd">SABnzbd</option>
<option value="nzbget">NZBGet</option>
</select>
Expand Down Expand Up @@ -116,18 +117,22 @@
</Checkbox>
</div>

<div class="form-group" v-if="formData.type === 'transmission'">
<div
class="form-group"
v-if="formData.type === 'transmission' || formData.type === 'deluge'"
>
<label for="urlBase">URL Base</label>
<input
id="urlBase"
v-model="formData.urlBase"
type="text"
placeholder="/transmission/rpc"
:placeholder="formData.type === 'deluge' ? '/deluge' : '/transmission/rpc'"
/>
<small
>RPC path for the Transmission endpoint. Default is <code>/transmission/rpc</code>.
Some seedbox providers use a custom path (e.g. <code>/rpc</code>).</small
>
<small>{{
formData.type === 'deluge'
? 'Optional reverse proxy prefix for Deluge Web. The JSON-RPC endpoint will be /json under this path.'
: 'RPC path for the Transmission endpoint. Default is /transmission/rpc. Some seedbox providers use a custom path (e.g. /rpc).'
}}</small>
</div>
</FormSection>

Expand Down Expand Up @@ -404,7 +409,7 @@ const testing = ref(false)

const defaultFormData = {
name: '',
type: 'qbittorrent' as 'qbittorrent' | 'transmission' | 'sabnzbd' | 'nzbget',
type: 'qbittorrent' as 'qbittorrent' | 'transmission' | 'deluge' | 'sabnzbd' | 'nzbget',
host: '',
port: 8080,
username: '',
Expand Down Expand Up @@ -469,6 +474,7 @@ const getHostPlaceholder = () => {
const placeholders: Record<string, string> = {
qbittorrent: 'qbittorrent.tld.com',
transmission: 'transmission.tld.com',
deluge: 'deluge.tld.com',
sabnzbd: 'sabnzbd.tld.com',
nzbget: 'nzbget.tld.com',
}
Expand All @@ -479,6 +485,7 @@ const getPortPlaceholder = () => {
const ports: Record<string, number> = {
qbittorrent: 8080,
transmission: 9091,
deluge: 8112,
sabnzbd: 8080,
nzbget: 6789,
}
Expand All @@ -489,6 +496,7 @@ const getPortHelpText = () => {
const hints: Record<string, string> = {
transmission:
'RPC port (default: 9091). This is not the web UI port if you changed it separately.',
deluge: 'Deluge Web UI port (default: 8112). The adapter uses Deluge Web JSON-RPC at /json.',
qbittorrent: 'Web UI port (default: 8080). Found in qBittorrent → Options → Web UI.',
sabnzbd: 'Web interface port (default: 8080). Found in SABnzbd → Config → General.',
nzbget: 'Web interface port (default: 6789). Found in NZBGet → Settings → Connection.',
Expand All @@ -500,6 +508,9 @@ const getCategoryHelp = () => {
if (isUsenet.value) {
return 'Adding a category specific to Listenarr avoids conflicts with unrelated non-Listenarr downloads. Using a category is optional, but strongly recommended.'
}
if (formData.value.type === 'deluge') {
return 'Adding a category specific to Listenarr avoids conflicts with unrelated downloads. Deluge category support requires the Label plugin to be enabled.'
}
return 'Adding a category specific to Listenarr avoids conflicts with unrelated downloads.'
}

Expand All @@ -508,6 +519,7 @@ const onTypeChange = () => {
const defaultPorts: Record<string, number> = {
qbittorrent: 8080,
transmission: 9091,
deluge: 8112,
sabnzbd: 8080,
nzbget: 6789,
}
Expand All @@ -518,6 +530,8 @@ const onTypeChange = () => {
formData.value.password = ''
} else {
formData.value.apiKey = ''
if (formData.value.type === 'deluge' && !formData.value.category)
formData.value.category = 'listenarr'
}
}

Expand Down Expand Up @@ -593,7 +607,8 @@ const testConnection = async () => {
...(formData.value.type === 'sabnzbd' && formData.value.apiKey
? { apiKey: formData.value.apiKey }
: {}),
...(formData.value.type === 'transmission' && formData.value.urlBase
...((formData.value.type === 'transmission' || formData.value.type === 'deluge') &&
formData.value.urlBase
? { urlBase: formData.value.urlBase }
: {}),
...(formData.value.category && { category: formData.value.category }),
Expand Down Expand Up @@ -653,7 +668,8 @@ const handleSubmit = async () => {
...(formData.value.type === 'sabnzbd' && formData.value.apiKey
? { apiKey: formData.value.apiKey }
: {}),
...(formData.value.type === 'transmission' && formData.value.urlBase
...((formData.value.type === 'transmission' || formData.value.type === 'deluge') &&
formData.value.urlBase
? { urlBase: formData.value.urlBase }
: {}),
...(formData.value.category && { category: formData.value.category }),
Expand Down
2 changes: 1 addition & 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' | 'deluge' | 'sabnzbd' | 'nzbget'
host: string
port: number
username: string
Expand Down
4 changes: 2 additions & 2 deletions fe/src/views/settings/DownloadClientsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
<div v-else-if="configStore.downloadClientConfigurations.length === 0" class="empty-state">
<PhDownloadSimple />
<p>
No download clients configured. Add qBittorrent, Transmission, SABnzbd, or NZBGet to
download audiobooks.
No download clients configured. Add qBittorrent, Transmission, Deluge, SABnzbd, or NZBGet
to download audiobooks.
</p>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ private async Task<bool> RemoveFromClientAsync(DownloadClientConfiguration clien
if (downloadRecord?.Metadata != null)
{
if ((string.Equals(client.Type, "qbittorrent", StringComparison.OrdinalIgnoreCase) ||
string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase)) &&
string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase) ||
string.Equals(client.Type, "deluge", StringComparison.OrdinalIgnoreCase)) &&
downloadRecord.Metadata.TryGetValue("TorrentHash", out var hashObj))
{
var hash = hashObj?.ToString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ public static void ApplyClientSpecificId(
download.Metadata["ClientDownloadId"] = clientSpecificId;

if (downloadClient.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase) ||
downloadClient.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase))
downloadClient.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase) ||
downloadClient.Type.Equals("deluge", StringComparison.OrdinalIgnoreCase))
{
download.Metadata["TorrentHash"] = clientSpecificId;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,13 @@ public class DownloadHashRetrievalService
public DownloadHashRetrievalService(
ILogger<DownloadHashRetrievalService> logger,
IDownloadHistoryRepository historyRepository,
IDownloadClientAdapter qbittorrentAdapter,
IDownloadClientAdapter transmissionAdapter,
IDownloadClientAdapter sabnzbdAdapter,
IDownloadClientAdapter nzbgetAdapter)
IEnumerable<IDownloadClientAdapter> adapters)
{
_logger = logger;
_historyRepository = historyRepository;

// Map adapters by protocol type
_adapters = new Dictionary<string, IDownloadClientAdapter>(StringComparer.OrdinalIgnoreCase)
{
["qbittorrent"] = qbittorrentAdapter,
["transmission"] = transmissionAdapter,
["sabnzbd"] = sabnzbdAdapter,
["nzbget"] = nzbgetAdapter
};
_adapters = adapters
.Where(a => !string.IsNullOrWhiteSpace(a.ClientType))
.ToDictionary(a => a.ClientType, StringComparer.OrdinalIgnoreCase);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,8 @@ private async Task PersistDiscoveredClientIdentifiersAsync(
allKnownClientItemIds.Add(originalClientId);

if (string.Equals(client.Type, "qbittorrent", StringComparison.OrdinalIgnoreCase) ||
string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase))
string.Equals(client.Type, "transmission", StringComparison.OrdinalIgnoreCase) ||
string.Equals(client.Type, "deluge", StringComparison.OrdinalIgnoreCase))
{
var existingTorrentHash = DownloadQueueMetadataMatcher.GetMetadataString(matchedDownload.Metadata, "TorrentHash");
if (!string.Equals(existingTorrentHash, originalClientId, StringComparison.OrdinalIgnoreCase))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,16 @@ public class DownloadClientSelector(
if (isTorrent)
{
var client = enabledClients.FirstOrDefault(c => c.Type.Equals("qbittorrent", StringComparison.OrdinalIgnoreCase))
?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase));
?? enabledClients.FirstOrDefault(c => c.Type.Equals("transmission", StringComparison.OrdinalIgnoreCase))
?? enabledClients.FirstOrDefault(c => c.Type.Equals("deluge", StringComparison.OrdinalIgnoreCase));

if (client != null)
{
logger.LogInformation("Selected torrent client: {ClientName} ({ClientType})", client.Name, client.Type);
}
else
{
logger.LogWarning("No torrent client (qBittorrent or Transmission) found among enabled clients");
logger.LogWarning("No torrent client (qBittorrent, Transmission, or Deluge) found among enabled clients");
}

return client?.Id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ public async Task<string> SendToDownloadClientAsync(
if (downloadClientId == null)
{
var clientType = isTorrent ? "torrent" : "NZB";
var neededClients = isTorrent ? "qBittorrent or Transmission" : "SABnzbd or NZBGet";
var neededClients = isTorrent ? "qBittorrent, Transmission, or Deluge" : "SABnzbd or NZBGet";
throw new Exception($"No suitable download client found for {clientType}. Please configure and enable a {clientType} client ({neededClients}) in Settings.");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public class DownloadClientConfiguration
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty; // "qbittorrent", "transmission", "sabnzbd", "nzbget"
public string Type { get; set; } = string.Empty; // "qbittorrent", "transmission", "deluge", "sabnzbd", "nzbget"
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public string Username { get; set; } = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public static IServiceCollection AddDownloadClientHttpClients(this IServiceColle

AddAdapterClient(services, DownloadClientTypes.Qbittorrent, useCookies: true, retryPolicy, circuitBreakerPolicy);
AddAdapterClient(services, DownloadClientTypes.Transmission, useCookies: false, retryPolicy, circuitBreakerPolicy);
AddAdapterClient(services, DownloadClientTypes.Deluge, useCookies: true, retryPolicy, circuitBreakerPolicy);
AddAdapterClient(services, DownloadClientTypes.Sabnzbd, useCookies: false, retryPolicy, circuitBreakerPolicy);
AddAdapterClient(services, DownloadClientTypes.Nzbget, useCookies: false, retryPolicy, circuitBreakerPolicy);
return services;
Expand All @@ -53,9 +54,9 @@ public static IServiceCollection AddDownloadClientAdapters(
services.TryAddSingleton(TimeProvider.System);
services.AddScoped<INzbUrlResolver, NzbUrlResolver>();
services.AddScoped<ITorrentFileDownloader, TorrentFileDownloader>();

services.AddQbittorrentWorkflows();
services.AddTransmissionWorkflows();
services.AddDelugeWorkflows();
services.AddSabnzbdWorkflows();
services.AddNzbgetWorkflows();

Expand All @@ -74,6 +75,15 @@ public static IServiceCollection AddDownloadClientAdapters(
sp.GetRequiredService<TransmissionQueueFetchWorkflow>(),
sp.GetRequiredService<TransmissionItemFetchWorkflow>(),
sp.GetRequiredService<TransmissionImportItemResolver>()));
services.AddScoped<IDownloadClientAdapter>(sp => new DelugeAdapter(
sp.GetRequiredService<DelugeRpcClient>(),
sp.GetRequiredService<DelugeConnectionTester>(),
sp.GetRequiredService<DelugeAddWorkflow>(),
sp.GetRequiredService<DelugeRemovalWorkflow>(),
sp.GetRequiredService<DelugeQueueFetchWorkflow>(),
sp.GetRequiredService<DelugeItemFetchWorkflow>(),
sp.GetRequiredService<DelugeImportItemResolver>(),
sp.GetRequiredService<ILogger<DelugeAdapter>>()));
services.AddScoped<IDownloadClientAdapter>(sp => new SabnzbdAdapter(
sp.GetRequiredService<SabnzbdConnectionTester>(),
sp.GetRequiredService<SabnzbdAddWorkflow>(),
Expand Down Expand Up @@ -264,6 +274,40 @@ private static IServiceCollection AddNzbgetWorkflows(this IServiceCollection ser
return services;
}

private static IServiceCollection AddDelugeWorkflows(this IServiceCollection services)
{
services.AddScoped<DelugeRpcClient>(sp =>
new DelugeRpcClient(
sp.GetRequiredService<IHttpClientFactory>(),
DownloadClientTypes.Deluge,
sp.GetRequiredService<ILogger<DelugeAdapter>>()));
services.AddScoped<DelugeConnectionTester>(sp =>
new DelugeConnectionTester(
sp.GetRequiredService<DelugeRpcClient>(),
sp.GetRequiredService<ILogger<DelugeAdapter>>()));
services.AddScoped<DelugeAddWorkflow>(sp =>
new DelugeAddWorkflow(
sp.GetRequiredService<DelugeRpcClient>(),
sp.GetRequiredService<ILogger<DelugeAdapter>>()));
services.AddScoped<DelugeRemovalWorkflow>(sp =>
new DelugeRemovalWorkflow(
sp.GetRequiredService<DelugeRpcClient>(),
sp.GetRequiredService<ILogger<DelugeAdapter>>()));
services.AddScoped<DelugeQueueFetchWorkflow>(sp =>
new DelugeQueueFetchWorkflow(
sp.GetRequiredService<DelugeRpcClient>(),
sp.GetRequiredService<ILogger<DelugeAdapter>>()));
services.AddScoped<DelugeItemFetchWorkflow>(sp =>
new DelugeItemFetchWorkflow(
sp.GetRequiredService<DelugeRpcClient>(),
sp.GetRequiredService<ILogger<DelugeAdapter>>()));
services.AddScoped<DelugeImportItemResolver>(sp =>
new DelugeImportItemResolver(
sp.GetRequiredService<DelugeRpcClient>(),
sp.GetRequiredService<ILogger<DelugeAdapter>>()));
return services;
}

private static void AddAdapterClient(
IServiceCollection services,
string name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ internal static class DownloadClientTypes
public const string Transmission = "transmission";
public const string Sabnzbd = "sabnzbd";
public const string Nzbget = "nzbget";
public const string Deluge = "deluge";
}
}
Loading