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

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

259
app.py
View File

@@ -11,6 +11,7 @@ from slowapi.errors import RateLimitExceeded
from dotenv import load_dotenv
import secrets
from fastapi.templating import Jinja2Templates
import hashlib
templates = Jinja2Templates(directory='templates')
@@ -20,6 +21,14 @@ PORT = int(os.environ.get('PORT', 5000))
HOST = os.environ.get('HOST', '0.0.0.0')
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)
app = FastAPI()
app.state.limiter = limiter
@@ -68,11 +77,98 @@ async def watch(request: Request, url: str = ''):
@app.get('/stream')
@limiter.limit('30/minute')
async def stream(request: Request, url: str, format_id: str = 'best'):
streamer = YTVideoStream(url, format_id)
async def stream(
request: Request,
url: str,
format_id: str = 'best',
client: str = 'browser'
):
streamer = YTVideoStream(url, format_id)
prebuffer = client != 'mobile'
return StreamingResponse(
streamer.generate(),
streamer.generate(prebuffer=prebuffer),
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')
@@ -91,45 +187,6 @@ async def subtitles(url: str, lang: str, auto: str = '0'):
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')
async def download_progress(url: str):
return JSONResponse(get_progress(url))
@@ -139,29 +196,37 @@ async def info(url: str):
if not url:
raise HTTPException(status_code=400, detail='No URL provided.')
# Simple check — playlist URLs contain 'list='
is_playlist = 'list=' in url and 'watch?v=' not in url
is_playlist = (
'playlist?list=' in url
or ('list=' in url and 'watch?v=' not in url)
)
try:
if is_playlist:
playlist = YTPlaylist(url)
raw = await playlist.fetch_flat()
return JSONResponse({
'type': 'playlist',
'type': 'playlist',
**playlist.summary(raw)
})
else:
info_obj = YTVideoInfo(url)
raw = await info_obj.fetch()
formats = info_obj.get_formats(raw)
return JSONResponse({
'type': 'video',
'title': raw.get('title'),
'uploader': raw.get('uploader'),
'duration': raw.get('duration'),
'formats': info_obj.get_formats(raw),
'subtitles': info_obj.get_subtitles(raw),
'type': 'video',
'title': raw.get('title'),
'uploader': raw.get('uploader'),
'duration': raw.get('duration'),
'thumbnail_url': raw.get('thumbnail'),
'formats': formats,
'subtitles': info_obj.get_subtitles(raw),
})
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@app.get('/playlist/info')
@@ -179,45 +244,84 @@ async def playlist_info(url: str):
return JSONResponse(summary)
@app.get('/playlist/stream')
async def playlist_stream(
request: Request,
url: str,
index: int = 0, # which video in the playlist to stream (0-based)
format_id: str = 'best'
@app.get('/stream')
@limiter.limit('30/minute')
async def stream(
request: Request,
url: str,
format_id: str = 'best',
client: str = 'browser'
):
if not url:
raise HTTPException(status_code=400, detail='No URL provided.')
client_ip = request.client.host
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:
playlist = YTPlaylist(url)
info = await playlist.fetch_flat()
entries = playlist.get_entries(info)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
info_obj = YTVideoInfo(url)
info = await info_obj.fetch()
for f in info.get('formats', []):
if f.get('format_id') == format_id:
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):
raise HTTPException(status_code=404, detail='Video index out of range.')
prebuffer = client != 'mobile'
streamer = YTVideoStream(url, format_id)
entry = entries[index]
video_url = entry['url']
async def stream_with_cleanup():
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
streamer = YTVideoStream(video_url, format_id)
headers = {
'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(
streamer.generate(),
stream_with_cleanup(),
media_type='video/mp4',
headers={
'X-Playlist-Index': str(index),
'X-Playlist-Total': str(len(entries)),
'X-Video-Title': urllib.parse.quote(entry['title']),
}
headers=headers,
)
@app.get('/playlist/download')
@limiter.limit('2/minute') # stricter limit — downloads entire playlist
@limiter.limit('2/minute')
async def playlist_download(
request: Request,
url: str,
@@ -227,7 +331,6 @@ async def playlist_download(
raise HTTPException(status_code=400, detail='No URL provided.')
try:
# Quick flat fetch just to get the playlist title
playlist = YTPlaylist(url)
info = await playlist.fetch_flat()
title = info.get('title', 'playlist')