From a684237725967788d1f1f45cd6ab3d1d87b9ef2a Mon Sep 17 00:00:00 2001 From: root Date: Wed, 25 Mar 2026 13:46:15 +0000 Subject: [PATCH] Phase 2 Complete: SQL migration with SQLModel and Alembic --- README.md | 303 +++++------------- alembic.ini | 116 +++++++ alembic/README | 1 + alembic/env.py | 85 +++++ alembic/script.py.mako | 26 ++ .../e0273f326a15_initial_migration.py | 30 ++ ...e88271d11851_add_watchlistsettingstable.py | 30 ++ app/auth.py | 9 + app/database.py | 7 +- app/favorites.py | 152 +++++---- app/models/__init__.py | 6 + app/models/auth.py | 5 +- app/models/favorites.py | 44 +++ app/models/sonarr.py | 61 +++- app/models/watchlist.py | 23 +- app/sonarr_handler.py | 258 ++++++++------- app/watchlist.py | 66 ++-- scripts/audit_database.py | 59 ++++ scripts/migrate_json_to_sql.py | 276 ++++++++++++++++ templates/base.html | 33 +- templates/components/header.html | 24 +- 21 files changed, 1148 insertions(+), 466 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/e0273f326a15_initial_migration.py create mode 100644 alembic/versions/e88271d11851_add_watchlistsettingstable.py create mode 100644 app/models/favorites.py create mode 100644 scripts/audit_database.py create mode 100644 scripts/migrate_json_to_sql.py diff --git a/README.md b/README.md index 4ed7f43..eebe0df 100644 --- a/README.md +++ b/README.md @@ -1,274 +1,127 @@ # 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 -### 🎬 Recherche d'Animes & Séries TV -- **Recherche unifiée** : Recherchez animes et séries TV simultanément -- **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga -- **Providers Séries** : FS7 (French-Stream) -- **Métadonnées riches** : Synopsis, genres, notes, année de sortie, studio, nombre d'épisodes -- **Téléchargement par épisode** : Sélectionnez et téléchargez des épisodes individuels -- **Téléchargement de saison complète** : Téléchargez tous les épisodes d'un coup -- **Streaming vidéo** : Regardez vos animes directement dans le navigateur -- **Recherche floue** : Gestion des fautes de frappe et variations de noms +### 🎬 Recherche & Streaming +- **Recherche unifiée** : Recherchez animes et séries TV simultanément. +- **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. +- **Streaming vidéo** : Lecteur intégré supportant divers hébergeurs. +- **Téléchargement flexible** : Épisode par épisode ou saison complète. -### 📋 Watchlist (Suivi Automatique) -- **Ajout à la watchlist** : Suivez vos animes préférés depuis la recherche -- **Téléchargement automatique** : Télécharge tous les épisodes dès le suivi -- **Vérification automatique** : Le planificateur vérifie les nouveaux épisodes automatiquement -- **Intervalle configurable** : Paramétrez la fréquence de vérification (1-168 heures) -- **Notifications** : Recevez des alertes pour les nouveaux épisodes -- **Filtres** : Visualisez tous / actifs / en pause / terminés -- **Contrôle granulaire** : Pausez, reprenez, vérifiez manuellement chaque anime +### 📋 Watchlist & Automatisation +- **Suivi intelligent** : Ajoutez des animes à votre watchlist pour ne rater aucun épisode. +- **Auto-Download** : Téléchargement automatique des nouveaux épisodes dès leur sortie. +- **Planificateur (Scheduler)** : Vérification périodique configurable (1h à 168h). +- **Filtres avancés** : Visualisation par statut (Actif, En pause, Terminé). +- **Intégration Sonarr** : Support des webhooks pour une automatisation complète du homelab. -### 📁 Hébergeurs de Fichiers Supportés -- **1fichier** (1fichier.com, 1fichier.fr) -- **Uptobox** (uptobox.com, uptobox.fr) -- **Doodstream** (doodstream.com, dood.to, dood.lol, etc.) -- **Rapidfile** (rapidfile.net, rapidfile.com) -- **Uqload** (uqload.co, uqload.io) -- **OneUpload** (oneupload.co) -- **SendVid** (sendvid.com) -- **VidZ** (vidzi.tv) +### 🚀 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. -### 🎥 Hébergeurs Vidéo Supportés -- **VidMoly** (vidmoly.to, vidmoly.com) -- **SendVid** (sendvid.com) -- **DoodStream** (doodstream.com) -- **LPlayer** (lplayer.net) -- **VidZy** (vidzy.tv) +## 🏗️ Architecture (Three-Tier System) -### 🚀 Gestion des Téléchargements -- **Téléchargements parallèles** : Hasta 5 téléchargements simultanés -- **Pause/Reprise** : Contrôle total sur vos téléchargements -- **Progression en temps réel** : Vitesse, progression, taille -- **Reprise automatique** : Support des HTTP Range pour reprendre les téléchargements interrompus +L'application repose sur un système à trois couches pour une robustesse maximale : +1. **Catalogues (Anime/Series Sites)** : Extraction des listes d'épisodes et métadonnées. +2. **Players Vidéo (Video Players)** : Extraction des liens de téléchargement direct depuis les embeds (VidMoly, DoodStream, etc.). +3. **Manager (Download Manager)** : Orchestration asynchrone des transferts de fichiers. -### 🌐 Interface Web -- **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 +## 📁 Hébergeurs Supportés -### 🔌 API REST -- **Endpoints REST** : Intégration facile avec d'autres applications -- **Documentation automatique** : Swagger UI disponible +| 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 | ## 📋 Configuration Requise -- Python 3.8+ -- pip +- **Python 3.11+** +- **Node.js** (pour les tests frontend uniquement) +- **Playwright** (pour l'extraction dynamique sur certains sites) -## 🚀 Installation +## 🚀 Installation Rapide ```bash # Cloner le repository git clone https://git.lanro.eu/Roman/ohm_streaming.git cd ohm_streaming -# Créer l'environnement virtuel +# Environnement Python python3 -m venv venv -source venv/bin/activate # Windows: venv\Scripts\activate - -# Installer les dépendances +source venv/bin/activate 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 ``` +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 -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 :** -```bash -curl -X POST http://localhost:3000/api/watchlist \ - -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"}' +# Frontend (Vitest & Playwright) +npm test # Tests unitaires JS +npx playwright test # Tests E2E ``` ## 🏗️ Structure du Projet ``` Ohm_streaming/ -├── main.py # Application FastAPI & endpoints API +├── main.py # Point d'entrée & API FastAPI ├── app/ -│ ├── models/ # Modèles Pydantic -│ ├── downloaders/ # Downloaders par provider -│ │ ├── anime_sites/ # Providers anime -│ │ └── series_sites/ # Providers séries -│ ├── providers.py # Configuration des providers -│ ├── download_manager.py # Gestionnaire de file d'attente -│ ├── watchlist.py # Gestion de la watchlist -│ ├── episode_checker.py # Vérification des nouveaux épisodes -│ └── auto_download_scheduler.py # Planificateur automatique -├── templates/ # Templates HTML -│ ├── index.html # Interface web principale -│ └── components/ # Composants réutilisables -├── static/ # Fichiers statiques (CSS, JS) -└── requirements.txt # Dépendances Python +│ ├── downloaders/ # Logique d'extraction (Scraping) +│ │ ├── anime_sites/ # Catalogues Anime +│ │ ├── series_sites/ # Catalogues Séries +│ │ └── video_players/ # Extracteurs de liens directs +│ ├── routers/ # Routes API modulaires (Auth, Watchlist, etc.) +│ ├── download_manager.py # Moteur de téléchargement asynchrone +│ ├── watchlist.py # Logique métier du suivi +│ └── scheduler.py # Planificateur de tâches +├── static/ # Frontend (JS Vanilla, CSS) +├── templates/ # Vues Jinja2 +└── config/ # Données persistantes (JSON) ``` -## ⚙️ 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) -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) -*Objectif : Remplacer les fichiers JSON par une base de données relationnelle.* -- **Migration SQL** : Utiliser **SQLModel** (SQLAlchemy + Pydantic) pour gérer la persistance. - - Tables : `users`, `watchlist`, `tasks`, `favorites`, `settings`. -- **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 : Consolidation & SQL (Terminé) +- Migration complète des fichiers JSON vers **SQLModel** (SQLite). +- Mise en place d'**Alembic** pour les migrations de base de données. +- Centralisation des métadonnées et persistance robuste. -### Phase 2 : Robustesse du Scraping (Cœur Technique) -*Objectif : Rendre l'extraction de données résiliente aux changements des sites tiers.* -- **Abstraction DSL (Domain Specific Language)** : Déporter les sélecteurs CSS et Regex dans des fichiers **YAML/JSON**. - - Permet de mettre à jour un provider sans modifier le code Python. -- **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 : UX & Modernisation Frontend (En cours) +- Adoption de **HTMX/Alpine.js** pour dynamiser l'interface. +- Intégration du lecteur vidéo avancé **Plyr.io**. +- Amélioration de la réactivité de la recherche et de la watchlist. -### Phase 3 : Modernisation du Frontend (UX & Maintenance) -*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). +## 📝 Licence & Sécurité -### Phase 4 : Sécurité et DevOps (Professionnalisation) -*Objectif : Sécuriser les accès et faciliter le déploiement.* -- **Dockerisation Complète** : `docker-compose.yml` incluant App, Redis (cache), PostgreSQL et Playwright. -- **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. +- Ce projet est à usage **éducatif et personnel** uniquement. +- Respectez les droits d'auteur et les conditions d'utilisation des sites sources. +- Ne partagez jamais votre `JWT_SECRET_KEY` en production. --- - +**Version actuelle : 2.3** +**Dernière mise à jour : Mars 2026** **Développé avec ❤️ pour la communauté anime** - -*Version actuelle : 2.2* -*Dernière mise à jour : Février 2026* diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..c10d4ca --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..89e442e --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/e0273f326a15_initial_migration.py b/alembic/versions/e0273f326a15_initial_migration.py new file mode 100644 index 0000000..391c491 --- /dev/null +++ b/alembic/versions/e0273f326a15_initial_migration.py @@ -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 ### diff --git a/alembic/versions/e88271d11851_add_watchlistsettingstable.py b/alembic/versions/e88271d11851_add_watchlistsettingstable.py new file mode 100644 index 0000000..757a3d3 --- /dev/null +++ b/alembic/versions/e88271d11851_add_watchlistsettingstable.py @@ -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 ### diff --git a/app/auth.py b/app/auth.py index 8df8da6..3872dc7 100644 --- a/app/auth.py +++ b/app/auth.py @@ -32,6 +32,7 @@ class UserManager: def get_user(self, username: str) -> Optional[UserTable]: """Get user by username""" + from app.models.watchlist import WatchlistItemTable # Force registration with Session(engine) as session: statement = select(UserTable).where(UserTable.username == username) 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}") +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]: """ Create both access and refresh tokens. diff --git a/app/database.py b/app/database.py index 82f7430..50cdf26 100644 --- a/app/database.py +++ b/app/database.py @@ -17,10 +17,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) def create_db_and_tables(): """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.watchlist import WatchlistItemTable - # Add other models as they are migrated + from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable + from app.models.favorites import FavoriteTable + from app.models.sonarr import SonarrMappingTable, SonarrConfigTable SQLModel.metadata.create_all(engine) diff --git a/app/favorites.py b/app/favorites.py index b78be94..cfd80be 100644 --- a/app/favorites.py +++ b/app/favorites.py @@ -1,52 +1,24 @@ """ 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 -from pathlib import Path from typing import List, Dict, Optional 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__) 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"): - self.storage_path = Path(storage_path) - self.storage_path.parent.mkdir(parents=True, exist_ok=True) - 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}") + def __init__(self, storage_path: str = None): + # Database connection is managed via engine and sessions + pass async def add_favorite( self, @@ -58,48 +30,55 @@ class FavoritesManager: poster_url: Optional[str] = None ) -> Dict: """Add an anime to favorites""" - async with self._lock: - await self._load_for_operation() + with Session(engine) as session: + 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 - self._favorites[anime_id]["updated_at"] = datetime.now().isoformat() + existing.updated_at = datetime.now() if metadata: - self._favorites[anime_id]["metadata"] = metadata + existing.anime_metadata = metadata 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: # Add new favorite - self._favorites[anime_id] = { - "id": anime_id, - "title": title, - "url": url, - "provider": provider, - "metadata": metadata or {}, - "poster_url": poster_url, - "created_at": datetime.now().isoformat(), - "updated_at": datetime.now().isoformat() - } - - await self._save() - return self._favorites[anime_id] + fav = FavoriteTable( + anime_id=anime_id, + title=title, + url=url, + provider=provider, + anime_metadata=metadata or {}, + poster_url=poster_url + ) + session.add(fav) + session.commit() + session.refresh(fav) + return self._to_dict(fav) async def remove_favorite(self, anime_id: str) -> bool: """Remove an anime from favorites""" - async with self._lock: - await self._load_for_operation() - - if anime_id in self._favorites: - del self._favorites[anime_id] - await self._save() + with Session(engine) as session: + statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) + existing = session.exec(statement).first() + if existing: + session.delete(existing) + session.commit() return True - return False async def get_favorite(self, anime_id: str) -> Optional[Dict]: """Get a specific favorite by ID""" - await self._load() - return self._favorites.get(anime_id) + with Session(engine) as session: + 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( self, @@ -109,13 +88,15 @@ class FavoritesManager: filter_genre: Optional[str] = None ) -> List[Dict]: """List all favorites with optional sorting and filtering""" - await self._load() - - favorites = list(self._favorites.values()) - - # Apply filters - if filter_provider: - favorites = [f for f in favorites if f["provider"] == filter_provider] + with Session(engine) as session: + statement = select(FavoriteTable) + + if filter_provider: + statement = statement.where(FavoriteTable.provider == filter_provider) + + # SQLite JSON filtering for genres is complex, handle it in Python + results = session.exec(statement).all() + favorites = [self._to_dict(fav) for fav in results] if filter_genre: favorites = [ @@ -144,8 +125,9 @@ class FavoritesManager: async def is_favorite(self, anime_id: str) -> bool: """Check if an anime is in favorites""" - await self._load() - return anime_id in self._favorites + with Session(engine) as session: + statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) + return session.exec(statement).first() is not None async def toggle_favorite( self, @@ -168,19 +150,18 @@ class FavoritesManager: async def get_stats(self) -> Dict: """Get statistics about favorites""" - await self._load() - - total = len(self._favorites) + favorites = await self.list_favorites() + total = len(favorites) # Count by provider by_provider = {} - for fav in self._favorites.values(): + for fav in favorites: provider = fav["provider"] by_provider[provider] = by_provider.get(provider, 0) + 1 # Count by genre by_genre = {} - for fav in self._favorites.values(): + for fav in favorites: for genre in fav.get("metadata", {}).get("genres", []): by_genre[genre] = by_genre.get(genre, 0) + 1 @@ -190,6 +171,19 @@ class FavoritesManager: "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 _favorites_manager: Optional[FavoritesManager] = None diff --git a/app/models/__init__.py b/app/models/__init__.py index c0e79b7..3363b09 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -63,3 +63,9 @@ class AnimeSearchResult(BaseModel): cover_image: Optional[str] = None type: str # "search_result" or "direct" 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 diff --git a/app/models/auth.py b/app/models/auth.py index f65aac0..75e3d93 100644 --- a/app/models/auth.py +++ b/app/models/auth.py @@ -28,7 +28,7 @@ class UserTable(UserBase, table=True): created_at: datetime = Field(default_factory=datetime.now) last_login: Optional[datetime] = None - # Relationships + # Relationships - Using string reference to avoid circular import errors watchlist_items: List["WatchlistItemTable"] = Relationship(back_populates="user") @@ -60,3 +60,6 @@ class Token(BaseModel): class UserInDB(User): """Schema for user stored in database (with hashed password)""" hashed_password: str + +# Import WatchlistItemTable here to resolve SQLModel Relationship mappings +from .watchlist import WatchlistItemTable diff --git a/app/models/favorites.py b/app/models/favorites.py new file mode 100644 index 0000000..5ca5834 --- /dev/null +++ b/app/models/favorites.py @@ -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 {}) diff --git a/app/models/sonarr.py b/app/models/sonarr.py index 6fd425b..e603f44 100644 --- a/app/models/sonarr.py +++ b/app/models/sonarr.py @@ -1,8 +1,10 @@ """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 datetime import datetime from enum import Enum +from sqlmodel import SQLModel, Field +import uuid class SonarrEventType(str, Enum): @@ -45,7 +47,7 @@ class SonarrEpisodeFile(BaseModel): class SonarrSeries(BaseModel): """Series information from Sonarr""" - tvdbId: int = Field(..., alias="tvdbId") + tvdbId: int = PydanticField(..., alias="tvdbId") title: str sortTitle: str status: str @@ -129,8 +131,33 @@ class SonarrWebhookPayload(BaseModel): 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): - """Mapping between Sonarr series and anime providers""" + """Mapping between Sonarr series and anime providers (API model)""" sonarr_series_id: int sonarr_title: str anime_provider: str # 'anime-sama', 'neko-sama', etc. @@ -139,8 +166,8 @@ class SonarrMapping(BaseModel): lang: str = "vostfr" quality_preference: Optional[str] = None # '1080p', '720p', etc. auto_download: bool = True - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) + created_at: datetime = PydanticField(default_factory=datetime.now) + updated_at: datetime = PydanticField(default_factory=datetime.now) class Config: 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): - """Sonarr webhook configuration""" + """Sonarr webhook configuration (API Model)""" webhook_enabled: bool = False webhook_secret: Optional[str] = None # HMAC SHA256 secret auto_download_enabled: bool = True diff --git a/app/models/watchlist.py b/app/models/watchlist.py index 8de77e5..1a0fec1 100644 --- a/app/models/watchlist.py +++ b/app/models/watchlist.py @@ -74,7 +74,7 @@ class WatchlistItemTable(WatchlistItemBase, table=True): def genres(self, value: List[str]): self.genres_json = json.dumps(value or []) - # Relationships + # Relationships - Using string reference user: Optional["UserTable"] = Relationship(back_populates="watchlist_items") @@ -148,6 +148,24 @@ class AutoDownloadResult(BaseModel): 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): """Global watchlist settings""" 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) notify_on_new_episodes: bool = PydanticField(default=False) include_completed_anime: bool = PydanticField(default=False) + +# Import UserTable here to resolve SQLModel Relationship mappings +from .auth import UserTable diff --git a/app/sonarr_handler.py b/app/sonarr_handler.py index 317c9b8..3f3d32d 100644 --- a/app/sonarr_handler.py +++ b/app/sonarr_handler.py @@ -1,18 +1,19 @@ -"""Sonarr webhook handler and integration logic""" +"""Sonarr webhook handler and integration logic using SQLModel""" import hmac import hashlib -import json import logging -import os -from typing import Optional, Dict, List, Tuple, Any -from pathlib import Path +from typing import Optional, Dict, List, Any from datetime import datetime +from sqlmodel import Session, select +from app.database import engine from app.models.sonarr import ( SonarrWebhookPayload, SonarrEventType, SonarrMapping, + SonarrMappingTable, SonarrConfig, + SonarrConfigTable, SonarrDownloadRequest ) from app.models import DownloadRequest @@ -23,69 +24,150 @@ logger = logging.getLogger(__name__) 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"): - self.config_path = Path(config_path) - self.mappings_path = Path(mappings_path) - self.config = self._load_config() - self.mappings = self._load_mappings() + def __init__(self, config_path: str = None, mappings_path: str = None): self.download_manager = None - - # Create config directories if they don't exist - self.config_path.parent.mkdir(exist_ok=True) - self.mappings_path.parent.mkdir(exist_ok=True) + self._ensure_default_config() def set_download_manager(self, download_manager): self.download_manager = download_manager - def _load_config(self) -> SonarrConfig: - """Load Sonarr configuration from file""" - if self.config_path.exists(): - try: - with open(self.config_path, 'r') as f: - data = json.load(f) - return SonarrConfig(**data) - except Exception as e: - logger.warning(f"Failed to load Sonarr config: {e}") - return SonarrConfig() + def _ensure_default_config(self): + """Ensure a default config exists in the database""" + with Session(engine) as session: + statement = select(SonarrConfigTable) + if not session.exec(statement).first(): + session.add(SonarrConfigTable()) + session.commit() - def _save_config(self): - try: - temp_file = f"{self.config_path}.tmp" - with open(temp_file, 'w') as f: - json.dump(self.config.model_dump(mode='json'), f, indent=2) - os.replace(temp_file, self.config_path) - except Exception as e: - logger.error(f"Failed to save Sonarr config: {e}") - raise + def get_config(self) -> SonarrConfig: + """Get current configuration""" + with Session(engine) as session: + statement = select(SonarrConfigTable) + db_config = session.exec(statement).first() + if db_config: + return SonarrConfig( + webhook_enabled=db_config.webhook_enabled, + 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]: - """Load Sonarr to anime mappings from file""" - if self.mappings_path.exists(): - try: - with open(self.mappings_path, 'r') as f: - data = json.load(f) - return [SonarrMapping(**item) for item in data] - except Exception as e: - logger.warning(f"Failed to load Sonarr mappings: {e}") - return [] + def update_config(self, config: SonarrConfig) -> SonarrConfig: + """Update configuration""" + with Session(engine) as session: + statement = select(SonarrConfigTable) + db_config = session.exec(statement).first() + + if not db_config: + db_config = SonarrConfigTable() + + 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): - try: - os.makedirs(os.path.dirname(self.mappings_path), exist_ok=True) - temp_file = f"{self.mappings_path}.tmp" - with open(temp_file, 'w') as f: - mappings_data = [m.model_dump(mode='json') for m in self.mappings] - json.dump(mappings_data, f, indent=2) - os.replace(temp_file, self.mappings_path) - except Exception as e: - logger.error(f"Failed to save mappings: {e}") - raise + def _to_pydantic(self, db_mapping: SonarrMappingTable) -> SonarrMapping: + return SonarrMapping( + sonarr_series_id=db_mapping.sonarr_series_id, + sonarr_title=db_mapping.sonarr_title, + anime_provider=db_mapping.anime_provider, + anime_url=db_mapping.anime_url, + anime_title=db_mapping.anime_title, + lang=db_mapping.lang, + quality_preference=db_mapping.quality_preference, + auto_download=db_mapping.auto_download, + 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: """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 try: @@ -94,7 +176,7 @@ class SonarrHandler: signature = signature[7:] computed_hmac = hmac.new( - self.config.webhook_secret.encode(), + config.webhook_secret.encode(), payload, hashlib.sha256 ).hexdigest() @@ -104,57 +186,6 @@ class SonarrHandler: logger.error(f"HMAC verification failed: {e}") 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]: """Search for anime by title using specified provider""" try: @@ -197,15 +228,16 @@ class SonarrHandler: async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]: """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"} - if self.config.log_webhooks: + if config.log_webhooks: logger.info(f"Received Sonarr webhook: {payload.eventType.value}") # Handle different event types if payload.eventType == SonarrEventType.GRAB: - return await self._handle_grab(payload) + return await self._handle_grab(payload, config) elif payload.eventType == SonarrEventType.DOWNLOAD: return await self._handle_download(payload) elif payload.eventType == SonarrEventType.RENAME: @@ -217,9 +249,9 @@ class SonarrHandler: else: 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)""" - if not self.config.auto_download_enabled: + if not config.auto_download_enabled: return {"status": "ignored", "reason": "Auto-download disabled"} if not payload.series or not payload.episodes: diff --git a/app/watchlist.py b/app/watchlist.py index 2227359..e1480a4 100644 --- a/app/watchlist.py +++ b/app/watchlist.py @@ -16,50 +16,70 @@ from app.models.watchlist import ( WatchlistItemUpdate, WatchlistStatus, WatchlistSettings, + WatchlistSettingsTable, NewEpisodeInfo, AutoDownloadResult ) logger = logging.getLogger(__name__) -# Settings file remains JSON for simplicity for now -WATCHLIST_SETTINGS_FILE = "config/watchlist_settings.json" - - class WatchlistManager: """Manages user watchlist for automatic episode downloads using SQL database""" def __init__(self): - self.settings_file = WATCHLIST_SETTINGS_FILE self.settings: Optional[WatchlistSettings] = None self._load_settings() def _load_settings(self): - """Load watchlist settings from JSON file""" + """Load watchlist settings from database""" try: - if os.path.exists(self.settings_file): - with open(self.settings_file, 'r', encoding='utf-8') as f: - data = json.load(f) - self.settings = WatchlistSettings(**data) - logger.info(f"Loaded watchlist settings") - else: - self.settings = WatchlistSettings() - self._save_settings() - logger.info("Settings file not found, using defaults") + with Session(engine) as session: + statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default") + db_settings = session.exec(statement).first() + if db_settings: + self.settings = WatchlistSettings( + check_interval_hours=db_settings.check_interval_hours, + auto_download_enabled=db_settings.auto_download_enabled, + max_concurrent_auto_downloads=db_settings.max_concurrent_auto_downloads, + 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: - logger.error(f"Error loading settings: {e}") + logger.error(f"Error loading settings from database: {e}") self.settings = WatchlistSettings() def _save_settings(self): try: - os.makedirs(os.path.dirname(self.settings_file), exist_ok=True) - temp_file = f"{self.settings_file}.tmp" - with open(temp_file, 'w', encoding='utf-8') as f: - json.dump(self.settings.model_dump(mode='json'), f, indent=2, ensure_ascii=False) - os.replace(temp_file, self.settings_file) - logger.debug("Saved watchlist settings") + with Session(engine) as session: + statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default") + db_settings = session.exec(statement).first() + + if db_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: - 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: """Convert database table model to API response model""" diff --git a/scripts/audit_database.py b/scripts/audit_database.py new file mode 100644 index 0000000..941c2bd --- /dev/null +++ b/scripts/audit_database.py @@ -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() diff --git a/scripts/migrate_json_to_sql.py b/scripts/migrate_json_to_sql.py new file mode 100644 index 0000000..cd1cd29 --- /dev/null +++ b/scripts/migrate_json_to_sql.py @@ -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.") diff --git a/templates/base.html b/templates/base.html index c313b0d..6bedbec 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,7 +11,7 @@ - +