Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87f245d3fc | |||
| 9e53579b36 | |||
| 0179ddbdf4 | |||
| 693615a7dc | |||
| 7529449f86 | |||
| 555816bf30 | |||
| 2da2a5bb27 | |||
| c921aafadd | |||
| e5b30741fe | |||
| 0af537e032 | |||
| 9f9df600c1 | |||
| 5d264d8f3b | |||
| c0f9c0c1c4 |
@@ -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**
|
||||
**Dernière mise à jour : Avril 2026**
|
||||
**Développé avec ❤️ pour la communauté anime**
|
||||
|
||||
@@ -23,9 +23,46 @@ def create_db_and_tables():
|
||||
from app.models.favorites import FavoriteTable
|
||||
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
||||
from app.models.settings import AppSettingsTable
|
||||
from app.models.download import DownloadTaskTable
|
||||
|
||||
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]:
|
||||
"""Dependency for getting a database session"""
|
||||
|
||||
@@ -2,13 +2,16 @@ import asyncio
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
import httpx
|
||||
from app.models import DownloadTask, DownloadStatus, DownloadRequest
|
||||
from app.models.download import DownloadTaskTable
|
||||
from app.database import engine
|
||||
from sqlmodel import Session, select
|
||||
from app.downloaders import get_downloader
|
||||
from app.utils import sanitize_filename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,6 +27,92 @@ class DownloadManager:
|
||||
self.active_downloads: Dict[str, asyncio.Task] = {}
|
||||
self._semaphore = asyncio.Semaphore(max_parallel)
|
||||
|
||||
# ==================== DB Persistence ====================
|
||||
|
||||
def _save_task_to_db(self, task: DownloadTask) -> None:
|
||||
"""Persist a download task to the database (upsert)."""
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
existing = session.get(DownloadTaskTable, task.id)
|
||||
if existing:
|
||||
existing.url = task.url
|
||||
existing.filename = task.filename
|
||||
existing.host = task.host.value if hasattr(task.host, 'value') else str(task.host)
|
||||
existing.status = task.status.value if hasattr(task.status, 'value') else str(task.status)
|
||||
existing.progress = task.progress
|
||||
existing.downloaded_bytes = task.downloaded_bytes
|
||||
existing.total_bytes = task.total_bytes
|
||||
existing.speed = task.speed
|
||||
existing.error = task.error
|
||||
existing.started_at = task.started_at
|
||||
existing.completed_at = task.completed_at
|
||||
existing.file_path = task.file_path
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
else:
|
||||
db_task = DownloadTaskTable(
|
||||
id=task.id,
|
||||
url=task.url,
|
||||
filename=task.filename,
|
||||
host=task.host.value if hasattr(task.host, 'value') else str(task.host),
|
||||
status=task.status.value if hasattr(task.status, 'value') else str(task.status),
|
||||
progress=task.progress,
|
||||
downloaded_bytes=task.downloaded_bytes,
|
||||
total_bytes=task.total_bytes,
|
||||
speed=task.speed,
|
||||
error=task.error,
|
||||
created_at=task.created_at,
|
||||
started_at=task.started_at,
|
||||
completed_at=task.completed_at,
|
||||
file_path=task.file_path,
|
||||
)
|
||||
session.add(db_task)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save task {task.id} to DB: {e}", exc_info=True)
|
||||
|
||||
def _delete_task_from_db(self, task_id: str) -> None:
|
||||
"""Remove a download task from the database."""
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
db_task = session.get(DownloadTaskTable, task_id)
|
||||
if db_task:
|
||||
session.delete(db_task)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete task {task_id} from DB: {e}", exc_info=True)
|
||||
|
||||
def _load_tasks_from_db(self) -> None:
|
||||
"""Load persisted download tasks from the database into memory."""
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
statement = select(DownloadTaskTable)
|
||||
db_tasks = session.exec(statement).all()
|
||||
for db_task in db_tasks:
|
||||
if db_task.id not in self.tasks:
|
||||
task = DownloadTask(
|
||||
id=db_task.id,
|
||||
url=db_task.url,
|
||||
filename=db_task.filename,
|
||||
host="other",
|
||||
status=DownloadStatus(db_task.status),
|
||||
progress=db_task.progress,
|
||||
downloaded_bytes=db_task.downloaded_bytes,
|
||||
total_bytes=db_task.total_bytes,
|
||||
speed=db_task.speed,
|
||||
error=db_task.error,
|
||||
created_at=db_task.created_at,
|
||||
started_at=db_task.started_at,
|
||||
completed_at=db_task.completed_at,
|
||||
file_path=db_task.file_path,
|
||||
)
|
||||
self.tasks[task.id] = task
|
||||
logger.info(f"Loaded {len(db_tasks)} download tasks from database")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load tasks from DB: {e}", exc_info=True)
|
||||
|
||||
# ==================== Task Management ====================
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
@@ -60,6 +149,8 @@ class DownloadManager:
|
||||
created_at=datetime.now()
|
||||
)
|
||||
self.tasks[task_id] = task
|
||||
# Persist to database
|
||||
self._save_task_to_db(task)
|
||||
return task
|
||||
|
||||
async def start_download(self, task_id: str):
|
||||
@@ -82,6 +173,7 @@ class DownloadManager:
|
||||
task = self.tasks.get(task_id)
|
||||
if task and task.status == DownloadStatus.DOWNLOADING:
|
||||
task.status = DownloadStatus.PAUSED
|
||||
self._save_task_to_db(task)
|
||||
if task_id in self.active_downloads:
|
||||
self.active_downloads[task_id].cancel()
|
||||
del self.active_downloads[task_id]
|
||||
@@ -90,6 +182,7 @@ class DownloadManager:
|
||||
task = self.tasks.get(task_id)
|
||||
if task:
|
||||
task.status = DownloadStatus.CANCELLED
|
||||
self._save_task_to_db(task)
|
||||
if task_id in self.active_downloads:
|
||||
self.active_downloads[task_id].cancel()
|
||||
del self.active_downloads[task_id]
|
||||
@@ -112,14 +205,16 @@ class DownloadManager:
|
||||
if task.file_path and os.path.exists(task.file_path):
|
||||
os.remove(task.file_path)
|
||||
|
||||
# Remove from tasks dict
|
||||
# Remove from tasks dict and database
|
||||
del self.tasks[task_id]
|
||||
self._delete_task_from_db(task_id)
|
||||
|
||||
async def _download(self, task: DownloadTask):
|
||||
async with self._semaphore:
|
||||
try:
|
||||
task.status = DownloadStatus.DOWNLOADING
|
||||
task.started_at = datetime.now()
|
||||
self._save_task_to_db(task)
|
||||
|
||||
# Get downloader and extract link
|
||||
downloader = get_downloader(task.url)
|
||||
@@ -150,6 +245,9 @@ class DownloadManager:
|
||||
else:
|
||||
logger.debug(f"Task filename kept as: {task.filename}")
|
||||
|
||||
# Sanitize filename to prevent path traversal and invalid characters
|
||||
task.filename = sanitize_filename(task.filename)
|
||||
|
||||
task.file_path = str(self.download_dir / task.filename)
|
||||
|
||||
# Check if URL is HLS/m3u8 - use ffmpeg to download
|
||||
@@ -157,6 +255,7 @@ class DownloadManager:
|
||||
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
|
||||
success = await self._download_hls(download_url, task)
|
||||
if success:
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
# If ffmpeg fails, fall through to regular download attempt
|
||||
logger.warning("ffmpeg download failed, trying regular download")
|
||||
@@ -167,8 +266,12 @@ class DownloadManager:
|
||||
# Move file to expected location if different
|
||||
import shutil
|
||||
if download_url != task.file_path:
|
||||
shutil.move(download_url, task.file_path)
|
||||
logger.debug(f"Moved file to: {task.file_path}")
|
||||
try:
|
||||
shutil.move(download_url, task.file_path)
|
||||
logger.debug(f"Moved file to: {task.file_path}")
|
||||
except shutil.Error:
|
||||
# Same file, no move needed
|
||||
pass
|
||||
|
||||
# Mark as complete
|
||||
file_size = os.path.getsize(task.file_path)
|
||||
@@ -178,6 +281,7 @@ class DownloadManager:
|
||||
task.downloaded_bytes = file_size
|
||||
task.total_bytes = file_size
|
||||
task.completed_at = datetime.now()
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
|
||||
# Check if file already exists and is complete (for VidMoly which downloads directly)
|
||||
@@ -190,6 +294,7 @@ class DownloadManager:
|
||||
task.downloaded_bytes = file_size
|
||||
task.total_bytes = file_size
|
||||
task.completed_at = datetime.now()
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
|
||||
# Check for partial download (resume)
|
||||
@@ -241,6 +346,7 @@ class DownloadManager:
|
||||
except Exception as e:
|
||||
task.status = DownloadStatus.FAILED
|
||||
task.error = str(e)
|
||||
self._save_task_to_db(task)
|
||||
finally:
|
||||
if task.id in self.active_downloads:
|
||||
del self.active_downloads[task.id]
|
||||
@@ -269,9 +375,11 @@ class DownloadManager:
|
||||
|
||||
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
||||
if task.status == DownloadStatus.CANCELLED:
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
|
||||
if task.status == DownloadStatus.PAUSED:
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
|
||||
f.write(chunk)
|
||||
@@ -295,6 +403,9 @@ class DownloadManager:
|
||||
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
|
||||
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
|
||||
|
||||
# Persist to database
|
||||
self._save_task_to_db(task)
|
||||
|
||||
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
|
||||
"""Download HLS/m3u8 stream using ffmpeg"""
|
||||
import subprocess
|
||||
@@ -386,6 +497,7 @@ class DownloadManager:
|
||||
task.downloaded_bytes = file_size
|
||||
task.total_bytes = file_size
|
||||
task.completed_at = datetime.now()
|
||||
self._save_task_to_db(task)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"HLS download failed: file not created")
|
||||
|
||||
@@ -144,12 +144,34 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}")
|
||||
return url, filename
|
||||
|
||||
# Check if URL contains the anime page context (format: video_url|anime_page_url|episode_title?)
|
||||
# Check if URL contains the anime page context (format: video_url|...|anime_page_url|episode_title)
|
||||
# The LAST two parts are always anime_page_url and episode_title.
|
||||
# Everything before them is video URLs (multiple sources for fallback).
|
||||
if "|" in url:
|
||||
parts = url.split("|")
|
||||
video_url = parts[0]
|
||||
anime_page_url = parts[1] if len(parts) > 1 else None
|
||||
episode_title = parts[2] if len(parts) > 2 else None
|
||||
# Correctly identify anime_page_url (2nd to last) and episode_title (last)
|
||||
if len(parts) >= 3:
|
||||
# Multiple video URLs + anime_page_url + episode_title
|
||||
potential_anime_url = parts[-2].strip()
|
||||
potential_title = parts[-1].strip()
|
||||
# Validate: anime_page_url should look like a URL
|
||||
# episode_title should NOT look like a URL
|
||||
if potential_title and not potential_title.startswith("http"):
|
||||
anime_page_url = potential_anime_url if potential_anime_url.startswith("http") else None
|
||||
episode_title = potential_title
|
||||
elif len(parts) >= 5 and parts[-2].startswith("http"):
|
||||
# Last part is also a URL (no episode title) - 2nd to last is anime page URL
|
||||
anime_page_url = potential_anime_url
|
||||
episode_title = None
|
||||
else:
|
||||
anime_page_url = None
|
||||
episode_title = None
|
||||
# Pass the full URL to fallback (it parses correctly)
|
||||
video_url = url
|
||||
else:
|
||||
video_url = parts[0]
|
||||
anime_page_url = parts[1] if len(parts) > 1 else None
|
||||
episode_title = None
|
||||
|
||||
logger.debug(
|
||||
f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}"
|
||||
@@ -160,6 +182,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
video_url,
|
||||
anime_page_url=anime_page_url,
|
||||
episode_title=episode_title,
|
||||
target_filename=target_filename,
|
||||
)
|
||||
|
||||
# Check if this is a third-party host URL
|
||||
|
||||
@@ -23,7 +23,7 @@ class FS7Downloader(BaseSeriesSite):
|
||||
self.id = "fs7"
|
||||
self.provider_id = "fs7"
|
||||
self.default_domain = "fs7.lol"
|
||||
self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"]
|
||||
self.test_tlds = ["lol", "one", "site", "vip", "fun", "stream", "com", "net", "org", "tv", "ws", "cc", "co"]
|
||||
self.base_url = f"https://{self.default_domain}"
|
||||
self._domain_checked = False
|
||||
self.client.headers.update(
|
||||
@@ -234,35 +234,93 @@ class FS7Downloader(BaseSeriesSite):
|
||||
# Clean up title: remove "affiche" suffix
|
||||
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
|
||||
|
||||
# Extract description/synopsis
|
||||
description_elem = soup.find("div", class_="full-text")
|
||||
description = (
|
||||
description_elem.get_text(strip=True) if description_elem else ""
|
||||
)
|
||||
# --- Synopsis: div.fdesc > p ---
|
||||
description = ""
|
||||
fdesc = soup.find("div", class_="fdesc")
|
||||
if fdesc:
|
||||
p = fdesc.find("p")
|
||||
if p:
|
||||
description = p.get_text(strip=True)
|
||||
else:
|
||||
description = fdesc.get_text(strip=True)
|
||||
|
||||
# Extract cover image
|
||||
img = soup.find("img", class_="poster")
|
||||
poster_image = img.get("src", "") if img else ""
|
||||
# --- Poster: div.fleft > img ---
|
||||
poster_image = ""
|
||||
fleft = soup.find("div", class_="fleft")
|
||||
if fleft:
|
||||
img = fleft.find("img")
|
||||
if img:
|
||||
poster_image = (
|
||||
img.get("data-src")
|
||||
or img.get("data-original")
|
||||
or img.get("src")
|
||||
or ""
|
||||
)
|
||||
|
||||
# Try to get poster from meta tag if not found
|
||||
# Fallback: img.poster, then og:image
|
||||
if not poster_image:
|
||||
img = soup.find("img", class_="poster")
|
||||
poster_image = img.get("src", "") if img else ""
|
||||
if not poster_image:
|
||||
meta_img = soup.find("meta", property="og:image")
|
||||
poster_image = meta_img.get("content", "") if meta_img else ""
|
||||
|
||||
# Extract year
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", description)
|
||||
release_year = int(year_match.group()) if year_match else None
|
||||
# --- Year: span.release ---
|
||||
release_year = None
|
||||
release_span = soup.find("span", class_="release")
|
||||
if release_span:
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", release_span.get_text())
|
||||
if year_match:
|
||||
release_year = int(year_match.group())
|
||||
|
||||
# --- Genres: span.genres ---
|
||||
genres = []
|
||||
genres_span = soup.find("span", class_="genres")
|
||||
if genres_span:
|
||||
genres = [
|
||||
g.strip()
|
||||
for g in genres_span.get_text().split(",")
|
||||
if g.strip()
|
||||
]
|
||||
|
||||
# --- Runtime: span.runtime ---
|
||||
runtime = None
|
||||
runtime_span = soup.find("span", class_="runtime")
|
||||
if runtime_span:
|
||||
runtime = runtime_span.get_text(strip=True)
|
||||
|
||||
# --- Casting info from second div.flist ---
|
||||
original_title = ""
|
||||
director = ""
|
||||
cast = []
|
||||
flists = soup.find_all("div", class_="flist")
|
||||
for fl in flists:
|
||||
text = fl.get_text(strip=True)
|
||||
if "Titre Original" in text:
|
||||
m = re.search(r"Titre Original\s*:\s*(.+?)(?:Réalisateur|$)", text)
|
||||
if m:
|
||||
original_title = m.group(1).strip()
|
||||
m2 = re.search(r"Réalisateur\s*:\s*(.+?)(?:Avec\s*:|$)", text)
|
||||
if m2:
|
||||
director = m2.group(1).strip()
|
||||
m3 = re.search(r"Avec\s*:\s*(.+?)(?:plus|$)", text)
|
||||
if m3:
|
||||
cast = [c.strip() for c in m3.group(1).split(",") if c.strip()]
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"synopsis": description,
|
||||
"poster_image": poster_image,
|
||||
"release_year": release_year,
|
||||
"genres": [],
|
||||
"genres": genres,
|
||||
"rating": None,
|
||||
"studio": None,
|
||||
"total_episodes": None,
|
||||
"status": None,
|
||||
"original_title": original_title,
|
||||
"director": director,
|
||||
"cast": cast,
|
||||
"runtime": runtime,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -301,3 +359,80 @@ class FS7Downloader(BaseSeriesSite):
|
||||
return await player.get_download_link(url, target_filename)
|
||||
else:
|
||||
raise ValueError(f"No video player found for URL: {url}")
|
||||
|
||||
async def get_latest_series(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Scrape the 'Nouveautés Séries' section from FS7 homepage.
|
||||
|
||||
Returns:
|
||||
List of dicts with title, url, cover_image, synopsis, lang, provider_id.
|
||||
"""
|
||||
await self._ensure_base_url()
|
||||
|
||||
try:
|
||||
resp = await self.client.get(self.base_url + "/", timeout=15)
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch FS7 homepage: {e}")
|
||||
return []
|
||||
|
||||
results = []
|
||||
|
||||
# Find the 'Nouveautés Séries' section
|
||||
for section in soup.find_all("div", class_="pages"):
|
||||
title_el = section.find("div", class_="sect-t")
|
||||
if not title_el:
|
||||
continue
|
||||
title = title_el.get_text(strip=True)
|
||||
if "Nouveautés" not in title or "Séries" not in title:
|
||||
continue
|
||||
|
||||
for item in section.find_all("div", class_="short"):
|
||||
# Get the poster link (contains real URL)
|
||||
poster_a = item.find("a", class_="short-poster", href=True)
|
||||
if not poster_a:
|
||||
continue
|
||||
|
||||
url = poster_a["href"]
|
||||
if url.startswith("/"):
|
||||
url = self.base_url + url
|
||||
|
||||
# Title from alt attribute
|
||||
title_attr = poster_a.get("alt", "").strip()
|
||||
if not title_attr:
|
||||
continue
|
||||
|
||||
# Poster image
|
||||
img = poster_a.find("img")
|
||||
cover_image = img.get("src", "") if img else ""
|
||||
|
||||
# Synopsis from hidden span
|
||||
desc_span = item.find("span", id=re.compile(r"^desc-\d+"))
|
||||
synopsis = desc_span.get_text(strip=True) if desc_span else ""
|
||||
|
||||
# Language (VF/VOSTFR)
|
||||
lang = "vf"
|
||||
version_span = item.find("span", class_="film-version")
|
||||
if version_span:
|
||||
version_text = version_span.get_text(strip=True).upper()
|
||||
if "VOSTFR" in version_text:
|
||||
lang = "vostfr"
|
||||
elif "VF" in version_text:
|
||||
lang = "vf"
|
||||
|
||||
results.append({
|
||||
"title": title_attr,
|
||||
"url": url,
|
||||
"cover_image": cover_image,
|
||||
"synopsis": synopsis,
|
||||
"lang": lang,
|
||||
"provider_id": self.provider_id,
|
||||
"content_type": "series",
|
||||
})
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
break # Only process the first matching section
|
||||
|
||||
logger.info(f"FS7 latest series: found {len(results)} items")
|
||||
return results
|
||||
|
||||
@@ -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,7 +101,7 @@ 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)
|
||||
@@ -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
|
||||
|
||||
@@ -70,3 +70,4 @@ from .watchlist import WatchlistItemTable, WatchlistSettingsTable
|
||||
from .favorites import FavoriteTable
|
||||
from .sonarr import SonarrMappingTable, SonarrConfigTable
|
||||
from .settings import AppSettingsTable
|
||||
from .download import DownloadTaskTable
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Models for download task persistence with SQLModel support"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel, Field, Column, String
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DownloadStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
DOWNLOADING = "downloading"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class DownloadTaskTable(SQLModel, table=True):
|
||||
"""Database table for persisting download tasks across server restarts."""
|
||||
__tablename__ = "download_tasks"
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
url: str = Field(default="", sa_column=Column(String))
|
||||
filename: str = Field(sa_column=Column(String))
|
||||
host: str = Field(default="other", sa_column=Column(String))
|
||||
status: str = Field(default="pending", sa_column=Column(String))
|
||||
progress: float = Field(default=0.0)
|
||||
downloaded_bytes: int = Field(default=0)
|
||||
total_bytes: Optional[int] = Field(default=None)
|
||||
speed: float = Field(default=0.0)
|
||||
error: Optional[str] = Field(default=None, sa_column=Column(String))
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
started_at: Optional[datetime] = Field(default=None)
|
||||
completed_at: Optional[datetime] = Field(default=None)
|
||||
file_path: Optional[str] = Field(default=None, sa_column=Column(String))
|
||||
@@ -15,6 +15,26 @@ 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")
|
||||
|
||||
# #13: Content weight mode ("auto" = based on download habits, "manual" = user-defined)
|
||||
content_weight_mode: str = Field(default="auto", sa_column=Column(String))
|
||||
|
||||
# #14: Manual content weights (used when content_weight_mode = "manual")
|
||||
content_weight_anime: int = Field(default=2)
|
||||
content_weight_series: int = Field(default=1)
|
||||
|
||||
@property
|
||||
def disabled_providers(self) -> List[str]:
|
||||
try:
|
||||
@@ -46,6 +66,14 @@ 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"
|
||||
content_weight_mode: str = "auto"
|
||||
content_weight_anime: int = 2
|
||||
content_weight_series: int = 1
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -56,3 +84,11 @@ 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
|
||||
content_weight_mode: Optional[str] = None
|
||||
content_weight_anime: Optional[int] = None
|
||||
content_weight_series: Optional[int] = None
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -278,8 +296,7 @@ async def search_series_unified(
|
||||
|
||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
# Enrich results with metadata (synopsis, rating, genres)
|
||||
enricher = await get_metadata_enricher()
|
||||
# Enrich results with metadata from scrapers (not Kitsu — Kitsu is anime-only)
|
||||
enrichment_tasks = []
|
||||
enrichment_mapping = []
|
||||
|
||||
@@ -290,17 +307,15 @@ async def search_series_unified(
|
||||
elif result:
|
||||
results[provider_id] = result
|
||||
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
|
||||
# Prepare enrichment for top 15 results
|
||||
for idx, item in enumerate(result[:15]):
|
||||
if isinstance(item, dict):
|
||||
enrichment_tasks.append(
|
||||
enricher.enrich_metadata(
|
||||
item.get("metadata") or {},
|
||||
item.get("title") or "",
|
||||
item.get("url") or "",
|
||||
# Enrich top 10 results with metadata from the scraper itself
|
||||
downloader = series_downloaders.get(provider_id)
|
||||
if downloader and hasattr(downloader, "get_anime_metadata"):
|
||||
for idx, item in enumerate(result[:10]):
|
||||
if isinstance(item, dict) and item.get("url"):
|
||||
enrichment_tasks.append(
|
||||
downloader.get_anime_metadata(item["url"])
|
||||
)
|
||||
)
|
||||
enrichment_mapping.append((provider_id, idx))
|
||||
enrichment_mapping.append((provider_id, idx))
|
||||
else:
|
||||
print(f"[SERIES SEARCH] {provider_id}: No results returned")
|
||||
|
||||
@@ -316,9 +331,7 @@ async def search_series_unified(
|
||||
and provider_id in results
|
||||
and pos < len(results[provider_id])
|
||||
):
|
||||
results[provider_id][pos]["metadata"] = (
|
||||
meta.model_dump() if hasattr(meta, "model_dump") else meta
|
||||
)
|
||||
results[provider_id][pos]["metadata"] = meta
|
||||
|
||||
# Truncate synopses at sentence boundaries
|
||||
for pid in results:
|
||||
|
||||
@@ -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,13 +28,21 @@ 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(
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,13 +3,22 @@ Recommendations and releases routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request, Query, HTTPException
|
||||
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.recommendation_engine import RecommendationEngine
|
||||
from app.models.auth import User
|
||||
from app.models.settings import AppSettingsTable
|
||||
from app.database import get_session
|
||||
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
||||
from app.routers.router_settings import _compute_auto_weights
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@@ -21,19 +30,133 @@ def hash_filter(s):
|
||||
templates.env.filters["hash"] = hash_filter
|
||||
|
||||
|
||||
def _get_effective_weights(session: Session, user_id: str) -> tuple:
|
||||
"""Return (anime_enabled, series_enabled, anime_weight, series_weight)."""
|
||||
settings = session.exec(
|
||||
select(AppSettingsTable).where(AppSettingsTable.user_id == user_id)
|
||||
).first()
|
||||
|
||||
if settings is None:
|
||||
return True, True, 1, 1
|
||||
|
||||
anime_enabled = getattr(settings, 'anime_enabled', True)
|
||||
series_enabled = getattr(settings, 'series_enabled', True)
|
||||
mode = getattr(settings, 'content_weight_mode', 'auto')
|
||||
download_dir = getattr(settings, 'download_dir', 'downloads')
|
||||
|
||||
if mode == "auto":
|
||||
weights = _compute_auto_weights(download_dir)
|
||||
return anime_enabled, series_enabled, weights["anime_weight"], weights["series_weight"]
|
||||
else:
|
||||
aw = getattr(settings, 'content_weight_anime', 2)
|
||||
sw = getattr(settings, 'content_weight_series', 1)
|
||||
return anime_enabled, series_enabled, int(aw), int(sw)
|
||||
|
||||
|
||||
def _weighted_mix(items_a: List[Dict], items_b: List[Dict], limit: int,
|
||||
weight_a: int = 2, weight_b: int = 1) -> List[Dict]:
|
||||
"""Mix two lists using weights. Distributes items proportionally and interleaves.
|
||||
|
||||
If weight_a=2, weight_b=1 and limit=15:
|
||||
- slots_a ≈ 10, slots_b ≈ 5
|
||||
- B items are spaced evenly across the list
|
||||
If one list is shorter, the other fills remaining slots.
|
||||
"""
|
||||
total_weight = weight_a + weight_b
|
||||
if total_weight == 0:
|
||||
return (items_a + items_b)[:limit]
|
||||
|
||||
slots_a = round(limit * weight_a / total_weight)
|
||||
slots_b = limit - slots_a
|
||||
|
||||
pick_a = min(slots_a, len(items_a))
|
||||
pick_b = min(slots_b, len(items_b))
|
||||
|
||||
# Redistribute unfilled slots
|
||||
if pick_a < slots_a:
|
||||
pick_b = min(pick_b + (slots_a - pick_a), len(items_b))
|
||||
elif pick_b < slots_b:
|
||||
pick_a = min(pick_a + (slots_b - pick_b), len(items_a))
|
||||
|
||||
a = items_a[:pick_a]
|
||||
b = items_b[:pick_b]
|
||||
|
||||
total = pick_a + pick_b
|
||||
if total == 0:
|
||||
return []
|
||||
if pick_b == 0:
|
||||
return a[:limit]
|
||||
if pick_a == 0:
|
||||
return b[:limit]
|
||||
|
||||
# Place B items at evenly spaced positions, fill gaps with A
|
||||
result = [None] * total
|
||||
for i, item in enumerate(b):
|
||||
pos = round(i * (total - 1) / max(pick_b - 1, 1))
|
||||
result[pos] = item
|
||||
a_idx = 0
|
||||
for i in range(total):
|
||||
if result[i] is None:
|
||||
result[i] = a[a_idx]
|
||||
a_idx += 1
|
||||
|
||||
return result[:limit]
|
||||
|
||||
|
||||
@router.get("/recommendations")
|
||||
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),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Get personalized anime recommendations based on download history"""
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
"""Get personalized recommendations based on user settings (anime + series)"""
|
||||
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")
|
||||
|
||||
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
||||
recommendations = []
|
||||
|
||||
try:
|
||||
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
||||
if anime_enabled:
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
try:
|
||||
anime_recs = await engine.get_personalized_recommendations(limit=limit)
|
||||
for r in anime_recs:
|
||||
r['content_type'] = 'anime'
|
||||
recommendations.extend(anime_recs)
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
if series_enabled:
|
||||
try:
|
||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||
downloader = FS7Downloader()
|
||||
series_recs = await downloader.get_latest_series(limit=limit)
|
||||
for r in series_recs:
|
||||
r['content_type'] = 'series'
|
||||
recommendations.extend(series_recs)
|
||||
except Exception as e:
|
||||
logger.warning(f"Series recommendations fetch failed: {e}")
|
||||
|
||||
if content_type and content_type != "all":
|
||||
recommendations = [r for r in recommendations if r.get("content_type") == content_type]
|
||||
else:
|
||||
anime_items = [r for r in recommendations if r.get("content_type") == "anime"]
|
||||
series_items = [r for r in recommendations if r.get("content_type") == "series"]
|
||||
recommendations = _weighted_mix(anime_items, series_items, limit,
|
||||
weight_a=anime_weight, weight_b=series_weight)
|
||||
|
||||
if html or is_htmx:
|
||||
return templates.TemplateResponse(
|
||||
"components/recommendations_list.html",
|
||||
{"request": request, "recommendations": recommendations}
|
||||
@@ -41,11 +164,8 @@ async def get_recommendations(
|
||||
|
||||
return {"recommendations": recommendations, "count": len(recommendations)}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Recommendations error: {e}", exc_info=True)
|
||||
logger.error(f"Recommendations error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
|
||||
@router.get("/releases/latest")
|
||||
@@ -53,14 +173,53 @@ 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"),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Get latest anime releases"""
|
||||
"""Get latest releases based on user settings (anime + series)"""
|
||||
from app.recommendations import get_latest_releases_with_info
|
||||
|
||||
try:
|
||||
releases = await get_latest_releases_with_info(limit=limit)
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if html 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")
|
||||
|
||||
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
||||
releases = []
|
||||
|
||||
try:
|
||||
if anime_enabled:
|
||||
anime_releases = await get_latest_releases_with_info(limit=limit)
|
||||
for r in anime_releases:
|
||||
r['content_type'] = 'anime'
|
||||
releases.extend(anime_releases)
|
||||
|
||||
if series_enabled:
|
||||
try:
|
||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||
downloader = FS7Downloader()
|
||||
series_releases = await downloader.get_latest_series(limit=limit)
|
||||
for r in series_releases:
|
||||
r['content_type'] = 'series'
|
||||
releases.extend(series_releases)
|
||||
except Exception as e:
|
||||
logger.warning(f"Series releases fetch failed: {e}")
|
||||
|
||||
if content_type and content_type != "all":
|
||||
releases = [r for r in releases if r.get("content_type") == content_type]
|
||||
else:
|
||||
anime_items = [r for r in releases if r.get("content_type") == "anime"]
|
||||
series_items = [r for r in releases if r.get("content_type") == "series"]
|
||||
releases = _weighted_mix(anime_items, series_items, limit,
|
||||
weight_a=anime_weight, weight_b=series_weight)
|
||||
|
||||
if html or is_htmx:
|
||||
return templates.TemplateResponse(
|
||||
"components/releases_list.html",
|
||||
{"request": request, "releases": releases}
|
||||
@@ -72,8 +231,7 @@ async def get_latest_releases(
|
||||
"updated": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Latest releases error: {e}", exc_info=True)
|
||||
logger.error(f"Latest releases error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -140,7 +298,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")
|
||||
|
||||
@@ -152,3 +312,41 @@ async def get_download_statistics():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
|
||||
@router.get("/series/latest")
|
||||
async def get_latest_series(
|
||||
request: Request,
|
||||
limit: int = 20,
|
||||
html: bool = Query(False),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get latest TV series releases from FS7 homepage"""
|
||||
if current_user is None and (html or request.headers.get("HX-Request")):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||
|
||||
downloader = FS7Downloader()
|
||||
series = await downloader.get_latest_series(limit=limit)
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
"components/series_releases_list.html",
|
||||
{"request": request, "releases": series}
|
||||
)
|
||||
|
||||
return {
|
||||
"releases": series,
|
||||
"count": len(series),
|
||||
"updated": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Latest series error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Application settings routes for Ohm Stream Downloader API"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -13,10 +15,74 @@ from app.routers.router_auth import get_current_user_from_token, get_optional_us
|
||||
from app.providers import get_anime_providers, get_series_providers
|
||||
from app.providers_manager import providers_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
def _compute_auto_weights(download_dir: str) -> Dict[str, Any]:
|
||||
"""Analyze downloaded files to compute anime vs series ratio.
|
||||
|
||||
Uses filename conventions:
|
||||
- Series: contains "Saison" or "Season" keywords
|
||||
- Anime: everything else in the downloads folder
|
||||
Returns dict with counts and computed weights.
|
||||
"""
|
||||
base = Path(download_dir)
|
||||
if not base.exists():
|
||||
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
||||
|
||||
anime_count = 0
|
||||
series_count = 0
|
||||
|
||||
for f in base.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
if f.suffix.lower() not in (".mp4", ".mkv", ".avi", ".wmv", ".flv", ".mov"):
|
||||
continue
|
||||
|
||||
name = f.stem.lower()
|
||||
# Heuristic: series TV files often have "Saison" or "Season" + number
|
||||
# Anime files rarely use this format (they use "Episode" or "S01E01")
|
||||
import re
|
||||
if re.search(r'(?:saison|season)\s*\d+', name):
|
||||
series_count += 1
|
||||
else:
|
||||
anime_count += 1
|
||||
|
||||
total = anime_count + series_count
|
||||
if total == 0:
|
||||
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
||||
|
||||
# Compute weights: proportional to download count, minimum 1
|
||||
if anime_count == 0:
|
||||
aw, sw = 0, 1
|
||||
elif series_count == 0:
|
||||
aw, sw = 1, 0
|
||||
else:
|
||||
# Keep weights small (max 5) for reasonable interleaving
|
||||
ratio = anime_count / series_count
|
||||
if ratio >= 4:
|
||||
aw, sw = 4, 1
|
||||
elif ratio >= 2:
|
||||
aw, sw = 2, 1
|
||||
elif ratio >= 1:
|
||||
aw, sw = 1, 1
|
||||
elif ratio >= 0.5:
|
||||
aw, sw = 1, 2
|
||||
else:
|
||||
aw, sw = 1, 4
|
||||
|
||||
return {
|
||||
"anime_count": anime_count,
|
||||
"series_count": series_count,
|
||||
"anime_weight": aw,
|
||||
"series_weight": sw,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
@@ -39,6 +105,14 @@ 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'),
|
||||
content_weight_mode=getattr(settings_obj, 'content_weight_mode', 'auto'),
|
||||
content_weight_anime=getattr(settings_obj, 'content_weight_anime', 2),
|
||||
content_weight_series=getattr(settings_obj, 'content_weight_series', 1),
|
||||
)
|
||||
|
||||
|
||||
@@ -65,6 +139,28 @@ 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
|
||||
if update_data.content_weight_mode is not None:
|
||||
settings_obj.content_weight_mode = update_data.content_weight_mode
|
||||
if update_data.content_weight_anime is not None:
|
||||
settings_obj.content_weight_anime = update_data.content_weight_anime
|
||||
if update_data.content_weight_series is not None:
|
||||
settings_obj.content_weight_series = update_data.content_weight_series
|
||||
|
||||
session.add(settings_obj)
|
||||
session.commit()
|
||||
@@ -77,6 +173,34 @@ async def update_settings(
|
||||
return settings_obj
|
||||
|
||||
|
||||
@router.get("/content-weight")
|
||||
async def get_content_weight(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Get current effective content weights (auto-computed or manual)"""
|
||||
statement = select(AppSettingsTable).where(
|
||||
AppSettingsTable.user_id == current_user.id
|
||||
)
|
||||
settings_obj = session.exec(statement).first()
|
||||
download_dir = getattr(settings_obj, 'download_dir', 'downloads') if settings_obj else 'downloads'
|
||||
mode = getattr(settings_obj, 'content_weight_mode', 'auto') if settings_obj else 'auto'
|
||||
|
||||
if mode == "auto":
|
||||
weights = _compute_auto_weights(download_dir)
|
||||
weights["mode"] = "auto"
|
||||
return weights
|
||||
else:
|
||||
return {
|
||||
"mode": "manual",
|
||||
"anime_weight": getattr(settings_obj, 'content_weight_anime', 2),
|
||||
"series_weight": getattr(settings_obj, 'content_weight_series', 1),
|
||||
"anime_count": None,
|
||||
"series_count": None,
|
||||
"total": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/providers/availability")
|
||||
async def get_providers_availability(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -212,14 +213,14 @@ async def delete_from_watchlist(
|
||||
raise HTTPException(status_code=500, detail="Failed to delete item")
|
||||
|
||||
|
||||
@router.post("/check", response_model=List)
|
||||
@router.post("/check")
|
||||
async def check_watchlist_now(
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
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)
|
||||
|
||||
@@ -95,13 +95,18 @@ class DomainManager:
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Active domain found for {provider_id}: {domain}")
|
||||
cls._cache[provider_id] = {
|
||||
'domain': domain,
|
||||
'last_check': datetime.now().isoformat()
|
||||
}
|
||||
cls._save_cache()
|
||||
return domain
|
||||
# Verify it's actually the right site, not a parking/placeholder page
|
||||
content = response.text.lower()
|
||||
body_size = len(response.text)
|
||||
# Valid pages should be reasonably large and contain expected keywords
|
||||
if body_size > 10000 and ('french' in content or 'stream' in content or 'serie' in content or 'anime' in content or 'film' in content or 'telechargement' in content or 'zone' in content):
|
||||
logger.info(f"Active domain found for {provider_id}: {domain} ({body_size} bytes)")
|
||||
cls._cache[provider_id] = {
|
||||
'domain': domain,
|
||||
'last_check': datetime.now().isoformat()
|
||||
}
|
||||
cls._save_cache()
|
||||
return domain
|
||||
except Exception as e:
|
||||
logger.debug(f"Domain test failed for {domain}: {e}")
|
||||
continue
|
||||
|
||||
@@ -216,8 +216,12 @@ class WatchlistManager:
|
||||
update_check_time = update_last_checked
|
||||
|
||||
def get_due_items(self) -> List[WatchlistItem]:
|
||||
"""Get all items that are due for a check based on current settings"""
|
||||
return self.get_due_for_check(self.settings.check_interval_hours if self.settings else 6)
|
||||
|
||||
def get_due_for_check(self, interval_hours: int) -> List[WatchlistItem]:
|
||||
"""Get all items that are due for a check based on settings"""
|
||||
interval = timedelta(hours=self.settings.check_interval_hours)
|
||||
interval = timedelta(hours=interval_hours)
|
||||
now = datetime.now()
|
||||
|
||||
with Session(engine) as session:
|
||||
@@ -234,6 +238,12 @@ class WatchlistManager:
|
||||
|
||||
return due_items
|
||||
|
||||
def get_settings(self) -> WatchlistSettings:
|
||||
"""Get global watchlist settings"""
|
||||
if self.settings is None:
|
||||
self._load_settings()
|
||||
return self.settings
|
||||
|
||||
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
|
||||
"""Update global watchlist settings"""
|
||||
self.settings = settings
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://127.0.0.1:3000';
|
||||
const opts = { waitUntil: 'domcontentloaded', timeout: 15000 };
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
|
||||
// Obtenir un token via API
|
||||
const apiCtx = await browser.newContext();
|
||||
const apiPage = await apiCtx.newPage();
|
||||
await apiPage.goto(BASE + '/api/auth/login', opts);
|
||||
const token = await apiPage.evaluate(async () => {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'roman', password: 'roman123' })
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.access_token || null;
|
||||
});
|
||||
await apiCtx.close();
|
||||
console.log(`Token obtained: ${token ? token.substring(0, 20) + '...' : 'FAILED'}`);
|
||||
|
||||
if (!token) {
|
||||
console.error('Cannot get token, aborting');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ========== NON AUTHENTIFIE ==========
|
||||
console.log('\n=== NON AUTHENTIFIE ===');
|
||||
const anonCtx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const anon = await anonCtx.newPage();
|
||||
|
||||
const snap = async (p, name, url, wait = 3000) => {
|
||||
try {
|
||||
await p.goto(url, opts);
|
||||
await p.waitForTimeout(wait);
|
||||
await p.screenshot({ path: `/tmp/screenshots/${name}.png`, fullPage: false });
|
||||
console.log(`OK: ${name}`);
|
||||
} catch(e) {
|
||||
console.log(`FAIL: ${name} - ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
await snap(anon, 'anon_01_home', `${BASE}/`);
|
||||
await snap(anon, 'anon_02_watchlist', `${BASE}/watchlist`);
|
||||
await snap(anon, 'anon_03_favorites', `${BASE}/favorites`);
|
||||
await snap(anon, 'anon_04_downloads', `${BASE}/downloads`);
|
||||
await snap(anon, 'anon_05_settings', `${BASE}/settings`);
|
||||
await snap(anon, 'anon_06_recommendations', `${BASE}/recommendations`);
|
||||
|
||||
// ========== AUTHENTIFIE (cookie + localStorage) ==========
|
||||
console.log('\n=== AUTHENTIFIE ===');
|
||||
const authCtx = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
});
|
||||
// Injecter le token comme cookie AVANT toute navigation
|
||||
await authCtx.addCookies([{
|
||||
name: 'auth_token',
|
||||
value: token,
|
||||
domain: '127.0.0.1',
|
||||
path: '/',
|
||||
sameSite: 'Strict',
|
||||
httpOnly: false,
|
||||
}]);
|
||||
|
||||
const auth = await authCtx.newPage();
|
||||
|
||||
// Injecter dans localStorage au premier chargement
|
||||
await auth.goto(BASE + '/', opts);
|
||||
await auth.evaluate((t) => {
|
||||
localStorage.setItem('auth_token', t);
|
||||
}, token);
|
||||
await auth.waitForTimeout(3000);
|
||||
await auth.screenshot({ path: '/tmp/screenshots/auth_01_home.png', fullPage: false });
|
||||
console.log('OK: auth_01_home');
|
||||
|
||||
await snap(auth, 'auth_02_watchlist', `${BASE}/watchlist`);
|
||||
await snap(auth, 'auth_03_favorites', `${BASE}/favorites`);
|
||||
await snap(auth, 'auth_04_downloads', `${BASE}/downloads`);
|
||||
await snap(auth, 'auth_05_settings', `${BASE}/settings`);
|
||||
await snap(auth, 'auth_06_recommendations', `${BASE}/recommendations`);
|
||||
|
||||
// ========== TESTS FONCTIONNELS ==========
|
||||
console.log('\n=== TESTS FONCTIONNELS ===');
|
||||
|
||||
// Test API: toggle favori
|
||||
const favResult = await auth.evaluate(async (t) => {
|
||||
try {
|
||||
const res = await fetch('/api/favorites/toggle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${t}`
|
||||
},
|
||||
body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' })
|
||||
});
|
||||
const data = await res.json();
|
||||
return { status: res.status, is_favorite: data.is_favorite };
|
||||
} catch(e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
}, token);
|
||||
console.log(`Favorite toggle: ${JSON.stringify(favResult)}`);
|
||||
|
||||
// Voir les favoris
|
||||
await snap(auth, 'auth_07_favorites_after_add', `${BASE}/favorites`);
|
||||
|
||||
// Test API: ajouter watchlist item
|
||||
const wlResult = await auth.evaluate(async (t) => {
|
||||
try {
|
||||
const res = await fetch('/api/watchlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${t}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
anime_title: 'Test Screenshot Anime',
|
||||
anime_url: 'https://example.com/anime/1',
|
||||
episode_count: 12,
|
||||
current_episode: 0,
|
||||
status: 'watching'
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
return { status: res.status, id: data.id, title: data.anime_title };
|
||||
} catch(e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
}, token);
|
||||
console.log(`Watchlist add: ${JSON.stringify(wlResult)}`);
|
||||
|
||||
// Voir la watchlist
|
||||
await snap(auth, 'auth_08_watchlist_with_item', `${BASE}/watchlist`);
|
||||
|
||||
// Scroller sur la home
|
||||
await auth.goto(`${BASE}/`, opts);
|
||||
await auth.waitForTimeout(2000);
|
||||
await auth.evaluate(() => window.scrollTo(0, 600));
|
||||
await auth.waitForTimeout(1000);
|
||||
await auth.screenshot({ path: '/tmp/screenshots/auth_09_home_scrolled.png', fullPage: false });
|
||||
console.log('OK: auth_09_home_scrolled');
|
||||
|
||||
// ========== NETTOYAGE ==========
|
||||
console.log('\n=== Nettoyage ===');
|
||||
// Retirer le favori de test
|
||||
await auth.evaluate(async (t) => {
|
||||
await fetch('/api/favorites/toggle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${t}`
|
||||
},
|
||||
body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' })
|
||||
});
|
||||
});
|
||||
|
||||
// Retirer le watchlist item de test
|
||||
if (wlResult.id) {
|
||||
await auth.evaluate(async ({t, id}) => {
|
||||
await fetch(`/api/watchlist/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${t}` }
|
||||
});
|
||||
}, { t: token, id: wlResult.id });
|
||||
console.log('Test watchlist item deleted');
|
||||
}
|
||||
console.log('Test favorite removed');
|
||||
|
||||
await browser.close();
|
||||
console.log('\n=== ALL DONE ===');
|
||||
})();
|
||||
@@ -86,12 +86,17 @@ async def startup_event():
|
||||
|
||||
|
||||
def restore_completed_downloads():
|
||||
"""Scan downloads directory and restore completed download tasks"""
|
||||
"""Restore download tasks: first from the database, then scan for untracked files."""
|
||||
# Step 1: Load persisted tasks from database
|
||||
download_manager._load_tasks_from_db()
|
||||
|
||||
# Step 2: Scan downloads directory for files not yet tracked in the database
|
||||
download_dir = Path("downloads")
|
||||
if not download_dir.exists():
|
||||
return
|
||||
|
||||
video_extensions = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"}
|
||||
tracked_filenames = {t.filename for t in download_manager.tasks.values()}
|
||||
|
||||
for file_path in download_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
|
||||
@@ -99,6 +104,11 @@ def restore_completed_downloads():
|
||||
continue
|
||||
|
||||
filename = file_path.name
|
||||
|
||||
# Skip if already tracked in DB
|
||||
if filename in tracked_filenames:
|
||||
continue
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
@@ -118,7 +128,8 @@ def restore_completed_downloads():
|
||||
)
|
||||
|
||||
download_manager.tasks[task_id] = task
|
||||
logger.info(f"Restored completed download: {filename}")
|
||||
download_manager._save_task_to_db(task)
|
||||
logger.info(f"Restored untracked completed download: {filename}")
|
||||
|
||||
|
||||
# Restore completed downloads on startup
|
||||
@@ -144,6 +155,7 @@ from app.routers import (
|
||||
static_router,
|
||||
root_router,
|
||||
settings_router,
|
||||
admin_router,
|
||||
)
|
||||
|
||||
|
||||
@@ -159,6 +171,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__":
|
||||
|
||||
@@ -82,7 +82,7 @@ async function searchAnimeDetails(query, malId = null) {
|
||||
if (hasResults) {
|
||||
streamingParts.unshift(
|
||||
`<div class="streaming-results-header">
|
||||
<h3>🎬 Résultats de streaming</h3>
|
||||
<h3><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
|
||||
</div>
|
||||
<div class="search-results" style="margin-top: 20px;">`
|
||||
);
|
||||
@@ -110,7 +110,7 @@ async function searchAnimeDetails(query, malId = null) {
|
||||
if (streamingHtml) {
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="no-results" style="margin-bottom: 20px;">
|
||||
<p>ℹ️ Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p>
|
||||
<p><i class="fa-solid fa-circle-info"></i> Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
|
||||
</p>
|
||||
@@ -125,7 +125,7 @@ async function searchAnimeDetails(query, malId = null) {
|
||||
} else {
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Aucun résultat trouvé pour "${escapeHtml(query)}"</p>
|
||||
<p><i class="fa-solid fa-xmark"></i> Aucun résultat trouvé pour "${escapeHtml(query)}"</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
|
||||
</p>
|
||||
@@ -138,7 +138,7 @@ async function searchAnimeDetails(query, malId = null) {
|
||||
console.error('Error searching anime details:', error);
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors de la recherche.</p>
|
||||
<p><i class="fa-solid fa-xmark"></i> Erreur lors de la recherche.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -177,7 +177,7 @@ async function getProviderSearchResults(query) {
|
||||
if (hasResults) {
|
||||
htmlParts.unshift(
|
||||
`<div class="streaming-results-header">
|
||||
<h3>🎬 Résultats de streaming</h3>
|
||||
<h3><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
|
||||
</div>
|
||||
<div class="search-results" style="margin-top: 20px;">`
|
||||
);
|
||||
@@ -249,16 +249,16 @@ function renderAnimeDetails(anime) {
|
||||
` : ''}
|
||||
|
||||
<div class="anime-details-meta">
|
||||
${score > 0 ? `<div class="anime-details-rating">★ ${score.toFixed(2)}</div>` : ''}
|
||||
${score > 0 ? `<div class="anime-details-rating"><i class="fa-solid fa-star"></i> ${score.toFixed(2)}</div>` : ''}
|
||||
${rank > 0 ? `<div class="anime-details-rank">#${rank}</div>` : ''}
|
||||
${popularity > 0 ? `<div class="anime-details-popularity">Popularity #${popularity}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="anime-details-stats">
|
||||
${anime.episodes ? `<span>📺 ${anime.episodes} épisodes</span>` : ''}
|
||||
${anime.status ? `<span>📡 ${translateStatus(anime.status)}</span>` : ''}
|
||||
${anime.duration ? `<span>⏱️ ${escapeHtml(anime.duration)}</span>` : ''}
|
||||
${anime.year ? `<span>📅 ${anime.year}</span>` : ''}
|
||||
${anime.episodes ? `<span><i class="fa-solid fa-tv"></i> ${anime.episodes} épisodes</span>` : ''}
|
||||
${anime.status ? `<span><i class="fa-solid fa-tower-broadcast"></i> ${translateStatus(anime.status)}</span>` : ''}
|
||||
${anime.duration ? `<span><i class="fa-solid fa-clock"></i> ${escapeHtml(anime.duration)}</span>` : ''}
|
||||
${anime.year ? `<span><i class="fa-solid fa-calendar"></i> ${anime.year}</span>` : ''}
|
||||
</div>
|
||||
|
||||
${studios.length > 0 ? `
|
||||
@@ -269,10 +269,10 @@ function renderAnimeDetails(anime) {
|
||||
|
||||
<div class="anime-details-actions">
|
||||
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-small">
|
||||
🔗 Voir sur MAL
|
||||
<i class="fa-solid fa-link"></i> Voir sur MAL
|
||||
</a>
|
||||
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-primary btn-small">
|
||||
📥 Télécharger
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,9 +290,9 @@ function renderAnimeDetails(anime) {
|
||||
${synopsis ? `
|
||||
<div class="anime-details-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">📖 Synopsis</h3>
|
||||
<h3 style="margin: 0;"><i class="fa-solid fa-book"></i> Synopsis</h3>
|
||||
<button onclick="translateSynopsis('${synopsisId}', this)" class="btn btn-secondary btn-small" style="font-size: 12px;">
|
||||
🌐 Traduire en français
|
||||
<i class="fa-solid fa-globe"></i> Traduire en français
|
||||
</button>
|
||||
</div>
|
||||
<p id="${synopsisId}" class="anime-details-synopsis">${escapeHtml(synopsis)}</p>
|
||||
@@ -302,7 +302,7 @@ function renderAnimeDetails(anime) {
|
||||
<!-- Seasons (Sequel/Prequel) -->
|
||||
${seasons.length > 0 ? `
|
||||
<div class="anime-details-section">
|
||||
<h3>📺 Saisons</h3>
|
||||
<h3><i class="fa-solid fa-tv"></i> Saisons</h3>
|
||||
<div class="anime-related-list">
|
||||
${seasons.map(season => `
|
||||
<div class="anime-related-group">
|
||||
@@ -310,7 +310,7 @@ function renderAnimeDetails(anime) {
|
||||
<div class="anime-related-items">
|
||||
${season.entries.map(entry => `
|
||||
<div class="anime-related-item" onclick="searchAnimeDetails('${escapeHtml(entry.title)}', ${entry.mal_id})" style="cursor: pointer;">
|
||||
${entry.type ? `<span style="color: #00d9ff; font-size: 11px; margin-right: 8px;">${escapeHtml(entry.type)}</span>` : ''}
|
||||
${entry.type ? `<span style="color: #FFBF69; font-size: 11px; margin-right: 8px;">${escapeHtml(entry.type)}</span>` : ''}
|
||||
${escapeHtml(entry.title)}
|
||||
${entry.url ? `<a href="${escapeHtml(entry.url)}" target="_blank" style="margin-left: auto; color: #888; font-size: 18px; text-decoration: none;" title="Voir sur MyAnimeList">↗</a>` : ''}
|
||||
</div>
|
||||
@@ -358,7 +358,7 @@ async function loadStreamingResults(query) {
|
||||
if (successfulResults.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>⚠️ Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p>
|
||||
<p><i class="fa-solid fa-triangle-exclamation"></i> Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -367,7 +367,7 @@ async function loadStreamingResults(query) {
|
||||
// Display results
|
||||
container.innerHTML = `
|
||||
<div class="streaming-results-header">
|
||||
<h3>🎬 Disponible sur</h3>
|
||||
<h3><i class="fa-solid fa-film"></i> Disponible sur</h3>
|
||||
</div>
|
||||
<div class="streaming-results-grid">
|
||||
${successfulResults.map(result => renderStreamingResult(result, query)).join('')}
|
||||
@@ -378,7 +378,7 @@ async function loadStreamingResults(query) {
|
||||
console.error('Error loading streaming results:', error);
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors de la recherche des sources de streaming.</p>
|
||||
<p><i class="fa-solid fa-xmark"></i> Erreur lors de la recherche des sources de streaming.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -406,7 +406,7 @@ function renderStreamingResult(result, query) {
|
||||
</select>
|
||||
|
||||
<button class="btn btn-primary btn-small streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
||||
📥 Télécharger
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -475,7 +475,7 @@ async function translateSynopsis(synopsisId, button) {
|
||||
// Revert to original
|
||||
synopsisElement.textContent = originalText;
|
||||
synopsisElement.dataset.translated = 'false';
|
||||
button.innerHTML = '🌐 Traduire en français';
|
||||
button.innerHTML = '<i class="fa-solid fa-globe"></i> Traduire en français';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -484,7 +484,7 @@ async function translateSynopsis(synopsisId, button) {
|
||||
|
||||
// Show loading state
|
||||
button.disabled = true;
|
||||
button.innerHTML = '⏳ Traduction...';
|
||||
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Traduction...';
|
||||
synopsisElement.style.opacity = '0.5';
|
||||
|
||||
try {
|
||||
@@ -509,7 +509,7 @@ async function translateSynopsis(synopsisId, button) {
|
||||
|
||||
synopsisElement.textContent = data.translatedText;
|
||||
synopsisElement.dataset.translated = 'true';
|
||||
button.innerHTML = '🔄 Voir l\'original';
|
||||
button.innerHTML = '<i class="fa-solid fa-rotate"></i> Voir l\'original';
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
console.error('Translation API error:', errorData);
|
||||
@@ -523,7 +523,7 @@ async function translateSynopsis(synopsisId, button) {
|
||||
const errorMessage = document.createElement('div');
|
||||
errorMessage.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 107, 107, 0.2); border-radius: 8px; font-size: 12px; color: #ff6b6b;';
|
||||
errorMessage.innerHTML = `
|
||||
⚠️ Service de traduction temporairement indisponible.<br>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> Service de traduction temporairement indisponible.<br>
|
||||
<small>Essayez à nouveau dans quelques instants.</small>
|
||||
`;
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ async function loadRecommendations() {
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>⚠️ Aucune recommandation disponible pour le moment.</p>
|
||||
<p><i class="fa-solid fa-triangle-exclamation"></i> Aucune recommandation disponible pour le moment.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
Soit l'API MyAnimeList est inaccessible, soit vous n'avez pas encore de téléchargements.
|
||||
</p>
|
||||
<button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -38,10 +38,10 @@ async function loadRecommendations() {
|
||||
console.error('Error loading recommendations:', error);
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des recommandations.</p>
|
||||
<p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des recommandations.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -71,12 +71,12 @@ async function loadLatestReleases() {
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>⚠️ Aucune sortie disponible pour le moment.</p>
|
||||
<p><i class="fa-solid fa-triangle-exclamation"></i> Aucune sortie disponible pour le moment.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||
L'API MyAnimeList pourrait être temporairement inaccessible.
|
||||
</p>
|
||||
<button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -87,10 +87,10 @@ async function loadLatestReleases() {
|
||||
console.error('Error loading releases:', error);
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des sorties.</p>
|
||||
<p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des sorties.</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -100,7 +100,7 @@ async function loadLatestReleases() {
|
||||
|
||||
// Load all home content
|
||||
async function loadHomeContent() {
|
||||
console.log('🏠 loadHomeContent() called');
|
||||
console.log('loadHomeContent() called');
|
||||
|
||||
const loading = document.getElementById('homeLoading');
|
||||
const recommendationsSection = document.getElementById('recommendationsSection');
|
||||
@@ -123,13 +123,13 @@ async function loadHomeContent() {
|
||||
loadRecommendations(),
|
||||
loadLatestReleases()
|
||||
]);
|
||||
console.log('✅ Home content loaded successfully');
|
||||
console.log('Home content loaded successfully');
|
||||
|
||||
// Show sections if they have content
|
||||
if (recommendationsSection) recommendationsSection.style.display = 'block';
|
||||
if (releasesSection) releasesSection.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading home content:', error);
|
||||
console.error('Error loading home content:', error);
|
||||
if (loading) {
|
||||
loading.innerHTML = 'Erreur lors du chargement. Consultez la console pour plus de détails.';
|
||||
}
|
||||
@@ -149,11 +149,11 @@ function renderRecommendationCard(anime) {
|
||||
|
||||
return `
|
||||
<div class="anime-card-horizontal recommendation-card">
|
||||
${reason ? `<div class="recommendation-badge">💡 ${escapeHtml(reason)}</div>` : ''}
|
||||
${reason ? `<div class="recommendation-badge"><i class="fa-solid fa-lightbulb"></i> ${escapeHtml(reason)}</div>` : ''}
|
||||
|
||||
<div class="anime-card-header">
|
||||
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||
${score > 0 ? `<div class="anime-card-rating">★ ${score.toFixed(1)}</div>` : ''}
|
||||
${score > 0 ? `<div class="anime-card-rating"><i class="fa-solid fa-star"></i> ${score.toFixed(1)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="anime-card-content">
|
||||
@@ -165,7 +165,7 @@ function renderRecommendationCard(anime) {
|
||||
</div>
|
||||
|
||||
<div class="anime-card-meta">
|
||||
${anime.episodes ? `📺 ${anime.episodes} ep` : ''}
|
||||
${anime.episodes ? `<i class="fa-solid fa-tv"></i> ${anime.episodes} ep` : ''}
|
||||
${anime.episodes && anime.status ? ' • ' : ''}
|
||||
${anime.status ? translateStatus(anime.status) : ''}
|
||||
</div>
|
||||
@@ -174,17 +174,17 @@ function renderRecommendationCard(anime) {
|
||||
|
||||
${anime.synopsis ? `
|
||||
<details class="anime-synopsis">
|
||||
<summary>📖 Synopsis</summary>
|
||||
<summary><i class="fa-solid fa-book"></i> Synopsis</summary>
|
||||
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
||||
</details>
|
||||
` : ''}
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
🔗 MAL
|
||||
<i class="fa-solid fa-link"></i> MAL
|
||||
</button>
|
||||
<button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
📥 Télécharger
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,11 +202,11 @@ function renderReleaseCard(anime) {
|
||||
|
||||
return `
|
||||
<div class="anime-card-horizontal release-card">
|
||||
<div class="release-badge">🔥 ${escapeHtml(releaseType)}</div>
|
||||
<div class="release-badge"><i class="fa-solid fa-fire"></i> ${escapeHtml(releaseType)}</div>
|
||||
|
||||
<div class="anime-card-header">
|
||||
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||
${score > 0 ? `<div class="anime-card-rating">★ ${score.toFixed(1)}</div>` : ''}
|
||||
${score > 0 ? `<div class="anime-card-rating"><i class="fa-solid fa-star"></i> ${score.toFixed(1)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="anime-card-content">
|
||||
@@ -218,7 +218,7 @@ function renderReleaseCard(anime) {
|
||||
</div>
|
||||
|
||||
<div class="anime-card-meta">
|
||||
${anime.episodes ? `📺 ${anime.episodes} ep` : ''}
|
||||
${anime.episodes ? `<i class="fa-solid fa-tv"></i> ${anime.episodes} ep` : ''}
|
||||
${anime.episodes && anime.status ? ' • ' : ''}
|
||||
${anime.status ? translateStatus(anime.status) : ''}
|
||||
</div>
|
||||
@@ -227,17 +227,17 @@ function renderReleaseCard(anime) {
|
||||
|
||||
${anime.synopsis ? `
|
||||
<details class="anime-synopsis">
|
||||
<summary>📖 Synopsis</summary>
|
||||
<summary><i class="fa-solid fa-book"></i> Synopsis</summary>
|
||||
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
||||
</details>
|
||||
` : ''}
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||
🔗 MAL
|
||||
<i class="fa-solid fa-link"></i> MAL
|
||||
</button>
|
||||
<button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||
📥 Télécharger
|
||||
<i class="fa-solid fa-download"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,11 +246,11 @@ function renderReleaseCard(anime) {
|
||||
|
||||
// Get rating color based on score
|
||||
function getRatingColor(score) {
|
||||
if (score >= 9) return 'linear-gradient(45deg, #ffd700, #ffed4e)';
|
||||
if (score >= 8) return 'linear-gradient(45deg, #00ff88, #00d9ff)';
|
||||
if (score >= 7) return 'linear-gradient(45deg, #00d9ff, #6c5ce7)';
|
||||
if (score >= 6) return 'linear-gradient(45deg, #ffa500, #ff6b6b)';
|
||||
return 'linear-gradient(45deg, #666, #888)';
|
||||
if (score >= 9) return '#ffd700';
|
||||
if (score >= 8) return '#2d936c';
|
||||
if (score >= 7) return '#FF9F1C';
|
||||
if (score >= 6) return '#f4a261';
|
||||
return '#888888';
|
||||
}
|
||||
|
||||
// Search anime on providers (redirects to anime tab)
|
||||
|
||||
@@ -26,7 +26,7 @@ async function handleSeriesSearch() {
|
||||
const series = data.results['fs7'];
|
||||
let html = `
|
||||
<div class="streaming-results-header">
|
||||
<h3>📺 Résultats pour "${escapeHtml(query)}"</h3>
|
||||
<h3><i class="fa-solid fa-tv"></i> Résultats pour "${escapeHtml(query)}"</h3>
|
||||
</div>
|
||||
<div class="search-results" style="margin-top: 20px;">
|
||||
`;
|
||||
@@ -46,19 +46,19 @@ async function handleSeriesSearch() {
|
||||
<div class="anime-card" id="series-fs7-${encodeURIComponent(s.url)}">
|
||||
<div class="anime-card-header">
|
||||
<div class="anime-card-title">${escapeHtml(s.title)}</div>
|
||||
<div class="anime-card-provider">📺 French Stream</div>
|
||||
<div class="anime-card-provider"><i class="fa-solid fa-tv"></i> French Stream</div>
|
||||
</div>
|
||||
${coverImage ? `
|
||||
<div style="text-align: center; margin: 10px 0;">
|
||||
<img src="${escapeHtml(coverImage)}" alt="" style="max-width: 200px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);" onerror="this.style.display='none'">
|
||||
<img src="${escapeHtml(coverImage)}" alt="" style="max-width: 200px; border-radius: 4px;" onerror="this.style.display='none'">
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(s.url)}', '_blank')">
|
||||
🔗 Voir sur FS7
|
||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||
</button>
|
||||
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
|
||||
📥 Voir les épisodes
|
||||
<i class="fa-solid fa-download"></i> Voir les épisodes
|
||||
</button>
|
||||
</div>
|
||||
<div id="episodes-fs7-${encodeURIComponent(s.url)}" style="margin-top: 10px;"></div>
|
||||
@@ -71,7 +71,7 @@ async function handleSeriesSearch() {
|
||||
} else {
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Aucune série trouvée pour "${escapeHtml(query)}"</p>
|
||||
<p><i class="fa-solid fa-xmark"></i> Aucune série trouvée pour "${escapeHtml(query)}"</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
|
||||
Essayez avec un autre titre ou vérifiez l'orthographe
|
||||
</p>
|
||||
@@ -81,7 +81,7 @@ async function handleSeriesSearch() {
|
||||
console.error('Error searching series:', error);
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors de la recherche</p>
|
||||
<p><i class="fa-solid fa-xmark"></i> Erreur lors de la recherche</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
</div>`;
|
||||
}
|
||||
@@ -102,10 +102,10 @@ async function loadSeriesEpisodesDirect(url, title) {
|
||||
if (data.episodes && data.episodes.length > 0) {
|
||||
let html = `
|
||||
<div style="margin-top: 15px;">
|
||||
<label style="font-size: 12px; color: #00d9ff; margin-bottom: 5px; display: block;">
|
||||
📺 Sélectionner un épisode:
|
||||
<label style="font-size: 12px; color: #FF9F1C; margin-bottom: 5px; display: block;">
|
||||
<i class="fa-solid fa-tv"></i> Sélectionner un épisode:
|
||||
</label>
|
||||
<select id="select-episodes-${encodeURIComponent(url)}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid rgba(0, 217, 255, 0.3); background: rgba(0, 0, 0, 0.3); color: white;">
|
||||
<select id="select-episodes-${encodeURIComponent(url)}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #2a2d32; background: #202327; color: #F2F2F2;">
|
||||
<option value="">Sélectionner un épisode</option>
|
||||
${data.episodes.map(ep => `
|
||||
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
|
||||
@@ -145,7 +145,7 @@ async function downloadSeriesEpisode(url, title) {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert(`✅ Téléchargement démarré pour "${title}"`);
|
||||
alert(`Téléchargement démarré pour "${title}"`);
|
||||
// Refresh downloads
|
||||
if (typeof loadDownloads === 'function') {
|
||||
loadDownloads();
|
||||
@@ -155,11 +155,11 @@ async function downloadSeriesEpisode(url, title) {
|
||||
const errorMessage = error.detail
|
||||
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
|
||||
: 'Impossible de démarrer le téléchargement';
|
||||
alert(`❌ Erreur: ${errorMessage}`);
|
||||
alert(`Erreur : ${errorMessage}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
alert(`❌ Erreur lors du téléchargement: ${error.message}`);
|
||||
alert(`Erreur lors du téléchargement : ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Settings page - form handlers for user preferences, filters, and weights.
|
||||
* Loaded on all pages via base.html so functions are available when
|
||||
* the settings section is dynamically loaded via HTMX.
|
||||
*/
|
||||
|
||||
function saveSettings() {
|
||||
const data = {
|
||||
default_lang: document.getElementById('default_lang')?.value,
|
||||
theme: document.getElementById('theme')?.value,
|
||||
download_dir: document.getElementById('download_dir')?.value,
|
||||
};
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => {
|
||||
if (r.ok) showToast('Preferences enregistrees', 'success');
|
||||
}).catch(e => {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function saveFilter(field, value) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value })
|
||||
}).then(r => {
|
||||
if (r.ok) showToast('Filtre mis a jour', 'success');
|
||||
}).catch(e => {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleCategory(field, value) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
const token = localStorage.getItem('auth_token');
|
||||
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) {
|
||||
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 onWeightModeChange(mode) {
|
||||
const autoInfo = document.getElementById('weight-auto-info');
|
||||
const manualControls = document.getElementById('weight-manual-controls');
|
||||
|
||||
if (mode === 'auto') {
|
||||
if (autoInfo) autoInfo.style.display = 'block';
|
||||
if (manualControls) manualControls.style.display = 'none';
|
||||
loadAutoWeights();
|
||||
} else {
|
||||
if (autoInfo) autoInfo.style.display = 'none';
|
||||
if (manualControls) manualControls.style.display = 'block';
|
||||
updateWeightPreview();
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content_weight_mode: mode })
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAutoWeights() {
|
||||
const details = document.getElementById('weight-auto-details');
|
||||
if (!details) return;
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
try {
|
||||
const r = await fetch('/api/settings/content-weight', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
const aw = data.anime_weight;
|
||||
const sw = data.series_weight;
|
||||
const ac = data.anime_count;
|
||||
const sc = data.series_count;
|
||||
const total = data.total || 0;
|
||||
|
||||
if (total === 0) {
|
||||
details.innerHTML = '<span style="color: var(--text-dim);">Aucun telechargement detecte. Ratio par defaut : ' + aw + ' anime / ' + sw + ' serie.</span>';
|
||||
} else {
|
||||
const pctA = total > 0 ? Math.round(ac / total * 100) : 50;
|
||||
const pctS = total > 0 ? Math.round(sc / total * 100) : 50;
|
||||
details.innerHTML = `
|
||||
<div style="margin-bottom: 8px;">
|
||||
<strong>${ac}</strong> anime${ac > 1 ? 's' : ''} (${pctA}%) — <strong>${sc}</strong> serie${sc > 1 ? 's' : ''} (${pctS}%)
|
||||
</div>
|
||||
<div style="height: 8px; background: var(--secondary); border-radius: 4px; overflow: hidden; display: flex;">
|
||||
<div style="width: ${pctA}%; background: var(--primary);"></div>
|
||||
<div style="width: ${pctS}%; background: #6CB4EE;"></div>
|
||||
</div>
|
||||
<div style="margin-top: 8px; font-size: 12px;">
|
||||
Ratio applique : <strong style="color: var(--primary);">${aw}</strong> anime / <strong style="color: #6CB4EE;">${sw}</strong> serie
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (e) {
|
||||
details.innerHTML = '<span style="color: var(--danger);">Erreur de chargement</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function updateWeightPreview() {
|
||||
const awEl = document.getElementById('content_weight_anime_range');
|
||||
const swEl = document.getElementById('content_weight_series_range');
|
||||
const preview = document.getElementById('weight-preview');
|
||||
if (!awEl || !swEl || !preview) return;
|
||||
|
||||
const aw = parseInt(awEl.value) || 0;
|
||||
const sw = parseInt(swEl.value) || 0;
|
||||
const total = aw + sw;
|
||||
|
||||
if (total === 0) {
|
||||
preview.innerHTML = '<span style="color: var(--danger);">Les deux poids ne peuvent pas etre a 0</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const pctA = Math.round(aw / total * 100);
|
||||
const pctS = 100 - pctA;
|
||||
|
||||
preview.innerHTML = `
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span style="color: var(--primary); font-weight: 700;">${pctA}%</span> animes /
|
||||
<span style="color: #6CB4EE; font-weight: 700;">${pctS}%</span> series
|
||||
</div>
|
||||
<div style="height: 8px; background: var(--secondary); border-radius: 4px; overflow: hidden; display: flex;">
|
||||
<div style="width: ${pctA}%; background: var(--primary); transition: width 0.2s;"></div>
|
||||
<div style="width: ${pctS}%; background: #6CB4EE; transition: width 0.2s;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function saveManualWeights() {
|
||||
const awEl = document.getElementById('content_weight_anime_range');
|
||||
const swEl = document.getElementById('content_weight_series_range');
|
||||
if (!awEl || !swEl) return;
|
||||
|
||||
const aw = parseInt(awEl.value) || 0;
|
||||
const sw = parseInt(swEl.value) || 0;
|
||||
|
||||
if (aw === 0 && sw === 0) {
|
||||
showToast('Les deux poids ne peuvent pas etre a 0', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
try {
|
||||
const r = await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content_weight_mode: 'manual', content_weight_anime: aw, content_weight_series: sw })
|
||||
});
|
||||
if (r.ok) showToast('Equilibre mis a jour', 'success');
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const event = new CustomEvent('show-toast', { detail: { message, type } });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Initialize weight display when settings tab content is loaded via HTMX
|
||||
document.addEventListener('htmx:afterSettle', function(evt) {
|
||||
if (evt.detail.target) {
|
||||
const mode = evt.detail.target.querySelector('#content_weight_mode');
|
||||
if (mode && mode.value === 'auto') {
|
||||
loadAutoWeights();
|
||||
} else if (mode && mode.value === 'manual') {
|
||||
updateWeightPreview();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -19,7 +19,7 @@ function renderSeriesRecommendationCard(series) {
|
||||
|
||||
return `
|
||||
<div class="anime-card-horizontal recommendation-card">
|
||||
<div class="recommendation-badge">🎺 Série TV populaire</div>
|
||||
<div class="recommendation-badge"><i class="fa-solid fa-music"></i> Série TV populaire</div>
|
||||
|
||||
<div class="anime-card-header">
|
||||
<div class="anime-card-title">${escapeHtml(series.title)}</div>
|
||||
@@ -30,17 +30,17 @@ function renderSeriesRecommendationCard(series) {
|
||||
|
||||
<div class="anime-card-info">
|
||||
<div class="anime-card-meta">
|
||||
📺 Série TV
|
||||
<i class="fa-solid fa-tv"></i> Série TV
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||
🔗 Voir sur FS7
|
||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||
</button>
|
||||
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
📥 Voir les épisodes
|
||||
<i class="fa-solid fa-download"></i> Voir les épisodes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,17 +92,17 @@ function renderSeriesReleaseCard(series) {
|
||||
|
||||
<div class="anime-card-info">
|
||||
<div class="anime-card-meta">
|
||||
📺 Série TV • Nouveau
|
||||
<i class="fa-solid fa-tv"></i> Série TV • Nouveau
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||
🔗 Voir sur FS7
|
||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
||||
</button>
|
||||
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||
📥 Voir les épisodes
|
||||
<i class="fa-solid fa-download"></i> Voir les épisodes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,10 +236,10 @@ async function loadSeriesReleases() {
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des séries</p>
|
||||
<p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des séries</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn btn-secondary btn-small" onclick="loadSeriesReleases()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -260,7 +260,7 @@ async function loadProvidersGrid() {
|
||||
let html = '';
|
||||
|
||||
// Section Anime providers
|
||||
html += '<div class="section-header"><h3 style="margin-top: 20px;">🎬 Sites Anime</h3></div>';
|
||||
html += '<div class="section-header"><h3 style="margin-top: 20px;"><i class="fa-solid fa-film"></i> Sites Anime</h3></div>';
|
||||
html += '<div class="search-results">';
|
||||
|
||||
const animeProviders = Object.entries(data.anime_providers || {});
|
||||
@@ -281,11 +281,11 @@ async function loadProvidersGrid() {
|
||||
<div class="anime-card-actions">
|
||||
${domains.length > 0 ? `
|
||||
<button class="btn btn-primary btn-small" onclick="window.open('https://${domains[0]}', '_blank')">
|
||||
🔗 Visiter le site
|
||||
<i class="fa-solid fa-link"></i> Visiter le site
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn btn-secondary btn-small" onclick="showProviderSearch('${id}')">
|
||||
🔍 Rechercher
|
||||
<i class="fa-solid fa-magnifying-glass"></i> Rechercher
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,7 +298,7 @@ async function loadProvidersGrid() {
|
||||
html += '</div>';
|
||||
|
||||
// Section File hosts
|
||||
html += '<div class="section-header" style="margin-top: 40px;"><h3>💾 Hébergeurs de fichiers</h3></div>';
|
||||
html += '<div class="section-header" style="margin-top: 40px;"><h3><i class="fa-solid fa-floppy-disk"></i> Hébergeurs de fichiers</h3></div>';
|
||||
html += '<div class="search-results">';
|
||||
|
||||
const fileHosts = Object.entries(data.file_hosts || {});
|
||||
@@ -311,7 +311,7 @@ async function loadProvidersGrid() {
|
||||
</div>
|
||||
<div class="anime-card-actions">
|
||||
<button class="btn btn-secondary btn-small" onclick="showDownloadInfo()">
|
||||
📥 Télécharger un fichier
|
||||
<i class="fa-solid fa-download"></i> Télécharger un fichier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,10 +330,10 @@ async function loadProvidersGrid() {
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<p>❌ Erreur lors du chargement des fournisseurs</p>
|
||||
<p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des fournisseurs</p>
|
||||
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||
<button class="btn btn-secondary btn-small" onclick="loadProvidersGrid()" style="margin-top: 10px;">
|
||||
🔄 Réessayer
|
||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -349,7 +349,7 @@ function showProviderSearch(providerId) {
|
||||
|
||||
// Show download info (explains how to download)
|
||||
function showDownloadInfo() {
|
||||
alert('💡 Pour télécharger un fichier:\n\n1. Utilisez l\'onglet "Recherche"\n2. Entrez le nom de l\'anime/série\n3. Cliquez sur "Télécharger" sur un épisode\n\nOu bien:\n- Copiez directement un lien de téléchargement dans la barre d\'adresse de votre navigateur');
|
||||
alert('Pour télécharger un fichier:\n\n1. Utilisez l\'onglet "Recherche"\n2. Entrez le nom de l\'anime/série\n3. Cliquez sur "Télécharger" sur un épisode\n\nOu bien:\n- Copiez directement un lien de téléchargement dans la barre d\'adresse de votre navigateur');
|
||||
}
|
||||
|
||||
// Make additional functions available globally
|
||||
|
||||
@@ -346,10 +346,10 @@ async function handleStartScheduler() {
|
||||
try {
|
||||
await startScheduler();
|
||||
await loadSchedulerStatus();
|
||||
alert('✅ Planificateur démarré!');
|
||||
alert('Planificateur démarré !');
|
||||
} catch (error) {
|
||||
console.error('Error starting scheduler:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
alert(`Erreur : ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,10 +360,10 @@ async function handleStopScheduler() {
|
||||
try {
|
||||
await stopScheduler();
|
||||
await loadSchedulerStatus();
|
||||
alert('✅ Planificateur arrêté!');
|
||||
alert('Planificateur arrêté !');
|
||||
} catch (error) {
|
||||
console.error('Error stopping scheduler:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
alert(`Erreur : ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +376,7 @@ async function handleCheckAll() {
|
||||
await loadSchedulerStatus();
|
||||
} catch (error) {
|
||||
console.error('Error checking all:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
alert(`Erreur : ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ async function handleOpenSettings() {
|
||||
document.body.appendChild(modalContainer);
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
alert(`Erreur : ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,17 +438,17 @@ function updateSchedulerUI(status) {
|
||||
|
||||
if (status.next_run) {
|
||||
const nextRun = new Date(status.next_run);
|
||||
nextRunInfo.innerHTML = `✓ En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
||||
nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
||||
} else {
|
||||
// Scheduler running but no next_run yet (just started)
|
||||
const interval = status.settings?.check_interval_hours || 6;
|
||||
nextRunInfo.innerHTML = `✓ En cours<br>Vérification toutes les ${interval}h`;
|
||||
nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Vérification toutes les ${interval}h`;
|
||||
}
|
||||
} else {
|
||||
// Update buttons if they exist
|
||||
if (startBtn) startBtn.style.display = 'inline-block';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
nextRunInfo.innerHTML = '⏸️ Arrêté';
|
||||
nextRunInfo.innerHTML = '<i class="fa-solid fa-pause"></i> Arrêté';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||
|
||||
<!-- External Libraries -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<!-- External Libraries (local first, CDN fallback) -->
|
||||
<script src="/static/vendor/htmx.min.js"></script>
|
||||
<script src="/static/vendor/alpine.min.js" defer></script>
|
||||
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
|
||||
|
||||
<style>
|
||||
@@ -40,6 +41,7 @@
|
||||
<script src="/static/js/watchlist.js?v=1.11" defer></script>
|
||||
<!-- <script src="/static/js/watchlist-ui.js?v=1.11" defer></script> -->
|
||||
<script src="/static/js/main.js?v=1.11" defer></script>
|
||||
<script src="/static/js/settings.js?v=1.0" defer></script>
|
||||
</head>
|
||||
<body x-data="globalAppState">
|
||||
{% include "components/toast_container.html" %}
|
||||
|
||||
@@ -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 #2a2d32;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 #2a2d32;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 #2a2d32;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 #2a2d32;overflow: hidden;">
|
||||
<div style="padding: 20px 25px; border-bottom: 1px solid #2a2d32;">
|
||||
<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 #2a2d32;">
|
||||
<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 #2a2d32; {% 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: 4px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_active %}rgba(45,147,108,0.1); color: #2d936c{% else %}rgba(230,57,70,0.1); color: #e63946{% 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: 4px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_admin %}rgba(244,162,97,0.15); color: #f4a261{% else %}var(--bg-elevated); 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>
|
||||
@@ -2,9 +2,9 @@
|
||||
<div class="hc" id="anime-{{ anime.url | hash }}"
|
||||
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); $nextTick(() => { const input = document.getElementById('animeSearchInput'); if (input) { input.value = '{{ anime.title | e }}'; htmx.trigger(input, 'keyup'); } });">
|
||||
<div class="hc-poster">
|
||||
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/161625/00d9ff?text=No+Image' %}
|
||||
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/e6e8e6/f15025?text=No+Image' %}
|
||||
<img src="{{ poster }}" alt="{{ anime.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||
onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Error'; this.onerror=null;">
|
||||
onerror="this.src='https://placehold.co/400x600/e6e8e6/f15025?text=Error'; this.onerror=null;">
|
||||
{% if anime.metadata and anime.metadata.rating %}
|
||||
<span class="hc-rating"><i class="fas fa-star"></i> {{ anime.metadata.rating }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% set accent = "#00d9ff" %}
|
||||
{% set accent = "#FF9F1C" %}
|
||||
{% set default_lang = settings.default_lang if settings else 'vostfr' %}
|
||||
|
||||
{% set _groups = namespace(items={}) %}
|
||||
@@ -36,9 +36,9 @@
|
||||
{% set first_url = group.providers[0].url %}
|
||||
<div class="sr-card" style="--sr-accent: {{ accent }};">
|
||||
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/00d9ff?text=No+Image' }}"
|
||||
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
|
||||
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||
onerror="this.src='https://placehold.co/240x360/161625/00d9ff?text=Error'; this.onerror=null;">
|
||||
onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
|
||||
</a>
|
||||
<div class="sr-body">
|
||||
<div class="sr-top">
|
||||
@@ -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
|
||||
@@ -114,11 +114,11 @@
|
||||
.sr-card {
|
||||
display: flex; gap: 20px;
|
||||
background: var(--bg-card); border-radius: var(--card-radius);
|
||||
padding: 20px; border: 1px solid rgba(255,255,255,0.05);
|
||||
padding: 20px; border: 1px solid #2a2d32;"
|
||||
transition: var(--transition);
|
||||
}
|
||||
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
|
||||
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
|
||||
.sr-card:hover { border-color: var(--sr-accent); }
|
||||
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 4px; overflow: hidden; background: #000; }
|
||||
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.sr-top { display: flex; align-items: baseline; gap: 12px; }
|
||||
@@ -126,24 +126,24 @@
|
||||
.sr-rating { flex-shrink: 0; font-size: 0.8rem; font-weight: 700; color: #ffcc00; }
|
||||
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; line-height: 1.5; }
|
||||
.sr-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; }
|
||||
.sr-tag { font-size: 0.65rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-dim); }
|
||||
.sr-tag { font-size: 0.65rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; background: var(--bg-elevated); color: var(--text-dim); }
|
||||
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
|
||||
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||
.sr-provider-badge:hover { background: var(--sr-accent); color: #fff; }
|
||||
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; }
|
||||
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
|
||||
.sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; border: 1px solid #2a2d32; cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; }
|
||||
.sr-btn:hover { border-color: var(--text-main); background: var(--bg-card); }
|
||||
.sr-btn-dl { border-color: var(--secondary); color: var(--secondary); }
|
||||
.sr-btn-dl:hover { background: var(--secondary); color: var(--bg-dark); }
|
||||
.sr-btn-dl:hover { background: var(--secondary); color: #ffffff; }
|
||||
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
|
||||
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||
.sr-btn-watch:hover { background: var(--sr-accent); color: #fff; }
|
||||
.sr-btn-follow { border-color: var(--accent); color: var(--accent); }
|
||||
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); }
|
||||
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
|
||||
.sr-btn-follow:hover { background: var(--accent); color: #fff; }
|
||||
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(255,191,105,0.1); pointer-events: none; }
|
||||
.sr-dropdown { position: relative; }
|
||||
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
|
||||
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; }
|
||||
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); }
|
||||
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid #2a2d32; border-radius: 4px; padding: 4px; z-index: 100; }
|
||||
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 4px; transition: var(--transition); text-align: left; }
|
||||
.sr-dropdown-item:hover { background: var(--bg-elevated); }
|
||||
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
|
||||
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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(241, 80, 37, 0.1);
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--secondary);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@@ -71,21 +71,20 @@
|
||||
}
|
||||
|
||||
.view-grid .episode-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--bg-elevated);
|
||||
padding: 20px 15px;
|
||||
border-radius: 12px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
transition: var(--transition);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.view-grid .episode-item:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
background: var(--text-dim);
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.view-grid .ep-title { display: none; }
|
||||
@@ -103,15 +102,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--bg-elevated);
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.view-list .episode-item:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
background: var(--text-dim);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -123,9 +122,8 @@
|
||||
margin: 20px 0 30px 0;
|
||||
padding: 25px;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--primary);
|
||||
box-shadow: 0 0 30px rgba(0, 217, 255, 0.15);
|
||||
}
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<header>
|
||||
<h1>⚡ Ohm Stream Downloader</h1>
|
||||
<h1><i class="fa-solid fa-bolt"></i> Ohm Stream Downloader</h1>
|
||||
<p class="subtitle">Téléchargez vos vidéos, animes et séries depuis vos hébergeurs préférés</p>
|
||||
|
||||
<!-- User info and logout button -->
|
||||
<div id="userInfo" x-show="isAuthenticated" class="auth-panel" x-cloak>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="color: var(--primary); font-size: 1.2rem;">👤</span>
|
||||
<span style="color: #fff; font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: var(--primary);">-</strong></span>
|
||||
<span style="color: var(--primary); font-size: 1.2rem;"><i class="fa-solid fa-user"></i></span>
|
||||
<span style="color: var(--text-main); font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: var(--primary);">-</strong></span>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
onclick="removeToken(); isAuthenticated = false"
|
||||
hx-post="/api/auth/logout"
|
||||
hx-on::after-request="window.location.href = '/login'">
|
||||
🚪 Déconnexion
|
||||
<i class="fa-solid fa-right-from-bracket"></i> Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Login prompt (shown when not logged in) -->
|
||||
<div id="loginPrompt" x-show="!isAuthenticated" class="auth-panel" style="justify-content: center;" x-cloak>
|
||||
<p style="color: var(--primary); margin: 0;">
|
||||
👋 Bienvenue! <a href="/login" class="btn btn-secondary btn-small" style="margin-left: 10px;">Se connecter</a> pour accéder à toutes les fonctionnalités.
|
||||
<i class="fa-solid fa-hand"></i> Bienvenue! <a href="/login" class="btn btn-secondary btn-small" style="margin-left: 10px;">Se connecter</a> pour accéder à toutes les fonctionnalités.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<h2>🎯 Recommandé pour vous</h2>
|
||||
<h2><i class="fa-solid fa-bullseye"></i> Recommandé pour vous</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/recommendations"
|
||||
hx-target="#recommendationsList">
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<h2>🔥 Dernières sorties</h2>
|
||||
<h2><i class="fa-solid fa-fire"></i> Dernières sorties</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/releases/latest"
|
||||
hx-target="#releasesList">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="login-prompt" style="text-align: center; padding: 40px 20px;">
|
||||
<i class="fas fa-lock" style="font-size: 2rem; color: #00d9ff; margin-bottom: 15px;"></i>
|
||||
<p style="color: #888; font-size: 0.95rem;">Connectez-vous pour accéder à cette section.</p>
|
||||
<i class="fa-solid fa-lock" style="font-size: 2rem; color: #FF9F1C; margin-bottom: 15px;"></i>
|
||||
<p style="color: #8a8f98; font-size: 0.95rem;">Connectez-vous pour accéder à cette section.</p>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
mozallowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="player-info-hint">
|
||||
💡 Lecteur externe utilisé. Les contrôles dépendent de l'hébergeur.
|
||||
<i class="fa-solid fa-lightbulb"></i> Lecteur externe utilisé. Les contrôles dépendent de l'hébergeur.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="video-wrapper">
|
||||
@@ -41,8 +41,8 @@
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
border-radius: 4px;
|
||||
border: 1px solid #2a2d32;
|
||||
}
|
||||
.iframe-container {
|
||||
position: relative;
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
.player-info-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
color: var(--text-dim);
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
{% macro series_card(series, in_watchlist=False, lang='vf') %}
|
||||
<div class="ac" id="series-{{ series.url | hash }}">
|
||||
<div class="ac-poster">
|
||||
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}"
|
||||
alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||
onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Error'; this.onerror=null;">
|
||||
<button class="ac-play"
|
||||
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
|
||||
hx-target="#player-container" hx-swap="innerHTML">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% macro series_card(series) %}
|
||||
<div class="hc"
|
||||
@click="activeTab = 'series'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'series' } })); $nextTick(() => { const input = document.getElementById('seriesSearchInput'); if (input) { input.value = '{{ series.title | e }}'; htmx.trigger(input, 'keyup'); } });">
|
||||
<div class="hc-poster">
|
||||
<img src="{{ series.cover_image or 'https://placehold.co/400x600/202327/FF9F1C?text=No+Image' }}" alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||
onerror="this.src='https://placehold.co/400x600/202327/FF9F1C?text=Error'; this.onerror=null;">
|
||||
{% if series.lang %}
|
||||
<span class="hc-rating" style="text-transform: uppercase;">{{ series.lang }}</span>
|
||||
{% endif %}
|
||||
<span class="hc-play"><i class="fas fa-search"></i></span>
|
||||
</div>
|
||||
<div class="ac-info">
|
||||
<span class="ac-provider" style="--ac-color: var(--secondary)">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
|
||||
<h3 class="ac-title" title="{{ series.title }}">{{ series.title }}</h3>
|
||||
<div class="hc-info">
|
||||
<span class="hc-src">{{ series.provider_id or 'FS7' }}</span>
|
||||
<span class="hc-title">{{ series.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{% from "components/series_card.html" import series_card %}
|
||||
|
||||
{% if releases %}
|
||||
{% for series in releases %}
|
||||
{{ series_card(series) }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Aucune sortie recente trouvee.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,4 +1,4 @@
|
||||
{% set accent = "#ff6b6b" %}
|
||||
{% set accent = "#FF9F1C" %}
|
||||
{% set default_lang = settings.default_lang if settings else 'vf' %}
|
||||
|
||||
{% set _groups = namespace(items={}) %}
|
||||
@@ -28,9 +28,9 @@
|
||||
{% set first_url = group.providers[0].url %}
|
||||
<div class="sr-card" style="--sr-accent: {{ accent }};">
|
||||
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/ff6b6b?text=No+Image' }}"
|
||||
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
|
||||
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||
onerror="this.src='https://placehold.co/240x360/161625/ff6b6b?text=Error'; this.onerror=null;">
|
||||
onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
|
||||
</a>
|
||||
<div class="sr-body">
|
||||
<h3 class="sr-title">{{ group.title }}</h3>
|
||||
@@ -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
|
||||
@@ -93,32 +93,32 @@
|
||||
.sr-card {
|
||||
display: flex; gap: 20px;
|
||||
background: var(--bg-card); border-radius: var(--card-radius);
|
||||
padding: 20px; border: 1px solid rgba(255,255,255,0.05);
|
||||
padding: 20px; border: 1px solid #2a2d32;"
|
||||
transition: var(--transition);
|
||||
}
|
||||
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
|
||||
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
|
||||
.sr-card:hover { border-color: var(--sr-accent); }
|
||||
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 4px; overflow: hidden; background: #000; }
|
||||
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.sr-title { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; line-height: 1.5; }
|
||||
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
|
||||
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||
.sr-provider-badge:hover { background: var(--sr-accent); color: #fff; }
|
||||
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; }
|
||||
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
|
||||
.sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; border: 1px solid #2a2d32; cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; }
|
||||
.sr-btn:hover { border-color: var(--text-main); background: var(--bg-card); }
|
||||
.sr-btn-dl { border-color: var(--secondary); color: var(--secondary); }
|
||||
.sr-btn-dl:hover { background: var(--secondary); color: var(--bg-dark); }
|
||||
.sr-btn-dl:hover { background: var(--secondary); color: #ffffff; }
|
||||
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
|
||||
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||
.sr-btn-watch:hover { background: var(--sr-accent); color: #fff; }
|
||||
.sr-btn-follow { border-color: var(--accent); color: var(--accent); }
|
||||
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); }
|
||||
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
|
||||
.sr-btn-follow:hover { background: var(--accent); color: #fff; }
|
||||
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(255,191,105,0.1); pointer-events: none; }
|
||||
.sr-dropdown { position: relative; }
|
||||
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
|
||||
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; }
|
||||
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); }
|
||||
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid #2a2d32; border-radius: 4px; padding: 4px; z-index: 100; }
|
||||
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 4px; transition: var(--transition); text-align: left; }
|
||||
.sr-dropdown-item:hover { background: var(--bg-elevated); }
|
||||
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
|
||||
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -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>
|
||||
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
|
||||
<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,30 +25,150 @@
|
||||
</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 #2a2d32;">
|
||||
<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 #2a2d32;">
|
||||
<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: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); 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: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); 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>
|
||||
|
||||
<!-- Content Weight -->
|
||||
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
|
||||
<h3 style="margin-bottom: 5px; color: var(--primary);">Equilibre du fil d'actualite</h3>
|
||||
<p style="color: var(--text-dim); font-size: 13px; margin-bottom: 20px;">
|
||||
Definissez la proportion d'animes et de series affiches dans les recommandations et dernieres sorties.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content_weight_mode" style="font-weight: 600; margin-bottom: 10px; display: block;">Mode</label>
|
||||
<select name="content_weight_mode" id="content_weight_mode" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="onWeightModeChange(this.value)">
|
||||
<option value="auto" {% if settings.content_weight_mode == 'auto' %}selected{% endif %}>Automatique (basé sur vos telechargements)</option>
|
||||
<option value="manual" {% if settings.content_weight_mode == 'manual' %}selected{% endif %}>Manuel</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Auto mode info -->
|
||||
<div id="weight-auto-info" style="margin-top: 15px; padding: 15px; background: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); display: {% if settings.content_weight_mode == 'auto' %}block{% else %}none{% endif %};">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||||
<i class="fas fa-chart-pie" style="color: var(--primary);"></i>
|
||||
<span style="font-weight: 600;">Analyse de vos telechargements</span>
|
||||
</div>
|
||||
<div id="weight-auto-details" style="font-size: 14px; color: var(--text-dim);">
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual mode controls -->
|
||||
<div id="weight-manual-controls" style="margin-top: 15px; display: {% if settings.content_weight_mode == 'manual' %}block{% else %}none{% endif %};">
|
||||
<div style="display: flex; gap: 15px; align-items: center;">
|
||||
<div style="flex: 1;">
|
||||
<label for="content_weight_anime" style="font-weight: 600; font-size: 0.9rem; display: block; margin-bottom: 8px;">
|
||||
<i class="fas fa-dragon" style="color: var(--primary);"></i> Poids Animes
|
||||
</label>
|
||||
<input type="range" id="content_weight_anime_range" min="0" max="5" step="1" value="{{ settings.content_weight_anime }}"
|
||||
style="width: 100%; accent-color: var(--primary);"
|
||||
oninput="document.getElementById('content_weight_anime').value = this.value; updateWeightPreview();">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); margin-top: 2px;">
|
||||
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label for="content_weight_series" style="font-weight: 600; font-size: 0.9rem; display: block; margin-bottom: 8px;">
|
||||
<i class="fas fa-tv" style="color: #6CB4EE;"></i> Poids Series
|
||||
</label>
|
||||
<input type="range" id="content_weight_series_range" min="0" max="5" step="1" value="{{ settings.content_weight_series }}"
|
||||
style="width: 100%; accent-color: #6CB4EE;"
|
||||
oninput="document.getElementById('content_weight_series').value = this.value; updateWeightPreview();">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); margin-top: 2px;">
|
||||
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="content_weight_anime" value="{{ settings.content_weight_anime }}">
|
||||
<input type="hidden" id="content_weight_series" value="{{ settings.content_weight_series }}">
|
||||
<div id="weight-preview" style="margin-top: 15px; padding: 12px; background: var(--bg-elevated); border-radius: 4px; text-align: center; font-size: 14px;">
|
||||
</div>
|
||||
<button class="btn btn-primary" style="margin-top: 15px; width: 100%;" onclick="saveManualWeights()">
|
||||
<i class="fas fa-balance-scale"></i> Appliquer
|
||||
</button>
|
||||
</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 class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
|
||||
<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>
|
||||
|
||||
<div class="providers-settings-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px;">
|
||||
{% for provider in providers %}
|
||||
<div class="provider-status-card" style="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;">
|
||||
<div class="provider-status-card" style="padding: 15px; background: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); display: flex; align-items: center; justify-content: space-between;">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<span style="font-size: 1.5rem;">{{ provider.icon }}</span>
|
||||
<div>
|
||||
<div style="font-weight: 600;">{{ provider.name }}</div>
|
||||
<div style="font-size: 0.75rem; display: flex; align-items: center; gap: 5px;">
|
||||
<span class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}#aaa{% endif %};"></span>
|
||||
<span class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}var(--text-muted){% endif %};"></span>
|
||||
<span style="color: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}var(--text-dim){% endif %}; text-transform: uppercase; font-weight: 800;">
|
||||
{{ provider.status | upper }}
|
||||
</span>
|
||||
@@ -61,7 +181,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 %}
|
||||
@@ -78,6 +198,5 @@
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
box-shadow: 0 0 5px currentColor;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<div id="toast-container"
|
||||
class="toast-container"
|
||||
style="pointer-events: none;"
|
||||
x-data="{ toasts: [] }"
|
||||
@show-toast.window="toasts.push({ id: Date.now(), message: $event.detail.message, type: $event.detail.type || 'info' }); setTimeout(() => { toasts = toasts.filter(t => t.id !== toasts[0].id) }, 5000)">
|
||||
|
||||
<template x-for="toast in toasts" :key="toast.id">
|
||||
<div class="toast"
|
||||
style="pointer-events: auto;"
|
||||
:class="'toast-' + toast.type"
|
||||
x-show="true"
|
||||
x-transition:enter="toast-enter"
|
||||
@@ -33,22 +35,25 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.toast {
|
||||
min-width: 250px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: #2d2d2d;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-left: 4px solid #ccc;
|
||||
border-left: 4px solid var(--secondary);
|
||||
}
|
||||
.toast-success { border-left-color: #4caf50; }
|
||||
.toast-error { border-left-color: #f44336; }
|
||||
.toast-info { border-left-color: #2196f3; }
|
||||
.toast-success { border-left-color: #2d936c; }
|
||||
.toast-error { border-left-color: #e63946; }
|
||||
.toast-info { border-left-color: #FFBF69; }
|
||||
.toast-content { display: flex; align-items: center; gap: 10px; }
|
||||
.toast-close { background: none; border: none; color: #aaa; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -1,39 +1,489 @@
|
||||
{% 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 #2a2d32;
|
||||
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: var(--bg-elevated);
|
||||
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 #2a2d32;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.watchlist-card:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* 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: #2d936c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.poster-badge.paused {
|
||||
background: #f4a261;
|
||||
color: #15171A;
|
||||
}
|
||||
|
||||
.poster-badge.completed {
|
||||
background: #FF9F1C;
|
||||
color: #15171A;
|
||||
}
|
||||
|
||||
.poster-badge.archived {
|
||||
background: rgba(206, 208, 206, 0.2);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.auto-download-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
background: #FF9F1C;
|
||||
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(255, 191, 105, 0.1);
|
||||
color: var(--primary);
|
||||
border: 1px solid rgba(255, 191, 105, 0.3);
|
||||
}
|
||||
|
||||
.meta-lang {
|
||||
background: rgba(206, 208, 206, 0.3);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--text-dim);
|
||||
}
|
||||
|
||||
.meta-quality {
|
||||
background: rgba(45, 147, 108, 0.1);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(45, 147, 108, 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 #2a2d32;
|
||||
}
|
||||
|
||||
.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: var(--bg-elevated);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--secondary);
|
||||
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: #FFBF69;
|
||||
}
|
||||
|
||||
.btn-complete:hover {
|
||||
background: rgba(255, 191, 105, 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 #2a2d32;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<h2>📋 Ma Watchlist</h2>
|
||||
<h2><i class="fa-solid fa-clipboard-list"></i> Ma Watchlist</h2>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-sm btn-primary" hx-post="/api/watchlist/check" hx-swap="none">
|
||||
<i class="fas fa-sync"></i> Vérifier épisodes
|
||||
@@ -35,15 +35,15 @@
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: transform 0.2s;
|
||||
background: var(--bg-card);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.watchlist-item:hover { transform: translateY(-3px); border-color: #00d9ff; }
|
||||
.item-poster img { width: 80px; height: 120px; border-radius: 8px; object-fit: cover; }
|
||||
.watchlist-item:hover { border-color: #FFBF69; }
|
||||
.item-poster img { width: 80px; height: 120px; border-radius: 4px; object-fit: cover; }
|
||||
.item-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.item-info h3 { font-size: 1rem; margin-bottom: 5px; color: #fff; }
|
||||
.item-info h3 { font-size: 1rem; margin-bottom: 5px; color: #F2F2F2; }
|
||||
.item-meta { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.item-actions { display: flex; gap: 10px; margin-top: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -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 -->
|
||||
@@ -49,13 +46,13 @@
|
||||
<!-- Player container for HTMX injections -->
|
||||
<div id="player-container"></div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
||||
<hr style="border: none; border-top: 1px solid #2a2d32; 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,35 +92,18 @@
|
||||
<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 -->
|
||||
<div id="seriesSearchResults" style="margin-bottom: 40px;"></div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
||||
<hr style="border: none; border-top: 1px solid #2a2d32; margin: 40px 0;">
|
||||
|
||||
<!-- Recommendations Section -->
|
||||
<!-- Latest Releases Section - Series only -->
|
||||
<div class="section-header">
|
||||
<h2>🎯 Recommandé pour vous</h2>
|
||||
<h2>Dernieres sorties Series TV</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/recommendations"
|
||||
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>
|
||||
</svg>
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations" hx-trigger="load delay:600ms"></div>
|
||||
|
||||
<!-- Latest Releases Section -->
|
||||
<div class="section-header" style="margin-top: 40px;">
|
||||
<h2>🔥 Dernières sorties Séries TV</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/releases/latest"
|
||||
hx-get="/api/series/latest?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 +111,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/series/latest?html=1" hx-trigger="load delay:700ms"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
|
||||
@@ -145,7 +125,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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<h1 class="auth-title">🎬 Ohm Stream</h1>
|
||||
<h1 class="auth-title"><i class="fa-solid fa-film"></i> Ohm Stream</h1>
|
||||
|
||||
<div class="auth-tabs">
|
||||
<div class="auth-tab active" data-tab="login">Connexion</div>
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
background: #15171A;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
color: #F2F2F2;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -36,13 +36,14 @@
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
color: #00d9ff;
|
||||
color: #FFBF69;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: #202327;
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #2a2d32;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -57,19 +58,18 @@
|
||||
}
|
||||
|
||||
.video-info .filesize {
|
||||
color: #aaa;
|
||||
color: #8a8f98;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
background: #000;
|
||||
border-radius: 15px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.plyr {
|
||||
border-radius: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -82,13 +82,13 @@
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
background: #202327;
|
||||
border: 1px solid #2a2d32;
|
||||
color: #F2F2F2;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -96,28 +96,28 @@
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(0, 217, 255, 0.2);
|
||||
border-color: #00d9ff;
|
||||
transform: translateY(-2px);
|
||||
background: #2a2d32;
|
||||
border-color: #FFBF69;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||
border: none;
|
||||
color: #000;
|
||||
background: #FF9F1C;
|
||||
border: 1px solid #FF9F1C;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #00ff88 0%, #00d9ff 100%);
|
||||
background: #e08a15;
|
||||
border-color: #e08a15;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(255, 71, 87, 0.1);
|
||||
border: 1px solid #ff4757;
|
||||
color: #ff4757;
|
||||
background: rgba(230, 57, 70, 0.1);
|
||||
border: 1px solid #e63946;
|
||||
color: #e63946;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -135,7 +135,7 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎬 Ohm Stream Player</h1>
|
||||
<h1><i class="fa-solid fa-film"></i> Ohm Stream Player</h1>
|
||||
</div>
|
||||
|
||||
<div class="video-info">
|
||||
@@ -151,7 +151,7 @@
|
||||
|
||||
<div class="controls">
|
||||
<a href="/web" class="btn">← Retour à l'accueil</a>
|
||||
<a href="/stream/{{ filename }}" class="btn btn-primary" download>⬇️ Télécharger</a>
|
||||
<a href="/stream/{{ filename }}" class="btn btn-primary" download><i class="fa-solid fa-download"></i> Télécharger</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,8 +169,8 @@
|
||||
wrapper.innerHTML = `
|
||||
<div class="error-message">
|
||||
Erreur lors de la lecture du flux vidéo.<br>
|
||||
<a href="/video/{{ task_id }}" style="color: #00d9ff; text-decoration: underline;">Réessayer</a> ou
|
||||
<a href="/stream/{{ filename }}" style="color: #00d9ff; text-decoration: underline;" download>Télécharger</a>
|
||||
<a href="/video/{{ task_id }}" style="color: #FF9F1C; text-decoration: underline;">Réessayer</a> ou
|
||||
<a href="/stream/{{ filename }}" style="color: #FF9F1C; text-decoration: underline;" download>Télécharger</a>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
@@ -10,29 +10,29 @@
|
||||
<body class="watchlist-body">
|
||||
<!-- Main Header -->
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h1 style="background: linear-gradient(45deg, #00d9ff, #00ff88); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-size: 32px; margin: 0;">⚡ Ohm Stream Downloader</h1>
|
||||
<p style="color: #888; font-size: 14px; margin: 5px 0 0;">Téléchargez vos vidéos, animes et séries</p>
|
||||
<h1 style="color: #FF9F1C; font-size: 32px; margin: 0;"><i class="fa-solid fa-bolt"></i> Ohm Stream Downloader</h1>
|
||||
<p style="color: #8a8f98; font-size: 14px; margin: 5px 0 0;">Téléchargez vos vidéos, animes et séries</p>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div id="userInfo" style="display: none; max-width: 1200px; margin: 0 auto 15px; padding: 10px; background: rgba(0,217,255,0.1); border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="color: #00d9ff;">👤 Connecté</span>
|
||||
<button class="btn-secondary btn-small" onclick="handleLogout()">🚪 Déconnexion</button>
|
||||
<div id="userInfo" style="display: none; max-width: 1200px; margin: 0 auto 15px; padding: 10px; background: rgba(255,191,105,0.1); border: 1px solid #FF9F1C; border-radius: 4px;">
|
||||
<span style="color: #FF9F1C;"><i class="fa-solid fa-user"></i> Connecté</span>
|
||||
<button class="btn-secondary btn-small" onclick="handleLogout()"><i class="fa-solid fa-right-from-bracket"></i> Déconnexion</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs" style="max-width: 1200px; margin: 0 auto 20px; display: flex; gap: 5px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 10px;">
|
||||
<button class="tab" onclick="window.location.href='/web'">🏠 Accueil</button>
|
||||
<button class="tab" onclick="window.location.href='/web#anime'">🎬 Anime</button>
|
||||
<button class="tab" onclick="window.location.href='/web#series'">📺 Série</button>
|
||||
<button class="tab" onclick="window.location.href='/web#providers'">📦 Fournisseurs</button>
|
||||
<button class="tab active" onclick="window.location.href='/watchlist'">📋 Watchlist</button>
|
||||
<div class="tabs" style="max-width: 1200px; margin: 0 auto 20px; display: flex; gap: 5px; border-bottom: 1px solid #2a2d32; padding-bottom: 10px;">
|
||||
<button class="tab" onclick="window.location.href='/web'"><i class="fa-solid fa-house"></i> Accueil</button>
|
||||
<button class="tab" onclick="window.location.href='/web#anime'"><i class="fa-solid fa-film"></i> Anime</button>
|
||||
<button class="tab" onclick="window.location.href='/web#series'"><i class="fa-solid fa-tv"></i> Série</button>
|
||||
<button class="tab" onclick="window.location.href='/web#providers'"><i class="fa-solid fa-box"></i> Fournisseurs</button>
|
||||
<button class="tab active" onclick="window.location.href='/watchlist'"><i class="fa-solid fa-clipboard-list"></i> Watchlist</button>
|
||||
</div>
|
||||
|
||||
<div class="watchlist-container">
|
||||
<!-- Header -->
|
||||
<div class="watchlist-header">
|
||||
<h1>📋 Ma Watchlist</h1>
|
||||
<h1><i class="fa-solid fa-clipboard-list"></i> Ma Watchlist</h1>
|
||||
<p>Suivez vos animes préférés et téléchargez automatiquement les nouveaux épisodes</p>
|
||||
<button type="button" class="btn-secondary watchlist-header-back-btn" onclick="window.location.href = '/web'">
|
||||
← Retour à l'accueil
|
||||
@@ -43,21 +43,21 @@
|
||||
<div class="scheduler-status" id="schedulerStatus">
|
||||
<div class="scheduler-status-header">
|
||||
<div>
|
||||
<h3>⏰ Planificateur Automatique</h3>
|
||||
<h3><i class="fa-solid fa-clock"></i> Planificateur Automatique</h3>
|
||||
<div id="nextRunInfo" class="next-run-info">Chargement...</div>
|
||||
</div>
|
||||
<div class="scheduler-controls">
|
||||
<button id="startSchedulerBtn" class="btn-primary btn-small watchlist-btn-small" onclick="handleStartScheduler()" style="display:none;">
|
||||
▶️ Démarrer
|
||||
<i class="fa-solid fa-play"></i> Démarrer
|
||||
</button>
|
||||
<button id="stopSchedulerBtn" class="btn-secondary btn-small watchlist-btn-small" onclick="handleStopScheduler()" style="display:none;">
|
||||
⏸️ Arrêter
|
||||
<i class="fa-solid fa-pause"></i> Arrêter
|
||||
</button>
|
||||
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleCheckAll()">
|
||||
🔍 Vérifier tout
|
||||
<i class="fa-solid fa-magnifying-glass"></i> Vérifier tout
|
||||
</button>
|
||||
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleOpenSettings()">
|
||||
⚙️ Paramètres
|
||||
<i class="fa-solid fa-gear"></i> Paramètres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,17 +161,17 @@
|
||||
|
||||
if (status.next_run) {
|
||||
const nextRun = new Date(status.next_run);
|
||||
nextRunInfo.innerHTML = `✓ En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
||||
nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
||||
} else {
|
||||
// Scheduler running but no next_run yet (just started)
|
||||
const interval = status.settings?.check_interval_hours || 6;
|
||||
nextRunInfo.innerHTML = `✓ En cours<br>Vérification toutes les ${interval}h`;
|
||||
nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Vérification toutes les ${interval}h`;
|
||||
}
|
||||
} else {
|
||||
// Update buttons if they exist
|
||||
if (startBtn) startBtn.style.display = 'inline-block';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
nextRunInfo.innerHTML = '⏸️ Arrêté';
|
||||
nextRunInfo.innerHTML = '<i class="fa-solid fa-pause"></i> Arrêté';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,10 +198,10 @@
|
||||
try {
|
||||
await startScheduler();
|
||||
await loadSchedulerStatus();
|
||||
alert('✅ Planificateur démarré!');
|
||||
alert('Planificateur démarré !');
|
||||
} catch (error) {
|
||||
console.error('Error starting scheduler:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
alert(`Erreur : ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,10 +212,10 @@
|
||||
try {
|
||||
await stopScheduler();
|
||||
await loadSchedulerStatus();
|
||||
alert('✅ Planificateur arrêté!');
|
||||
alert('Planificateur arrêté !');
|
||||
} catch (error) {
|
||||
console.error('Error stopping scheduler:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
alert(`Erreur : ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
await loadSchedulerStatus();
|
||||
} catch (error) {
|
||||
console.error('Error checking all:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
alert(`Erreur : ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
document.body.appendChild(modalContainer);
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
alert(`❌ Erreur: ${error.message}`);
|
||||
alert(`Erreur : ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Ohm Streaming - Automated Test Report
|
||||
**Date:** 2026-04-09T15:34:39.316Z
|
||||
**Duration:** 62.0s
|
||||
**Base URL:** http://127.0.0.1:3000
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| ✅ Passed | 30 |
|
||||
| ❌ Failed | 0 |
|
||||
| 📊 Total | 30 |
|
||||
| 📊 Pass Rate | 100.0% |
|
||||
|
||||
## All tests passed!
|
||||
|
||||
## Screenshots
|
||||
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 553 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 162 KiB |
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Ohm Streaming - Automated E2E Test Suite
|
||||
* Run: node tests/auto/run_tests.mjs
|
||||
* Output: tests/auto/results/report.md + screenshots/
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const BASE = 'http://127.0.0.1:3000';
|
||||
const RESULTS_DIR = path.join(import.meta.dirname, 'results');
|
||||
const SCREENSHOT_DIR = path.join(RESULTS_DIR, 'screenshots');
|
||||
|
||||
const CREDS = { username: 'roman', password: 'roman123' };
|
||||
|
||||
// ── Helpers ──
|
||||
const results = { passed: 0, failed: 0, errors: [], duration: 0 };
|
||||
const startTime = Date.now();
|
||||
|
||||
function screenshot(page, name) {
|
||||
const p = path.join(SCREENSHOT_DIR, `${name}.png`);
|
||||
return page.screenshot({ path: p, fullPage: true }).then(() => p);
|
||||
}
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
results.passed++;
|
||||
console.log(` ✅ ${name}`);
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
const msg = `❌ ${name}: ${err.message}`;
|
||||
results.errors.push(msg);
|
||||
console.error(` ❌ ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message || 'Assertion failed');
|
||||
}
|
||||
|
||||
// ── Main ──
|
||||
(async () => {
|
||||
// Ensure output dirs
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
// Collect console errors
|
||||
const consoleErrors = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
|
||||
// Network error tracking
|
||||
const networkErrors = [];
|
||||
page.on('requestfailed', req => {
|
||||
networkErrors.push(`${req.method()} ${req.url()}: ${req.failure()?.errorText}`);
|
||||
});
|
||||
|
||||
console.log('\n🧪 Ohm Streaming - Automated Test Suite\n');
|
||||
console.log('═══ Phase 1: API Health ═══');
|
||||
|
||||
// ── Phase 1: API Health Checks ──
|
||||
await page.goto(`${BASE}/health`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await test('GET /health returns 200', async () => {
|
||||
const text = await page.textContent('body');
|
||||
const json = JSON.parse(text);
|
||||
assert(json.status === 'healthy' || json.status === 'ok', `Unexpected status: ${json.status}`);
|
||||
});
|
||||
|
||||
await test('GET / returns landing page', async () => {
|
||||
const resp = await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
assert(resp.status() === 200, `Status ${resp.status()}`);
|
||||
await page.waitForTimeout(1500);
|
||||
const screenshotPath = await screenshot(page, '01_landing_page');
|
||||
console.log(` 📸 ${screenshotPath}`);
|
||||
});
|
||||
|
||||
await test('GET /login returns login page', async () => {
|
||||
const resp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
assert(resp.status() === 200);
|
||||
await page.waitForTimeout(1500);
|
||||
const screenshotPath = await screenshot(page, '02_login_page');
|
||||
console.log(` 📸 ${screenshotPath}`);
|
||||
});
|
||||
|
||||
// ── Phase 2: Authentication ──
|
||||
console.log('\n═══ Phase 2: Authentication ═══');
|
||||
|
||||
await test('Login with valid credentials (roman/roman123)', async () => {
|
||||
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Use API to login (SPA approach)
|
||||
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const token = await page.evaluate(async (creds) => {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(creds)
|
||||
});
|
||||
if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`);
|
||||
return (await res.json()).access_token;
|
||||
}, CREDS);
|
||||
|
||||
assert(token && token.length > 10, 'No valid token received');
|
||||
|
||||
// Inject token into localStorage
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem('auth_token', t);
|
||||
}, token);
|
||||
console.log(` 🔑 Token received (${token.substring(0, 20)}...)`);
|
||||
});
|
||||
|
||||
await test('GET /api/auth/me returns user info', async () => {
|
||||
const user = await page.evaluate(async () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
return res.json();
|
||||
});
|
||||
// Response may be { username, ... } or { user: { username, ... } }
|
||||
const name = user.username || user.user?.username || user.id;
|
||||
assert(name, `No username found in /me response: ${JSON.stringify(user).substring(0, 200)}`);
|
||||
console.log(` 👤 User: ${name} (admin: ${user.is_admin || user.user?.is_admin || false})`);
|
||||
});
|
||||
|
||||
// ── Phase 3: SPA Navigation ──
|
||||
console.log('\n═══ Phase 3: SPA Navigation (/web) ═══');
|
||||
|
||||
const tabs = ['home', 'anime', 'series', 'providers', 'downloads', 'watchlist', 'settings'];
|
||||
|
||||
for (const tab of tabs) {
|
||||
await test(`Navigate to tab: ${tab}`, async () => {
|
||||
await page.goto(`${BASE}/web`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Inject auth
|
||||
await page.evaluate(() => {
|
||||
// Token should already be in localStorage from login test
|
||||
// but let's verify
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) throw new Error('No auth token in localStorage');
|
||||
});
|
||||
|
||||
// Switch tab using the app's own mechanism
|
||||
await page.evaluate((tabName) => {
|
||||
window.location.hash = tabName;
|
||||
window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: tabName } }));
|
||||
}, tab);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check no JS errors during navigation
|
||||
const currentErrors = consoleErrors.length;
|
||||
// Just verify page didn't crash
|
||||
const content = await page.textContent('body');
|
||||
assert(content && content.length > 10, `Tab ${tab} rendered empty content`);
|
||||
|
||||
const screenshotPath = await screenshot(page, `03_tab_${tab}`);
|
||||
console.log(` 📸 ${screenshotPath}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phase 4: API Endpoints ──
|
||||
console.log('\n═══ Phase 4: API Endpoints ═══');
|
||||
|
||||
const apiTests = [
|
||||
{ name: 'GET /api/settings', endpoint: '/api/settings', method: 'GET' },
|
||||
{ name: 'GET /api/favorites', endpoint: '/api/favorites', method: 'GET' },
|
||||
{ name: 'GET /api/watchlist', endpoint: '/api/watchlist', method: 'GET' },
|
||||
{ name: 'GET /api/downloads', endpoint: '/api/downloads', method: 'GET' },
|
||||
{ name: 'GET /api/watchlist/settings', endpoint: '/api/watchlist/settings', method: 'GET' },
|
||||
{ name: 'GET /api/watchlist/stats/summary', endpoint: '/api/watchlist/stats/summary', method: 'GET' },
|
||||
{ name: 'GET /api/providers/health', endpoint: '/api/providers/health', method: 'GET' },
|
||||
{ name: 'GET /api/recommendations', endpoint: '/api/recommendations', method: 'GET' },
|
||||
{ name: 'GET /api/releases/latest', endpoint: '/api/releases/latest', method: 'GET' },
|
||||
{ name: 'GET /api/favorites/stats', endpoint: '/api/favorites/stats', method: 'GET' },
|
||||
];
|
||||
|
||||
for (const apiTest of apiTests) {
|
||||
await test(`${apiTest.name} returns 200`, async () => {
|
||||
const result = await page.evaluate(async ({ endpoint, method }) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
let body = null;
|
||||
try { body = await res.json(); } catch(e) { /* body stays null */ }
|
||||
return { status: res.status, body };
|
||||
}, apiTest);
|
||||
|
||||
assert(result.status === 200, `${apiTest.name} returned ${result.status}: ${JSON.stringify(result.body).substring(0, 200)}`);
|
||||
// Verify it's valid JSON
|
||||
assert(typeof result.body === 'object', `${apiTest.name} returned non-JSON`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phase 5: Content Validation ──
|
||||
console.log('\n═══ Phase 5: Content Validation ═══');
|
||||
|
||||
await test('Home tab renders content (not blank)', async () => {
|
||||
await page.goto(`${BASE}/web#home`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await page.waitForTimeout(3000);
|
||||
const content = await page.textContent('body');
|
||||
assert(content.length > 100, 'Home tab content too short - may be blank');
|
||||
console.log(` 📝 Content length: ${content.length} chars`);
|
||||
});
|
||||
|
||||
await test('Alpine.js loaded correctly', async () => {
|
||||
const alpineLoaded = await page.evaluate(() => typeof window.Alpine !== 'undefined');
|
||||
assert(alpineLoaded, 'Alpine.js not loaded - x-* directives are dead');
|
||||
console.log(` ⚡ Alpine.js: loaded`);
|
||||
});
|
||||
|
||||
await test('HTMX loaded correctly', async () => {
|
||||
const htmxLoaded = await page.evaluate(() => typeof window.htmx !== 'undefined');
|
||||
assert(htmxLoaded, 'HTMX not loaded');
|
||||
console.log(` ⚡ HTMX: loaded`);
|
||||
});
|
||||
|
||||
await test('No critical JS errors in console', async () => {
|
||||
// Filter out non-critical errors (network, extensions)
|
||||
const critical = consoleErrors.filter(e =>
|
||||
!e.includes('favicon') &&
|
||||
!e.includes('net::ERR_CONNECTION') &&
|
||||
!e.includes('404') &&
|
||||
!e.includes('DevTools')
|
||||
);
|
||||
assert(critical.length === 0, `${critical.length} critical JS errors: ${critical.slice(0, 3).join('; ')}`);
|
||||
console.log(` ✨ Console clean (${consoleErrors.length} total, 0 critical)`);
|
||||
});
|
||||
|
||||
// ── Phase 6: Search Functionality ──
|
||||
console.log('\n═══ Phase 6: Search Functionality ═══');
|
||||
|
||||
await test('Anime search API works', async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch('/api/anime/search?q=naruto&limit=3', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
});
|
||||
// Search may return empty if providers are down, but should not error
|
||||
assert(result.status === 200, `Search returned ${result.status}`);
|
||||
console.log(` 🔍 Search results: ${JSON.stringify(result.body).substring(0, 100)}`);
|
||||
});
|
||||
|
||||
// ── Phase 7: Responsive Design ──
|
||||
console.log('\n═══ Phase 7: Responsive Design ═══');
|
||||
|
||||
await test('Mobile viewport rendering', async () => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15'
|
||||
});
|
||||
const mobilePage = await context.newPage();
|
||||
|
||||
// Re-auth on mobile
|
||||
await mobilePage.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await mobilePage.waitForTimeout(2000);
|
||||
|
||||
const token = await mobilePage.evaluate(async (creds) => {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(creds)
|
||||
});
|
||||
return (await res.json()).access_token;
|
||||
}, CREDS);
|
||||
|
||||
await mobilePage.evaluate((t) => localStorage.setItem('auth_token', t), token);
|
||||
|
||||
await mobilePage.goto(`${BASE}/web#home`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
await mobilePage.waitForTimeout(3000);
|
||||
|
||||
const screenshotPath = await mobilePage.screenshot({ path: path.join(SCREENSHOT_DIR, '07_mobile_home.png'), fullPage: true });
|
||||
console.log(` 📸 ${screenshotPath}`);
|
||||
|
||||
// Check for horizontal overflow
|
||||
const overflow = await mobilePage.evaluate(() => {
|
||||
const w = window.innerWidth;
|
||||
return Array.from(document.querySelectorAll('*'))
|
||||
.filter(el => el.getBoundingClientRect().width > w)
|
||||
.length;
|
||||
});
|
||||
assert(overflow === 0, `${overflow} elements overflow horizontally on mobile`);
|
||||
|
||||
await context.close();
|
||||
console.log(` 📱 Mobile: no horizontal overflow`);
|
||||
});
|
||||
|
||||
// ── Phase 8: Settings API ──
|
||||
console.log('\n═══ Phase 8: Settings & Providers ═══');
|
||||
|
||||
await test('GET /api/settings returns valid config', async () => {
|
||||
const settings = await page.evaluate(async () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch('/api/settings', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
return res.json();
|
||||
});
|
||||
assert(settings && typeof settings === 'object', 'Settings not an object');
|
||||
console.log(` ⚙️ Settings keys: ${Object.keys(settings).join(', ')}`);
|
||||
});
|
||||
|
||||
await test('GET /api/providers/health check', async () => {
|
||||
const health = await page.evaluate(async () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch('/api/providers/health', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
return res.json();
|
||||
});
|
||||
assert(health !== null, 'Provider health returned null');
|
||||
const providerCount = Array.isArray(health) ? health.length : Object.keys(health).length;
|
||||
console.log(` 🏥 Providers checked: ${providerCount}`);
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
// ── Generate Report ──
|
||||
results.duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
consoleErrors.length = 0;
|
||||
|
||||
const report = `# Ohm Streaming - Automated Test Report
|
||||
**Date:** ${new Date().toISOString()}
|
||||
**Duration:** ${results.duration}s
|
||||
**Base URL:** ${BASE}
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| ✅ Passed | ${results.passed} |
|
||||
| ❌ Failed | ${results.failed} |
|
||||
| 📊 Total | ${results.passed + results.failed} |
|
||||
| 📊 Pass Rate | ${((results.passed / (results.passed + results.failed)) * 100).toFixed(1)}% |
|
||||
|
||||
${results.errors.length > 0 ? `## Failed Tests\n\n${results.errors.map((e, i) => `${i + 1}. ${e}`).join('\n')}` : '## All tests passed!'}
|
||||
|
||||
## Screenshots
|
||||
|
||||
${fs.readdirSync(SCREENSHOT_DIR).map(f => `- `).join('\n')}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(RESULTS_DIR, 'report.md'), report);
|
||||
|
||||
console.log('\n═══════════════════════════════════');
|
||||
console.log(` Results: ${results.passed}/${results.passed + results.failed} passed (${results.duration}s)`);
|
||||
console.log(` Report: ${path.join(RESULTS_DIR, 'report.md')}`);
|
||||
console.log(` Screenshots: ${SCREENSHOT_DIR}`);
|
||||
if (results.errors.length > 0) {
|
||||
console.log(`\n Failed tests:`);
|
||||
results.errors.forEach(e => console.log(` ${e}`));
|
||||
}
|
||||
console.log('═══════════════════════════════════\n');
|
||||
|
||||
process.exit(results.failed > 0 ? 1 : 0);
|
||||
})();
|
||||