Compare commits
21
Commits
40977438ff
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e178eecf | ||
|
|
12bdc8a7ef | ||
|
|
eae97f9129 | ||
|
|
fed61adc56 | ||
|
|
d5fa546dd7 | ||
|
|
c45f1b79fd | ||
|
|
d747c574b8 | ||
|
|
248ec60d35 | ||
|
|
79a44297fd | ||
|
|
89d9a98f80 | ||
|
|
28f1bbde31 | ||
|
|
65761885ba | ||
|
|
7399f4b781 | ||
|
|
5fedc4f9d7 | ||
|
|
853b4e0866 | ||
|
|
4e1faf4cd9 | ||
|
|
1f326d34dd | ||
|
|
9500a84a5e | ||
|
|
affc97c527 | ||
|
|
9148b5fb6a | ||
|
|
41566ab5fb |
@@ -0,0 +1,20 @@
|
|||||||
|
# Contexte de build minimal : ni secrets, ni données, ni caches
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
.venv
|
||||||
|
.plasma
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
data/
|
||||||
|
downloads/
|
||||||
|
tests/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.yml.example
|
||||||
|
README.md
|
||||||
|
Projet_descriptions.md
|
||||||
|
scripts/
|
||||||
+31
-10
@@ -1,13 +1,34 @@
|
|||||||
# Ohm Streaming API Configuration
|
# Copier en .env et adapter. Toutes les variables applicatives sont préfixées OHM_.
|
||||||
|
|
||||||
# Server
|
# OBLIGATOIRE en production : clé de signature des tokens (32+ caractères)
|
||||||
HOST=0.0.0.0
|
OHM_SECRET_KEY=change-me-in-production
|
||||||
PORT=8000
|
|
||||||
RELOAD=true
|
|
||||||
|
|
||||||
# Paths
|
# Chemins
|
||||||
UPLOAD_DIR=uploads
|
# OHM_DATA_DIR=./data
|
||||||
STREAM_DIR=streams
|
# OHM_DOWNLOAD_DIR=./downloads
|
||||||
|
# OHM_DATABASE_PATH=./data/ohm.db
|
||||||
|
|
||||||
# CORS
|
# Téléchargements
|
||||||
ALLOWED_ORIGINS=*
|
# 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
|
||||||
|
|
||||||
|
# ── Déploiement Docker (docker-compose.yml) ────────────────────────────────
|
||||||
|
# Secret partagé entre OhmStreaming et Watchtower pour déclencher les mises
|
||||||
|
# à jour depuis la page Admin. OBLIGATOIRE en Docker.
|
||||||
|
# Générer : openssl rand -hex 24
|
||||||
|
WATCHTOWER_TOKEN=change-me-watchtower
|
||||||
|
|
||||||
|
# Port hôte exposé par docker compose (défaut 8777)
|
||||||
|
# OHM_PORT=8777
|
||||||
|
|||||||
+6
-28
@@ -1,31 +1,9 @@
|
|||||||
# Python
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*$py.class
|
data/
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
.venv
|
|
||||||
|
|
||||||
# FastAPI
|
|
||||||
uploads/
|
|
||||||
streams/
|
|
||||||
downloads/
|
downloads/
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
.env
|
||||||
|
.pytest_cache/
|
||||||
# IDE
|
.ruff_cache/
|
||||||
.vscode/
|
.plasma/
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
# 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")
|
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Étape 1 — dépendances Python via uv (cache couche par couche)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
WORKDIR /opt/ohm
|
||||||
|
|
||||||
|
# D'abord les métadonnées seules : couche réutilisable tant que uv.lock ne bouge pas
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project --no-cache
|
||||||
|
|
||||||
|
# Puis le code
|
||||||
|
COPY app ./app
|
||||||
|
RUN uv sync --frozen --no-dev --no-cache
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Étape 2 — image d'exécution minimale
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM python:3.13-slim-bookworm
|
||||||
|
|
||||||
|
# ffmpeg (téléchargements HLS), ca-certificates (scraping HTTPS),
|
||||||
|
# gosu (bascule utilisateur non-root dans l'entrypoint)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates gosu \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Utilisateur non-root
|
||||||
|
RUN useradd --create-home --uid 1000 ohm
|
||||||
|
|
||||||
|
WORKDIR /opt/ohm
|
||||||
|
COPY --from=builder --chown=ohm:ohm /opt/ohm/.venv ./.venv
|
||||||
|
COPY --chown=ohm:ohm app ./app
|
||||||
|
COPY --chown=ohm:ohm docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENV PATH="/opt/ohm/.venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
# Chemins montés en volumes par docker-compose
|
||||||
|
OHM_DATA_DIR=/data \
|
||||||
|
OHM_DOWNLOAD_DIR=/downloads \
|
||||||
|
OHM_DATABASE_PATH=/data/ohm.db
|
||||||
|
|
||||||
|
# Version cuite dans l'image par scripts/release.sh (build-arg VERSION)
|
||||||
|
ARG VERSION=dev
|
||||||
|
ENV OHM_VERSION=${VERSION}
|
||||||
|
|
||||||
|
RUN mkdir -p /data /downloads && chown -R ohm:ohm /opt/ohm /data /downloads
|
||||||
|
# Root par défaut : l'entrypoint chown les volumes puis passe en « ohm »
|
||||||
|
|
||||||
|
EXPOSE 8777
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8777/health', timeout=4)"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8777"]
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,106 +1,220 @@
|
|||||||
# Ohm Stream Downloader
|
# ⛩ Ohm Stream Downloader
|
||||||
|
|
||||||
Web application pour télécharger des fichiers depuis divers hébergeurs (1fichier, Doodstream, Rapidfile, etc.).
|
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.
|
||||||
|
|
||||||
## Fonctionnalités
|
## Déploiement (Docker) — recommandé
|
||||||
|
|
||||||
- **Multi-hébergeurs** : Support pour 1fichier, Doodstream, Rapidfile et plus
|
Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est
|
||||||
- **Téléchargements parallèles** : Jusqu'à 3 téléchargements simultanés
|
hébergée sur le **registre privé du Gitea** — rien n'est publié publiquement.
|
||||||
- **Pause/Reprise** : Mettez en pause et reprenez vos téléchargements
|
|
||||||
- **Interface web moderne** : Interface intuitive avec progression en temps réel
|
|
||||||
- **API REST** : Intégration facile avec d'autres applications
|
|
||||||
|
|
||||||
## Installation
|
### Installation guidée (recommandée)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Créer l'environnement virtuel
|
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||||
python3 -m venv venv
|
./scripts/install.sh
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
|
||||||
|
|
||||||
# Installer les dépendances
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Lancer le serveur
|
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Utilisation
|
Le script vérifie Docker, demande la **destination des épisodes** (dossier dédié
|
||||||
|
recommandé — voir « Bibliothèque Plex/Sonarr » ci-dessous), le port, génère les
|
||||||
|
secrets, branche le montage et démarre. Non interactif aussi :
|
||||||
|
`./scripts/install.sh --dir /srv/animes --port 8777 --skip-login`.
|
||||||
|
|
||||||
### Interface Web
|
### À la main
|
||||||
|
|
||||||
Ouvrez votre navigateur sur : http://localhost:8000/web
|
|
||||||
|
|
||||||
Collez simplement un lien de téléchargement et cliquez sur "Télécharger".
|
|
||||||
|
|
||||||
### API
|
|
||||||
|
|
||||||
**Créer un téléchargement :**
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/api/download \
|
# 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture)
|
||||||
-H "Content-Type: application/json" \
|
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||||
-d '{"url": "https://1fichier.com/?xxxxx"}'
|
docker login git.lanro.eu
|
||||||
|
|
||||||
|
# 2. Configuration locale
|
||||||
|
cp .env.example .env
|
||||||
|
# → OHM_SECRET_KEY (openssl rand -hex 32) et WATCHTOWER_TOKEN (openssl rand -hex 24) obligatoires
|
||||||
|
|
||||||
|
# 3. Démarrage
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**Lister les téléchargements :**
|
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
||||||
|
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux
|
||||||
|
|
||||||
|
|
||||||
|
### Bibliothèque Plex / Sonarr existante
|
||||||
|
|
||||||
|
Ohm peut déposer ses épisodes directement dans ton serveur de média :
|
||||||
|
|
||||||
|
1. **Crée un dossier dédié** (recommandé : hors de la racine Sonarr, ex.
|
||||||
|
`/plex_videos/ohm`) et monte-le à la place de `./downloads` :
|
||||||
|
`- /plex_videos/ohm:/downloads` — c'est ce que fait `scripts/install.sh`.
|
||||||
|
2. **Ajoute ce dossier à Plex** comme dossier d'une bibliothèque (ou comme
|
||||||
|
dossier supplémentaire de ta bibliothèque animes). Les épisodes arrivent
|
||||||
|
rangés par animé : `One Piece/One Piece - E1010 (VOSTFR).mp4` — le nom du
|
||||||
|
dossier sert de série, le fichier d'épisode.
|
||||||
|
3. **Sonarr n'est pas touché** : l'entrypoint du conteneur ne prend possession
|
||||||
|
(`chown`) que d'un dossier de téléchargements **vide** ; une bibliothèque
|
||||||
|
existante et ses droits restent intacts. Pour piloter Ohm depuis Sonarr ou
|
||||||
|
Prowlarr, voir l'API Torznab plus bas.
|
||||||
|
|
||||||
|
Si tu pointes `/downloads` sur un dossier **non vide**, Ohm adopte les fichiers
|
||||||
|
présents (ils apparaissent dans sa bibliothèque interne) et conserve les droits
|
||||||
|
existants — assure-toi juste que l'uid 1000 du conteneur peut y écrire.
|
||||||
|
|
||||||
|
### Mettre à jour
|
||||||
|
|
||||||
|
**Depuis l'interface** (déploiement Docker) : page **Admin → Mise à jour** —
|
||||||
|
configurer une fois le dépôt Gitea + un jeton d'accès (droit lecture), puis
|
||||||
|
« Vérifier » et « ⬆ Mettre à jour maintenant ». Watchtower tire la nouvelle
|
||||||
|
image et recrée le conteneur : quelques secondes d'indisponibilité, les pages
|
||||||
|
ouvertes se reconnectent et rechargent automatiquement.
|
||||||
|
|
||||||
|
**En ligne de commande** (toujours possible) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/api/downloads
|
docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**Mettre en pause :**
|
### Publier une version (mainteneur)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/api/download/{task_id}/pause
|
./scripts/release.sh 0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**Reprendre :**
|
Bump de version, commit + tag git, build de l'image et push vers
|
||||||
|
`git.lanro.eu/roman/ohm_streaming` (tags `0.2.0` et `latest`).
|
||||||
|
|
||||||
|
## Démarrage rapide (développement)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/api/download/{task_id}/resume
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
**Annuler :**
|
Serveur persistant en dev : préférer Docker (voir plus haut) ; sinon
|
||||||
```bash
|
`tmux new-session -d -s ohm 'uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`.
|
||||||
curl -X DELETE http://localhost:8000/api/download/{task_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
||||||
|
|
||||||
| 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é |
|
|
||||||
| GET | `/web` | Interface web |
|
|
||||||
|
|
||||||
## Structure du Projet
|
|
||||||
|
|
||||||
```
|
|
||||||
Ohm_streaming/
|
|
||||||
├── main.py # Application FastAPI
|
|
||||||
├── app/
|
|
||||||
│ ├── models/ # Modèles de données
|
|
||||||
│ ├── downloaders/ # Extracteurs de liens par hébergeur
|
|
||||||
│ └── download_manager.py # Gestionnaire de téléchargements
|
|
||||||
├── downloads/ # Fichiers téléchargés
|
|
||||||
├── templates/
|
|
||||||
│ └── index.html # Interface web
|
|
||||||
└── static/ # Fichiers statiques
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ajouter un Hébergeur
|
|
||||||
|
|
||||||
Pour ajouter le support d'un nouvel hébergeur :
|
|
||||||
|
|
||||||
1. Créez un fichier dans `app/downloaders/` (ex: `myhost.py`)
|
|
||||||
2. Héritez de `BaseDownloader`
|
|
||||||
3. Implémentez `can_handle(url)` et `get_download_link(url)`
|
|
||||||
4. Ajoutez le downloader dans `app/downloaders/__init__.py`
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
- `max_parallel` : Nombre maximum de téléchargements simultanés (défaut: 3)
|
Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
|
||||||
- `download_dir` : Répertoire de stockage (défaut: "downloads")
|
|
||||||
|
|
||||||
Modifiez ces paramètres dans `main.py`.
|
| 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 |
|
||||||
|
| `OHM_WATCHTOWER_URL` | *(vide)* | URL Watchtower pour la mise à jour (réglé par docker-compose) |
|
||||||
|
| `OHM_WATCHTOWER_TOKEN` | *(vide)* | Jeton partagé Watchtower (réglé par docker-compose) |
|
||||||
|
| `OHM_VERSION` | *(pyproject)* | Version affichée — cuite dans l'image Docker au build |
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga, VoirAnime pour les
|
||||||
|
animes ; French-Stream pour les séries et films VF/VOSTFR) — chaque source
|
||||||
|
est un module interchangeable activable/désactivable à chaud (page Admin), dont l'URL
|
||||||
|
est modifiable à la volée (utile si un site change de domaine).
|
||||||
|
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe
|
||||||
|
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo ; VoirAnime résout via son
|
||||||
|
endpoint « prepare » : MP4 direct ou HLS relayé par le proxy du site).
|
||||||
|
- **Préférence de contenus par compte** : sélecteur topbar ⛨ Animés / 📺 Séries /
|
||||||
|
✨ Les deux — la recherche et les nouveautés filtrent par type de média, et les
|
||||||
|
sources hors périmètre ne sont même pas interrogées.
|
||||||
|
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
|
||||||
|
- **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`) : 🎭 exploration par genre (animés via Kitsu, séries/films
|
||||||
|
via French-Stream — état dans l'URL, partageable), 🆕 nouveautés en rails séparés
|
||||||
|
(animés triés par date de sortie via Kitsu ; séries & films de French-Stream), 🔥
|
||||||
|
incontournables animés (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). Sans
|
||||||
|
Sonarr, les genres des séries/films téléchargés sont lus sur leur fiche source ;
|
||||||
|
sans aucun historique, une carte d'amorçage invite à télécharger un premier titre.
|
||||||
|
|
||||||
|
|
||||||
|
## Intégration Sonarr / Prowlarr (*arr)
|
||||||
|
|
||||||
|
OhmStreaming expose une **API Torznab** (indexeur) **et une API compatible
|
||||||
|
qBittorrent** (client de téléchargement) : Sonarr peut lui déléguer toute la
|
||||||
|
chaîne — recherche, téléchargement, puis **import et renommage automatiques**
|
||||||
|
dans la bibliothèque Sonarr.
|
||||||
|
|
||||||
|
### 1. OhmStreaming comme indexeur
|
||||||
|
|
||||||
|
Dans **Admin → Intégrations Sonarr / Prowlarr**, copier :
|
||||||
|
|
||||||
|
| Champ | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| URL | `http://<hote-ohm>:8777/torznab/api` |
|
||||||
|
| Clé API | générée automatiquement (bouton Régénérer pour la changer) |
|
||||||
|
|
||||||
|
- **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).
|
||||||
|
|
||||||
|
Endpoints : `t=caps`, `t=tvsearch` (q, season, ep), `t=search` — auth par
|
||||||
|
`?apikey=` ou en-tête `X-Api-Key`.
|
||||||
|
|
||||||
|
### 2. OhmStreaming comme client de téléchargement (recommandé)
|
||||||
|
|
||||||
|
Dans Sonarr : **Settings → Download Clients → Add → qBittorrent** :
|
||||||
|
|
||||||
|
| Champ | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Host | `http://<hote-ohm>:8777` |
|
||||||
|
| Username | `ohm` |
|
||||||
|
| Password | la clé API Torznab (Admin → Intégrations) |
|
||||||
|
|
||||||
|
Le flux complet devient : Sonarr grab → Ohm télécharge (progression visible
|
||||||
|
dans la file Sonarr) → Sonarr **importe, renomme et range** l'épisode dans sa
|
||||||
|
bibliothèque selon ses propres règles → le « torrent » est retiré de la file
|
||||||
|
Ohm (fichier inclu si « Remove Completed » est coché).
|
||||||
|
|
||||||
|
**Chemin d'accès** : Ohm annonce les fichiers sous `/downloads/<Animé>/…`
|
||||||
|
(chemin conteneur). Si Sonarr tourne dans Docker sans ce montage, ajouter un
|
||||||
|
*Remote Path Mapping* : hôte = `<hote-ohm>`, distant = `/downloads`, local =
|
||||||
|
le dossier hôte monté (ex. `/plex_videos/ohm`).
|
||||||
|
|
||||||
|
Variante minimale sans import : client « Torrent Blackhole » — Sonarr pose le
|
||||||
|
`.torrent` de service et l'épisode reste dans la bibliothèque OhmStreaming
|
||||||
|
seulement (pas d'import/renommage Sonarr).
|
||||||
|
|
||||||
|
### 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, voiranime, french_stream
|
||||||
|
├── 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest # 132 tests
|
||||||
|
uv run ruff check . # lint
|
||||||
|
```
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Ohm Stream Downloader Package
|
|
||||||
|
|||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
"""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
|
||||||
|
content_preference: str = "both" # anime | serie | both
|
||||||
|
|
||||||
|
|
||||||
|
VALID_CONTENT_PREFERENCES = ("anime", "serie", "both")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 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, content_preference "
|
||||||
|
"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,
|
||||||
|
content_preference=row["content_preference"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user(user_id: int) -> User | None:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT id, username, is_admin, is_active, content_preference 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,
|
||||||
|
content_preference=row["content_preference"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_content_preference(user_id: int, preference: str) -> None:
|
||||||
|
if preference not in VALID_CONTENT_PREFERENCES:
|
||||||
|
raise ValueError(f"Préférence invalide : {preference!r}")
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE users SET content_preference = ? WHERE id = ?", (preference, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 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,))
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
# Mise à jour (dépôt public — lecture anonyme de l'API Gitea)
|
||||||
|
gitea_url: str = "https://git.lanro.eu"
|
||||||
|
gitea_repo: str = "Roman/ohm_streaming"
|
||||||
|
# Déploiement Docker — Watchtower compagnon
|
||||||
|
watchtower_url: str = ""
|
||||||
|
watchtower_token: str = ""
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""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,
|
||||||
|
content_preference TEXT NOT NULL DEFAULT 'both'
|
||||||
|
CHECK (content_preference IN ('anime','serie','both')),
|
||||||
|
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._migrate()
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def _migrate(self) -> None:
|
||||||
|
"""Migrations légères : colonnes ajoutées après coup pour les bases existantes."""
|
||||||
|
cursor = await self._conn.execute("PRAGMA table_info(users)")
|
||||||
|
columns = {row["name"] for row in await cursor.fetchall()}
|
||||||
|
if "content_preference" not in columns:
|
||||||
|
await self._conn.execute(
|
||||||
|
"ALTER TABLE users ADD COLUMN content_preference TEXT NOT NULL DEFAULT 'both'"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,501 +0,0 @@
|
|||||||
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 search_anime(self, query: str, lang: str = "vostfr") -> 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
|
|
||||||
"""
|
|
||||||
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}/'
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
'title': title,
|
|
||||||
'url': href,
|
|
||||||
'cover_image': cover_image,
|
|
||||||
'type': 'search_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 []
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
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 search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
|
|
||||||
"""
|
|
||||||
Search for anime on anime-ultime
|
|
||||||
Returns list of anime with title, url, and cover image
|
|
||||||
"""
|
|
||||||
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)
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
'title': better_title,
|
|
||||||
'url': href,
|
|
||||||
'type': 'search_result'
|
|
||||||
})
|
|
||||||
|
|
||||||
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 []
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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 []
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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)}")
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
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 search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
|
|
||||||
"""
|
|
||||||
Search for anime on neko-sama
|
|
||||||
"""
|
|
||||||
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)}")
|
|
||||||
return [{
|
|
||||||
'title': query,
|
|
||||||
'url': str(response.url),
|
|
||||||
'type': 'direct'
|
|
||||||
}]
|
|
||||||
|
|
||||||
print(f"[NEKO-SAMA] No anime found")
|
|
||||||
return []
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[NEKO-SAMA] Error: {str(e)}")
|
|
||||||
return []
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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)}")
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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)}")
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
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 search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
|
|
||||||
"""
|
|
||||||
Search for anime on vostfree
|
|
||||||
"""
|
|
||||||
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)}")
|
|
||||||
return [{
|
|
||||||
'title': query,
|
|
||||||
'url': str(response.url),
|
|
||||||
'type': 'direct'
|
|
||||||
}]
|
|
||||||
|
|
||||||
print(f"[VOSTFREE] No anime found")
|
|
||||||
return []
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[VOSTFREE] Error: {str(e)}")
|
|
||||||
return []
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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)
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
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,
|
||||||
|
qbit,
|
||||||
|
search,
|
||||||
|
system,
|
||||||
|
torznab,
|
||||||
|
)
|
||||||
|
from app.scrapers.http import close_client
|
||||||
|
from app.services.discover import discover as discover_service
|
||||||
|
from app.services.downloads import download_manager
|
||||||
|
from app.services.settings import apply_source_base_urls
|
||||||
|
|
||||||
|
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 apply_source_base_urls()
|
||||||
|
await download_manager.start()
|
||||||
|
warmup = asyncio.create_task(discover_service.latest_by_type())
|
||||||
|
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(qbit.router)
|
||||||
|
app.include_router(system.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()
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Administration : utilisateurs, activation des sources, santé, mises à jour."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
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,
|
||||||
|
SourceScraper,
|
||||||
|
all_sources,
|
||||||
|
get_source,
|
||||||
|
import_all_scrapers,
|
||||||
|
)
|
||||||
|
from app.services.discover import discover
|
||||||
|
from app.services.settings import (
|
||||||
|
get_sonarr_config,
|
||||||
|
get_source_health,
|
||||||
|
get_torznab_apikey,
|
||||||
|
is_source_enabled,
|
||||||
|
reset_torznab_apikey,
|
||||||
|
set_sonarr_config,
|
||||||
|
set_source_base_url,
|
||||||
|
set_source_enabled,
|
||||||
|
set_source_health,
|
||||||
|
)
|
||||||
|
from app.services.sonarr import sonarr
|
||||||
|
from app.services.update import UpdateError, fetch_latest_version, trigger_update
|
||||||
|
from app.services.update import status as update_status
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _source_or_404(name: str) -> SourceScraper:
|
||||||
|
try:
|
||||||
|
return get_source(name)
|
||||||
|
except ScrapeError as exc:
|
||||||
|
raise HTTPException(404, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sources/{name}/toggle")
|
||||||
|
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict:
|
||||||
|
_source_or_404(name)
|
||||||
|
await set_source_enabled(name, payload.enabled)
|
||||||
|
return {"name": name, "enabled": payload.enabled}
|
||||||
|
|
||||||
|
class SourceUrlUpdate(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/sources/{name}/url")
|
||||||
|
async def update_source_url(name: str, payload: SourceUrlUpdate, admin: AdminUser) -> dict:
|
||||||
|
"""Change l'URL d'une source (ex. le site a changé de domaine) ; vide = défaut."""
|
||||||
|
source = _source_or_404(name)
|
||||||
|
default = type(source).base_url
|
||||||
|
url = payload.url.strip().rstrip("/")
|
||||||
|
if url and url != default:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
raise HTTPException(422, "URL invalide — format attendu : https://domaine.tld")
|
||||||
|
await set_source_base_url(name, url)
|
||||||
|
else:
|
||||||
|
url = default
|
||||||
|
await set_source_base_url(name, None)
|
||||||
|
source.base_url = url
|
||||||
|
logger.info("URL de la source %s : %s", name, url)
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"base_url": url,
|
||||||
|
"default_base_url": default,
|
||||||
|
"overridden": url != default,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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 = _source_or_404(name)
|
||||||
|
try:
|
||||||
|
results = await asyncio.wait_for(source.search("naruto"), timeout=30)
|
||||||
|
healthy, detail = len(results) > 0, f"{len(results)} résultats"
|
||||||
|
except (ScrapeError, TimeoutError) as exc:
|
||||||
|
healthy, detail = False, str(exc)[:200]
|
||||||
|
logger.error("Health check %s KO : %s", name, exc)
|
||||||
|
except Exception as exc:
|
||||||
|
healthy, detail = False, f"Erreur inattendue : {exc}"[:200]
|
||||||
|
logger.exception("Health check %s : erreur inattendue", name)
|
||||||
|
state = await set_source_health(name, healthy, detail)
|
||||||
|
return {"name": name, **state}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sources")
|
||||||
|
async def sources_status(admin: AdminUser) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": s.name,
|
||||||
|
"label": s.label,
|
||||||
|
"base_url": s.base_url,
|
||||||
|
"default_base_url": type(s).base_url,
|
||||||
|
"overridden": s.base_url != type(s).base_url,
|
||||||
|
"enabled": await is_source_enabled(s.name),
|
||||||
|
"health": await get_source_health(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()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- mise à jour logicielle
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/update")
|
||||||
|
async def get_update(admin: AdminUser) -> dict:
|
||||||
|
"""Version courante, dernière version disponible et patchnote."""
|
||||||
|
return await update_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/update/check")
|
||||||
|
async def check_update(admin: AdminUser) -> dict:
|
||||||
|
"""Force la re-vérification de la dernière version (ignore le cache)."""
|
||||||
|
await fetch_latest_version(force=True)
|
||||||
|
return await update_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/update/apply")
|
||||||
|
async def apply_update(admin: AdminUser) -> dict:
|
||||||
|
"""Déclenche la mise à jour via Watchtower (le conteneur est recréé)."""
|
||||||
|
try:
|
||||||
|
return await trigger_update()
|
||||||
|
except UpdateError as exc:
|
||||||
|
raise HTTPException(502, str(exc)) from exc
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Routes d'authentification (cookies httponly, adaptées à l'UI htmx)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, 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,
|
||||||
|
"content_preference": user.content_preference,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences")
|
||||||
|
async def update_preferences(
|
||||||
|
user: CurrentUser,
|
||||||
|
content_preference: Annotated[str, Body(embed=True)],
|
||||||
|
) -> dict:
|
||||||
|
"""Préférence de contenus : animés, séries, ou les deux."""
|
||||||
|
try:
|
||||||
|
await auth.set_content_preference(user.id, content_preference)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, detail=str(exc)) from exc
|
||||||
|
return {"content_preference": content_preference}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Découverte : nouveautés par type, incontournables, recommandations, exploration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from app.routers.auth import CurrentUser, current_user
|
||||||
|
from app.routers.search import _allowed_media_types
|
||||||
|
from app.scrapers.base import ScrapeError, get_source
|
||||||
|
from app.services.discover import ANIME_GENRES, 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:
|
||||||
|
"""Rails de découverte en une requête (sections vides si source KO).
|
||||||
|
|
||||||
|
« latest_anime » et « latest_serie » (séries + films réels) sont des rails
|
||||||
|
indépendants, filtrés par la préférence de contenu du compte.
|
||||||
|
"""
|
||||||
|
allowed = _allowed_media_types(user.content_preference)
|
||||||
|
rails = await discover.latest_by_type(latest_limit)
|
||||||
|
latest_anime = rails["anime"] if "anime" in allowed else []
|
||||||
|
latest_serie = (rails["serie"] + rails["film"]) if {"serie", "film"} & allowed else []
|
||||||
|
# Incontournables et Pour toi sont issus de Kitsu (catalogue animés) :
|
||||||
|
# en mode séries, elles n'ont pas de sens — on ne les calcule même pas.
|
||||||
|
if "anime" in allowed:
|
||||||
|
must_watch = await discover.must_watch(must_watch_limit)
|
||||||
|
for_you = await discover.for_you(user.id, for_you_limit)
|
||||||
|
else:
|
||||||
|
must_watch = []
|
||||||
|
for_you = {"based_on": [], "items": []}
|
||||||
|
return {
|
||||||
|
"latest_anime": latest_anime,
|
||||||
|
"latest_serie": latest_serie,
|
||||||
|
"must_watch": must_watch,
|
||||||
|
"for_you": for_you,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/discover/genres")
|
||||||
|
async def get_genres(user: CurrentUser) -> dict:
|
||||||
|
"""Catalogue des genres parcourables (page Explorer), selon la préférence."""
|
||||||
|
allowed = _allowed_media_types(user.content_preference)
|
||||||
|
catalog: dict[str, list[dict]] = {}
|
||||||
|
if "anime" in allowed:
|
||||||
|
catalog["anime"] = [{"key": key, "label": label} for key, label in ANIME_GENRES.items()]
|
||||||
|
try:
|
||||||
|
french_stream = get_source("french_stream")
|
||||||
|
available = french_stream.browse_catalog()
|
||||||
|
except ScrapeError:
|
||||||
|
available = {}
|
||||||
|
for kind in ("serie", "film"):
|
||||||
|
if kind in allowed and kind in available:
|
||||||
|
catalog[kind] = [
|
||||||
|
{"key": key, "label": label} for key, label in available[kind].items()
|
||||||
|
]
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/discover/browse")
|
||||||
|
async def get_browse(
|
||||||
|
user: CurrentUser,
|
||||||
|
type: Annotated[str, Query(pattern="^(anime|serie|film)$")],
|
||||||
|
genre: Annotated[str, Query(min_length=1, max_length=40)],
|
||||||
|
limit: Annotated[int, Query(ge=1, le=40)] = 24,
|
||||||
|
) -> dict:
|
||||||
|
"""Titres d'un genre : animés via Kitsu, séries/films via French-Stream."""
|
||||||
|
if type not in _allowed_media_types(user.content_preference):
|
||||||
|
return {"items": []} # hors préférence du compte
|
||||||
|
if type == "anime":
|
||||||
|
return {"items": await discover.browse_anime(genre, limit)}
|
||||||
|
return {"items": await discover.browse_serie_film(type, genre, limit)}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""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.delete("/{download_id}")
|
||||||
|
async def delete_download(download_id: int, delete_file: bool = False) -> dict:
|
||||||
|
"""Retire une tâche de la file ; delete_file=true efface aussi le fichier."""
|
||||||
|
if not await download_manager.delete(download_id, delete_file=delete_file):
|
||||||
|
raise HTTPException(404, detail="Téléchargement introuvable")
|
||||||
|
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 message in download_manager.subscribe():
|
||||||
|
yield {"data": json.dumps(message, default=str)}
|
||||||
|
|
||||||
|
return EventSourceResponse(stream())
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""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")
|
||||||
|
|
||||||
|
# Cache-busting des assets : version dérivée de la date des fichiers statiques —
|
||||||
|
# tout changement de CSS/JS invalide le cache navigateur sans intervention.
|
||||||
|
_static_root = BASE_DIR / "app" / "static"
|
||||||
|
templates.env.globals["asset_v"] = str(
|
||||||
|
max(int(p.stat().st_mtime) for p in _static_root.rglob("*") if p.is_file())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""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"
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""API compatible qBittorrent Web API v2 — Ohm comme client de téléchargement Sonarr.
|
||||||
|
|
||||||
|
Sonarr sait piloter un qBittorrent ; Ohm implémente le sous-set suffisant pour
|
||||||
|
être vu comme un client de téléchargement « torrent » :
|
||||||
|
|
||||||
|
- login (`/auth/login`, mot de passe = clé API Torznab) + session SID
|
||||||
|
- `torrents/add` : Sonarr renvoie le .torrent de service servi par l'indexeur
|
||||||
|
Torznab → le grab est rejoué (dédupliqué par infohash) dans la file interne
|
||||||
|
- `torrents/info` / `properties` : progression temps réel, `content_path`
|
||||||
|
pointant vers le fichier dans /downloads (mapper en chemin hôte côté Sonarr
|
||||||
|
via « Remote Path Mapping » si besoin)
|
||||||
|
- `torrents/delete` : retrait de la file, avec ou sans le fichier
|
||||||
|
- `pause`/`resume` : branchés sur le gestionnaire de téléchargements
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import parse_qsl
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
|
||||||
|
|
||||||
|
from app.db import db
|
||||||
|
from app.scrapers.base import ScrapeError
|
||||||
|
from app.services.downloads import download_manager
|
||||||
|
from app.services.settings import get_torznab_apikey
|
||||||
|
from app.services.torznab import _bencode, bdecode, torznab
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _plain(text: str) -> Response:
|
||||||
|
"""qBittorrent répond en texte brut, pas en JSON."""
|
||||||
|
return Response(text, media_type="text/plain")
|
||||||
|
router = APIRouter(tags=["qbit"])
|
||||||
|
_OPTIONAL_FILE = File(None)
|
||||||
|
|
||||||
|
_SID_TTL = 3600.0
|
||||||
|
_sessions: dict[str, float] = {}
|
||||||
|
|
||||||
|
_QBIT_VERSION = "v4.6.0"
|
||||||
|
_WEBAPI_VERSION = "2.9.3"
|
||||||
|
|
||||||
|
# status Ohm → état qBittorrent (noms compris par Sonarr v3/v4)
|
||||||
|
_QBIT_STATES = {
|
||||||
|
"pending": "queuedDL",
|
||||||
|
"downloading": "downloading",
|
||||||
|
"paused": "pausedDL",
|
||||||
|
"done": "pausedUP", # terminé → Sonarr importe
|
||||||
|
"failed": "error",
|
||||||
|
"cancelled": "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PREFIX = "sonarr:"
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_sid(request: Request) -> None:
|
||||||
|
sid = request.cookies.get("SID")
|
||||||
|
if not sid or _sessions.get(sid, 0.0) < time.time():
|
||||||
|
_sessions.pop(sid, None)
|
||||||
|
raise HTTPException(403, "Session invalide — (re)connecte-toi via /api/v2/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v2/auth/login")
|
||||||
|
async def login(username: str = Form(""), password: str = Form("")) -> Response:
|
||||||
|
if password != await get_torznab_apikey():
|
||||||
|
logger.warning("Login qBittorrent refusé (utilisateur %r)", username)
|
||||||
|
raise HTTPException(403, "Fails.")
|
||||||
|
sid = secrets.token_hex(16)
|
||||||
|
_sessions[sid] = time.time() + _SID_TTL
|
||||||
|
response = _plain("Ok.")
|
||||||
|
response.set_cookie("SID", sid, httponly=True)
|
||||||
|
logger.info("Client qBittorrent authentifié (utilisateur %r)", username)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v2/app/version", dependencies=[Depends(_require_sid)])
|
||||||
|
async def app_version() -> Response:
|
||||||
|
return _plain(_QBIT_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v2/app/webapiVersion", dependencies=[Depends(_require_sid)])
|
||||||
|
async def webapi_version() -> Response:
|
||||||
|
return _plain(_WEBAPI_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v2/app/preferences", dependencies=[Depends(_require_sid)])
|
||||||
|
async def app_preferences() -> dict:
|
||||||
|
return {"save_path": "/downloads"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v2/transfer/info", dependencies=[Depends(_require_sid)])
|
||||||
|
async def transfer_info() -> dict:
|
||||||
|
return {"dl_info_speed": 0, "dl_info_data": 0, "up_info_speed": 0, "up_info_data": 0}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- torrents
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(sqlite_dt: str | None) -> int:
|
||||||
|
if not sqlite_dt:
|
||||||
|
return -1
|
||||||
|
try:
|
||||||
|
return int(
|
||||||
|
datetime.strptime(sqlite_dt, "%Y-%m-%d %H:%M:%S")
|
||||||
|
.replace(tzinfo=UTC)
|
||||||
|
.timestamp()
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
async def _sonarr_rows() -> dict[str, dict]:
|
||||||
|
"""Téléchargements d'origine Sonarr, indexés par infohash (dernier par hash)."""
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM downloads WHERE source_key LIKE '{_PREFIX}%' ORDER BY id"
|
||||||
|
)
|
||||||
|
by_hash: dict[str, dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
prefix_end = row["source_key"].find("|")
|
||||||
|
infohash = row["source_key"][len(_PREFIX) : prefix_end]
|
||||||
|
by_hash[infohash] = dict(row) # le plus grand id écrase les précédents
|
||||||
|
return by_hash
|
||||||
|
|
||||||
|
|
||||||
|
def _content_path(row: dict) -> str:
|
||||||
|
return "/downloads/" + (row["file_path"] or row["title"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v2/torrents/info", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_info() -> list[dict]:
|
||||||
|
items = []
|
||||||
|
for infohash, row in (await _sonarr_rows()).items():
|
||||||
|
live = await download_manager.get(row["id"])
|
||||||
|
downloaded = live["downloaded_bytes"]
|
||||||
|
total = live["total_bytes"]
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"hash": infohash,
|
||||||
|
"name": row["title"],
|
||||||
|
"state": _QBIT_STATES.get(row["status"], "error"),
|
||||||
|
"progress": round(downloaded / total, 4) if total else 0.0,
|
||||||
|
"dlspeed": live["speed_bps"],
|
||||||
|
"eta": live["eta_seconds"] or 0,
|
||||||
|
"total_size": total or 0,
|
||||||
|
"completed": downloaded,
|
||||||
|
"amount_left": max(0, (total or 0) - downloaded),
|
||||||
|
"category": "",
|
||||||
|
"tags": "",
|
||||||
|
"save_path": _content_path(row).rsplit("/", 1)[0],
|
||||||
|
"content_path": _content_path(row),
|
||||||
|
"added_on": _ts(row["created_at"]),
|
||||||
|
"completion_on": _ts(row["updated_at"]) if row["status"] == "done" else -1,
|
||||||
|
"ratio": 1,
|
||||||
|
"num_seeds": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v2/torrents/properties", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_properties(hash: str) -> dict:
|
||||||
|
rows = await _sonarr_rows()
|
||||||
|
row = rows.get(hash.lower())
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "Torrent introuvable")
|
||||||
|
live = await download_manager.get(row["id"])
|
||||||
|
return {
|
||||||
|
"name": row["title"],
|
||||||
|
"content_path": _content_path(row),
|
||||||
|
"save_path": _content_path(row).rsplit("/", 1)[0],
|
||||||
|
"total_size": live["total_bytes"] or 0,
|
||||||
|
"total_downloaded": live["downloaded_bytes"],
|
||||||
|
"addition_date": _ts(row["created_at"]),
|
||||||
|
"completion_date": _ts(row["updated_at"]) if row["status"] == "done" else -1,
|
||||||
|
"seeding_time": 0,
|
||||||
|
"share_ratio": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_categories: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v2/torrents/categories", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_categories() -> dict:
|
||||||
|
return _categories
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v2/torrents/createCategory", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_create_category(category: str = Form(""), savePath: str = Form("")) -> Response:
|
||||||
|
if category:
|
||||||
|
_categories[category] = {"name": category, "savePath": savePath}
|
||||||
|
return _plain("Ok.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v2/torrents/tags", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_tags() -> list:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v2/torrents/setCategory", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_set_category() -> Response:
|
||||||
|
return _plain("Ok.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_stub(stub: bytes) -> Response:
|
||||||
|
"""Rejoue le grab encodé dans un .torrent de service."""
|
||||||
|
try:
|
||||||
|
parsed = bdecode(stub)
|
||||||
|
info = parsed[b"info"]
|
||||||
|
announce = parsed[b"announce"].decode()
|
||||||
|
params = dict(parse_qsl(announce.split("?", 1)[1]))
|
||||||
|
infohash = hashlib.sha1(_bencode(info)).hexdigest()
|
||||||
|
await torznab.grab(
|
||||||
|
params["source"],
|
||||||
|
params["sid"],
|
||||||
|
int(params["season"]),
|
||||||
|
int(params["ep"]),
|
||||||
|
params["series"],
|
||||||
|
sonarr_hash=infohash,
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError, ScrapeError) as exc:
|
||||||
|
logger.error("Ajout qBittorrent refusé : %s", exc)
|
||||||
|
return _plain("Fals.")
|
||||||
|
return _plain("Ok.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v2/torrents/add", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_add(torrents: UploadFile | None = _OPTIONAL_FILE) -> Response:
|
||||||
|
if torrents is None or not torrents.filename:
|
||||||
|
return _plain("Fals.")
|
||||||
|
return await _add_stub(await torrents.read())
|
||||||
|
|
||||||
|
|
||||||
|
async def _ids_for_hashes(hashes: str) -> list[int]:
|
||||||
|
wanted = {h.lower() for h in hashes.split("|") if h}
|
||||||
|
rows = await _sonarr_rows()
|
||||||
|
return [row["id"] for infohash, row in rows.items() if infohash in wanted]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v2/torrents/delete", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_delete(
|
||||||
|
hashes: str = Form(...), deleteFiles: str = Form("false")
|
||||||
|
) -> Response:
|
||||||
|
for download_id in await _ids_for_hashes(hashes):
|
||||||
|
await download_manager.delete(download_id, delete_file=deleteFiles == "true")
|
||||||
|
return _plain("Ok.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v2/torrents/pause", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_pause(hashes: str = Form(...)) -> Response:
|
||||||
|
for download_id in await _ids_for_hashes(hashes):
|
||||||
|
await download_manager.pause(download_id)
|
||||||
|
return _plain("Ok.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v2/torrents/resume", dependencies=[Depends(_require_sid)])
|
||||||
|
async def torrents_resume(hashes: str = Form(...)) -> Response:
|
||||||
|
for download_id in await _ids_for_hashes(hashes):
|
||||||
|
await download_manager.resume(download_id)
|
||||||
|
return _plain("Ok.")
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""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 CurrentUser, 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()
|
||||||
|
|
||||||
|
# Types de médias gardés selon la préférence. Les films réels (French-Stream)
|
||||||
|
# accompagnent les séries — le mode animés reste sur l'animation.
|
||||||
|
_PREFERENCE_MEDIA_TYPES = {
|
||||||
|
"anime": {"anime"},
|
||||||
|
"serie": {"serie", "film"},
|
||||||
|
"both": {"anime", "serie", "film"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_media_types(preference: str) -> set[str]:
|
||||||
|
return _PREFERENCE_MEDIA_TYPES.get(preference, _PREFERENCE_MEDIA_TYPES["both"])
|
||||||
|
|
||||||
|
|
||||||
|
async def enabled_sources(allowed: set[str] | None = None) -> list[SourceScraper]:
|
||||||
|
"""Sources activées, limitées à celles pouvant servir les types de médias autorisés."""
|
||||||
|
sources = []
|
||||||
|
for source in all_sources():
|
||||||
|
if not await is_source_enabled(source.name):
|
||||||
|
continue
|
||||||
|
if allowed is not None and not set(source.media_types) & allowed:
|
||||||
|
continue
|
||||||
|
sources.append(source)
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
sources = []
|
||||||
|
for source in all_sources():
|
||||||
|
if await is_source_enabled(source.name):
|
||||||
|
sources.append(source)
|
||||||
|
|
||||||
|
|
||||||
|
@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)], user: CurrentUser) -> dict:
|
||||||
|
"""Recherche unifiée : une requête interroge toutes les sources activées."""
|
||||||
|
allowed = _allowed_media_types(user.content_preference)
|
||||||
|
sources = await enabled_sources(allowed)
|
||||||
|
|
||||||
|
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]
|
||||||
|
results = [r for r in results if r.get("media_type", "anime") in allowed]
|
||||||
|
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}")
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Endpoints système publics : version (utilisée par le frontend pour détecter
|
||||||
|
une mise à jour et recharger la page automatiquement)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.version import get_version
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["system"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/version")
|
||||||
|
async def version() -> dict[str, str]:
|
||||||
|
return {"name": get_settings().app_name, "version": get_version()}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""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 urllib.parse import urlencode
|
||||||
|
|
||||||
|
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 build_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:
|
||||||
|
releases = await torznab.latest_releases()
|
||||||
|
return Response(
|
||||||
|
torznab.results_xml(base, key, releases), media_type="application/rss+xml"
|
||||||
|
)
|
||||||
|
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 » ; Ohm télécharge l'épisode.
|
||||||
|
|
||||||
|
Le flux retourne un .torrent de service : avec un client « Torrent
|
||||||
|
Blackhole » c'est un simple accusé de réception ; avec le client
|
||||||
|
qBittumber (l'API /api/v2 d'Ohm), Sonarr le renvoie et le grab est
|
||||||
|
rejoué/dédupliqué, puis suivi comme un téléchargement classique.
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
name = f"{series} S{season:02d}E{ep:02d}"
|
||||||
|
announce = (
|
||||||
|
_base_url(request)
|
||||||
|
+ "/torznab/api?"
|
||||||
|
+ urlencode(
|
||||||
|
{"source": source, "sid": sid, "season": season, "ep": ep, "series": series}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stub, infohash = build_stub(announce, name)
|
||||||
|
try:
|
||||||
|
result = await torznab.grab(source, sid, season, ep, series, sonarr_hash=infohash)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
stub,
|
||||||
|
media_type="application/x-bittorrent",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": (
|
||||||
|
f'attachment; filename="ohm-{series.replace("/", "-")}'
|
||||||
|
f"-S{season:02d}E{ep:02d}.torrent"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""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
|
||||||
|
version: str | None = None # langue ("vf" / "vostfr") quand la source la distingue
|
||||||
|
|
||||||
|
@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]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""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 {}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 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"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Sélecteurs/endpoints French-Stream (french-stream.lat, DataLife Engine) — surchargeables
|
||||||
|
# sans toucher au code. Structure vérifiée en live (films & séries VF/VOSTFR).
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
search: "/engine/ajax/search.php" # POST query=<q>&page=1
|
||||||
|
episodes: "/ep-data.php?id={newsid}&format=js" # JSON {vf, vostfr, vo, info}
|
||||||
|
film: "/engine/ajax/film_api.php?id={newsid}" # JSON {players: {hoster: {version: url}}}
|
||||||
|
|
||||||
|
search:
|
||||||
|
item: "div.search-item" # bloc résultat (lien dans onclick="location.href='...'")
|
||||||
|
title: ".search-title"
|
||||||
|
poster: ".search-poster img"
|
||||||
|
|
||||||
|
details:
|
||||||
|
title: "h1#s-title"
|
||||||
|
synopsis: "div.fdesc" # le boilerplate p.desc-text est retiré
|
||||||
|
synopsis_boilerplate: "p.desc-text"
|
||||||
|
genres: ".facts .genres"
|
||||||
|
year: ".facts .release"
|
||||||
|
poster_serie: ".fposter img"
|
||||||
|
poster_film: "#film-data" # attribut data-affiche
|
||||||
|
serie_marker: "#serie-config" # présent = fiche série (sinon film)
|
||||||
|
|
||||||
|
latest:
|
||||||
|
path: "/series/" # mix films/séries ; « Saison » dans le titre = série
|
||||||
|
item: "div.short"
|
||||||
|
link: "a.short-poster" # href = fiche (/index.php?newsid=N), alt = titre
|
||||||
|
image: "img"
|
||||||
|
|
||||||
|
browse: # parcours par genre (page Explorer) — chemins vérifiés en live
|
||||||
|
serie: # pages /<genre>-series-/ (9 genres exposés par le site)
|
||||||
|
aventure: {path: "/aventure-series-/", label: "Aventure"}
|
||||||
|
familles: {path: "/familles-series-/", label: "Famille"}
|
||||||
|
fantastique: {path: "/fantastique-series-/", label: "Fantastique"}
|
||||||
|
judiciaire: {path: "/judiciare-series-/", label: "Judiciaire"} # coquille du site
|
||||||
|
medical: {path: "/medical-series-/", label: "Médical"}
|
||||||
|
romance: {path: "/romance-series-/", label: "Romance"}
|
||||||
|
science-fiction: {path: "/science-fiction-series-/", label: "Science-Fiction"}
|
||||||
|
thriller: {path: "/thriller-series-/", label: "Thriller"}
|
||||||
|
western: {path: "/western-series-/", label: "Western"}
|
||||||
|
film: # pages /films/<genre>/
|
||||||
|
actions: {path: "/films/actions/", label: "Action"}
|
||||||
|
animations: {path: "/films/animations/", label: "Animation"}
|
||||||
|
aventures: {path: "/films/aventures/", label: "Aventure"}
|
||||||
|
biopics: {path: "/films/biopics/", label: "Biopic"}
|
||||||
|
comedies: {path: "/films/comedies/", label: "Comédie"}
|
||||||
|
cultes: {path: "/films/cultes/", label: "Culte"}
|
||||||
|
documentaires: {path: "/films/documentaires/", label: "Documentaire"}
|
||||||
|
drames: {path: "/films/drames/", label: "Drame"}
|
||||||
|
epouvante-horreurs: {path: "/films/epouvante-horreurs/", label: "Épouvante-Horreur"}
|
||||||
|
espionnages: {path: "/films/espionnages/", label: "Espionnage"}
|
||||||
|
familles: {path: "/films/familles/", label: "Famille"}
|
||||||
|
fantastiques: {path: "/films/fantastiques/", label: "Fantastique"}
|
||||||
|
guerres: {path: "/films/guerres/", label: "Guerre"}
|
||||||
|
historiques: {path: "/films/historiques/", label: "Historique"}
|
||||||
|
policiers: {path: "/films/policiers/", label: "Policier"}
|
||||||
|
romances: {path: "/films/romances/", label: "Romance"}
|
||||||
|
science-fictions: {path: "/films/science-fictions/", label: "Science-Fiction"}
|
||||||
|
thrillers: {path: "/films/thrillers/", label: "Thriller"}
|
||||||
|
westerns: {path: "/films/westerns/", label: "Western"}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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/{}"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""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}")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Extracteur Uqload (uqload.to/.co/.com/.io/.vc) — 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", "uqload.vc")
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Extracteur Vidzy (vidzy.org/.cc/.live) — 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", "vidzy.cc", "vidzy.live")
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Extracteur VoirAnime — endpoint « prepare » → URL lisible (directe ou proxy site).
|
||||||
|
|
||||||
|
Chaque source d'un épisode voiranime.xyz se résout via
|
||||||
|
`/lecteur/prepare/<source_id>?content=..&episode=..` (AJAX : en-têtes Accept JSON et
|
||||||
|
X-Requested-With obligatoires) qui renvoie :
|
||||||
|
`{"success": true, "media_type": "mp4"|"hls", "stream_url": "/proxy/media?payload=<b64>"}`.
|
||||||
|
|
||||||
|
Le payload base64 est un JSON `{"url": ..., "referer": ..., "kind": ...}` où `url` est
|
||||||
|
l'URL directe chez l'hébergeur d'origine. Deux cas (vérifiés en live) :
|
||||||
|
- mp4 : l'URL d'origine est libre d'accès avec son Referer (Sibnet…) → on la retourne
|
||||||
|
directement (téléchargement/Range natifs, sans double saut).
|
||||||
|
- hls : les CDN utilisés (SmoothPre & co) signent leurs playlists pour le backend du
|
||||||
|
site — accès direct 403. Le proxy `/proxy/media?payload=…` du site, lui, sert la
|
||||||
|
playlist ET ses segments (chemins re-hostés) → on retourne l'URL proxy absolue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
|
||||||
|
from app.scrapers.http import fetch
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@register_hoster
|
||||||
|
class VoirAnimeExtractor(HosterExtractor):
|
||||||
|
name = "voiranime"
|
||||||
|
domains = ("voiranime.xyz",)
|
||||||
|
|
||||||
|
async def extract(self, embed_url: str) -> VideoLink:
|
||||||
|
origin = self._origin_of(embed_url)
|
||||||
|
raw = await fetch(
|
||||||
|
embed_url,
|
||||||
|
referer=self._referer_for(embed_url),
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ScrapeError(f"voiranime : réponse prepare invalide : {raw[:120]}") from exc
|
||||||
|
if not data.get("success"):
|
||||||
|
raise ScrapeError(f"voiranime : {data.get('error') or 'échec de préparation du lecteur'}")
|
||||||
|
|
||||||
|
media_type = str(data.get("media_type") or "").lower()
|
||||||
|
stream_url = str(data.get("stream_url") or "")
|
||||||
|
|
||||||
|
if media_type == "hls":
|
||||||
|
if not stream_url:
|
||||||
|
raise ScrapeError(f"voiranime : flux HLS sans stream_url dans {embed_url}")
|
||||||
|
return VideoLink(
|
||||||
|
url=f"{origin}/{stream_url.lstrip('/')}",
|
||||||
|
hoster=self.name,
|
||||||
|
headers={"Referer": f"{origin}/"},
|
||||||
|
is_hls=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = self._extract_payload(stream_url)
|
||||||
|
url = str(payload.get("url") or "")
|
||||||
|
if not url:
|
||||||
|
raise ScrapeError(f"voiranime : payload sans URL de flux dans {embed_url}")
|
||||||
|
referer = str(payload.get("referer") or "")
|
||||||
|
return VideoLink(
|
||||||
|
url=url,
|
||||||
|
hoster=self.name,
|
||||||
|
headers={"Referer": referer} if referer else {},
|
||||||
|
is_hls=url.split("?")[0].endswith(".m3u8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _origin_of(embed_url: str) -> str:
|
||||||
|
parsed = urlparse(embed_url)
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _referer_for(embed_url: str) -> str:
|
||||||
|
"""Reconstruit la page lecteur d'où provient la requête prepare."""
|
||||||
|
origin = VoirAnimeExtractor._origin_of(embed_url)
|
||||||
|
query = parse_qs(urlparse(embed_url).query)
|
||||||
|
content = query.get("content", [""])[0]
|
||||||
|
episode = query.get("episode", [""])[0]
|
||||||
|
if content and episode:
|
||||||
|
return f"{origin}/lecteur/{content}/{episode}"
|
||||||
|
return f"{origin}/"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_payload(stream_url: str) -> dict:
|
||||||
|
payload_b64 = parse_qs(urlparse(stream_url).query).get("payload", [""])[0]
|
||||||
|
if not payload_b64:
|
||||||
|
raise ScrapeError(f"voiranime : pas de payload dans stream_url ({stream_url[:80]})")
|
||||||
|
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(padded)
|
||||||
|
except (binascii.Error, ValueError):
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode(padded)
|
||||||
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
raise ScrapeError(f"voiranime : payload illisible : {payload_b64[:60]}") from exc
|
||||||
|
try:
|
||||||
|
data = json.loads(decoded)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
raise ScrapeError(f"voiranime : payload non-JSON : {payload_b64[:60]}") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ScrapeError("voiranime : payload inattendu (pas un objet JSON)")
|
||||||
|
return data
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""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,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
data: dict[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""GET (ou POST si `data` est fourni) avec retries ; lève ScrapeError en cas d'échec définitif."""
|
||||||
|
request_headers = dict(headers) if headers else {}
|
||||||
|
if referer:
|
||||||
|
request_headers.setdefault("Referer", referer)
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(retries + 1):
|
||||||
|
try:
|
||||||
|
if data is None:
|
||||||
|
response = await get_client().get(url, headers=request_headers)
|
||||||
|
else:
|
||||||
|
response = await get_client().post(url, headers=request_headers, data=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status = exc.response.status_code
|
||||||
|
if 400 <= status < 500 and status not in (408, 429):
|
||||||
|
raise ScrapeError(f"Échec de récupération de {url} : HTTP {status} (définitif)") from exc
|
||||||
|
last_error = exc
|
||||||
|
logger.warning("fetch %s — HTTP %d, tentative %d/%d", url, status, attempt + 1, retries + 1)
|
||||||
|
if attempt < retries:
|
||||||
|
await asyncio.sleep(1.0 * (attempt + 1))
|
||||||
|
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")
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"""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))
|
||||||
|
|
||||||
|
info = data.get("info") or {}
|
||||||
|
episodes: list[Episode] = []
|
||||||
|
for version in versions:
|
||||||
|
numbers: set[float] = set()
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
episodes.append(
|
||||||
|
Episode(
|
||||||
|
number=number,
|
||||||
|
title=label,
|
||||||
|
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
|
||||||
|
season=season,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
"""Source French-Stream (french-stream.lat) — films & séries VF/VOSTFR, moteur DataLife Engine.
|
||||||
|
|
||||||
|
Faits structurels (vérifiés en live) :
|
||||||
|
- Recherche : POST /engine/ajax/search.php (query, page) → blocs `div.search-item`
|
||||||
|
(lien dans onclick="location.href='...'", poster `.search-poster img`).
|
||||||
|
- Fiche : `/index.php?newsid=<id>` (ou la jolie URL `/<id>-<slug>.html`).
|
||||||
|
`source_id` = newsid numérique.
|
||||||
|
- Séries : une fiche par saison (« Titre - Saison N »). Épisodes via
|
||||||
|
GET /ep-data.php?id=<newsid>&format=js → JSON
|
||||||
|
`{"vf": {"1": {"vidzy": url, "uqload": url, ...}}, "vostfr": {...}, "info": {...}}`.
|
||||||
|
- Films : lecteurs via GET /engine/ajax/film_api.php?id=<newsid> → JSON
|
||||||
|
`{"players": {"vidzy": {"default": url, "vff": url, "vostfr": url, ...}}}`.
|
||||||
|
- URL d'épisode interne : `<fiche>#vf-3` (série) ou `<fiche>#film` (film).
|
||||||
|
- Nouveautés : /series/ → blocs `div.short` (mix films/séries, « Saison » dans le
|
||||||
|
titre = série).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict = {
|
||||||
|
"endpoints": {
|
||||||
|
"search": "/engine/ajax/search.php",
|
||||||
|
"episodes": "/ep-data.php?id={newsid}&format=js",
|
||||||
|
"film": "/engine/ajax/film_api.php?id={newsid}",
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"item": "div.search-item",
|
||||||
|
"title": ".search-title",
|
||||||
|
"poster": ".search-poster img",
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"title": "h1#s-title",
|
||||||
|
"synopsis": "div.fdesc",
|
||||||
|
"synopsis_boilerplate": "p.desc-text",
|
||||||
|
"genres": ".facts .genres",
|
||||||
|
"year": ".facts .release",
|
||||||
|
"poster_serie": ".fposter img",
|
||||||
|
"poster_film": "#film-data",
|
||||||
|
"serie_marker": "#serie-config",
|
||||||
|
},
|
||||||
|
"latest": {
|
||||||
|
"path": "/series/",
|
||||||
|
"item": "div.short",
|
||||||
|
"link": "a.short-poster",
|
||||||
|
"image": "img",
|
||||||
|
},
|
||||||
|
# Parcours par genre (page Explorer) : type → clé → {path, label}.
|
||||||
|
# Chemins vérifiés en live : films = /films/<genre>/, séries = /<genre>-series-/.
|
||||||
|
"browse": {
|
||||||
|
"serie": {
|
||||||
|
"aventure": {"path": "/aventure-series-/", "label": "Aventure"},
|
||||||
|
"familles": {"path": "/familles-series-/", "label": "Famille"},
|
||||||
|
"fantastique": {"path": "/fantastique-series-/", "label": "Fantastique"},
|
||||||
|
"judiciaire": {"path": "/judiciare-series-/", "label": "Judiciaire"}, # coquille du site
|
||||||
|
"medical": {"path": "/medical-series-/", "label": "Médical"},
|
||||||
|
"romance": {"path": "/romance-series-/", "label": "Romance"},
|
||||||
|
"science-fiction": {"path": "/science-fiction-series-/", "label": "Science-Fiction"},
|
||||||
|
"thriller": {"path": "/thriller-series-/", "label": "Thriller"},
|
||||||
|
"western": {"path": "/western-series-/", "label": "Western"},
|
||||||
|
},
|
||||||
|
"film": {
|
||||||
|
"actions": {"path": "/films/actions/", "label": "Action"},
|
||||||
|
"animations": {"path": "/films/animations/", "label": "Animation"},
|
||||||
|
"aventures": {"path": "/films/aventures/", "label": "Aventure"},
|
||||||
|
"biopics": {"path": "/films/biopics/", "label": "Biopic"},
|
||||||
|
"comedies": {"path": "/films/comedies/", "label": "Comédie"},
|
||||||
|
"cultes": {"path": "/films/cultes/", "label": "Culte"},
|
||||||
|
"documentaires": {"path": "/films/documentaires/", "label": "Documentaire"},
|
||||||
|
"drames": {"path": "/films/drames/", "label": "Drame"},
|
||||||
|
"epouvante-horreurs": {"path": "/films/epouvante-horreurs/", "label": "Épouvante-Horreur"},
|
||||||
|
"espionnages": {"path": "/films/espionnages/", "label": "Espionnage"},
|
||||||
|
"familles": {"path": "/films/familles/", "label": "Famille"},
|
||||||
|
"fantastiques": {"path": "/films/fantastiques/", "label": "Fantastique"},
|
||||||
|
"guerres": {"path": "/films/guerres/", "label": "Guerre"},
|
||||||
|
"historiques": {"path": "/films/historiques/", "label": "Historique"},
|
||||||
|
"policiers": {"path": "/films/policiers/", "label": "Policier"},
|
||||||
|
"romances": {"path": "/films/romances/", "label": "Romance"},
|
||||||
|
"science-fictions": {"path": "/films/science-fictions/", "label": "Science-Fiction"},
|
||||||
|
"thrillers": {"path": "/films/thrillers/", "label": "Thriller"},
|
||||||
|
"westerns": {"path": "/films/westerns/", "label": "Western"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_NEWSID_RE = re.compile(r"(?:newsid=|/)(\d+)(?:-[^/]*)?\.?html?$|(?:newsid=)(\d+)")
|
||||||
|
_ONCLICK_URL_RE = re.compile(r"location\.href='([^']+)'")
|
||||||
|
_SEASON_RE = re.compile(r"saison\s*:?\s*(\d+)", re.IGNORECASE)
|
||||||
|
_YEAR_RE = re.compile(r"(\d{4})")
|
||||||
|
_FRAGMENT_RE = re.compile(r"^(vf|vostfr|vo)-(\d+(?:\.\d+)?)$")
|
||||||
|
_VERSION_ORDER = {"vf": 0, "vostfr": 1, "vo": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_config() -> dict:
|
||||||
|
merged = deepcopy(DEFAULT_CONFIG)
|
||||||
|
for key, values in load_scraper_config("french_stream").items():
|
||||||
|
if isinstance(values, dict) and isinstance(merged.get(key), dict):
|
||||||
|
merged[key].update(values)
|
||||||
|
else:
|
||||||
|
merged[key] = values
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _newsid_from_url(url: str) -> str | None:
|
||||||
|
match = _NEWSID_RE.search(url)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group(1) or match.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _media_type(title: str, url: str) -> str:
|
||||||
|
if _SEASON_RE.search(title) or "-saison-" in url:
|
||||||
|
return "serie"
|
||||||
|
return "film"
|
||||||
|
|
||||||
|
|
||||||
|
@register_source
|
||||||
|
class FrenchStreamScraper(SourceScraper):
|
||||||
|
name = "french_stream"
|
||||||
|
label = "French-Stream"
|
||||||
|
base_url = "https://french-stream.lat"
|
||||||
|
media_types = ("serie", "film")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
def _title_url(self, source_id: str) -> str:
|
||||||
|
return f"{self.base_url}/index.php?newsid={source_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _text(element: Tag | None) -> str:
|
||||||
|
return element.get_text(" ", strip=True) if element else ""
|
||||||
|
|
||||||
|
def _is_serie(self, soup: BeautifulSoup, title: str, url: str) -> bool:
|
||||||
|
config = _merged_config()
|
||||||
|
if soup.select_one(config["details"]["serie_marker"]):
|
||||||
|
return True
|
||||||
|
return _media_type(title, url) == "serie"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- search
|
||||||
|
|
||||||
|
async def search(self, query: str) -> list[SearchResult]:
|
||||||
|
config = _merged_config()
|
||||||
|
url = f"{self.base_url}{config['endpoints']['search']}"
|
||||||
|
html = await fetch(
|
||||||
|
url, referer=f"{self.base_url}/", data={"query": query, "page": "1"}
|
||||||
|
)
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
results: list[SearchResult] = []
|
||||||
|
for block in soup.select(config["search"]["item"]):
|
||||||
|
onclick = block.get("onclick", "")
|
||||||
|
url_match = _ONCLICK_URL_RE.search(onclick)
|
||||||
|
if not url_match:
|
||||||
|
logger.warning("french_stream : résultat sans lien, ignoré")
|
||||||
|
continue
|
||||||
|
href = url_match.group(1)
|
||||||
|
source_id = _newsid_from_url(href)
|
||||||
|
if not source_id:
|
||||||
|
logger.warning("french_stream : newsid introuvable dans %s", href)
|
||||||
|
continue
|
||||||
|
title = self._text(block.select_one(config["search"]["title"]))
|
||||||
|
image = block.select_one(config["search"]["poster"])
|
||||||
|
results.append(
|
||||||
|
SearchResult(
|
||||||
|
source=self.name,
|
||||||
|
source_id=source_id,
|
||||||
|
title=title,
|
||||||
|
url=f"{self.base_url}{href}" if href.startswith("/") else href,
|
||||||
|
image_url=image.get("src") if image else None,
|
||||||
|
media_type=_media_type(title, href),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("french_stream : %d résultats pour %r", len(results), query)
|
||||||
|
return results
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- latest
|
||||||
|
|
||||||
|
async def latest(self) -> list[SearchResult]:
|
||||||
|
"""Ajouts récents — page /series/ (mix films & séries)."""
|
||||||
|
config = _merged_config()
|
||||||
|
soup = await fetch_soup(f"{self.base_url}{config['latest']['path']}")
|
||||||
|
results = self._short_blocks(soup)
|
||||||
|
logger.info("french_stream : %d nouveautés récupérées", len(results))
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def browse(self, media_type: str, category: str) -> list[SearchResult]:
|
||||||
|
"""Parcours par genre — pages /films/<genre>/ et /<genre>-series-/.
|
||||||
|
|
||||||
|
``media_type`` (« serie » | « film ») et ``category`` (clé du catalogue
|
||||||
|
YAML, ex. « thriller »). Le type est forcé sur les résultats : une page
|
||||||
|
genre séries ne liste que des séries, une page genre films que des films.
|
||||||
|
"""
|
||||||
|
catalog = _merged_config().get("browse", {}).get(media_type, {})
|
||||||
|
entry = catalog.get(category)
|
||||||
|
if entry is None:
|
||||||
|
raise ScrapeError(f"Catégorie inconnue : {media_type}/{category}")
|
||||||
|
soup = await fetch_soup(f"{self.base_url}{entry['path']}")
|
||||||
|
results = self._short_blocks(soup, force_type=media_type)
|
||||||
|
logger.info("french_stream : %d titres dans %s/%s", len(results), media_type, category)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _short_blocks(self, soup: BeautifulSoup, force_type: str | None = None) -> list[SearchResult]:
|
||||||
|
"""Bloc `div.short` → SearchResult (structure partagée nouveautés/genres)."""
|
||||||
|
config = _merged_config()
|
||||||
|
latest_cfg = config["latest"]
|
||||||
|
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:
|
||||||
|
continue
|
||||||
|
source_id = _newsid_from_url(href)
|
||||||
|
if not source_id:
|
||||||
|
logger.warning("french_stream : newsid introuvable dans %s", href)
|
||||||
|
continue
|
||||||
|
image = link.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=f"{self.base_url}{href}" if href.startswith("/") else href,
|
||||||
|
image_url=image.get("src") if image else None,
|
||||||
|
media_type=force_type or _media_type(title, href),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def browse_catalog(self) -> dict[str, dict[str, str]]:
|
||||||
|
"""Catalogue des genres parcourables : ``{type: {clé: libellé}}``."""
|
||||||
|
catalog = _merged_config().get("browse", {})
|
||||||
|
return {
|
||||||
|
media_type: {key: entry.get("label", key) for key, entry in genres.items()}
|
||||||
|
for media_type, genres in catalog.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def genres_of_page(self, page_url: str) -> list[str]:
|
||||||
|
"""Genres (libellés FR) de la fiche pointée par l'URL d'un téléchargement.
|
||||||
|
|
||||||
|
Utilisé par les recommandations « Pour toi » pour les séries/films,
|
||||||
|
absents de Kitsu (et donc sans signal de genre sans Sonarr).
|
||||||
|
"""
|
||||||
|
if self.base_url.split("//")[-1].split("/")[0] not in page_url:
|
||||||
|
return [] # fiche d'une autre source (animé) : rien à lire ici
|
||||||
|
source_id = _newsid_from_url(page_url)
|
||||||
|
try:
|
||||||
|
details = await self.get_details(source_id)
|
||||||
|
except ScrapeError:
|
||||||
|
return []
|
||||||
|
return details.genres or []
|
||||||
|
# ------------------------------------------------------------- details
|
||||||
|
|
||||||
|
async def get_details(self, source_id: str) -> TitleDetails:
|
||||||
|
config = _merged_config()
|
||||||
|
details_cfg = config["details"]
|
||||||
|
url = self._title_url(source_id)
|
||||||
|
soup = await fetch_soup(url)
|
||||||
|
|
||||||
|
title = " ".join(self._text(soup.select_one(details_cfg["title"])).split())
|
||||||
|
if not title:
|
||||||
|
raise ScrapeError(f"french_stream : fiche introuvable pour {source_id} ({url})")
|
||||||
|
|
||||||
|
synopsis_el = soup.select_one(details_cfg["synopsis"])
|
||||||
|
if synopsis_el is not None:
|
||||||
|
for boilerplate in synopsis_el.select(details_cfg["synopsis_boilerplate"]):
|
||||||
|
boilerplate.decompose()
|
||||||
|
synopsis = self._text(synopsis_el) or None
|
||||||
|
|
||||||
|
genres_el = soup.select_one(details_cfg["genres"])
|
||||||
|
if genres_el and genres_el.select("a"):
|
||||||
|
genres = [a.get_text(strip=True) for a in genres_el.select("a") if a.get_text(strip=True)]
|
||||||
|
else:
|
||||||
|
genres = [g.strip() for g in self._text(genres_el).split(",") if g.strip()]
|
||||||
|
|
||||||
|
year_match = _YEAR_RE.search(self._text(soup.select_one(details_cfg["year"])))
|
||||||
|
if year_match is None:
|
||||||
|
year_match = _YEAR_RE.search(title)
|
||||||
|
|
||||||
|
poster_el = soup.select_one(details_cfg["poster_serie"])
|
||||||
|
poster = poster_el.get("src") if poster_el else None
|
||||||
|
if not poster:
|
||||||
|
film_data = soup.select_one(details_cfg["poster_film"])
|
||||||
|
poster = film_data.get("data-affiche") if film_data else None
|
||||||
|
|
||||||
|
is_serie = self._is_serie(soup, title, url)
|
||||||
|
episodes = await self._fetch_episodes(source_id, url, title) if is_serie else []
|
||||||
|
|
||||||
|
return TitleDetails(
|
||||||
|
source=self.name,
|
||||||
|
source_id=source_id,
|
||||||
|
title=title,
|
||||||
|
url=url,
|
||||||
|
synopsis=synopsis,
|
||||||
|
image_url=poster,
|
||||||
|
genres=genres,
|
||||||
|
year=int(year_match.group(1)) if year_match else None,
|
||||||
|
episode_count=len(episodes) if is_serie else 1,
|
||||||
|
episodes=episodes if is_serie else [self._film_episode(url)],
|
||||||
|
media_type="serie" if is_serie else "film",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ episodes
|
||||||
|
|
||||||
|
async def list_episodes(self, source_id: str) -> list[Episode]:
|
||||||
|
url = self._title_url(source_id)
|
||||||
|
soup = await fetch_soup(url)
|
||||||
|
title = " ".join(
|
||||||
|
self._text(soup.select_one(_merged_config()["details"]["title"])).split()
|
||||||
|
)
|
||||||
|
if not self._is_serie(soup, title, url):
|
||||||
|
return [self._film_episode(url)]
|
||||||
|
episodes = await self._fetch_episodes(source_id, url, title)
|
||||||
|
if not episodes:
|
||||||
|
raise ScrapeError(f"french_stream : aucun épisode trouvé pour {source_id} ({url})")
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _film_episode(page_url: str) -> Episode:
|
||||||
|
return Episode(number=1, title="Film", url=f"{page_url}#film", season=1)
|
||||||
|
|
||||||
|
async def _fetch_episodes(
|
||||||
|
self, source_id: str, page_url: str, title: str
|
||||||
|
) -> list[Episode]:
|
||||||
|
"""Épisodes d'une saison via l'API JSON du site (versions vf/vostfr/vo)."""
|
||||||
|
config = _merged_config()
|
||||||
|
endpoint = config["endpoints"]["episodes"].format(newsid=source_id)
|
||||||
|
raw = await fetch(f"{self.base_url}{endpoint}", referer=page_url)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ScrapeError(
|
||||||
|
f"french_stream : JSON d'épisodes invalide pour {source_id}"
|
||||||
|
) from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
season_match = _SEASON_RE.search(title)
|
||||||
|
season = int(season_match.group(1)) if season_match else 1
|
||||||
|
info = data.get("info") if isinstance(data.get("info"), dict) else {}
|
||||||
|
|
||||||
|
episodes: list[Episode] = []
|
||||||
|
for version, eps in data.items():
|
||||||
|
if version == "info" or not isinstance(eps, dict):
|
||||||
|
continue
|
||||||
|
for number_key in eps:
|
||||||
|
number_match = re.match(r"^(\d+(?:\.\d+)?)$", number_key)
|
||||||
|
if not number_match:
|
||||||
|
continue
|
||||||
|
number = float(number_key)
|
||||||
|
ep_info = info.get(number_key) or info.get(str(int(number))) or {}
|
||||||
|
episodes.append(
|
||||||
|
Episode(
|
||||||
|
number=number,
|
||||||
|
title=ep_info.get("title") if isinstance(ep_info, dict) else None,
|
||||||
|
url=f"{page_url}#{version}-{number_key}",
|
||||||
|
season=season,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
episodes.sort(
|
||||||
|
key=lambda e: (e.number, _VERSION_ORDER.get(e.version or "", 99))
|
||||||
|
)
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- embeds
|
||||||
|
|
||||||
|
async def extract_embed_links(self, episode_url: str) -> list[str]:
|
||||||
|
config = _merged_config()
|
||||||
|
page_url, _, fragment = episode_url.partition("#")
|
||||||
|
source_id = _newsid_from_url(page_url)
|
||||||
|
if not source_id:
|
||||||
|
raise ScrapeError(f"french_stream : newsid introuvable dans {episode_url}")
|
||||||
|
|
||||||
|
if fragment == "film":
|
||||||
|
endpoint = config["endpoints"]["film"].format(newsid=source_id)
|
||||||
|
raw = await fetch(f"{self.base_url}{endpoint}", referer=page_url)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ScrapeError(
|
||||||
|
f"french_stream : JSON film invalide pour {source_id}"
|
||||||
|
) from exc
|
||||||
|
links: list[str] = []
|
||||||
|
for hoster_urls in (data.get("players") or {}).values():
|
||||||
|
if not isinstance(hoster_urls, dict):
|
||||||
|
continue
|
||||||
|
for embed in hoster_urls.values():
|
||||||
|
if isinstance(embed, str) and embed.startswith("http") and embed not in links:
|
||||||
|
links.append(embed)
|
||||||
|
else:
|
||||||
|
fragment_match = _FRAGMENT_RE.match(fragment)
|
||||||
|
if not fragment_match:
|
||||||
|
raise ScrapeError(
|
||||||
|
f"french_stream : fragment d'épisode invalide dans {episode_url}"
|
||||||
|
)
|
||||||
|
version, number = fragment_match.group(1), fragment_match.group(2)
|
||||||
|
endpoint = config["endpoints"]["episodes"].format(newsid=source_id)
|
||||||
|
raw = await fetch(f"{self.base_url}{endpoint}", referer=page_url)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ScrapeError(
|
||||||
|
f"french_stream : JSON d'épisodes invalide pour {source_id}"
|
||||||
|
) from exc
|
||||||
|
hosters = (data.get(version) or {}).get(number) or {}
|
||||||
|
links = [u for u in hosters.values() if isinstance(u, str) and u.startswith("http")]
|
||||||
|
|
||||||
|
if not links:
|
||||||
|
raise ScrapeError(f"french_stream : aucun lien embed extrait de {episode_url}")
|
||||||
|
logger.info("french_stream : %d liens embed pour %s", len(links), episode_url)
|
||||||
|
return links
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""Source VoirAnime (voiranime.xyz) — plateforme VODSPHERE, animes VOSTFR.
|
||||||
|
|
||||||
|
Faits structurels (vérifiés en live) :
|
||||||
|
- Recherche : GET /catalogue?q=<q> → `article.catalogue-card` (lien `a.catalogue-poster`,
|
||||||
|
poster et titre dans `img[src]` / `img[alt]`).
|
||||||
|
- Fiche : /catalogue/<slug> — titre `h1`, poster `img[alt^="Affiche de"]`, compteur
|
||||||
|
d'épisodes dans `.media-detail-meta span` (« 1192 épisodes »), synopsis `.media-detail-story p`.
|
||||||
|
- Épisodes : `a.detail-episode-card` avec `data-season` et libellé `<small>S1 · E1</small>`.
|
||||||
|
- Lecteur : /lecteur/<content>/<episode> — attributs data-prepare-url (résolution AJAX)
|
||||||
|
et data-fallback-source-url (source suivante) ; en fin de chaîne le fallback repointe
|
||||||
|
vers une source déjà vue → dédoublonnage par id de source obligatoire.
|
||||||
|
- Accueil : carrousels #carousel-new (nouveautés) et #carousel-added (ajouts), 24 cartes.
|
||||||
|
- La résolution d'un lien direct se fait dans l'extracteur hoster dédié (voir
|
||||||
|
app/scrapers/hosters/voiranime.py) : cette source ne renvoie que les URLs prepare.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from copy import deepcopy
|
||||||
|
from urllib.parse import quote_plus, urljoin
|
||||||
|
|
||||||
|
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": {
|
||||||
|
"endpoint": "/catalogue?q={query}",
|
||||||
|
"result": "article.catalogue-card",
|
||||||
|
"link": "a.catalogue-poster",
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"title": "h1",
|
||||||
|
"poster": 'img[alt^="Affiche de"]',
|
||||||
|
"meta_count": ".media-detail-meta span",
|
||||||
|
"synopsis": ".media-detail-story p",
|
||||||
|
},
|
||||||
|
"episodes": {
|
||||||
|
"card": "a.detail-episode-card",
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"root": "[data-prepare-url]",
|
||||||
|
"prepare_attr": "data-prepare-url",
|
||||||
|
"fallback_attr": "data-fallback-source-url",
|
||||||
|
"max_sources": 4,
|
||||||
|
},
|
||||||
|
"latest": {
|
||||||
|
"path": "/",
|
||||||
|
"carousels": ("#carousel-new", "#carousel-added"),
|
||||||
|
"item": "article.content-card",
|
||||||
|
"link": "a.card-poster",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_SLUG_RE = re.compile(r"/catalogue/([a-z0-9-]+)", re.IGNORECASE)
|
||||||
|
_EPISODE_LABEL_RE = re.compile(r"S\s*(\d+)\s*·\s*E\s*(\d+(?:[.,]\d+)?)", re.IGNORECASE)
|
||||||
|
_EPISODE_COUNT_RE = re.compile(r"(\d+)\s*épisodes", re.IGNORECASE)
|
||||||
|
_SOURCE_ID_RE = re.compile(r"/lecteur/prepare/(\d+)")
|
||||||
|
|
||||||
|
_FALLBACK_SOURCE_RE = re.compile(r"[?&]source=(\d+)")
|
||||||
|
def _merged_config() -> dict:
|
||||||
|
merged = deepcopy(DEFAULT_CONFIG)
|
||||||
|
for key, values in load_scraper_config("voiranime").items():
|
||||||
|
if isinstance(values, dict) and isinstance(merged.get(key), dict):
|
||||||
|
merged[key].update(values)
|
||||||
|
else:
|
||||||
|
merged[key] = values
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
@register_source
|
||||||
|
class VoirAnimeScraper(SourceScraper):
|
||||||
|
name = "voiranime"
|
||||||
|
label = "VoirAnime"
|
||||||
|
base_url = "https://voiranime.xyz"
|
||||||
|
media_types = ("anime",)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
def _title_url(self, source_id: str) -> str:
|
||||||
|
return f"{self.base_url}/catalogue/{source_id}"
|
||||||
|
|
||||||
|
@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 ""
|
||||||
|
|
||||||
|
def _result_from_card(self, link: Tag) -> SearchResult | None:
|
||||||
|
href = link.get("href")
|
||||||
|
if not href:
|
||||||
|
return None
|
||||||
|
source_id = self._slug_from_url(str(href))
|
||||||
|
if not source_id:
|
||||||
|
return None
|
||||||
|
img = link.select_one("img")
|
||||||
|
title = (img.get("alt") if img else None) or source_id.replace("-", " ")
|
||||||
|
image = img.get("src") if img else None
|
||||||
|
return SearchResult(
|
||||||
|
source=self.name,
|
||||||
|
source_id=source_id,
|
||||||
|
title=str(title).strip(),
|
||||||
|
url=self._title_url(source_id),
|
||||||
|
image_url=str(image) if image else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- search
|
||||||
|
|
||||||
|
async def search(self, query: str) -> list[SearchResult]:
|
||||||
|
config = _merged_config()
|
||||||
|
search_cfg = config["search"]
|
||||||
|
url = self.base_url + search_cfg["endpoint"].format(query=quote_plus(query))
|
||||||
|
soup = await fetch_soup(url)
|
||||||
|
results: list[SearchResult] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for card in soup.select(search_cfg["result"]):
|
||||||
|
link = card.select_one(search_cfg["link"])
|
||||||
|
if link is None:
|
||||||
|
logger.warning("voiranime : carte de résultat sans lien, ignorée")
|
||||||
|
continue
|
||||||
|
result = self._result_from_card(link)
|
||||||
|
if result and result.source_id not in seen:
|
||||||
|
seen.add(result.source_id)
|
||||||
|
results.append(result)
|
||||||
|
return results
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- détails
|
||||||
|
|
||||||
|
async def get_details(self, source_id: str) -> TitleDetails:
|
||||||
|
config = _merged_config()
|
||||||
|
details_cfg = config["details"]
|
||||||
|
url = self._title_url(source_id)
|
||||||
|
soup = await fetch_soup(url)
|
||||||
|
|
||||||
|
title = self._text(soup.select_one(details_cfg["title"])) or source_id.replace("-", " ")
|
||||||
|
poster = soup.select_one(details_cfg["poster"])
|
||||||
|
image = str(poster["src"]) if poster and poster.get("src") else None
|
||||||
|
synopsis = self._text(soup.select_one(details_cfg["synopsis"])) or None
|
||||||
|
|
||||||
|
episode_count: int | None = None
|
||||||
|
count_el = soup.select_one(details_cfg["meta_count"])
|
||||||
|
if count_el:
|
||||||
|
match = _EPISODE_COUNT_RE.search(self._text(count_el))
|
||||||
|
if match:
|
||||||
|
episode_count = int(match.group(1))
|
||||||
|
|
||||||
|
episodes = self._parse_episodes(soup, config)
|
||||||
|
return TitleDetails(
|
||||||
|
source=self.name,
|
||||||
|
source_id=source_id,
|
||||||
|
title=title,
|
||||||
|
url=url,
|
||||||
|
synopsis=synopsis,
|
||||||
|
image_url=image,
|
||||||
|
episode_count=episode_count if episode_count is not None else len(episodes),
|
||||||
|
episodes=episodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- épisodes
|
||||||
|
|
||||||
|
async def list_episodes(self, source_id: str) -> list[Episode]:
|
||||||
|
config = _merged_config()
|
||||||
|
soup = await fetch_soup(self._title_url(source_id))
|
||||||
|
return self._parse_episodes(soup, config)
|
||||||
|
|
||||||
|
def _parse_episodes(self, soup: BeautifulSoup, config: dict) -> list[Episode]:
|
||||||
|
episodes: list[Episode] = []
|
||||||
|
for card in soup.select(config["episodes"]["card"]):
|
||||||
|
href = card.get("href")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
label = self._text(card)
|
||||||
|
match = _EPISODE_LABEL_RE.search(label)
|
||||||
|
if not match:
|
||||||
|
logger.warning("voiranime : libellé d'épisode illisible : %r", label[:60])
|
||||||
|
continue
|
||||||
|
episodes.append(
|
||||||
|
Episode(
|
||||||
|
number=float(match.group(2).replace(",", ".")),
|
||||||
|
title=None,
|
||||||
|
url=urljoin(self.base_url + "/", str(href)),
|
||||||
|
season=int(match.group(1)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- lecteurs
|
||||||
|
|
||||||
|
async def extract_embed_links(self, episode_url: str) -> list[str]:
|
||||||
|
"""Parcourt la chaîne Lecteur 1 → 2 → … et renvoie les URLs prepare absolues."""
|
||||||
|
config = _merged_config()
|
||||||
|
player_cfg = config["player"]
|
||||||
|
prepare_urls: list[str] = []
|
||||||
|
seen_sources: set[str] = set()
|
||||||
|
page_url = episode_url
|
||||||
|
for _ in range(int(player_cfg["max_sources"])):
|
||||||
|
soup = await fetch_soup(page_url)
|
||||||
|
root = soup.select_one(player_cfg["root"])
|
||||||
|
if root is None:
|
||||||
|
break
|
||||||
|
prepare = root.get(player_cfg["prepare_attr"])
|
||||||
|
if not prepare:
|
||||||
|
break
|
||||||
|
prepare_url = urljoin(self.base_url + "/", str(prepare))
|
||||||
|
match = _SOURCE_ID_RE.search(prepare_url)
|
||||||
|
source_key = match.group(1) if match else prepare_url
|
||||||
|
if source_key in seen_sources:
|
||||||
|
break
|
||||||
|
seen_sources.add(source_key)
|
||||||
|
prepare_urls.append(prepare_url)
|
||||||
|
fallback = root.get(player_cfg["fallback_attr"])
|
||||||
|
if not fallback:
|
||||||
|
break
|
||||||
|
page_url = urljoin(self.base_url + "/", str(fallback))
|
||||||
|
next_source = _FALLBACK_SOURCE_RE.search(page_url)
|
||||||
|
if next_source and next_source.group(1) in seen_sources:
|
||||||
|
break
|
||||||
|
if not prepare_urls:
|
||||||
|
raise ScrapeError(f"voiranime : aucun lecteur trouvé sur {episode_url}")
|
||||||
|
return prepare_urls
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- découverte
|
||||||
|
|
||||||
|
async def latest(self) -> list[SearchResult]:
|
||||||
|
"""Nouveautés + ajouts récents — carrousels de la page d'accueil."""
|
||||||
|
config = _merged_config()
|
||||||
|
latest_cfg = config["latest"]
|
||||||
|
soup = await fetch_soup(self.base_url + latest_cfg["path"])
|
||||||
|
results: list[SearchResult] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for carousel in latest_cfg["carousels"]:
|
||||||
|
section = soup.select_one(carousel)
|
||||||
|
if section is None:
|
||||||
|
logger.debug("voiranime : carrousel %s absent de l'accueil", carousel)
|
||||||
|
continue
|
||||||
|
for card in section.select(latest_cfg["item"]):
|
||||||
|
link = card.select_one(latest_cfg["link"])
|
||||||
|
if link is None:
|
||||||
|
continue
|
||||||
|
result = self._result_from_card(link)
|
||||||
|
if result and result.source_id not in seen:
|
||||||
|
seen.add(result.source_id)
|
||||||
|
results.append(result)
|
||||||
|
return results
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""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_-]+$")
|
||||||
|
_VERSION_RE = re.compile(r"\b(vostfr|vf)\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
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 _detect_version(self, soup: BeautifulSoup, page_url: str) -> str | None:
|
||||||
|
config = _merged_config()
|
||||||
|
title_el = soup.select_one(config["details"]["title"])
|
||||||
|
for text in (self._text(title_el), page_url):
|
||||||
|
match = _VERSION_RE.search(text)
|
||||||
|
if match:
|
||||||
|
return match.group(1).lower()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
|
||||||
|
config = _merged_config()
|
||||||
|
version = self._detect_version(soup, page_url)
|
||||||
|
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,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
"""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,
|
||||||
|
get_source,
|
||||||
|
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)
|
||||||
|
# (les TTL des sections suivent, plus bas)
|
||||||
|
|
||||||
|
# Genres animés proposés à l'exploration — slugs des catégories Kitsu officielles.
|
||||||
|
ANIME_GENRES: dict[str, str] = {
|
||||||
|
"action": "Action",
|
||||||
|
"adventure": "Aventure",
|
||||||
|
"comedy": "Comédie",
|
||||||
|
"drama": "Drame",
|
||||||
|
"fantasy": "Fantasy",
|
||||||
|
"science-fiction": "Science-Fiction",
|
||||||
|
"romance": "Romance",
|
||||||
|
"slice-of-life": "Tranche de vie",
|
||||||
|
"sports": "Sport",
|
||||||
|
"supernatural": "Surnaturel",
|
||||||
|
"mystery": "Mystère",
|
||||||
|
"psychological": "Psychologique",
|
||||||
|
"horror": "Horreur",
|
||||||
|
"mecha": "Mecha",
|
||||||
|
"isekai": "Isekai",
|
||||||
|
"music": "Musique",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Genres des fiches séries/films (French-Stream, libellés FR, slugifiés) →
|
||||||
|
# catégorie Kitsu équivalente. Les genres sans équivalent (Médical, Western…)
|
||||||
|
# sont ignorés : mieux vaut un signal incomplet qu'une catégorie inexistante.
|
||||||
|
_GENRE_FR_TO_KITSU: dict[str, str] = {
|
||||||
|
"action": "Action",
|
||||||
|
"aventure": "Adventure",
|
||||||
|
"aventures": "Adventure",
|
||||||
|
"comedie": "Comedy",
|
||||||
|
"comedies": "Comedy",
|
||||||
|
"drame": "Drama",
|
||||||
|
"drames": "Drama",
|
||||||
|
"epouvante-horreur": "Horror",
|
||||||
|
"horreur": "Horror",
|
||||||
|
"fantastique": "Fantasy",
|
||||||
|
"fantastiques": "Fantasy",
|
||||||
|
"romance": "Romance",
|
||||||
|
"romances": "Romance",
|
||||||
|
"science-fiction": "Science Fiction",
|
||||||
|
"science-fictions": "Science Fiction",
|
||||||
|
"surnaturel": "Supernatural",
|
||||||
|
"thriller": "Thriller",
|
||||||
|
"thrillers": "Thriller",
|
||||||
|
}
|
||||||
|
|
||||||
|
_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)
|
||||||
|
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
|
||||||
|
|
||||||
|
|
||||||
|
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_by_type(self, limit: int = 24) -> dict[str, list[dict]]:
|
||||||
|
"""Nouveautés par type de média : ``{"anime": [...], "serie": [...], "film": [...]}``.
|
||||||
|
|
||||||
|
Les « récemment ajoutés » de chaque source activée sont fusionnés (doublons
|
||||||
|
retirés) puis répartis en rails indépendants — chaque type garde sa place,
|
||||||
|
aucun n'évince les autres. Le rail animés est enrichi via Kitsu (date de
|
||||||
|
sortie, statut) et trié du plus récent au plus ancien ; les séries et films
|
||||||
|
réels, absents de Kitsu, gardent l'ordre du site (déjà « récents d'abord »).
|
||||||
|
Chaque rail est tronqué à ``limit``.
|
||||||
|
"""
|
||||||
|
cache_key = f"latest_by_type:{limit}"
|
||||||
|
cached = self._cache.get(cache_key)
|
||||||
|
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
|
||||||
|
|
||||||
|
by_type: dict[str, list[dict]] = {"anime": [], "serie": [], "film": []}
|
||||||
|
for item in merged.values():
|
||||||
|
by_type.setdefault(item.get("media_type", "anime"), []).append(item)
|
||||||
|
|
||||||
|
# Enrichissement Kitsu limité aux animés (seul catalogue couvert) —
|
||||||
|
# inutile de bombarder l'API pour des titres qui n'y figurent pas.
|
||||||
|
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 by_type["anime"]))
|
||||||
|
by_type["anime"] = sorted(
|
||||||
|
enriched, key=lambda it: it.get("start_date") or "", reverse=True
|
||||||
|
)[:limit]
|
||||||
|
for kind in ("serie", "film"):
|
||||||
|
by_type[kind] = by_type[kind][:limit]
|
||||||
|
|
||||||
|
self._cache.set(cache_key, by_type, _LATEST_TTL_SECONDS)
|
||||||
|
return by_type
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def browse_serie_film(self, media_type: str, genre: str, limit: int = 24) -> list[dict]:
|
||||||
|
"""Séries/films d'un genre French-Stream, aplatis comme les nouveautés.
|
||||||
|
|
||||||
|
Liste vide si la catégorie est inconnue ou la source en échec.
|
||||||
|
"""
|
||||||
|
key = f"browse_fs:{media_type}:{genre}:{limit}"
|
||||||
|
cached = self._cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached # type: ignore[return-value]
|
||||||
|
try:
|
||||||
|
scraper = get_source("french_stream")
|
||||||
|
results = await scraper.browse(media_type, genre)
|
||||||
|
except ScrapeError as exc:
|
||||||
|
logger.warning("Parcours %s/%s indisponible : %s", media_type, genre, exc)
|
||||||
|
return []
|
||||||
|
items = [
|
||||||
|
{**dataclasses.asdict(r), "source": scraper.name, "label": scraper.label}
|
||||||
|
for r in results
|
||||||
|
][:limit]
|
||||||
|
self._cache.set(key, items, _LATEST_TTL_SECONDS)
|
||||||
|
return items
|
||||||
|
async def browse_anime(self, genre: str, limit: int = _KITSU_PAGE_MAX) -> list[dict]:
|
||||||
|
"""Animés populaires d'une catégorie Kitsu (genre = slug du catalogue).
|
||||||
|
|
||||||
|
Liste vide si le genre est inconnu ou si Kitsu échoue (dégradation gracieuse).
|
||||||
|
"""
|
||||||
|
limit = min(limit, _KITSU_PAGE_MAX)
|
||||||
|
if genre not in ANIME_GENRES:
|
||||||
|
return []
|
||||||
|
key = f"browse_anime:{genre}:{limit}"
|
||||||
|
cached = self._cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached # type: ignore[return-value]
|
||||||
|
items = await self._kitsu_anime(
|
||||||
|
{"filter[categories]": genre, "sort": "-userCount", "page[limit]": limit}
|
||||||
|
)
|
||||||
|
self._cache.set(key, items, _FOR_YOU_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, series_pages = 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
|
||||||
|
if series_pages:
|
||||||
|
# Séries/films téléchargés : genres lus sur leur fiche source (sans Sonarr)
|
||||||
|
for genre, count in (await self._genres_from_series_pages(series_pages)).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
|
||||||
|
|
||||||
|
if not (owned or favorite_genres or sonarr_owned):
|
||||||
|
# Aucun historique : proposition d'amorçage plutôt qu'une section muette
|
||||||
|
return {"based_on": [], "items": [], "cold_start": True}
|
||||||
|
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]
|
||||||
|
# Une requête par genre (Kitsu cumule les catégories en ET : quatre genres
|
||||||
|
# ensemble ne laissent que quelques titres — parfois le déjà-possédé !).
|
||||||
|
# Fusion entrelacée : chaque genre contribue, doublons retirés.
|
||||||
|
pools = [
|
||||||
|
list(pool)
|
||||||
|
for pool in await asyncio.gather(*(
|
||||||
|
self._kitsu_anime(
|
||||||
|
{"filter[categories]": category_slug(g), "sort": "-userCount", "page[limit]": limit}
|
||||||
|
)
|
||||||
|
for g in top_genres
|
||||||
|
))
|
||||||
|
if pool
|
||||||
|
]
|
||||||
|
candidates: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
while pools:
|
||||||
|
for pool in pools[:]:
|
||||||
|
item = pool.pop(0)
|
||||||
|
key = str(item.get("kitsu_id") or item.get("title", "").casefold())
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
candidates.append(item)
|
||||||
|
if not pool:
|
||||||
|
pools.remove(pool)
|
||||||
|
kept = [
|
||||||
|
item for item in candidates
|
||||||
|
if item["title"] and normalize_title(item["title"]).casefold() not in owned
|
||||||
|
][:limit]
|
||||||
|
result = {"based_on": top_genres, "items": kept}
|
||||||
|
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _genres_from_series_pages(self, pages: dict[str, str]) -> dict[str, int]:
|
||||||
|
"""Genres Kitsu des séries/films téléchargés, lus sur leur fiche source.
|
||||||
|
|
||||||
|
Les libellés français des fiches sont convertis vers les catégories Kitsu
|
||||||
|
(anglais) ; les genres sans équivalent sont ignorés.
|
||||||
|
"""
|
||||||
|
if not pages:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
scraper = get_source("french_stream")
|
||||||
|
except ScrapeError:
|
||||||
|
return {}
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for page_url in list(pages.values())[:_MAX_HISTORY_TITLES]:
|
||||||
|
for genre_fr in await scraper.genres_of_page(page_url):
|
||||||
|
canonical = _GENRE_FR_TO_KITSU.get(category_slug(genre_fr))
|
||||||
|
if canonical:
|
||||||
|
counts[canonical] = counts.get(canonical, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
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], dict[str, str]]:
|
||||||
|
"""Titres possédés (normalisés), genres des favoris, fiches des téléchargements.
|
||||||
|
|
||||||
|
``series_pages`` relie un titre possédé à l'URL de sa fiche chez la source
|
||||||
|
(séries/films French-Stream) : sans Sonarr, c'est la seule source de genres.
|
||||||
|
"""
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT DISTINCT title, page_url 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: set[str] = set()
|
||||||
|
series_pages: dict[str, str] = {}
|
||||||
|
for row in rows:
|
||||||
|
key = normalize_title(row["title"]).casefold()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
owned.add(key)
|
||||||
|
page_url = row["page_url"] or ""
|
||||||
|
if page_url and key not in series_pages:
|
||||||
|
series_pages[key] = page_url
|
||||||
|
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, series_pages
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
"""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]
|
||||||
|
|
||||||
|
|
||||||
|
_SERIES_RE = re.compile(r"^(.*?)[\s\-–—]*(?:épisode|episode|ep|e)\s*\d", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def series_dirname(title: str) -> str | None:
|
||||||
|
"""Nom du sous-dossier d'un titre (« One Piece - E12 » → « One Piece »).
|
||||||
|
|
||||||
|
Range les épisodes par animé sur le disque (bien meilleur parsing Plex).
|
||||||
|
None si aucun marqueur d'épisode détecté → fichier à plat.
|
||||||
|
"""
|
||||||
|
match = _SERIES_RE.match(title)
|
||||||
|
base = sanitize_filename(match.group(1).strip()) if match else ""
|
||||||
|
return base or None
|
||||||
|
|
||||||
|
|
||||||
|
_EPISODE_RE = re.compile(
|
||||||
|
r"^(?P<series>.+?)\s*-\s*(?:Saison\s+(?P<season>\d+)\s*-\s*)?"
|
||||||
|
r"E(?P<ep>\d+(?:[.,]\d+)?)(?P<tail>\s*\([^)]*\))?\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plex_filename(title: str, extension: str) -> str | None:
|
||||||
|
"""Nom de fichier compatible Plex (« S01E09 ») pour un titre d'épisode.
|
||||||
|
|
||||||
|
Les scrapers produisent « Série - Saison 1 - E9 (VF) » ; le scanner Plex
|
||||||
|
exige « Série - S01E09 (VF) » (les épisodes 1-9 sont sinon ignorés).
|
||||||
|
None si le titre ne suit pas le format épisode → nommage inchangé.
|
||||||
|
"""
|
||||||
|
match = _EPISODE_RE.match(title)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
series = match.group("series")
|
||||||
|
season = int(match.group("season") or 1)
|
||||||
|
ep_raw = match.group("ep").replace(",", ".")
|
||||||
|
ep = float(ep_raw)
|
||||||
|
ep_label = f"{int(ep):02d}" if ep.is_integer() else ep_raw
|
||||||
|
tail = match.group("tail") or ""
|
||||||
|
return sanitize_filename(f"{series} - S{season:02d}E{ep_label}{tail}") + extension
|
||||||
|
|
||||||
|
|
||||||
|
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.rglob("*"):
|
||||||
|
if not path.is_file() or path.suffix == ".part":
|
||||||
|
continue
|
||||||
|
rel_path = path.relative_to(download_dir).as_posix()
|
||||||
|
if rel_path in known:
|
||||||
|
continue
|
||||||
|
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:{rel_path}",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
path.stem,
|
||||||
|
rel_path,
|
||||||
|
size,
|
||||||
|
size,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info("Fichier restauré depuis le disque : %s", rel_path)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ API publique
|
||||||
|
|
||||||
|
async def enqueue(
|
||||||
|
self, video_url: str, page_url: str, title: str, source_key: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
"""Ajoute un téléchargement ; retourne la tâche existante si doublon actif.
|
||||||
|
|
||||||
|
source_key : clé de déduplication (par défaut l'URL vidéo). Les grabs
|
||||||
|
Sonarr utilisent « sonarr:<infohash>|<url> » pour rester suivis.
|
||||||
|
"""
|
||||||
|
source_key = source_key or 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)
|
||||||
|
|
||||||
|
extension = self._guess_extension(video_url)
|
||||||
|
filename = plex_filename(title, extension) or sanitize_filename(title) + extension
|
||||||
|
series = series_dirname(title)
|
||||||
|
file_path = f"{series}/{filename}" if series else filename
|
||||||
|
cursor = await db.execute(
|
||||||
|
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(source_key, video_url, page_url, title, file_path),
|
||||||
|
)
|
||||||
|
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 delete(self, download_id: int, delete_file: bool = False) -> bool:
|
||||||
|
"""Supprime une tâche de la file (annulée avant si active).
|
||||||
|
|
||||||
|
delete_file=True efface aussi le fichier téléchargé du disque.
|
||||||
|
"""
|
||||||
|
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
if row["status"] in ACTIVE_STATUSES:
|
||||||
|
await self.cancel(download_id)
|
||||||
|
if delete_file and row["file_path"]:
|
||||||
|
target = get_settings().download_dir / row["file_path"]
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
if target.parent != get_settings().download_dir:
|
||||||
|
with contextlib.suppress(OSError): # dossier de l'animé vide → retiré
|
||||||
|
target.parent.rmdir()
|
||||||
|
self._part_path(row["file_path"]).unlink(missing_ok=True)
|
||||||
|
await db.execute("DELETE FROM downloads WHERE id = ?", (download_id,))
|
||||||
|
await self._emit_removed(download_id)
|
||||||
|
logger.info("Téléchargement supprimé (id=%s, fichier=%s)", download_id, delete_file)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def list_all(self, limit: int = 2000) -> 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
|
||||||
|
self._publish({"type": "update", "item": data})
|
||||||
|
|
||||||
|
async def _emit_removed(self, download_id: int) -> None:
|
||||||
|
self._publish({"type": "removed", "id": download_id})
|
||||||
|
|
||||||
|
def _publish(self, message: dict) -> None:
|
||||||
|
for queue in self._listeners:
|
||||||
|
with contextlib.suppress(asyncio.QueueFull):
|
||||||
|
queue.put_nowait(message)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 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
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
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
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
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)
|
||||||
|
# L'historique a changé : les recommandations « Pour toi » se recalculent
|
||||||
|
from app.services.discover import discover
|
||||||
|
|
||||||
|
discover.invalidate_for_you()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 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()
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""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\-–—:.]*\b(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
|
||||||
|
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\bS\d+(?:\s*E\d+)?\s*$", re.IGNORECASE)
|
||||||
|
_TRAILING_EPISODE_RE = re.compile(r"[\s\-–—:.]*\b(?:e|ep|episode)\s*\d+\s*$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_title(title: str) -> str:
|
||||||
|
"""Nettoie un titre de scraping avant comparaison/recherche Kitsu.
|
||||||
|
|
||||||
|
Retire le bruit (VF/VOSTFR…), les marqueurs de saison/épisode en fin de titre
|
||||||
|
(« - Saison 1 - E3 », « S1 E1 ») — appliqués en boucle : un titre canonique
|
||||||
|
émergera identique d'un téléchargement (« Titre - Saison 1 - E3 ») ou d'une
|
||||||
|
fiche Kitsu (« Titre »), condition de l'exclusion du déjà-possédé.
|
||||||
|
"""
|
||||||
|
cleaned = _NOISE_WORDS_RE.sub(" ", title)
|
||||||
|
previous = None
|
||||||
|
while previous != cleaned:
|
||||||
|
previous = cleaned
|
||||||
|
cleaned = _TRAILING_SEASON_RE.sub("", cleaned)
|
||||||
|
cleaned = _TRAILING_CODE_RE.sub("", cleaned)
|
||||||
|
cleaned = _TRAILING_EPISODE_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
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Paramètres persistés en DB (activation des sources, santé, réglages UI…)."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_source_health(name: str) -> dict | None:
|
||||||
|
"""Dernier état de santé connu d'une source (None si jamais testée)."""
|
||||||
|
value = await get_setting(f"source:{name}:health")
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_source_health(name: str, healthy: bool, detail: str) -> dict:
|
||||||
|
state = {
|
||||||
|
"healthy": healthy,
|
||||||
|
"detail": detail,
|
||||||
|
"checked_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
await set_setting(f"source:{name}:health", state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
async def get_source_base_url(name: str) -> str | None:
|
||||||
|
"""URL personnalisée d'une source (None = valeur par défaut du code)."""
|
||||||
|
value = await get_setting(f"source:{name}:base_url")
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
async def set_source_base_url(name: str, url: str | None) -> None:
|
||||||
|
"""Persiste l'URL personnalisée (None/'' → retour à la valeur par défaut)."""
|
||||||
|
key = f"source:{name}:base_url"
|
||||||
|
if url:
|
||||||
|
await set_setting(key, url)
|
||||||
|
else:
|
||||||
|
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_source_base_urls() -> None:
|
||||||
|
"""Applique les URL personnalisées aux instances de sources (au démarrage)."""
|
||||||
|
from app.scrapers.base import all_sources
|
||||||
|
|
||||||
|
for source in all_sources():
|
||||||
|
override = await get_source_base_url(source.name)
|
||||||
|
if override and override != type(source).base_url:
|
||||||
|
source.base_url = override
|
||||||
|
logger.info("URL personnalisée pour %s : %s", source.name, override)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 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)
|
||||||
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""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},
|
||||||
|
verify=False, # reverse proxy swizzin : certificat auto-signé (LAN uniquement)
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""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 hashlib
|
||||||
|
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 build_stub(announce_url, name)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def build_stub(announce_url: str, name: str) -> tuple[bytes, str]:
|
||||||
|
"""Fichier .torrent de service + infohash SHA-1 (identité côté Sonarr).
|
||||||
|
|
||||||
|
L'announce embarque les paramètres du grab (source, sid, season, ep, series) :
|
||||||
|
quand Sonarr renvoie ce .torrent à l'API compatible qBittorrent d'Ohm,
|
||||||
|
le grab est rejoué à l'identique.
|
||||||
|
"""
|
||||||
|
info = {"name": name + ".mp4", "length": 0, "piece length": 32768, "pieces": b"\x00" * 20}
|
||||||
|
data = _bencode(
|
||||||
|
{
|
||||||
|
"announce": announce_url,
|
||||||
|
"created by": "OhmStreaming",
|
||||||
|
"comment": name,
|
||||||
|
"info": info,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data, hashlib.sha1(_bencode(info)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def bdecode(data: bytes):
|
||||||
|
"""Décode un flux bencode (les clés dict reviennent en bytes)."""
|
||||||
|
|
||||||
|
def _parse(offset: int) -> tuple[object, int]:
|
||||||
|
char = data[offset : offset + 1]
|
||||||
|
if char == b"i":
|
||||||
|
end = data.index(b"e", offset)
|
||||||
|
return int(data[offset + 1 : end]), end + 1
|
||||||
|
if char in (b"d", b"l"):
|
||||||
|
is_dict = char == b"d"
|
||||||
|
items: dict | list = {} if is_dict else []
|
||||||
|
offset += 1
|
||||||
|
while data[offset : offset + 1] != b"e":
|
||||||
|
first, offset = _parse(offset)
|
||||||
|
if is_dict:
|
||||||
|
second, offset = _parse(offset)
|
||||||
|
items[first] = second
|
||||||
|
else:
|
||||||
|
items.append(first)
|
||||||
|
return items, offset + 1
|
||||||
|
if char.isdigit():
|
||||||
|
colon = data.index(b":", offset)
|
||||||
|
length = int(data[offset:colon])
|
||||||
|
start = colon + 1
|
||||||
|
return data[start : start + length], start + length
|
||||||
|
raise ValueError(f"bencode invalide à l'octet {offset}")
|
||||||
|
|
||||||
|
value, end = _parse(0)
|
||||||
|
if end != len(data):
|
||||||
|
raise ValueError("données après la fin du flux bencode")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
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 latest_releases(self) -> list[Release]:
|
||||||
|
"""Flux RSS (Sonarr) : dernier épisode de chaque nouveauté du catalogue."""
|
||||||
|
releases: list[Release] = []
|
||||||
|
for source in await self._enabled_sources():
|
||||||
|
try:
|
||||||
|
latest = await asyncio.wait_for(source.latest(), timeout=_SEARCH_TIMEOUT)
|
||||||
|
except (ScrapeError, TimeoutError) as exc:
|
||||||
|
logger.warning("Torznab : nouveautés %s KO : %s", source.name, exc)
|
||||||
|
continue
|
||||||
|
for result in latest[:_MAX_SERIES_PER_SOURCE]:
|
||||||
|
try:
|
||||||
|
episodes = await self._episodes_of(source, result.source_id)
|
||||||
|
except (ScrapeError, TimeoutError):
|
||||||
|
continue
|
||||||
|
whole = [e for e in episodes if e.number == int(e.number)]
|
||||||
|
if not whole:
|
||||||
|
continue
|
||||||
|
newest = max(whole, key=lambda e: (e.season, e.number))
|
||||||
|
releases.extend(
|
||||||
|
self._releases_for(result.title, source, result.source_id, [newest], None, None)
|
||||||
|
)
|
||||||
|
if len(releases) >= _MAX_RELEASES:
|
||||||
|
return releases[:_MAX_RELEASES]
|
||||||
|
return releases
|
||||||
|
|
||||||
|
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,
|
||||||
|
sonarr_hash: str | None = None,
|
||||||
|
) -> 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.
|
||||||
|
sonarr_hash : infohash du .torrent de service — les téléchargements
|
||||||
|
Sonarr sont préfixés « sonarr:<hash>| » pour rester suivis via l'API
|
||||||
|
compatible qBittorrent.
|
||||||
|
"""
|
||||||
|
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}"
|
||||||
|
key = f"sonarr:{sonarr_hash}|{link.url}" if sonarr_hash else link.url
|
||||||
|
result = await download_manager.enqueue(link.url, match.url, title, source_key=key)
|
||||||
|
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()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Mises à jour logicielles.
|
||||||
|
|
||||||
|
- Détection : dernier tag semver du dépôt Gitea public via son API (lecture anonyme),
|
||||||
|
avec le message du tag comme patchnote.
|
||||||
|
- Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle
|
||||||
|
image et recrée le conteneur — quelques secondes d'indisponibilité.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.version import get_version
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
||||||
|
_CACHE_TTL = 300.0 # secondes
|
||||||
|
|
||||||
|
_cache: dict[str, object] = {"checked_at": 0.0, "latest": None, "notes": None}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateError(Exception):
|
||||||
|
"""Erreur de mise à jour (Gitea injoignable, Watchtower KO)."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tag(tag: str) -> tuple[int, int, int] | None:
|
||||||
|
"""'v0.2.1' → (0, 2, 1) ; None si le tag n'est pas un semver strict."""
|
||||||
|
m = _TAG_RE.match(tag.strip())
|
||||||
|
return tuple(int(g) for g in m.groups()) if m else None # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def is_newer(latest: str, current: str) -> bool:
|
||||||
|
a, b = parse_tag(latest), parse_tag(current)
|
||||||
|
if a is None or b is None:
|
||||||
|
return False
|
||||||
|
return a > b
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_latest_version(*, force: bool = False) -> str | None:
|
||||||
|
"""Dernier tag semver du dépôt + patchnote (cache 5 min). None si erreur."""
|
||||||
|
now = time.monotonic()
|
||||||
|
latest_cache = _cache["latest"]
|
||||||
|
if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL:
|
||||||
|
return str(latest_cache) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
url = f"{settings.gitea_url}/api/v1/repos/{settings.gitea_repo}/tags?limit=20"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
tags = [
|
||||||
|
(t["name"], (t.get("message") or "").strip())
|
||||||
|
for t in resp.json()
|
||||||
|
if parse_tag(t.get("name", ""))
|
||||||
|
]
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||||
|
logger.warning("Vérification de mise à jour impossible : %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest, notes = max(tags, key=lambda t: parse_tag(t[0])) if tags else (None, None)
|
||||||
|
_cache.update(checked_at=now, latest=latest, notes=notes or None)
|
||||||
|
if latest and is_newer(latest, get_version()):
|
||||||
|
logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version())
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
_cache.update(checked_at=0.0, latest=None, notes=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def status() -> dict[str, object]:
|
||||||
|
"""État complet : version courante, dernière dispo, patchnote."""
|
||||||
|
current = get_version()
|
||||||
|
latest = await fetch_latest_version()
|
||||||
|
return {
|
||||||
|
"current": current,
|
||||||
|
"latest": latest,
|
||||||
|
"notes": _cache["notes"],
|
||||||
|
"update_available": bool(latest and is_newer(latest, current)),
|
||||||
|
"docker": bool(get_settings().watchtower_url),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_update() -> dict[str, str]:
|
||||||
|
"""Demande à Watchtower de recréer le conteneur avec la dernière image.
|
||||||
|
|
||||||
|
Le conteneur courant (donc cette requête) disparaît quelques secondes après :
|
||||||
|
la réponse est renvoyée immédiatement, le frontend gère la reconnexion.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.watchtower_url:
|
||||||
|
raise UpdateError(
|
||||||
|
"Watchtower non configuré — mise à jour disponible uniquement en déploiement Docker"
|
||||||
|
)
|
||||||
|
headers = {}
|
||||||
|
if settings.watchtower_token:
|
||||||
|
headers["Authorization"] = f"Bearer {settings.watchtower_token}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(f"{settings.watchtower_url}/v1/update", headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise UpdateError(f"Watchtower injoignable : {exc}") from exc
|
||||||
|
logger.info("Mise à jour déclenchée via Watchtower (version courante %s)", get_version())
|
||||||
|
return {"status": "started"}
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
/* ============================================================
|
||||||
|
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); }
|
||||||
|
|
||||||
|
.pref-select {
|
||||||
|
background: var(--surface); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
font: inherit; font-size: 0.85rem;
|
||||||
|
padding: 0.35rem 0.5rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.pref-select:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 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; }
|
||||||
|
.btn-accent { background: var(--accent); color: #fff; border: none; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 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); }
|
||||||
|
.dl-groups { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||||
|
.dl-group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
|
||||||
|
.dl-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.75rem 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.dl-group-head:hover { background: var(--surface-2); }
|
||||||
|
.dl-chevron { color: var(--text-dim); font-size: 0.72rem; transition: transform 0.2s; }
|
||||||
|
.dl-chevron.open { transform: rotate(90deg); }
|
||||||
|
.dl-group-title { font-weight: 700; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dl-group-meta { display: flex; gap: 0.6rem; font-size: 0.78rem; font-weight: 600; white-space: nowrap; }
|
||||||
|
.dl-group-meta .m-downloading { color: #ff6b73; }
|
||||||
|
.dl-group-meta .m-pending { color: var(--warning); }
|
||||||
|
.dl-group-meta .m-done { color: var(--success); }
|
||||||
|
.dl-group-meta .m-failed { color: var(--danger); }
|
||||||
|
.dl-group-size { font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||||
|
.dl-group-body { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.75rem; border-top: 1px solid var(--border); }
|
||||||
|
.dl-group-body .dl-row { border-color: transparent; background: transparent; padding: 0.55rem 0.7rem; }
|
||||||
|
.dl-group-body .dl-row:hover { background: var(--surface-2); border-color: var(--border); }
|
||||||
|
.dl-del-wrap { position: relative; }
|
||||||
|
.dl-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.45rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
/* ------------------------------------------------------------ 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; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Explorer */
|
||||||
|
|
||||||
|
.explore-section { animation-delay: 0s; }
|
||||||
|
.explore-filters { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.chip-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.genre-chip {
|
||||||
|
background: var(--surface); color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border); border-radius: 99px;
|
||||||
|
font: inherit; font-size: 0.82rem; font-weight: 600;
|
||||||
|
padding: 0.32rem 0.85rem; cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.genre-chip:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
.genre-chip.active {
|
||||||
|
background: var(--accent); border-color: var(--accent); color: #fff;
|
||||||
|
}
|
||||||
|
.explore-skel .rail-skel { margin-top: 0.2rem; }
|
||||||
|
|
||||||
|
/* CTA d'amorçage « Pour toi » (aucun historique) */
|
||||||
|
.cta-card {
|
||||||
|
display: flex; align-items: center; gap: 1.6rem;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 14px; padding: 1.6rem 1.8rem;
|
||||||
|
}
|
||||||
|
.cta-emoji { font-size: 2.6rem; }
|
||||||
|
.cta-body { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.cta-title { font-weight: 700; font-size: 1.05rem; }
|
||||||
|
.cta-body p { margin: 0; color: var(--text-dim); font-size: 0.92rem; }
|
||||||
|
.cta-actions { display: flex; gap: 0.6rem; margin-top: 0.6rem; }
|
||||||
|
.cta-btn {
|
||||||
|
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
||||||
|
font: inherit; font-size: 0.88rem; font-weight: 600;
|
||||||
|
padding: 0.5rem 1rem; cursor: pointer; text-decoration: none;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
.cta-btn:hover { filter: brightness(1.12); }
|
||||||
|
.cta-btn.ghost {
|
||||||
|
background: transparent; color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.cta-btn.ghost:hover { border-color: var(--accent); color: var(--accent); filter: none; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cta-card { flex-direction: column; text-align: center; }
|
||||||
|
.cta-actions { justify-content: center; flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------- bandeau mise à jour (update-watcher.js) */
|
||||||
|
.update-banner {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto 0 1.2rem 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 90vw;
|
||||||
|
padding: 0.7rem 1.2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(20, 20, 24, 0.96);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.update-banner .spin { display: inline-block; animation: update-spin 1s linear infinite; }
|
||||||
|
@keyframes update-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ filtre VF/VOSTFR */
|
||||||
|
|
||||||
|
.version-filter {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 0.2rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
margin-left: 0.8rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.version-tab {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.28rem 0.75rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.version-tab:hover { color: var(--text); }
|
||||||
|
.version-tab.active { background: var(--accent); color: #fff; }
|
||||||
|
.badge-version { background: rgba(90, 200, 250, 0.14); color: #7ec8f5; }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,12 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/* Surveille la disponibilité du serveur et détecte les changements de version.
|
||||||
|
* Pendant une mise à jour Docker (quelques secondes d'indisponibilité), affiche
|
||||||
|
* un bandeau « Mise à jour en cours » puis recharge la page automatiquement
|
||||||
|
* quand le service revient avec une nouvelle version.
|
||||||
|
* Un serveur qui RÉPOND est considéré comme disponible (même 404 : instance
|
||||||
|
* antérieure à l'endpoint /api/version) — seul un échec réseau (connexion
|
||||||
|
* refusée, timeout) signifie « en cours de redémarrage ». */
|
||||||
|
(() => {
|
||||||
|
const POLL_MS = 8000;
|
||||||
|
const KEY_UPDATING = 'ohm-updating';
|
||||||
|
|
||||||
|
let initialVersion = null;
|
||||||
|
let failures = 0;
|
||||||
|
let banner = null;
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
const isUpdating = () => sessionStorage.getItem(KEY_UPDATING) === '1';
|
||||||
|
|
||||||
|
function showBanner() {
|
||||||
|
if (banner) return;
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.className = 'update-banner';
|
||||||
|
banner.innerHTML = '<span class="spin">⟳</span> Mise à jour en cours — reconnexion automatique…';
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideBanner() {
|
||||||
|
if (banner) banner.remove();
|
||||||
|
banner = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (document.hidden || done) return;
|
||||||
|
let version = null;
|
||||||
|
let up = false;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/version', { cache: 'no-store' });
|
||||||
|
up = true; // le serveur a répondu : il est vivant
|
||||||
|
if (r.ok) version = (await r.json()).version ?? null;
|
||||||
|
} catch {
|
||||||
|
up = false; // connexion refusée / réseau coupé : serveur injoignable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!up) {
|
||||||
|
failures += 1;
|
||||||
|
if (isUpdating() || failures >= 2) showBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
failures = 0;
|
||||||
|
hideBanner();
|
||||||
|
if (version !== null && initialVersion === null) initialVersion = version;
|
||||||
|
if (isUpdating() || (version !== null && version !== initialVersion)) {
|
||||||
|
done = true;
|
||||||
|
sessionStorage.removeItem(KEY_UPDATING);
|
||||||
|
setTimeout(() => location.reload(), 1000); // laisser le serveur finir de démarrer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) poll();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isUpdating()) showBanner();
|
||||||
|
poll();
|
||||||
|
setInterval(poll, POLL_MS);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
{% 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>
|
||||||
|
<div style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap">
|
||||||
|
<input class="input" x-model="s._url" :placeholder="s.default_base_url"
|
||||||
|
style="min-width:230px;font-size:0.8rem;padding:0.3rem 0.5rem"
|
||||||
|
@change="s._urlDirty = s._url.trim() !== s.base_url">
|
||||||
|
<button class="btn btn-sm btn-ghost" @click="saveUrl(s)" :disabled="!s._urlDirty || s._saving"
|
||||||
|
x-text="s._saving ? '…' : 'Enregistrer'"></button>
|
||||||
|
<button class="btn btn-sm btn-ghost" x-show="s.overridden" @click="resetUrl(s)"
|
||||||
|
:disabled="s._saving" title="Restaurer l'URL par défaut">↺</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
|
||||||
|
<td>
|
||||||
|
<span x-show="!s.health" style="color:var(--text-dim)">—</span>
|
||||||
|
<span x-show="s.health" :style="s.health?.healthy ? 'color:var(--success)' : 'color:var(--danger)'"
|
||||||
|
:title="s.health ? s.health.detail + (s.health.checked_at ? ' — vérifié le ' + new Date(s.health.checked_at).toLocaleString('fr-FR') : '') : ''"
|
||||||
|
x-text="s.health?.healthy ? '✔ OK' : '✖ 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>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.85rem;margin:0.6rem 0 0">
|
||||||
|
<strong>Encore mieux — client de téléchargement :</strong> dans Sonarr, ajoutez un client
|
||||||
|
<em>qBittorrent</em> pointant vers OhmStreaming (URL <code>http://<hote-ohm>:8777</code>,
|
||||||
|
utilisateur <code>ohm</code>, mot de passe = la clé API ci-dessus). Les épisodes seront
|
||||||
|
suivis en temps réel puis importés et renommés par Sonarr dans votre bibliothèque.
|
||||||
|
</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>🔄 Mise à jour</h2>
|
||||||
|
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
|
||||||
|
Version installée : <strong x-text="upd.current || '—'"></strong>
|
||||||
|
<span x-show="upd.update_available" class="badge" style="background:var(--accent);color:#fff"
|
||||||
|
x-text="(upd.latest || '?') + ' disponible'"></span>
|
||||||
|
<span x-show="upd.latest && !upd.update_available" style="color:var(--success)">✔ à jour</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
||||||
|
<button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
|
||||||
|
x-text="upd._checking ? '…' : 'Vérifier'"></button>
|
||||||
|
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()"
|
||||||
|
:disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
|
||||||
|
</div>
|
||||||
|
<details x-show="upd.notes" style="margin:0.6rem 0 0">
|
||||||
|
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim)">
|
||||||
|
Patchnote <span x-text="upd.latest"></span>
|
||||||
|
</summary>
|
||||||
|
<pre style="white-space:pre-wrap;font-size:0.82rem;margin:0.5rem 0 0;color:var(--text-dim)"
|
||||||
|
x-text="upd.notes"></pre>
|
||||||
|
</details>
|
||||||
|
<p x-show="!upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
|
||||||
|
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
|
||||||
|
(<code>docker compose pull && docker compose up -d</code> sinon).
|
||||||
|
</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,
|
||||||
|
upd: {
|
||||||
|
current: '', latest: null, notes: null, update_available: false, docker: false,
|
||||||
|
_checking: false, _applying: 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()).map((x) => ({ ...x, _url: x.base_url, _urlDirty: false, _saving: false }));
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
const upd = await fetch('/api/admin/update');
|
||||||
|
if (upd.ok) this.applyUpdateData(await upd.json());
|
||||||
|
this.checkAllSources();
|
||||||
|
},
|
||||||
|
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 saveUrl(s) {
|
||||||
|
s._saving = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/sources/${s.name}/url`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: s._url }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
s.base_url = data.base_url;
|
||||||
|
s.overridden = data.overridden;
|
||||||
|
s._url = data.base_url;
|
||||||
|
s._urlDirty = false;
|
||||||
|
toast('✔ URL enregistrée — test de la source…');
|
||||||
|
this.healthCheck(s);
|
||||||
|
} else {
|
||||||
|
toast('✖ ' + ((await res.json()).detail || 'Erreur'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast('✖ Erreur réseau');
|
||||||
|
} finally {
|
||||||
|
s._saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetUrl(s) {
|
||||||
|
s._url = '';
|
||||||
|
await this.saveUrl(s);
|
||||||
|
},
|
||||||
|
|
||||||
|
async healthCheck(s, notify = true) {
|
||||||
|
s._checking = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
|
||||||
|
const data = res.ok ? await res.json() : { healthy: false, detail: `Erreur serveur (${res.status})` };
|
||||||
|
s.health = { healthy: data.healthy, detail: data.detail, checked_at: data.checked_at };
|
||||||
|
if (notify) toast(`${data.healthy ? '✔' : '✖'} ${s.label} : ${data.detail}`);
|
||||||
|
} catch {
|
||||||
|
s.health = { healthy: false, detail: 'Erreur réseau', checked_at: null };
|
||||||
|
if (notify) toast(`✖ ${s.label} : erreur réseau`);
|
||||||
|
} finally {
|
||||||
|
s._checking = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAllSources() {
|
||||||
|
return Promise.allSettled(this.sources.filter(s => s.enabled).map(s => this.healthCheck(s, false)));
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
applyUpdateData(data) {
|
||||||
|
this.upd.current = data.current;
|
||||||
|
this.upd.latest = data.latest;
|
||||||
|
this.upd.notes = data.notes;
|
||||||
|
this.upd.update_available = data.update_available;
|
||||||
|
this.upd.docker = data.docker;
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkUpdate() {
|
||||||
|
this.upd._checking = true;
|
||||||
|
const res = await fetch('/api/admin/update/check', { method: 'POST' });
|
||||||
|
if (res.ok) this.applyUpdateData(await res.json());
|
||||||
|
this.upd._checking = false;
|
||||||
|
if (this.upd.update_available) toast('⬆ ' + this.upd.latest + ' disponible');
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyUpdate() {
|
||||||
|
if (!confirm('Mettre à jour maintenant ? Le service sera indisponible quelques secondes.')) return;
|
||||||
|
this.upd._applying = true;
|
||||||
|
sessionStorage.setItem('ohm-updating', '1');
|
||||||
|
const res = await fetch('/api/admin/update/apply', { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
sessionStorage.removeItem('ohm-updating');
|
||||||
|
const detail = (await res.json()).detail || 'Erreur';
|
||||||
|
toast('✖ ' + detail); this.upd._applying = false;
|
||||||
|
}
|
||||||
|
// sinon : le conteneur est recréé, update-watcher.js affiche le bandeau et recharge la page
|
||||||
|
},
|
||||||
|
|
||||||
|
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 %}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!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?v={{ asset_v }}">
|
||||||
|
<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: '', pref: 'both' }" x-init="fetch('/auth/me').then(r => r.json()).then(u => { username = u.username; pref = u.content_preference || 'both'; }).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">
|
||||||
|
<select class="pref-select" x-model="pref" x-show="username" title="Types de contenus"
|
||||||
|
@change="fetch('/auth/preferences', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content_preference: pref }) }).then(() => location.reload())">
|
||||||
|
<option value="anime">⛩ Animés</option>
|
||||||
|
<option value="serie">📺 Séries</option>
|
||||||
|
<option value="both">✨ Les deux</option>
|
||||||
|
</select>
|
||||||
|
<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?v={{ asset_v }}"></script>
|
||||||
|
<script src="/static/js/update-watcher.js?v={{ asset_v }}"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
{% 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">Explore par genre, 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>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------ Explorer par genre -->
|
||||||
|
<section class="rail-section explore-section" x-show="!loading && types.length > 0">
|
||||||
|
<h2 class="rail-title">🎭 Explorer
|
||||||
|
<span class="rail-source" x-text="exploreSubtitle"></span>
|
||||||
|
</h2>
|
||||||
|
<div class="explore-filters">
|
||||||
|
<div class="chip-row" role="tablist" aria-label="Type de contenu">
|
||||||
|
<template x-for="t in types" :key="t">
|
||||||
|
<button class="genre-chip" :class="{ active: exploreType === t }" role="tab"
|
||||||
|
:aria-selected="exploreType === t" @click="setType(t)"
|
||||||
|
x-text="typeLabels[t]"></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="chip-row" role="tablist" aria-label="Genre" x-show="currentGenres.length > 0">
|
||||||
|
<template x-for="g in currentGenres" :key="g.key">
|
||||||
|
<button class="genre-chip" :class="{ active: exploreGenre === g.key }" role="tab"
|
||||||
|
:aria-selected="exploreGenre === g.key" @click="setGenre(g.key)"
|
||||||
|
x-text="g.label"></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rail-wrap" x-show="!exploreLoading">
|
||||||
|
<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 exploreItems" :key="'ex' + i + (item.source_id || item.kitsu_id || item.title)">
|
||||||
|
<a class="card rail-card" :style="`--i:${i}`" :href="exploreHref(item)"
|
||||||
|
:title="item.source ? item.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.label" x-text="item.label"></span>
|
||||||
|
<span class="card-chip" x-show="!item.label && 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.start_date ? item.start_date.slice(0, 4) : (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>
|
||||||
|
<div class="explore-skel" x-show="exploreLoading">
|
||||||
|
<div class="rail rail-skel">
|
||||||
|
<template x-for="i in 8" :key="'sk' + i"><div class="rail-card skel skel-card"></div></template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------ 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 animés -->
|
||||||
|
<section class="rail-section" x-show="!loading && latestAnime.length > 0">
|
||||||
|
<h2 class="rail-title">🆕 Nouveautés animés <span class="rail-source">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 latestAnime" :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>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------ Nouveautés séries & films -->
|
||||||
|
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
|
||||||
|
<h2 class="rail-title">🆕 Nouveautés séries & films <span class="rail-source">ajouts récents de French-Stream</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 latestSerie" :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.media_type === 'film' ? 'Film' : ''"></span>
|
||||||
|
<div class="card-overlay">
|
||||||
|
<div class="card-title" x-text="item.title"></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 animés <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 || forYou.cold_start)">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Aucun historique : carte d'amorçage au lieu d'une section muette -->
|
||||||
|
<div class="cta-card" x-show="!loading && forYou.cold_start">
|
||||||
|
<div class="cta-emoji">🧭</div>
|
||||||
|
<div class="cta-body">
|
||||||
|
<div class="cta-title">On ne te connaît pas encore…</div>
|
||||||
|
<p>Télécharge tes premiers animés ou séries, ou ajoute des favoris :
|
||||||
|
« Pour toi » s'ajustera automatiquement à tes genres préférés.</p>
|
||||||
|
<div class="cta-actions">
|
||||||
|
<a class="cta-btn" href="/">Chercher un premier titre</a>
|
||||||
|
<a class="cta-btn ghost" href="/discover">Explorer par genre</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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 && latestAnime.length === 0 && latestSerie.length === 0 && mustWatch.length === 0 && forYou.items.length === 0 && exploreItems.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 {
|
||||||
|
latestAnime: [], latestSerie: [], mustWatch: [], forYou: { based_on: [], items: [] },
|
||||||
|
genres: {}, exploreType: null, exploreGenre: null,
|
||||||
|
exploreItems: [], exploreLoading: false,
|
||||||
|
loading: true, error: null,
|
||||||
|
typeLabels: { anime: '⛩ Animés', serie: '📺 Séries', film: '🎬 Films' },
|
||||||
|
|
||||||
|
get types() { return Object.keys(this.genres); },
|
||||||
|
get currentGenres() { return this.genres[this.exploreType] || []; },
|
||||||
|
get exploreSubtitle() {
|
||||||
|
const genre = (this.currentGenres.find(g => g.key === this.exploreGenre) || {}).label;
|
||||||
|
return genre ? `${this.typeLabels[this.exploreType] || ''} · ${genre}` : 'choisis un genre';
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollRail(el, dir) {
|
||||||
|
const rail = el.closest('.rail-wrap').querySelector('.rail');
|
||||||
|
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
|
||||||
|
exploreHref(item) {
|
||||||
|
return item.source
|
||||||
|
? `/title/${item.source}/${encodeURIComponent(item.source_id)}`
|
||||||
|
: `/?q=${encodeURIComponent(item.title)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
setType(type) {
|
||||||
|
this.exploreType = type;
|
||||||
|
this.exploreGenre = (this.genres[type][0] || {}).key || null;
|
||||||
|
this.browse();
|
||||||
|
},
|
||||||
|
|
||||||
|
setGenre(genre) {
|
||||||
|
this.exploreGenre = genre;
|
||||||
|
this.browse();
|
||||||
|
},
|
||||||
|
|
||||||
|
async browse() {
|
||||||
|
if (!this.exploreType || !this.exploreGenre) return;
|
||||||
|
this.exploreLoading = true;
|
||||||
|
const params = new URLSearchParams({ type: this.exploreType, genre: this.exploreGenre });
|
||||||
|
const url = `${location.pathname}?${params}`;
|
||||||
|
history.replaceState(null, '', url); // état partageable
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/discover/browse?${params}`);
|
||||||
|
if (!res.ok) throw new Error('Erreur ' + res.status);
|
||||||
|
this.exploreItems = (await res.json()).items;
|
||||||
|
} catch (e) {
|
||||||
|
this.exploreItems = [];
|
||||||
|
console.error('Explorer :', e);
|
||||||
|
} finally {
|
||||||
|
this.exploreLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true; this.error = null;
|
||||||
|
try {
|
||||||
|
const [discoverRes, genresRes] = await Promise.all([
|
||||||
|
fetch('/api/discover'), fetch('/api/discover/genres'),
|
||||||
|
]);
|
||||||
|
if (!discoverRes.ok) throw new Error('Erreur ' + discoverRes.status);
|
||||||
|
const data = await discoverRes.json();
|
||||||
|
this.latestAnime = data.latest_anime;
|
||||||
|
this.latestSerie = data.latest_serie;
|
||||||
|
this.mustWatch = data.must_watch;
|
||||||
|
this.forYou = data.for_you;
|
||||||
|
this.genres = await genresRes.json();
|
||||||
|
|
||||||
|
// État initial : paramètres d'URL (?t=&g=) sinon premier type/genre dispo
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
let type = params.get('t');
|
||||||
|
if (!this.genres[type]) type = this.types[0];
|
||||||
|
let genre = params.get('g');
|
||||||
|
if (!(this.genres[type] || []).some(g => g.key === genre)) {
|
||||||
|
genre = ((this.genres[type] || [])[0] || {}).key;
|
||||||
|
}
|
||||||
|
this.exploreType = type || null;
|
||||||
|
this.exploreGenre = genre || null;
|
||||||
|
await this.browse();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active = 'downloads' %}
|
||||||
|
{% block title %}Téléchargements — Ohm Stream{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="downloadsPage()" x-init="connect()" @click.outside="confirmDel = null">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem;flex-wrap:wrap">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Téléchargements</h1>
|
||||||
|
<p class="page-sub" style="margin-bottom:0">
|
||||||
|
<span x-text="groups.length"></span> animé(s) · <span x-text="items.length"></span> épisode(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="clearFinished()">🧹 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="dl-groups">
|
||||||
|
<template x-for="g in groups" :key="g.name">
|
||||||
|
<div class="dl-group">
|
||||||
|
<div class="dl-group-head" @click="toggle(g)">
|
||||||
|
<span class="dl-chevron" :class="{ open: isOpen(g) }">▶</span>
|
||||||
|
<span class="dl-group-title" x-text="g.name"></span>
|
||||||
|
<span class="dl-group-meta">
|
||||||
|
<span class="m-downloading" x-show="g.counts.downloading"
|
||||||
|
x-text="'⬇ ' + g.counts.downloading"></span>
|
||||||
|
<span class="m-pending" x-show="g.counts.pending + g.counts.paused"
|
||||||
|
x-text="'⏳ ' + (g.counts.pending + g.counts.paused)"></span>
|
||||||
|
<span class="m-done" x-show="g.counts.done" x-text="'✔ ' + g.counts.done"></span>
|
||||||
|
<span class="m-failed" x-show="g.counts.failed + g.counts.cancelled"
|
||||||
|
x-text="'✖ ' + (g.counts.failed + g.counts.cancelled)"></span>
|
||||||
|
</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<span class="dl-group-size" x-show="g.bytes" x-text="fmtBytes(g.bytes)"></span>
|
||||||
|
<div class="dl-actions" @click.stop>
|
||||||
|
<button class="btn btn-sm btn-ghost" title="Retirer les terminés de ce groupe"
|
||||||
|
x-show="g.counts.done + g.counts.failed + g.counts.cancelled"
|
||||||
|
@click="cleanGroup(g)">🧹</button>
|
||||||
|
<button class="btn btn-sm btn-danger" title="Annuler les téléchargements actifs de ce groupe"
|
||||||
|
x-show="g.counts.active" @click="cancelGroup(g)">✖</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dl-group-body" x-show="isOpen(g)" x-cloak>
|
||||||
|
<template x-for="d in g.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._label || 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 class="dl-del-wrap">
|
||||||
|
<button class="btn btn-sm btn-ghost" title="Supprimer"
|
||||||
|
@click.stop="confirmDel = confirmDel === d.id ? null : d.id">🗑</button>
|
||||||
|
<div class="dl-menu" x-show="confirmDel === d.id" x-cloak>
|
||||||
|
<button class="btn btn-sm" @click="remove(d, false)"
|
||||||
|
x-text="d.status === 'done' ? 'Liste seulement' : 'Supprimer'"></button>
|
||||||
|
<template x-if="d.status === 'done'">
|
||||||
|
<button class="btn btn-sm btn-danger" @click="remove(d, true)">Liste + fichier</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function downloadsPage() {
|
||||||
|
return {
|
||||||
|
items: [], connected: false, es: null,
|
||||||
|
confirmDel: null, // id de l'épisode dont le menu 🗑 est ouvert
|
||||||
|
toggled: {}, // choix manuel de repli/dépliage par groupe
|
||||||
|
|
||||||
|
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.map(d => this.decorate(d));
|
||||||
|
else if (msg.type === 'update') this.upsert(this.decorate(msg.item));
|
||||||
|
else if (msg.type === 'removed') this.items = this.items.filter(d => d.id !== msg.id);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Découpe « Anime - E12 (VOSTFR) » → base « Anime », numéro, libellé d'épisode
|
||||||
|
decorate(d) {
|
||||||
|
const m = d.title.match(/^(.*?)[\s\-–—]*(?:épisode|episode|ep|e)\s*(\d+(?:\.\d+)?)(.*)$/i);
|
||||||
|
if (m && m[1].trim()) {
|
||||||
|
d._base = m[1].trim();
|
||||||
|
d._num = parseFloat(m[2]);
|
||||||
|
d._label = ('E' + m[2] + ' ' + (m[3] || '')).replace(/\s+/g, ' ').trim();
|
||||||
|
} else {
|
||||||
|
d._base = d.title;
|
||||||
|
d._num = null;
|
||||||
|
d._label = '';
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
},
|
||||||
|
|
||||||
|
get groups() {
|
||||||
|
const map = new Map();
|
||||||
|
for (const d of this.items) {
|
||||||
|
if (!map.has(d._base)) map.set(d._base, []);
|
||||||
|
map.get(d._base).push(d);
|
||||||
|
}
|
||||||
|
const groups = [];
|
||||||
|
for (const [name, list] of map) {
|
||||||
|
const c = { downloading: 0, pending: 0, paused: 0, done: 0, failed: 0, cancelled: 0 };
|
||||||
|
for (const d of list) c[d.status]++;
|
||||||
|
c.active = c.downloading + c.pending + c.paused;
|
||||||
|
list.sort((a, b) => (a._num ?? Infinity) - (b._num ?? Infinity) || a.id - b.id);
|
||||||
|
groups.push({
|
||||||
|
name, items: list, counts: c,
|
||||||
|
bytes: list.reduce((s, d) => s + (d.status === 'done' ? d.total_bytes || 0 : 0), 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const prio = g => g.counts.downloading ? 0 : g.counts.active ? 1 : 2;
|
||||||
|
groups.sort((a, b) => prio(a) - prio(b) || a.name.localeCompare(b.name, 'fr'));
|
||||||
|
return groups;
|
||||||
|
},
|
||||||
|
|
||||||
|
isOpen(g) {
|
||||||
|
const t = this.toggled[g.name];
|
||||||
|
return t !== undefined ? t : g.counts.active > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle(g) {
|
||||||
|
this.toggled[g.name] = !this.isOpen(g);
|
||||||
|
},
|
||||||
|
|
||||||
|
upsert(item) {
|
||||||
|
const i = this.items.findIndex(d => d.id === item.id);
|
||||||
|
if (i >= 0) this.items[i] = item;
|
||||||
|
else {
|
||||||
|
this.items.unshift(item);
|
||||||
|
delete this.toggled[item._base]; // nouveau groupe/épisode → retour au comportement par défaut
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async action(path) {
|
||||||
|
await fetch('/api/downloads/' + path, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(d, deleteFile) {
|
||||||
|
this.confirmDel = null;
|
||||||
|
this.items = this.items.filter(i => i.id !== d.id);
|
||||||
|
await fetch('/api/downloads/' + d.id + (deleteFile ? '?delete_file=true' : ''), { method: 'DELETE' });
|
||||||
|
toast(deleteFile ? '🗑 Épisode et fichier supprimés' : '🗑 Retiré de la liste');
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearFinished() {
|
||||||
|
await this.action('clear-finished');
|
||||||
|
const res = await fetch('/api/downloads');
|
||||||
|
this.items = (await res.json()).map(d => this.decorate(d));
|
||||||
|
},
|
||||||
|
|
||||||
|
async cleanGroup(g) {
|
||||||
|
const ids = g.items
|
||||||
|
.filter(d => ['done', 'failed', 'cancelled'].includes(d.status))
|
||||||
|
.map(d => d.id);
|
||||||
|
this.items = this.items.filter(d => !ids.includes(d.id));
|
||||||
|
await Promise.all(ids.map(id => fetch('/api/downloads/' + id, { method: 'DELETE' })));
|
||||||
|
toast('🧹 ' + ids.length + ' épisode(s) retiré(s) : ' + g.name);
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelGroup(g) {
|
||||||
|
const actives = g.items.filter(d => ['pending', 'downloading', 'paused'].includes(d.status));
|
||||||
|
await Promise.all(actives.map(d => this.action(d.id + '/cancel')));
|
||||||
|
toast('✖ ' + actives.length + ' téléchargement(s) annulé(s) : ' + g.name);
|
||||||
|
},
|
||||||
|
|
||||||
|
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 %}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<!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?v={{ asset_v }}">
|
||||||
|
<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 & 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>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
{% 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="uniqueEpisodeCount"></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
|
||||||
|
<span class="version-filter" x-show="epVersions.length" x-cloak>
|
||||||
|
<template x-for="v in epVersions" :key="v">
|
||||||
|
<button class="version-tab" :class="{ active: versionFilter === v }"
|
||||||
|
@click="versionFilter = v" x-text="v.toUpperCase()"></button>
|
||||||
|
</template>
|
||||||
|
<button class="version-tab" :class="{ active: versionFilter === 'all' }"
|
||||||
|
@click="versionFilter = 'all'">Tout</button>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div class="episode-list">
|
||||||
|
<template x-for="ep in filteredEpisodes" :key="ep.number + '-' + (ep.version || '')">
|
||||||
|
<div class="episode-row">
|
||||||
|
<span class="episode-num" x-text="'E' + ep.number"></span>
|
||||||
|
<span class="badge badge-version" x-show="ep.version" x-text="ep.version.toUpperCase()"></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 },
|
||||||
|
versionFilter: 'all',
|
||||||
|
|
||||||
|
get epVersions() {
|
||||||
|
if (!this.details) return [];
|
||||||
|
return [...new Set(this.details.episodes.map(e => e.version).filter(Boolean))];
|
||||||
|
},
|
||||||
|
|
||||||
|
get filteredEpisodes() {
|
||||||
|
if (!this.details) return [];
|
||||||
|
if (this.versionFilter === 'all') return this.details.episodes;
|
||||||
|
return this.details.episodes.filter(e => e.version === this.versionFilter);
|
||||||
|
},
|
||||||
|
|
||||||
|
get uniqueEpisodeCount() {
|
||||||
|
if (!this.details) return 0;
|
||||||
|
return new Set(this.details.episodes.map(e => e.number)).size;
|
||||||
|
},
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.versionFilter = this.epVersions.includes('vostfr') ? 'vostfr' : 'all';
|
||||||
|
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) {
|
||||||
|
const version = ep.version ? ` (${ep.version.toUpperCase()})` : '';
|
||||||
|
return `${this.details.title} - E${ep.number}${version}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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.filteredEpisodes.length };
|
||||||
|
for (const ep of this.filteredEpisodes) {
|
||||||
|
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 %}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Version de l'application.
|
||||||
|
|
||||||
|
Source de vérité : `version` de pyproject.toml (lue via les métadonnées du paquet
|
||||||
|
installé par uv). Dans l'image Docker, `OHM_VERSION` est cuit au build par
|
||||||
|
scripts/release.sh et prime sur tout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.metadata
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def get_version() -> str:
|
||||||
|
v = os.environ.get("OHM_VERSION")
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
try:
|
||||||
|
return importlib.metadata.version("ohm-stream")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return "dev"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Déploiement OhmStreaming — https://git.lanro.eu/Roman/ohm_streaming
|
||||||
|
#
|
||||||
|
# Installation guidée (recommandée) : ./scripts/install.sh
|
||||||
|
# Ou à la main :
|
||||||
|
# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo)
|
||||||
|
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
|
||||||
|
# 3. docker compose up -d
|
||||||
|
#
|
||||||
|
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
|
||||||
|
# docker compose pull && docker compose up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
ohm:
|
||||||
|
image: git.lanro.eu/roman/ohm_streaming:latest
|
||||||
|
# Build local (dev) : décommenter ces lignes et commenter « image: » ci-dessus
|
||||||
|
# build:
|
||||||
|
# context: .
|
||||||
|
# args:
|
||||||
|
# VERSION: dev
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${OHM_PORT:-8777}:8777"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# Chemins internes (volumes ci-dessous) — ne pas changer
|
||||||
|
OHM_DATA_DIR: /data
|
||||||
|
OHM_DOWNLOAD_DIR: /downloads
|
||||||
|
OHM_DATABASE_PATH: /data/ohm.db
|
||||||
|
# Watchtower compagnon sur le réseau interne compose
|
||||||
|
OHM_WATCHTOWER_URL: http://watchtower:8080
|
||||||
|
OHM_WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||||
|
volumes:
|
||||||
|
- ./data:/data # base SQLite, persiste entre les mises à jour
|
||||||
|
# Bibliothèque d'épisodes : ./downloads par défaut, ou un dossier de ton
|
||||||
|
# serveur (script d'install ou à la main). Un dossier VIDE est chowné au
|
||||||
|
# démarrage par l'entrypoint ; un dossier existant (biblio Plex/Sonarr)
|
||||||
|
# n'est JAMAIS modifié — les épisodes arrivent rangés par animé :
|
||||||
|
# /plex_videos/ohm/One Piece/One Piece - E12 (VOSTFR).mp4
|
||||||
|
- ./downloads:/downloads
|
||||||
|
|
||||||
|
# Compagnon de mise à jour : recrée le conteneur ohm quand l'admin le demande
|
||||||
|
# (API HTTP interne uniquement, aucun port publié sur l'hôte)
|
||||||
|
watchtower:
|
||||||
|
image: containrrr/watchtower
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
# Pas de vérification périodique : uniquement sur appel de l'admin
|
||||||
|
WATCHTOWER_HTTP_API_UPDATE: "true"
|
||||||
|
WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||||
|
# Ne mettre à jour que les conteneurs étiquetés (ohm), pas watchtower lui-même
|
||||||
|
WATCHTOWER_LABEL_ENABLE: "true"
|
||||||
|
# Compatibilité Docker récent (l'image watchtower négocie une API trop ancienne)
|
||||||
|
DOCKER_API_VERSION: "1.44"
|
||||||
|
# Supprimer les anciennes images après mise à jour
|
||||||
|
WATCHTOWER_CLEANUP: "true"
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Prépare les volumes puis bascule sur l'utilisateur non-root « ohm ».
|
||||||
|
#
|
||||||
|
# /data (base SQLite) : toujours à nous → chown récursif.
|
||||||
|
# /downloads : peut être un montage sur une bibliothèque média existante
|
||||||
|
# (Plex/Sonarr…). On ne prend possession QUE d'un dossier vide — jamais
|
||||||
|
# d'une bibliothèque déjà remplie, dont les droits doivent rester intacts.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
chown -R ohm:ohm /data 2>/dev/null || true
|
||||||
|
if [ -z "$(ls -A /downloads 2>/dev/null)" ]; then
|
||||||
|
chown -R ohm:ohm /downloads 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
exec gosu ohm:ohm "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
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.0",
|
|
||||||
"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 /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"):
|
|
||||||
"""Search across all anime providers"""
|
|
||||||
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}")
|
|
||||||
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))
|
|
||||||
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,
|
|
||||||
"results": results
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
uvicorn.run(
|
|
||||||
"main:app",
|
|
||||||
host="0.0.0.0",
|
|
||||||
port=3000,
|
|
||||||
reload=True
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[project]
|
||||||
|
name = "ohm-stream"
|
||||||
|
version = "0.2.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 = ["."]
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Installation guidée d'Ohm Stream Downloader (premier setup).
|
||||||
|
#
|
||||||
|
# bash scripts/install.sh (depuis un clone du dépôt)
|
||||||
|
# curl -fsSL <gitea>/raw/branch/main/scripts/install.sh | bash
|
||||||
|
#
|
||||||
|
# Options non interactives : --dir <chemin> --port <n> --skip-login
|
||||||
|
# (les valeurs passées en option court-circuitent les questions)
|
||||||
|
#
|
||||||
|
# Pose les questions (destination des animés, port), génère les secrets,
|
||||||
|
# écrit .env, branche le montage Docker, démarre et attend le service.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY_HOST="git.lanro.eu"
|
||||||
|
REPO_URL="https://${REGISTRY_HOST}/Roman/ohm_streaming.git"
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
|
||||||
|
BOLD="\033[1m"; DIM="\033[2m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m"
|
||||||
|
step() { printf "\n${BOLD}▸ %s${RESET}\n" "$1"; }
|
||||||
|
ok() { printf " ${GREEN}✔${RESET} %s\n" "$1"; }
|
||||||
|
warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; }
|
||||||
|
die() { printf " ${RED}✖ %s${RESET}\n" "$1" >&2; exit 1; }
|
||||||
|
|
||||||
|
ARG_DIR="" ARG_PORT="" ARG_SKIP_LOGIN=0
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--dir) ARG_DIR="$2"; shift 2;;
|
||||||
|
--port) ARG_PORT="$2"; shift 2;;
|
||||||
|
--skip-login) ARG_SKIP_LOGIN=1; shift;;
|
||||||
|
*) shift;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
ask() { # ask <question> <défaut> — lit /dev/tty pour rester compatible curl|bash
|
||||||
|
local answer=""
|
||||||
|
if [ -r /dev/tty ]; then
|
||||||
|
read -r -p "$(printf ' %s ${DIM}[%s]${RESET} ' "$1" "$2")" answer </dev/tty 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "${answer:-$2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
rand_hex() { # rand_hex <nb octets>
|
||||||
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
|
openssl rand -hex "$1"
|
||||||
|
else
|
||||||
|
head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
printf "${BOLD}⛩ Ohm Stream Downloader — installation${RESET}\n"
|
||||||
|
printf "${DIM}Télécharge et streame des animes — Docker + Watchtower${RESET}\n"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- prérequis
|
||||||
|
step "Vérification des prérequis"
|
||||||
|
command -v docker >/dev/null 2>&1 || die "Docker n'est pas installé. → curl -fsSL https://get.docker.com | sh"
|
||||||
|
docker compose version >/dev/null 2>&1 || die "Le plugin Docker Compose manque (paquet docker-compose-plugin)."
|
||||||
|
ok "Docker + Compose présents"
|
||||||
|
|
||||||
|
# Depuis un clone ? sinon on clone (permet curl … | bash)
|
||||||
|
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||||
|
INSTALL_DIR="$(ask "Dossier d'installation" "$HOME/ohm_streaming")"
|
||||||
|
if [ -e "$INSTALL_DIR" ]; then
|
||||||
|
[ -f "$INSTALL_DIR/$COMPOSE_FILE" ] || die "$INSTALL_DIR existe déjà (et n'est pas une installation Ohm)."
|
||||||
|
step "Mise à jour du clone existant"
|
||||||
|
git -C "$INSTALL_DIR" pull --ff-only || warn "git pull a échoué, on continue avec la version locale."
|
||||||
|
else
|
||||||
|
step "Récupération du projet"
|
||||||
|
command -v git >/dev/null 2>&1 || die "git est requis pour cloner $REPO_URL"
|
||||||
|
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
ok "Projet prêt dans $(pwd)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- registre privé
|
||||||
|
step "Accès au registre privé ($REGISTRY_HOST)"
|
||||||
|
if [ "$ARG_SKIP_LOGIN" = "1" ]; then
|
||||||
|
warn "--skip-login : connexion au registre ignorée"
|
||||||
|
elif docker login "$REGISTRY_HOST" 2>/dev/null; then
|
||||||
|
ok "Connecté au registre"
|
||||||
|
else
|
||||||
|
warn "Pas encore connecté — un compte Gitea avec accès lecture au dépôt est requis."
|
||||||
|
if [ "$(ask "Se connecter maintenant (docker login) ?" "o")" = "o" ]; then
|
||||||
|
docker login "$REGISTRY_HOST" || die "docker login a échoué — relance le script après t'être connecté."
|
||||||
|
ok "Connecté au registre"
|
||||||
|
else
|
||||||
|
die "Sans docker login, l'image ne pourra pas être tirée."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- destination des épisodes
|
||||||
|
step "Destination des épisodes téléchargés"
|
||||||
|
if [ -n "$ARG_DIR" ]; then
|
||||||
|
HOST_DIR="$ARG_DIR"
|
||||||
|
else
|
||||||
|
printf " 1) ${BOLD}Dossier dédié « ohm »${RESET} (recommandé) : Ohm crée/utilise un dossier\n\
|
||||||
|
à part, à ajouter à ta bibliothèque Plex. Tes dossiers Sonarr/Plex\n\
|
||||||
|
existants ne sont JAMAIS modifiés.\n\
|
||||||
|
2) Dossier de ton choix (ex. un sous-dossier de ta biblio d'animes).\n\
|
||||||
|
S'il n'est pas vide, Ohm importera aussi les fichiers déjà présents.\n"
|
||||||
|
MODE="$(ask "Choix (1/2)" "1")"
|
||||||
|
if [ "$MODE" = "1" ]; then
|
||||||
|
HOST_DIR="$(ask "Chemin du dossier dédié" "/opt/ohm/animes")"
|
||||||
|
else
|
||||||
|
HOST_DIR="$(ask "Chemin du dossier de téléchargement" "/plex_videos/anime/ohm")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -e "$HOST_DIR" ] && [ -n "$(ls -A "$HOST_DIR" 2>/dev/null)" ]; then
|
||||||
|
warn "$HOST_DIR n'est pas vide : Ohm l'adoptera tel quel (droits existants conservés)."
|
||||||
|
warn "Le conteneur (uid 1000) doit pouvoir y écrire."
|
||||||
|
fi
|
||||||
|
if ! mkdir -p "$HOST_DIR" 2>/dev/null; then
|
||||||
|
printf ' sudo mkdir -p %q && sudo chown 1000:1000 %q\n' "$HOST_DIR" "$HOST_DIR" >&2
|
||||||
|
die "Impossible de créer $HOST_DIR — crée-le avec sudo puis relance."
|
||||||
|
fi
|
||||||
|
ok "Épisodes → $HOST_DIR (un dossier vide est pris en charge automatiquement)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- port + secrets
|
||||||
|
step "Configuration"
|
||||||
|
PORT="${ARG_PORT:-$(ask "Port HTTP exposé" "8777")}"
|
||||||
|
|
||||||
|
step "Écriture de .env"
|
||||||
|
if [ -f .env ]; then
|
||||||
|
cp .env ".env.backup.$(date +%s)"
|
||||||
|
warn ".env existant sauvegardé"
|
||||||
|
fi
|
||||||
|
SECRET_KEY="$(rand_hex 32)"
|
||||||
|
WT_TOKEN="$(rand_hex 24)"
|
||||||
|
cat > .env <<EOF
|
||||||
|
# Généré par scripts/install.sh le $(date '+%Y-%m-%d %H:%M')
|
||||||
|
OHM_SECRET_KEY=${SECRET_KEY}
|
||||||
|
WATCHTOWER_TOKEN=${WT_TOKEN}
|
||||||
|
OHM_PORT=${PORT}
|
||||||
|
EOF
|
||||||
|
chmod 600 .env
|
||||||
|
ok "Secrets générés (OHM_SECRET_KEY, WATCHTOWER_TOKEN)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- compose : montage téléchargements
|
||||||
|
step "Configuration du montage Docker"
|
||||||
|
cp "$COMPOSE_FILE" "$COMPOSE_FILE.dist"
|
||||||
|
if sed -i "s|^\([[:space:]]*- \)\./downloads:/downloads.*$|\1${HOST_DIR}:/downloads|" "$COMPOSE_FILE" \
|
||||||
|
&& grep -q -- "${HOST_DIR}:/downloads" "$COMPOSE_FILE"; then
|
||||||
|
ok "/downloads → $HOST_DIR (original conservé en $COMPOSE_FILE.dist)"
|
||||||
|
else
|
||||||
|
mv "$COMPOSE_FILE.dist" "$COMPOSE_FILE"
|
||||||
|
die "Montage introuvable dans $COMPOSE_FILE — édite la ligne ./downloads:/downloads à la main."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- démarrage
|
||||||
|
step "Démarrage (docker compose up -d)"
|
||||||
|
docker compose up -d || die "Démarrage échoué — logs : docker compose logs"
|
||||||
|
|
||||||
|
step "Attente du démarrage"
|
||||||
|
HEALTH_URL="http://127.0.0.1:${PORT}/health"
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf "$HEALTH_URL" >/dev/null 2>&1 || wget -q -O /dev/null "$HEALTH_URL" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
if [ "$i" = "30" ]; then docker compose logs --tail 30 ohm; die "Le service ne répond pas sur $HEALTH_URL"; fi
|
||||||
|
done
|
||||||
|
ok "Service en ligne"
|
||||||
|
|
||||||
|
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
printf "\n${BOLD}${GREEN}⛩ Ohm Stream est prêt !${RESET}\n\n"
|
||||||
|
printf " URL : ${BOLD}http://%s:%s${RESET}\n" "${HOST_IP:-<ip-serveur>}" "$PORT"
|
||||||
|
printf " Épisodes : %s\n" "$HOST_DIR"
|
||||||
|
printf " 1er compte : ${BOLD}le compte créé en premier devient administrateur${RESET}\n\n"
|
||||||
|
printf " ${DIM}Plex : ajoute %s comme dossier d'une bibliothèque (ou comme dossier\n supplémentaire de ta bibliothèque animes) — les épisodes arrivent rangés par\n animé : <Animé>/<Animé> - E12 (VOSTFR).mp4${RESET}\n\n" "$HOST_DIR"
|
||||||
|
printf " ${DIM}Commandes : docker compose logs -f · stop · pull && up -d (mise à jour)${RESET}\n"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publie une version d'OhmStreaming :
|
||||||
|
# bump de version → commit + tag git → build de l'image Docker → push registre Gitea.
|
||||||
|
#
|
||||||
|
# Usage : ./scripts/release.sh 0.2.0 ["patchnote multi-lignes"]
|
||||||
|
# Option : OHM_REGISTRY=git.lanro.eu/roman/ohm_streaming (défaut) pour viser un autre registre.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:?Usage : ./scripts/release.sh <version> [patchnote] (ex : 0.2.0)}"
|
||||||
|
REGISTRY="${OHM_REGISTRY:-git.lanro.eu/roman/ohm_streaming}"
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "✖ « $VERSION » n'est pas un semver X.Y.Z"; exit 1; }
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "✖ Dépôt sale — committez ou rangez vos modifications avant de publier :"
|
||||||
|
git status --short
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG="v$VERSION"
|
||||||
|
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||||
|
echo "✖ Le tag $TAG existe déjà"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "▶ 1/3 Version $VERSION dans pyproject.toml"
|
||||||
|
uv version "$VERSION"
|
||||||
|
git add pyproject.toml uv.lock
|
||||||
|
git commit -m "v$VERSION"
|
||||||
|
|
||||||
|
echo "▶ 2/3 Tag et push git"
|
||||||
|
git tag -a "$TAG" -m "${2:-$TAG}"
|
||||||
|
git push origin HEAD
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
echo "▶ 3/3 Image Docker → $REGISTRY"
|
||||||
|
docker build --build-arg VERSION="$VERSION" -t "$REGISTRY:$VERSION" -t "$REGISTRY:latest" .
|
||||||
|
docker push "$REGISTRY:$VERSION"
|
||||||
|
docker push "$REGISTRY:latest"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "✔ v$VERSION publiée ($REGISTRY:$VERSION et :latest)"
|
||||||
|
echo " Les instances la détecteront via Admin → Mise à jour → « Vérifier »."
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,220 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- client HTTP partagé
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Premier compte créé → administrateur, cookies de session."""
|
||||||
|
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||||
|
assert r.status_code == 303
|
||||||
|
return r.cookies
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Tests d'intégration API (auth, downloads, favoris, streaming, admin)."""
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import db
|
||||||
|
from app.scrapers.base import ScrapeError, SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
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_delete_download(client, admin_cookies):
|
||||||
|
path = get_settings().download_dir / "del1.mp4"
|
||||||
|
path.write_bytes(b"data" * 100)
|
||||||
|
cursor = await db.execute(
|
||||||
|
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path, status, "
|
||||||
|
"total_bytes, downloaded_bytes) "
|
||||||
|
"VALUES ('k2', 'u', 'p', 'Anime Y - E9 (VOSTFR)', 'del1.mp4', 'done', 400, 400)"
|
||||||
|
)
|
||||||
|
download_id = cursor.lastrowid
|
||||||
|
r = await client.delete(f"/api/downloads/{download_id}?delete_file=true", cookies=admin_cookies)
|
||||||
|
assert r.status_code == 200 and r.json() == {"ok": True}
|
||||||
|
assert not path.exists()
|
||||||
|
r = await client.delete(f"/api/downloads/{download_id}", cookies=admin_cookies)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def test_source_health(client, admin_cookies, monkeypatch):
|
||||||
|
# source inconnue → 404 (pas de 500)
|
||||||
|
r = await client.post("/api/admin/sources/inconnue/health", cookies=admin_cookies)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
# état initial : aucune santé connue
|
||||||
|
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||||
|
assert all(s["health"] is None for s in r.json())
|
||||||
|
|
||||||
|
async def ok_search(self, query: str) -> list[SearchResult]:
|
||||||
|
return [SearchResult(source=self.name, source_id="x", title="X", url="http://x")]
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", ok_search)
|
||||||
|
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||||
|
data = r.json()
|
||||||
|
assert data["healthy"] is True and data["detail"] == "1 résultats" and data["checked_at"]
|
||||||
|
|
||||||
|
# résultat persisté et exposé par GET /sources
|
||||||
|
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||||
|
health = {s["name"]: s["health"] for s in r.json()}
|
||||||
|
assert health["vostfree"]["healthy"] is True
|
||||||
|
assert health["french_manga"] is None
|
||||||
|
|
||||||
|
async def failing_search(self, query: str) -> list[SearchResult]:
|
||||||
|
raise ScrapeError("site indisponible")
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", failing_search)
|
||||||
|
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||||
|
data = r.json()
|
||||||
|
assert r.status_code == 200 and data["healthy"] is False and data["detail"] == "site indisponible"
|
||||||
|
|
||||||
|
async def broken_search(self, query: str) -> list[SearchResult]:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", broken_search)
|
||||||
|
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||||
|
data = r.json()
|
||||||
|
assert r.status_code == 200 and data["healthy"] is False and "boom" in data["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_source_toggle_unknown(client, admin_cookies):
|
||||||
|
r = await client.post(
|
||||||
|
"/api/admin/sources/inconnue/toggle", json={"enabled": True}, cookies=admin_cookies
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_source_url_override(client, admin_cookies):
|
||||||
|
from app.scrapers.base import get_source
|
||||||
|
|
||||||
|
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||||
|
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||||
|
default = src["default_base_url"]
|
||||||
|
assert src["base_url"] == default and src["overridden"] is False
|
||||||
|
|
||||||
|
# source inconnue → 404
|
||||||
|
r = await client.put("/api/admin/sources/inconnue/url", json={"url": "https://x.org"}, cookies=admin_cookies)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
# URL invalide → 422
|
||||||
|
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "pas-une-url"}, cookies=admin_cookies)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
# changement de domaine (slash final toléré) → appliqué à l'instance + persisté
|
||||||
|
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "https://exemple.org/"},
|
||||||
|
cookies=admin_cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["base_url"] == "https://exemple.org" and data["overridden"] is True
|
||||||
|
assert get_source("vostfree").base_url == "https://exemple.org"
|
||||||
|
|
||||||
|
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||||
|
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||||
|
assert src["base_url"] == "https://exemple.org" and src["overridden"] is True
|
||||||
|
|
||||||
|
# URL vide → retour au défaut du code
|
||||||
|
r = await client.put("/api/admin/sources/vostfree/url", json={"url": ""}, cookies=admin_cookies)
|
||||||
|
data = r.json()
|
||||||
|
assert data["base_url"] == default and data["overridden"] is False
|
||||||
|
assert get_source("vostfree").base_url == default
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Tests unitaires : format interne, sanitisation, auth, registres scrapers."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import auth
|
||||||
|
from app.scrapers import http as http_module
|
||||||
|
from app.scrapers.base import (
|
||||||
|
ScrapeError,
|
||||||
|
decode_internal_url,
|
||||||
|
encode_internal_url,
|
||||||
|
get_source,
|
||||||
|
import_all_scrapers,
|
||||||
|
resolve_hoster,
|
||||||
|
)
|
||||||
|
from app.services.downloads import plex_filename, 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"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ nommage Plex
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("title", "expected"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"The Eminence in Shadow - Saison 1 - E9 (VF)",
|
||||||
|
"The Eminence in Shadow - S01E09 (VF).mp4",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"The Eminence in Shadow - Saison 1 - E10 (VF)",
|
||||||
|
"The Eminence in Shadow - S01E10 (VF).mp4",
|
||||||
|
),
|
||||||
|
("Anime X - E1 (VOSTFR)", "Anime X - S01E01 (VOSTFR).mp4"),
|
||||||
|
("One Piece - E1122", "One Piece - S01E1122.mp4"),
|
||||||
|
("Série - saison 2 - e3 (vf)", "Série - S02E03 (vf).mp4"),
|
||||||
|
("Episode Test 1", None),
|
||||||
|
("Mon Titre", None),
|
||||||
|
("Series S01E05 VOSTFR WEB-DL", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_plex_filename(title, expected):
|
||||||
|
assert plex_filename(title, ".mp4") == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 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
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ client HTTP
|
||||||
|
|
||||||
|
|
||||||
|
class _StatusClient:
|
||||||
|
"""Fake client HTTP : sert une série de codes statut puis un corps 200."""
|
||||||
|
|
||||||
|
def __init__(self, statuses: list[int]):
|
||||||
|
self._statuses = statuses
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def get(self, url, headers=None):
|
||||||
|
self.calls += 1
|
||||||
|
request = httpx.Request("GET", url)
|
||||||
|
status = self._statuses.pop(0) if self._statuses else 200
|
||||||
|
response = httpx.Response(status, request=request, text="ok")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_fails_fast_on_definitive_4xx(monkeypatch):
|
||||||
|
client = _StatusClient([422])
|
||||||
|
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
|
||||||
|
with pytest.raises(ScrapeError, match="422"):
|
||||||
|
await http_module.fetch("https://site.example/lecteur/prepare/1?content=2&episode=3")
|
||||||
|
assert client.calls == 1 # aucun retry sur erreur définitive
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [429, 500, 503])
|
||||||
|
async def test_fetch_retries_transitory_errors(monkeypatch, status):
|
||||||
|
client = _StatusClient([status, 200])
|
||||||
|
monkeypatch.setattr("app.scrapers.http.get_client", lambda: client)
|
||||||
|
|
||||||
|
async def no_sleep(_delay):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.http.asyncio.sleep", no_sleep)
|
||||||
|
assert await http_module.fetch("https://site.example/page", retries=2) == "ok"
|
||||||
|
assert client.calls == 2
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"""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_by_type(limit=10))["anime"]
|
||||||
|
|
||||||
|
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_by_type(limit=10))["anime"]
|
||||||
|
assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"]
|
||||||
|
assert result[0]["rating"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_by_type_keeps_every_type(monkeypatch):
|
||||||
|
"""Chaque type garde son rail complet : les animés nombreux ne vicient pas
|
||||||
|
le rail séries (les séries n'existent pas chez Kitsu, donc sans date)."""
|
||||||
|
import app.services.discover as discover_module
|
||||||
|
|
||||||
|
animes = [
|
||||||
|
SearchResult(source="vostfree", source_id=str(n), title=f"Animé {n}", url=f"https://a/{n}")
|
||||||
|
for n in range(1, 31)
|
||||||
|
]
|
||||||
|
series = [
|
||||||
|
SearchResult(
|
||||||
|
source="french_stream", source_id=str(n), title=f"Série {n}",
|
||||||
|
url=f"https://s/{n}", media_type="serie",
|
||||||
|
)
|
||||||
|
for n in range(1, 10)
|
||||||
|
]
|
||||||
|
|
||||||
|
class AnimeSource(_FakeSource):
|
||||||
|
name, label, base_url, media_types = "vostfree", "Vostfree", "https://a", ("anime",)
|
||||||
|
|
||||||
|
class SerieSource(_FakeSource):
|
||||||
|
name, label, base_url, media_types = "french_stream", "French-Stream", "https://s", ("serie", "film")
|
||||||
|
|
||||||
|
monkeypatch.setattr(discover_module, "all_sources", lambda: [AnimeSource(animes), SerieSource(series)])
|
||||||
|
|
||||||
|
async def enabled(name: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||||
|
|
||||||
|
async def fake_match(title: str):
|
||||||
|
return None # rien chez Kitsu → aucune date de sortie
|
||||||
|
|
||||||
|
service = DiscoverService()
|
||||||
|
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||||
|
|
||||||
|
rails = await service.latest_by_type(limit=24)
|
||||||
|
assert len(rails["anime"]) == 24 # tronqué à la limite
|
||||||
|
assert len(rails["serie"]) == 9 # les 9 séries intactes, malgré 30 animés
|
||||||
|
assert all(item["media_type"] == "serie" for item in rails["serie"])
|
||||||
|
|
||||||
|
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_by_type() == {"anime": [], "serie": [], "film": []}
|
||||||
|
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
|
||||||
|
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]:
|
||||||
|
assert params["sort"] == "-userCount"
|
||||||
|
# Une catégorie par requête (le cumul Kitsu est un ET trop restrictif)
|
||||||
|
assert "," not in params["filter[categories]"]
|
||||||
|
return [
|
||||||
|
{"title": "Helck", "kitsu_id": "999"},
|
||||||
|
{"title": "Sousou no Frieren", "kitsu_id": "46474"},
|
||||||
|
{"title": "Konosuba", "kitsu_id": "1"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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_excludes_owned_with_episode_markers(monkeypatch):
|
||||||
|
"""Régression : « Titre - Saison 1 - E3 » possédé doit exclure le « Titre »
|
||||||
|
canonique recommandé (la normalisation retire saison ET épisode, en boucle)."""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||||
|
("reg-user", "x" * 64),
|
||||||
|
)
|
||||||
|
user_id = cursor.lastrowid
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)",
|
||||||
|
("kr", "https://v/r", "The Eminence in Shadow - Saison 1 - E3", "done"),
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(user_id, "vostfree", "r", "Favori R", None, '{"genres": ["Fantasy"]}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_match(title: str):
|
||||||
|
return None # genres apportés par le favori
|
||||||
|
|
||||||
|
async def fake_kitsu_anime(params: dict) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"title": "The Eminence in Shadow", "kitsu_id": "1"},
|
||||||
|
{"title": "Autre Anime", "kitsu_id": "2"},
|
||||||
|
]
|
||||||
|
|
||||||
|
service = DiscoverService()
|
||||||
|
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||||
|
monkeypatch.setattr(service, "_kitsu_anime", fake_kitsu_anime)
|
||||||
|
result = await service.for_you(user_id=user_id, limit=10)
|
||||||
|
titles = [item["title"] for item in result["items"]]
|
||||||
|
assert "The Eminence in Shadow" not in titles # possédé (« - Saison 1 - E3 »)
|
||||||
|
assert "Autre Anime" in titles
|
||||||
|
|
||||||
|
async def test_for_you_cold_start_without_history(monkeypatch):
|
||||||
|
"""Aucun téléchargement/favori/Sonarr : amorçage signalé (non caché)."""
|
||||||
|
service = DiscoverService()
|
||||||
|
result = await service.for_you(user_id=42)
|
||||||
|
assert result == {"based_on": [], "items": [], "cold_start": True}
|
||||||
|
assert service._cache.get("for_you:42:20") is None # pas de cache : bon marché, change au 1er téléchargement
|
||||||
|
|
||||||
|
async def test_for_you_uses_series_page_genres_without_sonarr(monkeypatch):
|
||||||
|
"""Sans Sonarr, les séries téléchargées donnent leurs genres via leur fiche
|
||||||
|
source (libellés FR convertis en catégories Kitsu)."""
|
||||||
|
cursor = await db.execute(
|
||||||
|
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||||
|
("serie-user", "x" * 64),
|
||||||
|
)
|
||||||
|
user_id = cursor.lastrowid
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO downloads (source_key, video_url, page_url, title, status) VALUES (?,?,?,?,?)",
|
||||||
|
("k9", "https://v/9", "https://french-stream.lat/15138132-stat-saison-5.html",
|
||||||
|
"STAT - Saison 5", "done"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_no_match(title: str):
|
||||||
|
return None # série réelle : aucun match Kitsu
|
||||||
|
|
||||||
|
async def fake_genres_of_page(self, page_url: str):
|
||||||
|
assert "15138132" in page_url
|
||||||
|
return ["Drame", "Médical"] # Médical : sans équivalent Kitsu → ignoré
|
||||||
|
|
||||||
|
service = DiscoverService()
|
||||||
|
monkeypatch.setattr(service._kitsu, "search_anime", fake_no_match)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.scrapers.sources.french_stream.FrenchStreamScraper.genres_of_page",
|
||||||
|
fake_genres_of_page,
|
||||||
|
)
|
||||||
|
result = await service.for_you(user_id=user_id, limit=10)
|
||||||
|
assert result["based_on"] == ["Drama"] # « Drame » converti, « Médical » écarté
|
||||||
|
assert result["items"] # des recommandations issues de la catégorie drama
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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_by_type(limit: int = 24):
|
||||||
|
return {
|
||||||
|
"anime": [
|
||||||
|
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
|
||||||
|
"media_type": "anime", "start_date": "2026-01-01", "status": "current", "rating": 8.1},
|
||||||
|
],
|
||||||
|
"serie": [
|
||||||
|
{"source": "french_stream", "label": "French-Stream", "source_id": "b",
|
||||||
|
"title": "S", "media_type": "serie"},
|
||||||
|
],
|
||||||
|
"film": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
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_by_type", fake_latest_by_type)
|
||||||
|
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_anime"][0]["label"] == "Vostfree"
|
||||||
|
assert data["latest_anime"][0]["start_date"] == "2026-01-01"
|
||||||
|
assert data["latest_serie"][0]["label"] == "French-Stream"
|
||||||
|
assert data["must_watch"][0]["title"] == "Attack on Titan"
|
||||||
|
assert data["for_you"]["based_on"] == ["Action"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_browse(client, admin_cookies, monkeypatch):
|
||||||
|
"""Explorer : animés via Kitsu, séries via French-Stream, hors préférence → vide."""
|
||||||
|
|
||||||
|
async def fake_browse_anime(genre: str, limit: int = 20):
|
||||||
|
assert genre == "comedy"
|
||||||
|
return [{"kitsu_id": "1", "title": "Nichijou"}]
|
||||||
|
|
||||||
|
async def fake_browse_serie_film(media_type: str, genre: str, limit: int = 24):
|
||||||
|
assert (media_type, genre) == ("serie", "medical")
|
||||||
|
return [{"source": "french_stream", "source_id": "9", "title": "STAT - Saison 5",
|
||||||
|
"media_type": "serie"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(discover, "browse_anime", fake_browse_anime)
|
||||||
|
monkeypatch.setattr(discover, "browse_serie_film", fake_browse_serie_film)
|
||||||
|
|
||||||
|
r = await client.get(
|
||||||
|
"/api/discover/browse", params={"type": "anime", "genre": "comedy"}, cookies=admin_cookies
|
||||||
|
)
|
||||||
|
assert r.json()["items"][0]["title"] == "Nichijou"
|
||||||
|
|
||||||
|
r = await client.get(
|
||||||
|
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
|
||||||
|
)
|
||||||
|
assert r.json()["items"][0]["title"] == "STAT - Saison 5"
|
||||||
|
|
||||||
|
# Préférence animés : le parcours séries renvoie vide sans même scraper
|
||||||
|
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
|
||||||
|
r = await client.get(
|
||||||
|
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
|
||||||
|
)
|
||||||
|
assert r.json()["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_genres_catalog(client, admin_cookies):
|
||||||
|
"""Catalogue de genres : animés + séries/films French-Stream en mode « les deux »."""
|
||||||
|
r = await client.get("/api/discover/genres", cookies=admin_cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
catalog = r.json()
|
||||||
|
assert {"anime", "serie", "film"} <= set(catalog)
|
||||||
|
assert {"key": "thriller", "label": "Thriller"} in catalog["serie"]
|
||||||
|
assert any(g["key"] == "comedies" for g in catalog["film"])
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""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.db import db
|
||||||
|
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
|
||||||
|
|
||||||
|
async def test_delete_keeps_file(file_server, manager):
|
||||||
|
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E1 (VOSTFR)")
|
||||||
|
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.is_file()
|
||||||
|
assert await dm.delete(d["id"]) is True
|
||||||
|
assert await dm.get(d["id"]) is None
|
||||||
|
assert path.is_file() # le fichier vidéo est conservé
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_with_file(file_server, manager):
|
||||||
|
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E2 (VOSTFR)")
|
||||||
|
data = await _wait_status(d["id"], {"done", "failed"})
|
||||||
|
path = get_settings().download_dir / data["file_path"]
|
||||||
|
assert await dm.delete(d["id"], delete_file=True) is True
|
||||||
|
assert await dm.get(d["id"]) is None
|
||||||
|
assert not path.exists()
|
||||||
|
assert await dm.delete(d["id"]) is False # déjà supprimé → introuvable
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_active(file_server, manager):
|
||||||
|
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime X - E3")
|
||||||
|
filename = d["file_path"]
|
||||||
|
assert await dm.delete(d["id"]) is True
|
||||||
|
assert await dm.get(d["id"]) is None
|
||||||
|
download_dir = get_settings().download_dir
|
||||||
|
assert not (download_dir / filename).exists()
|
||||||
|
assert not (download_dir / (filename + ".part")).exists()
|
||||||
|
|
||||||
|
async def test_series_subfolder_and_scan(file_server, manager):
|
||||||
|
"""Les épisodes sont rangés dans un sous-dossier par animé, restaurés au scan."""
|
||||||
|
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Anime Z - E7 (VOSTFR)")
|
||||||
|
data = await _wait_status(d["id"], {"done", "failed"})
|
||||||
|
assert data["status"] == "done", data.get("error")
|
||||||
|
assert data["file_path"] == "Anime Z/Anime Z - S01E07 (VOSTFR).mp4"
|
||||||
|
assert (get_settings().download_dir / data["file_path"]).read_bytes() == PAYLOAD
|
||||||
|
|
||||||
|
# DB vidée + redémarrage → le fichier en sous-dossier est restauré tel quel
|
||||||
|
await db.execute("DELETE FROM downloads")
|
||||||
|
await dm.stop()
|
||||||
|
await dm.start()
|
||||||
|
items = await dm.list_all()
|
||||||
|
paths = [i["file_path"] for i in items] # d'autres fichiers de tests peuvent trainer
|
||||||
|
assert "Anime Z/Anime Z - S01E07 (VOSTFR).mp4" in paths
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Tests du scraper French-Stream (séries/films VF-VOSTFR) sur fixtures réelles."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.scrapers.base import ScrapeError, get_source, import_all_scrapers
|
||||||
|
from app.scrapers.sources.french_stream import FrenchStreamScraper
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ fixtures
|
||||||
|
|
||||||
|
SEARCH_HTML = """
|
||||||
|
<div class='search-item' onclick="location.href='/9562-breaking-bad-saison-2-streaming-complet-vf-vostfr.html'">
|
||||||
|
<div class='search-poster'><img src='https://image.tmdb.org/t/p/w400/bb.jpg' alt='Breaking Bad - Saison 2'></div>
|
||||||
|
<div class='search-info'><div class='search-title'>Breaking Bad - Saison 2</div></div>
|
||||||
|
</div>
|
||||||
|
<div class='search-item' onclick="location.href='/1022-inception-streaming-complet-vf-vostfr.html'">
|
||||||
|
<div class='search-poster'><img src='https://image.tmdb.org/t/p/w400/inc.jpg' alt='Inception'></div>
|
||||||
|
<div class='search-info'><div class='search-title'>Inception (2010)</div></div>
|
||||||
|
</div>
|
||||||
|
<div class='search-item'><div class='search-info'><div class='search-title'>Sans lien</div></div></div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
SERIE_DETAILS_HTML = """
|
||||||
|
<div id="film-data" data-newsid="9562" data-affiche="https://image.tmdb.org/t/p/w300/bb.jpg"></div>
|
||||||
|
<div class="fmain"><div class="fcols fx-row"><div class="fmid">
|
||||||
|
<h1 id="s-title">Breaking Bad - Saison 2 </h1>
|
||||||
|
<div class="facts">
|
||||||
|
<span class="certification">-12</span> - <span class="release">2008 -</span>
|
||||||
|
<span class="genres">Drame, Crime</span> - <span class="runtime">48 min</span>
|
||||||
|
</div>
|
||||||
|
<div class="flist clearfix"><div class="fdesc"><p>Walter et Jesse montent leur propre affaire.</p></div></div>
|
||||||
|
</div></div></div>
|
||||||
|
<div class="fleft"><div class="fposter"><img src="https://image.tmdb.org/t/p/w400/bb.jpg" alt="Breaking Bad - Saison 2"></div></div>
|
||||||
|
<div id="serie-config" style="display:none" data-title="Breaking Bad - Saison 2" data-news-id="9562"></div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
FILM_DETAILS_HTML = """
|
||||||
|
<div id="film-data" data-newsid="1022" data-affiche="https://image.tmdb.org/t/p/w300/inc.jpg"></div>
|
||||||
|
<div class="fmain"><div class="fcols fx-row"><div class="fmid">
|
||||||
|
<h1 id="s-title"> Inception - 2010 <span class="tag release_date"></span> </h1>
|
||||||
|
<div class="facts">
|
||||||
|
<span class="genres"><a href="/index.php?do=xfsearch&xf=Action">Action</a><a href="/index.php?do=xfsearch&xf=Science-Fiction">Science-Fiction</a></span>
|
||||||
|
<span class="runtime">- 2h28</span>
|
||||||
|
</div>
|
||||||
|
<div class="fdesc clearfix slice-this" id="s-desc">
|
||||||
|
<p class="desc-text">Résumé du film Inception en streaming complet vf et vostfr hd vod gratuit</p>
|
||||||
|
Dom Cobb est un voleur expérimenté.
|
||||||
|
</div>
|
||||||
|
</div></div></div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
EP_DATA = {
|
||||||
|
"vf": {
|
||||||
|
"1": {"vidzy": "https://vidzy.cc/embed-aaa.html", "uqload": "https://uqload.vc/embed-bbb.html"},
|
||||||
|
"2": {"vidzy": "https://vidzy.cc/embed-ccc.html"},
|
||||||
|
},
|
||||||
|
"vostfr": {
|
||||||
|
"1": {"vidzy": "https://vidzy.cc/embed-ddd.html"},
|
||||||
|
"2": {"vidzy": "https://vidzy.cc/embed-eee.html"},
|
||||||
|
},
|
||||||
|
"vo": {},
|
||||||
|
"info": {
|
||||||
|
"1": {"title": "Traqués", "synopsis": "Walt et Jesse...", "poster": "https://img/ep1.jpg"},
|
||||||
|
"2": {"title": "Chasse à l'homme"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
FILM_API = {
|
||||||
|
"players": {
|
||||||
|
"vidzy": {
|
||||||
|
"default": "https://vidzy.live/embed-fff.html",
|
||||||
|
"vff": "https://vidzy.live/embed-fff.html",
|
||||||
|
"vostfr": "https://vidzy.live/embed-ggg.html",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LATEST_HTML = """
|
||||||
|
<div class="short"><div class="short-in nl">
|
||||||
|
<a class="short-poster img-box with-mask" href="/index.php?newsid=15137274" alt="The Drop - Saison 1">
|
||||||
|
<img src="https://image.tmdb.org/t/p/w300/drop.jpg">
|
||||||
|
</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="short"><div class="short-in nl">
|
||||||
|
<a class="short-poster img-box with-mask" href="/index.php?newsid=15139056" alt="La Rumeur">
|
||||||
|
<img src="https://image.tmdb.org/t/p/w300/rumeur.jpg">
|
||||||
|
</a>
|
||||||
|
</div></div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _soup(html: str):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
return BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ source
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search(monkeypatch):
|
||||||
|
async def fake_fetch(url, **kwargs):
|
||||||
|
assert url.endswith("/engine/ajax/search.php")
|
||||||
|
assert kwargs["data"] == {"query": "breaking bad", "page": "1"}
|
||||||
|
return SEARCH_HTML
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||||
|
results = await FrenchStreamScraper().search("breaking bad")
|
||||||
|
assert len(results) == 2
|
||||||
|
serie, film = results
|
||||||
|
assert serie.source_id == "9562"
|
||||||
|
assert serie.media_type == "serie"
|
||||||
|
assert serie.title == "Breaking Bad - Saison 2"
|
||||||
|
assert serie.url == "https://french-stream.lat/9562-breaking-bad-saison-2-streaming-complet-vf-vostfr.html"
|
||||||
|
assert serie.image_url == "https://image.tmdb.org/t/p/w400/bb.jpg"
|
||||||
|
assert film.media_type == "film"
|
||||||
|
assert film.source_id == "1022"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest(monkeypatch):
|
||||||
|
async def fake_fetch_soup(url, **kwargs):
|
||||||
|
assert url.endswith("/series/")
|
||||||
|
return _soup(LATEST_HTML)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||||
|
results = await FrenchStreamScraper().latest()
|
||||||
|
assert [(r.source_id, r.media_type) for r in results] == [
|
||||||
|
("15137274", "serie"),
|
||||||
|
("15139056", "film"),
|
||||||
|
]
|
||||||
|
assert results[0].title == "The Drop - Saison 1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browse(monkeypatch):
|
||||||
|
"""Parcours par genre : page du genre scrapée, type forcé sur les résultats."""
|
||||||
|
|
||||||
|
async def fake_fetch_soup(url, **kwargs):
|
||||||
|
assert url.endswith("/medical-series-/")
|
||||||
|
return _soup(LATEST_HTML)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||||
|
results = await FrenchStreamScraper().browse("serie", "medical")
|
||||||
|
# LATEST_HTML contient un film sans « Saison » : le type est forcé à serie
|
||||||
|
assert [r.media_type for r in results] == ["serie", "serie"]
|
||||||
|
assert results[0].title == "The Drop - Saison 1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_browse_unknown_category():
|
||||||
|
with pytest.raises(ScrapeError, match="Catégorie inconnue"):
|
||||||
|
await FrenchStreamScraper().browse("film", "inexistant")
|
||||||
|
|
||||||
|
async def test_get_details_serie(monkeypatch):
|
||||||
|
async def fake_fetch_soup(url, **kwargs):
|
||||||
|
assert "newsid=9562" in url
|
||||||
|
return _soup(SERIE_DETAILS_HTML)
|
||||||
|
|
||||||
|
async def fake_fetch(url, **kwargs):
|
||||||
|
assert "ep-data.php?id=9562" in url
|
||||||
|
return json.dumps(EP_DATA)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||||
|
details = await FrenchStreamScraper().get_details("9562")
|
||||||
|
assert details.media_type == "serie"
|
||||||
|
assert details.title == "Breaking Bad - Saison 2"
|
||||||
|
assert details.year == 2008
|
||||||
|
assert details.genres == ["Drame", "Crime"]
|
||||||
|
assert details.synopsis == "Walter et Jesse montent leur propre affaire."
|
||||||
|
assert details.image_url == "https://image.tmdb.org/t/p/w400/bb.jpg"
|
||||||
|
assert details.episode_count == 4
|
||||||
|
vf1 = details.episodes[0]
|
||||||
|
assert (vf1.season, vf1.number, vf1.version, vf1.title) == (2, 1.0, "vf", "Traqués")
|
||||||
|
assert vf1.url.endswith("#vf-1")
|
||||||
|
versions = {(e.number, e.version) for e in details.episodes}
|
||||||
|
assert versions == {(1.0, "vf"), (1.0, "vostfr"), (2.0, "vf"), (2.0, "vostfr")}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_details_film(monkeypatch):
|
||||||
|
async def fake_fetch_soup(url, **kwargs):
|
||||||
|
return _soup(FILM_DETAILS_HTML)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||||
|
details = await FrenchStreamScraper().get_details("1022")
|
||||||
|
assert details.media_type == "film"
|
||||||
|
assert details.title == "Inception - 2010"
|
||||||
|
assert details.year == 2010
|
||||||
|
assert details.genres == ["Action", "Science-Fiction"]
|
||||||
|
assert "Résumé du film" not in details.synopsis
|
||||||
|
assert details.episode_count == 1
|
||||||
|
assert details.episodes[0].url.endswith("#film")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_episodes_film(monkeypatch):
|
||||||
|
async def fake_fetch_soup(url, **kwargs):
|
||||||
|
return _soup(FILM_DETAILS_HTML)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||||
|
episodes = await FrenchStreamScraper().list_episodes("1022")
|
||||||
|
assert len(episodes) == 1
|
||||||
|
assert episodes[0].number == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_extract_embed_links_episode(monkeypatch):
|
||||||
|
async def fake_fetch(url, **kwargs):
|
||||||
|
return json.dumps(EP_DATA)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||||
|
links = await FrenchStreamScraper().extract_embed_links(
|
||||||
|
"https://french-stream.lat/index.php?newsid=9562#vf-1"
|
||||||
|
)
|
||||||
|
assert links == ["https://vidzy.cc/embed-aaa.html", "https://uqload.vc/embed-bbb.html"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_extract_embed_links_film_dedup(monkeypatch):
|
||||||
|
async def fake_fetch(url, **kwargs):
|
||||||
|
assert "film_api.php?id=1022" in url
|
||||||
|
return json.dumps(FILM_API)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||||
|
links = await FrenchStreamScraper().extract_embed_links(
|
||||||
|
"https://french-stream.lat/index.php?newsid=1022#film"
|
||||||
|
)
|
||||||
|
assert links == ["https://vidzy.live/embed-fff.html", "https://vidzy.live/embed-ggg.html"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_extract_embed_links_bad_fragment():
|
||||||
|
with pytest.raises(ScrapeError):
|
||||||
|
await FrenchStreamScraper().extract_embed_links(
|
||||||
|
"https://french-stream.lat/index.php?newsid=9562#nimportequoi"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_extract_embed_links_invalid_json(monkeypatch):
|
||||||
|
async def fake_fetch(url, **kwargs):
|
||||||
|
return "<html>oops</html>"
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch", fake_fetch)
|
||||||
|
with pytest.raises(ScrapeError):
|
||||||
|
await FrenchStreamScraper().extract_embed_links(
|
||||||
|
"https://french-stream.lat/index.php?newsid=9562#vf-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_detail_page(monkeypatch):
|
||||||
|
async def fake_fetch_soup(url, **kwargs):
|
||||||
|
return _soup("<html><body>404</body></html>")
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
|
||||||
|
with pytest.raises(ScrapeError):
|
||||||
|
await FrenchStreamScraper().get_details("999999999")
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered():
|
||||||
|
import_all_scrapers()
|
||||||
|
source = get_source("french_stream")
|
||||||
|
assert source.media_types == ("serie", "film")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user