Загрузить файлы в «/»

This commit is contained in:
2026-04-13 06:42:57 +00:00
parent d52654648e
commit 3d4e5fc690
2 changed files with 453 additions and 264 deletions

View File

@@ -2,10 +2,10 @@ import yt_dlp
import aiohttp import aiohttp
import asyncio import asyncio
from _config import * from _config import *
from cachetools import TTLCache
import zipfile import zipfile
import io import io
class YTVideoInfo: class YTVideoInfo:
def __init__(self, link: str): def __init__(self, link: str):
self.link = link self.link = link
@@ -56,11 +56,44 @@ class YTVideoInfo:
if key in seen: if key in seen:
continue continue
seen.add(key) seen.add(key)
size = f.get('filesize') or f.get('filesize_approx')
size_str = None
if size:
if size >= 1_073_741_824:
size_str = f"{size / 1_073_741_824:.1f} GB"
elif size >= 1_048_576:
size_str = f"{size / 1_048_576:.1f} MB"
else:
size_str = f"{size / 1024:.0f} KB"
audio_size = 0
for af in info['formats']:
if (af.get('vcodec') == 'none'
and af.get('acodec') != 'none'
and af.get('ext') == 'm4a'):
audio_size = af.get('filesize') or af.get('filesize_approx') or 0
break
total_size = (size or 0) + audio_size
label = f"{height}p" label = f"{height}p"
if fps and fps > 30: if fps and fps > 30:
label += f" {int(fps)}fps" label += f" {int(fps)}fps"
label += f" ({ext.upper()})" label += f" ({ext.upper()})"
formats.append({'id': f['format_id'], 'label': label, 'height': height}) if size_str:
label += f" ~ {size_str}"
formats.append({
'id': f['format_id'],
'label': label,
'height': height,
'ext': ext,
'fps': fps,
'vcodec': f.get('vcodec', ''),
'acodec': f.get('acodec', ''),
'filesize': total_size or None,
})
return sorted(formats, key=lambda x: x['height'], reverse=True) return sorted(formats, key=lambda x: x['height'], reverse=True)
def get_subtitles(self, info: dict) -> list[dict]: def get_subtitles(self, info: dict) -> list[dict]:
@@ -126,7 +159,17 @@ class YTVideoStream:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor(executor, self._get_urls_sync) return await loop.run_in_executor(executor, self._get_urls_sync)
async def generate(self): async def generate(
self,
prebuffer: bool = True,
stop_event: asyncio.Event | None = None,
):
"""
Yields MP4 chunks.
stop_event — when set, cleanly terminates FFmpeg and stops yielding.
This allows the server to cancel an in-progress stream when the
client reconnects with a new format_id.
"""
video_url, audio_url = await self._get_urls() video_url, audio_url = await self._get_urls()
if audio_url: if audio_url:
@@ -146,110 +189,61 @@ class YTVideoStream:
stderr=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
) )
pre_buffer, buffered_mb = [], 0
try: try:
while buffered_mb < PRE_BUFFER_MB: if prebuffer:
chunk = await process.stdout.read(CHUNK_SIZE) pre_buffer, buffered_mb = [], 0
if not chunk: while buffered_mb < PRE_BUFFER_MB:
for c in pre_buffer: if stop_event and stop_event.is_set():
yield c return
try:
chunk = await asyncio.wait_for(
process.stdout.read(CHUNK_SIZE),
timeout=15.0
)
except asyncio.TimeoutError:
yield b''
continue
if not chunk:
for c in pre_buffer: yield c
return
pre_buffer.append(chunk)
buffered_mb += len(chunk) / (1024 * 1024)
for c in pre_buffer:
yield c
else:
first = True
while first:
if stop_event and stop_event.is_set():
return
try:
chunk = await asyncio.wait_for(
process.stdout.read(CHUNK_SIZE),
timeout=15.0
)
if chunk:
yield chunk
first = False
else:
return
except asyncio.TimeoutError:
yield b''
continue
while True:
if stop_event and stop_event.is_set():
return return
pre_buffer.append(chunk) try:
buffered_mb += len(chunk) / (1024 * 1024) chunk = await asyncio.wait_for(
process.stdout.read(CHUNK_SIZE),
for c in pre_buffer: timeout=30.0
yield c )
except asyncio.TimeoutError:
while True: yield b''
chunk = await process.stdout.read(CHUNK_SIZE) continue
if not chunk: if not chunk:
break break
yield chunk yield chunk
finally:
try:
process.kill()
except Exception:
pass
else:
async with aiohttp.ClientSession() as session:
async with session.get(video_url) as r:
async for chunk in r.content.iter_chunked(CHUNK_SIZE):
yield chunk
class YTVideoDownloader:
def __init__(self, link: str, format_id: str = 'best'):
self.link = link
self.format_id = format_id
def _get_urls_sync(self) -> tuple[str, str | None, str]:
ydl_opts = {
'format': (
f'{self.format_id}[ext=mp4]+bestaudio[ext=m4a]'
f'/bestvideo[ext=mp4]+bestaudio[ext=m4a]'
f'/best[ext=mp4]/best'
),
'quiet': True,
'no_warnings': True,
'socket_timeout': 30,
'http_headers': {
'User-Agent': (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/120.0.0.0 Safari/537.36'
)
},
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(self.link, download=False)
title = info.get('title', 'video')
if 'requested_formats' in info and len(info['requested_formats']) == 2:
return (
info['requested_formats'][0]['url'],
info['requested_formats'][1]['url'],
title,
)
return info['url'], None, title
async def get_urls(self) -> tuple[str, str | None, str]:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(executor, self._get_urls_sync)
async def generate(self, progress_callback=None):
video_url, audio_url, _ = await self.get_urls()
if audio_url:
process = await asyncio.create_subprocess_exec(
'ffmpeg',
'-i', video_url,
'-i', audio_url,
'-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
'-g', '60',
'-f', 'mp4',
'-movflags', 'frag_keyframe+empty_moov+faststart',
'-frag_duration', '2000000',
'pipe:1',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
bytes_sent = 0
try:
while True:
chunk = await process.stdout.read(CHUNK_SIZE)
if not chunk:
break
bytes_sent += len(chunk)
if progress_callback:
progress_callback(bytes_sent)
yield chunk
finally: finally:
try: try:
process.kill() process.kill()
@@ -260,22 +254,118 @@ class YTVideoDownloader:
else: else:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(video_url) as r: async with session.get(video_url) as r:
bytes_sent = 0
async for chunk in r.content.iter_chunked(CHUNK_SIZE): async for chunk in r.content.iter_chunked(CHUNK_SIZE):
bytes_sent += len(chunk) if stop_event and stop_event.is_set():
if progress_callback: return
progress_callback(bytes_sent)
yield chunk yield chunk
class YTPlaylist: class YTVideoDownloader:
"""Handles playlist metadata extraction and streaming/downloading.""" def __init__(self, link: str, format_id: str = 'best'):
self.link = link
self.format_id = format_id
def _download_sync(self, progress_callback=None) -> tuple[str, str]:
tmp_dir = tempfile.mkdtemp()
out_tmpl = os.path.join(tmp_dir, 'video.%(ext)s')
ydl_opts = {
'format': (
f'{self.format_id}[ext=mp4]+bestaudio[ext=m4a]'
f'/bestvideo[ext=mp4]+bestaudio[ext=m4a]'
f'/best[ext=mp4]/best'
),
'outtmpl': out_tmpl,
'merge_output_format': 'mp4',
'quiet': True,
'no_warnings': True,
'concurrent_fragment_downloads': 4,
'retries': 5,
'fragment_retries': 5,
'socket_timeout': 30,
'postprocessors': [
{
'key': 'FFmpegVideoRemuxer',
'preferedformat': 'mp4',
},
],
'postprocessor_args': {
'ffmpeg': [
'-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
'-movflags', '+faststart',
]
},
}
if progress_callback:
def hook(d):
if d['status'] == 'downloading':
raw = d.get('_percent_str', '0%').strip().replace('%', '')
try:
pct = min(float(raw), 99.0)
progress_callback(pct)
except Exception:
pass
elif d['status'] == 'finished':
progress_callback(99)
ydl_opts['progress_hooks'] = [hook]
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([self.link])
except Exception as e:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise RuntimeError(f'yt-dlp failed: {e}')
mp4_files = [
f for f in os.listdir(tmp_dir)
if f.endswith('.mp4')
]
if not mp4_files:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise FileNotFoundError('No MP4 found after download.')
return os.path.join(tmp_dir, mp4_files[0]), tmp_dir
async def download(self, progress_callback=None) -> tuple[str, str]:
loop = asyncio.get_event_loop()
try:
return await asyncio.wait_for(
loop.run_in_executor(
executor,
lambda: self._download_sync(progress_callback)
),
timeout=600
)
except asyncio.TimeoutError:
raise TimeoutError('Download timed out after 10 minutes.')
async def stream_file(self, filepath: str):
file_size = os.path.getsize(filepath)
async def generator():
with open(filepath, 'rb') as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk:
break
yield chunk
return generator(), file_size
class YTPlaylist:
def __init__(self, link: str): def __init__(self, link: str):
self.link = link self.link = link
def _fetch_sync(self) -> dict: def _fetch_sync(self) -> dict:
"""Fetch full playlist info — each entry has its own formats."""
ydl_opts = { ydl_opts = {
'quiet': True, 'quiet': True,
'no_warnings': True, 'no_warnings': True,
@@ -287,10 +377,6 @@ class YTPlaylist:
return ydl.extract_info(self.link, download=False) return ydl.extract_info(self.link, download=False)
def _fetch_flat_sync(self) -> dict: def _fetch_flat_sync(self) -> dict:
"""
Fast fetch — only titles/IDs, no format details.
Use this for the info endpoint to avoid 30s+ waits.
"""
ydl_opts = { ydl_opts = {
'quiet': True, 'quiet': True,
'no_warnings': True, 'no_warnings': True,
@@ -309,7 +395,6 @@ class YTPlaylist:
return await loop.run_in_executor(executor, self._fetch_sync) return await loop.run_in_executor(executor, self._fetch_sync)
def get_entries(self, info: dict) -> list[dict]: def get_entries(self, info: dict) -> list[dict]:
"""Return clean list of video entries from playlist."""
entries = [] entries = []
for i, entry in enumerate(info.get('entries', []), 1): for i, entry in enumerate(info.get('entries', []), 1):
if not entry: if not entry:
@@ -334,87 +419,88 @@ class YTPlaylist:
'entries': entries, 'entries': entries,
} }
async def generate_zip( async def generate_zip(self, format_id: str = 'best', progress_callback=None):
self, info = await self.fetch_flat()
format_id: str = 'best', entries = self.get_entries(info)
progress_callback=None total = len(entries)
):
"""
Async generator — yields chunks of a ZIP file containing all videos.
Each video is streamed through FFmpeg and added to the zip on the fly.
"""
info = await self.fetch_flat()
entries = self.get_entries(info)
total = len(entries)
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
for i, entry in enumerate(entries, 1): for i, entry in enumerate(entries, 1):
video_url_yt = entry['url'] video_url_yt = entry['url']
safe_title = "".join( safe_title = "".join(
c for c in entry['title'] if c.isascii() and (c.isalnum() or c in ' ._-') c for c in entry['title']
)[:60].strip() or f'video_{i}' if c.isascii() and (c.isalnum() or c in ' ._-')
)[:60].strip() or f'video_{i}'
if progress_callback: if progress_callback:
progress_callback(i, total, entry['title']) progress_callback(i, total, entry['title'])
try: try:
downloader = YTVideoDownloader(video_url_yt, format_id) info_obj = YTVideoInfo(video_url_yt)
vid_url, aud_url, _ = await downloader.get_urls() vid_info = await info_obj.fetch()
except Exception as e:
print(f'Skipping {entry["title"]}: {e}')
continue
video_bytes = io.BytesIO() requested = vid_info.get('requested_formats', [])
if len(requested) == 2:
if aud_url: vid_url = requested[0]['url']
process = await asyncio.create_subprocess_exec( aud_url = requested[1]['url']
'ffmpeg',
'-i', vid_url,
'-i', aud_url,
'-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
'-f', 'mp4',
'-movflags', 'frag_keyframe+empty_moov+faststart',
'pipe:1',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
while True:
chunk = await process.stdout.read(256 * 1024)
if not chunk:
break
video_bytes.write(chunk)
finally:
try:
process.kill()
await process.wait()
except Exception:
pass
else: else:
async with aiohttp.ClientSession() as session: vid_url = vid_info.get('url')
async with session.get(vid_url) as r: aud_url = None
async for chunk in r.content.iter_chunked(256 * 1024): except Exception as e:
video_bytes.write(chunk) print(f'Skipping {entry["title"]}: {e}')
continue
video_bytes.seek(0) video_bytes = io.BytesIO()
filename = f'{i:02d}. {safe_title}.mp4'
with zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_STORED) as zf:
zf.writestr(filename, video_bytes.read())
zip_buffer.seek(0) if aud_url:
while True: process = await asyncio.create_subprocess_exec(
chunk = zip_buffer.read(256 * 1024) 'ffmpeg',
if not chunk: '-i', vid_url,
break '-i', aud_url,
yield chunk '-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
'-f', 'mp4',
'-movflags', 'frag_keyframe+empty_moov+faststart',
'pipe:1',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
while True:
chunk = await process.stdout.read(CHUNK_SIZE)
if not chunk:
break
video_bytes.write(chunk)
finally:
try:
process.kill()
await process.wait()
except Exception:
pass
else:
async with aiohttp.ClientSession() as session:
async with session.get(vid_url) as r:
async for chunk in r.content.iter_chunked(CHUNK_SIZE):
video_bytes.write(chunk)
zip_buffer.seek(0) video_bytes.seek(0)
zip_buffer.truncate(0) filename = f'{i:02d}. {safe_title}.mp4'
with zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_STORED) as zf:
zf.writestr(filename, video_bytes.read())
zip_buffer.seek(0) zip_buffer.seek(0)
remainder = zip_buffer.read() while True:
if remainder: chunk = zip_buffer.read(CHUNK_SIZE)
yield remainder if not chunk:
break
yield chunk
zip_buffer.seek(0)
zip_buffer.truncate(0)
zip_buffer.seek(0)
remainder = zip_buffer.read()
if remainder:
yield remainder

259
app.py
View File

@@ -11,6 +11,7 @@ from slowapi.errors import RateLimitExceeded
from dotenv import load_dotenv from dotenv import load_dotenv
import secrets import secrets
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
import hashlib
templates = Jinja2Templates(directory='templates') templates = Jinja2Templates(directory='templates')
@@ -20,6 +21,14 @@ PORT = int(os.environ.get('PORT', 5000))
HOST = os.environ.get('HOST', '0.0.0.0') HOST = os.environ.get('HOST', '0.0.0.0')
SECRET_KEY = os.environ.get('SECRET_KEY', secrets.token_hex(32)) SECRET_KEY = os.environ.get('SECRET_KEY', secrets.token_hex(32))
_active_streams: dict[str, asyncio.Event] = {}
_streams_lock = asyncio.Lock()
def _make_stream_key(client_ip: str, url: str) -> str:
raw = f"{client_ip}:{url}"
return hashlib.md5(raw.encode()).hexdigest()
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
app = FastAPI() app = FastAPI()
app.state.limiter = limiter app.state.limiter = limiter
@@ -68,11 +77,98 @@ async def watch(request: Request, url: str = ''):
@app.get('/stream') @app.get('/stream')
@limiter.limit('30/minute') @limiter.limit('30/minute')
async def stream(request: Request, url: str, format_id: str = 'best'): async def stream(
streamer = YTVideoStream(url, format_id) request: Request,
url: str,
format_id: str = 'best',
client: str = 'browser'
):
streamer = YTVideoStream(url, format_id)
prebuffer = client != 'mobile'
return StreamingResponse( return StreamingResponse(
streamer.generate(), streamer.generate(prebuffer=prebuffer),
media_type='video/mp4', media_type='video/mp4',
headers={
'Cache-Control': 'no-cache',
'X-Content-Type-Options': 'nosniff',
'Transfer-Encoding': 'chunked',
}
)
@app.get('/download')
@limiter.limit('5/minute')
async def download(request: Request, url: str, format_id: str = 'best'):
if not url:
raise HTTPException(status_code=400, detail='No URL provided.')
def progress_callback(pct: float):
set_progress(url, {'percent': pct, 'speed': '', 'eta': ''})
try:
downloader = YTVideoDownloader(url, format_id)
filepath, tmp_dir = await downloader.download(
progress_callback=progress_callback
)
except TimeoutError:
clear_progress(url)
raise HTTPException(status_code=504, detail='Download timed out.')
except Exception as e:
clear_progress(url)
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
file_size = os.path.getsize(filepath)
if file_size == 0:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise HTTPException(
status_code=500,
detail='Downloaded file is empty — download may have failed.'
)
try:
info_obj = YTVideoInfo(url)
info = await info_obj.fetch()
title = info.get('title', 'video')
except Exception:
title = 'video'
safe_title = "".join(
c for c in title
if c.isascii() and (c.isalnum() or c in ' ._-')
)[:80].strip() or 'video'
utf8_title = urllib.parse.quote(title[:80].strip(), safe='._- ')
set_progress(url, {'percent': 100, 'speed': '', 'eta': ''})
async def file_generator():
try:
with open(filepath, 'rb') as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk:
break
yield chunk
finally:
await asyncio.sleep(2)
shutil.rmtree(tmp_dir, ignore_errors=True)
clear_progress(url)
return StreamingResponse(
file_generator(),
media_type='video/mp4',
headers={
'Content-Length': str(file_size),
'Content-Disposition': (
f'attachment; '
f'filename="{safe_title}.mp4"; '
f"filename*=UTF-8''{utf8_title}.mp4"
),
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache',
}
) )
@app.get('/subtitles') @app.get('/subtitles')
@@ -91,45 +187,6 @@ async def subtitles(url: str, lang: str, auto: str = '0'):
return Response(text, media_type='text/vtt; charset=utf-8') return Response(text, media_type='text/vtt; charset=utf-8')
@app.get('/download')
@limiter.limit('5/minute')
async def download(request: Request, url: str, format_id: str = 'best'):
if not url:
raise HTTPException(status_code=400, detail='No URL provided.')
try:
downloader = YTVideoDownloader(url, format_id)
video_url, audio_url, title = await downloader.get_urls()
except Exception as e:
raise HTTPException(status_code=500, detail=f'Could not fetch video info: {str(e)}')
ascii_title = "".join(
c for c in title if c.isascii() and (c.isalnum() or c in ' ._-')
)[:80].strip() or 'video'
utf8_title = urllib.parse.quote(title[:80].strip(), safe='._- ')
def progress_callback(total_bytes: int):
mb = total_bytes / (1024 * 1024)
set_progress(url, {
'mb_received': round(mb, 1),
'speed': f'{mb:.1f} MB received',
})
return StreamingResponse(
downloader.generate(progress_callback=progress_callback),
media_type='video/mp4',
headers={
'Content-Disposition': (
f'attachment; '
f'filename="{ascii_title}.mp4"; '
f"filename*=UTF-8''{utf8_title}.mp4"
),
'X-Video-Title': utf8_title,
'Cache-Control': 'no-cache',
}
)
@app.get('/download/progress') @app.get('/download/progress')
async def download_progress(url: str): async def download_progress(url: str):
return JSONResponse(get_progress(url)) return JSONResponse(get_progress(url))
@@ -139,29 +196,37 @@ async def info(url: str):
if not url: if not url:
raise HTTPException(status_code=400, detail='No URL provided.') raise HTTPException(status_code=400, detail='No URL provided.')
# Simple check — playlist URLs contain 'list=' is_playlist = (
is_playlist = 'list=' in url and 'watch?v=' not in url 'playlist?list=' in url
or ('list=' in url and 'watch?v=' not in url)
)
try: try:
if is_playlist: if is_playlist:
playlist = YTPlaylist(url) playlist = YTPlaylist(url)
raw = await playlist.fetch_flat() raw = await playlist.fetch_flat()
return JSONResponse({ return JSONResponse({
'type': 'playlist', 'type': 'playlist',
**playlist.summary(raw) **playlist.summary(raw)
}) })
else: else:
info_obj = YTVideoInfo(url) info_obj = YTVideoInfo(url)
raw = await info_obj.fetch() raw = await info_obj.fetch()
formats = info_obj.get_formats(raw)
return JSONResponse({ return JSONResponse({
'type': 'video', 'type': 'video',
'title': raw.get('title'), 'title': raw.get('title'),
'uploader': raw.get('uploader'), 'uploader': raw.get('uploader'),
'duration': raw.get('duration'), 'duration': raw.get('duration'),
'formats': info_obj.get_formats(raw), 'thumbnail_url': raw.get('thumbnail'),
'subtitles': info_obj.get_subtitles(raw), 'formats': formats,
'subtitles': info_obj.get_subtitles(raw),
}) })
except HTTPException:
raise
except Exception as e: except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get('/playlist/info') @app.get('/playlist/info')
@@ -179,45 +244,84 @@ async def playlist_info(url: str):
return JSONResponse(summary) return JSONResponse(summary)
@app.get('/playlist/stream') @app.get('/stream')
async def playlist_stream( @limiter.limit('30/minute')
request: Request, async def stream(
url: str, request: Request,
index: int = 0, # which video in the playlist to stream (0-based) url: str,
format_id: str = 'best' format_id: str = 'best',
client: str = 'browser'
): ):
if not url: client_ip = request.client.host
raise HTTPException(status_code=400, detail='No URL provided.') stream_key = _make_stream_key(client_ip, url)
async with _streams_lock:
if stream_key in _active_streams:
_active_streams[stream_key].set()
await asyncio.sleep(0.5)
stop_event = asyncio.Event()
_active_streams[stream_key] = stop_event
estimated_size = None
try: try:
playlist = YTPlaylist(url) info_obj = YTVideoInfo(url)
info = await playlist.fetch_flat() info = await info_obj.fetch()
entries = playlist.get_entries(info) for f in info.get('formats', []):
except Exception as e: if f.get('format_id') == format_id:
raise HTTPException(status_code=500, detail=str(e)) video_size = f.get('filesize') or f.get('filesize_approx') or 0
audio_size = 0
for af in info['formats']:
if (af.get('vcodec') == 'none'
and af.get('ext') == 'm4a'):
audio_size = (
af.get('filesize')
or af.get('filesize_approx')
or 0
)
break
total = video_size + audio_size
if total > 0:
estimated_size = total
break
except Exception:
pass
if index >= len(entries): prebuffer = client != 'mobile'
raise HTTPException(status_code=404, detail='Video index out of range.') streamer = YTVideoStream(url, format_id)
entry = entries[index] async def stream_with_cleanup():
video_url = entry['url'] try:
async for chunk in streamer.generate(
prebuffer=prebuffer,
stop_event=stop_event,
):
yield chunk
finally:
async with _streams_lock:
if _active_streams.get(stream_key) is stop_event:
del _active_streams[stream_key]
# Reuse YTVideoStream for the individual video headers = {
streamer = YTVideoStream(video_url, format_id) 'Cache-Control': 'no-cache',
'X-Content-Type-Options': 'nosniff',
'Accept-Ranges': 'none',
}
if estimated_size:
headers['X-Estimated-Size'] = str(estimated_size)
if client == 'mobile':
headers['Content-Length'] = str(estimated_size)
return StreamingResponse( return StreamingResponse(
streamer.generate(), stream_with_cleanup(),
media_type='video/mp4', media_type='video/mp4',
headers={ headers=headers,
'X-Playlist-Index': str(index),
'X-Playlist-Total': str(len(entries)),
'X-Video-Title': urllib.parse.quote(entry['title']),
}
) )
@app.get('/playlist/download') @app.get('/playlist/download')
@limiter.limit('2/minute') # stricter limit — downloads entire playlist @limiter.limit('2/minute')
async def playlist_download( async def playlist_download(
request: Request, request: Request,
url: str, url: str,
@@ -227,7 +331,6 @@ async def playlist_download(
raise HTTPException(status_code=400, detail='No URL provided.') raise HTTPException(status_code=400, detail='No URL provided.')
try: try:
# Quick flat fetch just to get the playlist title
playlist = YTPlaylist(url) playlist = YTPlaylist(url)
info = await playlist.fetch_flat() info = await playlist.fetch_flat()
title = info.get('title', 'playlist') title = info.get('title', 'playlist')