New archi: domain driven development

Working but need to check out code
This commit is contained in:
2025-12-01 07:10:03 +01:00
parent 2b815502f6
commit 2c8cdd3ab1
73 changed files with 4084 additions and 853 deletions
+1
View File
@@ -0,0 +1 @@
"""API clients for external services."""
+22
View File
@@ -0,0 +1,22 @@
"""Knaben API client."""
from .client import KnabenClient
from .dto import TorrentResult
from .exceptions import (
KnabenError,
KnabenConfigurationError,
KnabenAPIError,
KnabenNotFoundError,
)
# Global Knaben client instance (singleton)
knaben_client = KnabenClient()
__all__ = [
"KnabenClient",
"TorrentResult",
"KnabenError",
"KnabenConfigurationError",
"KnabenAPIError",
"KnabenNotFoundError",
"knaben_client",
]
+191
View File
@@ -0,0 +1,191 @@
"""Knaben torrent search API client."""
from typing import Dict, Any, Optional, List
import logging
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from agent.config import Settings, settings
from .dto import TorrentResult
from .exceptions import KnabenError, KnabenAPIError, KnabenNotFoundError
logger = logging.getLogger(__name__)
class KnabenClient:
"""
Client for interacting with Knaben torrent search API.
Knaben is a torrent search engine that aggregates results from multiple trackers.
Example:
>>> client = KnabenClient()
>>> results = client.search("Inception 1080p")
>>> for torrent in results[:5]:
... print(f"{torrent.name} - Seeders: {torrent.seeders}")
"""
def __init__(
self,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
):
"""
Initialize Knaben client.
Args:
base_url: Knaben API base URL (defaults to https://api.knaben.org/v1)
timeout: Request timeout in seconds (defaults to settings)
config: Optional Settings instance (for testing)
Note:
Knaben API doesn't require an API key
"""
cfg = config or settings
self.base_url = base_url or "https://api.knaben.org/v1"
self.timeout = timeout or cfg.request_timeout
logger.info("Knaben client initialized")
def _make_request(
self,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Make a request to Knaben API.
Args:
params: Query parameters
Returns:
JSON response as dict
Raises:
KnabenAPIError: If request fails
"""
try:
logger.debug(f"Knaben request with params: {params}")
response = requests.post(self.base_url, json=params, timeout=self.timeout)
response.raise_for_status()
return response.json()
except Timeout as e:
logger.error(f"Knaben API timeout: {e}")
raise KnabenAPIError(f"Request timeout after {self.timeout} seconds") from e
except HTTPError as e:
logger.error(f"Knaben API HTTP error: {e}")
if e.response is not None:
status_code = e.response.status_code
if status_code == 404:
raise KnabenNotFoundError("Resource not found") from e
elif status_code == 429:
raise KnabenAPIError("Rate limit exceeded") from e
else:
raise KnabenAPIError(f"HTTP {status_code}: {e}") from e
raise KnabenAPIError(f"HTTP error: {e}") from e
except RequestException as e:
logger.error(f"Knaben API request failed: {e}")
raise KnabenAPIError(f"Failed to connect to Knaben API: {e}") from e
def search(
self,
query: str,
limit: int = 10
) -> List[TorrentResult]:
"""
Search for torrents.
Args:
query: Search query (e.g., "Inception 1080p")
limit: Maximum number of results (default: 10)
Returns:
List of TorrentResult objects
Raises:
KnabenAPIError: If request fails
ValueError: If query is invalid
"""
if not query or not isinstance(query, str):
raise ValueError("Query must be a non-empty string")
if len(query) > 500:
raise ValueError("Query is too long (max 500 characters)")
# Build params
params = {
"query": query,
"search_field": "title",
"order_by": "peers",
"order_direction": "desc",
"from": 0,
"size": limit,
"hide_unsafe": True,
"hide_xxx": True,
}
try:
data = self._make_request(params)
except KnabenNotFoundError:
logger.info(f"No torrents found for '{query}'")
return []
except Exception as e:
logger.error(f"Unexpected error in search: {e}", exc_info=True)
raise
# Parse results
results = []
torrents = data.get('hits', [])
if not torrents:
logger.info(f"No torrents found for '{query}'")
return []
for torrent in torrents:
try:
result = self._parse_torrent(torrent)
results.append(result)
except Exception as e:
logger.warning(f"Failed to parse torrent: {e}")
continue
logger.info(f"Found {len(results)} torrents for '{query}'")
return results
def _parse_torrent(self, torrent: Dict[str, Any]) -> TorrentResult:
"""
Parse a torrent result into a TorrentResult object.
Args:
torrent: Raw torrent dict from API
Returns:
TorrentResult object
"""
# Extract required fields (API uses camelCase)
title = torrent.get('title', 'Unknown')
size = torrent.get('size', 'Unknown')
seeders = int(torrent.get('seeders', 0) or 0)
leechers = int(torrent.get('leechers', 0) or 0)
magnet = torrent.get('magnetUrl', '')
# Extract optional fields
info_hash = torrent.get('hash')
tracker = torrent.get('tracker')
upload_date = torrent.get('date')
category = torrent.get('category')
return TorrentResult(
title=title,
size=size,
seeders=seeders,
leechers=leechers,
magnet=magnet,
info_hash=info_hash,
tracker=tracker,
upload_date=upload_date,
category=category
)
+17
View File
@@ -0,0 +1,17 @@
"""Knaben Data Transfer Objects."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class TorrentResult:
"""Represents a torrent search result from Knaben."""
title: str
size: str
seeders: int
leechers: int
magnet: str
info_hash: Optional[str] = None
tracker: Optional[str] = None
upload_date: Optional[str] = None
category: Optional[str] = None
+21
View File
@@ -0,0 +1,21 @@
"""Knaben API exceptions."""
class KnabenError(Exception):
"""Base exception for Knaben-related errors."""
pass
class KnabenConfigurationError(KnabenError):
"""Raised when Knaben API is not properly configured."""
pass
class KnabenAPIError(KnabenError):
"""Raised when Knaben API returns an error."""
pass
class KnabenNotFoundError(KnabenError):
"""Raised when no torrents are found."""
pass
@@ -0,0 +1,22 @@
"""qBittorrent API client."""
from .client import QBittorrentClient
from .dto import TorrentInfo
from .exceptions import (
QBittorrentError,
QBittorrentConfigurationError,
QBittorrentAPIError,
QBittorrentAuthError,
)
# Global qBittorrent client instance (singleton)
qbittorrent_client = QBittorrentClient()
__all__ = [
"QBittorrentClient",
"TorrentInfo",
"QBittorrentError",
"QBittorrentConfigurationError",
"QBittorrentAPIError",
"QBittorrentAuthError",
"qbittorrent_client",
]
+388
View File
@@ -0,0 +1,388 @@
"""qBittorrent Web API client."""
from typing import Dict, Any, Optional, List
import logging
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from agent.config import Settings, settings
from .dto import TorrentInfo
from .exceptions import QBittorrentError, QBittorrentAPIError, QBittorrentAuthError
logger = logging.getLogger(__name__)
class QBittorrentClient:
"""
Client for interacting with qBittorrent Web API.
This client provides methods to manage torrents in qBittorrent.
Example:
>>> client = QBittorrentClient()
>>> client.login()
>>> torrents = client.get_torrents()
>>> for torrent in torrents:
... print(f"{torrent.name} - {torrent.progress}%")
"""
def __init__(
self,
host: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
):
"""
Initialize qBittorrent client.
Args:
host: qBittorrent host URL (e.g., "http://192.168.1.100:8080")
username: qBittorrent username
password: qBittorrent password
timeout: Request timeout in seconds (defaults to settings)
config: Optional Settings instance (for testing)
"""
cfg = config or settings
self.host = host or "http://192.168.178.47:30024"
self.username = username or "admin"
self.password = password or "adminadmin"
self.timeout = timeout or cfg.request_timeout
self.session = requests.Session()
self._authenticated = False
logger.info(f"qBittorrent client initialized for {self.host}")
def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
files: Optional[Dict[str, Any]] = None
) -> Any:
"""
Make a request to qBittorrent API.
Args:
method: HTTP method (GET, POST)
endpoint: API endpoint (e.g., '/api/v2/torrents/info')
data: Request data
files: Files to upload
Returns:
Response (JSON or text)
Raises:
QBittorrentAPIError: If request fails
"""
url = f"{self.host}{endpoint}"
try:
logger.debug(f"qBittorrent {method} request: {endpoint}")
if method.upper() == "GET":
response = self.session.get(url, params=data, timeout=self.timeout)
elif method.upper() == "POST":
response = self.session.post(url, data=data, files=files, timeout=self.timeout)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
response.raise_for_status()
# Try to parse as JSON, otherwise return text
try:
return response.json()
except ValueError:
return response.text
except Timeout as e:
logger.error(f"qBittorrent API timeout: {e}")
raise QBittorrentAPIError(f"Request timeout after {self.timeout} seconds") from e
except HTTPError as e:
logger.error(f"qBittorrent API HTTP error: {e}")
if e.response is not None:
status_code = e.response.status_code
if status_code == 403:
raise QBittorrentAuthError("Authentication required or forbidden") from e
else:
raise QBittorrentAPIError(f"HTTP {status_code}: {e}") from e
raise QBittorrentAPIError(f"HTTP error: {e}") from e
except RequestException as e:
logger.error(f"qBittorrent API request failed: {e}")
raise QBittorrentAPIError(f"Failed to connect to qBittorrent: {e}") from e
def login(self) -> bool:
"""
Authenticate with qBittorrent.
Returns:
True if authentication successful
Raises:
QBittorrentAuthError: If authentication fails
"""
try:
data = {
"username": self.username,
"password": self.password
}
response = self._make_request("POST", "/api/v2/auth/login", data=data)
if response == "Ok.":
self._authenticated = True
logger.info("Successfully authenticated with qBittorrent")
return True
else:
raise QBittorrentAuthError("Authentication failed")
except QBittorrentAPIError as e:
logger.error(f"Login failed: {e}")
raise QBittorrentAuthError("Failed to authenticate") from e
def logout(self) -> bool:
"""
Logout from qBittorrent.
Returns:
True if logout successful
"""
try:
self._make_request("POST", "/api/v2/auth/logout")
self._authenticated = False
logger.info("Logged out from qBittorrent")
return True
except Exception as e:
logger.warning(f"Logout failed: {e}")
return False
def get_torrents(
self,
filter: Optional[str] = None,
category: Optional[str] = None
) -> List[TorrentInfo]:
"""
Get list of torrents.
Args:
filter: Filter torrents (all, downloading, completed, paused, active, inactive)
category: Filter by category
Returns:
List of TorrentInfo objects
Raises:
QBittorrentAPIError: If request fails
"""
if not self._authenticated:
self.login()
params = {}
if filter:
params["filter"] = filter
if category:
params["category"] = category
try:
data = self._make_request("GET", "/api/v2/torrents/info", data=params)
if not isinstance(data, list):
logger.warning("Unexpected response format")
return []
torrents = []
for torrent in data:
try:
torrents.append(self._parse_torrent(torrent))
except Exception as e:
logger.warning(f"Failed to parse torrent: {e}")
continue
logger.info(f"Retrieved {len(torrents)} torrents")
return torrents
except QBittorrentAPIError as e:
logger.error(f"Failed to get torrents: {e}")
raise
def add_torrent(
self,
magnet: str,
category: Optional[str] = None,
save_path: Optional[str] = None,
paused: bool = False
) -> bool:
"""
Add a torrent via magnet link.
Args:
magnet: Magnet link
category: Category to assign
save_path: Download path
paused: Start torrent paused
Returns:
True if torrent added successfully
Raises:
QBittorrentAPIError: If request fails
"""
if not self._authenticated:
self.login()
data = {
"urls": magnet,
"paused": "true" if paused else "false"
}
if category:
data["category"] = category
if save_path:
data["savepath"] = save_path
try:
response = self._make_request("POST", "/api/v2/torrents/add", data=data)
if response == "Ok.":
logger.info(f"Successfully added torrent")
return True
else:
logger.warning(f"Unexpected response: {response}")
return False
except QBittorrentAPIError as e:
logger.error(f"Failed to add torrent: {e}")
raise
def delete_torrent(
self,
torrent_hash: str,
delete_files: bool = False
) -> bool:
"""
Delete a torrent.
Args:
torrent_hash: Hash of the torrent
delete_files: Also delete downloaded files
Returns:
True if torrent deleted successfully
Raises:
QBittorrentAPIError: If request fails
"""
if not self._authenticated:
self.login()
data = {
"hashes": torrent_hash,
"deleteFiles": "true" if delete_files else "false"
}
try:
response = self._make_request("POST", "/api/v2/torrents/delete", data=data)
logger.info(f"Deleted torrent {torrent_hash}")
return True
except QBittorrentAPIError as e:
logger.error(f"Failed to delete torrent: {e}")
raise
def pause_torrent(self, torrent_hash: str) -> bool:
"""
Pause a torrent.
Args:
torrent_hash: Hash of the torrent
Returns:
True if torrent paused successfully
"""
if not self._authenticated:
self.login()
data = {"hashes": torrent_hash}
try:
self._make_request("POST", "/api/v2/torrents/pause", data=data)
logger.info(f"Paused torrent {torrent_hash}")
return True
except QBittorrentAPIError as e:
logger.error(f"Failed to pause torrent: {e}")
raise
def resume_torrent(self, torrent_hash: str) -> bool:
"""
Resume a torrent.
Args:
torrent_hash: Hash of the torrent
Returns:
True if torrent resumed successfully
"""
if not self._authenticated:
self.login()
data = {"hashes": torrent_hash}
try:
self._make_request("POST", "/api/v2/torrents/resume", data=data)
logger.info(f"Resumed torrent {torrent_hash}")
return True
except QBittorrentAPIError as e:
logger.error(f"Failed to resume torrent: {e}")
raise
def get_torrent_properties(self, torrent_hash: str) -> Dict[str, Any]:
"""
Get detailed properties of a torrent.
Args:
torrent_hash: Hash of the torrent
Returns:
Dict with torrent properties
"""
if not self._authenticated:
self.login()
params = {"hash": torrent_hash}
try:
data = self._make_request("GET", "/api/v2/torrents/properties", data=params)
return data
except QBittorrentAPIError as e:
logger.error(f"Failed to get torrent properties: {e}")
raise
def _parse_torrent(self, torrent: Dict[str, Any]) -> TorrentInfo:
"""
Parse a torrent dict into a TorrentInfo object.
Args:
torrent: Raw torrent dict from API
Returns:
TorrentInfo object
"""
return TorrentInfo(
hash=torrent.get("hash", ""),
name=torrent.get("name", "Unknown"),
size=torrent.get("size", 0),
progress=torrent.get("progress", 0.0) * 100, # Convert to percentage
state=torrent.get("state", "unknown"),
download_speed=torrent.get("dlspeed", 0),
upload_speed=torrent.get("upspeed", 0),
eta=torrent.get("eta", 0),
num_seeds=torrent.get("num_seeds", 0),
num_leechs=torrent.get("num_leechs", 0),
ratio=torrent.get("ratio", 0.0),
category=torrent.get("category"),
save_path=torrent.get("save_path")
)
+21
View File
@@ -0,0 +1,21 @@
"""qBittorrent Data Transfer Objects."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class TorrentInfo:
"""Represents a torrent in qBittorrent."""
hash: str
name: str
size: int
progress: float
state: str
download_speed: int
upload_speed: int
eta: int
num_seeds: int
num_leechs: int
ratio: float
category: Optional[str] = None
save_path: Optional[str] = None
@@ -0,0 +1,21 @@
"""qBittorrent API exceptions."""
class QBittorrentError(Exception):
"""Base exception for qBittorrent-related errors."""
pass
class QBittorrentConfigurationError(QBittorrentError):
"""Raised when qBittorrent is not properly configured."""
pass
class QBittorrentAPIError(QBittorrentError):
"""Raised when qBittorrent API returns an error."""
pass
class QBittorrentAuthError(QBittorrentError):
"""Raised when authentication fails."""
pass
+23
View File
@@ -0,0 +1,23 @@
"""TMDB API client."""
from .client import TMDBClient
from .dto import MediaResult, ExternalIds
from .exceptions import (
TMDBError,
TMDBConfigurationError,
TMDBAPIError,
TMDBNotFoundError,
)
# Global TMDB client instance (singleton)
tmdb_client = TMDBClient()
__all__ = [
"TMDBClient",
"MediaResult",
"ExternalIds",
"TMDBError",
"TMDBConfigurationError",
"TMDBAPIError",
"TMDBNotFoundError",
"tmdb_client",
]
+281
View File
@@ -0,0 +1,281 @@
"""TMDB (The Movie Database) API client."""
from typing import Dict, Any, Optional, List
import logging
import requests
from requests.exceptions import RequestException, Timeout, HTTPError
from agent.config import Settings, settings
from .dto import MediaResult
from .exceptions import TMDBError, TMDBConfigurationError, TMDBAPIError, TMDBNotFoundError
logger = logging.getLogger(__name__)
class TMDBClient:
"""
Client for interacting with The Movie Database (TMDB) API.
This client provides methods to search for movies and TV shows,
retrieve their details, and get external IDs (like IMDb).
Example:
>>> client = TMDBClient()
>>> result = client.search_media("Inception")
>>> print(result.imdb_id)
'tt1375666'
"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
config: Optional[Settings] = None
):
"""
Initialize TMDB client.
Args:
api_key: TMDB API key (defaults to settings)
base_url: TMDB API base URL (defaults to settings)
timeout: Request timeout in seconds (defaults to settings)
config: Optional Settings instance (for testing)
Raises:
TMDBConfigurationError: If API key is missing
"""
cfg = config or settings
self.api_key = api_key or cfg.tmdb_api_key
self.base_url = base_url or cfg.tmdb_base_url
self.timeout = timeout or cfg.request_timeout
if not self.api_key:
raise TMDBConfigurationError(
"TMDB API key is required. Set TMDB_API_KEY environment variable."
)
if not self.base_url:
raise TMDBConfigurationError(
"TMDB base URL is required. Set TMDB_BASE_URL environment variable."
)
logger.info("TMDB client initialized")
def _make_request(
self,
endpoint: str,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Make a request to TMDB API.
Args:
endpoint: API endpoint (e.g., '/search/multi')
params: Query parameters
Returns:
JSON response as dict
Raises:
TMDBAPIError: If request fails
"""
url = f"{self.base_url}{endpoint}"
# Add API key to params
request_params = params or {}
request_params['api_key'] = self.api_key
try:
logger.debug(f"TMDB request: {endpoint}")
response = requests.get(url, params=request_params, timeout=self.timeout)
response.raise_for_status()
return response.json()
except Timeout as e:
logger.error(f"TMDB API timeout: {e}")
raise TMDBAPIError(f"Request timeout after {self.timeout} seconds") from e
except HTTPError as e:
logger.error(f"TMDB API HTTP error: {e}")
if e.response is not None:
status_code = e.response.status_code
if status_code == 401:
raise TMDBAPIError("Invalid TMDB API key") from e
elif status_code == 404:
raise TMDBNotFoundError("Resource not found") from e
else:
raise TMDBAPIError(f"HTTP {status_code}: {e}") from e
raise TMDBAPIError(f"HTTP error: {e}") from e
except RequestException as e:
logger.error(f"TMDB API request failed: {e}")
raise TMDBAPIError(f"Failed to connect to TMDB API: {e}") from e
def search_multi(self, query: str) -> List[Dict[str, Any]]:
"""
Search for movies and TV shows.
Args:
query: Search query (movie or TV show title)
Returns:
List of search results
Raises:
TMDBAPIError: If request fails
TMDBNotFoundError: If no results found
"""
if not query or not isinstance(query, str):
raise ValueError("Query must be a non-empty string")
if len(query) > 500:
raise ValueError("Query is too long (max 500 characters)")
data = self._make_request('/search/multi', {'query': query})
results = data.get('results', [])
if not results:
raise TMDBNotFoundError(f"No results found for '{query}'")
logger.info(f"Found {len(results)} results for '{query}'")
return results
def get_external_ids(self, media_type: str, tmdb_id: int) -> Dict[str, Any]:
"""
Get external IDs (IMDb, TVDB, etc.) for a media item.
Args:
media_type: Type of media ('movie' or 'tv')
tmdb_id: TMDB ID of the media
Returns:
Dict with external IDs
Raises:
TMDBAPIError: If request fails
"""
if media_type not in ('movie', 'tv'):
raise ValueError(f"Invalid media_type: {media_type}. Must be 'movie' or 'tv'")
endpoint = f"/{media_type}/{tmdb_id}/external_ids"
return self._make_request(endpoint)
def search_media(self, title: str) -> MediaResult:
"""
Search for a media item and return detailed information including IMDb ID.
This is a convenience method that combines search and external ID lookup.
Args:
title: Title of the movie or TV show
Returns:
MediaResult with all available information
Raises:
TMDBAPIError: If request fails
TMDBNotFoundError: If media not found
"""
# Search for media
results = self.search_multi(title)
# Get the first (most relevant) result
top_result = results[0]
# Validate result structure
if 'id' not in top_result or 'media_type' not in top_result:
raise TMDBAPIError("Invalid TMDB response structure")
tmdb_id = top_result['id']
media_type = top_result['media_type']
# Skip if not movie or TV show
if media_type not in ('movie', 'tv'):
logger.warning(f"Skipping result of type: {media_type}")
if len(results) > 1:
# Try next result
return self._parse_result(results[1])
raise TMDBNotFoundError(f"No movie or TV show found for '{title}'")
return self._parse_result(top_result)
def _parse_result(self, result: Dict[str, Any]) -> MediaResult:
"""
Parse a TMDB result into a MediaResult object.
Args:
result: Raw TMDB result dict
Returns:
MediaResult object
"""
tmdb_id = result['id']
media_type = result['media_type']
title = result.get('title') or result.get('name', 'Unknown')
# Get external IDs (including IMDb)
try:
external_ids = self.get_external_ids(media_type, tmdb_id)
imdb_id = external_ids.get('imdb_id')
except TMDBAPIError as e:
logger.warning(f"Failed to get external IDs: {e}")
imdb_id = None
# Extract other useful information
overview = result.get('overview')
release_date = result.get('release_date') or result.get('first_air_date')
poster_path = result.get('poster_path')
vote_average = result.get('vote_average')
logger.info(f"Found: {title} (Type: {media_type}, TMDB ID: {tmdb_id}, IMDb: {imdb_id})")
return MediaResult(
tmdb_id=tmdb_id,
title=title,
media_type=media_type,
imdb_id=imdb_id,
overview=overview,
release_date=release_date,
poster_path=poster_path,
vote_average=vote_average
)
def get_movie_details(self, movie_id: int) -> Dict[str, Any]:
"""
Get detailed information about a movie.
Args:
movie_id: TMDB movie ID
Returns:
Dict with movie details
Raises:
TMDBAPIError: If request fails
"""
return self._make_request(f'/movie/{movie_id}')
def get_tv_details(self, tv_id: int) -> Dict[str, Any]:
"""
Get detailed information about a TV show.
Args:
tv_id: TMDB TV show ID
Returns:
Dict with TV show details
Raises:
TMDBAPIError: If request fails
"""
return self._make_request(f'/tv/{tv_id}')
def is_configured(self) -> bool:
"""
Check if TMDB client is properly configured.
Returns:
True if configured, False otherwise
"""
return bool(self.api_key and self.base_url)
+26
View File
@@ -0,0 +1,26 @@
"""TMDB Data Transfer Objects."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class MediaResult:
"""Represents a media search result from TMDB."""
tmdb_id: int
title: str
media_type: str # 'movie' or 'tv'
imdb_id: Optional[str] = None
overview: Optional[str] = None
release_date: Optional[str] = None
poster_path: Optional[str] = None
vote_average: Optional[float] = None
@dataclass
class ExternalIds:
"""External IDs for a media item."""
imdb_id: Optional[str] = None
tvdb_id: Optional[int] = None
facebook_id: Optional[str] = None
instagram_id: Optional[str] = None
twitter_id: Optional[str] = None
+21
View File
@@ -0,0 +1,21 @@
"""TMDB API exceptions."""
class TMDBError(Exception):
"""Base exception for TMDB-related errors."""
pass
class TMDBConfigurationError(TMDBError):
"""Raised when TMDB API is not properly configured."""
pass
class TMDBAPIError(TMDBError):
"""Raised when TMDB API returns an error."""
pass
class TMDBNotFoundError(TMDBError):
"""Raised when media is not found."""
pass