Phase 2 Complete: SQL migration with SQLModel and Alembic
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

This commit is contained in:
root
2026-03-25 13:46:15 +00:00
parent 96b12b66e2
commit a684237725
21 changed files with 1148 additions and 466 deletions
+78 -225
View File
@@ -1,274 +1,127 @@
# Ohm Stream Downloader # Ohm Stream Downloader
**Application web complète pour télécharger des animes, séries TV et fichiers depuis divers hébergeurs.** **Application web complète pour rechercher, streamer et télécharger des animes, séries TV et films.**
Interface moderne avec recherche unifiée, watchlist automatique, métadonnées enrichies, téléchargements parallèles et streaming vidéo. Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, métadonnées enrichies, téléchargements parallèles et intégration Sonarr.
## ✨ Fonctionnalités ## ✨ Fonctionnalités
### 🎬 Recherche d'Animes & Séries TV ### 🎬 Recherche & Streaming
- **Recherche unifiée** : Recherchez animes et séries TV simultanément - **Recherche unifiée** : Recherchez animes et séries TV simultanément.
- **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga - **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga.
- **Providers Séries** : FS7 (French-Stream) - **Providers Séries** : FS7 (French-Stream).
- **Métadonnées riches** : Synopsis, genres, notes, année de sortie, studio, nombre d'épisodes - **Métadonnées riches** : Synopsis, genres, notes, studio via intégration Kitsu.
- **Téléchargement par épisode** : Sélectionnez et téléchargez des épisodes individuels - **Streaming vidéo** : Lecteur intégré supportant divers hébergeurs.
- **Téléchargement de saison complète** : Téléchargez tous les épisodes d'un coup - **Téléchargement flexible** : Épisode par épisode ou saison complète.
- **Streaming vidéo** : Regardez vos animes directement dans le navigateur
- **Recherche floue** : Gestion des fautes de frappe et variations de noms
### 📋 Watchlist (Suivi Automatique) ### 📋 Watchlist & Automatisation
- **Ajout à la watchlist** : Suivez vos animes préférés depuis la recherche - **Suivi intelligent** : Ajoutez des animes à votre watchlist pour ne rater aucun épisode.
- **Téléchargement automatique** : Télécharge tous les épisodes dès le suivi - **Auto-Download** : Téléchargement automatique des nouveaux épisodes dès leur sortie.
- **Vérification automatique** : Le planificateur vérifie les nouveaux épisodes automatiquement - **Planificateur (Scheduler)** : Vérification périodique configurable (1h à 168h).
- **Intervalle configurable** : Paramétrez la fréquence de vérification (1-168 heures) - **Filtres avancés** : Visualisation par statut (Actif, En pause, Terminé).
- **Notifications** : Recevez des alertes pour les nouveaux épisodes - **Intégration Sonarr** : Support des webhooks pour une automatisation complète du homelab.
- **Filtres** : Visualisez tous / actifs / en pause / terminés
- **Contrôle granulaire** : Pausez, reprenez, vérifiez manuellement chaque anime
### 📁 Hébergeurs de Fichiers Supportés ### 🚀 Gestionnaire de Téléchargements
- **1fichier** (1fichier.com, 1fichier.fr) - **Multi-threading** : Jusqu'à 5 téléchargements simultanés avec gestion de file d'attente.
- **Uptobox** (uptobox.com, uptobox.fr) - **Pause/Reprise** : Support du protocole HTTP Range pour reprendre les téléchargements interrompus.
- **Doodstream** (doodstream.com, dood.to, dood.lol, etc.) - **Progression Temps Réel** : Vitesse (Mo/s), pourcentage et estimation du temps restant.
- **Rapidfile** (rapidfile.net, rapidfile.com) - **Sanitisation** : Nettoyage automatique des noms de fichiers pour une compatibilité maximale.
- **Uqload** (uqload.co, uqload.io)
- **OneUpload** (oneupload.co)
- **SendVid** (sendvid.com)
- **VidZ** (vidzi.tv)
### 🎥 Hébergeurs Vidéo Supportés ## 🏗️ Architecture (Three-Tier System)
- **VidMoly** (vidmoly.to, vidmoly.com)
- **SendVid** (sendvid.com)
- **DoodStream** (doodstream.com)
- **LPlayer** (lplayer.net)
- **VidZy** (vidzy.tv)
### 🚀 Gestion des Téléchargements L'application repose sur un système à trois couches pour une robustesse maximale :
- **Téléchargements parallèles** : Hasta 5 téléchargements simultanés 1. **Catalogues (Anime/Series Sites)** : Extraction des listes d'épisodes et métadonnées.
- **Pause/Reprise** : Contrôle total sur vos téléchargements 2. **Players Vidéo (Video Players)** : Extraction des liens de téléchargement direct depuis les embeds (VidMoly, DoodStream, etc.).
- **Progression en temps réel** : Vitesse, progression, taille 3. **Manager (Download Manager)** : Orchestration asynchrone des transferts de fichiers.
- **Reprise automatique** : Support des HTTP Range pour reprendre les téléchargements interrompus
### 🌐 Interface Web ## 📁 Hébergeurs Supportés
- **Design moderne** : Interface sombre avec onglets
- **5 onglets** : Accueil, Recherche, Séries, Providers, Watchlist
- **Responsive** : Fonctionne sur desktop et mobile
- **Mise à jour automatique** : Rafraîchissement automatique du contenu
- **Métadonnées visuelles** : Affichage des informations anime avec icônes
### 🔌 API REST | Type | Services Supportés |
- **Endpoints REST** : Intégration facile avec d'autres applications | :--- | :--- |
- **Documentation automatique** : Swagger UI disponible | **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 |
## 📋 Configuration Requise ## 📋 Configuration Requise
- Python 3.8+ - **Python 3.11+**
- pip - **Node.js** (pour les tests frontend uniquement)
- **Playwright** (pour l'extraction dynamique sur certains sites)
## 🚀 Installation ## 🚀 Installation Rapide
```bash ```bash
# Cloner le repository # Cloner le repository
git clone https://git.lanro.eu/Roman/ohm_streaming.git git clone https://git.lanro.eu/Roman/ohm_streaming.git
cd ohm_streaming cd ohm_streaming
# Créer l'environnement virtuel # Environnement Python
python3 -m venv venv python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate source venv/bin/activate
# Installer les dépendances
pip install -r requirements.txt pip install -r requirements.txt
# Lancer le serveur de développement # Initialisation Playwright (requis pour VidMoly)
playwright install chromium
# Lancer l'application
uvicorn main:app --reload --host 0.0.0.0 --port 3000 uvicorn main:app --reload --host 0.0.0.0 --port 3000
``` ```
Accès Web : `http://localhost:3000/web`
Accédez à l'interface : http://localhost:3000/web ## 🧪 Tests & Qualité
## 📖 Utilisation
### Interface Web
1. **Onglet Accueil** :
- Dernières sorties anime et séries populaires
- Recommandations personnalisées
2. **Onglet Recherche** :
- Entrez le nom d'un anime (ex: "Naruto", "Frieren")
- Sélectionnez la langue (VOSTFR ou VF)
- Cliquez sur "Rechercher"
- Sélectionnez un épisode et cliquez sur "Télécharger"
- Ou utilisez "Toute la saison" pour tout télécharger
- Cliquez sur " Suivre" pour ajouter à la watchlist
3. **Onglet Séries** :
- Recherchez des séries TV (Breaking Bad, Game of Thrones, etc.)
- Téléchargez des épisodes
4. **Onglet Providers** :
- Liste des hébergeurs de fichiers disponibles
5. **Onglet Watchlist** :
- Visualisez vos animes suivis
- Contrôlez le planificateur automatique
- Paramétrez l'intervalle de vérification
- Filtrez par statut (tous, actifs, en pause, terminés)
### API Endpoints
#### Authentication
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| POST | `/api/auth/register` | Créer un compte |
| POST | `/api/auth/login` | Connexion (JWT) |
| GET | `/api/auth/me` | Profil utilisateur |
#### Téléchargements
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| POST | `/api/download` | Créer un nouveau téléchargement |
| GET | `/api/downloads` | Lister tous les téléchargements |
| GET | `/api/download/{task_id}` | Statut d'un téléchargement |
| POST | `/api/download/{task_id}/pause` | Mettre en pause |
| POST | `/api/download/{task_id}/resume` | Reprendre |
| DELETE | `/api/download/{task_id}` | Annuler/Supprimer |
| GET | `/api/download/{task_id}/file` | Télécharger le fichier terminé |
#### Anime
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/anime/search` | Rechercher un anime |
| GET | `/api/anime/metadata` | Obtenir les métadonnées |
| GET | `/api/anime/episodes` | Liste des épisodes |
| POST | `/api/anime/download` | Télécharger un épisode |
| POST | `/api/anime/download-season` | Télécharger toute une saison |
#### Watchlist
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/watchlist` | Liste des animes suivis |
| POST | `/api/watchlist` | Ajouter à la watchlist |
| DELETE | `/api/watchlist/{item_id}` | Supprimer de la watchlist |
| POST | `/api/watchlist/check-all` | Vérifier tous les animes |
| GET | `/api/watchlist/settings` | Paramètres |
| PUT | `/api/watchlist/settings` | Mettre à jour les paramètres |
### Exemples API
**Rechercher un anime :**
```bash ```bash
curl "http://localhost:3000/api/anime/search?q=frieren&lang=vostfr" # Backend (Pytest)
``` pytest # Tous les tests
pytest -m "unit" # Tests unitaires rapides
**Ajouter à la watchlist :** # Frontend (Vitest & Playwright)
```bash npm test # Tests unitaires JS
curl -X POST http://localhost:3000/api/watchlist \ npx playwright test # Tests E2E
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"anime_title": "Frieren", "anime_url": "https://anime-sama.si/catalogue/frieren/saison1/vostfr/", "provider_id": "animesama", "lang": "vostfr"}'
``` ```
## 🏗️ Structure du Projet ## 🏗️ Structure du Projet
``` ```
Ohm_streaming/ Ohm_streaming/
├── main.py # Application FastAPI & endpoints API ├── main.py # Point d'entrée & API FastAPI
├── app/ ├── app/
│ ├── models/ # Modèles Pydantic │ ├── downloaders/ # Logique d'extraction (Scraping)
│ ├── downloaders/ # Downloaders par provider │ ├── anime_sites/ # Catalogues Anime
│ │ ├── anime_sites/ # Providers anime │ │ ├── series_sites/ # Catalogues Séries
│ │ └── series_sites/ # Providers séries │ │ └── video_players/ # Extracteurs de liens directs
│ ├── providers.py # Configuration des providers │ ├── routers/ # Routes API modulaires (Auth, Watchlist, etc.)
│ ├── download_manager.py # Gestionnaire de file d'attente │ ├── download_manager.py # Moteur de téléchargement asynchrone
│ ├── watchlist.py # Gestion de la watchlist │ ├── watchlist.py # Logique métier du suivi
── episode_checker.py # Vérification des nouveaux épisodes ── scheduler.py # Planificateur de tâches
│ └── auto_download_scheduler.py # Planificateur automatique ├── static/ # Frontend (JS Vanilla, CSS)
├── templates/ # Templates HTML ├── templates/ # Vues Jinja2
│ ├── index.html # Interface web principale └── config/ # Données persistantes (JSON)
│ └── components/ # Composants réutilisables
├── static/ # Fichiers statiques (CSS, JS)
└── requirements.txt # Dépendances Python
``` ```
## ⚙️ Configuration
Les paramètres peuvent être configurés via variables d'environnement ou fichier `.env` :
```
JWT_SECRET_KEY=votre-clé-secrète
DOWNLOAD_DIR=downloads
MAX_PARALLEL_DOWNLOADS=5
```
## 🔧 Ajouter un Provider
Voir la documentation dans le code source pour ajouter de nouveaux providers.
## 🤝 Contribution
Les contributions sont les bienvenues !
1. Fork le projet
2. Créez une branche (`git checkout -b feature/AmazingFeature`)
3. Commit (`git commit -m 'Add some AmazingFeature'`)
4. Push (`git push origin feature/AmazingFeature`)
5. Ouvrez une Pull Request
## 🗺️ Plan d'Évolution Global (Modernisation) ## 🗺️ Plan d'Évolution Global (Modernisation)
Ce plan détaille les étapes nécessaires pour transformer Ohm Stream Downloader en une application de production robuste, sécurisée et évolutive. ### ✅ Phase 1 : Restructuration (Terminé)
- Migration vers une architecture modulaire pour les downloaders.
- Séparation stricte entre catalogues et hébergeurs vidéo.
- Amélioration de la gestion des erreurs et des retries.
### Phase 1 : Consolidation de la Donnée (Fondation) ### Phase 2 : Consolidation & SQL (Terminé)
*Objectif : Remplacer les fichiers JSON par une base de données relationnelle.* - Migration complète des fichiers JSON vers **SQLModel** (SQLite).
- **Migration SQL** : Utiliser **SQLModel** (SQLAlchemy + Pydantic) pour gérer la persistance. - Mise en place d'**Alembic** pour les migrations de base de données.
- Tables : `users`, `watchlist`, `tasks`, `favorites`, `settings`. - Centralisation des métadonnées et persistance robuste.
- **Gestion des Migrations** : Mettre en place **Alembic** pour suivre l'évolution du schéma sans perte de données.
- **Support Multi-base** : Configurer SQLite par défaut et PostgreSQL pour les déploiements avancés.
### Phase 2 : Robustesse du Scraping (Cœur Technique) ### 🏗️ Phase 3 : UX & Modernisation Frontend (En cours)
*Objectif : Rendre l'extraction de données résiliente aux changements des sites tiers.* - Adoption de **HTMX/Alpine.js** pour dynamiser l'interface.
- **Abstraction DSL (Domain Specific Language)** : Déporter les sélecteurs CSS et Regex dans des fichiers **YAML/JSON**. - Intégration du lecteur vidéo avancé **Plyr.io**.
- Permet de mettre à jour un provider sans modifier le code Python. - Amélioration de la réactivité de la recherche et de la watchlist.
- **Découplage des Métadonnées** : Utiliser exclusivement les API de **Kitsu**, **Anilist** ou **MyAnimeList** pour les fiches d'animes.
- Le scraping ne sert plus qu'à récupérer les flux vidéo.
- **Health Checks Automatisés** : Script quotidien vérifiant que chaque provider répond toujours correctement (alerte en cas d'échec).
- **Service Playwright (Headless)** : Intégrer un service optionnel pour scraper les sites protégés par Cloudflare ou du JS complexe.
- **Résolution de Domaines (Auto-Mirrors)** : Détection automatique des changements de domaine (.si, .co, .pw) via DNS-over-HTTPS.
### Phase 3 : Modernisation du Frontend (UX & Maintenance) ## 📝 Licence & Sécurité
*Objectif : Simplifier le code JS et améliorer l'expérience utilisateur.*
- **Adoption de HTMX/Alpine.js** : Réduire la complexité du Vanilla JS en utilisant **HTMX** pour les mises à jour partielles (DOM diffing) et **Alpine.js** pour la réactivité légère.
- **Lecteur Vidéo Professionnel** : Intégrer **Plyr** ou **Video.js** pour supporter :
- Les sous-titres (.srt, .vtt).
- La gestion avancée du cache et du buffering.
- Une interface personnalisée et responsive.
- **Système de Toasts & Notifications** : Alertes visuelles pour la progression des tâches et les nouveaux épisodes détectés.
- **Design "Mobile First"** : Optimisation complète des CSS pour une utilisation fluide sur smartphone (PWA).
### Phase 4 : Sécurité et DevOps (Professionnalisation) - Ce projet est à usage **éducatif et personnel** uniquement.
*Objectif : Sécuriser les accès et faciliter le déploiement.* - Respectez les droits d'auteur et les conditions d'utilisation des sites sources.
- **Dockerisation Complète** : `docker-compose.yml` incluant App, Redis (cache), PostgreSQL et Playwright. - Ne partagez jamais votre `JWT_SECRET_KEY` en production.
- **Journalisation Structurée** : Remplacer les `print` par un logger structuré (ex: `structlog`) avec rotation des logs.
- **Rate Limiting** : Protection des endpoints API contre le brute-force et le spam de recherche.
- **Gestion Stricte des Secrets** : Validation rigoureuse des variables d'environnement et suppression des IPs codées en dur (CORS).
### Phase 5 : Nouvelles Fonctionnalités (Valeur Ajoutée)
*Objectif : Étendre les capacités du service.*
- **Transcodage à la volée** : Option FFmpeg pour convertir les .mkv incompatibles vers .mp4.
- **Bot de Notification** : Intégration Telegram/Discord pour être alerté dès qu'un épisode de la watchlist est téléchargé.
- **Multi-Utilisateurs Réel** : Bibliothèques et historiques de lecture totalement isolés par compte.
- **Support des Sous-titres Externes** : Upload de fichiers de sous-titres personnalisés pour le streaming.
## 📝 Licence
Ce projet est à usage éducatif uniquement. Respectez les droits d'auteur et les lois locales.
## ⚠️ Avertissement
Ce logiciel est destiné à un usage personnel et éducatif. Les utilisateurs sont responsables de vérifier qu'ils ont le droit de télécharger du contenu protégé par des droits d'auteur dans leur juridiction.
--- ---
**Version actuelle : 2.3**
**Dernière mise à jour : Mars 2026**
**Développé avec ❤️ pour la communauté anime** **Développé avec ❤️ pour la communauté anime**
*Version actuelle : 2.2*
*Dernière mise à jour : Février 2026*
+116
View File
@@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+85
View File
@@ -0,0 +1,85 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from sqlmodel import SQLModel
import app.models.auth
import app.models.watchlist
import app.models.favorites
import app.models.sonarr
from app.database import DATABASE_URL
target_metadata = SQLModel.metadata
# Set the sqlalchemy.url to the one we use in our app
config.set_main_option("sqlalchemy.url", DATABASE_URL)
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,30 @@
"""Initial migration
Revision ID: e0273f326a15
Revises:
Create Date: 2026-03-24 17:05:50.046027
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e0273f326a15'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
"""Add WatchlistSettingsTable
Revision ID: e88271d11851
Revises: e0273f326a15
Create Date: 2026-03-24 17:07:10.189457
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e88271d11851'
down_revision: Union[str, None] = 'e0273f326a15'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
+9
View File
@@ -32,6 +32,7 @@ class UserManager:
def get_user(self, username: str) -> Optional[UserTable]: def get_user(self, username: str) -> Optional[UserTable]:
"""Get user by username""" """Get user by username"""
from app.models.watchlist import WatchlistItemTable # Force registration
with Session(engine) as session: with Session(engine) as session:
statement = select(UserTable).where(UserTable.username == username) statement = select(UserTable).where(UserTable.username == username)
return session.exec(statement).first() return session.exec(statement).first()
@@ -210,6 +211,14 @@ def _save_refresh_tokens(tokens: Dict[str, dict]):
logger.error(f"Error saving refresh tokens: {e}") logger.error(f"Error saving refresh tokens: {e}")
def _get_jwt_config() -> dict:
return {
"SECRET_KEY": settings.jwt_secret_key,
"ALGORITHM": settings.jwt_algorithm,
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes,
"REFRESH_TOKEN_EXPIRE_DAYS": 30
}
def create_access_refresh_tokens(data: dict) -> tuple[str, str]: def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
""" """
Create both access and refresh tokens. Create both access and refresh tokens.
+4 -3
View File
@@ -17,10 +17,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
def create_db_and_tables(): def create_db_and_tables():
"""Create the database and tables based on the models""" """Create the database and tables based on the models"""
# Import all models here to ensure they are registered with SQLModel.metadata # CRITICAL: Import ALL models here to ensure they are registered with SQLModel.metadata
from app.models.auth import UserTable from app.models.auth import UserTable
from app.models.watchlist import WatchlistItemTable from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
# Add other models as they are migrated from app.models.favorites import FavoriteTable
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
+73 -79
View File
@@ -1,52 +1,24 @@
""" """
Favorites management system for Ohm Stream Downloader Favorites management system for Ohm Stream Downloader
Stores user's favorite anime with metadata in a local JSON file Stores user's favorite anime with metadata using SQLModel
""" """
import json
import asyncio
import logging import logging
from pathlib import Path
from typing import List, Dict, Optional from typing import List, Dict, Optional
from datetime import datetime from datetime import datetime
import aiofiles
from sqlmodel import Session, select
from app.database import engine
from app.models.favorites import FavoriteTable
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class FavoritesManager: class FavoritesManager:
"""Manages user's favorite anime list""" """Manages user's favorite anime list using SQL database"""
def __init__(self, storage_path: str = "data/favorites.json"): def __init__(self, storage_path: str = None):
self.storage_path = Path(storage_path) # Database connection is managed via engine and sessions
self.storage_path.parent.mkdir(parents=True, exist_ok=True) pass
self._favorites: Dict[str, Dict] = {}
self._lock = asyncio.Lock()
async def _load(self):
"""Load favorites from disk"""
async with self._lock:
await self._load_for_operation()
async def _load_for_operation(self):
"""Load favorites from disk without acquiring lock (lock must already be held)"""
if self.storage_path.exists():
try:
async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f:
content = await f.read()
self._favorites = json.loads(content) if content.strip() else {}
except Exception as e:
logger.error(f"Error loading favorites: {e}")
self._favorites = {}
else:
self._favorites = {}
async def _save(self):
"""Save favorites to disk (assumes lock is already held)"""
try:
async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f:
await f.write(json.dumps(self._favorites, indent=2, ensure_ascii=False))
except Exception as e:
logger.error(f"Error saving favorites: {e}")
async def add_favorite( async def add_favorite(
self, self,
@@ -58,48 +30,55 @@ class FavoritesManager:
poster_url: Optional[str] = None poster_url: Optional[str] = None
) -> Dict: ) -> Dict:
"""Add an anime to favorites""" """Add an anime to favorites"""
async with self._lock: with Session(engine) as session:
await self._load_for_operation() statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
existing = session.exec(statement).first()
if anime_id in self._favorites: if existing:
# Update existing favorite # Update existing favorite
self._favorites[anime_id]["updated_at"] = datetime.now().isoformat() existing.updated_at = datetime.now()
if metadata: if metadata:
self._favorites[anime_id]["metadata"] = metadata existing.anime_metadata = metadata
if poster_url: if poster_url:
self._favorites[anime_id]["poster_url"] = poster_url existing.poster_url = poster_url
session.add(existing)
session.commit()
session.refresh(existing)
return self._to_dict(existing)
else: else:
# Add new favorite # Add new favorite
self._favorites[anime_id] = { fav = FavoriteTable(
"id": anime_id, anime_id=anime_id,
"title": title, title=title,
"url": url, url=url,
"provider": provider, provider=provider,
"metadata": metadata or {}, anime_metadata=metadata or {},
"poster_url": poster_url, poster_url=poster_url
"created_at": datetime.now().isoformat(), )
"updated_at": datetime.now().isoformat() session.add(fav)
} session.commit()
session.refresh(fav)
await self._save() return self._to_dict(fav)
return self._favorites[anime_id]
async def remove_favorite(self, anime_id: str) -> bool: async def remove_favorite(self, anime_id: str) -> bool:
"""Remove an anime from favorites""" """Remove an anime from favorites"""
async with self._lock: with Session(engine) as session:
await self._load_for_operation() statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
existing = session.exec(statement).first()
if anime_id in self._favorites: if existing:
del self._favorites[anime_id] session.delete(existing)
await self._save() session.commit()
return True return True
return False return False
async def get_favorite(self, anime_id: str) -> Optional[Dict]: async def get_favorite(self, anime_id: str) -> Optional[Dict]:
"""Get a specific favorite by ID""" """Get a specific favorite by ID"""
await self._load() with Session(engine) as session:
return self._favorites.get(anime_id) statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
existing = session.exec(statement).first()
if existing:
return self._to_dict(existing)
return None
async def list_favorites( async def list_favorites(
self, self,
@@ -109,13 +88,15 @@ class FavoritesManager:
filter_genre: Optional[str] = None filter_genre: Optional[str] = None
) -> List[Dict]: ) -> List[Dict]:
"""List all favorites with optional sorting and filtering""" """List all favorites with optional sorting and filtering"""
await self._load() with Session(engine) as session:
statement = select(FavoriteTable)
favorites = list(self._favorites.values())
if filter_provider:
# Apply filters statement = statement.where(FavoriteTable.provider == filter_provider)
if filter_provider:
favorites = [f for f in favorites if f["provider"] == filter_provider] # SQLite JSON filtering for genres is complex, handle it in Python
results = session.exec(statement).all()
favorites = [self._to_dict(fav) for fav in results]
if filter_genre: if filter_genre:
favorites = [ favorites = [
@@ -144,8 +125,9 @@ class FavoritesManager:
async def is_favorite(self, anime_id: str) -> bool: async def is_favorite(self, anime_id: str) -> bool:
"""Check if an anime is in favorites""" """Check if an anime is in favorites"""
await self._load() with Session(engine) as session:
return anime_id in self._favorites statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
return session.exec(statement).first() is not None
async def toggle_favorite( async def toggle_favorite(
self, self,
@@ -168,19 +150,18 @@ class FavoritesManager:
async def get_stats(self) -> Dict: async def get_stats(self) -> Dict:
"""Get statistics about favorites""" """Get statistics about favorites"""
await self._load() favorites = await self.list_favorites()
total = len(favorites)
total = len(self._favorites)
# Count by provider # Count by provider
by_provider = {} by_provider = {}
for fav in self._favorites.values(): for fav in favorites:
provider = fav["provider"] provider = fav["provider"]
by_provider[provider] = by_provider.get(provider, 0) + 1 by_provider[provider] = by_provider.get(provider, 0) + 1
# Count by genre # Count by genre
by_genre = {} by_genre = {}
for fav in self._favorites.values(): for fav in favorites:
for genre in fav.get("metadata", {}).get("genres", []): for genre in fav.get("metadata", {}).get("genres", []):
by_genre[genre] = by_genre.get(genre, 0) + 1 by_genre[genre] = by_genre.get(genre, 0) + 1
@@ -190,6 +171,19 @@ class FavoritesManager:
"by_genre": by_genre "by_genre": by_genre
} }
def _to_dict(self, fav: FavoriteTable) -> Dict:
"""Convert a FavoriteTable instance to a dictionary for API compatibility"""
return {
"id": fav.anime_id,
"title": fav.title,
"url": fav.url,
"provider": fav.provider,
"metadata": fav.anime_metadata,
"poster_url": fav.poster_url,
"created_at": fav.created_at.isoformat() if fav.created_at else None,
"updated_at": fav.updated_at.isoformat() if fav.updated_at else None
}
# Global favorites manager instance # Global favorites manager instance
_favorites_manager: Optional[FavoritesManager] = None _favorites_manager: Optional[FavoritesManager] = None
+6
View File
@@ -63,3 +63,9 @@ class AnimeSearchResult(BaseModel):
cover_image: Optional[str] = None cover_image: Optional[str] = None
type: str # "search_result" or "direct" type: str # "search_result" or "direct"
metadata: Optional[AnimeMetadata] = None metadata: Optional[AnimeMetadata] = None
# Import all SQLModel tables here to ensure they are registered together
from .auth import UserTable
from .watchlist import WatchlistItemTable, WatchlistSettingsTable
from .favorites import FavoriteTable
from .sonarr import SonarrMappingTable, SonarrConfigTable
+4 -1
View File
@@ -28,7 +28,7 @@ class UserTable(UserBase, table=True):
created_at: datetime = Field(default_factory=datetime.now) created_at: datetime = Field(default_factory=datetime.now)
last_login: Optional[datetime] = None last_login: Optional[datetime] = None
# Relationships # Relationships - Using string reference to avoid circular import errors
watchlist_items: List["WatchlistItemTable"] = Relationship(back_populates="user") watchlist_items: List["WatchlistItemTable"] = Relationship(back_populates="user")
@@ -60,3 +60,6 @@ class Token(BaseModel):
class UserInDB(User): class UserInDB(User):
"""Schema for user stored in database (with hashed password)""" """Schema for user stored in database (with hashed password)"""
hashed_password: str hashed_password: str
# Import WatchlistItemTable here to resolve SQLModel Relationship mappings
from .watchlist import WatchlistItemTable
+44
View File
@@ -0,0 +1,44 @@
"""Models for Favorites system with SQLModel support"""
import uuid
import json
from typing import Optional, Dict, List
from datetime import datetime
from sqlmodel import SQLModel, Field, Column, String
class FavoriteBase(SQLModel):
"""Base schema for favorite anime"""
anime_id: str = Field(index=True)
title: str = Field(index=True)
url: str
provider: str
poster_url: Optional[str] = None
# Timestamps
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
class FavoriteTable(FavoriteBase, table=True):
"""Database table for favorites"""
__tablename__ = "favorites"
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
primary_key=True,
index=True,
nullable=False
)
user_id: str = Field(foreign_key="users.id", index=True, default="default")
# Store metadata dictionary as JSON string in SQLite
metadata_json: Optional[str] = Field(default="{}", sa_column=Column(String))
@property
def anime_metadata(self) -> Dict:
try:
return json.loads(self.metadata_json or "{}")
except json.JSONDecodeError:
return {}
@anime_metadata.setter
def anime_metadata(self, value: Dict):
self.metadata_json = json.dumps(value or {})
+55 -6
View File
@@ -1,8 +1,10 @@
"""Pydantic models for Sonarr webhook integration""" """Pydantic models for Sonarr webhook integration"""
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field as PydanticField, validator
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from sqlmodel import SQLModel, Field
import uuid
class SonarrEventType(str, Enum): class SonarrEventType(str, Enum):
@@ -45,7 +47,7 @@ class SonarrEpisodeFile(BaseModel):
class SonarrSeries(BaseModel): class SonarrSeries(BaseModel):
"""Series information from Sonarr""" """Series information from Sonarr"""
tvdbId: int = Field(..., alias="tvdbId") tvdbId: int = PydanticField(..., alias="tvdbId")
title: str title: str
sortTitle: str sortTitle: str
status: str status: str
@@ -129,8 +131,33 @@ class SonarrWebhookPayload(BaseModel):
return v return v
class SonarrMappingBase(SQLModel):
sonarr_series_id: int = Field(index=True, unique=True)
sonarr_title: str
anime_provider: str
anime_url: str
anime_title: str
lang: str = Field(default="vostfr")
quality_preference: Optional[str] = None
auto_download: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
class SonarrMappingTable(SonarrMappingBase, table=True):
"""Database table for Sonarr mappings"""
__tablename__ = "sonarr_mappings"
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
primary_key=True,
index=True,
nullable=False
)
user_id: str = Field(foreign_key="users.id", index=True, default="default")
class SonarrMapping(BaseModel): class SonarrMapping(BaseModel):
"""Mapping between Sonarr series and anime providers""" """Mapping between Sonarr series and anime providers (API model)"""
sonarr_series_id: int sonarr_series_id: int
sonarr_title: str sonarr_title: str
anime_provider: str # 'anime-sama', 'neko-sama', etc. anime_provider: str # 'anime-sama', 'neko-sama', etc.
@@ -139,8 +166,8 @@ class SonarrMapping(BaseModel):
lang: str = "vostfr" lang: str = "vostfr"
quality_preference: Optional[str] = None # '1080p', '720p', etc. quality_preference: Optional[str] = None # '1080p', '720p', etc.
auto_download: bool = True auto_download: bool = True
created_at: datetime = Field(default_factory=datetime.now) created_at: datetime = PydanticField(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = PydanticField(default_factory=datetime.now)
class Config: class Config:
json_encoders = { json_encoders = {
@@ -148,8 +175,30 @@ class SonarrMapping(BaseModel):
} }
class SonarrConfigBase(SQLModel):
webhook_enabled: bool = Field(default=False)
webhook_secret: Optional[str] = None
auto_download_enabled: bool = Field(default=True)
default_language: str = Field(default="vostfr")
default_quality: Optional[str] = None
default_provider: str = Field(default="anime-sama")
verify_hmac: bool = Field(default=False)
log_webhooks: bool = Field(default=True)
class SonarrConfigTable(SonarrConfigBase, table=True):
"""Database table for Sonarr configuration (singleton)"""
__tablename__ = "sonarr_config"
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
primary_key=True,
index=True,
nullable=False
)
class SonarrConfig(BaseModel): class SonarrConfig(BaseModel):
"""Sonarr webhook configuration""" """Sonarr webhook configuration (API Model)"""
webhook_enabled: bool = False webhook_enabled: bool = False
webhook_secret: Optional[str] = None # HMAC SHA256 secret webhook_secret: Optional[str] = None # HMAC SHA256 secret
auto_download_enabled: bool = True auto_download_enabled: bool = True
+22 -1
View File
@@ -74,7 +74,7 @@ class WatchlistItemTable(WatchlistItemBase, table=True):
def genres(self, value: List[str]): def genres(self, value: List[str]):
self.genres_json = json.dumps(value or []) self.genres_json = json.dumps(value or [])
# Relationships # Relationships - Using string reference
user: Optional["UserTable"] = Relationship(back_populates="watchlist_items") user: Optional["UserTable"] = Relationship(back_populates="watchlist_items")
@@ -148,6 +148,24 @@ class AutoDownloadResult(BaseModel):
checked_at: datetime = PydanticField(default_factory=datetime.now) checked_at: datetime = PydanticField(default_factory=datetime.now)
class WatchlistSettingsBase(SQLModel):
check_interval_hours: int = Field(default=6)
auto_download_enabled: bool = Field(default=True)
max_concurrent_auto_downloads: int = Field(default=2)
notify_on_new_episodes: bool = Field(default=False)
include_completed_anime: bool = Field(default=False)
class WatchlistSettingsTable(WatchlistSettingsBase, table=True):
"""Database table for global watchlist settings"""
__tablename__ = "watchlist_settings"
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
primary_key=True,
index=True,
nullable=False
)
user_id: str = Field(foreign_key="users.id", index=True, default="default")
class WatchlistSettings(BaseModel): class WatchlistSettings(BaseModel):
"""Global watchlist settings""" """Global watchlist settings"""
check_interval_hours: int = PydanticField(default=6, ge=1, le=168) check_interval_hours: int = PydanticField(default=6, ge=1, le=168)
@@ -155,3 +173,6 @@ class WatchlistSettings(BaseModel):
max_concurrent_auto_downloads: int = PydanticField(default=2, ge=1, le=10) max_concurrent_auto_downloads: int = PydanticField(default=2, ge=1, le=10)
notify_on_new_episodes: bool = PydanticField(default=False) notify_on_new_episodes: bool = PydanticField(default=False)
include_completed_anime: bool = PydanticField(default=False) include_completed_anime: bool = PydanticField(default=False)
# Import UserTable here to resolve SQLModel Relationship mappings
from .auth import UserTable
+145 -113
View File
@@ -1,18 +1,19 @@
"""Sonarr webhook handler and integration logic""" """Sonarr webhook handler and integration logic using SQLModel"""
import hmac import hmac
import hashlib import hashlib
import json
import logging import logging
import os from typing import Optional, Dict, List, Any
from typing import Optional, Dict, List, Tuple, Any
from pathlib import Path
from datetime import datetime from datetime import datetime
from sqlmodel import Session, select
from app.database import engine
from app.models.sonarr import ( from app.models.sonarr import (
SonarrWebhookPayload, SonarrWebhookPayload,
SonarrEventType, SonarrEventType,
SonarrMapping, SonarrMapping,
SonarrMappingTable,
SonarrConfig, SonarrConfig,
SonarrConfigTable,
SonarrDownloadRequest SonarrDownloadRequest
) )
from app.models import DownloadRequest from app.models import DownloadRequest
@@ -23,69 +24,150 @@ logger = logging.getLogger(__name__)
class SonarrHandler: class SonarrHandler:
"""Handles Sonarr webhooks and manages series mappings""" """Handles Sonarr webhooks and manages series mappings using SQL database"""
def __init__(self, config_path: str = "config/sonarr.json", mappings_path: str = "config/sonarr_mappings.json"): def __init__(self, config_path: str = None, mappings_path: str = None):
self.config_path = Path(config_path)
self.mappings_path = Path(mappings_path)
self.config = self._load_config()
self.mappings = self._load_mappings()
self.download_manager = None self.download_manager = None
self._ensure_default_config()
# Create config directories if they don't exist
self.config_path.parent.mkdir(exist_ok=True)
self.mappings_path.parent.mkdir(exist_ok=True)
def set_download_manager(self, download_manager): def set_download_manager(self, download_manager):
self.download_manager = download_manager self.download_manager = download_manager
def _load_config(self) -> SonarrConfig: def _ensure_default_config(self):
"""Load Sonarr configuration from file""" """Ensure a default config exists in the database"""
if self.config_path.exists(): with Session(engine) as session:
try: statement = select(SonarrConfigTable)
with open(self.config_path, 'r') as f: if not session.exec(statement).first():
data = json.load(f) session.add(SonarrConfigTable())
return SonarrConfig(**data) session.commit()
except Exception as e:
logger.warning(f"Failed to load Sonarr config: {e}")
return SonarrConfig()
def _save_config(self): def get_config(self) -> SonarrConfig:
try: """Get current configuration"""
temp_file = f"{self.config_path}.tmp" with Session(engine) as session:
with open(temp_file, 'w') as f: statement = select(SonarrConfigTable)
json.dump(self.config.model_dump(mode='json'), f, indent=2) db_config = session.exec(statement).first()
os.replace(temp_file, self.config_path) if db_config:
except Exception as e: return SonarrConfig(
logger.error(f"Failed to save Sonarr config: {e}") webhook_enabled=db_config.webhook_enabled,
raise webhook_secret=db_config.webhook_secret,
auto_download_enabled=db_config.auto_download_enabled,
default_language=db_config.default_language,
default_quality=db_config.default_quality,
default_provider=db_config.default_provider,
verify_hmac=db_config.verify_hmac,
log_webhooks=db_config.log_webhooks
)
return SonarrConfig()
def _load_mappings(self) -> List[SonarrMapping]: def update_config(self, config: SonarrConfig) -> SonarrConfig:
"""Load Sonarr to anime mappings from file""" """Update configuration"""
if self.mappings_path.exists(): with Session(engine) as session:
try: statement = select(SonarrConfigTable)
with open(self.mappings_path, 'r') as f: db_config = session.exec(statement).first()
data = json.load(f)
return [SonarrMapping(**item) for item in data] if not db_config:
except Exception as e: db_config = SonarrConfigTable()
logger.warning(f"Failed to load Sonarr mappings: {e}")
return [] db_config.webhook_enabled = config.webhook_enabled
db_config.webhook_secret = config.webhook_secret
db_config.auto_download_enabled = config.auto_download_enabled
db_config.default_language = config.default_language
db_config.default_quality = config.default_quality
db_config.default_provider = config.default_provider
db_config.verify_hmac = config.verify_hmac
db_config.log_webhooks = config.log_webhooks
session.add(db_config)
session.commit()
logger.info("Sonarr configuration updated in database")
return config
def _save_mappings(self): def _to_pydantic(self, db_mapping: SonarrMappingTable) -> SonarrMapping:
try: return SonarrMapping(
os.makedirs(os.path.dirname(self.mappings_path), exist_ok=True) sonarr_series_id=db_mapping.sonarr_series_id,
temp_file = f"{self.mappings_path}.tmp" sonarr_title=db_mapping.sonarr_title,
with open(temp_file, 'w') as f: anime_provider=db_mapping.anime_provider,
mappings_data = [m.model_dump(mode='json') for m in self.mappings] anime_url=db_mapping.anime_url,
json.dump(mappings_data, f, indent=2) anime_title=db_mapping.anime_title,
os.replace(temp_file, self.mappings_path) lang=db_mapping.lang,
except Exception as e: quality_preference=db_mapping.quality_preference,
logger.error(f"Failed to save mappings: {e}") auto_download=db_mapping.auto_download,
raise created_at=db_mapping.created_at,
updated_at=db_mapping.updated_at
)
def get_mappings(self) -> List[SonarrMapping]:
"""Get all mappings"""
with Session(engine) as session:
statement = select(SonarrMappingTable)
db_mappings = session.exec(statement).all()
return [self._to_pydantic(m) for m in db_mappings]
def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]:
"""Get mapping for specific series"""
with Session(engine) as session:
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id)
db_mapping = session.exec(statement).first()
if db_mapping:
return self._to_pydantic(db_mapping)
return None
def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping:
"""Add or update a mapping"""
with Session(engine) as session:
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == mapping.sonarr_series_id)
db_mapping = session.exec(statement).first()
if db_mapping:
# Update existing
db_mapping.sonarr_title = mapping.sonarr_title
db_mapping.anime_provider = mapping.anime_provider
db_mapping.anime_url = mapping.anime_url
db_mapping.anime_title = mapping.anime_title
db_mapping.lang = mapping.lang
db_mapping.quality_preference = mapping.quality_preference
db_mapping.auto_download = mapping.auto_download
db_mapping.updated_at = datetime.now()
logger.info(f"Updated mapping for series {mapping.sonarr_title}")
else:
# Create new
db_mapping = SonarrMappingTable(
user_id="default",
sonarr_series_id=mapping.sonarr_series_id,
sonarr_title=mapping.sonarr_title,
anime_provider=mapping.anime_provider,
anime_url=mapping.anime_url,
anime_title=mapping.anime_title,
lang=mapping.lang,
quality_preference=mapping.quality_preference,
auto_download=mapping.auto_download,
created_at=datetime.now(),
updated_at=datetime.now()
)
logger.info(f"Added mapping for series {mapping.sonarr_title}")
session.add(db_mapping)
session.commit()
session.refresh(db_mapping)
return self._to_pydantic(db_mapping)
def delete_mapping(self, sonarr_series_id: int) -> bool:
"""Delete a mapping"""
with Session(engine) as session:
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id)
db_mapping = session.exec(statement).first()
if db_mapping:
session.delete(db_mapping)
session.commit()
logger.info(f"Deleted mapping for series ID {sonarr_series_id}")
return True
return False
def verify_hmac(self, payload: bytes, signature: str) -> bool: def verify_hmac(self, payload: bytes, signature: str) -> bool:
"""Verify HMAC SHA256 signature""" """Verify HMAC SHA256 signature"""
if not self.config.verify_hmac or not self.config.webhook_secret: config = self.get_config()
if not config.verify_hmac or not config.webhook_secret:
return True return True
try: try:
@@ -94,7 +176,7 @@ class SonarrHandler:
signature = signature[7:] signature = signature[7:]
computed_hmac = hmac.new( computed_hmac = hmac.new(
self.config.webhook_secret.encode(), config.webhook_secret.encode(),
payload, payload,
hashlib.sha256 hashlib.sha256
).hexdigest() ).hexdigest()
@@ -104,57 +186,6 @@ class SonarrHandler:
logger.error(f"HMAC verification failed: {e}") logger.error(f"HMAC verification failed: {e}")
return False return False
def get_config(self) -> SonarrConfig:
"""Get current configuration"""
return self.config
def update_config(self, config: SonarrConfig) -> SonarrConfig:
"""Update configuration"""
self.config = config
self._save_config()
logger.info("Sonarr configuration updated")
return self.config
def get_mappings(self) -> List[SonarrMapping]:
"""Get all mappings"""
return self.mappings
def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]:
"""Get mapping for specific series"""
for mapping in self.mappings:
if mapping.sonarr_series_id == sonarr_series_id:
return mapping
return None
def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping:
"""Add or update a mapping"""
# Check if mapping already exists
for i, existing in enumerate(self.mappings):
if existing.sonarr_series_id == mapping.sonarr_series_id:
mapping.updated_at = datetime.now()
self.mappings[i] = mapping
self._save_mappings()
logger.info(f"Updated mapping for series {mapping.sonarr_title}")
return mapping
# Add new mapping
mapping.created_at = datetime.now()
mapping.updated_at = datetime.now()
self.mappings.append(mapping)
self._save_mappings()
logger.info(f"Added mapping for series {mapping.sonarr_title}")
return mapping
def delete_mapping(self, sonarr_series_id: int) -> bool:
"""Delete a mapping"""
for i, mapping in enumerate(self.mappings):
if mapping.sonarr_series_id == sonarr_series_id:
del self.mappings[i]
self._save_mappings()
logger.info(f"Deleted mapping for series ID {sonarr_series_id}")
return True
return False
async def search_anime_by_title(self, title: str, provider: str = "anime-sama", lang: str = "vostfr") -> List[Dict]: async def search_anime_by_title(self, title: str, provider: str = "anime-sama", lang: str = "vostfr") -> List[Dict]:
"""Search for anime by title using specified provider""" """Search for anime by title using specified provider"""
try: try:
@@ -197,15 +228,16 @@ class SonarrHandler:
async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]: async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]:
"""Process Sonarr webhook payload""" """Process Sonarr webhook payload"""
if not self.config.webhook_enabled: config = self.get_config()
if not config.webhook_enabled:
return {"status": "ignored", "reason": "Webhook not enabled"} return {"status": "ignored", "reason": "Webhook not enabled"}
if self.config.log_webhooks: if config.log_webhooks:
logger.info(f"Received Sonarr webhook: {payload.eventType.value}") logger.info(f"Received Sonarr webhook: {payload.eventType.value}")
# Handle different event types # Handle different event types
if payload.eventType == SonarrEventType.GRAB: if payload.eventType == SonarrEventType.GRAB:
return await self._handle_grab(payload) return await self._handle_grab(payload, config)
elif payload.eventType == SonarrEventType.DOWNLOAD: elif payload.eventType == SonarrEventType.DOWNLOAD:
return await self._handle_download(payload) return await self._handle_download(payload)
elif payload.eventType == SonarrEventType.RENAME: elif payload.eventType == SonarrEventType.RENAME:
@@ -217,9 +249,9 @@ class SonarrHandler:
else: else:
return {"status": "ignored", "reason": f"Unhandled event type: {payload.eventType}"} return {"status": "ignored", "reason": f"Unhandled event type: {payload.eventType}"}
async def _handle_grab(self, payload: SonarrWebhookPayload) -> Dict: async def _handle_grab(self, payload: SonarrWebhookPayload, config: SonarrConfig) -> Dict:
"""Handle Grab event (when Sonarr downloads a release)""" """Handle Grab event (when Sonarr downloads a release)"""
if not self.config.auto_download_enabled: if not config.auto_download_enabled:
return {"status": "ignored", "reason": "Auto-download disabled"} return {"status": "ignored", "reason": "Auto-download disabled"}
if not payload.series or not payload.episodes: if not payload.series or not payload.episodes:
+43 -23
View File
@@ -16,50 +16,70 @@ from app.models.watchlist import (
WatchlistItemUpdate, WatchlistItemUpdate,
WatchlistStatus, WatchlistStatus,
WatchlistSettings, WatchlistSettings,
WatchlistSettingsTable,
NewEpisodeInfo, NewEpisodeInfo,
AutoDownloadResult AutoDownloadResult
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Settings file remains JSON for simplicity for now
WATCHLIST_SETTINGS_FILE = "config/watchlist_settings.json"
class WatchlistManager: class WatchlistManager:
"""Manages user watchlist for automatic episode downloads using SQL database""" """Manages user watchlist for automatic episode downloads using SQL database"""
def __init__(self): def __init__(self):
self.settings_file = WATCHLIST_SETTINGS_FILE
self.settings: Optional[WatchlistSettings] = None self.settings: Optional[WatchlistSettings] = None
self._load_settings() self._load_settings()
def _load_settings(self): def _load_settings(self):
"""Load watchlist settings from JSON file""" """Load watchlist settings from database"""
try: try:
if os.path.exists(self.settings_file): with Session(engine) as session:
with open(self.settings_file, 'r', encoding='utf-8') as f: statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default")
data = json.load(f) db_settings = session.exec(statement).first()
self.settings = WatchlistSettings(**data) if db_settings:
logger.info(f"Loaded watchlist settings") self.settings = WatchlistSettings(
else: check_interval_hours=db_settings.check_interval_hours,
self.settings = WatchlistSettings() auto_download_enabled=db_settings.auto_download_enabled,
self._save_settings() max_concurrent_auto_downloads=db_settings.max_concurrent_auto_downloads,
logger.info("Settings file not found, using defaults") notify_on_new_episodes=db_settings.notify_on_new_episodes,
include_completed_anime=db_settings.include_completed_anime
)
logger.info(f"Loaded watchlist settings from database")
else:
self.settings = WatchlistSettings()
self._save_settings()
logger.info("Settings not found in database, created defaults")
except Exception as e: except Exception as e:
logger.error(f"Error loading settings: {e}") logger.error(f"Error loading settings from database: {e}")
self.settings = WatchlistSettings() self.settings = WatchlistSettings()
def _save_settings(self): def _save_settings(self):
try: try:
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True) with Session(engine) as session:
temp_file = f"{self.settings_file}.tmp" statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default")
with open(temp_file, 'w', encoding='utf-8') as f: db_settings = session.exec(statement).first()
json.dump(self.settings.model_dump(mode='json'), f, indent=2, ensure_ascii=False)
os.replace(temp_file, self.settings_file) if db_settings:
logger.debug("Saved watchlist settings") db_settings.check_interval_hours = self.settings.check_interval_hours
db_settings.auto_download_enabled = self.settings.auto_download_enabled
db_settings.max_concurrent_auto_downloads = self.settings.max_concurrent_auto_downloads
db_settings.notify_on_new_episodes = self.settings.notify_on_new_episodes
db_settings.include_completed_anime = self.settings.include_completed_anime
else:
db_settings = WatchlistSettingsTable(
user_id="default",
check_interval_hours=self.settings.check_interval_hours,
auto_download_enabled=self.settings.auto_download_enabled,
max_concurrent_auto_downloads=self.settings.max_concurrent_auto_downloads,
notify_on_new_episodes=self.settings.notify_on_new_episodes,
include_completed_anime=self.settings.include_completed_anime
)
session.add(db_settings)
session.commit()
logger.debug("Saved watchlist settings to database")
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {e}") logger.error(f"Error saving settings to database: {e}")
def _to_api_model(self, db_item: WatchlistItemTable) -> WatchlistItem: def _to_api_model(self, db_item: WatchlistItemTable) -> WatchlistItem:
"""Convert database table model to API response model""" """Convert database table model to API response model"""
+59
View File
@@ -0,0 +1,59 @@
import os
import sys
from sqlmodel import Session, select, create_engine, inspect
# Add root directory to sys.path
sys.path.append(os.getcwd())
from app.database import engine
from app.models.auth import UserTable
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
from app.models.favorites import FavoriteTable
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
def audit_db():
print("--- Ohm Stream Downloader: SQL Database Audit ---")
if not os.path.exists("ohm_streaming.db"):
print("ERROR: ohm_streaming.db not found!")
return
inspector = inspect(engine)
tables = inspector.get_table_names()
print(f"Tables found: {', '.join(tables)}")
expected_tables = ["users", "watchlist_items", "watchlist_settings", "favorites", "sonarr_mappings", "sonarr_config", "alembic_version"]
missing = [t for t in expected_tables if t not in tables]
if missing:
print(f"WARNING: Missing tables: {', '.join(missing)}")
else:
print("SUCCESS: All core tables are present.")
with Session(engine) as session:
# Check users
users_count = len(session.exec(select(UserTable)).all())
print(f"Users: {users_count}")
# Check watchlist
watchlist_count = len(session.exec(select(WatchlistItemTable)).all())
print(f"Watchlist Items: {watchlist_count}")
# Check settings
settings_count = len(session.exec(select(WatchlistSettingsTable)).all())
print(f"Watchlist Settings entries: {settings_count}")
# Check favorites
fav_count = len(session.exec(select(FavoriteTable)).all())
print(f"Favorites: {fav_count}")
# Check Sonarr
sonarr_map_count = len(session.exec(select(SonarrMappingTable)).all())
print(f"Sonarr Mappings: {sonarr_map_count}")
# Sample data check
if fav_count > 0:
sample_fav = session.exec(select(FavoriteTable).limit(1)).first()
print(f"Sample Favorite: {sample_fav.title} ({sample_fav.provider})")
if __name__ == "__main__":
audit_db()
+276
View File
@@ -0,0 +1,276 @@
import json
import os
from pathlib import Path
from datetime import datetime
from sqlmodel import Session, select
from sqlalchemy.exc import IntegrityError
import sys
# Add the root directory to sys.path to import app modules
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import engine, create_db_and_tables
from app.models.auth import UserTable
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable, WatchlistStatus, QualityPreference
from app.models.favorites import FavoriteTable
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
def migrate_users(session: Session):
path = Path("config/users.json")
if not path.exists():
print("No users.json found.")
return
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
users_data = data.get("users", {})
count = 0
for user_id, user_info in users_data.items():
existing = session.get(UserTable, user_id)
if existing:
continue
# Parse dates
created_at = datetime.now()
if "created_at" in user_info:
try:
created_at = datetime.fromisoformat(user_info["created_at"])
except ValueError:
pass
last_login = None
if "last_login" in user_info and user_info["last_login"]:
try:
last_login = datetime.fromisoformat(user_info["last_login"])
except ValueError:
pass
user = UserTable(
id=user_id,
username=user_info.get("username", "unknown"),
email=user_info.get("email"),
hashed_password=user_info.get("hashed_password", ""),
is_active=user_info.get("is_active", True),
created_at=created_at,
last_login=last_login
)
session.add(user)
count += 1
session.commit()
print(f"Migrated {count} users.")
def migrate_watchlist(session: Session):
path = Path("config/watchlist.json")
if not path.exists():
print("No watchlist.json found.")
return
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
count = 0
for user_id, items in data.items():
for item in items:
existing = session.get(WatchlistItemTable, item.get("id"))
if existing:
continue
last_checked = None
if "last_checked" in item and item["last_checked"]:
try:
last_checked = datetime.fromisoformat(item["last_checked"])
except ValueError:
pass
added_at = datetime.now()
if "added_at" in item:
try:
added_at = datetime.fromisoformat(item["added_at"])
except ValueError:
pass
updated_at = datetime.now()
if "updated_at" in item:
try:
updated_at = datetime.fromisoformat(item["updated_at"])
except ValueError:
pass
wl_item = WatchlistItemTable(
id=item["id"],
user_id=user_id,
anime_title=item["anime_title"],
anime_url=item["anime_url"],
provider_id=item["provider_id"],
lang=item.get("lang", "vostfr"),
last_checked=last_checked,
last_episode_downloaded=item.get("last_episode_downloaded", 0),
total_episodes=item.get("total_episodes"),
auto_download=item.get("auto_download", True),
quality_preference=item.get("quality_preference", QualityPreference.AUTO),
status=item.get("status", WatchlistStatus.ACTIVE),
poster_image=item.get("poster_image"),
cover_image=item.get("cover_image"),
synopsis=item.get("synopsis"),
genres=item.get("genres", []),
added_at=added_at,
updated_at=updated_at
)
session.add(wl_item)
count += 1
session.commit()
print(f"Migrated {count} watchlist items.")
def migrate_watchlist_settings(session: Session):
path = Path("config/watchlist_settings.json")
if not path.exists():
print("No watchlist_settings.json found.")
return
with open(path, "r", encoding="utf-8") as f:
settings = json.load(f)
count = 0
# Treat as global settings for "default" user
user_id = "default"
existing = session.exec(select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == user_id)).first()
if not existing:
setting_row = WatchlistSettingsTable(
user_id=user_id,
check_interval_hours=settings.get("check_interval_hours", 6),
auto_download_enabled=settings.get("auto_download_enabled", True),
max_concurrent_auto_downloads=settings.get("max_concurrent_auto_downloads", 2),
notify_on_new_episodes=settings.get("notify_on_new_episodes", False),
include_completed_anime=settings.get("include_completed_anime", False)
)
session.add(setting_row)
count += 1
session.commit()
print(f"Migrated {count} watchlist settings.")
def migrate_favorites(session: Session):
path = Path("data/favorites.json")
if not path.exists():
print("No favorites.json found.")
return
with open(path, "r", encoding="utf-8") as f:
try:
data = json.load(f)
except json.JSONDecodeError:
print("Invalid favorites.json.")
return
count = 0
for fav_id, fav in data.items():
existing = session.exec(select(FavoriteTable).where(FavoriteTable.anime_id == fav_id)).first()
if existing:
continue
created_at = datetime.now()
if "created_at" in fav:
try:
created_at = datetime.fromisoformat(fav["created_at"])
except ValueError:
pass
updated_at = datetime.now()
if "updated_at" in fav:
try:
updated_at = datetime.fromisoformat(fav["updated_at"])
except ValueError:
pass
fav_row = FavoriteTable(
anime_id=fav_id,
user_id="default", # Favorites were global
title=fav.get("title", ""),
url=fav.get("url", ""),
provider=fav.get("provider", ""),
poster_url=fav.get("poster_url"),
anime_metadata=fav.get("metadata", {}),
created_at=created_at,
updated_at=updated_at
)
session.add(fav_row)
count += 1
session.commit()
print(f"Migrated {count} favorites.")
def migrate_sonarr(session: Session):
# Config
path_config = Path("config/sonarr.json")
if path_config.exists():
with open(path_config, "r", encoding="utf-8") as f:
data = json.load(f)
existing = session.exec(select(SonarrConfigTable)).first()
if not existing:
conf = SonarrConfigTable(
webhook_enabled=data.get("webhook_enabled", False),
webhook_secret=data.get("webhook_secret"),
auto_download_enabled=data.get("auto_download_enabled", True),
default_language=data.get("default_language", "vostfr"),
default_quality=data.get("default_quality"),
default_provider=data.get("default_provider", "anime-sama"),
verify_hmac=data.get("verify_hmac", False),
log_webhooks=data.get("log_webhooks", True)
)
session.add(conf)
session.commit()
print("Migrated Sonarr config.")
# Mappings
path_maps = Path("config/sonarr_mappings.json")
if path_maps.exists():
with open(path_maps, "r", encoding="utf-8") as f:
data = json.load(f)
count = 0
for map_id, mapping in data.items():
existing = session.exec(select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == int(map_id))).first()
if existing:
continue
created_at = datetime.now()
if "created_at" in mapping:
try:
created_at = datetime.fromisoformat(mapping["created_at"])
except ValueError:
pass
updated_at = datetime.now()
if "updated_at" in mapping:
try:
updated_at = datetime.fromisoformat(mapping["updated_at"])
except ValueError:
pass
map_row = SonarrMappingTable(
user_id="default",
sonarr_series_id=mapping.get("sonarr_series_id", int(map_id)),
sonarr_title=mapping.get("sonarr_title", ""),
anime_provider=mapping.get("anime_provider", ""),
anime_url=mapping.get("anime_url", ""),
anime_title=mapping.get("anime_title", ""),
lang=mapping.get("lang", "vostfr"),
quality_preference=mapping.get("quality_preference"),
auto_download=mapping.get("auto_download", True),
created_at=created_at,
updated_at=updated_at
)
session.add(map_row)
count += 1
session.commit()
print(f"Migrated {count} Sonarr mappings.")
if __name__ == "__main__":
create_db_and_tables()
with Session(engine) as session:
migrate_users(session)
migrate_watchlist(session)
migrate_watchlist_settings(session)
migrate_favorites(session)
migrate_sonarr(session)
print("Data migration complete.")
+26 -7
View File
@@ -11,7 +11,7 @@
<!-- External Libraries --> <!-- External Libraries -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script> <script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
<style> <style>
@@ -31,15 +31,34 @@
<script src="/static/js/watchlist-ui.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/main.js?v=1.11" defer></script>
</head> </head>
<body x-data="{ <body x-data="globalAppState">
activeTab: 'home',
isAuthenticated: true,
username: ''
}" @set-tab.window="activeTab = $event.detail.tab"
@auth-success.window="isAuthenticated = true; username = $event.detail.username">
{% include "components/toast_container.html" %} {% include "components/toast_container.html" %}
<div class="container"> <div class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
<script>
// Global State initialized when Alpine is ready
document.addEventListener('alpine:init', () => {
console.log('Alpine.js initializing...');
Alpine.data('globalAppState', () => ({
activeTab: 'home',
isAuthenticated: true,
username: '',
init() {
console.log('Global app state ready');
window.addEventListener('auth-success', (e) => {
console.log('Alpine auth-success received');
this.isAuthenticated = true;
this.username = e.detail.username;
});
window.addEventListener('set-tab', (e) => {
console.log('Alpine set-tab received:', e.detail.tab);
this.activeTab = e.detail.tab;
});
}
}));
});
</script>
</body> </body>
</html> </html>
+16 -8
View File
@@ -4,7 +4,6 @@
<!-- User info and logout button --> <!-- User info and logout button -->
<div id="userInfo" <div id="userInfo"
x-show="isAuthenticated" x-show="isAuthenticated"
x-cloak
style="margin-bottom: 15px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; display: flex; justify-content: space-between; align-items: center;"> style="margin-bottom: 15px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="color: #00d9ff;">👤</span> <span style="color: #00d9ff;">👤</span>
@@ -21,39 +20,48 @@
<!-- Login prompt (shown when not logged in) --> <!-- Login prompt (shown when not logged in) -->
<div id="loginPrompt" <div id="loginPrompt"
x-show="!isAuthenticated" x-show="!isAuthenticated"
x-cloak
style="margin-bottom: 15px; padding: 15px; background: rgba(0, 217, 255, 0.1); border: 1px solid rgba(0, 217, 255, 0.3); border-radius: 8px; text-align: center;"> style="margin-bottom: 15px; padding: 15px; background: rgba(0, 217, 255, 0.1); border: 1px solid rgba(0, 217, 255, 0.3); border-radius: 8px; text-align: center;">
<p style="color: #00d9ff; margin: 0 0 10px 0;">👋 Bienvenue! <a href="/login" style="color: #00d9ff; text-decoration: underline;">Connectez-vous</a> pour télécharger des vidéos</p> <p style="color: #00d9ff; margin: 0 0 10px 0;">👋 Bienvenue! <a href="/login" style="color: #00d9ff; text-decoration: underline;">Connectez-vous</a> pour télécharger des vidéos</p>
</div> </div>
<!-- Tabs - Simple and direct --> <!-- Tabs - Robust navigation -->
<div id="mainTabs" class="tabs" style="display: flex !important; visibility: visible !important;"> <div id="mainTabs" class="tabs" style="display: flex !important; visibility: visible !important;">
<button class="tab" :class="{ 'active': activeTab === 'home' }" @click="activeTab = 'home'"> <button class="tab"
:class="{ 'active': activeTab === 'home' }"
@click="activeTab = 'home'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'home' } }))">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg> </svg>
Accueil Accueil
</button> </button>
<button class="tab" :class="{ 'active': activeTab === 'anime' }" @click="activeTab = 'anime'"> <button class="tab"
:class="{ 'active': activeTab === 'anime' }"
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } }))">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
Anime Anime
</button> </button>
<button class="tab" :class="{ 'active': activeTab === 'series' }" @click="activeTab = 'series'"> <button class="tab"
:class="{ 'active': activeTab === 'series' }"
@click="activeTab = 'series'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'series' } }))">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"></path>
</svg> </svg>
Série Série
</button> </button>
<button class="tab" :class="{ 'active': activeTab === 'watchlist' }" @click="activeTab = 'watchlist'"> <button class="tab"
:class="{ 'active': activeTab === 'watchlist' }"
@click="activeTab = 'watchlist'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'watchlist' } }))">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2m0 0a2 2 0 00-2 2h10a2 2 0 002-2V7a2 2 0 00-2-2"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2m0 0a2 2 0 00-2 2h10a2 2 0 002-2V7a2 2 0 00-2-2"></path>
</svg> </svg>
Watchlist Watchlist
</button> </button>
<button class="tab" :class="{ 'active': activeTab === 'downloads' }" @click="activeTab = 'downloads'"> <button class="tab"
:class="{ 'active': activeTab === 'downloads' }"
@click="activeTab = 'downloads'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'downloads' } }))">
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg> </svg>