Skip to content

Commit 9a45db9

Browse files
committed
Add prowlarr support
1 parent 600c0c0 commit 9a45db9

15 files changed

Lines changed: 276 additions & 31 deletions

File tree

README.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,7 @@ inside Telegram, in your language.
4040

4141
# 🔌 Search Provider
4242

43-
Torrent Hunt uses [Jackett](https://github.com/Jackett/Jackett) as its search
44-
provider. Every indexer you configure in Jackett becomes searchable from the
45-
bot.
46-
47-
**Todo**
48-
49-
- [ ] [Prowlarr](https://github.com/Prowlarr/Prowlarr) support
43+
Torrent Hunt supports [Jackett](https://github.com/Jackett/Jackett) and [Prowlarr](https://github.com/Prowlarr/Prowlarr) as its search providers. Every indexer you configure in them becomes searchable from the bot.
5044

5145
# 🗣️ Languages
5246

app.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"required": true
4343
},
4444
"TORRENT_PROVIDER": {
45-
"description": "Torrent search backend (currently supported: jackett)",
45+
"description": "Torrent search backend (currently supported: jackett, prowlarr)",
4646
"value": "jackett",
4747
"required": false
4848
},
@@ -56,6 +56,16 @@
5656
"value": "",
5757
"required": true
5858
},
59+
"PROWLARR_URL": {
60+
"description": "Base URL of the Prowlarr instance",
61+
"value": "http://localhost:9696",
62+
"required": false
63+
},
64+
"PROWLARR_API_KEY": {
65+
"description": "API key of the Prowlarr instance",
66+
"value": "",
67+
"required": false
68+
},
5969
"SENTRY_DSN": {
6070
"description": "Sentry.io token for error tracking",
6171
"value": "",

app/bot/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ async def load_sites() -> None:
126126
sources = await ctx.search.sources()
127127

128128
ctx.sites = {
129-
source_id: {"website": name} for source_id, name in sources.items()
129+
source.id: {"website": source.name} for source in sources.items
130130
}
131131

132132
logger.info(

app/config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
from pathlib import Path
10+
from typing import Literal
1011

1112
from pydantic_settings import BaseSettings, SettingsConfigDict
1213

@@ -36,12 +37,17 @@ class Settings(BaseSettings):
3637
cleanup_interval_minutes: float = 60
3738

3839
# Torrent search provider
39-
torrent_provider: str = "jackett"
40+
torrent_provider: Literal["jackett", "prowlarr"] = "jackett"
4041
jackett_url: str = "http://localhost:9117"
4142
jackett_api_key: str = ""
4243
jackett_indexer: str = "all"
4344
jackett_timeout: float = 40
4445

46+
prowlarr_url: str = "http://localhost:9696"
47+
prowlarr_api_key: str = ""
48+
prowlarr_indexer: str = "all"
49+
prowlarr_timeout: float = 40
50+
4551
# Message to forward on start
4652
start_ads: bool = False
4753
start_ads_channel: str = ""

app/main.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,26 @@
4949

5050
logger.info("Initializing torrent search service")
5151
torrent_store = DatabaseTorrentStore(retention_days=settings.torrent_cache_days)
52+
provider_kwargs = {}
53+
if settings.torrent_provider == "prowlarr":
54+
provider_kwargs = {
55+
"base_url": settings.prowlarr_url,
56+
"api_key": settings.prowlarr_api_key,
57+
"indexer": settings.prowlarr_indexer,
58+
"timeout": settings.prowlarr_timeout,
59+
}
60+
else:
61+
provider_kwargs = {
62+
"base_url": settings.jackett_url,
63+
"api_key": settings.jackett_api_key,
64+
"indexer": settings.jackett_indexer,
65+
"timeout": settings.jackett_timeout,
66+
}
67+
5268
ctx.search = SearchService(
5369
provider=create_provider(
5470
settings.torrent_provider,
55-
base_url=settings.jackett_url,
56-
api_key=settings.jackett_api_key,
57-
indexer=settings.jackett_indexer,
58-
timeout=settings.jackett_timeout,
71+
**provider_kwargs
5972
),
6073
store=torrent_store,
6174
)

app/search_engine/base.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from abc import ABC, abstractmethod
44

5-
from search_engine.models import SearchResults
5+
from search_engine.models import IndexerList, SearchResults
66

77

88
class TorrentProvider(ABC):
@@ -31,14 +31,13 @@ async def search(
3131
Must not raise on backend failures: log and return empty results.
3232
"""
3333

34-
async def sources(self) -> dict[str, str]:
34+
async def sources(self) -> IndexerList:
3535
"""Return the backend's individually searchable sources.
3636
37-
Maps a source id (usable as `search(source=...)`) to a display
38-
name, e.g. Jackett/Prowlarr indexers. Backends without such a
39-
concept return an empty dict.
37+
Backends without such a concept return an empty list.
4038
"""
41-
return {}
39+
return IndexerList()
4240

4341
async def close(self) -> None:
4442
"""Release any underlying resources (HTTP sessions etc.)."""
43+

app/search_engine/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,24 @@ def to_dict(self) -> dict[str, Any]:
5656
"page": self.page,
5757
"provider": self.provider,
5858
}
59+
60+
61+
@dataclass
62+
class Indexer:
63+
"""A search backend indexer/source."""
64+
65+
id: str
66+
name: str
67+
68+
def to_dict(self) -> dict[str, Any]:
69+
return asdict(self)
70+
71+
72+
@dataclass
73+
class IndexerList:
74+
"""A list of available indexers."""
75+
76+
items: list[Indexer] = field(default_factory=list)
77+
78+
def to_dict(self) -> dict[str, Any]:
79+
return {"items": [item.to_dict() for item in self.items]}

app/search_engine/providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88

99
from search_engine.base import TorrentProvider
1010
from search_engine.providers.jackett import JackettProvider
11+
from search_engine.providers.prowlarr.provider import ProwlarrProvider
1112

1213
PROVIDERS: dict[str, type[TorrentProvider]] = {
1314
JackettProvider.name: JackettProvider,
15+
ProwlarrProvider.name: ProwlarrProvider,
1416
}
1517

1618

app/search_engine/providers/jackett/provider.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from structlog import get_logger
99

1010
from search_engine.base import TorrentProvider
11-
from search_engine.models import SearchResults
11+
from search_engine.models import Indexer, IndexerList, SearchResults
1212
from search_engine.providers.jackett.constants import CATEGORY_MAP, RESULTS_ENDPOINT
1313
from search_engine.providers.jackett.parser import parse_torrent
1414
from search_engine.transport import HttpProvider
@@ -69,7 +69,7 @@ async def search(
6969

7070
return results
7171

72-
async def sources(self) -> dict[str, str]:
72+
async def sources(self) -> IndexerList:
7373
# Jackett's indexer listing endpoint needs cookie auth, but an
7474
# empty-query search reports every configured indexer it queried
7575
url = self.base_url + RESULTS_ENDPOINT.format(indexer=self.indexer)
@@ -81,10 +81,12 @@ async def sources(self) -> dict[str, str]:
8181
payload = await response.json()
8282
except Exception as err:
8383
logger.error("Jackett indexer listing failed", error=str(err))
84-
return {}
85-
86-
return {
87-
indexer["ID"]: indexer.get("Name") or indexer["ID"]
88-
for indexer in payload.get("Indexers") or []
89-
if indexer.get("ID")
90-
}
84+
return IndexerList()
85+
86+
return IndexerList(
87+
items=[
88+
Indexer(id=indexer["ID"], name=indexer.get("Name") or indexer["ID"])
89+
for indexer in payload.get("Indexers") or []
90+
if indexer.get("ID")
91+
]
92+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Prowlarr provider."""

0 commit comments

Comments
 (0)