|
from fastapi import FastAPI, HTTPException, Request, Query, Response |
|
from fastapi.responses import StreamingResponse, HTMLResponse |
|
from fastapi.templating import Jinja2Templates |
|
from pytubefix import YouTube |
|
from pytubefix.cli import on_progress |
|
import os |
|
import logging |
|
import httpx |
|
import hashlib |
|
from functools import lru_cache |
|
from encrypt import encrypt_video_id, decrypt_video_id |
|
|
|
app = FastAPI() |
|
|
|
CHUNK_SIZE = 1024 * 1024 |
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
def open_file_range(file_path: str, start: int, end: int): |
|
with open(file_path, "rb") as f: |
|
f.seek(start) |
|
bytes_to_read = end - start + 1 |
|
while bytes_to_read > 0: |
|
chunk = f.read(min(CHUNK_SIZE, bytes_to_read)) |
|
if not chunk: |
|
break |
|
bytes_to_read -= len(chunk) |
|
yield chunk |
|
|
|
|
|
def generate_etag(file_path): |
|
hasher = hashlib.md5() |
|
with open(file_path, 'rb') as f: |
|
while chunk := f.read(8192): |
|
hasher.update(chunk) |
|
return hasher.hexdigest() |
|
|
|
|
|
@lru_cache(maxsize=128) |
|
def get_video_metadata(video_id: str): |
|
yt = YouTube(f"https://www.youtube.com/watch?v={video_id}", client='WEB_EMBED') |
|
if yt.length >= 600: |
|
return { |
|
"title": yt.title, |
|
"description": yt.description, |
|
"author": yt.author, |
|
"duration": yt.length, |
|
"views": yt.views, |
|
"date": yt.publish_date, |
|
"video_url": yt.streams.get_highest_resolution().url, |
|
"audio_url": yt.streams.get_audio_only().url, |
|
} |
|
else: |
|
return { |
|
"title": yt.title, |
|
"description": yt.description, |
|
"author": yt.author, |
|
"duration": yt.length, |
|
"views": yt.views, |
|
"date": yt.publish_date, |
|
} |
|
|
|
|
|
@app.get("/api/video/{video_id}") |
|
def get_video_info(video_id: str, request: Request): |
|
try: |
|
metadata = get_video_metadata(video_id) |
|
encrypted_video_id = encrypt_video_id(video_id) |
|
|
|
BASE_URL = request.base_url |
|
|
|
if metadata['duration'] >= 600: |
|
return {**metadata} |
|
else: |
|
return { |
|
**metadata, |
|
"video_url": f"{BASE_URL}video/{encrypted_video_id}", |
|
"audio_url": f"{BASE_URL}audio/{encrypted_video_id}" |
|
} |
|
except Exception as e: |
|
raise HTTPException(status_code=500, detail=f"Error: {str(e)}") |
|
|
|
|
|
@app.get("/video/{video_id}") |
|
async def stream_video(video_id: str, request: Request, download: bool = Query(False)): |
|
try: |
|
decrypted_video_id = decrypt_video_id(video_id) |
|
yt = YouTube(f"https://www.youtube.com/watch?v={decrypted_video_id}") |
|
stream = yt.streams.get_highest_resolution() |
|
url = stream.url |
|
|
|
headers = {} |
|
if range_header := request.headers.get("range"): |
|
headers["Range"] = range_header |
|
|
|
async def proxy_stream(): |
|
try: |
|
async with httpx.AsyncClient() as client: |
|
async with client.stream("GET", url, headers=headers, timeout=60) as response: |
|
if response.status_code not in (200, 206): |
|
logger.error(f"Failed to stream: {response.status_code}") |
|
return |
|
async for chunk in response.aiter_bytes(CHUNK_SIZE): |
|
yield chunk |
|
except Exception as e: |
|
logger.error(f"Streaming error: {str(e)}") |
|
return |
|
|
|
response_headers = { |
|
"Accept-Ranges": "bytes", |
|
"Cache-Control": "public, max-age=3600" |
|
} |
|
|
|
|
|
title = yt.title.encode("utf-8", "ignore").decode("utf-8") |
|
if download: |
|
response_headers["Content-Disposition"] = f'attachment; filename="{title}.mp4"' |
|
else: |
|
response_headers["Content-Disposition"] = f'inline; filename="{title}.mp4"' |
|
|
|
return StreamingResponse( |
|
proxy_stream(), |
|
media_type="video/mp4", |
|
headers=response_headers |
|
) |
|
|
|
except Exception as e: |
|
raise HTTPException(status_code=500, detail=f"Could not fetch video URL: {str(e)}") |
|
|
|
@app.get("/audio/{video_id}") |
|
async def stream_audio(video_id: str, request: Request, download: bool = Query(False)): |
|
try: |
|
decrypted_video_id = decrypt_video_id(video_id) |
|
yt = YouTube(f"https://www.youtube.com/watch?v={decrypted_video_id}") |
|
stream = yt.streams.get_audio_only() |
|
url = stream.url |
|
|
|
headers = { |
|
"User-Agent": request.headers.get("user-agent", "Mozilla/5.0"), |
|
} |
|
if range_header := request.headers.get("range"): |
|
headers["Range"] = range_header |
|
|
|
async def proxy_stream(): |
|
async with httpx.AsyncClient(follow_redirects=True) as client: |
|
async with client.stream("GET", url, headers=headers) as response: |
|
if response.status_code not in (200, 206): |
|
raise HTTPException(status_code=502, detail="Source stream error") |
|
async for chunk in response.aiter_bytes(CHUNK_SIZE): |
|
yield chunk |
|
|
|
response_headers = { |
|
"Accept-Ranges": "bytes", |
|
"Cache-Control": "public, max-age=3600" |
|
} |
|
|
|
|
|
title = yt.title.encode("utf-8", "ignore").decode("utf-8") |
|
if download: |
|
response_headers["Content-Disposition"] = f'attachment; filename="{title}.mp3"' |
|
else: |
|
response_headers["Content-Disposition"] = f'inline; filename="{title}.mp3"' |
|
|
|
return StreamingResponse( |
|
proxy_stream(), |
|
media_type=stream.mime_type or "audio/mp4", |
|
headers=response_headers |
|
) |
|
|
|
except Exception as e: |
|
logger.error(f"Streaming error: {e}") |
|
raise HTTPException(status_code=500, detail=f"Error: {str(e)}") |
|
|