feat: Add SendVid downloader support

Add complete support for SendVid video hosting service used by Anime-Sama
for anime series like Hell's Paradise.

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
root
2026-01-23 08:17:10 +00:00
commit cb3ea8d926
25 changed files with 4657 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Ohm Streaming API Configuration
# Server
HOST=0.0.0.0
PORT=8000
RELOAD=true
# Paths
UPLOAD_DIR=uploads
STREAM_DIR=streams
# CORS
ALLOWED_ORIGINS=*
+31
View File
@@ -0,0 +1,31 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
# FastAPI
uploads/
streams/
downloads/
# Environment
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
+113
View File
@@ -0,0 +1,113 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Ohm Stream Downloader is a FastAPI-based web application for downloading media files from various file hosting services (1fichier, Doodstream, Rapidfile, etc.). It features a web interface, parallel downloads, pause/resume support, and direct file serving.
## Development Commands
```bash
# Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Run development server (auto-reload)
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Access web interface
# Open http://localhost:8000/web in browser
```
## Architecture
**Directory Structure:**
```
Ohm_streaming/
├── main.py # FastAPI application & API endpoints
├── app/
│ ├── models/ # Pydantic models (DownloadTask, DownloadStatus, etc.)
│ ├── downloaders/ # Host-specific downloaders
│ │ ├── base.py # BaseDownloader abstract class
│ │ ├── unfichier.py # 1fichier.com handler
│ │ ├── doodstream.py # Doodstream handler
│ │ └── rapidfile.py # Rapidfile handler
│ └── download_manager.py # Manages download queue, progress, parallel downloads
├── downloads/ # Downloaded files storage
├── templates/
│ └── index.html # Web interface (single-page app)
└── static/ # Static assets (CSS, JS, images)
```
**Core Components:**
1. **DownloadManager** (`app/download_manager.py`)
- Manages all download tasks with parallel download limit (default: 3 concurrent)
- Handles pause/resume/cancel operations
- Tracks progress, speed, and file chunks for resume support
- Uses semaphore to limit concurrent downloads
2. **Downloaders** (`app/downloaders/`)
- Each host has its own downloader class inheriting from `BaseDownloader`
- `can_handle(url)` - Checks if downloader supports the URL
- `get_download_link(url)` - Extracts direct download link and filename from host page
- Uses httpx for async HTTP requests and BeautifulSoup for HTML parsing
3. **Download Task Flow:**
- Client sends URL via POST `/api/download`
- DownloadManager creates task with unique ID
- Appropriate downloader extracts direct link
- File downloaded in chunks (1MB) to `downloads/` directory
- Progress tracked in real-time (bytes, speed, percentage)
- Resume uses HTTP Range headers to continue from last byte
**API Endpoints:**
- `POST /api/download` - Create new download task (starts automatically)
- `GET /api/downloads` - List all download tasks with status
- `GET /api/download/{task_id}` - Get specific task details
- `POST /api/download/{task_id}/pause` - Pause active download
- `POST /api/download/{task_id}/resume` - Resume paused download
- `DELETE /api/download/{task_id}` - Cancel/delete download
- `GET /api/download/{task_id}/file` - Download completed file
- `GET /web` - Web interface
**Web Interface:**
- Single-page app at `/web` (templates/index.html)
- Auto-refreshes every second to show progress
- Shows progress bar, speed, file size
- Controls: Pause, Resume, Cancel, Download completed file
## Adding New Host Support
To add support for a new file hosting service:
1. Create new file in `app/downloaders/` (e.g., `myhost.py`)
2. Inherit from `BaseDownloader`
3. Implement `can_handle(url)` to detect your host URLs
4. Implement `get_download_link(url)` to extract direct download link
5. Import and add to `downloaders` list in `app/downloaders/__init__.py`
Example:
```python
from .base import BaseDownloader
class MyHostDownloader(BaseDownloader):
def can_handle(self, url: str) -> bool:
return "myhost.com" in url.lower()
async def get_download_link(self, url: str) -> tuple[str, str]:
# Fetch page, parse HTML, extract download URL
soup = BeautifulSoup(await self._fetch_page(url), 'lxml')
# ... extraction logic ...
return download_url, filename
```
## Configuration
Edit `main.py` to configure:
- `max_parallel` - Maximum concurrent downloads (default: 3)
- `download_dir` - Storage location (default: "downloads")
+106
View File
@@ -0,0 +1,106 @@
# Ohm Stream Downloader
Web application pour télécharger des fichiers depuis divers hébergeurs (1fichier, Doodstream, Rapidfile, etc.).
## Fonctionnalités
- **Multi-hébergeurs** : Support pour 1fichier, Doodstream, Rapidfile et plus
- **Téléchargements parallèles** : Jusqu'à 3 téléchargements simultanés
- **Pause/Reprise** : Mettez en pause et reprenez vos téléchargements
- **Interface web moderne** : Interface intuitive avec progression en temps réel
- **API REST** : Intégration facile avec d'autres applications
## Installation
```bash
# Créer l'environnement virtuel
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Installer les dépendances
pip install -r requirements.txt
# Lancer le serveur
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
## Utilisation
### Interface Web
Ouvrez votre navigateur sur : http://localhost:8000/web
Collez simplement un lien de téléchargement et cliquez sur "Télécharger".
### API
**Créer un téléchargement :**
```bash
curl -X POST http://localhost:8000/api/download \
-H "Content-Type: application/json" \
-d '{"url": "https://1fichier.com/?xxxxx"}'
```
**Lister les téléchargements :**
```bash
curl http://localhost:8000/api/downloads
```
**Mettre en pause :**
```bash
curl -X POST http://localhost:8000/api/download/{task_id}/pause
```
**Reprendre :**
```bash
curl -X POST http://localhost:8000/api/download/{task_id}/resume
```
**Annuler :**
```bash
curl -X DELETE http://localhost:8000/api/download/{task_id}
```
## API Endpoints
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| POST | `/api/download` | Créer un nouveau téléchargement |
| GET | `/api/downloads` | Lister tous les téléchargements |
| GET | `/api/download/{task_id}` | Statut d'un téléchargement |
| POST | `/api/download/{task_id}/pause` | Mettre en pause |
| POST | `/api/download/{task_id}/resume` | Reprendre |
| DELETE | `/api/download/{task_id}` | Annuler/Supprimer |
| GET | `/api/download/{task_id}/file` | Télécharger le fichier terminé |
| GET | `/web` | Interface web |
## Structure du Projet
```
Ohm_streaming/
├── main.py # Application FastAPI
├── app/
│ ├── models/ # Modèles de données
│ ├── downloaders/ # Extracteurs de liens par hébergeur
│ └── download_manager.py # Gestionnaire de téléchargements
├── downloads/ # Fichiers téléchargés
├── templates/
│ └── index.html # Interface web
└── static/ # Fichiers statiques
```
## Ajouter un Hébergeur
Pour ajouter le support d'un nouvel hébergeur :
1. Créez un fichier dans `app/downloaders/` (ex: `myhost.py`)
2. Héritez de `BaseDownloader`
3. Implémentez `can_handle(url)` et `get_download_link(url)`
4. Ajoutez le downloader dans `app/downloaders/__init__.py`
## Configuration
- `max_parallel` : Nombre maximum de téléchargements simultanés (défaut: 3)
- `download_dir` : Répertoire de stockage (défaut: "downloads")
Modifiez ces paramètres dans `main.py`.
+1
View File
@@ -0,0 +1 @@
# Ohm Stream Downloader Package
+190
View File
@@ -0,0 +1,190 @@
import asyncio
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
import httpx
from app.models import DownloadTask, DownloadStatus, DownloadRequest
from app.downloaders import get_downloader
class DownloadManager:
"""Manages multiple downloads with queue and progress tracking"""
def __init__(self, download_dir: str = "downloads", max_parallel: int = 3):
self.download_dir = Path(download_dir)
self.download_dir.mkdir(exist_ok=True)
self.max_parallel = max_parallel
self.tasks: Dict[str, DownloadTask] = {}
self.active_downloads: Dict[str, asyncio.Task] = {}
self._semaphore = asyncio.Semaphore(max_parallel)
def get_task(self, task_id: str) -> Optional[DownloadTask]:
return self.tasks.get(task_id)
def get_all_tasks(self) -> list[DownloadTask]:
return list(self.tasks.values())
def create_task(self, request: DownloadRequest) -> DownloadTask:
task_id = str(uuid.uuid4())
task = DownloadTask(
id=task_id,
url=request.url,
filename=request.filename or "download",
host="other",
status=DownloadStatus.PENDING,
created_at=datetime.now()
)
self.tasks[task_id] = task
return task
async def start_download(self, task_id: str):
task = self.tasks.get(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
if task.status == DownloadStatus.DOWNLOADING:
return
# Cancel any existing download task
if task_id in self.active_downloads:
self.active_downloads[task_id].cancel()
# Start new download
download_task = asyncio.create_task(self._download(task))
self.active_downloads[task_id] = download_task
async def pause_download(self, task_id: str):
task = self.tasks.get(task_id)
if task and task.status == DownloadStatus.DOWNLOADING:
task.status = DownloadStatus.PAUSED
if task_id in self.active_downloads:
self.active_downloads[task_id].cancel()
del self.active_downloads[task_id]
async def cancel_download(self, task_id: str):
task = self.tasks.get(task_id)
if task:
task.status = DownloadStatus.CANCELLED
if task_id in self.active_downloads:
self.active_downloads[task_id].cancel()
del self.active_downloads[task_id]
# Delete partial file
if task.file_path and os.path.exists(task.file_path):
os.remove(task.file_path)
async def _download(self, task: DownloadTask):
async with self._semaphore:
try:
task.status = DownloadStatus.DOWNLOADING
task.started_at = datetime.now()
# Get downloader and extract link
downloader = get_downloader(task.url)
download_url, filename = await downloader.get_download_link(task.url)
if not task.filename or task.filename == "download":
task.filename = filename
task.file_path = str(self.download_dir / task.filename)
# Check if file already exists and is complete (for VidMoly which downloads directly)
if os.path.exists(task.file_path):
file_size = os.path.getsize(task.file_path)
if file_size > 1024: # More than 1KB - assume complete
print(f"[DOWNLOAD] File already exists: {task.filename} ({file_size / (1024*1024):.2f} MB)")
task.status = DownloadStatus.COMPLETED
task.progress = 100.0
task.downloaded_bytes = file_size
task.total_bytes = file_size
task.completed_at = datetime.now()
return
# Check for partial download (resume)
downloaded_bytes = 0
if os.path.exists(task.file_path):
downloaded_bytes = os.path.getsize(task.file_path)
headers = {}
# Add SendVid-specific headers to avoid 403 errors
if 'sendvid.com' in download_url:
headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': 'https://sendvid.com/',
})
if downloaded_bytes > 0:
headers['Range'] = f'bytes={downloaded_bytes}-'
# Download with streaming
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
# First attempt with Range header if resuming
try:
async with client.stream('GET', download_url, headers=headers) as response:
response.raise_for_status()
# Process download (same code for both cases)
await self._process_download(response, task, downloaded_bytes)
except httpx.HTTPStatusError as e:
# If server doesn't support Range (416 error), restart from beginning
if e.response.status_code == 416 and downloaded_bytes > 0:
print(f"[DOWNLOAD] Server doesn't support Range, restarting download: {task.filename}")
# Remove partial file and restart without Range header
if os.path.exists(task.file_path):
os.remove(task.file_path)
downloaded_bytes = 0
headers = {}
async with client.stream('GET', download_url, headers=headers) as response:
response.raise_for_status()
await self._process_download(response, task, downloaded_bytes)
else:
raise
except Exception as e:
task.status = DownloadStatus.FAILED
task.error = str(e)
finally:
if task.id in self.active_downloads:
del self.active_downloads[task.id]
async def _process_download(self, response, task: DownloadTask, downloaded_bytes: int):
"""Process the download response stream"""
# Get total size
if 'content-range' in response.headers:
# Resume mode
total_size = int(response.headers['content-range'].split('/')[-1])
else:
# New download
total_size = int(response.headers.get('content-length', 0))
downloaded_bytes = 0
task.total_bytes = total_size
# Write file
mode = 'ab' if downloaded_bytes > 0 else 'wb'
with open(task.file_path, mode) as f:
start_time = asyncio.get_event_loop().time()
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
if task.status == DownloadStatus.CANCELLED:
return
if task.status == DownloadStatus.PAUSED:
return
f.write(chunk)
downloaded_bytes += len(chunk)
task.downloaded_bytes = downloaded_bytes
# Calculate progress
if total_size > 0:
task.progress = (downloaded_bytes / total_size) * 100
# Calculate speed
elapsed = asyncio.get_event_loop().time() - start_time
if elapsed > 0:
task.speed = downloaded_bytes / elapsed
task.status = DownloadStatus.COMPLETED
task.completed_at = datetime.now()
task.progress = 100.0
+48
View File
@@ -0,0 +1,48 @@
from .base import BaseDownloader
from .unfichier import UnFichierDownloader
from .doodstream import DoodStreamDownloader
from .rapidfile import RapidFileDownloader
from .uptobox import UptoboxDownloader
from .animesama import AnimeSamaDownloader
from .animeultime import AnimeUltimeDownloader
from .nekosama import NekoSamaDownloader
from .vostfree import VostfreeDownloader
from .vidmoly import VidMolyDownloader
from .sendvid import SendVidDownloader
def get_downloader(url: str) -> BaseDownloader:
"""Factory function to get the appropriate downloader for a URL"""
downloaders = [
# Anime sites
AnimeSamaDownloader(),
AnimeUltimeDownloader(),
NekoSamaDownloader(),
VostfreeDownloader(),
# File hosts
UnFichierDownloader(),
UptoboxDownloader(),
DoodStreamDownloader(),
RapidFileDownloader(),
VidMolyDownloader(),
SendVidDownloader(),
]
for downloader in downloaders:
if downloader.can_handle(url):
return downloader
# Return generic downloader if no match
return GenericDownloader()
class GenericDownloader(BaseDownloader):
"""Generic downloader for unhandled hosts"""
def can_handle(self, url: str) -> bool:
return True
async def get_download_link(self, url: str) -> tuple[str, str]:
# Just return the URL as-is
filename = url.split('/')[-1] or "download"
return url, filename
+475
View File
@@ -0,0 +1,475 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
from urllib.parse import urljoin, unquote
class AnimeSamaDownloader(BaseDownloader):
"""Downloader for anime-sama.org / anime-sama.store"""
# Static list of known domains (will be updated dynamically)
BASE_DOMAINS = ["anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"]
@classmethod
async def get_current_domain(cls) -> str:
"""
Fetch the current active domain from anime-sama.pw
Returns the current domain (e.g., 'anime-sama.si')
"""
try:
import httpx
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get("https://anime-sama.pw")
# Look for the main link in the HTML
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, 'lxml')
# Look for the primary button/link
primary_link = soup.find('a', class_='btn-primary')
if primary_link and primary_link.get('href'):
href = primary_link['href']
# Extract domain from URL
from urllib.parse import urlparse
parsed = urlparse(href)
domain = parsed.netloc # e.g., 'anime-sama.si'
print(f"[ANIME-SAMA] Current domain from anime-sama.pw: {domain}")
return domain
# Fallback: look for any anime-sama.* link
for link in soup.find_all('a', href=True):
href = link['href']
if 'anime-sama.' in href and href.startswith('https://'):
from urllib.parse import urlparse
parsed = urlparse(href)
domain = parsed.netloc
if domain not in ['anime-sama.pw', 'www.anime-sama.pw']:
print(f"[ANIME-SAMA] Found domain via fallback: {domain}")
return domain
print("[ANIME-SAMA] Could not determine current domain, using default")
return "anime-sama.si"
except Exception as e:
print(f"[ANIME-SAMA] Error fetching current domain: {e}")
return "anime-sama.si"
@classmethod
async def update_domains(cls) -> None:
"""
Update the BASE_DOMAINS list with the current active domain
This should be called periodically to keep up with domain changes
"""
try:
current_domain = await cls.get_current_domain()
# Add the current domain and its www variant if not already present
domains_to_add = [current_domain]
if not current_domain.startswith('www.'):
domains_to_add.append(f'www.{current_domain}')
for domain in domains_to_add:
if domain not in cls.BASE_DOMAINS:
# Insert at the beginning for priority
cls.BASE_DOMAINS.insert(0, domain)
print(f"[ANIME-SAMA] Added new domain: {domain}")
except Exception as e:
print(f"[ANIME-SAMA] Error updating domains: {e}")
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""
Extract download link from anime-sama URL
Anime-Sama uses third-party video hosts (vidmoly, etc.)
We'll try to extract the video URL from these hosts
"""
try:
print(f"[ANIME-SAMA] Extracting link from: {url}")
# Check if URL contains the anime page context (format: video_url|anime_page_url|episode_title?)
if '|' in url:
parts = url.split('|')
video_url = parts[0]
anime_page_url = parts[1] if len(parts) > 1 else None
episode_title = parts[2] if len(parts) > 2 else None
print(f"[ANIME-SAMA] Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}")
# Extract video from the host URL with anime context for filename
if 'vidmoly.to' in video_url or 'vidmoly' in video_url:
return await self._extract_from_vidmoly(video_url, anime_page_url, episode_title)
elif 'sendvid.com' in video_url:
return await self._extract_from_sendvid(video_url, anime_page_url, episode_title)
else:
# Try to extract from other hosts
if episode_title:
filename = f"{self._generate_anime_name(anime_page_url)} - {episode_title}.mp4"
else:
filename = self._generate_filename_from_anime_url(anime_page_url)
return video_url, filename
# Check if this is a third-party host URL
if 'vidmoly.to' in url or 'vidmoly' in url:
return await self._extract_from_vidmoly(url)
# If it's an anime-sama page, try to find the video
if 'anime-sama' in url.lower():
response = await self.client.get(url, follow_redirects=True)
final_url = str(response.url)
soup = BeautifulSoup(response.text, 'lxml')
# Look for iframe with video player
iframes = soup.find_all('iframe')
for iframe in iframes:
src = iframe.get('src', '')
if src and any(provider in src for provider in ['vidmoly', 'player', 'stream', 'play', 'embed']):
if src.startswith('http'):
print(f"[ANIME-SAMA] Found iframe: {src}")
# Try to extract video from the player
video_url = await self._extract_from_player(src)
if video_url:
filename = self._generate_filename(final_url)
return video_url, filename
# Look for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src', '')
if src:
if not src.startswith('http'):
src = urljoin(final_url, src)
filename = self._generate_filename(final_url)
return src, filename
sources = video.find_all('source')
for source in sources:
src = source.get('src', '')
if src:
if not src.startswith('http'):
src = urljoin(final_url, src)
filename = self._generate_filename(final_url)
return src, filename
raise Exception("Could not find video link on page")
except Exception as e:
raise Exception(f"Error extracting AnimeSama link: {str(e)}")
async def _extract_from_vidmoly(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]:
"""Extract video URL from vidmoly player - delegate to VidMolyDownloader"""
try:
print(f"[ANIME-SAMA] Extracting from vidmoly: {url}")
print(f"[ANIME-SAMA] Delegating to VidMolyDownloader...")
# Import VidMolyDownloader
from .vidmoly import VidMolyDownloader
# Generate the target filename first
if episode_title and anime_page_url:
anime_name = self._generate_anime_name(anime_page_url)
target_filename = f"{anime_name} - {episode_title}.mp4"
print(f"[ANIME-SAMA] Generated filename: {target_filename} (episode: {episode_title})")
elif anime_page_url:
target_filename = self._generate_filename_from_anime_url(anime_page_url)
print(f"[ANIME-SAMA] Generated filename: {target_filename} (no episode title)")
else:
target_filename = None
print(f"[ANIME-SAMA] No target_filename generated")
# Use VidMolyDownloader to extract and download
vidmoly_downloader = VidMolyDownloader()
# Pass the target filename to VidMolyDownloader if available
if target_filename:
video_url, temp_filename = await vidmoly_downloader.get_download_link(url, target_filename=target_filename)
else:
video_url, temp_filename = await vidmoly_downloader.get_download_link(url)
# Use the target filename
filename = target_filename if target_filename else temp_filename
print(f"[ANIME-SAMA] Got video: {filename}")
# Rename the file if needed
import os
if temp_filename != filename:
# temp_filename might be a full path or just the name
temp_path = temp_filename if os.path.isabs(temp_filename) else os.path.join('downloads', temp_filename)
if os.path.exists(temp_path):
final_path = os.path.join('downloads', filename)
if os.path.exists(final_path):
os.remove(final_path)
os.rename(temp_path, final_path)
print(f"[ANIME-SAMA] Renamed {temp_filename} -> {filename}")
else:
print(f"[ANIME-SAMA] Warning: temp file not found: {temp_path}")
# Return the original VidMoly URL - the file exists so download_manager will skip it
return url, filename
except Exception as e:
print(f"[ANIME-SAMA] Vidmoly extraction error: {e}")
raise Exception(f"Error extracting from vidmoly: {str(e)}")
async def _extract_from_sendvid(self, url: str, anime_page_url: str = None, episode_title: str = None) -> tuple[str, str]:
"""Extract video URL from sendvid player - delegate to SendVidDownloader"""
try:
print(f"[ANIME-SAMA] Extracting from sendvid: {url}")
print(f"[ANIME-SAMA] Delegating to SendVidDownloader...")
# Import SendVidDownloader
from .sendvid import SendVidDownloader
# Generate the target filename first
if episode_title and anime_page_url:
anime_name = self._generate_anime_name(anime_page_url)
target_filename = f"{anime_name} - {episode_title}.mp4"
print(f"[ANIME-SAMA] Generated filename: {target_filename} (episode: {episode_title})")
elif anime_page_url:
target_filename = self._generate_filename_from_anime_url(anime_page_url)
print(f"[ANIME-SAMA] Generated filename: {target_filename} (no episode title)")
else:
target_filename = None
print(f"[ANIME-SAMA] No target_filename generated")
# Use SendVidDownloader to extract the video URL
sendvid_downloader = SendVidDownloader()
# Pass the target filename to SendVidDownloader if available
if target_filename:
video_url, filename = await sendvid_downloader.get_download_link(url, target_filename=target_filename)
else:
video_url, filename = await sendvid_downloader.get_download_link(url)
# Use the target filename
filename = target_filename if target_filename else filename
print(f"[ANIME-SAMA] Got video: {filename}")
# Return the direct video URL (SendVid provides direct MP4 links)
# The download_manager will handle the actual download
return video_url, filename
except Exception as e:
print(f"[ANIME-SAMA] SendVid extraction error: {e}")
raise Exception(f"Error extracting from sendvid: {str(e)}")
def _generate_filename_from_anime_url(self, anime_url: str) -> str:
"""Generate filename from anime-sama anime page URL"""
try:
# Extract anime name from URL like: https://anime-sama.si/catalogue/naruto/saison1/vostfr/
# Format: /catalogue/{anime}/saison{N}/{lang}/
parts = anime_url.split('/')
for i, part in enumerate(parts):
if part == 'catalogue' and i + 1 < len(parts):
anime_name = parts[i + 1].replace('-', ' ').title()
# Try to find episode number
episode = "01"
for j, part2 in enumerate(parts):
if 'saison' in part2 and j + 2 < len(parts):
# Look for episode in the remaining path
pass
return f"{anime_name} - Episode {episode}.mp4"
# Fallback
return "Anime - Episode 01.Mp4"
except:
return "Anime - Episode 01.Mp4"
def _generate_anime_name(self, anime_url: str) -> str:
"""Extract just the anime name from anime-sama URL"""
try:
# Extract anime name from URL like: https://anime-sama.si/catalogue/naruto/saison1/vostfr/
parts = anime_url.split('/')
for i, part in enumerate(parts):
if part == 'catalogue' and i + 1 < len(parts):
return parts[i + 1].replace('-', ' ').title()
# Fallback
return "Anime"
except:
return "Anime"
async def _extract_from_player(self, player_url: str) -> str | None:
"""Try to extract direct video URL from player iframe"""
try:
response = await self.client.get(player_url)
soup = BeautifulSoup(response.text, 'lxml')
# Check for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src') or video.get('data-src')
if src:
return src
# Check for source tags
sources = soup.find_all('source')
for source in sources:
src = source.get('src')
if src and any(ext in src for ext in ['mp4', 'm3u8', 'mkv']):
return src
# Check scripts in player page
scripts = soup.find_all('script')
for script in scripts:
if script.string:
match = re.search(r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)', script.string)
if match:
return match.group(1)
except:
pass
return None
def _generate_filename(self, url: str) -> str:
"""Generate filename from URL"""
# Extract anime name and episode info from URL
# URL format: .../catalogue/{anime}/saison{N}/{vostfr|vf}/episode-{N}
parts = url.split('/')
anime_name = "anime"
episode = "1"
for i, part in enumerate(parts):
if part == 'catalogue' and i + 1 < len(parts):
anime_name = parts[i + 1].replace('-', ' ')
elif 'episode-' in part:
episode = part.replace('episode-', '')
elif part in ['vostfr', 'vf']:
lang = part.upper()
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
"""
Search for anime on anime-sama
Returns list of anime with title, url, and cover image
"""
try:
# Update domains before searching to ensure we have the current domain
await self.update_domains()
import time
start = time.time()
print(f"[ANIME-SAMA] Searching for '{query}' ({lang})...")
# Use the current domain from anime-sama.pw
current_domain = await self.get_current_domain()
# Convert query to URL format (lowercase, replace spaces with hyphens)
query_formatted = query.lower().replace(' ', '-').replace("'", '').replace(':', '')
search_url = f"https://{current_domain}/catalogue/{query_formatted}/saison1/{lang}/"
response = await self.client.get(search_url, follow_redirects=True)
elapsed = time.time() - start
print(f"[ANIME-SAMA] Got response {response.status_code} in {elapsed:.2f}s")
if response.status_code == 200:
# Check if it's a valid anime page by looking for episode selector
if 'selectEpisodes' in response.text or 'episodes.js' in response.text:
print(f"[ANIME-SAMA] Found anime at {str(response.url)}")
return [{
'title': query,
'url': str(response.url),
'type': 'direct'
}]
print(f"[ANIME-SAMA] No anime found (status: {response.status_code})")
return []
except Exception as e:
print(f"[ANIME-SAMA] Error: {str(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
Anime-Sama uses a JavaScript file (episodes.js) to store episode URLs
"""
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
episodes = []
# Try to find the episodes.js file in the HTML
episodes_js_match = re.search(r'episodes\.js\?filever=(\d+)', response.text)
if episodes_js_match:
file_ver = episodes_js_match.group(1)
# Build the URL to episodes.js
episodes_js_url = f"{anime_url.rstrip('/')}/episodes.js?filever={file_ver}"
print(f"[ANIME-SAMA] Found episodes.js at {episodes_js_url}")
try:
# Fetch the episodes.js file
js_response = await self.client.get(episodes_js_url)
js_content = js_response.text
# Parse the JavaScript file to extract episode URLs
# The file contains arrays like: var eps1 = ['url1', 'url2', ...]
eps_matches = re.findall(r'var\s+eps\d+\s*=\s*(\[[^\]]+\])', js_content)
if eps_matches:
# Extract URLs from the first array found
urls_text = eps_matches[0]
# Parse the array of URLs
episode_urls = re.findall(r"'(https?://[^']+)'", urls_text)
for idx, url in enumerate(episode_urls, start=1):
episode_num = str(idx).zfill(2)
episode_title = f'Episode {episode_num}'
# Store both the video URL, the anime page URL, and the episode title
# Format: video_url|anime_page_url|episode_title
combined_url = f"{url}|{anime_url}|{episode_title}"
episodes.append({
'episode': episode_num,
'url': combined_url,
'title': episode_title
})
print(f"[ANIME-SAMA] Found {len(episodes)} episodes")
return episodes
except Exception as e:
print(f"[ANIME-SAMA] Error fetching episodes.js: {e}")
# Fallback: Try to find episode links in the HTML (old method)
episode_links = soup.find_all('a', href=True)
for link in episode_links:
href = link['href']
if 'episode-' in href:
# Extract episode number
match = re.search(r'episode-(\d+)', href)
if match:
episode_num = match.group(1)
full_url = urljoin(anime_url, href)
episodes.append({
'episode': episode_num,
'url': full_url
})
# Remove duplicates and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
return unique_episodes
except Exception as e:
print(f"[ANIME-SAMA] Error getting episodes: {e}")
return []
+313
View File
@@ -0,0 +1,313 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
from urllib.parse import urljoin
class AnimeUltimeDownloader(BaseDownloader):
"""Downloader for anime-ultime.net"""
BASE_DOMAINS = ["anime-ultime.com", "anime-ultime.net", "www.anime-ultime.net"]
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""
Extract download link from anime-ultime URL
Anime-Ultime stores video links in og:video meta tags
"""
try:
# Follow redirects
response = await self.client.get(url, follow_redirects=True)
final_url = str(response.url)
# Parse the page
soup = BeautifulSoup(response.text, 'lxml')
# Method 0: Look for og:video meta tag (most reliable for anime-ultime)
og_video = soup.find('meta', property='og:video')
if og_video and og_video.get('content'):
video_url = og_video['content']
if video_url.endswith('.mp4'):
filename = self._generate_filename(final_url)
print(f"[ANIME-ULTIME] Found og:video link: {video_url}")
return video_url, filename
# Method 1: Look for direct download links (DDL)
# Anime-Ultime often uses links to file hosts
download_links = soup.find_all('a', href=True)
for link in download_links:
href = link['href']
text = link.get_text().lower()
# Look for download buttons/links
if any(keyword in text for keyword in ['télécharger', 'download', 'ddl', 'mega', 'google', 'drive']):
# Check if it's a direct link or to a file host
if any(host in href.lower() for host in ['mega.nz', 'drive.google.com', 'uptobox.com', '1fichier.com']):
filename = self._generate_filename(final_url)
return href, filename
# Method 2: Look for iframe with video player
iframes = soup.find_all('iframe')
for iframe in iframes:
src = iframe.get('src', '')
if src and any(provider in src for provider in ['video', 'player', 'stream', 'play']):
if src.startswith('http'):
filename = self._generate_filename(final_url)
return src, filename
# Method 3: Look for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src', '')
if src:
filename = self._generate_filename(final_url)
return src, filename
# Check source tags
sources = video.find_all('source')
for source in sources:
src = source.get('src', '')
if src:
filename = self._generate_filename(final_url)
return src, filename
# Method 4: Look in scripts for video URLs
scripts = soup.find_all('script')
for script in scripts:
if script.string:
# Look for common video patterns
patterns = [
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8|mkv)(?:\?[^"\'>\s]*)?)',
r'"url":"([^"]+)"',
r'"video":"([^"]+)"',
r'"file":"([^"]+)"',
r'file:\s*"([^"]+)"',
]
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
# Clean up escaped characters
match = match.replace('\\/', '/').replace('\\', '')
if any(ext in match for ext in ['mp4', 'm3u8', 'mkv']):
filename = self._generate_filename(final_url)
return match, filename
# Look for anime-ultime specific patterns
# They sometimes store links in JavaScript variables
ddl_match = re.search(r'ddl["\']?\s*:\s*["\']([^"\']+)["\']', script.string)
if ddl_match:
ddl_url = ddl_match.group(1)
if ddl_url.startswith('http'):
filename = self._generate_filename(final_url)
return ddl_url, filename
# Method 5: Look for links with specific classes or IDs
# Anime-Ultime might use specific class names for download links
potential_links = soup.find_all('a', class_=re.compile(r'download|ddl|episode', re.I))
for link in potential_links:
href = link.get('href', '')
if href and href.startswith('http'):
filename = self._generate_filename(final_url)
return href, filename
# If nothing found, raise error
raise Exception("Could not find download link on page")
except Exception as e:
raise Exception(f"Error extracting Anime-Ultime link: {str(e)}")
def _generate_filename(self, url: str) -> str:
"""Generate filename from URL"""
# Extract anime name and episode from URL
# URL formats:
# - info-0-1/30200
# - info-0-1/30200/Naruto-OAV-01-vostfr
# - file-0-1/2991-Naruto-OAV
anime_name = "Anime"
episode = "01"
# Format: info-0-1/EPISODE_ID or info-0-1/EPISODE_ID/NAME-EP-vostfr
if 'info-0-1/' in url:
# Extract episode ID
ep_match = re.search(r'info-0-1/(\d+)', url)
if ep_match:
ep_id = ep_match.group(1)
# Try to get anime name from URL path
name_match = re.search(r'info-0-1/\d+/([^/]+)', url)
if name_match:
raw_name = name_match.group(1)
# Extract episode number
ep_num_match = re.search(r'-(\d+)-vostfr$', raw_name, re.I)
if ep_num_match:
episode = ep_num_match.group(1).zfill(2)
# Remove episode number and suffix from name
anime_name = re.sub(r'-\d+-vostfr$', '', raw_name, flags=re.I).replace('-', ' ')
else:
# Just use the ID
anime_name = f"Episode {ep_id}"
else:
anime_name = f"Episode {ep_id}"
elif 'file-0-1/' in url:
# Extract from file-0-1/ID-NAME format
file_match = re.search(r'file-0-1/\d+-(.+)$', url)
if file_match:
anime_name = file_match.group(1).replace('-', ' ')
# Sanitize filename
anime_name = anime_name.replace('/', ' ').strip()
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
"""
Search for anime on anime-ultime
Returns list of anime with title, url, and cover image
"""
try:
import time
start = time.time()
print(f"[ANIME-ULTIME] Searching for '{query}' ({lang})...")
# Anime-Ultime uses POST for search
search_url = "https://www.anime-ultime.net/search-0-1"
response = await self.client.post(search_url, data={'search': query})
soup = BeautifulSoup(response.text, 'lxml')
elapsed = time.time() - start
print(f"[ANIME-ULTIME] Got response {response.status_code} in {elapsed:.2f}s")
results = []
# Look for search result links - better parsing
# Search results use file-0-1/ pattern, not info-
search_results = soup.find_all('a', href=re.compile(r'file-0-1/'))
seen_urls = set()
for result in search_results[:10]: # Limit to 10 results
href = result.get('href', '')
raw_title = result.get_text().strip()
# Skip if no href
if not href:
continue
# Skip duplicates
if href in seen_urls:
continue
seen_urls.add(href)
# Extract better title from URL or parent elements
better_title = raw_title
# If raw_title is just "Télécharger" or similar, try to find better title
if len(raw_title) < 5 or raw_title.lower() in ['télécharger', 'download', 'ddl']:
# Try to extract from URL (file-0-1/ID-Title format)
url_match = re.search(r'file-0-1/\d+-(.+)$', href)
if url_match:
better_title = url_match.group(1).replace('-', ' ').title()
# If still no good title, look at parent/row elements
if len(better_title) < 5:
# Check parent row (table structure)
row = result.find_parent(['tr', 'td', 'div'])
if row:
# Look for text in the row that's not the link text
row_text = row.get_text().strip()
# Remove the link text from row text
if raw_title in row_text:
row_text = row_text.replace(raw_title, '').strip()
if len(row_text) > 5 and len(row_text) < 100:
better_title = row_text
# Make URL absolute
if not href.startswith('http'):
href = urljoin("https://www.anime-ultime.net/", href)
results.append({
'title': better_title,
'url': href,
'type': 'search_result'
})
print(f"[ANIME-ULTIME] Found {len(results)} results")
return results
except Exception as e:
print(f"[ANIME-ULTIME] Error: {e}")
return []
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
"""
Get list of episodes for an anime
Returns list of episode numbers and their URLs
"""
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
episodes = []
# Look for episode links - anime-ultime uses info-XXXXX-Name-XX-vostfr format
# The URL pattern is info-0-1/ID-Anime-Name-XX-vostfr where XX is episode number
episode_links = soup.find_all('a', href=re.compile(r'info-0-1/\d+'))
for link in episode_links:
href = link.get('href', '')
text = link.get_text().strip()
# Extract episode number from URL pattern
# Matches: info-0-1/30200/Naruto-OAV-01-vostfr
match = re.search(r'-(\d+)-vostfr$', href, re.I)
if not match:
# Try other patterns
match = re.search(r'Episode[-\s]?(\d+)', href, re.I)
if not match:
# Try to extract from text
match = re.search(r'(\d+)', text)
if match:
episode_num = match.group(1).zfill(2) # Pad with zero
# Extract the episode ID from href and build correct URL
# href might be "info-0-1/30200" or "info-0-1/30200/..."
# We need: https://www.anime-ultime.net/info-0-1/30200
ep_id_match = re.search(r'info-0-1/(\d+)', href)
if ep_id_match:
ep_id = ep_id_match.group(1)
# Build the correct episode URL
episode_url = f"https://www.anime-ultime.net/info-0-1/{ep_id}"
else:
# Fallback to making URL absolute
if not href.startswith('http'):
href = urljoin(anime_url, href)
episode_url = href
episodes.append({
'episode': episode_num,
'url': episode_url,
'title': text
})
# Remove duplicates and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
return unique_episodes
except Exception as e:
print(f"Error getting episodes: {e}")
return []
+54
View File
@@ -0,0 +1,54 @@
from abc import ABC, abstractmethod
from typing import Optional, Tuple
import httpx
import re
from bs4 import BeautifulSoup
class BaseDownloader(ABC):
"""Base class for all host downloaders"""
def __init__(self):
self.client = httpx.AsyncClient(timeout=10.0, follow_redirects=True)
@abstractmethod
async def get_download_link(self, url: str) -> Tuple[str, str]:
"""
Extract direct download link and filename from host URL
Returns: (download_url, filename)
"""
pass
@abstractmethod
def can_handle(self, url: str) -> bool:
"""Check if this downloader can handle the given URL"""
pass
async def close(self):
await self.client.aclose()
async def _fetch_page(self, url: str) -> str:
response = await self.client.get(url)
response.raise_for_status()
return response.text
def _extract_filename_from_headers(self, headers: dict) -> Optional[str]:
content_disposition = headers.get("content-disposition", "")
if "filename=" in content_disposition:
filename = content_disposition.split("filename=")[-1].strip('"')
return filename
return None
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
"""
Search for anime on this provider
Returns list of anime with title, url, and optional cover image
"""
return []
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
"""
Get list of episodes for an anime
Returns list of episode numbers and their URLs
"""
return []
+79
View File
@@ -0,0 +1,79 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
class DoodStreamDownloader(BaseDownloader):
"""Downloader for doodstream.com"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["doodstream.com", "dood.stream", "dood.to", "dood.lol", "dood.cx", "dood.so", "dood.watch", "dood.sh"])
async def get_download_link(self, url: str) -> tuple[str, str]:
try:
# Get the page
response = await self.client.get(url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'lxml')
# Doodstream usually has the video URL in a script with '$(function)'
# or in a token-based system
download_url = None
filename = "doodstream_video.mp4"
# Method 1: Look for /pass_md5 or similar patterns
scripts = soup.find_all('script')
for script in scripts:
if script.string:
# Look for token patterns
match = re.search(r'https?://[^\"\']+\.(?:mp4|mkv|avi)', script.string)
if match:
download_url = match.group(0)
break
# Look for doodstream CDN patterns
match = re.search(r'(https?://[^\s\"\'<>]+/download/[^\s\"\'<>]+)', script.string)
if match:
download_url = match.group(0)
break
# Method 2: Try to construct download URL from page
if not download_url:
# Extract video ID from URL
# Format: https://doodstream.com/e/VIDEO_ID or /d/VIDEO_ID
video_id_match = re.search(r'/[ed]/([a-zA-Z0-9]+)', url)
if video_id_match:
video_id = video_id_match.group(1)
# Try direct download pattern
download_url = f"https://dood.stream/e/{video_id}"
# Method 3: Look for any MP4 source in iframes or video tags
if not download_url:
video = soup.find('video')
if video and video.get('src'):
download_url = video['src']
else:
sources = soup.find_all('source')
for source in sources:
if source.get('src'):
download_url = source['src']
filename = source.get('src', '').split('/')[-1]
break
if download_url:
# Try to get real filename from HEAD request
try:
head_resp = await self.client.head(download_url, timeout=5.0)
fname = self._extract_filename_from_headers(head_resp.headers)
if fname:
filename = fname
except:
pass
return download_url, filename
raise Exception("Could not extract download link from Doodstream page")
except Exception as e:
raise Exception(f"Error extracting Doodstream link: {str(e)}")
+144
View File
@@ -0,0 +1,144 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
from urllib.parse import urljoin
class NekoSamaDownloader(BaseDownloader):
"""Downloader for neko-sama.fr"""
BASE_DOMAINS = ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"]
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""Extract download link from neko-sama URL"""
try:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml')
# Method 1: Look for iframes with video
iframes = soup.find_all('iframe')
for iframe in iframes:
src = iframe.get('src', '')
if src and any(p in src for p in ['video', 'player', 'stream']):
if not src.startswith('http'):
src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url))
return src, filename
# Method 2: Look for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src') or video.get('data-src')
if src:
filename = self._generate_filename(str(response.url))
return src, filename
sources = video.find_all('source')
for source in sources:
src = source.get('src', '')
if src:
filename = self._generate_filename(str(response.url))
return src, filename
# Method 3: Look in scripts
scripts = soup.find_all('script')
for script in scripts:
if script.string:
patterns = [
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)',
r'"url":"([^"]+)"',
r'"video":"([^"]+)"',
]
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
match = match.replace('\\/', '/')
if any(ext in match for ext in ['mp4', 'm3u8']):
filename = self._generate_filename(str(response.url))
return match, filename
raise Exception("Could not find video link")
except Exception as e:
raise Exception(f"Error extracting NekoSama link: {str(e)}")
def _generate_filename(self, url: str) -> str:
parts = url.split('/')
anime_name = "anime"
episode = "1"
for i, part in enumerate(parts):
if 'episode' in part.lower():
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
if match:
episode = match.group(1)
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
episodes = []
episode_links = soup.find_all('a', href=re.compile(r'episode'))
for link in episode_links:
href = link.get('href', '')
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
if match:
episode_num = match.group(1)
if not href.startswith('http'):
href = urljoin(anime_url, href)
episodes.append({'episode': episode_num, 'url': href})
# Deduplicate and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
return unique_episodes
except Exception as e:
return []
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
"""
Search for anime on neko-sama
"""
try:
import time
start = time.time()
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
# Neko-Sama URL pattern: https://neko-sama.fr/anime/{anime-name}
search_url = f"https://neko-sama.fr/anime/{query.lower().replace(' ', '-')}"
response = await self.client.get(search_url)
elapsed = time.time() - start
print(f"[NEKO-SAMA] Got response {response.status_code} in {elapsed:.2f}s")
if response.status_code == 200:
print(f"[NEKO-SAMA] Found anime at {str(response.url)}")
return [{
'title': query,
'url': str(response.url),
'type': 'direct'
}]
print(f"[NEKO-SAMA] No anime found")
return []
except Exception as e:
print(f"[NEKO-SAMA] Error: {str(e)}")
return []
+75
View File
@@ -0,0 +1,75 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
class RapidFileDownloader(BaseDownloader):
"""Downloader for rapidfile.net and similar hosts"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["rapidfile.net", "rapidfile.com", "rapid-file"])
async def get_download_link(self, url: str) -> tuple[str, str]:
try:
# Get the initial page
response = await self.client.get(url)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'lxml')
download_url = None
filename = "rapidfile_download"
# Method 1: Look for download button/link
download_btn = soup.find('a', {'id': 'downloadbtn'}) or soup.find('a', class_='download-btn')
if download_btn and download_btn.get('href'):
download_url = download_btn['href']
# Method 2: Look for form with POST action
if not download_url:
forms = soup.find_all('form')
for form in forms:
action = form.get('action', '')
if action and ('download' in action.lower() or 'file' in action.lower()):
download_url = action if action.startswith('http') else url + action
break
# Method 3: Look for any link with download/file in URL
if not download_url:
for link in soup.find_all('a', href=True):
href = link['href']
if any(keyword in href.lower() for keyword in ['download', 'get_file', 'file.php']):
if href.startswith('http'):
download_url = href
break
# Method 4: Check for direct file links in scripts
if not download_url:
scripts = soup.find_all('script')
for script in scripts:
if script.string:
match = re.search(r'(https?://[^\s\"\'<>]+/(?:download|file)[^\s\"\'<>]+)', script.string)
if match:
download_url = match.group(0)
break
if download_url:
# Get filename from headers or URL
try:
head_resp = await self.client.head(download_url, timeout=5.0)
fname = self._extract_filename_from_headers(head_resp.headers)
if fname:
filename = fname
else:
filename = download_url.split('/')[-1] or "rapidfile_download"
except:
filename = download_url.split('/')[-1] or "rapidfile_download"
return download_url, filename
# If all else fails, return the original URL
filename = url.split('/')[-1] or "rapidfile_download"
return url, filename
except Exception as e:
raise Exception(f"Error extracting Rapidfile link: {str(e)}")
+83
View File
@@ -0,0 +1,83 @@
from typing import Optional
from bs4 import BeautifulSoup
from .base import BaseDownloader
import re
class SendVidDownloader(BaseDownloader):
"""Downloader for SendVid videos"""
def can_handle(self, url: str) -> bool:
return "sendvid.com" in url.lower()
async def _fetch_page(self, url: str) -> str:
"""Fetch page with proper headers to avoid 403 errors"""
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': 'https://sendvid.com/',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
}
response = await self.client.get(url, headers=headers)
response.raise_for_status()
return response.text
async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]:
"""
Extract direct download link from SendVid embed page
SendVid embed pages contain the direct MP4 URL in a <source> tag
"""
print(f"[SENDVID] Fetching page: {url}")
html = await self._fetch_page(url)
soup = BeautifulSoup(html, 'lxml')
# Try to find the video source in the <source> tag
source_tag = soup.find('source', {'id': 'video_source'})
if source_tag and source_tag.get('src'):
video_url = source_tag['src']
print(f"[SENDVID] Found video URL in <source> tag")
# Generate filename
if target_filename:
filename = target_filename
else:
# Extract filename from video URL or generate one
filename = self._extract_filename_from_url(url, video_url)
print(f"[SENDVID] Download URL: {video_url}")
print(f"[SENDVID] Filename: {filename}")
return video_url, filename
# Fallback: try to find in og:video meta property
og_video = soup.find('meta', {'property': 'og:video'})
if og_video and og_video.get('content'):
video_url = og_video['content']
print(f"[SENDVID] Found video URL in og:video meta")
if target_filename:
filename = target_filename
else:
filename = self._extract_filename_from_url(url, video_url)
print(f"[SENDVID] Download URL: {video_url}")
print(f"[SENDVID] Filename: {filename}")
return video_url, filename
raise Exception("Could not extract video URL from SendVid page")
def _extract_filename_from_url(self, page_url: str, video_url: str) -> str:
"""Generate filename from SendVod URLs"""
# Try to extract video ID from page URL
video_id_match = re.search(r'/embed/([a-z0-9]+)', page_url)
if video_id_match:
video_id = video_id_match.group(1)
# Try to get title from page (might need to fetch, but for now use ID)
return f"sendvid_{video_id}.mp4"
# Fallback: extract from video URL
filename_match = re.search(r'/([^/]+\.mp4)', video_url)
if filename_match:
return filename_match.group(1)
return "sendvid_video.mp4"
+51
View File
@@ -0,0 +1,51 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
class UnFichierDownloader(BaseDownloader):
"""Downloader for 1fichier.com"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["1fichier.com", "1fichier.fr"])
async def get_download_link(self, url: str) -> tuple[str, str]:
try:
# Initial page
response = await self.client.get(url)
response.raise_for_status()
# Check if we need to wait (download button)
soup = BeautifulSoup(response.text, 'lxml')
# Check for direct download link
download_link = soup.find('a', class_='btn btn-download')
if download_link and download_link.get('href'):
download_url = download_link['href']
# Follow to get headers for filename
head_resp = await self.client.head(download_url)
filename = self._extract_filename_from_headers(head_resp.headers)
if not filename:
filename = download_url.split('/')[-1] or "downloaded_file"
return download_url, filename
# Alternative: look for any download link in the page
for link in soup.find_all('a', href=True):
href = link['href']
if href.startswith('http') and '1fichier' not in href:
# Try to head the URL to see if it's a file
try:
head_resp = await self.client.head(href, timeout=5.0)
if 'content-length' in head_resp.headers or 'attachment' in head_resp.headers.get('content-disposition', ''):
filename = self._extract_filename_from_headers(head_resp.headers)
if not filename:
filename = href.split('/')[-1] or "downloaded_file"
return href, filename
except:
continue
raise Exception("Could not find download link on page")
except Exception as e:
raise Exception(f"Error extracting 1fichier link: {str(e)}")
+59
View File
@@ -0,0 +1,59 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
class UptoboxDownloader(BaseDownloader):
"""Downloader for uptobox.com"""
BASE_DOMAINS = ["uptobox.com", "uptobox.fr"]
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""Extract direct download link from uptobox"""
try:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml')
# Method 1: Look for direct download button/link
download_btn = soup.find('a', {'id': 'directDownload'}) or soup.find('a', class_='download-btn')
if download_btn and download_btn.get('href'):
href = download_btn['href']
filename = self._extract_filename_from_url(url) or "uptobox_file"
return href, filename
# Method 2: Look for any download link in page
links = soup.find_all('a', href=True)
for link in links:
href = link['href']
text = link.get_text().lower()
if any(keyword in text for keyword in ['download', 'télécharger', 'ddl']):
if href.startswith('http'):
filename = self._extract_filename_from_url(url) or "uptobox_file"
return href, filename
# Method 3: Return the original URL (uptobox handles downloads directly)
filename = self._extract_filename_from_url(url) or "uptobox_file"
return url, filename
except Exception as e:
raise Exception(f"Error extracting Uptobox link: {str(e)}")
def _extract_filename_from_url(self, url: str) -> str | None:
"""Try to extract filename from URL"""
# Look for filename parameter in URL
match = re.search(r'[&?]filename=([^&]+)', url)
if match:
from urllib.parse import unquote
return unquote(match.group(1))
# Extract from path
parts = url.split('/')
if len(parts) > 0:
last_part = parts[-1]
if '.' in last_part:
return last_part
return None
+439
View File
@@ -0,0 +1,439 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
import subprocess
import os
import tempfile
from pathlib import Path
import asyncio
from typing import Optional
class VidMolyDownloader(BaseDownloader):
"""Downloader for vidmoly.to using Playwright network interception"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["vidmoly.to", "vidmoly.org", "vidmoly.biz"])
async def get_download_link(self, url: str, target_filename: str = None) -> tuple[str, str]:
try:
# Extract VidMoly ID from URL
vidmoly_id = self._extract_vidmoly_id(url)
if not vidmoly_id:
raise Exception("Could not extract VidMoly ID from URL")
# Construct embed URL - try vidmoly.biz first (it works better than .to/.org)
# If original URL uses .biz, keep it. Otherwise try .biz first
domains_to_try = []
if "vidmoly.biz" in url.lower():
domains_to_try = ["vidmoly.biz"]
elif "vidmoly.to" in url.lower() or "vidmoly.org" in url.lower():
# For .to/.org, try .biz first (it has actual content), then original
domains_to_try = ["vidmoly.biz", url.split("//")[1].split("/")[0]]
else:
domains_to_try = ["vidmoly.biz", "vidmoly.to"]
video_source = None
last_error = None
working_domain = None
for domain in domains_to_try:
embed_url = f"https://{domain}/embed-{vidmoly_id}.html"
print(f"[VIDMOLY] Trying: {embed_url}")
# Use Playwright with network interception
video_source = await self._extract_with_playwright_network(embed_url)
if not video_source:
# Fallback to HTTP method
print("[VIDMOLY] Playwright failed, trying HTTP fallback...")
video_source = await self._extract_with_http(embed_url)
if video_source:
print(f"[VIDMOLY] ✅ Found video on {domain}")
working_domain = domain
break
else:
print(f"[VIDMOLY] ❌ No video on {domain}")
last_error = f"No video found on {domain}"
if not video_source:
raise Exception(f"Could not find video source - tried: {', '.join(domains_to_try)}. Last error: {last_error}")
# Use target_filename if provided, otherwise generate default
filename = target_filename if target_filename else f"vidmoly_{vidmoly_id}"
# Check if it's an M3U8 playlist
if '.m3u8' in video_source:
print(f"[VIDMOLY] Found M3U8 source: {video_source[:100]}...")
# Download and convert M3U8 to MP4 directly
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': f'https://{working_domain}/',
}
mp4_path = await self._download_m3u8_as_mp4(video_source, filename, headers)
return mp4_path, filename
# It's a direct MP4 link
if not video_source.endswith('.mp4'):
filename += '.mp4'
print(f"[VIDMOLY] Found MP4 source")
return video_source, filename
except Exception as e:
raise Exception(f"Error extracting VidMoly link: {str(e)}")
async def _extract_with_playwright_network(self, url: str) -> Optional[str]:
"""Extract video source using Playwright with network interception (like DownloadHelper)"""
try:
from playwright.async_api import async_playwright
print("[VIDMOLY] Launching browser with network interception...")
video_urls = []
async with async_playwright() as p:
# Launch browser in headless mode
browser = await p.chromium.launch(
headless=True,
args=['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
viewport={'width': 1920, 'height': 1080}
)
page = await context.new_page()
# Set up request interception BEFORE navigation
async def handle_request(route):
# Capture all requests
req_url = route.request.url
print(f"[VIDMOLY] Request: {req_url[:80]}...")
# Look for video files (m3u8, mp4, etc.)
if any(ext in req_url.lower() for ext in ['.m3u8', '.mp4', '.mkv']):
# Only capture non-vidmoly URLs (the actual video files)
if 'vidmoly' not in req_url.lower():
print(f"[VIDMOLY] 🎥 Captured video URL: {req_url[:100]}...")
video_urls.append(req_url)
# Continue with the request
await route.continue_()
# Enable request interception
await page.route('**', handle_request)
# Also set up response interception to catch redirects
page.on("response", lambda response: None)
print("[VIDMOLY] Navigating to page...")
# Navigate to URL and wait for load
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
except Exception as e:
print(f"[VIDMOLY] Navigation warning: {e}")
# Wait for page to fully load and JavaScript to execute
print("[VIDMOLY] Waiting for video player to load...")
await asyncio.sleep(5)
# Try to find and click play button if exists
try:
# Look for common play button selectors
play_selectors = [
'button.jw-icon-play',
'.jw-play-btn',
'button[aria-label="Play"]',
'.play-button',
'video',
]
for selector in play_selectors:
try:
element = await page.query_selector(selector)
if element:
print(f"[VIDMOLY] Found element: {selector}")
# For video tags, we can just wait
# For buttons, click them
if 'button' in selector or '.jw-' in selector:
await element.click()
await asyncio.sleep(3)
break
except:
continue
except Exception as e:
print(f"[VIDMOLY] Play button interaction: {e}")
# Wait a bit more for network requests to complete
await asyncio.sleep(3)
# Also try JavaScript extraction as backup
try:
js_result = await page.evaluate("""
() => {
// Check all video elements
const videos = document.querySelectorAll('video');
for (let v of videos) {
if (v.src) {
console.log('Found video src:', v.src);
return v.src;
}
const sources = v.querySelectorAll('source');
for (let s of sources) {
if (s.src && (s.src.includes('.m3u8') || s.src.includes('.mp4'))) {
console.log('Found source src:', s.src);
return s.src;
}
}
}
// Check for jwplayer
if (window.jwplayer) {
try {
const player = jwplayer();
const playlist = player.getPlaylist();
if (playlist && playlist[0] && playlist[0].sources) {
const src = playlist[0].sources[0].file;
console.log('Found jwplayer source:', src);
return src;
}
} catch(e) {
console.log('jwplayer error:', e);
}
}
// Check for other player configurations
if (window.player && window.player.config) {
if (window.player.config.sources && window.player.config.sources[0]) {
return window.player.config.sources[0].file;
}
}
// Look in window object for video URLs
for (let key in window) {
if (typeof window[key] === 'string') {
const str = window[key];
if ((str.includes('.m3u8') || str.includes('.mp4')) && str.startsWith('http')) {
return str;
}
}
}
return null;
}
""")
if js_result and ('.m3u8' in js_result or '.mp4' in js_result):
print(f"[VIDMOLY] Found video URL via JavaScript")
video_urls.append(js_result)
except Exception as e:
print(f"[VIDMOLY] JS extraction error: {e}")
# Final check: parse page HTML for video URLs
try:
content = await page.content()
patterns = [
r'"file"\s*:\s*"([^"]+\.m3u8[^"]*)"',
r'"file"\s*:\s*"([^"]+\.mp4[^"]*)"',
r"'file'\s*:\s*'([^']+\.m3u8[^']*)'",
r"'file'\s*:\s*'([^']+\.mp4[^']*)'",
r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)',
r'(https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*)',
]
for pattern in patterns:
matches = re.findall(pattern, content)
for match in matches:
# Clean up the URL
match = match.replace('\\', '').replace('\/', '/')
if 'http' in match and 'vidmoly' not in match:
print(f"[VIDMOLY] Found in HTML: {match[:100]}...")
video_urls.append(match)
except Exception as e:
print(f"[VIDMOLY] HTML parsing error: {e}")
await browser.close()
# Return the first valid video URL found
if video_urls:
# Deduplicate while preserving order
seen = set()
unique_urls = []
for url in video_urls:
if url not in seen:
seen.add(url)
unique_urls.append(url)
if unique_urls:
print(f"[VIDMOLY] ✅ Found {len(unique_urls)} video URL(s)")
return unique_urls[0]
print("[VIDMOLY] ❌ No video URLs found")
return None
except ImportError:
print("[VIDMOLY] Playwright not installed")
return None
except Exception as e:
print(f"[VIDMOLY] Playwright error: {e}")
import traceback
traceback.print_exc()
return None
async def _extract_with_http(self, url: str) -> Optional[str]:
"""Fallback: Extract video source using pure HTTP requests"""
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
'Referer': 'https://vidmoly.to/',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
}
response = await self.client.get(url, headers=headers)
# Follow JS redirect if present
if 'window.location.replace' in response.text:
redirect_match = re.search(r"window\.location\.replace\('([^']+)'", response.text)
if redirect_match:
redirect_url = redirect_match.group(1)
response = await self.client.get(redirect_url, headers=headers, follow_redirects=True)
# Try to find video source
patterns = [
r'file:"([^"]+)"',
r'"file"\s*:\s*"([^"]+)"',
r"'file'\s*:\s*'([^']+)'",
r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)',
r'(https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*)',
]
for pattern in patterns:
matches = re.findall(pattern, response.text)
if matches:
for match in matches:
match = match.replace('\\', '').replace('\/', '/')
if 'http' in match and 'vidmoly' not in match:
return match
return None
except Exception as e:
print(f"[VIDMOLY] HTTP extraction error: {e}")
return None
async def _get_m3u8_qualities(self, master_m3u8_url: str, headers: dict) -> list[dict]:
"""Fetch master M3U8 and extract available qualities"""
try:
response = await self.client.get(master_m3u8_url, headers=headers)
response.raise_for_status()
content = response.text
lines = [line.strip() for line in content.split('\n') if line.strip()]
qualities = []
current_quality = {}
for line in lines:
if line.startswith('#EXT-X-STREAM-INF'):
resolution_match = re.search(r'RESOLUTION=\d+x(\d+)', line)
if resolution_match:
current_quality['label'] = resolution_match.group(1)
elif line.endswith('.m3u8') and current_quality:
current_quality['url'] = line if line.startswith('http') else master_m3u8_url.rsplit('/', 1)[0] + '/' + line
qualities.append(current_quality)
current_quality = {}
qualities.sort(key=lambda x: int(x['label']), reverse=True)
return qualities
except Exception as e:
print(f"Error fetching M3U8 qualities: {e}")
return []
async def _download_m3u8_as_mp4(self, m3u8_url: str, filename: str, headers: dict, download_dir: str = "downloads") -> str:
"""Download M3U8 stream and convert to MP4 using ffmpeg"""
# Create downloads directory if it doesn't exist
os.makedirs(download_dir, exist_ok=True)
output_path = os.path.join(download_dir, filename)
# Build headers for ffmpeg - using multiple -headers options
header_args = []
for key, value in headers.items():
header_args.extend(['-headers', f'{key}: {value}'])
cmd = [
'ffmpeg',
*header_args,
'-i', m3u8_url,
'-c', 'copy',
'-bsf:a', 'aac_adtstoasc',
'-y',
output_path
]
try:
print(f"[VIDMOLY] Downloading M3U8 with ffmpeg...")
print(f"[VIDMOLY] URL: {m3u8_url[:80]}...")
print(f"[VIDMOLY] Output: {output_path}")
# Run ffmpeg without capturing output to avoid buffering issues
# Use a log file instead
log_path = output_path + '.log'
with open(log_path, 'w') as log_file:
result = subprocess.run(
cmd,
stdout=log_file,
stderr=log_file,
timeout=600 # 10 minutes for very long videos
)
# Check if file was created even if ffmpeg had issues
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
if file_size > 1000: # At least 1KB
print(f"[VIDMOLY] ✅ Download complete: {file_size / (1024*1024):.2f} MB")
return output_path
# If we get here, something went wrong
raise Exception(f"FFmpeg failed - no output file created")
except subprocess.TimeoutExpired:
# Check if file was created despite timeout
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
if file_size > 1000: # At least 1KB
print(f"[VIDMOLY] ⚠️ Timeout but file created: {file_size / (1024*1024):.2f} MB")
return output_path
raise Exception("FFmpeg timeout (10 minutes) - video too large")
except FileNotFoundError:
raise Exception("ffmpeg not found - please install ffmpeg: apt install ffmpeg")
except Exception as e:
raise Exception(f"Error downloading M3U8: {str(e)}")
def _extract_vidmoly_id(self, url: str) -> Optional[str]:
"""Extract VidMoly video ID from URL"""
embed_match = re.search(r'embed-([a-z0-9]+)', url, re.IGNORECASE)
if embed_match:
return embed_match.group(1)
param_match = re.search(r'[?&]v=([a-z0-9]+)', url, re.IGNORECASE)
if param_match:
return param_match.group(1)
path_match = re.search(r'vidmoly\.(?:to|org|biz)/([a-z0-9]+)', url, re.IGNORECASE)
if path_match:
return path_match.group(1)
return None
+195
View File
@@ -0,0 +1,195 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
import httpx
import subprocess
import os
import tempfile
from pathlib import Path
class VidMolyDownloader(BaseDownloader):
"""Downloader for vidmoly.to - Video streaming host with M3U8 to MP4 conversion"""
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in ["vidmoly.to", "vidmoly.org"])
async def get_download_link(self, url: str) -> tuple[str, str]:
try:
# Extract VidMoly ID from URL
vidmoly_id = self._extract_vidmoly_id(url)
if not vidmoly_id:
raise Exception("Could not extract VidMoly ID from URL")
# Construct embed URL
embed_url = f"https://vidmoly.to/embed-{vidmoly_id}.html"
# Fetch embed page
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
'Referer': 'https://vidmoly.to/',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
}
response = await self.client.get(embed_url, headers=headers)
response.raise_for_status()
# Check for JavaScript redirect with token
if 'window.location.replace' in response.text:
# Extract the redirect URL with token
redirect_match = re.search(r"window\.location\.replace\('([^']+)'", response.text)
if redirect_match:
redirect_url = redirect_match.group(1)
print(f"[VIDMOLY] Following redirect with token...")
# Follow the redirect WITH follow_redirects to handle 302
response = await self.client.get(redirect_url, headers=headers, follow_redirects=True)
response.raise_for_status()
# Extract video source using regex (like the PHP version)
# Pattern: file:"URL"
sources_match = re.findall(r'file:"([^"]+)"', response.text)
if not sources_match:
raise Exception("Could not find video source in page")
video_source = sources_match[0]
# Check if it's an M3U8 playlist
if 'master.m3u8' in video_source or '.m3u8' in video_source:
# Fetch master playlist to get available qualities
qualities = await self._get_m3u8_qualities(video_source, headers)
if qualities:
# Use highest quality (first one in list)
best_quality_url = qualities[0]['url']
quality_label = qualities[0]['label']
# Convert M3U8 to MP4 using ffmpeg
mp4_path = await self._convert_m3u8_to_mp4(
best_quality_url,
vidmoly_id,
quality_label,
headers
)
return mp4_path, f"vidmoly_{vidmoly_id}_{quality_label}p.mp4"
else:
# Direct M3U8 without quality variants
mp4_path = await self._convert_m3u8_to_mp4(
video_source,
vidmoly_id,
"720",
headers
)
return mp4_path, f"vidmoly_{vidmoly_id}_720p.mp4"
# It's a direct MP4 link
filename = f"vidmoly_{vidmoly_id}.mp4"
if not video_source.endswith('.mp4'):
filename += '.mp4'
return video_source, filename
except Exception as e:
raise Exception(f"Error extracting VidMoly link: {str(e)}")
async def _get_m3u8_qualities(self, master_m3u8_url: str, headers: dict) -> list[dict]:
"""Fetch master M3U8 and extract available qualities"""
try:
response = await self.client.get(master_m3u8_url, headers=headers)
response.raise_for_status()
content = response.text
lines = [line.strip() for line in content.split('\n') if line.strip()]
qualities = []
current_quality = {}
for line in lines:
# Parse quality line (RESOLUTION=...xHEIGHT)
if line.startswith('#EXT-X-STREAM-INF'):
resolution_match = re.search(r'RESOLUTION=\d+x(\d+)', line)
if resolution_match:
current_quality['label'] = resolution_match.group(1)
# Parse URL line
elif line.endswith('.m3u8') and current_quality:
current_quality['url'] = line if line.startswith('http') else master_m3u8_url.rsplit('/', 1)[0] + '/' + line
qualities.append(current_quality)
current_quality = {}
# Sort by resolution (descending)
qualities.sort(key=lambda x: int(x['label']), reverse=True)
return qualities
except Exception as e:
print(f"Error fetching M3U8 qualities: {e}")
return []
async def _convert_m3u8_to_mp4(self, m3u8_url: str, vidmoly_id: str, quality: str, headers: dict) -> str:
"""Convert M3U8 stream to MP4 using ffmpeg"""
# Create temp directory for output
temp_dir = tempfile.gettempdir()
output_path = os.path.join(temp_dir, f"vidmoly_{vidmoly_id}_{quality}p.mp4")
# Prepare ffmpeg headers
ffmpeg_headers = '|'.join([f'{k}: {v}' for k, v in headers.items()])
# Build ffmpeg command
cmd = [
'ffmpeg',
'-headers', f'"{ffmpeg_headers}"',
'-i', m3u8_url,
'-c', 'copy',
'-bsf:a', 'aac_adtstoasc',
'-y', # Overwrite output file if exists
output_path
]
# Execute ffmpeg
try:
result = subprocess.run(
' '.join(cmd),
shell=True,
capture_output=True,
text=True,
timeout=300 # 5 minutes timeout
)
if result.returncode != 0:
raise Exception(f"FFmpeg conversion failed: {result.stderr}")
if not os.path.exists(output_path):
raise Exception("FFmpeg output file not created")
return output_path
except subprocess.TimeoutExpired:
raise Exception("FFmpeg conversion timeout (5 minutes)")
except Exception as e:
raise Exception(f"Error converting M3U8 to MP4: {str(e)}")
def _extract_vidmoly_id(self, url: str) -> str:
"""Extract VidMoly video ID from URL"""
# Patterns:
# - vidmoly.to/embed-ID.html
# - vidmoly.to/?v=ID
# - vidmoly.to/ID
# Try to extract from embed pattern
embed_match = re.search(r'embed-([a-z0-9]+)', url, re.IGNORECASE)
if embed_match:
return embed_match.group(1)
# Try to extract from ?v= parameter
param_match = re.search(r'[?&]v=([a-z0-9]+)', url, re.IGNORECASE)
if param_match:
return param_match.group(1)
# Try to extract ID from path
path_match = re.search(r'vidmoly\.(?:to|org)/([a-z0-9]+)', url, re.IGNORECASE)
if path_match:
return path_match.group(1)
return None
+144
View File
@@ -0,0 +1,144 @@
from .base import BaseDownloader
from bs4 import BeautifulSoup
import re
from urllib.parse import urljoin
class VostfreeDownloader(BaseDownloader):
"""Downloader for vostfree.tv"""
BASE_DOMAINS = ["vostfree.tv", "www.vostfree.tv"]
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(self, url: str) -> tuple[str, str]:
"""Extract download link from vostfree URL"""
try:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, 'lxml')
# Method 1: Look for iframe players
iframes = soup.find_all('iframe')
for iframe in iframes:
src = iframe.get('src', '')
if src and any(p in src for p in ['player', 'video', 'stream']):
if not src.startswith('http'):
src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url))
return src, filename
# Method 2: Look for video tags
videos = soup.find_all('video')
for video in videos:
src = video.get('src')
if src:
filename = self._generate_filename(str(response.url))
return src, filename
sources = video.find_all('source')
for source in sources:
src = source.get('src', '')
if src and any(ext in src for ext in ['mp4', 'm3u8']):
filename = self._generate_filename(str(response.url))
return src, filename
# Method 3: Look in scripts
scripts = soup.find_all('script')
for script in scripts:
if script.string:
patterns = [
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)',
r'"url":"([^"]+)"',
r'"file":"([^"]+)"',
r'"video":"([^"]+)"',
]
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
match = match.replace('\\/', '/')
if any(ext in match for ext in ['mp4', 'm3u8']):
filename = self._generate_filename(str(response.url))
return match, filename
raise Exception("Could not find video link")
except Exception as e:
raise Exception(f"Error extracting Vostfree link: {str(e)}")
def _generate_filename(self, url: str) -> str:
parts = url.split('/')
anime_name = "anime"
episode = "1"
for part in parts:
match = re.search(r'episode[-\s]*(\d+)', part, re.I)
if match:
episode = match.group(1)
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, 'lxml')
episodes = []
episode_links = soup.find_all('a', href=re.compile(r'episode', re.I))
for link in episode_links:
href = link.get('href', '')
match = re.search(r'episode[-\s]*(\d+)', href, re.I)
if match:
episode_num = match.group(1)
if not href.startswith('http'):
href = urljoin(anime_url, href)
episodes.append({'episode': episode_num, 'url': href})
# Deduplicate and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep['episode'] not in seen:
seen.add(ep['episode'])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x['episode']))
return unique_episodes
except Exception as e:
return []
async def search_anime(self, query: str, lang: str = "vostfr") -> list[dict]:
"""
Search for anime on vostfree
"""
try:
import time
start = time.time()
print(f"[VOSTFREE] Searching for '{query}' ({lang})...")
# Vostfree URL pattern
search_url = f"https://vostfree.tv/anime/{query.lower().replace(' ', '-')}"
response = await self.client.get(search_url)
elapsed = time.time() - start
print(f"[VOSTFREE] Got response {response.status_code} in {elapsed:.2f}s")
if response.status_code == 200:
print(f"[VOSTFREE] Found anime at {str(response.url)}")
return [{
'title': query,
'url': str(response.url),
'type': 'direct'
}]
print(f"[VOSTFREE] No anime found")
return []
except Exception as e:
print(f"[VOSTFREE] Error: {str(e)}")
return []
+42
View File
@@ -0,0 +1,42 @@
from pydantic import BaseModel
from enum import Enum
from typing import Optional
from datetime import datetime
class DownloadStatus(str, Enum):
PENDING = "pending"
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class HostType(str, Enum):
RAPIDFILE = "rapidfile"
UNFICHIER = "1fichier"
DOODSTREAM = "doodstream"
OTHER = "other"
class DownloadTask(BaseModel):
id: str
url: str
filename: str
host: HostType
status: DownloadStatus
progress: float = 0.0
downloaded_bytes: int = 0
total_bytes: Optional[int] = None
speed: float = 0.0
error: Optional[str] = None
created_at: datetime
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
file_path: Optional[str] = None
class DownloadRequest(BaseModel):
url: str
filename: Optional[str] = None
+82
View File
@@ -0,0 +1,82 @@
"""Anime and file hosting providers configuration"""
ANIME_PROVIDERS = {
"anime-sama": {
"name": "Anime-Sama",
"domains": ["anime-sama.si", "www.anime-sama.si", "anime-sama.org", "anime-sama.store", "anime-sama.eu"],
"url_pattern": "https://anime-sama.si/catalogue/{anime}/saison{season}/{lang}/",
"icon": "🎬",
"color": "#00d9ff"
},
"anime-ultime": {
"name": "Anime-Ultime",
"domains": ["anime-ultime.net", "anime-ultime.com", "www.anime-ultime.net"],
"url_pattern": "https://www.anime-ultime.net/info-{id}-{slug}",
"icon": "▶️",
"color": "#00ff88"
},
"neko-sama": {
"name": "Neko-Sama",
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
"url_pattern": "https://neko-sama.fr/anime/{slug}",
"icon": "🐱",
"color": "#ff6b6b"
},
"vostfree": {
"name": "Vostfree",
"domains": ["vostfree.tv", "www.vostfree.tv"],
"url_pattern": "https://vostfree.tv/anime/{slug}",
"icon": "📺",
"color": "#ffd93d"
}
}
FILE_HOSTS = {
"1fichier": {
"name": "1fichier",
"domains": ["1fichier.com", "1fichier.fr"],
"icon": "📁",
"color": "#4ecdc4"
},
"uptobox": {
"name": "Uptobox",
"domains": ["uptobox.com", "uptobox.fr"],
"icon": "📦",
"color": "#45b7d1"
},
"doodstream": {
"name": "Doodstream",
"domains": ["doodstream.com", "dood.to", "dood.lol", "dood.cx", "dood.so", "dood.watch"],
"icon": "🎥",
"color": "#f7b731"
},
"rapidfile": {
"name": "Rapidfile",
"domains": ["rapidfile.net", "rapidfile.com"],
"icon": "",
"color": "#ff6b6b"
}
}
def get_all_providers():
"""Get all supported providers (anime + file hosts)"""
return {**ANIME_PROVIDERS, **FILE_HOSTS}
def get_anime_providers():
"""Get all anime streaming providers"""
return ANIME_PROVIDERS
def get_file_hosts():
"""Get all file hosting providers"""
return FILE_HOSTS
def detect_provider_from_url(url: str) -> str | None:
"""Detect which provider can handle the given URL"""
url_lower = url.lower()
for provider_id, provider in get_all_providers().items():
for domain in provider['domains']:
if domain in url_lower:
return provider_id
return None
+484
View File
@@ -0,0 +1,484 @@
from fastapi import FastAPI, UploadFile, File, BackgroundTasks, HTTPException
from fastapi.responses import StreamingResponse, FileResponse, JSONResponse, Response
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi import Request
import uvicorn
from pathlib import Path
from typing import List
import shutil
import os
import re
from app.models import DownloadRequest, DownloadTask, DownloadStatus
from app.download_manager import DownloadManager
from app.downloaders import AnimeSamaDownloader
from app import providers
app = FastAPI(title="Ohm Stream Downloader")
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize download manager
download_manager = DownloadManager(download_dir="downloads", max_parallel=3)
# Mount static files and templates
app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/downloads", StaticFiles(directory="downloads"), name="downloads")
templates = Jinja2Templates(directory="templates")
@app.get("/")
async def root():
return {
"message": "Ohm Stream Downloader API",
"status": "running",
"version": "2.0",
"endpoints": {
"POST /api/download": "Start a new download",
"GET /api/downloads": "List all downloads",
"GET /api/download/{task_id}": "Get download status",
"POST /api/download/{task_id}/pause": "Pause a download",
"POST /api/download/{task_id}/resume": "Resume a download",
"DELETE /api/download/{task_id}": "Cancel a download",
"GET /api/providers": "List all supported providers",
"GET /web": "Web interface"
}
}
@app.get("/api/providers")
async def list_providers():
"""List all supported anime and file hosting providers"""
return {
"anime_providers": providers.get_anime_providers(),
"file_hosts": providers.get_file_hosts()
}
@app.get("/health")
async def health():
return {"status": "healthy"}
# Web Interface
@app.get("/web")
async def web_interface(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
# API Endpoints
@app.post("/api/download")
async def create_download(request: DownloadRequest, background_tasks: BackgroundTasks):
"""Create a new download task"""
task = download_manager.create_task(request)
background_tasks.add_task(download_manager.start_download, task.id)
return {"task_id": task.id, "task": task}
@app.get("/api/downloads")
async def list_downloads():
"""List all download tasks"""
return {"downloads": download_manager.get_all_tasks()}
@app.get("/api/download/{task_id}")
async def get_download_status(task_id: str):
"""Get status of a specific download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@app.post("/api/download/{task_id}/pause")
async def pause_download(task_id: str):
"""Pause a download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await download_manager.pause_download(task_id)
return {"status": "paused"}
@app.post("/api/download/{task_id}/resume")
async def resume_download(task_id: str, background_tasks: BackgroundTasks):
"""Resume a paused download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status == DownloadStatus.PAUSED:
background_tasks.add_task(download_manager.start_download, task_id)
return {"status": "resumed"}
return {"status": "already running or completed"}
@app.delete("/api/download/{task_id}")
async def cancel_download(task_id: str):
"""Cancel a download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await download_manager.cancel_download(task_id)
return {"status": "cancelled"}
@app.get("/api/download/{task_id}/file")
async def download_file(task_id: str):
"""Download the completed file"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED:
raise HTTPException(status_code=400, detail="Download not completed")
if not task.file_path or not os.path.exists(task.file_path):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
task.file_path,
filename=task.filename,
media_type='application/octet-stream'
)
# Unified Anime Search endpoints
@app.get("/api/anime/search")
async def search_anime_unified(q: str, lang: str = "vostfr"):
"""Search across all anime providers"""
import time
import asyncio
from app.providers import get_anime_providers
from app.downloaders import AnimeSamaDownloader, AnimeUltimeDownloader, NekoSamaDownloader, VostfreeDownloader
print(f"\n[SEARCH] Starting search for '{q}' in {lang}")
start_time = time.time()
results = {}
# Create downloader instances
downloaders = {
"anime-sama": AnimeSamaDownloader(),
"anime-ultime": AnimeUltimeDownloader(),
"neko-sama": NekoSamaDownloader(),
"vostfree": VostfreeDownloader()
}
# Search across all providers in parallel with timeout
search_tasks = []
provider_ids = []
for provider_id, provider in get_anime_providers().items():
if provider_id in downloaders:
downloader = downloaders[provider_id]
print(f"[SEARCH] Queueing search on {provider_id}...")
search_tasks.append(downloader.search_anime(q, lang))
provider_ids.append(provider_id)
# Wait for all searches to complete with a timeout per provider
print(f"[SEARCH] Waiting for {len(search_tasks)} searches...")
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
# Combine results
for provider_id, result in zip(provider_ids, search_results):
if isinstance(result, Exception):
print(f"[SEARCH] {provider_id} error: {str(result)}")
elif result:
print(f"[SEARCH] {provider_id} found {len(result)} results")
results[provider_id] = result
else:
print(f"[SEARCH] {provider_id} no results")
elapsed = time.time() - start_time
print(f"[SEARCH] Completed in {elapsed:.2f}s - Total results: {sum(len(r) for r in results.values())}\n")
return {
"query": q,
"lang": lang,
"results": results
}
@app.get("/api/anime/episodes")
async def get_anime_episodes(url: str, lang: str = "vostfr"):
"""Get list of episodes for an anime"""
from app.downloaders import get_downloader
downloader = get_downloader(url)
episodes = await downloader.get_episodes(url, lang)
return {
"url": url,
"lang": lang,
"episodes": episodes
}
@app.get("/api/anime/providers")
async def get_anime_providers_list():
"""Get list of anime providers with info"""
from app.providers import get_anime_providers
return {"providers": get_anime_providers()}
# Anime-Sama specific endpoints (legacy)
@app.get("/api/anime-sama/search")
async def search_anime_sama(q: str, lang: str = "vostfr"):
"""Search for anime on anime-sama"""
downloader = AnimeSamaDownloader()
results = await downloader.search_anime(q, lang)
return {"query": q, "lang": lang, "results": results}
@app.post("/api/anime/download")
async def download_anime_episode(
url: str,
background_tasks: BackgroundTasks,
episode: str | None = None
):
"""Download an anime episode"""
# Construct episode URL if not provided
if episode and 'episode-' not in url:
url = f"{url.rstrip('/')}/episode-{episode}"
request = DownloadRequest(url=url)
task = download_manager.create_task(request)
background_tasks.add_task(download_manager.start_download, task.id)
return {"task_id": task.id, "task": task}
# Video Streaming endpoints
@app.get("/video/{task_id}")
async def stream_video(task_id: str, request: Request):
"""Stream a video file with Range support for seeking"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED:
raise HTTPException(status_code=400, detail="Download not completed")
if not task.file_path or not os.path.exists(task.file_path):
raise HTTPException(status_code=404, detail="File not found")
file_path = Path(task.file_path)
file_size = file_path.stat().st_size
# Parse Range header
range_header = request.headers.get("range")
headers = {
"Accept-Ranges": "bytes",
"Content-Type": "video/mp4",
}
if range_header:
# Parse Range header (format: bytes=start-end)
try:
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
start = int(range_match.group(1))
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
# Validate range
if start >= file_size or end >= file_size or start > end:
headers["Content-Range"] = f"bytes */{file_size}"
return Response(
status_code=416,
headers=headers,
content="Requested Range Not Satisfiable"
)
# Read the requested range
content_length = end - start + 1
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
headers["Content-Length"] = str(content_length)
async def video_range_reader():
with open(file_path, 'rb') as f:
f.seek(start)
remaining = content_length
while remaining > 0:
chunk_size = min(1024 * 1024, remaining) # 1MB chunks
data = f.read(chunk_size)
if not data:
break
remaining -= len(data)
yield data
return Response(
content=video_range_reader(),
status_code=206,
headers=headers
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
else:
# No Range header - stream entire file
async def video_reader():
with open(file_path, 'rb') as f:
while True:
data = f.read(1024 * 1024) # 1MB chunks
if not data:
break
yield data
headers["Content-Length"] = str(file_size)
return Response(
content=video_reader(),
headers=headers
)
# Direct video streaming endpoint (by filename)
@app.get("/stream/{filename}")
async def stream_video_by_filename(filename: str, request: Request):
"""Stream a video file by filename with Range support for seeking"""
# Sanitize filename to prevent directory traversal
filename = os.path.basename(filename)
file_path = Path("downloads") / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
file_size = file_path.stat().st_size
# Parse Range header
range_header = request.headers.get("range")
if range_header:
# Parse Range header (format: bytes=start-end)
try:
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
start = int(range_match.group(1))
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
# Validate range
if start >= file_size or end >= file_size or start > end:
return Response(
status_code=416,
headers={
"Content-Range": f"bytes */{file_size}",
"Accept-Ranges": "bytes"
},
content="Requested Range Not Satisfiable"
)
# Read the requested range
content_length = end - start + 1
def video_range_reader():
with open(file_path, 'rb') as f:
f.seek(start)
remaining = content_length
while remaining > 0:
chunk_size = min(1024 * 1024, remaining) # 1MB chunks
data = f.read(chunk_size)
if not data:
break
remaining -= len(data)
yield data
return StreamingResponse(
video_range_reader(),
status_code=206,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(content_length),
"Accept-Ranges": "bytes",
"Content-Type": "video/mp4",
}
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid Range header: {e}")
else:
# No Range header - stream entire file
def video_reader():
with open(file_path, 'rb') as f:
while True:
data = f.read(1024 * 1024) # 1MB chunks
if not data:
break
yield data
return StreamingResponse(
video_reader(),
headers={
"Content-Length": str(file_size),
"Accept-Ranges": "bytes",
"Content-Type": "video/mp4",
}
)
# Video Player page (by task_id)
@app.get("/player/{task_id}")
async def video_player(request: Request, task_id: str):
"""Video player page for watching downloaded anime"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED:
raise HTTPException(status_code=400, detail="Download not completed")
if not task.file_path or not os.path.exists(task.file_path):
raise HTTPException(status_code=404, detail="File not found")
# Get video info
file_path = Path(task.file_path)
file_size = file_path.stat().st_size
# Calculate video duration (rough estimation based on file size)
# Assuming ~1MB per minute for 720p, ~2MB per minute for 1080p
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
return templates.TemplateResponse("player.html", {
"request": request,
"task_id": task_id,
"filename": task.filename,
"file_size": file_size,
"estimated_duration": estimated_duration_seconds
})
# Video Player page (by filename)
@app.get("/watch/{filename}")
async def video_player_by_filename(request: Request, filename: str):
"""Video player page for watching downloaded anime by filename"""
# Sanitize filename
filename = os.path.basename(filename)
file_path = Path("downloads") / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
file_size = file_path.stat().st_size
estimated_duration_seconds = int(file_size / (1.5 * 1024 * 1024))
return templates.TemplateResponse("player.html", {
"request": request,
"task_id": filename, # Use filename instead of task_id
"filename": filename,
"file_size": file_size,
"estimated_duration": estimated_duration_seconds
})
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=3000,
reload=True
)
+11
View File
@@ -0,0 +1,11 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
python-multipart==0.0.20
aiofiles==24.1.0
pydantic==2.10.4
pydantic-settings==2.7.1
httpx==0.28.1
aiohttp==3.11.11
beautifulsoup4==4.12.3
lxml==5.3.0
jieba==0.42.1
+1205
View File
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ filename }} - Ohm Stream Player</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
color: #fff;
}
.container {
max-width: 1200px;
width: 100%;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
font-size: 1.5rem;
margin-bottom: 10px;
color: #00d9ff;
}
.video-info {
background: rgba(255, 255, 255, 0.05);
padding: 15px 20px;
border-radius: 10px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.video-info .filename {
font-size: 1.1rem;
font-weight: 500;
}
.video-info .filesize {
color: #aaa;
font-size: 0.9rem;
}
.video-wrapper {
background: #000;
border-radius: 15px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
video {
width: 100%;
display: block;
max-height: 80vh;
}
.controls {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn:hover {
background: rgba(0, 217, 255, 0.2);
border-color: #00d9ff;
transform: translateY(-2px);
}
.btn-primary {
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
border: none;
color: #000;
font-weight: 600;
}
.btn-primary:hover {
background: linear-gradient(135deg, #00ff88 0%, #00d9ff 100%);
}
.error-message {
background: rgba(255, 71, 87, 0.1);
border: 1px solid #ff4757;
color: #ff4757;
padding: 20px;
border-radius: 10px;
text-align: center;
margin-top: 20px;
}
.loading {
text-align: center;
padding: 60px 20px;
color: #aaa;
}
.loading::after {
content: '...';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
@media (max-width: 768px) {
.header h1 {
font-size: 1.2rem;
}
.video-info {
flex-direction: column;
align-items: flex-start;
}
.controls {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎬 Ohm Stream Player</h1>
</div>
<div class="video-info">
<span class="filename">{{ filename }}</span>
<span class="filesize">{{ "%.2f"|format(file_size / 1024 / 1024) }} MB</span>
</div>
<div class="video-wrapper">
<video controls preload="metadata">
<source src="/stream/{{ filename }}" type="video/mp4">
<div class="error-message">
Votre navigateur ne supporte pas la lecture vidéo.<br>
<a href="/stream/{{ filename }}" style="color: #00d9ff;">Télécharger la vidéo</a>
</div>
</video>
</div>
<div class="controls">
<a href="/web" class="btn">← Retour à l'accueil</a>
<a href="/stream/{{ filename }}" class="btn btn-primary" download>⬇️ Télécharger</a>
</div>
</div>
<script>
// Video error handling
const video = document.querySelector('video');
video.addEventListener('error', (e) => {
console.error('Video error:', e);
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.innerHTML = `
Erreur lors du chargement de la vidéo.<br>
<a href="/video/{{ task_id }}" style="color: #00d9ff;">Réessayer</a>
`;
video.parentNode.replaceChild(errorDiv, video);
});
// Video loaded successfully
video.addEventListener('loadedmetadata', () => {
console.log('Video duration:', video.duration);
});
// Log seeking events for debugging
video.addEventListener('seeking', () => {
console.log('Seeking to:', video.currentTime);
});
video.addEventListener('seeked', () => {
console.log('Seeked to:', video.currentTime);
});
</script>
</body>
</html>