You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/prowlarr_client.py

242 lines
8.6 KiB

"""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