Author SHA1 Message Date
Roman c7e178eecf v0.2.0 2026-09-25 15:39:35 +00:00
Roman 12bdc8a7ef release.sh : tag annoté avec patchnote (affiché par le panneau MAJ) 2026-09-25 15:39:07 +00:00
Roman eae97f9129 Panneau MAJ simplifié : dépôt public, plus de configuration Gitea — patchnote du dernier tag 2026-09-25 15:39:00 +00:00
Roman fed61adc56 Pour toi : recommandations diverses et déjà-possédé réellement exclu
Deux défauts enchaînés : la requête Kitsu cumulait les genres en ET
(quatre catégories → 1 seul titre, précisément le téléchargement à
l'origine des genres), et l'exclusion échouait car « Titre - Saison 1 -
E3 » ne normalisait jamais vers « Titre » canonique.

- Une requête Kitsu par genre, fusion entrelacée (chaque genre contribue)
- normalize_title retire saison/épisode en boucle avec frontières de mots
  (« - Saison 1 - E3 », « S1 E1 » → titre canonique ; « Série 9 » intact)
- test de non-régession : le possédé avec marqueurs n'est plus recommandé
2026-09-25 15:23:29 +00:00
Roman d5fa546dd7 Pour toi : fallback sans Sonarr et amorçage sans historique
- Sans Sonarr, les séries/films téléchargés donnent leurs genres via leur
  fiche French-Stream (page_url des téléchargements), convertis vers les
  catégories Kitsu (mapping FR→EN ; genres sans équivalent ignorés)
- Aucun historique (ni téléchargement, ni favori, ni Sonarr) : réponse
  cold_start non cachée → carte d'amorçage « téléchargez un premier titre »
- Fin de téléchargement → invalidation immédiate des recommandations
- genres_of_page ignore les fiches hors French-Stream (URLs d'animés)
2026-09-25 15:14:14 +00:00
Roman c45f1b79fd Cache-busting des assets statiques
Le navigateur servait un CSS périmé après mise à jour (aucun invalidateur
sur /static). Les liens CSS/JS portent désormais ?v=<mtime des fichiers
statiques> : toute modification du design est visible immédiatement chez
tous les clients, sans hard-refresh ni bump manuel de version.
2026-09-25 14:58:42 +00:00
Roman d747c574b8 Découvrir v2 : rails par type, exploration par genre
- Rails Nouveautés séparés : 🆕 animés (tri date Kitsu) et 🆕 séries & films
  (French-Stream) — chaque type garde son rail, plus d'écrasement mutuel
- Section 🎭 Explorer : chips type × genre — animés via catégories Kitsu,
  séries (/<genre>-series-/) et films (/films/<genre>/) via French-Stream ;
  état dans l'URL (/discover?t=serie&g=medical), partageable
- Scraper : browse(media_type, genre) + catalogue YAML surchargeable ;
  parsing div.short mutualisé avec les nouveautés
- Films réels retirés du mode « Animés » (ils suivent le rail séries)
- Endpoints /api/discover (latest_anime/latest_serie), /api/discover/genres,
  /api/discover/browse — parcours filtré par la préférence du compte
- Warmup démarrage : latest_by_type()
2026-09-25 14:48:29 +00:00
Roman 248ec60d35 Découverte : le filtre par préférence s'applique avant la troncature du rail
En mode séries, le rail Nouveautés était vide : discover.latest() triait par
date de sortie Kitsu (catalogue animés uniquement) puis tronquait à 24 AVANT
le filtrage — les séries et films réels, absents de Kitsu (donc sans date),
étaient systématiquement évincés.

- allowed (types autorisés par préférence) descend dans discover.latest()
  et filtre dès la collecte des sources
- remplissage du rail équilibré par type (round-robin) : en mode « les deux »,
  8 animés / 8 séries / 8 films au lieu de 24 animés
- test de non-régression : 9 séries conservées face à 30 animés datés
2026-09-25 14:22:10 +00:00
Roman 79a44297fd Ajout de la source French-Stream (french-stream.lat) : séries et films
- Scraper DataLife Engine : recherche AJAX, fiches par saison, épisodes via
  ep-data.php (VF/VOSTFR), films via film_api.php, nouveautés /series/
- Config YAML externalisée (sélecteurs réparables sans coder)
- Domaines étendus : uqload.vc, vidzy.cc/.live
- 11 tests (126 au total)
2026-09-25 14:08:10 +00:00
Roman 89d9a98f80 Découverte : masquer Incontournables et Pour toi en mode séries
Ces deux sections sont issues de Kitsu (catalogue animés) — elles
n'ont pas de sens pour un compte en préférence « séries » et montraient
des animés malgré le filtre des nouveautés. Elles ne sont même plus
calculées (aucun appel Kitsu) et l'UI masque les sections vides.

+1 test (Kitsu simulé) — 115 au total.
2026-09-23 15:14:55 +00:00
Roman 28f1bbde31 Préférence de contenus par compte : animés / séries / les deux
- Colonne users.content_preference (anime|serie|both, défaut both) avec
  migration automatique des bases existantes.
- GET /auth/me expose la préférence ; PUT /auth/preferences la met à
  jour (422 si valeur invalide).
- /api/search : filtre les résultats par media_type ET ignore les
  sources hors périmètre (une source anime-only n'est pas interrogée
  en mode séries). Les films restent associés aux animés.
- /api/discover : nouveautés filtrées selon la même règle.
- Sélecteur compact dans la topbar (⛩/📺/✨), persisté serveur.
- 7 tests dédiés (roundtrip, rejet invalide, filtrage search/discover,
  skip de sources) — 114 au total, ruff clean.

Prête pour la première source de séries : il ne reste qu'un module
SourceScraper avec media_types=("serie",) à écrire.
2026-09-23 15:02:59 +00:00
Roman 65761885ba Corrections : test Torznab périmé et retries sur erreurs définitives
- test_torznab_search_requires_query attendait un 400 mais le commit
  853b4e0 a volontairement fait servir les nouveautés quand q est
  absent (RSS sync Sonarr) — le test valide désormais ce comportement.
- fetch() ne retente plus les 4xx définitifs (sauf 408/429) : un
  lecteur VoirAnime en échec 422 coûtait 2 requêtes et 3 s de pauses
  avant de basculer sur le lecteur suivant.
- 4 nouveaux tests unitaires du client HTTP (fail-fast + retries).
2026-09-23 12:42:24 +00:00
Roman 7399f4b781 Ajout de la source VoirAnime (voiranime.xyz)
- Scraper catalogue : recherche /catalogue?q=, fiches + épisodes
  (cartes S·E, multi-saisons), chaîne de lecteurs prepare/fallback
  avec dédoublonnage par id de source, latest via carrousels accueil.
- Extracteur hoster : endpoint prepare (AJAX) → MP4 direct décodé du
  payload (Referer d'origine) ou HLS relayé par le proxy du site
  (CDN signés pour leur backend → accès direct en 403).
- fetch() accepte désormais des en-têtes additionnels (X-Requested-With).
- 9 tests sur fixtures HTML réelles ; README mis à jour.
2026-09-23 10:51:29 +00:00
roman 5fedc4f9d7 Nommage Plex SxxExx des fichiers téléchargés
Le scanner Plex ignorait les épisodes 1-9 : le pattern « Saison 1 - E9 »
n'est reconnu qu'avec un numéro d'épisode à 2 chiffres. Les titres
« Série - Saison N - EX (VF) » sont désormais renommés en
« Série - S01E09 (VF) » à l'ajout dans la file (plex_filename).
2026-09-23 09:30:31 +00:00
roman 853b4e0866 Compatibilité Sonarr : flux RSS Torznab, catégories qBittorrent, save_path, TLS auto-signé
- torznab : tvsearch sans q sert les nouveautés (RSS sync + test Sonarr)
  au lieu d'une erreur 400 — ajoute TorznabService.latest_releases()
- qbit : POST /torrents/createCategory + GET /torrents/categories stateful
  (Sonarr crée sa catégorie tv-sonarr puis la revérifie)
- qbit : /app/preferences expose save_path=/downloads (health Sonarr)
- sonarr : verify=False sur httpx — reverse proxy swizzin en certificat
  auto-signé (usage LAN)
2026-09-22 18:25:03 +00:00
Roman 4e1faf4cd9 Ohm comme client de téléchargement Sonarr (API compatible qBittorrent)
- /api/v2/* : login SID (mot de passe = clé Torznab), app/version,
  torrents/info (progression temps réel), properties (content_path),
  add (rejoue le grab encodé dans le .torrent de service, dédupliqué
  par infohash SHA-1), delete (± fichiers), pause/resume
- Les grabs Sonarr sont marqués « sonarr:<hash>| » dans source_key →
  suivis de bout en bout : Sonarr importe, renomme et range les épisodes
  dans sa bibliothèque, puis retire le torrent de la file Ohm
- L'indexeur Torznab embarque les paramètres du grab dans l'announce
- README : nouveau mode « client de téléchargement » recommandé (Remote
  Path Mapping documenté), blackhole en variante minimale
- 3 nouveaux tests (flux complet add → suivi → import → delete)
2026-09-22 16:42:12 +00:00
Roman 1f326d34dd Installation guidée + intégration bibliothèque Plex/Sonarr
- scripts/install.sh : premier setup interactif (destination des épisodes,
  port, secrets auto-générés, montage, démarrage) — options --dir/--port/
  --skip-login pour une install non interactive
- Épisodes rangés par animé sur le disque : <Animé>/<Animé> - E12 (VOSTFR).mp4
  (meilleur parsing Plex, suppression du dossier quand il se vide)
- Entrypoint : chown récursif uniquement si le dossier de téléchargements est
  vide — une bibliothèque Plex/Sonarr existante n'est jamais modifiée
- Scan disque récursif (sous-dossiers par animé restaurés)
- README : installation guidée + section « Bibliothèque Plex / Sonarr »
2026-09-22 12:38:18 +00:00
Roman 9500a84a5e Téléchargements : suppression par épisode et regroupement par animé
- DELETE /api/downloads/{id} (?delete_file=true pour effacer aussi le fichier)
- Événement SSE « removed » → toutes les pages ouvertes se synchronisent
- Page Téléchargements groupée par animé : groupes repliables (dépliés si
  actifs), compteurs par statut, taille totale, tri par numéro d'épisode
- Actions par groupe : 🧹 retirer les terminés, ✖ annuler les actifs
- Limite de liste 200 → 2000 tâches
2026-09-22 12:17:27 +00:00
Roman affc97c527 Choix de la langue VF/VOSTFR sur la fiche titre
- Episode : nouveau champ version (vf/vostfr)
- French-Manga : épisodes listés pour chaque langue disponible
  (VOSTFR puis VF) au lieu d'un choix automatique, fragment
  #ep=<version>-<num> déjà résolu par extract_embed_links
- Vostfree : langue déduite du titre h1 ou du slug de la fiche
- UI : filtre VOSTFR/VF/Tout (défaut VOSTFR), badge par épisode,
  téléchargement de saison limité à la langue filtrée, compteur
  d'épisodes unique, version incluse dans le nom de fichier
2026-09-22 11:58:10 +00:00
Roman 9148b5fb6a Mise à jour automatique (Gitea + Watchtower) et conteneurisation Docker
- Détection de version via l'API Gitea, application via Watchtower
- Router système, UI admin (intégrations), bandeau de mise à jour
- Dockerfile multi-étapes, docker-compose, scripts/release.sh
- Version centralisée dans app/version.py (pyproject ou OHM_VERSION au build)
2026-09-22 11:58:03 +00:00
Roman 41566ab5fb v0.1.0 — Réécriture complète de OhmStreaming
Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree,
French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement
HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration,
découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
2026-09-22 10:05:47 +00:00
332 changed files with 13190 additions and 41714 deletions
+20
View File
@@ -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/
+27 -24
View File
@@ -1,31 +1,34 @@
# Ohm Stream Downloader Environment Configuration
# Copier en .env et adapter. Toutes les variables applicatives sont préfixées OHM_.
# Application
APP_NAME=Ohm Stream Downloader
APP_VERSION=2.2
DEBUG=false
# OBLIGATOIRE en production : clé de signature des tokens (32+ caractères)
OHM_SECRET_KEY=change-me-in-production
# Server Configuration
HOST=0.0.0.0
PORT=3000
RELOAD=true
# Chemins
# OHM_DATA_DIR=./data
# OHM_DOWNLOAD_DIR=./downloads
# OHM_DATABASE_PATH=./data/ohm.db
# Download Settings
DOWNLOAD_DIR=downloads
MAX_PARALLEL_DOWNLOADS=3
CHUNK_SIZE=1048576
# Téléchargements
# OHM_MAX_PARALLEL_DOWNLOADS=3
# CORS Origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://192.168.1.204:3000
# Scraping
# OHM_HTTP_TIMEOUT=20
# OHM_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
# Storage Paths
FAVORITES_STORAGE_PATH=favorites.json
SONARR_CONFIG_PATH=config/sonarr.json
SONARR_MAPPINGS_PATH=config/sonarr_mappings.json
# Métadonnées Kitsu
# OHM_METADATA_CACHE_TTL_HOURS=72
# API Timeouts
HTTP_TIMEOUT=10.0
DOWNLOAD_TIMEOUT=300
# Auth
# OHM_ACCESS_TOKEN_TTL_MINUTES=15
# OHM_REFRESH_TOKEN_TTL_DAYS=30
# Logging
LOG_LEVEL=INFO
# 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
-154
View File
@@ -1,154 +0,0 @@
# GitHub Actions CI Workflow for Ohm Streaming
# Runs tests, coverage, and quality checks on push and pull requests
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
# Cancel in-progress runs when a new workflow with the same group name starts
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Run pytest tests with coverage
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
python-version: ['3.11', '3.12']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'requirements.txt'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run pytest (exclude slow tests by default)
run: |
pytest -m "not slow" --cov=app --cov-report=term-missing --cov-report=html --no-cov-on-fail -v
- name: Upload coverage reports
uses: actions/upload-artifact@v4
if: success()
with:
name: coverage-report-${{ matrix.python-version }}
path: htmlcov/
retention-days: 30
- name: Upload test logs
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-logs-${{ matrix.python-version }}
path: |
.pytest_cache/
*.html
retention-days: 7
# Run linting with ruff
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install ruff
run: pip install ruff
- name: Run ruff
run: ruff check app/ --output-format=github
- name: Run ruff (format check)
run: ruff format --check app/
# Run type checking with mypy
type-check:
name: Type Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install mypy types-requests types-aiohttp types-python-jose
- name: Run mypy
run: |
mypy app/ --ignore-missing-imports --no-error-summary
# Summary job - runs after all other jobs
summary:
name: Summary
runs-on: ubuntu-latest
timeout-minutes: 2
needs: [test, lint, type-check]
steps:
- name: Create summary
run: |
echo "## CI Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Test results
echo "### Tests" >> $GITHUB_STEP_SUMMARY
if [ "${{ needs.test.result }}" = "success" ]; then
echo "✅ Tests passed for Python 3.11 and 3.12" >> $GITHUB_STEP_SUMMARY
else
echo "❌ Tests failed" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
# Lint results
echo "### Linting" >> $GITHUB_STEP_SUMMARY
if [ "${{ needs.lint.result }}" = "success" ]; then
echo "✅ Linting passed" >> $GITHUB_STEP_SUMMARY
else
echo "❌ Linting failed" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
# Type check results
echo "### Type Checking" >> $GITHUB_STEP_SUMMARY
if [ "${{ needs.type-check.result }}" = "success" ]; then
echo "✅ Type checking passed" >> $GITHUB_STEP_SUMMARY
else
echo "⚠️ Type checking had issues" >> $GITHUB_STEP_SUMMARY
fi
+4 -66
View File
@@ -1,71 +1,9 @@
# Python
.venv/
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
# FastAPI
uploads/
streams/
*.pyc
data/
downloads/
# Environment
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Testing
.pytest_cache/
.coverage
.coverage.*
htmlcov/
coverage.xml
*.cover
.hypothesis/
# Project data
data/
favorites.json
*.db
*.sqlite
ohm_streaming.db
# Config (runtime-generated)
config/*.json
config/domain_cache.json
!config/*.example.json
data/
favorites.json
*.db
*.sqlite
ohm_streaming.db
# Node
node_modules/
package-lock.json.tmp
playwright-report/
test-results/
# Agent/Tool specific
.serena/
.sisyphus/
.claude/
.opencode/
.mypy_cache/
.ruff_cache/
.plasma/
-9
View File
@@ -1,9 +0,0 @@
{
"active_plan": "/opt/Ohm_streaming/.sisyphus/plans/cors-fix.md",
"started_at": "2026-03-18T13:17:43.401Z",
"session_ids": [
"ses_3388359e2ffe5brQanNc9Qb8FL"
],
"plan_name": "cors-fix",
"agent": "atlas"
}
@@ -1,36 +0,0 @@
# Draft: Anime-Sama Player Fallback System
## Requirements
- **Mode**: Automatique - essayer tous les players jusqu'à en trouver un qui fonctionne
- **Success Criterion**: Test téléchargement (télécharger un petit chunk pour vérifier)
- **Workflow**: Si le player détecté échoue, essayer VidMoly, SendVid, Sibnet, etc. automatiquement
## Technical Decisions
### Player Priority Order (for Anime-Sama fallback)
1. VidMoly - most reliable
2. SendVid - second most reliable
3. Sibnet - third
4. Lpayer - last (requires Playwright, slower)
### Success Detection
- Download first 10KB of the video
- If successful (200 OK, valid data), consider player working
- Cache which player works for future episodes
### Implementation Approach
1. Add `get_download_link_with_fallback()` method in `AnimeSamaDownloader`
2. Test each player by downloading first 10KB
3. Use first player that returns valid data
4. Cache working player per anime URL/series
## Scope
- INCLUDE: Anime-Sama downloader with automatic player fallback
- INCLUDE: Video URL validation via chunk download test
- INCLUDE: Player caching for performance
- EXCLUDE: Frontend UI changes (backend only)
- EXCLUDE: Other anime sites (Anime-Sama only for now)
## Files to Modify
- `app/downloaders/anime_sites/animesama.py` - Add fallback logic
- `app/downloaders/base.py` - May need base helper method
Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

-1
View File
@@ -1 +0,0 @@
364: window.watchlistTabLoaded = false;
-16
View File
@@ -1,16 +0,0 @@
# Evidence: Task 1 - Timeout URL Test
## Scenario: Invalid video URL times out
**Tool**: Python3
**Preconditions**: URL that times out (httpbin.org/delay/20)
**Steps**:
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://httpbin.org/delay/20'); print(f'Result: {result}')"
**Expected Result**: Returns False (timeout)
**Actual Result**:
Video URL validation FAILED: Timeout for https://httpbin.org/delay/20...
Result for timeout URL: False
**Status**: PASS
-15
View File
@@ -1,15 +0,0 @@
# Evidence: Task 1 - Valid URL Test
## Scenario: Valid video URL returns 200 OK
**Tool**: Python3
**Preconditions**: URL that returns HTTP 200
**Steps**:
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://www.google.com/'); print(f'Result: {result}')"
**Expected Result**: Returns True
**Actual Result**:
Result for google.com: True
**Status**: PASS
-16
View File
@@ -1,16 +0,0 @@
# Evidence: Task 2 - All Players Fail
## Scenario: All players fail
**Tool**: Python3
**Preconditions**: Mock all extractions to fail
**Steps**:
1. Mock all _extract_from_* methods to raise Exception
2. Call get_download_link_with_fallback()
**Expected Result**: Raises exception "All video players failed"
**Actual Result**:
Exception raised: All players failed. Last error: Player failed
**Status**: PASS
@@ -1,42 +0,0 @@
# CSS Class Conflicts Check Results
## Check 4: filter-tab class in style.css
No matches found for "filter-tab" in static/css/style.css
However, filter-tab IS defined in watchlist.html inline styles:
/opt/Ohm_streaming/templates/watchlist.html
123: .filter-tabs {
130: .filter-tab {
140: .filter-tab:hover {
144: .filter-tab.active {
257: <div class="filter-tabs">
258: <button class="filter-tab active" onclick="filterWatchlist('all', this)">Tous</button>
259: <button class="filter-tab" onclick="filterWatchlist('active', this)">Actifs</button>
260: <button class="filter-tab" onclick="filterWatchlist('paused', this)">En pause</button>
261: <button class="filter-tab" onclick="filterWatchlist('completed', this)">Terminés</button>
363: document.querySelectorAll('.filter-tab').forEach(tab => {
## Check 5: .tab class in style.css
Found 2 matches in static/css/style.css
151: .tab {
733: .tab {
## Tab Class Usage Across Templates:
- login.html: auth-tabs, auth-tab
- watchlist.html: .tab (navigation), .filter-tabs, .filter-tab
- components/header.html: .tab (navigation tabs)
## Potential CSS Conflict Analysis:
1. filter-tab: Defined inline in watchlist.html, NOT in style.css
- Risk: LOW (isolated to watchlist page)
2. .tab: Defined in style.css at lines 151 and 733
- Used in multiple templates for navigation tabs
- .filter-tab is DIFFERENT from .tab
- Risk: LOW (.tab and .filter-tab are distinct classes)
## Conclusion:
NO CSS CLASS CONFLICTS DETECTED
- filter-tab is isolated to watchlist.html (inline CSS)
- .tab class in style.css is for main navigation tabs
- .filter-tab is a separate, distinct class for watchlist filtering
@@ -1,32 +0,0 @@
# DOM ID Conflicts Check Results
## Check 1: watchlistContainer & schedulerStatus
Found 2 matches in 1 file(s):
/opt/Ohm_streaming/templates/watchlist.html
233: <div class="scheduler-status" id="schedulerStatus">
265: <div id="watchlistContainer">
## Check 2: settingsModal & nextRunInfo
Found 2 matches in 1 file(s):
/opt/Ohm_streaming/templates/watchlist.html
237: <div id="nextRunInfo" class="next-run-info">Chargement...</div>
422: <div id="settingsModal" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000;">
## Check 3: startSchedulerBtn & stopSchedulerBtn
Found 2 matches in 1 file(s):
/opt/Ohm_streaming/templates/watchlist.html
240: <button id="startSchedulerBtn" class="btn-primary btn-small" onclick="handleStartScheduler()" style="display:none;">
243: <button id="stopSchedulerBtn" class="btn-secondary btn-small" onclick="handleStopScheduler()" style="display:none;">
## Conflict Analysis:
All IDs are unique to watchlist.html only. No conflicts found with other templates.
Checked templates:
- login.html (auth-tabs, auth-tab)
- index.html (tab-anime, tab-series, tab-providers)
- components/header.html (mainTabs, tab-home, tab-anime, tab-series, etc.)
- components/home_section.html (tab-home)
- watchlist.html (these IDs are local to this file)
## Conclusion:
NO DOM ID CONFLICTS DETECTED
-19
View File
@@ -1,19 +0,0 @@
# Evidence: Task 2 - First Player Works
## Scenario: First player (VidMoly) works
**Tool**: Python3
**Preconditions**: Mock VidMoly URL that passes validation
**Steps**:
1. Mock _test_video_url to return True
2. Mock _extract_from_vidmoly to return valid URL
3. Call get_download_link_with_fallback()
**Expected Result**: Returns VidMoly URL, logs "VidMoly player succeeded"
**Actual Result**:
Video URL: https://vidmoly.to/video.mp4
Filename: vidmoly_video.mp4
Used player: VidMoly
**Status**: PASS
@@ -1,19 +0,0 @@
# Evidence: Task 2 - Second Player Works
## Scenario: First player fails, second works
**Tool**: Python3
**Preconditions**: Mock VidMoly to fail, SendVid to succeed
**Steps**:
1. Mock _extract_from_vidmoly to raise Exception
2. Mock _extract_from_sendvid to return valid URL
3. Mock _test_video_url to return True
4. Call get_download_link_with_fallback()
**Expected Result**: Returns SendVid URL (VidMoly failed, SendVid succeeded)
**Actual Result**:
Video URL: https://sendvid.com/video.mp4
Used player: SendVid
**Status**: PASS
-17
View File
@@ -1,17 +0,0 @@
# Evidence: Task 3 - Direct URL Skips Fallback
## Scenario: Direct video URL skips fallback
**Tool**: Python3
**Preconditions**: Anime-Sama downloader with fallback method
**Steps**:
1. Mock get_download_link_with_fallback
2. Call get_download_link() with direct URL (no pipe)
**Expected Result**: Fallback method is NOT called (False) - direct extraction used
**Actual Result**:
Fallback called: False
Result: ('https://direct.mp4', 'direct.mp4')
**Status**: PASS
-16
View File
@@ -1,16 +0,0 @@
# Evidence: Task 3 - Pipe URL Triggers Fallback
## Scenario: Pipe URL triggers fallback
**Tool**: Python3
**Preconditions**: Anime-Sama downloader with fallback method
**Steps**:
1. Mock get_download_link_with_fallback
2. Call get_download_link() with pipe URL
**Expected Result**: Fallback method is called (True)
**Actual Result**:
Fallback called: True
**Status**: PASS
-93
View File
@@ -1,93 +0,0 @@
# JavaScript Duplication Audit Report
**Generated:** 2026-02-26
**Scope:** static/js/**/*.js (13 files)
**Files Audited:** api.js, utils.js, auth.js, main.js, tabs.js, anime.js, series-search.js, downloads.js, watchlist/main.js, anime-details.js, recommendations.js, watchlist.js, watchlist-ui.js
---
## CRITICAL DUPLICATIONS (Potential Syntax Errors)
### 1. translateStatus() Function - DUPLICATED DEFINITION
- **File 1:** `static/js/utils.js:35` - Primary definition
- **File 2:** `static/js/anime-details.js:428` - Duplicate definition
**Impact:** HIGH - If both files are loaded, the second definition will overwrite the first, causing unpredictable behavior. The utils.js version is used by downloads.js and recommendations.js, while anime-details.js has its own localized version.
**Recommendation:** Remove duplicate in anime-details.js and ensure anime-details.js imports from utils.js
---
## MINOR DUPLICATIONS (Non-Breaking)
### 2. Redundant const Declarations in Same Function Scope (Different Functions)
#### auth.js - Duplicate variable declarations across functions
- `mainContent` declared at line 70 and line 76 (in different functions showMainContent/hideMainContent)
- `userInfo` declared at line 57 and line 82 (in showUserInfo/showLoginPrompt)
- `loginPrompt` declared at line 58 and line 83
- `mainTabs` declared at line 59 and line 84
**Impact:** LOW - These are in different function scopes, not causing syntax errors but creating redundant code
#### recommendations.js - Duplicate variable names in different functions
- `container` declared at lines 5, 54, 105 (in different functions)
- `section` declared at lines 6, 55 (in different functions)
**Impact:** LOW - Different function scopes
#### tabs.js - Duplicate container variable
- `container` declared at lines 115, 152, 160, 178, 186, 235, 252, 329
**Impact:** LOW - Different function scopes
#### anime.js - Duplicate variable names across functions
- `selectElement` declared at lines 156, 245, 253, 261, 307, 352
- `seasonSelectElement` declared at lines 156, 245
- `actionsDiv` declared at lines 287, 325
**Impact:** LOW - Different function scopes
---
## PATTERN OBSERVATIONS
### Utility Functions Shared Across Files
The following functions are defined once but used across multiple files:
- `escapeHtml()` - Defined in utils.js:26, used in 8 files
- `translateStatus()` - DEFINED TWICE (CRITICAL ISSUE)
- `formatBytes()` - Defined in utils.js
- `formatSpeed()` - Defined in utils.js
- `extractSeriesName()` - Defined in utils.js
- `getDayString()` - Defined in utils.js
### Cross-File Function Usage
- `renderReleaseCard()` - Defined in recommendations.js:195, called in tabs.js:171
- `renderAnimeCard()` - Defined in anime.js:58, called in anime-details.js
- `loadDownloads()` - Defined in downloads.js, called from multiple files
---
## SUMMARY
| Severity | Count | Issue |
|----------|-------|-------|
| CRITICAL | 1 | translateStatus() defined twice (utils.js + anime-details.js) |
| MINOR | 4+ | Redundant const declarations across functions (auth.js) |
| MINOR | 3+ | Duplicate container/section variables (recommendations.js, tabs.js, anime.js) |
---
## RECOMMENDATIONS
1. **FIX CRITICAL:** Remove duplicate `translateStatus()` from anime-details.js and use the version from utils.js
2. **Consider:** Consolidating utility functions into a single utils module that all files import
3. **Future Cleanup:** Review auth.js for redundant variable declarations (minor optimization)
---
## VERIFICATION
Audit completed: 13 JavaScript files scanned
Duplicate function definitions: 1 CRITICAL
Redundant const declarations: Multiple (non-critical)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

-111
View File
@@ -1,111 +0,0 @@
# Task 5: Watchlist API Structure Documentation
## Base URL
`http://localhost:3000/api/watchlist`
## Available Endpoints
### 1. GET /api/watchlist
- **Description**: List all watchlist items for current user
- **Auth**: Required (JWT Bearer token)
- **Query Params**:
- `status` (optional): Filter by status (active, paused, completed, archived)
- **Response 200**: `{"watchlist": [], "total": 0, "filters": {"status": null}}`
- **Response 403**: `{"detail": "Not authenticated"}`
### 2. POST /api/watchlist
- **Description**: Add a new anime to the watchlist
- **Auth**: Required
- **Body**:
```json
{
"anime_title": "string",
"anime_url": "string",
"provider_id": "string",
"lang": "vostfr",
"auto_download": true,
"quality_preference": "auto",
"poster_image": "string (optional)",
"cover_image": "string (optional)",
"synopsis": "string (optional)",
"genres": ["string"] (optional)
}
```
- **Response**: `{"status": "added", "item": {...}}`
### 3. GET /api/watchlist/{item_id}
- **Description**: Get details of a specific watchlist item
- **Auth**: Required
- **Response 200**: `{"item": {...}}`
- **Response 404**: `{"detail": "Watchlist item not found"}`
- **Response 403**: `{"detail": "Access denied"}`
### 4. PUT /api/watchlist/{item_id}
- **Description**: Update a watchlist item
- **Auth**: Required
- **Response**: `{"status": "updated", "item": {...}}`
### 5. DELETE /api/watchlist/{item_id}
- **Description**: Remove an anime from the watchlist
- **Auth**: Required
- **Response**: `{"status": "deleted", "item_id": "string"}`
### 6. GET /api/watchlist/{item_id}/episodes
- **Description**: Get all downloaded episodes for a watchlist item
- **Auth**: Required
### 7. POST /api/watchlist/{item_id}/download/{episode}
- **Description**: Download a specific episode
- **Auth**: Required
- **Response**: `{"status": "downloading", "task_id": "string", "episode": int, "item_id": "string"}`
### 8. GET /api/watchlist/stats ⚠️ BUG
- **Description**: Get watchlist statistics
- **Auth**: Required
- **Expected Response**:
```json
{
"total": 0,
"active": 0,
"paused": 0,
"completed": 0,
"archived": 0,
"auto_download_enabled": 0,
"total_episodes_downloaded": 0,
"providers": {}
}
```
- **Actual Response**: 404 "Watchlist item not found" (ROUTING BUG)
### 9. GET /api/watchlist/settings ⚠️ BUG
- **Description**: Get watchlist settings
- **Auth**: Required
- **Expected Response**: `{"settings": {...}}`
- **Actual Response**: 404 "Watchlist item not found" (ROUTING BUG)
### 10. GET /api/watchlist/notifications ⚠️ BUG
- **Description**: Get user notifications
- **Auth**: Required
- **Query Params**: `unread_only` (bool)
- **Expected Response**: `{"notifications": [], "total": 0, "unread_only": false}`
- **Actual Response**: 404 "Watchlist item not found" (ROUTING BUG)
### 11. PUT /api/watchlist/notifications/{notification_id}/read
- **Description**: Mark a notification as read
- **Auth**: Required
### 12. PUT /api/watchlist/settings
- **Description**: Update watchlist settings
- **Auth**: Required
## Authentication
- Uses JWT Bearer tokens
- Token obtained from POST /api/auth/login
- Pass as: `Authorization: Bearer <token>`
## Bug Summary
- **Issue**: 3 endpoints return 404 instead of correct responses
- **Affected**: /stats, /settings, /notifications
- **Cause**: Route ordering - `/{item_id}` catch-all defined before these specific routes
- **Location**: app/routes/watchlist.py
- **Fix needed**: Move specific routes BEFORE the `/{item_id}` route
@@ -1,19 +0,0 @@
# Evidence: Task 5 - Integration Test with Real Anime-Sama URL
## Scenario: Download Frieren S1 E1 with fallback
**Tool**: curl + API
**Preconditions**: Server running, fallback implemented
**Steps**:
1. Get episodes from anime-sama.tv
2. Download episode via API
**Expected Result**: Download completes successfully
**Actual Result**:
- Download status: COMPLETED
- File size: 321MB
- File: downloads/Frieren - S1 - Episode 01.mp4
- Logs show: Using SendVid for extraction (fallback working)
**Status**: PASS
@@ -1,41 +0,0 @@
# Task 5: GET /api/watchlist Test Results
## Test Date: 2026-02-26
## Server Status
- Server running on port 3000: ✓
- Health check: ✓ PASS
## Authentication Test
- Unauthenticated request to /api/watchlist:
- HTTP Status: 403
- Response: {"detail":"Not authenticated"}
- Authenticated request to /api/watchlist:
- HTTP Status: 200
- Response: {"watchlist":[],"total":0,"filters":{"status":null}}
## Endpoints Tested
| Endpoint | Auth | Expected Status | Actual Status | Result |
|----------|------|-----------------|---------------|--------|
| GET /api/watchlist | No | 401/403 | 403 | ✓ PASS |
| GET /api/watchlist | Yes | 200 | 200 | ✓ PASS |
| GET /api/watchlist/stats | Yes | 200 | 404 | ✗ FAIL (BUG) |
| GET /api/watchlist/settings | Yes | 200 | 404 | ✗ FAIL (BUG) |
| GET /api/watchlist/notifications | Yes | 200 | 404 | ✗ FAIL (BUG) |
## Issue Found
The following endpoints return 404 "Watchlist item not found" when they should work:
- /api/watchlist/stats
- /api/watchlist/settings
- /api/watchlist/notifications
**Root Cause**: Route ordering issue in `app/routes/watchlist.py`
- The `/{item_id}` catch-all route (line 134) is defined BEFORE the specific routes like `/stats` (line 372), `/settings` (line 335), and `/notifications` (line 285)
- FastAPI matches these paths as item IDs instead of the intended routes
## Test User
- Username: watchlist_test
- Token: JWT (7-day expiry)
-14
View File
@@ -1,14 +0,0 @@
Watchlist Integration Test Results
============================================================
[PASS] Navigate to /watchlist
[PASS] Watchlist tab highlighted
[PASS] Header/nav present
[PASS] Scheduler panel displays
[PASS] Filter tabs present and clickable
[PASS] Settings modal works
[PASS] Refresh mechanism present
[PASS] Tab switching works
[PASS] /web#watchlist loads watchlist
[PASS] /watchlist page has content
============================================================
Total: 10/10 tests passed
Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

@@ -1,98 +0,0 @@
## 2026-02-25 Task 1: Add video URL validation helper
**Task**: Add `_test_video_url()` method to AnimeSamaDownloader
**What was implemented**:
- Method `_test_video_url(url: str) -> bool` added to end of AnimeSamaDownloader class
- Downloads first 10KB using HTTP Range header (`bytes=0-10240`)
- 10 second timeout handling
- Returns True if HTTP 200 and data > 0 bytes
- Returns False on timeout, connection error, or empty response
- Logs all validation results
**Issues encountered**:
- Subagent created duplicate imports and modified unrelated files
- Had to revert changes to other files
- Had to fix duplicate logger line
- Had to revert unintended get_download_link signature change
**Verification**:
- Valid URL (google.com): Returns True ✓
- Timeout URL (httpbin.org/delay/20): Returns False ✓
- Method exists: True ✓
---
## 2026-02-25 Task 2: Implement player fallback logic
**Task**: Add `get_download_link_with_fallback()` method with player priority list
**What was implemented**:
- Added `__init__` method with cache initialization: `self._working_players = {}`
- Added `get_download_link_with_fallback()` method with:
- Player priority list: ['vidmoly', 'sendvid', 'sibnet', 'lpayer']
- Tries each player in order
- Validates each URL with _test_video_url()
- Caches working player per anime URL
- Logs each player attempt (success/failure)
- Returns (video_url, filename) on first success
- Raises exception if all players fail
**Verification**:
- First player works: VidMoly URL returned ✓
- First fails, second works: SendVid URL returned ✓
- All fail: Exception raised ✓
---
## 2026-02-25 Task 3: Integrate fallback into get_download_link()
**Task**: Update `get_download_link()` to use fallback for pipe-separated URLs
**What was implemented**:
- Modified `get_download_link()` to call `get_download_link_with_fallback()` for pipe-separated URLs
- Direct URLs (no pipe) still use existing extraction flow for performance
- Backward compatibility maintained
- Fixed target_filename parameter to match download_manager expectations
**Verification**:
- Pipe URL triggers fallback: True ✓
- Direct URL skips fallback: True ✓
---
## 2026-02-25 Task 4: Add unit tests
**Task**: Create unit tests for fallback logic
**What was implemented**:
- Created `tests/test_anime_sama_fallback.py` with 10 tests:
1. test_fallback_tries_players_in_priority_order
2. test_caching_mechanism_stores_working_player
3. test_all_players_failing_raises_exception
4. test_test_video_url_returns_true_for_valid_url
5. test_test_video_url_returns_false_for_invalid_url
6. test_test_video_url_returns_false_for_empty_response
7. test_test_video_url_returns_false_for_timeout
8. test_test_video_url_returns_false_for_connection_error
9. test_fallback_skips_invalid_player_url
10. test_cache_not_used_without_anime_page_url
**Verification**:
- All 10 tests pass: ✓
---
## 2026-02-25 Task 5: Integration testing
**Task**: Test with real Anime-Sama URLs
**What was implemented**:
- Downloaded Frieren S1 E1 from anime-sama.tv
- Used pipe-separated URL format
- Download completed successfully
**Verification**:
- Download status: COMPLETED ✓
- File size: 321MB ✓
- Fallback logic working (SendVid used) ✓
@@ -1,650 +0,0 @@
# Anime-Sama Player Fallback System
## TL;DR
> **Quick Summary**: Implement automatic player fallback for Anime-Sama downloads to handle cases where the detected player fails
>
> **Deliverables**:
> - `get_download_link_with_fallback()` method in AnimeSamaDownloader
> - Player success validation via chunk download test
> - Player caching for performance optimization
>
> **Estimated Effort**: Medium
> **Parallel Execution**: NO - sequential implementation
> **Critical Path**: Test implementation → AnimeSamaDownloader update → Integration testing
---
## Context
### Original Request
User requested a new feature for Anime-Sama provider: ability to change video player on the site. When a player (like Lpayer) fails, the downloader should automatically test different players until finding one that works.
### Interview Summary
**Key Decisions**:
- Mode: Automatic - if Lpayer fails, try VidMoly, SendVid, Sibnet, etc. automatically
- Success Criterion: Download test (download first 10KB chunk to verify URL works)
- Priority Order: VidMoly → SendVid → Sibnet → Lpayer
**Technical Requirements**:
- Test video URL by downloading small chunk (10KB)
- If successful, consider player working
- Cache working player per anime/series for future episodes
- Automatic retry without user intervention
---
## Work Objectives
### Core Objective
Implement automatic player fallback in Anime-Sama downloader to handle failed extractions by trying alternative players sequentially.
### Concrete Deliverables
- `AnimeSamaDownloader.get_download_link_with_fallback()` - Main fallback method
- `_test_video_url()` - Helper to validate video URL by downloading chunk
- Player priority list with caching mechanism
- Updated `get_download_link()` to use fallback by default
### Definition of Done
- [ ] Fallback method tries players in priority order
- [ ] Video URL validated before returning (10KB download test)
- [ ] Working player cached per anime for performance
- [ ] All existing Anime-Sama functionality preserved
### Must Have
- Players tested sequentially: VidMoly → SendVid → Sibnet → Lpayer
- Success detection via HTTP 200 + valid data download (10KB chunk)
- Cache mechanism to avoid re-testing for same anime
- Automatic integration with existing download flow
### Must NOT Have (Guardrails)
- NO frontend changes required (backend-only implementation)
- NO manual player selection via API (automatic only)
- NO changes to other anime sites (Anime-Sama only)
- NO breaking changes to existing Anime-Sama functionality
---
## Verification Strategy (MANDATORY)
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
### Test Decision
- **Infrastructure exists**: YES
- **Automated tests**: Tests-after (unit tests for fallback logic)
- **Framework**: pytest
### QA Policy
Every task MUST include agent-executed QA scenarios (see TODO template below).
- **Unit Tests**: pytest with mocked HTTP clients
- **Integration Tests**: Test with real Anime-Sama URLs
- **Edge Cases**: All players failing, first player working, cache invalidation
---
## Execution Strategy
### Sequential Implementation
Since this is a focused feature on a single file, implementation will be sequential:
```
Step 1: Add URL validation helper (can test independently)
→ _test_video_url(url) method
Step 2: Implement fallback logic
→ get_download_link_with_fallback() method
Step 3: Integrate with existing flow
→ Update get_download_link() to use fallback
Step 4: Add unit tests
→ Test fallback logic and URL validation
Step 5: Integration testing
→ Test with real Anime-Sama URLs (Frieren S2 E1)
```
### Dependency Matrix
- **1**: — 2
- **2**: — 3
- **3**: — 4, 5
- **4**: — 5
- **5**: Final
### Agent Dispatch Summary
- **1**: `quick` - Helper method
- **2**: `quick` - Main fallback logic
- **3**: `quick` - Integration
- **4**: `quick` - Unit tests
- **5**: `quick` - Integration testing
---
## TODOs
- [x] 1. Add video URL validation helper method
**What to do**:
- Add `_test_video_url(url: str) -> bool` method to AnimeSamaDownloader
- Download first 10KB of video using self.client
- Return True if HTTP 200 and valid data received, False otherwise
- Include timeout handling (10 seconds for 10KB)
- Log validation results for debugging
**Must NOT do**:
- Download entire video
- Change existing player extraction logic
**Recommended Agent Profile**:
> **Category**: `quick`
- Reason: Simple helper method, focused task
- **Skills**: None needed
- **Skills Evaluated but Omitted**:
- No additional skills needed
**Parallelization**:
- **Can Run In Parallel**: NO - Sequential
- **Parallel Group**: Sequential
- **Blocks**: Task 2
- **Blocked By**: None
**References** (CRITICAL):
> The executor has NO context from your interview. References are their ONLY guide.
> Each reference must answer: "What should I look at and WHY?"
**Pattern References** (existing code to follow):
- `app/downloaders/anime_sites/animesama.py:120-150` - Existing video URL extraction methods
- `app/downloaders/anime_sites/animesama.py:402-445` - Existing Lplayer extraction pattern
**API/Type References** (contracts to implement against):
- `httpx.AsyncClient.stream()` - For downloading chunks efficiently
**External References** (libraries and frameworks):
- httpx docs: `https://www.python-httpx.org/advanced/#streaming-responses` - Chunked downloads
**WHY Each Reference Matters**:
- Existing extraction methods show how video URLs are currently handled
- httpx streaming allows efficient chunk download without loading full video
**Acceptance Criteria**:
> **AGENT-EXECUTABLE VERIFICATION ONLY** — No human action permitted.
> Every criterion MUST be verifiable by running a command or using a tool.
- [ ] `_test_video_url()` method added to AnimeSamaDownloader
- [ ] Downloads first 10KB chunk with 10s timeout
- [ ] Returns True if HTTP 200 and data > 0 bytes
- [ ] Returns False if timeout, error, or empty response
- [ ] Logs validation results (success/failure)
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
```
Scenario: Valid video URL returns 200 OK
Tool: Bash (python3)
Preconditions: Mock a video URL that returns 200 with data
Steps:
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://example.com/video.mp4'); print(f'Result: {result}')"
Expected Result: Returns True
Evidence: .sisyphus/evidence/task-1-valid-url.txt
Scenario: Invalid video URL times out
Tool: Bash (python3)
Preconditions: Mock a video URL that times out
Steps:
1. python3 -c "from app.downloaders.anime_sites.animesama import AnimeSamaDownloader; d = AnimeSamaDownloader(); result = d._test_video_url('https://httpbin.org/delay/20'); print(f'Result: {result}')"
Expected Result: Returns False (timeout)
Evidence: .sisyphus/evidence/task-1-timeout-url.txt
```
**Evidence to Capture**:
- [ ] Each evidence file named: task-{N}-{scenario-slug}.txt
- [ ] Contains test results with True/False output
**Commit**: YES
- Message: `feat(anime-sama): add video URL validation helper method`
- Files: `app/downloaders/anime_sites/animesama.py`
---
- [x] 2. Implement player fallback logic with priority list
**What to do**:
- Add `get_download_link_with_fallback(url, target_filename=None, anime_page_url=None, episode_title=None)` method
- Define player priority list: ['vidmoly', 'sendvid', 'sibnet', 'lpayer']
- For each player in priority order:
- Try existing extraction methods (_extract_from_vidmoly, etc.)
- If extraction succeeds, validate URL with _test_video_url()
- If validation succeeds, return (video_url, filename)
- Add player caching: `self._working_players = {}` dict to cache working player per anime URL
- If cached player exists for anime, try it first
- Log each attempted player with success/failure
**Must NOT do**:
- Modify existing _extract_from_* methods
- Break existing Anime-Sama download flow
**Recommended Agent Profile**:
> **Category**: `quick`
- Reason: Sequential logic implementation, clear requirements
- **Skills**: None needed
- **Skills Evaluated but Omitted**:
- No additional skills needed
**Parallelization**:
- **Can Run In Parallel**: NO - Depends on Task 1
- **Parallel Group**: Sequential
- **Blocks**: Task 3
- **Blocked By**: Task 1
**References** (CRITICAL):
**Pattern References** (existing code to follow):
- `app/downloaders/anime_sites/animesama.py:95-170` - VidMoly extraction pattern
- `app/downloaders/anime_sites/animesama.py:280-320` - SendVid extraction pattern
- `app/downloaders/anime_sites/animesama.py:250-280` - Sibnet extraction pattern
- `app/downloaders/anime_sites/animesama.py:402-445` - Lpayer extraction pattern
- `app/downloaders/anime_sites/animesama.py:117-120` - Player detection logic
**WHY Each Reference Matters**:
- Existing extraction methods show the interface each player uses
- Player detection logic shows how to identify which player URL to extract
- Need to understand the signature of each extraction method
**Acceptance Criteria**:
- [ ] `get_download_link_with_fallback()` method added
- [ ] Player priority list defined: vidmoly → sendvid → sibnet → lpayer
- [ ] Tries each player in order if previous fails
- [ ] Validates video URL with _test_video_url() before returning
- [ ] Caches working player per anime_page_url
- [ ] Logs each player attempt (success/failure)
- [ ] Returns (video_url, filename) on first success
- [ ] Raises exception if all players fail
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
```
Scenario: First player (VidMoly) works
Tool: Bash (python3)
Preconditions: Mock VidMoly URL that passes validation
Steps:
1. python3 -c "
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
d = AnimeSamaDownloader()
# Mock _test_video_url to return True
original_test = d._test_video_url
d._test_video_url = lambda url: True
# Call fallback
video_url, filename = d.get_download_link_with_fallback('test_url|anime_page|Ep1', episode_title='Episode 1')
print(f'Video URL: {video_url[:50] if video_url else None}')
print(f'Filename: {filename}')
"
Expected Result: Returns VidMoly URL, logs "VidMoly player succeeded"
Evidence: .sisyphus/evidence/task-2-first-works.txt
Scenario: First player fails, second works
Tool: Bash (python3)
Preconditions: Mock VidMoly to fail, SendVid to succeed
Steps:
1. python3 -c "
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
d = AnimeSamaDownloader()
call_count = [0]
def mock_extract(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1: # VidMoly call
raise Exception('VidMoly failed')
elif call_count[0] == 2: # SendVid call
return ('https://sendvid.com/video.mp4', 'sendvid_video.mp4')
# Mock extraction methods
d._extract_from_vidmoly = lambda *a, **kw: mock_extract(*a, **kw)
d._extract_from_sendvid = lambda *a, **kw: mock_extract(*a, **kw)
d._test_video_url = lambda url: True
# Call fallback
video_url, filename = d.get_download_link_with_fallback('test_url|anime_page|Ep1')
print(f'Video URL: {video_url}')
print(f'Used player: {\"SendVid\" if \"sendvid\" in video_url else \"Unknown\"}')
"
Expected Result: Returns SendVid URL (VidMoly failed, SendVid succeeded)
Evidence: .sisyphus/evidence/task-2-second-works.txt
Scenario: All players fail
Tool: Bash (python3)
Preconditions: Mock all extractions to fail
Steps:
1. python3 -c "
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
d = AnimeSamaDownloader()
def mock_fail(*args, **kwargs):
raise Exception('Player failed')
# Mock all extraction methods
d._extract_from_vidmoly = mock_fail
d._extract_from_sendvid = mock_fail
d._extract_from_sibnet = mock_fail
d._extract_from_lpayer = mock_fail
# Call fallback
try:
video_url, filename = d.get_download_link_with_fallback('test_url|anime_page|Ep1')
print('ERROR: Should have raised exception')
except Exception as e:
print(f'Exception raised: {e}')
"
Expected Result: Raises exception "All video players failed"
Evidence: .sisyphus/evidence/task-2-all-fail.txt
```
**Evidence to Capture**:
- [ ] Each evidence file contains video URL and logs output
- [ ] Test confirms fallback logic works correctly
**Commit**: YES
- Message: `feat(anime-sama): add player fallback logic with priority retry`
- Files: `app/downloaders/anime_sites/animesama.py`
---
- [x] 3. Integrate fallback into existing get_download_link() method
**What to do**:
- Update `get_download_link()` to use `get_download_link_with_fallback()` by default
- Maintain backward compatibility: if direct video URL detected, skip fallback
- Pass anime_page_url and episode_title from pipe-separated URL format
- Keep existing player detection and direct extraction flow for simple cases
**Must NOT do**:
- Remove existing extraction methods
- Change existing player detection logic
**Recommended Agent Profile**:
> **Category**: `quick`
- Reason: Integration task, minimal changes
- **Skills**: None needed
- **Skills Evaluated but Omitted**:
- No additional skills needed
**Parallelization**:
- **Can Run In Parallel**: NO - Depends on Task 2
- **Parallel Group**: Sequential
- **Blocks**: Task 4, 5
- **Blocked By**: Task 2
**References** (CRITICAL):
**Pattern References** (existing code to follow):
- `app/downloaders/anime_sites/animesama.py:93-120` - Current get_download_link implementation
**WHY Each Reference Matters**:
- Need to understand current logic to integrate fallback without breaking it
- Player detection and pipe URL parsing must be preserved
**Acceptance Criteria**:
- [ ] `get_download_link()` calls `get_download_link_with_fallback()` for complex URLs
- [ ] Direct video URLs (no pipe format) skip fallback (performance)
- [ ] Pipe-separated URLs trigger fallback with anime_page_url and episode_title
- [ ] Existing Anime-Sama functionality preserved (VidMoly, SendVid, Sibnet, Lpayer)
- [ ] Backward compatible with existing download flow
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
```
Scenario: Pipe URL triggers fallback
Tool: Bash (python3)
Preconditions: Anime-Sama downloader with fallback method
Steps:
1. python3 -c "
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
d = AnimeSamaDownloader()
# Mock to test that fallback is called
fallback_called = [False]
original_fallback = d.get_download_link_with_fallback
def mock_fallback(*args, **kwargs):
fallback_called[0] = True
return original_fallback(*args, **kw)
d.get_download_link_with_fallback = mock_fallback
# Call with pipe URL
d.get_download_link('https://vidmoly.to/vid|https://anime-sama.si/cat/naruto/s1|Episode+1')
print(f'Fallback called: {fallback_called[0]}')
"
Expected Result: Fallback method is called (True)
Evidence: .sisyphus/evidence/task-3-pipe-url.txt
Scenario: Direct video URL skips fallback
Tool: Bash (python3)
Preconditions: Anime-Sama downloader with fallback method
Steps:
1. python3 -c "
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
d = AnimeSamaDownloader()
# Mock to test that fallback is NOT called
fallback_called = [False]
def mock_fallback(*args, **kwargs):
fallback_called[0] = True
return ('https://video.mp4', 'video.mp4')
d.get_download_link_with_fallback = mock_fallback
# Call with direct URL (no pipe)
d.get_download_link('https://vidmoly.to/vid')
print(f'Fallback called: {fallback_called[0]}')
"
Expected Result: Fallback method is NOT called (False) - direct extraction used
Evidence: .sisyphus/evidence/task-3-direct-url.txt
```
**Evidence to Capture**:
- [ ] Evidence files show fallback called/not-called correctly
- [ ] Integration preserves existing functionality
**Commit**: YES
- Message: `feat(anime-sama): integrate fallback into get_download_link()`
- Files: `app/downloaders/anime_sites/animesama.py`
---
- [x] 4. Add unit tests for fallback logic
**What to do**:
- Create `tests/test_anime_sama_fallback.py`
- Test 1: Fallback tries players in priority order
- Test 2: Caching mechanism stores working player
- Test 3: All players failing raises exception
- Test 4: _test_video_url() returns True/False correctly
- Use pytest with mock_httpx_client fixture
**Must NOT do**:
- Make real HTTP requests in tests (use mocks)
- Test other anime sites (Anime-Sama only)
**Recommended Agent Profile**:
> **Category**: `quick`
- Reason: Unit tests are straightforward
- **Skills**: None needed
- **Skills Evaluated but Omitted**:
- No additional skills needed
**Parallelization**:
- **Can Run In Parallel**: NO - Depends on Task 3
- **Parallel Group**: Sequential
- **Blocks**: Task 5
- **Blocked By**: Task 3
**References** (CRITICAL):
**Test References** (testing patterns to follow):
- `tests/test_downloaders.py:40-70` - Mock pattern for downloaders
- `tests/conftest.py:40-50` - Mock HTTP client fixture
**WHY Each Reference Matters**:
- Mocking patterns show how to simulate HTTP responses without network calls
- Conftest fixtures provide reusable test setup
**Acceptance Criteria**:
- [ ] `tests/test_anime_sama_fallback.py` file created
- [ ] Test priority order: VidMoly → SendVid → Sibnet → Lpayer
- [ ] Test caching: working player reused for same anime
- [ ] Test _test_video_url: returns True/False correctly
- [ ] Test all players fail: exception raised
- [ ] All tests pass with pytest
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
```
Scenario: Run all fallback unit tests
Tool: Bash
Preconditions: Tests implemented in test_anime_sama_fallback.py
Steps:
1. pytest tests/test_anime_sama_fallback.py -v --tb=short
Expected Result: All tests pass
Failure Indicators: Any test fails, pytest exit code non-zero
Evidence: .sisyphus/evidence/task-4-tests-run.txt
```
**Evidence to Capture**:
- [ ] pytest output shows all tests passed
- [ ] Evidence file contains test summary
**Commit**: YES
- Message: `test(anime-sama): add unit tests for player fallback logic`
- Files: `tests/test_anime_sama_fallback.py`
---
- [x] 5. Integration testing with real Anime-Sama URLs
**What to do**:
- Test Frieren S2 E1 download with fallback enabled
- Verify that fallback tries multiple players if first fails
- Check logs to see which player succeeded
- Validate that downloaded video is playable
- Test with different Anime-Sama URLs to ensure general functionality
**Must NOT do**:
- Only test with Frieren (test variety)
- Modify production code during testing
**Recommended Agent Profile**:
> **Category**: `quick`
- Reason: Integration testing with real data
- **Skills**: `playwright` (may be needed for Lpayer)
- **Skills Evaluated but Omitted**:
- `git-master`: Not needed for testing
**Parallelization**:
- **Can Run In Parallel**: NO - Depends on Task 4
- **Parallel Group**: Sequential
- **Blocks**: Final Verification
- **Blocked By**: Task 4
**References** (CRITICAL):
**API/Type References** (contracts to implement against):
- `/api/anime/download` - Download endpoint
- `/api/downloads` - List downloads endpoint
**WHY Each Reference Matters**:
- Need to know how to trigger downloads and check status
**Acceptance Criteria**:
- [ ] Frieren S2 E1 download completes successfully
- [ ] Logs show multiple players tried if first fails
- [ ] Downloaded video file is valid (not empty, correct extension)
- [ ] Fallback logic works without errors
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
```
Scenario: Download Frieren S2 E1 with fallback
Tool: Bash (curl) + Playwright
Preconditions: Server running, fallback implemented
Steps:
1. curl -s "http://localhost:3000/api/anime/episodes?url=https://anime-sama.si/catalogue/frieren-s1/vostfr/&lang=vostfr" | python3 -m json.tool
2. Extract first episode URL
3. curl -X POST "http://localhost:3000/api/anime/download" -H "Content-Type: application/json" -d '{"url": "EPISODE_URL|PAGE_URL|Episode+1"}'
4. curl -s "http://localhost:3000/api/downloads" | python3 -m json.tool
5. Wait for download to complete (status COMPLETED)
6. ls -lh downloads/Frieren*.mp4 2>&1
Expected Result: Download completes with status COMPLETED, video file exists with > 1MB
Failure Indicators: Status FAILED, no video file, file size < 1MB
Evidence: .sisyphus/evidence/task-5-frieren-download.txt
```
**Evidence to Capture**:
- [ ] Evidence file contains download status
- [ ] Video file exists and is playable
**Commit**: YES (if successful)
- Message: `test(anime-sama): verify fallback works with Frieren S2 E1`
- Files: `downloads/` (test artifacts)
---
## Final Verification Wave
- [ ] F1. **Unit Test Coverage** — `pytest`
Run pytest on anime-sama tests to ensure fallback logic is covered.
- Run: `pytest tests/test_anime_sama_fallback.py -v --cov=app.downloaders.anime_sites.animesama`
- Verify: All tests pass, coverage > 80% for new methods
Output: `Tests [N/N pass] | Coverage [%] | VERDICT`
- [ ] F2. **Real Download Test** — `curl` + `Bash`
Test actual download with Anime-Sama fallback enabled.
- Trigger: Download Frieren S2 E1 via API
- Verify: Download completes, fallback logs visible, file valid
Output: `Download [COMPLETE/FAILED] | Player [name] | File [size] | VERDICT`
- [ ] F3. **Log Analysis** — `Bash`
Check server logs for fallback behavior.
- Run: `tail -100 /tmp/uvicorn.log | grep -E "(LPAYER|fallback|player)"`
- Verify: Multiple player attempts logged when first fails
Output: `Attempts [N] | Success [True/False] | VERDICT`
- [ ] F4. **No Regressions** — `pytest`
Ensure existing Anime-Sama functionality still works.
- Run: `pytest tests/test_anime_sama.py -v -k "not fallback"`
- Verify: All existing tests pass
Output: `Tests [N/N pass] | VERDICT`
---
## Commit Strategy
- **1**: `feat(anime-sama): add video URL validation helper method` — `app/downloaders/anime_sites/animesama.py`
- **2**: `feat(anime-sama): add player fallback logic with priority retry` — `app/downloaders/anime_sites/animesama.py`
- **3**: `feat(anime-sama): integrate fallback into get_download_link()` — `app/downloaders/anime_sites/animesama.py`
- **4**: `test(anime-sama): add unit tests for player fallback logic` — `tests/test_anime_sama_fallback.py`
- **5**: `test(anime-sama): verify fallback works with real downloads` — `downloads/` (test artifacts)
---
## Success Criteria
### Verification Commands
```bash
# Unit tests
pytest tests/test_anime_sama_fallback.py -v
# Integration test
curl -X POST "http://localhost:3000/api/anime/download" \
-H "Content-Type: application/json" \
-d '{"url": "URL|PAGE|TITLE"}'
# Check logs
tail -50 /tmp/uvicorn.log | grep fallback
```
### Final Checklist
- [ ] Fallback method tries players in priority order
- [ ] Video URLs validated before returning (10KB download test)
- [ ] Working player cached per anime for performance
- [ ] All unit tests pass
- [ ] Real download test succeeds
- [ ] No regressions in existing Anime-Sama functionality
- [ ] All "Must Have" present
- [ ] All "Must NOT Have" absent
-46
View File
@@ -1,46 +0,0 @@
# Plan: Faire fonctionner Frieren S2 - Analyse et Solutions
## Analyse de la situation
### Fournisseurs disponibles pour Frieren S2
| Episode | Fournisseur | Status |
|---------|-------------|--------|
| 1 | Lpayer | ❌ Besoin JavaScript |
| 2 | VidMoly | ❌ Bloqué (ffmpeg) |
| 3 | Sibnet | ❌ 403 Forbidden |
| 4 | SendVid + VidMoly | ❌ Bloqué |
| 5 | Dingtez | ❌ JavaScript obfusqué |
### Causes du blocage
1. **Lpayer** : Charge les vidéos avec JavaScript React - Playwright n'arrive pas à extraire
2. **VidMoly** : Vérifie si ffmpeg est disponible, bloque les requêtes automatisées
3. **Sibnet** : Retourne 403 Forbidden pour les requêtes non-browser
4. **SendVid** : Bloque les requêtes automatisées
5. **Dingtez** : JavaScript obfusqué avec JWPlayer
---
## Solutions possibles
### Solution 1: Interface de saisie manuelle (PRIORITÉ)
- [ ] Ajouter un champ "URL vidéo directe" dans l'interface
- [ ] L'utilisateur colle l'URL qu'il a trouvée ailleurs
- [ ] Le système télécharge directement sans extraction
### Solution 2: Real-Debrid
- [ ] Intégrer l'API Real-Debrid
- [ ] Le service débride les URLs automatiquement
- [ ] Fonctionne avec tous les hébergeurs
### Solution 3: Navigateur Playwright intégré
- [ ] Utiliser Playwright pour TOUTES les extractions
- [ ] Plus lent mais plus fiable
- [ ] Nécessite plus de ressources
---
## Recommandation
Commencer par **Solution 1** (la plus simple et fiable) puis **Solution 2** (Real-Debrid).
File diff suppressed because it is too large Load Diff
-423
View File
@@ -1,423 +0,0 @@
# Harmonize Watchlist Design - Align with Main Page
## TL;DR
> **Quick Summary**: Harmonize the visual design of watchlist page to match /web page while keeping watchlist as separate autonomous page.
> **Deliverables**:
> - Update watchlist.html to use same background gradient and styling as /web
> - Unify header design (colors, layout, icons)
> - Align button styles to match /web patterns
> - Maintain watchlist functionality (no breaking changes)
> **Estimated Effort**: Medium
> **Parallel Execution**: NO - single task
> **Critical Path**: CSS updates → styling verification → commit
---
## Context
### Original Request Summary
User identified that watchlist (/watchlist) page has a completely different design from the main page (/web), creating UX inconsistency:
- Watchlist has dark violet gradient background, /web has cleaner light gradient
- Watchlist has custom header "📋 Ma Watchlist", /web has unified navigation tabs
- Watchlist has its own navigation button, /web has tab-based navigation
- Different color schemes, layouts, and styling patterns
### User's Decision
User chose **Option 2: Harmonize watchlist design** - adapt watchlist visual design to match /web styling while keeping it as a separate page.
### Key Findings
- /web uses light gradient background (135deg, #1e1e2e 0%, #2d1b69 0%, #1e1e2e 100%)
- Watchlist currently uses dark violet gradient background
- /web has tab-based navigation (Accueil, Anime, Série, Fournisseurs, Watchlist)
- Watchlist has standalone page design
---
## Work Objectives
### Core Objective
Harmonize the visual design of templates/watchlist.html to match the styling and patterns of templates/index.html (/web), creating visual consistency across the application.
### Concrete Deliverables
- Updated `templates/watchlist.html` background to match /web
- Unified header design colors and layout
- Aligned button styles (btn-primary, btn-secondary)
- Consistent typography and spacing
- Maintained all watchlist functionality (scheduler, stats, search, add/remove items)
### Definition of Done
- [ ] Watchlist background gradient matches /web
- [ ] Header text color and styling matches /web
- [ ] Button styles (btn-primary, btn-secondary) match /web
- [ ] Overall visual appearance is consistent with /web
- [ ] All watchlist features still work (no breaking changes)
### Must Have
- Harmonize visual design with /web
- Match background gradient colors
- Align header styling (fonts, colors, icons)
- Unify button class styles
- Maintain all existing watchlist functionality
### Must NOT Have (Guardrails)
- **DO NOT remove watchlist functionality** - scheduler, stats, notifications must still work
- **DO NOT change /web design** - only adapt watchlist to match
- **DO NOT break existing URL routes** - /watchlist and /web must both work
- **DO NOT modify JavaScript files** - only HTML/CSS changes
- **DO NOT add new features** - this is visual harmonization only
---
## Verification Strategy
### Test Decision
- **Infrastructure exists**: YES (uvicorn server)
- **Automated tests**: NO (visual changes, manual QA)
- **Framework**: None - manual browser verification
- **Rationale**: This is visual CSS/template change, requires manual browser verification
### QA Policy
Visual verification required for design changes:
- Use dev-browser (playwright) to load both pages
- Compare visual appearance side-by-side
- Verify no functionality broken
- Evidence saved to `.sisyphus/evidence/`
---
## Execution Strategy
### Sequential Execution
```
Task 1: Update Watchlist Background Gradient
- Modify templates/watchlist.html
- Replace dark violet gradient with /web's light gradient
- Verify page loads and looks correct
Task 2: Harmonize Header Design
- Update header colors, fonts, layout
- Match /web navigation header styling
- Ensure text colors are consistent
- [ ] 2. Harmonize Header Design
Task 3: Align Button Styles
- Update button classes to use same styles as /web
- Verify hover states and interactions
- Ensure responsive behavior matches
- [ ] 4. Final Verification
- Load both /web and /watchlist in browser
- Take screenshots for comparison
- Verify all functionality works
```
### Agent Dispatch Summary
- **1**: **1** — T1 (visual-engineering)
- **2**: **1** — T2 (visual-engineering)
- **3**: **1** — T3 (visual-engineering)
- **4**: **1** — T4 (visual-engineering)
---
## TODOs
- [ ] 1. Update Watchlist Background Gradient
**What to do**:
- Read `templates/watchlist.html` to find current background styling
- Read `templates/index.html` to get the light gradient background
- Replace watchlist's dark violet gradient: `background: linear-gradient(135deg, #1e1e2e 0%, #2d1b69 0%, #1e1e2e 100%)`
- With /web's light gradient: Need to check index.html for exact colors
**Must NOT do**:
- Remove watchlist functionality (scheduler, stats, search)
- Change the structure of the page
- Modify JavaScript files
**Recommended Agent Profile**:
- **Category**: `visual-engineering`
- Reason: CSS styling update for visual consistency
- **Skills**: []
- No special skills needed - CSS gradient change
**Parallelization**:
- **Can Run In Parallel**: NO
- **Parallel Group**: Single task
- **Blocks**: Task 2
- **Blocked By**: None (can start immediately)
**References**:
**Pattern References**:
- `templates/index.html` - Reference for correct background gradient
- `templates/watchlist.html` - File to modify
**Acceptance Criteria**:
```bash
# Watchlist uses light gradient like /web
grep -c "linear-gradient(135deg" templates/watchlist.html
Expected: 1
```
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
```
Scenario: Watchlist page uses light gradient background
Tool: dev-browser (playwright)
Preconditions: Server running on port 3000
Steps:
1. Navigate to http://localhost:3000/watchlist
2. Wait for page to load (timeout 10s)
3. Take screenshot of page background
4. Navigate to http://localhost:3000/web
5. Take screenshot for comparison
Expected Result: Watchlist background matches /web's light gradient
Failure Indicators: Background still dark violet, colors don't match
Evidence: .sisyphus/evidence/task-1-background-gradient.png
```
**Commit**: NO
- Groups with Task 2, 3
- [ ] 2. Harmonize Header Design
**What to do**:
- Read `templates/watchlist.html` to check current header styling
- Read `templates/index.html` to get header reference
- Update watchlist header colors to match /web's color scheme
- Update fonts to match /web typography
- Ensure header layout and spacing match /web
- Keep "📋 Ma Watchlist" title but update colors
**Must NOT do**:
- Remove header functionality
- Change header text/title
- Remove the "Retour à l'accueil" button added earlier
**Recommended Agent Profile**:
- **Category**: `visual-engineering`
- Reason: Header styling harmonization
- **Skills**: []
- CSS styling task
**Parallelization**:
- **Can Run In Parallel**: NO
- **Parallel Group**: Single task
- **Blocks**: Task 3
- **Blocked By**: Task 1 (background must be updated first)
**References**:
**Pattern References**:
- `templates/index.html` - Reference for header styling
- `templates/watchlist.html` - File to modify
**Acceptance Criteria**:
```bash
# Header uses same colors as /web
# Verify no dark violet colors remain
```
**QA Scenarios (MANDATORY):**
```
Scenario: Header design matches /web
Tool: dev-browser (playwright)
Preconditions: Tasks 1 complete, server running
Steps:
1. Navigate to http://localhost:3000/watchlist
2. Take screenshot of header section
3. Navigate to http://localhost:3000/web
4. Take screenshot of navigation header
5. Compare screenshots side-by-side
Expected Result: Watchlist header colors, fonts, layout match /web
Failure Indicators: Different colors, fonts mismatched, layout differences
Evidence: .sisyphus/evidence/task-2-header-harmonization.png
```
**Commit**: NO
- Groups with Task 3
- [ ] 3. Align Button Styles
**What to do**:
- Read `templates/watchlist.html` to identify all button elements
- Read `templates/index.html` to get button class references
- Ensure all buttons use consistent classes (btn-primary, btn-secondary)
- Verify hover states and interactions work correctly
- Make sure "Retour à l'accueil" button style is aligned
**Must NOT do**:
- Change button functionality or behavior
- Remove any buttons
- Modify JavaScript event handlers
**Recommended Agent Profile**:
- **Category**: `visual-engineering`
- Reason: Button styling alignment
- **Skills**: []
- CSS class updates
**Parallelization**:
- **Can Run In Parallel**: NO
- **Parallel Group**: Single task
- **Blocks**: Task 4
- **Blocked By**: Task 2 (header must be updated first)
**References**:
**Pattern References**:
- `templates/index.html` - Reference for button styles
- `templates/watchlist.html` - File to modify
- `static/css/style.css` - Button class definitions (if exists)
**Acceptance Criteria**:
```bash
# All buttons use btn-primary or btn-secondary classes
grep -o "class=\"btn-" templates/watchlist.html | sort | uniq
Expected: btn-primary, btn-secondary (or similar consistent classes)
```
**QA Scenarios (MANDATORY):**
```
Scenario: Button styles are consistent with /web
Tool: dev-browser (playwright)
Preconditions: Tasks 1, 2 complete
Steps:
1. Navigate to http://localhost:3000/watchlist
- [ ] 3. Align Button Styles
3. Click buttons, verify interactions work
4. Check no console errors
Expected Result: All buttons have consistent styling with /web, hover states work
Failure Indicators: Different button styles, broken interactions, console errors
Evidence: .sisyphus/evidence/task-3-button-alignment.png
```
**Commit**: YES
- Message: `style(ui): Harmonize watchlist design to match /web`
- Files: `templates/watchlist.html`
- [ ] 4. Final Verification
**What to do**:
- Start server if not running: `uvicorn main:app --host 0.0.0.0 --port 3000`
- Navigate to `/web` and verify page works
- Navigate to `/watchlist` and verify page works
- Take comparison screenshots
- Verify navigation works both ways
- Check browser console for errors
- Verify watchlist features (search, scheduler, stats, add/remove items) still work
**Must NOT do**:
- Make any code changes
- Modify functionality
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Final integration verification
- **Skills**: [`dev-browser`]
- dev-browser: Use Playwright for browser automation and screenshots
**Parallelization**:
- **Can Run In Parallel**: NO
- **Parallel Group**: Final task
- **Blocks**: None
- **Blocked By**: Tasks 1, 2, 3 (all tasks must complete)
**References**:
**Pattern References**:
- `templates/index.html` - Reference for expected design
- `templates/watchlist.html` - File being modified
**Acceptance Criteria**:
```bash
# Both pages work
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/web
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/watchlist
Expected: 200 for both
```
**QA Scenarios (MANDATORY):**
```
Scenario: Visual design is harmonized between /web and /watchlist
Tool: dev-browser (playwright)
Preconditions: All styling tasks complete, server running
Steps:
1. Navigate to http://localhost:3000/web
2. Take full page screenshot
3. Navigate to http://localhost:3000/watchlist
4. Take full page screenshot
5. Compare side-by-side
6. Verify backgrounds match
7. Verify header styles match
8. Verify button styles match
Expected Result: Visual design is consistent between both pages
Failure Indicators: Color mismatch, style differences, broken features
Evidence: .sisyphus/evidence/task-4-verification-screenshot.png
```
**Commit**: NO
- This is verification only, no code changes
---
## Final Verification Wave (MANDATORY — after ALL implementation tasks)
> 3 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
- [ ] F1. **Visual Design Review** — `visual-engineering`
Compare watchlist and /web designs side-by-side. Verify colors, gradients, typography, spacing, and layout are harmonized. Check for any visual inconsistencies.
Output: `Background [MATCH/MISMATCH] | Header [MATCH/MISMATCH] | Buttons [MATCH/MISMATCH] | VERDICT: APPROVE/REJECT`
- [ ] F2. **Functionality Verification** — `unspecified-high` (+ `dev-browser` skill)
Navigate to /watchlist and verify all features work: search, scheduler controls, stats display, add/remove items, navigation. Check browser console for errors.
Output: `Features [N/N working] | Console Errors [0/N] | VERDICT: APPROVE/REJECT`
- [ ] F3. **Code Quality Check** — `quick`
Check for CSS syntax errors, invalid colors, or broken HTML structure.
Output: `CSS [VALID/INVALID] | HTML [VALID/INVALID] | VERDICT: APPROVE/REJECT`
---
## Commit Strategy
- **1**: `style(ui): Harmonize watchlist design to match /web`
- Files: `templates/watchlist.html`
---
## Success Criteria
### Verification Commands
```bash
# Watchlist uses light gradient
grep -c "linear-gradient(135deg" templates/watchlist.html
# Expected: 1
# Both pages work
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/web
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/watchlist
# Expected: 200 for both
```
### Final Checklist
- [ ] Watchlist background matches /web
- [ ] Header design harmonized with /web
- [ ] Button styles aligned with /web
- [ ] All watchlist features still work
- [ ] Both pages load without errors
- [ ] Visual design is consistent
-535
View File
@@ -1,535 +0,0 @@
# Plan : Refonte du Système Watchlist
## TL;DR
> **Objectif** : Refaire le système de watchlist avec auto-téléchargement, notifications et stockage SQLite
>
> **Deliverables** :
> - Base de données SQLite pour la watchlist
> - API REST pour gérer les animes suivis
> - Système d'auto-téléchargement (vérification automatique des nouveaux épisodes)
> - Système de notifications (in-app)
> - Interface frontend (page séparée, même style que le reste)
>
> **Effort** : XL
> **Exécution** : En waves parallèles
---
## Contexte
### Système Actuel
- Stockage JSON (`config/watchlist.json`)
- Pas de SQLite
- Auto-download basique via scheduler
- Pas de système de notifications
- Interface intégrée à la page principale
### Besoins Utilisateur
- Auto-téléchargement des nouveaux épisodes ✅
- Notifications quand un nouvel épisode est dispo ✅
- Stockage SQLite ✅
- Même style que le reste du site ✅
- Page séparée ✅
---
## Work Objectives
### Objectif Principal
Créer un système de watchlist complet permettant de :
1. Suivre des animes (ajout via recherche)
2. Détecter automatiquement les nouveaux épisodes
3. Télécharger automatiquement les nouveaux épisodes
4. Notifier l'utilisateur quand un nouvel épisode est disponible
### Deliverables Concrets
- [ ] Base de données SQLite (`config/watchlist.db`)
- [ ] Modèles Pydantic pour la watchlist
- [ ] API endpoints (CRUD + actions)
- [ ] Service d'auto-check (scheduler)
- [ ] Service de notifications
- [ ] Page frontend dédiée
- [ ] Intégration avec le système de download existant
### Définition de Terminé
- [ ] Un anime peut être ajouté à la watchlist
- [ ] La watchlist affiche tous les animes suivis
- [ ] Les épisodes peuvent être téléchargés manuellement
- [ ] Le scheduler vérifie automatiquement les nouveaux épisodes
- [ ] Les nouveaux épisodes sont téléchargés automatiquement
- [ ] Une notification apparaît quand un nouvel épisode est dispo
---
## Architecture
### Structure des Fichiers
```
app/
├── watchlist/
│ ├── __init__.py
│ ├── models.py # Modèles Pydantic
│ ├── database.py # Connexion SQLite
│ ├── service.py # Logique métier
│ ├── scheduler.py # Auto-check
│ └── notifications.py # Notifications
├── routes/
│ └── watchlist.py # API endpoints
static/
└── js/
└── watchlist/ # Frontend
├── index.js
├── components/
└── style.css
```
### Schéma Base de Données (SQLite)
```sql
-- Table principale : watchlist items
CREATE TABLE watchlist_items (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
anime_title TEXT NOT NULL,
anime_url TEXT NOT NULL,
provider_id TEXT NOT NULL,
lang TEXT DEFAULT 'vostfr',
poster_image TEXT,
cover_image TEXT,
synopsis TEXT,
genres TEXT, -- JSON array
-- Tracking
status TEXT DEFAULT 'active', -- active, paused, completed
auto_download INTEGER DEFAULT 1,
quality_preference TEXT DEFAULT 'auto',
last_episode_downloaded INTEGER DEFAULT 0,
total_episodes INTEGER,
last_checked_at TEXT,
-- Metadata
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Table : Episodes téléchargés
CREATE TABLE downloaded_episodes (
id TEXT PRIMARY KEY,
watchlist_item_id TEXT NOT NULL,
episode_number INTEGER NOT NULL,
filename TEXT NOT NULL,
file_path TEXT,
file_size INTEGER,
downloaded_at TEXT NOT NULL,
FOREIGN KEY (watchlist_item_id) REFERENCES watchlist_items(id)
);
-- Table : Notifications
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
watchlist_item_id TEXT,
type TEXT NOT NULL, -- new_episode, download_complete, error
title TEXT NOT NULL,
message TEXT,
read INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
FOREIGN KEY (watchlist_item_id) REFERENCES watchlist_items(id)
);
-- Table : Settings
CREATE TABLE watchlist_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
check_interval_hours INTEGER DEFAULT 6,
auto_download_enabled INTEGER DEFAULT 1,
max_concurrent_downloads INTEGER DEFAULT 2,
notifications_enabled INTEGER DEFAULT 1
);
```
---
## Execution Strategy
### Wave 1 (Fondations)
```
Tâches :
├── 1. Créer structure du module watchlist/
├── 2. Créer database.py (connexion SQLite, migrations)
├── 3. Créer models.py (Pydantic models)
├── 4. Créer service.py (CRUD operations)
└── 5. Mettre à jour models/__init__.py
Dépendances :Aucune (start immediate)
```
### Wave 2 (API + Scheduler)
```
Tâches (dépendent de Wave 1) :
├── 6. Créer routes/watchlist.py (API endpoints)
├── 7. Créer scheduler.py (auto-check)
├── 8. Intégrer scheduler dans main.py
└── 9. Créer notifications.py
Bloqué par : 1-5
```
### Wave 3 (Frontend)
```
Tâches (dépendent de Wave 2) :
├── 10. Créer page HTML watchlist.html
├── 11. Créer watchlist-ui.js (logique)
├── 12. Ajouter CSS pour la page
└── 13. Ajouter routes pour servir la page
Bloqué par : 6-9
```
### Wave 4 (Intégration + Tests)
```
Tâches :
├── 14. Tester l'ajout d'un anime
├── 15. Tester le téléchargement manuel
├── 16. Tester l'auto-download
├── 17. Tester les notifications
└── 18. Nettoyer l'ancien code
Bloqué par : 10-13
```
---
## TODOs
- [ ] 1. **Créer la structure du module watchlist/**
**Quoi faire** :
- Créer le répertoire `app/watchlist/`
- Créer `__init__.py` avec exports
**Pas faire** :
- Toucher aux autres modules
**Agent recommandé** : `quick`
**QA Scenarios** :
```
Scenario: Le répertoire existe
Tool: Bash
Command: ls -la app/watchlist/
Expected: Le répertoire existe avec __init__.py
```
- [ ] 2. **Créer database.py (connexion SQLite)**
**Quoi faire** :
- Créer `app/watchlist/database.py`
- Implémenter connexion SQLite avec `sqlite3`
- Implémenter fonctions : `init_db()`, `get_connection()`, `migrate()`
- Créer les tables définies dans le schéma
**Pas faire** :
- Toucher aux autres fichiers
**Agent recommandé** : `quick`
**QA Scenarios** :
```
Scenario: La base de données est créée
Tool: Bash
Command: python3 -c "from app.watchlist.database import init_db; init_db(); import os; print(os.path.exists('config/watchlist.db'))"
Expected: True
Scenario: Les tables existent
Tool: Bash
Command: sqlite3 config/watchlist.db ".tables"
Expected: watchlist_items downloaded_episodes notifications watchlist_settings
```
- [ ] 3. **Créer models.py**
**Quoi faire** :
- Créer `app/watchlist/models.py`
- Définir les modèles Pydantic :
- WatchlistItem, WatchlistItemCreate, WatchlistItemUpdate
- DownloadedEpisode
- Notification, NotificationCreate
- WatchlistSettings
- Utiliser les types existants de `app/models/`
**Pas faire** :
- Dupliquer les types existants
**Agent recommandé** : `quick`
**QA Scenarios** :
```
Scenario: Les modèles peuvent être importés
Tool: Bash
Command: python3 -c "from app.watchlist.models import WatchlistItem, Notification; print('OK')"
Expected: OK (no error)
```
- [ ] 4. **Créer service.py**
**Quoi faire** :
- Créer `app/watchlist/service.py`
- Implémenter `WatchlistService` avec :
- `add_item()`, `get_items()`, `get_item()`, `update_item()`, `delete_item()`
- `mark_episode_downloaded()`, `get_downloaded_episodes()`
- `create_notification()`, `get_notifications()`, `mark_notification_read()`
- `get_settings()`, `update_settings()`
- `get_items_due_for_check()`
- Utiliser SQLite directement (pas d'ORM)
**Pas faire** :
- Toucher au frontend
**Agent recommandé** : `unspecified-high`
**QA Scenarios** :
```
Scenario: Ajouter un item à la watchlist
Tool: Bash
Command: python3 -c "
from app.watchlist.service import WatchlistService
svc = WatchlistService()
item = svc.add_item(user_id='test', anime_title='Test Anime', anime_url='https://example.com', provider_id='anime-sama')
print(f'Created: {item.id}')
"
Expected: Un UUID est retourné
Scenario: Récupérer les items
Tool: Bash
Command: python3 -c "
from app.watchlist.service import WatchlistService
svc = WatchlistService()
items = svc.get_items()
print(f'Count: {len(items)}')
"
Expected: Count: 1
```
- [ ] 5. **Mettre à jour models/__init__.py**
**Quoi faire** :
- Ajouter export des nouveaux modèles si besoin
**Agent recommandé** : `quick`
- [ ] 6. **Créer routes/watchlist.py**
**Quoi faire** :
- Créer `app/routes/watchlist.py`
- Définir les endpoints :
- `GET /api/watchlist` - Liste des items
- `POST /api/watchlist` - Ajouter un item
- `GET /api/watchlist/{id}` - Détail d'un item
- `PUT /api/watchlist/{id}` - Modifier un item
- `DELETE /api/watchlist/{id}` - Supprimer un item
- `POST /api/watchlist/{id}/download/{episode}` - Télécharger un épisode
- `GET /api/watchlist/{id}/episodes` - Épisodes téléchargés
- `GET /api/watchlist/notifications` - Liste des notifications
- `PUT /api/watchlist/notifications/{id}/read` - Marquer comme lu
- `GET /api/watchlist/settings` - Settings
- `PUT /api/watchlist/settings` - Mettre à jour settings
- Ajouter auth (Bearer token)
- Intégrer avec `download_manager` pour les téléchargements
**Agent recommandé** : `unspecified-high`
**QA Scenarios** :
```
Scenario: L'API répond
Tool: Bash
Command: curl -s http://127.0.0.1:3000/api/watchlist
Expected: {"items": [...], "count": N}
```
- [ ] 7. **Créer scheduler.py**
**Quoi faire** :
- Créer `app/watchlist/scheduler.py`
- Implémenter `WatchlistScheduler` :
- `start()`, `stop()`
- `_check_loop()` - Boucle principale
- `check_item(item)` - Vérifier un anime
- `download_new_episodes(item, new_episodes)` - Télécharger
- Utiliser `APScheduler` (déjà dans requirements)
- Intervalle configurable (défaut: 6h)
**Agent recommandé** : `unspecified-high`
- [ ] 8. **Intégrer scheduler dans main.py**
**Quoi faire** :
- Importer et initialiser le scheduler
- Ajouter au startup event
- Ajouter au shutdown event
**Agent recommandé** : `quick`
- [ ] 9. **Créer notifications.py**
**Quoi faire** :
- Créer `app/watchlist/notifications.py`
- Implémenter `NotificationService`
- Types de notifications :
- `new_episode` - Nouvel épisode détecté
- `download_started` - Téléchargement commencé
- `download_complete` - Téléchargement terminé
- `download_error` - Erreur de téléchargement
- Stocker dans SQLite
- Retourner via API pour affichage
**Agent recommandé** : `quick`
- [ ] 10. **Créer page HTML watchlist.html**
**Quoi faire** :
- Créer `templates/watchlist.html`
- Même structure que `index.html`
- Sections :
- Header avec stats
- Liste des animes (cards)
- Zone de notifications
- Modal pour les détails
**Agent recommandé** : `visual-engineering`
**QA Scenarios** :
```
Scenario: La page se charge
Tool: playwright
Navigate: http://127.0.0.1:3000/watchlist
Expected: Titre "Ma Watchlist" affiché
```
- [ ] 11. **Créer watchlist-ui.js**
**Quoi faire** :
- Créer `static/js/watchlist/main.js`
- Fonctions :
- `loadWatchlist()` - Charger la liste
- `renderWatchlist(items)` - Afficher les cards
- `addAnime(animeData)` - Ajouter un anime
- `removeAnime(id)` - Retirer
- `downloadEpisode(itemId, episode)` - Télécharger
- `loadNotifications()` - Charger les notifs
- `renderNotifications(notifs)` - Afficher
- `markAsRead(id)` - Marquer lu
- Appels API vers les endpoints créés
**Agent recommandé** : `visual-engineering`
- [ ] 12. **Ajouter CSS**
**Quoi faire** :
- Créer `static/css/watchlist.css`
- Style cohérent avec `style.css` existant
- Cards, badges, buttons, notifications
**Agent recommandé** : `visual-engineering`
- [ ] 13. **Ajouter routes pour servir la page**
**Quoi faire** :
- Ajouter route `GET /watchlist` dans main.py
- Servir le template
**Agent recommandé** : `quick`
- [ ] 14-17. **Tests d'intégration**
**Quoi faire** :
- Tester le flux complet :
1. Ajouter un anime via API
2. Voir dans la liste
3. Télécharger un épisode manuellement
4. Recevoir une notification
- Tester l'auto-download (simuler un nouvel épisode)
**Agent recommandé** : `unspecified-high`
- [ ] 18. **Nettoyer l'ancien code**
**Quoi faire** :
- Supprimer `app/watchlist.py` (l'ancien)
- Supprimer les fichiers JSON `config/watchlist*.json`
- Mettre à jour les imports
**Agent recommandé** : `quick`
---
## Stratégie de Vérification
### Test Manual (Agent QA)
**Scenario: Ajout d'un anime**
```
1. Ouvrir /watchlist
2. Cliquer "Ajouter un anime"
3. Rechercher "Frieren"
4. Sélectionner un résultat
5. Cliquer "Suivre"
Expected: L'anime apparaît dans la liste
```
**Scenario: Téléchargement manuel**
```
1. Dans la watchlist, cliquer sur un anime
2. Voir la liste des épisodes
3. Cliquer "Télécharger" sur épisode 1
4. Vérifier dans /downloads
Expected: Le téléchargement commence
```
**Scenario: Auto-download**
```
1. Ajouter un anime avec auto-download activé
2. Simuler l'apparition d'un nouvel épisode (via scheduler)
3. Vérifier dans les downloads
Expected: L'épisode est téléchargé automatiquement
```
**Scenario: Notification**
```
1. Un nouvel épisode est détecté
2. Une notification apparaît
3. Cliquer sur la notification
Expected: Redirection vers l'épisode
```
---
## Critères de Succès
- [ ] La base SQLite est créée et fonctionnelle
- [ ] Les animes peuvent être ajoutés/retirés de la watchlist
- [ ] Les épisodes peuvent être téléchargés manuellement
- [ ] Le scheduler vérifie automatiquement les nouveaux épisodes
- [ ] L'auto-téléchargement fonctionne
- [ ] Les notifications sont créées et affichées
- [ ] L'interface est cohérente avec le reste du site
- [ ] L'ancien code est nettoyé
---
## Commit Strategy
- Wave 1: `feat(watchlist): add SQLite database and models`
- Wave 2: `feat(watchlist): add API routes and scheduler`
- Wave 3: `feat(watchlist): add frontend UI`
- Wave 4: `feat(watchlist): integrate and test`
---
## Notes
- Le système de download existant (`download_manager`) est réutilisé
- Les providers existants (anime-sama, vostfree, etc.) sont réutilisés
- Le système de notification est simple (in-app) pour éviter les dépendances supplémentaires
- Le scheduler utilise APScheduler déjà présent dans le projet
File diff suppressed because it is too large Load Diff
-156
View File
@@ -1,156 +0,0 @@
# AGENTS.md — Ohm Stream Downloader
FastAPI anime/series downloader with HTMX+Alpine.js frontend, SQLModel/SQLite DB,
3-tier scraper architecture, JWT auth, Sonarr webhooks, and auto-download scheduler.
## COMMANDS
```bash
# Dev server
uvicorn main:app --reload --host 0.0.0.0 --port 3000
# --- Tests (pytest, configured in pytest.ini, asyncio_mode=auto) ---
pytest # All tests (coverage + verbose by default)
pytest -m "unit" # Fast unit tests only
pytest -m "integration" # API integration tests
pytest -m "not slow" # CI default — excludes slow tests
pytest -m "network" # Tests requiring network access
# Single file / class / test
pytest tests/test_sonarr.py -v
pytest tests/test_sonarr.py::TestSonarrHandler -v
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
# Debug
pytest -s # Show print() output
pytest --cov=app --cov-report=html # HTML coverage report in htmlcov/
# --- Lint & Format (ruff) ---
ruff check app/ # Lint
ruff format --check app/ # Format check (CI enforces this)
ruff format app/ # Auto-format
# --- Type Check ---
mypy app/ --ignore-missing-imports # Type check (CI enforces)
# --- DB Migrations ---
alembic revision --autogenerate -m "description"
alembic upgrade head
# --- Frontend (optional) ---
npm test # Vitest JS tests
npx playwright test # E2E browser tests
```
## CODE STYLE
### Imports
Three groups separated by blank lines: stdlib → third-party → local (`app.*`).
Always absolute imports (no relative). No wildcard imports (`from X import *` enforced).
### Formatting
PEP 8, 120 chars max line length. Single quotes for strings, double quotes for docstrings.
Ruff handles linting and formatting (no local config — CI-only).
### Types
Explicit type hints on all function signatures and return types.
Use `Optional[X]` from `typing`. Use `list[str]` / `dict[str, X]` (lowercase generics).
Pydantic models for all API schemas. Return type annotations required on public methods.
### Naming
- `snake_case` for functions, variables, constants
- `PascalCase` for classes and enums
- `UPPER_SNAKE_CASE` for enum values (`DownloadStatus.PENDING`)
- `logger = logging.getLogger(__name__)` at module level
- `_` prefix for private methods (`_fetch_page`, `_sanitize`)
- `get_*` for factory functions (`get_downloader`, `get_anime_site`)
### Error Handling
- `HTTPException` for API errors with proper status codes
- `raise ValueError()` for business logic validation
- `try/except` with logging — never bare `except:` (known tech debt exists)
- `response.raise_for_status()` for HTTP errors
- Never return `None` for missing URLs from downloaders — raise an exception
### Docstrings
Triple double quotes `"""`. Module docstrings describe purpose. Class docstrings describe
responsibility. Complex methods use Args/Returns sections. Not all methods require docstrings.
## ARCHITECTURE
```
main.py # App entry, middleware, startup, router registration
app/
├── routers/ # 11 APIRouter modules (one per feature domain)
├── downloaders/ # 3-tier: anime_sites/ → series_sites/ → video_players/
├── models/ # Pydantic/SQLModel (Base → Table → Schema pattern)
├── config.py # Pydantic Settings from .env
├── database.py # SQLModel engine (created at import time)
├── download_manager.py # Async queue, semaphore-based parallelism
├── auth.py # JWT + bcrypt, SQLModel user storage
├── providers.py # ANIME_PROVIDERS, SERIES_PROVIDERS, FILE_HOSTS registries
└── utils.py # sanitize_filename(), is_safe_filename()
templates/ # Jinja2 + HTMX + Alpine.js
static/js/ # Vanilla ES modules (no build step)
tests/ # pytest suite (conftest.py has shared fixtures)
config/ # Runtime JSON files (users, watchlist, sonarr)
alembic/ # DB migrations
```
## KEY CONVENTIONS
- **URL pipe format**: `video_url|anime_page_url|episode_title` — metadata preserved through download pipeline
- **Module-level init**: `database.py` creates engine on import; `main.py` creates `download_manager` on import
- **Async everywhere**: Always `async/await` for I/O — use `httpx.AsyncClient`, never `requests`
- **Factory pattern**: `get_downloader(url)` routes anime → series → video player → generic
- **Router deps**: `Depends(lambda: download_manager)`, `Depends(get_current_user_from_token)`, `Depends(lambda: templates)`
- **Dual storage**: Some features use JSON files (legacy) + SQLModel tables (newer)
- **Frontend**: No JS build step. HTMX for server interactions, Alpine.js for client state, Plyr.io for video
- **Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`)
## ANTI-PATTERNS (DO NOT)
- Use sync `requests` — always `httpx.AsyncClient`
- Return `None` for missing URLs from downloaders — raise an exception
- Skip `sanitize_filename()` on extracted filenames — path traversal risk
- Forget `await self.close()` in downloaders — resource leak
- Hardcode User-Agent in individual players — use base class headers
- Use `from X import *` — always explicit imports
- Import `download_manager` from `main.py` in app/ modules — causes circular imports
- Store secrets in `config/*.json` — use `.env`
- Use `as any`, `@ts-ignore` to suppress type errors (if adding TS)
## TEST CONVENTIONS
- `tests/` directory with `conftest.py` for shared fixtures
- Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`, `@pytest.mark.slow`
- `asyncio_mode = auto` — async test functions run without explicit marker
- Test naming: `test_<verb>_<noun>` in `Test*` classes
- 300s timeout configured in pytest.ini; `testpaths = tests`
- Legacy test files at project root: `test_watchlist_simple.py`, `test_watchlist_e2e.py`
## ADDING NEW PROVIDERS
**Video player**: Create in `app/downloaders/video_players/`, inherit `BaseVideoPlayer`,
implement `can_handle()` + `get_download_link(url, target_filename=None)`, register in
`__init__.py`, add to `FILE_HOSTS` in `providers.py`.
**Anime/series site**: Create in `app/downloaders/anime_sites/` or `series_sites/`, inherit
base class, implement `search_anime()` + `get_episodes()` + `get_anime_metadata()` +
`get_download_link()`, register in `__init__.py`, add to `providers.py`.
## NOTES
- Python 3.11+, CI tests on 3.11 and 3.12
- No `pyproject.toml` — uses `requirements.txt` with exact version pinning
- `GEMINI.md` and `CLAUDE.md` exist with tool-specific instructions (merge if conflicting)
- French-language project (animes, séries, VOSTFR) but all code and comments in English
- ~20 empty `except:` blocks in downloaders/tests — known tech debt
- `JWT_SECRET_KEY` min 32 chars, default rejected at startup; generate via `Settings.generate_secret()`
- Sub-AGENTS.md files exist in `app/`, `app/routers/`, `app/downloaders/`, `app/models/`,
`app/downloaders/anime_sites/`, `app/downloaders/video_players/` for deeper context
-806
View File
@@ -1,806 +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 anime episodes and media files from various file hosting services (1fichier, Doodstream, Rapidfile, Uptobox, VidMoly, SendVid, Sibnet, Lpayer, Vidzy, LuLuvid, Uqload) and streaming platforms (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga, FS7). It features a modern web interface, parallel downloads, pause/resume support, video streaming, personalized recommendations, JWT authentication, and Sonarr webhook integration for automated downloads.
## 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
# Install JavaScript test dependencies (optional, for frontend tests)
npm install
# Run development server (auto-reload)
uvicorn main:app --reload --host 0.0.0.0 --port 3000
# Access web interface
# Open http://localhost:3000/web in browser
# --- Python Tests (pytest) ---
# Run all tests
pytest
# Run tests with coverage report
pytest --cov=app --cov-report=html
# Run only unit tests (fast, isolated)
pytest -m "unit"
# Run only integration tests
pytest -m "integration"
# Exclude slow tests
pytest -m "not slow"
# Verbose output
pytest -v
# Show print debugging
pytest -s
# Run specific test file
pytest tests/test_sonarr.py -v
# Run specific test class
pytest tests/test_sonarr.py::TestSonarrHandler -v
# Run specific test
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
# --- JavaScript Tests (vitest) ---
# Run all JavaScript tests
npm test
# Run JavaScript tests in watch mode
npm run test:watch
# Run specific JavaScript test file
npx vitest run static/js/__tests__/auth-api.test.js
```
## Architecture
**Directory Structure:**
```
Ohm_streaming/
├── main.py # FastAPI application startup & middleware
├── app/
│ ├── routers/ # FastAPI routers (API endpoints organized by feature)
│ │ ├── __init__.py # Exports all routers
│ │ ├── router_auth.py # /api/auth/* routes (user authentication)
│ │ ├── router_anime.py # /api/anime/* and /api/series/* routes
│ │ ├── router_downloads.py # /api/download/* routes
│ │ ├── router_favorites.py # /api/favorites/* routes
│ │ ├── router_player.py # /player/* and /watch/* routes
│ │ ├── router_recommendations.py # /api/recommendations and /api/releases routes
│ │ ├── router_root.py # / and /web routes
│ │ ├── router_sonarr.py # /api/sonarr/* and /api/webhook/sonarr routes
│ │ ├── router_static.py # /static/* and /video/* routes
│ │ └── router_watchlist.py # /api/watchlist/* routes
│ ├── models/ # Pydantic models (DownloadTask, AnimeMetadata, Sonarr, etc.)
│ ├── downloaders/ # Host-specific downloaders (organized structure)
│ │ ├── base.py # BaseDownloader abstract class (legacy, kept for compatibility)
│ │ ├── __init__.py # Factory function (three-tier: anime sites → series sites → video players)
│ │ ├── anime_sites/ # Anime streaming sites (catalogs)
│ │ │ ├── base.py # BaseAnimeSite abstract class
│ │ │ ├── __init__.py # Anime site factory
│ │ │ ├── animesama.py # Anime-Sama (anime provider)
│ │ │ ├── animeultime.py # Anime-Ultime (anime provider)
│ │ │ ├── nekosama.py # Neko-Sama (anime provider)
│ │ │ ├── vostfree.py # Vostfree (anime provider)
│ │ │ └── frenchmanga.py # French-Manga (anime provider)
│ │ ├── series_sites/ # TV series streaming sites (catalogs)
│ │ │ ├── base.py # BaseSeriesSite abstract class
│ │ │ ├── __init__.py # Series site factory
│ │ │ └── fs7.py # FS7 (French Stream)
│ │ └── video_players/ # File hosting services (players)
│ │ ├── base.py # BaseVideoPlayer abstract class
│ │ ├── __init__.py # Video player factory
│ │ ├── unfichier.py # 1fichier.com handler
│ │ ├── doodstream.py # Doodstream handler
│ │ ├── rapidfile.py # Rapidfile handler
│ │ ├── uptobox.py # Uptobox handler
│ │ ├── vidmoly.py # VidMoly handler
│ │ ├── sendvid.py # SendVid handler
│ │ ├── sibnet.py # Sibnet handler
│ │ ├── lpayer.py # Lpayer handler
│ │ ├── vidzy.py # Vidzy handler
│ │ ├── luluv.py # LuLuvid handler
│ │ └── uqload.py # Uqload handler
│ ├── providers.py # Provider configuration (domains, icons, colors)
│ ├── config.py # Environment-based configuration (Pydantic Settings)
│ ├── utils.py # Security utilities (sanitize_filename, is_safe_filename)
│ ├── download_manager.py # Manages download queue, progress, parallel downloads
│ ├── favorites.py # Favorites management system (JSON-based)
│ ├── recommendation_engine.py # Analyzes download history for personalized recommendations
│ ├── recommendations.py # Fetches latest releases from anime sources
│ ├── kitsu_api.py # Kitsu API integration for anime metadata
│ ├── sonarr_handler.py # Sonarr webhook integration handler
│ ├── auth.py # JWT authentication system
│ └── models/
│ ├── __init__.py # Core models (DownloadTask, AnimeMetadata, etc.)
│ └── sonarr.py # Sonarr Pydantic models
├── downloads/ # Downloaded files storage
├── templates/
│ ├── index.html # Main web interface
│ ├── player.html # Video player page
│ └── base.html # Base template
├── static/ # Static assets (CSS, JS, images)
│ ├── js/
│ │ ├── __tests__/ # JavaScript tests (vitest)
│ │ │ ├── auth-api.test.js
│ │ │ ├── auth-utils.test.js
│ │ │ └── smoke.test.js
│ │ ├── auth.js # Authentication UI logic
│ │ ├── auth-api.js # Authentication API client
│ │ ├── auth-ui.js # Authentication UI components
│ │ └── auth-utils.js # Authentication utilities
├── tests/ # Python test suite with fixtures
│ ├── e2e/ # End-to-end tests (Playwright)
└── vitest.config.js # Vitest configuration for JS tests
```
**Core Components:**
### 0. Configuration (`app/config.py`)
- `Settings` class using Pydantic Settings for environment-based configuration
- Loads from `.env` file with sensible defaults
- Provides `get_settings()` function for accessing configuration globally
### 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 `asyncio.Semaphore` to limit concurrent downloads
- Auto-restores completed downloads from disk on server startup
### 2. Downloaders (`app/downloaders/`)
**Architecture:**
The downloaders are organized into three categories with separate base classes:
**Anime Sites** (`app/downloaders/anime_sites/`):
- Provide anime catalogs, metadata, and episode listings
- Link to video players for actual file hosting
- Inherit from `BaseAnimeSite` abstract class
- Factory: `get_anime_site(url)` in `anime_sites/__init__.py`
- Implement: `search_anime()`, `get_episodes()`, `get_anime_metadata()`, `get_download_link()`
**Series Sites** (`app/downloaders/series_sites/`):
- Provide TV series catalogs, metadata, and episode listings
- Similar to anime sites but for general TV series content
- Inherit from `BaseSeriesSite` abstract class
- Factory: `get_series_site(url)` in `series_sites/__init__.py`
- Implement: `search_anime()`, `get_episodes()`, `get_anime_metadata()`, `get_download_link()`
**Video Players** (`app/downloaders/video_players/`):
- Host actual video files and provide direct download links
- Extract URLs from embedded players and handle file downloads
- Inherit from `BaseVideoPlayer` abstract class
- Factory: `get_video_player(url)` in `video_players/__init__.py`
- Implement: `get_download_link(url, target_filename=None)`
**Three-Tier Factory Pattern:**
- `get_downloader(url)` in main `__init__.py` checks: anime sites → series sites → video players
- Falls back to `GenericDownloader` if no match
- This separation allows anime/series sites to delegate to video players for actual downloads
**BaseAnimeSite Interface:**
- `can_handle(url)` - Check if this anime site can handle the URL
- `search_anime(query, lang)` - Search for anime, returns list with title, url, cover_image
- `get_episodes(anime_url, lang)` - Get episode list with episode_number, url, title, host
- `get_anime_metadata(anime_url)` - Get metadata dict (synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status)
- `get_download_link(url)` - Get video player URL from episode page (NOT direct download link)
**BaseSeriesSite Interface:**
- `can_handle(url)` - Check if this series site can handle the URL
- `search_anime(query, lang)` - Search for series, returns list with title, url, cover_image, lang
- `get_episodes(anime_url, lang)` - Get episode list with episode_number, url, title, host
- `get_anime_metadata(anime_url)` - Get metadata dict (title, synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status, languages)
- `get_download_link(url)` - Get video player URL from episode page (NOT direct download link)
**BaseVideoPlayer Interface:**
- `can_handle(url)` - Check if this player can handle the URL
- `get_download_link(url, target_filename=None)` - Extract direct download link and filename
- Note: `target_filename` parameter is optional but MUST be supported for VidMoly/SendVid compatibility
- Always use `sanitize_filename()` on extracted filenames!
**Key Patterns:**
- All downloaders use httpx.AsyncClient for HTTP requests
- BeautifulSoup with lxml for HTML parsing
- Async/await throughout for non-blocking I/O
- Fuzzy search using jieba for Chinese text segmentation and typo tolerance
- Security: Filename sanitization enforced via `app.utils` functions
**URL Format Convention:**
- **Pipe-separated format**: `video_url|anime_page_url|episode_title`
- Preserves metadata through the download process
- Example: `https://vidmoly.to/abc123|https://anime-sama.si/catalogue/naruto/s1/vostfr/|Episode+1`
- `target_filename` parameter allows anime/series sites to suggest filenames
- Video players extract the final download link and filename
### 3. Provider Configuration (`app/providers.py`)
- `ANIME_PROVIDERS` - Anime streaming sites configuration
- `FILE_HOSTS` - File hosting services configuration
- Each provider has: name, domains, icon, color, url_pattern
- `detect_provider_from_url(url)` - Identify provider from URL
### 4. Router Architecture (`app/routers/`)
**Overview:**
- API endpoints have been migrated from a monolithic `main.py` (2200+ lines) to modular routers
- Each router is responsible for a specific feature domain
- Routers are imported and registered in `main.py` using FastAPI's APIRouter
- This improves maintainability, testability, and code organization
**Router Organization:**
- `router_auth.py` - `/api/auth/*` - User registration, login, token refresh, profile management
- `router_anime.py` - `/api/anime/*` and `/api/series/*` - Search, metadata, episodes, downloads
- `router_downloads.py` - `/api/download/*` - Download task management (pause, resume, cancel, delete)
- `router_favorites.py` - `/api/favorites/*` - Favorites CRUD operations
- `router_player.py` - `/player/*` and `/watch/*` - Video player endpoints
- `router_recommendations.py` - `/api/recommendations` and `/api/releases/latest` - Personalization and latest releases
- `router_root.py` - `/` and `/web` - Root and main web interface routes
- `router_sonarr.py` - `/api/sonarr/*` and `/api/webhook/sonarr` - Sonarr integration and webhooks
- `router_static.py` - `/static/*` and `/video/*` - Static file serving and video streaming
- `router_watchlist.py` - `/api/watchlist/*` - Watchlist and auto-download scheduler management
**Key Benefits:**
- Clear separation of concerns - each router handles one feature area
- Easier testing - routers can be tested independently
- Better navigation - smaller files focused on specific functionality
- Shared dependencies via FastAPI's dependency injection (e.g., `download_manager`, `get_current_user_from_token`)
- No URL changes - frontend remains fully compatible
**When Adding New Endpoints:**
1. Identify which router the endpoint belongs to based on its URL prefix
2. Add the endpoint function to the appropriate router file in `app/routers/`
3. Use FastAPI dependencies for shared services (`download_manager`, `templates`, authentication)
4. Follow existing patterns for error handling and response models
### 5. API Endpoints
**Download Management:**
- `POST /api/download` - Create new download task
- `GET /api/downloads` - List all download tasks
- `GET /api/download/{task_id}` - Get task details
- `POST /api/download/{task_id}/pause` - Pause download
- `POST /api/download/{task_id}/resume` - Resume download
- `DELETE /api/download/{task_id}` - Delete task (keeps completed files)
- `GET /api/download/{task_id}/file` - Download completed file
**Anime Features:**
- `GET /api/anime/search` - Unified search across all providers
- `GET /api/anime/metadata` - Get anime metadata
- `GET /api/anime/episodes` - Get episode list
- `POST /api/anime/download` - Download single episode
- `POST /api/anime/download-season` - Download entire season
**Video Streaming:**
- `GET /video/{task_id}` - Stream video with Range support
- `GET /stream/{filename}` - Stream by filename
- `GET /player/{task_id}` - Video player page
- `GET /watch/{filename}` - Player by filename
**Recommendations & Favorites:**
- `GET /api/recommendations` - Personalized recommendations
- `GET /api/releases/latest` - Latest anime releases
- `GET /api/favorites` - List favorites
- `POST /api/favorites` - Add favorite
- `DELETE /api/favorites/{anime_id}` - Remove favorite
**Sonarr Integration:**
- `POST /api/webhook/sonarr` - Receive Sonarr webhooks
- `GET /api/sonarr/config` - Get Sonarr configuration
- `PUT /api/sonarr/config` - Update Sonarr configuration
- `GET /api/sonarr/mappings` - List Sonarr to anime mappings
- `POST /api/sonarr/mappings` - Create/update mapping
- `DELETE /api/sonarr/mappings/{series_id}` - Delete mapping
- `GET /api/sonarr/search` - Search anime for mapping
- `GET /api/sonarr/episodes` - Get episode list
- `GET /api/sonarr/suggest` - Suggest anime matches
- `POST /api/sonarr/download` - Manually trigger download
### 6. Web Interface
- Single-page app at `/web` (templates/index.html)
- Auto-refreshes every second to show progress
- Video player with seeking support (HTTP Range headers)
- Dark theme with gradients and animations
### 7. Security Utilities (`app/utils.py`)
- `sanitize_filename(filename, max_length=255)` - Sanitize filenames to prevent path traversal
- Removes dangerous characters: `\ / : * ? " < > |`
- Strips path separators and leading dots/dashes
- Limits filename length while preserving extension
- `is_safe_filename(filename)` - Validate filename safety
- Checks for path traversal patterns (`..`, `/`, `\`)
- Detects absolute paths and drive letters
- Used throughout the codebase for file operations
### 8. Authentication System (`app/auth.py`)
- **UserManager** - JSON-based user storage in `config/users.json`
- User registration with bcrypt password hashing
- Password truncated to 72 bytes (bcrypt limitation)
- User authentication and last login tracking
- **JWT Tokens** - Stateless authentication with refresh token support
- Access tokens: 24-hour expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`)
- Refresh tokens: 30-day expiration (stored in `config/refresh_tokens.json`)
- HS256 algorithm with JWT_SECRET_KEY (change in production!)
- Token verification and user extraction
- **Password Security**
- bcrypt hashing with passlib
- Automatic deprecated scheme migration
- **JWT Secret Validation** (in `app/config.py`)
- Default secret is rejected at startup (security enforcement)
- Minimum 32 characters required
- Use `Settings.generate_secret()` to generate secure secrets
- **Configuration**
- `JWT_SECRET_KEY` environment variable (MUST be changed from default)
- Users stored in `config/users.json`
- Refresh tokens stored in `config/refresh_tokens.json`
**Authentication Endpoints:**
- `POST /api/auth/register` - User registration
- `POST /api/auth/login` - Login and receive JWT token
- `GET /api/auth/me` - Get current user profile
- `PUT /api/auth/me` - Update user profile
### 9. Recommendation Engine (`app/recommendation_engine.py`)
- Analyzes download history to generate personalized recommendations
- Tracks genre preferences and viewing patterns
- Scores anime based on user's download history
- Used by `/api/recommendations` endpoint
### 10. Kitsu API (`app/kitsu_api.py`)
- Integrates with Kitsu anime database for metadata
- Fetches anime information by title or ID
- Provides enriched metadata (synopsis, genres, ratings, poster images)
- Used as fallback when provider metadata is incomplete
### 11. Watchlist & Auto-Download System
**WatchlistManager** (`app/watchlist.py`):
- JSON-based storage in `config/watchlist.json`
- Per-user watchlist management (multi-tenant)
- CRUD operations for tracked anime
- Statistics and queries
- Settings management in `config/watchlist_settings.json`
**EpisodeChecker** (`app/episode_checker.py`):
- Checks for new episodes for anime in watchlist
- Downloads episodes automatically when detected
- Integrates with existing downloaders
- Handles errors and retries
- Lazy initialization to avoid circular imports
**AutoDownloadScheduler** (`app/auto_download_scheduler.py`):
- APScheduler-based periodic checking
- Configurable intervals (1-168 hours)
- Start/stop control via API
- Next run tracking
- Background task execution
**Watchlist Models** (`app/models/watchlist.py`):
- `WatchlistItem` - Tracked anime with settings
- `WatchlistStatus` - ACTIVE, PAUSED, COMPLETED, ARCHIVED
- `QualityPreference` - AUTO, 1080p, 720p, 480p
- `WatchlistSettings` - Global configuration
- `AutoDownloadResult` - Operation results
**Watchlist Endpoints:**
- `GET /api/watchlist` - List user's watchlist (with status filter)
- `POST /api/watchlist` - Add anime to watchlist
- `GET /api/watchlist/{item_id}` - Get specific item
- `PUT /api/watchlist/{item_id}` - Update watchlist item
- `DELETE /api/watchlist/{item_id}` - Remove from watchlist
- `POST /api/watchlist/{item_id}/check` - Check specific anime
- `POST /api/watchlist/check-all` - Check all due items
- `POST /api/watchlist/{item_id}/pause` - Pause tracking
- `POST /api/watchlist/{item_id}/resume` - Resume tracking
- `GET /api/watchlist/settings` - Get global settings
- `PUT /api/watchlist/settings` - Update settings
- `GET /api/watchlist/stats` - Get watchlist statistics
- `GET /api/watchlist/scheduler/status` - Get scheduler status
- `POST /api/watchlist/scheduler/start` - Start scheduler
- `POST /api/watchlist/scheduler/stop` - Stop scheduler
### 12. Pydantic Models (`app/models/`)
- **`__init__.py`** - Core models:
- `DownloadStatus` - Enum for task states (PENDING, DOWNLOADING, PAUSED, COMPLETED, FAILED, CANCELLED)
- `HostType` - Enum for file host types (RAPIDFILE, UNFICHIER, DOODSTREAM, OTHER)
- `DownloadTask` - Main task model with progress tracking
- `DownloadRequest` - Request model for creating downloads
- `AnimeMetadata` - Anime information (synopsis, genres, rating, release_year, studio, etc.)
- `AnimeSearchResult` - Enhanced search result with metadata
- **`sonarr.py`** - Sonarr-specific models:
- `SonarrWebhookPayload` - Complete webhook payload schema
- `SonarrEventType` - Enum for event types (Grab, Download, Rename, Delete, Test)
- `SonarrMapping` - Mapping between Sonarr series and anime providers
- `SonarrConfig` - Webhook configuration (enabled, secret, auto-download, etc.)
- **`auth.py`** - Authentication models:
- `UserCreate` - User registration request
- `UserLogin` - Login request
- `User` - User profile
- `Token` - JWT token response
- **`watchlist.py`** - Watchlist models:
- `WatchlistItem` - Tracked anime item
- `WatchlistItemCreate` - Create request
- `WatchlistItemUpdate` - Update request
- `WatchlistStatus` - Status enum
- `WatchlistSettings` - Global settings
## Test Structure
**Python Test Organization (tests/):**
- `conftest.py` - Pytest configuration and fixtures
- `test_models.py` - Pydantic model tests
- `test_downloaders.py` - Downloader tests
- `test_download_manager.py` - DownloadManager tests
- `test_favorites.py` - Favorites system tests
- `test_api.py` - FastAPI endpoint tests
- `test_sonarr.py` - Sonarr integration tests
- `test_anime_sama_seasons.py` - Anime-Sama season handling tests
- `test_translate_api.py` - Translation API tests
- `test_delete_and_restore.py` - Delete and restore functionality tests
- `test_french_manga.py` - French-Manga provider tests
- `test_jwt_secret_validation.py` - JWT secret key validation tests
- `test_token_refresh.py` - Token refresh functionality tests
**JavaScript Test Organization (static/js/__tests__/):**
- `smoke.test.js` - Basic smoke tests
- `auth-api.test.js` - Authentication API client tests
- `auth-utils.test.js` - Authentication utility function tests
- Uses Vitest with jsdom environment
- Coverage reports generated in `htmlcov/` (shared with Python tests)
**Fixtures in conftest.py:**
- `temp_dir` - Temporary directory
- `temp_download_dir` - Temporary download directory
- `download_manager` - DownloadManager instance
- `favorites_manager` - FavoritesManager instance
- `mock_httpx_client` - Mock for httpx.AsyncClient
- `sample_download_task` - Sample task data
- `sample_anime_metadata` - Sample metadata
**Test Markers:**
- `unit` - Unit tests (isolated, fast) - auto-applied
- `integration` - Integration tests (API endpoints) - auto-applied
- `asyncio` - Async tests - auto-applied
- `slow` - Slow tests - manual
- `network` - Requires network - manual
**pytest.ini Configuration:**
- Auto-applies markers for async and integration tests
- Coverage enabled by default (`--cov=app`)
- HTML coverage report generated in `htmlcov/`
- Verbose output with local variables in tracebacks
- 300-second timeout for tests
- `asyncio_mode = auto` for async test support
**Running Single Test:**
```bash
# Run specific test file
pytest tests/test_sonarr.py -v
# Run specific test class
pytest tests/test_sonarr.py::TestSonarrHandler -v
# Run specific test
pytest tests/test_sonarr.py::TestSonarrHandler::test_add_mapping -v
```
## Adding New Host Support
To add support for a new file hosting service:
1. Create new file in `app/downloaders/video_players/` (e.g., `myhost.py`)
2. Inherit from `BaseVideoPlayer`
3. Implement required methods (`can_handle`, `get_download_link`)
4. Add to imports in `app/downloaders/video_players/__init__.py`
5. Add to `players` list in `get_video_player()`
6. Add configuration to `FILE_HOSTS` in `app/providers.py`
Example:
```python
from .base import BaseVideoPlayer
from bs4 import BeautifulSoup
class MyHostDownloader(BaseVideoPlayer):
def can_handle(self, url: str) -> bool:
return "myhost.com" in url.lower()
async def get_download_link(self, url: str, target_filename: Optional[str] = None) -> tuple[str, str]:
soup = BeautifulSoup(await self._fetch_page(url), 'lxml')
# ... extraction logic ...
# IMPORTANT: Always sanitize filenames!
from app.utils import sanitize_filename
filename = sanitize_filename(extracted_filename)
return download_url, filename
async def close(self):
# IMPORTANT: Always close the HTTP client
await self.client.aclose()
```
**Important:**
- Always close the HTTP client in your downloader to avoid resource leaks
- Use `sanitize_filename()` from `app.utils` when extracting filenames from URLs
- Use `is_safe_filename()` to validate filenames before file operations
- The `target_filename` parameter is required for compatibility with anime/series sites
## Adding New Series Site
To add a new TV series streaming provider (similar to anime sites but for general TV series):
1. Create new file in `app/downloaders/series_sites/` (e.g., `mysite.py`)
2. Inherit from `BaseSeriesSite`
3. Implement series-specific methods:
- `search_anime(query, lang)` - Return list of series with title, url, cover_image, lang
- `get_episodes(anime_url, lang)` - Return list of episodes
- `get_anime_metadata(anime_url)` - Return metadata dict (should include languages field)
- `get_download_link(url)` - Return video player URL from episode page
4. Add to imports in `app/downloaders/series_sites/__init__.py`
5. Add to `sites` list in `get_series_site()`
BaseSeriesSite is nearly identical to BaseAnimeSite but designed for general TV series content rather than anime-specific content.
## Sonarr Integration
The application includes full Sonarr webhook support for automated anime downloads.
### Architecture
**SonarrHandler (`app/sonarr_handler.py`):**
- Processes incoming webhooks from Sonarr
- Manages series mappings (Sonarr TVDB ID → Anime Provider URL)
- Supports HMAC SHA256 signature verification for security
- Auto-triggers downloads on Grab events
- Provides search and suggestion APIs for mapping setup
**Sonarr Models (`app/models/sonarr.py`):**
- `SonarrWebhookPayload` - Complete webhook payload schema
- `SonarrEventType` - Enum for event types (Grab, Download, Rename, Delete, Test)
- `SonarrMapping` - Mapping between Sonarr series and anime providers
- `SonarrConfig` - Webhook configuration (enabled, secret, auto-download, etc.)
### Workflow
1. **Setup in Sonarr:**
- Configure webhook: Settings > Connect > Sonarr > Webhook
- URL: `http://your-server:3000/api/webhook/sonarr`
- Enable "Grab" event
2. **Create Mappings:**
- Get Sonarr series TVDB ID from series details
- Search anime: `GET /api/sonarr/search?q={title}`
- Create mapping: `POST /api/sonarr/mappings`
3. **Automatic Download:**
- Sonarr grabs new episode → Sends webhook
- Ohm Stream Downloader receives webhook
- Looks up mapping by TVDB ID
- Finds matching episode on anime provider
- Creates and starts download task
### Configuration Files
- `config/sonarr.json` - Webhook configuration
- `config/sonarr_mappings.json` - Series mappings
### Example Mapping
```json
{
"sonarr_series_id": 79644,
"sonarr_title": "Naruto Shippuden",
"anime_provider": "anime-sama",
"anime_url": "https://anime-sama.si/catalogue/naruto-shippuden/saison1/vostfr/",
"anime_title": "Naruto Shippuden",
"lang": "vostfr",
"quality_preference": "1080p",
"auto_download": true
}
```
### Security
- Optional HMAC SHA256 signature verification
- Configure secret in both Sonarr and Ohm Stream Downloader
- Enable with `verify_hmac: true` in config
### Testing
- Test endpoint: `POST /api/webhook/test/sonarr`
- Manual trigger: `POST /api/sonarr/download`
- Get suggestions: `GET /api/sonarr/suggest?sonarr_title={title}`
**Documentation:** See `docs/SONARR_INTEGRATION.md` for complete setup guide.
## Adding New Anime Provider
To add a new anime streaming provider:
1. Create new file in `app/downloaders/anime_sites/` (e.g., `mysite.py`)
2. Inherit from `BaseAnimeSite`
3. Implement anime-specific methods:
- `search_anime(query, lang)` - Return list of anime with title, url, cover_image
- `get_episodes(anime_url, lang)` - Return list of episodes
- `get_anime_metadata(anime_url)` - Return metadata dict
- `get_download_link(url)` - Return video player URL from episode page
4. Add to imports in `app/downloaders/anime_sites/__init__.py`
5. Add to `sites` list in `get_anime_site()`
6. Add to `ANIME_PROVIDERS` in `app/providers.py`
7. Update `main.py` to include in unified search
Metadata should include:
- synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status
## Working with Routers
**Adding New Endpoints:**
1. Identify which router handles the URL prefix you need
2. Edit the appropriate router file in `app/routers/`
3. Use FastAPI's APIRouter pattern with proper dependencies
4. Import the router in `app/routers/__init__.py` if creating a new router
5. Register the router in `main.py`
**Example - Adding a new endpoint to router_anime.py:**
```python
from fastapi import APIRouter, Depends
from app.download_manager import DownloadManager
router = APIRouter(prefix="/api/anime", tags=["anime"])
@router.get("/custom-endpoint")
async def custom_endpoint(
download_manager: DownloadManager = Depends(lambda: download_manager)
):
# Your logic here
return {"status": "success"}
```
**Common Dependencies:**
- `download_manager: DownloadManager = Depends(lambda: download_manager)` - Access download queue
- `current_user: User = Depends(get_current_user_from_token)` - Authenticated user
- `templates: Jinja2Templates = Depends(lambda: templates)` - Template rendering
**Router Organization Principles:**
- Group related endpoints by URL prefix
- Keep routers focused on a single feature area
- Use dependency injection for shared services
- Tag routers for OpenAPI documentation
## Configuration
The application uses environment variables for configuration via `app/config.py` (Pydantic Settings).
**Environment Variables (.env):**
```bash
# Copy the example file
cp .env.example .env
# Edit .env to configure:
APP_NAME=Ohm Stream Downloader # Application name
DEBUG=false # Debug mode
HOST=0.0.0.0 # Server host
PORT=3000 # Server port
DOWNLOAD_DIR=downloads # Download storage location
MAX_PARALLEL_DOWNLOADS=3 # Maximum concurrent downloads
CHUNK_SIZE=1048576 # Download chunk size (1MB)
CORS_ORIGINS=... # Comma-separated allowed origins
HTTP_TIMEOUT=10.0 # HTTP request timeout (seconds)
DOWNLOAD_TIMEOUT=300 # Download timeout (seconds)
LOG_LEVEL=INFO # Logging level
JWT_SECRET_KEY=change-me-in-production # JWT signing key (MUST be changed, min 32 chars)
# Generate a secure key with: python -c "from app.config import Settings; print(Settings.generate_secret())"
```
**Configuration Files:**
- `.env` - Environment configuration (create from .env.example)
- `config/users.json` - User authentication database (created automatically)
- `config/refresh_tokens.json` - Refresh token storage (created automatically)
- `config/sonarr.json` - Sonarr webhook configuration (created automatically)
- `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically)
- `config/watchlist.json` - User watchlist items (created automatically)
- `config/watchlist_settings.json` - Watchlist global settings (created automatically)
- `config/.gitkeep` - Ensures config directory is tracked in git
- Example files: `config/sonarr.example.json`, `config/sonarr_mappings.example.json`
**Documentation:**
- `README.md` - User-facing features and roadmap
- `CLAUDE.md` - This file (developer guide)
- `docs/SONARR_INTEGRATION.md` - Complete Sonarr setup guide
- `docs/SONARR_IMPLEMENTATION.md` - Technical implementation summary
- `docs/IMPROVEMENTS_2024-01-24.md` - Recent security and quality improvements
- `docs/WATCHLIST_AUTO_DOWNLOAD.md` - Watchlist system documentation
## Security
**Filename Sanitization (`app/utils.py`):**
- `sanitize_filename()` - Removes dangerous characters (`\ / : * ? " < > |`)
- `is_safe_filename()` - Validates against path traversal patterns
- Used throughout the codebase for all file operations
- Prevents `../../../etc/passwd` style attacks
- Limits filename length to 255 characters
**CORS Configuration:**
- Restricted origins (not `*`) in production
- Specific allowed methods (GET, POST, PUT, DELETE, PATCH, OPTIONS)
- Configured in `main.py` via environment variables
**Authentication:**
- JWT token-based authentication with 24-hour access token expiration
- Refresh token support with 30-day expiration
- bcrypt password hashing with passlib
- Passwords truncated to 72 bytes (bcrypt limitation)
- JWT secret key validation (minimum 32 characters, default rejected)
- Credentials stored in `config/users.json`
- Refresh tokens stored in `config/refresh_tokens.json`
## Key Implementation Details
**Resume Support:**
- Downloads use HTTP Range headers to resume from last byte
- Files downloaded in 1MB chunks
- Partial files cleaned up on cancel
- Resume position tracked in `downloaded_bytes` field
**Domain Handling:**
- Anime providers use dynamic domain detection (e.g., Anime-Sama fetches current domain from anime-sama.pw)
- Multiple domains per provider supported in configuration
- Domain detection via `detect_provider_from_url(url)` in providers.py
**Task Lifecycle:**
- PENDING → DOWNLOADING → PAUSED / COMPLETED / CANCELLED / FAILED
- Active downloads tracked in `active_downloads` dict
- All tasks stored in `tasks` dict with UUID keys
- Completed files preserved when deleting tasks (only partial files removed)
**Video Streaming:**
- Range header support for seeking in video player
- Serves from `/downloads` directory via StaticFiles
- Video extensions: .mp4, .mkv, .avi, .mov, .wmv, .flv, .webm
**Error Handling:**
- Graceful degradation with status tracking
- Network errors caught and reported in task status
- Automatic retry on resume
- Downloads > 1MB considered complete to skip small error files
## Dependencies
**Core:**
- fastapi - Web framework
- uvicorn - ASGI server
- httpx - Async HTTP client
- beautifulsoup4, lxml - HTML parsing
- aiofiles - Async file operations
- jieba - Chinese text segmentation for fuzzy search
- passlib[bcrypt] - Password hashing
- python-jose[cryptography] - JWT token handling
- apscheduler - Task scheduling for auto-download
- pydantic-settings - Environment-based configuration
**Python Testing:**
- pytest - Test framework
- pytest-asyncio - Async test support
- pytest-cov - Coverage reporting
- pytest-mock - Mocking support
- pytest-timeout - Test timeout handling
- pytest-html - HTML test reports
**JavaScript Testing (optional, for frontend):**
- vitest - Fast JavaScript test runner
- jsdom - DOM implementation for tests
- @playwright/test - End-to-end browser testing
+56
View File
@@ -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"]
-74
View File
@@ -1,74 +0,0 @@
# 🔧 Correction Import Error - VidMoly
## Problème
Quand on tentait un téléchargement depuis le web avec une URL Anime-Sama qui pointait vers VidMoly:
```
Error extracting AnimeSama link: Error extracting from vidmoly:
No module named 'app.downloaders.anime_sites.vidmoly'
```
## Cause Racine
Après la restructuration, les players vidéo ont été déplacés de `app/downloaders/` vers `app/downloaders/video_players/`, mais `AnimeSamaDownloader` essayait encore d'importer `VidMolyDownloader` depuis `anime_sites/`:
```python
# ❌ Ancien import (ne fonctionne plus)
from .vidmoly import VidMolyDownloader
```
## Solution
Corriger tous les imports de players vidéo dans `AnimeSamaDownloader`:
```python
# ✅ Nouvel import (correct)
from ..video_players.vidmoly import VidMolyDownloader
from ..video_players.sendvid import SendVidDownloader
from ..video_players.sibnet import SibnetDownloader
from ..video_players.lpayer import LpayerDownloader
```
## Fichiers Modifiés
**`app/downloaders/anime_sites/animesama.py`**:
- Ligne 195: `from ..video_players.vidmoly import VidMolyDownloader`
- Ligne 257: `from ..video_players.sendvid import SendVidDownloader`
- Ligne 304: `from ..video_players.sibnet import SibnetDownloader`
- Ligne 401: `from ..video_players.lpayer import LpayerDownloader`
## Vérification
✅ **23/23 tests passants**
✅ **Téléchargement test**: Anime-Sama → VidMoly fonctionne
✅ **API endpoint**: `/api/download` fonctionne correctement
✅ **Imports**: Tous les paths sont corrects
## Tests
```python
# Test d'un téléchargement complet
POST /api/download
{
"url": "https://anime-sama.si/catalogue/naruto/saison1/vostfr/episode-1"
}
# Réponse: 200 OK
{
"task_id": "...",
"status": "pending",
...
}
```
## Autres Sites Anime
✅ **NekoSama**: Aucun import de video player (OK)
✅ **AnimeUltime**: Aucun import de video player (OK)
✅ **Vostfree**: Aucun import de video player (OK)
Seul `AnimeSama` utilise des imports directs de video players.
---
**Statut**: ✅ Corrigé et testé
**Impact**: Le téléchargement depuis le web fonctionne maintenant
-50
View File
@@ -1,50 +0,0 @@
# ✅ Verification Frontend - Restructuration
## Tests Effectués
### 1. ✅ Application Startup
- Import de `main.py`: ✅ réussi
- 59 routes chargées: ✅
- Routes clés présentes:
- `/api/download` ✅
- `/api/downloads` ✅
- `/api/anime/search` ✅
- `/web` ✅
### 2. ✅ Providers API
- **Endpoint**: `GET /api/providers`
- **Status**: 200 ✅
- **Anime providers**: 4 (Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree)
- **File hosts**: 8 (1fichier, Uptobox, Doodstream, Rapidfile, VidMoly, SendVid, Sibnet, Lplayer)
### 3. ✅ Downloader Routing
Tous les downloaders sont correctement routés:
- DoodStreamDownloader ✅
- AnimeSamaDownloader ✅
- NekoSamaDownloader ✅
- SibnetDownloader ✅
- VidMolyDownloader ✅
- SendVidDownloader ✅
- UnFichierDownloader ✅
- UptoboxDownloader ✅
- RapidFileDownloader ✅
- LpayerDownloader ✅
### 4. ✅ Frontend Pages
- **Page d'accueil** (`/web`): Status 200, HTML valide ✅
- **API downloads** (`/api/downloads`): Status 200, retourne dict ✅
## Modifications Apportées
### `app/providers.py`
Ajout des 4 nouveaux file hosts qui manquaient:
- VidMoly (vidmoly.to, vidmoly.org, vidmoly.biz)
- SendVid (sendvid.com, sendvid.io)
- Sibnet (sibnet.ru, video.sibnet.ru)
- Lplayer (lpayer.embed4me.com, lpayer.com, lplayer.fr)
## Conclusion
✅ **Le frontend fonctionne parfaitement avec la nouvelle structure!**
Aucune rupture de fonctionnalité détectée. Tous les endpoints API sont opérationnels et le frontend peut accéder à tous les providers.
-102
View File
@@ -1,102 +0,0 @@
# ✅ Rapport Final - Vérification Frontend
## Date: 2026-01-24
## 🎯 Conclusion
**🎉 Le frontend est 100% cohérent et fonctionnel!**
Aucune erreur ou incohérence détectée.
## 📊 Fichiers Vérifiés
### Static Files (11 fichiers)
✅ **JavaScript (7 fichiers)**:
- api.js (3,545 octets)
- utils.js (2,429 octets)
- downloads.js (14,380 octets)
- anime.js (14,085 octets)
- anime-details.js (18,829 octets)
- recommendations.js (11,008 octets)
- main.js (7,494 octets)
✅ **CSS (1 fichier)**:
- style.css (31,976 octets)
✅ **Templates HTML (3 fichiers)**:
- index.html (286 octets)
- base.html (834 octets)
- player.html (6,082 octets)
## 🔗 Vérifications Effectuées
### 1. ✅ Intégrité des Fichiers
- Tous les fichiers JS/CSS/HTML sont présents
- Tous les fichiers référencés dans base.html existent
- Aucun lien cassé
### 2. ✅ Cohérence Frontend/Backend
Tous les endpoints API fonctionnent:
- `GET /web` → 200 ✅
- `GET /api/providers` → 200 ✅
- `GET /api/downloads` → 200 ✅
- `POST /api/download` → 200 ✅
### 3. ✅ Providers Configurés
**8 File hosts** (tous complets avec name, domains, icon, color):
1. 1fichier ✅
2. Uptobox ✅
3. Doodstream ✅
4. Rapidfile ✅
5. VidMoly ✅
6. SendVid ✅
7. Sibnet ✅
8. Lplayer ✅
**4 Anime sites**:
1. Anime-Sama ✅
2. Neko-Sama ✅
3. Anime-Ultime ✅
4. Vostfree ✅
### 4. ✅ Imports JavaScript
- Tous les imports entre modules JS sont valides
- Les appels API utilisent les bons endpoints
- Les références aux providers sont cohérentes
### 5. ✅ Structure HTML/CSS
- base.html référence correctement tous les scripts
- Les IDs et classes CSS sont cohérents
- Les styles sont correctement chargés
## 📝 Tests Réalisés
| Test | Résultat | Détails |
|------|----------|---------|
| Fichiers statiques | ✅ | 11/11 présents |
| Références HTML | ✅ | Tous les liens valides |
| Endpoints API | ✅ | 4/4 fonctionnels |
| Providers | ✅ | 12/12 complets |
| Imports JS | ✅ | Aucune erreur |
| Cohérence CSS | ✅ | Styles chargés |
## ✨ Points Forts du Frontend
1. **Code propre**: Gestion d'erreur présente dans tous les fichiers JS
2. **Modulaire**: Séparation claire (api, utils, downloads, anime, etc.)
3. **Complet**: Tous les endpoints backend sont accessibles
4. **Maintenable**: Structure claire et bien organisée
5. **Robuste**: Gestion d'erreur à tous les niveaux
## 🚀 Après Restructuration
La restructuration des downloaders n'a **AUCUN IMPACT** négatif sur le frontend:
- Tous les endpoints API fonctionnent identiquement
- Les providers sont tous accessibles
- L'interface web est pleinement fonctionnelle
- Aucune modification nécessaire dans le code JS
---
**Vérifié par**: Claude Code
**Date**: 2026-01-24
**Statut**: ✅ Frontend 100% valide
-101
View File
@@ -1,101 +0,0 @@
# GEMINI.md - Project Context & Instructions
This file provides foundational context and instructions for AI agents working on the **Ohm Stream Downloader** project.
## 🚀 Project Overview
**Ohm Stream Downloader** is a full-stack web application designed for searching, streaming, and downloading anime and TV series from various French and international providers. It features a modern SPA-like interface, automated watchlist tracking, and integration with ecosystem tools like Sonarr.
- **Backend:** Python 3.11+ with **FastAPI**, Uvicorn, Pydantic (v2), and APScheduler.
- **Frontend:** Vanilla JavaScript (modular), Jinja2 templates, and CSS.
- **Testing:** Pytest (backend), Vitest & Playwright (frontend).
- **Architecture:** Modular routers and a specialized three-tier downloader system.
---
## 🛠️ Quick Start
### Installation
```bash
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
npm install # For frontend tests
```
### Running the Application
```bash
# Start development server (Port 3000)
uvicorn main:app --reload --host 0.0.0.0 --port 3000
```
Access the web interface at `http://localhost:3000/web`.
### Running Tests
```bash
# Backend (Pytest)
pytest # Run all tests
pytest -m "unit" # Fast unit tests
pytest -m "integration" # API integration tests
# Frontend (Vitest)
npm test # Run JS tests
npx playwright test # E2E tests
```
---
## 🏗️ Architecture & Core Logic
### Three-Tier Downloader System
Logic is separated into three distinct layers in `app/downloaders/`:
1. **Anime/Series Catalogs** (`anime_sites/`, `series_sites/`): Handles searching, metadata extraction (synopsis, posters), and episode listing (e.g., Anime-Sama, FS7).
2. **Video Players** (`video_players/`): Extracts direct download links from embedded players (e.g., VidMoly, DoodStream, 1fichier).
3. **Download Manager** (`app/download_manager.py`): Orchestrates the actual file transfer, supporting parallel downloads, pause/resume (via HTTP Range), and progress tracking.
### Key Modules
- `app/routers/`: Modular API endpoints (Auth, Anime, Watchlist, Sonarr, etc.).
- `app/watchlist.py`: User-specific tracking and automated episode detection.
- `app/sonarr_handler.py`: Webhook integration for Sonarr.
- `static/js/`: Feature-scoped frontend logic (api.js, auth.js, watchlist-ui.js, etc.).
---
## 📝 Development Conventions
### Coding Style (Python)
- **Formatting:** PEP 8, 120 chars max line length.
- **Typing:** Use explicit Pydantic models and type hints (`Optional[X]`, `list[X]`).
- **Async:** Always use `async/await` for I/O (httpx, aiofiles).
- **Naming:** `snake_case` for functions/variables, `PascalCase` for classes/enums.
### Security & Safety
- **Filename Sanitization:** ALWAYS use `app.utils.sanitize_filename()` before any disk write.
- **Path Validation:** Use `app.utils.is_safe_filename()` to prevent traversal attacks.
- **Authentication:** JWT-based. `JWT_SECRET_KEY` must be at least 32 chars and never the default.
- **Secrets:** Never hardcode. Use `.env` (via `app/config.py`).
### Testing Requirements
- All new features **must** include tests in `tests/`.
- Use pytest markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.network`.
- Verify changes with `pytest --cov=app` to ensure coverage.
---
## ⚙️ Configuration
- **Environment:** `.env` file (see `.env.example`).
- **JSON Storage:** Data persists in `config/` (users, watchlist, sonarr mappings).
- **Downloads:** Default directory is `downloads/`.
## 📂 Key File Map
| Path | Purpose |
| :--- | :--- |
| `main.py` | App entry & middleware |
| `app/models/` | Pydantic schemas |
| `app/routers/` | API Route definitions |
| `app/downloaders/` | Provider-specific scraping logic |
| `templates/` | HTML (Jinja2) |
| `static/js/` | Frontend logic |
| `config/` | Persistent JSON data |
---
*For detailed developer guides, refer to `CLAUDE.md` and `AGENTS.md`.*
-119
View File
@@ -1,119 +0,0 @@
# ✅ Rapport de Vérification - Imports Complets
## Date: 2026-01-24
## 🔍 Vérifications Effectuées
### 1. ✅ Analyse Statique du Code
- **14 fichiers Python** vérifiés dans la nouvelle structure
- **0 erreur** d'import détectée
- Fichiers vérifiés:
- `anime_sites/`: animesama.py, nekosama.py, animeultime.py, vostfree.py, base.py
- `video_players/`: doodstream.py, sibnet.py, vidmoly.py, sendvid.py, lpayer.py, unfichier.py, uptobox.py, rapidfile.py, base.py
### 2. ✅ Test des Imports Python
Tous les imports testés avec succès:
**Imports principaux:**
```python
from app.downloaders import (
get_downloader, BaseDownloader, GenericDownloader,
# Video players (8)
BaseVideoPlayer, DoodStreamDownloader, SibnetDownloader,
VidMolyDownloader, SendVidDownloader, LpayerDownloader,
UnFichierDownloader, UptoboxDownloader, RapidFileDownloader,
# Anime sites (4)
BaseAnimeSite, AnimeSamaDownloader, NekoSamaDownloader,
AnimeUltimeDownloader, VostfreeDownloader
)
```
**Imports factories:**
```python
from app.downloaders.video_players import get_video_player
from app.downloaders.anime_sites import get_anime_site
```
**Imports directs (modules individuels):**
```python
from app.downloaders.video_players.vidmoly import VidMolyDownloader
from app.downloaders.video_players.sendvid import SendVidDownloader
from app.downloaders.video_players.sibnet import SibnetDownloader
from app.downloaders.video_players.lpayer import LpayerDownloader
from app.downloaders.anime_sites.animesama import AnimeSamaDownloader
from app.downloaders.anime_sites.nekosama import NekoSamaDownloader
```
### 3. ✅ Test d'Instanciation et Typage
Toutes les classes s'instancient correctement:
- `VidMolyDownloader()` → instance de `BaseVideoPlayer` ✅
- `SendVidDownloader()` → instance de `BaseVideoPlayer` ✅
- `AnimeSamaDownloader()` → instance de `BaseAnimeSite` ✅
- `NekoSamaDownloader()` → instance de `BaseAnimeSite` ✅
### 4. ✅ Test des Imports Croisés
L'import croisé critique fonctionne:
```python
# Dans AnimeSamaDownloader._extract_from_vidmoly():
from ..video_players.vidmoly import VidMolyDownloader # ✅ CORRECT
```
Autres imports croisés dans AnimeSama:
- `from ..video_players.sendvid import SendVidDownloader` ✅
- `from ..video_players.sibnet import SibnetDownloader` ✅
- `from ..video_players.lpayer import LpayerDownloader` ✅
### 5. ✅ Tests Frontend
Tous les endpoints API fonctionnent:
| Endpoint | Status | Résultat |
|----------|--------|----------|
| `GET /web` | 200 | ✅ Page HTML chargée |
| `GET /api/providers` | 200 | ✅ 4 anime + 8 hosts |
| `POST /api/download` | 200 | ✅ Task créé |
| `GET /api/downloads` | 200 | ✅ Liste téléchargements |
### 6. ✅ Tests Pytest
```bash
pytest tests/test_downloaders.py -v
======================== 23 passed, 3 warnings in 1.56s ========================
```
## 📊 Résultat Global
| Catégorie | Status | Détails |
|-----------|--------|---------|
| **Structure** | ✅ | 12 fichiers déplacés correctement |
| **Imports** | ✅ | Tous les imports fonctionnent |
| **Typage** | ✅ | Héritage correct (BaseVideoPlayer, BaseAnimeSite) |
| **Frontend** | ✅ | Tous les endpoints API opérationnels |
| **Tests** | ✅ | 23/23 tests passants |
| **Imports croisés** | ✅ | AnimeSama → VideoPlayers fonctionne |
## 🎯 Imports Corrigés
Fichier: `app/downloaders/anime_sites/animesama.py`
| Ligne | Avant | Après |
|-------|-------|-------|
| 195 | `from .vidmoly import` | `from ..video_players.vidmoly import` |
| 257 | `from .sendvid import` | `from ..video_players.sendvid import` |
| 304 | `from .sibnet import` | `from ..video_players.sibnet import` |
| 401 | `from .lpayer import` | `from ..video_players.lpayer import` |
## ✨ Conclusion
🎉 **Tous les imports sont corrects et fonctionnels!**
- Aucune erreur d'import détectée
- La structure est propre et maintenable
- Le frontend fonctionne parfaitement
- Tous les tests passent
- Les imports croisés (anime_sites → video_players) fonctionnent
**La restructuration est complète et 100% opérationnelle!**
---
**Vérifié par**: Claude Code
**Date**: 2026-01-24
**Statut**: ✅ Validé
+96
View File
@@ -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.
+189 -191
View File
@@ -1,222 +1,220 @@
# Ohm Stream Downloader
# ⛩ Ohm Stream Downloader
**Application web complète pour rechercher, streamer et télécharger des animes, séries TV et films.**
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.
Interface moderne (SPA-like) avec recherche unifiée, watchlist automatique, métadonnées enrichies, téléchargements parallèles et intégration Sonarr. Propulsée par FastAPI, SQLModel et une interface dynamique HTMX/Alpine.js.
## Déploiement (Docker) — recommandé
## ✨ Fonctionnalités
Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est
hébergée sur le **registre privé du Gitea** — rien n'est publié publiquement.
### 🎬 Recherche & Streaming
- **Recherche unifiée** : Recherchez animes et séries TV simultanément via plusieurs sources.
- **Providers Anime** : Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga.
- **Providers Séries** : FS7 (French-Stream), Zone-Telechargement.
- **Métadonnées riches** : Synopsis, genres, notes, studio via intégration Kitsu/MAL.
- **Streaming vidéo** : Lecteur **Plyr.io** intégré supportant plus de 15 hébergeurs.
- **Téléchargement flexible** : Épisode par épisode ou saison complète.
### Installation guidée (recommandée)
### 🔐 Authentification
- **Inscription / Connexion** : Système JWT avec tokens d'accès et de refresh.
- **Sécurité** : Clé secrète configurable, tokens expirables (24h access, 30j refresh).
### 📋 Watchlist & Automatisation
- **Suivi intelligent** : Ajoutez des titres à votre watchlist pour ne rater aucun épisode.
- **Auto-Download** : Téléchargement automatique des nouveaux épisodes dès leur parution.
- **Planificateur (Scheduler)** : Vérification périodique configurable (1h à 168h).
- **Filtres avancés** : Visualisation par statut (Actif, En pause, Terminé).
- **Intégration Sonarr** : Support des webhooks pour une automatisation complète du homelab.
### ⭐ Favoris & Recommandations
- **Favoris** : Sauvegardez vos animes préférés avec tri et filtres.
- **Recommandations** : Suggestions basées sur les tendances et sorties récentes.
- **Sorties saisonnières** : Suivi des sorties anime (top, latest, seasonal).
### 🚀 Gestionnaire de Téléchargements
- **Multi-threading** : Jusqu'à 5 téléchargements simultanés avec gestion de file d'attente.
- **Pause/Reprise** : Support du protocole HTTP Range pour reprendre les téléchargements interrompus.
- **Progression Temps Réel** : Vitesse (Mo/s), pourcentage et estimation du temps restant.
- **Sanitisation** : Nettoyage automatique des noms de fichiers pour une compatibilité maximale.
### ⚙️ Paramètres
- **Désactivation de providers** : Activez/désactivez les sources individuellement.
- **UI Settings** : Configuration de l'interface utilisateur.
- **Sonarr Config** : Configuration de l'intégration Sonarr avec mapping de séries.
## 🏗️ Architecture & Stack Technique
L'application repose sur une architecture moderne et robuste :
- **Backend** : Python 3.11+, **FastAPI** pour l'API asynchrone.
- **Base de Données** : **SQLModel** (SQLAlchemy + Pydantic) avec **SQLite**.
- **Migrations** : **Alembic** pour la gestion évolutive du schéma de données.
- **Frontend** : **HTMX** pour les interactions serveur, **Alpine.js** pour l'état client, **Vanilla CSS**.
- **Streaming** : **Plyr.io** pour une expérience de lecture fluide et moderne.
- **Authentification** : JWT via **python-jose** + **passlib/bcrypt**.
## 📁 Hébergeurs Supportés
| Type | Services Supportés |
| :--- | :--- |
| **Catalogues Anime** | Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, French-Manga |
| **Catalogues Séries** | FS7 (French-Stream), Zone-Telechargement |
| **Players/Hosts** | VidMoly, DoodStream, 1fichier, Uptobox, SendVid, Sibnet, Lplayer, Uqload, Rapidfile, LuLuvid, Smoothpre, Vidzy, OneUpload |
## 📊 État des Providers
| Provider | Type | Status |
| :--- | :--- | :--- |
| Anime-Sama | Anime | ✅ UP |
| Neko-Sama | Anime | ✅ UP |
| Anime-Ultime | Anime | ✅ UP |
| Vostfree | Anime | ✅ UP |
| French-Manga | Anime | ✅ UP |
| FS7 | Séries | ✅ UP |
| Zone-Telechargement | Séries | ✅ UP |
> Dernière vérification : Avril 2026
## 🚀 Installation & Configuration
### 1. Prérequis
- Python 3.11+
- Node.js (pour les tests optionnels uniquement)
- Playwright (requis pour l'extraction de certains lecteurs comme VidMoly)
### 2. Installation
```bash
# Cloner le repository
git clone https://git.lanro.eu/Roman/ohm_streaming.git
cd ohm_streaming
# Créer et activer l'environnement virtuel
python3 -m venv venv
source venv/bin/activate
# Installer les dépendances
pip install -r requirements.txt
pip install pydantic[email] # Requis pour la validation des emails
# Initialisation Playwright (optionnel, pour l'extraction VidMoly)
playwright install chromium
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
./scripts/install.sh
```
### 3. Configuration
Créez un fichier `.env` à la racine du projet à partir du modèle :
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`.
### À la main
```bash
# 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture)
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
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
```
**Générez une clé secrète JWT sécurisée** (obligatoire, min. 32 caractères) :
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
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
docker compose pull && docker compose up -d
```
Editez le `.env` et ajoutez :
```env
JWT_SECRET_KEY=<la_clé_générée_ci_dessus>
```
> ⚠️ **Ne pas** définir `CORS_ORIGINS` dans le `.env` si vous utilisez les valeurs par défaut (format JSON requis, les valeurs par défaut du code suffisent).
### 4. Lancement
```bash
# Lancer l'application (Port 3000 par défaut)
source venv/bin/activate
uvicorn main:app --reload --host 0.0.0.0 --port 3000
```
Ou via le script fourni :
```bash
./run_app.sh
```
**Points d'accès :**
- Interface web : `http://localhost:3000/web`
- Documentation API : `http://localhost:3000/docs`
- Page de connexion : `http://localhost:3000/login`
## 🧪 Tests & Qualité
### Publier une version (mainteneur)
```bash
# Backend (Pytest)
pytest # Tous les tests
pytest -m "unit" # Tests unitaires rapides
# Frontend (Vitest & Playwright)
npm install # Installer les dépendances dev
npm test # Tests unitaires JS (Vitest)
npx playwright test # Tests E2E complets
./scripts/release.sh 0.2.0
```
## 🏗️ Structure du Projet
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`).
```
ohm_streaming/
├── main.py # Point d'entrée & Middleware FastAPI
├── app/
│ ├── downloaders/ # Logique d'extraction (Scraping multi-tier)
│ │ ├── anime_sama.py # Downloader Anime-Sama
│ │ ├── anime_ultime.py # Downloader Anime-Ultime
│ │ ├── neko_sama.py # Downloader Neko-Sama
│ │ ├── vostfree.py # Downloader Vostfree
│ │ ├── french_manga.py # Downloader French-Manga
│ │ ├── fs7.py # Downloader FS7
│ │ └── zone_telechargement.py # Downloader Zone-TG
│ ├── models/ # Modèles SQLModel & Pydantic
│ ├── routers/ # Routes API modulaires (~40 endpoints)
│ ├── download_manager.py # Moteur de téléchargement asynchrone
│ ├── watchlist.py # Logique métier du suivi
│ ├── episode_checker.py # Vérification automatique de nouveaux épisodes
│ ├── auto_download_scheduler.py # Planificateur de téléchargements
│ ├── sonarr_handler.py # Intégration Sonarr
│ ├── metadata_enrichment.py # Enrichissement des métadonnées (Kitsu/MAL)
│ ├── recommendations.py # Système de recommandations
│ ├── providers_manager.py # Gestion des providers (health check, activation)
│ └── database.py # Configuration de la base de données
├── config/ # Fichiers de configuration (Sonarr, mappings)
├── alembic/ # Migrations de base de données
├── static/ # Frontend (JS, CSS, Images)
├── templates/ # Vues Jinja2 (avec HTMX & Alpine.js)
├── tests/ # Tests backend
├── scripts/ # Scripts utilitaires
└── downloads/ # Répertoire par défaut des médias
## Démarrage rapide (développement)
```bash
uv sync
# ffmpeg requis pour les flux HLS (binaire statique dans ~/.local/bin, ou apt install ffmpeg)
uv run uvicorn app.main:app --host 0.0.0.0 --port 8777
```
## 🔧 Endpoints API Principaux
Serveur persistant en dev : préférer Docker (voir plus haut) ; sinon
`tmux new-session -d -s ohm 'uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`.
| Endpoint | Méthode | Description |
| :--- | :--- | :--- |
| `/api/auth/register` | POST | Création de compte |
| `/api/auth/login` | POST | Connexion (JWT) |
| `/api/auth/me` | GET | Profil utilisateur |
| `/api/anime/search?q=` | GET | Recherche multi-providers |
| `/api/series/search?q=` | GET | Recherche séries |
| `/api/anime/seasons?url=` | GET | Liste des saisons |
| `/api/anime/episodes?url=` | GET | Liste des épisodes |
| `/api/anime/download?url=` | POST | Lancer un téléchargement |
| `/api/anime/download-season?url=` | POST | Télécharger une saison complète |
| `/api/downloads` | GET | Liste des téléchargements |
| `/api/favorites` | GET | Liste des favoris |
| `/api/watchlist` | GET | Liste de la watchlist |
| `/api/providers/health` | GET | État des providers |
| `/api/settings` | GET | Configuration |
| `/api/sonarr/config` | GET/POST | Configuration Sonarr |
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
## 🐛 Problèmes Connus
## Configuration
- **Smoothpre** : L'extracteur de liens vidéo peut échouer si la structure de la page change côté serveur.
- **Sibnet filename** : Le nom de fichier généré peut contenir des caractères invalides issus de l'URL (à corriger dans la sanitisation du DownloadManager).
- **Anime-Ultime download** : La méthode `get_download_link()` a une incompatibilité de signature lors de l'appel par le routeur de téléchargement.
- **Table watchlist_settings** : La table SQLite n'est pas créée automatiquement au premier lancement (affiche un warning dans les logs mais n'empêche pas le fonctionnement).
Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
## 📝 Licence & Sécurité
| 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 |
- Ce projet est à usage **éducatif et personnel** uniquement.
- Respectez les droits d'auteur et les conditions d'utilisation des sites sources.
- L'utilisation de ce logiciel est sous votre entière responsabilité.
## 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.
**Version actuelle : 2.4**
**Dernière mise à jour : Avril 2026**
**Développé avec ❤️ pour la communauté anime**
## 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
```
-175
View File
@@ -1,175 +0,0 @@
# Restructuration des Downloaders - Résumé
## 🎯 Objectif Accompli
Restructuration complète du système de downloaders avec une distinction claire entre:
- **Sites d'anime** (catalogues avec métadonnées)
- **Players vidéo** (hébergement de fichiers)
## 📊 Nouvelle Structure
```
app/downloaders/
├── __init__.py # Factory principal (get_downloader)
├── base.py # BaseDownloader (classe racine)
│
├── anime_sites/ # 🎌 Sites d'anime (4 downloaders)
│ ├── __init__.py # Factory: get_anime_site()
│ ├── base.py # BaseAnimeSite
│ ├── animesama.py # Anime-Sama
│ ├── nekosama.py # Neko-Sama
│ ├── animeultime.py # Anime-Ultime
│ └── vostfree.py # Vostfree
│
└── video_players/ # 🎬 Players vidéo (8 downloaders)
├── __init__.py # Factory: get_video_player()
├── base.py # BaseVideoPlayer
├── doodstream.py # Doodstream
├── sibnet.py # Sibnet
├── vidmoly.py # VidMoly (avec support M3U8 + target_filename)
├── sendvid.py # SendVid (avec target_filename)
├── lpayer.py # Lpayer
├── unfichier.py # 1fichier
├── uptobox.py # Uptobox
└── rapidfile.py # Rapidfile
```
## ✨ Changements Clés
### 1. Classes de Base Spécialisées
**BaseVideoPlayer** (`video_players/base.py`):
- Pour les hébergeurs de fichiers vidéo
- Méthode clé: `get_download_link(url, target_filename=None)`
- Supporte le paramètre optionnel `target_filename` (VidMoly, SendVid)
- Gère l'extraction d'URL de téléchargement direct
**BaseAnimeSite** (`anime_sites/base.py`):
- Pour les sites de streaming anime
- Méthodes clés:
- `search_anime(query, lang)` - Recherche dans le catalogue
- `get_episodes(anime_url, lang)` - Liste des épisodes
- `get_anime_metadata(anime_url)` - Métadonnées riches
- `get_download_link(url)` - URL du player vidéo
### 2. Preservation des Spécificités
✅ **VidMoly**: Toutes ses spécificités préservées
- Support M3U8 → MP4 conversion
- Playwright network interception
- Multi-domaines (.biz, .to, .org)
- Paramètre `target_filename`
✅ **SendVid**: Paramètre `target_filename` préservé
✅ **Tous les autres**: Aucune modification de fonctionnalité
### 3. Factory Pattern
**Nouveau `get_downloader()` dans `__init__.py`**:
```python
def get_downloader(url: str):
# Essaye les sites anime d'abord
anime_site = get_anime_site(url)
if anime_site:
return anime_site
# Puis les players vidéo
video_player = get_video_player(url)
if video_player:
return video_player
# Fallback générique
return GenericDownloader()
```
## 🧪 Tests
✅ **23/23 tests passants** dans `tests/test_downloaders.py`
✅ **Imports mis à jour** pour utiliser la nouvelle structure
✅ **URL routing correct** pour tous les types
## 📈 Avantages
1. **Organisation claire**: Distinction évidente entre catalogues et hébergeurs
2. **Maintenabilité**: Ajouter un nouveau player ou site est plus intuitif
3. **Type safety**: Héritage spécifique avec méthodes appropriées
4. **Flexibilité**: Support des cas particuliers (VidMoly, SendVid)
5. **Backward compatibility**: L'API principale `get_downloader()` fonctionne toujours
## 🚀 Comment Ajouter un Nouveau Downloader
### Nouveau Player Vidéo:
```python
# app/downloaders/video_players/myplayer.py
from .base import BaseVideoPlayer
class MyPlayerDownloader(BaseVideoPlayer):
def can_handle(self, url: str) -> bool:
return "myplayer.com" in url.lower()
async def get_download_link(self, url: str, target_filename: str = None):
# ... extraction logic ...
return download_url, filename
```
### Nouveau Site Anime:
```python
# app/downloaders/anime_sites/mysite.py
from .base import BaseAnimeSite
class MyAnimeSiteDownloader(BaseAnimeSite):
def can_handle(self, url: str) -> bool:
return "myanime.site" in url.lower()
async def search_anime(self, query: str, lang: str = "vostfr"):
# ... search logic ...
return anime_list
async def get_episodes(self, anime_url: str, lang: str = "vostfr"):
# ... episode listing logic ...
return episode_list
async def get_anime_metadata(self, anime_url: str):
# ... metadata extraction ...
return metadata
async def get_download_link(self, url: str):
# ... extract video player URL ...
return player_url, title
```
## ✅ Validation
```bash
# Tests
pytest tests/test_downloaders.py -v # 23/23 passed ✅
# Imports
from app.downloaders import get_downloader # ✅
from app.downloaders.video_players import BaseVideoPlayer # ✅
from app.downloaders.anime_sites import BaseAnimeSite # ✅
# Routing
get_downloader('https://doodstream.com/e/abc') # → DoodStreamDownloader ✅
get_downloader('https://anime-sama.si/naruto') # → AnimeSamaDownloader ✅
```
## 📝 Fichiers Modifiés
**Nouveaux**: 18 fichiers
- 2 classes de base (base.py)
- 2 __init__.py avec factories
- 12 downloaders migrés
- 2 dossiers (anime_sites/, video_players/)
**Supprimés**: 12 anciens fichiers dans `app/downloaders/`
**Mis à jour**:
- `app/downloaders/__init__.py` (factory principal)
- `tests/test_downloaders.py` (imports)
---
**Date**: 2026-01-24
**Statut**: ✅ Terminé et testé
**Impact**: Aucune rupture de fonctionnalité
-116
View File
@@ -1,116 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
-85
View File
@@ -1,85 +0,0 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from sqlmodel import SQLModel
import app.models.auth
import app.models.watchlist
import app.models.favorites
import app.models.sonarr
from app.database import DATABASE_URL
target_metadata = SQLModel.metadata
# Set the sqlalchemy.url to the one we use in our app
config.set_main_option("sqlalchemy.url", DATABASE_URL)
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-26
View File
@@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -1,30 +0,0 @@
"""Initial migration
Revision ID: e0273f326a15
Revises:
Create Date: 2026-03-24 17:05:50.046027
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e0273f326a15'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -1,30 +0,0 @@
"""Add WatchlistSettingsTable
Revision ID: e88271d11851
Revises: e0273f326a15
Create Date: 2026-03-24 17:07:10.189457
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e88271d11851'
down_revision: Union[str, None] = 'e0273f326a15'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
-47
View File
@@ -1,47 +0,0 @@
# App Core (app/)
## OVERVIEW
FastAPI application core — config, auth, download management, providers, and business logic. Routes are in `routers/`, scrapers in `downloaders/`, models in `models/`.
## STRUCTURE
```
app/
├── config.py # Pydantic Settings (loads .env)
├── database.py # SQLModel engine (created at import time)
├── download_manager.py # Async download queue (semaphore-based)
├── auth.py # JWT + bcrypt, JSON-backed UserManager
├── providers.py # ANIME_PROVIDERS, FILE_HOSTS registries
├── utils.py # sanitize_filename(), is_safe_filename()
├── watchlist.py # WatchlistManager (JSON + SQLModel hybrid)
├── episode_checker.py # New episode detection for watchlist
├── auto_download_scheduler.py # APScheduler periodic checks
├── sonarr_handler.py # Sonarr webhook processing
├── favorites.py # FavoritesManager (JSON-backed)
├── recommendation_engine.py # Download history analysis
├── recommendations.py # Latest releases fetcher
└── kitsu_api.py # Kitsu anime metadata API
```
## WHERE TO LOOK
| Need | File | Notes |
|------|------|-------|
| Add env var | `config.py` | Add to Settings class, update `.env.example` |
| Add provider domain | `providers.py` | ANIME_PROVIDERS or FILE_HOSTS dict |
| Download queue logic | `download_manager.py` | Semaphore-limited parallel downloads |
| Auth/token logic | `auth.py` | UserManager, JWT create/verify |
| Filename safety | `utils.py` | ALWAYS use sanitize_filename() |
## CONVENTIONS
**Circular import avoidance**: `episode_checker.py` uses lazy init (`set_download_manager()`) — called from `main.py:47` after both modules loaded.
**Dual storage**: Some features use JSON files (favorites, users) + SQLModel tables (watchlist, sonarr mappings). JSON is legacy, SQLModel is newer.
**Module-level side effects**: `database.py` creates engine on import. `main.py` creates `download_manager` on import (line 44). `restore_completed_downloads()` runs at module level (line 108).
## ANTI-PATTERNS
- Do NOT import `download_manager` from `main.py` in other app/ modules — causes circular imports
- Do NOT use `requests` — always `httpx.AsyncClient`
- Do NOT store secrets in `config/*.json` — use `.env`
-1
View File
@@ -1 +0,0 @@
# Ohm Stream Downloader Package
+136 -329
View File
@@ -1,354 +1,161 @@
"""User authentication and management system with SQLModel support"""
"""Authentification : JWT court + refresh token longue durée, rôles admin/user."""
import os
import hashlib
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from jose import jwt
from passlib.context import CryptContext
import logging
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from sqlmodel import Session, select
from app.database import engine
from app.models.auth import UserTable
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__)
# Load settings at module level for easier mocking and access
settings = get_settings()
password_hash = PasswordHash.recommended()
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
class UserManager:
"""Manages user storage and authentication using SQL database"""
@dataclass
class User:
id: int
username: str
is_admin: bool
is_active: bool
content_preference: str = "both" # anime | serie | both
def __init__(self):
# Database connection is managed via engine and sessions
pass
def get_user(self, username: str) -> Optional[UserTable]:
"""Get user by username"""
from app.models.watchlist import WatchlistItemTable # Force registration
VALID_CONTENT_PREFERENCES = ("anime", "serie", "both")
with Session(engine) as session:
statement = select(UserTable).where(UserTable.username == username)
return session.exec(statement).first()
def get_user_by_id(self, user_id: str) -> Optional[UserTable]:
"""Get user by ID"""
with Session(engine) as session:
statement = select(UserTable).where(UserTable.id == user_id)
return session.exec(statement).first()
# ---------------------------------------------------------------- mots de passe
def create_user(
self,
username: str,
password: str,
email: Optional[str] = None,
full_name: Optional[str] = None,
) -> UserTable:
"""Create a new user"""
with Session(engine) as session:
# Check if user already exists
statement = select(UserTable).where(UserTable.username == username)
if session.exec(statement).first():
raise ValueError(f"Username '{username}' already exists")
# Truncate password to 72 bytes if necessary (bcrypt limitation)
password_bytes = password.encode("utf-8")
if len(password_bytes) > 72:
password = password_bytes[:72].decode("utf-8", errors="ignore")
def hash_password(password: str) -> str:
return password_hash.hash(password)
# Hash password
hashed_password = pwd_context.hash(password)
# Create user
user = UserTable(
username=username,
email=email,
full_name=full_name,
hashed_password=hashed_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,
created_at=datetime.now(),
content_preference=row["content_preference"],
)
session.add(user)
session.commit()
session.refresh(user)
logger.info(f"Created user: {username}")
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
def authenticate_user(self, username: str, password: str) -> Optional[UserTable]:
"""Authenticate user with username and password"""
user = self.get_user(username)
if not user:
return None
if not pwd_context.verify(password, user.hashed_password):
return None
# Update last login
with Session(engine) as session:
db_user = session.get(UserTable, user.id)
if db_user:
db_user.last_login = datetime.now()
session.add(db_user)
session.commit()
session.refresh(db_user)
return db_user
return user
def update_user(self, user_id: str, update_data: dict) -> Optional[UserTable]:
"""Update user information"""
with Session(engine) as session:
db_user = session.get(UserTable, user_id)
if not db_user:
return None
for key, value in update_data.items():
if hasattr(db_user, key):
setattr(db_user, key, value)
session.add(db_user)
session.commit()
session.refresh(db_user)
return db_user
# Global user manager instance
user_manager = UserManager()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password for storage"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
"""Create JWT access token"""
SECRET_KEY = settings.jwt_secret_key
ALGORITHM = settings.jwt_algorithm
ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minutes
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(token: str) -> Optional[str]:
"""Verify JWT token and return username"""
from jose.exceptions import JWTError
SECRET_KEY = settings.jwt_secret_key
ALGORITHM = settings.jwt_algorithm
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
return username
except JWTError:
return None
# Alias for backward compatibility
get_user_from_token = verify_token
def get_current_user(credentials: HTTPAuthorizationCredentials) -> UserTable:
"""Get current user from JWT token"""
token = credentials.credentials
username = verify_token(token)
if username:
user = user_manager.get_user(username)
if not user:
raise HTTPException(status_code=401, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=401, detail="Inactive user")
return user
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
# Refresh tokens storage
REFRESH_TOKENS_FILE = "config/refresh_tokens.json"
def _load_refresh_tokens() -> Dict[str, dict]:
"""Load refresh tokens from file"""
import json
try:
if os.path.exists(REFRESH_TOKENS_FILE):
with open(REFRESH_TOKENS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading refresh tokens: {e}")
return {}
def _save_refresh_tokens(tokens: Dict[str, dict]):
"""Save refresh tokens to file"""
import json
try:
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True)
with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f:
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
except Exception as e:
logger.error(f"Error saving refresh tokens: {e}")
def _get_jwt_config() -> dict:
return {
"SECRET_KEY": settings.jwt_secret_key,
"ALGORITHM": settings.jwt_algorithm,
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes,
"REFRESH_TOKEN_EXPIRE_DAYS": 30,
}
def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
"""
Create both access and refresh tokens.
Access token: short-lived (24 hours by default)
Refresh token: long-lived (30 days by default)
Returns: (access_token, refresh_token)
"""
from jose import jwt
import secrets
jwt_config = _get_jwt_config()
# Create access token (short-lived)
access_expire = datetime.utcnow() + timedelta(
minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"]
)
access_data = data.copy()
access_data.update({"exp": access_expire, "type": "access"})
access_token = jwt.encode(
access_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
)
# Create refresh token (long-lived)
refresh_expire = datetime.utcnow() + timedelta(
days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"]
)
# Generate a unique token ID
token_id = secrets.token_urlsafe(32)
refresh_data = {
"sub": data["sub"],
"token_id": token_id,
"exp": refresh_expire,
"type": "refresh",
}
refresh_token = jwt.encode(
refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
)
# Store refresh token mapping
refresh_tokens = _load_refresh_tokens()
refresh_tokens[token_id] = {
"username": data["sub"],
"token_id": token_id,
"created_at": datetime.now().isoformat(),
"expires_at": refresh_expire.isoformat(),
}
_save_refresh_tokens(refresh_tokens)
return access_token, refresh_token
def verify_refresh_token(token: str) -> Optional[str]:
"""
Verify refresh token and return username if valid.
Returns None if token is invalid or expired.
"""
from jose import jwt
from jose.exceptions import JWTError
jwt_config = _get_jwt_config()
try:
payload = jwt.decode(
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
)
# Verify this is a refresh token
if payload.get("type") != "refresh":
return None
username = payload.get("sub")
token_id = payload.get("token_id")
if not username or not token_id:
return None
# Check if token exists in storage
refresh_tokens = _load_refresh_tokens()
stored_token = refresh_tokens.get(token_id)
if not stored_token:
return None
# Verify token hasn't been revoked or expired
if stored_token.get("revoked"):
return None
return username
except JWTError:
return None
def revoke_refresh_token(token: str) -> bool:
"""
Revoke a refresh token.
Returns True if token was revoked, False if not found.
"""
from jose import jwt
from jose.exceptions import JWTError
jwt_config = _get_jwt_config()
try:
payload = jwt.decode(
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
)
token_id = payload.get("token_id")
if not token_id:
return False
refresh_tokens = _load_refresh_tokens()
if token_id in refresh_tokens:
refresh_tokens[token_id]["revoked"] = True
refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat()
_save_refresh_tokens(refresh_tokens)
return True
return False
except JWTError:
return False
async def revoke_all_refresh_tokens(user_id: int) -> None:
await db.execute("UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?", (user_id,))
-182
View File
@@ -1,182 +0,0 @@
"""Scheduler for automatic episode checking and downloading"""
import asyncio
import logging
from datetime import datetime
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from app.watchlist import watchlist_manager, WatchlistManager
from app.episode_checker import EpisodeChecker, episode_checker
from app.providers_manager import providers_manager
logger = logging.getLogger(__name__)
class AutoDownloadScheduler:
"""Manages automatic episode checking and downloading on a schedule"""
def __init__(
self,
wlm: Optional[WatchlistManager] = None,
checker: Optional[EpisodeChecker] = None
):
self.wlm = wlm or watchlist_manager
self.checker = checker or episode_checker
self.providers_mgr = providers_manager
self.scheduler: Optional[AsyncIOScheduler] = None
self._running = False
async def _check_job(self):
"""Job function that runs periodically to check for new episodes"""
try:
logger.info("Running scheduled episode check...")
results = await self.checker.check_all_due()
# Log summary
for result in results:
if result.new_episodes_found > 0:
logger.info(
f"✓ {result.anime_title}: "
f"{result.new_episodes_found} new, "
f"{len(result.episodes_downloaded)} downloaded"
)
logger.info(f"Scheduled check complete: processed {len(results)} items")
except Exception as e:
logger.error(f"Error in scheduled check job: {e}", exc_info=True)
async def _health_check_job(self):
"""Job function that runs periodically to check provider health"""
try:
logger.info("Running scheduled provider health check...")
await self.providers_mgr.check_all_health()
except Exception as e:
logger.error(f"Error in health check job: {e}")
def start(self):
"""Start the scheduler"""
if self._running:
logger.warning("Scheduler already running")
return
try:
self.scheduler = AsyncIOScheduler()
# Get initial check interval from settings
settings = self.wlm.settings
interval_hours = settings.check_interval_hours
# Add the job for episode checking
self.scheduler.add_job(
self._check_job,
trigger=IntervalTrigger(hours=interval_hours),
id='episode_check',
name='Check for new episodes',
replace_existing=True
)
# Add the job for provider health check (every 6 hours)
self.scheduler.add_job(
self._health_check_job,
trigger=IntervalTrigger(hours=6),
id='provider_health',
name='Check provider health',
replace_existing=True
)
# Start the scheduler
self.scheduler.start()
self._running = True
logger.info(
f"Auto-download scheduler started (checking every {interval_hours}h)"
)
except Exception as e:
logger.error(f"Error starting scheduler: {e}", exc_info=True)
raise
def stop(self):
"""Stop the scheduler"""
if not self._running:
logger.warning("Scheduler not running")
return
try:
if self.scheduler:
self.scheduler.shutdown(wait=False)
self.scheduler = None
self._running = False
logger.info("Auto-download scheduler stopped")
except Exception as e:
logger.error(f"Error stopping scheduler: {e}", exc_info=True)
def restart(self):
"""Restart the scheduler with updated settings"""
logger.info("Restarting scheduler with new settings...")
self.stop()
self.start()
def update_interval(self, hours: int):
"""Update the check interval"""
if not self._running:
logger.warning("Scheduler not running, interval will be applied on start")
return
try:
settings = self.wlm.get_settings()
settings.check_interval_hours = hours
self.wlm.update_settings(settings)
# Restart to apply new interval
self.restart()
logger.info(f"Updated check interval to {hours}h")
except Exception as e:
logger.error(f"Error updating interval: {e}", exc_info=True)
def get_next_run_time(self) -> Optional[datetime]:
"""Get the next scheduled run time"""
if not self._running or not self.scheduler:
return None
try:
job = self.scheduler.get_job('episode_check')
if job:
return job.next_run_time
except Exception as e:
logger.error(f"Error getting next run time: {e}")
return None
def is_running(self) -> bool:
"""Check if scheduler is running"""
return self._running
async def trigger_check_now(self):
"""Manually trigger an episode check now"""
logger.info("Manually triggering episode check...")
try:
await self._check_job()
except Exception as e:
logger.error(f"Error in manual check: {e}", exc_info=True)
raise
async def trigger_health_check_now(self):
"""Manually trigger a health check now"""
logger.info("Manually triggering provider health check...")
try:
await self._health_check_job()
except Exception as e:
logger.error(f"Error in manual health check: {e}")
raise
# Global scheduler instance
auto_download_scheduler = AutoDownloadScheduler()
+33 -74
View File
@@ -1,94 +1,53 @@
"""Application configuration using environment variables"""
import secrets
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings
from pydantic import model_validator
from typing import List
BASE_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
"""Application settings loaded from environment variables"""
model_config = SettingsConfigDict(env_prefix="OHM_", env_file=".env", extra="ignore")
# Application
app_name: str = "Ohm Stream Downloader"
app_version: str = "2.2"
debug: bool = False
# Server
host: str = "0.0.0.0"
port: int = 3000
reload: bool = True
# Données
data_dir: Path = BASE_DIR / "data"
download_dir: Path = BASE_DIR / "downloads"
database_path: Path = BASE_DIR / "data" / "ohm.db"
# Authentication
jwt_secret_key: str = "dev-secret-change-in-production"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 # 24 hours (short-lived for security)
refresh_token_expire_days: int = 30
# 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
@model_validator(mode="after")
def validate_jwt_secret_key(self) -> "Settings":
"""Validate JWT_SECRET_KEY is not the default or too short"""
default_secret = "dev-secret-change-in-production"
# 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"
if self.jwt_secret_key == default_secret:
raise ValueError(
f"JWT_SECRET_KEY cannot be the default value '{default_secret}'. "
f"Please set a secure secret in your .env file. "
f"Use Settings.generate_secret() to generate a secure secret."
)
if len(self.jwt_secret_key) < 32:
raise ValueError(
f"JWT_SECRET_KEY must be at least 32 characters long. "
f"Current length: {len(self.jwt_secret_key)} characters. "
f"Use Settings.generate_secret() to generate a secure secret."
)
return self
@staticmethod
def generate_secret() -> str:
"""Generate a cryptographically secure JWT secret key"""
return secrets.token_urlsafe(32)
# Downloads
download_dir: str = "downloads"
# Téléchargements
max_parallel_downloads: int = 3
chunk_size: int = 1024 * 1024 # 1MB chunks
# CORS
cors_origins: List[str] = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://192.168.1.204:3000",
"http://192.168.1.204",
]
# Kitsu
kitsu_base_url: str = "https://kitsu.io/api/edge"
metadata_cache_ttl_hours: int = 72
# Storage
favorites_storage_path: str = "favorites.json"
# 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 = ""
# Sonarr
sonarr_config_path: str = "config/sonarr.json"
sonarr_mappings_path: str = "config/sonarr_mappings.json"
# API Timeouts
http_timeout: float = 10.0
download_timeout: int = 300 # 5 minutes
# Logging
log_level: str = "INFO"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
# Global settings instance
settings = Settings()
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:
"""Get the global settings instance"""
settings = Settings()
settings.ensure_dirs()
return settings
-70
View File
@@ -1,70 +0,0 @@
"""Database configuration and session management using SQLModel"""
import os
from typing import Generator
from sqlalchemy import create_engine
from sqlmodel import SQLModel, Session, create_engine
from app.config import get_settings
settings = get_settings()
# Database URL can be overridden by environment variable DATABASE_URL
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./ohm_streaming.db")
# Create the engine
# connect_args={"check_same_thread": False} is required for SQLite and FastAPI
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
def create_db_and_tables():
"""Create the database and tables based on the models"""
# CRITICAL: Import ALL models here to ensure they are registered with SQLModel.metadata
from app.models.auth import UserTable
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
from app.models.favorites import FavoriteTable
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
from app.models.settings import AppSettingsTable
from app.models.download import DownloadTaskTable
SQLModel.metadata.create_all(engine)
# Add new columns to existing tables if they don't exist (SQLite workaround)
_ensure_columns(engine)
def _ensure_columns(engine):
"""Add new columns to app_settings table if they don't exist"""
from sqlalchemy import inspect, text
inspector = inspect(engine)
if 'app_settings' not in inspector.get_table_names():
return
existing = {col['name'] for col in inspector.get_columns('app_settings')}
new_columns = {
'recommendations_filter': 'TEXT DEFAULT "all"',
'releases_filter': 'TEXT DEFAULT "all"',
'anime_enabled': 'BOOLEAN DEFAULT 1',
'series_enabled': 'BOOLEAN DEFAULT 1',
'download_dir': 'TEXT DEFAULT "downloads"',
}
# Add is_admin to users table if missing
if 'users' in inspector.get_table_names():
user_cols = {col['name'] for col in inspector.get_columns('users')}
if 'is_admin' not in user_cols:
with engine.connect() as conn:
conn.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0'))
conn.commit()
with engine.connect() as conn:
for col_name, col_def in new_columns.items():
if col_name not in existing:
conn.execute(text(f'ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}'))
conn.commit()
def get_session() -> Generator[Session, None, None]:
"""Dependency for getting a database session"""
with Session(engine) as session:
yield session
+135
View File
@@ -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()
-517
View File
@@ -1,517 +0,0 @@
import asyncio
import os
import uuid
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
import httpx
from app.models import DownloadTask, DownloadStatus, DownloadRequest
from app.models.download import DownloadTaskTable
from app.database import engine
from sqlmodel import Session, select
from app.downloaders import get_downloader
from app.utils import sanitize_filename
logger = logging.getLogger(__name__)
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)
# ==================== DB Persistence ====================
def _save_task_to_db(self, task: DownloadTask) -> None:
"""Persist a download task to the database (upsert)."""
try:
with Session(engine) as session:
existing = session.get(DownloadTaskTable, task.id)
if existing:
existing.url = task.url
existing.filename = task.filename
existing.host = task.host.value if hasattr(task.host, 'value') else str(task.host)
existing.status = task.status.value if hasattr(task.status, 'value') else str(task.status)
existing.progress = task.progress
existing.downloaded_bytes = task.downloaded_bytes
existing.total_bytes = task.total_bytes
existing.speed = task.speed
existing.error = task.error
existing.started_at = task.started_at
existing.completed_at = task.completed_at
existing.file_path = task.file_path
session.add(existing)
session.commit()
else:
db_task = DownloadTaskTable(
id=task.id,
url=task.url,
filename=task.filename,
host=task.host.value if hasattr(task.host, 'value') else str(task.host),
status=task.status.value if hasattr(task.status, 'value') else str(task.status),
progress=task.progress,
downloaded_bytes=task.downloaded_bytes,
total_bytes=task.total_bytes,
speed=task.speed,
error=task.error,
created_at=task.created_at,
started_at=task.started_at,
completed_at=task.completed_at,
file_path=task.file_path,
)
session.add(db_task)
session.commit()
except Exception as e:
logger.error(f"Failed to save task {task.id} to DB: {e}", exc_info=True)
def _delete_task_from_db(self, task_id: str) -> None:
"""Remove a download task from the database."""
try:
with Session(engine) as session:
db_task = session.get(DownloadTaskTable, task_id)
if db_task:
session.delete(db_task)
session.commit()
except Exception as e:
logger.error(f"Failed to delete task {task_id} from DB: {e}", exc_info=True)
def _load_tasks_from_db(self) -> None:
"""Load persisted download tasks from the database into memory."""
try:
with Session(engine) as session:
statement = select(DownloadTaskTable)
db_tasks = session.exec(statement).all()
for db_task in db_tasks:
if db_task.id not in self.tasks:
task = DownloadTask(
id=db_task.id,
url=db_task.url,
filename=db_task.filename,
host="other",
status=DownloadStatus(db_task.status),
progress=db_task.progress,
downloaded_bytes=db_task.downloaded_bytes,
total_bytes=db_task.total_bytes,
speed=db_task.speed,
error=db_task.error,
created_at=db_task.created_at,
started_at=db_task.started_at,
completed_at=db_task.completed_at,
file_path=db_task.file_path,
)
self.tasks[task.id] = task
logger.info(f"Loaded {len(db_tasks)} download tasks from database")
except Exception as e:
logger.error(f"Failed to load tasks from DB: {e}", exc_info=True)
# ==================== Task Management ====================
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:
# Check for existing tasks with the same URL
# Extract actual URL from pipe-separated format
url_to_check = request.url.split('|')[0] if '|' in request.url else request.url
# Look for existing non-failed tasks with the same URL
for existing_task in self.tasks.values():
existing_url = existing_task.url.split('|')[0] if '|' in existing_task.url else existing_task.url
# If same URL and task is not failed/cancelled/completed
if existing_url == url_to_check and existing_task.status not in [
DownloadStatus.FAILED,
DownloadStatus.CANCELLED,
DownloadStatus.COMPLETED
]:
logger.info(f"Duplicate download detected: {url_to_check[:80]}...")
logger.info(f"Returning existing task: {existing_task.id}")
return existing_task
# No duplicate found, create new task
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
# Persist to database
self._save_task_to_db(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
self._save_task_to_db(task)
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
self._save_task_to_db(task)
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 delete_task(self, task_id: str):
"""Completely remove a task from the task list (keeps completed files)"""
task = self.tasks.get(task_id)
if task:
# Cancel if downloading
if task_id in self.active_downloads:
self.active_downloads[task_id].cancel()
del self.active_downloads[task_id]
# Delete partial file ONLY if download is not completed
if task.status != DownloadStatus.COMPLETED:
if task.file_path and os.path.exists(task.file_path):
os.remove(task.file_path)
# Remove from tasks dict and database
del self.tasks[task_id]
self._delete_task_from_db(task_id)
async def _download(self, task: DownloadTask):
async with self._semaphore:
try:
task.status = DownloadStatus.DOWNLOADING
task.started_at = datetime.now()
self._save_task_to_db(task)
# Get downloader and extract link
downloader = get_downloader(task.url)
# Extract episode title from pipe-separated URL if present
# Format: video_url1|video_url2|...|anime_page_url|episode_title
target_filename = None
if '|' in task.url:
parts = task.url.split('|')
# Last part is episode title, second to last is anime page URL
if len(parts) >= 2:
# Get the last part as episode title
potential_title = parts[-1].strip()
# Only use it if it looks like a title (not a URL)
if potential_title and not potential_title.startswith('http'):
target_filename = potential_title
logger.debug(f"Extracted target filename from pipe: {target_filename}")
download_url, filename = await downloader.get_download_link(task.url, target_filename)
logger.info(f"Download URL: {download_url[:100] if len(download_url) > 100 else download_url}")
logger.debug(f"Downloader filename: {filename}")
logger.debug(f"Task filename before: {task.filename}")
if not task.filename or task.filename == "download":
task.filename = filename
logger.debug(f"Task filename updated to: {task.filename}")
else:
logger.debug(f"Task filename kept as: {task.filename}")
# Sanitize filename to prevent path traversal and invalid characters
task.filename = sanitize_filename(task.filename)
task.file_path = str(self.download_dir / task.filename)
# Check if URL is HLS/m3u8 - use ffmpeg to download
if download_url.endswith('.m3u8') or '.m3u8?' in download_url:
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
success = await self._download_hls(download_url, task)
if success:
self._save_task_to_db(task)
return
# If ffmpeg fails, fall through to regular download attempt
logger.warning("ffmpeg download failed, trying regular download")
# Check if download_url is a local file path (VidMoly M3U8 pre-download)
if os.path.exists(download_url):
logger.info(f"VidMoly already downloaded file to: {download_url}")
# Move file to expected location if different
import shutil
if download_url != task.file_path:
try:
shutil.move(download_url, task.file_path)
logger.debug(f"Moved file to: {task.file_path}")
except shutil.Error:
# Same file, no move needed
pass
# Mark as complete
file_size = os.path.getsize(task.file_path)
logger.info(f"File size: {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()
self._save_task_to_db(task)
return
# 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
logger.info(f"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()
self._save_task_to_db(task)
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/',
})
# Add Sibnet-specific headers to avoid 403 errors
elif 'sibnet.ru' in download_url:
headers.update({
'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://video.sibnet.ru/',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
})
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:
logger.info(f" 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)
self._save_task_to_db(task)
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"""
# Log response info
logger.info(f" Response status: {response.status_code}")
logger.info(f" Response headers: {dict(response.headers)}")
# 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:
self._save_task_to_db(task)
return
if task.status == DownloadStatus.PAUSED:
self._save_task_to_db(task)
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
# Log completion info
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
# Persist to database
self._save_task_to_db(task)
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
"""Download HLS/m3u8 stream using ffmpeg"""
import subprocess
import re
try:
# Build ffmpeg command for HLS download
cmd = [
'ffmpeg',
'-y', # Overwrite output file
'-headers', 'Referer: https://lpayer.embed4me.com/',
'-i', m3u8_url,
'-c', 'copy', # Stream copy (no re-encoding)
'-bsf:a', 'aac_adtstoasc', # Fix AAC streams
'-progress', 'pipe:1', # Output progress to stdout
task.file_path
]
logger.info(f"Starting ffmpeg HLS download: {task.filename}")
# Run ffmpeg as subprocess
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
start_time = asyncio.get_event_loop().time()
# Read progress from ffmpeg
while True:
if task.status == DownloadStatus.CANCELLED:
process.terminate()
return False
if task.status == DownloadStatus.PAUSED:
process.terminate()
return False
try:
line = await asyncio.wait_for(process.stdout.readline(), timeout=1.0)
if not line:
break
line = line.decode('utf-8', errors='ignore').strip()
# Parse ffmpeg progress output
if line.startswith('out_time_ms='):
try:
out_time_us = int(line.split('=')[1])
out_time_sec = out_time_us / 1_000_000
# Update progress based on duration (if known)
# ffmpeg doesn't always report total duration
task.downloaded_bytes = int(out_time_sec * 1000000) # Approximate
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed > 0:
task.speed = task.downloaded_bytes / elapsed
except (ValueError, IndexError):
pass
elif line.startswith('total_size='):
try:
size = int(line.split('=')[1])
if size > 0:
task.total_bytes = size
if task.downloaded_bytes > 0:
task.progress = (task.downloaded_bytes / size) * 100
except (ValueError, IndexError):
pass
except asyncio.TimeoutError:
# Check if process is still running
if process.returncode is not None:
break
continue
# Wait for process to complete
await process.wait()
if process.returncode == 0:
# Check if file was created
if os.path.exists(task.file_path):
file_size = os.path.getsize(task.file_path)
logger.info(f"✅ HLS download complete: {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()
self._save_task_to_db(task)
return True
else:
logger.error(f"HLS download failed: file not created")
return False
else:
# Get stderr for error message
stderr = await process.stderr.read()
error_msg = stderr.decode('utf-8', errors='ignore')
logger.error(f"ffmpeg failed with code {process.returncode}: {error_msg[:500]}")
return False
except FileNotFoundError:
logger.error("ffmpeg not found - cannot download HLS streams")
return False
except Exception as e:
logger.error(f"HLS download error: {e}")
return False
-49
View File
@@ -1,49 +0,0 @@
# Downloaders (app/downloaders/)
## OVERVIEW
3-tier scraper architecture: anime catalogs → series catalogs → video players. Factory pattern routes URLs through each tier.
## STRUCTURE
```
downloaders/
├── __init__.py # get_downloader(url) — 3-tier factory + GenericDownloader
├── base.py # Legacy BaseDownloader (kept for compat)
├── anime_sites/ # Anime streaming catalogs (see anime_sites/AGENTS.md)
│ ├── __init__.py # get_anime_site(url) factory
│ ├── base.py # BaseAnimeSite abstract class
│ └── *.py # 5 anime providers
├── series_sites/ # TV series catalogs (see series_sites/AGENTS.md)
│ ├── __init__.py # get_series_site(url) factory
│ ├── base.py # BaseSeriesSite abstract class
│ └── fs7.py # 1 series provider
└── video_players/ # File hosting extractors (see video_players/AGENTS.md)
├── __init__.py # get_video_player(url) factory
├── base.py # BaseVideoPlayer abstract class
└── *.py # 13 video player handlers
```
## WHERE TO LOOK
| Need | File | Notes |
|------|------|-------|
| Route URL to downloader | `__init__.py:32` | `get_downloader(url)` tries anime→series→video→generic |
| Add anime provider | `anime_sites/` | Inherit BaseAnimeSite, register in anime_sites/__init__.py |
| Add series provider | `series_sites/` | Inherit BaseSeriesSite, register in series_sites/__init__.py |
| Add video player | `video_players/` | Inherit BaseVideoPlayer, register in video_players/__init__.py |
| Provider domains/icons | `app/providers.py` | Separate from downloader code |
## CONVENTIONS
**URL pipe format**: `video_url|anime_page_url|episode_title` — metadata preserved through tiers. Anime/series sites return player URLs (not direct downloads). Video players extract final download links.
**Factory chain**: `get_downloader()` → `get_anime_site()` → `get_series_site()` → `get_video_player()` → `GenericDownloader`.
**New provider checklist**: 1) Create .py inheriting base class, 2) Implement required methods, 3) Add to `__init__.py` factory list, 4) Add to `app/providers.py`.
## ANTI-PATTERNS
- Do NOT return None from `get_download_link()` — raise Exception
- Do NOT use sync `requests` — always `httpx.AsyncClient`
- Do NOT forget `await self.close()` — causes resource leaks
- Do NOT skip `sanitize_filename()` on extracted filenames
- Do NOT hardcode User-Agent per player — use base class headers
-70
View File
@@ -1,70 +0,0 @@
from .base import BaseDownloader
# Import from new organized structure
from .video_players import (
BaseVideoPlayer,
get_video_player,
DoodStreamDownloader,
SibnetDownloader,
VidMolyDownloader,
SendVidDownloader,
LpayerDownloader,
UnFichierDownloader,
UptoboxDownloader,
RapidFileDownloader
)
from .anime_sites import (
BaseAnimeSite,
get_anime_site,
AnimeSamaDownloader,
NekoSamaDownloader,
AnimeUltimeDownloader,
VostfreeDownloader
)
from .series_sites import (
BaseSeriesSite,
get_series_site,
FS7Downloader,
ZoneTelechargementDownloader
)
def get_downloader(url: str) -> BaseDownloader:
"""
Factory function to get the appropriate downloader for a URL.
This function now uses the organized structure:
- Checks anime sites first (for catalogs/search)
- Then checks series sites (for catalogs/search)
- Then checks video players (for direct download links)
- Falls back to generic downloader if no match
"""
# Try anime sites first
anime_site = get_anime_site(url)
if anime_site:
return anime_site
# Then try series sites
series_site = get_series_site(url)
if series_site:
return series_site
# Then try video players
video_player = get_video_player(url)
if video_player:
return video_player
# 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, target_filename: str = None) -> tuple[str, str]:
# Just return the URL as-is
filename = target_filename or url.split('/')[-1] or "download"
return url, filename
-41
View File
@@ -1,41 +0,0 @@
# Anime Sites (app/downloaders/anime_sites/)
## OVERVIEW
Handlers for French anime streaming catalogs that provide metadata and episode listings, delegating actual video extraction to video player handlers.
## WHERE TO LOOK
| File | Purpose |
|------|---------|
| `base.py` | Abstract `BaseAnimeSite` class defining the interface |
| `animesama.py` | Primary provider — dynamic domain switching, multiple video player extraction |
| `nekosama.py` | Neko-Sama / Gupy integration (metadata-only, no direct downloads) |
| `animeultime.py` | Anime-Ultime catalog handler |
| `vostfree.py` | Vostfree catalog handler |
| `frenchmanga.py` | French-Manga catalog handler |
## CONVENTIONS
**Interface contract** — each site implements from `BaseAnimeSite`:
- `can_handle(url)` — URL pattern matching
- `search_anime(query, lang)` → `[{title, url, cover_image}]`
- `get_episodes(anime_url, lang)` → `[{episode_number, url, title, host}]`
- `get_anime_metadata(anime_url)` → `{synopsis, genres, rating, release_year, studio, poster_image, total_episodes, status}`
- `get_download_link(url)` → `(video_player_url, filename)`
**Key patterns**:
- Pipe-separated URLs: `video_url|anime_page_url|episode_title`
- Language param: `lang="vostfr"` or `"vf"`
- Video player delegation: returns player URLs (vidmoly, sendvid, etc.), NOT direct downloads
- Filename format: `{anime_name} - S{season} - {episode}.mp4`
- Browser UA + referer headers required
**Domain detection**: `AnimeSamaDownloader` fetches current domain from `anime-sama.pw` dynamically. Uses fallback chain for video extraction.
**Error handling**: Raise `Exception` with descriptive message. Log at `debug` for expected failures, `error` for unexpected. Validate URLs with `_test_video_url()` before returning.
## ANTI-PATTERNS
- Do NOT return direct download URLs from anime sites — return player URLs
- Do NOT skip URL validation — use `_test_video_url()`
- 5 empty `except:` blocks in `animesama.py` — known tech debt, silently swallow failures
-35
View File
@@ -1,35 +0,0 @@
"""Anime streaming sites (catalogs) downloaders"""
from .base import BaseAnimeSite
# Import all anime site downloaders
from .animesama import AnimeSamaDownloader
from .nekosama import NekoSamaDownloader
from .animeultime import AnimeUltimeDownloader
from .vostfree import VostfreeDownloader
from .frenchmanga import FrenchMangaDownloader
__all__ = [
"BaseAnimeSite",
"AnimeSamaDownloader",
"NekoSamaDownloader",
"AnimeUltimeDownloader",
"VostfreeDownloader",
"FrenchMangaDownloader",
]
def get_anime_site(url: str) -> BaseAnimeSite:
"""Factory function to get the appropriate anime site for a URL"""
sites = [
AnimeSamaDownloader(),
AnimeUltimeDownloader(),
NekoSamaDownloader(),
VostfreeDownloader(),
FrenchMangaDownloader(),
]
for site in sites:
if site.can_handle(url):
return site
# Return None if no match (should not happen in normal flow)
return None
File diff suppressed because it is too large Load Diff
-478
View File
@@ -1,478 +0,0 @@
from .base import BaseAnimeSite
from bs4 import BeautifulSoup
import re
import httpx
from urllib.parse import urljoin
class AnimeUltimeDownloader(BaseAnimeSite):
"""Downloader for anime-ultime.net"""
BASE_DOMAINS = ["anime-ultime.com", "anime-ultime.net", "www.anime-ultime.net"]
def __init__(self):
super().__init__()
self.id = "anime-ultime"
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""
Extract download link from anime-ultime URL
Anime-Ultime stores video links in og:video meta tags
"""
try:
# Follow redirects
response = await self.client.get(url, follow_redirects=True)
final_url = str(response.url)
# Parse the page
soup = BeautifulSoup(response.text, "lxml")
# Method 0: Look for og:video meta tag (most reliable for anime-ultime)
og_video = soup.find("meta", property="og:video")
if og_video and og_video.get("content"):
video_url = og_video["content"]
if video_url.endswith(".mp4"):
filename = self._generate_filename(final_url)
print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
return video_url, filename
# Method 1: Look for direct download links (DDL)
# Anime-Ultime often uses links to file hosts
download_links = soup.find_all("a", href=True)
for link in download_links:
href = link["href"]
text = link.get_text().lower()
# Look for download buttons/links
if any(
keyword in text
for keyword in [
"télécharger",
"download",
"ddl",
"mega",
"google",
"drive",
]
):
# Check if it's a direct link or to a file host
if any(
host in href.lower()
for host in [
"mega.nz",
"drive.google.com",
"uptobox.com",
"1fichier.com",
]
):
filename = self._generate_filename(final_url)
return href, filename
# Method 2: Look for iframe with video player
iframes = soup.find_all("iframe")
for iframe in iframes:
src = iframe.get("src", "")
if src and any(
provider in src
for provider in ["video", "player", "stream", "play"]
):
if src.startswith("http"):
filename = self._generate_filename(final_url)
return src, filename
# Method 3: Look for video tags
videos = soup.find_all("video")
for video in videos:
src = video.get("src", "")
if src:
filename = self._generate_filename(final_url)
return src, filename
# Check source tags
sources = video.find_all("source")
for source in sources:
src = source.get("src", "")
if src:
filename = self._generate_filename(final_url)
return src, filename
# Method 4: Look in scripts for video URLs
scripts = soup.find_all("script")
for script in scripts:
if script.string:
# Look for common video patterns
patterns = [
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8|mkv)(?:\?[^"\'>\s]*)?)',
r'"url":"([^"]+)"',
r'"video":"([^"]+)"',
r'"file":"([^"]+)"',
r'file:\s*"([^"]+)"',
]
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
# Clean up escaped characters
match = match.replace("\\/", "/").replace("\\", "")
if any(ext in match for ext in ["mp4", "m3u8", "mkv"]):
filename = self._generate_filename(final_url)
return match, filename
# Look for anime-ultime specific patterns
# They sometimes store links in JavaScript variables
ddl_match = re.search(
r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string
)
if ddl_match:
ddl_url = ddl_match.group(1)
if ddl_url.startswith("http"):
filename = self._generate_filename(final_url)
return ddl_url, filename
# Method 5: Look for links with specific classes or IDs
# Anime-Ultime might use specific class names for download links
potential_links = soup.find_all(
"a", class_=re.compile(r"download|ddl|episode", re.I)
)
for link in potential_links:
href = link.get("href", "")
if href and href.startswith("http"):
filename = self._generate_filename(final_url)
return href, filename
# If nothing found, raise error
raise Exception("Could not find download link on page")
except Exception as e:
raise Exception(f"Error extracting Anime-Ultime link: {str(e)}")
def _generate_filename(self, url: str) -> str:
"""Generate filename from URL"""
# Extract anime name and episode from URL
# URL formats:
# - info-0-1/30200
# - info-0-1/30200/Naruto-OAV-01-vostfr
# - file-0-1/2991-Naruto-OAV
anime_name = "Anime"
episode = "01"
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr
if "info-0-1/" in url:
# Extract episode ID
ep_match = re.search(r"info-0-1/(\d+)", url)
if ep_match:
ep_id = ep_match.group(1)
# Try to get anime name from URL path
name_match = re.search(r"info-0-1/\d+/([^/]+)", url)
if name_match:
raw_name = name_match.group(1)
# Extract episode number
ep_num_match = re.search(r"-(\d+)-vostfr$", raw_name, re.I)
if ep_num_match:
episode = ep_num_match.group(1).zfill(2)
# Remove episode number and suffix from name
anime_name = re.sub(
r"-\d+-vostfr$", "", raw_name, flags=re.I
).replace("-", " ")
else:
# Just use the ID
anime_name = f"Episode {ep_id}"
else:
anime_name = f"Episode {ep_id}"
elif "file-0-1/" in url:
# Extract from file-0-1/ID-NAME format
file_match = re.search(r"file-0-1/\d+-(.+)$", url)
if file_match:
anime_name = file_match.group(1).replace("-", " ")
# Sanitize filename
anime_name = anime_name.replace("/", " ").strip()
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def get_anime_metadata(self, anime_url: str) -> dict:
"""
Extract rich metadata from anime page
Returns synopsis, genres, rating, release year, studio, etc.
"""
try:
print(f"[ANIME-ULTIME] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, "lxml")
metadata = {
"synopsis": None,
"genres": [],
"rating": None,
"release_year": None,
"studio": None,
"poster_image": None,
"banner_image": None,
"total_episodes": None,
"status": None,
"alternative_titles": [],
}
# Extract synopsis
synopsis_selectors = [
"div.synopsis",
"div.description",
'div[class*="synopsis"]',
'div[class*="synopsis"]',
"p.synopsis",
".info",
"div.texte",
]
for selector in synopsis_selectors:
synopsis_elem = soup.select_one(selector)
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50:
metadata["synopsis"] = synopsis
break
# Extract genres from meta tags and page content
page_text = soup.get_text()
# Look for genre in meta tags
genre_meta = soup.find("meta", property="genre") or soup.find(
"meta", attrs={"name": "genre"}
)
if genre_meta:
genres_text = genre_meta.get("content", "")
if genres_text:
metadata["genres"] = [g.strip() for g in genres_text.split(",")]
# Try to find genre links
genre_links = soup.find_all(
"a", href=re.compile(r"genre|tag|type|cat", re.I)
)
if genre_links:
for link in genre_links[:5]:
genre = link.get_text(strip=True)
if genre and genre not in metadata["genres"]:
metadata["genres"].append(genre)
# Extract rating
rating_selectors = [
"span.rating",
"div.rating",
"span.score",
"div.note",
".rating",
]
for selector in rating_selectors:
rating_elem = soup.select_one(selector)
if rating_elem:
rating_text = rating_elem.get_text(strip=True)
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
if rating_match:
metadata["rating"] = f"{rating_match.group(1)}/10"
break
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*5", rating_text)
if rating_match:
rating_val = float(rating_match.group(1)) * 2
metadata["rating"] = f"{rating_val:.1f}/10"
break
# Extract release year
year_match = re.search(r"\b(19\d{2}|20\d{2})\b", page_text)
if year_match:
import datetime
current_year = datetime.datetime.now().year + 2
year = int(year_match.group(1))
if 1950 <= year <= current_year:
metadata["release_year"] = year
# Extract poster image from og:image
og_image = soup.find("meta", property="og:image")
if og_image:
metadata["poster_image"] = og_image.get("content")
# Extract total episodes
episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0:
metadata["total_episodes"] = episodes_count
print(f"[ANIME-ULTIME] Extracted metadata: {metadata}")
return metadata
except Exception as e:
print(f"[ANIME-ULTIME] Error extracting metadata: {e}")
return {}
async def search_anime(
self, query: str, lang: str = "vostfr", include_metadata: bool = False
) -> list[dict]:
"""
Search for anime on anime-ultime
Returns list of anime with title, url, and cover image
Args:
query: Search query string
lang: Language preference (vostfr, vf)
include_metadata: Whether to fetch full metadata for each result (slower)
"""
try:
import time
start = time.time()
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
# Anime-Ultime uses POST for search
search_url = "https://www.anime-ultime.net/search-0-1"
response = await self.client.post(search_url, data={"search": query})
soup = BeautifulSoup(response.text, "lxml")
elapsed = time.time() - start
print(
f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s"
)
results = []
# Look for search result links - better parsing
# Search results use file-0-1/ pattern, not info-
search_results = soup.find_all("a", href=re.compile(r"file-0-1/"))
seen_urls = set()
for result in search_results[:10]: # Limit to 10 results
href = result.get("href", "")
raw_title = result.get_text().strip()
# Skip if no href
if not href:
continue
# Skip duplicates
if href in seen_urls:
continue
seen_urls.add(href)
# Extract better title from URL or parent elements
better_title = raw_title
# If raw_title is just "Télécharger" or similar, try to find better title
if len(raw_title) < 5 or raw_title.lower() in [
"télécharger",
"download",
"ddl",
]:
# Try to extract from URL (file-0-1/ID-Title format)
url_match = re.search(r"file-0-1/\d+-(.+)$", href)
if url_match:
better_title = url_match.group(1).replace("-", " ").title()
# If still no good title, look at parent/row elements
if len(better_title) < 5:
# Check parent row (table structure)
row = result.find_parent(["tr", "td", "div"])
if row:
# Look for text in the row that's not the link text
row_text = row.get_text().strip()
# Remove the link text from row text
if raw_title in row_text:
row_text = row_text.replace(raw_title, "").strip()
if len(row_text) > 5 and len(row_text) < 100:
better_title = row_text
# Make URL absolute
if not href.startswith("http"):
href = urljoin("https://www.anime-ultime.net/", href)
result_item = {
"title": better_title,
"url": href,
"type": "search_result",
"metadata": None,
}
# Fetch metadata if requested
if include_metadata:
metadata = await self.get_anime_metadata(href)
result_item["metadata"] = metadata
results.append(result_item)
print(f"[ANIME-ULTIME] Found {len(results)} results")
return results
except Exception as e:
print(f"[ANIME-ULTIME] Error: {e}")
return []
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
"""
Get list of episodes for an anime
Returns list of episode numbers and their URLs
"""
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, "lxml")
episodes = []
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format
# The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number
episode_links = soup.find_all("a", href=re.compile(r"info-0-1/\d+"))
for link in episode_links:
href = link.get("href", "")
text = link.get_text().strip()
# Extract episode number from URL pattern
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr
match = re.search(r"-(\d+)-vostfr$", href, re.I)
if not match:
# Try other patterns
match = re.search(r"Episode[-\s]?(\d+)", href, re.I)
if not match:
# Try to extract from text
match = re.search(r"(\d+)", text)
if match:
episode_num = match.group(1).zfill(2) # Pad with zero
# Extract the episode ID from href and build correct URL
# href might be "info-0-1/30200" or "info-0-1/30200/..."
# We need: https://www.anime-ultime.net/info-0-1/30200
ep_id_match = re.search(r"info-0-1/(\d+)", href)
if ep_id_match:
ep_id = ep_id_match.group(1)
# Build the correct episode URL
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
else:
# Fallback to making URL absolute
if not href.startswith("http"):
href = urljoin(anime_url, href)
episode_url = href
episodes.append(
{"episode": episode_num, "url": episode_url, "title": text}
)
# Remove duplicates and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep["episode"] not in seen:
seen.add(ep["episode"])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x["episode"]))
return unique_episodes
except Exception as e:
print(f"Error getting episodes: {e}")
return []
-140
View File
@@ -1,140 +0,0 @@
"""Base class for anime streaming sites (catalogs)"""
from abc import abstractmethod
from typing import List, Dict, Any, Optional, Tuple
import logging
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
class BaseAnimeSite:
"""
Base class for anime streaming sites.
Anime sites provide catalogs, metadata, and episode listings.
They typically link to video players for actual file hosting.
Examples: Anime-Sama, Neko-Sama, Anime-Ultime, Vostfree, etc.
KEY FEATURE: Provides rich metadata and episode management
"""
def __init__(self):
# Realistic browser headers to avoid blocking by video hosts
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,fr;q=0.8",
"Referer": "https://anime-sama.tv/",
}
# Initialize HTTP client with browser headers
self.client = httpx.AsyncClient(timeout=10.0, follow_redirects=True, headers=headers)
@abstractmethod
def can_handle(self, url: str) -> bool:
"""Check if this anime site can handle the given URL"""
pass
@abstractmethod
async def search_anime(
self,
query: str,
lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Search for anime on this site.
Args:
query: Search query (anime title)
lang: Language preference (vostfr, vf)
Returns:
List of anime with keys:
- title: Anime title
- url: Anime page URL
- cover_image: Optional cover image URL
- lang: Available languages
"""
pass
@abstractmethod
async def get_episodes(
self,
anime_url: str,
lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Get list of episodes for an anime.
Args:
anime_url: URL of the anime page
lang: Language preference
Returns:
List of episodes with keys:
- episode_number: Episode number
- url: Episode page URL
- title: Optional episode title
- host: Video player hosting the file
"""
pass
@abstractmethod
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
"""
Get detailed metadata for an anime.
Args:
anime_url: URL of the anime page
Returns:
Dict with metadata:
- title: Anime title
- synopsis: Plot summary
- genres: List of genres
- rating: Rating (e.g., "8.5/10")
- release_year: Release year
- studio: Animation studio
- poster_image: Poster URL
- total_episodes: Total episode count
- status: Airing status (ongoing, completed)
- languages: Available languages
"""
pass
@abstractmethod
async def get_download_link(self, url: str) -> Tuple[str, str]:
"""
Get download link for a specific episode.
For anime sites, this extracts the video player URL from an episode page.
Note: Returns video player URL, NOT direct download link!
Returns:
Tuple of (video_player_url, episode_title)
"""
pass
# Common methods for all anime sites
async def close(self):
"""Close HTTP client"""
await self.client.aclose()
async def _fetch_page(self, url: str) -> str:
"""Fetch HTML page content"""
response = await self.client.get(url)
response.raise_for_status()
return response.text
def _parse_html(self, html: str) -> BeautifulSoup:
"""Parse HTML with BeautifulSoup"""
return BeautifulSoup(html, 'lxml')
def _extract_season_number(self, title: str) -> Optional[int]:
"""Extract season number from title (e.g., 'Saison 2' -> 2)"""
import re
match = re.search(r'saison\s*(\d+)', title.lower())
return int(match.group(1)) if match else None
-307
View File
@@ -1,307 +0,0 @@
"""French-Manga.net anime streaming site downloader"""
from .base import BaseAnimeSite
from bs4 import BeautifulSoup
import re
from typing import List, Dict, Any
from app.utils import sanitize_filename
import logging
logger = logging.getLogger(__name__)
class FrenchMangaDownloader(BaseAnimeSite):
"""Downloader for french-manga.net anime streaming site"""
# Known domains for French-Manga
BASE_DOMAINS = [
"french-manga.net",
"w16.french-manga.net",
"w15.french-manga.net",
"www.french-manga.net",
]
def __init__(self):
super().__init__()
self.id = "french-manga"
self.base_url = "https://w16.french-manga.net"
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def search_anime(
self, query: str, lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Search for anime on French-Manga.
Args:
query: Search query (anime title)
lang: Language preference (vostfr, vf)
Returns:
List of anime with title, url, cover_image
"""
try:
# French-Manga uses a search endpoint
search_url = f"{self.base_url}/index.php?do=search"
params = {
"do": "search",
"subaction": "search",
"story": query,
"x": "0",
"y": "0",
}
response = await self.client.post(search_url, data=params)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
results = []
# Look for search results in article or story classes
for item in soup.find_all(
"article", class_=lambda x: x and "story" in x.lower()
):
title_elem = item.find(["h2", "h3", "h4"])
link_elem = item.find("a", href=True)
img_elem = item.find("img")
if title_elem and link_elem:
title = title_elem.get_text(strip=True)
url = link_elem["href"]
# Ensure absolute URL
if url.startswith("/"):
url = self.base_url + url
cover_image = ""
if img_elem and img_elem.get("src"):
cover_image = img_elem["src"]
if cover_image.startswith("/"):
cover_image = self.base_url + cover_image
results.append(
{
"title": title,
"url": url,
"cover_image": cover_image,
"lang": lang,
}
)
logger.info(f"Found {len(results)} anime results for query: {query}")
return results
except Exception as e:
logger.error(f"Error searching anime: {e}")
return []
async def get_episodes(
self, anime_url: str, lang: str = "vostfr"
) -> List[Dict[str, str]]:
"""
Get episode list for an anime.
Args:
anime_url: URL of the anime page
lang: Language preference
Returns:
List of episodes with episode_number, url, title
"""
try:
response = await self.client.get(anime_url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
episodes = []
# Look for episode links (typically in a list or table)
# French-Manga usually has episode links in <a> tags with episode numbers
for link in soup.find_all("a", href=True):
href = link["href"]
text = link.get_text(strip=True)
# Pattern: Episode links usually contain "episode" or numbers
if re.search(r"episode?\s*\d+", text.lower()):
episode_num = re.search(r"(\d+)", text)
if episode_num:
episode_number = int(episode_num.group(1))
# Ensure absolute URL
if href.startswith("/"):
href = self.base_url + href
episodes.append(
{
"episode_number": episode_number,
"url": href,
"title": text,
"host": "french-manga",
}
)
# Sort by episode number
episodes.sort(key=lambda x: x["episode_number"])
logger.info(f"Found {len(episodes)} episodes for {anime_url}")
return episodes
except Exception as e:
logger.error(f"Error getting episodes: {e}")
return []
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
"""
Get detailed metadata for an anime.
Args:
anime_url: URL of the anime page
Returns:
Dict with metadata (synopsis, genres, rating, etc.)
"""
try:
response = await self.client.get(anime_url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
# Extract title
title = ""
title_elem = soup.find("h1") or soup.find("h2", class_="title")
if title_elem:
title = title_elem.get_text(strip=True)
# Extract synopsis
synopsis = ""
synopsis_elem = soup.find(
"div", class_=lambda x: x and "story" in x.lower()
)
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
# Extract cover image
poster_image = ""
img_elem = soup.find("img", class_=lambda x: x and "poster" in x.lower())
if img_elem and img_elem.get("src"):
poster_image = img_elem["src"]
if poster_image.startswith("/"):
poster_image = self.base_url + poster_image
# Extract genres
genres = []
genre_links = soup.find_all("a", href=re.compile(r"/xfsearch/.*genre/"))
for link in genre_links[:10]: # Limit to 10 genres
genre = link.get_text(strip=True)
if genre:
genres.append(genre)
# Extract rating (if available)
rating = ""
rating_elem = soup.find(
["span", "div"], class_=lambda x: x and "rating" in x.lower()
)
if rating_elem:
rating = rating_elem.get_text(strip=True)
return {
"title": title,
"synopsis": synopsis,
"genres": genres,
"rating": rating,
"release_year": "",
"studio": "",
"poster_image": poster_image,
"total_episodes": len(await self.get_episodes(anime_url)),
"status": "",
"languages": ["vf", "vostfr"],
}
except Exception as e:
logger.error(f"Error getting anime metadata: {e}")
return {
"title": "",
"synopsis": "",
"genres": [],
"rating": "",
"release_year": "",
"studio": "",
"poster_image": "",
"total_episodes": 0,
"status": "",
"languages": ["vf", "vostfr"],
}
async def get_download_link(self, url: str) -> tuple[str, str]:
"""
Get download link from episode page.
For French-Manga, this returns the video player URL.
The actual video extraction will be handled by the video player downloaders.
Args:
url: Episode page URL
Returns:
Tuple of (video_player_url, episode_title)
"""
try:
response = await self.client.get(url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
# Look for iframe or video player
iframe = soup.find("iframe", src=True)
if iframe:
video_url = iframe["src"]
else:
# Look for video tag directly
video = soup.find("video", src=True)
if video:
video_url = video["src"]
else:
# Try to find in script tags
scripts = soup.find_all("script")
for script in scripts:
if script.string:
# Look for iframe or video URLs in JavaScript
patterns = [
r'iframe.*?src=["\']([^"\']+)["\']',
r'video.*?src=["\']([^"\']+)["\']',
]
for pattern in patterns:
match = re.search(pattern, script.string, re.IGNORECASE)
if match:
video_url = match.group(1)
break
if "video_url" in locals():
break
if "video_url" not in locals():
raise ValueError("Could not find video player URL")
# Ensure absolute URL
if video_url.startswith("//"):
video_url = "https:" + video_url
elif video_url.startswith("/"):
video_url = self.base_url + video_url
# Extract episode title
title_elem = soup.find("h1") or soup.find("h2")
episode_title = title_elem.get_text(strip=True) if title_elem else "Episode"
episode_title = sanitize_filename(episode_title)
logger.info(f"Extracted video player URL: {video_url[:60]}...")
return video_url, episode_title
except Exception as e:
logger.error(f"Error getting download link: {e}")
raise ValueError(f"Failed to extract download link: {str(e)}")
-317
View File
@@ -1,317 +0,0 @@
from .base import BaseAnimeSite
from bs4 import BeautifulSoup
import re
from typing import Optional
from urllib.parse import urljoin
class NekoSamaDownloader(BaseAnimeSite):
"""Downloader for neko-sama.org (anime streaming via Gupy)
NOTE: neko-sama.org now redirects to Gupy, which is a legal streaming search engine.
It does NOT host video content - it provides metadata about where to watch legally.
This provider can search and get metadata but cannot provide direct download links.
"""
BASE_DOMAINS = [
"neko-sama.org",
"www.neko-sama.org",
"neko-sama.fr",
"nekosama.fr",
"www.gupy.fr",
"gupy.fr",
]
def __init__(self):
super().__init__()
self.id = "neko-sama"
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, target_filename: Optional[str] = None
) -> tuple[str, str]:
"""
Extract download link from neko-sama URL.
NOTE: neko-sama.org/Gupy is a legal streaming search engine, NOT a video host.
This returns streaming platform information instead of direct video links.
"""
try:
# Check if this is a Gupy URL
if "gupy.fr" in url or "neko-sama.org" in url:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, "lxml")
# Look for streaming platform links
streaming_links = []
for link in soup.find_all("a", href=True):
href = link.get("href", "")
if "/out/" in href:
text = link.get_text(strip=True)
if text and "Regarder" in text:
streaming_links.append(f"{text}: {href}")
if streaming_links:
title_elem = soup.find("h1") or soup.find("title")
title = (
title_elem.get_text(strip=True).split("|")[0].strip()
if title_elem
else "Unknown"
)
info = "Available streaming platforms:\n" + "\n".join(
streaming_links[:5]
)
filename = target_filename or f"{title}_streaming_info.txt"
return info, filename
raise Exception(
"No streaming links found - Gupy is a legal streaming search, not a video host"
)
# Legacy: try original method for other URLs
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 - Neko-Sama/Gupy does not host video content"
)
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]:
"""Get list of episodes for an anime."""
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, "lxml")
episodes = []
# Try to find episode links
episode_links = soup.find_all("a", href=re.compile(r"episode"))
for link in episode_links:
href = link.get("href", "")
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
if match:
episode_num = match.group(1)
if not href.startswith("http"):
href = urljoin(anime_url, href)
episodes.append({"episode": episode_num, "url": href})
# Deduplicate and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep["episode"] not in seen:
seen.add(ep["episode"])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x["episode"]))
return unique_episodes
except Exception as e:
return []
async def get_anime_metadata(self, anime_url: str) -> dict:
"""Extract rich metadata from anime page."""
try:
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, "lxml")
metadata = {
"synopsis": None,
"genres": [],
"rating": None,
"release_year": None,
"studio": None,
"poster_image": None,
"banner_image": None,
"total_episodes": None,
"status": None,
"alternative_titles": [],
}
# Extract title and year from h1
title_elem = soup.find("h1")
if title_elem:
title_text = title_elem.get_text(strip=True)
# Extract year from title like "Naruto (2002)"
year_match = re.search(r"\((\d{4})\)", title_text)
if year_match:
metadata["release_year"] = int(year_match.group(1))
# Extract synopsis - Gupy shows it as paragraphs
synopsis_elem = soup.find("p")
if synopsis_elem:
text = synopsis_elem.get_text(strip=True)
if len(text) > 50:
metadata["synopsis"] = text
# Extract genres from meta tags or links
genre_links = soup.find_all("a", href=re.compile(r"serie-|genre|tag"))
if genre_links:
genres = []
for link in genre_links[:5]:
text = link.get_text(strip=True)
if text and "/" not in text and len(text) < 30:
genres.append(text)
metadata["genres"] = genres
# Extract rating from percentage
rating_elem = soup.find(string=re.compile(r"\d+(\.\d+)?%"))
if rating_elem:
match = re.search(r"(\d+(\.\d+)?)%", rating_elem)
if match:
rating = float(match.group(1)) / 10
metadata["rating"] = f"{rating:.1f}/10"
# Extract poster image
poster_elem = soup.find("img", src=re.compile(r"poster|poster"))
if poster_elem:
metadata["poster_image"] = poster_elem.get("src")
# Extract episode count from page text
page_text = soup.get_text()
ep_match = re.search(r"(\d+)\s*episodes?", page_text, re.I)
if ep_match:
metadata["total_episodes"] = int(ep_match.group(1))
# Extract studio/director
director_elem = soup.find("a", href=re.compile(r"person|réalisé"))
if director_elem:
metadata["studio"] = director_elem.get_text(strip=True)
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
return metadata
except Exception as e:
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
return {}
async def search_anime(
self, query: str, lang: str = "vostfr", include_metadata: bool = False
) -> list[dict]:
"""Search for anime on neko-sama (uses Gupy backend)."""
try:
import time
from html import unescape
start = time.time()
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
# Neko-Sama now uses Gupy - try the direct URL pattern
search_slug = query.lower().replace(" ", "-")
search_urls = [
f"https://www.gupy.fr/series/{search_slug}/",
f"https://neko-sama.org/series/{search_slug}/",
]
results = []
for search_url in search_urls:
response = await self.client.get(search_url, follow_redirects=True)
print(f"[NEKO-SAMA] Tried {search_url} -> {response.status_code}")
if response.status_code == 200:
final_url = str(response.url)
print(f"[NEKO-SAMA] Found anime at {final_url}")
# Extract title from page
soup = BeautifulSoup(response.text, "lxml")
title_elem = soup.find("h1") or soup.find("title")
title = (
unescape(title_elem.get_text(strip=True))
if title_elem
else query
)
# Clean up title
title = title.split("|")[0].split("-")[0].strip()
result = {
"title": title,
"url": final_url,
"cover_image": None,
"type": "direct",
"metadata": None,
}
# Try to get poster
poster = soup.find("img", src=re.compile(r"poster"))
if poster:
result["cover_image"] = poster.get("src")
if include_metadata:
metadata = await self.get_anime_metadata(final_url)
result["metadata"] = metadata
results.append(result)
break
elapsed = time.time() - start
print(
f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results"
)
return results
except Exception as e:
print(f"[NEKO-SAMA] Error: {str(e)}")
return []
-268
View File
@@ -1,268 +0,0 @@
from .base import BaseAnimeSite
from bs4 import BeautifulSoup
import re
from urllib.parse import urljoin
class VostfreeDownloader(BaseAnimeSite):
"""Downloader for vostfree.tv"""
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
def __init__(self):
super().__init__()
self.id = "vostfree"
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""Extract download link from vostfree URL"""
try:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, "lxml")
# Method 1: Look for iframe players
iframes = soup.find_all("iframe")
for iframe in iframes:
src = iframe.get("src", "")
if src and any(p in src for p in ["player", "video", "stream"]):
if not src.startswith("http"):
src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url))
return src, filename
# Method 2: Look for video tags
videos = soup.find_all("video")
for video in videos:
src = video.get("src")
if src:
filename = self._generate_filename(str(response.url))
return src, filename
sources = video.find_all("source")
for source in sources:
src = source.get("src", "")
if src and any(ext in src for ext in ["mp4", "m3u8"]):
filename = self._generate_filename(str(response.url))
return src, filename
# Method 3: Look in scripts
scripts = soup.find_all("script")
for script in scripts:
if script.string:
patterns = [
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)',
r'"url":"([^"]+)"',
r'"file":"([^"]+)"',
r'"video":"([^"]+)"',
]
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
match = match.replace("\\/", "/")
if any(ext in match for ext in ["mp4", "m3u8"]):
filename = self._generate_filename(str(response.url))
return match, filename
raise Exception("Could not find video link")
except Exception as e:
raise Exception(f"Error extracting Vostfree link: {str(e)}")
def _generate_filename(self, url: str) -> str:
parts = url.split("/")
anime_name = "anime"
episode = "1"
for part in parts:
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
if match:
episode = match.group(1)
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, "lxml")
episodes = []
episode_links = soup.find_all("a", href=re.compile(r"episode", re.I))
for link in episode_links:
href = link.get("href", "")
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
if match:
episode_num = match.group(1)
if not href.startswith("http"):
href = urljoin(anime_url, href)
episodes.append({"episode": episode_num, "url": href})
# Deduplicate and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep["episode"] not in seen:
seen.add(ep["episode"])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x["episode"]))
return unique_episodes
except Exception as e:
return []
async def get_anime_metadata(self, anime_url: str) -> dict:
"""
Extract rich metadata from anime page
Returns synopsis, genres, rating, release year, studio, etc.
"""
try:
print(f"[VOSTFREE] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, "lxml")
metadata = {
"synopsis": None,
"genres": [],
"rating": None,
"release_year": None,
"studio": None,
"poster_image": None,
"banner_image": None,
"total_episodes": None,
"status": None,
"alternative_titles": [],
}
# Extract synopsis
synopsis_selectors = [
"div.synopsis",
"div.description",
'div[class*="synopsis"]',
'div[class*="desc"]',
"p.synopsis",
".anime-synopsis",
]
for selector in synopsis_selectors:
synopsis_elem = soup.select_one(selector)
if synopsis_elem:
synopsis = synopsis_elem.get_text(strip=True)
if len(synopsis) > 50:
metadata["synopsis"] = synopsis
break
# Extract genres
genre_links = soup.find_all("a", href=re.compile(r"genre|tag|type", re.I))
if genre_links:
metadata["genres"] = [
link.get_text(strip=True) for link in genre_links[:5]
]
# Extract rating
rating_selectors = [
"span.rating",
"div.rating",
"span.score",
'div[class*="rating"]',
'div[class*="score"]',
]
for selector in rating_selectors:
rating_elem = soup.select_one(selector)
if rating_elem:
rating_text = rating_elem.get_text(strip=True)
rating_match = re.search(r"(\d+\.?\d*)\s*/\s*10", rating_text)
if rating_match:
metadata["rating"] = f"{rating_match.group(1)}/10"
break
# Extract release year
page_text = soup.get_text()
year_matches = re.findall(r"\b(19\d{2}|20\d{2})\b", page_text)
if year_matches:
import datetime
current_year = datetime.datetime.now().year + 2
valid_years = [
int(y) for y in year_matches if 1950 <= int(y) <= current_year
]
if valid_years:
from collections import Counter
metadata["release_year"] = Counter(valid_years).most_common(1)[0][0]
# Extract poster image
poster_elem = soup.select_one("img.poster, img.cover, .anime-poster img")
if poster_elem:
metadata["poster_image"] = poster_elem.get("src") or poster_elem.get(
"data-src"
)
# Extract poster from og:image
og_image = soup.find("meta", property="og:image")
if og_image and not metadata["poster_image"]:
metadata["poster_image"] = og_image.get("content")
# Extract total episodes
episodes_count = len(await self.get_episodes(anime_url))
if episodes_count > 0:
metadata["total_episodes"] = episodes_count
print(f"[VOSTFREE] Extracted metadata: {metadata}")
return metadata
except Exception as e:
print(f"[VOSTFREE] Error extracting metadata: {e}")
return {}
async def search_anime(
self, query: str, lang: str = "vostfr", include_metadata: bool = False
) -> list[dict]:
"""
Search for anime on vostfree
Args:
query: Search query string
lang: Language preference (vostfr, vf)
include_metadata: Whether to fetch full metadata for each result (slower)
"""
try:
import time
start = time.time()
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
# Vostfree URL pattern
search_url = f"https://vostfree.tv/anime/{query.lower().replace(' ', '-')}"
response = await self.client.get(search_url)
elapsed = time.time() - start
print(f"[VOSTFREE] Got response {response.status_code} in {elapsed:.2f}s")
if response.status_code == 200:
print(f"[VOSTFREE] Found anime at {str(response.url)}")
result = {
"title": query,
"url": str(response.url),
"type": "direct",
"metadata": None,
}
if include_metadata:
metadata = await self.get_anime_metadata(str(response.url))
result["metadata"] = metadata
return [result]
print(f"[VOSTFREE] No anime found")
return []
except Exception as e:
print(f"[VOSTFREE] Error: {str(e)}")
return []
-54
View File
@@ -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 []
-122
View File
@@ -1,122 +0,0 @@
"""Generic scraper driven by YAML configuration"""
import yaml
import logging
import httpx
from bs4 import BeautifulSoup
from typing import List, Dict, Optional, Any
from pathlib import Path
from urllib.parse import urljoin, quote
from app.downloaders.anime_sites.base import BaseAnimeSite
from app.models import AnimeSearchResult, AnimeMetadata
from app.metadata_enrichment import get_metadata_enricher
logger = logging.getLogger(__name__)
class GenericScraper(BaseAnimeSite):
"""A scraper that uses external configuration for its logic"""
def __init__(self, config_path: str):
with open(config_path, 'r', encoding='utf-8') as f:
self.config = yaml.safe_load(f)
self.id = self.config['id']
self.name = self.config['name']
self.base_url = self.config['base_url']
self.mirrors = self.config.get('mirrors', [])
# Current active base URL (can change if mirror found)
self.active_url = self.base_url
self.client = httpx.AsyncClient(
timeout=20.0,
follow_redirects=True,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
)
async def search(self, query: str) -> List[AnimeSearchResult]:
"""Search using configured selectors"""
search_config = self.config.get('search')
if not search_config:
logger.warning(f"No search config for {self.name}")
return []
search_path = search_config['path'].format(query=quote(query))
url = urljoin(self.active_url, search_path)
try:
response = await self.client.get(url)
soup = BeautifulSoup(response.text, 'lxml')
results = []
container = search_config.get('container_selector')
items = soup.select(container) if container else [soup]
for item in items:
try:
title_node = item.select_one(search_config['title_selector'])
url_node = item.select_one(search_config['url_selector'])
if not title_node or not url_node:
continue
title = title_node.get_text(strip=True)
href = url_node.get('href')
anime_url = urljoin(self.active_url, href)
img_node = item.select_one(search_config.get('image_selector', 'img'))
cover_image = img_node.get('src') if img_node else None
if cover_image:
cover_image = urljoin(self.active_url, cover_image)
# Initial metadata from scraper
meta_dict = {
"poster_image": cover_image,
"status": "Unknown"
}
# Enrich with Kitsu via global service
enricher = await get_metadata_enricher()
metadata = await enricher.enrich_metadata(meta_dict, title, anime_url)
results.append(AnimeSearchResult(
title=title,
url=anime_url,
cover_image=metadata.poster_image or cover_image,
type="search_result",
metadata=metadata
))
except Exception as e:
logger.error(f"Error parsing search result item: {e}")
return results
except Exception as e:
logger.error(f"Search failed for {self.name}: {e}")
return []
async def get_episodes(self, anime_url: str) -> List[Dict[str, Any]]:
"""Get episodes list (to be specialized if site logic is complex)"""
# Default implementation for simple sites
# For complex sites like Anime-Sama, we might still need a specialized subclass
# but driven by the YAML config for base parameters.
return []
async def check_health(self) -> bool:
"""Check if the site is up and selectors still work"""
try:
# Try a test search for a very common anime
results = await self.search("One Piece")
is_healthy = len(results) > 0
if not is_healthy:
logger.warning(f"Health check failed for {self.name}: No results found")
return is_healthy
except Exception as e:
logger.error(f"Health check failed for {self.name} with error: {e}")
return False
async def close(self):
await self.client.aclose()
@@ -1,24 +0,0 @@
name: "Anime-Sama"
id: "animesama"
base_url: "https://anime-sama.fr"
mirrors:
- "https://anime-sama.si"
- "https://anime-sama.co"
search:
path: "/search?q={query}"
container_selector: ".result-item"
title_selector: "h3"
url_selector: "a"
image_selector: "img"
episodes:
container_selector: "#episodes-list"
item_selector: ".episode-item"
# Logic for Anime-Sama can be complex, we'll handle custom logic in GenericScraper
# but keep common selectors here.
player_iframe_selector: "iframe#player"
metadata:
synopsis_selector: ".synopsis"
genres_selector: ".genres .genre"
-26
View File
@@ -1,26 +0,0 @@
"""Series streaming sites (catalogs) downloaders"""
from .base import BaseSeriesSite
# Import all series site downloaders
from .fs7 import FS7Downloader
from .zonetelechargement import ZoneTelechargementDownloader
__all__ = [
"BaseSeriesSite",
"FS7Downloader",
"ZoneTelechargementDownloader",
]
def get_series_site(url: str) -> BaseSeriesSite:
"""Factory function to get the appropriate series site for a URL"""
sites = [
FS7Downloader(),
ZoneTelechargementDownloader(),
]
for site in sites:
if site.can_handle(url):
return site
# Return None if no match (should not happen in normal flow)
return None
-131
View File
@@ -1,131 +0,0 @@
"""Base class for series streaming sites (catalogs)"""
from abc import abstractmethod
from typing import List, Dict, Any, Optional, Tuple
import logging
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
class BaseSeriesSite:
"""
Base class for series streaming sites.
Series sites provide catalogs, metadata, and episode listings.
They typically link to video players for actual file hosting.
Examples: FS7 (French Stream), etc.
KEY FEATURE: Provides rich metadata and episode management for TV series
"""
def __init__(self):
# Initialize HTTP client directly
self.client = httpx.AsyncClient(timeout=10.0, follow_redirects=True)
@abstractmethod
def can_handle(self, url: str) -> bool:
"""Check if this series site can handle the given URL"""
pass
@abstractmethod
async def search_anime(
self,
query: str,
lang: str = "vf"
) -> List[Dict[str, str]]:
"""
Search for series on this site.
Args:
query: Search query (series title)
lang: Language preference (vf, vostfr)
Returns:
List of series with keys:
- title: Series title
- url: Series page URL
- cover_image: Optional cover image URL
- lang: Available languages
"""
pass
@abstractmethod
async def get_episodes(
self,
anime_url: str,
lang: str = "vf"
) -> List[Dict[str, str]]:
"""
Get list of episodes for a series.
Args:
anime_url: URL of the series page
lang: Language preference
Returns:
List of episodes with keys:
- episode_number: Episode number
- url: Episode page URL
- title: Optional episode title
- host: Video player hosting the file
"""
pass
@abstractmethod
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
"""
Get detailed metadata for a series.
Args:
anime_url: URL of the series page
Returns:
Dict with metadata:
- title: Series title
- synopsis: Plot summary
- genres: List of genres
- rating: Rating (e.g., "8.5/10")
- release_year: Release year
- studio: Production studio
- poster_image: Poster URL
- total_episodes: Total episode count
- status: Airing status (ongoing, completed)
- languages: Available languages
"""
pass
@abstractmethod
async def get_download_link(self, url: str) -> Tuple[str, str]:
"""
Get download link for a specific episode.
For series sites, this extracts the video player URL from an episode page.
Note: Returns video player URL, NOT direct download link!
Returns:
Tuple of (video_player_url, episode_title)
"""
pass
# Common methods for all series sites
async def close(self):
"""Close HTTP client"""
await self.client.aclose()
async def _fetch_page(self, url: str) -> str:
"""Fetch HTML page content"""
response = await self.client.get(url)
response.raise_for_status()
return response.text
def _parse_html(self, html: str) -> BeautifulSoup:
"""Parse HTML with BeautifulSoup"""
return BeautifulSoup(html, 'lxml')
def _extract_season_number(self, title: str) -> Optional[int]:
"""Extract season number from title (e.g., 'Saison 2' -> 2)"""
import re
match = re.search(r'saison\s*(\d+)', title.lower())
return int(match.group(1)) if match else None
-438
View File
@@ -1,438 +0,0 @@
"""FS7 (French Stream) series site downloader"""
import logging
import re
from typing import List, Dict, Any, Optional
from urllib.parse import urljoin, urlparse
from bs4 import BeautifulSoup
from app.utils import sanitize_filename
from .base import BaseSeriesSite
logger = logging.getLogger(__name__)
class FS7Downloader(BaseSeriesSite):
"""
Downloader for FS7 (French Stream) series site.
FS7 is a French streaming site for TV series and films.
"""
def __init__(self):
super().__init__()
self.id = "fs7"
self.provider_id = "fs7"
self.default_domain = "fs7.lol"
self.test_tlds = ["lol", "one", "site", "vip", "fun", "stream", "com", "net", "org", "tv", "ws", "cc", "co"]
self.base_url = f"https://{self.default_domain}"
self._domain_checked = False
self.client.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
)
async def _ensure_base_url(self):
"""Ensure base_url is set to the current active domain"""
if self._domain_checked:
return
self._domain_checked = True
try:
from app.utils import DomainManager
active_domain = await DomainManager.get_active_domain(
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
)
self.base_url = f"https://{active_domain}"
logger.info(f"Using active domain for FS7: {self.base_url}")
except Exception as e:
logger.warning(f"Domain check failed for FS7, using default: {e}")
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
return "fs7.lol" in url.lower() or "french-stream" in url.lower()
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
"""
Search for series on FS7 using DLE AJAX search endpoint.
Args:
query: Search query
lang: Language preference (vf, vostfr)
Returns:
List of series with title, url, cover_image
"""
try:
await self._ensure_base_url()
logger.info(f"Searching FS7 for: {query}")
ajax_url = f"{self.base_url}/engine/ajax/search.php"
response = await self.client.post(
ajax_url,
data={"query": query, "page": "1"},
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"{self.base_url}/",
},
)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
results = []
for item in soup.find_all("div", class_="search-item")[:24]:
onclick = item.get("onclick", "")
url_match = re.search(r"location\.href=['\"]([^'\"]+)['\"]", onclick)
if not url_match:
continue
url = url_match.group(1)
if not url.startswith("http"):
url = urljoin(self.base_url, url)
title_elem = item.find("div", class_="search-title")
title = title_elem.get_text(strip=True) if title_elem else ""
title = re.sub(r"\s+", " ", title).strip()
cover_image = ""
poster_elem = item.find("div", class_="search-poster")
if poster_elem:
img = poster_elem.find("img")
if img:
cover_image = (
img.get("data-src")
or img.get("data-original")
or img.get("src")
or ""
)
if title and len(title) > 2:
results.append(
{
"title": title,
"url": url,
"cover_image": cover_image,
"provider_id": self.provider_id,
}
)
logger.info(f"Found {len(results)} results on FS7 for '{query}'")
return results
except Exception as e:
logger.error(f"Error searching FS7: {e}")
return []
async def get_episodes(
self, anime_url: str, lang: str = "vf"
) -> List[Dict[str, str]]:
"""
Get episode list for a series.
Args:
anime_url: URL of the series page
lang: Language preference
Returns:
List of episodes with episode number and url
"""
try:
logger.info(f"Fetching episodes from: {anime_url}")
response = await self.client.get(anime_url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
episodes = []
# Get series title for episode naming
title_elem = soup.find("h1")
series_title = title_elem.get_text(strip=True) if title_elem else "Series"
# Clean up title: remove "affiche" suffix
series_title = re.sub(
r"\s+affiche$", "", series_title, flags=re.IGNORECASE
).strip()
# FS7 stores episode data in JavaScript div elements
# Format: <div data-ep="1" data-vidzy="..." data-uqload="..." data-netu="..." data-voe="..."></div>
episode_divs = soup.find_all("div", attrs={"data-ep": True})
for div in episode_divs:
ep_num = div.get("data-ep", "").strip()
# Try different video players in order of preference
video_url = None
host_name = None
for player in ["data-vidzy", "data-uqload", "data-voe", "data-netu"]:
player_url = div.get(player, "").strip()
if player_url:
video_url = player_url
# Extract host name from attribute name
host_name = player.replace("data-", "").title()
logger.debug(f"Found episode {ep_num} on {host_name}")
break
if video_url and ep_num:
# Create episode title for filename
episode_title = f"{series_title} - Episode {ep_num}"
# Use pipe-separated format: video_url|anime_url|episode_title
combined_url = f"{video_url}|{anime_url}|{episode_title}"
episodes.append(
{
"episode": ep_num,
"url": combined_url,
"title": episode_title,
"host": host_name or "Unknown",
}
)
# Sort by episode number
episodes.sort(
key=lambda x: int(x["episode"]) if x["episode"].isdigit() else 0
)
logger.info(f"Found {len(episodes)} episodes")
return episodes
except Exception as e:
logger.error(f"Error getting episodes from FS7: {e}")
return []
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
"""
Get metadata for a series.
Args:
anime_url: URL of the series page
Returns:
Dictionary with metadata
"""
try:
logger.info(f"Fetching metadata from: {anime_url}")
response = await self.client.get(anime_url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
# Extract title
title = soup.find("h1")
title = title.get_text(strip=True) if title else "Unknown"
# Clean up title: remove "affiche" suffix
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
# --- Synopsis: div.fdesc > p ---
description = ""
fdesc = soup.find("div", class_="fdesc")
if fdesc:
p = fdesc.find("p")
if p:
description = p.get_text(strip=True)
else:
description = fdesc.get_text(strip=True)
# --- Poster: div.fleft > img ---
poster_image = ""
fleft = soup.find("div", class_="fleft")
if fleft:
img = fleft.find("img")
if img:
poster_image = (
img.get("data-src")
or img.get("data-original")
or img.get("src")
or ""
)
# Fallback: img.poster, then og:image
if not poster_image:
img = soup.find("img", class_="poster")
poster_image = img.get("src", "") if img else ""
if not poster_image:
meta_img = soup.find("meta", property="og:image")
poster_image = meta_img.get("content", "") if meta_img else ""
# --- Year: span.release ---
release_year = None
release_span = soup.find("span", class_="release")
if release_span:
year_match = re.search(r"\b(19|20)\d{2}\b", release_span.get_text())
if year_match:
release_year = int(year_match.group())
# --- Genres: span.genres ---
genres = []
genres_span = soup.find("span", class_="genres")
if genres_span:
genres = [
g.strip()
for g in genres_span.get_text().split(",")
if g.strip()
]
# --- Runtime: span.runtime ---
runtime = None
runtime_span = soup.find("span", class_="runtime")
if runtime_span:
runtime = runtime_span.get_text(strip=True)
# --- Casting info from second div.flist ---
original_title = ""
director = ""
cast = []
flists = soup.find_all("div", class_="flist")
for fl in flists:
text = fl.get_text(strip=True)
if "Titre Original" in text:
m = re.search(r"Titre Original\s*:\s*(.+?)(?:Réalisateur|$)", text)
if m:
original_title = m.group(1).strip()
m2 = re.search(r"Réalisateur\s*:\s*(.+?)(?:Avec\s*:|$)", text)
if m2:
director = m2.group(1).strip()
m3 = re.search(r"Avec\s*:\s*(.+?)(?:plus|$)", text)
if m3:
cast = [c.strip() for c in m3.group(1).split(",") if c.strip()]
return {
"title": title,
"synopsis": description,
"poster_image": poster_image,
"release_year": release_year,
"genres": genres,
"rating": None,
"studio": None,
"total_episodes": None,
"status": None,
"original_title": original_title,
"director": director,
"cast": cast,
"runtime": runtime,
}
except Exception as e:
logger.error(f"Error getting metadata from FS7: {e}")
return {
"title": "Unknown",
"synopsis": "",
"poster_image": "",
"genres": [],
"rating": None,
"release_year": None,
"studio": None,
"total_episodes": None,
"status": None,
}
async def get_download_link(
self, url: str, target_filename: Optional[str] = None
) -> tuple[str, str]:
"""
Extract download link from video player URL.
Args:
url: Video player URL
target_filename: Optional filename override
Returns:
Tuple of (download_url, filename)
"""
# FS7 uses embedded video players
# Delegate to the appropriate video player downloader
from app.downloaders.video_players import get_video_player
player = get_video_player(url)
if player:
return await player.get_download_link(url, target_filename)
else:
raise ValueError(f"No video player found for URL: {url}")
async def get_latest_series(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
Scrape the 'Nouveautés Séries' section from FS7 homepage.
Returns:
List of dicts with title, url, cover_image, synopsis, lang, provider_id.
"""
await self._ensure_base_url()
try:
resp = await self.client.get(self.base_url + "/", timeout=15)
soup = BeautifulSoup(resp.text, "html.parser")
except Exception as e:
logger.error(f"Failed to fetch FS7 homepage: {e}")
return []
results = []
# Find the 'Nouveautés Séries' section
for section in soup.find_all("div", class_="pages"):
title_el = section.find("div", class_="sect-t")
if not title_el:
continue
title = title_el.get_text(strip=True)
if "Nouveautés" not in title or "Séries" not in title:
continue
for item in section.find_all("div", class_="short"):
# Get the poster link (contains real URL)
poster_a = item.find("a", class_="short-poster", href=True)
if not poster_a:
continue
url = poster_a["href"]
if url.startswith("/"):
url = self.base_url + url
# Title from alt attribute
title_attr = poster_a.get("alt", "").strip()
if not title_attr:
continue
# Poster image
img = poster_a.find("img")
cover_image = img.get("src", "") if img else ""
# Synopsis from hidden span
desc_span = item.find("span", id=re.compile(r"^desc-\d+"))
synopsis = desc_span.get_text(strip=True) if desc_span else ""
# Language (VF/VOSTFR)
lang = "vf"
version_span = item.find("span", class_="film-version")
if version_span:
version_text = version_span.get_text(strip=True).upper()
if "VOSTFR" in version_text:
lang = "vostfr"
elif "VF" in version_text:
lang = "vf"
results.append({
"title": title_attr,
"url": url,
"cover_image": cover_image,
"synopsis": synopsis,
"lang": lang,
"provider_id": self.provider_id,
"content_type": "series",
})
if len(results) >= limit:
break
break # Only process the first matching section
logger.info(f"FS7 latest series: found {len(results)} items")
return results
@@ -1,236 +0,0 @@
"""Zone-Telechargement series site downloader"""
import logging
import re
from typing import List, Dict, Any, Optional, Tuple
from urllib.parse import urljoin, quote
from bs4 import BeautifulSoup
from app.utils import DomainManager
from .base import BaseSeriesSite
logger = logging.getLogger(__name__)
class ZoneTelechargementDownloader(BaseSeriesSite):
"""
Downloader for Zone-Telechargement series site.
Handles dynamic TLD verification.
"""
def __init__(self):
super().__init__()
self.id = "zonetelechargement"
self.provider_id = "zonetelechargement"
self.default_domain = "zone-telechargement.golf"
self.test_tlds = ["golf", "cam", "net", "org", "blue", "lol", "work", "ws"]
self.base_url = f"https://{self.default_domain}"
self._domain_checked = False
self.client.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
}
)
async def _ensure_base_url(self):
"""Ensure base_url is set to the current active domain"""
if self._domain_checked:
return
self._domain_checked = True
try:
active_domain = await DomainManager.get_active_domain(
self.provider_id, self.default_domain, self.test_tlds, test_path="/"
)
self.base_url = f"https://{active_domain}"
logger.info(f"Using active domain for Zone-Telechargement: {self.base_url}")
except Exception as e:
logger.warning(
f"Domain check failed for Zone-Telechargement, using default: {e}"
)
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
return "zone-telechargement" in url.lower() or "zt-za" in url.lower()
async def search_anime(self, query: str, lang: str = "vf") -> List[Dict[str, str]]:
"""Search for series on Zone-Telechargement.
ZT uses server-side rendered search: GET /?p=series&search=QUERY.
Results are in div.cover_global containers with nested cover_infos_title links.
"""
try:
await self._ensure_base_url()
logger.info(f"Searching Zone-Telechargement for: {query}")
search_url = f"{self.base_url}/"
params = {"p": "series", "search": query}
response = await self.client.get(search_url, params=params)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "lxml")
results = []
for cover_div in soup.find_all("div", class_="cover_global")[:24]:
link_in_cover = cover_div.find("a", class_="mainimg")
if not link_in_cover:
link_in_cover = cover_div.find("a")
if not link_in_cover:
continue
url = link_in_cover.get("href", "")
if not url.startswith("http"):
url = urljoin(self.base_url, url)
img = cover_div.find("img")
cover_image = ""
if img:
cover_image = img.get("data-src") or img.get("src") or ""
if cover_image and not cover_image.startswith("http"):
cover_image = urljoin(self.base_url, cover_image)
title = ""
info_div = cover_div.find("div", class_="cover_infos_title")
if info_div:
title_link = info_div.find("a")
if title_link:
title = title_link.get_text(strip=True)
else:
title = info_div.get_text(strip=True)
else:
title = link_in_cover.get("title", "")
if not title:
title = link_in_cover.get_text(strip=True)
if title and len(title) > 2:
results.append(
{
"title": title,
"url": url,
"cover_image": cover_image,
"provider_id": self.provider_id,
}
)
logger.info(
f"Zone-Telechargement found {len(results)} results for '{query}'"
)
return results
except Exception as e:
logger.error(f"Error searching Zone-Telechargement: {e}")
return []
async def get_episodes(
self, anime_url: str, lang: str = "vf"
) -> List[Dict[str, str]]:
"""Extract episodes from a series page"""
try:
await self._ensure_base_url()
html = await self._fetch_page(anime_url)
soup = BeautifulSoup(html, "lxml")
episodes = []
seen_urls = set()
# ZT lists episodes as <a> tags inside <b> inside div.postinfo
# Text matches "Episode X" pattern, URLs go through dl-protect
for link in soup.find_all("a"):
text = link.get_text(strip=True)
ep_match = re.search(r"episode\s*(\d+)", text, re.I)
if not ep_match:
continue
href = link.get("href", "")
if not href or href in seen_urls:
continue
seen_urls.add(href)
ep_number = int(ep_match.group(1))
episodes.append(
{"episode_number": ep_number, "url": href, "title": text}
)
# Sort by episode number
episodes.sort(key=lambda x: x["episode_number"])
logger.info(f"Found {len(episodes)} episodes on {anime_url}")
return episodes
except Exception as e:
logger.error(f"Error getting episodes from Zone-Telechargement: {e}")
return []
async def get_anime_metadata(self, anime_url: str) -> Dict[str, Any]:
"""Extract metadata from a series page"""
try:
await self._ensure_base_url()
html = await self._fetch_page(anime_url)
soup = BeautifulSoup(html, "lxml")
metadata = {
"title": "",
"synopsis": "",
"genres": [],
"poster_image": "",
"status": "Unknown",
}
title_elem = soup.find("h1")
if title_elem:
metadata["title"] = title_elem.get_text(strip=True)
# Synopsis
syn_elem = soup.find("div", class_="shm-description") or soup.find(
"div", class_="movie-desc"
)
if syn_elem:
metadata["synopsis"] = syn_elem.get_text(strip=True)
# Poster
img_elem = (
soup.find("div", class_="shm-img").find("img")
if soup.find("div", class_="shm-img")
else None
)
if img_elem:
metadata["poster_image"] = urljoin(
self.base_url, img_elem.get("src", "")
)
return metadata
except Exception as e:
logger.error(f"Error getting metadata from Zone-Telechargement: {e}")
return {}
async def get_download_link(self, url: str) -> Tuple[str, str]:
"""Extract video player URL from an episode page"""
try:
await self._ensure_base_url()
html = await self._fetch_page(url)
soup = BeautifulSoup(html, "lxml")
# Look for video player links (Uptobox, 1fichier, etc.)
# ZT often has multiple hosts
links = soup.find_all(
"a", href=re.compile(r"uptobox|1fichier|doodstream|vidmoly")
)
if links:
player_url = links[0].get("href", "")
title = (
soup.find("h1").get_text(strip=True)
if soup.find("h1")
else "Episode"
)
return player_url, title
return "", ""
except Exception as e:
logger.error(f"Error getting download link from Zone-Telechargement: {e}")
return "", ""

Some files were not shown because too many files have changed in this diff Show More