Phase 2 Complete: SQL migration with SQLModel and Alembic
This commit is contained in:
@@ -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*
|
||||
|
||||
+116
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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 ###
|
||||
@@ -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.
|
||||
|
||||
+4
-3
@@ -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)
|
||||
|
||||
|
||||
+73
-79
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+22
-1
@@ -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
|
||||
|
||||
+145
-113
@@ -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:
|
||||
|
||||
+43
-23
@@ -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"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -11,7 +11,7 @@
|
||||
|
||||
<!-- External Libraries -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></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>
|
||||
|
||||
<style>
|
||||
@@ -31,15 +31,34 @@
|
||||
<script src="/static/js/watchlist-ui.js?v=1.11" defer></script>
|
||||
<script src="/static/js/main.js?v=1.11" defer></script>
|
||||
</head>
|
||||
<body x-data="{
|
||||
activeTab: 'home',
|
||||
isAuthenticated: true,
|
||||
username: ''
|
||||
}" @set-tab.window="activeTab = $event.detail.tab"
|
||||
@auth-success.window="isAuthenticated = true; username = $event.detail.username">
|
||||
<body x-data="globalAppState">
|
||||
{% include "components/toast_container.html" %}
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<!-- User info and logout button -->
|
||||
<div id="userInfo"
|
||||
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;">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="color: #00d9ff;">👤</span>
|
||||
@@ -21,39 +20,48 @@
|
||||
<!-- Login prompt (shown when not logged in) -->
|
||||
<div id="loginPrompt"
|
||||
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;">
|
||||
<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>
|
||||
|
||||
<!-- Tabs - Simple and direct -->
|
||||
<!-- Tabs - Robust navigation -->
|
||||
<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">
|
||||
<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>
|
||||
Accueil
|
||||
</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">
|
||||
<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>
|
||||
</svg>
|
||||
Anime
|
||||
</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">
|
||||
<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>
|
||||
Série
|
||||
</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">
|
||||
<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>
|
||||
Watchlist
|
||||
</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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user