Phase 2 Complete: SQL migration with SQLModel and Alembic
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

This commit is contained in:
root
2026-03-25 13:46:15 +00:00
parent 96b12b66e2
commit a684237725
21 changed files with 1148 additions and 466 deletions
+145 -113
View File
@@ -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: