c1c31d7685
Major improvements: - Series TV support via FS7 provider with dedicated search endpoint - Vidzy downloader now uses Playwright for JS obfuscation and ffmpeg for HLS streams - Episode filenames properly named (Series Title - Episode X) instead of master.m3u8.mp4 - Duplicate download prevention: checks existing tasks before creating new ones - Removed host preference system in favor of intelligent URL-based detection Technical changes: - Vidzy: Added Playwright extraction and M3U8→MP4 conversion with ffmpeg - FS7: Episodes now use pipe format (video_url|series_url|episode_title) - DownloadManager: Extract target_filename from pipe URL and prevent duplicates - UI: New Series tab with search, recommendations, and releases sections - Anime-Sama: Removed hardcoded host preferences, uses site's URL order Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1574 lines
50 KiB
Python
1574 lines
50 KiB
Python
from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException, Query, Request
|
|
from fastapi.responses import StreamingResponse, FileResponse, JSONResponse, Response
|
|
from fastapi.responses import HTMLResponse
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
import uvicorn
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List
|
|
import shutil
|
|
import os
|
|
import re
|
|
from datetime import datetime
|
|
from urllib.parse import quote
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
from app.models import DownloadRequest, DownloadTask, DownloadStatus
|
|
from app.download_manager import DownloadManager
|
|
from app.downloaders import AnimeSamaDownloader
|
|
from app import providers
|
|
from app.favorites import get_favorites_manager
|
|
from app.recommendations import get_latest_releases_with_info
|
|
from app.recommendation_engine import RecommendationEngine
|
|
from app.sonarr_handler import get_sonarr_handler
|
|
from app.models.sonarr import (
|
|
SonarrWebhookPayload,
|
|
SonarrConfig,
|
|
SonarrMapping,
|
|
SonarrDownloadRequest
|
|
)
|
|
from app.utils import sanitize_filename, is_safe_filename
|
|
|
|
app = FastAPI(title="Ohm Stream Downloader")
|
|
|
|
# Configure CORS
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"http://192.168.1.204:3000",
|
|
"http://192.168.1.204" # Sans port spécifié
|
|
],
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Initialize download manager
|
|
download_manager = DownloadManager(download_dir="downloads", max_parallel=3)
|
|
|
|
|
|
def restore_completed_downloads():
|
|
"""Scan downloads directory and restore completed download tasks"""
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
import uuid
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
download_dir = Path("downloads")
|
|
if not download_dir.exists():
|
|
return
|
|
|
|
# Get all video files (exclude partial files and logs)
|
|
video_extensions = {'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm'}
|
|
|
|
for file_path in download_dir.iterdir():
|
|
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
|
|
# Skip small files (likely partial or errors)
|
|
if file_path.stat().st_size < 1024 * 1024: # Less than 1MB
|
|
continue
|
|
|
|
filename = file_path.name
|
|
file_size = file_path.stat().st_size
|
|
|
|
# Create a task for this file
|
|
task_id = str(uuid.uuid4())
|
|
task = DownloadTask(
|
|
id=task_id,
|
|
url="", # No original URL
|
|
filename=filename,
|
|
host="other",
|
|
status=DownloadStatus.COMPLETED,
|
|
progress=100.0,
|
|
downloaded_bytes=file_size,
|
|
total_bytes=file_size,
|
|
speed=0.0,
|
|
file_path=str(file_path),
|
|
created_at=datetime.fromtimestamp(file_path.stat().st_ctime),
|
|
completed_at=datetime.fromtimestamp(file_path.stat().st_mtime)
|
|
)
|
|
|
|
download_manager.tasks[task_id] = task
|
|
logger.info(f"Restored completed download: {filename}")
|
|
|
|
|
|
# Restore completed downloads on startup
|
|
restore_completed_downloads()
|
|
|
|
# Mount static files and templates
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
app.mount("/downloads", StaticFiles(directory="downloads"), name="downloads")
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
return {
|
|
"message": "Ohm Stream Downloader API",
|
|
"status": "running",
|
|
"version": "2.2",
|
|
"endpoints": {
|
|
"POST /api/download": "Start a new download",
|
|
"GET /api/downloads": "List all downloads",
|
|
"GET /api/download/{task_id}": "Get download status",
|
|
"POST /api/download/{task_id}/pause": "Pause a download",
|
|
"POST /api/download/{task_id}/resume": "Resume a download",
|
|
"DELETE /api/download/{task_id}": "Cancel a download",
|
|
"GET /api/providers": "List all supported providers",
|
|
"GET /api/anime/search": "Search anime across all providers",
|
|
"GET /api/anime/metadata": "Get detailed anime metadata (synopsis, genres, rating, etc.)",
|
|
"GET /api/anime/episodes": "Get episode list for an anime",
|
|
"POST /api/anime/download-season": "Download all episodes of a season",
|
|
"GET /api/favorites": "List all favorite anime",
|
|
"POST /api/favorites": "Add anime to favorites",
|
|
"DELETE /api/favorites/{anime_id}": "Remove from favorites",
|
|
"GET /api/favorites/{anime_id}": "Get favorite anime details",
|
|
"GET /api/favorites/stats": "Get favorites statistics",
|
|
"POST /api/favorites/toggle": "Toggle anime in favorites",
|
|
"GET /web": "Web interface"
|
|
}
|
|
}
|
|
|
|
|
|
@app.get("/api/providers")
|
|
async def list_providers():
|
|
"""List all supported anime, series and file hosting providers"""
|
|
return {
|
|
"anime_providers": providers.get_anime_providers(),
|
|
"series_providers": providers.get_series_providers(),
|
|
"file_hosts": providers.get_file_hosts()
|
|
}
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "healthy"}
|
|
|
|
|
|
# Web Interface
|
|
@app.get("/web")
|
|
async def web_interface(request: Request):
|
|
return templates.TemplateResponse("index.html", {"request": request})
|
|
|
|
|
|
# API Endpoints
|
|
@app.post("/api/download")
|
|
async def create_download(request: DownloadRequest, background_tasks: BackgroundTasks):
|
|
"""Create a new download task"""
|
|
# Sanitize filename if provided
|
|
if request.filename:
|
|
request.filename = sanitize_filename(request.filename)
|
|
|
|
# Safety check
|
|
if not is_safe_filename(request.filename):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid filename. Path traversal attempts are not allowed."
|
|
)
|
|
|
|
task = download_manager.create_task(request)
|
|
background_tasks.add_task(download_manager.start_download, task.id)
|
|
return {"task_id": task.id, "task": task}
|
|
|
|
|
|
@app.get("/api/downloads")
|
|
async def list_downloads():
|
|
"""List all download tasks"""
|
|
return {"downloads": download_manager.get_all_tasks()}
|
|
|
|
|
|
@app.get("/api/download/{task_id}")
|
|
async def get_download_status(task_id: str):
|
|
"""Get status of a specific download"""
|
|
task = download_manager.get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
return task
|
|
|
|
|
|
@app.post("/api/download/{task_id}/pause")
|
|
async def pause_download(task_id: str):
|
|
"""Pause a download"""
|
|
task = download_manager.get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
await download_manager.pause_download(task_id)
|
|
return {"status": "paused"}
|
|
|
|
|
|
@app.post("/api/download/{task_id}/resume")
|
|
async def resume_download(task_id: str, background_tasks: BackgroundTasks):
|
|
"""Resume a paused download"""
|
|
task = download_manager.get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
if task.status == DownloadStatus.PAUSED:
|
|
background_tasks.add_task(download_manager.start_download, task_id)
|
|
return {"status": "resumed"}
|
|
|
|
return {"status": "already running or completed"}
|
|
|
|
|
|
@app.delete("/api/download/{task_id}")
|
|
async def delete_download(task_id: str):
|
|
"""Delete/cancel a download (removes it from the list)"""
|
|
task = download_manager.get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
await download_manager.delete_task(task_id)
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@app.get("/api/download/{task_id}/file")
|
|
async def download_file(task_id: str):
|
|
"""Download the completed file"""
|
|
task = download_manager.get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
if task.status != DownloadStatus.COMPLETED:
|
|
raise HTTPException(status_code=400, detail="Download not completed")
|
|
|
|
if not task.file_path or not os.path.exists(task.file_path):
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
return FileResponse(
|
|
task.file_path,
|
|
filename=task.filename,
|
|
media_type='application/octet-stream'
|
|
)
|
|
|
|
|
|
# Unified Anime Search endpoints
|
|
@app.get("/api/anime/search")
|
|
async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: bool = False):
|
|
"""
|
|
Search across all anime providers
|
|
|
|
Args:
|
|
q: Search query
|
|
lang: Language preference (vostfr, vf)
|
|
include_metadata: Whether to fetch full metadata (slower but more detailed)
|
|
"""
|
|
import time
|
|
import asyncio
|
|
from app.providers import get_anime_providers, get_series_providers
|
|
from app.downloaders import AnimeSamaDownloader, AnimeUltimeDownloader, NekoSamaDownloader, VostfreeDownloader
|
|
from app.downloaders.series_sites import FS7Downloader
|
|
|
|
print(f"\n[SEARCH] Starting search for '{q}' in {lang} (metadata={include_metadata})")
|
|
start_time = time.time()
|
|
|
|
results = {}
|
|
|
|
# Create downloader instances
|
|
downloaders = {
|
|
"anime-sama": AnimeSamaDownloader(),
|
|
"anime-ultime": AnimeUltimeDownloader(),
|
|
"neko-sama": NekoSamaDownloader(),
|
|
"vostfree": VostfreeDownloader()
|
|
}
|
|
|
|
# Create series downloader instances
|
|
series_downloaders = {
|
|
"fs7": FS7Downloader()
|
|
}
|
|
|
|
# Search across all anime providers in parallel with timeout
|
|
search_tasks = []
|
|
provider_ids = []
|
|
|
|
for provider_id, provider in get_anime_providers().items():
|
|
if provider_id in downloaders:
|
|
downloader = downloaders[provider_id]
|
|
print(f"[SEARCH] Queueing search on {provider_id}...")
|
|
search_tasks.append(downloader.search_anime(q, lang, include_metadata=include_metadata))
|
|
provider_ids.append(provider_id)
|
|
|
|
# Search across all series providers in parallel with timeout
|
|
for provider_id, provider in get_series_providers().items():
|
|
if provider_id in series_downloaders:
|
|
downloader = series_downloaders[provider_id]
|
|
print(f"[SEARCH] Queueing search on {provider_id} (series)...")
|
|
search_tasks.append(downloader.search_anime(q, lang))
|
|
provider_ids.append(provider_id)
|
|
|
|
# Wait for all searches to complete with a timeout per provider
|
|
print(f"[SEARCH] Waiting for {len(search_tasks)} searches...")
|
|
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
|
|
|
# Combine results
|
|
for provider_id, result in zip(provider_ids, search_results):
|
|
if isinstance(result, Exception):
|
|
print(f"[SEARCH] {provider_id} error: {str(result)}")
|
|
elif result:
|
|
print(f"[SEARCH] {provider_id} found {len(result)} results")
|
|
results[provider_id] = result
|
|
else:
|
|
print(f"[SEARCH] {provider_id} no results")
|
|
|
|
elapsed = time.time() - start_time
|
|
print(f"[SEARCH] Completed in {elapsed:.2f}s - Total results: {sum(len(r) for r in results.values())}\n")
|
|
|
|
return {
|
|
"query": q,
|
|
"lang": lang,
|
|
"include_metadata": include_metadata,
|
|
"results": results
|
|
}
|
|
|
|
|
|
@app.get("/api/series/search")
|
|
async def search_series_unified(q: str, lang: str = "vf"):
|
|
"""
|
|
Search across all TV series providers (FS7, etc.)
|
|
|
|
Args:
|
|
q: Search query
|
|
lang: Language preference (vf, vostfr)
|
|
"""
|
|
import time
|
|
import asyncio
|
|
from app.providers import get_series_providers
|
|
from app.downloaders.series_sites import FS7Downloader
|
|
|
|
print(f"\n[SERIES SEARCH] Starting search for '{q}' in {lang}")
|
|
start_time = time.time()
|
|
|
|
results = {}
|
|
|
|
# Create series downloader instances
|
|
series_downloaders = {
|
|
"fs7": FS7Downloader()
|
|
}
|
|
|
|
# Search across all series providers in parallel
|
|
search_tasks = []
|
|
provider_ids = []
|
|
|
|
for provider_id, provider in get_series_providers().items():
|
|
if provider_id in series_downloaders:
|
|
downloader = series_downloaders[provider_id]
|
|
print(f"[SERIES SEARCH] Queueing search on {provider_id}...")
|
|
search_tasks.append(downloader.search_anime(q, lang))
|
|
provider_ids.append(provider_id)
|
|
|
|
# Wait for all searches to complete with a timeout per provider
|
|
print(f"[SERIES SEARCH] Waiting for {len(search_tasks)} searches...")
|
|
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
|
|
|
# Combine results
|
|
for provider_id, result in zip(provider_ids, search_results):
|
|
if isinstance(result, Exception):
|
|
print(f"[SERIES SEARCH] {provider_id} error: {str(result)}")
|
|
elif result:
|
|
print(f"[SERIES SEARCH] {provider_id} found {len(result)} results")
|
|
results[provider_id] = result
|
|
else:
|
|
print(f"[SERIES SEARCH] {provider_id} no results")
|
|
|
|
elapsed = time.time() - start_time
|
|
print(f"[SERIES SEARCH] Completed in {elapsed:.2f}s - Total results: {sum(len(r) for r in results.values())}\n")
|
|
|
|
return {
|
|
"query": q,
|
|
"lang": lang,
|
|
"results": results
|
|
}
|
|
|
|
|
|
@app.get("/api/anime/metadata")
|
|
async def get_anime_metadata(url: str):
|
|
"""
|
|
Get detailed metadata for a specific anime
|
|
|
|
Args:
|
|
url: The anime page URL
|
|
"""
|
|
from app.downloaders import get_downloader
|
|
|
|
try:
|
|
downloader = get_downloader(url)
|
|
|
|
# Check if the downloader has metadata support
|
|
if hasattr(downloader, 'get_anime_metadata'):
|
|
metadata = await downloader.get_anime_metadata(url)
|
|
return {
|
|
"url": url,
|
|
"metadata": metadata
|
|
}
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Downloader for {url} does not support metadata extraction"
|
|
)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/anime/episodes")
|
|
async def get_anime_episodes(url: str, lang: str = "vostfr"):
|
|
"""Get list of episodes for an anime"""
|
|
from app.downloaders import get_downloader
|
|
|
|
downloader = get_downloader(url)
|
|
episodes = await downloader.get_episodes(url, lang)
|
|
|
|
return {
|
|
"url": url,
|
|
"lang": lang,
|
|
"episodes": episodes
|
|
}
|
|
|
|
|
|
@app.get("/api/anime/providers")
|
|
async def get_anime_providers_list():
|
|
"""Get list of anime providers with info"""
|
|
from app.providers import get_anime_providers
|
|
return {"providers": get_anime_providers()}
|
|
|
|
|
|
# Anime-Sama specific endpoints (legacy)
|
|
@app.get("/api/anime-sama/search")
|
|
async def search_anime_sama(q: str, lang: str = "vostfr"):
|
|
"""Search for anime on anime-sama"""
|
|
downloader = AnimeSamaDownloader()
|
|
results = await downloader.search_anime(q, lang)
|
|
return {"query": q, "lang": lang, "results": results}
|
|
|
|
|
|
@app.post("/api/anime/download")
|
|
async def download_anime_episode(
|
|
url: str,
|
|
background_tasks: BackgroundTasks,
|
|
episode: str | None = None
|
|
):
|
|
"""Download an anime episode"""
|
|
# Only construct episode URL if it's not already in the pipe-separated format
|
|
# The pipe format (video_url|anime_page_url|episode_title) is already complete
|
|
if episode and 'episode-' not in url and '|' not in url:
|
|
url = f"{url.rstrip('/')}/episode-{episode}"
|
|
|
|
request = DownloadRequest(url=url)
|
|
task = download_manager.create_task(request)
|
|
background_tasks.add_task(download_manager.start_download, task.id)
|
|
return {"task_id": task.id, "task": task}
|
|
|
|
|
|
@app.post("/api/download/direct")
|
|
async def direct_download(
|
|
url: str,
|
|
filename: str,
|
|
background_tasks: BackgroundTasks
|
|
):
|
|
"""Download directly from a video URL with custom filename"""
|
|
request = DownloadRequest(url=url, filename=filename)
|
|
task = download_manager.create_task(request)
|
|
background_tasks.add_task(download_manager.start_download, task.id)
|
|
return {"task_id": task.id, "task": task}
|
|
|
|
|
|
@app.get("/api/anime/frieren/episodes")
|
|
async def get_frieren_episodes():
|
|
"""Get Frieren episodes from local database"""
|
|
import json
|
|
try:
|
|
with open('app/frieren_episodes.json', 'r') as f:
|
|
data = json.load(f)
|
|
return data
|
|
except Exception as e:
|
|
raise HTTPException(status_code=404, detail=f"Episodes not found: {e}")
|
|
|
|
|
|
@app.post("/api/anime/frieren/download")
|
|
async def download_frieren_episode(
|
|
season: int,
|
|
episode: str,
|
|
background_tasks: BackgroundTasks
|
|
):
|
|
"""Download Frieren episode from local database"""
|
|
import json
|
|
try:
|
|
with open('app/frieren_episodes.json', 'r') as f:
|
|
data = json.load(f)
|
|
|
|
season_key = str(season)
|
|
if season_key not in data['seasons']:
|
|
raise HTTPException(status_code=404, detail=f"Season {season} not found")
|
|
|
|
season_data = data['seasons'][season_key]
|
|
ep_data = next((ep for ep in season_data['episodes'] if ep['episode'] == episode), None)
|
|
|
|
if not ep_data:
|
|
raise HTTPException(status_code=404, detail=f"Episode {episode} not found in season {season}")
|
|
|
|
url = ep_data['sibnet_url']
|
|
filename = f"Frieren - S{season} - Episode {episode}.mp4"
|
|
|
|
request = DownloadRequest(url=url, filename=filename)
|
|
task = download_manager.create_task(request)
|
|
background_tasks.add_task(download_manager.start_download, task.id)
|
|
|
|
return {"task_id": task.id, "task": task}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
|
|
|
|
|
@app.post("/api/anime/download-season")
|
|
async def download_anime_season(
|
|
url: str,
|
|
background_tasks: BackgroundTasks,
|
|
lang: str = "vostfr"
|
|
):
|
|
"""Download all episodes of an anime season"""
|
|
from app.downloaders import get_downloader
|
|
|
|
downloader = get_downloader(url)
|
|
episodes = await downloader.get_episodes(url, lang)
|
|
|
|
if not episodes:
|
|
raise HTTPException(status_code=404, detail="No episodes found")
|
|
|
|
# Create download tasks for all episodes
|
|
task_ids = []
|
|
for episode in episodes:
|
|
request = DownloadRequest(url=episode['url'])
|
|
task = download_manager.create_task(request)
|
|
task_ids.append(task.id)
|
|
background_tasks.add_task(download_manager.start_download, task.id)
|
|
|
|
return {
|
|
"message": f"Started downloading {len(task_ids)} episodes",
|
|
"task_ids": task_ids,
|
|
"total_episodes": len(episodes)
|
|
}
|
|
|
|
|
|
@app.get("/api/anime/seasons")
|
|
async def get_anime_seasons(url: str):
|
|
"""
|
|
Get list of seasons for an anime
|
|
Returns seasons with their URLs and episode counts
|
|
"""
|
|
from app.downloaders import get_downloader
|
|
|
|
downloader = get_downloader(url)
|
|
|
|
# Check if it's an AnimeSamaDownloader
|
|
if hasattr(downloader, 'get_seasons'):
|
|
seasons = await downloader.get_seasons(url)
|
|
|
|
if not seasons:
|
|
return {"seasons": [], "message": "No seasons found"}
|
|
|
|
return {"seasons": seasons}
|
|
else:
|
|
# If not AnimeSama, return empty
|
|
return {"seasons": [], "message": "Season information not available for this provider"}
|
|
|
|
|
|
|
|
# ========== Recommendations & Latest Releases ==========
|
|
|
|
@app.get("/api/recommendations")
|
|
async def get_recommendations(limit: int = 15):
|
|
"""
|
|
Get personalized anime recommendations based on download history
|
|
|
|
Analyzes user's downloads and suggests similar anime
|
|
"""
|
|
engine = RecommendationEngine(download_dir="downloads")
|
|
|
|
try:
|
|
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
|
|
|
return {
|
|
"recommendations": recommendations,
|
|
"count": len(recommendations)
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
await engine.close()
|
|
|
|
|
|
@app.get("/api/releases/latest")
|
|
async def get_latest_releases(limit: int = 20):
|
|
"""
|
|
Get latest anime releases
|
|
|
|
Returns current season anime and weekly schedule
|
|
"""
|
|
try:
|
|
releases = await get_latest_releases_with_info(limit=limit)
|
|
|
|
return {
|
|
"releases": releases,
|
|
"count": len(releases),
|
|
"updated": datetime.now().isoformat()
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/releases/seasonal")
|
|
async def get_seasonal_anime(year: int = None, season: str = None):
|
|
"""
|
|
Get current/previously seasonal anime
|
|
|
|
Args:
|
|
year: Year (defaults to current year)
|
|
season: Season (winter, spring, summer, fall)
|
|
"""
|
|
from app.recommendations import AnimeReleasesFetcher
|
|
|
|
fetcher = AnimeReleasesFetcher()
|
|
|
|
try:
|
|
anime = await fetcher.get_seasonal_anime(year, season)
|
|
|
|
return {
|
|
"anime": anime,
|
|
"count": len(anime),
|
|
"year": year or datetime.now().year,
|
|
"season": season or "current"
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
await fetcher.close()
|
|
|
|
|
|
@app.get("/api/releases/scheduled")
|
|
async def get_scheduled_anime(day: str = None):
|
|
"""
|
|
Get anime scheduled for a specific day
|
|
|
|
Args:
|
|
day: Day of the week (monday, tuesday, etc.) or None for today
|
|
"""
|
|
from app.recommendations import AnimeReleasesFetcher
|
|
|
|
fetcher = AnimeReleasesFetcher()
|
|
|
|
try:
|
|
anime = await fetcher.get_scheduled_anime(day)
|
|
|
|
return {
|
|
"anime": anime,
|
|
"count": len(anime),
|
|
"day": day or "today"
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
await fetcher.close()
|
|
|
|
|
|
@app.get("/api/releases/top")
|
|
async def get_top_anime(type: str = "tv", limit: int = 15):
|
|
"""
|
|
Get top rated anime
|
|
|
|
Args:
|
|
type: Type of anime (tv, movie, etc.)
|
|
limit: Number of results
|
|
"""
|
|
from app.recommendations import AnimeReleasesFetcher
|
|
|
|
fetcher = AnimeReleasesFetcher()
|
|
|
|
try:
|
|
anime = await fetcher.get_top_anime(type=type, limit=limit)
|
|
|
|
return {
|
|
"anime": anime,
|
|
"count": len(anime)
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
await fetcher.close()
|
|
|
|
|
|
@app.get("/api/stats/downloads")
|
|
async def get_download_statistics():
|
|
"""
|
|
Get download statistics and preferences
|
|
|
|
Returns genre distribution, recent downloads, etc.
|
|
"""
|
|
engine = RecommendationEngine(download_dir="downloads")
|
|
|
|
try:
|
|
stats = await engine.get_download_stats()
|
|
|
|
return stats
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
await engine.close()
|
|
|
|
|
|
# Video Streaming endpoints
|
|
@app.get("/video/{task_id}")
|
|
async def stream_video(task_id: str, request: Request):
|
|
"""Stream a video file with Range support for seeking"""
|
|
task = download_manager.get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
if task.status != DownloadStatus.COMPLETED:
|
|
raise HTTPException(status_code=400, detail="Download not completed")
|
|
|
|
if not task.file_path or not os.path.exists(task.file_path):
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
file_path = Path(task.file_path)
|
|
file_size = file_path.stat().st_size
|
|
|
|
# Parse Range header
|
|
range_header = request.headers.get("range")
|
|
headers = {
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Type": "video/mp4",
|
|
}
|
|
|
|
if range_header:
|
|
# Parse Range header (format: bytes=start-end)
|
|
try:
|
|
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
|
|
start = int(range_match.group(1))
|
|
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
|
|
|
|
# Validate range
|
|
if start >= file_size or end >= file_size or start > end:
|
|
headers["Content-Range"] = f"bytes */{file_size}"
|
|
return Response(
|
|
status_code=416,
|
|
headers=headers,
|
|
content="Requested Range Not Satisfiable"
|
|
)
|
|
|
|
# Read the requested range
|
|
content_length = end - start + 1
|
|
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
|
headers["Content-Length"] = str(content_length)
|
|
|
|
async def video_range_reader():
|
|
with open(file_path, 'rb') as f:
|
|
f.seek(start)
|
|
remaining = content_length
|
|
while remaining > 0:
|
|
chunk_size = min(1024 * 1024, remaining) # 1MB chunks
|
|
data = f.read(chunk_size)
|
|
if not data:
|
|
break
|
|
remaining -= len(data)
|
|
yield data
|
|
|
|
return Response(
|
|
content=video_range_reader(),
|
|
status_code=206,
|
|
headers=headers
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
|
|
else:
|
|
# No Range header - stream entire file
|
|
async def video_reader():
|
|
with open(file_path, 'rb') as f:
|
|
while True:
|
|
data = f.read(1024 * 1024) # 1MB chunks
|
|
if not data:
|
|
break
|
|
yield data
|
|
|
|
headers["Content-Length"] = str(file_size)
|
|
return Response(
|
|
content=video_reader(),
|
|
headers=headers
|
|
)
|
|
|
|
|
|
# Direct video streaming endpoint (by filename)
|
|
@app.get("/stream/{filename}")
|
|
async def stream_video_by_filename(filename: str, request: Request):
|
|
"""Stream a video file by filename with Range support for seeking"""
|
|
# Sanitize filename to prevent directory traversal
|
|
filename = os.path.basename(filename)
|
|
file_path = Path("downloads") / filename
|
|
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
file_size = file_path.stat().st_size
|
|
|
|
# Parse Range header
|
|
range_header = request.headers.get("range")
|
|
|
|
if range_header:
|
|
# Parse Range header (format: bytes=start-end)
|
|
try:
|
|
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
|
|
start = int(range_match.group(1))
|
|
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
|
|
|
|
# Validate range
|
|
if start >= file_size or end >= file_size or start > end:
|
|
return Response(
|
|
status_code=416,
|
|
headers={
|
|
"Content-Range": f"bytes */{file_size}",
|
|
"Accept-Ranges": "bytes"
|
|
},
|
|
content="Requested Range Not Satisfiable"
|
|
)
|
|
|
|
# Read the requested range
|
|
content_length = end - start + 1
|
|
|
|
def video_range_reader():
|
|
with open(file_path, 'rb') as f:
|
|
f.seek(start)
|
|
remaining = content_length
|
|
while remaining > 0:
|
|
chunk_size = min(1024 * 1024, remaining) # 1MB chunks
|
|
data = f.read(chunk_size)
|
|
if not data:
|
|
break
|
|
remaining -= len(data)
|
|
yield data
|
|
|
|
return StreamingResponse(
|
|
video_range_reader(),
|
|
status_code=206,
|
|
headers={
|
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
|
"Content-Length": str(content_length),
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Type": "video/mp4",
|
|
}
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
|
|
else:
|
|
# No Range header - stream entire file
|
|
def video_reader():
|
|
with open(file_path, 'rb') as f:
|
|
while True:
|
|
data = f.read(1024 * 1024) # 1MB chunks
|
|
if not data:
|
|
break
|
|
yield data
|
|
|
|
return StreamingResponse(
|
|
video_reader(),
|
|
headers={
|
|
"Content-Length": str(file_size),
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Type": "video/mp4",
|
|
}
|
|
)
|
|
|
|
|
|
# Video Player page (by task_id)
|
|
@app.get("/player/{task_id}")
|
|
async def video_player(request: Request, task_id: str):
|
|
"""Video player page for watching downloaded anime"""
|
|
task = download_manager.get_task(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
|
|
if task.status != DownloadStatus.COMPLETED:
|
|
raise HTTPException(status_code=400, detail="Download not completed")
|
|
|
|
if not task.file_path or not os.path.exists(task.file_path):
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
# Get video info
|
|
file_path = Path(task.file_path)
|
|
file_size = file_path.stat().st_size
|
|
|
|
# Calculate video duration (rough estimation based on file size)
|
|
# Assuming ~1MB per minute for 720p, ~2MB per minute for 1080p
|
|
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
|
|
|
|
return templates.TemplateResponse("player.html", {
|
|
"request": request,
|
|
"task_id": task_id,
|
|
"filename": task.filename,
|
|
"file_size": file_size,
|
|
"estimated_duration": estimated_duration_seconds
|
|
})
|
|
|
|
|
|
# Video Player page (by filename)
|
|
@app.get("/watch/{filename}")
|
|
async def video_player_by_filename(request: Request, filename: str):
|
|
"""Video player page for watching downloaded anime by filename"""
|
|
# Sanitize and validate filename
|
|
filename = sanitize_filename(filename)
|
|
|
|
# Safety check
|
|
if not is_safe_filename(filename):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid filename. Path traversal attempts are not allowed."
|
|
)
|
|
|
|
file_path = Path("downloads") / filename
|
|
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
file_size = file_path.stat().st_size
|
|
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
|
|
|
|
return templates.TemplateResponse("player.html", {
|
|
"request": request,
|
|
"task_id": filename, # Use filename instead of task_id
|
|
"filename": filename,
|
|
"file_size": file_size,
|
|
"estimated_duration": estimated_duration_seconds
|
|
})
|
|
|
|
|
|
# ==================== FAVORITES API ====================
|
|
|
|
@app.get("/api/favorites")
|
|
async def list_favorites(
|
|
sort_by: str = "created_at",
|
|
order: str = "desc",
|
|
filter_provider: str = None,
|
|
filter_genre: str = None
|
|
):
|
|
"""
|
|
List all favorite anime with optional sorting and filtering
|
|
|
|
Query params:
|
|
- sort_by: title, rating, year, created_at, updated_at (default: created_at)
|
|
- order: asc, desc (default: desc)
|
|
- filter_provider: Filter by provider (anime-sama, neko-sama, etc.)
|
|
- filter_genre: Filter by genre (Action, Adventure, etc.)
|
|
"""
|
|
fav_manager = get_favorites_manager()
|
|
favorites = await fav_manager.list_favorites(
|
|
sort_by=sort_by,
|
|
order=order,
|
|
filter_provider=filter_provider,
|
|
filter_genre=filter_genre
|
|
)
|
|
return {
|
|
"favorites": favorites,
|
|
"total": len(favorites),
|
|
"filters": {
|
|
"sort_by": sort_by,
|
|
"order": order,
|
|
"provider": filter_provider,
|
|
"genre": filter_genre
|
|
}
|
|
}
|
|
|
|
|
|
@app.post("/api/favorites")
|
|
async def add_favorite(request: Request):
|
|
"""
|
|
Add an anime to favorites
|
|
|
|
Body params (JSON):
|
|
- anime_id: Unique identifier (e.g., provider + slug)
|
|
- title: Anime title
|
|
- url: Anime page URL
|
|
- provider: Provider name
|
|
- metadata: Optional metadata dict (synopsis, genres, rating, etc.)
|
|
- poster_url: Optional poster image URL
|
|
"""
|
|
import json
|
|
data = await request.json()
|
|
|
|
required_fields = ["anime_id", "title", "url", "provider"]
|
|
for field in required_fields:
|
|
if field not in data:
|
|
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
|
|
|
fav_manager = get_favorites_manager()
|
|
favorite = await fav_manager.add_favorite(
|
|
anime_id=data["anime_id"],
|
|
title=data["title"],
|
|
url=data["url"],
|
|
provider=data["provider"],
|
|
metadata=data.get("metadata"),
|
|
poster_url=data.get("poster_url")
|
|
)
|
|
|
|
return {"status": "added", "favorite": favorite}
|
|
|
|
|
|
@app.delete("/api/favorites/{anime_id}")
|
|
async def remove_favorite(anime_id: str):
|
|
"""Remove an anime from favorites"""
|
|
fav_manager = get_favorites_manager()
|
|
removed = await fav_manager.remove_favorite(anime_id)
|
|
|
|
if not removed:
|
|
raise HTTPException(status_code=404, detail="Favorite not found")
|
|
|
|
return {"status": "removed", "anime_id": anime_id}
|
|
|
|
|
|
@app.get("/api/favorites/stats")
|
|
async def get_favorites_stats():
|
|
"""Get statistics about favorites"""
|
|
fav_manager = get_favorites_manager()
|
|
stats = await fav_manager.get_stats()
|
|
return stats
|
|
|
|
|
|
@app.get("/api/favorites/{anime_id}")
|
|
async def get_favorite(anime_id: str):
|
|
"""Get details of a specific favorite anime"""
|
|
fav_manager = get_favorites_manager()
|
|
favorite = await fav_manager.get_favorite(anime_id)
|
|
|
|
if not favorite:
|
|
raise HTTPException(status_code=404, detail="Favorite not found")
|
|
|
|
return {"favorite": favorite}
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/favorites/toggle")
|
|
async def toggle_favorite(request: Request):
|
|
"""
|
|
Toggle an anime in favorites (add if not exists, remove if exists)
|
|
|
|
Body params (JSON):
|
|
- anime_id: Unique identifier
|
|
- title: Anime title
|
|
- url: Anime page URL
|
|
- provider: Provider name
|
|
- metadata: Optional metadata dict
|
|
- poster_url: Optional poster image URL
|
|
"""
|
|
import json
|
|
data = await request.json()
|
|
|
|
required_fields = ["anime_id", "title", "url", "provider"]
|
|
for field in required_fields:
|
|
if field not in data:
|
|
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
|
|
|
fav_manager = get_favorites_manager()
|
|
result = await fav_manager.toggle_favorite(
|
|
anime_id=data["anime_id"],
|
|
title=data["title"],
|
|
url=data["url"],
|
|
provider=data["provider"],
|
|
metadata=data.get("metadata"),
|
|
poster_url=data.get("poster_url")
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
# ==================== ANIME SEARCH & DETAILS ====================
|
|
|
|
@app.get("/api/anime/mal/search")
|
|
async def search_anime_mal_details(
|
|
q: str = Query(..., description="Anime search query"),
|
|
limit: int = Query(5, description="Number of results")
|
|
):
|
|
"""
|
|
Search for an anime on MyAnimeList and get full details
|
|
|
|
Returns anime matching the query with complete information including:
|
|
- Basic info (title, episodes, score, status)
|
|
- Synopsis
|
|
- Genres
|
|
- Images
|
|
- Related anime (prequels, sequels, spin-offs)
|
|
"""
|
|
from app.recommendations import AnimeReleasesFetcher
|
|
|
|
fetcher = AnimeReleasesFetcher()
|
|
|
|
try:
|
|
# Search for anime
|
|
search_results = await fetcher.search_anime(q, limit=limit)
|
|
|
|
if not search_results:
|
|
return {
|
|
"anime": None,
|
|
"message": "No anime found"
|
|
}
|
|
|
|
# Get the first result's full details including relations
|
|
main_anime = search_results[0]
|
|
|
|
# Fetch full details and relations for the main anime
|
|
anime_details = await fetcher.get_anime_details(main_anime['mal_id'])
|
|
|
|
# Include other search results as alternatives
|
|
alternatives = search_results[1:] if len(search_results) > 1 else []
|
|
|
|
return {
|
|
"anime": anime_details,
|
|
"alternatives": alternatives,
|
|
"total_results": len(search_results)
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
await fetcher.close()
|
|
|
|
|
|
@app.get("/api/anime/mal/{mal_id}")
|
|
async def get_anime_by_id(mal_id: int):
|
|
"""
|
|
Get full details of an anime by its MyAnimeList ID
|
|
|
|
Returns complete information including:
|
|
- Basic info, synopsis, genres, images
|
|
- Related anime (prequels, sequels, spin-offs, etc.)
|
|
"""
|
|
from app.recommendations import AnimeReleasesFetcher
|
|
|
|
fetcher = AnimeReleasesFetcher()
|
|
|
|
try:
|
|
anime_details = await fetcher.get_anime_details(mal_id)
|
|
|
|
if not anime_details:
|
|
raise HTTPException(status_code=404, detail="Anime not found")
|
|
|
|
return anime_details
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
await fetcher.close()
|
|
|
|
|
|
@app.post("/api/translate")
|
|
async def translate_text(request: Request):
|
|
"""
|
|
Translate text from English to French using backend APIs
|
|
Uses Google Translate through a free translation service
|
|
"""
|
|
import httpx
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
body = await request.json()
|
|
text = body.get("text", "")
|
|
|
|
if not text:
|
|
raise HTTPException(status_code=400, detail="Text is required")
|
|
|
|
# Limit text length
|
|
text = text[:5000]
|
|
|
|
# Use Google Translate via translate.googleapis.com (free, no quota limit)
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
# Using Google Translate's unofficial API
|
|
url = "https://translate.googleapis.com/translate_a/single"
|
|
params = {
|
|
"client": "gtx",
|
|
"sl": "en", # source language
|
|
"tl": "fr", # target language
|
|
"dt": "t",
|
|
"q": text
|
|
}
|
|
|
|
logger.info(f"Translation request for text length: {len(text)}")
|
|
|
|
response = await client.get(url, params=params)
|
|
|
|
logger.info(f"Translation API response status: {response.status_code}")
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
|
|
# Google Translate returns a nested array structure
|
|
# Format: [[["translated text", "original text", ...]], ...]
|
|
if data and len(data) > 0 and data[0]:
|
|
translated_text = "".join([item[0] for item in data[0] if item[0]])
|
|
|
|
if translated_text:
|
|
logger.info(f"Translation successful, length: {len(translated_text)}")
|
|
return {
|
|
"translatedText": translated_text,
|
|
"status": "success"
|
|
}
|
|
|
|
logger.warning(f"Unexpected Google Translate response structure: {data}")
|
|
|
|
# If we got here, something went wrong
|
|
raise HTTPException(status_code=500, detail="Translation failed")
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Translation error: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")
|
|
|
|
|
|
# ==================== SONARR WEBHOOK API ====================
|
|
|
|
@app.post("/api/webhook/sonarr")
|
|
async def sonarr_webhook(request: Request):
|
|
"""
|
|
Receive and process Sonarr webhook events
|
|
|
|
Sonarr sends webhooks for various events:
|
|
- Grab: When Sonarr downloads a release
|
|
- Download: When download is completed
|
|
- Rename: When files are renamed
|
|
- Delete: When series/episodes are deleted
|
|
|
|
Configure in Sonarr Settings > Connect > Sonarr > Webhook
|
|
URL: http://your-server:3000/api/webhook/sonarr
|
|
"""
|
|
sonarr_handler = get_sonarr_handler()
|
|
|
|
# Get raw body for HMAC verification
|
|
body = await request.body()
|
|
|
|
# Verify HMAC if configured
|
|
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:
|
|
# Parse payload
|
|
payload_data = await request.json()
|
|
payload = SonarrWebhookPayload(**payload_data)
|
|
|
|
# Process webhook
|
|
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)}")
|
|
|
|
|
|
@app.post("/api/webhook/test/sonarr")
|
|
async def test_sonarr_webhook(request: Request):
|
|
"""
|
|
Test endpoint for Sonarr webhook configuration
|
|
|
|
This endpoint accepts any payload and returns it back,
|
|
useful for testing webhook connectivity from Sonarr.
|
|
"""
|
|
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)
|
|
}
|
|
|
|
|
|
# ==================== SONARR CONFIGURATION ====================
|
|
|
|
@app.get("/api/sonarr/config")
|
|
async def get_sonarr_config():
|
|
"""Get Sonarr webhook configuration"""
|
|
sonarr_handler = get_sonarr_handler()
|
|
return sonarr_handler.get_config()
|
|
|
|
|
|
@app.put("/api/sonarr/config")
|
|
async def update_sonarr_config(config: SonarrConfig):
|
|
"""
|
|
Update Sonarr webhook configuration
|
|
|
|
Parameters:
|
|
- webhook_enabled: Enable/disable webhook processing
|
|
- webhook_secret: HMAC SHA256 secret for signature verification
|
|
- auto_download_enabled: Automatically trigger downloads on Grab events
|
|
- default_language: Default language (vostfr, vf)
|
|
- default_quality: Default quality preference (1080p, 720p, etc.)
|
|
- default_provider: Default anime provider
|
|
- verify_hmac: Enable HMAC signature verification
|
|
- log_webhooks: Log all incoming webhooks
|
|
"""
|
|
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))
|
|
|
|
|
|
# ==================== SONARR MAPPINGS ====================
|
|
|
|
@app.get("/api/sonarr/mappings")
|
|
async def get_sonarr_mappings():
|
|
"""Get all Sonarr to anime mappings"""
|
|
sonarr_handler = get_sonarr_handler()
|
|
return sonarr_handler.get_mappings()
|
|
|
|
|
|
@app.get("/api/sonarr/mappings/{series_id}")
|
|
async def get_sonarr_mapping(series_id: int):
|
|
"""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
|
|
|
|
|
|
@app.post("/api/sonarr/mappings")
|
|
async def create_sonarr_mapping(mapping: SonarrMapping):
|
|
"""
|
|
Create or update a Sonarr to anime mapping
|
|
|
|
This allows automatic anime downloads when Sonarr triggers events.
|
|
You need to map Sonarr series IDs to anime URLs from providers.
|
|
|
|
Example:
|
|
{
|
|
"sonarr_series_id": 123,
|
|
"sonarr_title": "Naruto Shippuden",
|
|
"anime_provider": "anime-sama",
|
|
"anime_url": "https://anime-sama.si/catalogue/naruto-shippuden/saison1/vostfr/",
|
|
"anime_title": "Naruto Shippuden",
|
|
"lang": "vostfr",
|
|
"quality_preference": "1080p",
|
|
"auto_download": true
|
|
}
|
|
"""
|
|
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))
|
|
|
|
|
|
@app.delete("/api/sonarr/mappings/{series_id}")
|
|
async def delete_sonarr_mapping(series_id: int):
|
|
"""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"
|
|
}
|
|
|
|
|
|
# ==================== SONARR SEARCH & DISCOVERY ====================
|
|
|
|
@app.get("/api/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)")
|
|
):
|
|
"""
|
|
Search for anime on providers to create Sonarr mappings
|
|
|
|
Use this endpoint to find the correct anime URL when setting up 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))
|
|
|
|
|
|
@app.get("/api/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)")
|
|
):
|
|
"""
|
|
Get episode list for anime (useful for setting up mappings)
|
|
|
|
Returns all episodes available for the given anime URL.
|
|
"""
|
|
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))
|
|
|
|
|
|
@app.get("/api/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")
|
|
):
|
|
"""
|
|
Suggest possible anime mappings based on Sonarr series title
|
|
|
|
Returns a list of potential matches with similarity scores.
|
|
Useful for quickly finding the right anime when setting up mappings.
|
|
"""
|
|
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))
|
|
|
|
|
|
# ==================== SONARR DOWNLOAD TRIGGER ====================
|
|
|
|
@app.post("/api/sonarr/download")
|
|
async def trigger_sonarr_download(request: SonarrDownloadRequest, background_tasks: BackgroundTasks):
|
|
"""
|
|
Manually trigger a download based on Sonarr information
|
|
|
|
This allows manually triggering downloads using Sonarr series information.
|
|
Useful for testing or when automatic download is disabled.
|
|
|
|
Example:
|
|
{
|
|
"sonarr_series_id": 123,
|
|
"sonarr_title": "Naruto Shippuden",
|
|
"season_number": 1,
|
|
"episode_number": 1,
|
|
"quality": "1080p",
|
|
"lang": "vostfr",
|
|
"provider": "anime-sama"
|
|
}
|
|
"""
|
|
sonarr_handler = get_sonarr_handler()
|
|
|
|
# Find mapping
|
|
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:
|
|
# Get episodes for the anime
|
|
episodes = await sonarr_handler.get_episodes_for_anime(
|
|
mapping.anime_url,
|
|
request.provider or mapping.anime_provider,
|
|
request.lang or mapping.lang
|
|
)
|
|
|
|
# Find matching episode
|
|
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"
|
|
)
|
|
|
|
# Extract video URL from episode URL
|
|
episode_url = target_episode.get('url')
|
|
if not episode_url:
|
|
raise HTTPException(status_code=400, detail="Episode URL not found")
|
|
|
|
# Create download task
|
|
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))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(
|
|
"main:app",
|
|
host="0.0.0.0",
|
|
port=3000,
|
|
reload=True
|
|
)
|