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

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