Initial commit
This commit is contained in:
BIN
backend/app/clients/__pycache__/base.cpython-312.pyc
Normal file
BIN
backend/app/clients/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/clients/__pycache__/jellyseerr.cpython-312.pyc
Normal file
BIN
backend/app/clients/__pycache__/jellyseerr.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/clients/__pycache__/prowlarr.cpython-312.pyc
Normal file
BIN
backend/app/clients/__pycache__/prowlarr.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/clients/__pycache__/qbittorrent.cpython-312.pyc
Normal file
BIN
backend/app/clients/__pycache__/qbittorrent.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/clients/__pycache__/radarr.cpython-312.pyc
Normal file
BIN
backend/app/clients/__pycache__/radarr.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/clients/__pycache__/sonarr.cpython-312.pyc
Normal file
BIN
backend/app/clients/__pycache__/sonarr.cpython-312.pyc
Normal file
Binary file not shown.
32
backend/app/clients/base.py
Normal file
32
backend/app/clients/base.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: Optional[str], api_key: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip("/") if base_url else None
|
||||
self.api_key = api_key
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url)
|
||||
|
||||
def headers(self) -> Dict[str, str]:
|
||||
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
||||
|
||||
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=self.headers(), params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def post(self, path: str, payload: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}{path}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=self.headers(), json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
60
backend/app/clients/jellyfin.py
Normal file
60
backend/app/clients/jellyfin.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellyfinClient(ApiClient):
|
||||
def __init__(self, base_url: Optional[str], api_key: Optional[str]):
|
||||
super().__init__(base_url, api_key)
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.api_key)
|
||||
|
||||
async def get_users(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}/Users"
|
||||
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def authenticate_by_name(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
url = f"{self.base_url}/Users/AuthenticateByName"
|
||||
headers = {"X-Emby-Token": self.api_key} if self.api_key else {}
|
||||
payload = {"Username": username, "Pw": password}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def search_items(
|
||||
self, term: str, item_types: Optional[list[str]] = None, limit: int = 20
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
"SearchTerm": term,
|
||||
"IncludeItemTypes": ",".join(item_types or []),
|
||||
"Recursive": "true",
|
||||
"Limit": limit,
|
||||
}
|
||||
headers = {"X-Emby-Token": self.api_key}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_system_info(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
url = f"{self.base_url}/System/Info"
|
||||
headers = {"X-Emby-Token": self.api_key}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
37
backend/app/clients/jellyseerr.py
Normal file
37
backend/app/clients/jellyseerr.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class JellyseerrClient(ApiClient):
|
||||
async def get_status(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v1/status")
|
||||
|
||||
async def get_request(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/request/{request_id}")
|
||||
|
||||
async def get_recent_requests(self, take: int = 10, skip: int = 0) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(
|
||||
"/api/v1/request",
|
||||
params={
|
||||
"take": take,
|
||||
"skip": skip,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_media(self, media_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/media/{media_id}")
|
||||
|
||||
async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/movie/{tmdb_id}")
|
||||
|
||||
async def get_tv(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(f"/api/v1/tv/{tmdb_id}")
|
||||
|
||||
async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]:
|
||||
return await self.get(
|
||||
"/api/v1/search",
|
||||
params={
|
||||
"query": query,
|
||||
"page": page,
|
||||
},
|
||||
)
|
||||
10
backend/app/clients/prowlarr.py
Normal file
10
backend/app/clients/prowlarr.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class ProwlarrClient(ApiClient):
|
||||
async def get_health(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v1/health")
|
||||
|
||||
async def search(self, query: str) -> Optional[Any]:
|
||||
return await self.get("/api/v1/search", params={"query": query})
|
||||
69
backend/app/clients/qbittorrent.py
Normal file
69
backend/app/clients/qbittorrent.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class QBittorrentClient(ApiClient):
|
||||
def __init__(self, base_url: Optional[str], username: Optional[str], password: Optional[str]):
|
||||
super().__init__(base_url, None)
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.username and self.password)
|
||||
|
||||
async def _login(self, client: httpx.AsyncClient) -> None:
|
||||
if not self.base_url or not self.username or not self.password:
|
||||
raise RuntimeError("qBittorrent not configured")
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v2/auth/login",
|
||||
data={"username": self.username, "password": self.password},
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.text.strip().lower() != "ok.":
|
||||
raise RuntimeError("qBittorrent login failed")
|
||||
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
return response.text.strip()
|
||||
|
||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
async def get_torrents(self) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info")
|
||||
|
||||
async def get_torrents_by_hashes(self, hashes: str) -> Optional[Any]:
|
||||
return await self._get("/api/v2/torrents/info", params={"hashes": hashes})
|
||||
|
||||
async def get_app_version(self) -> Optional[Any]:
|
||||
return await self._get_text("/api/v2/app/version")
|
||||
|
||||
async def resume_torrents(self, hashes: str) -> None:
|
||||
try:
|
||||
await self._post_form("/api/v2/torrents/resume", data={"hashes": hashes})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response is not None and exc.response.status_code == 404:
|
||||
await self._post_form("/api/v2/torrents/start", data={"hashes": hashes})
|
||||
return
|
||||
raise
|
||||
45
backend/app/clients/radarr.py
Normal file
45
backend/app/clients/radarr.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class RadarrClient(ApiClient):
|
||||
async def get_system_status(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/system/status")
|
||||
|
||||
async def get_movie_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/movie", params={"tmdbId": tmdb_id})
|
||||
|
||||
async def get_movies(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/movie")
|
||||
|
||||
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/rootfolder")
|
||||
|
||||
async def get_quality_profiles(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/qualityprofile")
|
||||
|
||||
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/queue", params={"movieId": movie_id})
|
||||
|
||||
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
||||
|
||||
async def add_movie(
|
||||
self,
|
||||
tmdb_id: int,
|
||||
quality_profile_id: int,
|
||||
root_folder: str,
|
||||
monitored: bool = True,
|
||||
search_for_movie: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload = {
|
||||
"tmdbId": tmdb_id,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
"addOptions": {"searchForMovie": search_for_movie},
|
||||
}
|
||||
return await self.post("/api/v3/movie", payload=payload)
|
||||
|
||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||
52
backend/app/clients/sonarr.py
Normal file
52
backend/app/clients/sonarr.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from .base import ApiClient
|
||||
|
||||
|
||||
class SonarrClient(ApiClient):
|
||||
async def get_system_status(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/system/status")
|
||||
|
||||
async def get_series_by_tvdb_id(self, tvdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/series", params={"tvdbId": tvdb_id})
|
||||
|
||||
async def get_root_folders(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/rootfolder")
|
||||
|
||||
async def get_quality_profiles(self) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/qualityprofile")
|
||||
|
||||
async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/queue", params={"seriesId": series_id})
|
||||
|
||||
async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.get("/api/v3/episode", params={"seriesId": series_id})
|
||||
|
||||
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
||||
|
||||
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids})
|
||||
|
||||
async def add_series(
|
||||
self,
|
||||
tvdb_id: int,
|
||||
quality_profile_id: int,
|
||||
root_folder: str,
|
||||
monitored: bool = True,
|
||||
title: Optional[str] = None,
|
||||
search_missing: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload = {
|
||||
"tvdbId": tvdb_id,
|
||||
"qualityProfileId": quality_profile_id,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": monitored,
|
||||
"seasonFolder": True,
|
||||
"addOptions": {"searchForMissingEpisodes": search_missing},
|
||||
}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
return await self.post("/api/v3/series", payload=payload)
|
||||
|
||||
async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id})
|
||||
Reference in New Issue
Block a user