From f2edfb076c50db930f548eaa7b0f52e2bc97dc55 Mon Sep 17 00:00:00 2001 From: maxDorninger <97409287+maxDorninger@users.noreply.github.com> Date: Wed, 9 Jul 2025 20:52:01 +0200 Subject: [PATCH] add usenet support to indexer module, add make jackett and prowlarr search for either tv or movies --- media_manager/indexer/indexers/generic.py | 3 ++- media_manager/indexer/indexers/jackett.py | 9 +++++--- media_manager/indexer/indexers/prowlarr.py | 27 ++++++++++++++++++---- media_manager/indexer/models.py | 4 +++- media_manager/indexer/schemas.py | 10 ++++++-- media_manager/indexer/service.py | 5 ++-- media_manager/movies/service.py | 3 ++- media_manager/tv/service.py | 3 ++- 8 files changed, 48 insertions(+), 16 deletions(-) diff --git a/media_manager/indexer/indexers/generic.py b/media_manager/indexer/indexers/generic.py index 0238645..70aa243 100644 --- a/media_manager/indexer/indexers/generic.py +++ b/media_manager/indexer/indexers/generic.py @@ -10,10 +10,11 @@ class GenericIndexer(object): else: raise ValueError("indexer name must not be None") - def search(self, query: str) -> list[IndexerQueryResult]: + def search(self, query: str, is_tv: bool) -> list[IndexerQueryResult]: """ Sends a search request to the Indexer and returns the results. + :param is_tv: Whether to search for TV shows or movies. :param query: The search query to send to the Indexer. :return: A list of IndexerQueryResult objects representing the search results. """ diff --git a/media_manager/indexer/indexers/jackett.py b/media_manager/indexer/indexers/jackett.py index 7352a5f..75a01e2 100644 --- a/media_manager/indexer/indexers/jackett.py +++ b/media_manager/indexer/indexers/jackett.py @@ -3,6 +3,7 @@ import xml.etree.ElementTree as ET from xml.etree.ElementTree import Element import requests +from pydantic import HttpUrl from media_manager.indexer.indexers.generic import GenericIndexer from media_manager.indexer.config import JackettConfig @@ -25,7 +26,7 @@ class Jackett(GenericIndexer): log.debug("Registering Jacket as Indexer") # NOTE: this could be done in parallel, but if there aren't more than a dozen indexers, it shouldn't matter - def search(self, query: str) -> list[IndexerQueryResult]: + def search(self, query: str, is_tv: bool) -> list[IndexerQueryResult]: log.debug("Searching for " + query) responses = [] @@ -33,7 +34,7 @@ class Jackett(GenericIndexer): log.debug(f"Searching in indexer: {indexer}") url = ( self.url - + f"/api/v2.0/indexers/{indexer}/results/torznab/api?apikey={self.api_key}&t=search&q={query}" + + f"/api/v2.0/indexers/{indexer}/results/torznab/api?apikey={self.api_key}&t={'tvsearch' if is_tv else 'movie'}&q={query}" ) response = requests.get(url) responses.append(response) @@ -62,10 +63,12 @@ class Jackett(GenericIndexer): result = IndexerQueryResult( title=item.find("title").text, - download_url=item.find("link").text, + download_url=HttpUrl(item.find("enclosure").attrib["url"]), seeders=seeders, flags=[], size=int(item.find("size").text), + usenet=False, # always False, because Jackett doesn't support usenet + age=0 # always 0 for torrents, as Jackett does not provide age information in a convenient format ) result_list.append(result) log.debug(f"Raw result: {result.model_dump()}") diff --git a/media_manager/indexer/indexers/prowlarr.py b/media_manager/indexer/indexers/prowlarr.py index a4f1268..d0b73ff 100644 --- a/media_manager/indexer/indexers/prowlarr.py +++ b/media_manager/indexer/indexers/prowlarr.py @@ -23,21 +23,22 @@ class Prowlarr(GenericIndexer): self.url = config.url log.debug("Registering Prowlarr as Indexer") - def search(self, query: str) -> list[IndexerQueryResult]: + def search(self, query: str, is_tv: bool) -> list[IndexerQueryResult]: log.debug("Searching for " + query) url = self.url + "/api/v1/search" - headers = {"accept": "application/json", "X-Api-Key": self.api_key} params = { "query": query, + "apikey": self.api_key, + "categories": "5000" if is_tv else "2000" # TV: 5000, Movies: 2000 } - response = requests.get(url, headers=headers, params=params) + response = requests.get(url, params=params) if response.status_code == 200: result_list: list[IndexerQueryResult] = [] for result in response.json(): - if result["protocol"] == "torrent": - log.debug("torrent result: " + result.__str__()) + is_torrent = result["protocol"] == "torrent" + if is_torrent: result_list.append( IndexerQueryResult( download_url=result["downloadUrl"], @@ -45,8 +46,24 @@ class Prowlarr(GenericIndexer): seeders=result["seeders"], flags=result["indexerFlags"], size=result["size"], + usenet=True, + age=0, # Torrent results do not need age information ) ) + else: + result_list.append( + IndexerQueryResult( + download_url=result["downloadUrl"], + title=result["sortTitle"], + seeders=0, # Usenet results do not have seeders + flags=result["indexerFlags"], + size=result["size"], + usenet=False, + age=int(result["ageMinutes"])*60, + ) + ) + log.debug("torrent result: " + result.__str__()) + return result_list else: log.error(f"Prowlarr Error: {response.status_code}") diff --git a/media_manager/indexer/models.py b/media_manager/indexer/models.py index d362ca1..568ab88 100644 --- a/media_manager/indexer/models.py +++ b/media_manager/indexer/models.py @@ -1,6 +1,6 @@ from uuid import UUID -from sqlalchemy import String, Integer +from sqlalchemy import String, Integer, Boolean from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql.sqltypes import BigInteger @@ -19,3 +19,5 @@ class IndexerQueryResult(Base): quality: Mapped[Quality] season = mapped_column(ARRAY(Integer)) size = mapped_column(BigInteger) + usenet: Mapped[bool] + age: Mapped[int] diff --git a/media_manager/indexer/schemas.py b/media_manager/indexer/schemas.py index e3bfe32..4fdcc95 100644 --- a/media_manager/indexer/schemas.py +++ b/media_manager/indexer/schemas.py @@ -3,7 +3,7 @@ import typing from uuid import UUID, uuid4 import pydantic -from pydantic import BaseModel, computed_field, ConfigDict +from pydantic import BaseModel, computed_field, ConfigDict, HttpUrl from media_manager.torrent.models import Quality @@ -15,11 +15,14 @@ class IndexerQueryResult(BaseModel): id: IndexerQueryResultId = pydantic.Field(default_factory=uuid4) title: str - download_url: str + download_url: HttpUrl seeders: int flags: list[str] size: int + usenet: bool + age: int + @computed_field(return_type=Quality) @property def quality(self) -> Quality: @@ -73,3 +76,6 @@ class PublicIndexerQueryResult(BaseModel): flags: list[str] season: list[int] size: int + + usenet: bool + age: int diff --git a/media_manager/indexer/service.py b/media_manager/indexer/service.py index 99ff8ee..bb29c3f 100644 --- a/media_manager/indexer/service.py +++ b/media_manager/indexer/service.py @@ -11,10 +11,11 @@ class IndexerService: def get_result(self, result_id: IndexerQueryResultId) -> IndexerQueryResult: return self.repository.get_result(result_id=result_id) - def search(self, query: str) -> list[IndexerQueryResult]: + def search(self, query: str, is_tv: bool) -> list[IndexerQueryResult]: """ Search for results using the indexers based on a query. + :param is_tv: Whether the search is for TV shows or movies. :param query: The search query. :param db: The database session. :return: A list of search results. @@ -25,7 +26,7 @@ class IndexerService: for indexer in indexers: try: - indexer_results = indexer.search(query) + indexer_results = indexer.search(query, is_tv=is_tv) results.extend(indexer_results) log.debug( f"Indexer {indexer.__class__.__name__} returned {len(indexer_results)} results for query: {query}" diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index dd276d7..9fcd42c 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -178,7 +178,8 @@ class MovieService: search_query = f"{movie.name}" torrents: list[IndexerQueryResult] = self.indexer_service.search( - query=search_query + query=search_query, + is_tv=False ) if search_query_override: diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 22456e4..a836f91 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -189,7 +189,8 @@ class TvService: search_query = show.name + " s" + str(season_number).zfill(2) torrents: list[IndexerQueryResult] = self.indexer_service.search( - query=search_query + query=search_query, + is_tv=True ) if search_query_override: