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.
This commit is contained in:
parent
3375b6c4bd
commit
579eff8807
7 changed files with 430 additions and 1 deletions
|
|
@ -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', '')
|
||||
|
|
|
|||
241
core/prowlarr_client.py
Normal file
241
core/prowlarr_client.py
Normal file
|
|
@ -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 & 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">▼</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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…
Reference in a new issue