feat: Add SendVid downloader support
Add complete support for SendVid video hosting service used by Anime-Sama for anime series like Hell's Paradise. Changes: - Create SendVidDownloader class with proper headers to avoid 403 errors - Add SendVid detection and handling in AnimeSamaDownloader - Update download_manager to include SendVid-specific headers - Support custom episode naming (e.g., "Hells Paradise - Episode 01.mp4") Technical details: - SendVid embed pages require User-Agent and Referer headers - Direct MP4 URLs extracted from <source> tags with IP/time-based parameters - Tested with Hell's Paradise Episode 01 (7MB, 24min, 1280x720) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -0,0 +1,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
|
||||
)
|
||||
Reference in New Issue
Block a user