6 Commits
Author SHA1 Message Date
d2e1bd8ab0 feat: Add favorites management system with full API endpoints
Implement a comprehensive favorites system for anime tracking with the following features:
- Add/remove anime from favorites with unique anime_id
- Toggle favorite status (add if not exists, remove if exists)
- List favorites with sorting (title, rating, year, created_at, updated_at)
- Filter favorites by provider and genre
- Get detailed statistics (total count, provider breakdown, genre distribution, top-rated)
- Persistent storage using JSON file (favorites.json)
- Full REST API with 6 endpoints

API Endpoints:
- GET /api/favorites - List all favorites with sorting/filtering
- POST /api/favorites - Add anime to favorites
- DELETE /api/favorites/{anime_id} - Remove from favorites
- GET /api/favorites/{anime_id} - Get specific favorite details
- GET /api/favorites/stats - Get favorites statistics
- POST /api/favorites/toggle - Toggle favorite status

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-23 10:06:20 +00:00
6168e9ed60 docs: Enhance README with comprehensive feature documentation
Add detailed documentation for:
- Anime search and download features (4 providers)
- File host and video host support
- Download management and web interface
- Complete API endpoints reference
- Usage examples and configuration guide
- Provider addition guide
- Detailed roadmap for future versions

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-23 10:00:37 +00:00
20cad0b4fe feat: Add anime metadata extraction and fix episode selection bug
Features:
- Added rich metadata extraction for all anime providers (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree)
- New AnimeMetadata model with synopsis, genres, rating, release year, studio, poster/banner images, episode count, and status
- New /api/anime/metadata endpoint for fetching metadata of specific anime
- Enhanced /api/anime/search endpoint with optional include_metadata parameter
- Updated web interface with metadata display (expandable synopsis, genres, rating, year)
- Added metadata toggle checkbox in search UI (disabled by default for performance)

Bug Fixes:
- Fixed episode selection bug where select would reset to default after any change
- Removed onchange event from select element that was causing unwanted reloads
- Fixed download button disappearing after episode download
- Episodes can now be downloaded multiple times without page refresh

Enhancements:
- Metadata displayed with icons (📅 year, ⭐ rating, 🏷️ genres, 📺 episodes, 📡 status)
- Expandable synopsis section for detailed descriptions
- Better visual organization of anime information
- Maintains backward compatibility (metadata is optional)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-23 09:36:59 +00:00
40977438ff feat: Add batch download for entire anime seasons
Add ability to download all episodes of a season with one click.

Backend changes:
- New POST /api/anime/download-season endpoint
- Retrieves all episodes and creates download tasks for each
- Returns list of task IDs and total episode count

Frontend changes:
- Add "Toute la saison" button next to episode selector
- Button shown immediately when episodes are loaded
- Confirmation dialog before starting batch download
- Success message showing number of episodes queued

Features:
- Respects max_parallel limit (default: 3 concurrent downloads)
- Proper episode naming (e.g., "Hells Paradise - Episode 01.mp4")
- Works with all anime providers (Anime-Sama, Anime-Ultime, etc.)

Example usage:
- Click "Toute la saison" button on any anime card
- Confirm the dialog
- All episodes are queued and download automatically

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-23 08:40:33 +00:00
c977306020 feat: Improve Anime-Sama search with fuzzy matching
Replace direct URL conversion with official Anime-Sama search API that
handles typos, partial matches, and returns multiple results with cover images.

Changes:
- Use /template-php/defaut/fetch.php API endpoint for search
- Parse HTML search results to extract title, URL, and cover image
- Return multiple results instead of single direct match
- Support for fuzzy matching (e.g., "hell paradise" finds "Hell's Paradise")

Examples:
- "hell paradise" → finds "Hell's Paradise" (handles missing 's')
- "one pie" → finds "One Piece" (handles incomplete words)
- "dragon" → returns 5 Dragon Ball series with cover images

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-23 08:28:15 +00:00
cb3ea8d926 feat: Add SendVid downloader support
Add complete support for SendVid video hosting service used by Anime-Sama
for anime series like Hell's Paradise.

Changes:
- Create SendVidDownloader class with proper headers to avoid 403 errors
- Add SendVid detection and handling in AnimeSamaDownloader
- Update download_manager to include SendVid-specific headers
- Support custom episode naming (e.g., "Hells Paradise - Episode 01.mp4")

Technical details:
- SendVid embed pages require User-Agent and Referer headers
- Direct MP4 URLs extracted from <source> tags with IP/time-based parameters
- Tested with Hell's Paradise Episode 01 (7MB, 24min, 1280x720)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-23 08:17:10 +00:00
88 changed files with 5938 additions and 8864 deletions
+10 -22
View File
@@ -1,25 +1,13 @@
# Copier en .env et adapter. Toutes les variables sont préfixées OHM_.
# Ohm Streaming API Configuration
# OBLIGATOIRE en production : clé de signature des tokens (32+ caractères)
OHM_SECRET_KEY=change-me-in-production
# Server
HOST=0.0.0.0
PORT=8000
RELOAD=true
# Chemins
# OHM_DATA_DIR=./data
# OHM_DOWNLOAD_DIR=./downloads
# OHM_DATABASE_PATH=./data/ohm.db
# Paths
UPLOAD_DIR=uploads
STREAM_DIR=streams
# Téléchargements
# OHM_MAX_PARALLEL_DOWNLOADS=3
# Scraping
# OHM_HTTP_TIMEOUT=20
# OHM_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
# Métadonnées Kitsu
# OHM_METADATA_CACHE_TTL_HOURS=72
# Auth
# OHM_ACCESS_TOKEN_TTL_MINUTES=15
# OHM_REFRESH_TOKEN_TTL_DAYS=30
# OHM_DEBUG=false
# CORS
ALLOWED_ORIGINS=*
+28 -6
View File
@@ -1,9 +1,31 @@
.venv/
# Python
__pycache__/
*.pyc
data/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
# FastAPI
uploads/
streams/
downloads/
# Environment
.env
.pytest_cache/
.ruff_cache/
.plasma/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
+113
View File
@@ -0,0 +1,113 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Ohm Stream Downloader is a FastAPI-based web application for downloading media files from various file hosting services (1fichier, Doodstream, Rapidfile, etc.). It features a web interface, parallel downloads, pause/resume support, and direct file serving.
## Development Commands
```bash
# Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Run development server (auto-reload)
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Access web interface
# Open http://localhost:8000/web in browser
```
## Architecture
**Directory Structure:**
```
Ohm_streaming/
├── main.py # FastAPI application & API endpoints
├── app/
│ ├── models/ # Pydantic models (DownloadTask, DownloadStatus, etc.)
│ ├── downloaders/ # Host-specific downloaders
│ │ ├── base.py # BaseDownloader abstract class
│ │ ├── unfichier.py # 1fichier.com handler
│ │ ├── doodstream.py # Doodstream handler
│ │ └── rapidfile.py # Rapidfile handler
│ └── download_manager.py # Manages download queue, progress, parallel downloads
├── downloads/ # Downloaded files storage
├── templates/
│ └── index.html # Web interface (single-page app)
└── static/ # Static assets (CSS, JS, images)
```
**Core Components:**
1. **DownloadManager** (`app/download_manager.py`)
- Manages all download tasks with parallel download limit (default: 3 concurrent)
- Handles pause/resume/cancel operations
- Tracks progress, speed, and file chunks for resume support
- Uses semaphore to limit concurrent downloads
2. **Downloaders** (`app/downloaders/`)
- Each host has its own downloader class inheriting from `BaseDownloader`
- `can_handle(url)` - Checks if downloader supports the URL
- `get_download_link(url)` - Extracts direct download link and filename from host page
- Uses httpx for async HTTP requests and BeautifulSoup for HTML parsing
3. **Download Task Flow:**
- Client sends URL via POST `/api/download`
- DownloadManager creates task with unique ID
- Appropriate downloader extracts direct link
- File downloaded in chunks (1MB) to `downloads/` directory
- Progress tracked in real-time (bytes, speed, percentage)
- Resume uses HTTP Range headers to continue from last byte
**API Endpoints:**
- `POST /api/download` - Create new download task (starts automatically)
- `GET /api/downloads` - List all download tasks with status
- `GET /api/download/{task_id}` - Get specific task details
- `POST /api/download/{task_id}/pause` - Pause active download
- `POST /api/download/{task_id}/resume` - Resume paused download
- `DELETE /api/download/{task_id}` - Cancel/delete download
- `GET /api/download/{task_id}/file` - Download completed file
- `GET /web` - Web interface
**Web Interface:**
- Single-page app at `/web` (templates/index.html)
- Auto-refreshes every second to show progress
- Shows progress bar, speed, file size
- Controls: Pause, Resume, Cancel, Download completed file
## Adding New Host Support
To add support for a new file hosting service:
1. Create new file in `app/downloaders/` (e.g., `myhost.py`)
2. Inherit from `BaseDownloader`
3. Implement `can_handle(url)` to detect your host URLs
4. Implement `get_download_link(url)` to extract direct download link
5. Import and add to `downloaders` list in `app/downloaders/__init__.py`
Example:
```python
from .base import BaseDownloader
class MyHostDownloader(BaseDownloader):
def can_handle(self, url: str) -> bool:
return "myhost.com" in url.lower()
async def get_download_link(self, url: str) -> tuple[str, str]:
# Fetch page, parse HTML, extract download URL
soup = BeautifulSoup(await self._fetch_page(url), 'lxml')
# ... extraction logic ...
return download_url, filename
```
## Configuration
Edit `main.py` to configure:
- `max_parallel` - Maximum concurrent downloads (default: 3)
- `download_dir` - Storage location (default: "downloads")
-96
View File
@@ -1,96 +0,0 @@
# Ohm Stream Downloader — Description fonctionnelle
> Cahier des charges pour une réécriture à partir de zéro.
## 🎯 Le but du projet
Une **application web auto-hébergée** (pensée pour un homelab, usage personnel) qui sert de **centre de contrôle unique** pour les contenus vidéo en français (animes, séries TV, VOSTFR) :
1. **Découvrir** du contenu en cherchant sur plusieurs sites/sources en même temps,
2. **Regarder** directement dans l'application via un lecteur intégré,
3. **Télécharger** des épisodes ou des saisons complètes,
4. **Ne rien rater** : suivre des titres et télécharger automatiquement chaque nouvel épisode dès sa sortie.
L'application agrège donc le rôle de moteur de recherche, de plateforme de streaming, de gestionnaire de téléchargements et d'outil d'automatisation.
## 👤 Parcours utilisateur type
1. L'utilisateur se connecte (ou s'inscrit).
2. Il lance une **recherche unifiée** : une seule requête interroge tous les sites d'animes et de séries activés, les résultats sont fusionnés.
3. Il consulte la **fiche détaillée** d'un titre : poster, bannière, synopsis, genres, note, année, nombre d'épisodes, liste des saisons et épisodes.
4. Depuis la fiche, il peut :
- **streamer** un épisode dans le lecteur intégré,
- **télécharger** un épisode précis ou une **saison entière** d'un coup,
- **l'ajouter à sa watchlist** pour un suivi automatique,
- **l'ajouter aux favoris**.
5. Il suit l'avancement des téléchargements en temps réel, puis **regarde les fichiers téléchargés** dans une bibliothèque locale (streaming depuis le serveur, reprise de lecture, épisode suivant).
## ⚙️ Fonctionnalités détaillées
### 1. Recherche multi-sources
- Sources « animes » : environ 5 sites francophones (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga).
- Sources « séries/films » : environ 2 sites (FS7/French-Stream, Zone-Téléchargement).
- Chaque source est un **module interchangeable** avec le même contrat : chercher, lister les épisodes, récupérer les métadonnées, extraire le lien vidéo réel.
- Résolution automatique : pour une URL donnée, le système trouve tout seul le bon module (site → hébergeur vidéo → générique).
- **Extraction en 2 niveaux** : la page de l'épisode contient des lecteurs embarqués hébergés ailleurs (une quinzaine d'hébergeurs supportés : DoodStream, VidMoly, 1fichier, Uptobox, Sibnet, Uqload, SendVid, etc.). Le système sait résoudre l'URL directe du fichier pour chacun d'eux.
### 2. Métadonnées enrichies
- Les infos de base viennent du site scrappé, puis sont **enrichies/complétées** via une base externe de données anime (Kitsu, en alternative à MAL) : synopsis, genres, notes, année, images.
- Fusion intelligente : on ne remplit que les champs manquants, avec normalisation des formats entre sources.
- **Cache des métadonnées** pour limiter les appels externes.
### 3. Streaming et lecteur
- Lecteur vidéo intégré pour les liens extraits des hébergeurs (avec contournement des protections courantes de ces hébergeurs).
- **Bibliothèque locale** : tous les fichiers téléchargés sont streamables depuis le serveur (page « watch » avec reprise de lecture, navigation épisode suivant, streaming avec support des requêtes de plage pour l'avance rapide).
### 4. Gestionnaire de téléchargements
- File d'attente avec **téléchargements parallèles limités** (~3–5 simultanés).
- Statuts : en attente, en cours, en pause, terminé, échec, annulé.
- **Pause / reprise** (via requêtes de plage HTTP), **retry**, **annulation**, « tout annuler », nettoyage des tâches finies.
- **Progression temps réel** : pourcentage, vitesse, temps restant — rafraîchie dynamiquement côté interface.
- **Anti-doublons** : si un téléchargement de la même source est déjà actif/en attente, on retourne la tâche existante au lieu d'en créer une.
- **Persistance** : au démarrage, le dossier de téléchargements est scanné pour **restaurer** les tâches terminées.
- Noms de fichiers **nettoyés et sécurisés** automatiquement (suppression de caractères interdits, protection contre la traversée de répertoires).
- Les URLs circulent au format interne `video_url|page_url|titre` pour ne pas perdre le contexte entre l'extraction et le téléchargement.
### 5. Watchlist + téléchargement automatique (le cœur de l'app)
- L'utilisateur suit un titre ; chaque item a un **statut** : actif, en pause, terminé, archivé.
- Mémorisation du **dernier épisode téléchargé** et du dernier épisode disponible.
- Un **planificateur interne** vérifie périodiquement (intervalle configurable, d'1 h à 168 h) tous les titres « dus » :
- détecte les nouveaux épisodes par comparaison au dernier connu,
- les **télécharge automatiquement**,
- journalise un rapport (nouveaux épisodes trouvés / téléchargés).
- Réglages globaux de la watchlist (activation de l'auto-download, intervalle, etc.), statistiques de suivi.
- Vérification manuelle possible à la demande (« vérifier maintenant »).
### 6. Intégration Sonarr (homelab)
- Réception de **webhooks Sonarr** (événements type « épisode importé/téléchargé ») avec **vérification d'authenticité** (signature HMAC + secret) optionnelle.
- **Mapping entre séries Sonarr et sources de scraping** : pour chaque série suivie dans Sonarr, on associe le titre/URL correspondant sur un site d'animes, pour que Sonarr déclenche le téléchargement dans la bonne source.
- Endpoints d'aide : recherche Sonarr, recherche d'épisodes, **suggestions automatiques de mapping**, configuration (langue, qualité, provider par défaut, activation du webhook, journalisation).
### 7. Favoris
- Sauvegarde de titres aimés avec leurs métadonnées et images, avec statistiques et pagination/navigation.
### 8. Recommandations et découvertes
- **Analyse de l'historique de téléchargement** (parsing des noms de fichiers pour extraire titres, groupes, saisons, qualité) → profil de goûts (genres, titres) → **suggestions personnalisées**.
- Sections découverte : **dernières sorties**, **sorties saisonnières**, **calendrier des sorties**, **top** actuel.
### 9. Comptes et administration
- Inscription/connexion/déconnexion avec **sessions par tokens** (token court + refresh token longue durée).
- Rôles **admin / utilisateur**, activation/désactivation de comptes.
- Interface d'**administration** : liste des utilisateurs, stats, bascule admin/actif, suppression.
### 10. Paramètres et gestion des sources
- **Activation/désactivation de chaque source** individuellement (sans redémarrage), avec état de **santé** vérifiable (test manuel + contrôle automatique périodique par le planificateur).
- Réglages de l'interface utilisateur, configuration persistée.
- Endpoints publics de santé de l'application et liste des sources disponibles.
## 🧩 Principes de conception pour la réécriture
- **Architecture à 3 niveaux de scrapers** : sites de catalogues (animes / séries) → hébergeurs vidéo → extraction générique. Chaque niveau a une classe de base et un registre ; ajouter une nouvelle source = écrire un module qui implémente le contrat et le déclarer dans le registre. *Point le plus important : les sites changent souvent, il faut pouvoir en ajouter un en une heure.*
- **Configuration de scraping externalisée** : piloter au moins certaines sources par fichier de configuration (sélecteurs), pour réparer un site cassé sans toucher au code.
- **Un seul format interne** pour transporter le contexte (`vidéo|page|titre`) du scraping jusqu'au téléchargement.
- **Sécurité non négociable** : nettoyage systématique des noms de fichiers, secrets hors fichiers de config, tokens signés.
- **Une seule source de vérité** pour les données (pas de double stockage JSON + base).
- **Pas d'erreurs silencieuses** : chaque échec de scraping/téléchargement est journalisé et exposé (statut d'échec visible dans l'UI).
- **Injection de dépendances** entre les composants (vérificateur d'épisodes ↔ gestionnaire de téléchargements) pour éviter les dépendances circulaires.
+308 -88
View File
@@ -1,113 +1,333 @@
# ⛩ Ohm Stream Downloader
# Ohm Stream Downloader
Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir,
regarder et télécharger des animes et séries VOSTFR/VF.
**Application web complète pour télécharger des animes et fichiers depuis divers hébergeurs.**
> MVP — Phase 1 : recherche multi-sources, fiches enrichies, streaming, gestionnaire
> de téléchargements temps réel, bibliothèque locale, favoris, administration.
> Phase 2 : intégration Sonarr/Prowlarr — OhmStreaming est un **indexeur Torznab**
> et personnalise « Pour toi » avec vos téléchargements Sonarr.
Interface moderne avec recherche d'anime, métadonnées enrichies, téléchargements parallèles et streaming vidéo.
## Démarrage rapide
## ✨ Fonctionnalités
### 🎬 Recherche et Téléchargement d'Animes
- **Recherche unifiée** : Recherchez sur 4 providers simultanément (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree)
- **Métadonnées riches** : Synopsis, genres, notes, année de sortie, studio, nombre d'épisodes, statut
- **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
### 📁 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)
### 🎥 Hébergeurs Vidéo Supportés
- **VidMoly** (vidmoly.to, vidmoly.com)
- **SendVid** (sendvid.com)
### 🚀 Gestion des Téléchargements
- **Téléchargements parallèles** : Jusqu'à 3 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
### 🌐 Interface Web
- **Design moderne** : Interface sombre avec gradients et animations
- **Responsive** : Fonctionne sur desktop et mobile
- **Mise à jour automatique** : Rafraîchissement chaque seconde
- **Métadonnées visuelles** : Affichage des informations anime avec icônes
### 🔌 API REST
- **Endpoints REST** : Intégration facile avec d'autres applications
- **Documentation automatique** : Swagger UI disponible
## 📋 Configuration Requise
- Python 3.8+
- pip
## 🚀 Installation
```bash
uv sync
# ffmpeg requis pour les flux HLS (binaire statique dans ~/.local/bin, ou apt install ffmpeg)
uv run uvicorn app.main:app --host 0.0.0.0 --port 8777
# Cloner le repository
git clone https://github.com/votre-user/Ohm_streaming.git
cd Ohm_streaming
# Créer l'environnement virtuel
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Installer les dépendances
pip install -r requirements.txt
# Lancer le serveur de développement
uvicorn main:app --reload --host 0.0.0.0 --port 3000
```
Serveur persistant : `tmux new-session -d -s ohm 'cd ~/Développement/ohm_streaming && uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`
Accédez à l'interface : http://localhost:3000/web
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
## 📖 Utilisation
## Configuration
### Interface Web
Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
1. **Onglet Recherche d'Anime** :
- Entrez le nom d'un anime (ex: "Naruto", "One Piece")
- Sélectionnez la langue (VOSTFR ou VF)
- Cochez "Inclure les métadonnées" pour plus d'informations
- Cliquez sur "Rechercher"
- Sélectionnez un épisode et cliquez sur "Télécharger"
- Ou utilisez "Toute la saison" pour tout télécharger
| Variable | Défaut | Rôle |
|---|---|---|
| `OHM_SECRET_KEY` | `change-me-in-production` | **À changer** — signature des tokens |
| `OHM_DOWNLOAD_DIR` | `./downloads` | Dossier des fichiers téléchargés |
| `OHM_DATABASE_PATH` | `./data/ohm.db` | Base SQLite |
| `OHM_MAX_PARALLEL_DOWNLOADS` | `3` | Téléchargements simultanés |
2. **Onglet Lien Direct** :
- Collez un lien de téléchargement direct
- Cliquez sur "Télécharger"
## Fonctionnalités
3. **Onglet Providers** :
- Utilisez les onglets spécifiques à chaque provider
- Chaque onglet a ses propres options de recherche
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source
est un module interchangeable activable/désactivable à chaud (page Admin).
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo).
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
- **Streaming HLS** via hls.js ; **téléchargement HLS** via ffmpeg (remux mp4, progression temps réel).
- **Métadonnées enrichies** via Kitsu (synopsis, genres, note, images) avec cache 72 h.
- **Téléchargements** : file parallèle, pause/reprise (HTTP Range), retry, anti-doublons,
progression en temps réel (SSE), persistance au redémarrage.
- **Bibliothèque locale** : streaming avec range requests, reprise de lecture,
navigation épisode suivant/précédent.
- **Comptes** : JWT court + refresh token (rotation), rôles admin/utilisateur,
administration des comptes.
- **Découverte** (`/discover`) : 🆕 nouveautés fusionnées de toutes les sources, triées
par date de sortie réelle (enrichissement Kitsu, badge « en cours de diffusion »),
🔥 incontournables (top popularité Kitsu) et ✨ recommandations par genres déduites
des téléchargements et favoris, titres déjà possédés exclus (cache mémoire).
### API Endpoints
#### Téléchargements
## Intégration Sonarr / Prowlarr (*arr)
| 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é |
OhmStreaming expose une **API Torznab** : la suite *arr le voit comme un indexeur
de plus, et chaque grab Sonarr déclenche l'extraction + le téléchargement dans
la file interne (les épisodes arrivent dans la bibliothèque OhmStreaming).
#### Anime
### 1. OhmStreaming comme indexeur
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/anime/search` | Rechercher un anime (paramètres: `q`, `lang`, `include_metadata`) |
| GET | `/api/anime/metadata` | Obtenir les métadonnées d'un anime (paramètre: `url`) |
| GET | `/api/anime/episodes` | Liste des épisodes d'un anime (paramètres: `url`, `lang`) |
| POST | `/api/anime/download` | Télécharger un épisode |
| POST | `/api/anime/download-season` | Télécharger toute une saison |
Dans **Admin → Intégrations Sonarr / Prowlarr**, copier :
#### Streaming Vidéo
| Champ | Valeur |
|---|---|
| URL | `http://<hote-ohm>:8777/torznab/api` |
| Clé API | générée automatiquement (bouton Régénérer pour la changer) |
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/video/{task_id}` | Stream une vidéo (support Range/seeking) |
| GET | `/stream/{filename}` | Stream par nom de fichier |
| GET | `/player/{task_id}` | Lecteur vidéo pour un téléchargement |
| GET | `/watch/{filename}` | Lecteur vidéo par nom de fichier |
- **Prowlarr** : Indexers → Add Indexer → *Generic Torznab* → coller URL + clé,
puis synchroniser vers Sonarr.
- **Sonarr** (direct) : Settings → Indexers → Add → *Torznab* → coller URL + clé.
Catégories : TV (5000) / Anime (5070).
- **Client de téléchargement** : « Torrent Blackhole » — Sonarr enregistre le
`.torrent` de service tandis qu'OhmStreaming télécharge réellement l'épisode
(extraction embed → HLS/HTTP → mp4 dans `downloads/`).
#### Système
Endpoints : `t=caps`, `t=tvsearch` (q, season, ep), `t=search` — auth par
`?apikey=` ou en-tête `X-Api-Key`.
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/` | Informations sur l'API |
| GET | `/api/providers` | Liste des providers supportés |
| GET | `/health` | Vérifier l'état du serveur |
| GET | `/web` | Interface web |
### 2. « Pour toi » personnalisé par Sonarr
Toujours dans **Admin → Intégrations**, renseigner l'URL Sonarr
(`http://sonarr:8989`) et sa clé API (*Settings → General → API Key*), puis
« Enregistrer » et « Tester ». Les genres des séries téléchargées/grabées sur
Sonarr alimentent la section ✨ **Pour toi** (et ce qui y est possédé n'est pas
re-recommandé). Sonarr absent ou KO → dégradation gracieuse, rien ne casse.
## Architecture
```
app/
├── main.py # FastAPI + lifespan (DB, download manager)
├── config.py # pydantic-settings (OHM_*)
├── db.py # SQLite (aiosqlite) — source de vérité unique
├── routers/ # auth, search, discover, downloads, library, admin, torznab, pages
├── scrapers/
│ ├── base.py # contrats SourceScraper/HosterExtractor + registres
│ ├── configs/ # sélecteurs YAML externalisés (réparer sans coder)
│ ├── sources/ # vostfree, french_manga
├── services/ # downloads, kitsu, discover, sonarr, torznab, settings
└── templates/ + static/ # UI htmx + Alpine.js, thème sombre
```
**Ajouter une source** : créer `app/scrapers/sources/ma_source.py` qui implémente
`SourceScraper`, la décorer `@register_source` — c'est tout (registre auto-découvert).
## Tests
### Exemples API
**Rechercher un anime avec métadonnées :**
```bash
uv run pytest # 61 tests
uv run ruff check . # lint
curl "http://localhost:3000/api/anime/search?q=naruto&lang=vostfr&include_metadata=true"
```
**Obtenir les épisodes d'un anime :**
```bash
curl "http://localhost:3000/api/anime/episodes?url=https://anime-sama.si/catalogue/naruto/saison1/vostfr/&lang=vostfr"
```
**Télécharger une saison complète :**
```bash
curl -X POST "http://localhost:3000/api/anime/download-season?url=https://anime-sama.si/catalogue/naruto/saison1/vostfr/&lang=vostfr"
```
**Créer un téléchargement direct :**
```bash
curl -X POST http://localhost:3000/api/download \
-H "Content-Type: application/json" \
-d '{"url": "https://1fichier.com/?xxxxx"}'
```
## 🏗️ Structure du Projet
```
Ohm_streaming/
├── main.py # Application FastAPI & endpoints API
├── app/
│ ├── models/ # Modèles Pydantic
│ │ └── __init__.py # DownloadTask, AnimeMetadata, etc.
│ ├── downloaders/ # Downloaders par provider
│ │ ├── base.py # Classe BaseDownloader
│ │ ├── animesama.py # Anime-Sama (avec métadonnées)
│ │ ├── animeultime.py # Anime-Ultime (avec métadonnées)
│ │ ├── nekosama.py # Neko-Sama (avec métadonnées)
│ │ ├── vostfree.py # Vostfree (avec métadonnées)
│ │ ├── unfichier.py # 1fichier
│ │ ├── uptobox.py # Uptobox
│ │ ├── doodstream.py # Doodstream
│ │ ├── rapidfile.py # Rapidfile
│ │ ├── vidmoly.py # VidMoly
│ │ ├── sendvid.py # SendVid
│ │ └── __init__.py # Registry des downloaders
│ ├── providers.py # Configuration des providers
│ └── download_manager.py # Gestionnaire de file d'attente
├── downloads/ # Fichiers téléchargés
├── templates/
│ ├── index.html # Interface web principale
│ └── player.html # Lecteur vidéo
├── static/ # Fichiers statiques (CSS, JS, images)
└── requirements.txt # Dépendances Python
```
## ⚙️ Configuration
Modifiez ces paramètres dans `main.py` :
```python
download_manager = DownloadManager(
download_dir="downloads", # Répertoire de stockage
max_parallel=3 # Téléchargements simultanés
)
```
## 🔧 Ajouter un Provider
### Ajouter un Hébergeur de Fichiers
1. Créez `app/downloaders/myhost.py` :
```python
from .base import BaseDownloader
from bs4 import BeautifulSoup
class MyHostDownloader(BaseDownloader):
def can_handle(self, url: str) -> bool:
return "myhost.com" in url.lower()
async def get_download_link(self, url: str) -> tuple[str, str]:
# Extraire le lien de téléchargement direct
response = await self.client.get(url)
soup = BeautifulSoup(response.text, 'lxml')
# ... logique d'extraction ...
return download_url, filename
```
2. Ajoutez-le dans `app/providers.py` :
```python
FILE_HOSTS = {
# ...
"myhost": {
"name": "MyHost",
"domains": ["myhost.com"],
"icon": "📁",
"color": "#4ecdc4"
}
}
```
### Ajouter un Provider Anime avec Métadonnées
1. Créez le downloader avec les méthodes requises :
```python
class MyAnimeDownloader(BaseDownloader):
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False):
# Implémenter la recherche
async def get_anime_metadata(self, anime_url: str) -> dict:
# Extraire: synopsis, genres, rating, release_year, studio, etc.
return {
'synopsis': '...',
'genres': ['Action', 'Aventure'],
'rating': '8.5/10',
'release_year': 2023,
'studio': 'Studio Name',
# ...
}
async def get_episodes(self, anime_url: str, lang: str = "vostfr"):
# Retourner la liste des épisodes
```
2. Enregistrez-le dans `app/providers.py` et `main.py`
## 🗺️ Roadmap / Plans Futurs
### Version 2.2 - Améliorations des Métadonnées
- [ ] **Affichage des posters** : Afficher les images de couverture dans les résultats de recherche
- [ ] **Filtrage avancé** : Filtrer par genre, année, studio, statut
- [ ] **Tri des résultats** : Par popularité, date, note
- [ ] **Favoris** : Sauvegarder les animes favoris
- [ ] **Historique** : Voir les animes récemment consultés
### Version 2.3 - Gestion de Bibliothèque
- [ ] **Bibliothèque personnelle** : Gérer sa collection d'anime téléchargés
- [ ] **Statistiques** : Temps de visionnage, espace disque utilisé
- [ ] **Listes de lecture** : Créer des playlists personnalisées
- [ ] **Marquage** : Marquer les épisodes comme vus/non vus
- [ ] **Notes personnelles** : Noter les animes et laisser des commentaires
### Version 2.4 - Qualité et Formats
- [ ] **Sélection de qualité** : Choisir entre 1080p, 720p, 480p
- [ ] **Conversion automatique** : Convertir en différents formats
- [ ] **Compression** : Réduire la taille des fichiers
- [ ] **Extraction de sous-titres** : Télécharger les subs automatiquement
- [ ] **Multi-audio** : Gérer les versions VF/VOSTFR
### Version 2.5 - Fonctionnalités Sociales
- [ ] **Partage de listes** : Partager ses playlists avec amis
- [ ] **Recommandations** : Suggestions basées sur l'historique
- [ ] **Notes et avis** : Système de commentaires
- [ ] **Intégration Discord/Telegram** : Notifications de nouveaux épisodes
### Version 2.6 - Mobile et Applications
- [ ] **Application mobile** : App native iOS/Android
- [ ] **PWA** : Progressive Web App pour offline
- [ ] **Cast** : Chromecast/AirPlay support
- [ ] **Download sur mobile** : Interface optimée mobile
### Version 3.0 - Fonctionnalités Avancées
- [ ] **Sauvegarde cloud** : Sync avec Google Drive/Dropbox
- [ ] **Streaming distant** : Regarder partout
- [ ] **Multi-utilisateurs** : Profils et permissions
- [ ] **API publique** : API pour développeurs tiers
- [ ] **Plugins** : Système d'extensions
### Améliorations Continues
- [ ] **Performance** : Optimisation du chargement et de l'interface
- [ ] **Accessibilité** : Support lecteur d'écran, clavier
- [ ] **Tests automatisés** : Suite de tests E2E
- [ ] **Documentation** : Guides d'utilisation et API
- [ ] **Internationalisation** : Support multilingue complet
## 🤝 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
## 📝 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.
## 📧 Support
Pour les bugs et suggestions :
- Ouvrez une issue sur GitHub
- Discutez avec la communauté
---
**Développé avec ❤️ pour la communauté anime**
*Version actuelle : 2.1*
*Dernière mise à jour : Janvier 2026*
+1
View File
@@ -0,0 +1 @@
# Ohm Stream Downloader Package
-139
View File
@@ -1,139 +0,0 @@
"""Authentification : JWT court + refresh token longue durée, rôles admin/user."""
import hashlib
import logging
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import jwt
from pwdlib import PasswordHash
from app.config import get_settings
from app.db import db
logger = logging.getLogger(__name__)
password_hash = PasswordHash.recommended()
ALGORITHM = "HS256"
@dataclass
class User:
id: int
username: str
is_admin: bool
is_active: bool
# ---------------------------------------------------------------- mots de passe
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return password_hash.verify(password, hashed)
# ---------------------------------------------------------------- utilisateurs
async def create_user(username: str, password: str) -> User:
"""Crée un compte ; le tout premier utilisateur devient admin."""
row = await db.fetchone("SELECT COUNT(*) AS n FROM users")
is_first = row["n"] == 0
cursor = await db.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
(username.strip(), hash_password(password), int(is_first)),
)
user = User(id=cursor.lastrowid, username=username.strip(), is_admin=is_first, is_active=True)
logger.info("Utilisateur créé : %s (admin=%s)", user.username, user.is_admin)
return user
async def authenticate(username: str, password: str) -> User | None:
row = await db.fetchone(
"SELECT id, username, password_hash, is_admin, is_active FROM users WHERE username = ?",
(username.strip(),),
)
if row is None or not verify_password(password, row["password_hash"]):
logger.warning("Échec d'authentification pour %r", username)
return None
if not row["is_active"]:
logger.warning("Compte désactivé : %r", username)
return None
return User(
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
)
async def get_user(user_id: int) -> User | None:
row = await db.fetchone(
"SELECT id, username, is_admin, is_active FROM users WHERE id = ?", (user_id,)
)
if row is None or not row["is_active"]:
return None
return User(
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
)
# ---------------------------------------------------------------- tokens
def create_access_token(user: User) -> str:
settings = get_settings()
payload = {
"sub": str(user.id),
"username": user.username,
"admin": user.is_admin,
"exp": datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict | None:
try:
return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM])
except jwt.PyJWTError as exc:
logger.debug("Token d'accès invalide : %s", exc)
return None
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def create_refresh_token(user_id: int) -> str:
"""Refresh token opaque ; seul son hash est stocké en DB (révocable)."""
token = secrets.token_urlsafe(48)
expires = datetime.now(UTC) + timedelta(days=get_settings().refresh_token_ttl_days)
await db.execute(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
(user_id, _hash_token(token), expires.strftime("%Y-%m-%d %H:%M:%S")),
)
return token
async def use_refresh_token(token: str) -> User | None:
"""Valide un refresh token, le révoque (rotation) et retourne l'utilisateur."""
row = await db.fetchone(
"SELECT id, user_id FROM refresh_tokens "
"WHERE token_hash = ? AND revoked = 0 AND expires_at > datetime('now')",
(_hash_token(token),),
)
if row is None:
logger.warning("Refresh token invalide ou expiré")
return None
await db.execute("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?", (row["id"],))
user = await get_user(row["user_id"])
if user is None:
logger.warning("Refresh token d'un compte supprimé/désactivé (user_id=%s)", row["user_id"])
return user
async def revoke_all_refresh_tokens(user_id: int) -> None:
await db.execute("UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?", (user_id,))
-46
View File
@@ -1,46 +0,0 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OHM_", env_file=".env", extra="ignore")
app_name: str = "Ohm Stream Downloader"
debug: bool = False
# Données
data_dir: Path = BASE_DIR / "data"
download_dir: Path = BASE_DIR / "downloads"
database_path: Path = BASE_DIR / "data" / "ohm.db"
# Sécurité — à surcharger via OHM_SECRET_KEY en production
secret_key: str = "change-me-in-production"
access_token_ttl_minutes: int = 15
refresh_token_ttl_days: int = 30
# Scraping
http_timeout: float = 20.0
user_agent: str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
scrapers_config_dir: Path = BASE_DIR / "app" / "scrapers" / "configs"
# Téléchargements
max_parallel_downloads: int = 3
# Kitsu
kitsu_base_url: str = "https://kitsu.io/api/edge"
metadata_cache_ttl_hours: int = 72
def ensure_dirs(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True)
self.download_dir.mkdir(parents=True, exist_ok=True)
@lru_cache
def get_settings() -> Settings:
settings = Settings()
settings.ensure_dirs()
return settings
-123
View File
@@ -1,123 +0,0 @@
"""Couche d'accès SQLite (aiosqlite) — unique source de vérité de l'application."""
import aiosqlite
from app.config import get_settings
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_key TEXT NOT NULL, -- clé de déduplication (video_url normalisée)
video_url TEXT NOT NULL,
page_url TEXT,
title TEXT NOT NULL,
file_path TEXT, -- relatif au dossier de téléchargement
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','downloading','paused','done','failed','cancelled')),
total_bytes INTEGER,
downloaded_bytes INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_downloads_active_source
ON downloads(source_key) WHERE status IN ('pending','downloading','paused');
CREATE TABLE IF NOT EXISTS metadata_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL, -- JSON
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL -- JSON
);
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source TEXT NOT NULL,
source_id TEXT NOT NULL,
title TEXT NOT NULL,
image_url TEXT,
payload TEXT, -- JSON métadonnées
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, source, source_id)
);
CREATE TABLE IF NOT EXISTS watch_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
position_seconds REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, download_id)
);
"""
class Database:
"""Connexion SQLite partagée, initialisée au démarrage de l'app."""
def __init__(self) -> None:
self._conn: aiosqlite.Connection | None = None
async def connect(self) -> None:
path = get_settings().database_path
self._conn = await aiosqlite.connect(path)
self._conn.row_factory = aiosqlite.Row
await self._conn.execute("PRAGMA journal_mode=WAL")
await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.executescript(SCHEMA)
await self._conn.commit()
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
self._conn = None
@property
def conn(self) -> aiosqlite.Connection:
if self._conn is None:
raise RuntimeError("Database.connect() n'a pas été appelé")
return self._conn
async def execute(self, sql: str, params: tuple = ()) -> aiosqlite.Cursor:
cursor = await self.conn.execute(sql, params)
await self.conn.commit()
return cursor
async def fetchone(self, sql: str, params: tuple = ()) -> aiosqlite.Row | None:
cursor = await self.conn.execute(sql, params)
row = await cursor.fetchone()
await cursor.close()
return row
async def fetchall(self, sql: str, params: tuple = ()) -> list[aiosqlite.Row]:
cursor = await self.conn.execute(sql, params)
rows = await cursor.fetchall()
await cursor.close()
return rows
db = Database()
+190
View File
@@ -0,0 +1,190 @@
import asyncio
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
import httpx
from app.models import DownloadTask, DownloadStatus, DownloadRequest
from app.downloaders import get_downloader
class DownloadManager:
"""Manages multiple downloads with queue and progress tracking"""
def __init__(self, download_dir: str = "downloads", max_parallel: int = 3):
self.download_dir = Path(download_dir)
self.download_dir.mkdir(exist_ok=True)
self.max_parallel = max_parallel
self.tasks: Dict[str, DownloadTask] = {}
self.active_downloads: Dict[str, asyncio.Task] = {}
self._semaphore = asyncio.Semaphore(max_parallel)
def get_task(self, task_id: str) -> Optional[DownloadTask]:
return self.tasks.get(task_id)
def get_all_tasks(self) -> list[DownloadTask]:
return list(self.tasks.values())
def create_task(self, request: DownloadRequest) -> DownloadTask:
task_id = str(uuid.uuid4())
task = DownloadTask(
id=task_id,
url=request.url,
filename=request.filename or "download",
host="other",
status=DownloadStatus.PENDING,
created_at=datetime.now()
)
self.tasks[task_id] = task
return task
async def start_download(self, task_id: str):
task = self.tasks.get(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
if task.status == DownloadStatus.DOWNLOADING:
return
# Cancel any existing download task
if task_id in self.active_downloads:
self.active_downloads[task_id].cancel()
# Start new download
download_task = asyncio.create_task(self._download(task))
self.active_downloads[task_id] = download_task
async def pause_download(self, task_id: str):
task = self.tasks.get(task_id)
if task and task.status == DownloadStatus.DOWNLOADING:
task.status = DownloadStatus.PAUSED
if task_id in self.active_downloads:
self.active_downloads[task_id].cancel()
del self.active_downloads[task_id]
async def cancel_download(self, task_id: str):
task = self.tasks.get(task_id)
if task:
task.status = DownloadStatus.CANCELLED
if task_id in self.active_downloads:
self.active_downloads[task_id].cancel()
del self.active_downloads[task_id]
# Delete partial file
if task.file_path and os.path.exists(task.file_path):
os.remove(task.file_path)
async def _download(self, task: DownloadTask):
async with self._semaphore:
try:
task.status = DownloadStatus.DOWNLOADING
task.started_at = datetime.now()
# Get downloader and extract link
downloader = get_downloader(task.url)
download_url, filename = await downloader.get_download_link(task.url)
if not task.filename or task.filename == "download":
task.filename = filename
task.file_path = str(self.download_dir / task.filename)
# Check if file already exists and is complete (for VidMoly which downloads directly)
if os.path.exists(task.file_path):
file_size = os.path.getsize(task.file_path)
if file_size > 1024: # More than 1KB - assume complete
print(f"[DOWNLOAD] File already exists: {task.filename} ({file_size / (1024*1024):.2f} MB)")
task.status = DownloadStatus.COMPLETED
task.progress = 100.0
task.downloaded_bytes = file_size
task.total_bytes = file_size
task.completed_at = datetime.now()
return
# Check for partial download (resume)
downloaded_bytes = 0
if os.path.exists(task.file_path):
downloaded_bytes = os.path.getsize(task.file_path)
headers = {}
# Add SendVid-specific headers to avoid 403 errors
if 'sendvid.com' in download_url:
headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': 'https://sendvid.com/',
})
if downloaded_bytes > 0:
headers['Range'] = f'bytes={downloaded_bytes}-'
# Download with streaming
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
# First attempt with Range header if resuming
try:
async with client.stream('GET', download_url, headers=headers) as response:
response.raise_for_status()
# Process download (same code for both cases)
await self._process_download(response, task, downloaded_bytes)
except httpx.HTTPStatusError as e:
# If server doesn't support Range (416 error), restart from beginning
if e.response.status_code == 416 and downloaded_bytes > 0:
print(f"[DOWNLOAD] Server doesn't support Range, restarting download: {task.filename}")
# Remove partial file and restart without Range header
if os.path.exists(task.file_path):
os.remove(task.file_path)
downloaded_bytes = 0
headers = {}
async with client.stream('GET', download_url, headers=headers) as response:
response.raise_for_status()
await self._process_download(response, task, downloaded_bytes)
else:
raise
except Exception as e:
task.status = DownloadStatus.FAILED
task.error = str(e)
finally:
if task.id in self.active_downloads:
del self.active_downloads[task.id]
async def _process_download(self, response, task: DownloadTask, downloaded_bytes: int):
"""Process the download response stream"""
# Get total size
if 'content-range' in response.headers:
# Resume mode
total_size = int(response.headers['content-range'].split('/')[-1])
else:
# New download
total_size = int(response.headers.get('content-length', 0))
downloaded_bytes = 0
task.total_bytes = total_size
# Write file
mode = 'ab' if downloaded_bytes > 0 else 'wb'
with open(task.file_path, mode) as f:
start_time = asyncio.get_event_loop().time()
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
if task.status == DownloadStatus.CANCELLED:
return
if task.status == DownloadStatus.PAUSED:
return
f.write(chunk)
downloaded_bytes += len(chunk)
task.downloaded_bytes = downloaded_bytes
# Calculate progress
if total_size > 0:
task.progress = (downloaded_bytes / total_size) * 100
# Calculate speed
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed > 0:
task.speed = downloaded_bytes / elapsed
task.status = DownloadStatus.COMPLETED
task.completed_at = datetime.now()
task.progress = 100.0
+48
View File
@@ -0,0 +1,48 @@
from .base import BaseDownloader
from .unfichier import UnFichierDownloader
from .doodstream import DoodStreamDownloader
from .rapidfile import RapidFileDownloader
from .uptobox import UptoboxDownloader
from .animesama import AnimeSamaDownloader
from .animeultime import AnimeUltimeDownloader
from .nekosama import NekoSamaDownloader
from .vostfree import VostfreeDownloader
from .vidmoly import VidMolyDownloader
from .sendvid import SendVidDownloader
def get_downloader(url: str) -> BaseDownloader:
"""Factory function to get the appropriate downloader for a URL"""
downloaders = [
# Anime sites
AnimeSamaDownloader(),
AnimeUltimeDownloader(),
NekoSamaDownloader(),
VostfreeDownloader(),
# File hosts
UnFichierDownloader(),
UptoboxDownloader(),
DoodStreamDownloader(),
RapidFileDownloader(),
VidMolyDownloader(),
SendVidDownloader(),
]
for downloader in downloaders:
if downloader.can_handle(url):
return downloader
# Return generic downloader if no match
return GenericDownloader()
class GenericDownloader(BaseDownloader):
"""Generic downloader for unhandled hosts"""
def can_handle(self, url: str) -> bool:
return True
async def get_download_link(self, url: str) -> tuple[str, str]:
# Just return the URL as-is
filename = url.split('/')[-1] or "download"
return url, filename
+686
View File
@@ -0,0 +1,686 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
from urllib.parse import urljoin, unquote
class AnimeSamaDownloader(BaseDownloader):
"""Downloader for anime-sama.org / anime-sama.store"""
# Static list of known domains (will be updated dynamically)
BASE_DOMAINS = ["anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"]
@classmethod
async def get_current_domain(cls) -> str:
"""
Fetch the current active domain from anime-sama.pw
Returns the current domain (e.g., 'anime-sama.si')
"""
try:
import httpx
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get("https://anime-sama.pw")
# Look for the main link in the HTML
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, 'lxml')
# Look for the primary button/link
primary_link = soup.find('a', class_='btn-primary')
if primary_link and primary_link.get('href'):
href = primary_link['href']
# Extract domain from URL
from urllib.parse import urlparse
parsed = urlparse(href)
domain = parsed.netloc # e.g., 'anime-sama.si'
print(f"[ANIME-SAMA] Current domain from anime-sama.pw: {domain}")
return domain
# Fallback: look for any anime-sama.* link
for link in soup.find_all('a', href=True):
href = link['href']
if 'anime-sama.' in href and href.startswith('https://'):
from urllib.parse import urlparse
parsed = urlparse(href)
domain = parsed.netloc
if domain not in ['anime-sama.pw', 'www.anime-sama.pw']:
print(f"[ANIME-SAMA] Found domain via fallback: {domain}")
return domain
print("[ANIME-SAMA] Could not determine current domain, using default")
return "anime-sama.si"
except Exception as e:
print(f"[ANIME-SAMA] Error fetching current domain: {e}")
return "anime-sama.si"
@classmethod
async def update_domains(cls) -> None:
"""
Update the BASE_DOMAINS list with the current active domain
This should be called periodically to keep up with domain changes
"""
try:
current_domain = await cls.get_current_domain()
# Add the current domain and its www variant if not already present
domains_to_add = [current_domain]
if not current_domain.startswith('www.'):
domains_to_add.append(f'www.{current_domain}')
for domain in domains_to_add:
if domain not in cls.BASE_DOMAINS:
# Insert at the beginning for priority
cls.BASE_DOMAINS.insert(0, domain)
print(f"[ANIME-SAMA] Added new domain: {domain}")
except Exception as e:
print(f"[ANIME-SAMA] Error updating domains: {e}")
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""
Extract download link from anime-sama URL
Anime-Sama uses third-party video hosts (vidmoly, etc.)
We'll try to extract the video URL from these hosts
"""
try:
print(f"[ANIME-SAMA] Extracting link from: {url}")
# Check if URL contains the anime page context (format: video_url|anime_page_url|episode_title?)
if '|' in url:
parts = url.split('|')
video_url = parts[0]
anime_page_url = parts[1] if len(parts) > 1 else None
episode_title = parts[2] if len(parts) > 2 else None
print(f"[ANIME-SAMA] Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}")
# Extract video from the host URL with anime context for filename
if 'vidmoly.to' in video_url or 'vidmoly' in video_url:
return await self._extract_from_vidmoly(video_url, anime_page_url, episode_title)
elif 'sendvid.com' in video_url:
return await self._extract_from_sendvid(video_url, anime_page_url, episode_title)
else:
# Try to extract from other hosts
if episode_title:
filename = f"{self._generate_anime_name(anime_page_url)} - {episode_title}.mp4"
else:
filename = self._generate_filename_from_anime_url(anime_page_url)
return video_url, filename
# Check if this is a third-party host URL
if 'vidmoly.to' in url or 'vidmoly' in url:
return await self._extract_from_vidmoly(url)
# If it's an anime-sama page, try to find the video
if 'anime-sama' in url.lower():
response = await self.client.get(url, follow_redirects=True)
final_url = str(response.url)
soup = BeautifulSoup(response.text, 'lxml')
# Look for iframe with video player
iframes = soup.find_all('iframe')
for iframe in iframes:
src = iframe.get('src', '')
if src and any(provider in src for provider in ['vidmoly', 'player', 'stream', 'play', 'embed']):
if src.startswith('http'):
print(f"[ANIME-SAMA] Found iframe: {src}")
# Try to extract video from the player
video_url = await self._extract_from_player(src)
if video_url:
filename = self._generate_filename(final_url)
return video_url, filename
# Look for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src', '')
if src:
if not src.startswith('http'):
src = urljoin(final_url, src)
filename = self._generate_filename(final_url)
return src, filename
sources = video.find_all('source')
for source in sources:
src = source.get('src', '')
if src:
if not src.startswith('http'):
src = urljoin(final_url, src)
filename = self._generate_filename(final_url)
return src, filename
raise Exception("Could not find video link on page")
except Exception as e:
raise Exception(f"Error extracting AnimeSama link: {str(e)}")
async def _extract_from_vidmoly(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]:
"""Extract video URL from vidmoly player - delegate to VidMolyDownloader"""
try:
print(f"[ANIME-SAMA] Extracting from vidmoly: {url}")
print(f"[ANIME-SAMA] Delegating to VidMolyDownloader...")
# Import VidMolyDownloader
from .vidmoly import VidMolyDownloader
# Generate the target filename first
if episode_title and anime_page_url:
anime_name = self._generate_anime_name(anime_page_url)
target_filename = f"{anime_name} - {episode_title}.mp4"
print(f"[ANIME-SAMA] Generated filename: {target_filename} (episode: {episode_title})")
elif anime_page_url:
target_filename = self._generate_filename_from_anime_url(anime_page_url)
print(f"[ANIME-SAMA] Generated filename: {target_filename} (no episode title)")
else:
target_filename = None
print(f"[ANIME-SAMA] No target_filename generated")
# Use VidMolyDownloader to extract and download
vidmoly_downloader = VidMolyDownloader()
# Pass the target filename to VidMolyDownloader if available
if target_filename:
video_url, temp_filename = await vidmoly_downloader.get_download_link(url, target_filename=target_filename)
else:
video_url, temp_filename = await vidmoly_downloader.get_download_link(url)
# Use the target filename
filename = target_filename if target_filename else temp_filename
print(f"[ANIME-SAMA] Got video: {filename}")
# Rename the file if needed
import os
if temp_filename != filename:
# temp_filename might be a full path or just the name
temp_path = temp_filename if os.path.isabs(temp_filename) else os.path.join('downloads', temp_filename)
if os.path.exists(temp_path):
final_path = os.path.join('downloads', filename)
if os.path.exists(final_path):
os.remove(final_path)
os.rename(temp_path, final_path)
print(f"[ANIME-SAMA] Renamed {temp_filename} -> {filename}")
else:
print(f"[ANIME-SAMA] Warning: temp file not found: {temp_path}")
# Return the original VidMoly URL - the file exists so download_manager will skip it
return url, filename
except Exception as e:
print(f"[ANIME-SAMA] Vidmoly extraction error: {e}")
raise Exception(f"Error extracting from vidmoly: {str(e)}")
async def _extract_from_sendvid(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]:
"""Extract video URL from sendvid player - delegate to SendVidDownloader"""
try:
print(f"[ANIME-SAMA] Extracting from sendvid: {url}")
print(f"[ANIME-SAMA] Delegating to SendVidDownloader...")
# Import SendVidDownloader
from .sendvid import SendVidDownloader
# Generate the target filename first
if episode_title and anime_page_url:
anime_name = self._generate_anime_name(anime_page_url)
target_filename = f"{anime_name} - {episode_title}.mp4"
print(f"[ANIME-SAMA] Generated filename: {target_filename} (episode: {episode_title})")
elif anime_page_url:
target_filename = self._generate_filename_from_anime_url(anime_page_url)
print(f"[ANIME-SAMA] Generated filename: {target_filename} (no episode title)")
else:
target_filename = None
print(f"[ANIME-SAMA] No target_filename generated")
# Use SendVidDownloader to extract the video URL
sendvid_downloader = SendVidDownloader()
# Pass the target filename to SendVidDownloader if available
if target_filename:
video_url, filename = await sendvid_downloader.get_download_link(url, target_filename=target_filename)
else:
video_url, filename = await sendvid_downloader.get_download_link(url)
# Use the target filename
filename = target_filename if target_filename else filename
print(f"[ANIME-SAMA] Got video: {filename}")
# Return the direct video URL (SendVid provides direct MP4 links)
# The download_manager will handle the actual download
return video_url, filename
except Exception as e:
print(f"[ANIME-SAMA] SendVid extraction error: {e}")
raise Exception(f"Error extracting from sendvid: {str(e)}")
def _generate_filename_from_anime_url(self, anime_url: str) -> str:
"""Generate filename from anime-sama anime page URL"""
try:
# Extract anime name from URL like: https://anime-sama.si/catalogue/naruto/saison1/vostfr/
# Format: /catalogue/{anime}/saison{N}/{lang}/
parts = anime_url.split('/')
for i, part in enumerate(parts):
if part == 'catalogue' and i + 1 < len(parts):
anime_name = parts[i + 1].replace('-', ' ').title()
# Try to find episode number
episode = "01"
for j, part2 in enumerate(parts):
if 'saison' in part2 and j + 2 < len(parts):
# Look for episode in the remaining path
pass
return f"{anime_name} - Episode {episode}.mp4"
# Fallback
return "Anime - Episode 01.Mp4"
except:
return "Anime - Episode 01.Mp4"
def _generate_anime_name(self, anime_url: str) -> str:
"""Extract just the anime name from anime-sama URL"""
try:
# Extract anime name from URL like: https://anime-sama.si/catalogue/naruto/saison1/vostfr/
parts = anime_url.split('/')
for i, part in enumerate(parts):
if part == 'catalogue' and i + 1 < len(parts):
return parts[i + 1].replace('-', ' ').title()
# Fallback
return "Anime"
except:
return "Anime"
async def _extract_from_player(self, player_url: str) -> str | None:
"""Try to extract direct video URL from player iframe"""
try:
response = await self.client.get(player_url)
soup = BeautifulSoup(response.text, 'lxml')
# Check for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src') or video.get('data-src')
if src:
return src
# Check for source tags
sources = soup.find_all('source')
for source in sources:
src = source.get('src')
if src and any(ext in src for ext in ['mp4', 'm3u8', 'mkv']):
return src
# Check scripts in player page
scripts = soup.find_all('script')
for script in scripts:
if script.string:
match = re.search(r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)', script.string)
if match:
return match.group(1)
except:
pass
return None
def _generate_filename(self, url: str) -> str:
"""Generate filename from URL"""
# Extract anime name and episode info from URL
# URL format: .../catalogue/{anime}/saison{N}/{vostfr|vf}/episode-{N}
parts = url.split('/')
anime_name = "anime"
episode = "1"
for i, part in enumerate(parts):
if part == 'catalogue' and i + 1 < len(parts):
anime_name = parts[i + 1].replace('-', ' ')
elif 'episode-' in part:
episode = part.replace('episode-', '')
elif part in ['vostfr', 'vf']:
lang = part.upper()
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def get_anime_metadata(self, anime_url: str) -> dict:
"""
Extract rich metadata from anime page
Returns synopsis, genres, rating, release year, studio, etc.
"""
try:
print(f"[ANIME-SAMA] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
metadata = {
'synopsis': None,
'genres': [],
'rating': None,
'release_year': None,
'studio': None,
'poster_image': None,
'banner_image': None,
'total_episodes': None,
'status': None,
'alternative_titles': []
}
# Extract synopsis
# Anime-Sama typically has synopsis in a div with specific classes
synopsis_selectors = [
'div.synopsis',
'div.description',
'div[class*="synopsis"]',
'div[class*="description"]',
'p.synopsis',
'div.texte',
'.asn-synopsis'
]
for selector in synopsis_selectors:
synopsis_elem = soup.select_one(selector)
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50: # Ensure it's actual content
metadata['synopsis'] = synopsis
break
# Extract genres
# Look for genre tags/links
genre_patterns = [
r'Genre?\s*:?\s*([^\n]+)',
r'Type?\s*:?\s*([^\n]+)',
]
# Try to find genre links
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
if genre_links:
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
# Also try to find genres in text
page_text = soup.get_text()
for pattern in genre_patterns:
match = re.search(pattern, page_text, re.IGNORECASE)
if match:
genres_text = match.group(1)
# Split by common separators
genres = [g.strip() for g in re.split(r'[,;/|]', genres_text)]
genres = [g for g in genres if g and len(g) > 2]
if genres:
metadata['genres'].extend(genres)
break
# Remove duplicates
metadata['genres'] = list(set(metadata['genres']))
# Extract rating
rating_selectors = [
'span.rating',
'div.rating',
'span.score',
'div[class*="rating"]',
'div[class*="score"]',
'.asn-rating'
]
for selector in rating_selectors:
rating_elem = soup.select_one(selector)
if rating_elem:
rating_text = rating_elem.get_text(strip=True)
# Look for rating patterns like "8.5/10", "4/5", "★★★★☆"
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
if rating_match:
metadata['rating'] = f"{rating_match.group(1)}/10"
break
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text)
if rating_match:
rating_val = float(rating_match.group(1)) * 2 # Convert to /10
metadata['rating'] = f"{rating_val:.1f}/10"
break
# Extract release year
year_patterns = [
r'(\d{4})',
r'Année?\s*:?\s*(\d{4})',
r'Year?\s*:?\s*(\d{4})',
r'Sortie?\s*:?\s*(\d{4})',
]
for pattern in year_patterns:
matches = re.findall(pattern, page_text)
# Filter valid years (between 1950 and current year + 2)
import datetime
current_year = datetime.datetime.now().year + 2
valid_years = [int(m) for m in matches if 1950 <= int(m) <= current_year]
if valid_years:
# Take the most common year (likely the release year)
from collections import Counter
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
break
# Extract studio
studio_patterns = [
r'Studio\s*:?\s*([^\n,]+)',
r'Produit\s*par\s*:?\s*([^\n,]+)',
r'Animation\s*:?\s*([^\n,]+)',
]
for pattern in studio_patterns:
match = re.search(pattern, page_text, re.IGNORECASE)
if match:
studio = match.group(1).strip()
if len(studio) > 2 and len(studio) < 100:
metadata['studio'] = studio
break
# Extract poster image
poster_elem = soup.select_one('img.poster, img.cover, img[class*="poster"], img[class*="cover"], .asn-poster img')
if poster_elem:
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
# Extract banner image
banner_elem = soup.select_one('div.banner img, .asn-banner img, img[class*="banner"]')
if banner_elem:
metadata['banner_image'] = banner_elem.get('src') or banner_elem.get('data-src')
# Extract total episodes
episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0:
metadata['total_episodes'] = episodes_count
# Extract status (ongoing/completed)
status_patterns = [
r'En\s*cours',
r'Ongoing',
r'Terminé',
r'Completed',
r'Finished',
]
for pattern in status_patterns:
if re.search(pattern, page_text, re.IGNORECASE):
if 'cour' in pattern.lower() or 'ongoing' in pattern.lower():
metadata['status'] = 'Ongoing'
else:
metadata['status'] = 'Completed'
break
print(f"[ANIME-SAMA] Extracted metadata: {metadata}")
return metadata
except Exception as e:
print(f"[ANIME-SAMA] Error extracting metadata: {e}")
import traceback
traceback.print_exc()
return {}
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
"""
Search for anime on anime-sama
Returns list of anime with title, url, and cover image
Uses the official Anime-Sama search API which handles typos and fuzzy matching
Args:
query: Search query string
lang: Language preference (vostfr, vf)
include_metadata: Whether to fetch full metadata for each result (slower)
"""
try:
# Update domains before searching to ensure we have the current domain
await self.update_domains()
import time
from html import unescape
start = time.time()
print(f"[ANIME-SAMA] Searching for '{query}' ({lang})...")
# Use the current domain from anime-sama.pw
current_domain = await self.get_current_domain()
# Use the official search API endpoint
search_api_url = f"https://{current_domain}/template-php/defaut/fetch.php"
# Make POST request to search API
response = await self.client.post(
search_api_url,
data={'query': query},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
elapsed = time.time() - start
print(f"[ANIME-SAMA] Got search response in {elapsed:.2f}s")
if response.status_code == 200 and response.text.strip():
# Parse HTML results
soup = BeautifulSoup(response.text, 'lxml')
results = []
# Extract all search result links
for link in soup.find_all('a', class_='asn-search-result'):
href = link.get('href', '')
title_elem = link.find('h3', class_='asn-search-result-title')
img_elem = link.find('img', class_='asn-search-result-img')
title = unescape(title_elem.get_text()) if title_elem else "Unknown"
cover_image = img_elem.get('src', '') if img_elem else None
# Add language parameter to URL
if '/saison1/' not in href:
href = href.rstrip('/') + f'/saison1/{lang}/'
result = {
'title': title,
'url': href,
'cover_image': cover_image,
'type': 'search_result',
'metadata': None
}
# Fetch metadata if requested
if include_metadata:
metadata = await self.get_anime_metadata(href)
result['metadata'] = metadata
results.append(result)
print(f"[ANIME-SAMA] Found {len(results)} results")
return results
print(f"[ANIME-SAMA] No results found")
return []
except Exception as e:
print(f"[ANIME-SAMA] Search error: {str(e)}")
import traceback
traceback.print_exc()
return []
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
"""
Get list of episodes for an anime
Returns list of episode numbers and their URLs
Anime-Sama uses a JavaScript file (episodes.js) to store episode URLs
"""
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
episodes = []
# Try to find the episodes.js file in the HTML
episodes_js_match = re.search(r'episodes\.js\?filever=(\d+)', response.text)
if episodes_js_match:
file_ver = episodes_js_match.group(1)
# Build the URL to episodes.js
episodes_js_url = f"{anime_url.rstrip('/')}/episodes.js?filever={file_ver}"
print(f"[ANIME-SAMA] Found episodes.js at {episodes_js_url}")
try:
# Fetch the episodes.js file
js_response = await self.client.get(episodes_js_url)
js_content = js_response.text
# Parse the JavaScript file to extract episode URLs
# The file contains arrays like: var eps1 = ['url1', 'url2', ...]
eps_matches = re.findall(r'var\s+eps\d+\s*=\s*(\[[^\]]+\])', js_content)
if eps_matches:
# Extract URLs from the first array found
urls_text = eps_matches[0]
# Parse the array of URLs
episode_urls = re.findall(r"'(https?://[^']+)'", urls_text)
for idx, url in enumerate(episode_urls, start=1):
episode_num = str(idx).zfill(2)
episode_title = f'Episode {episode_num}'
# Store both the video URL, the anime page URL, and the episode title
# Format: video_url|anime_page_url|episode_title
combined_url = f"{url}|{anime_url}|{episode_title}"
episodes.append({
'episode': episode_num,
'url': combined_url,
'title': episode_title
})
print(f"[ANIME-SAMA] Found {len(episodes)} episodes")
return episodes
except Exception as e:
print(f"[ANIME-SAMA] Error fetching episodes.js: {e}")
# Fallback: Try to find episode links in the HTML (old method)
episode_links = soup.find_all('a', href=True)
for link in episode_links:
href = link['href']
if 'episode-' in href:
# Extract episode number
match = re.search(r'episode-(\d+)', href)
if match:
episode_num = match.group(1)
full_url = urljoin(anime_url, href)
episodes.append({
'episode': episode_num,
'url': full_url
})
# Remove duplicates and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
return unique_episodes
except Exception as e:
print(f"[ANIME-SAMA] Error getting episodes: {e}")
return []
+435
View File
@@ -0,0 +1,435 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
from urllib.parse import urljoin
class AnimeUltimeDownloader(BaseDownloader):
"""Downloader for anime-ultime.net"""
BASE_DOMAINS = ["anime-ultime.com", "anime-ultime.net", "www.anime-ultime.net"]
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""
Extract download link from anime-ultime URL
Anime-Ultime stores video links in og:video meta tags
"""
try:
# Follow redirects
response = await self.client.get(url, follow_redirects=True)
final_url = str(response.url)
# Parse the page
soup = BeautifulSoup(response.text, 'lxml')
# Method 0: Look for og:video meta tag (most reliable for anime-ultime)
og_video = soup.find('meta', property='og:video')
if og_video and og_video.get('content'):
video_url = og_video['content']
if video_url.endswith('.mp4'):
filename = self._generate_filename(final_url)
print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
return video_url, filename
# Method 1: Look for direct download links (DDL)
# Anime-Ultime often uses links to file hosts
download_links = soup.find_all('a', href=True)
for link in download_links:
href = link['href']
text = link.get_text().lower()
# Look for download buttons/links
if any(keyword in text for keyword in ['télécharger', 'download', 'ddl', 'mega', 'google', 'drive']):
# Check if it's a direct link or to a file host
if any(host in href.lower() for host in ['mega.nz', 'drive.google.com', 'uptobox.com', '1fichier.com']):
filename = self._generate_filename(final_url)
return href, filename
# Method 2: Look for iframe with video player
iframes = soup.find_all('iframe')
for iframe in iframes:
src = iframe.get('src', '')
if src and any(provider in src for provider in ['video', 'player', 'stream', 'play']):
if src.startswith('http'):
filename = self._generate_filename(final_url)
return src, filename
# Method 3: Look for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src', '')
if src:
filename = self._generate_filename(final_url)
return src, filename
# Check source tags
sources = video.find_all('source')
for source in sources:
src = source.get('src', '')
if src:
filename = self._generate_filename(final_url)
return src, filename
# Method 4: Look in scripts for video URLs
scripts = soup.find_all('script')
for script in scripts:
if script.string:
# Look for common video patterns
patterns = [
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8|mkv)(?:\?[^"\'>\s]*)?)',
r'"url":"([^"]+)"',
r'"video":"([^"]+)"',
r'"file":"([^"]+)"',
r'file:\s*"([^"]+)"',
]
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
# Clean up escaped characters
match = match.replace('\\/', '/').replace('\\', '')
if any(ext in match for ext in ['mp4', 'm3u8', 'mkv']):
filename = self._generate_filename(final_url)
return match, filename
# Look for anime-ultime specific patterns
# They sometimes store links in JavaScript variables
ddl_match = re.search(r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string)
if ddl_match:
ddl_url = ddl_match.group(1)
if ddl_url.startswith('http'):
filename = self._generate_filename(final_url)
return ddl_url, filename
# Method 5: Look for links with specific classes or IDs
# Anime-Ultime might use specific class names for download links
potential_links = soup.find_all('a', class_=re.compile(r'download|ddl|episode', re.I))
for link in potential_links:
href = link.get('href', '')
if href and href.startswith('http'):
filename = self._generate_filename(final_url)
return href, filename
# If nothing found, raise error
raise Exception("Could not find download link on page")
except Exception as e:
raise Exception(f"Error extracting Anime-Ultime link: {str(e)}")
def _generate_filename(self, url: str) -> str:
"""Generate filename from URL"""
# Extract anime name and episode from URL
# URL formats:
# - info-0-1/30200
# - info-0-1/30200/Naruto-OAV-01-vostfr
# - file-0-1/2991-Naruto-OAV
anime_name = "Anime"
episode = "01"
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr
if 'info-0-1/' in url:
# Extract episode ID
ep_match = re.search(r'info-0-1/(\d+)', url)
if ep_match:
ep_id = ep_match.group(1)
# Try to get anime name from URL path
name_match = re.search(r'info-0-1/\d+/([^/]+)', url)
if name_match:
raw_name = name_match.group(1)
# Extract episode number
ep_num_match = re.search(r'-(\d+)-vostfr$', raw_name, re.I)
if ep_num_match:
episode = ep_num_match.group(1).zfill(2)
# Remove episode number and suffix from name
anime_name = re.sub(r'-\d+-vostfr$', '', raw_name, flags=re.I).replace('-', ' ')
else:
# Just use the ID
anime_name = f"Episode {ep_id}"
else:
anime_name = f"Episode {ep_id}"
elif 'file-0-1/' in url:
# Extract from file-0-1/ID-NAME format
file_match = re.search(r'file-0-1/\d+-(.+)$', url)
if file_match:
anime_name = file_match.group(1).replace('-', ' ')
# Sanitize filename
anime_name = anime_name.replace('/', ' ').strip()
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def get_anime_metadata(self, anime_url: str) -> dict:
"""
Extract rich metadata from anime page
Returns synopsis, genres, rating, release year, studio, etc.
"""
try:
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
metadata = {
'synopsis': None,
'genres': [],
'rating': None,
'release_year': None,
'studio': None,
'poster_image': None,
'banner_image': None,
'total_episodes': None,
'status': None,
'alternative_titles': []
}
# Extract synopsis
synopsis_selectors = [
'div.synopsis',
'div.description',
'div[class*="synopsis"]',
'div[class*="synopsis"]',
'p.synopsis',
'.info',
'div.texte'
]
for selector in synopsis_selectors:
synopsis_elem = soup.select_one(selector)
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50:
metadata['synopsis'] = synopsis
break
# Extract genres from meta tags and page content
page_text = soup.get_text()
# Look for genre in meta tags
genre_meta = soup.find('meta', property='genre') or soup.find('meta', attrs={'name': 'genre'})
if genre_meta:
genres_text = genre_meta.get('content', '')
if genres_text:
metadata['genres'] = [g.strip() for g in genres_text.split(',')]
# Try to find genre links
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type|cat', re.I))
if genre_links:
for link in genre_links[:5]:
genre = link.get_text(strip=True)
if genre and genre not in metadata['genres']:
metadata['genres'].append(genre)
# Extract rating
rating_selectors = [
'span.rating',
'div.rating',
'span.score',
'div.note',
'.rating'
]
for selector in rating_selectors:
rating_elem = soup.select_one(selector)
if rating_elem:
rating_text = rating_elem.get_text(strip=True)
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
if rating_match:
metadata['rating'] = f"{rating_match.group(1)}/10"
break
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*5', rating_text)
if rating_match:
rating_val = float(rating_match.group(1)) * 2
metadata['rating'] = f"{rating_val:.1f}/10"
break
# Extract release year
year_match = re.search(r'\b(19\d{2}|20\d{2})\b', page_text)
if year_match:
import datetime
current_year = datetime.datetime.now().year + 2
year = int(year_match.group(1))
if 1950 <= year <= current_year:
metadata['release_year'] = year
# Extract poster image from og:image
og_image = soup.find('meta', property='og:image')
if og_image:
metadata['poster_image'] = og_image.get('content')
# Extract total episodes
episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0:
metadata['total_episodes'] = episodes_count
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
return metadata
except Exception as e:
print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
return {}
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
"""
Search for anime on anime-ultime
Returns list of anime with title, url, and cover image
Args:
query: Search query string
lang: Language preference (vostfr, vf)
include_metadata: Whether to fetch full metadata for each result (slower)
"""
try:
import time
start = time.time()
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
# Anime-Ultime uses POST for search
search_url = "https://www.anime-ultime.net/search-0-1"
response = await self.client.post(search_url, data={'search': query})
soup = BeautifulSoup(response.text, 'lxml')
elapsed = time.time() - start
print(f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s")
results = []
# Look for search result links - better parsing
# Search results use file-0-1/ pattern, not info-
search_results = soup.find_all('a', href=re.compile(r'file-0-1/'))
seen_urls = set()
for result in search_results[:10]: # Limit to 10 results
href = result.get('href', '')
raw_title = result.get_text().strip()
# Skip if no href
if not href:
continue
# Skip duplicates
if href in seen_urls:
continue
seen_urls.add(href)
# Extract better title from URL or parent elements
better_title = raw_title
# If raw_title is just "Télécharger" or similar, try to find better title
if len(raw_title) < 5 or raw_title.lower() in ['télécharger', 'download', 'ddl']:
# Try to extract from URL (file-0-1/ID-Title format)
url_match = re.search(r'file-0-1/\d+-(.+)$', href)
if url_match:
better_title = url_match.group(1).replace('-', ' ').title()
# If still no good title, look at parent/row elements
if len(better_title) < 5:
# Check parent row (table structure)
row = result.find_parent(['tr', 'td', 'div'])
if row:
# Look for text in the row that's not the link text
row_text = row.get_text().strip()
# Remove the link text from row text
if raw_title in row_text:
row_text = row_text.replace(raw_title, '').strip()
if len(row_text) > 5 and len(row_text) < 100:
better_title = row_text
# Make URL absolute
if not href.startswith('http'):
href = urljoin("https://www.anime-ultime.net/", href)
result_item = {
'title': better_title,
'url': href,
'type': 'search_result',
'metadata': None
}
# Fetch metadata if requested
if include_metadata:
metadata = await self.get_anime_metadata(href)
result_item['metadata'] = metadata
results.append(result_item)
print(f"[ANIME-ULTIME] Found {len(results)} results")
return results
except Exception as e:
print(f"[ANIME-ULTIME] Error: {e}")
return []
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
"""
Get list of episodes for an anime
Returns list of episode numbers and their URLs
"""
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
episodes = []
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format
# The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number
episode_links = soup.find_all('a', href=re.compile(r'info-0-1/\d+'))
for link in episode_links:
href = link.get('href', '')
text = link.get_text().strip()
# Extract episode number from URL pattern
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr
match = re.search(r'-(\d+)-vostfr$', href, re.I)
if not match:
# Try other patterns
match = re.search(r'Episode[-\s]?(\d+)', href, re.I)
if not match:
# Try to extract from text
match = re.search(r'(\d+)', text)
if match:
episode_num = match.group(1).zfill(2) # Pad with zero
# Extract the episode ID from href and build correct URL
# href might be "info-0-1/30200" or "info-0-1/30200/..."
# We need: https://www.anime-ultime.net/info-0-1/30200
ep_id_match = re.search(r'info-0-1/(\d+)', href)
if ep_id_match:
ep_id = ep_id_match.group(1)
# Build the correct episode URL
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
else:
# Fallback to making URL absolute
if not href.startswith('http'):
href = urljoin(anime_url, href)
episode_url = href
episodes.append({
'episode': episode_num,
'url': episode_url,
'title': text
})
# Remove duplicates and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
return unique_episodes
except Exception as e:
print(f"Error getting episodes: {e}")
return []
+54
View File
@@ -0,0 +1,54 @@
from abc import ABC, abstractmethod
from typing import Optional, Tuple
import httpx
import re
from bs4 import BeautifulSoup
class BaseDownloader(ABC):
"""Base class for all host downloaders"""
def __init__(self):
self.client = httpx.AsyncClient(timeout=10.0, follow_redirects=True)
@abstractmethod
async def get_download_link(self, url: str) -> Tuple[str, str]:
"""
Extract direct download link and filename from host URL
Returns: (download_url, filename)
"""
pass
@abstractmethod
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
pass
async def close(self):
await self.client.aclose()
async def _fetch_page(self, url: str) -> str:
response = await self.client.get(url)
response.raise_for_status()
return response.text
def _extract_filename_from_headers(self, headers: dict) -> Optional[str]:
content_disposition = headers.get("content-disposition", "")
if "filename=" in content_disposition:
filename = content_disposition.split("filename=")[-1].strip('"')
return filename
return None
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
"""
Search for anime on this provider
Returns list of anime with title, url, and optional cover image
"""
return []
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
"""
Get list of episodes for an anime
Returns list of episode numbers and their URLs
"""
return []
+79
View File
@@ -0,0 +1,79 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
class DoodStreamDownloader(BaseDownloader):
"""Downloader for doodstream.com"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["doodstream.com", "dood.stream", "dood.to", "dood.lol", "dood.cx", "dood.so", "dood.watch", "dood.sh"])
async def get_download_link(self, url: str) -> tuple[str, str]:
try:
# Get the page
response = await self.client.get(url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'lxml')
# Doodstream usually has the video URL in a script with '$(function)'
# or in a token-based system
download_url = None
filename = "doodstream_video.mp4"
# Method 1: Look for /pass_md5 or similar patterns
scripts = soup.find_all('script')
for script in scripts:
if script.string:
# Look for token patterns
match = re.search(r'https?://[^\"\']+\.(?:mp4|mkv|avi)', script.string)
if match:
download_url = match.group(0)
break
# Look for doodstream CDN patterns
match = re.search(r'(https?://[^\s\"\'<>]+/download/[^\s\"\'<>]+)', script.string)
if match:
download_url = match.group(0)
break
# Method 2: Try to construct download URL from page
if not download_url:
# Extract video ID from URL
# Format: https://doodstream.com/e/VIDEO_ID or /d/VIDEO_ID
video_id_match = re.search(r'/[ed]/([a-zA-Z0-9]+)', url)
if video_id_match:
video_id = video_id_match.group(1)
# Try direct download pattern
download_url = f"https://dood.stream/e/{video_id}"
# Method 3: Look for any MP4 source in iframes or video tags
if not download_url:
video = soup.find('video')
if video and video.get('src'):
download_url = video['src']
else:
sources = soup.find_all('source')
for source in sources:
if source.get('src'):
download_url = source['src']
filename = source.get('src', '').split('/')[-1]
break
if download_url:
# Try to get real filename from HEAD request
try:
head_resp = await self.client.head(download_url, timeout=5.0)
fname = self._extract_filename_from_headers(head_resp.headers)
if fname:
filename = fname
except:
pass
return download_url, filename
raise Exception("Could not extract download link from Doodstream page")
except Exception as e:
raise Exception(f"Error extracting Doodstream link: {str(e)}")
+249
View File
@@ -0,0 +1,249 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
from urllib.parse import urljoin
class NekoSamaDownloader(BaseDownloader):
"""Downloader for neko-sama.fr"""
BASE_DOMAINS = ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"]
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""Extract download link from neko-sama URL"""
try:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml')
# Method 1: Look for iframes with video
iframes = soup.find_all('iframe')
for iframe in iframes:
src = iframe.get('src', '')
if src and any(p in src for p in ['video', 'player', 'stream']):
if not src.startswith('http'):
src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url))
return src, filename
# Method 2: Look for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src') or video.get('data-src')
if src:
filename = self._generate_filename(str(response.url))
return src, filename
sources = video.find_all('source')
for source in sources:
src = source.get('src', '')
if src:
filename = self._generate_filename(str(response.url))
return src, filename
# Method 3: Look in scripts
scripts = soup.find_all('script')
for script in scripts:
if script.string:
patterns = [
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)',
r'"url":"([^"]+)"',
r'"video":"([^"]+)"',
]
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
match = match.replace('\\/', '/')
if any(ext in match for ext in ['mp4', 'm3u8']):
filename = self._generate_filename(str(response.url))
return match, filename
raise Exception("Could not find video link")
except Exception as e:
raise Exception(f"Error extracting NekoSama link: {str(e)}")
def _generate_filename(self, url: str) -> str:
parts = url.split('/')
anime_name = "anime"
episode = "1"
for i, part in enumerate(parts):
if 'episode' in part.lower():
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
if match:
episode = match.group(1)
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
episodes = []
episode_links = soup.find_all('a', href=re.compile(r'episode'))
for link in episode_links:
href = link.get('href', '')
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
if match:
episode_num = match.group(1)
if not href.startswith('http'):
href = urljoin(anime_url, href)
episodes.append({'episode': episode_num, 'url': href})
# Deduplicate and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
return unique_episodes
except Exception as e:
return []
async def get_anime_metadata(self, anime_url: str) -> dict:
"""
Extract rich metadata from anime page
Returns synopsis, genres, rating, release year, studio, etc.
"""
try:
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
metadata = {
'synopsis': None,
'genres': [],
'rating': None,
'release_year': None,
'studio': None,
'poster_image': None,
'banner_image': None,
'total_episodes': None,
'status': None,
'alternative_titles': []
}
# Extract synopsis
synopsis_selectors = [
'div.synopsis',
'div.description',
'div[class*="synopsis"]',
'div[class*="desc"]',
'p.synopsis',
'.anime-synopsis',
'.summary'
]
for selector in synopsis_selectors:
synopsis_elem = soup.select_one(selector)
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50:
metadata['synopsis'] = synopsis
break
# Extract genres
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
if genre_links:
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
# Extract rating
rating_selectors = [
'span.rating',
'div.rating',
'span.score',
'div[class*="rating"]',
'div[class*="score"]'
]
for selector in rating_selectors:
rating_elem = soup.select_one(selector)
if rating_elem:
rating_text = rating_elem.get_text(strip=True)
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
if rating_match:
metadata['rating'] = f"{rating_match.group(1)}/10"
break
# Extract release year
page_text = soup.get_text()
year_matches = re.findall(r'\b(19\d{2}|20\d{2})\b', page_text)
if year_matches:
import datetime
current_year = datetime.datetime.now().year + 2
valid_years = [int(y) for y in year_matches if 1950 <= int(y) <= current_year]
if valid_years:
from collections import Counter
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
# Extract poster image
poster_elem = soup.select_one('img.poster, img.cover, .anime-poster img')
if poster_elem:
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
# Extract total episodes
episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0:
metadata['total_episodes'] = episodes_count
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
return metadata
except Exception as e:
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
return {}
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
"""
Search for anime on neko-sama
Args:
query: Search query string
lang: Language preference (vostfr, vf)
include_metadata: Whether to fetch full metadata for each result (slower)
"""
try:
import time
start = time.time()
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
# Neko-Sama URL pattern: https://neko-sama.fr/anime/{anime-name}
search_url = f"https://neko-sama.fr/anime/{query.lower().replace(' ', '-')}"
response = await self.client.get(search_url)
elapsed = time.time() - start
print(f"[NEKO-SAMA] Got response {response.status_code} in {elapsed:.2f}s")
if response.status_code == 200:
print(f"[NEKO-SAMA] Found anime at {str(response.url)}")
result = {
'title': query,
'url': str(response.url),
'type': 'direct',
'metadata': None
}
if include_metadata:
metadata = await self.get_anime_metadata(str(response.url))
result['metadata'] = metadata
return [result]
print(f"[NEKO-SAMA] No anime found")
return []
except Exception as e:
print(f"[NEKO-SAMA] Error: {str(e)}")
return []
+75
View File
@@ -0,0 +1,75 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
class RapidFileDownloader(BaseDownloader):
"""Downloader for rapidfile.net and similar hosts"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["rapidfile.net", "rapidfile.com", "rapid-file"])
async def get_download_link(self, url: str) -> tuple[str, str]:
try:
# Get the initial page
response = await self.client.get(url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'lxml')
download_url = None
filename = "rapidfile_download"
# Method 1: Look for download button/link
download_btn = soup.find('a', {'id': 'downloadbtn'}) or soup.find('a', class_='download-btn')
if download_btn and download_btn.get('href'):
download_url = download_btn['href']
# Method 2: Look for form with POST action
if not download_url:
forms = soup.find_all('form')
for form in forms:
action = form.get('action', '')
if action and ('download' in action.lower() or 'file' in action.lower()):
download_url = action if action.startswith('http') else url + action
break
# Method 3: Look for any link with download/file in URL
if not download_url:
for link in soup.find_all('a', href=True):
href = link['href']
if any(keyword in href.lower() for keyword in ['download', 'get_file', 'file.php']):
if href.startswith('http'):
download_url = href
break
# Method 4: Check for direct file links in scripts
if not download_url:
scripts = soup.find_all('script')
for script in scripts:
if script.string:
match = re.search(r'(https?://[^\s\"\'<>]+/(?:download|file)[^\s\"\'<>]+)', script.string)
if match:
download_url = match.group(0)
break
if download_url:
# Get filename from headers or URL
try:
head_resp = await self.client.head(download_url, timeout=5.0)
fname = self._extract_filename_from_headers(head_resp.headers)
if fname:
filename = fname
else:
filename = download_url.split('/')[-1] or "rapidfile_download"
except:
filename = download_url.split('/')[-1] or "rapidfile_download"
return download_url, filename
# If all else fails, return the original URL
filename = url.split('/')[-1] or "rapidfile_download"
return url, filename
except Exception as e:
raise Exception(f"Error extracting Rapidfile link: {str(e)}")
+83
View File
@@ -0,0 +1,83 @@
from typing import Optional
from bs4 import BeautifulSoup
from .base import BaseDownloader
import re
class SendVidDownloader(BaseDownloader):
"""Downloader for SendVid videos"""
def can_handle(self, url: str) -> bool:
return "sendvid.com" in url.lower()
async def _fetch_page(self, url: str) -> str:
"""Fetch page with proper headers to avoid 403 errors"""
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': 'https://sendvid.com/',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
}
response = await self.client.get(url, headers=headers)
response.raise_for_status()
return response.text
async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]:
"""
Extract direct download link from SendVid embed page
SendVid embed pages contain the direct MP4 URL in a <source> tag
"""
print(f"[SENDVID] Fetching page: {url}")
html = await self._fetch_page(url)
soup = BeautifulSoup(html, 'lxml')
# Try to find the video source in the <source> tag
source_tag = soup.find('source', {'id': 'video_source'})
if source_tag and source_tag.get('src'):
video_url = source_tag['src']
print(f"[SENDVID] Found video URL in <source> tag")
# Generate filename
if target_filename:
filename = target_filename
else:
# Extract filename from video URL or generate one
filename = self._extract_filename_from_url(url, video_url)
print(f"[SENDVID] Download URL: {video_url}")
print(f"[SENDVID] Filename: {filename}")
return video_url, filename
# Fallback: try to find in og:video meta property
og_video = soup.find('meta', {'property': 'og:video'})
if og_video and og_video.get('content'):
video_url = og_video['content']
print(f"[SENDVID] Found video URL in og:video meta")
if target_filename:
filename = target_filename
else:
filename = self._extract_filename_from_url(url, video_url)
print(f"[SENDVID] Download URL: {video_url}")
print(f"[SENDVID] Filename: {filename}")
return video_url, filename
raise Exception("Could not extract video URL from SendVid page")
def _extract_filename_from_url(self, page_url: str, video_url: str) -> str:
"""Generate filename from SendVod URLs"""
# Try to extract video ID from page URL
video_id_match = re.search(r'/embed/([a-z0-9]+)', page_url)
if video_id_match:
video_id = video_id_match.group(1)
# Try to get title from page (might need to fetch, but for now use ID)
return f"sendvid_{video_id}.mp4"
# Fallback: extract from video URL
filename_match = re.search(r'/([^/]+\.mp4)', video_url)
if filename_match:
return filename_match.group(1)
return "sendvid_video.mp4"
+51
View File
@@ -0,0 +1,51 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
class UnFichierDownloader(BaseDownloader):
"""Downloader for 1fichier.com"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["1fichier.com", "1fichier.fr"])
async def get_download_link(self, url: str) -> tuple[str, str]:
try:
# Initial page
response = await self.client.get(url)
response.raise_for_status()
# Check if we need to wait (download button)
soup = BeautifulSoup(response.text, 'lxml')
# Check for direct download link
download_link = soup.find('a', class_='btn btn-download')
if download_link and download_link.get('href'):
download_url = download_link['href']
# Follow to get headers for filename
head_resp = await self.client.head(download_url)
filename = self._extract_filename_from_headers(head_resp.headers)
if not filename:
filename = download_url.split('/')[-1] or "downloaded_file"
return download_url, filename
# Alternative: look for any download link in the page
for link in soup.find_all('a', href=True):
href = link['href']
if href.startswith('http') and '1fichier' not in href:
# Try to head the URL to see if it's a file
try:
head_resp = await self.client.head(href, timeout=5.0)
if 'content-length' in head_resp.headers or 'attachment' in head_resp.headers.get('content-disposition', ''):
filename = self._extract_filename_from_headers(head_resp.headers)
if not filename:
filename = href.split('/')[-1] or "downloaded_file"
return href, filename
except:
continue
raise Exception("Could not find download link on page")
except Exception as e:
raise Exception(f"Error extracting 1fichier link: {str(e)}")
+59
View File
@@ -0,0 +1,59 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
class UptoboxDownloader(BaseDownloader):
"""Downloader for uptobox.com"""
BASE_DOMAINS = ["uptobox.com", "uptobox.fr"]
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""Extract direct download link from uptobox"""
try:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml')
# Method 1: Look for direct download button/link
download_btn = soup.find('a', {'id': 'directDownload'}) or soup.find('a', class_='download-btn')
if download_btn and download_btn.get('href'):
href = download_btn['href']
filename = self._extract_filename_from_url(url) or "uptobox_file"
return href, filename
# Method 2: Look for any download link in page
links = soup.find_all('a', href=True)
for link in links:
href = link['href']
text = link.get_text().lower()
if any(keyword in text for keyword in ['download', 'télécharger', 'ddl']):
if href.startswith('http'):
filename = self._extract_filename_from_url(url) or "uptobox_file"
return href, filename
# Method 3: Return the original URL (uptobox handles downloads directly)
filename = self._extract_filename_from_url(url) or "uptobox_file"
return url, filename
except Exception as e:
raise Exception(f"Error extracting Uptobox link: {str(e)}")
def _extract_filename_from_url(self, url: str) -> str | None:
"""Try to extract filename from URL"""
# Look for filename parameter in URL
match = re.search(r'[&?]filename=([^&]+)', url)
if match:
from urllib.parse import unquote
return unquote(match.group(1))
# Extract from path
parts = url.split('/')
if len(parts) > 0:
last_part = parts[-1]
if '.' in last_part:
return last_part
return None
+439
View File
@@ -0,0 +1,439 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
import subprocess
import os
import tempfile
from pathlib import Path
import asyncio
from typing import Optional
class VidMolyDownloader(BaseDownloader):
"""Downloader for vidmoly.to using Playwright network interception"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["vidmoly.to", "vidmoly.org", "vidmoly.biz"])
async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]:
try:
# Extract VidMoly ID from URL
vidmoly_id = self._extract_vidmoly_id(url)
if not vidmoly_id:
raise Exception("Could not extract VidMoly ID from URL")
# Construct embed URL - try vidmoly.biz first (it works better than .to/.org)
# If original URL uses .biz, keep it. Otherwise try .biz first
domains_to_try = []
if "vidmoly.biz" in url.lower():
domains_to_try = ["vidmoly.biz"]
elif "vidmoly.to" in url.lower() or "vidmoly.org" in url.lower():
# For .to/.org, try .biz first (it has actual content), then original
domains_to_try = ["vidmoly.biz", url.split("//")[1].split("/")[0]]
else:
domains_to_try = ["vidmoly.biz", "vidmoly.to"]
video_source = None
last_error = None
working_domain = None
for domain in domains_to_try:
embed_url = f"https://{domain}/embed-{vidmoly_id}.html"
print(f"[VIDMOLY] Trying: {embed_url}")
# Use Playwright with network interception
video_source = await self._extract_with_playwright_network(embed_url)
if not video_source:
# Fallback to HTTP method
print("[VIDMOLY] Playwright failed, trying HTTP fallback...")
video_source = await self._extract_with_http(embed_url)
if video_source:
print(f"[VIDMOLY] ✅ Found video on {domain}")
working_domain = domain
break
else:
print(f"[VIDMOLY] ❌ No video on {domain}")
last_error = f"No video found on {domain}"
if not video_source:
raise Exception(f"Could not find video source - tried: {', '.join(domains_to_try)}. Last error: {last_error}")
# Use target_filename if provided, otherwise generate default
filename = target_filename if target_filename else f"vidmoly_{vidmoly_id}"
# Check if it's an M3U8 playlist
if '.m3u8' in video_source:
print(f"[VIDMOLY] Found M3U8 source: {video_source[:100]}...")
# Download and convert M3U8 to MP4 directly
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': f'https://{working_domain}/',
}
mp4_path = await self._download_m3u8_as_mp4(video_source, filename, headers)
return mp4_path, filename
# It's a direct MP4 link
if not video_source.endswith('.mp4'):
filename += '.mp4'
print(f"[VIDMOLY] Found MP4 source")
return video_source, filename
except Exception as e:
raise Exception(f"Error extracting VidMoly link: {str(e)}")
async def _extract_with_playwright_network(self, url: str) -> Optional[str]:
"""Extract video source using Playwright with network interception (like DownloadHelper)"""
try:
from playwright.async_api import async_playwright
print("[VIDMOLY] Launching browser with network interception...")
video_urls = []
async with async_playwright() as p:
# Launch browser in headless mode
browser = await p.chromium.launch(
headless=True,
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
viewport={'width': 1920, 'height': 1080}
)
page = await context.new_page()
# Set up request interception BEFORE navigation
async def handle_request(route):
# Capture all requests
req_url = route.request.url
print(f"[VIDMOLY] Request: {req_url[:80]}...")
# Look for video files (m3u8, mp4, etc.)
if any(ext in req_url.lower() for ext in ['.m3u8', '.mp4', '.mkv']):
# Only capture non-vidmoly URLs (the actual video files)
if 'vidmoly' not in req_url.lower():
print(f"[VIDMOLY] 🎥 Captured video URL: {req_url[:100]}...")
video_urls.append(req_url)
# Continue with the request
await route.continue_()
# Enable request interception
await page.route('**', handle_request)
# Also set up response interception to catch redirects
page.on("response", lambda response: None)
print("[VIDMOLY] Navigating to page...")
# Navigate to URL and wait for load
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
except Exception as e:
print(f"[VIDMOLY] Navigation warning: {e}")
# Wait for page to fully load and JavaScript to execute
print("[VIDMOLY] Waiting for video player to load...")
await asyncio.sleep(5)
# Try to find and click play button if exists
try:
# Look for common play button selectors
play_selectors = [
'button.jw-icon-play',
'.jw-play-btn',
'button[aria-label="Play"]',
'.play-button',
'video',
]
for selector in play_selectors:
try:
element = await page.query_selector(selector)
if element:
print(f"[VIDMOLY] Found element: {selector}")
# For video tags, we can just wait
# For buttons, click them
if 'button' in selector or '.jw-' in selector:
await element.click()
await asyncio.sleep(3)
break
except:
continue
except Exception as e:
print(f"[VIDMOLY] Play button interaction: {e}")
# Wait a bit more for network requests to complete
await asyncio.sleep(3)
# Also try JavaScript extraction as backup
try:
js_result = await page.evaluate("""
() => {
// Check all video elements
const videos = document.querySelectorAll('video');
for (let v of videos) {
if (v.src) {
console.log('Found video src:', v.src);
return v.src;
}
const sources = v.querySelectorAll('source');
for (let s of sources) {
if (s.src && (s.src.includes('.m3u8') || s.src.includes('.mp4'))) {
console.log('Found source src:', s.src);
return s.src;
}
}
}
// Check for jwplayer
if (window.jwplayer) {
try {
const player = jwplayer();
const playlist = player.getPlaylist();
if (playlist && playlist[0] && playlist[0].sources) {
const src = playlist[0].sources[0].file;
console.log('Found jwplayer source:', src);
return src;
}
} catch(e) {
console.log('jwplayer error:', e);
}
}
// Check for other player configurations
if (window.player && window.player.config) {
if (window.player.config.sources && window.player.config.sources[0]) {
return window.player.config.sources[0].file;
}
}
// Look in window object for video URLs
for (let key in window) {
if (typeof window[key] === 'string') {
const str = window[key];
if ((str.includes('.m3u8') || str.includes('.mp4')) && str.startsWith('http')) {
return str;
}
}
}
return null;
}
""")
if js_result and ('.m3u8' in js_result or '.mp4' in js_result):
print(f"[VIDMOLY] Found video URL via JavaScript")
video_urls.append(js_result)
except Exception as e:
print(f"[VIDMOLY] JS extraction error: {e}")
# Final check: parse page HTML for video URLs
try:
content = await page.content()
patterns = [
r'"file"\s*:\s*"([^"]+\.m3u8[^"]*)"',
r'"file"\s*:\s*"([^"]+\.mp4[^"]*)"',
r"'file'\s*:\s*'([^']+\.m3u8[^']*)'",
r"'file'\s*:\s*'([^']+\.mp4[^']*)'",
r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)',
r'(https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*)',
]
for pattern in patterns:
matches = re.findall(pattern, content)
for match in matches:
# Clean up the URL
match = match.replace('\\', '').replace('\/', '/')
if 'http' in match and 'vidmoly' not in match:
print(f"[VIDMOLY] Found in HTML: {match[:100]}...")
video_urls.append(match)
except Exception as e:
print(f"[VIDMOLY] HTML parsing error: {e}")
await browser.close()
# Return the first valid video URL found
if video_urls:
# Deduplicate while preserving order
seen = set()
unique_urls = []
for url in video_urls:
if url not in seen:
seen.add(url)
unique_urls.append(url)
if unique_urls:
print(f"[VIDMOLY] ✅ Found {len(unique_urls)} video URL(s)")
return unique_urls[0]
print("[VIDMOLY] ❌ No video URLs found")
return None
except ImportError:
print("[VIDMOLY] Playwright not installed")
return None
except Exception as e:
print(f"[VIDMOLY] Playwright error: {e}")
import traceback
traceback.print_exc()
return None
async def _extract_with_http(self, url: str) -> Optional[str]:
"""Fallback: Extract video source using pure HTTP requests"""
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
'Referer': 'https://vidmoly.to/',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
}
response = await self.client.get(url, headers=headers)
# Follow JS redirect if present
if 'window.location.replace' in response.text:
redirect_match = re.search(r"window\.location\.replace\('([^']+)'", response.text)
if redirect_match:
redirect_url = redirect_match.group(1)
response = await self.client.get(redirect_url, headers=headers, follow_redirects=True)
# Try to find video source
patterns = [
r'file:"([^"]+)"',
r'"file"\s*:\s*"([^"]+)"',
r"'file'\s*:\s*'([^']+)'",
r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)',
r'(https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*)',
]
for pattern in patterns:
matches = re.findall(pattern, response.text)
if matches:
for match in matches:
match = match.replace('\\', '').replace('\/', '/')
if 'http' in match and 'vidmoly' not in match:
return match
return None
except Exception as e:
print(f"[VIDMOLY] HTTP extraction error: {e}")
return None
async def _get_m3u8_qualities(self, master_m3u8_url: str, headers: dict) -> list[dict]:
"""Fetch master M3U8 and extract available qualities"""
try:
response = await self.client.get(master_m3u8_url, headers=headers)
response.raise_for_status()
content = response.text
lines = [line.strip() for line in content.split('\n') if line.strip()]
qualities = []
current_quality = {}
for line in lines:
if line.startswith('#EXT-X-STREAM-INF'):
resolution_match = re.search(r'RESOLUTION=\d+x(\d+)', line)
if resolution_match:
current_quality['label'] = resolution_match.group(1)
elif line.endswith('.m3u8') and current_quality:
current_quality['url'] = line if line.startswith('http') else master_m3u8_url.rsplit('/', 1)[0] + '/' + line
qualities.append(current_quality)
current_quality = {}
qualities.sort(key=lambda x: int(x['label']), reverse=True)
return qualities
except Exception as e:
print(f"Error fetching M3U8 qualities: {e}")
return []
async def _download_m3u8_as_mp4(self, m3u8_url: str, filename: str, headers: dict, download_dir: str = "downloads") -> str:
"""Download M3U8 stream and convert to MP4 using ffmpeg"""
# Create downloads directory if it doesn't exist
os.makedirs(download_dir, exist_ok=True)
output_path = os.path.join(download_dir, filename)
# Build headers for ffmpeg - using multiple -headers options
header_args = []
for key, value in headers.items():
header_args.extend(['-headers', f'{key}: {value}'])
cmd = [
'ffmpeg',
*header_args,
'-i', m3u8_url,
'-c', 'copy',
'-bsf:a', 'aac_adtstoasc',
'-y',
output_path
]
try:
print(f"[VIDMOLY] Downloading M3U8 with ffmpeg...")
print(f"[VIDMOLY] URL: {m3u8_url[:80]}...")
print(f"[VIDMOLY] Output: {output_path}")
# Run ffmpeg without capturing output to avoid buffering issues
# Use a log file instead
log_path = output_path + '.log'
with open(log_path, 'w') as log_file:
result = subprocess.run(
cmd,
stdout=log_file,
stderr=log_file,
timeout=600 # 10 minutes for very long videos
)
# Check if file was created even if ffmpeg had issues
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
if file_size > 1000: # At least 1KB
print(f"[VIDMOLY] ✅ Download complete: {file_size / (1024*1024):.2f} MB")
return output_path
# If we get here, something went wrong
raise Exception(f"FFmpeg failed - no output file created")
except subprocess.TimeoutExpired:
# Check if file was created despite timeout
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
if file_size > 1000: # At least 1KB
print(f"[VIDMOLY] ⚠️ Timeout but file created: {file_size / (1024*1024):.2f} MB")
return output_path
raise Exception("FFmpeg timeout (10 minutes) - video too large")
except FileNotFoundError:
raise Exception("ffmpeg not found - please install ffmpeg: apt install ffmpeg")
except Exception as e:
raise Exception(f"Error downloading M3U8: {str(e)}")
def _extract_vidmoly_id(self, url: str) -> Optional[str]:
"""Extract VidMoly video ID from URL"""
embed_match = re.search(r'embed-([a-z0-9]+)', url, re.IGNORECASE)
if embed_match:
return embed_match.group(1)
param_match = re.search(r'[?&]v=([a-z0-9]+)', url, re.IGNORECASE)
if param_match:
return param_match.group(1)
path_match = re.search(r'vidmoly\.(?:to|org|biz)/([a-z0-9]+)', url, re.IGNORECASE)
if path_match:
return path_match.group(1)
return None
+195
View File
@@ -0,0 +1,195 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
import subprocess
import os
import tempfile
from pathlib import Path
class VidMolyDownloader(BaseDownloader):
"""Downloader for vidmoly.to - Video streaming host with M3U8 to MP4 conversion"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["vidmoly.to", "vidmoly.org"])
async def get_download_link(self, url: str) -> tuple[str, str]:
try:
# Extract VidMoly ID from URL
vidmoly_id = self._extract_vidmoly_id(url)
if not vidmoly_id:
raise Exception("Could not extract VidMoly ID from URL")
# Construct embed URL
embed_url = f"https://vidmoly.to/embed-{vidmoly_id}.html"
# Fetch embed page
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
'Referer': 'https://vidmoly.to/',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
}
response = await self.client.get(embed_url, headers=headers)
response.raise_for_status()
# Check for JavaScript redirect with token
if 'window.location.replace' in response.text:
# Extract the redirect URL with token
redirect_match = re.search(r"window\.location\.replace\('([^']+)'", response.text)
if redirect_match:
redirect_url = redirect_match.group(1)
print(f"[VIDMOLY] Following redirect with token...")
# Follow the redirect WITH follow_redirects to handle 302
response = await self.client.get(redirect_url, headers=headers, follow_redirects=True)
response.raise_for_status()
# Extract video source using regex (like the PHP version)
# Pattern: file:"URL"
sources_match = re.findall(r'file:"([^"]+)"', response.text)
if not sources_match:
raise Exception("Could not find video source in page")
video_source = sources_match[0]
# Check if it's an M3U8 playlist
if 'master.m3u8' in video_source or '.m3u8' in video_source:
# Fetch master playlist to get available qualities
qualities = await self._get_m3u8_qualities(video_source, headers)
if qualities:
# Use highest quality (first one in list)
best_quality_url = qualities[0]['url']
quality_label = qualities[0]['label']
# Convert M3U8 to MP4 using ffmpeg
mp4_path = await self._convert_m3u8_to_mp4(
best_quality_url,
vidmoly_id,
quality_label,
headers
)
return mp4_path, f"vidmoly_{vidmoly_id}_{quality_label}p.mp4"
else:
# Direct M3U8 without quality variants
mp4_path = await self._convert_m3u8_to_mp4(
video_source,
vidmoly_id,
"720",
headers
)
return mp4_path, f"vidmoly_{vidmoly_id}_720p.mp4"
# It's a direct MP4 link
filename = f"vidmoly_{vidmoly_id}.mp4"
if not video_source.endswith('.mp4'):
filename += '.mp4'
return video_source, filename
except Exception as e:
raise Exception(f"Error extracting VidMoly link: {str(e)}")
async def _get_m3u8_qualities(self, master_m3u8_url: str, headers: dict) -> list[dict]:
"""Fetch master M3U8 and extract available qualities"""
try:
response = await self.client.get(master_m3u8_url, headers=headers)
response.raise_for_status()
content = response.text
lines = [line.strip() for line in content.split('\n') if line.strip()]
qualities = []
current_quality = {}
for line in lines:
# Parse quality line (RESOLUTION=...xHEIGHT)
if line.startswith('#EXT-X-STREAM-INF'):
resolution_match = re.search(r'RESOLUTION=\d+x(\d+)', line)
if resolution_match:
current_quality['label'] = resolution_match.group(1)
# Parse URL line
elif line.endswith('.m3u8') and current_quality:
current_quality['url'] = line if line.startswith('http') else master_m3u8_url.rsplit('/', 1)[0] + '/' + line
qualities.append(current_quality)
current_quality = {}
# Sort by resolution (descending)
qualities.sort(key=lambda x: int(x['label']), reverse=True)
return qualities
except Exception as e:
print(f"Error fetching M3U8 qualities: {e}")
return []
async def _convert_m3u8_to_mp4(self, m3u8_url: str, vidmoly_id: str, quality: str, headers: dict) -> str:
"""Convert M3U8 stream to MP4 using ffmpeg"""
# Create temp directory for output
temp_dir = tempfile.gettempdir()
output_path = os.path.join(temp_dir, f"vidmoly_{vidmoly_id}_{quality}p.mp4")
# Prepare ffmpeg headers
ffmpeg_headers = '|'.join([f'{k}: {v}' for k, v in headers.items()])
# Build ffmpeg command
cmd = [
'ffmpeg',
'-headers', f'"{ffmpeg_headers}"',
'-i', m3u8_url,
'-c', 'copy',
'-bsf:a', 'aac_adtstoasc',
'-y', # Overwrite output file if exists
output_path
]
# Execute ffmpeg
try:
result = subprocess.run(
' '.join(cmd),
shell=True,
capture_output=True,
text=True,
timeout=300 # 5 minutes timeout
)
if result.returncode != 0:
raise Exception(f"FFmpeg conversion failed: {result.stderr}")
if not os.path.exists(output_path):
raise Exception("FFmpeg output file not created")
return output_path
except subprocess.TimeoutExpired:
raise Exception("FFmpeg conversion timeout (5 minutes)")
except Exception as e:
raise Exception(f"Error converting M3U8 to MP4: {str(e)}")
def _extract_vidmoly_id(self, url: str) -> str:
"""Extract VidMoly video ID from URL"""
# Patterns:
# - vidmoly.to/embed-ID.html
# - vidmoly.to/?v=ID
# - vidmoly.to/ID
# Try to extract from embed pattern
embed_match = re.search(r'embed-([a-z0-9]+)', url, re.IGNORECASE)
if embed_match:
return embed_match.group(1)
# Try to extract from ?v= parameter
param_match = re.search(r'[?&]v=([a-z0-9]+)', url, re.IGNORECASE)
if param_match:
return param_match.group(1)
# Try to extract ID from path
path_match = re.search(r'vidmoly\.(?:to|org)/([a-z0-9]+)', url, re.IGNORECASE)
if path_match:
return path_match.group(1)
return None
+253
View File
@@ -0,0 +1,253 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
from urllib.parse import urljoin
class VostfreeDownloader(BaseDownloader):
"""Downloader for vostfree.tv"""
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""Extract download link from vostfree URL"""
try:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml')
# Method 1: Look for iframe players
iframes = soup.find_all('iframe')
for iframe in iframes:
src = iframe.get('src', '')
if src and any(p in src for p in ['player', 'video', 'stream']):
if not src.startswith('http'):
src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url))
return src, filename
# Method 2: Look for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src')
if src:
filename = self._generate_filename(str(response.url))
return src, filename
sources = video.find_all('source')
for source in sources:
src = source.get('src', '')
if src and any(ext in src for ext in ['mp4', 'm3u8']):
filename = self._generate_filename(str(response.url))
return src, filename
# Method 3: Look in scripts
scripts = soup.find_all('script')
for script in scripts:
if script.string:
patterns = [
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)',
r'"url":"([^"]+)"',
r'"file":"([^"]+)"',
r'"video":"([^"]+)"',
]
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
match = match.replace('\\/', '/')
if any(ext in match for ext in ['mp4', 'm3u8']):
filename = self._generate_filename(str(response.url))
return match, filename
raise Exception("Could not find video link")
except Exception as e:
raise Exception(f"Error extracting Vostfree link: {str(e)}")
def _generate_filename(self, url: str) -> str:
parts = url.split('/')
anime_name = "anime"
episode = "1"
for part in parts:
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
if match:
episode = match.group(1)
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
episodes = []
episode_links = soup.find_all('a', href=re.compile(r'episode', re.I))
for link in episode_links:
href = link.get('href', '')
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
if match:
episode_num = match.group(1)
if not href.startswith('http'):
href = urljoin(anime_url, href)
episodes.append({'episode': episode_num, 'url': href})
# Deduplicate and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
return unique_episodes
except Exception as e:
return []
async def get_anime_metadata(self, anime_url: str) -> dict:
"""
Extract rich metadata from anime page
Returns synopsis, genres, rating, release year, studio, etc.
"""
try:
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
metadata = {
'synopsis': None,
'genres': [],
'rating': None,
'release_year': None,
'studio': None,
'poster_image': None,
'banner_image': None,
'total_episodes': None,
'status': None,
'alternative_titles': []
}
# Extract synopsis
synopsis_selectors = [
'div.synopsis',
'div.description',
'div[class*="synopsis"]',
'div[class*="desc"]',
'p.synopsis',
'.anime-synopsis'
]
for selector in synopsis_selectors:
synopsis_elem = soup.select_one(selector)
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50:
metadata['synopsis'] = synopsis
break
# Extract genres
genre_links = soup.find_all('a', href=re.compile(r'genre|tag|type', re.I))
if genre_links:
metadata['genres'] = [link.get_text(strip=True) for link in genre_links[:5]]
# Extract rating
rating_selectors = [
'span.rating',
'div.rating',
'span.score',
'div[class*="rating"]',
'div[class*="score"]'
]
for selector in rating_selectors:
rating_elem = soup.select_one(selector)
if rating_elem:
rating_text = rating_elem.get_text(strip=True)
rating_match = re.search(r'(\d+\.?\d*)\s*/\s*10', rating_text)
if rating_match:
metadata['rating'] = f"{rating_match.group(1)}/10"
break
# Extract release year
page_text = soup.get_text()
year_matches = re.findall(r'\b(19\d{2}|20\d{2})\b', page_text)
if year_matches:
import datetime
current_year = datetime.datetime.now().year + 2
valid_years = [int(y) for y in year_matches if 1950 <= int(y) <= current_year]
if valid_years:
from collections import Counter
metadata['release_year'] = Counter(valid_years).most_common(1)[0][0]
# Extract poster image
poster_elem = soup.select_one('img.poster, img.cover, .anime-poster img')
if poster_elem:
metadata['poster_image'] = poster_elem.get('src') or poster_elem.get('data-src')
# Extract poster from og:image
og_image = soup.find('meta', property='og:image')
if og_image and not metadata['poster_image']:
metadata['poster_image'] = og_image.get('content')
# Extract total episodes
episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0:
metadata['total_episodes'] = episodes_count
print(f"[VOSTFREE] Extracted metadata: {metadata}")
return metadata
except Exception as e:
print(f"[VOSTFREE] Error extracting metadata: {e}")
return {}
async def search_anime(self, query: str, lang: str = "vostfr", include_metadata: bool = False) -> list[dict]:
"""
Search for anime on vostfree
Args:
query: Search query string
lang: Language preference (vostfr, vf)
include_metadata: Whether to fetch full metadata for each result (slower)
"""
try:
import time
start = time.time()
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
# Vostfree URL pattern
search_url = f"https://vostfree.tv/anime/{query.lower().replace(' ', '-')}"
response = await self.client.get(search_url)
elapsed = time.time() - start
print(f"[VOSTFREE] Got response {response.status_code} in {elapsed:.2f}s")
if response.status_code == 200:
print(f"[VOSTFREE] Found anime at {str(response.url)}")
result = {
'title': query,
'url': str(response.url),
'type': 'direct',
'metadata': None
}
if include_metadata:
metadata = await self.get_anime_metadata(str(response.url))
result['metadata'] = metadata
return [result]
print(f"[VOSTFREE] No anime found")
return []
except Exception as e:
print(f"[VOSTFREE] Error: {str(e)}")
return []
+195
View File
@@ -0,0 +1,195 @@
"""
Favorites management system for Ohm Stream Downloader
Stores user's favorite anime with metadata in a local JSON file
"""
import json
import asyncio
from pathlib import Path
from typing import List, Dict, Optional
from datetime import datetime
import aiofiles
class FavoritesManager:
"""Manages user's favorite anime list"""
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:
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:
print(f"Error loading favorites: {e}")
self._favorites = {}
else:
self._favorites = {}
async def _save(self):
"""Save favorites to disk"""
async with self._lock:
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:
print(f"Error saving favorites: {e}")
async def add_favorite(
self,
anime_id: str,
title: str,
url: str,
provider: str,
metadata: Optional[Dict] = None,
poster_url: Optional[str] = None
) -> Dict:
"""Add an anime to favorites"""
await self._load()
if anime_id in self._favorites:
# Update existing favorite
self._favorites[anime_id]["updated_at"] = datetime.now().isoformat()
if metadata:
self._favorites[anime_id]["metadata"] = metadata
if poster_url:
self._favorites[anime_id]["poster_url"] = poster_url
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]
async def remove_favorite(self, anime_id: str) -> bool:
"""Remove an anime from favorites"""
await self._load()
if anime_id in self._favorites:
del self._favorites[anime_id]
await self._save()
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)
async def list_favorites(
self,
sort_by: str = "created_at",
order: str = "desc",
filter_provider: Optional[str] = None,
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]
if filter_genre:
favorites = [
f for f in favorites
if filter_genre in f.get("metadata", {}).get("genres", [])
]
# Sort favorites
reverse = order == "desc"
if sort_by == "title":
favorites.sort(key=lambda x: x["title"].lower(), reverse=reverse)
elif sort_by == "rating":
favorites.sort(
key=lambda x: float(x.get("metadata", {}).get("rating", "0").split("/")[0]),
reverse=reverse
)
elif sort_by == "year":
favorites.sort(
key=lambda x: x.get("metadata", {}).get("release_year", 0),
reverse=reverse
)
else: # created_at, updated_at
favorites.sort(key=lambda x: x.get(sort_by, ""), reverse=reverse)
return favorites
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
async def toggle_favorite(
self,
anime_id: str,
title: str,
url: str,
provider: str,
metadata: Optional[Dict] = None,
poster_url: Optional[str] = None
) -> Dict:
"""Toggle an anime in favorites (add if not exists, remove if exists)"""
is_fav = await self.is_favorite(anime_id)
if is_fav:
await self.remove_favorite(anime_id)
return {"action": "removed", "anime_id": anime_id}
else:
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url)
return {"action": "added", "anime_id": anime_id, "favorite": fav}
async def get_stats(self) -> Dict:
"""Get statistics about favorites"""
await self._load()
total = len(self._favorites)
# Count by provider
by_provider = {}
for fav in self._favorites.values():
provider = fav["provider"]
by_provider[provider] = by_provider.get(provider, 0) + 1
# Count by genre
by_genre = {}
for fav in self._favorites.values():
for genre in fav.get("metadata", {}).get("genres", []):
by_genre[genre] = by_genre.get(genre, 0) + 1
return {
"total": total,
"by_provider": by_provider,
"by_genre": by_genre
}
# Global favorites manager instance
_favorites_manager: Optional[FavoritesManager] = None
def get_favorites_manager() -> FavoritesManager:
"""Get the global favorites manager instance"""
global _favorites_manager
if _favorites_manager is None:
_favorites_manager = FavoritesManager()
return _favorites_manager
-15
View File
@@ -1,15 +0,0 @@
import logging
import sys
def setup_logging(debug: bool = False) -> None:
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
stream=sys.stdout,
force=True,
)
# Réduit le bruit des libs tierces
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
-69
View File
@@ -1,69 +0,0 @@
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.config import BASE_DIR, get_settings
from app.db import db
from app.logging_config import setup_logging
from app.routers import admin, auth, discover, downloads, library, pages, proxy, search, torznab
from app.scrapers.http import close_client
from app.services.discover import discover as discover_service
from app.services.downloads import download_manager
logger = logging.getLogger(__name__)
def _log_task_error(task: asyncio.Task) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.warning("Réchauffe découverte échouée : %s", exc)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
setup_logging(settings.debug)
await db.connect()
await download_manager.start()
warmup = asyncio.create_task(discover_service.latest())
warmup.add_done_callback(_log_task_error)
logger.info("%s prêt", settings.app_name)
yield
warmup.cancel()
await download_manager.stop()
await close_client()
await db.close()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(torznab.router)
app.include_router(pages.router)
app.include_router(pages.protected)
app.include_router(auth.router)
app.include_router(search.router)
app.include_router(discover.router)
app.include_router(downloads.router)
app.include_router(library.router)
app.include_router(admin.router)
app.include_router(proxy.router)
app.mount("/static", StaticFiles(directory=BASE_DIR / "app" / "static"), name="static")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
return app
app = create_app()
+65
View File
@@ -0,0 +1,65 @@
from pydantic import BaseModel
from enum import Enum
from typing import Optional
from datetime import datetime
class DownloadStatus(str, Enum):
PENDING = "pending"
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class HostType(str, Enum):
RAPIDFILE = "rapidfile"
UNFICHIER = "1fichier"
DOODSTREAM = "doodstream"
OTHER = "other"
class DownloadTask(BaseModel):
id: str
url: str
filename: str
host: HostType
status: DownloadStatus
progress: float = 0.0
downloaded_bytes: int = 0
total_bytes: Optional[int] = None
speed: float = 0.0
error: Optional[str] = None
created_at: datetime
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
file_path: Optional[str] = None
class DownloadRequest(BaseModel):
url: str
filename: Optional[str] = None
class AnimeMetadata(BaseModel):
"""Metadata for anime series"""
synopsis: Optional[str] = None
genres: list[str] = []
rating: Optional[str] = None # Could be "PG-13", "R", etc., or numeric like "8.5/10"
release_year: Optional[int] = None
studio: Optional[str] = None
poster_image: Optional[str] = None
banner_image: Optional[str] = None
total_episodes: Optional[int] = None
status: Optional[str] = None # "Ongoing", "Completed", etc.
alternative_titles: list[str] = []
class AnimeSearchResult(BaseModel):
"""Enhanced search result with metadata"""
title: str
url: str
cover_image: Optional[str] = None
type: str # "search_result" or "direct"
metadata: Optional[AnimeMetadata] = None
+82
View File
@@ -0,0 +1,82 @@
"""Anime and file hosting providers configuration"""
ANIME_PROVIDERS = {
"anime-sama": {
"name": "Anime-Sama",
"domains": ["anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"],
"url_pattern": "https://anime-sama.si/catalogue/{anime}/saison{season}/{lang}/",
"icon": "🎬",
"color": "#00d9ff"
},
"anime-ultime": {
"name": "Anime-Ultime",
"domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"],
"url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}",
"icon": "▶️",
"color": "#00ff88"
},
"neko-sama": {
"name": "Neko-Sama",
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
"url_pattern": "https://neko-sama.fr/anime/{slug}",
"icon": "🐱",
"color": "#ff6b6b"
},
"vostfree": {
"name": "Vostfree",
"domains": ["vostfree.tv", "www.vostfree.tv"],
"url_pattern": "https://vostfree.tv/anime/{slug}",
"icon": "📺",
"color": "#ffd93d"
}
}
FILE_HOSTS = {
"1fichier": {
"name": "1fichier",
"domains": ["1fichier.com", "1fichier.fr"],
"icon": "📁",
"color": "#4ecdc4"
},
"uptobox": {
"name": "Uptobox",
"domains": ["uptobox.com", "uptobox.fr"],
"icon": "📦",
"color": "#45b7d1"
},
"doodstream": {
"name": "Doodstream",
"domains": ["doodstream.com", "dood.to", "dood.lol", "dood.cx", "dood.so", "dood.watch"],
"icon": "🎥",
"color": "#f7b731"
},
"rapidfile": {
"name": "Rapidfile",
"domains": ["rapidfile.net", "rapidfile.com"],
"icon": "⚡",
"color": "#ff6b6b"
}
}
def get_all_providers():
"""Get all supported providers (anime + file hosts)"""
return {**ANIME_PROVIDERS, **FILE_HOSTS}
def get_anime_providers():
"""Get all anime streaming providers"""
return ANIME_PROVIDERS
def get_file_hosts():
"""Get all file hosting providers"""
return FILE_HOSTS
def detect_provider_from_url(url: str) -> str | None:
"""Detect which provider can handle the given URL"""
url_lower = url.lower()
for provider_id, provider in get_all_providers().items():
for domain in provider['domains']:
if domain in url_lower:
return provider_id
return None
View File
-168
View File
@@ -1,168 +0,0 @@
"""Administration : utilisateurs, activation des sources, santé."""
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app import auth
from app.db import db
from app.routers.auth import AdminUser
from app.scrapers.base import ScrapeError, all_sources, get_source, import_all_scrapers
from app.services.discover import discover
from app.services.settings import (
get_sonarr_config,
get_torznab_apikey,
is_source_enabled,
reset_torznab_apikey,
set_sonarr_config,
set_source_enabled,
)
from app.services.sonarr import sonarr
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
import_all_scrapers()
# ---------------------------------------------------------------- utilisateurs
@router.get("/users")
async def list_users(admin: AdminUser) -> list[dict]:
rows = await db.fetchall(
"SELECT id, username, is_admin, is_active, created_at FROM users ORDER BY id"
)
return [dict(row) for row in rows]
@router.post("/users/{user_id}/toggle-active")
async def toggle_active(user_id: int, admin: AdminUser) -> dict:
if user_id == admin.id:
raise HTTPException(400, "Impossible de désactiver son propre compte")
row = await db.fetchone("SELECT is_active FROM users WHERE id = ?", (user_id,))
if row is None:
raise HTTPException(404, "Utilisateur introuvable")
new_state = 0 if row["is_active"] else 1
await db.execute("UPDATE users SET is_active = ? WHERE id = ?", (new_state, user_id))
if not new_state:
await auth.revoke_all_refresh_tokens(user_id)
return {"is_active": bool(new_state)}
@router.post("/users/{user_id}/toggle-admin")
async def toggle_admin(user_id: int, admin: AdminUser) -> dict:
if user_id == admin.id:
raise HTTPException(400, "Impossible de modifier ses propres droits")
row = await db.fetchone("SELECT is_admin FROM users WHERE id = ?", (user_id,))
if row is None:
raise HTTPException(404, "Utilisateur introuvable")
new_state = 0 if row["is_admin"] else 1
await db.execute("UPDATE users SET is_admin = ? WHERE id = ?", (new_state, user_id))
return {"is_admin": bool(new_state)}
@router.delete("/users/{user_id}")
async def delete_user(user_id: int, admin: AdminUser) -> dict:
if user_id == admin.id:
raise HTTPException(400, "Impossible de supprimer son propre compte")
cursor = await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
if cursor.rowcount == 0:
raise HTTPException(404, "Utilisateur introuvable")
return {"ok": True}
@router.get("/stats")
async def stats(admin: AdminUser) -> dict:
users = await db.fetchone("SELECT COUNT(*) AS n FROM users")
downloads = await db.fetchall("SELECT status, COUNT(*) AS n FROM downloads GROUP BY status")
return {
"users": users["n"],
"downloads": {row["status"]: row["n"] for row in downloads},
}
# ---------------------------------------------------------------- sources
class SourceToggle(BaseModel):
enabled: bool
@router.post("/sources/{name}/toggle")
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict:
get_source(name) # 404 implicite si inconnue
await set_source_enabled(name, payload.enabled)
return {"name": name, "enabled": payload.enabled}
@router.post("/sources/{name}/health")
async def health_check(name: str, admin: AdminUser) -> dict:
"""Test de santé manuel : la source doit répondre à une recherche simple."""
source = get_source(name)
try:
results = await asyncio.wait_for(source.search("naruto"), timeout=30)
healthy = len(results) > 0
detail = f"{len(results)} résultats"
except (ScrapeError, TimeoutError) as exc:
healthy = False
detail = str(exc)[:200]
logger.error("Health check %s KO : %s", name, exc)
return {"name": name, "healthy": healthy, "detail": detail}
@router.get("/sources")
async def sources_status(admin: AdminUser) -> list[dict]:
return [
{
"name": s.name,
"label": s.label,
"base_url": s.base_url,
"enabled": await is_source_enabled(s.name),
}
for s in all_sources()
]
# ---------------------------------------------------------------- intégrations *arr
class SonarrConfig(BaseModel):
url: str
apikey: str
@router.get("/integrations")
async def integrations(admin: AdminUser, request: Request) -> dict:
"""Configuration Torznab (indexeur) et Sonarr (recommandations)."""
config = await get_sonarr_config()
base = str(request.base_url).rstrip("/")
return {
"torznab": {
"apikey": await get_torznab_apikey(),
"endpoint": f"{base}/torznab/api",
},
"sonarr": config,
}
@router.post("/integrations/torznab/regenerate")
async def regenerate_torznab_key(admin: AdminUser) -> dict:
key = await reset_torznab_apikey()
return {"apikey": key}
@router.put("/integrations/sonarr")
async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict:
"""Enregistre la connexion Sonarr et recalcule les recommandations."""
await set_sonarr_config(payload.url, payload.apikey)
sonarr.invalidate()
discover.invalidate_for_you()
return {"ok": True}
@router.post("/integrations/sonarr/test")
async def test_sonarr(admin: AdminUser) -> dict:
return await sonarr.test_connection()
-110
View File
@@ -1,110 +0,0 @@
"""Routes d'authentification (cookies httponly, adaptées à l'UI htmx)."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from app import auth
from app.auth import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
ACCESS_COOKIE = "ohm_access"
REFRESH_COOKIE = "ohm_refresh"
def set_auth_cookies(response: Response, user: User, refresh_token: str) -> None:
response.set_cookie(
ACCESS_COOKIE, auth.create_access_token(user), httponly=True, samesite="lax"
)
response.set_cookie(REFRESH_COOKIE, refresh_token, httponly=True, samesite="lax", path="/")
def clear_auth_cookies(response: Response) -> None:
response.delete_cookie(ACCESS_COOKIE)
response.delete_cookie(REFRESH_COOKIE, path="/")
# ---------------------------------------------------------------- dépendances
async def current_user(request: Request, response: Response) -> User:
"""Utilisateur courant via cookie d'accès ; tente un refresh si expiré."""
token = request.cookies.get(ACCESS_COOKIE)
if token:
payload = auth.decode_access_token(token)
if payload:
user = await auth.get_user(int(payload["sub"]))
if user:
return user
refresh = request.cookies.get(REFRESH_COOKIE)
if refresh:
user = await auth.use_refresh_token(refresh)
if user:
new_refresh = await auth.create_refresh_token(user.id)
set_auth_cookies(response, user, new_refresh)
return user
raise HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
async def require_admin(user: Annotated[User, Depends(current_user)]) -> User:
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Droits administrateur requis")
return user
CurrentUser = Annotated[User, Depends(current_user)]
AdminUser = Annotated[User, Depends(require_admin)]
# ---------------------------------------------------------------- routes
@router.post("/register")
async def register(
response: Response,
username: Annotated[str, Form(min_length=3, max_length=32)],
password: Annotated[str, Form(min_length=6)],
) -> RedirectResponse:
row = await auth.db.fetchone("SELECT id FROM users WHERE username = ?", (username.strip(),))
if row is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail="Nom d'utilisateur déjà pris")
user = await auth.create_user(username, password)
refresh = await auth.create_refresh_token(user.id)
redirect = RedirectResponse("/", status.HTTP_303_SEE_OTHER)
set_auth_cookies(redirect, user, refresh)
return redirect
@router.post("/login")
async def login(
response: Response,
username: Annotated[str, Form()],
password: Annotated[str, Form()],
) -> RedirectResponse:
user = await auth.authenticate(username, password)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Identifiants invalides")
refresh = await auth.create_refresh_token(user.id)
redirect = RedirectResponse("/", status.HTTP_303_SEE_OTHER)
set_auth_cookies(redirect, user, refresh)
return redirect
@router.post("/logout")
async def logout(user: CurrentUser) -> RedirectResponse:
await auth.revoke_all_refresh_tokens(user.id)
redirect = RedirectResponse("/login", status.HTTP_303_SEE_OTHER)
clear_auth_cookies(redirect)
return redirect
@router.get("/me")
async def me(user: CurrentUser) -> dict:
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
-27
View File
@@ -1,27 +0,0 @@
"""Découverte : nouveautés des sources, incontournables, recommandations."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.routers.auth import CurrentUser, current_user
from app.services.discover import discover
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["discover"], dependencies=[Depends(current_user)])
@router.get("/discover")
async def get_discover(
user: CurrentUser,
latest_limit: Annotated[int, Query(ge=1, le=50)] = 24,
must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20,
for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20,
) -> dict:
"""Les trois sections de découverte en une requête (sections vides si source KO)."""
must_watch = await discover.must_watch(must_watch_limit)
for_you = await discover.for_you(user.id, for_you_limit)
latest = await discover.latest(latest_limit)
return {"latest": latest, "must_watch": must_watch, "for_you": for_you}
-87
View File
@@ -1,87 +0,0 @@
"""Gestion de la file de téléchargements + progression temps réel (SSE)."""
import json
import logging
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse
from app.routers.auth import current_user
from app.scrapers.base import decode_internal_url
from app.services.downloads import download_manager
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/downloads", tags=["downloads"], dependencies=[Depends(current_user)]
)
class EnqueueRequest(BaseModel):
internal_url: str # format `video_url|page_url|titre`
@router.get("")
async def list_downloads() -> list[dict]:
return await download_manager.list_all()
@router.post("", status_code=201)
async def enqueue(payload: EnqueueRequest) -> dict:
try:
video_url, page_url, title = decode_internal_url(payload.internal_url)
except ValueError as exc:
raise HTTPException(422, detail=str(exc)) from exc
return await download_manager.enqueue(video_url, page_url, title)
@router.post("/{download_id}/pause")
async def pause(download_id: int) -> dict:
await download_manager.pause(download_id)
return {"ok": True}
@router.post("/{download_id}/resume")
async def resume(download_id: int) -> dict:
if not await download_manager.resume(download_id):
raise HTTPException(409, detail="Ce téléchargement n'est pas en pause")
return {"ok": True}
@router.post("/{download_id}/retry")
async def retry(download_id: int) -> dict:
if not await download_manager.retry(download_id):
raise HTTPException(
409, detail="Seules les tâches en échec/annulées peuvent être relancées"
)
return {"ok": True}
@router.post("/{download_id}/cancel")
async def cancel(download_id: int) -> dict:
await download_manager.cancel(download_id)
return {"ok": True}
@router.post("/cancel-all")
async def cancel_all() -> dict:
return {"cancelled": await download_manager.cancel_all()}
@router.post("/clear-finished")
async def clear_finished() -> dict:
return {"removed": await download_manager.clear_finished()}
@router.get("/events")
async def events() -> EventSourceResponse:
"""Flux SSE : progression de tous les téléchargements en temps réel."""
async def stream() -> AsyncIterator[dict]:
yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})}
async for update in download_manager.subscribe():
yield {"data": json.dumps({"type": "update", "item": update}, default=str)}
return EventSourceResponse(stream())
-201
View File
@@ -1,201 +0,0 @@
"""Bibliothèque locale, streaming de fichiers (range requests) et favoris."""
import logging
import mimetypes
import re
from collections.abc import AsyncIterator
import aiosqlite
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.config import get_settings
from app.db import db
from app.routers.auth import CurrentUser, current_user
from app.services.downloads import download_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["library"], dependencies=[Depends(current_user)])
CHUNK_SIZE = 1 << 20 # 1 Mio
# ---------------------------------------------------------------- bibliothèque
@router.get("/library")
async def library(user: CurrentUser) -> list[dict]:
"""Fichiers téléchargés, streamables, avec progression de visionnage."""
rows = await db.fetchall(
"SELECT d.*, wp.position_seconds FROM downloads d "
"LEFT JOIN watch_progress wp ON wp.download_id = d.id AND wp.user_id = ? "
"WHERE d.status = 'done' ORDER BY d.updated_at DESC",
(user.id,),
)
return [dict(row) for row in rows]
@router.get("/library/{download_id}/neighbors")
async def neighbors(download_id: int) -> dict:
"""Épisode précédent/suivant : heuristique sur les titres (même série, N±1)."""
rows = await db.fetchall("SELECT id, title FROM downloads WHERE status = 'done' ORDER BY title")
def parse(title: str) -> tuple[str, float | None]:
match = re.search(r"(?:épisode|episode|ep|e)\s*(\d+(?:\.\d+)?)", title, re.IGNORECASE)
if not match:
return title, None
return title[: match.start()].strip(), float(match.group(1))
current = await db.fetchone("SELECT id, title FROM downloads WHERE id = ?", (download_id,))
if current is None:
raise HTTPException(404, "Fichier introuvable")
base, number = parse(current["title"])
prev_ep = next_ep = None
for row in rows:
other_base, other_num = parse(row["title"])
if other_base != base or other_num is None or row["id"] == download_id:
continue
if number is not None and other_num == number - 1:
prev_ep = row["id"]
if number is not None and other_num == number + 1:
next_ep = row["id"]
return {"previous": prev_ep, "next": next_ep}
# ---------------------------------------------------------------- streaming
_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)")
@router.get("/stream/{download_id}")
async def stream(download_id: int, request: Request) -> StreamingResponse:
"""Streaming d'un fichier local avec support des requêtes Range (206)."""
row = await db.fetchone(
"SELECT file_path FROM downloads WHERE id = ? AND status = 'done'", (download_id,)
)
if row is None:
raise HTTPException(404, "Fichier introuvable ou téléchargement incomplet")
path = get_settings().download_dir / row["file_path"]
if not path.is_file():
logger.error("Fichier manquant sur disque : %s", path)
raise HTTPException(404, "Fichier absent du disque")
size = path.stat().st_size
content_type = mimetypes.guess_type(path.name)[0] or "video/mp4"
start, end = 0, size - 1
status_code = 200
range_header = request.headers.get("range")
if range_header:
match = _RANGE_RE.fullmatch(range_header)
if match:
if match.group(1):
start = int(match.group(1))
if match.group(2):
end = min(int(match.group(2)), size - 1)
if start >= size:
raise HTTPException(416, "Plage invalide")
status_code = 206
length = end - start + 1
async def iter_file() -> AsyncIterator[bytes]:
with path.open("rb") as fh:
fh.seek(start)
remaining = length
while remaining > 0:
chunk = fh.read(min(CHUNK_SIZE, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Content-Range": f"bytes {start}-{end}/{size}",
"Accept-Ranges": "bytes",
"Content-Length": str(length),
}
return StreamingResponse(
iter_file(), status_code=status_code, headers=headers, media_type=content_type
)
class ProgressRequest(BaseModel):
position_seconds: float = Field(ge=0)
@router.post("/stream/{download_id}/progress")
async def save_progress(download_id: int, payload: ProgressRequest, user: CurrentUser) -> dict:
"""Reprise de lecture : mémorise la position de visionnage."""
await db.execute(
"INSERT INTO watch_progress (user_id, download_id, position_seconds, updated_at) "
"VALUES (?, ?, ?, datetime('now')) "
"ON CONFLICT(user_id, download_id) DO UPDATE SET "
"position_seconds = excluded.position_seconds, updated_at = excluded.updated_at",
(user.id, download_id, payload.position_seconds),
)
return {"ok": True}
# ---------------------------------------------------------------- favoris
class FavoriteRequest(BaseModel):
source: str
source_id: str
title: str
image_url: str | None = None
payload: dict | None = None
@router.get("/favorites")
async def list_favorites(user: CurrentUser, offset: int = 0, limit: int = 24) -> dict:
total = await db.fetchone("SELECT COUNT(*) AS n FROM favorites WHERE user_id = ?", (user.id,))
rows = await db.fetchall(
"SELECT * FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
(user.id, limit, offset),
)
return {"total": total["n"], "items": [dict(row) for row in rows]}
@router.post("/favorites", status_code=201)
async def add_favorite(payload: FavoriteRequest, user: CurrentUser) -> dict:
import json
try:
await db.execute(
"INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
user.id,
payload.source,
payload.source_id,
payload.title,
payload.image_url,
json.dumps(payload.payload, ensure_ascii=False) if payload.payload else None,
),
)
except aiosqlite.IntegrityError as exc:
raise HTTPException(409, "Déjà dans les favoris") from exc
return {"ok": True}
@router.delete("/favorites/{favorite_id}")
async def remove_favorite(favorite_id: int, user: CurrentUser) -> dict:
cursor = await db.execute(
"DELETE FROM favorites WHERE id = ? AND user_id = ?", (favorite_id, user.id)
)
if cursor.rowcount == 0:
raise HTTPException(404, "Favori introuvable")
return {"ok": True}
# raccourci pratique pour l'UI
@router.get("/downloads/{download_id}")
async def get_download(download_id: int) -> dict:
data = await download_manager.get(download_id)
if data is None:
raise HTTPException(404, "Téléchargement introuvable")
return data
-60
View File
@@ -1,60 +0,0 @@
"""Pages HTML (Jinja2 + htmx). Toutes protégées sauf /login."""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.config import BASE_DIR
from app.routers.auth import current_user
router = APIRouter(tags=["pages"], include_in_schema=False)
protected = APIRouter(tags=["pages"], include_in_schema=False, dependencies=[Depends(current_user)])
templates = Jinja2Templates(directory=BASE_DIR / "app" / "templates")
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "login.html")
@protected.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "index.html")
@protected.get("/discover", response_class=HTMLResponse)
async def discover_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "discover.html")
@protected.get("/title/{source}/{source_id:path}", response_class=HTMLResponse)
async def title_page(request: Request, source: str, source_id: str) -> HTMLResponse:
return templates.TemplateResponse(
request, "title.html", {"source": source, "source_id": source_id}
)
@protected.get("/downloads", response_class=HTMLResponse)
async def downloads_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "downloads.html")
@protected.get("/library", response_class=HTMLResponse)
async def library_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "library.html")
@protected.get("/watch/{download_id}", response_class=HTMLResponse)
async def watch_page(request: Request, download_id: int) -> HTMLResponse:
return templates.TemplateResponse(request, "watch.html", {"download_id": download_id})
@protected.get("/favorites", response_class=HTMLResponse)
async def favorites_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "favorites.html")
@protected.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "admin.html")
-129
View File
@@ -1,129 +0,0 @@
"""Proxy vidéo : sert les flux distants (mp4/HLS) via le serveur.
Indispensable pour les hébergeurs dont les tokens sont liés à l'IP qui a
résolu le lien (le navigateur de l'utilisateur a une IP différente du serveur).
Les playlists m3u8 sont réécrites pour que segments et variantes passent aussi
par le proxy. Accès réservé aux utilisateurs connectés.
"""
import logging
import re
from collections.abc import AsyncIterator
from urllib.parse import quote, urljoin
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response, StreamingResponse
from app.routers.auth import current_user
from app.scrapers.http import get_client
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["proxy"], dependencies=[Depends(current_user)])
HLS_CONTENT_TYPES = ("application/vnd.apple.mpegurl", "application/x-mpegurl", "audio/mpegurl")
HOP_HEADERS = (
"content-type", "content-length", "content-range", "accept-ranges", "cache-control"
)
def _check_url(url: str) -> None:
if not url.startswith(("http://", "https://")):
raise HTTPException(422, "URL invalide")
def proxy_url(remote_url: str, referer: str | None = None) -> str:
out = "/api/proxy?url=" + quote(remote_url, safe="")
if referer:
out += "&ref=" + quote(referer, safe="")
return out
@router.get("/proxy")
async def proxy(
request: Request,
url: str = Query(),
ref: str | None = Query(None),
) -> Response:
_check_url(url)
headers: dict[str, str] = {}
if ref:
_check_url(ref)
headers["Referer"] = ref
if range_header := request.headers.get("range"):
headers["Range"] = range_header
client = get_client()
try:
upstream = await client.send(
client.build_request("GET", url, headers=headers), stream=True
)
except httpx.HTTPError as exc:
logger.error("Proxy : échec de connexion à %s : %s", url, exc)
raise HTTPException(502, f"Hébergeur injoignable : {exc}") from exc
content_type = upstream.headers.get("content-type", "")
is_hls = any(t in content_type for t in HLS_CONTENT_TYPES) or url.endswith(".m3u8")
if is_hls:
# Playlist : lecture intégrale puis réécriture des URIs via le proxy
try:
body = (await upstream.aread()).decode("utf-8", errors="replace")
finally:
await upstream.aclose()
if upstream.status_code >= 400:
logger.error("Proxy HLS : %s → HTTP %s", url, upstream.status_code)
raise HTTPException(502, f"Hébergeur : HTTP {upstream.status_code}")
rewritten = _rewrite_playlist(body, url, ref)
return Response(
rewritten,
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-store"},
)
if upstream.status_code >= 400:
await upstream.aclose()
logger.error("Proxy : %s → HTTP %s", url, upstream.status_code)
raise HTTPException(502, f"Hébergeur : HTTP {upstream.status_code}")
async def byte_stream() -> AsyncIterator[bytes]:
try:
async for chunk in upstream.aiter_bytes(1 << 16):
yield chunk
finally:
await upstream.aclose()
passthrough = {
h: upstream.headers[h] for h in HOP_HEADERS if h in upstream.headers
}
passthrough.setdefault("accept-ranges", "bytes")
return StreamingResponse(
byte_stream(),
status_code=upstream.status_code,
headers=passthrough,
media_type=content_type.split(";")[0] or "application/octet-stream",
)
_KEY_URI_RE = re.compile(r'URI="([^"]+)"')
def _rewrite_playlist(body: str, base_url: str, referer: str | None) -> str:
"""Réécrit toutes les URIs d'une playlist m3u8 pour passer par le proxy."""
def absolutize(uri: str) -> str:
return proxy_url(urljoin(base_url, uri), referer)
lines = []
for line in body.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("#"):
lines.append(_KEY_URI_RE.sub(
lambda m: f'URI="{absolutize(m.group(1))}"', line
))
else:
lines.append(absolutize(stripped))
return "\n".join(lines) + "\n"
-131
View File
@@ -1,131 +0,0 @@
"""Recherche multi-sources, fiches de titres et extraction de liens vidéo."""
import asyncio
import dataclasses
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.routers.auth import current_user
from app.scrapers.base import (
ScrapeError,
SourceScraper,
VideoLink,
all_sources,
get_source,
import_all_scrapers,
resolve_hoster,
)
from app.services.kitsu import KitsuService
from app.services.settings import is_source_enabled
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current_user)])
import_all_scrapers()
kitsu = KitsuService()
async def enabled_sources() -> list[SourceScraper]:
sources = []
for source in all_sources():
if await is_source_enabled(source.name):
sources.append(source)
return sources
@router.get("/sources")
async def list_sources() -> list[dict]:
"""Sources disponibles avec leur état d'activation."""
return [
{
"name": source.name,
"label": source.label,
"base_url": source.base_url,
"media_types": list(source.media_types),
"enabled": await is_source_enabled(source.name),
}
for source in all_sources()
]
@router.get("/search")
async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
"""Recherche unifiée : une requête interroge toutes les sources activées."""
sources = await enabled_sources()
async def safe_search(source: SourceScraper) -> tuple[list, str | None]:
try:
results = [dataclasses.asdict(r) for r in await source.search(q)]
return results, None
except ScrapeError as exc:
logger.error("Recherche échouée sur %s : %s", source.name, exc)
return [], source.name
outcomes = await asyncio.gather(*(safe_search(s) for s in sources))
results = [item for items, _ in outcomes for item in items]
failed = [name for _, name in outcomes if name]
return {"query": q, "count": len(results), "results": results, "failed_sources": failed}
@router.get("/titles/{source}/{source_id:path}")
async def title_details(source: str, source_id: str, enrich: bool = True) -> dict:
"""Fiche détaillée d'un titre (+ enrichissement Kitsu des champs manquants)."""
scraper = get_source(source)
try:
details = await scraper.get_details(source_id)
except ScrapeError as exc:
raise HTTPException(502, detail=str(exc)) from exc
if enrich:
details = await kitsu.enrich(details)
return dataclasses.asdict(details)
@router.get("/episodes/{source}/{source_id:path}")
async def list_episodes(source: str, source_id: str) -> dict:
scraper = get_source(source)
try:
episodes = await scraper.list_episodes(source_id)
except ScrapeError as exc:
raise HTTPException(502, detail=str(exc)) from exc
return {"episodes": [dataclasses.asdict(e) for e in episodes]}
@router.get("/extract")
async def extract(episode_url: Annotated[str, Query()]) -> dict:
"""Résout la chaîne complète : page d'épisode → embeds → URL vidéo directe."""
try:
source = _find_source_for_url(episode_url)
embeds = await source.extract_embed_links(episode_url)
except ScrapeError as exc:
raise HTTPException(502, detail=str(exc)) from exc
links: list[dict] = []
errors: list[str] = []
for embed in embeds:
extractor = resolve_hoster(embed)
if extractor is None:
errors.append(f"Hébergeur non supporté : {embed}")
logger.warning("Aucun extracteur pour %s", embed)
continue
try:
link: VideoLink = await extractor.extract(embed)
data = dataclasses.asdict(link)
data["embed_url"] = embed
links.append(data)
except ScrapeError as exc:
errors.append(str(exc))
logger.error("Extraction échouée pour %s : %s", embed, exc)
if not links and not embeds:
raise HTTPException(502, detail="Aucun lecteur trouvé sur la page de l'épisode")
return {"episode_url": episode_url, "links": links, "errors": errors}
def _find_source_for_url(url: str) -> SourceScraper:
for source in all_sources():
if source.base_url.split("//")[-1].split("/")[0] in url:
return source
raise ScrapeError(f"Aucune source ne correspond à l'URL : {url}")
-124
View File
@@ -1,124 +0,0 @@
"""API Torznab/Newznab consommée par Sonarr, Prowlarr (et les *arr en général).
Authentification par clé API (`?apikey=…` ou en-tête `X-Api-Key`), indépendante
des sessions utilisateurs — aucun cookie requis.
"""
import logging
import secrets
from fastapi import APIRouter, Request, Response
from app.scrapers.base import ScrapeError
from app.services.settings import get_torznab_apikey
from app.services.torznab import torrent_stub, torznab
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/torznab", tags=["torznab"])
def _base_url(request: Request) -> str:
return str(request.base_url).rstrip("/")
async def _auth_error(request: Request, apikey: str | None) -> Response | None:
"""Réponse d'erreur XML si la clé est invalide (None = authentifié)."""
expected = await get_torznab_apikey()
provided = apikey or request.headers.get("X-Api-Key") or ""
if secrets.compare_digest(provided, expected):
return None
return Response(
torznab.error_xml(100, "Invalid API key"),
media_type="application/xml",
status_code=401,
)
@router.get("/api")
@router.post("/api")
async def torznab_api(
request: Request,
t: str = "caps",
q: str | None = None,
season: int | None = None,
ep: int | None = None,
apikey: str | None = None,
) -> Response:
"""Point d'entrée Torznab : caps / tvsearch / search."""
error = await _auth_error(request, apikey)
if error is not None:
return error
key = await get_torznab_apikey()
base = _base_url(request)
if t == "caps":
return Response(torznab.caps_xml(base), media_type="application/xml")
if t in ("tvsearch", "search"):
if not q or len(q) < 2:
return Response(
torznab.error_xml(200, "Paramètre q requis"),
media_type="application/xml",
status_code=400,
)
try:
releases = await torznab.tvsearch(q, season=season, ep=ep)
except Exception as exc: # noqa: BLE001 — Sonarr attend du XML, pas un traceback
logger.error("Torznab %s %r KO : %s", t, q, exc)
return Response(
torznab.error_xml(300, "Recherche indisponible"),
media_type="application/xml",
status_code=502,
)
return Response(
torznab.results_xml(base, key, releases), media_type="application/rss+xml"
)
return Response(
torznab.error_xml(203, f"Fonction non supportée : {t}"),
media_type="application/xml",
status_code=400,
)
@router.get("/download")
async def torznab_download(
request: Request,
source: str | None = None,
sid: str | None = None,
season: int | None = None,
ep: int | None = None,
series: str | None = None,
apikey: str | None = None,
) -> Response:
"""Grab : Sonarr récupère le « .torrent » ; OhmStreaming télécharge l'épisode.
Le flux retourne un .torrent de service (blackhole-friendly) pendant que
l'épisode réel entre dans la file de téléchargements interne.
"""
error = await _auth_error(request, apikey)
if error is not None:
return error
if None in (source, sid, season, ep, series):
return Response(
torznab.error_xml(201, "Paramètres manquants : source, sid, season, ep, series"),
media_type="application/xml",
status_code=400,
)
try:
result = await torznab.grab(source, sid, season, ep, series)
logger.info("Torznab grab OK : %s → download %s", series, result.get("id"))
except ScrapeError as exc:
logger.error("Torznab grab KO : %s", exc)
return Response(
torznab.error_xml(300, str(exc)),
media_type="application/xml",
status_code=502,
)
stub = torrent_stub(_base_url(request) + "/torznab/api", f"{series} S{season:02d}E{ep:02d}")
return Response(
stub,
media_type="application/x-bittorrent",
headers={"Content-Disposition": f'attachment; filename="ohm-{series.replace("/", "-")}-S{season:02d}E{ep:02d}.torrent"'},
)
View File
-192
View File
@@ -1,192 +0,0 @@
"""Contrats et registres des scrapers.
Architecture à 2 registres :
- sources (sites de catalogues animes/séries) : recherche, détails, épisodes, liens embed
- hébergeurs vidéo : résolution d'une URL embed → URL directe du fichier
Ajouter une source = écrire une classe qui implémente le contrat et la décorer
@register_source / @register_hoster.
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
class ScrapeError(Exception):
"""Échec de scraping/extraction — jamais silencieux, toujours journalisé et remonté."""
# ---------------------------------------------------------------- modèles
@dataclass
class SearchResult:
source: str
source_id: str # identifiant stable chez la source (slug, id…)
title: str
url: str
image_url: str | None = None
media_type: str = "anime" # anime | serie | film
@dataclass
class Episode:
number: float # 1, 2, 2.5 pour les OAV intermédiaires
title: str | None
url: str # page de l'épisode chez la source
season: int = 1
@dataclass
class TitleDetails:
source: str
source_id: str
title: str
url: str
synopsis: str | None = None
image_url: str | None = None
banner_url: str | None = None
genres: list[str] = field(default_factory=list)
rating: float | None = None
year: int | None = None
episode_count: int | None = None
episodes: list[Episode] = field(default_factory=list)
media_type: str = "anime"
@dataclass
class VideoLink:
"""Lien vidéo résolu (URL directe du fichier, lisible par un lecteur)."""
url: str
hoster: str
quality: str | None = None
headers: dict[str, str] = field(default_factory=dict) # ex. Referer obligatoire
is_hls: bool = False
# ---------------------------------------------------------------- contrats
class SourceScraper(ABC):
"""Contrat d'un site catalogue (animes, séries…)."""
name: str # identifiant unique, ex. "vostfree"
label: str # nom affiché
base_url: str
media_types: tuple[str, ...] = ("anime",)
@abstractmethod
async def search(self, query: str) -> list[SearchResult]: ...
@abstractmethod
async def get_details(self, source_id: str) -> TitleDetails: ...
@abstractmethod
async def list_episodes(self, source_id: str) -> list[Episode]: ...
@abstractmethod
async def extract_embed_links(self, episode_url: str) -> list[str]:
"""URLs des lecteurs embarqués trouvés sur la page d'un épisode."""
...
async def latest(self) -> list[SearchResult]:
"""Ajouts récents du catalogue (découverte). Vide si la source ne le supporte pas."""
return []
class HosterExtractor(ABC):
"""Contrat d'un hébergeur vidéo : embed URL → URL directe."""
name: str
domains: tuple[str, ...] = ()
def can_handle(self, url: str) -> bool:
return any(d in url for d in self.domains)
@abstractmethod
async def extract(self, embed_url: str) -> VideoLink: ...
# ---------------------------------------------------------------- registres
_source_registry: dict[str, type[SourceScraper]] = {}
_hoster_registry: list[type[HosterExtractor]] = []
def register_source(cls: type[SourceScraper]) -> type[SourceScraper]:
if cls.name in _source_registry:
raise ValueError(f"Source déjà enregistrée : {cls.name}")
_source_registry[cls.name] = cls
logger.debug("Source enregistrée : %s", cls.name)
return cls
def register_hoster(cls: type[HosterExtractor]) -> type[HosterExtractor]:
_hoster_registry.append(cls)
logger.debug("Hébergeur enregistré : %s", cls.name)
return cls
_source_instances: dict[str, SourceScraper] = {}
_hoster_instances: list[HosterExtractor] | None = None
def get_source(name: str) -> SourceScraper:
if name not in _source_instances:
cls = _source_registry.get(name)
if cls is None:
raise ScrapeError(f"Source inconnue : {name}")
_source_instances[name] = cls()
return _source_instances[name]
def all_sources() -> list[SourceScraper]:
return [get_source(name) for name in _source_registry]
def resolve_hoster(url: str) -> HosterExtractor | None:
"""Trouve l'extracteur capable de traiter une URL embed (None → générique)."""
global _hoster_instances
if _hoster_instances is None:
_hoster_instances = [cls() for cls in _hoster_registry]
for extractor in _hoster_instances:
if extractor.can_handle(url):
return extractor
return None
def import_all_scrapers() -> None:
"""Importe tous les modules pour déclencher les décorateurs d'enregistrement."""
import importlib
import pkgutil
import app.scrapers.hosters as hosters_pkg
import app.scrapers.sources as sources_pkg
for pkg in (sources_pkg, hosters_pkg):
for mod in pkgutil.iter_modules(pkg.__path__):
importlib.import_module(f"{pkg.__name__}.{mod.name}")
# ---------------------------------------------------------------- format interne
INTERNAL_SEP = "|"
def encode_internal_url(video_url: str, page_url: str, title: str) -> str:
"""Format interne `video_url|page_url|titre` pour transporter le contexte."""
for part in (video_url, page_url, title):
if INTERNAL_SEP in part:
raise ValueError(f"Caractère interdit '{INTERNAL_SEP}' dans : {part!r}")
return INTERNAL_SEP.join([video_url, page_url, title])
def decode_internal_url(value: str) -> tuple[str, str, str]:
parts = value.split(INTERNAL_SEP)
if len(parts) != 3 or not parts[0]:
raise ValueError(f"URL interne invalide : {value!r}")
return parts[0], parts[1], parts[2]
-31
View File
@@ -1,31 +0,0 @@
"""Chargement des configurations de scraping externalisées (sélecteurs YAML).
Permet de réparer un site cassé en modifiant un fichier de config sans toucher
au code. Chaque source peut avoir un fichier `configs/<nom_source>.yaml`.
"""
import logging
from functools import lru_cache
from typing import Any
import yaml
from app.config import get_settings
logger = logging.getLogger(__name__)
@lru_cache
def load_scraper_config(source_name: str) -> dict[str, Any]:
path = get_settings().scrapers_config_dir / f"{source_name}.yaml"
if not path.exists():
logger.debug("Pas de config externe pour %s (%s)", source_name, path)
return {}
try:
with path.open(encoding="utf-8") as fh:
config = yaml.safe_load(fh) or {}
logger.info("Config de scraping chargée pour %s", source_name)
return config
except yaml.YAMLError as exc:
logger.error("Config %s invalide : %s", path, exc)
return {}
-29
View File
@@ -1,29 +0,0 @@
# Sélecteurs de scraping French-Manga (DataLife Engine + API AJAX maison)
# Structure vérifiée en live sur https://w16.french-manga.net
search:
endpoint: "/engine/ajax/search.php" # POST query=<q>&page=1 (la recherche DLE native est cassée)
result: ".search-item" # lien dans l'attribut onclick
title: ".search-title"
image: ".search-poster img"
details:
config_el: "#serie-config" # data-title / data-news-id
poster: ".fposter img"
synopsis: ".flist .fdesc p"
release: ".facts .release" # année
genres: ".facts .genres a"
episode_badge: ".short-meta.short-label" # "Ep 32 sur 32"
episodes:
api: "/engine/ajax/manga_episodes_api.php" # ?id=<newsid> → JSON {vf, vostfr, info}
versions: ["vostfr", "vf"] # priorité des versions
fragment_prefix: "ep" # URL épisode : <fiche>#ep=<version>-<numéro>
# Nouveautés — section « Récemment mises à jour » de la page d'accueil épinglée
latest:
path: "/manga-streaming-1/"
item: ".short"
link: "a.short-poster" # href = index.php?newsid=<id>, alt = titre
title: ".short-title"
image: "a.short-poster img"
-47
View File
@@ -1,47 +0,0 @@
# Sélecteurs de scraping Vostfree (DataLife Engine) — surchargeables sans toucher au code.
# Structure vérifiée en live sur https://ipv4.vostfree.ws
search:
result: "div.search-result" # bloc d'un résultat de recherche
link: "div.title a" # lien + titre
image: "span.image img" # poster
genres: "ul.additional li" # "Genre:...", "Anneé:..." (détection film)
details:
title: "h1"
poster: ".slide-poster img"
synopsis: ".slide-desc" # les .cast internes sont retirés
genres: '.slide-top li.right a[href*="/genre/"]'
episode_badge: ".slide-poster .year" # ex. "Episode 293"
season_li: "ul.slide-top li" # ex. "Saison: 01"
episodes:
option: "select.new_player_selector option" # value="buttons_N" → Episode NN
button: "div.button_box" # repli si pas de sélecteur
content_prefix: "content_" # #player_M → #content_player_M
# Nouveautés — page « Animes VOSTFR récemment ajoutés »
latest:
path: "/animes-vostfr-recement-ajoutees.html"
item: "div.movie-poster"
link: ".play a" # href = fiche, alt = titre
image: "span.image img"
# class CSS du lecteur → template d'URL ({} = valeur de #content_player_M)
# chaîne vide = la valeur est déjà une URL complète
players:
new_player_vip: ""
new_player_moevideo: ""
new_player_sibnet: "https://video.sibnet.ru/shell.php?videoid={}"
new_player_netu: "https://video.sibnet.ru/shell.php?videoid={}"
new_player_uqload: "https://uqload.com/embed-{}.html"
new_player_mp4: "https://www.mp4upload.com/embed-{}.html"
new_player_fembed: "https://www.fembed.com/v/{}"
new_player_mytv: "https://www.myvi.top/embed/{}"
new_player_myvi: "https://myvi.ru/player/embed/html/{}"
new_player_rutube: "https://rutube.ru/play/embed/{}"
new_player_ok: "https://ok.ru/video/{}"
new_player_mail2: "https://my.mail.ru/video/embed/{}"
new_player_rapids: "https://rapidstream.co/embed-{}.html"
new_player_gtv: "https://iframedream.com/embed/{}.html"
new_player_cloudvideo: "https://cloudvideo.tv/embed-{}.html"
new_player_uptostream: "https://uptostream.com/iframe/{}"
View File
-40
View File
@@ -1,40 +0,0 @@
"""Décompresseur pour le JavaScript packé « p,a,c,k,e,d » (Dean Edwards).
Utilisé par plusieurs hébergeurs vidéo (Uqload, VidMoly…) pour masquer
l'URL du lecteur : le HTML contient `eval(function(p,a,c,k,e,d){...}(...))`.
"""
import re
_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
_PACKED_RE = re.compile(
r"\}\('(?P<payload>.*?)',(?P<a>\d+),(?P<c>\d+),'(?P<keys>.*?)'\.split\('\|'\)",
re.DOTALL,
)
def _base_n(number: int, base: int) -> str:
if number == 0:
return "0"
chars = []
while number:
number, rest = divmod(number, base)
chars.append(_DIGITS[rest])
return "".join(reversed(chars))
def unpack_packed_js(text: str) -> str | None:
"""Retourne le JS dépacké si `text` contient un bloc packé, sinon None."""
match = _PACKED_RE.search(text)
if not match:
return None
payload = match.group("payload").replace("\\\\", "\\").replace("\\'", "'")
base, count = int(match.group("a")), int(match.group("c"))
keys = match.group("keys").split("|")
for index in range(count - 1, -1, -1):
token = _base_n(index, base)
if keys[index]:
replacement = keys[index].replace("\\", "\\\\")
payload = re.sub(rf"\b{re.escape(token)}\b", replacement, payload)
return payload
-50
View File
@@ -1,50 +0,0 @@
"""Extracteur Luluvid/Luluvdo (luluvdo.com, luluvid.com, lulustream.com) — embed → m3u8.
Famille StreamSB : la page embed configure jwplayer dans du JS packé
(p,a,c,k,e,d) avec `sources:[{file:"https://.../master.m3u8?t=<token>"}]`.
Le token CDN (tnmr.org…) est lié à l'IP **et à l'User-Agent** de la requête
d'embed : la VideoLink doit donc renvoyer le User-Agent du socle pour que le
téléchargement passe — sans lui, le CDN répond 403.
"""
import logging
import re
from urllib.parse import urlparse
from app.config import get_settings
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{[^}]*?file\s*:\s*["\'](?P<url>[^"\']+\.m3u8[^"\']*)["\']'),
re.compile(r'file\s*:\s*["\'](?P<url>https?://[^"\']+?\.m3u8[^"\']*)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.m3u8(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class LuluvdoExtractor(HosterExtractor):
name = "luluvdo"
domains = ("luluvdo.com", "luluvid.com", "lulustream.com")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
url = match.group("url")
if url.startswith("http"):
return VideoLink(
url=url,
hoster=self.name,
headers={
"Referer": f"https://{urlparse(embed_url).hostname}/",
"User-Agent": get_settings().user_agent,
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
},
is_hls=".m3u8" in url,
)
raise ScrapeError(f"luluvdo : URL vidéo introuvable dans {embed_url}")
-51
View File
@@ -1,51 +0,0 @@
"""Extracteur SendVid (sendvid.com) — page embed → mp4 direct.
La page embed expose soit une balise `<source src="...">` ( lecteur HTML5),
soit une variable JS `video_source`. Extraction par regex, sans dépendance JS.
"""
import logging
import re
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'<source[^>]+src=["\'](?P<url>[^"\']+)["\']'),
re.compile(r'video_source\s*[:=]\s*["\'](?P<url>[^"\']+)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.(?:mp4|m3u8)(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class SendvidExtractor(HosterExtractor):
name = "sendvid"
domains = ("sendvid.com",)
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
candidates: list[str] = []
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
candidates.append(match.group("url"))
url = self._pick(candidates)
if not url:
raise ScrapeError(f"sendvid : URL vidéo introuvable dans {embed_url}")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": embed_url},
is_hls=".m3u8" in url,
)
@staticmethod
def _pick(candidates: list[str]) -> str | None:
video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)]
for url in video:
if ".mp4" in url:
return url
return video[0] if video else None
-69
View File
@@ -1,69 +0,0 @@
"""Extracteur Sibnet (video.sibnet.ru) — page shell/watch → mp4 direct.
La page embed `shell.php?videoid=N` (ou la page publique `/videoN`) contient
une config jwplayer du type `player.src([{src: "/v/<hash>/<id>.mp4"}])`.
`shell.php` répond 403 depuis certains réseaux : on retombe alors sur la page
publique de la vidéo, qui expose la même config.
"""
import logging
import re
from urllib.parse import urljoin
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_BASE = "https://video.sibnet.ru"
_VIDEO_ID_RE = re.compile(r"(?:videoid=|/video)(\d+)")
_MP4_PATTERNS = (
re.compile(r'player\.src\(\[\{src:\s*"(?P<url>/v/[^"]+?\.mp4)"'),
re.compile(r'["\'](?P<url>/v/[0-9a-f]{32}/\d+\.mp4)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?/v/[^"\']+?\.mp4)["\']'),
)
@register_hoster
class SibnetExtractor(HosterExtractor):
name = "sibnet"
domains = ("video.sibnet.ru",)
async def extract(self, embed_url: str) -> VideoLink:
match = _VIDEO_ID_RE.search(embed_url)
if not match:
raise ScrapeError(f"sibnet : identifiant vidéo introuvable dans {embed_url}")
video_id = match.group(1)
watch_url = f"{_BASE}/video{video_id}"
pages: list[tuple[str, str]] = []
try:
pages.append(("embed", await fetch(embed_url, retries=0)))
except ScrapeError as exc:
logger.info(
"sibnet : page embed %s inaccessible (%s), essai page publique", embed_url, exc
)
try:
pages.append(("watch", await fetch(watch_url, retries=1)))
except ScrapeError as exc:
logger.error("sibnet : page publique %s inaccessible : %s", watch_url, exc)
for source, html in pages:
url = self._find_mp4(html, source)
if url:
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": watch_url},
is_hls=False,
)
raise ScrapeError(f"sibnet : URL mp4 introuvable pour la vidéo {video_id}")
def _find_mp4(self, html: str, source: str) -> str | None:
for pattern in _MP4_PATTERNS:
match = pattern.search(html)
if match:
url = urljoin(_BASE + "/", match.group("url"))
logger.debug("sibnet : mp4 trouvé via %s → %s", source, url)
return url
return None
-45
View File
@@ -1,45 +0,0 @@
"""Extracteur Uqload (uqload.to/.co/.com/.io) — page embed → mp4/m3u8.
La page embed contient un jwplayer configuré dans du JS packé
(p,a,c,k,e,d) : `sources:[{file:"https://.../master.m3u8?..."}]`.
On dépacke puis on extrait par regex.
"""
import logging
import re
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{[^}]*?(?:file|src)\s*:\s*["\'](?P<url>[^"\']+)["\']'),
re.compile(r'(?:file|src)\s*:\s*["\'](?P<url>https?://[^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.(?:m3u8|mp4)(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class UqloadExtractor(HosterExtractor):
name = "uqload"
domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
candidates: list[str] = []
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
candidates.append(match.group("url"))
video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)]
url = next((u for u in video if ".mp4" in u), None) or (video[0] if video else None)
if not url:
raise ScrapeError(f"uqload : URL vidéo introuvable dans {embed_url}")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": embed_url},
is_hls=".m3u8" in url,
)
-44
View File
@@ -1,44 +0,0 @@
"""Extracteur VidMoly (vidmoly.to / vidmoly.me) — page embed → mp4/m3u8.
Le lecteur jwplayer est configuré dans du JS parfois packé (p,a,c,k,e,d) :
`sources: [{file: "...m3u8"}]`. On dépacke si besoin puis on extrait par regex.
"""
import logging
import re
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{[^}]*?file\s*:\s*["\'](?P<url>[^"\']+)["\']'),
re.compile(r'file\s*:\s*["\'](?P<url>https?://[^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.(?:m3u8|mp4)(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class VidmolyExtractor(HosterExtractor):
name = "vidmoly"
domains = ("vidmoly.to", "vidmoly.me", "vidmoly.biz", "vidmoly.com")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
candidates: list[str] = []
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
candidates.append(match.group("url"))
video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)]
url = next((u for u in video if ".mp4" in u), None) or (video[0] if video else None)
if not url:
raise ScrapeError(f"vidmoly : URL vidéo introuvable dans {embed_url}")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": embed_url},
is_hls=".m3u8" in url,
)
-78
View File
@@ -1,78 +0,0 @@
"""Extracteur Vidzy (vidzy.org) — page embed videojs → m3u8.
La page embed ne contient pas l'URL vidéo en clair : le script videojs appelle
une fonction de décodage inline `atob(s)` + reverse + XOR, avec une graine
dérivée du hostname (`somme des codes des caractères & 0xFF`). Si le décodage
échoue, la page sert un leurre `https://s1.fsvid.lol/troll/master.m3u8`
(même valeur pour tous les épisodes) — on le rejette explicitement.
Algorithme (reproduit fidèlement depuis le JS de la page) :
b = base64decode(s) ; a = b[::-1]
r[i] = chr(a[i] ^ ((0x3D + i*89 + H) & 0xFF)) avec H = sum(ord(hostname)) & 0xFF
"""
import base64
import logging
import re
from urllib.parse import urlparse
from app.config import get_settings
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_B64_RE = re.compile(
r"atob\(s\)(?s:.){0,800}?\}\)\(\"(?P<b64>[A-Za-z0-9+/=]{50,})\"",
)
_CLEAR_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{\s*src\s*:\s*["\'](?P<url>https?://[^"\']+?\.m3u8[^"\']*)["\']'),
re.compile(r'_fsvHls\s*=\s*"(?P<url>https?://[^"]+?\.m3u8[^"]*)"'),
)
_TROLL = "/troll/"
def _decode(b64: str, hostname: str) -> str | None:
try:
data = base64.b64decode(b64)
except (ValueError, TypeError):
return None
seed = sum(ord(c) for c in hostname) & 0xFF
decoded = "".join(chr(b ^ ((0x3D + i * 89 + seed) & 0xFF)) for i, b in enumerate(data[::-1]))
return decoded if decoded.startswith(("http://", "https://")) else None
@register_hoster
class VidzyExtractor(HosterExtractor):
name = "vidzy"
domains = ("vidzy.org",)
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
hostname = urlparse(embed_url).hostname or ""
for match in _B64_RE.finditer(html):
url = _decode(match.group("b64"), hostname)
if url and _TROLL not in url:
return self._video_link(url, embed_url)
logger.warning("vidzy : décodage atob+XOR sans résultat pour %s", embed_url)
for pattern in _CLEAR_PATTERNS:
for match in pattern.finditer(html):
url = match.group("url")
if _TROLL not in url:
return self._video_link(url, embed_url)
raise ScrapeError(
f"vidzy : URL vidéo introuvable dans {embed_url} (leurre anti-bot ou page modifiée)"
)
def _video_link(self, url: str, embed_url: str) -> VideoLink:
return VideoLink(
url=url,
hoster=self.name,
headers={
"Referer": f"https://{urlparse(embed_url).hostname}/",
"User-Agent": get_settings().user_agent,
},
is_hls=".m3u8" in url,
)
-63
View File
@@ -1,63 +0,0 @@
"""Client HTTP partagé pour le scraping (httpx async, headers navigateur, retries)."""
import asyncio
import logging
import httpx
from bs4 import BeautifulSoup
from app.config import get_settings
from app.scrapers.base import ScrapeError
logger = logging.getLogger(__name__)
_client: httpx.AsyncClient | None = None
def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
settings = get_settings()
_client = httpx.AsyncClient(
timeout=settings.http_timeout,
follow_redirects=True,
headers={
"User-Agent": settings.user_agent,
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
},
)
return _client
async def close_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
async def fetch(
url: str,
*,
referer: str | None = None,
retries: int = 2,
) -> str:
"""GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif."""
headers = {"Referer": referer} if referer else {}
last_error: Exception | None = None
for attempt in range(retries + 1):
try:
response = await get_client().get(url, headers=headers)
response.raise_for_status()
return response.text
except (httpx.HTTPError, httpx.InvalidURL) as exc:
last_error = exc
logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc)
if attempt < retries:
await asyncio.sleep(1.0 * (attempt + 1))
raise ScrapeError(f"Échec de récupération de {url} : {last_error}")
async def fetch_soup(url: str, *, referer: str | None = None) -> BeautifulSoup:
html = await fetch(url, referer=referer)
return BeautifulSoup(html, "lxml")
View File
-313
View File
@@ -1,313 +0,0 @@
"""Source French-Manga (w16.french-manga.net) — animes VF/VOSTFR, moteur DataLife Engine.
Faits structurels (vérifiés en live) :
- La recherche native DLE est cassée (erreur MySQL en GET, « moins de 4 caractères » en POST) ;
le site utilise un endpoint AJAX : POST /engine/ajax/search.php avec `query=<q>&page=1`
→ blocs `.search-item` (lien dans l'attribut onclick).
- Nouveautés : /manga-streaming-1/ (« Récemment mises à jour ») → blocs `.short`
(lien `a.short-poster` href = `index.php?newsid=<id>`, titre `.short-title`, poster img).
- Fiche : `/<id>-<slug>.html` (alias canonique `index.php?newsid=<id>`), métadonnées dans
`.facts`, `.fdesc`, `.fposter` et `#serie-config` (data-title, data-news-id).
- Épisodes et lecteurs : GET /engine/ajax/manga_episodes_api.php?id=<newsid> → JSON
{"vf": {ep: {hoster: url}}, "vostfr": {...}, "info": {ep: {title, poster}}}.
- Pas de page par épisode : l'URL d'épisode est `<fiche>#ep=<version>-<numéro>`.
"""
import asyncio
import json
import logging
import re
from copy import deepcopy
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup, Tag
from app.scrapers.base import (
Episode,
ScrapeError,
SearchResult,
SourceScraper,
TitleDetails,
register_source,
)
from app.scrapers.config_loader import load_scraper_config
from app.scrapers.http import fetch, fetch_soup, get_client
logger = logging.getLogger(__name__)
DEFAULT_CONFIG: dict = {
"search": {
"endpoint": "/engine/ajax/search.php",
"result": ".search-item",
"title": ".search-title",
"image": ".search-poster img",
},
"details": {
"config_el": "#serie-config",
"poster": ".fposter img",
"synopsis": ".flist .fdesc p",
"release": ".facts .release",
"genres": ".facts .genres a",
"episode_badge": ".short-meta.short-label",
"episodes": {
"api": "/engine/ajax/manga_episodes_api.php",
"versions": ["vostfr", "vf"],
"fragment_prefix": "ep",
},
"latest": {
"path": "/manga-streaming-1/", # « Récemment mises à jour »
"item": ".short",
"link": "a.short-poster", # href = index.php?newsid=<id>, alt = titre
"title": ".short-title",
"image": "a.short-poster img",
},
},
}
_ID_RE = re.compile(r"newsid=(\d+)|/(\d+)-[^/]+\.html?")
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
_SEASON_RE = re.compile(r"Saison\s+(\d+)", re.IGNORECASE)
_YEAR_RE = re.compile(r"\((\d{4})\)\s*$")
def _merged_config() -> dict:
merged = deepcopy(DEFAULT_CONFIG)
for key, values in load_scraper_config("french_manga").items():
if isinstance(values, dict) and isinstance(merged.get(key), dict):
merged[key].update(values)
else:
merged[key] = values
return merged
@register_source
class FrenchMangaScraper(SourceScraper):
name = "french_manga"
label = "French-Manga"
base_url = "https://w16.french-manga.net"
media_types = ("anime",)
# ------------------------------------------------------------- helpers
@staticmethod
def _id_from_url(url: str) -> str | None:
match = _ID_RE.search(url)
if not match:
return None
return match.group(1) or match.group(2)
def _title_url(self, source_id: str) -> str:
return f"{self.base_url}/index.php?newsid={source_id}"
async def _post(self, url: str, data: dict[str, str]) -> str:
"""POST avec retries (fetch du socle est GET uniquement)."""
last_error: Exception | None = None
for attempt in range(3):
try:
response = await get_client().post(url, data=data)
response.raise_for_status()
return response.text
except (httpx.HTTPError, httpx.InvalidURL) as exc:
last_error = exc
logger.warning("french_manga POST %s — tentative %d/3 : %s", url, attempt + 1, exc)
await asyncio.sleep(1.0 * (attempt + 1))
raise ScrapeError(f"Échec de récupération de {url} : {last_error}")
async def _fetch_episodes_api(self, source_id: str) -> dict:
config = _merged_config()
api_url = urljoin(self.base_url + "/", config["episodes"]["api"])
html = await fetch(f"{api_url}?id={source_id}", referer=self._title_url(source_id))
try:
data = json.loads(html)
except json.JSONDecodeError as exc:
raise ScrapeError(
f"french_manga : réponse JSON invalide de l'API épisodes ({source_id}) : {exc}"
) from exc
if not isinstance(data, dict):
raise ScrapeError(f"french_manga : réponse inattendue de l'API épisodes ({source_id})")
return data
# ------------------------------------------------------------- search
async def search(self, query: str) -> list[SearchResult]:
config = _merged_config()
search_cfg = config["search"]
endpoint = urljoin(self.base_url + "/", search_cfg["endpoint"])
html = await self._post(endpoint, {"query": query, "page": "1"})
soup = BeautifulSoup(html, "lxml")
results: list[SearchResult] = []
for item in soup.select(search_cfg["result"]):
onclick = item.get("onclick", "")
match = re.search(r"location\.href='([^']+)'", onclick)
if not match:
logger.warning("french_manga : search-item sans lien (onclick=%r)", onclick[:80])
continue
url = urljoin(self.base_url + "/", match.group(1))
source_id = self._id_from_url(url)
if not source_id:
logger.warning("french_manga : identifiant introuvable dans %s", url)
continue
title_el = item.select_one(search_cfg["title"])
title = title_el.get_text(strip=True) if title_el else url
image = item.select_one(search_cfg["image"])
results.append(
SearchResult(
source=self.name,
source_id=source_id,
title=_YEAR_RE.sub("", title).strip() or title,
url=url,
image_url=image.get("src") if image else None,
media_type="film" if re.search(r"\bfilm\b", title, re.IGNORECASE) else "anime",
)
)
logger.info("french_manga : %d résultats pour %r", len(results), query)
return results
# ------------------------------------------------------------- latest
async def latest(self) -> list[SearchResult]:
"""Ajouts récents — section « Récemment mises à jour »."""
config = _merged_config()
latest_cfg = config["latest"]
url = urljoin(self.base_url + "/", latest_cfg["path"])
soup = await fetch_soup(url)
results: list[SearchResult] = []
for item in soup.select(latest_cfg["item"]):
link = item.select_one(latest_cfg["link"])
href = link.get("href") if link else None
if not href:
logger.warning("french_manga : bloc nouveauté sans lien, ignoré")
continue
absolute = urljoin(self.base_url + "/", href)
source_id = self._id_from_url(absolute)
if not source_id:
logger.warning("french_manga : identifiant introuvable dans %s", absolute)
continue
image = item.select_one(latest_cfg["image"])
title_el = item.select_one(latest_cfg["title"])
title = title_el.get_text(strip=True) if title_el else (link.get("alt") or absolute)
title = _YEAR_RE.sub("", title).strip() or title
results.append(
SearchResult(
source=self.name,
source_id=source_id,
title=title,
url=self._title_url(source_id),
image_url=image.get("src") if image else None,
)
)
logger.info("french_manga : %d nouveautés récupérées", len(results))
return results
# ------------------------------------------------------------- details
async def get_details(self, source_id: str) -> TitleDetails:
config = _merged_config()
url = self._title_url(source_id)
soup = await fetch_soup(url)
details_cfg = config["details"]
config_el = soup.select_one(details_cfg["config_el"])
news_id = config_el.get("data-news-id") if config_el else None
if news_id != source_id:
raise ScrapeError(f"french_manga : fiche introuvable pour {source_id} ({url})")
title = config_el.get("data-title") or (
soup.title.get_text(strip=True) if soup.title else source_id
)
release = self._text(soup.select_one(details_cfg["release"]))
release_match = re.search(r"(\d{4})", release)
badge = self._text(soup.select_one(details_cfg["episode_badge"]))
badge_match = _NUMBER_RE.search(badge)
episodes = await self._episodes_from_api(source_id, title, url)
return TitleDetails(
source=self.name,
source_id=source_id,
title=title,
url=url,
synopsis=self._text(soup.select_one(details_cfg["synopsis"])) or None,
image_url=(soup.select_one(details_cfg["poster"]) or Tag(name="img")).get("src"),
genres=[a.get_text(strip=True) for a in soup.select(details_cfg["genres"])],
year=int(release_match.group(1)) if release_match else None,
episode_count=int(float(badge_match.group(1).replace(",", ".")))
if badge_match
else None,
episodes=episodes,
)
@staticmethod
def _text(element: Tag | None) -> str:
return element.get_text(" ", strip=True) if element else ""
# ------------------------------------------------------------ episodes
async def list_episodes(self, source_id: str) -> list[Episode]:
return await self._episodes_from_api(source_id, None, self._title_url(source_id))
async def _episodes_from_api(
self, source_id: str, title: str | None, page_url: str
) -> list[Episode]:
config = _merged_config()
data = await self._fetch_episodes_api(source_id)
versions: list[str] = config["episodes"]["versions"]
season = 1
if title:
season_match = _SEASON_RE.search(title)
if season_match:
season = int(season_match.group(1))
numbers: set[float] = set()
for version in versions:
for number_text in data.get(version) or {}:
try:
numbers.add(float(number_text))
except ValueError:
logger.warning("french_manga : numéro d'épisode invalide %r", number_text)
info = data.get("info") or {}
episodes: list[Episode] = []
for number in sorted(numbers):
number_text = str(int(number)) if number.is_integer() else str(number)
episode_info = info.get(number_text) or info.get(str(int(number)))
label = episode_info.get("title") if isinstance(episode_info, dict) else None
version = next((v for v in versions if number_text in (data.get(v) or {})), versions[0])
episodes.append(
Episode(
number=number,
title=label,
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
season=season,
)
)
if not episodes:
raise ScrapeError(f"french_manga : aucun épisode trouvé pour {source_id}")
return episodes
# -------------------------------------------------------------- embeds
async def extract_embed_links(self, episode_url: str) -> list[str]:
config = _merged_config()
page_url, _, fragment = episode_url.partition("#")
prefix = config["episodes"]["fragment_prefix"] + "="
match = re.match(rf"{re.escape(prefix)}([A-Za-z0-9]+)-([\d.]+)$", fragment)
if not match:
raise ScrapeError(f"french_manga : fragment d'épisode invalide dans {episode_url}")
version, number_text = match.group(1), match.group(2)
if number_text.isdigit():
number_text = str(int(number_text))
source_id = self._id_from_url(page_url)
if not source_id:
raise ScrapeError(f"french_manga : identifiant introuvable dans {episode_url}")
data = await self._fetch_episodes_api(source_id)
hosters = (data.get(version) or {}).get(number_text)
if not hosters:
raise ScrapeError(
f"french_manga : aucun lecteur pour {version} épisode {number_text} ({episode_url})"
)
links = list(hosters.values())
logger.info("french_manga : %d liens embed pour %s", len(links), episode_url)
return links
-327
View File
@@ -1,327 +0,0 @@
"""Source Vostfree (ipv4.vostfree.ws) — moteur DataLife Engine, animes/films VF & VOSTFR.
Faits structurels (vérifiés en live) :
- Recherche : GET /index.php?do=search&subaction=search&story=<q> → blocs `div.search-result`.
- Nouveautés : /animes-vostfr-recement-ajoutees.html → blocs `div.movie-poster`
(lien `.play a`, alt = titre, poster `span.image img`).
- Fiche : `/444-telecharger-....html` — métadonnées dans `.slide-*`, épisodes dans un
`select.new_player_selector` (une `option value="buttons_N"` par épisode).
- Chaque `div#buttons_N` contient un ou plusieurs `div.new_player_<hoster>#player_M` ;
l'URL embed est le texte de `div#content_player_M` (URL complète ou simple ID selon
l'hébergeur — les templates de reconstruction viennent du JS `anime.js` du site).
- Pas de page par épisode : l'URL d'épisode est `<fiche>#buttons_N`.
"""
import logging
import re
from copy import deepcopy
from urllib.parse import quote
from bs4 import BeautifulSoup, Tag
from app.scrapers.base import (
Episode,
ScrapeError,
SearchResult,
SourceScraper,
TitleDetails,
register_source,
)
from app.scrapers.config_loader import load_scraper_config
from app.scrapers.http import fetch_soup
logger = logging.getLogger(__name__)
DEFAULT_CONFIG: dict = {
"search": {
"result": "div.search-result",
"link": "div.title a",
"image": "span.image img",
"genres": "ul.additional li",
},
"details": {
"title": "h1",
"poster": ".slide-poster img",
"synopsis": ".slide-desc",
"genres": '.slide-top li.right a[href*="/genre/"]',
"episode_badge": ".slide-poster .year",
"season_li": "ul.slide-top li",
},
"episodes": {
"option": "select.new_player_selector option",
"button": "div.button_box",
"content_prefix": "content_",
},
"latest": {
"path": "/animes-vostfr-recement-ajoutees.html",
"item": "div.movie-poster",
"link": ".play a", # href = fiche, alt = titre
"image": "span.image img",
},
# class CSS du lecteur → template d'URL ({} = valeur de #content_player_M ;
# chaîne vide = la valeur est déjà une URL complète)
"players": {
"new_player_vip": "",
"new_player_moevideo": "",
"new_player_sibnet": "https://video.sibnet.ru/shell.php?videoid={}",
"new_player_netu": "https://video.sibnet.ru/shell.php?videoid={}",
"new_player_uqload": "https://uqload.com/embed-{}.html",
"new_player_mp4": "https://www.mp4upload.com/embed-{}.html",
"new_player_fembed": "https://www.fembed.com/v/{}",
"new_player_mytv": "https://www.myvi.top/embed/{}",
"new_player_myvi": "https://myvi.ru/player/embed/html/{}",
"new_player_moevideo_mail": "",
"new_player_rutube": "https://rutube.ru/play/embed/{}",
"new_player_ok": "https://ok.ru/video/{}",
"new_player_mail2": "https://my.mail.ru/video/embed/{}",
"new_player_rapids": "https://rapidstream.co/embed-{}.html",
"new_player_gtv": "https://iframedream.com/embed/{}.html",
"new_player_cloudvideo": "https://cloudvideo.tv/embed-{}.html",
"new_player_uptostream": "https://uptostream.com/iframe/{}",
},
}
_SLUG_RE = re.compile(r"([^/]+)\.html?$")
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE)
_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _merged_config() -> dict:
merged = deepcopy(DEFAULT_CONFIG)
for key, values in load_scraper_config("vostfree").items():
if isinstance(values, dict) and isinstance(merged.get(key), dict):
merged[key].update(values)
else:
merged[key] = values
return merged
@register_source
class VostfreeScraper(SourceScraper):
name = "vostfree"
label = "Vostfree"
base_url = "https://ipv4.vostfree.ws"
media_types = ("anime",)
# ------------------------------------------------------------- helpers
def _title_url(self, source_id: str) -> str:
return f"{self.base_url}/{source_id}.html"
@staticmethod
def _slug_from_url(url: str) -> str | None:
match = _SLUG_RE.search(url)
return match.group(1) if match else None
@staticmethod
def _text(element: Tag | None) -> str:
return element.get_text(" ", strip=True) if element else ""
# ------------------------------------------------------------- search
async def search(self, query: str) -> list[SearchResult]:
config = _merged_config()
url = f"{self.base_url}/index.php?do=search&subaction=search&story={quote(query)}"
soup = await fetch_soup(url)
results: list[SearchResult] = []
for block in soup.select(config["search"]["result"]):
link = block.select_one(config["search"]["link"])
href = link.get("href") if link else None
if not href:
logger.warning("vostfree : bloc de résultat sans lien, ignoré")
continue
source_id = self._slug_from_url(href)
if not source_id:
logger.warning("vostfree : slug introuvable dans %s", href)
continue
image = block.select_one(config["search"]["image"])
genres_text = " ".join(
li.get_text(" ", strip=True) for li in block.select(config["search"]["genres"])
)
results.append(
SearchResult(
source=self.name,
source_id=source_id,
title=self._text(link),
url=href,
image_url=image.get("src") if image else None,
media_type="film"
if re.search(r"\bfilm", genres_text, re.IGNORECASE)
else "anime",
)
)
logger.info("vostfree : %d résultats pour %r", len(results), query)
return results
# ------------------------------------------------------------- latest
async def latest(self) -> list[SearchResult]:
"""Ajouts récents — page « Animes VOSTFR récemment ajoutés »."""
config = _merged_config()
latest_cfg = config["latest"]
soup = await fetch_soup(f"{self.base_url}{latest_cfg['path']}")
results: list[SearchResult] = []
for block in soup.select(latest_cfg["item"]):
link = block.select_one(latest_cfg["link"])
href = link.get("href") if link else None
if not href:
logger.warning("vostfree : bloc nouveauté sans lien, ignoré")
continue
source_id = self._slug_from_url(href)
if not source_id:
logger.warning("vostfree : slug introuvable dans %s", href)
continue
image = block.select_one(latest_cfg["image"])
title = link.get("alt") or self._text(link)
results.append(
SearchResult(
source=self.name,
source_id=source_id,
title=title,
url=href,
image_url=image.get("src") if image else None,
)
)
logger.info("vostfree : %d nouveautés récupérées", len(results))
return results
# ------------------------------------------------------------- details
async def get_details(self, source_id: str) -> TitleDetails:
config = _merged_config()
url = self._title_url(source_id)
soup = await fetch_soup(url)
details_cfg = config["details"]
title_el = soup.select_one(details_cfg["title"])
if title_el is None:
raise ScrapeError(f"vostfree : fiche introuvable pour {source_id} ({url})")
synopsis_el = soup.select_one(details_cfg["synopsis"])
if synopsis_el is not None:
for cast in synopsis_el.select(".cast"):
cast.decompose()
synopsis = self._text(synopsis_el) or None
badge = self._text(soup.select_one(details_cfg["episode_badge"]))
badge_match = _NUMBER_RE.search(badge)
season = 1
for li in soup.select(details_cfg["season_li"]):
season_match = _SEASON_RE.search(self._text(li))
if season_match:
season = int(season_match.group(1))
break
episodes = self._parse_episodes(soup, url, season)
return TitleDetails(
source=self.name,
source_id=source_id,
title=self._text(title_el),
url=url,
synopsis=synopsis,
image_url=(soup.select_one(details_cfg["poster"]) or Tag(name="img")).get("src"),
genres=[a.get_text(strip=True) for a in soup.select(details_cfg["genres"])],
episode_count=int(badge_match.group(1).replace(",", ".")) if badge_match else None,
episodes=episodes,
)
# ------------------------------------------------------------ episodes
async def list_episodes(self, source_id: str) -> list[Episode]:
url = self._title_url(source_id)
soup = await fetch_soup(url)
season = self._find_season(soup)
episodes = self._parse_episodes(soup, url, season)
if not episodes:
raise ScrapeError(f"vostfree : aucun épisode trouvé pour {source_id} ({url})")
return episodes
def _find_season(self, soup: BeautifulSoup) -> int:
config = _merged_config()
for li in soup.select(config["details"]["season_li"]):
season_match = _SEASON_RE.search(self._text(li))
if season_match:
return int(season_match.group(1))
return 1
def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
config = _merged_config()
options = soup.select(config["episodes"]["option"])
entries: list[tuple[str, str]] = []
seen_ids: set[str] = set()
for opt in options:
value = opt.get("value", "")
if value in seen_ids:
continue # le site duplique parfois une option (ex. buttons_268)
seen_ids.add(value)
entries.append((value, opt.get_text(strip=True)))
if not entries: # fiche sans sélecteur : on retombe sur les blocs de boutons
entries = [
(box.get("id", ""), f"Episode {index}")
for index, box in enumerate(soup.select(config["episodes"]["button"]), start=1)
]
episodes: list[Episode] = []
for index, (button_id, label) in enumerate(entries, start=1):
number_match = _NUMBER_RE.search(label)
number = (
float(number_match.group(1).replace(",", ".")) if number_match else float(index)
)
episodes.append(
Episode(
number=number,
title=label or f"Episode {index}",
url=f"{page_url}#{button_id}" if button_id else page_url,
season=season,
)
)
return episodes
# -------------------------------------------------------------- embeds
async def extract_embed_links(self, episode_url: str) -> list[str]:
config = _merged_config()
page_url, _, fragment = episode_url.partition("#")
soup = await fetch_soup(page_url)
button_cfg = config["episodes"]
button: Tag | None = None
if fragment and _SAFE_FRAGMENT_RE.match(fragment):
button = soup.select_one(f"#{fragment}")
if button is None:
logger.warning(
"vostfree : fragment #%s sans bloc correspondant dans %s", fragment, page_url
)
if button is None:
button = soup.select_one(button_cfg["button"])
if button is None:
raise ScrapeError(f"vostfree : aucun lecteur trouvé sur {episode_url}")
prefix = button_cfg["content_prefix"]
links: list[str] = []
for player in button.find_all("div", recursive=False):
player_id = player.get("id")
content = soup.select_one(f"#{prefix}{player_id}") if player_id else None
if content is None:
logger.warning(
"vostfree : contenu absent pour le lecteur #%s (%s)", player_id, page_url
)
continue
value = content.get_text(strip=True)
if not value:
continue
player_class = (player.get("class") or [""])[0]
template = config["players"].get(player_class)
if value.startswith("http"):
links.append(value)
elif template is not None:
links.append(template.format(value))
else:
logger.warning(
"vostfree : pas de template pour le lecteur %r (valeur %r)", player_class, value
)
if not links:
raise ScrapeError(f"vostfree : aucun lien embed extrait de {episode_url}")
logger.info("vostfree : %d liens embed pour %s", len(links), episode_url)
return links
View File
-352
View File
@@ -1,352 +0,0 @@
"""Découverte : nouveautés des sources, incontournables et recommandations.
Trois sections :
- **latest** — « récemment ajoutés » scrapés sur chaque source activée (cliquables
directement vers la fiche) ;
- **must_watch** — titres les plus populaires du catalogue Kitsu (tous temps) ;
- **for_you** — recommandations par genres : les genres des titres téléchargés
(serveur), des favoris (par utilisateur) et des séries téléchargées sur Sonarr
sont agrégés, puis Kitsu est interrogé sur ces catégories en excluant le
déjà-possédé.
Toutes les sources externes sont optionnelles : un échec (réseau, scraping, API)
laisse la section vide et n'est jamais remonté au caller (dégradation gracieuse).
Un cache mémoire TTL évite de re-scaper à chaque chargement de page.
"""
import asyncio
import dataclasses
import json
import logging
import re
import time
import unicodedata
import httpx
from app.config import get_settings
from app.db import db
from app.scrapers.base import (
ScrapeError,
SourceScraper,
all_sources,
import_all_scrapers,
)
from app.services.kitsu import KitsuService, normalize_title
from app.services.settings import is_source_enabled
from app.services.sonarr import sonarr
logger = logging.getLogger(__name__)
import_all_scrapers()
# Bornes de l'algorithme
_MAX_HISTORY_TITLES = 12 # titres récents analysés (téléchargements + favoris)
_MAX_GENRES = 4 # genres retenus pour la requête Kitsu
_KITSU_PAGE_MAX = 20 # limite dure de l'API Kitsu (page[limit] > 20 → 400)
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
_LATEST_TTL_SECONDS = 600 # nouveautés : re-scrape au bout de 10 min
_MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h
_FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement)
class _TTLCache:
"""Cache mémoire minimal avec expiration (mono-processus, suffisant ici)."""
def __init__(self) -> None:
self._data: dict[str, tuple[float, object]] = {}
def get(self, key: str) -> object | None:
entry = self._data.get(key)
if entry is None:
return None
expires_at, value = entry
if expires_at <= time.monotonic():
del self._data[key]
return None
return value
def set(self, key: str, value: object, ttl_seconds: float) -> None:
self._data[key] = (time.monotonic() + ttl_seconds, value)
def clear(self, prefix: str = "") -> None:
"""Invalide les clés commençant par prefix (vide = tout le cache)."""
for key in [k for k in self._data if k.startswith(prefix)]:
del self._data[key]
def category_slug(name: str) -> str:
"""Nom de genre → slug de catégorie Kitsu (« Slice of Life » → « slice-of-life »)."""
decomposed = unicodedata.normalize("NFKD", name)
ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char))
return re.sub(r"[^a-z0-9]+", "-", ascii_only.casefold()).strip("-")
class DiscoverService:
"""Agrégation des trois sections de découverte, avec cache mémoire."""
def __init__(self) -> None:
self._cache = _TTLCache()
self._kitsu = KitsuService()
# ------------------------------------------------------------ nouveautés
async def latest(self, limit: int = 24) -> list[dict]:
"""Nouveautés toutes sources confondues, triées par date de sortie réelle.
Les « récemment ajoutés » de chaque source sont fusionnés (doublons retirés),
enrichis via Kitsu (date de début, statut de diffusion) puis triés du plus
récent au plus ancien — ce qui sort / vient de sortir en premier.
"""
cached = self._cache.get(f"latest:{limit}")
if cached is not None:
return cached # type: ignore[return-value]
sources = [s for s in all_sources() if await is_source_enabled(s.name)]
outcomes = await asyncio.gather(*(self._latest_of(source) for source in sources))
items = [item for outcome in outcomes for item in (outcome or [])]
merged: dict[str, dict] = {}
for item in items:
key = normalize_title(item["title"]).casefold()
existing = merged.get(key)
if existing is None or (not existing.get("image_url") and item.get("image_url")):
merged[key] = item
semaphore = asyncio.Semaphore(_ENRICH_CONCURRENCY)
async def bounded(item: dict) -> dict:
async with semaphore:
return await self._with_release_info(item)
enriched = await asyncio.gather(*(bounded(item) for item in merged.values()))
result = sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True)
result = result[:limit]
self._cache.set(f"latest:{limit}", result, _LATEST_TTL_SECONDS)
return result
async def _latest_of(self, source: SourceScraper) -> list[dict] | None:
"""Items latest() d'une source, aplatis avec les infos de source ([] si KO)."""
try:
results = await source.latest()
except ScrapeError as exc:
logger.warning("Nouveautés indisponibles pour %s : %s", source.name, exc)
return None
return [
{**dataclasses.asdict(r), "source": source.name, "label": source.label}
for r in results
]
async def _with_release_info(self, item: dict) -> dict:
"""Complète un item de nouveauté avec sa date de sortie Kitsu (None si absent)."""
item.setdefault("start_date", None)
item.setdefault("status", None)
item.setdefault("rating", None)
match = await self._kitsu_match_for_title(item["title"])
if match is None:
return item
attrs = match.get("attributes", {})
item["start_date"] = attrs.get("startDate")
item["status"] = attrs.get("status")
item["rating"] = KitsuService._to_rating_10(attrs.get("averageRating"))
return item
# --------------------------------------------------------- incontournables
async def must_watch(self, limit: int = _KITSU_PAGE_MAX) -> list[dict]:
"""Titres les plus populaires du catalogue Kitsu (tous temps)."""
limit = min(limit, _KITSU_PAGE_MAX)
key = f"must_watch:{limit}"
cached = self._cache.get(key)
if cached is not None:
return cached # type: ignore[return-value]
items = await self._kitsu_anime({"sort": "-userCount", "page[limit]": limit})
self._cache.set(key, items, _MUST_WATCH_TTL_SECONDS)
return items
# ------------------------------------------------------------ pour toi
async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict:
"""Recommandations par genres, à partir de l'historique de l'utilisateur.
Genres = téléchargements du serveur (titres → Kitsu) + favoris de l'utilisateur
(genres du payload) + séries téléchargées sur Sonarr (genres fournis par
Sonarr). On exclut les titres déjà possédés (local et Sonarr).
"""
limit = min(limit, _KITSU_PAGE_MAX)
cache_key = f"for_you:{user_id}:{limit}"
cached = self._cache.get(cache_key)
if cached is not None:
return cached # type: ignore[return-value]
owned, favorite_genres = await self._owned(user_id)
genre_counts = await self._genres_from_downloads(owned)
for genre, count in favorite_genres.items():
genre_counts[genre] = genre_counts.get(genre, 0) + count
sonarr_owned, sonarr_genres = await sonarr.profile()
for genre, count in sonarr_genres.items():
genre_counts[genre] = genre_counts.get(genre, 0) + count
owned |= sonarr_owned
if not genre_counts:
result: dict = {"based_on": [], "items": []}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result
top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES]
slugs = [category_slug(genre) for genre in top_genres]
items = await self._kitsu_anime(
{
"filter[categories]": ",".join(slugs),
"sort": "-userCount",
"page[limit]": limit, # le déjà-possédé est filtré après
}
)
kept = [item for item in items if item["title"] and item["title"].casefold() not in owned]
result = {"based_on": top_genres, "items": kept}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result
def invalidate_for_you(self) -> None:
"""Recommandations recalculées au prochain appel (réglages Sonarr modifiés)."""
self._cache.clear("for_you:")
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int]]:
"""Titres possédés (normalisés) + genres directement connus via les favoris."""
rows = await db.fetchall(
"SELECT DISTINCT title FROM downloads ORDER BY created_at DESC LIMIT ?",
(_MAX_HISTORY_TITLES,),
)
fav_rows = await db.fetchall(
"SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
(user_id, _MAX_HISTORY_TITLES),
)
owned = {normalize_title(row["title"]).casefold() for row in rows}
owned.discard("")
genre_counts: dict[str, int] = {}
for row in fav_rows:
try:
payload = json.loads(row["payload"]) if row["payload"] else {}
except (TypeError, ValueError):
continue
for genre in payload.get("genres") or []:
if isinstance(genre, str) and genre.strip():
genre_counts[genre.strip()] = genre_counts.get(genre.strip(), 0) + 1
return owned, genre_counts
async def _genres_from_downloads(self, titles: set[str]) -> dict[str, int]:
"""Genres Kitsu des titres téléchargés (cache DB puis recherche)."""
semaphore = asyncio.Semaphore(_MAX_HISTORY_TITLES)
async def genres_of(title: str) -> list[str]:
async with semaphore:
return await self._kitsu_genres_for_title(title)
outcomes = await asyncio.gather(*(genres_of(t) for t in list(titles)[:_MAX_HISTORY_TITLES]))
counts: dict[str, int] = {}
for genres in outcomes:
for genre in genres:
counts[genre] = counts.get(genre, 0) + 1
return counts
async def _kitsu_match_for_title(self, title: str) -> dict | None:
"""Match Kitsu d'un titre scrapé (cache DB 72 h via metadata_cache)."""
query = normalize_title(title)
if not query:
return None
cache_key = f"kitsu:anime:{query.casefold()}"
match = await self._kitsu.get_cached(cache_key)
if match is None:
match = await self._kitsu.search_anime(title)
if match is not None:
await self._kitsu.set_cached(cache_key, match)
return match
async def _kitsu_genres_for_title(self, title: str) -> list[str]:
"""Genres Kitsu d'un titre scrapé (cache DB 72 h via metadata_cache).
La recherche Kitsu ne renvoie plus les genres (`include=genres` vide) :
on complète avec l'endpoint /anime/<id>/categories.
"""
match = await self._kitsu_match_for_title(title)
if match is None:
return []
genres = [g for g in match.get("genres", []) if isinstance(g, str)]
if not genres:
genres = await self._kitsu_categories(match.get("id"))
match["genres"] = genres
query = normalize_title(title)
await self._kitsu.set_cached( # refresh avec les genres
f"kitsu:anime:{query.casefold()}", match
)
return genres
# -------------------------------------------------------------- Kitsu
async def _kitsu_anime(self, params: dict) -> list[dict]:
"""Requête générique liste Kitsu → items normalisés ([] si échec)."""
settings = get_settings()
try:
async with httpx.AsyncClient(
timeout=settings.http_timeout,
headers={
"User-Agent": settings.user_agent,
"Accept": "application/vnd.api+json",
},
) as client:
response = await client.get(f"{settings.kitsu_base_url}/anime", params=params)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Liste Kitsu échouée (%s) : %s", params, exc)
return []
return [
self._normalize_anime(item)
for item in payload.get("data", [])
if item.get("type") == "anime"
]
async def _kitsu_categories(self, anime_id: object) -> list[str]:
"""Titres des catégories Kitsu d'un anime ([] si échec)."""
if not anime_id:
return []
settings = get_settings()
try:
async with httpx.AsyncClient(
timeout=settings.http_timeout,
headers={
"User-Agent": settings.user_agent,
"Accept": "application/vnd.api+json",
},
) as client:
response = await client.get(
f"{settings.kitsu_base_url}/anime/{anime_id}/categories",
params={"page[limit]": _KITSU_PAGE_MAX},
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Catégories Kitsu échouées (anime %s) : %s", anime_id, exc)
return []
return [
attrs["title"]
for item in payload.get("data", [])
if isinstance(attrs := item.get("attributes", {}), dict) and attrs.get("title")
]
@staticmethod
def _normalize_anime(item: dict) -> dict:
attrs = item.get("attributes", {})
titles = attrs.get("titles") or {}
poster = attrs.get("posterImage") or {}
return {
"kitsu_id": item.get("id"),
"title": attrs.get("canonicalTitle") or titles.get("en_jp"),
"image_url": poster.get("large") or poster.get("medium") or poster.get("tiny"),
"rating": KitsuService._to_rating_10(attrs.get("averageRating")),
"year": KitsuService._extract_year(attrs.get("startDate")),
"subtype": attrs.get("subtype"),
"user_count": attrs.get("userCount"),
}
discover = DiscoverService()
-499
View File
@@ -1,499 +0,0 @@
"""Gestionnaire de téléchargements : file asyncio, parallélisme limité,
pause/reprise (Range HTTP), anti-doublons, persistance, progression temps réel.
Les statuts : pending → downloading → done | failed | cancelled
↕ paused
"""
import asyncio
import contextlib
import logging
import re
import signal
import time
import unicodedata
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from urllib.parse import urljoin
import httpx
from app.config import get_settings
from app.db import db
logger = logging.getLogger(__name__)
ACTIVE_STATUSES = ("pending", "downloading", "paused")
_FFMPEG_TIME_RE = re.compile(r"time=(\d+:\d+:\d+(?:\.\d+)?)")
_EXTINF_RE = re.compile(r"#EXTINF:([\d.]+)")
_BANDWIDTH_RE = re.compile(r"#EXT-X-STREAM-INF:[^\n]*BANDWIDTH=(\d+)[^\n]*\n(\S+)")
def _parse_ffmpeg_time(value: str) -> float:
parts = value.split(":")
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
def _best_variant(master_body: str, base_url: str) -> str | None:
"""URL de la variante au plus haut débit d'une playlist maître HLS."""
variants = [
(int(bw), urljoin(base_url, uri)) for bw, uri in _BANDWIDTH_RE.findall(master_body)
]
return max(variants)[1] if variants else None
_STATUS_LABELS = {
"pending": "en attente",
"downloading": "en cours",
"paused": "en pause",
"done": "terminé",
"failed": "échec",
"cancelled": "annulé",
}
def sanitize_filename(name: str) -> str:
"""Nettoie un nom de fichier : caractères interdits retirés, anti-traversée."""
name = unicodedata.normalize("NFKC", name)
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", name)
name = re.sub(r"\s+", " ", name).strip(" .")
if not name or name in (".", ".."):
name = "video"
return name[:150]
class DownloadManager:
"""File d'attente de téléchargements, injectée dans les routes via app.state."""
def __init__(self) -> None:
self._queue: asyncio.Queue[int] # créée dans start() (affinité avec la boucle)
self._tasks: dict[int, asyncio.Task] = {} # download_id → tâche asyncio
self._pause_events: dict[int, asyncio.Event] = {} # set = peut tourner
self._progress: dict[int, dict[str, Any]] = {} # progression temps réel en mémoire
self._listeners: list[asyncio.Queue] = []
self._workers: list[asyncio.Task] = []
self._client: httpx.AsyncClient | None = None
self._hls_processes: dict[int, asyncio.subprocess.Process] = {}
# ------------------------------------------------------------ cycle de vie
async def start(self) -> None:
self._queue = asyncio.Queue()
settings = get_settings()
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0, read=300.0),
follow_redirects=True,
headers={
"User-Agent": settings.user_agent,
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
},
)
# Restaure les téléchargements interrompus (crash/arrêt) en 'pending'
await db.execute(
"UPDATE downloads SET status = 'pending', updated_at = datetime('now') "
"WHERE status = 'downloading'"
)
await self._scan_download_dir()
for _ in range(settings.max_parallel_downloads):
self._workers.append(asyncio.create_task(self._worker()))
logger.info("DownloadManager démarré (%d workers)", settings.max_parallel_downloads)
async def stop(self) -> None:
for worker in self._workers:
worker.cancel()
for task in self._tasks.values():
task.cancel()
for proc in self._hls_processes.values():
if proc.returncode is None:
proc.kill()
if self._client:
await self._client.aclose()
self._workers.clear()
self._tasks.clear()
async def _scan_download_dir(self) -> None:
"""Restaure en 'done' les fichiers présents sur disque mais inconnus de la DB."""
download_dir = get_settings().download_dir
rows = await db.fetchall("SELECT file_path FROM downloads WHERE file_path IS NOT NULL")
known = {row["file_path"] for row in rows}
for path in download_dir.iterdir():
if path.is_file() and path.suffix != ".part" and path.name not in known:
size = path.stat().st_size
await db.execute(
"INSERT INTO downloads "
"(source_key, video_url, page_url, title, file_path, status, "
" total_bytes, downloaded_bytes) "
"VALUES (?, ?, ?, ?, ?, 'done', ?, ?)",
(
f"file:{path.name}",
"",
"",
path.stem,
path.name,
size,
size,
),
)
logger.info("Fichier restauré depuis le disque : %s", path.name)
# ------------------------------------------------------------ API publique
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict:
"""Ajoute un téléchargement ; retourne la tâche existante si doublon actif."""
source_key = video_url
existing = await db.fetchone(
f"SELECT * FROM downloads WHERE source_key = ? AND status IN "
f"({','.join('?' * len(ACTIVE_STATUSES))})",
(source_key, *ACTIVE_STATUSES),
)
if existing:
logger.info("Anti-doublon : %s déjà en file (id=%s)", title, existing["id"])
return self._to_dict(existing, duplicate=True)
filename = sanitize_filename(title) + self._guess_extension(video_url)
cursor = await db.execute(
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path) "
"VALUES (?, ?, ?, ?, ?)",
(source_key, video_url, page_url, title, filename),
)
download_id = cursor.lastrowid
await self._queue.put(download_id)
await self._emit(download_id)
logger.info("Téléchargement ajouté : %s (id=%s)", title, download_id)
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
return self._to_dict(row)
async def pause(self, download_id: int) -> bool:
event = self._pause_events.get(download_id)
if event:
event.clear()
await self._set_status(download_id, "paused")
return True
async def resume(self, download_id: int) -> bool:
row = await self._get_row(download_id)
if row["status"] != "paused":
return False
await self._set_status(download_id, "pending")
await self._queue.put(download_id)
return True
async def retry(self, download_id: int) -> bool:
row = await self._get_row(download_id)
if row["status"] not in ("failed", "cancelled"):
return False
await db.execute(
"UPDATE downloads SET status = 'pending', error = NULL, downloaded_bytes = 0, "
"updated_at = datetime('now') WHERE id = ?",
(download_id,),
)
part = self._part_path(row["file_path"])
part.unlink(missing_ok=True)
await self._queue.put(download_id)
await self._emit(download_id)
return True
async def cancel(self, download_id: int) -> bool:
task = self._tasks.get(download_id)
if task:
task.cancel()
proc = self._hls_processes.get(download_id)
if proc and proc.returncode is None:
proc.kill()
await self._set_status(download_id, "cancelled")
row = await self._get_row(download_id)
self._part_path(row["file_path"]).unlink(missing_ok=True)
await self._emit(download_id)
return True
async def cancel_all(self) -> int:
rows = await db.fetchall(
f"SELECT id FROM downloads WHERE status IN ({','.join('?' * len(ACTIVE_STATUSES))})",
ACTIVE_STATUSES,
)
for row in rows:
await self.cancel(row["id"])
return len(rows)
async def clear_finished(self) -> int:
"""Supprime de la file les tâches terminées/échouées/annulées (fichiers gardés)."""
cursor = await db.execute(
"DELETE FROM downloads WHERE status IN ('done', 'failed', 'cancelled')"
)
return cursor.rowcount or 0
async def list_all(self, limit: int = 200) -> list[dict]:
rows = await db.fetchall(
"SELECT * FROM downloads ORDER BY "
"CASE status WHEN 'downloading' THEN 0 WHEN 'pending' THEN 1 WHEN 'paused' THEN 2 "
"ELSE 3 END, updated_at DESC LIMIT ?",
(limit,),
)
return [self._to_dict(row) for row in rows]
async def get(self, download_id: int) -> dict | None:
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
return self._to_dict(row) if row else None
# ------------------------------------------------------------ événements SSE
async def subscribe(self) -> AsyncIterator[dict]:
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
self._listeners.append(queue)
try:
while True:
yield await queue.get()
finally:
self._listeners.remove(queue)
async def _emit(self, download_id: int) -> None:
data = await self.get(download_id)
if data is None:
return
for queue in self._listeners:
with contextlib.suppress(asyncio.QueueFull):
queue.put_nowait(data)
# ------------------------------------------------------------ worker interne
async def _worker(self) -> None:
while True:
download_id = await self._queue.get()
row = await db.fetchone("SELECT status FROM downloads WHERE id = ?", (download_id,))
if row is None or row["status"] != "pending":
continue # annulé/pausé entre-temps
# Tâche dédiée : annuler un téléchargement ne doit pas tuer le worker
task = asyncio.create_task(self._download(download_id))
self._tasks[download_id] = task
self._pause_events[download_id] = asyncio.Event()
self._pause_events[download_id].set()
try:
await task
except asyncio.CancelledError:
if asyncio.current_task().cancelling() > 0:
raise # le worker lui-même s'arrête (stop())
finally:
self._tasks.pop(download_id, None)
self._pause_events.pop(download_id, None)
self._progress.pop(download_id, None)
async def _download(self, download_id: int) -> None:
"""Dispatche HTTP/HLS ; gestion d'erreurs centralisée ici."""
row = await self._get_row(download_id)
try:
if ".m3u8" in row["video_url"]:
await self._download_hls(download_id, row)
else:
await self._download_http(download_id, row)
except asyncio.CancelledError:
logger.info("Téléchargement annulé : %s", row["file_path"])
raise
except (httpx.HTTPError, OSError) as exc:
# Échec réseau/disque : journalisé, statut 'failed' visible dans l'UI
logger.error("Échec du téléchargement de %s : %s", row["file_path"], exc)
part = self._part_path(row["file_path"])
downloaded = part.stat().st_size if part.exists() else 0
await self._fail(download_id, exc, downloaded)
async def _download_http(self, download_id: int, row: Any) -> None:
"""Téléchargement HTTP direct avec reprise via Range."""
video_url, file_path = row["video_url"], row["file_path"]
target = get_settings().download_dir / file_path
part = self._part_path(file_path)
downloaded = part.stat().st_size if part.exists() else 0
headers: dict[str, str] = {}
if row["page_url"]:
headers["Referer"] = row["page_url"]
if downloaded:
headers["Range"] = f"bytes={downloaded}-"
logger.info("Reprise de %s à %d octets", file_path, downloaded)
await self._set_status(download_id, "downloading")
await self._emit(download_id)
started = time.monotonic()
last_emit = 0.0
async with self._client.stream("GET", video_url, headers=headers) as response:
if response.status_code == 416: # plage invalide → déjà complet
part.rename(target)
await self._finish(download_id, downloaded)
return
response.raise_for_status()
if downloaded and response.status_code != 206:
downloaded = 0 # serveur sans support Range → on repart de zéro
logger.warning("Pas de reprise possible pour %s", file_path)
total = int(response.headers.get("content-length") or 0) + downloaded or None
await db.execute(
"UPDATE downloads SET total_bytes = ? WHERE id = ?", (total, download_id)
)
mode = "ab" if downloaded else "wb"
with part.open(mode) as fh:
async for chunk in response.aiter_bytes(1 << 16):
event = self._pause_events.get(download_id)
if event is not None:
await event.wait() # pause coopérative
fh.write(chunk)
downloaded += len(chunk)
now = time.monotonic()
if now - last_emit >= 1.0:
last_emit = now
await self._report_progress(download_id, downloaded, total, started)
part.rename(target)
await self._finish(download_id, downloaded)
async def _download_hls(self, download_id: int, row: Any) -> None:
"""Télécharge un flux HLS (.m3u8) via ffmpeg (remux en mp4).
Pause via SIGSTOP/SIGCONT du processus, annulation via kill.
La progression est estimée depuis la durée totale de la playlist.
"""
video_url, file_path = row["video_url"], row["file_path"]
target = get_settings().download_dir / file_path
part = self._part_path(file_path)
part.unlink(missing_ok=True) # pas de reprise partielle en HLS
ffmpeg_headers = f"User-Agent: {get_settings().user_agent}\r\n"
if row["page_url"]:
ffmpeg_headers += f"Referer: {row['page_url']}\r\n"
ffmpeg_headers += "Accept-Language: fr-FR,fr;q=0.9,en;q=0.8\r\n"
total_seconds = await self._hls_duration(video_url, row["page_url"])
await self._set_status(download_id, "downloading")
await self._emit(download_id)
started = time.monotonic()
process = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-nostdin", "-v", "error", "-nostats", "-progress", "pipe:2",
"-headers", ffmpeg_headers,
"-i", video_url,
"-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4",
str(part),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
self._hls_processes[download_id] = process
try:
assert process.stderr is not None
async for raw_line in process.stderr:
event = self._pause_events.get(download_id)
if event is not None and not event.is_set():
process.send_signal(signal.SIGSTOP)
await event.wait()
process.send_signal(signal.SIGCONT)
line = raw_line.decode(errors="replace")
if match := _FFMPEG_TIME_RE.search(line):
elapsed_video = _parse_ffmpeg_time(match.group(1))
size = part.stat().st_size if part.exists() else 0
total_est = None
if total_seconds and elapsed_video > 0:
total_est = int(size / elapsed_video * total_seconds)
await self._report_progress(download_id, size, total_est, started)
return_code = await process.wait()
finally:
self._hls_processes.pop(download_id, None)
if return_code != 0:
part.unlink(missing_ok=True)
raise OSError(f"ffmpeg a échoué (code {return_code}) sur le flux HLS")
size = part.stat().st_size
part.rename(target)
await self._finish(download_id, size)
async def _hls_duration(self, playlist_url: str, referer: str | None) -> float | None:
"""Durée totale d'une playlist HLS (somme des EXTINF de la variante max)."""
headers = {"Referer": referer} if referer else {}
try:
response = await self._client.get(playlist_url, headers=headers)
response.raise_for_status()
body = response.text
# Playlist maître → on suit la variante de plus haut débit
variant = _best_variant(body, playlist_url)
if variant and variant != playlist_url:
response = await self._client.get(variant, headers=headers)
response.raise_for_status()
body = response.text
durations = [float(m) for m in _EXTINF_RE.findall(body)]
return sum(durations) if durations else None
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Durée HLS indéterminée pour %s : %s", playlist_url, exc)
return None
async def _report_progress(
self, download_id: int, downloaded: int, total: int | None, started: float
) -> None:
elapsed = time.monotonic() - started
self._progress[download_id] = {
"downloaded_bytes": downloaded,
"total_bytes": total,
"speed_bps": int(downloaded / elapsed) if elapsed > 0 else 0,
}
await self._emit(download_id)
async def _fail(self, download_id: int, exc: Exception, downloaded: int = 0) -> None:
await db.execute(
"UPDATE downloads SET status = 'failed', error = ?, "
"downloaded_bytes = ?, updated_at = datetime('now') WHERE id = ?",
(str(exc)[:500], downloaded, download_id),
)
await self._emit(download_id)
async def _finish(self, download_id: int, size: int) -> None:
await db.execute(
"UPDATE downloads SET status = 'done', total_bytes = ?, downloaded_bytes = ?, "
"updated_at = datetime('now') WHERE id = ?",
(size, size, download_id),
)
await self._emit(download_id)
logger.info("Téléchargement terminé (id=%s, %d octets)", download_id, size)
# ------------------------------------------------------------ helpers
async def _get_row(self, download_id: int) -> Any:
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
if row is None:
raise KeyError(f"Téléchargement introuvable : {download_id}")
return row
async def _set_status(self, download_id: int, status: str) -> None:
await db.execute(
"UPDATE downloads SET status = ?, updated_at = datetime('now') WHERE id = ?",
(status, download_id),
)
@staticmethod
def _part_path(file_path: str | None) -> Path:
name = file_path or "video"
return get_settings().download_dir / (name + ".part")
@staticmethod
def _guess_extension(url: str) -> str:
match = re.search(r"\.(mp4|mkv|webm|avi|m3u8)(?:\?|$)", url)
ext = match.group(1) if match else "mp4"
return ".mp4" if ext == "m3u8" else f".{ext}"
def _to_dict(self, row: Any, duplicate: bool = False) -> dict:
data = dict(row)
live = self._progress.get(data["id"], {})
downloaded = live.get("downloaded_bytes", data["downloaded_bytes"])
total = live.get("total_bytes", data["total_bytes"])
speed = live.get("speed_bps", 0)
percent = round(downloaded / total * 100, 1) if total else None
eta = int((total - downloaded) / speed) if total and speed else None
data.update(
downloaded_bytes=downloaded,
total_bytes=total,
percent=percent,
speed_bps=speed,
eta_seconds=eta,
status_label=_STATUS_LABELS.get(data["status"], data["status"]),
duplicate=duplicate,
)
return data
download_manager = DownloadManager()
-198
View File
@@ -1,198 +0,0 @@
"""Enrichissement de métadonnées via l'API Kitsu (https://kitsu.io/api/edge).
Fusion intelligente : seuls les champs manquants d'un TitleDetails sont complétés.
Les échecs (réseau, cache indisponible) sont loggés en warning et jamais remontés
au caller — la fiche d'origine est retournée inchangée (dégradation gracieuse).
"""
import json
import logging
import re
import sqlite3
import httpx
from app.config import get_settings
from app.db import db
from app.scrapers.base import TitleDetails
logger = logging.getLogger(__name__)
_NOISE_WORDS_RE = re.compile(
r"\b(?:VOSTFR\d*|VOST|VF[IV]?|TRUEFRENCH|FRENCH|MULTI|SUBFR?)\b", re.IGNORECASE
)
_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\s*(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\s*S\d+(?:E\d+)?\s*$", re.IGNORECASE)
def normalize_title(title: str) -> str:
"""Nettoie un titre de scraping avant recherche Kitsu (bruit, saison, tirets)."""
cleaned = _NOISE_WORDS_RE.sub(" ", title)
cleaned = _TRAILING_SEASON_RE.sub("", cleaned)
cleaned = _TRAILING_CODE_RE.sub("", cleaned)
cleaned = re.sub(r"\s*[-–—_]+\s*", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned.strip(" -–—:.")
class KitsuService:
"""Recherche et cache des métadonnées anime depuis l'API Kitsu."""
async def search_anime(self, title: str) -> dict | None:
"""Recherche le meilleur match Kitsu pour un titre (None si échec/rien trouvé)."""
settings = get_settings()
query = normalize_title(title)
if not query:
return None
try:
async with httpx.AsyncClient(
timeout=settings.http_timeout,
headers={
"User-Agent": settings.user_agent,
"Accept": "application/vnd.api+json",
},
) as client:
response = await client.get(
f"{settings.kitsu_base_url}/anime",
params={"filter[text]": query, "page[limit]": 5, "include": "genres"},
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Recherche Kitsu échouée pour %r : %s", title, exc)
return None
return self._pick_best_match(payload, query)
async def enrich(self, details: TitleDetails) -> TitleDetails:
"""Complète les champs manquants de details via Kitsu (jamais episodes/url/source)."""
query = normalize_title(details.title)
cache_key = f"kitsu:anime:{query.casefold()}"
result = await self.get_cached(cache_key)
from_cache = result is not None
if result is None:
result = await self.search_anime(details.title)
if result is not None:
await self.set_cached(cache_key, result)
if result is None:
logger.info("Aucune métadonnée Kitsu pour %r — fiche inchangée", details.title)
return details
attrs = result.get("attributes", {})
if not details.synopsis:
details.synopsis = attrs.get("synopsis")
if not details.image_url:
poster = attrs.get("posterImage") or {}
details.image_url = poster.get("large") or poster.get("medium")
if not details.banner_url:
cover = attrs.get("coverImage") or {}
details.banner_url = cover.get("large") or cover.get("original")
if not details.genres:
details.genres = list(result.get("genres", []))
if details.rating is None:
details.rating = self._to_rating_10(attrs.get("averageRating"))
if details.year is None:
details.year = self._extract_year(attrs.get("startDate"))
if details.episode_count is None:
details.episode_count = attrs.get("episodeCount")
logger.debug(
"Fiche %r enrichie via Kitsu (cache=%s, id=%s)",
details.title,
from_cache,
result.get("id"),
)
return details
async def get_cached(self, cache_key: str) -> dict | None:
"""Retourne le payload en cache s'il existe et est frais (TTL), sinon None."""
ttl_hours = get_settings().metadata_cache_ttl_hours
try:
row = await db.fetchone(
"SELECT payload FROM metadata_cache "
f"WHERE cache_key = ? AND fetched_at > datetime('now', '-{ttl_hours} hours')",
(cache_key,),
)
except (RuntimeError, sqlite3.Error) as exc:
logger.warning(
"Cache métadonnées illisible (%s), continuation sans cache : %s", cache_key, exc
)
return None
if row is None:
return None
try:
return json.loads(row["payload"])
except (TypeError, ValueError) as exc:
logger.warning("Cache métadonnées corrompu pour %s, re-fetch : %s", cache_key, exc)
return None
async def set_cached(self, cache_key: str, payload: dict) -> None:
"""Écrit/upsert une entrée de cache (échec non bloquant, warning seulement)."""
try:
await db.execute(
"INSERT INTO metadata_cache (cache_key, payload, fetched_at) "
"VALUES (?, ?, datetime('now')) "
"ON CONFLICT(cache_key) DO UPDATE SET "
"payload = excluded.payload, fetched_at = excluded.fetched_at",
(cache_key, json.dumps(payload, ensure_ascii=False)),
)
except (RuntimeError, sqlite3.Error) as exc:
logger.warning(
"Cache métadonnées non écrit (%s), continuation sans cache : %s", cache_key, exc
)
@staticmethod
def _pick_best_match(payload: dict, query: str) -> dict | None:
items = [item for item in payload.get("data", []) if item.get("type") == "anime"]
if not items:
return None
genre_names = {
obj.get("id"): obj.get("attributes", {}).get("name")
for obj in payload.get("included", [])
if obj.get("type") == "genres"
}
def titles_of(item: dict) -> set[str]:
attrs = item.get("attributes", {})
titles = attrs.get("titles") or {}
return {
t.strip().casefold()
for t in (attrs.get("canonicalTitle"), titles.get("en"), titles.get("en_jp"))
if t
}
def popularity(item: dict) -> tuple[int, float]:
attrs = item.get("attributes", {})
return (
attrs.get("userCount") or 0,
float(attrs.get("averageRating") or 0),
)
best = next((item for item in items if query.casefold() in titles_of(item)), None)
if best is None:
best = max(items, key=popularity)
genre_ids = best.get("relationships", {}).get("genres", {}).get("data") or []
genres = [genre_names[g["id"]] for g in genre_ids if g.get("id") in genre_names]
return {"id": best.get("id"), "attributes": best.get("attributes", {}), "genres": genres}
@staticmethod
def _to_rating_10(average_rating: str | None) -> float | None:
"""Kitsu note sur 100 (chaîne) → note sur 10 arrondie à 1 décimale."""
if average_rating is None:
return None
try:
return round(float(average_rating) / 10, 1)
except (TypeError, ValueError):
return None
@staticmethod
def _extract_year(start_date: str | None) -> int | None:
if not start_date:
return None
try:
return int(str(start_date)[:4])
except ValueError:
return None
-72
View File
@@ -1,72 +0,0 @@
"""Paramètres persistés en DB (activation des sources, réglages UI…)."""
import json
import logging
import secrets
from app.db import db
logger = logging.getLogger(__name__)
async def get_setting(key: str, default: object = None) -> object:
row = await db.fetchone("SELECT value FROM settings WHERE key = ?", (key,))
if row is None:
return default
try:
return json.loads(row["value"])
except json.JSONDecodeError:
logger.warning("Paramètre %r corrompu, valeur par défaut utilisée", key)
return default
async def set_setting(key: str, value: object) -> None:
await db.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, json.dumps(value, ensure_ascii=False)),
)
async def is_source_enabled(name: str) -> bool:
return bool(await get_setting(f"source:{name}:enabled", True))
async def set_source_enabled(name: str, enabled: bool) -> None:
await set_setting(f"source:{name}:enabled", enabled)
logger.info("Source %s %s", name, "activée" if enabled else "désactivée")
# ---------------------------------------------------------------- intégrations *arr
TORZNAB_APIKEY_KEY = "torznab:apikey"
SONARR_URL_KEY = "sonarr:url"
SONARR_APIKEY_KEY = "sonarr:apikey"
async def get_torznab_apikey() -> str:
"""Clé API Torznab (générée au premier appel, persistée en DB)."""
key = await get_setting(TORZNAB_APIKEY_KEY)
if not isinstance(key, str) or not key:
key = secrets.token_hex(16)
await set_setting(TORZNAB_APIKEY_KEY, key)
logger.info("Clé API Torznab générée")
return key
async def reset_torznab_apikey() -> str:
key = secrets.token_hex(16)
await set_setting(TORZNAB_APIKEY_KEY, key)
logger.info("Clé API Torznab régénérée")
return key
async def get_sonarr_config() -> dict[str, str]:
return {
"url": await get_setting(SONARR_URL_KEY, ""),
"apikey": await get_setting(SONARR_APIKEY_KEY, ""),
}
async def set_sonarr_config(url: str, apikey: str) -> None:
await set_setting(SONARR_URL_KEY, url.rstrip("/"))
await set_setting(SONARR_APIKEY_KEY, apikey.strip())
logger.info("Configuration Sonarr enregistrée (%s)", url)
-152
View File
@@ -1,152 +0,0 @@
"""Client Sonarr (API v3) — personnalisation de « Pour toi ».
OhmStreaming lit ce qui est téléchargé/grabé sur Sonarr :
- `/api/v3/history` (événements grab/import) → titres récemment obtenus ;
- `/api/v3/series` → genres de chaque série (fournis par Sonarr lui-même).
Ces données alimentent le profil de genres des recommandations et la liste
d'exclusion (le déjà-possédé sur Sonarr n'est pas re-recommandé).
Toujours tolérant aux pannes : Sonarr absent/non configuré → profil vide,
la découverte continue de fonctionner avec l'historique local.
"""
import logging
import time
import httpx
from app.services.kitsu import normalize_title
from app.services.settings import get_sonarr_config
logger = logging.getLogger(__name__)
_HISTORY_PAGE_SIZE = 100
_MAX_TITLES = 30 # titres Sonarr récents analysés
_PROFILE_TTL = 900.0 # profil Sonarr re-quantifié au bout de 15 min
_REQUEST_TIMEOUT = 15.0
class SonarrService:
"""Profil de consommation Sonarr : titres téléchargés + genres associés."""
def __init__(self) -> None:
self._cache: tuple[float, tuple[set[str], dict[str, int]]] | None = None
async def _config(self) -> tuple[str, str]:
"""(url, apikey) — lu en DB à chaque usage (lecture SQLite négligeable)."""
config = await get_sonarr_config()
return config["url"], config["apikey"]
def invalidate(self) -> None:
"""Force le recalcul du profil au prochain appel (réglages modifiés)."""
self._cache = None
async def _client(self) -> httpx.AsyncClient | None:
url, apikey = await self._config()
if not url or not apikey:
return None
return httpx.AsyncClient(
base_url=url,
timeout=_REQUEST_TIMEOUT,
headers={"X-Api-Key": apikey},
)
async def _get(self, path: str, params: dict | None = None) -> dict | list | None:
client = await self._client()
if client is None:
return None
try:
async with client:
response = await client.get(path, params=params)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Sonarr %s KO : %s", path, exc)
return None
# ------------------------------------------------------------ profil
async def profile(self) -> tuple[set[str], dict[str, int]]:
"""(titres possédés normalisés, comptage de genres) — vide si non configuré."""
if self._cache is not None and self._cache[0] > time.monotonic():
return self._cache[1]
titles = await self.downloaded_titles()
genres_by_title = await self.series_genres()
owned: set[str] = set()
genre_counts: dict[str, int] = {}
for title in titles:
normalized = normalize_title(title).casefold()
if normalized:
owned.add(normalized)
for genre in genres_by_title.get(normalized, []):
genre_counts[genre] = genre_counts.get(genre, 0) + 1
result = (owned, genre_counts)
self._cache = (time.monotonic() + _PROFILE_TTL, result)
return result
async def downloaded_titles(self) -> list[str]:
"""Titres récemment grabés/importés sur Sonarr (les plus récents d'abord)."""
payload = await self._get(
"/api/v3/history",
{
"page": 1,
"pageSize": _HISTORY_PAGE_SIZE,
"eventType": 1, # grab ; les imports (3) suivent le même titre
"sortKey": "date",
"sortDir": "desc",
},
)
if not isinstance(payload, dict):
return []
titles: list[str] = []
seen: set[str] = set()
for record in payload.get("records", []):
title = record.get("series", {}).get("title")
if not title or title in seen:
continue
seen.add(title)
titles.append(title)
if len(titles) >= _MAX_TITLES:
break
return titles
async def series_genres(self) -> dict[str, list[str]]:
"""Genres par série, clé = titre normalisé (minuscule)."""
payload = await self._get("/api/v3/series")
if not isinstance(payload, list):
return {}
return {
normalize_title(s.get("title", "")).casefold(): [
g for g in s.get("genres", []) if isinstance(g, str)
]
for s in payload
if s.get("title")
}
# ------------------------------------------------------------ admin
async def test_connection(self) -> dict:
"""Diagnostic admin : version Sonarr, séries, grabs récents."""
url, apikey = await self._config()
if not url or not apikey:
return {"ok": False, "detail": "URL ou clé API manquante"}
payload = await self._get("/api/v3/system/status")
if payload is None:
return {"ok": False, "detail": "Connexion impossible — vérifiez URL et clé"}
series = await self._get("/api/v3/series")
titles = await self.downloaded_titles()
return {
"ok": True,
"detail": (
f"Sonarr {payload.get('version', '?')} — "
f"{len(series) if isinstance(series, list) else 0} séries, "
f"{len(titles)} saisies récentes"
),
}
sonarr = SonarrService()
-304
View File
@@ -1,304 +0,0 @@
"""Indexeur Torznab/Newznab : expose le catalogue OhmStreaming à Sonarr/Prowlarr.
OhmStreaming devient une source d'indexeur à part entière : Sonarr (ou Prowlarr,
qui relaiera vers Radarr/Lidarr…) interroge `/torznab/api` comme n'importe quel
indexer Jackett. Chaque « release » correspond à un épisode résolu depuis les
sources de scraping ; le grab (`/torznab/download`) déclenche l'extraction puis
l'ajout dans la file de téléchargements OhmStreaming — le fichier arrive donc
dans la bibliothèque locale, comme un téléchargement manuel.
Formats :
- `t=caps` → capacités du serveur (catégories TV/Anime, paramètres supportés)
- `t=tvsearch` → recherche par série + saison + épisode (Sonarr)
- `t=search` → recherche libre (Prowlarr, recherche manuelle)
"""
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import format_datetime
from urllib.parse import urlencode
from app.scrapers.base import (
Episode,
ScrapeError,
SourceScraper,
VideoLink,
all_sources,
import_all_scrapers,
resolve_hoster,
)
from app.services.downloads import download_manager
from app.services.settings import is_source_enabled
logger = logging.getLogger(__name__)
import_all_scrapers()
TORZNAB_NS = "http://torznab.com/schemas/2015/feed"
CAT_TV = "5000"
CAT_ANIME = "5070"
_SIZE_ESTIMATE = 400 * 1024 * 1024 # estimation affichée (~400 Mo/épisode)
_MAX_SERIES_PER_SOURCE = 2 # fiches explorées par source lors d'une recherche
_MAX_RELEASES = 100 # borne du flux retourné
_SEARCH_TIMEOUT = 40.0 # scraping lent : garde-fou par source
_EPISODE_CACHE_TTL = 600.0 # listes d'épisodes re-scrapées au bout de 10 min
@dataclass
class Release:
"""Un épisode vu comme une release par Sonarr/Prowlarr."""
series: str
season: int
ep: int
source: str
source_id: str
episode_url: str
@property
def sonarr_title(self) -> str:
return f"{self.series} S{self.season:02d}E{self.ep:02d} VOSTFR WEB-DL"
def _bencode(value) -> bytes:
if isinstance(value, int):
return f"i{value}e".encode()
if isinstance(value, str):
raw = value.encode()
return f"{len(raw)}:".encode() + raw
if isinstance(value, bytes):
return f"{len(value)}:".encode() + value
if isinstance(value, dict):
return b"d" + b"".join(
_bencode(k) + _bencode(v) for k, v in sorted(value.items())
) + b"e"
if isinstance(value, list):
return b"l" + b"".join(_bencode(v) for v in value) + b"e"
raise TypeError(f"type non encodable en bencode : {type(value)!r}")
def torrent_stub(announce_url: str, name: str) -> bytes:
"""Fichier .torrent minimal (le vrai téléchargement est fait par OhmStreaming)."""
return _bencode(
{
"announce": announce_url,
"created by": "OhmStreaming",
"comment": name,
"info": {
"name": name + ".mp4",
"length": 0,
"piece length": 32768,
"pieces": b"\x00" * 20,
},
}
)
class TorznabService:
"""Recherche multi-sources mappée en releases Torznab + grab → file interne."""
def __init__(self) -> None:
self._episodes_cache: dict[tuple[str, str], tuple[float, list[Episode]]] = {}
# ------------------------------------------------------------ recherche
async def tvsearch(self, q: str, season: int | None = None, ep: int | None = None) -> list[Release]:
"""Recherche type Sonarr : série (+ saison/épisode optionnels)."""
releases: list[Release] = []
for source in await self._enabled_sources():
outcomes = await self._search_source(source, q)
for result in outcomes[:_MAX_SERIES_PER_SOURCE]:
try:
episodes = await self._episodes_of(source, result.source_id)
except (ScrapeError, TimeoutError):
continue
releases.extend(
self._releases_for(result.title, source, result.source_id, episodes, season, ep)
)
if len(releases) >= _MAX_RELEASES:
return releases[:_MAX_RELEASES]
return releases
async def search(self, q: str) -> list[Release]:
"""Recherche libre : tous les épisodes des fiches trouvées."""
return await self.tvsearch(q)
async def _search_source(self, source: SourceScraper, q: str):
try:
return await asyncio.wait_for(source.search(q), timeout=_SEARCH_TIMEOUT)
except (ScrapeError, TimeoutError) as exc:
logger.warning("Torznab : recherche %s KO pour %r : %s", source.name, q, exc)
return []
async def _enabled_sources(self) -> list[SourceScraper]:
return [s for s in all_sources() if await is_source_enabled(s.name)]
async def _episodes_of(self, source: SourceScraper, source_id: str) -> list[Episode]:
"""Liste d'épisodes d'une fiche, avec cache mémoire TTL."""
key = (source.name, source_id)
cached = self._episodes_cache.get(key)
if cached is not None and cached[0] > time.monotonic():
return cached[1]
episodes = await asyncio.wait_for(source.list_episodes(source_id), timeout=_SEARCH_TIMEOUT)
self._episodes_cache[key] = (time.monotonic() + _EPISODE_CACHE_TTL, episodes)
return episodes
def _releases_for(
self,
series: str,
source: SourceScraper,
source_id: str,
episodes: list[Episode],
season: int | None,
ep: int | None,
) -> list[Release]:
"""Filtre les épisodes selon saison/épisode demandés (entiers uniquement)."""
out = []
for item in episodes:
if item.number != int(item.number): # OAV 2.5 → ignorée (inparseable Sonarr)
continue
if season is not None and item.season != season:
continue
if ep is not None and int(item.number) != ep:
continue
out.append(
Release(
series=series,
season=item.season,
ep=int(item.number),
source=source.name,
source_id=source_id,
episode_url=item.url,
)
)
return out
# ------------------------------------------------------------ grab
async def grab(self, source: str, source_id: str, season: int, ep: int, series: str) -> dict:
"""Résout l'épisode (embed → vidéo directe) puis l'ajoute à la file interne.
Retourne le dict du téléchargement (existant si doublon actif).
Lève ScrapeError si introuvable ou qu'aucun hébergeur n'a répondu.
"""
from app.scrapers.base import get_source
scraper = get_source(source)
episodes = await self._episodes_of(scraper, source_id)
match = next(
(
e
for e in episodes
if e.season == season and e.number == int(ep)
),
None,
)
if match is None:
raise ScrapeError(f"Épisode S{season:02d}E{ep:02d} introuvable sur {source}")
link = await self._resolve_video(scraper, match.url)
title = f"{series} S{season:02d}E{ep:02d}"
result = await download_manager.enqueue(link.url, match.url, title)
if link.is_hls or link.headers.get("Referer"):
result["note"] = "HLS/proxy : OhmStreaming gère le téléchargement via ffmpeg"
logger.info("Torznab grab %s → download id=%s", title, result.get("id"))
return result
async def _resolve_video(self, scraper: SourceScraper, episode_url: str) -> VideoLink:
"""Chaîne complète : page épisode → embeds → première URL directe valide."""
embeds = await asyncio.wait_for(
scraper.extract_embed_links(episode_url), timeout=_SEARCH_TIMEOUT
)
errors: list[str] = []
for embed in embeds:
extractor = resolve_hoster(embed)
if extractor is None:
errors.append(f"hébergeur non supporté : {embed}")
continue
try:
link = await asyncio.wait_for(extractor.extract(embed), timeout=_SEARCH_TIMEOUT)
if link and link.url:
return link
except (ScrapeError, TimeoutError) as exc:
errors.append(str(exc))
raise ScrapeError(f"Aucun hébergeur résolu pour {episode_url} ({'; '.join(errors[:3])})")
# ------------------------------------------------------------ XML
def download_url(self, base_url: str, apikey: str, release: Release) -> str:
query = urlencode(
{
"apikey": apikey,
"source": release.source,
"sid": release.source_id,
"season": release.season,
"ep": release.ep,
"series": release.series,
}
)
return f"{base_url}/torznab/download?{query}"
def caps_xml(self, base_url: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<caps>
<server version="1.0" title="OhmStreaming" url="{base_url}"
email="ohm@localhost" image="{base_url}/static/img/logo.png"/>
<searching>
<search available="yes" supportedParams="q"/>
<tv-search available="yes" supportedParams="q,season,ep"/>
<movie-search available="no" supportedParams=""/>
<audio-search available="no" supportedParams=""/>
</searching>
<categories>
<category id="{CAT_TV}" name="TV">
<subcat id="{CAT_ANIME}" name="Anime"/>
</category>
</categories>
</caps>"""
def results_xml(self, base_url: str, apikey: str, releases: list[Release]) -> str:
from xml.sax.saxutils import escape
items = []
for release in releases:
url = escape(self.download_url(base_url, apikey, release))
pubdate = escape(format_datetime(datetime.now(UTC)))
items.append(f""" <item>
<title>{escape(release.sonarr_title)}</title>
<guid isPermaLink="true">{url}</guid>
<link>{url}</link>
<comments>{escape(release.episode_url)}</comments>
<pubDate>{pubdate}</pubDate>
<category>{CAT_ANIME}</category>
<enclosure url="{url}" length="{_SIZE_ESTIMATE}" type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="1"/>
<torznab:attr name="peers" value="1"/>
<torznab:attr name="downloadvolumefactor" value="0"/>
<torznab:attr name="uploadvolumefactor" value="0"/>
</item>""")
body = "\n".join(items)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:torznab="{TORZNAB_NS}">
<channel>
<title>OhmStreaming</title>
<link>{escape(base_url)}</link>
<description>Indexeur OhmStreaming — animes VOSTFR scrapés en direct</description>
<language>fr-FR</language>
{body}
</channel>
</rss>"""
def error_xml(self, code: int, description: str) -> str:
from xml.sax.saxutils import escape
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<error code="{code}" description="{escape(description)}"/>'
)
torznab = TorznabService()
-654
View File
@@ -1,654 +0,0 @@
/* ============================================================
Ohm Stream — Design system « plateforme streaming »
Sombre immersif, accent rouge, typographie Outfit
============================================================ */
:root {
--bg: #0a0a0c;
--surface: #141416;
--surface-2: #1c1c20;
--border: #26262b;
--text: #f5f5f7;
--text-dim: #8e8e96;
--accent: #e50914;
--accent-2: #f5c518;
--success: #46d369;
--warning: #f5c518;
--danger: #ff5c5c;
--radius: 10px;
--radius-lg: 16px;
--shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
--font: "Outfit", system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
min-height: 100vh;
line-height: 1.55;
}
a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
button { font-family: inherit; }
/* ------------------------------------------------------------ topbar */
.topbar {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 1.8rem;
padding: 0.85rem 2.2rem;
background: linear-gradient(180deg, rgba(10, 10, 12, 0.96), rgba(10, 10, 12, 0.8));
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.logo {
font-weight: 800;
font-size: 1.25rem;
letter-spacing: 0.04em;
color: var(--accent);
white-space: nowrap;
}
.logo span { color: var(--text); font-weight: 400; }
.topnav { display: flex; gap: 0.25rem; }
.topnav a {
color: var(--text-dim);
font-size: 0.88rem;
font-weight: 500;
padding: 0.45rem 0.85rem;
border-radius: 99px;
transition: color 0.15s, background 0.15s;
}
.topnav a:hover { color: var(--text); }
.topnav a.active { color: var(--text); background: var(--surface-2); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 0.8rem; }
.avatar {
width: 34px; height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.85rem; color: #fff;
text-transform: uppercase;
}
.icon-btn {
background: none; border: none; color: var(--text-dim);
font-size: 1.05rem; cursor: pointer; padding: 0.4rem;
border-radius: 8px; transition: color 0.15s, background 0.15s;
}
.icon-btn:hover { color: var(--text); background: var(--surface-2); }
/* ------------------------------------------------------------ layout */
.main { padding: 2rem clamp(1rem, 3.5vw, 4rem) 4rem; }
.page-title { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.3rem; letter-spacing: -0.01em; }
.page-sub { color: var(--text-dim); font-size: 0.92rem; margin-bottom: 1.6rem; }
/* ------------------------------------------------------------ recherche */
.search-bar { display: flex; gap: 0.7rem; margin-bottom: 1.8rem; max-width: 720px; }
.input, .search-bar input {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 0.85rem 1.3rem;
border-radius: 99px;
font-size: 1rem;
font-family: inherit;
outline: none;
transition: border 0.15s, box-shadow 0.15s;
}
.input:focus, .search-bar input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(229, 9, 20, 0.15);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: var(--accent);
color: #fff;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 99px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
white-space: nowrap;
}
.btn:hover { filter: brightness(1.15); }
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost {
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--border);
color: var(--text);
backdrop-filter: blur(6px);
}
.btn-ghost:hover { background: rgba(255, 255, 255, 0.16); filter: none; }
.btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; }
.btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; }
.btn-danger:hover { background: #4d1518; filter: none; }
/* ------------------------------------------------------------ grille de posters */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(135px, 12vw, 190px), 1fr));
gap: 1.1rem;
}
.card {
position: relative;
border-radius: var(--radius);
overflow: hidden;
background: var(--surface);
transition: transform 0.25s cubic-bezier(0.22, 0.68, 0.35, 1), box-shadow 0.25s;
cursor: pointer;
}
.card:hover { transform: scale(1.04); box-shadow: var(--shadow); z-index: 2; }
.card-poster {
aspect-ratio: 2 / 3;
width: 100%;
object-fit: cover;
background: var(--surface-2);
transition: transform 0.4s ease;
}
.card:hover .card-poster { transform: scale(1.07); }
.card-overlay {
position: absolute;
inset: auto 0 0;
padding: 2rem 0.75rem 0.65rem;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.92), transparent);
}
.card-title {
font-size: 0.84rem;
font-weight: 600;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.35rem;
}
.card-chip {
position: absolute;
top: 0.5rem; left: 0.5rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.18rem 0.5rem;
border-radius: 5px;
color: var(--accent-2);
}
.badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 5px;
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-source { background: rgba(229, 9, 20, 0.18); color: #ff6b73; }
.badge-type { background: rgba(245, 197, 24, 0.14); color: var(--accent-2); }
/* ------------------------------------------------------------ fiche titre */
.title-hero {
position: relative;
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: 2rem;
background: var(--surface);
min-height: 380px;
display: flex;
align-items: flex-end;
}
.title-banner {
position: absolute; inset: 0;
background-size: cover; background-position: center 25%;
}
.title-banner::after {
content: "";
position: absolute; inset: 0;
background:
linear-gradient(90deg, rgba(10, 10, 12, 0.94) 15%, rgba(10, 10, 12, 0.4) 60%, transparent),
linear-gradient(0deg, var(--bg) 0%, transparent 45%);
}
.title-hero-inner {
position: relative;
z-index: 1;
display: flex;
gap: 2rem;
padding: 2.2rem;
width: 100%;
align-items: flex-end;
}
.title-poster {
width: 180px;
border-radius: var(--radius);
box-shadow: var(--shadow);
flex-shrink: 0;
}
.title-info h1 { font-size: 2.4rem; font-weight: 800; letter-spacing: -0.02em; margin-bottom: 0.5rem; }
.title-tags { display: flex; flex-wrap: wrap; gap: 0.45rem; margin: 0.7rem 0; }
.title-synopsis {
color: #cfcfd6;
font-size: 0.95rem;
max-width: 640px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.title-stats { display: flex; gap: 1.3rem; margin-top: 0.8rem; font-size: 0.88rem; color: var(--text-dim); }
.title-stats strong { color: var(--text); }
.title-actions { display: flex; gap: 0.7rem; margin-top: 1.2rem; flex-wrap: wrap; }
.rating-star { color: var(--accent-2); }
/* ------------------------------------------------------------ épisodes */
.section-title { font-size: 1.15rem; font-weight: 700; margin: 2rem 0 1rem; }
.episode-list { display: flex; flex-direction: column; gap: 0.55rem; }
.episode-row {
display: flex;
align-items: center;
gap: 1rem;
background: var(--surface);
border: 1px solid transparent;
border-radius: var(--radius);
padding: 0.65rem 1rem;
transition: background 0.15s, border 0.15s;
}
.episode-row:hover { background: var(--surface-2); border-color: var(--border); }
.episode-num {
min-width: 3rem;
font-weight: 800;
color: var(--accent-2);
font-variant-numeric: tabular-nums;
font-size: 0.95rem;
}
.episode-name { flex: 1; font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d6d6dc; }
/* ------------------------------------------------------------ téléchargements */
.progress {
height: 6px;
background: var(--surface-2);
border-radius: 99px;
overflow: hidden;
flex: 1;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width 0.5s ease;
}
.progress-fill.done { background: var(--success); }
.progress-fill.failed, .progress-fill.cancelled { background: var(--danger); }
.progress-fill.paused, .progress-fill.pending { background: var(--warning); }
.dl-row {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.dl-head { display: flex; align-items: center; gap: 0.9rem; }
.dl-title { flex: 1; font-weight: 600; font-size: 0.95rem; }
.dl-stats { display: flex; gap: 1.2rem; font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.dl-actions { display: flex; gap: 0.45rem; }
.status-pill { font-size: 0.7rem; font-weight: 700; padding: 0.2rem 0.65rem; border-radius: 99px; text-transform: uppercase; letter-spacing: 0.03em; }
.status-pending, .status-paused { background: rgba(245, 197, 24, 0.14); color: var(--warning); }
.status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; }
.status-done { background: rgba(70, 211, 105, 0.14); color: var(--success); }
.status-failed, .status-cancelled { background: rgba(255, 92, 92, 0.14); color: var(--danger); }
/* ------------------------------------------------------------ lecteur */
.player-wrap {
background: #000;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow);
margin-bottom: 1.2rem;
}
.player-wrap video { width: 100%; max-height: 74vh; }
/* ------------------------------------------------------------ admin / tables */
.table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.table th, .table td { padding: 0.7rem 0.9rem; text-align: left; border-bottom: 1px solid var(--border); }
.table th { color: var(--text-dim); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; }
.table tr:last-child td { border-bottom: none; }
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.4rem 1.5rem;
margin-bottom: 1.5rem;
}
.panel h2 { font-size: 1.05rem; font-weight: 700; margin-bottom: 1.1rem; }
.toggle {
position: relative;
width: 44px; height: 24px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 99px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 18px; height: 18px;
border-radius: 50%;
background: var(--text-dim);
transition: transform 0.2s, background 0.2s;
}
.toggle.on { background: var(--accent); border-color: var(--accent); }
.toggle.on::after { transform: translateX(20px); background: #fff; }
/* ------------------------------------------------------------ divers */
.spinner {
width: 36px; height: 36px;
border: 3px solid var(--surface-2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 3rem auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state { text-align: center; color: var(--text-dim); padding: 4rem 1rem; font-size: 0.95rem; }
.empty-state .big { font-size: 2.6rem; margin-bottom: 0.7rem; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: block; }
.toast {
position: fixed;
bottom: 1.5rem; right: 1.5rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
color: var(--text);
padding: 0.8rem 1.2rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
font-size: 0.88rem;
z-index: 100;
animation: toast-in 0.25s ease;
}
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } }
/* ------------------------------------------------------------ login */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background:
radial-gradient(ellipse 55% 45% at 50% 0%, rgba(229, 9, 20, 0.13), transparent),
var(--bg);
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2.5rem;
width: 400px;
box-shadow: var(--shadow);
}
.login-card .logo { display: block; text-align: center; font-size: 1.5rem; margin-bottom: 0.3rem; }
.login-sub { text-align: center; color: var(--text-dim); font-size: 0.88rem; margin-bottom: 1.8rem; }
.login-card form { display: flex; flex-direction: column; gap: 0.85rem; }
.login-card .input { border-radius: var(--radius); }
.login-card .btn { border-radius: var(--radius); justify-content: center; }
.login-tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; background: var(--surface-2); padding: 0.3rem; border-radius: 99px; }
.login-tab {
flex: 1;
text-align: center;
padding: 0.5rem;
border-radius: 99px;
cursor: pointer;
color: var(--text-dim);
font-size: 0.88rem;
font-weight: 600;
transition: all 0.15s;
}
.login-tab.active { background: var(--accent); color: #fff; }
.login-error {
border: 1px solid rgba(255, 92, 92, 0.4);
color: var(--danger);
border-radius: var(--radius);
padding: 0.6rem 0.9rem;
font-size: 0.85rem;
}
.card-year {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
font-size: 0.68rem;
font-weight: 700;
padding: 0.18rem 0.45rem;
border-radius: 5px;
color: var(--text-dim);
}
.card-airing {
position: absolute;
bottom: 3.4rem;
left: 0.5rem;
background: rgba(46, 160, 67, 0.85);
color: #fff;
font-size: 0.58rem;
font-weight: 800;
letter-spacing: 0.06em;
padding: 0.16rem 0.45rem;
border-radius: 5px;
}
[x-cloak] { display: none !important; }
/* ---------------------------------------------------------- découverte (rails) */
.rail-section { margin-bottom: 2.4rem; animation: rise 0.5s ease backwards; }
.rail-title {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 0.8rem;
display: flex;
align-items: baseline;
gap: 0.6rem;
flex-wrap: wrap;
}
.rail-source {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-dim);
}
.rail-wrap { position: relative; }
.rail {
--visible: 2.4;
--rail-gap: 1.1rem;
display: flex;
gap: var(--rail-gap);
overflow-x: auto;
padding: 0.3rem 0.2rem 0.8rem;
scroll-snap-type: x proximity;
scroll-behavior: smooth;
}
.rail::-webkit-scrollbar { height: 8px; }
.rail::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 4px; }
.rail-card {
flex: 1 0 calc((100% - (var(--visible) - 1) * var(--rail-gap)) / var(--visible));
max-width: 300px;
scroll-snap-align: start;
animation: card-in 0.55s cubic-bezier(0.22, 0.68, 0.35, 1) backwards;
animation-delay: calc(var(--i, 0) * 45ms);
}
.rail-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 3;
width: 42px;
height: 42px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(20, 20, 22, 0.85);
backdrop-filter: blur(6px);
color: var(--text);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.rail-prev { left: -10px; }
.rail-next { right: -10px; }
.rail-wrap:hover .rail-nav { opacity: 1; }
.rail-nav:hover { background: var(--accent); transform: translateY(-50%) scale(1.1); }
@media (hover: none) { .rail-nav { display: none; } }
/* ------------------------------------------------------------ skeletons */
.skel {
background: linear-gradient(100deg, var(--surface) 40%, var(--surface-2) 50%, var(--surface) 60%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
.skel-title { height: 1.2rem; width: 220px; border-radius: 6px; margin-bottom: 0.8rem; }
.skel-card { aspect-ratio: 2 / 3; border-radius: var(--radius); }
.rail-skel { overflow: hidden; }
/* ------------------------------------------------------------ animations */
@keyframes card-in { from { opacity: 0; transform: translateY(16px) scale(0.96); } }
@keyframes rise { from { opacity: 0; transform: translateY(14px); } }
@keyframes shimmer { to { background-position: -200% 0; } }
.page-title { animation: rise 0.4s ease backwards; }
.page-sub { animation: rise 0.4s 0.08s ease backwards; }
.grid .card {
animation: card-in 0.5s cubic-bezier(0.22, 0.68, 0.35, 1) backwards;
animation-delay: calc(var(--i, 0) * 40ms);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ------------------------------------------------------------ responsive */
@media (min-width: 720px) { .rail { --visible: 3.4; } }
@media (min-width: 1000px) { .rail { --visible: 4; } }
@media (min-width: 1200px) { .rail { --visible: 5; } }
@media (min-width: 1450px) { .rail { --visible: 6; } }
@media (min-width: 1700px) { .rail { --visible: 7; } }
@media (min-width: 1920px) { .rail { --visible: 8; } }
@media (min-width: 2150px) { .rail { --visible: 9; } }
@media (min-width: 2400px) { .rail { --visible: 10; } }
@media (min-width: 2650px) { .rail { --visible: 12; } }
@media (min-width: 3000px) { .rail { --visible: 13; } }
@media (min-width: 3400px) { .rail { --visible: 14; } }
@media (min-width: 3800px) { .rail { --visible: 15; } }
@media (min-width: 1800px) {
.page-title { font-size: 1.8rem; }
.rail-title { font-size: 1.3rem; }
}
@media (max-width: 860px) {
.topbar { padding: 0.6rem 1rem 0; gap: 0.8rem; flex-wrap: wrap; }
.topbar-right { margin-left: auto; }
.topnav {
order: 3;
width: 100%;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 0.5rem;
}
.topnav::-webkit-scrollbar { display: none; }
.topnav a { padding: 0.4rem 0.7rem; font-size: 0.82rem; white-space: nowrap; }
.main { padding: 1.2rem 1rem 3rem; }
.title-hero { min-height: 0; }
.title-hero-inner { flex-direction: column; align-items: flex-start; padding: 1.4rem; }
.title-info h1 { font-size: 1.8rem; }
.title-poster { width: 130px; }
}
@media (max-width: 640px) {
.main { padding: 1rem 0.9rem 3rem; }
.page-title { font-size: 1.35rem; }
.page-sub { font-size: 0.86rem; margin-bottom: 1.2rem; }
.grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.7rem; }
.rail { --rail-gap: 0.7rem; }
.rail-section { margin-bottom: 1.8rem; }
.search-bar { flex-direction: column; }
.search-bar .btn { justify-content: center; }
}
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="300" viewBox="0 0 200 300">
<rect width="200" height="300" fill="#222738"/>
<text x="100" y="145" font-size="42" text-anchor="middle" fill="#4a5170">⛩</text>
<text x="100" y="180" font-size="13" text-anchor="middle" fill="#4a5170" font-family="sans-serif">Pas d'image</text>
</svg>

Before

Width:  |  Height:  |  Size: 350 B

-12
View File
@@ -1,12 +0,0 @@
// Petit toast global utilisé par les pages
function toast(message) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = message;
document.body.appendChild(el);
setTimeout(() => {
el.style.opacity = '0';
el.style.transition = 'opacity 0.4s';
setTimeout(() => el.remove(), 400);
}, 2800);
}
-199
View File
@@ -1,199 +0,0 @@
{% extends "base.html" %}
{% set active = 'admin' %}
{% block title %}Administration — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Administration</h1>
<p class="page-sub">Utilisateurs, sources et santé du système.</p>
<div x-data="adminPage()" x-init="load()">
<div class="panel">
<h2>📊 Statistiques</h2>
<div class="title-stats" style="margin-top:0">
<span>Utilisateurs : <strong x-text="stats.users ?? '—'"></strong></span>
<template x-for="(n, st) in stats.downloads || {}" :key="st">
<span><span class="status-pill" :class="'status-' + st" x-text="st"></span> <strong x-text="n"></strong></span>
</template>
</div>
</div>
<div class="panel">
<h2>🌐 Sources de scraping</h2>
<table class="table">
<thead><tr><th>Source</th><th>URL</th><th>Activée</th><th>Santé</th><th></th></tr></thead>
<tbody>
<template x-for="s in sources" :key="s.name">
<tr>
<td><strong x-text="s.label"></strong> <span style="color:var(--text-dim);font-size:0.8rem" x-text="'(' + s.name + ')'"></span></td>
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="s.base_url"></td>
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
<td>
<span x-show="s.health == null" style="color:var(--text-dim)">—</span>
<span x-show="s.health === true" style="color:var(--success)">✔ OK</span>
<span x-show="s.health === false" style="color:var(--danger)" :title="s.healthDetail">✖ KO</span>
</td>
<td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="panel">
<h2>🔗 Intégrations Sonarr / Prowlarr</h2>
<h3 style="margin:1rem 0 0.4rem;font-size:0.95rem">OhmStreaming comme indexeur (Torznab)</h3>
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
Ajoutez un indexeur « Torznab » dans Prowlarr ou Sonarr avec cette URL et cette clé —
les épisodes grabés par Sonarr entrent directement dans la file de téléchargements OhmStreaming.
</p>
<table class="table">
<tbody>
<tr>
<td style="width:110px"><strong>URL</strong></td>
<td><code x-text="integrations.torznab.endpoint"></code></td>
<td style="width:90px"><button class="btn btn-sm btn-ghost" @click="copy(integrations.torznab.endpoint)">Copier</button></td>
</tr>
<tr>
<td><strong>Clé API</strong></td>
<td><code x-text="integrations.torznab.apikey"></code></td>
<td style="display:flex;gap:0.4rem">
<button class="btn btn-sm btn-ghost" @click="copy(integrations.torznab.apikey)">Copier</button>
<button class="btn btn-sm btn-ghost" @click="regenerateKey()" :disabled="integrations._regen">Régénérer</button>
</td>
</tr>
</tbody>
</table>
<h3 style="margin:1.4rem 0 0.4rem;font-size:0.95rem">Sonarr → « Pour toi »</h3>
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
Les genres des séries téléchargées sur Sonarr personnalisent la section
« Pour toi » (ce qui y est possédé n'est pas re-recommandé).
</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<input class="input" x-model="integrations.sonarr.url" placeholder="http://sonarr:8989"
style="flex:1;min-width:200px" @change="integrations.sonarr._dirty = true">
<input class="input" x-model="integrations.sonarr.apikey" placeholder="Clé API Sonarr"
style="flex:1;min-width:200px" @change="integrations.sonarr._dirty = true">
<button class="btn btn-sm" @click="saveSonarr()" :disabled="!integrations.sonarr._dirty">Enregistrer</button>
<button class="btn btn-sm btn-ghost" @click="testSonarr()" :disabled="integrations._testing"
x-text="integrations._testing ? '…' : 'Tester'"></button>
</div>
<p x-show="integrations.testResult" style="margin:0.5rem 0 0;font-size:0.85rem"
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="integrations.testResult?.detail"></p>
</div>
<div class="panel">
<h2>👥 Utilisateurs</h2>
<table class="table">
<thead><tr><th>ID</th><th>Nom</th><th>Rôle</th><th>Actif</th><th>Créé le</th><th></th></tr></thead>
<tbody>
<template x-for="u in users" :key="u.id">
<tr>
<td x-text="u.id"></td>
<td><strong x-text="u.username"></strong></td>
<td>
<span class="badge" :class="u.is_admin ? 'badge-source' : 'badge-type'"
x-text="u.is_admin ? 'admin' : 'utilisateur'"></span>
</td>
<td><div class="toggle" :class="u.is_active && 'on'" @click="post(`/api/admin/users/${u.id}/toggle-active`)"></div></td>
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="u.created_at?.slice(0, 10)"></td>
<td style="display:flex;gap:0.4rem">
<button class="btn btn-sm btn-ghost" @click="post(`/api/admin/users/${u.id}/toggle-admin`)" x-text="u.is_admin ? 'Rétrograder' : 'Promouvoir'"></button>
<button class="btn btn-sm btn-danger" @click="del(u)">Supprimer</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="forbidden">
<div class="login-error">Cette page est réservée aux administrateurs.</div>
</template>
</div>
{% endblock %}
{% block scripts %}
<script>
function adminPage() {
return {
users: [], sources: [], stats: {}, forbidden: false,
integrations: {
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
_regen: false, _testing: false, testResult: null,
},
async load() {
const [u, s, st] = await Promise.all([
fetch('/api/admin/users'), fetch('/api/admin/sources'), fetch('/api/admin/stats'),
]);
if (u.status === 403) { this.forbidden = true; return; }
this.users = await u.json();
this.sources = await s.json();
this.stats = await st.json();
const itg = await fetch('/api/admin/integrations');
if (itg.ok) {
const data = await itg.json();
this.integrations.torznab = data.torznab;
this.integrations.sonarr = { ...data.sonarr, _dirty: false };
}
},
copy(value) {
navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
},
async regenerateKey() {
if (!confirm('Régénérer la clé API Torznab ? Les indexeurs configurés devront être mis à jour.')) return;
this.integrations._regen = true;
const res = await fetch('/api/admin/integrations/torznab/regenerate', { method: 'POST' });
if (res.ok) { this.integrations.torznab.apikey = (await res.json()).apikey; toast('✔ Nouvelle clé générée'); }
this.integrations._regen = false;
},
async saveSonarr() {
const res = await fetch('/api/admin/integrations/sonarr', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.integrations.sonarr.url, apikey: this.integrations.sonarr.apikey }),
});
if (res.ok) { this.integrations.sonarr._dirty = false; toast('✔ Configuration Sonarr enregistrée'); }
},
async toggle(s) {
const res = await fetch(`/api/admin/sources/${s.name}/toggle`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !s.enabled }),
});
if (res.ok) s.enabled = !s.enabled;
},
async healthCheck(s) {
s._checking = true;
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
const data = await res.json();
s.health = data.healthy; s.healthDetail = data.detail;
s._checking = false;
toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`);
},
async post(url) { await fetch(url, { method: 'POST' }); await this.load(); },
async testSonarr() {
this.integrations._testing = true;
const res = await fetch('/api/admin/integrations/sonarr/test', { method: 'POST' });
this.integrations.testResult = res.ok ? await res.json() : { ok: false, detail: 'Erreur serveur' };
this.integrations._testing = false;
},
async del(u) {
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
await this.load();
},
};
}
</script>
{% endblock %}
-37
View File
@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Ohm Stream{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head>
<body>
<header class="topbar" x-data="{ username: '' }" x-init="fetch('/auth/me').then(r => r.json()).then(u => username = u.username).catch(() => {})">
<a class="logo" href="/">OHM<span>STREAM</span></a>
<nav class="topnav">
<a class="{{ 'active' if active == 'discover' }}" href="/discover">Découvrir</a>
<a class="{{ 'active' if active == 'search' }}" href="/">Recherche</a>
<a class="{{ 'active' if active == 'downloads' }}" href="/downloads">Téléchargements</a>
<a class="{{ 'active' if active == 'library' }}" href="/library">Bibliothèque</a>
<a class="{{ 'active' if active == 'favorites' }}" href="/favorites">Favoris</a>
<a class="{{ 'active' if active == 'admin' }}" href="/admin">Admin</a>
</nav>
<div class="topbar-right">
<div class="avatar" x-text="username ? username[0] : '·'" :title="username"></div>
<form method="post" action="/auth/logout" style="display:inline">
<button class="icon-btn" type="submit" title="Déconnexion">⏻</button>
</form>
</div>
</header>
<main class="main">
{% block content %}{% endblock %}
</main>
<script src="/static/js/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
-141
View File
@@ -1,141 +0,0 @@
{% extends "base.html" %}
{% set active = 'discover' %}
{% block title %}Découvrir — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Découvrir</h1>
<p class="page-sub">Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
<div x-data="discoverPage()" x-init="load()" x-cloak>
<!-- ------------------------------------------------ Skeletons de chargement -->
<div x-show="loading">
<section class="rail-section" x-data="{ n: 8 }">
<div class="skel skel-title"></div>
<div class="rail rail-skel">
<template x-for="i in n"><div class="rail-card skel skel-card"></div></template>
</div>
</section>
<section class="rail-section" x-data="{ n: 8 }">
<div class="skel skel-title"></div>
<div class="rail rail-skel">
<template x-for="i in n"><div class="rail-card skel skel-card"></div></template>
</div>
</section>
</div>
<template x-if="error">
<div class="login-error" x-text="error"></div>
</template>
<!-- ------------------------------------------------ Nouveautés -->
<section class="rail-section" x-show="!loading && latest.length > 0">
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in latest" :key="item.source + item.source_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-text="item.label"></span>
<span class="card-year" x-text="item.start_date ? item.start_date.slice(0, 4) : ''"></span>
<span class="card-airing" x-show="item.status === 'current'" title="En cours de diffusion">● EN COURS</span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
<div class="card-meta">
<span class="badge badge-type" x-show="item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
</div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
</section>
<!-- ------------------------------------------------ Incontournables -->
<section class="rail-section" x-show="!loading && mustWatch.length > 0">
<h2 class="rail-title">🔥 Incontournables <span class="rail-source">les classiques les mieux notés</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in mustWatch" :key="'mw' + item.kitsu_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/?q=${encodeURIComponent(item.title)}`" :title="`Rechercher « ${item.title} »`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-show="item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
<div class="card-meta"><span class="badge badge-type" x-text="item.year || ''"></span></div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
</section>
<!-- ------------------------------------------------ Pour toi -->
<section class="rail-section" x-show="!loading && forYou.items.length > 0">
<h2 class="rail-title">✨ Pour toi
<span class="rail-source" x-show="forYou.based_on.length"
x-text="forYou.based_on.length ? 'parce que tu aimes : ' + forYou.based_on.join(', ') : ''"></span>
</h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in forYou.items" :key="'fy' + item.kitsu_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/?q=${encodeURIComponent(item.title)}`" :title="`Rechercher « ${item.title} »`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-show="item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
<div class="card-meta"><span class="badge badge-type" x-text="item.year || ''"></span></div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
</section>
<template x-if="!loading && !error && latest.length === 0 && mustWatch.length === 0 && forYou.items.length === 0">
<div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div>
</template>
</div>
{% endblock %}
{% block scripts %}
<script>
function discoverPage() {
return {
latest: [], mustWatch: [], forYou: { based_on: [], items: [] },
loading: true, error: null,
scrollRail(el, dir) {
const rail = el.closest('.rail-wrap').querySelector('.rail');
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' });
},
async load() {
this.loading = true; this.error = null;
try {
const res = await fetch('/api/discover');
if (!res.ok) throw new Error('Erreur ' + res.status);
const data = await res.json();
this.latest = data.latest;
this.mustWatch = data.must_watch;
this.forYou = data.for_you;
} catch (e) { this.error = e.message; }
finally { this.loading = false; }
},
};
}
</script>
{% endblock %}
-122
View File
@@ -1,122 +0,0 @@
{% extends "base.html" %}
{% set active = 'downloads' %}
{% block title %}Téléchargements — Ohm Stream{% endblock %}
{% block content %}
<div x-data="downloadsPage()" x-init="connect()">
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem">
<div>
<h1 class="page-title">Téléchargements</h1>
<p class="page-sub" style="margin-bottom:0">
<span x-text="items.length"></span> tâche(s) —
<span :style="connected ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="connected ? '● temps réel' : '○ reconnecté…'"></span>
</p>
</div>
<div style="flex:1"></div>
<button class="btn btn-ghost btn-sm" @click="action('clear-finished')">🧹 Nettoyer</button>
<button class="btn btn-danger btn-sm" @click="action('cancel-all')">✖ Tout annuler</button>
</div>
<template x-if="items.length === 0">
<div class="empty-state"><div class="big">📭</div>Aucun téléchargement. Lance une recherche !</div>
</template>
<div class="episode-list">
<template x-for="d in items" :key="d.id">
<div class="dl-row">
<div class="dl-head">
<span class="status-pill" :class="'status-' + d.status" x-text="d.status_label"></span>
<span class="dl-title" x-text="d.title"></span>
<div class="dl-actions">
<template x-if="d.status === 'downloading'">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button>
</template>
<template x-if="d.status === 'paused'">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/resume')">▶</button>
</template>
<template x-if="['failed', 'cancelled'].includes(d.status)">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/retry')">↻</button>
</template>
<template x-if="['pending', 'downloading', 'paused'].includes(d.status)">
<button class="btn btn-sm btn-danger" @click="action(d.id + '/cancel')">✖</button>
</template>
<template x-if="d.status === 'done'">
<a class="btn btn-sm" :href="'/watch/' + d.id">▶ Regarder</a>
</template>
</div>
</div>
<div style="display:flex;align-items:center;gap:0.9rem">
<div class="progress">
<div class="progress-fill" :class="d.status"
:style="`width:${d.percent ?? (d.status === 'done' ? 100 : 0)}%`"></div>
</div>
<span style="min-width:4rem;text-align:right;font-size:0.85rem;font-weight:600"
x-text="d.percent != null ? d.percent + '%' : (d.status === 'done' ? '100%' : '—')"></span>
</div>
<div class="dl-stats">
<span x-text="fmtBytes(d.downloaded_bytes) + ' / ' + (d.total_bytes ? fmtBytes(d.total_bytes) : '?')"></span>
<span x-show="d.speed_bps" x-text="'⚡ ' + fmtBytes(d.speed_bps) + '/s'"></span>
<span x-show="d.eta_seconds != null" x-text="'⏱ ' + fmtEta(d.eta_seconds)"></span>
<span x-show="d.error" style="color:var(--danger)" x-text="d.error"></span>
</div>
</div>
</template>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function downloadsPage() {
return {
items: [], connected: false, es: null,
connect() {
this.es = new EventSource('/api/downloads/events');
this.es.onopen = () => this.connected = true;
this.es.onerror = () => this.connected = false;
this.es.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'snapshot') this.items = msg.items;
else if (msg.type === 'update') this.upsert(msg.item);
};
},
upsert(item) {
const i = this.items.findIndex(d => d.id === item.id);
if (i >= 0) this.items[i] = item;
else this.items.unshift(item);
this.sort();
},
sort() {
const order = { downloading: 0, pending: 1, paused: 2 };
this.items.sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
},
async action(path) {
await fetch('/api/downloads/' + path, { method: 'POST' });
if (path === 'clear-finished') {
const res = await fetch('/api/downloads');
this.items = await res.json();
}
},
fmtBytes(n) {
if (n == null) return '?';
const units = ['o', 'Ko', 'Mo', 'Go'];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return n.toFixed(i ? 1 : 0) + ' ' + units[i];
},
fmtEta(s) {
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'min';
return Math.floor(s / 3600) + 'h' + String(Math.floor((s % 3600) / 60)).padStart(2, '0');
},
};
}
</script>
{% endblock %}
-62
View File
@@ -1,62 +0,0 @@
{% extends "base.html" %}
{% set active = 'favorites' %}
{% block title %}Favoris — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Favoris</h1>
<p class="page-sub" x-data x-text="$store.fav?.total != null ? $store.fav.total + ' titre(s) suivi(s)' : ''"></p>
<div x-data="favoritesPage()" x-init="load()">
<div class="spinner" x-show="loading"></div>
<template x-if="!loading && items.length === 0">
<div class="empty-state"><div class="big">⭐</div>Aucun favori — ajoute-en depuis une fiche de titre.</div>
</template>
<div class="grid">
<template x-for="f in items" :key="f.id">
<div class="card">
<a :href="`/title/${f.source}/${encodeURIComponent(f.source_id)}`">
<img class="card-poster" :src="f.image_url || '/static/img/placeholder.svg'" loading="lazy"
onerror="this.src='/static/img/placeholder.svg'">
</a>
<span class="card-chip" x-text="f.source"></span>
<button class="btn btn-sm btn-danger" style="position:absolute;top:0.5rem;right:0.5rem"
@click.prevent="remove(f)">✖</button>
<div class="card-overlay">
<div class="card-title" x-text="f.title"></div>
</div>
</div>
</template>
</div>
<div style="display:flex;gap:0.6rem;justify-content:center;margin-top:1.6rem" x-show="total > limit">
<button class="btn btn-ghost btn-sm" :disabled="offset === 0" @click="page(-1)">← Précédent</button>
<button class="btn btn-ghost btn-sm" :disabled="offset + limit >= total" @click="page(1)">Suivant →</button>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function favoritesPage() {
return {
items: [], total: 0, offset: 0, limit: 24, loading: true,
async load() {
this.loading = true;
const res = await fetch(`/api/favorites?offset=${this.offset}&limit=${this.limit}`);
const data = await res.json();
this.items = data.items; this.total = data.total;
Alpine.store('fav', { total: data.total });
this.loading = false;
},
async page(dir) { this.offset = Math.max(0, this.offset + dir * this.limit); await this.load(); },
async remove(f) {
await fetch('/api/favorites/' + f.id, { method: 'DELETE' });
await this.load();
toast('Favori retiré');
},
};
}
</script>
{% endblock %}
-66
View File
@@ -1,66 +0,0 @@
{% extends "base.html" %}
{% set active = 'search' %}
{% block title %}Recherche — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Recherche unifiée</h1>
<p class="page-sub">Une seule requête interroge toutes tes sources activées.</p>
<div x-data="searchPage()">
<form class="search-bar" @submit.prevent="run">
<input type="search" x-model="query" placeholder="Naruto, One Piece, Frieren…" autofocus>
<button class="btn" type="submit" :disabled="loading">Rechercher</button>
</form>
<div class="spinner" x-show="loading"></div>
<template x-if="error">
<div class="login-error" x-text="error"></div>
</template>
<template x-if="!loading && searched && results.length === 0">
<div class="empty-state"><div class="big">🏜️</div>Aucun résultat pour cette recherche.</div>
</template>
<div class="grid" x-show="results.length > 0">
<template x-for="r in results" :key="r.source + r.source_id">
<a class="card" :href="`/title/${r.source}/${encodeURIComponent(r.source_id)}`">
<img class="card-poster" :src="r.image_url || '/static/img/placeholder.svg'" :alt="r.title" loading="lazy"
onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-text="r.source"></span>
<div class="card-overlay">
<div class="card-title" x-text="r.title"></div>
<div class="card-meta">
<span class="badge badge-type" x-text="r.media_type"></span>
</div>
</div>
</a>
</template>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function searchPage() {
return {
query: new URLSearchParams(location.search).get('q') || '',
results: [], loading: false, searched: false, error: null,
init() { if (this.query) this.run(); },
async run() {
if (this.query.trim().length < 2) return;
this.loading = true; this.error = null; this.searched = false;
history.replaceState(null, '', '?q=' + encodeURIComponent(this.query));
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(this.query.trim()));
if (!res.ok) throw new Error('Erreur ' + res.status);
const data = await res.json();
this.results = data.results;
if (data.failed_sources.length) this.error = 'Sources en échec : ' + data.failed_sources.join(', ');
} catch (e) { this.error = e.message; }
finally { this.loading = false; this.searched = true; }
}
};
}
</script>
{% endblock %}
-54
View File
@@ -1,54 +0,0 @@
{% extends "base.html" %}
{% set active = 'library' %}
{% block title %}Bibliothèque — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Bibliothèque</h1>
<p class="page-sub">Tes fichiers téléchargés, prêts à être regardés.</p>
<div x-data="libraryPage()" x-init="load()">
<div class="spinner" x-show="loading"></div>
<template x-if="!loading && items.length === 0">
<div class="empty-state"><div class="big">🎞️</div>Rien ici pour l'instant — télécharge des épisodes !</div>
</template>
<div class="episode-list">
<template x-for="f in items" :key="f.id">
<a class="episode-row" :href="'/watch/' + f.id">
<span style="font-size:1.3rem">🎬</span>
<span class="episode-name" style="font-weight:600" x-text="f.title"></span>
<span style="font-size:0.8rem;color:var(--text-dim)" x-text="fmtBytes(f.total_bytes)"></span>
<span x-show="f.position_seconds > 0" class="badge badge-type"
x-text="'⏵ ' + fmtTime(f.position_seconds)"></span>
<span class="btn btn-sm">▶</span>
</a>
</template>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function libraryPage() {
return {
items: [], loading: true,
async load() {
const res = await fetch('/api/library');
this.items = await res.json();
this.loading = false;
},
fmtBytes(n) {
if (n == null) return '';
const units = ['o', 'Ko', 'Mo', 'Go']; let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return n.toFixed(i ? 1 : 0) + ' ' + units[i];
},
fmtTime(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(sec).padStart(2, '0');
},
};
}
</script>
{% endblock %}
-52
View File
@@ -1,52 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion — Ohm Stream</title>
<link rel="stylesheet" href="/static/css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head>
<body class="login-page">
<div class="login-card" x-data="{ mode: 'login', error: null }"
x-init="
window.addEventListener('htmx:responseError', e => {
error = e.detail.xhr.status === 401 ? 'Identifiants invalides'
: e.detail.xhr.status === 409 ? 'Nom d\'utilisateur déjà pris'
: 'Erreur ' + e.detail.xhr.status;
})">
<span class="logo">OHM<span style="color:var(--text);font-weight:400">STREAM</span></span>
<p class="login-sub">Ton centre de contrôle anime &amp; séries</p>
<div class="login-tabs">
<div class="login-tab" :class="mode === 'login' && 'active'" @click="mode = 'login'; error = null">Connexion</div>
<div class="login-tab" :class="mode === 'register' && 'active'" @click="mode = 'register'; error = null">Inscription</div>
</div>
<div class="login-error" x-show="error" x-text="error" style="margin-bottom:0.8rem"></div>
<form x-show="mode === 'login'" hx-post="/auth/login" hx-swap="none">
<input class="input" name="username" placeholder="Nom d'utilisateur" required autocomplete="username">
<input class="input" name="password" type="password" placeholder="Mot de passe" required autocomplete="current-password">
<button class="btn" type="submit">Se connecter</button>
</form>
<form x-show="mode === 'register'" hx-post="/auth/register" hx-swap="none">
<input class="input" name="username" placeholder="Nom d'utilisateur (3+ caractères)" required minlength="3" autocomplete="username">
<input class="input" name="password" type="password" placeholder="Mot de passe (6+ caractères)" required minlength="6" autocomplete="new-password">
<button class="btn" type="submit">Créer mon compte</button>
<p style="font-size:0.75rem;color:var(--text-dim);text-align:center">Le premier compte créé est administrateur.</p>
</form>
</div>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
// Redirection après login/register réussi (303 intercepté par htmx)
document.body.addEventListener('htmx:afterRequest', e => {
if (e.detail.successful && (e.detail.pathInfo.requestPath.startsWith('/auth/login') || e.detail.pathInfo.requestPath.startsWith('/auth/register'))) {
window.location.href = '/';
}
});
</script>
</body>
</html>
-176
View File
@@ -1,176 +0,0 @@
{% extends "base.html" %}
{% set active = 'search' %}
{% block title %}Fiche — Ohm Stream{% endblock %}
{% block content %}
<div x-data="titlePage()" data-source="{{ source }}" data-source-id="{{ source_id }}"
x-init="load($el.dataset.source, $el.dataset.sourceId)">
<div class="spinner" x-show="loading"></div>
<template x-if="error">
<div class="login-error" x-text="error"></div>
</template>
<template x-if="details">
<div>
<div class="title-hero">
<div class="title-banner" :style="details.banner_url || details.image_url ? `background-image:url('${details.banner_url || details.image_url}')` : ''"></div>
<div class="title-hero-inner">
<img class="title-poster" :src="details.image_url || '/static/img/placeholder.svg'"
onerror="this.src='/static/img/placeholder.svg'">
<div class="title-info">
<h1 x-text="details.title"></h1>
<div class="title-tags">
<span class="badge badge-source" x-text="details.source"></span>
<template x-for="g in details.genres.slice(0, 6)"><span class="badge badge-type" x-text="g"></span></template>
</div>
<p class="title-synopsis" x-text="details.synopsis || 'Pas de synopsis disponible.'"></p>
<div class="title-stats">
<span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span>
<span x-show="details.year">Année : <strong x-text="details.year"></strong></span>
<span><strong x-text="details.episodes.length"></strong> épisode(s)</span>
</div>
<div class="title-actions">
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
<span x-show="!seasonJob.running">⬇ Télécharger la saison</span>
<span x-show="seasonJob.running" x-text="`⏳ ${seasonJob.done}/${seasonJob.total} ajoutés…`"></span>
</button>
<button class="btn btn-ghost" @click="toggleFavorite">
<span x-text="favorite ? '★ Dans les favoris' : '☆ Ajouter aux favoris'"></span>
</button>
</div>
</div>
</div>
</div>
<h2 class="section-title">Épisodes</h2>
<div class="episode-list">
<template x-for="ep in details.episodes" :key="ep.number">
<div class="episode-row">
<span class="episode-num" x-text="'E' + ep.number"></span>
<span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span>
<button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button>
</div>
</template>
</div>
<!-- Lecteur modal -->
<div x-show="player.active" style="position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:50;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:0.8rem"
@keydown.escape.window="closePlayer()" x-transition x-cloak>
<video x-ref="player" controls autoplay style="max-width:90vw;max-height:80vh;border-radius:8px"></video>
<button class="btn btn-ghost" @click="closePlayer()">Fermer ✕</button>
</div>
</div>
</template>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>
<script>
function titlePage() {
return {
source: null, sourceId: null,
details: null, loading: true, error: null, favorite: false,
player: { active: false, hls: null },
seasonJob: { running: false, done: 0, total: 0 },
async load(source, sourceId) {
this.source = source; this.sourceId = sourceId;
try {
const res = await fetch(`/api/titles/${source}/${encodeURIComponent(sourceId)}`);
if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
this.details = await res.json();
this.details.episodes.forEach(e => e._busy = false);
document.title = this.details.title + ' — Ohm Stream';
} catch (e) { this.error = e.message; }
finally { this.loading = false; }
},
// Chaîne complète : page épisode → embeds → lien vidéo direct
async extract(ep) {
const res = await fetch('/api/extract?episode_url=' + encodeURIComponent(ep.url));
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Extraction impossible');
if (!data.links.length) throw new Error(data.errors[0] || 'Aucun lien vidéo résolu');
return data.links[0];
},
epLabel(ep) { return `${this.details.title} - E${ep.number}`; },
// Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
openPlayer(link) {
const ref = link.headers?.Referer || '';
const src = '/api/proxy?url=' + encodeURIComponent(link.url)
+ (ref ? '&ref=' + encodeURIComponent(ref) : '');
const video = this.$refs.player;
if (link.is_hls && window.Hls && window.Hls.isSupported()) {
this.player.hls = new Hls();
this.player.hls.loadSource(src);
this.player.hls.attachMedia(video);
} else {
video.src = src; // Safari lit le HLS nativement
}
this.player.active = true;
},
closePlayer() {
if (this.player.hls) { this.player.hls.destroy(); this.player.hls = null; }
this.$refs.player.pause();
this.$refs.player.removeAttribute('src');
this.player.active = false;
},
async streamEpisode(ep) {
ep._busy = true;
try {
const link = await this.extract(ep);
this.openPlayer(link);
} catch (e) { toast('⚠ ' + e.message); }
finally { ep._busy = false; }
},
async downloadEpisode(ep) {
ep._busy = true;
try {
const link = await this.extract(ep);
const referer = link.headers?.Referer || ep.url;
const internal = [link.url, referer, this.epLabel(ep)].join('|');
await fetch('/api/downloads', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_url: internal }),
});
toast('⬇ Ajouté : ' + this.epLabel(ep));
} catch (e) { toast('⚠ ' + e.message); }
finally { ep._busy = false; }
},
async downloadSeason() {
this.seasonJob = { running: true, done: 0, total: this.details.episodes.length };
for (const ep of this.details.episodes) {
await this.downloadEpisode(ep);
this.seasonJob.done++;
}
this.seasonJob.running = false;
toast('✔ Saison ajoutée à la file de téléchargement');
},
async toggleFavorite() {
if (this.favorite) return;
await fetch('/api/favorites', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: this.source, source_id: this.sourceId,
title: this.details.title, image_url: this.details.image_url,
payload: { year: this.details.year, genres: this.details.genres },
}),
});
this.favorite = true;
toast('⭐ Ajouté aux favoris');
},
};
}
</script>
{% endblock %}
-62
View File
@@ -1,62 +0,0 @@
{% extends "base.html" %}
{% set active = 'library' %}
{% block title %}Lecture — Ohm Stream{% endblock %}
{% block content %}
<div x-data="watchPage({{ download_id }})" x-init="load()">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.2rem">
<a href="/library" class="btn btn-ghost btn-sm">← Bibliothèque</a>
<h1 class="page-title" style="margin:0" x-text="file ? file.title : '…'"></h1>
<div style="flex:1"></div>
<a class="btn btn-ghost btn-sm" x-show="neighbors.previous" :href="'/watch/' + neighbors.previous">⏮ Précédent</a>
<a class="btn btn-sm" x-show="neighbors.next" :href="'/watch/' + neighbors.next">Suivant ⏭</a>
</div>
<div class="player-wrap">
<video x-ref="video" :src="`/api/stream/${id}`" controls
@timeupdate.throttle.5s="saveProgress"
@loadedmetadata="restorePosition">
</video>
</div>
<p class="page-sub" x-show="position > 0">⏵ Reprise automatique à ta position précédente.</p>
</div>
{% endblock %}
{% block scripts %}
<script>
function watchPage(id) {
return {
id, file: null, neighbors: {}, position: 0,
async load() {
const [fileRes, neighRes] = await Promise.all([
fetch('/api/downloads/' + id),
fetch(`/api/library/${id}/neighbors`),
]);
this.file = await fileRes.json();
this.neighbors = await neighRes.json();
document.title = (this.file.title || 'Lecture') + ' — Ohm Stream';
// Position enregistrée côté serveur via /api/library
const lib = await fetch('/api/library').then(r => r.json());
const entry = lib.find(f => f.id === id);
this.position = entry?.position_seconds || 0;
},
restorePosition() {
if (this.position > 5 && this.$refs.video.duration - this.position > 10) {
this.$refs.video.currentTime = this.position;
}
},
saveProgress() {
const t = this.$refs.video.currentTime;
if (!t) return;
fetch(`/api/stream/${id}/progress`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ position_seconds: t }),
});
},
};
}
</script>
{% endblock %}
+700
View File
@@ -0,0 +1,700 @@
from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException
from fastapi.responses import StreamingResponse, FileResponse, JSONResponse, Response
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi import Request
import uvicorn
from pathlib import Path
from typing import List
import shutil
import os
import re
from app.models import DownloadRequest, DownloadTask, DownloadStatus
from app.download_manager import DownloadManager
from app.downloaders import AnimeSamaDownloader
from app import providers
from app.favorites import get_favorites_manager
app = FastAPI(title="Ohm Stream Downloader")
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize download manager
download_manager = DownloadManager(download_dir="downloads", max_parallel=3)
# Mount static files and templates
app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/downloads", StaticFiles(directory="downloads"), name="downloads")
templates = Jinja2Templates(directory="templates")
@app.get("/")
async def root():
return {
"message": "Ohm Stream Downloader API",
"status": "running",
"version": "2.2",
"endpoints": {
"POST /api/download": "Start a new download",
"GET /api/downloads": "List all downloads",
"GET /api/download/{task_id}": "Get download status",
"POST /api/download/{task_id}/pause": "Pause a download",
"POST /api/download/{task_id}/resume": "Resume a download",
"DELETE /api/download/{task_id}": "Cancel a download",
"GET /api/providers": "List all supported providers",
"GET /api/anime/search": "Search anime across all providers",
"GET /api/anime/metadata": "Get detailed anime metadata (synopsis, genres, rating, etc.)",
"GET /api/anime/episodes": "Get episode list for an anime",
"POST /api/anime/download-season": "Download all episodes of a season",
"GET /api/favorites": "List all favorite anime",
"POST /api/favorites": "Add anime to favorites",
"DELETE /api/favorites/{anime_id}": "Remove from favorites",
"GET /api/favorites/{anime_id}": "Get favorite anime details",
"GET /api/favorites/stats": "Get favorites statistics",
"POST /api/favorites/toggle": "Toggle anime in favorites",
"GET /web": "Web interface"
}
}
@app.get("/api/providers")
async def list_providers():
"""List all supported anime and file hosting providers"""
return {
"anime_providers": providers.get_anime_providers(),
"file_hosts": providers.get_file_hosts()
}
@app.get("/health")
async def health():
return {"status": "healthy"}
# Web Interface
@app.get("/web")
async def web_interface(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
# API Endpoints
@app.post("/api/download")
async def create_download(request: DownloadRequest, background_tasks: BackgroundTasks):
"""Create a new download task"""
task = download_manager.create_task(request)
background_tasks.add_task(download_manager.start_download, task.id)
return {"task_id": task.id, "task": task}
@app.get("/api/downloads")
async def list_downloads():
"""List all download tasks"""
return {"downloads": download_manager.get_all_tasks()}
@app.get("/api/download/{task_id}")
async def get_download_status(task_id: str):
"""Get status of a specific download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@app.post("/api/download/{task_id}/pause")
async def pause_download(task_id: str):
"""Pause a download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await download_manager.pause_download(task_id)
return {"status": "paused"}
@app.post("/api/download/{task_id}/resume")
async def resume_download(task_id: str, background_tasks: BackgroundTasks):
"""Resume a paused download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status == DownloadStatus.PAUSED:
background_tasks.add_task(download_manager.start_download, task_id)
return {"status": "resumed"}
return {"status": "already running or completed"}
@app.delete("/api/download/{task_id}")
async def cancel_download(task_id: str):
"""Cancel a download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await download_manager.cancel_download(task_id)
return {"status": "cancelled"}
@app.get("/api/download/{task_id}/file")
async def download_file(task_id: str):
"""Download the completed file"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED:
raise HTTPException(status_code=400, detail="Download not completed")
if not task.file_path or not os.path.exists(task.file_path):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
task.file_path,
filename=task.filename,
media_type='application/octet-stream'
)
# Unified Anime Search endpoints
@app.get("/api/anime/search")
async def search_anime_unified(q: str, lang: str = "vostfr", include_metadata: bool = False):
"""
Search across all anime providers
Args:
q: Search query
lang: Language preference (vostfr, vf)
include_metadata: Whether to fetch full metadata (slower but more detailed)
"""
import time
import asyncio
from app.providers import get_anime_providers
from app.downloaders import AnimeSamaDownloader, AnimeUltimeDownloader, NekoSamaDownloader, VostfreeDownloader
print(f"\n[SEARCH] Starting search for '{q}' in {lang} (metadata={include_metadata})")
start_time = time.time()
results = {}
# Create downloader instances
downloaders = {
"anime-sama": AnimeSamaDownloader(),
"anime-ultime": AnimeUltimeDownloader(),
"neko-sama": NekoSamaDownloader(),
"vostfree": VostfreeDownloader()
}
# Search across all providers in parallel with timeout
search_tasks = []
provider_ids = []
for provider_id, provider in get_anime_providers().items():
if provider_id in downloaders:
downloader = downloaders[provider_id]
print(f"[SEARCH] Queueing search on {provider_id}...")
search_tasks.append(downloader.search_anime(q, lang, include_metadata=include_metadata))
provider_ids.append(provider_id)
# Wait for all searches to complete with a timeout per provider
print(f"[SEARCH] Waiting for {len(search_tasks)} searches...")
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
# Combine results
for provider_id, result in zip(provider_ids, search_results):
if isinstance(result, Exception):
print(f"[SEARCH] {provider_id} error: {str(result)}")
elif result:
print(f"[SEARCH] {provider_id} found {len(result)} results")
results[provider_id] = result
else:
print(f"[SEARCH] {provider_id} no results")
elapsed = time.time() - start_time
print(f"[SEARCH] Completed in {elapsed:.2f}s - Total results: {sum(len(r) for r in results.values())}\n")
return {
"query": q,
"lang": lang,
"include_metadata": include_metadata,
"results": results
}
@app.get("/api/anime/metadata")
async def get_anime_metadata(url: str):
"""
Get detailed metadata for a specific anime
Args:
url: The anime page URL
"""
from app.downloaders import get_downloader
try:
downloader = get_downloader(url)
# Check if the downloader has metadata support
if hasattr(downloader, 'get_anime_metadata'):
metadata = await downloader.get_anime_metadata(url)
return {
"url": url,
"metadata": metadata
}
else:
raise HTTPException(
status_code=400,
detail=f"Downloader for {url} does not support metadata extraction"
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/anime/episodes")
async def get_anime_episodes(url: str, lang: str = "vostfr"):
"""Get list of episodes for an anime"""
from app.downloaders import get_downloader
downloader = get_downloader(url)
episodes = await downloader.get_episodes(url, lang)
return {
"url": url,
"lang": lang,
"episodes": episodes
}
@app.get("/api/anime/providers")
async def get_anime_providers_list():
"""Get list of anime providers with info"""
from app.providers import get_anime_providers
return {"providers": get_anime_providers()}
# Anime-Sama specific endpoints (legacy)
@app.get("/api/anime-sama/search")
async def search_anime_sama(q: str, lang: str = "vostfr"):
"""Search for anime on anime-sama"""
downloader = AnimeSamaDownloader()
results = await downloader.search_anime(q, lang)
return {"query": q, "lang": lang, "results": results}
@app.post("/api/anime/download")
async def download_anime_episode(
url: str,
background_tasks: BackgroundTasks,
episode: str | None = None
):
"""Download an anime episode"""
# Construct episode URL if not provided
if episode and 'episode-' not in url:
url = f"{url.rstrip('/')}/episode-{episode}"
request = DownloadRequest(url=url)
task = download_manager.create_task(request)
background_tasks.add_task(download_manager.start_download, task.id)
return {"task_id": task.id, "task": task}
@app.post("/api/anime/download-season")
async def download_anime_season(
url: str,
background_tasks: BackgroundTasks,
lang: str = "vostfr"
):
"""Download all episodes of an anime season"""
from app.downloaders import get_downloader
downloader = get_downloader(url)
episodes = await downloader.get_episodes(url, lang)
if not episodes:
raise HTTPException(status_code=404, detail="No episodes found")
# Create download tasks for all episodes
task_ids = []
for episode in episodes:
request = DownloadRequest(url=episode['url'])
task = download_manager.create_task(request)
task_ids.append(task.id)
background_tasks.add_task(download_manager.start_download, task.id)
return {
"message": f"Started downloading {len(task_ids)} episodes",
"task_ids": task_ids,
"total_episodes": len(episodes)
}
# Video Streaming endpoints
@app.get("/video/{task_id}")
async def stream_video(task_id: str, request: Request):
"""Stream a video file with Range support for seeking"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED:
raise HTTPException(status_code=400, detail="Download not completed")
if not task.file_path or not os.path.exists(task.file_path):
raise HTTPException(status_code=404, detail="File not found")
file_path = Path(task.file_path)
file_size = file_path.stat().st_size
# Parse Range header
range_header = request.headers.get("range")
headers = {
"Accept-Ranges": "bytes",
"Content-Type": "video/mp4",
}
if range_header:
# Parse Range header (format: bytes=start-end)
try:
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
start = int(range_match.group(1))
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
# Validate range
if start >= file_size or end >= file_size or start > end:
headers["Content-Range"] = f"bytes */{file_size}"
return Response(
status_code=416,
headers=headers,
content="Requested Range Not Satisfiable"
)
# Read the requested range
content_length = end - start + 1
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
headers["Content-Length"] = str(content_length)
async def video_range_reader():
with open(file_path, 'rb') as f:
f.seek(start)
remaining = content_length
while remaining > 0:
chunk_size = min(1024 * 1024, remaining) # 1MB chunks
data = f.read(chunk_size)
if not data:
break
remaining -= len(data)
yield data
return Response(
content=video_range_reader(),
status_code=206,
headers=headers
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
else:
# No Range header - stream entire file
async def video_reader():
with open(file_path, 'rb') as f:
while True:
data = f.read(1024 * 1024) # 1MB chunks
if not data:
break
yield data
headers["Content-Length"] = str(file_size)
return Response(
content=video_reader(),
headers=headers
)
# Direct video streaming endpoint (by filename)
@app.get("/stream/{filename}")
async def stream_video_by_filename(filename: str, request: Request):
"""Stream a video file by filename with Range support for seeking"""
# Sanitize filename to prevent directory traversal
filename = os.path.basename(filename)
file_path = Path("downloads") / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
file_size = file_path.stat().st_size
# Parse Range header
range_header = request.headers.get("range")
if range_header:
# Parse Range header (format: bytes=start-end)
try:
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
start = int(range_match.group(1))
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
# Validate range
if start >= file_size or end >= file_size or start > end:
return Response(
status_code=416,
headers={
"Content-Range": f"bytes */{file_size}",
"Accept-Ranges": "bytes"
},
content="Requested Range Not Satisfiable"
)
# Read the requested range
content_length = end - start + 1
def video_range_reader():
with open(file_path, 'rb') as f:
f.seek(start)
remaining = content_length
while remaining > 0:
chunk_size = min(1024 * 1024, remaining) # 1MB chunks
data = f.read(chunk_size)
if not data:
break
remaining -= len(data)
yield data
return StreamingResponse(
video_range_reader(),
status_code=206,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(content_length),
"Accept-Ranges": "bytes",
"Content-Type": "video/mp4",
}
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
else:
# No Range header - stream entire file
def video_reader():
with open(file_path, 'rb') as f:
while True:
data = f.read(1024 * 1024) # 1MB chunks
if not data:
break
yield data
return StreamingResponse(
video_reader(),
headers={
"Content-Length": str(file_size),
"Accept-Ranges": "bytes",
"Content-Type": "video/mp4",
}
)
# Video Player page (by task_id)
@app.get("/player/{task_id}")
async def video_player(request: Request, task_id: str):
"""Video player page for watching downloaded anime"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED:
raise HTTPException(status_code=400, detail="Download not completed")
if not task.file_path or not os.path.exists(task.file_path):
raise HTTPException(status_code=404, detail="File not found")
# Get video info
file_path = Path(task.file_path)
file_size = file_path.stat().st_size
# Calculate video duration (rough estimation based on file size)
# Assuming ~1MB per minute for 720p, ~2MB per minute for 1080p
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
return templates.TemplateResponse("player.html", {
"request": request,
"task_id": task_id,
"filename": task.filename,
"file_size": file_size,
"estimated_duration": estimated_duration_seconds
})
# Video Player page (by filename)
@app.get("/watch/{filename}")
async def video_player_by_filename(request: Request, filename: str):
"""Video player page for watching downloaded anime by filename"""
# Sanitize filename
filename = os.path.basename(filename)
file_path = Path("downloads") / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
file_size = file_path.stat().st_size
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
return templates.TemplateResponse("player.html", {
"request": request,
"task_id": filename, # Use filename instead of task_id
"filename": filename,
"file_size": file_size,
"estimated_duration": estimated_duration_seconds
})
# ==================== FAVORITES API ====================
@app.get("/api/favorites")
async def list_favorites(
sort_by: str = "created_at",
order: str = "desc",
filter_provider: str = None,
filter_genre: str = None
):
"""
List all favorite anime with optional sorting and filtering
Query params:
- sort_by: title, rating, year, created_at, updated_at (default: created_at)
- order: asc, desc (default: desc)
- filter_provider: Filter by provider (anime-sama, neko-sama, etc.)
- filter_genre: Filter by genre (Action, Adventure, etc.)
"""
fav_manager = get_favorites_manager()
favorites = await fav_manager.list_favorites(
sort_by=sort_by,
order=order,
filter_provider=filter_provider,
filter_genre=filter_genre
)
return {
"favorites": favorites,
"total": len(favorites),
"filters": {
"sort_by": sort_by,
"order": order,
"provider": filter_provider,
"genre": filter_genre
}
}
@app.post("/api/favorites")
async def add_favorite(request: Request):
"""
Add an anime to favorites
Body params (JSON):
- anime_id: Unique identifier (e.g., provider + slug)
- title: Anime title
- url: Anime page URL
- provider: Provider name
- metadata: Optional metadata dict (synopsis, genres, rating, etc.)
- poster_url: Optional poster image URL
"""
import json
data = await request.json()
required_fields = ["anime_id", "title", "url", "provider"]
for field in required_fields:
if field not in data:
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
fav_manager = get_favorites_manager()
favorite = await fav_manager.add_favorite(
anime_id=data["anime_id"],
title=data["title"],
url=data["url"],
provider=data["provider"],
metadata=data.get("metadata"),
poster_url=data.get("poster_url")
)
return {"status": "added", "favorite": favorite}
@app.delete("/api/favorites/{anime_id}")
async def remove_favorite(anime_id: str):
"""Remove an anime from favorites"""
fav_manager = get_favorites_manager()
removed = await fav_manager.remove_favorite(anime_id)
if not removed:
raise HTTPException(status_code=404, detail="Favorite not found")
return {"status": "removed", "anime_id": anime_id}
@app.get("/api/favorites/{anime_id}")
async def get_favorite(anime_id: str):
"""Get details of a specific favorite anime"""
fav_manager = get_favorites_manager()
favorite = await fav_manager.get_favorite(anime_id)
if not favorite:
raise HTTPException(status_code=404, detail="Favorite not found")
return {"favorite": favorite}
@app.get("/api/favorites/stats")
async def get_favorites_stats():
"""Get statistics about favorites"""
fav_manager = get_favorites_manager()
stats = await fav_manager.get_stats()
return stats
@app.post("/api/favorites/toggle")
async def toggle_favorite(request: Request):
"""
Toggle an anime in favorites (add if not exists, remove if exists)
Body params (JSON):
- anime_id: Unique identifier
- title: Anime title
- url: Anime page URL
- provider: Provider name
- metadata: Optional metadata dict
- poster_url: Optional poster image URL
"""
import json
data = await request.json()
required_fields = ["anime_id", "title", "url", "provider"]
for field in required_fields:
if field not in data:
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
fav_manager = get_favorites_manager()
result = await fav_manager.toggle_favorite(
anime_id=data["anime_id"],
title=data["title"],
url=data["url"],
provider=data["provider"],
metadata=data.get("metadata"),
poster_url=data.get("poster_url")
)
return result
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=3000,
reload=True
)
-38
View File
@@ -1,38 +0,0 @@
[project]
name = "ohm-stream"
version = "0.1.0"
description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR"
requires-python = ">=3.13"
dependencies = [
"aiosqlite>=0.22.1",
"beautifulsoup4>=4.15.0",
"fastapi>=0.141.1",
"httpx>=0.28.1",
"jinja2>=3.1.6",
"lxml>=6.1.3",
"pwdlib[argon2]>=0.3.1",
"pydantic-settings>=2.15.0",
"pyjwt>=2.14.0",
"python-multipart>=0.0.32",
"pyyaml>=6.0.3",
"sse-starlette>=3.4.11",
"uvicorn[standard]>=0.53.0",
]
[dependency-groups]
dev = [
"aiohttp>=3.14.3",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"ruff>=0.16.8",
]
[tool.ruff]
target-version = "py313"
line-length = 100
extend-exclude = [".plasma"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
+11
View File
@@ -0,0 +1,11 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
python-multipart==0.0.20
aiofiles==24.1.0
pydantic==2.10.4
pydantic-settings==2.7.1
httpx==0.28.1
aiohttp==3.11.11
beautifulsoup4==4.12.3
lxml==5.3.0
jieba==0.42.1
+1309
View File
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ filename }} - Ohm Stream Player</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
color: #fff;
}
.container {
max-width: 1200px;
width: 100%;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
font-size: 1.5rem;
margin-bottom: 10px;
color: #00d9ff;
}
.video-info {
background: rgba(255, 255, 255, 0.05);
padding: 15px 20px;
border-radius: 10px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.video-info .filename {
font-size: 1.1rem;
font-weight: 500;
}
.video-info .filesize {
color: #aaa;
font-size: 0.9rem;
}
.video-wrapper {
background: #000;
border-radius: 15px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
video {
width: 100%;
display: block;
max-height: 80vh;
}
.controls {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn:hover {
background: rgba(0, 217, 255, 0.2);
border-color: #00d9ff;
transform: translateY(-2px);
}
.btn-primary {
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
border: none;
color: #000;
font-weight: 600;
}
.btn-primary:hover {
background: linear-gradient(135deg, #00ff88 0%, #00d9ff 100%);
}
.error-message {
background: rgba(255, 71, 87, 0.1);
border: 1px solid #ff4757;
color: #ff4757;
padding: 20px;
border-radius: 10px;
text-align: center;
margin-top: 20px;
}
.loading {
text-align: center;
padding: 60px 20px;
color: #aaa;
}
.loading::after {
content: '...';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
@media (max-width: 768px) {
.header h1 {
font-size: 1.2rem;
}
.video-info {
flex-direction: column;
align-items: flex-start;
}
.controls {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎬 Ohm Stream Player</h1>
</div>
<div class="video-info">
<span class="filename">{{ filename }}</span>
<span class="filesize">{{ "%.2f"|format(file_size / 1024 / 1024) }} MB</span>
</div>
<div class="video-wrapper">
<video controls preload="metadata">
<source src="/stream/{{ filename }}" type="video/mp4">
<div class="error-message">
Votre navigateur ne supporte pas la lecture vidéo.<br>
<a href="/stream/{{ filename }}" style="color: #00d9ff;">Télécharger la vidéo</a>
</div>
</video>
</div>
<div class="controls">
<a href="/web" class="btn">← Retour à l'accueil</a>
<a href="/stream/{{ filename }}" class="btn btn-primary" download>⬇️ Télécharger</a>
</div>
</div>
<script>
// Video error handling
const video = document.querySelector('video');
video.addEventListener('error', (e) => {
console.error('Video error:', e);
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.innerHTML = `
Erreur lors du chargement de la vidéo.<br>
<a href="/video/{{ task_id }}" style="color: #00d9ff;">Réessayer</a>
`;
video.parentNode.replaceChild(errorDiv, video);
});
// Video loaded successfully
video.addEventListener('loadedmetadata', () => {
console.log('Video duration:', video.duration);
});
// Log seeking events for debugging
video.addEventListener('seeking', () => {
console.log('Seeking to:', video.currentTime);
});
video.addEventListener('seeked', () => {
console.log('Seeked to:', video.currentTime);
});
</script>
</body>
</html>
-40
View File
@@ -1,40 +0,0 @@
import os
import tempfile
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
# Environnement de test AVANT tout import de l'app
_tmp = Path(tempfile.mkdtemp(prefix="ohm-test-"))
os.environ["OHM_DATA_DIR"] = str(_tmp)
os.environ["OHM_DOWNLOAD_DIR"] = str(_tmp / "downloads")
os.environ["OHM_DATABASE_PATH"] = str(_tmp / "test.db")
os.environ["OHM_SECRET_KEY"] = "test-secret-key-with-32-bytes-minimum!"
from app.config import get_settings
from app.db import db
@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture(autouse=True)
async def database() -> AsyncIterator[None]:
"""DB fraîche par test."""
get_settings.cache_clear()
await db.connect()
for table in (
"users",
"refresh_tokens",
"downloads",
"metadata_cache",
"settings",
"favorites",
"watch_progress",
):
await db.execute(f"DELETE FROM {table}")
yield
await db.close()
-123
View File
@@ -1,123 +0,0 @@
"""Tests d'intégration API (auth, downloads, favoris, streaming, admin)."""
import pytest
from httpx import ASGITransport, AsyncClient
from app.config import get_settings
from app.db import db
from app.main import app
@pytest.fixture
async def client() -> AsyncClient:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def admin_cookies(client: AsyncClient):
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
assert r.status_code == 303
return r.cookies
async def test_health(client):
r = await client.get("/health")
assert r.status_code == 200 and r.json() == {"status": "ok"}
async def test_pages_require_auth(client):
r = await client.get("/", follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/login"
r = await client.get("/api/downloads", follow_redirects=False)
assert r.status_code == 303
async def test_register_login_me(client, admin_cookies):
r = await client.get("/auth/me", cookies=admin_cookies)
data = r.json()
assert data["username"] == "admin" and data["is_admin"] is True
async def test_duplicate_username(client):
await client.post("/auth/register", data={"username": "toto", "password": "secret123"})
r = await client.post("/auth/register", data={"username": "toto", "password": "secret123"})
assert r.status_code == 409
async def test_enqueue_bad_internal_url(client, admin_cookies):
r = await client.post(
"/api/downloads", json={"internal_url": "invalide"}, cookies=admin_cookies
)
assert r.status_code == 422
async def test_favorites_flow(client, admin_cookies):
fav = {"source": "vostfree", "source_id": "1-x", "title": "Frieren"}
r = await client.post("/api/favorites", json=fav, cookies=admin_cookies)
assert r.status_code == 201
r = await client.post("/api/favorites", json=fav, cookies=admin_cookies)
assert r.status_code == 409
r = await client.get("/api/favorites", cookies=admin_cookies)
assert r.json()["total"] == 1
fav_id = r.json()["items"][0]["id"]
r = await client.delete(f"/api/favorites/{fav_id}", cookies=admin_cookies)
assert r.status_code == 200
async def test_stream_range(client, admin_cookies):
payload = b"video-bytes" * 1000
path = get_settings().download_dir / "ep1.mp4"
path.write_bytes(payload)
await db.execute(
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path, status, "
"total_bytes, downloaded_bytes) VALUES ('k', 'u', 'p', 'Ep 1', 'ep1.mp4', 'done', ?, ?)",
(len(payload), len(payload)),
)
r = await client.get("/api/stream/1", cookies=admin_cookies)
assert r.status_code == 200 and r.content == payload
r = await client.get("/api/stream/1", headers={"Range": "bytes=10-19"}, cookies=admin_cookies)
assert r.status_code == 206
assert r.content == payload[10:20]
assert r.headers["content-range"] == f"bytes 10-19/{len(payload)}"
r = await client.get(
"/api/stream/1", headers={"Range": "bytes=999999999-"}, cookies=admin_cookies
)
assert r.status_code == 416
async def test_admin_required(client):
await client.post("/auth/register", data={"username": "admin2", "password": "secret123"})
r = await client.post("/auth/register", data={"username": "user", "password": "secret123"})
user_cookies = r.cookies
r = await client.get("/api/admin/users", cookies=user_cookies)
assert r.status_code == 403
async def test_admin_user_management(client, admin_cookies):
await client.post("/auth/register", data={"username": "user", "password": "secret123"})
r = await client.get("/api/admin/users", cookies=admin_cookies)
users = {u["username"]: u["id"] for u in r.json()}
assert set(users) == {"admin", "user"}
admin_id, user_id = users["admin"], users["user"]
r = await client.post(f"/api/admin/users/{user_id}/toggle-active", cookies=admin_cookies)
assert r.json() == {"is_active": False}
r = await client.post(f"/api/admin/users/{user_id}/toggle-admin", cookies=admin_cookies)
assert r.json() == {"is_admin": True}
# pas d'auto-modification
r = await client.post(f"/api/admin/users/{admin_id}/toggle-admin", cookies=admin_cookies)
assert r.status_code == 400
r = await client.delete(f"/api/admin/users/{user_id}", cookies=admin_cookies)
assert r.status_code == 200
async def test_source_toggle(client, admin_cookies):
r = await client.post(
"/api/admin/sources/vostfree/toggle", json={"enabled": False}, cookies=admin_cookies
)
assert r.json()["enabled"] is False
r = await client.get("/api/sources", cookies=admin_cookies)
states = {s["name"]: s["enabled"] for s in r.json()}
assert states["vostfree"] is False
assert states["french_manga"] is True
-109
View File
@@ -1,109 +0,0 @@
"""Tests unitaires : format interne, sanitisation, auth, registres scrapers."""
import pytest
from app import auth
from app.scrapers.base import (
decode_internal_url,
encode_internal_url,
get_source,
import_all_scrapers,
resolve_hoster,
)
from app.services.downloads import sanitize_filename
# ------------------------------------------------------------ format interne
def test_internal_url_roundtrip():
value = encode_internal_url("https://cdn/x.mp4", "https://site/ep1", "Mon Titre")
assert decode_internal_url(value) == ("https://cdn/x.mp4", "https://site/ep1", "Mon Titre")
def test_internal_url_rejects_separator():
with pytest.raises(ValueError):
encode_internal_url("https://a|b", "p", "t")
def test_internal_url_decode_invalid():
with pytest.raises(ValueError):
decode_internal_url("un|deux")
# ------------------------------------------------------------ sanitisation
@pytest.mark.parametrize(
("raw", "expected"),
[
("Naruto: Ép 01 <VOSTFR> / test", "Naruto Ép 01 VOSTFR test"),
("..", "video"),
("", "video"),
("titre/norm\\al", "titre norm al"),
(" espaces multiples ", "espaces multiples"),
],
)
def test_sanitize_filename(raw, expected):
assert sanitize_filename(raw) == expected
def test_sanitize_no_traversal():
assert "/" not in sanitize_filename("../../etc/passwd")
assert sanitize_filename("../../etc/passwd") == "etc passwd"
# ------------------------------------------------------------ auth
async def test_first_user_is_admin():
user = await auth.create_user("alice", "password1")
assert user.is_admin is True
second = await auth.create_user("bob", "password1")
assert second.is_admin is False
async def test_authenticate():
await auth.create_user("carol", "password1")
assert await auth.authenticate("carol", "password1") is not None
assert await auth.authenticate("carol", "wrong") is None
assert await auth.authenticate("nobody", "password1") is None
async def test_access_token_roundtrip():
user = await auth.create_user("dave", "password1")
token = auth.create_access_token(user)
payload = auth.decode_access_token(token)
assert payload["username"] == "dave"
assert auth.decode_access_token("garbage") is None
async def test_refresh_token_rotation():
user = await auth.create_user("erin", "password1")
token = await auth.create_refresh_token(user.id)
assert (await auth.use_refresh_token(token)).username == "erin"
# Un refresh token est à usage unique (rotation)
assert await auth.use_refresh_token(token) is None
async def test_disabled_account_cannot_login():
user = await auth.create_user("frank", "password1")
await auth.db.execute("UPDATE users SET is_active = 0 WHERE id = ?", (user.id,))
assert await auth.authenticate("frank", "password1") is None
# ------------------------------------------------------------ registres scrapers
def test_sources_registered():
import_all_scrapers()
assert get_source("vostfree").name == "vostfree"
assert get_source("french_manga").name == "french_manga"
def test_hoster_resolution():
import_all_scrapers()
assert resolve_hoster("https://video.sibnet.ru/shell.php?videoid=1").name == "sibnet"
assert resolve_hoster("https://uqload.com/embed-x.html").name == "uqload"
assert resolve_hoster("https://vidmoly.to/embed-x.html").name == "vidmoly"
assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid"
assert resolve_hoster("https://inconnu.example.com/v/1") is None
-367
View File
@@ -1,367 +0,0 @@
"""Tests découverte : parsing latest(), service for_you et endpoint /api/discover."""
import asyncio
import pytest
from httpx import ASGITransport, AsyncClient
from app.db import db
from app.main import app
from app.scrapers.base import ScrapeError, SearchResult
from app.services.discover import DiscoverService, category_slug, discover
# --------------------------------------------------------------- parsing latest
VOSTFREE_LATEST_HTML = """
<html><body>
<div class="movie-poster">
<div class="play"><a class="fa fa-play link"
href="https://ipv4.vostfree.ws/1404-helck-vostfr-ddl-streaming-1fichier-uptobox.html"
alt="Helck VOSTFR"><span>Helck VOSTFR</span></a></div>
<div class="quality">VOSTFR</div>
<span class="image"><img src="https://vostfree.ws/uploads/posts/helck.jpg" alt="Helck VOSTFR"/></span>
</div>
<div class="movie-poster">
<div class="play"></div>
</div>
<div class="movie-poster">
<div class="play"><a class="fa fa-play link" href="/sans-slug-" alt="Bizarre"></a></div>
</div>
</body></html>
"""
FRENCH_MANGA_LATEST_HTML = """
<html><body>
<div class="short"><div class="short-in nl">
<a class="short-poster img-box with-mask"
href="https://w16.french-manga.net/index.php?newsid=1498905" alt="Draw This, Then Die! - Saison 1">
<img src="https://image.tmdb.org/t/p/w500/dZp.jpg" width="160" height="240" alt="affiche"/>
</a>
<span class="mli-eps">11 / 12</span>
<div class="short-title">Draw This, Then Die! - Saison 1 (2024)</div>
</div></div>
<div class="short"><div class="short-in nl">
<a class="short-poster img-box with-mask" href="/relative-no-id.html"></a>
</div></div>
</body></html>
"""
class _FakeSource:
"""Source factice injectée dans le registre du service discover."""
name = "fake"
label = "Fake"
def __init__(self, results: list[SearchResult] | None = None, error: bool = False):
self._results = results or []
self._error = error
async def latest(self) -> list[SearchResult]:
if self._error:
raise ScrapeError("source indisponible")
return self._results
@pytest.fixture
async def client() -> AsyncClient:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def admin_cookies(client: AsyncClient):
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
assert r.status_code == 303
return r.cookies
def test_category_slug():
assert category_slug("Action") == "action"
assert category_slug("Slice of Life") == "slice-of-life"
assert category_slug("Comédie") == "comedie" # accents retirés
assert category_slug(" Super Power! ") == "super-power"
async def test_latest_vostfree_parsing(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
from bs4 import BeautifulSoup
return BeautifulSoup(VOSTFREE_LATEST_HTML, "lxml")
monkeypatch.setattr("app.scrapers.sources.vostfree.fetch_soup", fake_fetch_soup)
results = await asyncio.shield(_latest_of("vostfree"))
assert len(results) == 1 # les blocs sans lien/slug valide sont ignorés
item = results[0]
assert item.source == "vostfree"
assert item.source_id == "1404-helck-vostfr-ddl-streaming-1fichier-uptobox.html".removesuffix(
".html"
)
assert item.title == "Helck VOSTFR" # depuis l'attribut alt
assert item.image_url == "https://vostfree.ws/uploads/posts/helck.jpg"
async def _latest_of(name: str) -> list[SearchResult]:
from app.scrapers.base import get_source
return await get_source(name).latest()
async def test_latest_french_manga_parsing(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
from bs4 import BeautifulSoup
return BeautifulSoup(FRENCH_MANGA_LATEST_HTML, "lxml")
monkeypatch.setattr("app.scrapers.sources.french_manga.fetch_soup", fake_fetch_soup)
results = await _latest_of("french_manga")
assert len(results) == 1
item = results[0]
assert item.source == "french_manga"
assert item.source_id == "1498905"
assert item.title == "Draw This, Then Die! - Saison 1" # année en queue retirée
assert item.image_url == "https://image.tmdb.org/t/p/w500/dZp.jpg"
assert item.url.endswith("newsid=1498905")
async def test_latest_merges_and_sorts_by_release_date(monkeypatch):
"""Sources fusionnées, doublons retirés, tri du plus récent au plus ancien."""
import app.services.discover as discover_module
src_a = _FakeSource(
[
SearchResult(source="a", source_id="1", title="Frieren VOSTFR", url="https://x/1"),
SearchResult(source="a", source_id="2", title="Helck VOSTFR", url="https://x/2"),
]
)
src_b = _FakeSource(
[
SearchResult(source="b", source_id="9", title="Frieren - Saison 1", url="https://y/9"),
SearchResult(source="b", source_id="8", title="Old Anime", url="https://y/8"),
]
)
monkeypatch.setattr(discover_module, "all_sources", lambda: [src_a, src_b])
async def enabled(name: str) -> bool:
return True
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
async def fake_match(title: str):
return None # aucun match → items sans date, triés en fin de liste
service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
result = await service.latest(limit=10)
titles = [item["title"] for item in result]
assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné
assert "Helck VOSTFR" in titles and "Old Anime" in titles
assert all(item["start_date"] is None for item in result)
async def test_latest_orders_by_kitsu_start_date(monkeypatch):
"""Les dates de sortie Kitsu pilotent l'ordre (plus récent d'abord)."""
import app.services.discover as discover_module
src = _FakeSource(
[
SearchResult(source="a", source_id=str(i), title=f"Anime {i}", url=f"https://x/{i}")
for i in range(3)
]
)
monkeypatch.setattr(discover_module, "all_sources", lambda: [src])
async def enabled(name: str) -> bool:
return True
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
async def fake_match(title: str):
dates = {"Anime 0": "2021-01-01", "Anime 1": "2026-01-01", "Anime 2": None}
start = dates[title]
return {"id": "1", "attributes": {"startDate": start}, "genres": []}
service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
result = await service.latest(limit=10)
assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"]
assert result[0]["rating"] is None
async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
"""Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes."""
import app.services.discover as discover_module
calls = {"n": 0}
def make_source():
async def latest():
calls["n"] += 1
return []
return type("S", (), {"name": "s", "label": "S", "latest": staticmethod(latest)})()
monkeypatch.setattr(
discover_module, "all_sources", lambda: [_FakeSource(error=True), make_source()]
)
async def enabled(name: str) -> bool:
return True
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
service = DiscoverService()
assert await service.latest() == []
assert await service.latest() == []
assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL
# --------------------------------------------------------------- pour toi
@pytest.fixture
async def history(monkeypatch):
"""Téléchargements + favoris en base, Kitsu mocké."""
cursor = await db.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
("tester", "x" * 64),
)
user_id = cursor.lastrowid
await db.execute(
"INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)",
("k1", "https://v/1", "Frieren S1 - E1", "done"),
)
await db.execute(
"INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)",
("k2", "https://v/2", "Helck VOSTFR", "done"),
)
await db.execute(
"INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) "
"VALUES (?,?,?,?,?,?)",
(user_id, "vostfree", "x", "Favori", None, '{"genres": ["Comedy", "Action"]}'),
)
async def fake_search_anime(title: str):
lowered = title.lower()
if "frieren" in lowered:
return {"id": "46474", "attributes": {}, "genres": []} # genres → via catégories
if "helck" in lowered:
return {"id": "999", "attributes": {}, "genres": ["Action"]}
return None
async def fake_categories(anime_id: object) -> list[str]:
return ["Fantasy", "Adventure"] if anime_id == "46474" else []
async def fake_kitsu_anime(params: dict) -> list[dict]:
slugs = frozenset(params["filter[categories]"].split(","))
assert params["sort"] == "-userCount"
catalog = {
frozenset(["fantasy", "adventure", "action", "comedy"]): [
{"title": "Helck", "kitsu_id": "999"},
{"title": "Sousou no Frieren", "kitsu_id": "46474"},
{"title": "Konosuba", "kitsu_id": "1"},
],
}
return catalog.get(slugs, [])
service = DiscoverService()
service.test_user_id = user_id # pour les assertions du test
monkeypatch.setattr(service._kitsu, "search_anime", fake_search_anime)
monkeypatch.setattr(service, "_kitsu_categories", fake_categories)
monkeypatch.setattr(service, "_kitsu_anime", fake_kitsu_anime)
return service
async def test_for_you_aggregates_genres_and_excludes_owned(history):
result = await history.for_you(user_id=history.test_user_id, limit=10)
assert set(result["based_on"]) == {"Action", "Fantasy", "Adventure", "Comedy"}
assert result["based_on"][0] == "Action" # 2 occurrences (téléchargement + favori)
titles = [item["title"] for item in result["items"]]
assert "Helck" not in titles # déjà possédé (« Helck VOSTFR » → « helck ») → exclu
assert "Sousou no Frieren" in titles
assert "Konosuba" in titles
async def test_for_you_empty_without_history(monkeypatch):
service = DiscoverService()
result = await service.for_you(user_id=42)
assert result == {"based_on": [], "items": []}
class _FakeSonarr:
"""Profil Sonarr factice : titres possédés + genres."""
def __init__(self, owned: set[str], genres: dict[str, int]) -> None:
self._profile = (owned, genres)
async def profile(self) -> tuple[set[str], dict[str, int]]:
return self._profile
async def test_for_you_merges_sonarr_genres_and_owned(monkeypatch, history):
monkeypatch.setattr(
"app.services.discover.sonarr",
_FakeSonarr(
owned={"mob psycho 100"}, # déjà possédé sur Sonarr → exclu
genres={"Fantasy": 5, "Action": 5}, # Action cumule avec l'historique local
),
)
async def fake_kitsu_anime(params: dict) -> list[dict]:
return [
{"title": "Konosuba", "kitsu_id": "1"},
{"title": "Mob Psycho 100", "kitsu_id": "2"},
]
monkeypatch.setattr(history, "_kitsu_anime", fake_kitsu_anime)
result = await history.for_you(user_id=history.test_user_id, limit=10)
# genres locaux (Action×2, Fantasy, Adventure, Comedy) + Sonarr (Fantasy+5, Action+5)
counts = {"Action": 7, "Fantasy": 6, "Adventure": 1, "Comedy": 1}
assert sorted(result["based_on"]) == sorted(
sorted(counts, key=counts.get, reverse=True)[:4]
)
titles = [item["title"] for item in result["items"]]
assert "Mob Psycho 100" not in titles # possédé sur Sonarr → exclu
assert "Konosuba" in titles
# --------------------------------------------------------------- endpoint
async def test_api_discover_requires_auth(client):
r = await client.get("/api/discover", follow_redirects=False)
assert r.status_code == 303
async def test_api_discover_sections(client, admin_cookies, monkeypatch):
async def fake_latest(limit: int = 24):
return [
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
"start_date": "2026-01-01", "status": "current", "rating": 8.1},
]
async def fake_must_watch(limit: int = 20):
return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}]
async def fake_for_you(user_id: int, limit: int = 20):
return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]}
monkeypatch.setattr(discover, "latest", fake_latest)
monkeypatch.setattr(discover, "must_watch", fake_must_watch)
monkeypatch.setattr(discover, "for_you", fake_for_you)
r = await client.get("/api/discover", cookies=admin_cookies)
assert r.status_code == 200
data = r.json()
assert data["latest"][0]["label"] == "Vostfree"
assert data["latest"][0]["start_date"] == "2026-01-01"
assert data["must_watch"][0]["title"] == "Attack on Titan"
assert data["for_you"]["based_on"] == ["Action"]
async def test_discover_page_renders(client, admin_cookies):
r = await client.get("/discover", cookies=admin_cookies)
assert r.status_code == 200
assert "Découvrir" in r.text
-104
View File
@@ -1,104 +0,0 @@
"""Tests du gestionnaire de téléchargements (serveur HTTP local avec Range)."""
import asyncio
import pytest
from aiohttp import web
from app.config import get_settings
from app.services.downloads import download_manager as dm
PAYLOAD = b"x" * 500_000
async def _start_file_server() -> tuple[web.AppRunner, int]:
"""Mini serveur HTTP supportant les requêtes Range."""
async def handle(request: web.Request) -> web.StreamResponse:
range_header = request.headers.get("Range")
if range_header:
start = int(range_header.removeprefix("bytes=").split("-")[0])
return web.Response(
body=PAYLOAD[start:],
status=206,
headers={"Content-Range": f"bytes {start}-{len(PAYLOAD) - 1}/{len(PAYLOAD)}"},
)
return web.Response(body=PAYLOAD, headers={"Content-Length": str(len(PAYLOAD))})
app = web.Application()
app.router.add_get("/video.mp4", handle)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
return runner, port
@pytest.fixture
async def file_server():
runner, port = await _start_file_server()
yield f"http://127.0.0.1:{port}"
await runner.cleanup()
@pytest.fixture
async def manager():
await dm.start()
yield dm
await dm.stop()
async def _wait_status(download_id: int, wanted: set[str], timeout: float = 10) -> dict:
for _ in range(int(timeout * 10)):
data = await dm.get(download_id)
if data["status"] in wanted:
return data
await asyncio.sleep(0.1)
raise AssertionError(f"statut {wanted} non atteint (actuel: {data['status']})")
async def test_full_download(file_server, manager):
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Episode Test 1")
data = await _wait_status(d["id"], {"done", "failed"})
assert data["status"] == "done", data.get("error")
assert data["percent"] == 100.0
path = get_settings().download_dir / data["file_path"]
assert path.read_bytes() == PAYLOAD
async def test_anti_duplicate(file_server, manager):
d1 = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep")
d2 = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep")
assert d1["id"] == d2["id"]
assert d2["duplicate"] is True
await _wait_status(d1["id"], {"done", "failed"})
async def test_pause_resume(file_server, manager):
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep Pause")
await asyncio.sleep(0.2)
await dm.pause(d["id"])
data = await _wait_status(d["id"], {"paused", "done"})
if data["status"] == "paused": # assez rapide pour être pausé
assert await dm.resume(d["id"]) is True
data = await _wait_status(d["id"], {"done", "failed"})
assert data["status"] == "done", data.get("error")
path = get_settings().download_dir / data["file_path"]
assert path.read_bytes() == PAYLOAD
async def test_failed_download_and_retry(manager):
d = await dm.enqueue("http://127.0.0.1:1/nope.mp4", "http://page", "Ep KO")
data = await _wait_status(d["id"], {"failed"})
assert data["error"]
assert await dm.retry(d["id"]) is True
data = await _wait_status(d["id"], {"failed"})
assert await dm.cancel(d["id"]) is True
async def test_cancel(file_server, manager):
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep Cancel")
await dm.cancel(d["id"])
data = await dm.get(d["id"])
assert data["status"] in ("cancelled", "done") # course possible si déjà fini
-52
View File
@@ -1,52 +0,0 @@
"""Tests : réécriture de playlists HLS (proxy) et sélection de variante."""
from app.routers.proxy import _rewrite_playlist
from app.services.downloads import _best_variant, _parse_ffmpeg_time
MASTER = """#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=640x360
low/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=1280x720
https://cdn.example.com/high/index.m3u8
"""
MEDIA = """#EXTM3U
#EXT-X-KEY:METHOD=AES-128,URI="https://cdn.example.com/key.bin",IV=0xabc
#EXTINF:6.0,
seg-1.ts
#EXTINF:6.0,
https://cdn.example.com/seg-2.ts
"""
def test_best_variant_picks_highest_bandwidth():
url = _best_variant(MASTER, "https://site.example.com/master.m3u8")
assert url == "https://cdn.example.com/high/index.m3u8"
def test_best_variant_none_for_media_playlist():
assert _best_variant(MEDIA, "https://x/") is None
def test_parse_ffmpeg_time():
assert _parse_ffmpeg_time("00:01:30.50") == 90.5
assert _parse_ffmpeg_time("01:00:00.00") == 3600.0
def test_rewrite_playlist_routes_everything_through_proxy():
out = _rewrite_playlist(MEDIA, "https://cdn.example.com/hls/master.m3u8", "https://ref/")
assert "/api/proxy?url=" in out
# URI relative des segments résolue contre l'URL de base
assert "seg-1.ts" in out and "https%3A%2F%2Fcdn.example.com%2Fhls%2Fseg-1.ts" in out
# URI absolue conservée mais proxiée
assert "seg-2.ts" in out
# La clé AES dans l'attribut URI="..." est aussi réécrite
assert 'URI="/api/proxy?url=' in out
# Le referer est propagé aux URLs proxiées
assert "ref=https%3A%2F%2Fref%2F" in out
def test_rewrite_playlist_without_referer():
out = _rewrite_playlist("#EXTM3U\nseg.ts\n", "https://cdn.example.com/m.m3u8", None)
assert "ref=" not in out
assert out.startswith("#EXTM3U")
-237
View File
@@ -1,237 +0,0 @@
"""Tests de l'indexeur Torznab (compatibilité Sonarr / Prowlarr)."""
import xml.etree.ElementTree as ET
import pytest
from httpx import ASGITransport, AsyncClient
from app.db import db
from app.main import app
from app.scrapers.base import Episode, SearchResult, VideoLink
from app.services.settings import get_torznab_apikey
from app.services.torznab import torznab as service
@pytest.fixture
async def client() -> AsyncClient:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture
async def apikey() -> str:
return await get_torznab_apikey()
class FakeSource:
name = "fake"
label = "Fake"
base_url = "https://fake.example"
async def search(self, query: str) -> list[SearchResult]:
return [
SearchResult(
source="fake",
source_id="frieren-1",
title="Frieren",
url="https://fake.example/frieren",
)
]
async def list_episodes(self, source_id: str) -> list[Episode]:
return [
Episode(number=1, title="Épisode 1", url="https://fake.example/ep1", season=1),
Episode(number=2, title="Épisode 2", url="https://fake.example/ep2", season=1),
Episode(number=2.5, title="OAV", url="https://fake.example/oav", season=1),
Episode(number=1, title="Épisode 1", url="https://fake.example/s2ep1", season=2),
]
async def extract_embed_links(self, episode_url: str) -> list[str]:
return ["https://embed.example/player"]
async def get_details(self, source_id): ...
async def latest(self): return []
@pytest.fixture
def fake_source(monkeypatch):
source = FakeSource()
async def _sources():
return [source]
monkeypatch.setattr(service, "_enabled_sources", _sources)
return source
# ---------------------------------------------------------------- auth + caps
async def test_torznab_rejects_missing_apikey(client):
r = await client.get("/torznab/api", params={"t": "caps"})
assert r.status_code == 401
assert 'code="100"' in r.text
async def test_torznab_rejects_wrong_apikey(client):
r = await client.get("/torznab/api", params={"t": "caps", "apikey": "mauvaise"})
assert r.status_code == 401
async def test_torznab_caps(client, apikey):
r = await client.get("/torznab/api", params={"t": "caps", "apikey": apikey})
assert r.status_code == 200
root = ET.fromstring(r.text)
assert root.tag == "caps"
tv = root.find(".//tv-search")
assert tv is not None and tv.get("available") == "yes"
assert "q,season,ep" in (tv.get("supportedParams") or "")
assert any(c.get("id") == "5070" for c in root.findall(".//subcat"))
async def test_torznab_apikey_via_header(client, apikey):
r = await client.get("/torznab/api", params={"t": "caps"}, headers={"X-Api-Key": apikey})
assert r.status_code == 200
async def test_torznab_unknown_function(client, apikey):
r = await client.get("/torznab/api", params={"t": "music", "apikey": apikey})
assert r.status_code == 400
assert 'code="203"' in r.text
# ---------------------------------------------------------------- recherche
async def test_torznab_tvsearch_filters_season_ep(client, apikey, fake_source):
r = await client.get(
"/torznab/api",
params={"t": "tvsearch", "q": "Frieren", "season": 1, "ep": 2, "apikey": apikey},
)
assert r.status_code == 200
root = ET.fromstring(r.text)
items = root.findall(".//item")
assert len(items) == 1
assert items[0].find("title").text == "Frieren S01E02 VOSTFR WEB-DL"
link = items[0].find("link").text
assert "/torznab/download" in link and "season=1" in link and "ep=2" in link
async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source):
r = await client.get(
"/torznab/api", params={"t": "tvsearch", "q": "Frieren", "apikey": apikey}
)
root = ET.fromstring(r.text)
titles = [i.find("title").text for i in root.findall(".//item")]
assert len(titles) == 3 # OAV 2.5 exclue : S01E01, S01E02, S02E01
assert "Frieren S02E01 VOSTFR WEB-DL" in titles
async def test_torznab_search_requires_query(client, apikey):
r = await client.get("/torznab/api", params={"t": "search", "apikey": apikey})
assert r.status_code == 400
# ---------------------------------------------------------------- grab
class _FakeManager:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str]] = []
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict:
self.calls.append((video_url, page_url, title))
return {"id": 7, "title": title, "status": "pending"}
class _FakeExtractor:
name = "fakehost"
async def extract(self, embed_url: str) -> VideoLink:
return VideoLink(url="https://cdn.example/video.mp4", hoster="fakehost")
async def test_torznab_download_enqueues_and_returns_torrent(client, apikey, fake_source, monkeypatch):
manager = _FakeManager()
monkeypatch.setattr("app.scrapers.base.get_source", lambda name: fake_source)
monkeypatch.setattr("app.services.torznab.resolve_hoster", lambda url: _FakeExtractor())
monkeypatch.setattr("app.services.torznab.download_manager", manager)
r = await client.get(
"/torznab/download",
params={
"apikey": apikey,
"source": "fake",
"sid": "frieren-1",
"season": 1,
"ep": 2,
"series": "Frieren",
},
)
assert r.status_code == 200
assert r.headers["content-type"].startswith("application/x-bittorrent")
assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide
assert manager.calls == [
("https://cdn.example/video.mp4", "https://fake.example/ep2", "Frieren S01E02")
]
async def test_torznab_download_rejects_bad_key(client):
r = await client.get("/torznab/download", params={"apikey": "mauvaise"})
assert r.status_code == 401
async def test_torznab_download_unknown_episode(client, apikey, fake_source, monkeypatch):
monkeypatch.setattr("app.scrapers.base.get_source", lambda name: fake_source)
r = await client.get(
"/torznab/download",
params={
"apikey": apikey,
"source": "fake",
"sid": "frieren-1",
"season": 9,
"ep": 9,
"series": "Frieren",
},
)
assert r.status_code == 502
assert 'code="300"' in r.text
# ---------------------------------------------------------------- admin
async def _admin(client: AsyncClient):
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
assert r.status_code == 303
return r.cookies
async def test_admin_integrations(client, apikey):
cookies = await _admin(client)
r = await client.get("/api/admin/integrations", cookies=cookies)
assert r.status_code == 200
data = r.json()
assert data["torznab"]["apikey"] == apikey
assert data["torznab"]["endpoint"].endswith("/torznab/api")
assert data["sonarr"] == {"url": "", "apikey": ""}
r = await client.put(
"/api/admin/integrations/sonarr",
json={"url": "http://sonarr:8989/", "apikey": "abc"},
cookies=cookies,
)
assert r.status_code == 200
row = await db.fetchone("SELECT value FROM settings WHERE key = 'sonarr:url'")
assert row["value"] == '"http://sonarr:8989"' # slash final retiré
async def test_admin_regenerate_torznab_key(client, apikey):
cookies = await _admin(client)
r = await client.post("/api/admin/integrations/torznab/regenerate", cookies=cookies)
assert r.status_code == 200
new_key = r.json()["apikey"]
assert new_key != apikey and len(new_key) == 32
r = await client.get("/torznab/api", params={"t": "caps", "apikey": apikey})
assert r.status_code == 401 # l'ancienne clé est révoquée
Generated
-1555
View File
File diff suppressed because it is too large Load Diff