+ Configure your Prowlarr instance to feed search results, and pick a torrent client and/or usenet client to handle the actual downloads. You only need to set up the protocols you actually use.
+
+
+
+
+
+ ▼
+
🔎 Indexers
+ Prowlarr — search aggregator
+
+
+
+
+
+
⬢ Prowlarr
+
+ Prowlarr manages your Usenet and torrent indexers and exposes them through one API. SoulSync uses Prowlarr to search across every indexer at once for the Torrent and Usenet download sources.
+
+ Don't have it? Grab Prowlarr from prowlarr.com (or your *arr stack). You point Prowlarr at your indexers, then point SoulSync at Prowlarr.
+
+
+
+
+
+ Full URL to your Prowlarr instance (e.g. http://192.168.1.100:9696).
+
+
+
+
+
+
+ Found in Prowlarr → Settings → General → Security → API Key.
+
+
+
+
+
+
+ Comma-separated Prowlarr indexer IDs. Leave blank to search every enabled indexer. Restrict when you have a private tracker you want to prioritise or a noisy public one to exclude.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connect to Prowlarr and click Refresh Indexer List to see the indexers SoulSync can search.
+
+
+
+
+
+
+
+
+ ▼
+
🧲 Torrent Client
+ qBittorrent · Transmission · Deluge
+
+
+
+
+
+
⬢ Torrent Downloader
+
+ Where SoulSync sends torrents once Prowlarr finds them. Pick the client you already use — only one can be active at a time. Add the URL of the client's WebUI, log in if it asks for credentials, and hit Test Connection.
+
+
+
+
+
+ qBittorrent: WebUI port (default 8080). Transmission: RPC port (default 9091). Deluge: WebUI port (default 8112).
+
+
+
+
+
+
+
+
+
+
+ qBittorrent and Transmission use username + password. Deluge uses password only — paste it in the password field below.
+
+
+
+
+
+
+
+
+
+
+ SoulSync tags every torrent it adds with this label so it's easy to spot in your client. Deluge needs the Label plugin installed for this to stick.
+
+
+
+
+
+
+ Override where the torrent client writes downloads. This path is on the torrent client's machine, not SoulSync's.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ▼
+
📰 Usenet Client
+ SABnzbd · NZBGet
+
+
+
+
+
+
⬢ Usenet Downloader
+
+ Where SoulSync sends NZBs once Prowlarr finds them. Pick one usenet downloader. SABnzbd uses an API key for auth; NZBGet uses a username + password.
+
+
+
+
+
+ SABnzbd: default WebUI port 8080. NZBGet: default WebUI port 6789.
+
+
+
+
+
+
+
+
+
+
+ SABnzbd → Config → General → API Key. Used by SABnzbd only.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SoulSync tags every NZB with this category so it ends up in a predictable post-processing folder.
+
+
+
+
+
+
+
+
+
+
+
+
+
▼
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 61df89d5..f60b0527 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3413,6 +3413,21 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
+ '2.6.0': [
+ { unreleased: true },
+ { title: 'Indexers & Downloaders tab restyled with collapsible sections', desc: 'tab now opens with an intro hero card explaining the flow (indexers find releases → downloader fetches them → SoulSync imports) and folds the rest into three collapsible sections: Indexers, Torrent Client, Usenet Client. each section gets a per-service color accent (Prowlarr orange, torrent sky-blue, usenet violet), a status dot in the header (green when Test Connection succeeds, red on failure, grey before testing), and Lidarr-style indexer cards with protocol badges instead of the inline emoji list. Indexers section is open by default; the downloader sections start collapsed since not everyone uses both protocols.' },
+ { title: 'Regression tests for the new indexer + downloader plumbing', desc: 'mocked unit tests for Prowlarr + all five downloader adapters (qBittorrent, Transmission, Deluge, SABnzbd, NZBGet). 54 tests pin the state-mapping tables, the parse logic, and the protocol quirks each client needs handled (qBit Referer header, Transmission session-id renegotiation, Deluge magnet-vs-URL method split, SAB queue+history merge, NZBGet 64-bit size fields). next time anyone touches one of these adapters, CI catches breakage before it hits a real downloader.' },
+ { title: 'Usenet client adapters (SABnzbd, NZBGet)', desc: 'third commit in the torrent + usenet rollout. SoulSync now talks to SABnzbd and NZBGet through a sibling adapter contract that mirrors the torrent adapter set — pick one downloader in Settings → Indexers & Downloaders, fill in its API key (SABnzbd) or username + password (NZBGet), and Test Connection confirms the link. all three layers are now stood up: Prowlarr finds releases, the torrent adapter and the usenet adapter each know how to ship work to the underlying client. next commit wires Prowlarr search → adapter dispatch → archive extraction so the new sources actually download. job state mapping covers SABnzbd queue + history and NZBGet groups + history, including the verify/repair/unpack phases that are unique to usenet.' },
+ { title: 'Torrent client adapters (qBittorrent, Transmission, Deluge)', desc: 'second commit in the torrent + usenet rollout. SoulSync can now talk to any of the three big torrent clients through a single adapter contract — pick which one you use in Settings → Indexers & Downloaders, paste your WebUI URL and credentials, and Test Connection confirms the link. each adapter handles its own auth quirk (qBit cookies, Transmission session-id, Deluge JSON-RPC) and maps native state strings onto a uniform set so the rest of the app stays client-agnostic. no download wiring yet — that gets layered on once the usenet client adapters land in the next commit.' },
+ { title: 'Prowlarr integration', desc: 'new Indexers & Downloaders tab in Settings. point SoulSync at your Prowlarr instance with a URL and API key, and you can browse the indexers Prowlarr exposes from inside the app. this is the search half of the upcoming torrent and usenet download sources — wires up the indexer list now so later commits can plug the download flow on top. Lidarr already pulls from its own indexers; Prowlarr unlocks the same search surface to the rest of the download pipeline.' },
+ ],
+ '2.5.8': [
+ { date: 'May 20, 2026 — 2.5.8 release' },
+ { title: 'Fix: blank artist pages on Python / git-pull installs', desc: 'PR #644 moved the artist detail page behind a TanStack React route. installs that pull from git but never run `npm install && npm run build` ship without the Vite bundle, so the legacy shell saw `/artist-detail//` URLs and bailed — every click left a blank pane. the legacy startup path now parses the URL itself and hands off to the existing artist detail loader, so Python users get artist pages back without needing to build the webui. Docker / built installs still take the React route as before.' },
+ { title: 'Fix: downloads marked complete before post-processing finished', desc: 'monitored Soulseek transfers were getting flipped to "successful" the moment slskd reported the file done — before SoulSync had actually run the post-processing worker (find on disk, fingerprint-verify, import to library). a failed import after that point left a phantom "completed" row that never made it into the library. completion now waits for the real post-processing result; if the worker can\'t even be scheduled, the task is marked failed and the batch slot is released so the queue keeps moving.' },
+ { title: 'Disk-backed artwork cache', desc: 'image fetches now route through a disk + SQLite cache with hashed URLs, size / mime validation, stale fallback when the upstream is down, and per-image fetch locking so 12 simultaneous requests for the same album cover share one network round-trip. cuts repeat-load latency and survives metadata source rate limits. served from new `/api/image-cache`; `/api/image-proxy` stays as a compatibility shim.' },
+ { title: 'Strict-source downloads check duration before pulling', desc: 'Tidal / Qobuz / HiFi / Deezer-DL / Amazon candidates now get a duration-tolerance check before the download starts, using the same tolerance logic post-processing would apply after. tracks whose duration is far enough off the metadata reference to fail the integrity check are skipped at pick time instead of after wasting the download. Soulseek and YouTube unchanged (they don\'t expose reliable pre-download duration).' },
+ ],
'2.5.7': [
{ date: 'May 19, 2026 — 2.5.7 release' },
{ title: 'Fix: MusicBrainz manual search missing results', desc: 'the Fix popup and manual library service search were using strict Lucene phrase-match queries against the `recording` / `release` / `artist` fields — diacritics ("Bjork" vs canonical "Björk"), bracketed suffixes like "(Live)", and any AND-clause mismatch all killed recall. switched user-facing manual lookups to bare queries that hit MB\'s alias / sortname indexes with diacritic folding. enrichment workers stay strict for precision.' },
@@ -3464,6 +3479,46 @@ const WHATS_NEW = {
// Section shape: { title, description, features: [bullet strings],
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
+ {
+ title: "Usenet Client Adapters (SABnzbd, NZBGet)",
+ description: "third phase of the torrent + usenet rollout. SoulSync now also talks to the two big usenet downloaders through a sibling adapter contract. Prowlarr + torrent + usenet are all stood up — next commit wires them together into actual download sources.",
+ features: [
+ "• supports SABnzbd (API-key auth) and NZBGet (JSON-RPC basic auth)",
+ "• new Usenet Client section on the Indexers & Downloaders tab; client picker swaps the credential fields automatically (API key vs username + password)",
+ "• state mapping covers the verify / repair / unpack phases unique to usenet",
+ "• category override so SoulSync's NZBs land in a predictable post-processing folder",
+ "• Test Connection probes the live API",
+ "• next commit wires Prowlarr → adapter → archive extraction → match so the new sources fully download",
+ ],
+ usage_note: "Settings → Indexers & Downloaders → Usenet Client",
+ },
+ {
+ title: "Torrent Client Adapters (qBit, Transmission, Deluge)",
+ description: "second phase of the torrent + usenet rollout. SoulSync now speaks the three big torrent client APIs through one uniform adapter — pick which client you use and SoulSync handles the auth and protocol quirks for you.",
+ features: [
+ "• supports qBittorrent (WebUI v2), Transmission (RPC), Deluge 2.x (JSON-RPC)",
+ "• new Torrent Client section on the Indexers & Downloaders tab",
+ "• single client type picker — switching between clients is a dropdown change, no code path divergence",
+ "• per-client credential fields with hints (qBit / Transmission use username + password, Deluge uses password only)",
+ "• optional category/label and save-path overrides so SoulSync's torrents are easy to spot in your client",
+ "• Test Connection probes the live WebUI and confirms auth works",
+ "• no downloads triggered yet — the wire-up to Prowlarr search results lands once usenet client adapters ship",
+ ],
+ usage_note: "Settings → Indexers & Downloaders → Torrent Client",
+ },
+ {
+ title: "Prowlarr Integration (Phase 1 of Torrent + Usenet)",
+ description: "first commit toward torrent and usenet download sources. SoulSync can now talk to your Prowlarr instance and pull the list of configured indexers, setting up the search surface that the torrent and usenet clients will plug into next.",
+ features: [
+ "• new Indexers & Downloaders tab on the Settings page",
+ "• point SoulSync at Prowlarr with a URL and API key — same kind of setup as Lidarr",
+ "• Test Connection button confirms Prowlarr is reachable and authenticated",
+ "• Refresh Indexer List pulls the full list of indexers Prowlarr is currently managing (torrent + usenet, enabled state, privacy level)",
+ "• optional indexer-ID allowlist if you want SoulSync to only search a subset",
+ "• no downloads yet — torrent and usenet client adapters land in the next commits",
+ ],
+ usage_note: "Settings → Indexers & Downloaders → Prowlarr",
+ },
{
title: "MusicBrainz Is Now a First-Class Metadata Source",
description: "MusicBrainz was already available as an optional search tab, but it wasn't selectable as your primary metadata source. now it is — switch to it in Settings → Metadata Source and the whole app routes through it.",
@@ -3795,7 +3850,7 @@ function _getLatestWhatsNewVersion() {
const versions = Object.keys(WHATS_NEW)
.filter(v => _compareVersions(v, buildVer) <= 0)
.sort((a, b) => _compareVersions(b, a));
- return versions[0] || '2.5.7';
+ return versions[0] || '2.5.8';
}
function openWhatsNew() {
diff --git a/webui/static/settings.js b/webui/static/settings.js
index 2329d142..abe02287 100644
--- a/webui/static/settings.js
+++ b/webui/static/settings.js
@@ -946,6 +946,37 @@ async function loadSettingsData() {
document.getElementById('amazon-allow-fallback').checked = settings.amazon_download?.allow_fallback !== false;
document.getElementById('lidarr-url').value = settings.lidarr_download?.url || '';
document.getElementById('lidarr-api-key').value = settings.lidarr_download?.api_key || '';
+ const _prowUrl = document.getElementById('prowlarr-url');
+ const _prowKey = document.getElementById('prowlarr-api-key');
+ const _prowIds = document.getElementById('prowlarr-indexer-ids');
+ if (_prowUrl) _prowUrl.value = settings.prowlarr?.url || '';
+ if (_prowKey) _prowKey.value = settings.prowlarr?.api_key || '';
+ if (_prowIds) _prowIds.value = settings.prowlarr?.indexer_ids || '';
+ const _tcType = document.getElementById('torrent-client-type');
+ const _tcUrl = document.getElementById('torrent-client-url');
+ const _tcUser = document.getElementById('torrent-client-username');
+ const _tcPass = document.getElementById('torrent-client-password');
+ const _tcCat = document.getElementById('torrent-client-category');
+ const _tcPath = document.getElementById('torrent-client-save-path');
+ if (_tcType) _tcType.value = settings.torrent_client?.type || 'qbittorrent';
+ if (_tcUrl) _tcUrl.value = settings.torrent_client?.url || '';
+ if (_tcUser) _tcUser.value = settings.torrent_client?.username || '';
+ if (_tcPass) _tcPass.value = settings.torrent_client?.password || '';
+ if (_tcCat) _tcCat.value = settings.torrent_client?.category || 'soulsync';
+ if (_tcPath) _tcPath.value = settings.torrent_client?.save_path || '';
+ const _ucType = document.getElementById('usenet-client-type');
+ const _ucUrl = document.getElementById('usenet-client-url');
+ const _ucKey = document.getElementById('usenet-client-api-key');
+ const _ucUser = document.getElementById('usenet-client-username');
+ const _ucPass = document.getElementById('usenet-client-password');
+ const _ucCat = document.getElementById('usenet-client-category');
+ if (_ucType) _ucType.value = settings.usenet_client?.type || 'sabnzbd';
+ if (_ucUrl) _ucUrl.value = settings.usenet_client?.url || '';
+ if (_ucKey) _ucKey.value = settings.usenet_client?.api_key || '';
+ if (_ucUser) _ucUser.value = settings.usenet_client?.username || '';
+ if (_ucPass) _ucPass.value = settings.usenet_client?.password || '';
+ if (_ucCat) _ucCat.value = settings.usenet_client?.category || 'soulsync';
+ if (typeof updateUsenetClientUI === 'function') updateUsenetClientUI();
// Sync ARL to connections tab field + bidirectional listeners
const _connArl = document.getElementById('deezer-connection-arl');
const _dlArl = document.getElementById('deezer-download-arl');
@@ -2691,6 +2722,27 @@ async function saveSettings(quiet = false) {
url: document.getElementById('lidarr-url').value || '',
api_key: document.getElementById('lidarr-api-key').value || '',
},
+ prowlarr: {
+ url: document.getElementById('prowlarr-url')?.value || '',
+ api_key: document.getElementById('prowlarr-api-key')?.value || '',
+ indexer_ids: document.getElementById('prowlarr-indexer-ids')?.value || '',
+ },
+ torrent_client: {
+ type: document.getElementById('torrent-client-type')?.value || 'qbittorrent',
+ url: document.getElementById('torrent-client-url')?.value || '',
+ username: document.getElementById('torrent-client-username')?.value || '',
+ password: document.getElementById('torrent-client-password')?.value || '',
+ category: document.getElementById('torrent-client-category')?.value || 'soulsync',
+ save_path: document.getElementById('torrent-client-save-path')?.value || '',
+ },
+ usenet_client: {
+ type: document.getElementById('usenet-client-type')?.value || 'sabnzbd',
+ url: document.getElementById('usenet-client-url')?.value || '',
+ api_key: document.getElementById('usenet-client-api-key')?.value || '',
+ username: document.getElementById('usenet-client-username')?.value || '',
+ password: document.getElementById('usenet-client-password')?.value || '',
+ category: document.getElementById('usenet-client-category')?.value || 'soulsync',
+ },
soundcloud_download: {
// No knobs yet — anonymous-only. Keeping the key present so
// future tier-2 OAuth wiring (Go+ session token) doesn't have
@@ -3530,6 +3582,160 @@ async function testLidarrConnection() {
}
}
+function _setIndStatusDot(dotId, state) {
+ const dot = document.getElementById(dotId);
+ if (!dot) return;
+ dot.classList.remove('ind-status-dot-unknown', 'ind-status-dot-connected', 'ind-status-dot-error');
+ if (state === 'connected') {
+ dot.classList.add('ind-status-dot-connected');
+ dot.title = 'Connected';
+ } else if (state === 'error') {
+ dot.classList.add('ind-status-dot-error');
+ dot.title = 'Connection failed';
+ } else {
+ dot.classList.add('ind-status-dot-unknown');
+ dot.title = 'Not tested';
+ }
+}
+
+async function testProwlarrConnection() {
+ const statusEl = document.getElementById('prowlarr-connection-status');
+ if (!statusEl) return;
+ statusEl.textContent = 'Checking...';
+ statusEl.style.color = '#aaa';
+ try {
+ await saveSettings();
+ const resp = await fetch('/api/test-connection', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service: 'prowlarr' })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ statusEl.textContent = data.message || 'Connected';
+ statusEl.style.color = '#4caf50';
+ _setIndStatusDot('prowlarr-status-dot', 'connected');
+ loadProwlarrIndexers();
+ } else {
+ statusEl.textContent = data.error || 'Connection failed';
+ statusEl.style.color = '#f44336';
+ _setIndStatusDot('prowlarr-status-dot', 'error');
+ }
+ } catch (e) {
+ statusEl.textContent = 'Connection error';
+ statusEl.style.color = '#f44336';
+ _setIndStatusDot('prowlarr-status-dot', 'error');
+ }
+}
+
+function updateUsenetClientUI() {
+ const type = document.getElementById('usenet-client-type')?.value || 'sabnzbd';
+ const apikeyGroup = document.getElementById('usenet-apikey-group');
+ const userGroup = document.getElementById('usenet-username-group');
+ const passGroup = document.getElementById('usenet-password-group');
+ if (type === 'sabnzbd') {
+ if (apikeyGroup) apikeyGroup.style.display = '';
+ if (userGroup) userGroup.style.display = 'none';
+ if (passGroup) passGroup.style.display = 'none';
+ } else {
+ if (apikeyGroup) apikeyGroup.style.display = 'none';
+ if (userGroup) userGroup.style.display = '';
+ if (passGroup) passGroup.style.display = '';
+ }
+}
+
+async function testUsenetClientConnection() {
+ const statusEl = document.getElementById('usenet-client-connection-status');
+ if (!statusEl) return;
+ statusEl.textContent = 'Checking...';
+ statusEl.style.color = '#aaa';
+ try {
+ await saveSettings();
+ const resp = await fetch('/api/test-connection', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service: 'usenet_client' })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ statusEl.textContent = data.message || 'Connected';
+ statusEl.style.color = '#4caf50';
+ _setIndStatusDot('usenet-client-status-dot', 'connected');
+ } else {
+ statusEl.textContent = data.error || 'Connection failed';
+ statusEl.style.color = '#f44336';
+ _setIndStatusDot('usenet-client-status-dot', 'error');
+ }
+ } catch (e) {
+ statusEl.textContent = 'Connection error';
+ statusEl.style.color = '#f44336';
+ _setIndStatusDot('usenet-client-status-dot', 'error');
+ }
+}
+
+async function testTorrentClientConnection() {
+ const statusEl = document.getElementById('torrent-client-connection-status');
+ if (!statusEl) return;
+ statusEl.textContent = 'Checking...';
+ statusEl.style.color = '#aaa';
+ try {
+ await saveSettings();
+ const resp = await fetch('/api/test-connection', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ service: 'torrent_client' })
+ });
+ const data = await resp.json();
+ if (data.success) {
+ statusEl.textContent = data.message || 'Connected';
+ statusEl.style.color = '#4caf50';
+ _setIndStatusDot('torrent-client-status-dot', 'connected');
+ } else {
+ statusEl.textContent = data.error || 'Connection failed';
+ statusEl.style.color = '#f44336';
+ _setIndStatusDot('torrent-client-status-dot', 'error');
+ }
+ } catch (e) {
+ statusEl.textContent = 'Connection error';
+ statusEl.style.color = '#f44336';
+ _setIndStatusDot('torrent-client-status-dot', 'error');
+ }
+}
+
+async function loadProwlarrIndexers() {
+ const listEl = document.getElementById('prowlarr-indexer-list');
+ if (!listEl) return;
+ listEl.innerHTML = 'Loading…';
+ try {
+ const resp = await fetch('/api/prowlarr/indexers');
+ const data = await resp.json();
+ if (!data.success) {
+ listEl.innerHTML = `${data.error || 'Prowlarr not configured.'}`;
+ return;
+ }
+ if (!data.indexers || data.indexers.length === 0) {
+ listEl.innerHTML = 'No indexers configured in Prowlarr yet. Add some in Prowlarr → Indexers.';
+ return;
+ }
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ const rows = data.indexers.map(idx => {
+ const protoClass = idx.protocol === 'usenet' ? 'ind-indexer-card-proto-usenet' : 'ind-indexer-card-proto-torrent';
+ const protoLabel = idx.protocol === 'usenet' ? 'Usenet' : 'Torrent';
+ const privacyHTML = idx.privacy ? `${esc(idx.privacy)}` : '';
+ const disabledClass = idx.enable ? '' : ' ind-indexer-card-disabled';
+ return `