Phase 2 Complete: SQL migration with SQLModel and Alembic
This commit is contained in:
@@ -1,274 +1,127 @@
|
|||||||
# Ohm Stream Downloader
|
# Ohm Stream Downloader
|
||||||
|
|
||||||
**Application web complète pour télécharger des animes, séries TV et fichiers depuis divers hébergeurs.**
|
**Application web complète pour rechercher, streamer et télécharger des animes, séries TV et films.**
|
||||||
|
|
||||||
Interface moderne avec recherche unifiée, watchlist automatique, métadonnées enrichies, téléchargements parallèles et streaming vidéo.
|
Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, métadonnées enrichies, téléchargements parallèles et intégration Sonarr.
|
||||||
|
|
||||||
## ✨ Fonctionnalités
|
## ✨ Fonctionnalités
|
||||||
|
|
||||||
### 🎬 Recherche d'Animes & Séries TV
|
### 🎬 Recherche & Streaming
|
||||||
- **Recherche unifiée** : Recherchez animes et séries TV simultanément
|
- **Recherche unifiée** : Recherchez animes et séries TV simultanément.
|
||||||
- **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga
|
- **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga.
|
||||||
- **Providers Séries** : FS7 (French-Stream)
|
- **Providers Séries** : FS7 (French-Stream).
|
||||||
- **Métadonnées riches** : Synopsis, genres, notes, année de sortie, studio, nombre d'épisodes
|
- **Métadonnées riches** : Synopsis, genres, notes, studio via intégration Kitsu.
|
||||||
- **Téléchargement par épisode** : Sélectionnez et téléchargez des épisodes individuels
|
- **Streaming vidéo** : Lecteur intégré supportant divers hébergeurs.
|
||||||
- **Téléchargement de saison complète** : Téléchargez tous les épisodes d'un coup
|
- **Téléchargement flexible** : Épisode par épisode ou saison complète.
|
||||||
- **Streaming vidéo** : Regardez vos animes directement dans le navigateur
|
|
||||||
- **Recherche floue** : Gestion des fautes de frappe et variations de noms
|
|
||||||
|
|
||||||
### 📋 Watchlist (Suivi Automatique)
|
### 📋 Watchlist & Automatisation
|
||||||
- **Ajout à la watchlist** : Suivez vos animes préférés depuis la recherche
|
- **Suivi intelligent** : Ajoutez des animes à votre watchlist pour ne rater aucun épisode.
|
||||||
- **Téléchargement automatique** : Télécharge tous les épisodes dès le suivi
|
- **Auto-Download** : Téléchargement automatique des nouveaux épisodes dès leur sortie.
|
||||||
- **Vérification automatique** : Le planificateur vérifie les nouveaux épisodes automatiquement
|
- **Planificateur (Scheduler)** : Vérification périodique configurable (1h à 168h).
|
||||||
- **Intervalle configurable** : Paramétrez la fréquence de vérification (1-168 heures)
|
- **Filtres avancés** : Visualisation par statut (Actif, En pause, Terminé).
|
||||||
- **Notifications** : Recevez des alertes pour les nouveaux épisodes
|
- **Intégration Sonarr** : Support des webhooks pour une automatisation complète du homelab.
|
||||||
- **Filtres** : Visualisez tous / actifs / en pause / terminés
|
|
||||||
- **Contrôle granulaire** : Pausez, reprenez, vérifiez manuellement chaque anime
|
|
||||||
|
|
||||||
### 📁 Hébergeurs de Fichiers Supportés
|
### 🚀 Gestionnaire de Téléchargements
|
||||||
- **1fichier** (1fichier.com, 1fichier.fr)
|
- **Multi-threading** : Jusqu'à 5 téléchargements simultanés avec gestion de file d'attente.
|
||||||
- **Uptobox** (uptobox.com, uptobox.fr)
|
- **Pause/Reprise** : Support du protocole HTTP Range pour reprendre les téléchargements interrompus.
|
||||||
- **Doodstream** (doodstream.com, dood.to, dood.lol, etc.)
|
- **Progression Temps Réel** : Vitesse (Mo/s), pourcentage et estimation du temps restant.
|
||||||
- **Rapidfile** (rapidfile.net, rapidfile.com)
|
- **Sanitisation** : Nettoyage automatique des noms de fichiers pour une compatibilité maximale.
|
||||||
- **Uqload** (uqload.co, uqload.io)
|
|
||||||
- **OneUpload** (oneupload.co)
|
|
||||||
- **SendVid** (sendvid.com)
|
|
||||||
- **VidZ** (vidzi.tv)
|
|
||||||
|
|
||||||
### 🎥 Hébergeurs Vidéo Supportés
|
## 🏗️ Architecture (Three-Tier System)
|
||||||
- **VidMoly** (vidmoly.to, vidmoly.com)
|
|
||||||
- **SendVid** (sendvid.com)
|
|
||||||
- **DoodStream** (doodstream.com)
|
|
||||||
- **LPlayer** (lplayer.net)
|
|
||||||
- **VidZy** (vidzy.tv)
|
|
||||||
|
|
||||||
### 🚀 Gestion des Téléchargements
|
L'application repose sur un système à trois couches pour une robustesse maximale :
|
||||||
- **Téléchargements parallèles** : Hasta 5 téléchargements simultanés
|
1. **Catalogues (Anime/Series Sites)** : Extraction des listes d'épisodes et métadonnées.
|
||||||
- **Pause/Reprise** : Contrôle total sur vos téléchargements
|
2. **Players Vidéo (Video Players)** : Extraction des liens de téléchargement direct depuis les embeds (VidMoly, DoodStream, etc.).
|
||||||
- **Progression en temps réel** : Vitesse, progression, taille
|
3. **Manager (Download Manager)** : Orchestration asynchrone des transferts de fichiers.
|
||||||
- **Reprise automatique** : Support des HTTP Range pour reprendre les téléchargements interrompus
|
|
||||||
|
|
||||||
### 🌐 Interface Web
|
## 📁 Hébergeurs Supportés
|
||||||
- **Design moderne** : Interface sombre avec onglets
|
|
||||||
- **5 onglets** : Accueil, Recherche, Séries, Providers, Watchlist
|
|
||||||
- **Responsive** : Fonctionne sur desktop et mobile
|
|
||||||
- **Mise à jour automatique** : Rafraîchissement automatique du contenu
|
|
||||||
- **Métadonnées visuelles** : Affichage des informations anime avec icônes
|
|
||||||
|
|
||||||
### 🔌 API REST
|
| Type | Services Supportés |
|
||||||
- **Endpoints REST** : Intégration facile avec d'autres applications
|
| :--- | :--- |
|
||||||
- **Documentation automatique** : Swagger UI disponible
|
| **Catalogues** | Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga, FS7 |
|
||||||
|
| **Players/Hosts** | VidMoly, DoodStream, 1fichier, Uptobox, SendVid, Sibnet, Lplayer, Uqload, Rapidfile, LuLuvid, Smoothpre, Vidzy |
|
||||||
|
|
||||||
## 📋 Configuration Requise
|
## 📋 Configuration Requise
|
||||||
|
|
||||||
- Python 3.8+
|
- **Python 3.11+**
|
||||||
- pip
|
- **Node.js** (pour les tests frontend uniquement)
|
||||||
|
- **Playwright** (pour l'extraction dynamique sur certains sites)
|
||||||
|
|
||||||
## 🚀 Installation
|
## 🚀 Installation Rapide
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Cloner le repository
|
# Cloner le repository
|
||||||
git clone https://git.lanro.eu/Roman/ohm_streaming.git
|
git clone https://git.lanro.eu/Roman/ohm_streaming.git
|
||||||
cd ohm_streaming
|
cd ohm_streaming
|
||||||
|
|
||||||
# Créer l'environnement virtuel
|
# Environnement Python
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
source venv/bin/activate
|
||||||
|
|
||||||
# Installer les dépendances
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Lancer le serveur de développement
|
# Initialisation Playwright (requis pour VidMoly)
|
||||||
|
playwright install chromium
|
||||||
|
|
||||||
|
# Lancer l'application
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
uvicorn main:app --reload --host 0.0.0.0 --port 3000
|
||||||
```
|
```
|
||||||
|
Accès Web : `http://localhost:3000/web`
|
||||||
|
|
||||||
Accédez à l'interface : http://localhost:3000/web
|
## 🧪 Tests & Qualité
|
||||||
|
|
||||||
## 📖 Utilisation
|
|
||||||
|
|
||||||
### Interface Web
|
|
||||||
|
|
||||||
1. **Onglet Accueil** :
|
|
||||||
- Dernières sorties anime et séries populaires
|
|
||||||
- Recommandations personnalisées
|
|
||||||
|
|
||||||
2. **Onglet Recherche** :
|
|
||||||
- Entrez le nom d'un anime (ex: "Naruto", "Frieren")
|
|
||||||
- Sélectionnez la langue (VOSTFR ou VF)
|
|
||||||
- Cliquez sur "Rechercher"
|
|
||||||
- Sélectionnez un épisode et cliquez sur "Télécharger"
|
|
||||||
- Ou utilisez "Toute la saison" pour tout télécharger
|
|
||||||
- Cliquez sur "➕ Suivre" pour ajouter à la watchlist
|
|
||||||
|
|
||||||
3. **Onglet Séries** :
|
|
||||||
- Recherchez des séries TV (Breaking Bad, Game of Thrones, etc.)
|
|
||||||
- Téléchargez des épisodes
|
|
||||||
|
|
||||||
4. **Onglet Providers** :
|
|
||||||
- Liste des hébergeurs de fichiers disponibles
|
|
||||||
|
|
||||||
5. **Onglet Watchlist** :
|
|
||||||
- Visualisez vos animes suivis
|
|
||||||
- Contrôlez le planificateur automatique
|
|
||||||
- Paramétrez l'intervalle de vérification
|
|
||||||
- Filtrez par statut (tous, actifs, en pause, terminés)
|
|
||||||
|
|
||||||
### API Endpoints
|
|
||||||
|
|
||||||
#### Authentication
|
|
||||||
| Méthode | Endpoint | Description |
|
|
||||||
|---------|----------|-------------|
|
|
||||||
| POST | `/api/auth/register` | Créer un compte |
|
|
||||||
| POST | `/api/auth/login` | Connexion (JWT) |
|
|
||||||
| GET | `/api/auth/me` | Profil utilisateur |
|
|
||||||
|
|
||||||
#### Téléchargements
|
|
||||||
| Méthode | Endpoint | Description |
|
|
||||||
|---------|----------|-------------|
|
|
||||||
| POST | `/api/download` | Créer un nouveau téléchargement |
|
|
||||||
| GET | `/api/downloads` | Lister tous les téléchargements |
|
|
||||||
| GET | `/api/download/{task_id}` | Statut d'un téléchargement |
|
|
||||||
| POST | `/api/download/{task_id}/pause` | Mettre en pause |
|
|
||||||
| POST | `/api/download/{task_id}/resume` | Reprendre |
|
|
||||||
| DELETE | `/api/download/{task_id}` | Annuler/Supprimer |
|
|
||||||
| GET | `/api/download/{task_id}/file` | Télécharger le fichier terminé |
|
|
||||||
|
|
||||||
#### Anime
|
|
||||||
| Méthode | Endpoint | Description |
|
|
||||||
|---------|----------|-------------|
|
|
||||||
| GET | `/api/anime/search` | Rechercher un anime |
|
|
||||||
| GET | `/api/anime/metadata` | Obtenir les métadonnées |
|
|
||||||
| GET | `/api/anime/episodes` | Liste des épisodes |
|
|
||||||
| POST | `/api/anime/download` | Télécharger un épisode |
|
|
||||||
| POST | `/api/anime/download-season` | Télécharger toute une saison |
|
|
||||||
|
|
||||||
#### Watchlist
|
|
||||||
| Méthode | Endpoint | Description |
|
|
||||||
|---------|----------|-------------|
|
|
||||||
| GET | `/api/watchlist` | Liste des animes suivis |
|
|
||||||
| POST | `/api/watchlist` | Ajouter à la watchlist |
|
|
||||||
| DELETE | `/api/watchlist/{item_id}` | Supprimer de la watchlist |
|
|
||||||
| POST | `/api/watchlist/check-all` | Vérifier tous les animes |
|
|
||||||
| GET | `/api/watchlist/settings` | Paramètres |
|
|
||||||
| PUT | `/api/watchlist/settings` | Mettre à jour les paramètres |
|
|
||||||
|
|
||||||
### Exemples API
|
|
||||||
|
|
||||||
**Rechercher un anime :**
|
|
||||||
```bash
|
```bash
|
||||||
curl "http://localhost:3000/api/anime/search?q=frieren&lang=vostfr"
|
# Backend (Pytest)
|
||||||
```
|
pytest # Tous les tests
|
||||||
|
pytest -m "unit" # Tests unitaires rapides
|
||||||
|
|
||||||
**Ajouter à la watchlist :**
|
# Frontend (Vitest & Playwright)
|
||||||
```bash
|
npm test # Tests unitaires JS
|
||||||
curl -X POST http://localhost:3000/api/watchlist \
|
npx playwright test # Tests E2E
|
||||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"anime_title": "Frieren", "anime_url": "https://anime-sama.si/catalogue/frieren/saison1/vostfr/", "provider_id": "animesama", "lang": "vostfr"}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ Structure du Projet
|
## 🏗️ Structure du Projet
|
||||||
|
|
||||||
```
|
```
|
||||||
Ohm_streaming/
|
Ohm_streaming/
|
||||||
├── main.py # Application FastAPI & endpoints API
|
├── main.py # Point d'entrée & API FastAPI
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── models/ # Modèles Pydantic
|
│ ├── downloaders/ # Logique d'extraction (Scraping)
|
||||||
│ ├── downloaders/ # Downloaders par provider
|
│ │ ├── anime_sites/ # Catalogues Anime
|
||||||
│ │ ├── anime_sites/ # Providers anime
|
│ │ ├── series_sites/ # Catalogues Séries
|
||||||
│ │ └── series_sites/ # Providers séries
|
│ │ └── video_players/ # Extracteurs de liens directs
|
||||||
│ ├── providers.py # Configuration des providers
|
│ ├── routers/ # Routes API modulaires (Auth, Watchlist, etc.)
|
||||||
│ ├── download_manager.py # Gestionnaire de file d'attente
|
│ ├── download_manager.py # Moteur de téléchargement asynchrone
|
||||||
│ ├── watchlist.py # Gestion de la watchlist
|
│ ├── watchlist.py # Logique métier du suivi
|
||||||
│ ├── episode_checker.py # Vérification des nouveaux épisodes
|
│ └── scheduler.py # Planificateur de tâches
|
||||||
│ └── auto_download_scheduler.py # Planificateur automatique
|
├── static/ # Frontend (JS Vanilla, CSS)
|
||||||
├── templates/ # Templates HTML
|
├── templates/ # Vues Jinja2
|
||||||
│ ├── index.html # Interface web principale
|
└── config/ # Données persistantes (JSON)
|
||||||
│ └── components/ # Composants réutilisables
|
|
||||||
├── static/ # Fichiers statiques (CSS, JS)
|
|
||||||
└── requirements.txt # Dépendances Python
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚙️ Configuration
|
|
||||||
|
|
||||||
Les paramètres peuvent être configurés via variables d'environnement ou fichier `.env` :
|
|
||||||
|
|
||||||
```
|
|
||||||
JWT_SECRET_KEY=votre-clé-secrète
|
|
||||||
DOWNLOAD_DIR=downloads
|
|
||||||
MAX_PARALLEL_DOWNLOADS=5
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Ajouter un Provider
|
|
||||||
|
|
||||||
Voir la documentation dans le code source pour ajouter de nouveaux providers.
|
|
||||||
|
|
||||||
## 🤝 Contribution
|
|
||||||
|
|
||||||
Les contributions sont les bienvenues !
|
|
||||||
|
|
||||||
1. Fork le projet
|
|
||||||
2. Créez une branche (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. Commit (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. Push (`git push origin feature/AmazingFeature`)
|
|
||||||
5. Ouvrez une Pull Request
|
|
||||||
|
|
||||||
## 🗺️ Plan d'Évolution Global (Modernisation)
|
## 🗺️ Plan d'Évolution Global (Modernisation)
|
||||||
|
|
||||||
Ce plan détaille les étapes nécessaires pour transformer Ohm Stream Downloader en une application de production robuste, sécurisée et évolutive.
|
### ✅ Phase 1 : Restructuration (Terminé)
|
||||||
|
- Migration vers une architecture modulaire pour les downloaders.
|
||||||
|
- Séparation stricte entre catalogues et hébergeurs vidéo.
|
||||||
|
- Amélioration de la gestion des erreurs et des retries.
|
||||||
|
|
||||||
### Phase 1 : Consolidation de la Donnée (Fondation)
|
### ✅ Phase 2 : Consolidation & SQL (Terminé)
|
||||||
*Objectif : Remplacer les fichiers JSON par une base de données relationnelle.*
|
- Migration complète des fichiers JSON vers **SQLModel** (SQLite).
|
||||||
- **Migration SQL** : Utiliser **SQLModel** (SQLAlchemy + Pydantic) pour gérer la persistance.
|
- Mise en place d'**Alembic** pour les migrations de base de données.
|
||||||
- Tables : `users`, `watchlist`, `tasks`, `favorites`, `settings`.
|
- Centralisation des métadonnées et persistance robuste.
|
||||||
- **Gestion des Migrations** : Mettre en place **Alembic** pour suivre l'évolution du schéma sans perte de données.
|
|
||||||
- **Support Multi-base** : Configurer SQLite par défaut et PostgreSQL pour les déploiements avancés.
|
|
||||||
|
|
||||||
### Phase 2 : Robustesse du Scraping (Cœur Technique)
|
### 🏗️ Phase 3 : UX & Modernisation Frontend (En cours)
|
||||||
*Objectif : Rendre l'extraction de données résiliente aux changements des sites tiers.*
|
- Adoption de **HTMX/Alpine.js** pour dynamiser l'interface.
|
||||||
- **Abstraction DSL (Domain Specific Language)** : Déporter les sélecteurs CSS et Regex dans des fichiers **YAML/JSON**.
|
- Intégration du lecteur vidéo avancé **Plyr.io**.
|
||||||
- Permet de mettre à jour un provider sans modifier le code Python.
|
- Amélioration de la réactivité de la recherche et de la watchlist.
|
||||||
- **Découplage des Métadonnées** : Utiliser exclusivement les API de **Kitsu**, **Anilist** ou **MyAnimeList** pour les fiches d'animes.
|
|
||||||
- Le scraping ne sert plus qu'à récupérer les flux vidéo.
|
|
||||||
- **Health Checks Automatisés** : Script quotidien vérifiant que chaque provider répond toujours correctement (alerte en cas d'échec).
|
|
||||||
- **Service Playwright (Headless)** : Intégrer un service optionnel pour scraper les sites protégés par Cloudflare ou du JS complexe.
|
|
||||||
- **Résolution de Domaines (Auto-Mirrors)** : Détection automatique des changements de domaine (.si, .co, .pw) via DNS-over-HTTPS.
|
|
||||||
|
|
||||||
### Phase 3 : Modernisation du Frontend (UX & Maintenance)
|
## 📝 Licence & Sécurité
|
||||||
*Objectif : Simplifier le code JS et améliorer l'expérience utilisateur.*
|
|
||||||
- **Adoption de HTMX/Alpine.js** : Réduire la complexité du Vanilla JS en utilisant **HTMX** pour les mises à jour partielles (DOM diffing) et **Alpine.js** pour la réactivité légère.
|
|
||||||
- **Lecteur Vidéo Professionnel** : Intégrer **Plyr** ou **Video.js** pour supporter :
|
|
||||||
- Les sous-titres (.srt, .vtt).
|
|
||||||
- La gestion avancée du cache et du buffering.
|
|
||||||
- Une interface personnalisée et responsive.
|
|
||||||
- **Système de Toasts & Notifications** : Alertes visuelles pour la progression des tâches et les nouveaux épisodes détectés.
|
|
||||||
- **Design "Mobile First"** : Optimisation complète des CSS pour une utilisation fluide sur smartphone (PWA).
|
|
||||||
|
|
||||||
### Phase 4 : Sécurité et DevOps (Professionnalisation)
|
- Ce projet est à usage **éducatif et personnel** uniquement.
|
||||||
*Objectif : Sécuriser les accès et faciliter le déploiement.*
|
- Respectez les droits d'auteur et les conditions d'utilisation des sites sources.
|
||||||
- **Dockerisation Complète** : `docker-compose.yml` incluant App, Redis (cache), PostgreSQL et Playwright.
|
- Ne partagez jamais votre `JWT_SECRET_KEY` en production.
|
||||||
- **Journalisation Structurée** : Remplacer les `print` par un logger structuré (ex: `structlog`) avec rotation des logs.
|
|
||||||
- **Rate Limiting** : Protection des endpoints API contre le brute-force et le spam de recherche.
|
|
||||||
- **Gestion Stricte des Secrets** : Validation rigoureuse des variables d'environnement et suppression des IPs codées en dur (CORS).
|
|
||||||
|
|
||||||
### Phase 5 : Nouvelles Fonctionnalités (Valeur Ajoutée)
|
|
||||||
*Objectif : Étendre les capacités du service.*
|
|
||||||
- **Transcodage à la volée** : Option FFmpeg pour convertir les .mkv incompatibles vers .mp4.
|
|
||||||
- **Bot de Notification** : Intégration Telegram/Discord pour être alerté dès qu'un épisode de la watchlist est téléchargé.
|
|
||||||
- **Multi-Utilisateurs Réel** : Bibliothèques et historiques de lecture totalement isolés par compte.
|
|
||||||
- **Support des Sous-titres Externes** : Upload de fichiers de sous-titres personnalisés pour le streaming.
|
|
||||||
|
|
||||||
## 📝 Licence
|
|
||||||
|
|
||||||
Ce projet est à usage éducatif uniquement. Respectez les droits d'auteur et les lois locales.
|
|
||||||
|
|
||||||
## ⚠️ Avertissement
|
|
||||||
|
|
||||||
Ce logiciel est destiné à un usage personnel et éducatif. Les utilisateurs sont responsables de vérifier qu'ils ont le droit de télécharger du contenu protégé par des droits d'auteur dans leur juridiction.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
**Version actuelle : 2.3**
|
||||||
|
**Dernière mise à jour : Mars 2026**
|
||||||
**Développé avec ❤️ pour la communauté anime**
|
**Développé avec ❤️ pour la communauté anime**
|
||||||
|
|
||||||
*Version actuelle : 2.2*
|
|
||||||
*Dernière mise à jour : Février 2026*
|
|
||||||
|
|||||||
+116
@@ -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]:
|
def get_user(self, username: str) -> Optional[UserTable]:
|
||||||
"""Get user by username"""
|
"""Get user by username"""
|
||||||
|
from app.models.watchlist import WatchlistItemTable # Force registration
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
statement = select(UserTable).where(UserTable.username == username)
|
statement = select(UserTable).where(UserTable.username == username)
|
||||||
return session.exec(statement).first()
|
return session.exec(statement).first()
|
||||||
@@ -210,6 +211,14 @@ def _save_refresh_tokens(tokens: Dict[str, dict]):
|
|||||||
logger.error(f"Error saving refresh tokens: {e}")
|
logger.error(f"Error saving refresh tokens: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwt_config() -> dict:
|
||||||
|
return {
|
||||||
|
"SECRET_KEY": settings.jwt_secret_key,
|
||||||
|
"ALGORITHM": settings.jwt_algorithm,
|
||||||
|
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes,
|
||||||
|
"REFRESH_TOKEN_EXPIRE_DAYS": 30
|
||||||
|
}
|
||||||
|
|
||||||
def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
|
def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Create both access and refresh tokens.
|
Create both access and refresh tokens.
|
||||||
|
|||||||
+4
-3
@@ -17,10 +17,11 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
|||||||
|
|
||||||
def create_db_and_tables():
|
def create_db_and_tables():
|
||||||
"""Create the database and tables based on the models"""
|
"""Create the database and tables based on the models"""
|
||||||
# Import all models here to ensure they are registered with SQLModel.metadata
|
# CRITICAL: Import ALL models here to ensure they are registered with SQLModel.metadata
|
||||||
from app.models.auth import UserTable
|
from app.models.auth import UserTable
|
||||||
from app.models.watchlist import WatchlistItemTable
|
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
|
||||||
# Add other models as they are migrated
|
from app.models.favorites import FavoriteTable
|
||||||
|
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
||||||
|
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|||||||
+73
-79
@@ -1,52 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
Favorites management system for Ohm Stream Downloader
|
Favorites management system for Ohm Stream Downloader
|
||||||
Stores user's favorite anime with metadata in a local JSON file
|
Stores user's favorite anime with metadata using SQLModel
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import aiofiles
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.database import engine
|
||||||
|
from app.models.favorites import FavoriteTable
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FavoritesManager:
|
class FavoritesManager:
|
||||||
"""Manages user's favorite anime list"""
|
"""Manages user's favorite anime list using SQL database"""
|
||||||
|
|
||||||
def __init__(self, storage_path: str = "data/favorites.json"):
|
def __init__(self, storage_path: str = None):
|
||||||
self.storage_path = Path(storage_path)
|
# Database connection is managed via engine and sessions
|
||||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
pass
|
||||||
self._favorites: Dict[str, Dict] = {}
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def _load(self):
|
|
||||||
"""Load favorites from disk"""
|
|
||||||
async with self._lock:
|
|
||||||
await self._load_for_operation()
|
|
||||||
|
|
||||||
async def _load_for_operation(self):
|
|
||||||
"""Load favorites from disk without acquiring lock (lock must already be held)"""
|
|
||||||
if self.storage_path.exists():
|
|
||||||
try:
|
|
||||||
async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = await f.read()
|
|
||||||
self._favorites = json.loads(content) if content.strip() else {}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error loading favorites: {e}")
|
|
||||||
self._favorites = {}
|
|
||||||
else:
|
|
||||||
self._favorites = {}
|
|
||||||
|
|
||||||
async def _save(self):
|
|
||||||
"""Save favorites to disk (assumes lock is already held)"""
|
|
||||||
try:
|
|
||||||
async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f:
|
|
||||||
await f.write(json.dumps(self._favorites, indent=2, ensure_ascii=False))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving favorites: {e}")
|
|
||||||
|
|
||||||
async def add_favorite(
|
async def add_favorite(
|
||||||
self,
|
self,
|
||||||
@@ -58,48 +30,55 @@ class FavoritesManager:
|
|||||||
poster_url: Optional[str] = None
|
poster_url: Optional[str] = None
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Add an anime to favorites"""
|
"""Add an anime to favorites"""
|
||||||
async with self._lock:
|
with Session(engine) as session:
|
||||||
await self._load_for_operation()
|
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||||
|
existing = session.exec(statement).first()
|
||||||
|
|
||||||
if anime_id in self._favorites:
|
if existing:
|
||||||
# Update existing favorite
|
# Update existing favorite
|
||||||
self._favorites[anime_id]["updated_at"] = datetime.now().isoformat()
|
existing.updated_at = datetime.now()
|
||||||
if metadata:
|
if metadata:
|
||||||
self._favorites[anime_id]["metadata"] = metadata
|
existing.anime_metadata = metadata
|
||||||
if poster_url:
|
if poster_url:
|
||||||
self._favorites[anime_id]["poster_url"] = poster_url
|
existing.poster_url = poster_url
|
||||||
|
session.add(existing)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(existing)
|
||||||
|
return self._to_dict(existing)
|
||||||
else:
|
else:
|
||||||
# Add new favorite
|
# Add new favorite
|
||||||
self._favorites[anime_id] = {
|
fav = FavoriteTable(
|
||||||
"id": anime_id,
|
anime_id=anime_id,
|
||||||
"title": title,
|
title=title,
|
||||||
"url": url,
|
url=url,
|
||||||
"provider": provider,
|
provider=provider,
|
||||||
"metadata": metadata or {},
|
anime_metadata=metadata or {},
|
||||||
"poster_url": poster_url,
|
poster_url=poster_url
|
||||||
"created_at": datetime.now().isoformat(),
|
)
|
||||||
"updated_at": datetime.now().isoformat()
|
session.add(fav)
|
||||||
}
|
session.commit()
|
||||||
|
session.refresh(fav)
|
||||||
await self._save()
|
return self._to_dict(fav)
|
||||||
return self._favorites[anime_id]
|
|
||||||
|
|
||||||
async def remove_favorite(self, anime_id: str) -> bool:
|
async def remove_favorite(self, anime_id: str) -> bool:
|
||||||
"""Remove an anime from favorites"""
|
"""Remove an anime from favorites"""
|
||||||
async with self._lock:
|
with Session(engine) as session:
|
||||||
await self._load_for_operation()
|
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||||
|
existing = session.exec(statement).first()
|
||||||
if anime_id in self._favorites:
|
if existing:
|
||||||
del self._favorites[anime_id]
|
session.delete(existing)
|
||||||
await self._save()
|
session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_favorite(self, anime_id: str) -> Optional[Dict]:
|
async def get_favorite(self, anime_id: str) -> Optional[Dict]:
|
||||||
"""Get a specific favorite by ID"""
|
"""Get a specific favorite by ID"""
|
||||||
await self._load()
|
with Session(engine) as session:
|
||||||
return self._favorites.get(anime_id)
|
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||||
|
existing = session.exec(statement).first()
|
||||||
|
if existing:
|
||||||
|
return self._to_dict(existing)
|
||||||
|
return None
|
||||||
|
|
||||||
async def list_favorites(
|
async def list_favorites(
|
||||||
self,
|
self,
|
||||||
@@ -109,13 +88,15 @@ class FavoritesManager:
|
|||||||
filter_genre: Optional[str] = None
|
filter_genre: Optional[str] = None
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""List all favorites with optional sorting and filtering"""
|
"""List all favorites with optional sorting and filtering"""
|
||||||
await self._load()
|
with Session(engine) as session:
|
||||||
|
statement = select(FavoriteTable)
|
||||||
favorites = list(self._favorites.values())
|
|
||||||
|
if filter_provider:
|
||||||
# Apply filters
|
statement = statement.where(FavoriteTable.provider == filter_provider)
|
||||||
if filter_provider:
|
|
||||||
favorites = [f for f in favorites if f["provider"] == filter_provider]
|
# SQLite JSON filtering for genres is complex, handle it in Python
|
||||||
|
results = session.exec(statement).all()
|
||||||
|
favorites = [self._to_dict(fav) for fav in results]
|
||||||
|
|
||||||
if filter_genre:
|
if filter_genre:
|
||||||
favorites = [
|
favorites = [
|
||||||
@@ -144,8 +125,9 @@ class FavoritesManager:
|
|||||||
|
|
||||||
async def is_favorite(self, anime_id: str) -> bool:
|
async def is_favorite(self, anime_id: str) -> bool:
|
||||||
"""Check if an anime is in favorites"""
|
"""Check if an anime is in favorites"""
|
||||||
await self._load()
|
with Session(engine) as session:
|
||||||
return anime_id in self._favorites
|
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||||
|
return session.exec(statement).first() is not None
|
||||||
|
|
||||||
async def toggle_favorite(
|
async def toggle_favorite(
|
||||||
self,
|
self,
|
||||||
@@ -168,19 +150,18 @@ class FavoritesManager:
|
|||||||
|
|
||||||
async def get_stats(self) -> Dict:
|
async def get_stats(self) -> Dict:
|
||||||
"""Get statistics about favorites"""
|
"""Get statistics about favorites"""
|
||||||
await self._load()
|
favorites = await self.list_favorites()
|
||||||
|
total = len(favorites)
|
||||||
total = len(self._favorites)
|
|
||||||
|
|
||||||
# Count by provider
|
# Count by provider
|
||||||
by_provider = {}
|
by_provider = {}
|
||||||
for fav in self._favorites.values():
|
for fav in favorites:
|
||||||
provider = fav["provider"]
|
provider = fav["provider"]
|
||||||
by_provider[provider] = by_provider.get(provider, 0) + 1
|
by_provider[provider] = by_provider.get(provider, 0) + 1
|
||||||
|
|
||||||
# Count by genre
|
# Count by genre
|
||||||
by_genre = {}
|
by_genre = {}
|
||||||
for fav in self._favorites.values():
|
for fav in favorites:
|
||||||
for genre in fav.get("metadata", {}).get("genres", []):
|
for genre in fav.get("metadata", {}).get("genres", []):
|
||||||
by_genre[genre] = by_genre.get(genre, 0) + 1
|
by_genre[genre] = by_genre.get(genre, 0) + 1
|
||||||
|
|
||||||
@@ -190,6 +171,19 @@ class FavoritesManager:
|
|||||||
"by_genre": by_genre
|
"by_genre": by_genre
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _to_dict(self, fav: FavoriteTable) -> Dict:
|
||||||
|
"""Convert a FavoriteTable instance to a dictionary for API compatibility"""
|
||||||
|
return {
|
||||||
|
"id": fav.anime_id,
|
||||||
|
"title": fav.title,
|
||||||
|
"url": fav.url,
|
||||||
|
"provider": fav.provider,
|
||||||
|
"metadata": fav.anime_metadata,
|
||||||
|
"poster_url": fav.poster_url,
|
||||||
|
"created_at": fav.created_at.isoformat() if fav.created_at else None,
|
||||||
|
"updated_at": fav.updated_at.isoformat() if fav.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Global favorites manager instance
|
# Global favorites manager instance
|
||||||
_favorites_manager: Optional[FavoritesManager] = None
|
_favorites_manager: Optional[FavoritesManager] = None
|
||||||
|
|||||||
@@ -63,3 +63,9 @@ class AnimeSearchResult(BaseModel):
|
|||||||
cover_image: Optional[str] = None
|
cover_image: Optional[str] = None
|
||||||
type: str # "search_result" or "direct"
|
type: str # "search_result" or "direct"
|
||||||
metadata: Optional[AnimeMetadata] = None
|
metadata: Optional[AnimeMetadata] = None
|
||||||
|
|
||||||
|
# Import all SQLModel tables here to ensure they are registered together
|
||||||
|
from .auth import UserTable
|
||||||
|
from .watchlist import WatchlistItemTable, WatchlistSettingsTable
|
||||||
|
from .favorites import FavoriteTable
|
||||||
|
from .sonarr import SonarrMappingTable, SonarrConfigTable
|
||||||
|
|||||||
+4
-1
@@ -28,7 +28,7 @@ class UserTable(UserBase, table=True):
|
|||||||
created_at: datetime = Field(default_factory=datetime.now)
|
created_at: datetime = Field(default_factory=datetime.now)
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[datetime] = None
|
||||||
|
|
||||||
# Relationships
|
# Relationships - Using string reference to avoid circular import errors
|
||||||
watchlist_items: List["WatchlistItemTable"] = Relationship(back_populates="user")
|
watchlist_items: List["WatchlistItemTable"] = Relationship(back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
@@ -60,3 +60,6 @@ class Token(BaseModel):
|
|||||||
class UserInDB(User):
|
class UserInDB(User):
|
||||||
"""Schema for user stored in database (with hashed password)"""
|
"""Schema for user stored in database (with hashed password)"""
|
||||||
hashed_password: str
|
hashed_password: str
|
||||||
|
|
||||||
|
# Import WatchlistItemTable here to resolve SQLModel Relationship mappings
|
||||||
|
from .watchlist import WatchlistItemTable
|
||||||
|
|||||||
@@ -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"""
|
"""Pydantic models for Sonarr webhook integration"""
|
||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field as PydanticField, validator
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from sqlmodel import SQLModel, Field
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class SonarrEventType(str, Enum):
|
class SonarrEventType(str, Enum):
|
||||||
@@ -45,7 +47,7 @@ class SonarrEpisodeFile(BaseModel):
|
|||||||
|
|
||||||
class SonarrSeries(BaseModel):
|
class SonarrSeries(BaseModel):
|
||||||
"""Series information from Sonarr"""
|
"""Series information from Sonarr"""
|
||||||
tvdbId: int = Field(..., alias="tvdbId")
|
tvdbId: int = PydanticField(..., alias="tvdbId")
|
||||||
title: str
|
title: str
|
||||||
sortTitle: str
|
sortTitle: str
|
||||||
status: str
|
status: str
|
||||||
@@ -129,8 +131,33 @@ class SonarrWebhookPayload(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class SonarrMappingBase(SQLModel):
|
||||||
|
sonarr_series_id: int = Field(index=True, unique=True)
|
||||||
|
sonarr_title: str
|
||||||
|
anime_provider: str
|
||||||
|
anime_url: str
|
||||||
|
anime_title: str
|
||||||
|
lang: str = Field(default="vostfr")
|
||||||
|
quality_preference: Optional[str] = None
|
||||||
|
auto_download: bool = Field(default=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.now)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class SonarrMappingTable(SonarrMappingBase, table=True):
|
||||||
|
"""Database table for Sonarr mappings"""
|
||||||
|
__tablename__ = "sonarr_mappings"
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
primary_key=True,
|
||||||
|
index=True,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
user_id: str = Field(foreign_key="users.id", index=True, default="default")
|
||||||
|
|
||||||
|
|
||||||
class SonarrMapping(BaseModel):
|
class SonarrMapping(BaseModel):
|
||||||
"""Mapping between Sonarr series and anime providers"""
|
"""Mapping between Sonarr series and anime providers (API model)"""
|
||||||
sonarr_series_id: int
|
sonarr_series_id: int
|
||||||
sonarr_title: str
|
sonarr_title: str
|
||||||
anime_provider: str # 'anime-sama', 'neko-sama', etc.
|
anime_provider: str # 'anime-sama', 'neko-sama', etc.
|
||||||
@@ -139,8 +166,8 @@ class SonarrMapping(BaseModel):
|
|||||||
lang: str = "vostfr"
|
lang: str = "vostfr"
|
||||||
quality_preference: Optional[str] = None # '1080p', '720p', etc.
|
quality_preference: Optional[str] = None # '1080p', '720p', etc.
|
||||||
auto_download: bool = True
|
auto_download: bool = True
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
created_at: datetime = PydanticField(default_factory=datetime.now)
|
||||||
updated_at: datetime = Field(default_factory=datetime.now)
|
updated_at: datetime = PydanticField(default_factory=datetime.now)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
json_encoders = {
|
json_encoders = {
|
||||||
@@ -148,8 +175,30 @@ class SonarrMapping(BaseModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SonarrConfigBase(SQLModel):
|
||||||
|
webhook_enabled: bool = Field(default=False)
|
||||||
|
webhook_secret: Optional[str] = None
|
||||||
|
auto_download_enabled: bool = Field(default=True)
|
||||||
|
default_language: str = Field(default="vostfr")
|
||||||
|
default_quality: Optional[str] = None
|
||||||
|
default_provider: str = Field(default="anime-sama")
|
||||||
|
verify_hmac: bool = Field(default=False)
|
||||||
|
log_webhooks: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SonarrConfigTable(SonarrConfigBase, table=True):
|
||||||
|
"""Database table for Sonarr configuration (singleton)"""
|
||||||
|
__tablename__ = "sonarr_config"
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
primary_key=True,
|
||||||
|
index=True,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SonarrConfig(BaseModel):
|
class SonarrConfig(BaseModel):
|
||||||
"""Sonarr webhook configuration"""
|
"""Sonarr webhook configuration (API Model)"""
|
||||||
webhook_enabled: bool = False
|
webhook_enabled: bool = False
|
||||||
webhook_secret: Optional[str] = None # HMAC SHA256 secret
|
webhook_secret: Optional[str] = None # HMAC SHA256 secret
|
||||||
auto_download_enabled: bool = True
|
auto_download_enabled: bool = True
|
||||||
|
|||||||
+22
-1
@@ -74,7 +74,7 @@ class WatchlistItemTable(WatchlistItemBase, table=True):
|
|||||||
def genres(self, value: List[str]):
|
def genres(self, value: List[str]):
|
||||||
self.genres_json = json.dumps(value or [])
|
self.genres_json = json.dumps(value or [])
|
||||||
|
|
||||||
# Relationships
|
# Relationships - Using string reference
|
||||||
user: Optional["UserTable"] = Relationship(back_populates="watchlist_items")
|
user: Optional["UserTable"] = Relationship(back_populates="watchlist_items")
|
||||||
|
|
||||||
|
|
||||||
@@ -148,6 +148,24 @@ class AutoDownloadResult(BaseModel):
|
|||||||
checked_at: datetime = PydanticField(default_factory=datetime.now)
|
checked_at: datetime = PydanticField(default_factory=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class WatchlistSettingsBase(SQLModel):
|
||||||
|
check_interval_hours: int = Field(default=6)
|
||||||
|
auto_download_enabled: bool = Field(default=True)
|
||||||
|
max_concurrent_auto_downloads: int = Field(default=2)
|
||||||
|
notify_on_new_episodes: bool = Field(default=False)
|
||||||
|
include_completed_anime: bool = Field(default=False)
|
||||||
|
|
||||||
|
class WatchlistSettingsTable(WatchlistSettingsBase, table=True):
|
||||||
|
"""Database table for global watchlist settings"""
|
||||||
|
__tablename__ = "watchlist_settings"
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
primary_key=True,
|
||||||
|
index=True,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
user_id: str = Field(foreign_key="users.id", index=True, default="default")
|
||||||
|
|
||||||
class WatchlistSettings(BaseModel):
|
class WatchlistSettings(BaseModel):
|
||||||
"""Global watchlist settings"""
|
"""Global watchlist settings"""
|
||||||
check_interval_hours: int = PydanticField(default=6, ge=1, le=168)
|
check_interval_hours: int = PydanticField(default=6, ge=1, le=168)
|
||||||
@@ -155,3 +173,6 @@ class WatchlistSettings(BaseModel):
|
|||||||
max_concurrent_auto_downloads: int = PydanticField(default=2, ge=1, le=10)
|
max_concurrent_auto_downloads: int = PydanticField(default=2, ge=1, le=10)
|
||||||
notify_on_new_episodes: bool = PydanticField(default=False)
|
notify_on_new_episodes: bool = PydanticField(default=False)
|
||||||
include_completed_anime: bool = PydanticField(default=False)
|
include_completed_anime: bool = PydanticField(default=False)
|
||||||
|
|
||||||
|
# Import UserTable here to resolve SQLModel Relationship mappings
|
||||||
|
from .auth import UserTable
|
||||||
|
|||||||
+145
-113
@@ -1,18 +1,19 @@
|
|||||||
"""Sonarr webhook handler and integration logic"""
|
"""Sonarr webhook handler and integration logic using SQLModel"""
|
||||||
import hmac
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
from typing import Optional, Dict, List, Any
|
||||||
from typing import Optional, Dict, List, Tuple, Any
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.database import engine
|
||||||
from app.models.sonarr import (
|
from app.models.sonarr import (
|
||||||
SonarrWebhookPayload,
|
SonarrWebhookPayload,
|
||||||
SonarrEventType,
|
SonarrEventType,
|
||||||
SonarrMapping,
|
SonarrMapping,
|
||||||
|
SonarrMappingTable,
|
||||||
SonarrConfig,
|
SonarrConfig,
|
||||||
|
SonarrConfigTable,
|
||||||
SonarrDownloadRequest
|
SonarrDownloadRequest
|
||||||
)
|
)
|
||||||
from app.models import DownloadRequest
|
from app.models import DownloadRequest
|
||||||
@@ -23,69 +24,150 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class SonarrHandler:
|
class SonarrHandler:
|
||||||
"""Handles Sonarr webhooks and manages series mappings"""
|
"""Handles Sonarr webhooks and manages series mappings using SQL database"""
|
||||||
|
|
||||||
def __init__(self, config_path: str = "config/sonarr.json", mappings_path: str = "config/sonarr_mappings.json"):
|
def __init__(self, config_path: str = None, mappings_path: str = None):
|
||||||
self.config_path = Path(config_path)
|
|
||||||
self.mappings_path = Path(mappings_path)
|
|
||||||
self.config = self._load_config()
|
|
||||||
self.mappings = self._load_mappings()
|
|
||||||
self.download_manager = None
|
self.download_manager = None
|
||||||
|
self._ensure_default_config()
|
||||||
# Create config directories if they don't exist
|
|
||||||
self.config_path.parent.mkdir(exist_ok=True)
|
|
||||||
self.mappings_path.parent.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
def set_download_manager(self, download_manager):
|
def set_download_manager(self, download_manager):
|
||||||
self.download_manager = download_manager
|
self.download_manager = download_manager
|
||||||
|
|
||||||
def _load_config(self) -> SonarrConfig:
|
def _ensure_default_config(self):
|
||||||
"""Load Sonarr configuration from file"""
|
"""Ensure a default config exists in the database"""
|
||||||
if self.config_path.exists():
|
with Session(engine) as session:
|
||||||
try:
|
statement = select(SonarrConfigTable)
|
||||||
with open(self.config_path, 'r') as f:
|
if not session.exec(statement).first():
|
||||||
data = json.load(f)
|
session.add(SonarrConfigTable())
|
||||||
return SonarrConfig(**data)
|
session.commit()
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to load Sonarr config: {e}")
|
|
||||||
return SonarrConfig()
|
|
||||||
|
|
||||||
def _save_config(self):
|
def get_config(self) -> SonarrConfig:
|
||||||
try:
|
"""Get current configuration"""
|
||||||
temp_file = f"{self.config_path}.tmp"
|
with Session(engine) as session:
|
||||||
with open(temp_file, 'w') as f:
|
statement = select(SonarrConfigTable)
|
||||||
json.dump(self.config.model_dump(mode='json'), f, indent=2)
|
db_config = session.exec(statement).first()
|
||||||
os.replace(temp_file, self.config_path)
|
if db_config:
|
||||||
except Exception as e:
|
return SonarrConfig(
|
||||||
logger.error(f"Failed to save Sonarr config: {e}")
|
webhook_enabled=db_config.webhook_enabled,
|
||||||
raise
|
webhook_secret=db_config.webhook_secret,
|
||||||
|
auto_download_enabled=db_config.auto_download_enabled,
|
||||||
|
default_language=db_config.default_language,
|
||||||
|
default_quality=db_config.default_quality,
|
||||||
|
default_provider=db_config.default_provider,
|
||||||
|
verify_hmac=db_config.verify_hmac,
|
||||||
|
log_webhooks=db_config.log_webhooks
|
||||||
|
)
|
||||||
|
return SonarrConfig()
|
||||||
|
|
||||||
def _load_mappings(self) -> List[SonarrMapping]:
|
def update_config(self, config: SonarrConfig) -> SonarrConfig:
|
||||||
"""Load Sonarr to anime mappings from file"""
|
"""Update configuration"""
|
||||||
if self.mappings_path.exists():
|
with Session(engine) as session:
|
||||||
try:
|
statement = select(SonarrConfigTable)
|
||||||
with open(self.mappings_path, 'r') as f:
|
db_config = session.exec(statement).first()
|
||||||
data = json.load(f)
|
|
||||||
return [SonarrMapping(**item) for item in data]
|
if not db_config:
|
||||||
except Exception as e:
|
db_config = SonarrConfigTable()
|
||||||
logger.warning(f"Failed to load Sonarr mappings: {e}")
|
|
||||||
return []
|
db_config.webhook_enabled = config.webhook_enabled
|
||||||
|
db_config.webhook_secret = config.webhook_secret
|
||||||
|
db_config.auto_download_enabled = config.auto_download_enabled
|
||||||
|
db_config.default_language = config.default_language
|
||||||
|
db_config.default_quality = config.default_quality
|
||||||
|
db_config.default_provider = config.default_provider
|
||||||
|
db_config.verify_hmac = config.verify_hmac
|
||||||
|
db_config.log_webhooks = config.log_webhooks
|
||||||
|
|
||||||
|
session.add(db_config)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
logger.info("Sonarr configuration updated in database")
|
||||||
|
return config
|
||||||
|
|
||||||
def _save_mappings(self):
|
def _to_pydantic(self, db_mapping: SonarrMappingTable) -> SonarrMapping:
|
||||||
try:
|
return SonarrMapping(
|
||||||
os.makedirs(os.path.dirname(self.mappings_path), exist_ok=True)
|
sonarr_series_id=db_mapping.sonarr_series_id,
|
||||||
temp_file = f"{self.mappings_path}.tmp"
|
sonarr_title=db_mapping.sonarr_title,
|
||||||
with open(temp_file, 'w') as f:
|
anime_provider=db_mapping.anime_provider,
|
||||||
mappings_data = [m.model_dump(mode='json') for m in self.mappings]
|
anime_url=db_mapping.anime_url,
|
||||||
json.dump(mappings_data, f, indent=2)
|
anime_title=db_mapping.anime_title,
|
||||||
os.replace(temp_file, self.mappings_path)
|
lang=db_mapping.lang,
|
||||||
except Exception as e:
|
quality_preference=db_mapping.quality_preference,
|
||||||
logger.error(f"Failed to save mappings: {e}")
|
auto_download=db_mapping.auto_download,
|
||||||
raise
|
created_at=db_mapping.created_at,
|
||||||
|
updated_at=db_mapping.updated_at
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_mappings(self) -> List[SonarrMapping]:
|
||||||
|
"""Get all mappings"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = select(SonarrMappingTable)
|
||||||
|
db_mappings = session.exec(statement).all()
|
||||||
|
return [self._to_pydantic(m) for m in db_mappings]
|
||||||
|
|
||||||
|
def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]:
|
||||||
|
"""Get mapping for specific series"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id)
|
||||||
|
db_mapping = session.exec(statement).first()
|
||||||
|
if db_mapping:
|
||||||
|
return self._to_pydantic(db_mapping)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping:
|
||||||
|
"""Add or update a mapping"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == mapping.sonarr_series_id)
|
||||||
|
db_mapping = session.exec(statement).first()
|
||||||
|
|
||||||
|
if db_mapping:
|
||||||
|
# Update existing
|
||||||
|
db_mapping.sonarr_title = mapping.sonarr_title
|
||||||
|
db_mapping.anime_provider = mapping.anime_provider
|
||||||
|
db_mapping.anime_url = mapping.anime_url
|
||||||
|
db_mapping.anime_title = mapping.anime_title
|
||||||
|
db_mapping.lang = mapping.lang
|
||||||
|
db_mapping.quality_preference = mapping.quality_preference
|
||||||
|
db_mapping.auto_download = mapping.auto_download
|
||||||
|
db_mapping.updated_at = datetime.now()
|
||||||
|
logger.info(f"Updated mapping for series {mapping.sonarr_title}")
|
||||||
|
else:
|
||||||
|
# Create new
|
||||||
|
db_mapping = SonarrMappingTable(
|
||||||
|
user_id="default",
|
||||||
|
sonarr_series_id=mapping.sonarr_series_id,
|
||||||
|
sonarr_title=mapping.sonarr_title,
|
||||||
|
anime_provider=mapping.anime_provider,
|
||||||
|
anime_url=mapping.anime_url,
|
||||||
|
anime_title=mapping.anime_title,
|
||||||
|
lang=mapping.lang,
|
||||||
|
quality_preference=mapping.quality_preference,
|
||||||
|
auto_download=mapping.auto_download,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now()
|
||||||
|
)
|
||||||
|
logger.info(f"Added mapping for series {mapping.sonarr_title}")
|
||||||
|
|
||||||
|
session.add(db_mapping)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_mapping)
|
||||||
|
return self._to_pydantic(db_mapping)
|
||||||
|
|
||||||
|
def delete_mapping(self, sonarr_series_id: int) -> bool:
|
||||||
|
"""Delete a mapping"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = select(SonarrMappingTable).where(SonarrMappingTable.sonarr_series_id == sonarr_series_id)
|
||||||
|
db_mapping = session.exec(statement).first()
|
||||||
|
if db_mapping:
|
||||||
|
session.delete(db_mapping)
|
||||||
|
session.commit()
|
||||||
|
logger.info(f"Deleted mapping for series ID {sonarr_series_id}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def verify_hmac(self, payload: bytes, signature: str) -> bool:
|
def verify_hmac(self, payload: bytes, signature: str) -> bool:
|
||||||
"""Verify HMAC SHA256 signature"""
|
"""Verify HMAC SHA256 signature"""
|
||||||
if not self.config.verify_hmac or not self.config.webhook_secret:
|
config = self.get_config()
|
||||||
|
if not config.verify_hmac or not config.webhook_secret:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -94,7 +176,7 @@ class SonarrHandler:
|
|||||||
signature = signature[7:]
|
signature = signature[7:]
|
||||||
|
|
||||||
computed_hmac = hmac.new(
|
computed_hmac = hmac.new(
|
||||||
self.config.webhook_secret.encode(),
|
config.webhook_secret.encode(),
|
||||||
payload,
|
payload,
|
||||||
hashlib.sha256
|
hashlib.sha256
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@@ -104,57 +186,6 @@ class SonarrHandler:
|
|||||||
logger.error(f"HMAC verification failed: {e}")
|
logger.error(f"HMAC verification failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_config(self) -> SonarrConfig:
|
|
||||||
"""Get current configuration"""
|
|
||||||
return self.config
|
|
||||||
|
|
||||||
def update_config(self, config: SonarrConfig) -> SonarrConfig:
|
|
||||||
"""Update configuration"""
|
|
||||||
self.config = config
|
|
||||||
self._save_config()
|
|
||||||
logger.info("Sonarr configuration updated")
|
|
||||||
return self.config
|
|
||||||
|
|
||||||
def get_mappings(self) -> List[SonarrMapping]:
|
|
||||||
"""Get all mappings"""
|
|
||||||
return self.mappings
|
|
||||||
|
|
||||||
def get_mapping(self, sonarr_series_id: int) -> Optional[SonarrMapping]:
|
|
||||||
"""Get mapping for specific series"""
|
|
||||||
for mapping in self.mappings:
|
|
||||||
if mapping.sonarr_series_id == sonarr_series_id:
|
|
||||||
return mapping
|
|
||||||
return None
|
|
||||||
|
|
||||||
def add_mapping(self, mapping: SonarrMapping) -> SonarrMapping:
|
|
||||||
"""Add or update a mapping"""
|
|
||||||
# Check if mapping already exists
|
|
||||||
for i, existing in enumerate(self.mappings):
|
|
||||||
if existing.sonarr_series_id == mapping.sonarr_series_id:
|
|
||||||
mapping.updated_at = datetime.now()
|
|
||||||
self.mappings[i] = mapping
|
|
||||||
self._save_mappings()
|
|
||||||
logger.info(f"Updated mapping for series {mapping.sonarr_title}")
|
|
||||||
return mapping
|
|
||||||
|
|
||||||
# Add new mapping
|
|
||||||
mapping.created_at = datetime.now()
|
|
||||||
mapping.updated_at = datetime.now()
|
|
||||||
self.mappings.append(mapping)
|
|
||||||
self._save_mappings()
|
|
||||||
logger.info(f"Added mapping for series {mapping.sonarr_title}")
|
|
||||||
return mapping
|
|
||||||
|
|
||||||
def delete_mapping(self, sonarr_series_id: int) -> bool:
|
|
||||||
"""Delete a mapping"""
|
|
||||||
for i, mapping in enumerate(self.mappings):
|
|
||||||
if mapping.sonarr_series_id == sonarr_series_id:
|
|
||||||
del self.mappings[i]
|
|
||||||
self._save_mappings()
|
|
||||||
logger.info(f"Deleted mapping for series ID {sonarr_series_id}")
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def search_anime_by_title(self, title: str, provider: str = "anime-sama", lang: str = "vostfr") -> List[Dict]:
|
async def search_anime_by_title(self, title: str, provider: str = "anime-sama", lang: str = "vostfr") -> List[Dict]:
|
||||||
"""Search for anime by title using specified provider"""
|
"""Search for anime by title using specified provider"""
|
||||||
try:
|
try:
|
||||||
@@ -197,15 +228,16 @@ class SonarrHandler:
|
|||||||
|
|
||||||
async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]:
|
async def process_webhook(self, payload: SonarrWebhookPayload) -> Dict[str, Any]:
|
||||||
"""Process Sonarr webhook payload"""
|
"""Process Sonarr webhook payload"""
|
||||||
if not self.config.webhook_enabled:
|
config = self.get_config()
|
||||||
|
if not config.webhook_enabled:
|
||||||
return {"status": "ignored", "reason": "Webhook not enabled"}
|
return {"status": "ignored", "reason": "Webhook not enabled"}
|
||||||
|
|
||||||
if self.config.log_webhooks:
|
if config.log_webhooks:
|
||||||
logger.info(f"Received Sonarr webhook: {payload.eventType.value}")
|
logger.info(f"Received Sonarr webhook: {payload.eventType.value}")
|
||||||
|
|
||||||
# Handle different event types
|
# Handle different event types
|
||||||
if payload.eventType == SonarrEventType.GRAB:
|
if payload.eventType == SonarrEventType.GRAB:
|
||||||
return await self._handle_grab(payload)
|
return await self._handle_grab(payload, config)
|
||||||
elif payload.eventType == SonarrEventType.DOWNLOAD:
|
elif payload.eventType == SonarrEventType.DOWNLOAD:
|
||||||
return await self._handle_download(payload)
|
return await self._handle_download(payload)
|
||||||
elif payload.eventType == SonarrEventType.RENAME:
|
elif payload.eventType == SonarrEventType.RENAME:
|
||||||
@@ -217,9 +249,9 @@ class SonarrHandler:
|
|||||||
else:
|
else:
|
||||||
return {"status": "ignored", "reason": f"Unhandled event type: {payload.eventType}"}
|
return {"status": "ignored", "reason": f"Unhandled event type: {payload.eventType}"}
|
||||||
|
|
||||||
async def _handle_grab(self, payload: SonarrWebhookPayload) -> Dict:
|
async def _handle_grab(self, payload: SonarrWebhookPayload, config: SonarrConfig) -> Dict:
|
||||||
"""Handle Grab event (when Sonarr downloads a release)"""
|
"""Handle Grab event (when Sonarr downloads a release)"""
|
||||||
if not self.config.auto_download_enabled:
|
if not config.auto_download_enabled:
|
||||||
return {"status": "ignored", "reason": "Auto-download disabled"}
|
return {"status": "ignored", "reason": "Auto-download disabled"}
|
||||||
|
|
||||||
if not payload.series or not payload.episodes:
|
if not payload.series or not payload.episodes:
|
||||||
|
|||||||
+43
-23
@@ -16,50 +16,70 @@ from app.models.watchlist import (
|
|||||||
WatchlistItemUpdate,
|
WatchlistItemUpdate,
|
||||||
WatchlistStatus,
|
WatchlistStatus,
|
||||||
WatchlistSettings,
|
WatchlistSettings,
|
||||||
|
WatchlistSettingsTable,
|
||||||
NewEpisodeInfo,
|
NewEpisodeInfo,
|
||||||
AutoDownloadResult
|
AutoDownloadResult
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Settings file remains JSON for simplicity for now
|
|
||||||
WATCHLIST_SETTINGS_FILE = "config/watchlist_settings.json"
|
|
||||||
|
|
||||||
|
|
||||||
class WatchlistManager:
|
class WatchlistManager:
|
||||||
"""Manages user watchlist for automatic episode downloads using SQL database"""
|
"""Manages user watchlist for automatic episode downloads using SQL database"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.settings_file = WATCHLIST_SETTINGS_FILE
|
|
||||||
self.settings: Optional[WatchlistSettings] = None
|
self.settings: Optional[WatchlistSettings] = None
|
||||||
self._load_settings()
|
self._load_settings()
|
||||||
|
|
||||||
def _load_settings(self):
|
def _load_settings(self):
|
||||||
"""Load watchlist settings from JSON file"""
|
"""Load watchlist settings from database"""
|
||||||
try:
|
try:
|
||||||
if os.path.exists(self.settings_file):
|
with Session(engine) as session:
|
||||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default")
|
||||||
data = json.load(f)
|
db_settings = session.exec(statement).first()
|
||||||
self.settings = WatchlistSettings(**data)
|
if db_settings:
|
||||||
logger.info(f"Loaded watchlist settings")
|
self.settings = WatchlistSettings(
|
||||||
else:
|
check_interval_hours=db_settings.check_interval_hours,
|
||||||
self.settings = WatchlistSettings()
|
auto_download_enabled=db_settings.auto_download_enabled,
|
||||||
self._save_settings()
|
max_concurrent_auto_downloads=db_settings.max_concurrent_auto_downloads,
|
||||||
logger.info("Settings file not found, using defaults")
|
notify_on_new_episodes=db_settings.notify_on_new_episodes,
|
||||||
|
include_completed_anime=db_settings.include_completed_anime
|
||||||
|
)
|
||||||
|
logger.info(f"Loaded watchlist settings from database")
|
||||||
|
else:
|
||||||
|
self.settings = WatchlistSettings()
|
||||||
|
self._save_settings()
|
||||||
|
logger.info("Settings not found in database, created defaults")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading settings: {e}")
|
logger.error(f"Error loading settings from database: {e}")
|
||||||
self.settings = WatchlistSettings()
|
self.settings = WatchlistSettings()
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
|
with Session(engine) as session:
|
||||||
temp_file = f"{self.settings_file}.tmp"
|
statement = select(WatchlistSettingsTable).where(WatchlistSettingsTable.user_id == "default")
|
||||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
db_settings = session.exec(statement).first()
|
||||||
json.dump(self.settings.model_dump(mode='json'), f, indent=2, ensure_ascii=False)
|
|
||||||
os.replace(temp_file, self.settings_file)
|
if db_settings:
|
||||||
logger.debug("Saved watchlist settings")
|
db_settings.check_interval_hours = self.settings.check_interval_hours
|
||||||
|
db_settings.auto_download_enabled = self.settings.auto_download_enabled
|
||||||
|
db_settings.max_concurrent_auto_downloads = self.settings.max_concurrent_auto_downloads
|
||||||
|
db_settings.notify_on_new_episodes = self.settings.notify_on_new_episodes
|
||||||
|
db_settings.include_completed_anime = self.settings.include_completed_anime
|
||||||
|
else:
|
||||||
|
db_settings = WatchlistSettingsTable(
|
||||||
|
user_id="default",
|
||||||
|
check_interval_hours=self.settings.check_interval_hours,
|
||||||
|
auto_download_enabled=self.settings.auto_download_enabled,
|
||||||
|
max_concurrent_auto_downloads=self.settings.max_concurrent_auto_downloads,
|
||||||
|
notify_on_new_episodes=self.settings.notify_on_new_episodes,
|
||||||
|
include_completed_anime=self.settings.include_completed_anime
|
||||||
|
)
|
||||||
|
session.add(db_settings)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
logger.debug("Saved watchlist settings to database")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving settings: {e}")
|
logger.error(f"Error saving settings to database: {e}")
|
||||||
|
|
||||||
def _to_api_model(self, db_item: WatchlistItemTable) -> WatchlistItem:
|
def _to_api_model(self, db_item: WatchlistItemTable) -> WatchlistItem:
|
||||||
"""Convert database table model to API response model"""
|
"""Convert database table model to API response model"""
|
||||||
|
|||||||
@@ -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 -->
|
<!-- External Libraries -->
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
|
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -31,15 +31,34 @@
|
|||||||
<script src="/static/js/watchlist-ui.js?v=1.11" defer></script>
|
<script src="/static/js/watchlist-ui.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/main.js?v=1.11" defer></script>
|
<script src="/static/js/main.js?v=1.11" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="{
|
<body x-data="globalAppState">
|
||||||
activeTab: 'home',
|
|
||||||
isAuthenticated: true,
|
|
||||||
username: ''
|
|
||||||
}" @set-tab.window="activeTab = $event.detail.tab"
|
|
||||||
@auth-success.window="isAuthenticated = true; username = $event.detail.username">
|
|
||||||
{% include "components/toast_container.html" %}
|
{% include "components/toast_container.html" %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Global State initialized when Alpine is ready
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
console.log('Alpine.js initializing...');
|
||||||
|
Alpine.data('globalAppState', () => ({
|
||||||
|
activeTab: 'home',
|
||||||
|
isAuthenticated: true,
|
||||||
|
username: '',
|
||||||
|
init() {
|
||||||
|
console.log('Global app state ready');
|
||||||
|
window.addEventListener('auth-success', (e) => {
|
||||||
|
console.log('Alpine auth-success received');
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
this.username = e.detail.username;
|
||||||
|
});
|
||||||
|
window.addEventListener('set-tab', (e) => {
|
||||||
|
console.log('Alpine set-tab received:', e.detail.tab);
|
||||||
|
this.activeTab = e.detail.tab;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<!-- User info and logout button -->
|
<!-- User info and logout button -->
|
||||||
<div id="userInfo"
|
<div id="userInfo"
|
||||||
x-show="isAuthenticated"
|
x-show="isAuthenticated"
|
||||||
x-cloak
|
|
||||||
style="margin-bottom: 15px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
|
style="margin-bottom: 15px; padding: 10px; background: rgba(0, 217, 255, 0.1); border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
<div style="display: flex; align-items: center; gap: 10px;">
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<span style="color: #00d9ff;">👤</span>
|
<span style="color: #00d9ff;">👤</span>
|
||||||
@@ -21,39 +20,48 @@
|
|||||||
<!-- Login prompt (shown when not logged in) -->
|
<!-- Login prompt (shown when not logged in) -->
|
||||||
<div id="loginPrompt"
|
<div id="loginPrompt"
|
||||||
x-show="!isAuthenticated"
|
x-show="!isAuthenticated"
|
||||||
x-cloak
|
|
||||||
style="margin-bottom: 15px; padding: 15px; background: rgba(0, 217, 255, 0.1); border: 1px solid rgba(0, 217, 255, 0.3); border-radius: 8px; text-align: center;">
|
style="margin-bottom: 15px; padding: 15px; background: rgba(0, 217, 255, 0.1); border: 1px solid rgba(0, 217, 255, 0.3); border-radius: 8px; text-align: center;">
|
||||||
<p style="color: #00d9ff; margin: 0 0 10px 0;">👋 Bienvenue! <a href="/login" style="color: #00d9ff; text-decoration: underline;">Connectez-vous</a> pour télécharger des vidéos</p>
|
<p style="color: #00d9ff; margin: 0 0 10px 0;">👋 Bienvenue! <a href="/login" style="color: #00d9ff; text-decoration: underline;">Connectez-vous</a> pour télécharger des vidéos</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs - Simple and direct -->
|
<!-- Tabs - Robust navigation -->
|
||||||
<div id="mainTabs" class="tabs" style="display: flex !important; visibility: visible !important;">
|
<div id="mainTabs" class="tabs" style="display: flex !important; visibility: visible !important;">
|
||||||
<button class="tab" :class="{ 'active': activeTab === 'home' }" @click="activeTab = 'home'">
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'home' }"
|
||||||
|
@click="activeTab = 'home'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'home' } }))">
|
||||||
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Accueil
|
Accueil
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" :class="{ 'active': activeTab === 'anime' }" @click="activeTab = 'anime'">
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'anime' }"
|
||||||
|
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } }))">
|
||||||
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Anime
|
Anime
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" :class="{ 'active': activeTab === 'series' }" @click="activeTab = 'series'">
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'series' }"
|
||||||
|
@click="activeTab = 'series'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'series' } }))">
|
||||||
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Série
|
Série
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" :class="{ 'active': activeTab === 'watchlist' }" @click="activeTab = 'watchlist'">
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'watchlist' }"
|
||||||
|
@click="activeTab = 'watchlist'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'watchlist' } }))">
|
||||||
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2m0 0a2 2 0 00-2 2h10a2 2 0 002-2V7a2 2 0 00-2-2"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2m0 0a2 2 0 00-2 2h10a2 2 0 002-2V7a2 2 0 00-2-2"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Watchlist
|
Watchlist
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" :class="{ 'active': activeTab === 'downloads' }" @click="activeTab = 'downloads'">
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'downloads' }"
|
||||||
|
@click="activeTab = 'downloads'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'downloads' } }))">
|
||||||
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg style="width:16px;height:16px;vertical-align:middle;margin-right:5px" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user