2 Commits

Author SHA1 Message Date
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
root c0f9c0c1c4 docs: mise à jour complète du README
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
- Ajout provider Zone-Telechargement (séries)
- Section Authentification (JWT, refresh tokens)
- Section Favoris & Recommandations
- Section Paramètres (désactivation providers, UI settings, Sonarr)
- Tableau état des providers (vérifié Avril 2026)
- Tableau des endpoints API principaux (~40 endpoints)
- Section Problèmes Connus (Smoothpre, Sibnet, Anime-Ultime, watchlist_settings)
- Dépendance manquante: pydantic[email]
- Avertissement CORS_ORIGINS dans .env
- Structure détaillée des downloaders
- Points d'accès documentés (web, docs, login)
2026-04-02 21:50:01 +00:00
6 changed files with 268 additions and 62 deletions
+114 -17
View File
@@ -9,11 +9,15 @@ Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, mé
### 🎬 Recherche & Streaming ### 🎬 Recherche & Streaming
- **Recherche unifiée** : Recherchez animes et séries TV simultanément via plusieurs sources. - **Recherche unifiée** : Recherchez animes et séries TV simultanément via plusieurs sources.
- **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga. - **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga.
- **Providers Séries** : FS7 (French-Stream). - **Providers Séries** : FS7 (French-Stream), Zone-Telechargement.
- **Métadonnées riches** : Synopsis, genres, notes, studio via intégration Kitsu. - **Métadonnées riches** : Synopsis, genres, notes, studio via intégration Kitsu/MAL.
- **Streaming vidéo** : Lecteur **Plyr.io** intégré supportant plus de 15 hébergeurs. - **Streaming vidéo** : Lecteur **Plyr.io** intégré supportant plus de 15 hébergeurs.
- **Téléchargement flexible** : Épisode par épisode ou saison complète. - **Téléchargement flexible** : Épisode par épisode ou saison complète.
### 🔐 Authentification
- **Inscription / Connexion** : Système JWT avec tokens d'accès et de refresh.
- **Sécurité** : Clé secrète configurable, tokens expirables (24h access, 30j refresh).
### 📋 Watchlist & Automatisation ### 📋 Watchlist & Automatisation
- **Suivi intelligent** : Ajoutez des titres à votre watchlist pour ne rater aucun épisode. - **Suivi intelligent** : Ajoutez des titres à votre watchlist pour ne rater aucun épisode.
- **Auto-Download** : Téléchargement automatique des nouveaux épisodes dès leur parution. - **Auto-Download** : Téléchargement automatique des nouveaux épisodes dès leur parution.
@@ -21,12 +25,22 @@ Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, mé
- **Filtres avancés** : Visualisation par statut (Actif, En pause, Terminé). - **Filtres avancés** : Visualisation par statut (Actif, En pause, Terminé).
- **Intégration Sonarr** : Support des webhooks pour une automatisation complète du homelab. - **Intégration Sonarr** : Support des webhooks pour une automatisation complète du homelab.
### ⭐ Favoris & Recommandations
- **Favoris** : Sauvegardez vos animes préférés avec tri et filtres.
- **Recommandations** : Suggestions basées sur les tendances et sorties récentes.
- **Sorties saisonnières** : Suivi des sorties anime (top, latest, seasonal).
### 🚀 Gestionnaire de Téléchargements ### 🚀 Gestionnaire de Téléchargements
- **Multi-threading** : Jusqu'à 5 téléchargements simultanés avec gestion de file d'attente. - **Multi-threading** : Jusqu'à 5 téléchargements simultanés avec gestion de file d'attente.
- **Pause/Reprise** : Support du protocole HTTP Range pour reprendre les téléchargements interrompus. - **Pause/Reprise** : Support du protocole HTTP Range pour reprendre les téléchargements interrompus.
- **Progression Temps Réel** : Vitesse (Mo/s), pourcentage et estimation du temps restant. - **Progression Temps Réel** : Vitesse (Mo/s), pourcentage et estimation du temps restant.
- **Sanitisation** : Nettoyage automatique des noms de fichiers pour une compatibilité maximale. - **Sanitisation** : Nettoyage automatique des noms de fichiers pour une compatibilité maximale.
### ⚙️ Paramètres
- **Désactivation de providers** : Activez/désactivez les sources individuellement.
- **UI Settings** : Configuration de l'interface utilisateur.
- **Sonarr Config** : Configuration de l'intégration Sonarr avec mapping de séries.
## 🏗️ Architecture & Stack Technique ## 🏗️ Architecture & Stack Technique
L'application repose sur une architecture moderne et robuste : L'application repose sur une architecture moderne et robuste :
@@ -35,19 +49,35 @@ L'application repose sur une architecture moderne et robuste :
- **Migrations** : **Alembic** pour la gestion évolutive du schéma de données. - **Migrations** : **Alembic** pour la gestion évolutive du schéma de données.
- **Frontend** : **HTMX** pour les interactions serveur, **Alpine.js** pour l'état client, **Vanilla CSS**. - **Frontend** : **HTMX** pour les interactions serveur, **Alpine.js** pour l'état client, **Vanilla CSS**.
- **Streaming** : **Plyr.io** pour une expérience de lecture fluide et moderne. - **Streaming** : **Plyr.io** pour une expérience de lecture fluide et moderne.
- **Authentification** : JWT via **python-jose** + **passlib/bcrypt**.
## 📁 Hébergeurs Supportés ## 📁 Hébergeurs Supportés
| Type | Services Supportés | | Type | Services Supportés |
| :--- | :--- | | :--- | :--- |
| **Catalogues** | Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga, FS7 | | **Catalogues Anime** | Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga |
| **Players/Hosts** | VidMoly, DoodStream, 1fichier, Uptobox, SendVid, Sibnet, Lplayer, Uqload, Rapidfile, LuLuvid, Smoothpre, Vidzy, **OneUpload** | | **Catalogues Séries** | FS7 (French-Stream), Zone-Telechargement |
| **Players/Hosts** | VidMoly, DoodStream, 1fichier, Uptobox, SendVid, Sibnet, Lplayer, Uqload, Rapidfile, LuLuvid, Smoothpre, Vidzy, OneUpload |
## 📊 État des Providers
| Provider | Type | Status |
| :--- | :--- | :--- |
| Anime-Sama | Anime | ✅ UP |
| Neko-Sama | Anime | ✅ UP |
| Anime-Ultime | Anime | ✅ UP |
| Vostfree | Anime | ✅ UP |
| French-Manga | Anime | ✅ UP |
| FS7 | Séries | ✅ UP |
| Zone-Telechargement | Séries | ✅ UP |
> Dernière vérification : Avril 2026
## 🚀 Installation & Configuration ## 🚀 Installation & Configuration
### 1. Prérequis ### 1. Prérequis
- Python 3.11+ - Python 3.11+
- Node.js (pour les tests optionnels) - Node.js (pour les tests optionnels uniquement)
- Playwright (requis pour l'extraction de certains lecteurs comme VidMoly) - Playwright (requis pour l'extraction de certains lecteurs comme VidMoly)
### 2. Installation ### 2. Installation
@@ -62,26 +92,48 @@ source venv/bin/activate
# Installer les dépendances # Installer les dépendances
pip install -r requirements.txt pip install -r requirements.txt
pip install pydantic[email] # Requis pour la validation des emails
# Initialisation Playwright # Initialisation Playwright (optionnel, pour l'extraction VidMoly)
playwright install chromium playwright install chromium
``` ```
### 3. Configuration ### 3. Configuration
Créez un fichier `.env` à la racine du projet (voir `.env.example`). Créez un fichier `.env` à la racine du projet à partir du modèle :
**Note importante sur la sécurité :** Générez une clé secrète JWT sécurisée.
```bash
cp .env.example .env
```
**Générez une clé secrète JWT sécurisée** (obligatoire, min. 32 caractères) :
```bash ```bash
# Commande pour générer une clé secrète
python3 -c "import secrets; print(secrets.token_urlsafe(32))" python3 -c "import secrets; print(secrets.token_urlsafe(32))"
``` ```
Editez le `.env` et ajoutez :
```env
JWT_SECRET_KEY=<la_clé_générée_ci_dessus>
```
> ⚠️ **Ne pas** définir `CORS_ORIGINS` dans le `.env` si vous utilisez les valeurs par défaut (format JSON requis, les valeurs par défaut du code suffisent).
### 4. Lancement ### 4. Lancement
```bash ```bash
# Lancer l'application (Port 3000 par défaut) # Lancer l'application (Port 3000 par défaut)
source venv/bin/activate
uvicorn main:app --reload --host 0.0.0.0 --port 3000 uvicorn main:app --reload --host 0.0.0.0 --port 3000
``` ```
Accès Web : `http://localhost:3000/web`
Ou via le script fourni :
```bash
./run_app.sh
```
**Points d'accès :**
- Interface web : `http://localhost:3000/web`
- Documentation API : `http://localhost:3000/docs`
- Page de connexion : `http://localhost:3000/login`
## 🧪 Tests & Qualité ## 🧪 Tests & Qualité
@@ -91,28 +143,72 @@ pytest # Tous les tests
pytest -m "unit" # Tests unitaires rapides pytest -m "unit" # Tests unitaires rapides
# Frontend (Vitest & Playwright) # Frontend (Vitest & Playwright)
npm test # Tests unitaires JS npm install # Installer les dépendances dev
npm test # Tests unitaires JS (Vitest)
npx playwright test # Tests E2E complets npx playwright test # Tests E2E complets
``` ```
## 🏗️ Structure du Projet ## 🏗️ Structure du Projet
``` ```
Ohm_streaming/ ohm_streaming/
├── main.py # Point d'entrée & Middleware FastAPI ├── main.py # Point d'entrée & Middleware FastAPI
├── app/ ├── app/
│ ├── downloaders/ # Logique d'extraction (Scraping 3-tier) │ ├── downloaders/ # Logique d'extraction (Scraping multi-tier)
│ │ ├── anime_sama.py # Downloader Anime-Sama
│ │ ├── anime_ultime.py # Downloader Anime-Ultime
│ │ ├── neko_sama.py # Downloader Neko-Sama
│ │ ├── vostfree.py # Downloader Vostfree
│ │ ├── french_manga.py # Downloader French-Manga
│ │ ├── fs7.py # Downloader FS7
│ │ └── zone_telechargement.py # Downloader Zone-TG
│ ├── models/ # Modèles SQLModel & Pydantic │ ├── models/ # Modèles SQLModel & Pydantic
│ ├── routers/ # Routes API modulaires │ ├── routers/ # Routes API modulaires (~40 endpoints)
│ ├── download_manager.py # Moteur de téléchargement asynchrone │ ├── download_manager.py # Moteur de téléchargement asynchrone
│ ├── watchlist.py # Logique métier du suivi │ ├── watchlist.py # Logique métier du suivi
│ ├── episode_checker.py # Vérification automatique de nouveaux épisodes
│ ├── auto_download_scheduler.py # Planificateur de téléchargements
│ ├── sonarr_handler.py # Intégration Sonarr
│ ├── metadata_enrichment.py # Enrichissement des métadonnées (Kitsu/MAL)
│ ├── recommendations.py # Système de recommandations
│ ├── providers_manager.py # Gestion des providers (health check, activation)
│ └── database.py # Configuration de la base de données │ └── database.py # Configuration de la base de données
├── config/ # Fichiers de configuration (Sonarr, mappings)
├── alembic/ # Migrations de base de données ├── alembic/ # Migrations de base de données
├── static/ # Frontend (JS, CSS, Img) ├── static/ # Frontend (JS, CSS, Images)
├── templates/ # Vues Jinja2 (avec HTMX & Alpine.js) ├── templates/ # Vues Jinja2 (avec HTMX & Alpine.js)
├── tests/ # Tests backend
├── scripts/ # Scripts utilitaires
└── downloads/ # Répertoire par défaut des médias └── downloads/ # Répertoire par défaut des médias
``` ```
## 🔧 Endpoints API Principaux
| Endpoint | Méthode | Description |
| :--- | :--- | :--- |
| `/api/auth/register` | POST | Création de compte |
| `/api/auth/login` | POST | Connexion (JWT) |
| `/api/auth/me` | GET | Profil utilisateur |
| `/api/anime/search?q=` | GET | Recherche multi-providers |
| `/api/series/search?q=` | GET | Recherche séries |
| `/api/anime/seasons?url=` | GET | Liste des saisons |
| `/api/anime/episodes?url=` | GET | Liste des épisodes |
| `/api/anime/download?url=` | POST | Lancer un téléchargement |
| `/api/anime/download-season?url=` | POST | Télécharger une saison complète |
| `/api/downloads` | GET | Liste des téléchargements |
| `/api/favorites` | GET | Liste des favoris |
| `/api/watchlist` | GET | Liste de la watchlist |
| `/api/providers/health` | GET | État des providers |
| `/api/settings` | GET | Configuration |
| `/api/sonarr/config` | GET/POST | Configuration Sonarr |
## 🐛 Problèmes Connus
- **Smoothpre** : L'extracteur de liens vidéo peut échouer si la structure de la page change côté serveur.
- **Sibnet filename** : Le nom de fichier généré peut contenir des caractères invalides issus de l'URL (à corriger dans la sanitisation du DownloadManager).
- **Anime-Ultime download** : La méthode `get_download_link()` a une incompatibilité de signature lors de l'appel par le routeur de téléchargement.
- **Table watchlist_settings** : La table SQLite n'est pas créée automatiquement au premier lancement (affiche un warning dans les logs mais n'empêche pas le fonctionnement).
## 📝 Licence & Sécurité ## 📝 Licence & Sécurité
- Ce projet est à usage **éducatif et personnel** uniquement. - Ce projet est à usage **éducatif et personnel** uniquement.
@@ -120,6 +216,7 @@ Ohm_streaming/
- L'utilisation de ce logiciel est sous votre entière responsabilité. - L'utilisation de ce logiciel est sous votre entière responsabilité.
--- ---
**Version actuelle : 2.4**
**Dernière mise à jour : Mars 2026** **Version actuelle : 2.4**
**Dernière mise à jour : Avril 2026**
**Développé avec ❤️ pour la communauté anime** **Développé avec ❤️ pour la communauté anime**
+34 -18
View File
@@ -27,11 +27,15 @@ class FavoritesManager:
url: str, url: str,
provider: str, provider: str,
metadata: Optional[Dict] = None, metadata: Optional[Dict] = None,
poster_url: Optional[str] = None poster_url: Optional[str] = None,
user_id: str = "default"
) -> Dict: ) -> Dict:
"""Add an anime to favorites""" """Add an anime to favorites"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
existing = session.exec(statement).first() existing = session.exec(statement).first()
if existing: if existing:
@@ -53,17 +57,21 @@ class FavoritesManager:
url=url, url=url,
provider=provider, provider=provider,
anime_metadata=metadata or {}, anime_metadata=metadata or {},
poster_url=poster_url poster_url=poster_url,
user_id=user_id
) )
session.add(fav) session.add(fav)
session.commit() session.commit()
session.refresh(fav) session.refresh(fav)
return self._to_dict(fav) return self._to_dict(fav)
async def remove_favorite(self, anime_id: str) -> bool: async def remove_favorite(self, anime_id: str, user_id: str = "default") -> bool:
"""Remove an anime from favorites""" """Remove an anime from favorites"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
existing = session.exec(statement).first() existing = session.exec(statement).first()
if existing: if existing:
session.delete(existing) session.delete(existing)
@@ -71,10 +79,13 @@ class FavoritesManager:
return True return True
return False return False
async def get_favorite(self, anime_id: str) -> Optional[Dict]: async def get_favorite(self, anime_id: str, user_id: str = "default") -> Optional[Dict]:
"""Get a specific favorite by ID""" """Get a specific favorite by ID"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
existing = session.exec(statement).first() existing = session.exec(statement).first()
if existing: if existing:
return self._to_dict(existing) return self._to_dict(existing)
@@ -82,6 +93,7 @@ class FavoritesManager:
async def list_favorites( async def list_favorites(
self, self,
user_id: str = "default",
sort_by: str = "created_at", sort_by: str = "created_at",
order: str = "desc", order: str = "desc",
filter_provider: Optional[str] = None, filter_provider: Optional[str] = None,
@@ -89,11 +101,11 @@ class FavoritesManager:
) -> List[Dict]: ) -> List[Dict]:
"""List all favorites with optional sorting and filtering""" """List all favorites with optional sorting and filtering"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable) statement = select(FavoriteTable).where(FavoriteTable.user_id == user_id)
if filter_provider: if filter_provider:
statement = statement.where(FavoriteTable.provider == filter_provider) statement = statement.where(FavoriteTable.provider == filter_provider)
# SQLite JSON filtering for genres is complex, handle it in Python # SQLite JSON filtering for genres is complex, handle it in Python
results = session.exec(statement).all() results = session.exec(statement).all()
favorites = [self._to_dict(fav) for fav in results] favorites = [self._to_dict(fav) for fav in results]
@@ -123,10 +135,13 @@ class FavoritesManager:
return favorites return favorites
async def is_favorite(self, anime_id: str) -> bool: async def is_favorite(self, anime_id: str, user_id: str = "default") -> bool:
"""Check if an anime is in favorites""" """Check if an anime is in favorites"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
return session.exec(statement).first() is not None return session.exec(statement).first() is not None
async def toggle_favorite( async def toggle_favorite(
@@ -136,21 +151,22 @@ class FavoritesManager:
url: str, url: str,
provider: str, provider: str,
metadata: Optional[Dict] = None, metadata: Optional[Dict] = None,
poster_url: Optional[str] = None poster_url: Optional[str] = None,
user_id: str = "default"
) -> Dict: ) -> Dict:
"""Toggle an anime in favorites (add if not exists, remove if exists)""" """Toggle an anime in favorites (add if not exists, remove if exists)"""
is_fav = await self.is_favorite(anime_id) is_fav = await self.is_favorite(anime_id, user_id=user_id)
if is_fav: if is_fav:
await self.remove_favorite(anime_id) await self.remove_favorite(anime_id, user_id=user_id)
return {"action": "removed", "anime_id": anime_id} return {"action": "removed", "anime_id": anime_id}
else: else:
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url) fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url, user_id=user_id)
return {"action": "added", "anime_id": anime_id, "favorite": fav} return {"action": "added", "anime_id": anime_id, "favorite": fav}
async def get_stats(self) -> Dict: async def get_stats(self, user_id: str = "default") -> Dict:
"""Get statistics about favorites""" """Get statistics about favorites"""
favorites = await self.list_favorites() favorites = await self.list_favorites(user_id=user_id)
total = len(favorites) total = len(favorites)
# Count by provider # Count by provider
+20 -6
View File
@@ -2,13 +2,15 @@
Download management routes for Ohm Stream Downloader API. Download management routes for Ohm Stream Downloader API.
""" """
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from app.download_manager import DownloadManager from app.download_manager import DownloadManager
from app.models import DownloadRequest from app.models import DownloadRequest
from app.routers.router_auth import get_current_user_from_token from app.models.auth import User
from app.routers.router_auth import get_current_user_from_token, get_optional_user
router = APIRouter(prefix="/api/downloads", tags=["downloads"]) router = APIRouter(prefix="/api/downloads", tags=["downloads"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -24,20 +26,28 @@ async def get_downloads(
request: Request, request: Request,
html: bool = Query(False), html: bool = Query(False),
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""Get list of all download tasks. Returns HTML for HTMX requests.""" """Get list of all download tasks. Returns HTML for HTMX requests."""
tasks = download_manager.get_all_tasks()
# Strictly check for HTMX or explicit HTML flag
is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request") is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request")
if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
tasks = download_manager.get_all_tasks()
if html or is_htmx: if html or is_htmx:
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.") print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
return templates.TemplateResponse( return templates.TemplateResponse(
"components/downloads_list.html", "components/downloads_list.html",
{"request": request, "tasks": tasks} {"request": request, "tasks": tasks}
) )
print(f"[DOWNLOADS] API Request. Returning JSON.") print(f"[DOWNLOADS] API Request. Returning JSON.")
return {"downloads": tasks} return {"downloads": tasks}
@@ -56,8 +66,12 @@ async def create_download(
async def get_download_status( async def get_download_status(
task_id: str, task_id: str,
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""Get status of a specific download task""" """Get status of a specific download task"""
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
task = download_manager.get_task(task_id) task = download_manager.get_task(task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
+56 -12
View File
@@ -2,24 +2,42 @@
Favorites management routes for Ohm Stream Downloader API. Favorites management routes for Ohm Stream Downloader API.
""" """
from fastapi import APIRouter, HTTPException from typing import Optional
from fastapi.requests import Request from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates
from app.favorites import get_favorites_manager from app.favorites import get_favorites_manager
from app.models.auth import User
from app.routers.router_auth import get_current_user_from_token, get_optional_user
router = APIRouter(prefix="/api/favorites", tags=["favorites"]) router = APIRouter(prefix="/api/favorites", tags=["favorites"])
templates = Jinja2Templates(directory="templates")
@router.get("") @router.get("")
async def list_favorites( async def list_favorites(
request: Request,
sort_by: str = "created_at", sort_by: str = "created_at",
order: str = "desc", order: str = "desc",
filter_provider: str = None, filter_provider: Optional[str] = None,
filter_genre: str = None, filter_genre: Optional[str] = None,
html: bool = Query(False),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""List all favorite anime with optional sorting and filtering""" """List all favorite anime with optional sorting and filtering"""
is_htmx = request.headers.get("HX-Request")
if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
favorites = await fav_manager.list_favorites( favorites = await fav_manager.list_favorites(
user_id=current_user.id,
sort_by=sort_by, sort_by=sort_by,
order=order, order=order,
filter_provider=filter_provider, filter_provider=filter_provider,
@@ -38,7 +56,11 @@ async def list_favorites(
@router.post("") @router.post("")
async def add_favorite(request: Request): async def add_favorite(
request: Request,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Add an anime to favorites""" """Add an anime to favorites"""
data = await request.json() data = await request.json()
@@ -51,6 +73,7 @@ async def add_favorite(request: Request):
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
favorite = await fav_manager.add_favorite( favorite = await fav_manager.add_favorite(
user_id=current_user.id,
anime_id=data["anime_id"], anime_id=data["anime_id"],
title=data["title"], title=data["title"],
url=data["url"], url=data["url"],
@@ -59,34 +82,45 @@ async def add_favorite(request: Request):
poster_url=data.get("poster_url"), poster_url=data.get("poster_url"),
) )
response.headers["HX-Trigger"] = '{"show-toast": {"message": "' + favorite['title'] + ' ajouté aux favoris", "type": "success"}}'
return {"status": "added", "favorite": favorite} return {"status": "added", "favorite": favorite}
@router.delete("/{anime_id}") @router.delete("/{anime_id}")
async def remove_favorite(anime_id: str): async def remove_favorite(
anime_id: str,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Remove an anime from favorites""" """Remove an anime from favorites"""
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
removed = await fav_manager.remove_favorite(anime_id) removed = await fav_manager.remove_favorite(anime_id, user_id=current_user.id)
if not removed: if not removed:
raise HTTPException(status_code=404, detail="Favorite not found") raise HTTPException(status_code=404, detail="Favorite not found")
response.headers["HX-Trigger"] = '{"show-toast": {"message": "Favori supprimé", "type": "info"}}'
return {"status": "removed", "anime_id": anime_id} return {"status": "removed", "anime_id": anime_id}
@router.get("/stats") @router.get("/stats")
async def get_favorites_stats(): async def get_favorites_stats(
current_user: User = Depends(get_current_user_from_token),
):
"""Get statistics about favorites""" """Get statistics about favorites"""
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
stats = await fav_manager.get_stats() stats = await fav_manager.get_stats(user_id=current_user.id)
return stats return stats
@router.get("/{anime_id}") @router.get("/{anime_id}")
async def get_favorite(anime_id: str): async def get_favorite(
anime_id: str,
current_user: User = Depends(get_current_user_from_token),
):
"""Get details of a specific favorite anime""" """Get details of a specific favorite anime"""
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
favorite = await fav_manager.get_favorite(anime_id) favorite = await fav_manager.get_favorite(anime_id, user_id=current_user.id)
if not favorite: if not favorite:
raise HTTPException(status_code=404, detail="Favorite not found") raise HTTPException(status_code=404, detail="Favorite not found")
@@ -95,7 +129,11 @@ async def get_favorite(anime_id: str):
@router.post("/toggle") @router.post("/toggle")
async def toggle_favorite(request: Request): async def toggle_favorite(
request: Request,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Toggle an anime in favorites""" """Toggle an anime in favorites"""
data = await request.json() data = await request.json()
@@ -108,6 +146,7 @@ async def toggle_favorite(request: Request):
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
result = await fav_manager.toggle_favorite( result = await fav_manager.toggle_favorite(
user_id=current_user.id,
anime_id=data["anime_id"], anime_id=data["anime_id"],
title=data["title"], title=data["title"],
url=data["url"], url=data["url"],
@@ -116,4 +155,9 @@ async def toggle_favorite(request: Request):
poster_url=data.get("poster_url"), poster_url=data.get("poster_url"),
) )
action = result.get("action", "unknown")
message = f"'{data['title']}' {'ajouté aux' if action == 'added' else 'retiré des'} favoris"
toast_type = "success" if action == "added" else "info"
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{message}", "type": "{toast_type}"}}}}'
return result return result
+18 -3
View File
@@ -6,10 +6,12 @@ import hashlib
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import APIRouter, Request, Query, HTTPException from fastapi import APIRouter, Request, Query, HTTPException, Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.recommendation_engine import RecommendationEngine from app.recommendation_engine import RecommendationEngine
from app.models.auth import User
from app.routers.router_auth import get_optional_user, get_current_user_from_token
router = APIRouter(prefix="/api", tags=["recommendations"]) router = APIRouter(prefix="/api", tags=["recommendations"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -26,14 +28,25 @@ async def get_recommendations(
request: Request, request: Request,
limit: int = 15, limit: int = 15,
html: bool = Query(False), html: bool = Query(False),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""Get personalized anime recommendations based on download history""" """Get personalized anime recommendations based on download history"""
is_htmx = request.headers.get("HX-Request")
if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
engine = RecommendationEngine(download_dir="downloads") engine = RecommendationEngine(download_dir="downloads")
try: try:
recommendations = await engine.get_personalized_recommendations(limit=limit) recommendations = await engine.get_personalized_recommendations(limit=limit)
if html or request.headers.get("HX-Request"): if html or is_htmx:
return templates.TemplateResponse( return templates.TemplateResponse(
"components/recommendations_list.html", "components/recommendations_list.html",
{"request": request, "recommendations": recommendations} {"request": request, "recommendations": recommendations}
@@ -140,7 +153,9 @@ async def get_top_anime(
@router.get("/stats/downloads") @router.get("/stats/downloads")
async def get_download_statistics(): async def get_download_statistics(
current_user: User = Depends(get_current_user_from_token),
):
"""Get download statistics and preferences""" """Get download statistics and preferences"""
engine = RecommendationEngine(download_dir="downloads") engine = RecommendationEngine(download_dir="downloads")
+26 -6
View File
@@ -68,14 +68,19 @@ async def test_sonarr_webhook(request: Request):
@router.get("/sonarr/config") @router.get("/sonarr/config")
async def get_sonarr_config(): async def get_sonarr_config(
current_user: User = Depends(get_current_user_from_token),
):
"""Get Sonarr webhook configuration""" """Get Sonarr webhook configuration"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_config() return sonarr_handler.get_config()
@router.put("/sonarr/config") @router.put("/sonarr/config")
async def update_sonarr_config(config: SonarrConfig): async def update_sonarr_config(
config: SonarrConfig,
current_user: User = Depends(get_current_user_from_token),
):
"""Update Sonarr webhook configuration""" """Update Sonarr webhook configuration"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
try: try:
@@ -87,14 +92,19 @@ async def update_sonarr_config(config: SonarrConfig):
@router.get("/sonarr/mappings") @router.get("/sonarr/mappings")
async def get_sonarr_mappings(): async def get_sonarr_mappings(
current_user: User = Depends(get_current_user_from_token),
):
"""Get all Sonarr to anime mappings""" """Get all Sonarr to anime mappings"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_mappings() return sonarr_handler.get_mappings()
@router.get("/sonarr/mappings/{series_id}") @router.get("/sonarr/mappings/{series_id}")
async def get_sonarr_mapping(series_id: int): async def get_sonarr_mapping(
series_id: int,
current_user: User = Depends(get_current_user_from_token),
):
"""Get specific mapping by Sonarr series ID""" """Get specific mapping by Sonarr series ID"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
mapping = sonarr_handler.get_mapping(series_id) mapping = sonarr_handler.get_mapping(series_id)
@@ -104,7 +114,10 @@ async def get_sonarr_mapping(series_id: int):
@router.post("/sonarr/mappings") @router.post("/sonarr/mappings")
async def create_sonarr_mapping(mapping: SonarrMapping): async def create_sonarr_mapping(
mapping: SonarrMapping,
current_user: User = Depends(get_current_user_from_token),
):
"""Create or update a Sonarr to anime mapping""" """Create or update a Sonarr to anime mapping"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
try: try:
@@ -116,7 +129,10 @@ async def create_sonarr_mapping(mapping: SonarrMapping):
@router.delete("/sonarr/mappings/{series_id}") @router.delete("/sonarr/mappings/{series_id}")
async def delete_sonarr_mapping(series_id: int): async def delete_sonarr_mapping(
series_id: int,
current_user: User = Depends(get_current_user_from_token),
):
"""Delete a Sonarr mapping""" """Delete a Sonarr mapping"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
success = sonarr_handler.delete_mapping(series_id) success = sonarr_handler.delete_mapping(series_id)
@@ -130,6 +146,7 @@ async def search_anime_for_sonarr(
q: str = Query(..., description="Series title to search"), q: str = Query(..., description="Series title to search"),
provider: str = Query("anime-sama", description="Anime provider to search"), provider: str = Query("anime-sama", description="Anime provider to search"),
lang: str = Query("vostfr", description="Language (vostfr, vf)"), 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""" """Search for anime on providers to create Sonarr mappings"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
@@ -152,6 +169,7 @@ async def get_anime_episodes(
url: str = Query(..., description="Anime URL from provider"), url: str = Query(..., description="Anime URL from provider"),
provider: str = Query("anime-sama", description="Anime provider"), provider: str = Query("anime-sama", description="Anime provider"),
lang: str = Query("vostfr", description="Language (vostfr, vf)"), lang: str = Query("vostfr", description="Language (vostfr, vf)"),
current_user: User = Depends(get_current_user_from_token),
): ):
"""Get episode list for anime""" """Get episode list for anime"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
@@ -174,6 +192,7 @@ async def suggest_anime_mapping(
sonarr_title: str = Query(..., description="Sonarr series title"), sonarr_title: str = Query(..., description="Sonarr series title"),
provider: str = Query("anime-sama", description="Anime provider"), provider: str = Query("anime-sama", description="Anime provider"),
lang: str = Query("vostfr", description="Language"), lang: str = Query("vostfr", description="Language"),
current_user: User = Depends(get_current_user_from_token),
): ):
"""Suggest possible anime mappings based on Sonarr series title""" """Suggest possible anime mappings based on Sonarr series title"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
@@ -195,6 +214,7 @@ async def suggest_anime_mapping(
async def trigger_sonarr_download( async def trigger_sonarr_download(
request: SonarrDownloadRequest, request: SonarrDownloadRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user_from_token),
): ):
"""Manually trigger a download based on Sonarr information""" """Manually trigger a download based on Sonarr information"""
from main import download_manager from main import download_manager