Загрузить файлы в «/»
This commit is contained in:
259
app.py
259
app.py
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user