Files
ohm_streaming/app/routers/router_sonarr.py
T
root 5d264d8f3b fix: sécuriser watchlist, favorites, downloads et recommendations sans auth (#15)
- router_favorites.py: toutes les routes requièrent maintenant l'auth
  - GET utilise get_optional_user + login_prompt.html pour HTMX
  - POST/DELETE/toggle requièrent get_current_user_from_token
  - Filtrage par user_id dans toutes les requêtes favorites
- router_downloads.py: GET list et GET status protégés (401 sans token)
- router_recommendations.py: GET protégé (login_prompt HTMX, 401 JSON)
- router_sonarr.py: tous les endpoints de gestion protégés
  - Webhooks restent publics (reçus de Sonarr)
- app/favorites.py: ajout du paramètre user_id à toutes les méthodes

Closes #15
2026-04-02 22:20:29 +00:00

274 lines
9.3 KiB
Python

"""
Sonarr integration routes for Ohm Stream Downloader API.
"""
import logging
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi.requests import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.models import DownloadRequest
from app.models.auth import User
from app.models.sonarr import SonarrConfig, SonarrDownloadRequest, SonarrMapping
from app.routers.router_auth import get_current_user_from_token
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["sonarr"])
def get_sonarr_handler():
from app.sonarr_handler import get_sonarr_handler
return get_sonarr_handler()
@router.post("/webhook/sonarr")
async def sonarr_webhook(request: Request):
"""Receive and process Sonarr webhook events"""
from app.models.sonarr import SonarrWebhookPayload
sonarr_handler = get_sonarr_handler()
body = await request.body()
signature = request.headers.get("X-Sonarr-Event", "")
if not sonarr_handler.verify_hmac(body, signature):
logger.warning("Invalid HMAC signature for Sonarr webhook")
raise HTTPException(status_code=403, detail="Invalid signature")
try:
payload_data = await request.json()
payload = SonarrWebhookPayload(**payload_data)
result = await sonarr_handler.process_webhook(payload)
return JSONResponse(content=result, status_code=200)
except Exception as e:
logger.error(f"Error processing Sonarr webhook: {e}", exc_info=True)
raise HTTPException(status_code=422, detail=f"Invalid payload: {str(e)}")
@router.post("/webhook/test/sonarr")
async def test_sonarr_webhook(request: Request):
"""Test endpoint for Sonarr webhook configuration"""
try:
payload = await request.json()
logger.info(
f"Received test Sonarr webhook: {payload.get('eventType', 'unknown')}"
)
return {
"status": "ok",
"message": "Test webhook received successfully",
"received_payload": payload,
}
except Exception as e:
logger.error(f"Error in test webhook: {e}")
return {"status": "error", "message": str(e)}
@router.get("/sonarr/config")
async def get_sonarr_config(
current_user: User = Depends(get_current_user_from_token),
):
"""Get Sonarr webhook configuration"""
sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_config()
@router.put("/sonarr/config")
async def update_sonarr_config(
config: SonarrConfig,
current_user: User = Depends(get_current_user_from_token),
):
"""Update Sonarr webhook configuration"""
sonarr_handler = get_sonarr_handler()
try:
updated_config = sonarr_handler.update_config(config)
return {"status": "success", "config": updated_config}
except Exception as e:
logger.error(f"Error updating Sonarr config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sonarr/mappings")
async def get_sonarr_mappings(
current_user: User = Depends(get_current_user_from_token),
):
"""Get all Sonarr to anime mappings"""
sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_mappings()
@router.get("/sonarr/mappings/{series_id}")
async def get_sonarr_mapping(
series_id: int,
current_user: User = Depends(get_current_user_from_token),
):
"""Get specific mapping by Sonarr series ID"""
sonarr_handler = get_sonarr_handler()
mapping = sonarr_handler.get_mapping(series_id)
if not mapping:
raise HTTPException(status_code=404, detail="Mapping not found")
return mapping
@router.post("/sonarr/mappings")
async def create_sonarr_mapping(
mapping: SonarrMapping,
current_user: User = Depends(get_current_user_from_token),
):
"""Create or update a Sonarr to anime mapping"""
sonarr_handler = get_sonarr_handler()
try:
mapping = sonarr_handler.add_mapping(mapping)
return {"status": "success", "mapping": mapping}
except Exception as e:
logger.error(f"Error creating mapping: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/sonarr/mappings/{series_id}")
async def delete_sonarr_mapping(
series_id: int,
current_user: User = Depends(get_current_user_from_token),
):
"""Delete a Sonarr mapping"""
sonarr_handler = get_sonarr_handler()
success = sonarr_handler.delete_mapping(series_id)
if not success:
raise HTTPException(status_code=404, detail="Mapping not found")
return {"status": "success", "message": f"Mapping for series {series_id} deleted"}
@router.get("/sonarr/search")
async def search_anime_for_sonarr(
q: str = Query(..., description="Series title to search"),
provider: str = Query("anime-sama", description="Anime provider to search"),
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
current_user: User = Depends(get_current_user_from_token),
):
"""Search for anime on providers to create Sonarr mappings"""
sonarr_handler = get_sonarr_handler()
try:
results = await sonarr_handler.search_anime_by_title(q, provider, lang)
return {
"status": "success",
"query": q,
"provider": provider,
"lang": lang,
"results": results,
}
except Exception as e:
logger.error(f"Error searching anime: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sonarr/episodes")
async def get_anime_episodes(
url: str = Query(..., description="Anime URL from provider"),
provider: str = Query("anime-sama", description="Anime provider"),
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
current_user: User = Depends(get_current_user_from_token),
):
"""Get episode list for anime"""
sonarr_handler = get_sonarr_handler()
try:
episodes = await sonarr_handler.get_episodes_for_anime(url, provider, lang)
return {
"status": "success",
"url": url,
"provider": provider,
"lang": lang,
"episodes": episodes,
}
except Exception as e:
logger.error(f"Error getting episodes: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sonarr/suggest")
async def suggest_anime_mapping(
sonarr_title: str = Query(..., description="Sonarr series title"),
provider: str = Query("anime-sama", description="Anime provider"),
lang: str = Query("vostfr", description="Language"),
current_user: User = Depends(get_current_user_from_token),
):
"""Suggest possible anime mappings based on Sonarr series title"""
sonarr_handler = get_sonarr_handler()
try:
suggestions = await sonarr_handler.suggest_mapping(sonarr_title, provider, lang)
return {
"status": "success",
"sonarr_title": sonarr_title,
"provider": provider,
"lang": lang,
"suggestions": suggestions,
}
except Exception as e:
logger.error(f"Error getting suggestions: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sonarr/download")
async def trigger_sonarr_download(
request: SonarrDownloadRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user_from_token),
):
"""Manually trigger a download based on Sonarr information"""
from main import download_manager
sonarr_handler = get_sonarr_handler()
mapping = sonarr_handler.get_mapping(request.sonarr_series_id)
if not mapping:
raise HTTPException(
status_code=404,
detail=f"No mapping found for series {request.sonarr_series_id}. Create a mapping first.",
)
try:
episodes = await sonarr_handler.get_episodes_for_anime(
mapping.anime_url,
request.provider or mapping.anime_provider,
request.lang or mapping.lang,
)
target_episode = None
for ep in episodes:
ep_num = ep.get("episode", 0)
season_num = ep.get("season", 1)
if ep_num == request.episode_number and season_num == request.season_number:
target_episode = ep
break
if not target_episode:
raise HTTPException(
status_code=404,
detail=f"Episode S{request.season_number}E{request.episode_number} not found",
)
episode_url = target_episode.get("url")
if not episode_url:
raise HTTPException(status_code=400, detail="Episode URL not found")
download_request = DownloadRequest(
url=episode_url,
filename=f"{mapping.anime_title} - S{request.season_number}E{request.episode_number}.mp4",
)
task = download_manager.create_task(download_request)
background_tasks.add_task(download_manager.start_download, task.id)
return {
"status": "success",
"task_id": task.id,
"message": f"Download started for {mapping.anime_title} S{request.season_number}E{request.episode_number}",
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error triggering download: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))