Phase 2 Complete: SQL migration with SQLModel and Alembic
This commit is contained in:
+145
-113
@@ -1,18 +1,19 @@
|
||||
"""Sonarr webhook handler and integration logic"""
|
||||
"""Sonarr webhook handler and integration logic using SQLModel"""
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Dict, List, Tuple, Any
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from app.database import engine
|
||||
from app.models.sonarr import (
|
||||
SonarrWebhookPayload,
|
||||
SonarrEventType,
|
||||
SonarrMapping,
|
||||
SonarrMappingTable,
|
||||
SonarrConfig,
|
||||
SonarrConfigTable,
|
||||
SonarrDownloadRequest
|
||||
)
|
||||
from app.models import DownloadRequest
|
||||
@@ -23,69 +24,150 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SonarrHandler:
|
||||
"""Handles Sonarr webhooks and manages series mappings"""
|
||||
"""Handles Sonarr webhooks and manages series mappings using SQL database"""
|
||||
|
||||
def __init__(self, config_path: str = "config/sonarr.json", mappings_path: str = "config/sonarr_mappings.json"):
|
||||
self.config_path = Path(config_path)
|
||||
self.mappings_path = Path(mappings_path)
|
||||
self.config = self._load_config()
|
||||
self.mappings = self._load_mappings()
|
||||
def __init__(self, config_path: str = None, mappings_path: str = None):
|
||||
self.download_manager = None
|
||||
|
||||
# Create config directories if they don't exist
|
||||
self.config_path.parent.mkdir(exist_ok=True)
|
||||
self.mappings_path.parent.mkdir(exist_ok=True)
|
||||
self._ensure_default_config()
|
||||
|
||||
def set_download_manager(self, download_manager):
|
||||
self.download_manager = download_manager
|
||||
|
||||
def _load_config(self) -> SonarrConfig:
|
||||
"""Load Sonarr configuration from file"""
|
||||
if self.config_path.exists():
|
||||
try:
|
||||
with open(self.config_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
return SonarrConfig(**data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load Sonarr config: {e}")
|
||||
return SonarrConfig()
|
||||
def _ensure_default_config(self):
|
||||
"""Ensure a default config exists in the database"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrConfigTable)
|
||||
if not session.exec(statement).first():
|
||||
session.add(SonarrConfigTable())
|
||||
session.commit()
|
||||
|
||||
def _save_config(self):
|
||||
try:
|
||||
temp_file = f"{self.config_path}.tmp"
|
||||
with open(temp_file, 'w') as f:
|
||||
json.dump(self.config.model_dump(mode='json'), f, indent=2)
|
||||
os.replace(temp_file, self.config_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save Sonarr config: {e}")
|
||||
raise
|
||||
def get_config(self) -> SonarrConfig:
|
||||
"""Get current configuration"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrConfigTable)
|
||||
db_config = session.exec(statement).first()
|
||||
if db_config:
|
||||
return SonarrConfig(
|
||||
webhook_enabled=db_config.webhook_enabled,
|
||||
webhook_secret=db_config.webhook_secret,
|
||||
auto_download_enabled=db_config.auto_download_enabled,
|
||||
default_language=db_config.default_language,
|
||||
default_quality=db_config.default_quality,
|
||||
default_provider=db_config.default_provider,
|
||||
verify_hmac=db_config.verify_hmac,
|
||||
log_webhooks=db_config.log_webhooks
|
||||
)
|
||||
return SonarrConfig()
|
||||
|
||||
def _load_mappings(self) -> List[SonarrMapping]:
|
||||
"""Load Sonarr to anime mappings from file"""
|
||||
if self.mappings_path.exists():
|
||||
try:
|
||||
with open(self.mappings_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
return [SonarrMapping(**item) for item in data]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load Sonarr mappings: {e}")
|
||||
return []
|
||||
def update_config(self, config: SonarrConfig) -> SonarrConfig:
|
||||
"""Update configuration"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrConfigTable)
|
||||
db_config = session.exec(statement).first()
|
||||
|
||||
if not db_config:
|
||||
db_config = SonarrConfigTable()
|
||||
|
||||
db_config.webhook_enabled = config.webhook_enabled
|
||||
db_config.webhook_secret = config.webhook_secret
|
||||
db_config.auto_download_enabled = config.auto_download_enabled
|
||||
db_config.default_language = config.default_language
|
||||
db_config.default_quality = config.default_quality
|
||||
db_config.default_provider = config.default_provider
|
||||
db_config.verify_hmac = config.verify_hmac
|
||||
db_config.log_webhooks = config.log_webhooks
|
||||
|
||||
session.add(db_config)
|
||||
session.commit()
|
||||
|
||||
logger.info("Sonarr configuration updated in database")
|
||||
return config
|
||||
|
||||
def _save_mappings(self):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.mappings_path), exist_ok=True)
|
||||
temp_file = f"{self.mappings_path}.tmp"
|
||||
with open(temp_file, 'w') as f:
|
||||
mappings_data = [m.model_dump(mode='json') for m in self.mappings]
|
||||
json.dump(mappings_data, f, indent=2)
|
||||
os.replace(temp_file, self.mappings_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save mappings: {e}")
|
||||
raise
|
||||
def _to_pydantic(self, db_mapping: SonarrMappingTable) -> SonarrMapping:
|
||||
return SonarrMapping(
|
||||
sonarr_series_id=db_mapping.sonarr_series_id,
|
||||
sonarr_title=db_mapping.sonarr_title,
|
||||
anime_provider=db_mapping.anime_provider,
|
||||
anime_url=db_mapping.anime_url,
|
||||
anime_title=db_mapping.anime_title,
|
||||
lang=db_mapping.lang,
|
||||
quality_preference=db_mapping.quality_preference,
|
||||
auto_download=db_mapping.auto_download,
|
||||
created_at=db_mapping.created_at,
|
||||
updated_at=db_mapping.updated_at
|
||||
)
|
||||
|
||||
def get_mappings(self) -> List[SonarrMapping]:
|
||||
"""Get all mappings"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrMappingTable)
|
||||
db_mappings = session.exec(statement).all()
|
||||
return [self._to_pydantic(m) for m in db_mappings]
|
||||
|
||||
def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]:
|
||||
"""Get mapping for specific series"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id)
|
||||
db_mapping = session.exec(statement).first()
|
||||
if db_mapping:
|
||||
return self._to_pydantic(db_mapping)
|
||||
return None
|
||||
|
||||
def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping:
|
||||
"""Add or update a mapping"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == mapping.sonarr_series_id)
|
||||
db_mapping = session.exec(statement).first()
|
||||
|
||||
if db_mapping:
|
||||
# Update existing
|
||||
db_mapping.sonarr_title = mapping.sonarr_title
|
||||
db_mapping.anime_provider = mapping.anime_provider
|
||||
db_mapping.anime_url = mapping.anime_url
|
||||
db_mapping.anime_title = mapping.anime_title
|
||||
db_mapping.lang = mapping.lang
|
||||
db_mapping.quality_preference = mapping.quality_preference
|
||||
db_mapping.auto_download = mapping.auto_download
|
||||
db_mapping.updated_at = datetime.now()
|
||||
logger.info(f"Updated mapping for series {mapping.sonarr_title}")
|
||||
else:
|
||||
# Create new
|
||||
db_mapping = SonarrMappingTable(
|
||||
user_id="default",
|
||||
sonarr_series_id=mapping.sonarr_series_id,
|
||||
sonarr_title=mapping.sonarr_title,
|
||||
anime_provider=mapping.anime_provider,
|
||||
anime_url=mapping.anime_url,
|
||||
anime_title=mapping.anime_title,
|
||||
lang=mapping.lang,
|
||||
quality_preference=mapping.quality_preference,
|
||||
auto_download=mapping.auto_download,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
logger.info(f"Added mapping for series {mapping.sonarr_title}")
|
||||
|
||||
session.add(db_mapping)
|
||||
session.commit()
|
||||
session.refresh(db_mapping)
|
||||
return self._to_pydantic(db_mapping)
|
||||
|
||||
def delete_mapping(self, sonarr_series_id: int) -> bool:
|
||||
"""Delete a mapping"""
|
||||
with Session(engine) as session:
|
||||
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id)
|
||||
db_mapping = session.exec(statement).first()
|
||||
if db_mapping:
|
||||
session.delete(db_mapping)
|
||||
session.commit()
|
||||
logger.info(f"Deleted mapping for series ID {sonarr_series_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def verify_hmac(self, payload: bytes, signature: str) -> bool:
|
||||
"""Verify HMAC SHA256 signature"""
|
||||
if not self.config.verify_hmac or not self.config.webhook_secret:
|
||||
config = self.get_config()
|
||||
if not config.verify_hmac or not config.webhook_secret:
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -94,7 +176,7 @@ class SonarrHandler:
|
||||
signature = signature[7:]
|
||||
|
||||
computed_hmac = hmac.new(
|
||||
self.config.webhook_secret.encode(),
|
||||
config.webhook_secret.encode(),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
@@ -104,57 +186,6 @@ class SonarrHandler:
|
||||
logger.error(f"HMAC verification failed: {e}")
|
||||
return False
|
||||
|
||||
def get_config(self) -> SonarrConfig:
|
||||
"""Get current configuration"""
|
||||
return self.config
|
||||
|
||||
def update_config(self, config: SonarrConfig) -> SonarrConfig:
|
||||
"""Update configuration"""
|
||||
self.config = config
|
||||
self._save_config()
|
||||
logger.info("Sonarr configuration updated")
|
||||
return self.config
|
||||
|
||||
def get_mappings(self) -> List[SonarrMapping]:
|
||||
"""Get all mappings"""
|
||||
return self.mappings
|
||||
|
||||
def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]:
|
||||
"""Get mapping for specific series"""
|
||||
for mapping in self.mappings:
|
||||
if mapping.sonarr_series_id == sonarr_series_id:
|
||||
return mapping
|
||||
return None
|
||||
|
||||
def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping:
|
||||
"""Add or update a mapping"""
|
||||
# Check if mapping already exists
|
||||
for i, existing in enumerate(self.mappings):
|
||||
if existing.sonarr_series_id == mapping.sonarr_series_id:
|
||||
mapping.updated_at = datetime.now()
|
||||
self.mappings[i] = mapping
|
||||
self._save_mappings()
|
||||
logger.info(f"Updated mapping for series {mapping.sonarr_title}")
|
||||
return mapping
|
||||
|
||||
# Add new mapping
|
||||
mapping.created_at = datetime.now()
|
||||
mapping.updated_at = datetime.now()
|
||||
self.mappings.append(mapping)
|
||||
self._save_mappings()
|
||||
logger.info(f"Added mapping for series {mapping.sonarr_title}")
|
||||
return mapping
|
||||
|
||||
def delete_mapping(self, sonarr_series_id: int) -> bool:
|
||||
"""Delete a mapping"""
|
||||
for i, mapping in enumerate(self.mappings):
|
||||
if mapping.sonarr_series_id == sonarr_series_id:
|
||||
del self.mappings[i]
|
||||
self._save_mappings()
|
||||
logger.info(f"Deleted mapping for series ID {sonarr_series_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
async def search_anime_by_title(self, title: str, provider: str = "anime-sama", lang: str = "vostfr") -> List[Dict]:
|
||||
"""Search for anime by title using specified provider"""
|
||||
try:
|
||||
@@ -197,15 +228,16 @@ class SonarrHandler:
|
||||
|
||||
async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]:
|
||||
"""Process Sonarr webhook payload"""
|
||||
if not self.config.webhook_enabled:
|
||||
config = self.get_config()
|
||||
if not config.webhook_enabled:
|
||||
return {"status": "ignored", "reason": "Webhook not enabled"}
|
||||
|
||||
if self.config.log_webhooks:
|
||||
if config.log_webhooks:
|
||||
logger.info(f"Received Sonarr webhook: {payload.eventType.value}")
|
||||
|
||||
# Handle different event types
|
||||
if payload.eventType == SonarrEventType.GRAB:
|
||||
return await self._handle_grab(payload)
|
||||
return await self._handle_grab(payload, config)
|
||||
elif payload.eventType == SonarrEventType.DOWNLOAD:
|
||||
return await self._handle_download(payload)
|
||||
elif payload.eventType == SonarrEventType.RENAME:
|
||||
@@ -217,9 +249,9 @@ class SonarrHandler:
|
||||
else:
|
||||
return {"status": "ignored", "reason": f"Unhandled event type: {payload.eventType}"}
|
||||
|
||||
async def _handle_grab(self, payload: SonarrWebhookPayload) -> Dict:
|
||||
async def _handle_grab(self, payload: SonarrWebhookPayload, config: SonarrConfig) -> Dict:
|
||||
"""Handle Grab event (when Sonarr downloads a release)"""
|
||||
if not self.config.auto_download_enabled:
|
||||
if not config.auto_download_enabled:
|
||||
return {"status": "ignored", "reason": "Auto-download disabled"}
|
||||
|
||||
if not payload.series or not payload.episodes:
|
||||
|
||||
Reference in New Issue
Block a user