From cb3ea8d926df4fad6f1f3264a5e11c77041dc0cf Mon Sep 17 00:00:00 2001 From: root Date: Fri, 23 Jan 2026 08:17:10 +0000 Subject: [PATCH] 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 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 Co-Authored-By: Happy --- .env.example | 13 + .gitignore | 31 + CLAUDE.md | 113 +++ README.md | 106 +++ app/__init__.py | 1 + app/download_manager.py | 190 +++++ app/downloaders/__init__.py | 48 ++ app/downloaders/animesama.py | 475 +++++++++++++ app/downloaders/animeultime.py | 313 +++++++++ app/downloaders/base.py | 54 ++ app/downloaders/doodstream.py | 79 +++ app/downloaders/nekosama.py | 144 ++++ app/downloaders/rapidfile.py | 75 ++ app/downloaders/sendvid.py | 83 +++ app/downloaders/unfichier.py | 51 ++ app/downloaders/uptobox.py | 59 ++ app/downloaders/vidmoly.py | 439 ++++++++++++ app/downloaders/vidmoly_old.py | 195 ++++++ app/downloaders/vostfree.py | 144 ++++ app/models/__init__.py | 42 ++ app/providers.py | 82 +++ main.py | 484 +++++++++++++ requirements.txt | 11 + templates/index.html | 1205 ++++++++++++++++++++++++++++++++ templates/player.html | 220 ++++++ 25 files changed, 4657 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/download_manager.py create mode 100644 app/downloaders/__init__.py create mode 100644 app/downloaders/animesama.py create mode 100644 app/downloaders/animeultime.py create mode 100644 app/downloaders/base.py create mode 100644 app/downloaders/doodstream.py create mode 100644 app/downloaders/nekosama.py create mode 100644 app/downloaders/rapidfile.py create mode 100644 app/downloaders/sendvid.py create mode 100644 app/downloaders/unfichier.py create mode 100644 app/downloaders/uptobox.py create mode 100644 app/downloaders/vidmoly.py create mode 100644 app/downloaders/vidmoly_old.py create mode 100644 app/downloaders/vostfree.py create mode 100644 app/models/__init__.py create mode 100644 app/providers.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 templates/index.html create mode 100644 templates/player.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5d56029 --- /dev/null +++ b/.env.example @@ -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=* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b20acb3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b35f71e --- /dev/null +++ b/CLAUDE.md @@ -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") diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cef53b --- /dev/null +++ b/README.md @@ -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`. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e0dad43 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Ohm Stream Downloader Package diff --git a/app/download_manager.py b/app/download_manager.py new file mode 100644 index 0000000..a1a0a1e --- /dev/null +++ b/app/download_manager.py @@ -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 diff --git a/app/downloaders/__init__.py b/app/downloaders/__init__.py new file mode 100644 index 0000000..74fcade --- /dev/null +++ b/app/downloaders/__init__.py @@ -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 diff --git a/app/downloaders/animesama.py b/app/downloaders/animesama.py new file mode 100644 index 0000000..c5ebadb --- /dev/null +++ b/app/downloaders/animesama.py @@ -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 [] diff --git a/app/downloaders/animeultime.py b/app/downloaders/animeultime.py new file mode 100644 index 0000000..38fcff5 --- /dev/null +++ b/app/downloaders/animeultime.py @@ -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 [] diff --git a/app/downloaders/base.py b/app/downloaders/base.py new file mode 100644 index 0000000..1ccf9c8 --- /dev/null +++ b/app/downloaders/base.py @@ -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 [] diff --git a/app/downloaders/doodstream.py b/app/downloaders/doodstream.py new file mode 100644 index 0000000..0b7b210 --- /dev/null +++ b/app/downloaders/doodstream.py @@ -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)}") diff --git a/app/downloaders/nekosama.py b/app/downloaders/nekosama.py new file mode 100644 index 0000000..b321570 --- /dev/null +++ b/app/downloaders/nekosama.py @@ -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 [] diff --git a/app/downloaders/rapidfile.py b/app/downloaders/rapidfile.py new file mode 100644 index 0000000..de3fcc5 --- /dev/null +++ b/app/downloaders/rapidfile.py @@ -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)}") diff --git a/app/downloaders/sendvid.py b/app/downloaders/sendvid.py new file mode 100644 index 0000000..f65e093 --- /dev/null +++ b/app/downloaders/sendvid.py @@ -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 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 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 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" diff --git a/app/downloaders/unfichier.py b/app/downloaders/unfichier.py new file mode 100644 index 0000000..008883c --- /dev/null +++ b/app/downloaders/unfichier.py @@ -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)}") diff --git a/app/downloaders/uptobox.py b/app/downloaders/uptobox.py new file mode 100644 index 0000000..eac6da1 --- /dev/null +++ b/app/downloaders/uptobox.py @@ -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 diff --git a/app/downloaders/vidmoly.py b/app/downloaders/vidmoly.py new file mode 100644 index 0000000..0739093 --- /dev/null +++ b/app/downloaders/vidmoly.py @@ -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 diff --git a/app/downloaders/vidmoly_old.py b/app/downloaders/vidmoly_old.py new file mode 100644 index 0000000..da935f4 --- /dev/null +++ b/app/downloaders/vidmoly_old.py @@ -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 diff --git a/app/downloaders/vostfree.py b/app/downloaders/vostfree.py new file mode 100644 index 0000000..bcc8c4c --- /dev/null +++ b/app/downloaders/vostfree.py @@ -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 [] diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..f235893 --- /dev/null +++ b/app/models/__init__.py @@ -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 diff --git a/app/providers.py b/app/providers.py new file mode 100644 index 0000000..3fa69de --- /dev/null +++ b/app/providers.py @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..44e6d2c --- /dev/null +++ b/main.py @@ -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 + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a99d129 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..59ab38f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,1205 @@ + + + + + + Ohm Stream Downloader + + + +
+

⚡ Ohm Stream Downloader

+

Téléchargez vos vidéos et animes depuis vos hébergeurs préférés

+ + +
+ + + +
+ + + + + +
+
+
+
+ + +
+
+
+ 1fichier + Doodstream + Rapidfile + Anime-Sama + Anime-Ultime +
+
+
+ +
+
+ + + +

Aucun téléchargement pour le moment

+
+
+
+ + + + diff --git a/templates/player.html b/templates/player.html new file mode 100644 index 0000000..c7c5e00 --- /dev/null +++ b/templates/player.html @@ -0,0 +1,220 @@ + + + + + + {{ filename }} - Ohm Stream Player + + + +
+
+

🎬 Ohm Stream Player

+
+ +
+ {{ filename }} + {{ "%.2f"|format(file_size / 1024 / 1024) }} MB +
+ +
+ +
+ + +
+ + + +