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:
@@ -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
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Ohm Stream Downloader Package
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 []
|
||||||
@@ -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 []
|
||||||
@@ -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 []
|
||||||
@@ -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)}")
|
||||||
@@ -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 []
|
||||||
@@ -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)}")
|
||||||
@@ -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"
|
||||||
@@ -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)}")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 []
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
Reference in New Issue
Block a user