13 Commits

Author SHA1 Message Date
root 66912a0b71 fix: filtre content_type, doublons seasonaux, et is_admin manquant
CI / Test (Python 3.11) (pull_request) Has been cancelled
CI / Test (Python 3.12) (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Type Check (pull_request) Has been cancelled
CI / Summary (pull_request) Has been cancelled
- Bug 1: Ajout du champ 'type' dans les dict de AnimeReleasesFetcher
  (get_seasonal_anime, get_scheduled_anime, get_top_anime, search_anime)
  et dans _get_fallback_recommendations pour que le filtre content_type
  fonctionne correctement
- Bug 2: Déduplication par mal_id dans get_seasonal_anime() pour
  éviter les doublons retournés par l'API Jikan
- Bug 3: Ajout de is_admin dans get_current_user_from_token(),
  get_optional_user(), le constructeur User du register, et la
  réponse /me
2026-04-03 15:19:15 +00:00
root 693615a7dc fix: corriger les imports cassés dans router_watchlist.py
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
Remplace 'from main import watchlist_manager' par 'from app.watchlist import watchlist_manager'
et 'from main import auto_download_scheduler' par 'from app.auto_download_scheduler import auto_download_scheduler'.
watchlist_manager n'est pas exposé dans main.py, ce qui causait un ImportError 500
sur GET /api/watchlist.

Lié à #15
2026-04-03 06:39:34 +00:00
root 7529449f86 feat: refonte UI Material Design (#18)
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
- Variables Material Design (primary, secondary, surface, elevation)
- Boutons avec elevation, ripple, letter-spacing
- Cards avec hover elevation et border-radius 16px
- Tabs avec indicator 3px bottom
- Inputs underline style Material
- Toasts bottom-center avec slide-up animation
- Skeleton loader et circular spinner
- Scrollbar custom stylisee
- Responsive breakpoints (mobile/tablet/desktop)
- Variables light theme pretes
- Toutes les classes existantes conservees

Closes #18
2026-04-02 22:46:54 +00:00
root 555816bf30 feat: recherche amelioree - scoring fuzzy multi-niveaux (#7)
- Algorithme de scoring: exact > starts-with > substring > all words > any word
- Scores: 1.0 > 0.95 > 0.85 > 0.7 > 0.5 > 0.3
- Tolérance aux fautes de frappe via matching partiel sur mots
- Résultats triés par pertinence décroissante
- Supporte les titres en français, anglais, romaji

Closes #7
2026-04-02 22:45:15 +00:00
root 2da2a5bb27 feat: panel admin - gestion utilisateurs (#16)
- Route /api/admin avec middleware require_admin
- Liste utilisateurs avec statut, role, dates
- Actions: activer/desactiver, promouvoir/rétrograder admin, supprimer
- Dashboard stats (utilisateurs, téléchargements)
- Template admin_panel.html avec table responsive
- Champ is_admin ajoute au modele User
- Migration automatique colonne is_admin
- Protection: impossible de modifier son propre compte

Closes #16
2026-04-02 22:44:33 +00:00
root c921aafadd feat: filtre par type pour recommandations et sorties (#14)
- Parametre content_type sur /api/recommendations et /api/releases/latest
- Section anime: filtre content_type=anime sur releases
- Section series: filtre content_type=series sur recommendations et releases
- Nettoyage emojis dans titres de section

Closes #14
2026-04-02 22:42:36 +00:00
root e5b30741fe feat: parametres - filtres contenu, categories, repertoire (#9, #10, #11, #12)
- Filtre recommandations (all/anime/series)
- Filtre dernieres sorties (all/anime/series)
- Toggle categories anime/series (min 1 active)
- Repertoire de telechargement personnalisable
- Migration automatique des nouvelles colonnes SQLite
- Template settings avec tous les nouveaux controles
- Validation cote backend (400 si les deux categories desactivees)

Closes #9, Closes #10, Closes #11, Closes #12
2026-04-02 22:41:18 +00:00
root 0af537e032 feat: watchlist fonctionnelle CRUD complete (#13)
- Template watchlist_items_list.html refait avec filtres par statut
- Cards avec poster, titre, provider, statut, episodes
- Boutons Pause/Resume/Terminer/Supprimer via HTMX
- Bouton Suivre dans resultats anime et series search
- Poster image envoye dans les donnees watchlist
- Design responsive et moderne

Closes #13
2026-04-02 22:39:32 +00:00
root 9f9df600c1 fix: boutons telechargement fonctionnels + refonte UI downloads (#17, #8)
- Route GET /api/downloads/video/{task_id} pour streamer les videos
- Route POST /api/downloads/{task_id}/retry pour relancer les failed
- Route POST /api/downloads/cancel-all pour annuler tous les actifs
- Barre de progression animee (shimmer + pulse)
- Indicateurs visuels par status (bordures colorees)
- Bouton Retry pour telechargements echoues/annules
- Actions groupees (Nettoyer termines, Tout annuler)
- Compteur de telechargements actifs
- hx-on::after-request pour refresh auto

Closes #17, Closes #8
2026-04-02 22:35:49 +00:00
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
root 29c051be69 test: implement E2E user journey tests with Playwright
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
- Implement registration flow with API response verification
- Implement login flow with token storage validation
- Implement homepage browsing with JS error detection
- Implement anime search with HTMX debounce handling
- Implement settings update with PATCH request verification
- Implement logout flow with redirect and token cleanup
- Convert all .fixme() tests to executable test() functions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-31 16:19:46 +00:00
root 18c3c4d27b test: add E2E user journey test suite (pytest + Playwright skeleton)
- tests/test_user_journey.py: 23 pytest tests covering auth, search, settings, and download flows
  using TestClient with mocked providers (no real network calls)
- tests/e2e/user_journey.spec.ts: 6 fixme Playwright test placeholders for full UI journey
  (register, login, browse, search, settings, logout)
2026-03-30 17:42:14 +00:00
29 changed files with 2901 additions and 306 deletions
+114 -17
View File
@@ -9,11 +9,15 @@ Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, mé
### 🎬 Recherche & Streaming
- **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 Séries** : FS7 (French-Stream).
- **Métadonnées riches** : Synopsis, genres, notes, studio via intégration Kitsu.
- **Providers Séries** : FS7 (French-Stream), Zone-Telechargement.
- **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.
- **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
- **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.
@@ -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é).
- **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
- **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.
- **Progression Temps Réel** : Vitesse (Mo/s), pourcentage et estimation du temps restant.
- **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
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.
- **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.
- **Authentification** : JWT via **python-jose** + **passlib/bcrypt**.
## 📁 Hébergeurs Supportés
| Type | Services Supportés |
| :--- | :--- |
| **Catalogues** | Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga, FS7 |
| **Players/Hosts** | VidMoly, DoodStream, 1fichier, Uptobox, SendVid, Sibnet, Lplayer, Uqload, Rapidfile, LuLuvid, Smoothpre, Vidzy, **OneUpload** |
| **Catalogues Anime** | Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga |
| **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
### 1. Prérequis
- 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)
### 2. Installation
@@ -62,26 +92,48 @@ source venv/bin/activate
# Installer les dépendances
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
```
### 3. Configuration
Créez un fichier `.env` à la racine du projet (voir `.env.example`).
**Note importante sur la sécurité :** Générez une clé secrète JWT sécurisée.
Créez un fichier `.env` à la racine du projet à partir du modèle :
```bash
cp .env.example .env
```
**Générez une clé secrète JWT sécurisée** (obligatoire, min. 32 caractères) :
```bash
# Commande pour générer une clé secrète
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
```bash
# Lancer l'application (Port 3000 par défaut)
source venv/bin/activate
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é
@@ -91,28 +143,72 @@ pytest # Tous les tests
pytest -m "unit" # Tests unitaires rapides
# 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
```
## 🏗️ Structure du Projet
```
Ohm_streaming/
ohm_streaming/
├── main.py # Point d'entrée & Middleware FastAPI
├── 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
│ ├── routers/ # Routes API modulaires
│ ├── routers/ # Routes API modulaires (~40 endpoints)
│ ├── download_manager.py # Moteur de téléchargement asynchrone
│ ├── 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
├── config/ # Fichiers de configuration (Sonarr, mappings)
├── alembic/ # Migrations de base de données
├── static/ # Frontend (JS, CSS, Img)
├── static/ # Frontend (JS, CSS, Images)
├── templates/ # Vues Jinja2 (avec HTMX & Alpine.js)
├── tests/ # Tests backend
├── scripts/ # Scripts utilitaires
└── 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é
- 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é.
---
**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**
+36
View File
@@ -25,6 +25,42 @@ def create_db_and_tables():
from app.models.settings import AppSettingsTable
SQLModel.metadata.create_all(engine)
# Add new columns to existing tables if they don't exist (SQLite workaround)
_ensure_columns(engine)
def _ensure_columns(engine):
"""Add new columns to app_settings table if they don't exist"""
from sqlalchemy import inspect, text
inspector = inspect(engine)
if 'app_settings' not in inspector.get_table_names():
return
existing = {col['name'] for col in inspector.get_columns('app_settings')}
new_columns = {
'recommendations_filter': 'TEXT DEFAULT "all"',
'releases_filter': 'TEXT DEFAULT "all"',
'anime_enabled': 'BOOLEAN DEFAULT 1',
'series_enabled': 'BOOLEAN DEFAULT 1',
'download_dir': 'TEXT DEFAULT "downloads"',
}
# Add is_admin to users table if missing
if 'users' in inspector.get_table_names():
user_cols = {col['name'] for col in inspector.get_columns('users')}
if 'is_admin' not in user_cols:
with engine.connect() as conn:
conn.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0'))
conn.commit()
with engine.connect() as conn:
for col_name, col_def in new_columns.items():
if col_name not in existing:
conn.execute(text(f'ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}'))
conn.commit()
def get_session() -> Generator[Session, None, None]:
+34 -18
View File
@@ -27,11 +27,15 @@ class FavoritesManager:
url: str,
provider: str,
metadata: Optional[Dict] = None,
poster_url: Optional[str] = None
poster_url: Optional[str] = None,
user_id: str = "default"
) -> Dict:
"""Add an anime to favorites"""
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()
if existing:
@@ -53,17 +57,21 @@ class FavoritesManager:
url=url,
provider=provider,
anime_metadata=metadata or {},
poster_url=poster_url
poster_url=poster_url,
user_id=user_id
)
session.add(fav)
session.commit()
session.refresh(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"""
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()
if existing:
session.delete(existing)
@@ -71,10 +79,13 @@ class FavoritesManager:
return True
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"""
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()
if existing:
return self._to_dict(existing)
@@ -82,6 +93,7 @@ class FavoritesManager:
async def list_favorites(
self,
user_id: str = "default",
sort_by: str = "created_at",
order: str = "desc",
filter_provider: Optional[str] = None,
@@ -89,11 +101,11 @@ class FavoritesManager:
) -> List[Dict]:
"""List all favorites with optional sorting and filtering"""
with Session(engine) as session:
statement = select(FavoriteTable)
statement = select(FavoriteTable).where(FavoriteTable.user_id == user_id)
if filter_provider:
statement = statement.where(FavoriteTable.provider == filter_provider)
# SQLite JSON filtering for genres is complex, handle it in Python
results = session.exec(statement).all()
favorites = [self._to_dict(fav) for fav in results]
@@ -123,10 +135,13 @@ class FavoritesManager:
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"""
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
async def toggle_favorite(
@@ -136,21 +151,22 @@ class FavoritesManager:
url: str,
provider: str,
metadata: Optional[Dict] = None,
poster_url: Optional[str] = None
poster_url: Optional[str] = None,
user_id: str = "default"
) -> Dict:
"""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:
await self.remove_favorite(anime_id)
await self.remove_favorite(anime_id, user_id=user_id)
return {"action": "removed", "anime_id": anime_id}
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}
async def get_stats(self) -> Dict:
async def get_stats(self, user_id: str = "default") -> Dict:
"""Get statistics about favorites"""
favorites = await self.list_favorites()
favorites = await self.list_favorites(user_id=user_id)
total = len(favorites)
# Count by provider
+1
View File
@@ -12,6 +12,7 @@ class UserBase(SQLModel):
email: Optional[str] = Field(default=None, index=True)
full_name: Optional[str] = None
is_active: bool = Field(default=True)
is_admin: bool = Field(default=False)
class UserTable(UserBase, table=True):
+23
View File
@@ -14,6 +14,19 @@ class AppSettingsBase(SQLModel):
# Store list of disabled providers as a JSON string
disabled_providers_json: str = Field(default="[]", sa_column=Column(String))
# #9: Filter for recommendations section ("all", "anime", "series")
recommendations_filter: str = Field(default="all", sa_column=Column(String))
# #10: Filter for latest releases section ("all", "anime", "series")
releases_filter: str = Field(default="all", sa_column=Column(String))
# #11: Enable/disable categories
anime_enabled: bool = Field(default=True)
series_enabled: bool = Field(default=True)
# #12: Custom download directory
download_dir: str = Field(default="downloads")
@property
def disabled_providers(self) -> List[str]:
@@ -46,6 +59,11 @@ class AppSettings(BaseModel):
default_lang: str = "vostfr"
theme: str = "dark"
disabled_providers: List[str] = []
recommendations_filter: str = "all"
releases_filter: str = "all"
anime_enabled: bool = True
series_enabled: bool = True
download_dir: str = "downloads"
class Config:
from_attributes = True
@@ -56,3 +74,8 @@ class AppSettingsUpdate(BaseModel):
default_lang: Optional[str] = None
theme: Optional[str] = None
disabled_providers: Optional[List[str]] = None
recommendations_filter: Optional[str] = None
releases_filter: Optional[str] = None
anime_enabled: Optional[bool] = None
series_enabled: Optional[bool] = None
download_dir: Optional[str] = None
+5
View File
@@ -285,6 +285,7 @@ class RecommendationEngine:
'score': 9.09,
'episodes': 64,
'status': 'Finished Airing',
'type': 'TV',
'genres': ['Action', 'Adventure', 'Fantasy'],
'synopsis': 'Two brothers lose their mother to an incurable disease. With the power of alchemy, they use taboo knowledge to resurrect her. The process fails, and as a toll for crossing into the realm of God, they lose their bodies.',
'images': {},
@@ -298,6 +299,7 @@ class RecommendationEngine:
'score': 8.51,
'episodes': 75,
'status': 'Finished Airing',
'type': 'TV',
'genres': ['Action', 'Drama', 'Fantasy'],
'synopsis': 'Centuries ago, mankind was slaughtered to near extinction by monstrous humanoid creatures called titans. To protect what remains, humanity built walls and lived peacefully for a hundred years.',
'images': {},
@@ -311,6 +313,7 @@ class RecommendationEngine:
'score': 8.63,
'episodes': 37,
'status': 'Finished Airing',
'type': 'TV',
'genres': ['Mystery', 'Police', 'Psychological'],
'synopsis': 'A shinigami, as a god of death, can kill any person—provided they see their victim\'s face and write their victim\'s name in a notebook called a Death Note.',
'images': {},
@@ -324,6 +327,7 @@ class RecommendationEngine:
'score': 8.48,
'episodes': 26,
'status': 'Finished Airing',
'type': 'TV',
'genres': ['Action', 'Adventure', 'Supernatural'],
'synopsis': 'It is the Taisho Period in Japan. Tanjiro, a kindhearted boy who sells charcoal for a living, finds his family slaughtered by a demon. To make matters worse, his younger sister Nezuko is turned into a demon.',
'images': {},
@@ -337,6 +341,7 @@ class RecommendationEngine:
'score': 8.35,
'episodes': 24,
'status': 'Finished Airing',
'type': 'TV',
'genres': ['Action', 'Supernatural'],
'synopsis': 'Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a friend who has been attacked by curses, he eats the finger of a curse.',
'images': {},
+15 -6
View File
@@ -92,7 +92,12 @@ class AnimeReleasesFetcher:
data = response.json()
anime_list = []
for anime in data.get('data', [])[:20]:
seen_mal_ids = set()
for anime in data.get('data', []):
mal_id = anime.get('mal_id')
if not mal_id or mal_id in seen_mal_ids:
continue
seen_mal_ids.add(mal_id)
anime_list.append({
'title': anime.get('title', ''),
'title_japanese': anime.get('title_japanese', ''),
@@ -105,9 +110,10 @@ class AnimeReleasesFetcher:
'cover_image': self._extract_cover_image(anime),
'images': anime.get('images', {}),
'url': anime.get('url', ''),
'mal_id': anime.get('mal_id')
'mal_id': mal_id,
'type': anime.get('type', '')
})
return anime_list
return anime_list[:20]
except Exception as e:
logger.error(f"Error fetching seasonal anime: {e}", exc_info=True)
return []
@@ -143,7 +149,8 @@ class AnimeReleasesFetcher:
'cover_image': self._extract_cover_image(anime),
'broadcast': anime.get('broadcast', {}),
'url': anime.get('url', ''),
'mal_id': anime.get('mal_id')
'mal_id': anime.get('mal_id'),
'type': anime.get('type', '')
})
return anime_list
except Exception as e:
@@ -178,7 +185,8 @@ class AnimeReleasesFetcher:
'cover_image': self._extract_cover_image(anime),
'images': anime.get('images', {}),
'url': anime.get('url', ''),
'mal_id': anime.get('mal_id')
'mal_id': anime.get('mal_id'),
'type': anime.get('type', '')
})
return anime_list
except Exception as e:
@@ -209,7 +217,8 @@ class AnimeReleasesFetcher:
'cover_image': self._extract_cover_image(anime),
'images': anime.get('images', {}),
'url': anime.get('url', ''),
'mal_id': anime.get('mal_id')
'mal_id': anime.get('mal_id'),
'type': anime.get('type', '')
})
return anime_list
except Exception as e:
+2
View File
@@ -13,6 +13,7 @@ from app.routers.router_player import router as player_router
from .router_static import router as static_router
from .router_root import router as root_router
from .router_settings import router as settings_router
from .router_admin import router as admin_router
__all__ = [
"auth_router",
@@ -26,5 +27,6 @@ __all__ = [
"static_router",
"root_router",
"settings_router",
"admin_router",
]
+165
View File
@@ -0,0 +1,165 @@
"""
Admin panel routes for Ohm Stream Downloader API.
"""
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from app.database import get_session, engine
from app.models.auth import User, UserTable
from app.routers.router_auth import get_current_user_from_token
router = APIRouter(prefix="/api/admin", tags=["admin"])
templates = Jinja2Templates(directory="templates")
async def require_admin(current_user: User = Depends(get_current_user_from_token)) -> User:
"""Dependency that requires the current user to be an admin."""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
@router.get("/users")
async def list_users(
current_user: User = Depends(require_admin),
):
"""List all users (admin only)"""
with Session(engine) as session:
statement = select(UserTable)
users = session.exec(statement).all()
return {
"users": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"full_name": u.full_name,
"is_active": u.is_active,
"is_admin": u.is_admin,
"created_at": u.created_at.isoformat() if u.created_at else None,
"last_login": u.last_login.isoformat() if u.last_login else None,
}
for u in users
],
"total": len(users),
}
@router.get("/stats")
async def get_admin_stats(
current_user: User = Depends(require_admin),
):
"""Get admin dashboard statistics"""
from app.download_manager import DownloadManager
from main import download_manager
with Session(engine) as session:
total_users = len(session.exec(select(UserTable)).all())
active_users = len(session.exec(select(UserTable).where(UserTable.is_active == True)).all())
admin_users = len(session.exec(select(UserTable).where(UserTable.is_admin == True)).all())
tasks = download_manager.get_all_tasks()
total_downloads = len(tasks)
completed_downloads = len([t for t in tasks if t.status == "completed"])
active_downloads = len([t for t in tasks if t.status in ("downloading", "pending")])
return {
"users": {
"total": total_users,
"active": active_users,
"admins": admin_users,
},
"downloads": {
"total": total_downloads,
"completed": completed_downloads,
"active": active_downloads,
},
}
@router.put("/users/{user_id}/toggle-active")
async def toggle_user_active(
user_id: str,
response: Response,
current_user: User = Depends(require_admin),
):
"""Activate or deactivate a user"""
with Session(engine) as session:
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot modify your own account")
user.is_active = not user.is_active
session.add(user)
session.commit()
status = "active" if user.is_active else "inactive"
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {user.username} is now {status}", "type": "success"}}}}'
return {"id": user_id, "is_active": user.is_active}
@router.put("/users/{user_id}/toggle-admin")
async def toggle_user_admin(
user_id: str,
response: Response,
current_user: User = Depends(require_admin),
):
"""Promote or demote a user to/from admin"""
with Session(engine) as session:
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot modify your own admin status")
user.is_admin = not user.is_admin
session.add(user)
session.commit()
role = "admin" if user.is_admin else "user"
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{user.username} is now {role}", "type": "success"}}}}'
return {"id": user_id, "is_admin": user.is_admin}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
response: Response,
current_user: User = Depends(require_admin),
):
"""Delete a user"""
with Session(engine) as session:
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
username = user.username
session.delete(user)
session.commit()
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {username} deleted", "type": "info"}}}}'
return {"deleted": user_id}
@router.get("/ui")
async def get_admin_ui(
request: Request,
current_user: Optional[User] = Depends(get_current_user_from_token),
):
"""Get admin panel UI"""
if current_user is None or not current_user.is_admin:
from app.routers.router_auth import get_optional_user
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
with Session(engine) as session:
users = session.exec(select(UserTable)).all()
return templates.TemplateResponse(
"components/admin_panel.html",
{"request": request, "users": users, "current_user": current_user},
)
+20 -2
View File
@@ -174,10 +174,28 @@ async def search_anime_unified(
if url and url not in seen_urls:
seen_urls.add(url)
if q.lower() in (item_dict.get("title") or "").lower():
# Fuzzy relevance scoring
title = (item_dict.get("title") or "").lower()
query_lower = q.lower()
# Exact match
if query_lower == title:
item_dict["_relevance_boost"] = 1.0
else:
# Title starts with query
elif title.startswith(query_lower):
item_dict["_relevance_boost"] = 0.95
# Query is a substring of title
elif query_lower in title:
item_dict["_relevance_boost"] = 0.85
# Words from query all appear in title
elif all(word in title.split() for word in query_lower.split() if len(word) > 1):
item_dict["_relevance_boost"] = 0.7
# At least one word matches
elif any(word in title.split() for word in query_lower.split() if len(word) > 2):
item_dict["_relevance_boost"] = 0.5
else:
item_dict["_relevance_boost"] = 0.3
results[pid].append(item_dict)
# Prepare enrichment task for top 15 results per provider
+4
View File
@@ -54,6 +54,7 @@ async def get_current_user_from_token(
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
created_at=user.created_at,
last_login=user.last_login,
)
@@ -79,6 +80,7 @@ async def get_optional_user(
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
created_at=user.created_at,
last_login=user.last_login,
)
@@ -108,6 +110,7 @@ async def register(user_data: UserCreate):
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
created_at=user.created_at,
last_login=user.last_login,
)
@@ -174,6 +177,7 @@ async def get_me(current_user: User = Depends(get_current_user_from_token)):
"email": current_user.email,
"full_name": current_user.full_name,
"is_active": current_user.is_active,
"is_admin": current_user.is_admin,
"created_at": current_user.created_at,
"last_login": current_user.last_login,
}
+92 -9
View File
@@ -2,13 +2,17 @@
Download management routes for Ohm Stream Downloader API.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
import json
from typing import Optional
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, FileResponse
from app.download_manager import DownloadManager
from app.models import DownloadRequest
from app.routers.router_auth import get_current_user_from_token
from app.models import DownloadRequest, DownloadStatus
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"])
templates = Jinja2Templates(directory="templates")
@@ -24,20 +28,28 @@ async def get_downloads(
request: Request,
html: bool = Query(False),
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."""
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")
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:
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
return templates.TemplateResponse(
"components/downloads_list.html",
{"request": request, "tasks": tasks}
)
print(f"[DOWNLOADS] API Request. Returning JSON.")
return {"downloads": tasks}
@@ -56,8 +68,12 @@ async def create_download(
async def get_download_status(
task_id: str,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
):
"""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)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
@@ -106,6 +122,73 @@ async def cancel_download(
raise HTTPException(status_code=400, detail="Failed to cancel download")
@router.get("/video/{task_id}")
async def stream_video(
task_id: str,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
):
"""Stream a completed download as video"""
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED or not task.file_path:
raise HTTPException(status_code=400, detail="Download not completed")
file_path = Path(task.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
media_types = {
".mp4": "video/mp4", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo",
".webm": "video/webm", ".flv": "video/x-flv",
}
media_type = media_types.get(file_path.suffix.lower(), "video/mp4")
return FileResponse(str(file_path), media_type=media_type)
@router.post("/{task_id}/retry")
async def retry_download(
task_id: str,
background_tasks: BackgroundTasks,
response: Response,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: User = Depends(get_current_user_from_token),
):
"""Retry a failed or cancelled download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status not in ("failed", "cancelled"):
raise HTTPException(status_code=400, detail="Can only retry failed or cancelled downloads")
task.status = DownloadStatus.PENDING
task.progress = 0.0
if hasattr(download_manager, "_process_download"):
background_tasks.add_task(download_manager._process_download, task_id)
response.headers["HX-Trigger"] = json.dumps(
{"show-toast": {"message": "Telechargement relance", "type": "success"}}
)
return {"status": "retrying"}
@router.post("/cancel-all")
async def cancel_all_downloads(
response: Response,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: User = Depends(get_current_user_from_token),
):
"""Cancel all active downloads"""
count = 0
for tid, task in list(download_manager.tasks.items()):
if task.status in ("downloading", "pending"):
download_manager.cancel_download(tid) if hasattr(download_manager, "cancel_download") else download_manager.tasks.pop(tid, None)
count += 1
response.headers["HX-Trigger"] = json.dumps(
{"show-toast": {"message": f"{count} telechargement(s) annule(s)", "type": "info"}}
)
return {"status": "cancelled", "count": count}
@router.post("/cleanup")
async def cleanup_completed(
download_manager: DownloadManager = Depends(get_download_manager),
+56 -12
View File
@@ -2,24 +2,42 @@
Favorites management routes for Ohm Stream Downloader API.
"""
from fastapi import APIRouter, HTTPException
from fastapi.requests import Request
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates
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"])
templates = Jinja2Templates(directory="templates")
@router.get("")
async def list_favorites(
request: Request,
sort_by: str = "created_at",
order: str = "desc",
filter_provider: str = None,
filter_genre: str = None,
filter_provider: Optional[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"""
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()
favorites = await fav_manager.list_favorites(
user_id=current_user.id,
sort_by=sort_by,
order=order,
filter_provider=filter_provider,
@@ -38,7 +56,11 @@ async def list_favorites(
@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"""
data = await request.json()
@@ -51,6 +73,7 @@ async def add_favorite(request: Request):
fav_manager = get_favorites_manager()
favorite = await fav_manager.add_favorite(
user_id=current_user.id,
anime_id=data["anime_id"],
title=data["title"],
url=data["url"],
@@ -59,34 +82,45 @@ async def add_favorite(request: Request):
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}
@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"""
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:
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}
@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"""
fav_manager = get_favorites_manager()
stats = await fav_manager.get_stats()
stats = await fav_manager.get_stats(user_id=current_user.id)
return stats
@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"""
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:
raise HTTPException(status_code=404, detail="Favorite not found")
@@ -95,7 +129,11 @@ async def get_favorite(anime_id: str):
@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"""
data = await request.json()
@@ -108,6 +146,7 @@ async def toggle_favorite(request: Request):
fav_manager = get_favorites_manager()
result = await fav_manager.toggle_favorite(
user_id=current_user.id,
anime_id=data["anime_id"],
title=data["title"],
url=data["url"],
@@ -116,4 +155,9 @@ async def toggle_favorite(request: Request):
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
+28 -3
View File
@@ -6,10 +6,12 @@ import hashlib
from datetime import datetime
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 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"])
templates = Jinja2Templates(directory="templates")
@@ -26,14 +28,30 @@ async def get_recommendations(
request: Request,
limit: int = 15,
html: bool = Query(False),
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
current_user: Optional[User] = Depends(get_optional_user),
):
"""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")
try:
recommendations = await engine.get_personalized_recommendations(limit=limit)
# Filter by content_type if specified
if content_type and content_type != "all":
recommendations = [r for r in recommendations if r.get("content_type", r.get("type", "")) == content_type]
if html or request.headers.get("HX-Request"):
if html or is_htmx:
return templates.TemplateResponse(
"components/recommendations_list.html",
{"request": request, "recommendations": recommendations}
@@ -53,12 +71,17 @@ async def get_latest_releases(
request: Request,
limit: int = 20,
html: bool = Query(False),
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
):
"""Get latest anime releases"""
from app.recommendations import get_latest_releases_with_info
try:
releases = await get_latest_releases_with_info(limit=limit)
# Filter by content_type if specified
if content_type and content_type != "all":
releases = [r for r in releases if r.get("content_type", r.get("type", "")) == content_type]
if html or request.headers.get("HX-Request"):
return templates.TemplateResponse(
@@ -140,7 +163,9 @@ async def get_top_anime(
@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"""
engine = RecommendationEngine(download_dir="downloads")
+21
View File
@@ -39,6 +39,11 @@ async def get_settings(
default_lang=settings_obj.default_lang,
theme=settings_obj.theme,
disabled_providers=settings_obj.disabled_providers,
recommendations_filter=getattr(settings_obj, 'recommendations_filter', 'all'),
releases_filter=getattr(settings_obj, 'releases_filter', 'all'),
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
series_enabled=getattr(settings_obj, 'series_enabled', True),
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
)
@@ -65,6 +70,22 @@ async def update_settings(
settings_obj.theme = update_data.theme
if update_data.disabled_providers is not None:
settings_obj.disabled_providers = update_data.disabled_providers
if update_data.recommendations_filter is not None:
settings_obj.recommendations_filter = update_data.recommendations_filter
if update_data.releases_filter is not None:
settings_obj.releases_filter = update_data.releases_filter
if update_data.anime_enabled is not None:
# Prevent disabling both categories
if not update_data.anime_enabled and not getattr(settings_obj, 'series_enabled', True):
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
settings_obj.anime_enabled = update_data.anime_enabled
if update_data.series_enabled is not None:
# Prevent disabling both categories
if not update_data.series_enabled and not getattr(settings_obj, 'anime_enabled', True):
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
settings_obj.series_enabled = update_data.series_enabled
if update_data.download_dir is not None:
settings_obj.download_dir = update_data.download_dir
session.add(settings_obj)
session.commit()
+26 -6
View File
@@ -68,14 +68,19 @@ async def test_sonarr_webhook(request: Request):
@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"""
sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_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"""
sonarr_handler = get_sonarr_handler()
try:
@@ -87,14 +92,19 @@ async def update_sonarr_config(config: SonarrConfig):
@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"""
sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_mappings()
@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"""
sonarr_handler = get_sonarr_handler()
mapping = sonarr_handler.get_mapping(series_id)
@@ -104,7 +114,10 @@ async def get_sonarr_mapping(series_id: int):
@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"""
sonarr_handler = get_sonarr_handler()
try:
@@ -116,7 +129,10 @@ async def create_sonarr_mapping(mapping: SonarrMapping):
@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"""
sonarr_handler = get_sonarr_handler()
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"),
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()
@@ -152,6 +169,7 @@ 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()
@@ -174,6 +192,7 @@ 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()
@@ -195,6 +214,7 @@ async def suggest_anime_mapping(
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
+10 -9
View File
@@ -47,7 +47,7 @@ async def add_to_watchlist(
current_user: User = Depends(get_current_user_from_token),
):
"""Add an anime to the watchlist"""
from main import watchlist_manager
from app.watchlist import watchlist_manager
try:
existing = watchlist_manager.get_by_anime_url(
@@ -81,7 +81,7 @@ async def get_watchlist(
html: bool = Query(False),
current_user: Optional[User] = Depends(get_optional_user),
):
from main import watchlist_manager
from app.watchlist import watchlist_manager
is_htmx = request.headers.get("HX-Request")
@@ -108,7 +108,7 @@ async def get_watchlist_settings(
current_user: User = Depends(get_current_user_from_token),
):
"""Get global watchlist settings"""
from main import watchlist_manager
from app.watchlist import watchlist_manager
return watchlist_manager.get_settings()
@@ -120,7 +120,8 @@ async def update_watchlist_settings(
current_user: User = Depends(get_current_user_from_token),
):
"""Update global watchlist settings"""
from main import auto_download_scheduler, watchlist_manager
from app.auto_download_scheduler import auto_download_scheduler
from app.watchlist import watchlist_manager
try:
updated_settings = watchlist_manager.update_settings(settings)
@@ -148,7 +149,7 @@ async def get_watchlist_item(
current_user: User = Depends(get_current_user_from_token),
):
"""Get a specific watchlist item"""
from main import watchlist_manager
from app.watchlist import watchlist_manager
item = watchlist_manager.get_by_id(item_id)
if not item or item.user_id != current_user.id:
@@ -164,7 +165,7 @@ async def update_watchlist_item(
current_user: User = Depends(get_current_user_from_token),
):
"""Update a watchlist item"""
from main import watchlist_manager
from app.watchlist import watchlist_manager
item = watchlist_manager.get_by_id(item_id)
if not item or item.user_id != current_user.id:
@@ -190,7 +191,7 @@ async def delete_from_watchlist(
current_user: User = Depends(get_current_user_from_token),
):
"""Remove an anime from the watchlist"""
from main import watchlist_manager
from app.watchlist import watchlist_manager
item = watchlist_manager.get_by_id(item_id)
if not item or item.user_id != current_user.id:
@@ -219,7 +220,7 @@ async def check_watchlist_now(
current_user: User = Depends(get_current_user_from_token),
):
"""Trigger an immediate check for new episodes"""
from main import auto_download_scheduler
from app.auto_download_scheduler import auto_download_scheduler
background_tasks.add_task(auto_download_scheduler.trigger_check_now)
response.headers["HX-Trigger"] = json.dumps(
@@ -239,6 +240,6 @@ async def get_watchlist_stats(
current_user: User = Depends(get_current_user_from_token),
):
"""Get watchlist statistics for the user"""
from main import watchlist_manager
from app.watchlist import watchlist_manager
return watchlist_manager.get_stats(current_user.id)
+2
View File
@@ -144,6 +144,7 @@ from app.routers import (
static_router,
root_router,
settings_router,
admin_router,
)
@@ -159,6 +160,7 @@ app.include_router(sonarr_router)
app.include_router(player_router)
app.include_router(static_router)
app.include_router(settings_router)
app.include_router(admin_router)
if __name__ == "__main__":
+685 -140
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
<div class="settings-container section-container">
<div class="section-header">
<h2>Administration</h2>
</div>
<!-- Stats Cards -->
<div id="admin-stats" class="admin-stats-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px;">
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
<div style="font-size: 2rem; font-weight: 800; color: var(--primary);" id="stat-total-users">{{ users|length }}</div>
<div style="color: var(--text-dim); font-size: 0.85rem;">Utilisateurs</div>
</div>
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
<div style="font-size: 2rem; font-weight: 800; color: var(--accent);" id="stat-active-users">{{ users|selectattr('is_active')|list|length }}</div>
<div style="color: var(--text-dim); font-size: 0.85rem;">Actifs</div>
</div>
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
<div style="font-size: 2rem; font-weight: 800; color: #f0a500;" id="stat-admin-users">{{ users|selectattr('is_admin')|list|length }}</div>
<div style="color: var(--text-dim); font-size: 0.85rem;">Admins</div>
</div>
</div>
<!-- Users Table -->
<div style="background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); overflow: hidden;">
<div style="padding: 20px 25px; border-bottom: 1px solid rgba(255,255,255,0.05);">
<h3 style="margin: 0; color: var(--primary);">Gestion des utilisateurs</h3>
</div>
{% if users %}
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">
<th style="padding: 12px 20px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Utilisateur</th>
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Email</th>
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Statut</th>
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Role</th>
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Derniere connexion</th>
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Inscription</th>
<th style="padding: 12px 20px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr style="border-bottom: 1px solid rgba(255,255,255,0.03); {% if not user.is_active %}opacity: 0.5;{% endif %}">
<td style="padding: 12px 20px;">
<div style="font-weight: 600;">{{ user.username }}</div>
{% if user.full_name %}
<div style="font-size: 0.8rem; color: var(--text-dim);">{{ user.full_name }}</div>
{% endif %}
</td>
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.9rem;">{{ user.email or '-' }}</td>
<td style="padding: 12px 15px; text-align: center;">
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_active %}rgba(0,255,136,0.15); color: var(--accent){% else %}rgba(255,77,77,0.15); color: var(--danger){% endif %};">
{% if user.is_active %}Actif{% else %}Inactif{% endif %}
</span>
</td>
<td style="padding: 12px 15px; text-align: center;">
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_admin %}rgba(240,165,0,0.15); color: #f0a500{% else %}rgba(255,255,255,0.05); color: var(--text-dim){% endif %};">
{% if user.is_admin %}Admin{% else %}User{% endif %}
</span>
</td>
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
{{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else '-' }}
</td>
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '-' }}
</td>
<td style="padding: 12px 20px; text-align: center; white-space: nowrap;">
{% if user.id != current_user.id %}
<button class="btn btn-sm {% if user.is_active %}btn-secondary{% else %}btn-accent{% endif %}"
hx-put="/api/admin/users/{{ user.id }}/toggle-active" hx-swap="none"
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
title="{% if user.is_active %}Desactiver{% else %}Activer{% endif %}">
{% if user.is_active %}Desactiver{% else %}Activer{% endif %}
</button>
<button class="btn btn-sm {% if user.is_admin %}btn-secondary{% else %}btn-accent{% endif %}"
hx-put="/api/admin/users/{{ user.id }}/toggle-admin" hx-swap="none"
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
title="{% if user.is_admin %}Retrograder{% else %}Promouvoir{% endif %}">
{% if user.is_admin %}Retrograder{% else %}Admin{% endif %}
</button>
<button class="btn btn-sm btn-danger"
hx-delete="/api/admin/users/{{ user.id }}" hx-swap="none"
hx-confirm="Supprimer {{ user.username }} ?"
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
title="Supprimer">
<i class="fas fa-trash"></i>
</button>
{% else %}
<span style="color: var(--text-dim); font-size: 0.8rem;">Vous</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="padding: 40px; text-align: center; color: var(--text-dim);">Aucun utilisateur</div>
{% endif %}
</div>
</div>
@@ -92,7 +92,7 @@
</div>
<button class="sr-btn sr-btn-follow"
hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
<i class="fas fa-plus"></i> Suivre
+18 -8
View File
@@ -1,7 +1,7 @@
{% if tasks %}
<div class="downloads-grid">
{% for task in tasks %}
<div class="download-item task-{{ task.status }}">
<div class="download-item status-{{ task.status }}">
<div class="download-info">
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
<span class="badge badge-{{ task.status }}">{{ task.status | upper }}</span>
@@ -19,28 +19,38 @@
<div class="download-actions">
{% if task.status == 'downloading' or task.status == 'pending' %}
<button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none">
<button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Pause">
<i class="fas fa-pause"></i>
</button>
{% elif task.status == 'paused' %}
<button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none">
<button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Reprendre">
<i class="fas fa-play"></i>
</button>
{% endif %}
{% if task.status == 'failed' or task.status == 'cancelled' %}
<button class="btn-icon warning" hx-post="/api/downloads/{{ task.id }}/retry" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Relancer">
<i class="fas fa-redo"></i>
</button>
{% endif %}
{% if task.status == 'completed' %}
<a href="/video/{{ task.id }}" class="btn-icon success" title="Voir la vidéo">
<i class="fas fa-external-link-alt"></i>
<a href="/api/downloads/video/{{ task.id }}" class="btn-icon success" title="Streamer">
<i class="fas fa-play-circle"></i>
</a>
<a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Télécharger le fichier">
<a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Telecharger">
<i class="fas fa-file-download"></i>
</a>
{% endif %}
<button class="btn-icon danger"
hx-delete="/api/downloads/{{ task.id }}"
hx-confirm="Supprimer ce téléchargement ?"
hx-confirm="Supprimer ce telechargement ?"
hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')"
title="Supprimer">
<i class="fas fa-trash"></i>
</button>
@@ -51,6 +61,6 @@
{% else %}
<div class="empty-state" style="text-align: center; padding: 60px 20px; color: var(--text-dim);">
<i class="fas fa-cloud-download-alt" style="font-size: 3rem; margin-bottom: 20px; opacity: 0.1; display: block;"></i>
<p>Aucun téléchargement en cours</p>
<p>Aucun telechargement en cours</p>
</div>
{% endif %}
+20 -4
View File
@@ -1,12 +1,20 @@
<div class="section-container">
<div class="section-header">
<h2>📥 Téléchargements</h2>
<h2>Telechargements <span id="activeDownloadsCount" class="active-downloads-counter" style="display:none;">(0 actif)</span></h2>
<div class="header-actions">
<button class="btn btn-sm btn-secondary"
hx-post="/api/downloads/cleanup"
hx-swap="none"
hx-confirm="Nettoyer tous les telechargements termines ?"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
Nettoyer terminés
<i class="fas fa-broom"></i> Nettoyer termines
</button>
<button class="btn btn-sm btn-danger"
hx-post="/api/downloads/cancel-all"
hx-swap="none"
hx-confirm="Annuler tous les telechargements actifs ?"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
<i class="fas fa-stop-circle"></i> Tout annuler
</button>
</div>
</div>
@@ -17,12 +25,20 @@
hx-trigger="load, refresh, every 3s"
hx-swap="innerHTML">
<div class="loading-placeholder">
<div class="spinner"></div> Chargement des téléchargements...
<div class="spinner"></div> Chargement des telechargements...
</div>
</div>
</div>
<style>
.section-container { margin-bottom: 40px; }
/* Styles already defined or moved to downloads_list.html */
.active-downloads-counter {
font-size: 0.85rem;
font-weight: 600;
color: var(--primary);
background: rgba(0, 217, 255, 0.1);
padding: 2px 10px;
border-radius: 12px;
margin-left: 10px;
}
</style>
@@ -71,7 +71,7 @@
</div>
<button class="sr-btn sr-btn-follow"
hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
<i class="fas fa-plus"></i> Suivre
+157 -12
View File
@@ -1,23 +1,23 @@
<div class="settings-container section-container">
<div class="section-header">
<h2>⚙️ Paramètres</h2>
<h2>Parametres</h2>
</div>
<!-- General Preferences -->
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
<h3 style="margin-bottom: 20px; color: var(--primary);">Général</h3>
<h3 style="margin-bottom: 20px; color: var(--primary);">General</h3>
<form hx-patch="/api/settings" hx-swap="none" hx-ext="json-enc" class="settings-form">
<form id="settings-form" class="settings-form">
<div class="form-group">
<label for="default_lang">Langue par défaut</label>
<label for="default_lang">Langue par defaut</label>
<select name="default_lang" id="default_lang" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
<option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR (Version Originale Sous-Titrée Français)</option>
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF (Version Française)</option>
<option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR</option>
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF</option>
</select>
</div>
<div class="form-group" style="margin-top: 20px;">
<label for="theme">Thème</label>
<label for="theme">Theme</label>
<select name="theme" id="theme" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Sombre (Premium Dark)</option>
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Clair (Soon)</option>
@@ -25,18 +25,76 @@
</select>
</div>
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;">
<i class="fas fa-save"></i> Enregistrer les préférences
<div class="form-group" style="margin-top: 20px;">
<label for="download_dir">Repertoire de telechargement</label>
<div style="display: flex; gap: 8px;">
<input type="text" name="download_dir" id="download_dir" value="{{ settings.download_dir }}"
class="btn btn-secondary" style="flex: 1; text-align: left; padding: 12px 15px;">
</div>
<small style="color: var(--text-dim); font-size: 12px; margin-top: 5px; display: block;">
Repertoire ou les fichiers seront telecharges (defaut: downloads/)
</small>
</div>
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;" onclick="event.preventDefault(); saveSettings();">
<i class="fas fa-save"></i> Enregistrer les preferences
</button>
</form>
</div>
<!-- Content Filters -->
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
<h3 style="margin-bottom: 20px; color: var(--primary);">Filtres de contenu</h3>
<div class="form-group">
<label for="recommendations_filter">Recommande pour vous : afficher</label>
<select name="recommendations_filter" id="recommendations_filter" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="saveFilter('recommendations_filter', this.value)">
<option value="all" {% if settings.recommendations_filter == 'all' %}selected{% endif %}>Les deux (Animes + Series)</option>
<option value="anime" {% if settings.recommendations_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
<option value="series" {% if settings.recommendations_filter == 'series' %}selected{% endif %}>Series uniquement</option>
</select>
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="releases_filter">Dernieres sorties : afficher</label>
<select name="releases_filter" id="releases_filter" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="saveFilter('releases_filter', this.value)">
<option value="all" {% if settings.releases_filter == 'all' %}selected{% endif %}>Les deux (Animes + Series)</option>
<option value="anime" {% if settings.releases_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
<option value="series" {% if settings.releases_filter == 'series' %}selected{% endif %}>Series uniquement</option>
</select>
</div>
</div>
<!-- Categories -->
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
<h3 style="margin-bottom: 20px; color: var(--primary);">Categories</h3>
<p style="color: var(--text-dim); font-size: 13px; margin-bottom: 15px;">Activez ou desactivez les categories. Au moins une doit rester active.</p>
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
<label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<div>
<div style="font-weight: 600; font-size: 1.1rem;">Animes</div>
<div style="font-size: 0.8rem; color: var(--text-dim);">Films et series anime</div>
</div>
<input type="checkbox" id="anime_enabled" {% if settings.anime_enabled %}checked{% endif %} onchange="toggleCategory('anime_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);">
</label>
<label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<div>
<div style="font-weight: 600; font-size: 1.1rem;">Series TV</div>
<div style="font-size: 0.8rem; color: var(--text-dim);">Series americaines et europeennes</div>
</div>
<input type="checkbox" id="series_enabled" {% if settings.series_enabled %}checked{% endif %} onchange="toggleCategory('series_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);">
</label>
</div>
</div>
<!-- Providers Management -->
<div class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0; color: var(--primary);">Disponibilité des Fournisseurs</h3>
<h3 style="margin: 0; color: var(--primary);">Disponibilite des Fournisseurs</h3>
<button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none">
<i class="fas fa-sync-alt"></i> Forcer vérification
<i class="fas fa-sync-alt"></i> Forcer verification
</button>
</div>
@@ -61,7 +119,7 @@
hx-swap="none"
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')"
style="min-width: 100px;">
{% if provider.enabled %}Désactiver{% else %}Activer{% endif %}
{% if provider.enabled %}Desactiver{% else %}Activer{% endif %}
</button>
</div>
{% endfor %}
@@ -69,6 +127,93 @@
</div>
</div>
<script>
function getToken() {
return localStorage.getItem('auth_token') || null;
}
async function saveSettings() {
const token = getToken();
if (!token) return;
const data = {
default_lang: document.getElementById('default_lang').value,
theme: document.getElementById('theme').value,
download_dir: document.getElementById('download_dir').value,
};
try {
const r = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (r.ok) {
showToast('Preferences enregistrees', 'success');
}
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
async function saveFilter(field, value) {
const token = getToken();
if (!token) return;
try {
const r = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value })
});
if (r.ok) {
showToast('Filtre mis a jour', 'success');
}
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
async function toggleCategory(field, value) {
const token = getToken();
if (!token) return;
// Prevent disabling both
if (!value) {
const otherField = field === 'anime_enabled' ? 'series_enabled' : 'anime_enabled';
const otherCheckbox = document.getElementById(otherField);
if (otherCheckbox && !otherCheckbox.checked) {
showToast('Au moins une categorie doit rester active', 'error');
document.getElementById(field).checked = true;
return;
}
}
try {
const r = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value })
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
showToast(err.detail || 'Erreur', 'error');
document.getElementById(field).checked = !value;
} else {
showToast('Categorie ' + (value ? 'activee' : 'desactivee'), 'success');
}
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
document.getElementById(field).checked = !value;
}
}
function showToast(message, type) {
const event = new CustomEvent('show-toast', { detail: { message, type } });
document.dispatchEvent(event);
}
</script>
<style>
.settings-form label {
display: block;
+488 -36
View File
@@ -1,39 +1,491 @@
{% if items %}
<div class="watchlist-grid">
{% for item in items %}
<div class="watchlist-item card" id="watchlist-{{ item.id }}">
<div class="item-poster">
<img src="{{ item.poster_image or '/static/img/no-poster.png' }}" alt="{{ item.anime_title }}">
</div>
<div class="item-info">
<h3>{{ item.anime_title }}</h3>
<div class="item-meta">
<span class="badge">{{ item.provider_id }}</span>
<span class="badge badge-{{ item.status }}">{{ item.status }}</span>
{% set status_filter = request.query_params.get('status', 'all') %}
<div class="watchlist-container" x-data="{ currentFilter: '{{ status_filter }}' }">
<!-- Filter Tabs -->
<div class="filter-tabs">
<button class="filter-tab {{ 'active' if status_filter == 'all' or not status_filter else '' }}"
hx-get="/api/watchlist?status=all"
hx-target="#watchlist-items-container"
hx-swap="outerHTML"
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
<i class="fas fa-list"></i> Tous
</button>
<button class="filter-tab {{ 'active' if status_filter == 'active' else '' }}"
hx-get="/api/watchlist?status=active"
hx-target="#watchlist-items-container"
hx-swap="outerHTML"
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
<i class="fas fa-play"></i> Actifs
</button>
<button class="filter-tab {{ 'active' if status_filter == 'paused' else '' }}"
hx-get="/api/watchlist?status=paused"
hx-target="#watchlist-items-container"
hx-swap="outerHTML"
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
<i class="fas fa-pause"></i> En pause
</button>
<button class="filter-tab {{ 'active' if status_filter == 'completed' else '' }}"
hx-get="/api/watchlist?status=completed"
hx-target="#watchlist-items-container"
hx-swap="outerHTML"
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
<i class="fas fa-check"></i> Terminés
</button>
</div>
<!-- Watchlist Items Grid -->
{% if items and items | length > 0 %}
<div class="watchlist-grid">
{% for item in items %}
<div class="watchlist-card" id="watchlist-{{ item.id }}" data-item-id="{{ item.id }}">
<!-- Poster -->
<div class="watchlist-poster">
<img src="{{ item.poster_image or item.cover_image or '/static/img/no-poster.png' }}"
alt="{{ item.anime_title }}"
onerror="this.src='/static/img/no-poster.png'">
<div class="poster-badge {{ item.status }}">
{% if item.status == 'active' %}
<i class="fas fa-play"></i> Actif
{% elif item.status == 'paused' %}
<i class="fas fa-pause"></i> En pause
{% elif item.status == 'completed' %}
<i class="fas fa-check"></i> Terminé
{% else %}
<i class="fas fa-archive"></i> Archivé
{% endif %}
</div>
{% if item.auto_download %}
<div class="auto-download-badge">
<i class="fas fa-magic"></i> Auto
</div>
{% endif %}
</div>
<div class="item-stats">
<span>Épisode: {{ item.last_episode_downloaded }}</span>
</div>
<div class="item-actions">
<button class="btn btn-sm btn-primary"
hx-get="/api/anime/episodes?url={{ item.anime_url | urlencode }}"
hx-target="#player-container">
<i class="fas fa-play"></i>
</button>
<button class="btn btn-sm btn-danger"
hx-delete="/api/watchlist/{{ item.id }}"
hx-target="#watchlist-{{ item.id }}"
hx-swap="outerHTML"
hx-confirm="Retirer de la watchlist ?">
<i class="fas fa-trash"></i>
</button>
<!-- Content -->
<div class="watchlist-content">
<h3 class="watchlist-title">{{ item.anime_title }}</h3>
<div class="watchlist-meta">
<span class="meta-provider">
<i class="fas fa-tv"></i> {{ item.provider_id | upper }}
</span>
<span class="meta-lang">{{ item.lang | upper }}</span>
{% if item.quality_preference and item.quality_preference != 'auto' %}
<span class="meta-quality">{{ item.quality_preference }}</span>
{% endif %}
</div>
{% if item.synopsis %}
<p class="watchlist-synopsis">{{ item.synopsis | truncate(150) }}</p>
{% endif %}
<div class="watchlist-stats">
<span class="stat">
<i class="fas fa-download"></i>
Ép. {{ item.last_episode_downloaded }}
{% if item.total_episodes %}
/ {{ item.total_episodes }}
{% endif %}
</span>
{% if item.added_at %}
<span class="stat" title="Ajouté le {{ item.added_at.strftime('%d/%m/%Y') }}">
<i class="fas fa-calendar"></i>
{{ item.added_at.strftime('%d/%m/%Y') }}
</span>
{% endif %}
</div>
<!-- Actions -->
<div class="watchlist-actions">
<!-- Pause/Resume Toggle -->
{% if item.status == 'active' %}
<button class="action-btn btn-pause"
hx-put="/api/watchlist/{{ item.id }}"
hx-vals='{"status": "paused"}'
hx-swap="none"
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
title="Mettre en pause">
<i class="fas fa-pause"></i>
</button>
{% elif item.status == 'paused' %}
<button class="action-btn btn-resume"
hx-put="/api/watchlist/{{ item.id }}"
hx-vals='{"status": "active"}'
hx-swap="none"
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
title="Reprendre">
<i class="fas fa-play"></i>
</button>
{% endif %}
<!-- Mark as completed -->
{% if item.status not in ['completed', 'archived'] %}
<button class="action-btn btn-complete"
hx-put="/api/watchlist/{{ item.id }}"
hx-vals='{"status": "completed"}'
hx-swap="none"
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
title="Marquer comme terminé">
<i class="fas fa-check"></i>
</button>
{% endif %}
<!-- Delete -->
<button class="action-btn btn-delete"
hx-delete="/api/watchlist/{{ item.id }}"
hx-target="#watchlist-{{ item.id }}"
hx-swap="outerHTML"
hx-confirm="Supprimer '{{ item.anime_title }}' de votre watchlist ?"
title="Supprimer">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>Votre watchlist est vide.</p>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="watchlist-empty">
<i class="fas fa-inbox"></i>
<h3>Votre watchlist est vide</h3>
<p>Ajoutez des animes ou séries depuis l'onglet Recherche pour commencer à suivre leurs sorties.</p>
<button class="btn btn-primary" @click="$dispatch('set-tab', {tab: 'anime'})">
<i class="fas fa-search"></i> Rechercher des animes
</button>
</div>
{% endif %}
</div>
<style>
/* Container */
.watchlist-container {
display: flex;
flex-direction: column;
gap: 20px;
}
/* Filter Tabs */
.filter-tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 12px;
margin-bottom: 8px;
}
.filter-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border: none;
background: transparent;
color: var(--text-dim);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
border-radius: var(--input-radius);
transition: var(--transition);
}
.filter-tab:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
}
.filter-tab.active {
background: var(--primary);
color: var(--bg-dark);
}
/* Grid */
.watchlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
/* Card */
.watchlist-card {
display: flex;
gap: 16px;
background: var(--bg-card);
border-radius: var(--card-radius);
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
transition: var(--transition);
}
.watchlist-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 24px rgba(0, 217, 255, 0.15);
transform: translateY(-2px);
}
/* Poster */
.watchlist-poster {
position: relative;
flex-shrink: 0;
width: 100px;
aspect-ratio: 2/3;
border-radius: 8px;
overflow: hidden;
background: var(--bg-dark);
}
.watchlist-poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.poster-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 4px;
}
.poster-badge.active {
background: rgba(0, 255, 136, 0.9);
color: var(--bg-dark);
}
.poster-badge.paused {
background: rgba(255, 193, 7, 0.9);
color: var(--bg-dark);
}
.poster-badge.completed {
background: rgba(156, 39, 176, 0.9);
color: var(--bg-dark);
}
.poster-badge.archived {
background: rgba(255, 255, 255, 0.15);
color: var(--text-dim);
}
.auto-download-badge {
position: absolute;
bottom: 8px;
left: 8px;
padding: 4px 10px;
border-radius: 20px;
background: rgba(0, 217, 255, 0.9);
color: var(--bg-dark);
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 4px;
}
/* Content */
.watchlist-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.watchlist-title {
font-size: 1rem;
font-weight: 700;
margin: 0;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watchlist-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 0.75rem;
}
.meta-provider,
.meta-lang,
.meta-quality {
padding: 3px 10px;
border-radius: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-provider {
background: rgba(0, 217, 255, 0.15);
color: var(--primary);
border: 1px solid rgba(0, 217, 255, 0.3);
}
.meta-lang {
background: rgba(255, 107, 107, 0.15);
color: var(--secondary);
border: 1px solid rgba(255, 107, 107, 0.3);
}
.meta-quality {
background: rgba(0, 255, 136, 0.15);
color: var(--accent);
border: 1px solid rgba(0, 255, 136, 0.3);
}
.watchlist-synopsis {
font-size: 0.8rem;
color: var(--text-dim);
margin: 0;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.watchlist-stats {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.75rem;
color: var(--text-dim);
}
.stat {
display: flex;
align-items: center;
gap: 4px;
}
/* Actions */
.watchlist-actions {
display: flex;
gap: 6px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: var(--transition);
background: rgba(255, 255, 255, 0.05);
color: var(--text-dim);
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-main);
}
.btn-pause {
color: #ffc107;
}
.btn-pause:hover {
background: rgba(255, 193, 7, 0.15);
}
.btn-resume {
color: var(--accent);
}
.btn-resume:hover {
background: rgba(0, 255, 136, 0.15);
}
.btn-complete {
color: #9c27b0;
}
.btn-complete:hover {
background: rgba(156, 39, 176, 0.15);
}
.btn-delete {
color: #f44336;
}
.btn-delete:hover {
background: rgba(244, 67, 54, 0.15);
}
/* Empty State */
.watchlist-empty {
text-align: center;
padding: 80px 40px;
background: var(--bg-card);
border-radius: var(--card-radius);
border: 1px dashed rgba(255, 255, 255, 0.1);
}
.watchlist-empty i {
font-size: 4rem;
color: var(--text-dim);
opacity: 0.3;
margin-bottom: 20px;
display: block;
}
.watchlist-empty h3 {
font-size: 1.3rem;
margin-bottom: 8px;
color: var(--text-main);
}
.watchlist-empty p {
color: var(--text-dim);
margin-bottom: 24px;
}
/* Responsive */
@media (max-width: 768px) {
.watchlist-grid {
grid-template-columns: 1fr;
}
.filter-tabs {
overflow-x: auto;
flex-wrap: nowrap;
}
.filter-tab {
white-space: nowrap;
}
.watchlist-card {
flex-direction: column;
align-items: center;
text-align: center;
}
.watchlist-poster {
width: 140px;
}
.watchlist-meta,
.watchlist-stats {
justify-content: center;
}
}
</style>
+24 -22
View File
@@ -12,7 +12,7 @@
<div id="tab-anime" class="tab-content" x-show="activeTab === 'anime'">
<!-- Anime Search Section -->
<div class="section-header">
<h2>🎬 Rechercher un Anime</h2>
<h2>Rechercher un Anime</h2>
</div>
<div class="url-form">
<form hx-get="/api/anime/search"
@@ -38,9 +38,6 @@
<div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary);">
<div class="spinner"></div> Recherche en cours...
</div>
<div style="margin-top: 15px; padding: 12px; background: rgba(0, 217, 255, 0.05); border: 1px solid rgba(0, 217, 255, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
💡 <strong>Astuce :</strong> La recherche unifiée explore plusieurs sources pour trouver vos animes préférés.
</div>
</div>
<!-- Anime search results -->
@@ -51,11 +48,11 @@
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
<!-- Latest Releases Section -->
<!-- Latest Releases Section - Anime only -->
<div class="section-header">
<h2>🔥 Dernières sorties Anime</h2>
<h2>Dernieres sorties Anime</h2>
<button class="btn btn-secondary btn-small"
hx-get="/api/releases/latest"
hx-get="/api/releases/latest?content_type=anime&html=1"
hx-target="#animeReleasesList">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
@@ -63,13 +60,13 @@
Actualiser
</button>
</div>
<div id="animeReleasesList" class="recommendations-carousel" hx-get="/api/releases/latest" hx-trigger="load delay:500ms"></div>
<div id="animeReleasesList" class="recommendations-carousel" hx-get="/api/releases/latest?content_type=anime&html=1" hx-trigger="load delay:500ms"></div>
</div>
<div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
<!-- Series Search Section -->
<div class="section-header">
<h2>📺 Rechercher une Série TV</h2>
<h2>Rechercher une Serie TV</h2>
</div>
<div class="url-form">
<form hx-get="/api/series/search"
@@ -82,7 +79,7 @@
type="text"
name="q"
id="seriesSearchInput"
placeholder="Rechercher une série (ex: Breaking Bad, Game of Thrones...)"
placeholder="Rechercher une serie (ex: Breaking Bad, Game of Thrones...)"
required
>
<button type="submit" class="btn btn-primary btn-search">
@@ -95,9 +92,6 @@
<div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary);">
<div class="spinner"></div> Recherche en cours...
</div>
<div style="margin-top: 15px; padding: 12px; background: rgba(255, 107, 107, 0.05); border: 1px solid rgba(255, 107, 107, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
💡 <strong>Info:</strong> La recherche utilise FS7 pour trouver des séries TV américaines et européennes.
</div>
</div>
<!-- Series search results -->
@@ -105,11 +99,11 @@
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
<!-- Recommendations Section -->
<!-- Recommendations Section - Series only -->
<div class="section-header">
<h2>🎯 Recommandé pour vous</h2>
<h2>Recommande pour vous</h2>
<button class="btn btn-secondary btn-small"
hx-get="/api/recommendations"
hx-get="/api/recommendations?content_type=series&html=1"
hx-target="#seriesRecommendationsList">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
@@ -117,13 +111,13 @@
Actualiser
</button>
</div>
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations" hx-trigger="load delay:600ms"></div>
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations?content_type=series&html=1" hx-trigger="load delay:600ms"></div>
<!-- Latest Releases Section -->
<!-- Latest Releases Section - Series only -->
<div class="section-header" style="margin-top: 40px;">
<h2>🔥 Dernières sorties Séries TV</h2>
<h2>Dernieres sorties Series TV</h2>
<button class="btn btn-secondary btn-small"
hx-get="/api/releases/latest"
hx-get="/api/releases/latest?content_type=series&html=1"
hx-target="#seriesReleasesList">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
@@ -131,7 +125,7 @@
Actualiser
</button>
</div>
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest" hx-trigger="load delay:700ms"></div>
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest?content_type=series&html=1" hx-trigger="load delay:700ms"></div>
</div>
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
@@ -145,7 +139,15 @@
<div id="tab-settings" class="tab-content" x-show="activeTab === 'settings'">
<div hx-get="/api/settings/ui" hx-trigger="set-tab[detail.tab === 'settings'] from:window, refresh-settings" hx-swap="innerHTML">
<div class="loading-placeholder">
<div class="spinner"></div> Chargement des paramètres...
<div class="spinner"></div> Chargement des parametres...
</div>
</div>
</div>
<div id="tab-admin" class="tab-content" x-show="activeTab === 'admin'">
<div id="admin-panel-content" hx-get="/api/admin/ui" hx-trigger="set-tab[detail.tab === 'admin'] from:window" hx-swap="innerHTML">
<div class="loading-placeholder">
<div class="spinner"></div> Chargement du panel admin...
</div>
</div>
</div>
+164
View File
@@ -0,0 +1,164 @@
import { test, expect } from '@playwright/test';
/**
* User Journey E2E Tests
*
* Simulates a complete user flow: register → login → browse → search → settings → logout.
* All tests are serial because they share browser state (auth token, navigation).
*
* FORBIDDEN: Do NOT use page.waitForTimeout() — use waitForResponse() or waitForSelector()
*/
test.describe('User Journey E2E', () => {
test.describe.configure({ mode: 'serial' });
const testData = {
username: `e2e_user_${Date.now()}`,
password: 'TestPass123!',
};
// Register a new user account via the UI form
test('should register a new user', async ({ page }) => {
await page.goto('/login');
// Switch to the register tab
await page.click('text=Inscription');
// Fill out the registration form
await page.fill('#registerUsername', testData.username);
await page.fill('#registerPassword', testData.password);
await page.fill('#registerPasswordConfirm', testData.password);
// Submit and wait for the API response
const [response] = await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/auth/register')),
page.click('#registerSubmit'),
]);
// Registration should succeed (201 or 200)
expect(response.status()).toBeLessThan(400);
// Verify the success message appears
await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 10000 });
const successText = await page.locator('#authSuccess').textContent();
expect(successText).toMatch(/réussie|inscription/i);
});
// Login with the credentials registered in the previous test
test('should login with registered credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('#loginUsername', testData.username);
await page.fill('#loginPassword', testData.password);
// Submit and wait for the login API response
const [response] = await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
page.click('#loginSubmit'),
]);
expect(response.status()).toBeLessThan(400);
// Verify success message
await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 10000 });
const successText = await page.locator('#authSuccess').textContent();
expect(successText).toMatch(/réussie/i);
// Wait for redirect to /web
await page.waitForURL('**/web**', { timeout: 10000 });
// Verify the auth token is stored in localStorage
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
expect(token).toBeTruthy();
});
// Browse the homepage — verify layout loads without JS errors
test('should browse homepage without errors', async ({ page }) => {
// Collect JS page errors
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
// Ensure we are on /web (carried over from login)
if (!page.url().includes('/web')) {
await page.goto('/web');
}
// Wait for main content area to be visible
await page.locator('#main-content').waitFor({ state: 'visible', timeout: 10000 });
// Verify the header heading
await expect(page.locator('header h1')).toContainText('Ohm Stream');
// Verify at least one navigation tab is visible
await expect(page.locator('.tab').first()).toBeVisible();
// Verify the user info panel (logged-in state indicator)
await expect(page.locator('#userInfo')).toBeVisible();
// No JavaScript errors should have been thrown
expect(errors).toHaveLength(0);
});
// Search for an anime via the Anime tab — results may be empty but the UI must respond
test('should search for anime', async ({ page }) => {
// Navigate to the Anime tab
await page.click('.tab:has-text("Anime")');
await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 });
// Fill the search input — HTMX debounce triggers the request automatically
await page.fill('#animeSearchInput', 'Naruto');
// Wait for either results, an empty-state message, or the loading spinner to disappear
await Promise.race([
page.locator('#animeSearchResults .sr-card').first().waitFor({ timeout: 15000 }),
page.locator('#animeSearchResults .sr-empty').first().waitFor({ timeout: 15000 }),
page.locator('#search-loading').waitFor({ state: 'detached', timeout: 15000 }),
]);
// The search results container must be present regardless of result count
await expect(page.locator('#animeSearchResults')).toBeVisible();
});
// Change a setting (language) and verify the PATCH response and toast notification
test('should update settings', async ({ page }) => {
// Open the settings tab
await page.click('.tab:has-text("Paramètres")');
// Settings panel is loaded dynamically via HTMX — wait for the form
await page.locator('#default_lang').waitFor({ state: 'visible', timeout: 15000 });
// Change the default language
await page.selectOption('#default_lang', 'vf');
// Submit the settings form and capture the PATCH response
const [response] = await Promise.all([
page.waitForResponse(
(resp) => resp.url().includes('/api/settings') && resp.request().method() === 'PATCH'
),
page.locator('form[hx-patch="/api/settings"] button[type="submit"]').click(),
]);
expect(response.status()).toBe(200);
// Verify a toast notification appears confirming the save
await page.locator('.toast').first().waitFor({ state: 'visible', timeout: 5000 });
});
// Logout — verify the API call succeeds, redirect happens, and token is cleared
test('should logout successfully', async ({ page }) => {
// Click the logout button and wait for the API response
const [response] = await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/auth/logout')),
page.locator('#userInfo button:has-text("Déconnexion")').click(),
]);
expect(response.status()).toBeLessThan(400);
// Should be redirected back to the login page
await page.waitForURL('**/login**', { timeout: 10000 });
// The auth token must be cleared from localStorage
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
expect(token).toBeNull();
});
});
+587
View File
@@ -0,0 +1,587 @@
"""
End-to-end user journey tests for Ohm Stream Downloader.
These tests verify complete user workflows including authentication,
search, settings management, and download operations.
All tests use mocked providers and in-memory SQLite — no real network calls.
"""
import os
import time
import uuid
# FORCE DATABASE_URL to in-memory for ALL tests before ANY app imports
os.environ["DATABASE_URL"] = "sqlite://"
import pytest
from typing import Dict, List, Optional, Tuple
from unittest.mock import AsyncMock, Mock, patch
from fastapi.testclient import TestClient
from main import app
# =============================================================================
# MOCK DATA CONSTANTS
# =============================================================================
MOCK_ANIME_SEARCH_RESULTS: Dict[str, List[Dict]] = {
"anime-sama": [
{
"title": "Naruto Shippuden",
"url": "https://anime-sama.si/catalogue/naruto/saison1/vostfr/",
"cover_image": "https://example.com/naruto.jpg",
"type": "search_result",
},
{
"title": "One Piece",
"url": "https://anime-sama.si/catalogue/one-piece/saison1/vostfr/",
"cover_image": "https://example.com/onepiece.jpg",
"type": "search_result",
},
],
"anime-ultime": [
{
"title": "Naruto Shippuden",
"url": "https://www.anime-ultime.net/naruto-shippuden",
"cover_image": "https://example.com/naruto-au.jpg",
"type": "search_result",
},
],
}
MOCK_SERIES_SEARCH_RESULTS: Dict[str, Dict] = {
"Breaking Bad": {
"base_name": "Breaking Bad",
"cover": "https://example.com/bb.jpg",
"synopsis": "A chemistry teacher turns to manufacturing methamphetamine...",
"seasons": {
1: [
{
"id": "fs7",
"url": "https://fs7.fr/breaking-bad/saison-1",
"provider_id": "fs7",
}
],
2: [
{
"id": "fs7",
"url": "https://fs7.fr/breaking-bad/saison-2",
"provider_id": "fs7",
}
],
},
}
}
MOCK_EPISODE_LIST: List[Dict] = [
{
"episode": 1,
"url": "https://example.com/video1.mp4|https://anime-sama.si/catalogue/naruto/s1/ep1|Naruto - Episode 1",
},
{
"episode": 2,
"url": "https://example.com/video2.mp4|https://anime-sama.si/catalogue/naruto/s1/ep2|Naruto - Episode 2",
},
{
"episode": 3,
"url": "https://example.com/video3.mp4|https://anime-sama.si/catalogue/naruto/s1/ep3|Naruto - Episode 3",
},
{
"episode": 4,
"url": "https://example.com/video4.mp4|https://anime-sama.si/catalogue/naruto/s1/ep4|Naruto - Episode 4",
},
{
"episode": 5,
"url": "https://example.com/video5.mp4|https://anime-sama.si/catalogue/naruto/s1/ep5|Naruto - Episode 5",
},
]
MOCK_DOWNLOAD_LINK: Tuple[str, str] = (
"https://example.com/video1.mp4",
"Naruto_Episode_1.mp4",
)
# =============================================================================
# FIXTURES
# =============================================================================
@pytest.fixture
def journey_client():
"""
Create a TestClient with a registered and logged-in user.
Uses 'with' context manager to ensure startup events fire
and database tables are properly initialized.
"""
unique_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
test_username = f"journey_user_{unique_id}"
test_password = "TestPassword123!"
test_email = f"{test_username}@test.example.com"
with TestClient(app) as client:
register_response = client.post(
"/api/auth/register",
json={
"username": test_username,
"password": test_password,
"email": test_email,
},
)
assert register_response.status_code == 200, (
f"Registration failed: {register_response.json()}"
)
login_response = client.post(
"/api/auth/login",
json={
"username": test_username,
"password": test_password,
},
)
assert login_response.status_code == 200, (
f"Login failed: {login_response.json()}"
)
access_token = login_response.json()["access_token"]
client.headers["Authorization"] = f"Bearer {access_token}"
yield client
app.dependency_overrides.clear()
@pytest.fixture
def unauth_client():
"""Create a plain TestClient without authentication."""
with TestClient(app) as client:
yield client
# =============================================================================
# TEST CLASSES
# =============================================================================
class TestAuthJourney:
"""Tests for the authentication user journey — registration, login, tokens."""
def test_register_new_user(self):
unique_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
username = f"auth_test_{unique_id}"
with TestClient(app) as client:
response = client.post(
"/api/auth/register",
json={"username": username, "password": "SecurePass123!"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["user"]["username"] == username
assert "id" in data["user"]
def test_register_duplicate_user(self):
username = f"dup_{uuid.uuid4().hex[:8]}"
with TestClient(app) as client:
first = client.post(
"/api/auth/register",
json={"username": username, "password": "SecurePass123!"},
)
assert first.status_code == 200
second = client.post(
"/api/auth/register",
json={"username": username, "password": "SecurePass123!"},
)
assert second.status_code in (400, 500)
def test_login_success(self):
unique_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
username = f"login_test_{unique_id}"
password = "SecurePass123!"
with TestClient(app) as client:
client.post(
"/api/auth/register",
json={"username": username, "password": password},
)
response = client.post(
"/api/auth/login",
json={"username": username, "password": password},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert data["user"]["username"] == username
def test_login_wrong_password(self):
unique_id = f"{int(time.time())}_{uuid.uuid4().hex[:8]}"
username = f"wrong_pw_{unique_id}"
with TestClient(app) as client:
client.post(
"/api/auth/register",
json={"username": username, "password": "CorrectPass123!"},
)
with TestClient(app) as client:
response = client.post(
"/api/auth/login",
json={"username": username, "password": "WrongPass999!"},
)
assert response.status_code == 401
def test_get_current_user(self, journey_client):
response = journey_client.get("/api/auth/me")
assert response.status_code == 200
data = response.json()
assert "user" in data
assert "username" in data["user"]
assert "id" in data["user"]
def test_unauthorized_access(self):
with TestClient(app) as client:
response = client.get("/api/auth/me")
assert response.status_code == 403
class TestSearchJourney:
"""Tests for the search user journey — anime/series search, episodes, metadata."""
@patch("app.routers.router_anime.providers_manager")
@patch("app.routers.router_anime.get_metadata_enricher")
def test_search_anime(self, mock_enricher, mock_pm, journey_client):
mock_provider = Mock()
mock_provider.id = "anime-sama"
mock_provider.search = AsyncMock(
return_value=[
Mock(
model_dump=Mock(
return_value={
"title": "Naruto Shippuden",
"url": "https://anime-sama.si/catalogue/naruto/s1/vostfr/",
"cover_image": "https://example.com/naruto.jpg",
"type": "search_result",
}
)
),
]
)
mock_pm.get_active_providers.return_value = [mock_provider]
mock_meta = AsyncMock()
mock_meta.enrich_metadata = AsyncMock(return_value=None)
mock_enricher.return_value = mock_meta
response = journey_client.get("/api/anime/search?q=naruto")
assert response.status_code == 200
data = response.json()
assert data["query"] == "naruto"
assert "results" in data
@patch("app.routers.router_anime.providers_manager")
@patch("app.routers.router_anime.get_metadata_enricher")
def test_search_anime_results_have_providers(
self, mock_enricher, mock_pm, journey_client
):
mock_provider = Mock()
mock_provider.id = "anime-sama"
mock_provider.search = AsyncMock(
return_value=[
Mock(
model_dump=Mock(
return_value={
"title": "Naruto Shippuden",
"url": "https://anime-sama.si/catalogue/naruto/s1/vostfr/",
"cover_image": "https://example.com/naruto.jpg",
"type": "search_result",
}
)
),
]
)
mock_pm.get_active_providers.return_value = [mock_provider]
mock_meta = AsyncMock()
mock_meta.enrich_metadata = AsyncMock(return_value=None)
mock_enricher.return_value = mock_meta
response = journey_client.get("/api/anime/search?q=naruto")
data = response.json()
assert "anime-sama" in data["results"]
@patch("app.routers.router_anime.get_series_providers")
@patch("app.routers.router_anime.get_metadata_enricher")
def test_search_series(self, mock_enricher, mock_series_providers, journey_client):
mock_series_providers.return_value = {"fs7": {"name": "FS7"}}
mock_meta = AsyncMock()
mock_meta.enrich_metadata = AsyncMock(return_value=None)
mock_enricher.return_value = mock_meta
mock_fs7_instance = Mock()
mock_fs7_instance.search_anime = AsyncMock(
return_value=[
{
"title": "Breaking Bad",
"url": "https://fs7.fr/breaking-bad/saison-1",
"cover_image": "https://example.com/bb.jpg",
}
]
)
with patch.dict(
"sys.modules",
{
"app.downloaders.series_sites.fs7": Mock(
FS7Downloader=Mock(return_value=mock_fs7_instance)
)
},
):
response = journey_client.get("/api/series/search?q=breaking+bad")
assert response.status_code == 200
data = response.json()
assert data["query"] == "breaking bad"
assert "results" in data
@patch("app.routers.router_anime.providers_manager")
@patch("app.routers.router_anime.get_metadata_enricher")
def test_search_anime_no_results(self, mock_enricher, mock_pm, journey_client):
mock_provider = Mock()
mock_provider.id = "anime-sama"
mock_provider.search = AsyncMock(return_value=[])
mock_pm.get_active_providers.return_value = [mock_provider]
mock_meta = AsyncMock()
mock_meta.enrich_metadata = AsyncMock(return_value=None)
mock_enricher.return_value = mock_meta
response = journey_client.get("/api/anime/search?q=test_no_results_xyz")
assert response.status_code == 200
data = response.json()
assert "results" in data
assert "anime-sama" not in data["results"]
@patch("app.routers.router_anime.get_downloader")
def test_get_episodes(self, mock_get_dl):
mock_dl = Mock()
mock_dl.get_episodes = AsyncMock(return_value=MOCK_EPISODE_LIST)
mock_get_dl.return_value = mock_dl
with TestClient(app) as client:
response = client.get(
"/api/anime/episodes?url=https://anime-sama.si/test&lang=vostfr"
)
assert response.status_code == 200
data = response.json()
assert data["lang"] == "vostfr"
assert len(data["episodes"]) == 5
assert data["episodes"][0]["episode"] == 1
@patch("app.routers.router_anime.get_downloader")
def test_get_anime_metadata(self, mock_get_dl):
mock_dl = Mock()
mock_dl.get_anime_metadata = AsyncMock(
return_value={
"synopsis": "A ninja story",
"genres": ["Action", "Adventure"],
"rating": "8.5/10",
}
)
mock_dl.hasattr = Mock(return_value=True)
mock_get_dl.return_value = mock_dl
with TestClient(app) as client:
response = client.get(
"/api/anime/metadata?url=https://anime-sama.si/naruto"
)
assert response.status_code == 200
data = response.json()
assert data["url"] == "https://anime-sama.si/naruto"
assert data["metadata"]["synopsis"] == "A ninja story"
class TestSettingsJourney:
"""Tests for the settings management user journey — preferences, providers, theme."""
def test_get_default_settings(self, journey_client):
response = journey_client.get("/api/settings")
assert response.status_code == 200
data = response.json()
assert data["default_lang"] == "vostfr"
assert data["theme"] == "dark"
def test_update_lang(self, journey_client):
response = journey_client.patch("/api/settings", json={"default_lang": "vf"})
assert response.status_code == 200
data = response.json()
assert data["default_lang"] == "vf"
verify = journey_client.get("/api/settings")
assert verify.json()["default_lang"] == "vf"
def test_update_theme(self, journey_client):
response = journey_client.patch("/api/settings", json={"theme": "oled"})
assert response.status_code == 200
assert response.json()["theme"] == "oled"
def test_settings_persist_across_requests(self, journey_client):
journey_client.patch(
"/api/settings",
json={
"default_lang": "vf",
"theme": "oled",
},
)
first = journey_client.get("/api/settings").json()
second = journey_client.get("/api/settings").json()
assert first["default_lang"] == "vf"
assert first["theme"] == "oled"
assert second["default_lang"] == first["default_lang"]
assert second["theme"] == first["theme"]
@patch("app.routers.router_settings.providers_manager")
def test_get_providers_availability(self, mock_pm, journey_client):
mock_pm.get_all_status.return_value = {
"anime-sama": {"status": "up"},
"fs7": {"status": "up"},
}
response = journey_client.get("/api/settings/providers/availability")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
provider_ids = {p["id"] for p in data}
assert len(provider_ids) > 0
@patch("app.routers.router_settings.providers_manager")
def test_toggle_provider(self, mock_pm, journey_client):
mock_pm.get_all_status.return_value = {}
toggle = journey_client.post("/api/settings/providers/anime-sama/toggle")
assert toggle.status_code == 200
data = toggle.json()
assert data["id"] == "anime-sama"
assert data["enabled"] is False
toggle_back = journey_client.post("/api/settings/providers/anime-sama/toggle")
assert toggle_back.json()["enabled"] is True
settings = journey_client.get("/api/settings").json()
assert "anime-sama" not in settings["disabled_providers"]
class TestDownloadJourney:
"""Tests for the download management user journey — create, list, status, cancel."""
@patch("app.routers.router_anime.get_download_manager")
def test_create_single_download(self, mock_get_dm, journey_client):
import tempfile
from pathlib import Path
from app.download_manager import DownloadManager
tmp_dir = Path(tempfile.mkdtemp()) / "downloads"
tmp_dir.mkdir(exist_ok=True)
manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1)
mock_get_dm.return_value = manager
response = journey_client.post(
"/api/anime/download?url=https://example.com/video.mp4"
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
@patch("app.routers.router_downloads.get_download_manager")
def test_list_downloads(self, mock_get_dm, journey_client):
import tempfile
from pathlib import Path
from app.download_manager import DownloadManager
tmp_dir = Path(tempfile.mkdtemp()) / "downloads"
tmp_dir.mkdir(exist_ok=True)
manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1)
mock_get_dm.return_value = manager
journey_client.post(
"/api/downloads",
json={"url": "https://example.com/video1.mp4"},
)
response = journey_client.get("/api/downloads")
assert response.status_code == 200
data = response.json()
assert "downloads" in data
assert len(data["downloads"]) >= 1
@patch("app.routers.router_downloads.get_download_manager")
def test_download_task_status(self, mock_get_dm, journey_client):
import tempfile
from pathlib import Path
from app.download_manager import DownloadManager
tmp_dir = Path(tempfile.mkdtemp()) / "downloads"
tmp_dir.mkdir(exist_ok=True)
manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1)
mock_get_dm.return_value = manager
task_resp = journey_client.post(
"/api/downloads",
json={"url": "https://example.com/status_test.mp4"},
)
task_id = task_resp.json()["id"]
status_resp = journey_client.get(f"/api/downloads/{task_id}")
assert status_resp.status_code == 200
assert status_resp.json()["id"] == task_id
@patch("app.routers.router_downloads.get_download_manager")
def test_cancel_download(self, mock_get_dm, journey_client):
import tempfile
from pathlib import Path
from app.download_manager import DownloadManager
tmp_dir = Path(tempfile.mkdtemp()) / "downloads"
tmp_dir.mkdir(exist_ok=True)
manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1)
mock_get_dm.return_value = manager
task_resp = journey_client.post(
"/api/downloads",
json={"url": "https://example.com/cancel_test.mp4"},
)
assert task_resp.status_code == 200
task_id = task_resp.json()["id"]
cancel_resp = journey_client.delete(f"/api/downloads/{task_id}")
assert cancel_resp.status_code == 200
@patch("app.routers.router_anime.get_download_manager")
@patch("app.routers.router_anime.get_downloader")
def test_download_season(self, mock_get_dl, mock_get_dm, journey_client):
import tempfile
from pathlib import Path
from app.download_manager import DownloadManager
mock_dl = Mock()
mock_dl.get_episodes = AsyncMock(return_value=MOCK_EPISODE_LIST)
mock_get_dl.return_value = mock_dl
tmp_dir = Path(tempfile.mkdtemp()) / "downloads"
tmp_dir.mkdir(exist_ok=True)
manager = DownloadManager(download_dir=str(tmp_dir), max_parallel=1)
mock_get_dm.return_value = manager
mock_get_dm.return_value = manager
response = journey_client.post(
"/api/anime/download-season?url=https://anime-sama.si/test&lang=vostfr"
)
assert response.status_code == 200
data = response.json()
assert data["total_episodes"] == 5
assert len(data["task_ids"]) == 5