feat(settings): add Prowlarr integration as indexer aggregator

First commit toward torrent and usenet download sources. Prowlarr is
the indexer manager component of the *arr stack — it exposes Usenet
and torrent indexers behind a single Newznab-style API so SoulSync
doesn't have to integrate each indexer individually. This commit
wires up Prowlarr as a search-only source; the torrent and usenet
download client adapters land in the next commits and plug into
this search surface.

- core/prowlarr_client.py: sync-backed async client. is_configured,
  check_connection, get_indexers, search by Newznab category. Music
  category constants (3000 all / 3010 MP3 / 3040 lossless / etc.).
- core/connection_test.py: 'prowlarr' branch hits /api/v1/system/status
  for the Test Connection button.
- web_server.py: GET /api/prowlarr/indexers returns the live indexer
  list (id, name, protocol, enabled, privacy). Settings POST allow-list
  now accepts 'prowlarr' so saved config persists.
- config/settings.py: prowlarr.{url, api_key, indexer_ids} defaults
  plus prowlarr.api_key in the encrypted-at-rest secrets list.
- webui/index.html: new "Indexers & Downloaders" tab on Settings with
  the Prowlarr panel (URL, API key, Test, Refresh Indexer List,
  optional indexer-ID allowlist).
- webui/static/settings.js: load/save wiring, testProwlarrConnection,
  loadProwlarrIndexers (HTML-escapes user-supplied indexer names).
- webui/static/helper.js: WHATS_NEW 2.6.0 unreleased block plus a
  curated VERSION_MODAL_SECTIONS entry.
pull/665/head
Broque Thomas 4 days ago
parent 3375b6c4bd
commit 579eff8807

@ -87,6 +87,7 @@ class ConfigManager:
'soulseek.api_key',
'deezer_download.arl',
'lidarr_download.api_key',
'prowlarr.api_key',
# Enrichment services
'listenbrainz.token',
'acoustid.api_key',
@ -519,6 +520,15 @@ class ConfigManager:
"quality_profile": "Any",
"cleanup_after_import": True,
},
# Prowlarr — indexer aggregator. Feeds the torrent / usenet
# download plugins. Not a standalone source.
"prowlarr": {
"url": "",
"api_key": "",
# Comma-separated list of indexer IDs to limit searches to.
# Empty = search all enabled indexers.
"indexer_ids": "",
},
"soundcloud_download": {
# Anonymous-only for now — SoundCloud Go+ OAuth tier could be
# added later, with credentials living under a "session" subkey

@ -305,6 +305,21 @@ def run_service_test(service, test_config):
return False, "Invalid Genius access token."
except Exception as e:
return False, f"Genius connection error: {str(e)}"
elif service == "prowlarr":
url = config_manager.get('prowlarr.url', '')
api_key = config_manager.get('prowlarr.api_key', '')
if not url or not api_key:
return False, "Prowlarr URL and API key are required."
try:
import requests as _req
resp = _req.get(f"{url.rstrip('/')}/api/v1/system/status",
headers={'X-Api-Key': api_key}, timeout=10)
if resp.ok:
version = resp.json().get('version', '?')
return True, f"Connected to Prowlarr v{version}"
return False, f"Prowlarr returned HTTP {resp.status_code}"
except Exception as e:
return False, f"Prowlarr connection error: {str(e)}"
elif service == "lidarr" or service == "lidarr_download":
url = config_manager.get('lidarr_download.url', '')
api_key = config_manager.get('lidarr_download.api_key', '')

@ -0,0 +1,241 @@
"""Prowlarr client — indexer aggregator.
Prowlarr is the indexer manager component of the *arr stack. It exposes
configured Usenet / torrent indexers behind a single Newznab-style API
so downstream apps (Lidarr, Sonarr, Radarr, SoulSync) don't have to
implement an indexer integration per provider.
This client is NOT a download source plugin. It does not implement
``DownloadSourcePlugin`` Prowlarr only *searches*. The torrent /
usenet download plugins (built in subsequent commits) own the
add-to-client / poll-status / extract flow and call this client for
the search step.
Surface:
- ``is_configured()`` URL + API key present.
- ``check_connection()`` hits ``/api/v1/system/status``.
- ``get_indexers()`` list of configured indexers (id, name, protocol,
capabilities).
- ``search(query, categories, indexer_ids)`` Newznab search across
selected indexers. Music categories default to the full audio tree.
Auth: ``X-Api-Key`` header. Found in Prowlarr Settings General
Security API Key.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence
import requests as http_requests
from config.settings import config_manager
from utils.logging_config import get_logger
logger = get_logger("prowlarr_client")
# Newznab Music category tree. Prowlarr / Jackett / Newznab indexers
# all agree on these numeric IDs. 3000 is the parent — most indexers
# tag releases against the parent OR a leaf; searching the parent
# pulls everything.
MUSIC_CATEGORY_ALL = 3000
MUSIC_CATEGORY_MP3 = 3010
MUSIC_CATEGORY_VIDEO = 3020
MUSIC_CATEGORY_AUDIOBOOK = 3030
MUSIC_CATEGORY_LOSSLESS = 3040
MUSIC_CATEGORY_OTHER = 3050
MUSIC_CATEGORY_FOREIGN = 3060
DEFAULT_MUSIC_CATEGORIES: tuple = (
MUSIC_CATEGORY_ALL,
MUSIC_CATEGORY_MP3,
MUSIC_CATEGORY_LOSSLESS,
MUSIC_CATEGORY_OTHER,
)
@dataclass
class ProwlarrIndexer:
"""One configured indexer exposed by Prowlarr."""
id: int
name: str
protocol: str # "torrent" | "usenet"
enable: bool
privacy: str # "public" | "private" | "semiPrivate"
categories: List[int] = field(default_factory=list)
capabilities: Dict[str, Any] = field(default_factory=dict)
@dataclass
class ProwlarrSearchResult:
"""One release returned by a Prowlarr search.
``download_url`` is the link the torrent / usenet client gets fed.
For torrent indexers it may be either a ``.torrent`` HTTP URL or
a magnet URI (sometimes both ``magnet_uri`` is set when the
indexer exposes the magnet separately).
"""
guid: str
title: str
indexer_id: int
indexer_name: str
protocol: str # "torrent" | "usenet"
download_url: Optional[str] = None
magnet_uri: Optional[str] = None
info_url: Optional[str] = None
size: int = 0 # bytes
seeders: Optional[int] = None
leechers: Optional[int] = None
grabs: Optional[int] = None
publish_date: Optional[str] = None
categories: List[int] = field(default_factory=list)
raw: Dict[str, Any] = field(default_factory=dict)
class ProwlarrClient:
"""Thin sync-backed async wrapper around the Prowlarr v1 API."""
DEFAULT_TIMEOUT = 15
def __init__(self) -> None:
self._load_config()
def _load_config(self) -> None:
self._url = (config_manager.get('prowlarr.url', '') or '').rstrip('/')
self._api_key = config_manager.get('prowlarr.api_key', '') or ''
def reload_settings(self) -> None:
self._load_config()
logger.info("Prowlarr settings reloaded")
def is_configured(self) -> bool:
return bool(self._url and self._api_key)
async def check_connection(self) -> bool:
if not self.is_configured():
return False
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._check_connection_sync)
def _check_connection_sync(self) -> bool:
data = self._api_get('system/status')
return bool(data and 'version' in data)
async def get_indexers(self) -> List[ProwlarrIndexer]:
if not self.is_configured():
return []
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._get_indexers_sync)
def _get_indexers_sync(self) -> List[ProwlarrIndexer]:
data = self._api_get('indexer')
if not isinstance(data, list):
return []
return [self._parse_indexer(entry) for entry in data if isinstance(entry, dict)]
async def search(
self,
query: str,
categories: Sequence[int] = DEFAULT_MUSIC_CATEGORIES,
indexer_ids: Optional[Sequence[int]] = None,
limit: int = 100,
) -> List[ProwlarrSearchResult]:
"""Run a Newznab search across the selected indexers.
``indexer_ids`` is the list of Prowlarr internal indexer IDs to
query. ``None`` means all enabled indexers.
"""
if not self.is_configured() or not query.strip():
return []
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, self._search_sync, query, list(categories), list(indexer_ids or []), limit
)
def _search_sync(
self,
query: str,
categories: List[int],
indexer_ids: List[int],
limit: int,
) -> List[ProwlarrSearchResult]:
# Prowlarr's search endpoint accepts repeated params: ``categories=3000&categories=3010``.
# ``requests`` serializes lists in that exact form when passed as tuples of pairs.
params: List[tuple] = [('query', query), ('type', 'search'), ('limit', limit)]
for cat in categories:
params.append(('categories', cat))
for indexer_id in indexer_ids:
params.append(('indexerIds', indexer_id))
data = self._api_get('search', params=params)
if not isinstance(data, list):
return []
return [self._parse_result(entry) for entry in data if isinstance(entry, dict)]
def _parse_indexer(self, entry: Dict[str, Any]) -> ProwlarrIndexer:
return ProwlarrIndexer(
id=int(entry.get('id') or 0),
name=entry.get('name') or '',
protocol=entry.get('protocol') or '',
enable=bool(entry.get('enable', True)),
privacy=entry.get('privacy') or '',
categories=[int(c.get('id') or 0) for c in entry.get('capabilities', {}).get('categories', []) if isinstance(c, dict)],
capabilities=entry.get('capabilities', {}) or {},
)
def _parse_result(self, entry: Dict[str, Any]) -> ProwlarrSearchResult:
cats = entry.get('categories') or []
category_ids: List[int] = []
for cat in cats:
if isinstance(cat, dict) and cat.get('id') is not None:
try:
category_ids.append(int(cat['id']))
except (TypeError, ValueError):
continue
elif isinstance(cat, int):
category_ids.append(cat)
return ProwlarrSearchResult(
guid=str(entry.get('guid') or entry.get('infoUrl') or entry.get('downloadUrl') or ''),
title=entry.get('title') or '',
indexer_id=int(entry.get('indexerId') or 0),
indexer_name=entry.get('indexer') or '',
protocol=entry.get('protocol') or '',
download_url=entry.get('downloadUrl') or None,
magnet_uri=entry.get('magnetUrl') or None,
info_url=entry.get('infoUrl') or None,
size=int(entry.get('size') or 0),
seeders=entry.get('seeders'),
leechers=entry.get('leechers'),
grabs=entry.get('grabs'),
publish_date=entry.get('publishDate'),
categories=category_ids,
raw=entry,
)
def _api_get(self, path: str, params=None) -> Optional[Any]:
if not self.is_configured():
return None
url = f"{self._url}/api/v1/{path.lstrip('/')}"
try:
resp = http_requests.get(
url,
headers={'X-Api-Key': self._api_key, 'Accept': 'application/json'},
params=params,
timeout=self.DEFAULT_TIMEOUT,
)
if not resp.ok:
logger.warning("Prowlarr %s returned HTTP %s", path, resp.status_code)
return None
return resp.json()
except http_requests.exceptions.RequestException as e:
logger.error("Prowlarr request to %s failed: %s", path, e)
return None
except ValueError as e:
logger.error("Prowlarr response to %s was not JSON: %s", path, e)
return None

@ -2753,7 +2753,7 @@ def handle_settings():
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
@ -3043,6 +3043,36 @@ def hydrabase_send():
_hydrabase_ws = None
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/prowlarr/indexers', methods=['GET'])
def prowlarr_indexers_endpoint():
"""List indexers Prowlarr currently exposes — name, protocol, enabled state.
Drives the Indexers panel on Settings Indexers & Downloaders so
the user can see what they're searching against without leaving
SoulSync. Returns ``[]`` if Prowlarr isn't configured / reachable.
"""
try:
from core.prowlarr_client import ProwlarrClient
client = ProwlarrClient()
if not client.is_configured():
return jsonify({"success": False, "error": "Prowlarr not configured", "indexers": []}), 200
indexers = run_async(client.get_indexers())
items = [
{
'id': idx.id,
'name': idx.name,
'protocol': idx.protocol,
'enable': idx.enable,
'privacy': idx.privacy,
}
for idx in indexers
]
return jsonify({"success": True, "indexers": items})
except Exception as e:
logger.error(f"prowlarr indexers fetch error: {e}")
return jsonify({"success": False, "error": str(e), "indexers": []}), 500
@app.route('/api/settings/log-level', methods=['GET', 'POST'])
@admin_only
def handle_log_level():

@ -3635,6 +3635,7 @@
<div class="stg-tabbar">
<button class="stg-tab active" data-tab="connections" onclick="switchSettingsTab('connections')">Connections</button>
<button class="stg-tab" data-tab="downloads" onclick="switchSettingsTab('downloads')">Downloads</button>
<button class="stg-tab" data-tab="indexers" onclick="switchSettingsTab('indexers')">Indexers &amp; Downloaders</button>
<button class="stg-tab" data-tab="library" onclick="switchSettingsTab('library')">Library</button>
<button class="stg-tab" data-tab="appearance" onclick="switchSettingsTab('appearance')">Appearance</button>
<button class="stg-tab" data-tab="advanced" onclick="switchSettingsTab('advanced')">Advanced</button>
@ -4945,6 +4946,55 @@
</div>
</div>
<!-- ═══ INDEXERS & DOWNLOADERS ═══ -->
<div class="settings-group" data-stg="indexers">
<h3>🔎 Prowlarr (Indexer Aggregator)</h3>
<div class="setting-help-text" style="margin-bottom: 10px;">
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.
<br><br>
Don't have it? Grab Prowlarr from <code>prowlarr.com</code> (or your *arr stack). You point Prowlarr at your indexers, then point SoulSync at Prowlarr.
</div>
<div class="form-group">
<label>Prowlarr URL:</label>
<input type="text" id="prowlarr-url" placeholder="http://localhost:9696">
<div class="setting-help-text">
Full URL to your Prowlarr instance (e.g. <code>http://192.168.1.100:9696</code>).
</div>
</div>
<div class="form-group">
<label>API Key:</label>
<input type="password" id="prowlarr-api-key" placeholder="Your Prowlarr API key">
<div class="setting-help-text">
Found in Prowlarr → Settings → General → Security → API Key.
</div>
</div>
<div class="form-group">
<label>Prowlarr Status:</label>
<div class="form-actions" style="margin-top: 4px;">
<button class="test-button" id="prowlarr-test-btn" onclick="testProwlarrConnection()">
Test Connection
</button>
<button class="test-button" id="prowlarr-refresh-indexers-btn" onclick="loadProwlarrIndexers()" style="margin-left: 6px;">
Refresh Indexer List
</button>
<span id="prowlarr-connection-status" class="setting-help-text" style="margin-left: 8px;"></span>
</div>
</div>
<div class="form-group">
<label>Configured Indexers:</label>
<div id="prowlarr-indexer-list" class="setting-help-text" style="margin-top: 4px;">
Connect to Prowlarr and click <strong>Refresh Indexer List</strong> to see the indexers SoulSync can search.
</div>
</div>
<div class="form-group">
<label>Restrict to indexer IDs (optional):</label>
<input type="text" id="prowlarr-indexer-ids" placeholder="e.g. 1,3,7 (blank = all enabled indexers)">
<div class="setting-help-text">
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.
</div>
</div>
</div>
<!-- ═══ PATHS & ORGANIZATION ═══ -->
<div class="settings-section-header collapsed" data-stg="library" onclick="this.classList.toggle('collapsed'); const b=this.nextElementSibling; b.classList.toggle('collapsed'); b.style.display=b.classList.contains('collapsed')?'none':''">
<span class="settings-section-arrow">&#9660;</span>

@ -3413,6 +3413,10 @@ 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: '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/<source>/<id>` 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.' },
@ -3471,6 +3475,19 @@ const WHATS_NEW = {
// Section shape: { title, description, features: [bullet strings],
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
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.",

@ -946,6 +946,12 @@ 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 || '';
// Sync ARL to connections tab field + bidirectional listeners
const _connArl = document.getElementById('deezer-connection-arl');
const _dlArl = document.getElementById('deezer-download-arl');
@ -2691,6 +2697,11 @@ 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 || '',
},
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 +3541,61 @@ async function testLidarrConnection() {
}
}
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';
loadProwlarrIndexers();
} else {
statusEl.textContent = data.error || 'Connection failed';
statusEl.style.color = '#f44336';
}
} catch (e) {
statusEl.textContent = 'Connection error';
statusEl.style.color = '#f44336';
}
}
async function loadProwlarrIndexers() {
const listEl = document.getElementById('prowlarr-indexer-list');
if (!listEl) return;
listEl.innerHTML = '<em>Loading…</em>';
try {
const resp = await fetch('/api/prowlarr/indexers');
const data = await resp.json();
if (!data.success) {
listEl.innerHTML = `<em style="color:#f44336;">${data.error || 'Prowlarr not configured.'}</em>`;
return;
}
if (!data.indexers || data.indexers.length === 0) {
listEl.innerHTML = '<em>No indexers configured in Prowlarr yet. Add some in Prowlarr → Indexers.</em>';
return;
}
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const rows = data.indexers.map(idx => {
const proto = idx.protocol === 'usenet' ? '📰 Usenet' : '🧲 Torrent';
const enabled = idx.enable ? '✅' : '⛔';
const privacy = idx.privacy ? `<span style="opacity:0.6;">(${esc(idx.privacy)})</span>` : '';
return `<div style="padding:3px 0;">${enabled} <strong>#${esc(idx.id)}</strong> ${esc(idx.name)}${proto} ${privacy}</div>`;
}).join('');
listEl.innerHTML = rows;
} catch (e) {
listEl.innerHTML = `<em style="color:#f44336;">Failed to load indexers: ${e.message}</em>`;
}
}
async function loadHiFiInstances() {
const listEl = document.getElementById('hifi-instances-list');
if (!listEl) return;

Loading…
Cancel
Save