New archi: domain driven development
Working but need to check out code
This commit is contained in:
+21
-3
@@ -3,9 +3,10 @@ from typing import Any, Dict, List
|
||||
import json
|
||||
|
||||
from .llm import DeepSeekClient
|
||||
from .memory import Memory
|
||||
from infrastructure.persistence.memory import Memory
|
||||
from .registry import make_tools, Tool
|
||||
from .prompts import PromptBuilder
|
||||
from .config import settings
|
||||
|
||||
class Agent:
|
||||
def __init__(self, llm: DeepSeekClient, memory: Memory, max_tool_iterations: int = 5):
|
||||
@@ -69,18 +70,35 @@ class Agent:
|
||||
# Build system prompt using PromptBuilder
|
||||
system_prompt = self.prompt_builder.build_system_prompt(self.memory.data)
|
||||
|
||||
# Initialize conversation with user input
|
||||
# Initialize conversation with system prompt
|
||||
messages: List[Dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_input},
|
||||
]
|
||||
|
||||
# Add conversation history from memory (last N messages for context)
|
||||
# Only add user/assistant messages, NOT system messages
|
||||
history = self.memory.get("history", [])
|
||||
max_history = settings.max_history_messages
|
||||
if history and max_history > 0:
|
||||
# Filter to keep only user and assistant messages
|
||||
filtered_history = [
|
||||
msg for msg in history
|
||||
if msg.get("role") in ("user", "assistant")
|
||||
]
|
||||
recent_history = filtered_history[-max_history:]
|
||||
messages.extend(recent_history)
|
||||
print(f"Added {len(recent_history)} messages from history (filtered)")
|
||||
|
||||
# Add current user input
|
||||
messages.append({"role": "user", "content": user_input})
|
||||
|
||||
# Tool execution loop
|
||||
iteration = 0
|
||||
while iteration < self.max_tool_iterations:
|
||||
print(f"\n--- Iteration {iteration + 1} ---")
|
||||
|
||||
# Get LLM response
|
||||
print(messages)
|
||||
llm_response = self.llm.complete(messages)
|
||||
print("LLM response:", llm_response)
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""API clients module."""
|
||||
from .themoviedb import (
|
||||
TMDBClient,
|
||||
tmdb_client,
|
||||
TMDBError,
|
||||
TMDBConfigurationError,
|
||||
TMDBAPIError,
|
||||
TMDBNotFoundError,
|
||||
MediaResult
|
||||
)
|
||||
|
||||
from .knaben import (
|
||||
KnabenClient,
|
||||
knaben_client,
|
||||
KnabenError,
|
||||
KnabenConfigurationError,
|
||||
KnabenAPIError,
|
||||
KnabenNotFoundError,
|
||||
TorrentResult
|
||||
)
|
||||
|
||||
from .qbittorrent import (
|
||||
QBittorrentClient,
|
||||
qbittorrent_client,
|
||||
QBittorrentError,
|
||||
QBittorrentConfigurationError,
|
||||
QBittorrentAPIError,
|
||||
QBittorrentAuthError,
|
||||
TorrentInfo
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# TMDB
|
||||
'TMDBClient',
|
||||
'tmdb_client',
|
||||
'TMDBError',
|
||||
'TMDBConfigurationError',
|
||||
'TMDBAPIError',
|
||||
'TMDBNotFoundError',
|
||||
'MediaResult',
|
||||
# Knaben
|
||||
'KnabenClient',
|
||||
'knaben_client',
|
||||
'KnabenError',
|
||||
'KnabenConfigurationError',
|
||||
'KnabenAPIError',
|
||||
'KnabenNotFoundError',
|
||||
'TorrentResult',
|
||||
# qBittorrent
|
||||
'QBittorrentClient',
|
||||
'qbittorrent_client',
|
||||
'QBittorrentError',
|
||||
'QBittorrentConfigurationError',
|
||||
'QBittorrentAPIError',
|
||||
'QBittorrentAuthError',
|
||||
'TorrentInfo'
|
||||
]
|
||||
@@ -1,230 +0,0 @@
|
||||
"""Knaben torrent search API client."""
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, Timeout, HTTPError
|
||||
|
||||
from ..config import Settings, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
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:
|
||||
endpoint: API endpoint (e.g., '/search')
|
||||
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: 50)
|
||||
|
||||
Returns:
|
||||
List of TorrentResult objects
|
||||
|
||||
Raises:
|
||||
KnabenAPIError: If request fails
|
||||
KnabenNotFoundError: If no results found
|
||||
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 as e:
|
||||
# No results found
|
||||
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
|
||||
)
|
||||
|
||||
# Global Knaben client instance (singleton)
|
||||
knaben_client = KnabenClient()
|
||||
@@ -1,429 +0,0 @@
|
||||
"""qBittorrent Web API client."""
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, Timeout, HTTPError
|
||||
|
||||
from ..config import Settings, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
|
||||
# Global qBittorrent client instance (singleton)
|
||||
qbittorrent_client = QBittorrentClient()
|
||||
@@ -1,317 +0,0 @@
|
||||
"""TMDB (The Movie Database) API client."""
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import requests
|
||||
from requests.exceptions import RequestException, Timeout, HTTPError
|
||||
|
||||
from ..config import Settings, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Global TMDB client instance (singleton)
|
||||
tmdb_client = TMDBClient()
|
||||
@@ -34,6 +34,9 @@ class Settings:
|
||||
# Security Configuration
|
||||
max_tool_iterations: int = field(default_factory=lambda: int(os.getenv("MAX_TOOL_ITERATIONS", "5")))
|
||||
request_timeout: int = field(default_factory=lambda: int(os.getenv("REQUEST_TIMEOUT", "30")))
|
||||
|
||||
# Memory Configuration
|
||||
max_history_messages: int = field(default_factory=lambda: int(os.getenv("MAX_HISTORY_MESSAGES", "10")))
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate settings after initialization."""
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
"""LLM client module."""
|
||||
from .deepseek import DeepSeekClient
|
||||
from .ollama import OllamaClient
|
||||
|
||||
__all__ = ['DeepSeekClient', 'OllamaClient']
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Ollama LLM client with robust error handling."""
|
||||
from typing import List, Dict, Any, Optional
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
|
||||
from requests.exceptions import RequestException, Timeout, HTTPError
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""Base exception for LLM-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMConfigurationError(LLMError):
|
||||
"""Raised when LLM is not properly configured."""
|
||||
pass
|
||||
|
||||
|
||||
class LLMAPIError(LLMError):
|
||||
"""Raised when LLM API returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
"""
|
||||
Client for interacting with Ollama API.
|
||||
|
||||
Ollama runs locally and provides an OpenAI-compatible API.
|
||||
|
||||
Example:
|
||||
>>> client = OllamaClient(model="llama3.2")
|
||||
>>> messages = [{"role": "user", "content": "Hello!"}]
|
||||
>>> response = client.complete(messages)
|
||||
>>> print(response)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
temperature: Optional[float] = None,
|
||||
):
|
||||
"""
|
||||
Initialize Ollama client.
|
||||
|
||||
Args:
|
||||
base_url: Ollama API base URL (defaults to http://localhost:11434)
|
||||
model: Model name to use (e.g., "llama3.2", "mistral", "codellama")
|
||||
timeout: Request timeout in seconds (defaults to settings)
|
||||
temperature: Temperature for generation (defaults to settings)
|
||||
|
||||
Raises:
|
||||
LLMConfigurationError: If configuration is invalid
|
||||
"""
|
||||
self.base_url = base_url or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
self.model = model or os.getenv("OLLAMA_MODEL", "llama3.2")
|
||||
self.timeout = timeout or settings.request_timeout
|
||||
self.temperature = temperature if temperature is not None else settings.temperature
|
||||
|
||||
if not self.base_url:
|
||||
raise LLMConfigurationError(
|
||||
"Ollama base URL is required. Set OLLAMA_BASE_URL environment variable."
|
||||
)
|
||||
|
||||
if not self.model:
|
||||
raise LLMConfigurationError(
|
||||
"Ollama model is required. Set OLLAMA_MODEL environment variable."
|
||||
)
|
||||
|
||||
logger.info(f"Ollama client initialized with model: {self.model}")
|
||||
|
||||
def complete(self, messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
Generate a completion from the LLM.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content' keys
|
||||
|
||||
Returns:
|
||||
Generated text response
|
||||
|
||||
Raises:
|
||||
LLMAPIError: If API request fails
|
||||
ValueError: If messages format is invalid
|
||||
"""
|
||||
# Validate messages format
|
||||
if not messages:
|
||||
raise ValueError("Messages list cannot be empty")
|
||||
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
raise ValueError(f"Each message must be a dict, got {type(msg)}")
|
||||
if "role" not in msg or "content" not in msg:
|
||||
raise ValueError(f"Each message must have 'role' and 'content' keys, got {msg.keys()}")
|
||||
if msg["role"] not in ("system", "user", "assistant"):
|
||||
raise ValueError(f"Invalid role: {msg['role']}")
|
||||
|
||||
url = f"{self.base_url}/api/chat"
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": self.temperature,
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
logger.debug(f"Sending request to {url} with {len(messages)} messages")
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Validate response structure
|
||||
if "message" not in data:
|
||||
raise LLMAPIError("Invalid API response: missing 'message'")
|
||||
|
||||
if "content" not in data["message"]:
|
||||
raise LLMAPIError("Invalid API response: missing 'content' in message")
|
||||
|
||||
content = data["message"]["content"]
|
||||
logger.debug(f"Received response with {len(content)} characters")
|
||||
|
||||
return content
|
||||
|
||||
except Timeout as e:
|
||||
logger.error(f"Request timeout after {self.timeout}s: {e}")
|
||||
raise LLMAPIError(f"Request timeout after {self.timeout} seconds") from e
|
||||
|
||||
except HTTPError as e:
|
||||
logger.error(f"HTTP error from Ollama API: {e}")
|
||||
if e.response is not None:
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
error_msg = error_data.get("error", str(e))
|
||||
except Exception:
|
||||
error_msg = str(e)
|
||||
raise LLMAPIError(f"Ollama API error: {error_msg}") from e
|
||||
raise LLMAPIError(f"HTTP error: {e}") from e
|
||||
|
||||
except RequestException as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
raise LLMAPIError(f"Failed to connect to Ollama API: {e}") from e
|
||||
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
logger.error(f"Failed to parse API response: {e}")
|
||||
raise LLMAPIError(f"Invalid API response format: {e}") from e
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""
|
||||
List available models in Ollama.
|
||||
|
||||
Returns:
|
||||
List of model names
|
||||
"""
|
||||
url = f"{self.base_url}/api/tags"
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
models = [model["name"] for model in data.get("models", [])]
|
||||
logger.info(f"Found {len(models)} models: {models}")
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list models: {e}")
|
||||
return []
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""
|
||||
Check if Ollama is running and accessible.
|
||||
|
||||
Returns:
|
||||
True if Ollama is available, False otherwise
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/tags"
|
||||
response = requests.get(url, timeout=5)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,86 +0,0 @@
|
||||
# agent/memory.py
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
import json
|
||||
|
||||
from .config import settings
|
||||
from .parameters import validate_parameter, get_parameter_schema
|
||||
|
||||
|
||||
class Memory:
|
||||
"""
|
||||
Generic memory storage for agent state.
|
||||
|
||||
Provides a simple key-value store that persists to JSON.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str = "memory.json"):
|
||||
self.file = Path(path)
|
||||
self.data: Dict[str, Any] = {}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
"""Load memory from file or initialize with defaults."""
|
||||
if self.file.exists():
|
||||
try:
|
||||
self.data = json.loads(self.file.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f"Warning: Could not load memory file: {e}")
|
||||
self.data = {
|
||||
"config": {},
|
||||
"tv_shows": [],
|
||||
"history": [],
|
||||
}
|
||||
else:
|
||||
self.data = {
|
||||
"config": {},
|
||||
"tv_shows": [],
|
||||
"history": [],
|
||||
}
|
||||
|
||||
def save(self) -> None:
|
||||
self.file.write_text(
|
||||
json.dumps(self.data, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a value from memory by key."""
|
||||
return self.data.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Set a value in memory and save.
|
||||
|
||||
Validates the value against the parameter schema if one exists.
|
||||
"""
|
||||
# Validate if schema exists
|
||||
is_valid, error_msg = validate_parameter(key, value)
|
||||
if not is_valid:
|
||||
print(f'Validation failed for {key}: {error_msg}')
|
||||
raise ValueError(f"Invalid value for {key}: {error_msg}")
|
||||
|
||||
print(f'Setting {key} in memory to: {value}')
|
||||
self.data[key] = value
|
||||
self.save()
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Check if a key exists and has a non-None value."""
|
||||
return key in self.data and self.data[key] is not None
|
||||
|
||||
def append_history(self, role: str, content: str) -> None:
|
||||
"""
|
||||
Append a message to conversation history.
|
||||
|
||||
Args:
|
||||
role: Message role ('user' or 'assistant')
|
||||
content: Message content
|
||||
"""
|
||||
if "history" not in self.data:
|
||||
self.data["history"] = []
|
||||
|
||||
self.data["history"].append({
|
||||
"role": role,
|
||||
"content": content
|
||||
})
|
||||
self.save()
|
||||
@@ -1,2 +0,0 @@
|
||||
"""Models module."""
|
||||
from .tv_show import TVShow, ShowStatus, validate_tv_shows_structure
|
||||
@@ -1,58 +0,0 @@
|
||||
"""TV Show models and validation."""
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ShowStatus(Enum):
|
||||
"""Status of a TV show - whether it's still airing or has ended."""
|
||||
ONGOING = "ongoing"
|
||||
ENDED = "ended"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TVShow:
|
||||
"""Represents a TV show."""
|
||||
imdb_id: str
|
||||
title: str
|
||||
seasons_count: int
|
||||
status: ShowStatus # ongoing or ended
|
||||
|
||||
|
||||
def validate_tv_shows_structure(tv_shows: Any) -> bool:
|
||||
"""
|
||||
Validate the structure of the tv_shows parameter.
|
||||
|
||||
Expected structure: list of TV show objects
|
||||
[
|
||||
{
|
||||
"imdb_id": str,
|
||||
"title": str,
|
||||
"seasons_count": int,
|
||||
"status": str # "ongoing" or "ended"
|
||||
}
|
||||
]
|
||||
"""
|
||||
if not isinstance(tv_shows, list):
|
||||
return False
|
||||
|
||||
for show in tv_shows:
|
||||
if not isinstance(show, dict):
|
||||
return False
|
||||
|
||||
# Check required fields
|
||||
required_fields = {"imdb_id", "title", "seasons_count", "status"}
|
||||
if not all(field in show for field in required_fields):
|
||||
return False
|
||||
|
||||
# Validate field types
|
||||
if not isinstance(show["imdb_id"], str):
|
||||
return False
|
||||
if not isinstance(show["title"], str):
|
||||
return False
|
||||
if not isinstance(show["seasons_count"], int):
|
||||
return False
|
||||
if show["status"] not in ["ongoing", "ended"]:
|
||||
return False
|
||||
|
||||
return True
|
||||
+1
-1
@@ -3,7 +3,7 @@ from dataclasses import dataclass
|
||||
from typing import Callable, Any, Dict
|
||||
from functools import partial
|
||||
|
||||
from .memory import Memory
|
||||
from infrastructure.persistence.memory import Memory
|
||||
from .tools.filesystem import set_path_for_folder, list_folder
|
||||
from .tools.api import find_media_imdb_id, find_torrent, add_torrent_to_qbittorrent
|
||||
|
||||
|
||||
+10
-2
@@ -1,3 +1,11 @@
|
||||
"""Tools module - filesystem and API tools."""
|
||||
from .filesystem import FolderName, set_path_for_folder, list_folder
|
||||
from .api import find_media_imdb_id
|
||||
from .filesystem import set_path_for_folder, list_folder
|
||||
from .api import find_media_imdb_id, find_torrent, add_torrent_to_qbittorrent
|
||||
|
||||
__all__ = [
|
||||
'set_path_for_folder',
|
||||
'list_folder',
|
||||
'find_media_imdb_id',
|
||||
'find_torrent',
|
||||
'add_torrent_to_qbittorrent',
|
||||
]
|
||||
|
||||
+38
-175
@@ -1,224 +1,87 @@
|
||||
"""API tools for interacting with external services."""
|
||||
"""API tools for interacting with external services - Adapted for DDD architecture."""
|
||||
from typing import Dict, Any
|
||||
import logging
|
||||
|
||||
from ..api import tmdb_client, TMDBError, TMDBNotFoundError, TMDBAPIError, TMDBConfigurationError
|
||||
from ..api.knaben import knaben_client, KnabenError, KnabenNotFoundError, KnabenAPIError
|
||||
from ..api.qbittorrent import qbittorrent_client, QBittorrentError, QBittorrentAuthError, QBittorrentAPIError
|
||||
# Import use cases instead of direct API clients
|
||||
from application.movies import SearchMovieUseCase
|
||||
from application.torrents import SearchTorrentsUseCase, AddTorrentUseCase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# Import infrastructure clients
|
||||
from infrastructure.api.tmdb import tmdb_client
|
||||
from infrastructure.api.knaben import knaben_client
|
||||
from infrastructure.api.qbittorrent import qbittorrent_client
|
||||
|
||||
|
||||
def find_media_imdb_id(media_title: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Find the IMDb ID for a given media title using TMDB API.
|
||||
|
||||
This is a wrapper around the TMDB client that returns a standardized
|
||||
dict format for compatibility with the agent's tool system.
|
||||
This is a wrapper that uses the SearchMovieUseCase.
|
||||
|
||||
Args:
|
||||
media_title: Title of the media to search for
|
||||
|
||||
Returns:
|
||||
Dict with IMDb ID or error information:
|
||||
- Success: {"status": "ok", "imdb_id": str, "title": str, ...}
|
||||
- Error: {"error": str, "message": str}
|
||||
Dict with IMDb ID or error information
|
||||
|
||||
Example:
|
||||
>>> result = find_media_imdb_id("Inception")
|
||||
>>> print(result)
|
||||
{'status': 'ok', 'imdb_id': 'tt1375666', 'title': 'Inception', ...}
|
||||
"""
|
||||
try:
|
||||
# Use the TMDB client to search for media
|
||||
result = tmdb_client.search_media(media_title)
|
||||
|
||||
# Check if IMDb ID was found
|
||||
if result.imdb_id:
|
||||
logger.info(f"IMDb ID found for '{media_title}': {result.imdb_id}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"imdb_id": result.imdb_id,
|
||||
"title": result.title,
|
||||
"media_type": result.media_type,
|
||||
"tmdb_id": result.tmdb_id,
|
||||
"overview": result.overview,
|
||||
"release_date": result.release_date,
|
||||
"vote_average": result.vote_average
|
||||
}
|
||||
else:
|
||||
logger.warning(f"No IMDb ID available for '{media_title}'")
|
||||
return {
|
||||
"error": "no_imdb_id",
|
||||
"message": f"No IMDb ID available for '{result.title}'",
|
||||
"title": result.title,
|
||||
"media_type": result.media_type,
|
||||
"tmdb_id": result.tmdb_id
|
||||
}
|
||||
|
||||
except TMDBNotFoundError as e:
|
||||
logger.info(f"Media not found: {e}")
|
||||
return {
|
||||
"error": "not_found",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
except TMDBConfigurationError as e:
|
||||
logger.error(f"TMDB configuration error: {e}")
|
||||
return {
|
||||
"error": "configuration_error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
except TMDBAPIError as e:
|
||||
logger.error(f"TMDB API error: {e}")
|
||||
return {
|
||||
"error": "api_error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {e}")
|
||||
return {
|
||||
"error": "validation_failed",
|
||||
"message": str(e)
|
||||
}
|
||||
# Create use case with TMDB client
|
||||
use_case = SearchMovieUseCase(tmdb_client)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(media_title)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
|
||||
|
||||
def find_torrent(media_title: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Find torrents for a given media title using Knaben API.
|
||||
|
||||
This is a wrapper around the Knaben client that returns a standardized
|
||||
dict format for compatibility with the agent's tool system.
|
||||
This is a wrapper that uses the SearchTorrentsUseCase.
|
||||
|
||||
Args:
|
||||
media_title: Title of the media to search for
|
||||
|
||||
Returns:
|
||||
Dict with torrent information or error details:
|
||||
- Success: {"status": "ok", "torrents": List[Dict[str, Any]]}
|
||||
- Error: {"error": str, "message": str}
|
||||
Dict with torrent information or error details
|
||||
"""
|
||||
try:
|
||||
# Search for torrents
|
||||
results = knaben_client.search(media_title, limit=10)
|
||||
|
||||
if not results:
|
||||
logger.info(f"No torrents found for '{media_title}'")
|
||||
return {
|
||||
"error": "not_found",
|
||||
"message": f"No torrents found for '{media_title}'"
|
||||
}
|
||||
|
||||
# Convert to dict format
|
||||
torrents = []
|
||||
for torrent in results:
|
||||
torrents.append({
|
||||
"name": torrent.title,
|
||||
"size": torrent.size,
|
||||
"seeders": torrent.seeders,
|
||||
"leechers": torrent.leechers,
|
||||
"magnet": torrent.magnet,
|
||||
"info_hash": torrent.info_hash,
|
||||
"tracker": torrent.tracker,
|
||||
"upload_date": torrent.upload_date,
|
||||
"category": torrent.category
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(torrents)} torrents for '{media_title}'")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"torrents": torrents,
|
||||
"count": len(torrents)
|
||||
}
|
||||
|
||||
except KnabenNotFoundError as e:
|
||||
logger.info(f"Torrents not found: {e}")
|
||||
return {
|
||||
"error": "not_found",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
except KnabenAPIError as e:
|
||||
logger.error(f"Knaben API error: {e}")
|
||||
return {
|
||||
"error": "api_error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {e}")
|
||||
return {
|
||||
"error": "validation_failed",
|
||||
"message": str(e)
|
||||
}
|
||||
# Create use case with Knaben client
|
||||
use_case = SearchTorrentsUseCase(knaben_client)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(media_title, limit=10)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
|
||||
|
||||
def add_torrent_to_qbittorrent(magnet_link: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a torrent to qBittorrent using a magnet link.
|
||||
|
||||
This is a wrapper around the qBittorrent client that returns a standardized
|
||||
dict format for compatibility with the agent's tool system.
|
||||
This is a wrapper that uses the AddTorrentUseCase.
|
||||
|
||||
Args:
|
||||
magnet_link: Magnet link of the torrent to add
|
||||
|
||||
Returns:
|
||||
Dict with success or error information:
|
||||
- Success: {"status": "ok", "message": str}
|
||||
- Error: {"error": str, "message": str}
|
||||
Dict with success or error information
|
||||
|
||||
Example:
|
||||
>>> result = add_torrent_to_qbittorrent("magnet:?xt=urn:btih:...")
|
||||
>>> print(result)
|
||||
{'status': 'ok', 'message': 'Torrent added successfully'}
|
||||
"""
|
||||
try:
|
||||
# Validate magnet link
|
||||
if not magnet_link or not isinstance(magnet_link, str):
|
||||
raise ValueError("Magnet link must be a non-empty string")
|
||||
|
||||
if not magnet_link.startswith("magnet:"):
|
||||
raise ValueError("Invalid magnet link format")
|
||||
|
||||
logger.info("Adding torrent to qBittorrent")
|
||||
|
||||
# Add torrent to qBittorrent
|
||||
success = qbittorrent_client.add_torrent(magnet_link)
|
||||
|
||||
if success:
|
||||
logger.info("Torrent added successfully to qBittorrent")
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Torrent added successfully to qBittorrent"
|
||||
}
|
||||
else:
|
||||
logger.warning("Failed to add torrent to qBittorrent")
|
||||
return {
|
||||
"error": "add_failed",
|
||||
"message": "Failed to add torrent to qBittorrent"
|
||||
}
|
||||
|
||||
except QBittorrentAuthError as e:
|
||||
logger.error(f"qBittorrent authentication error: {e}")
|
||||
return {
|
||||
"error": "authentication_failed",
|
||||
"message": "Failed to authenticate with qBittorrent"
|
||||
}
|
||||
|
||||
except QBittorrentAPIError as e:
|
||||
logger.error(f"qBittorrent API error: {e}")
|
||||
return {
|
||||
"error": "api_error",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {e}")
|
||||
return {
|
||||
"error": "validation_failed",
|
||||
"message": str(e)
|
||||
}
|
||||
# Create use case with qBittorrent client
|
||||
use_case = AddTorrentUseCase(qbittorrent_client)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(magnet_link)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
|
||||
+30
-419
@@ -1,111 +1,17 @@
|
||||
"""Filesystem tools for managing folders and files with security."""
|
||||
"""Filesystem tools - Adapted for DDD architecture."""
|
||||
from typing import Dict, Any
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..memory import Memory
|
||||
# Import use cases
|
||||
from application.filesystem import SetFolderPathUseCase, ListFolderUseCase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FolderName(Enum):
|
||||
"""Types of folders that can be managed."""
|
||||
DOWNLOAD = "download"
|
||||
TVSHOW = "tvshow"
|
||||
MOVIE = "movie"
|
||||
TORRENT = "torrent"
|
||||
|
||||
|
||||
class FilesystemError(Exception):
|
||||
"""Base exception for filesystem operations."""
|
||||
pass
|
||||
|
||||
|
||||
class PathTraversalError(FilesystemError):
|
||||
"""Raised when path traversal attack is detected."""
|
||||
pass
|
||||
|
||||
|
||||
def _validate_folder_name(folder_name: str) -> bool:
|
||||
"""
|
||||
Validate folder name against allowed values.
|
||||
|
||||
Args:
|
||||
folder_name: Name to validate
|
||||
|
||||
Returns:
|
||||
True if valid
|
||||
|
||||
Raises:
|
||||
ValueError: If folder name is invalid
|
||||
"""
|
||||
valid_names = [fn.value for fn in FolderName]
|
||||
if folder_name not in valid_names:
|
||||
raise ValueError(
|
||||
f"Invalid folder_name '{folder_name}'. Must be one of: {', '.join(valid_names)}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _sanitize_path(path: str) -> str:
|
||||
"""
|
||||
Sanitize path to prevent path traversal attacks.
|
||||
|
||||
Args:
|
||||
path: Path to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized path
|
||||
|
||||
Raises:
|
||||
PathTraversalError: If path contains dangerous patterns
|
||||
"""
|
||||
# Normalize path
|
||||
normalized = os.path.normpath(path)
|
||||
|
||||
# Check for absolute paths
|
||||
if os.path.isabs(normalized):
|
||||
raise PathTraversalError("Absolute paths are not allowed")
|
||||
|
||||
# Check for parent directory references
|
||||
if normalized.startswith("..") or "/.." in normalized or "\\.." in normalized:
|
||||
raise PathTraversalError("Parent directory references are not allowed")
|
||||
|
||||
# Check for null bytes
|
||||
if "\x00" in normalized:
|
||||
raise PathTraversalError("Null bytes in path are not allowed")
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _is_safe_path(base_path: Path, target_path: Path) -> bool:
|
||||
"""
|
||||
Check if target path is within base path (prevents path traversal).
|
||||
|
||||
Args:
|
||||
base_path: Base directory path
|
||||
target_path: Target path to check
|
||||
|
||||
Returns:
|
||||
True if safe, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Resolve both paths to absolute paths
|
||||
base_resolved = base_path.resolve()
|
||||
target_resolved = target_path.resolve()
|
||||
|
||||
# Check if target is relative to base
|
||||
target_resolved.relative_to(base_resolved)
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
# Import infrastructure
|
||||
from infrastructure.filesystem import FileManager
|
||||
from infrastructure.persistence.memory import Memory
|
||||
|
||||
|
||||
def set_path_for_folder(memory: Memory, folder_name: str, path_value: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Set a path in the config with validation.
|
||||
Set a path in the configuration.
|
||||
|
||||
Args:
|
||||
memory: Memory instance to store the configuration
|
||||
@@ -115,60 +21,22 @@ def set_path_for_folder(memory: Memory, folder_name: str, path_value: str) -> Di
|
||||
Returns:
|
||||
Dict with status or error information
|
||||
"""
|
||||
try:
|
||||
# Validate folder name
|
||||
_validate_folder_name(folder_name)
|
||||
|
||||
# Convert to Path object for better handling
|
||||
path_obj = Path(path_value).resolve()
|
||||
|
||||
# Validate path exists and is a directory
|
||||
if not path_obj.exists():
|
||||
logger.warning(f"Path does not exist: {path_value}")
|
||||
return {
|
||||
"error": "invalid_path",
|
||||
"message": f"Path does not exist: {path_value}"
|
||||
}
|
||||
|
||||
if not path_obj.is_dir():
|
||||
logger.warning(f"Path is not a directory: {path_value}")
|
||||
return {
|
||||
"error": "invalid_path",
|
||||
"message": f"Path is not a directory: {path_value}"
|
||||
}
|
||||
|
||||
# Check if path is readable
|
||||
if not os.access(path_obj, os.R_OK):
|
||||
logger.warning(f"Path is not readable: {path_value}")
|
||||
return {
|
||||
"error": "permission_denied",
|
||||
"message": f"Path is not readable: {path_value}"
|
||||
}
|
||||
|
||||
# Store in memory
|
||||
config = memory.get("config", {})
|
||||
config[f"{folder_name}_folder"] = str(path_obj)
|
||||
memory.set("config", config)
|
||||
|
||||
logger.info(f"Set {folder_name}_folder to: {path_obj}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"folder_name": folder_name,
|
||||
"path": str(path_obj)
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {e}")
|
||||
return {"error": "validation_failed", "message": str(e)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error setting path: {e}", exc_info=True)
|
||||
return {"error": "internal_error", "message": "Failed to set path"}
|
||||
# Create file manager
|
||||
file_manager = FileManager(memory)
|
||||
|
||||
# Create use case
|
||||
use_case = SetFolderPathUseCase(file_manager)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(folder_name, path_value)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
|
||||
|
||||
def list_folder(memory: Memory, folder_type: str, path: str = ".") -> Dict[str, Any]:
|
||||
"""
|
||||
List contents of a folder with security checks.
|
||||
List contents of a folder.
|
||||
|
||||
Args:
|
||||
memory: Memory instance to retrieve the configuration
|
||||
@@ -178,271 +46,14 @@ def list_folder(memory: Memory, folder_type: str, path: str = ".") -> Dict[str,
|
||||
Returns:
|
||||
Dict with folder contents or error information
|
||||
"""
|
||||
try:
|
||||
# Validate folder type
|
||||
_validate_folder_name(folder_type)
|
||||
|
||||
# Sanitize the path
|
||||
safe_path = _sanitize_path(path)
|
||||
|
||||
# Get root folder from config
|
||||
folder_key = f"{folder_type}_folder"
|
||||
config = memory.get("config", {})
|
||||
|
||||
if folder_key not in config or not config[folder_key]:
|
||||
logger.warning(f"Folder not configured: {folder_type}")
|
||||
return {
|
||||
"error": "folder_not_set",
|
||||
"message": f"{folder_type.capitalize()} folder not set in config."
|
||||
}
|
||||
|
||||
root = Path(config[folder_key])
|
||||
target = root / safe_path
|
||||
|
||||
# Security check: ensure target is within root
|
||||
if not _is_safe_path(root, target):
|
||||
logger.warning(f"Path traversal attempt detected: {path}")
|
||||
return {
|
||||
"error": "forbidden",
|
||||
"message": "Access denied: path outside allowed directory"
|
||||
}
|
||||
|
||||
# Check if target exists
|
||||
if not target.exists():
|
||||
logger.warning(f"Path does not exist: {target}")
|
||||
return {
|
||||
"error": "not_found",
|
||||
"message": f"Path does not exist: {safe_path}"
|
||||
}
|
||||
|
||||
# Check if target is a directory
|
||||
if not target.is_dir():
|
||||
logger.warning(f"Path is not a directory: {target}")
|
||||
return {
|
||||
"error": "not_a_directory",
|
||||
"message": f"Path is not a directory: {safe_path}"
|
||||
}
|
||||
|
||||
# List directory contents
|
||||
try:
|
||||
entries = [entry.name for entry in target.iterdir()]
|
||||
logger.debug(f"Listed {len(entries)} entries in {target}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"folder_type": folder_type,
|
||||
"path": safe_path,
|
||||
"entries": sorted(entries),
|
||||
"count": len(entries)
|
||||
}
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied accessing: {target}")
|
||||
return {
|
||||
"error": "permission_denied",
|
||||
"message": f"Permission denied accessing: {safe_path}"
|
||||
}
|
||||
|
||||
except PathTraversalError as e:
|
||||
logger.warning(f"Path traversal attempt: {e}")
|
||||
return {
|
||||
"error": "forbidden",
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {e}")
|
||||
return {"error": "validation_failed", "message": str(e)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing folder: {e}", exc_info=True)
|
||||
return {"error": "internal_error", "message": "Failed to list folder"}
|
||||
|
||||
def move_file(path: str, destination: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Move a file from one location to another with safety checks.
|
||||
|
||||
This function is designed to safely move files from downloads to movies/series
|
||||
folders with comprehensive validation and error handling to prevent data loss.
|
||||
|
||||
Args:
|
||||
path: Source file path (absolute or relative)
|
||||
destination: Destination file path (absolute or relative)
|
||||
|
||||
Returns:
|
||||
Dict with status or error information:
|
||||
- Success: {"status": "ok", "source": str, "destination": str, "size": int}
|
||||
- Error: {"error": str, "message": str}
|
||||
|
||||
Safety features:
|
||||
- Validates source file exists and is readable
|
||||
- Validates destination directory exists and is writable
|
||||
- Prevents overwriting existing files
|
||||
- Verifies file integrity after move (size check)
|
||||
- Atomic operation using shutil.move
|
||||
- Comprehensive logging
|
||||
|
||||
Example:
|
||||
>>> result = move_file(
|
||||
... "/downloads/movie.mkv",
|
||||
... "/movies/Inception (2010)/movie.mkv"
|
||||
... )
|
||||
>>> print(result)
|
||||
{'status': 'ok', 'source': '...', 'destination': '...', 'size': 1234567890}
|
||||
"""
|
||||
import shutil
|
||||
|
||||
try:
|
||||
# Convert to Path objects
|
||||
source_path = Path(path).resolve()
|
||||
dest_path = Path(destination).resolve()
|
||||
|
||||
logger.info(f"Moving file from {source_path} to {dest_path}")
|
||||
|
||||
# === VALIDATION: Source file ===
|
||||
|
||||
# Check source exists
|
||||
if not source_path.exists():
|
||||
logger.error(f"Source file does not exist: {source_path}")
|
||||
return {
|
||||
"error": "source_not_found",
|
||||
"message": f"Source file does not exist: {path}"
|
||||
}
|
||||
|
||||
# Check source is a file (not a directory)
|
||||
if not source_path.is_file():
|
||||
logger.error(f"Source is not a file: {source_path}")
|
||||
return {
|
||||
"error": "source_not_file",
|
||||
"message": f"Source is not a file: {path}"
|
||||
}
|
||||
|
||||
# Check source is readable
|
||||
if not os.access(source_path, os.R_OK):
|
||||
logger.error(f"Source file is not readable: {source_path}")
|
||||
return {
|
||||
"error": "permission_denied",
|
||||
"message": f"Source file is not readable: {path}"
|
||||
}
|
||||
|
||||
# Get source file size for verification
|
||||
source_size = source_path.stat().st_size
|
||||
logger.debug(f"Source file size: {source_size} bytes")
|
||||
|
||||
# === VALIDATION: Destination ===
|
||||
|
||||
# Check destination parent directory exists
|
||||
dest_parent = dest_path.parent
|
||||
if not dest_parent.exists():
|
||||
logger.error(f"Destination directory does not exist: {dest_parent}")
|
||||
return {
|
||||
"error": "destination_dir_not_found",
|
||||
"message": f"Destination directory does not exist: {dest_parent}"
|
||||
}
|
||||
|
||||
# Check destination parent is a directory
|
||||
if not dest_parent.is_dir():
|
||||
logger.error(f"Destination parent is not a directory: {dest_parent}")
|
||||
return {
|
||||
"error": "destination_not_dir",
|
||||
"message": f"Destination parent is not a directory: {dest_parent}"
|
||||
}
|
||||
|
||||
# Check destination parent is writable
|
||||
if not os.access(dest_parent, os.W_OK):
|
||||
logger.error(f"Destination directory is not writable: {dest_parent}")
|
||||
return {
|
||||
"error": "permission_denied",
|
||||
"message": f"Destination directory is not writable: {dest_parent}"
|
||||
}
|
||||
|
||||
# Check destination file doesn't already exist
|
||||
if dest_path.exists():
|
||||
logger.warning(f"Destination file already exists: {dest_path}")
|
||||
return {
|
||||
"error": "destination_exists",
|
||||
"message": f"Destination file already exists: {destination}"
|
||||
}
|
||||
|
||||
# === SAFETY CHECK: Prevent moving to same location ===
|
||||
|
||||
if source_path == dest_path:
|
||||
logger.warning("Source and destination are the same")
|
||||
return {
|
||||
"error": "same_location",
|
||||
"message": "Source and destination are the same"
|
||||
}
|
||||
|
||||
# === PERFORM MOVE ===
|
||||
|
||||
logger.info(f"Moving file: {source_path.name} ({source_size} bytes)")
|
||||
|
||||
try:
|
||||
# Use shutil.move for atomic operation
|
||||
# This handles cross-filesystem moves automatically
|
||||
shutil.move(str(source_path), str(dest_path))
|
||||
logger.info(f"File moved successfully to {dest_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to move file: {e}", exc_info=True)
|
||||
return {
|
||||
"error": "move_failed",
|
||||
"message": f"Failed to move file: {str(e)}"
|
||||
}
|
||||
|
||||
# === VERIFICATION: Ensure file was moved correctly ===
|
||||
|
||||
# Check destination file exists
|
||||
if not dest_path.exists():
|
||||
logger.error("Destination file does not exist after move!")
|
||||
# Try to recover by checking if source still exists
|
||||
if source_path.exists():
|
||||
logger.info("Source file still exists, move may have failed")
|
||||
return {
|
||||
"error": "move_verification_failed",
|
||||
"message": "File was not moved successfully (destination not found)"
|
||||
}
|
||||
else:
|
||||
logger.critical("Both source and destination missing after move!")
|
||||
return {
|
||||
"error": "file_lost",
|
||||
"message": "CRITICAL: File missing after move operation"
|
||||
}
|
||||
|
||||
# Check destination file size matches source
|
||||
dest_size = dest_path.stat().st_size
|
||||
if dest_size != source_size:
|
||||
logger.error(f"File size mismatch! Source: {source_size}, Dest: {dest_size}")
|
||||
return {
|
||||
"error": "size_mismatch",
|
||||
"message": f"File size mismatch after move (expected {source_size}, got {dest_size})"
|
||||
}
|
||||
|
||||
# Check source file no longer exists
|
||||
if source_path.exists():
|
||||
logger.warning("Source file still exists after move (copy instead of move?)")
|
||||
# This is not necessarily an error (shutil.move copies across filesystems)
|
||||
# but we should log it
|
||||
|
||||
# === SUCCESS ===
|
||||
|
||||
logger.info(f"File successfully moved and verified: {dest_path.name}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"source": str(source_path),
|
||||
"destination": str(dest_path),
|
||||
"filename": dest_path.name,
|
||||
"size": dest_size
|
||||
}
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied: {e}")
|
||||
return {
|
||||
"error": "permission_denied",
|
||||
"message": f"Permission denied: {str(e)}"
|
||||
}
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"OS error during move: {e}", exc_info=True)
|
||||
return {
|
||||
"error": "os_error",
|
||||
"message": f"OS error: {str(e)}"
|
||||
}
|
||||
# Create file manager
|
||||
file_manager = FileManager(memory)
|
||||
|
||||
# Create use case
|
||||
use_case = ListFolderUseCase(file_manager)
|
||||
|
||||
# Execute use case
|
||||
response = use_case.execute(folder_type, path)
|
||||
|
||||
# Return as dict
|
||||
return response.to_dict()
|
||||
|
||||
Reference in New Issue
Block a user