fixes, orval, downloader functioning again
This commit is contained in:
430
backend/thirdparty/downloader/views.py
vendored
430
backend/thirdparty/downloader/views.py
vendored
@@ -1,31 +1,27 @@
|
||||
# ---------------------- Inline serializers for documentation only ----------------------
|
||||
# Using inline_serializer to avoid creating new files.
|
||||
|
||||
import yt_dlp
|
||||
import tempfile
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import mimetypes
|
||||
import base64
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import time
|
||||
|
||||
import yt_dlp
|
||||
import requests
|
||||
import base64
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .consumers import TOKEN_TTL
|
||||
|
||||
from django.core import signing
|
||||
from rest_framework import serializers
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.permissions import AllowAny
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from django.conf import settings
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.utils.text import slugify
|
||||
# NEW: aggregations and timeseries helpers
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.db.models.functions import TruncDay, TruncHour
|
||||
from .models import DownloaderRecord
|
||||
|
||||
# Common container formats - user can provide any extension supported by ffmpeg
|
||||
@@ -122,6 +118,8 @@ class Downloader(APIView):
|
||||
"no_check_certificates": True, # Bypass SSL verification in Docker
|
||||
"extract_flat": False, # Extract full info for playlists too
|
||||
"ignoreerrors": False, # Don't ignore errors to get accurate info
|
||||
"js_runtimes": {"node": {}},
|
||||
"remote_components": {"ejs:github"},
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -233,381 +231,58 @@ class Downloader(APIView):
|
||||
|
||||
@extend_schema(
|
||||
tags=["downloader", "public"],
|
||||
summary="Download video or playlist from URL",
|
||||
description="""
|
||||
Download video/playlist with optional quality constraints and container format conversion.
|
||||
|
||||
**For Playlists:**
|
||||
- Returns a ZIP file containing all selected videos
|
||||
- Use `selected_videos` to specify which videos to download (e.g., [1,3,5] or [1,2,3,4,5])
|
||||
- If `selected_videos` is not provided, all videos in the playlist will be downloaded
|
||||
|
||||
**Quality Parameters (optional):**
|
||||
- If not specified, yt-dlp will automatically select the best available quality.
|
||||
- `video_quality`: Maximum video height in pixels (e.g., 1080, 720, 480).
|
||||
- `audio_quality`: Maximum audio bitrate in kbps (e.g., 320, 192, 128).
|
||||
|
||||
**Format/Extension:**
|
||||
- Any format supported by ffmpeg (mp4, mkv, webm, avi, mov, flv, m4a, mp3, etc.).
|
||||
- Defaults to 'mp4' if not specified.
|
||||
- The conversion is handled automatically by ffmpeg in the background.
|
||||
|
||||
**Advanced Options:**
|
||||
- `subtitles`: Download subtitles (language codes like 'en,cs' or 'all')
|
||||
- `embed_subtitles`: Embed subtitles into video file
|
||||
- `embed_thumbnail`: Embed thumbnail as cover art
|
||||
- `extract_audio`: Extract audio only (ignores video quality)
|
||||
- `cookies`: Browser cookies for age-restricted content (Netscape format)
|
||||
""",
|
||||
request=inline_serializer(
|
||||
name="DownloadRequest",
|
||||
fields={
|
||||
"url": serializers.URLField(help_text="Video/Playlist URL to download from supported platforms"),
|
||||
"ext": serializers.CharField(
|
||||
required=False,
|
||||
default="mp4",
|
||||
help_text=FORMAT_HELP,
|
||||
),
|
||||
"video_quality": serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Optional: Target max video height in pixels (e.g. 1080, 720). If omitted, best quality is selected."
|
||||
),
|
||||
"audio_quality": serializers.IntegerField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Optional: Target max audio bitrate in kbps (e.g. 320, 192, 128). If omitted, best quality is selected."
|
||||
),
|
||||
"selected_videos": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_empty=True,
|
||||
help_text="For playlists: specify which videos to download as array of numbers (e.g., [1,3,5]). If omitted, all videos are downloaded."
|
||||
),
|
||||
"subtitles": serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
help_text="Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles"
|
||||
),
|
||||
"embed_subtitles": serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
help_text="Embed subtitles into the video file (requires mkv or mp4 container)"
|
||||
),
|
||||
"embed_thumbnail": serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
help_text="Embed thumbnail as cover art in the file"
|
||||
),
|
||||
"extract_audio": serializers.BooleanField(
|
||||
required=False,
|
||||
default=False,
|
||||
help_text="Extract audio only, ignoring video quality settings"
|
||||
),
|
||||
"cookies": serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
help_text="Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt'"
|
||||
),
|
||||
},
|
||||
),
|
||||
summary="Download file via signed token",
|
||||
description="Serve a file using a signed token from WebSocket download. Token expires in 10 minutes.",
|
||||
parameters=[
|
||||
inline_serializer(
|
||||
name="DownloadTokenParams",
|
||||
fields={
|
||||
"token": serializers.CharField(help_text="Signed token containing file info"),
|
||||
},
|
||||
)
|
||||
],
|
||||
responses={
|
||||
200: OpenApiTypes.BINARY,
|
||||
400: inline_serializer(
|
||||
name="DownloadErrorResponse",
|
||||
fields={
|
||||
"error": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
400: inline_serializer(name="TokenError", fields={"error": serializers.CharField()}),
|
||||
},
|
||||
)
|
||||
def post(self, request):
|
||||
url = request.data.get("url")
|
||||
# Accept ext parameter, default to mp4
|
||||
ext = request.data.get("ext", "mp4")
|
||||
|
||||
# Optional quality parameters - only parse if provided
|
||||
video_quality = None
|
||||
audio_quality = None
|
||||
|
||||
if request.data.get("video_quality"):
|
||||
try:
|
||||
video_quality = int(request.data.get("video_quality"))
|
||||
except (ValueError, TypeError):
|
||||
return Response({"error": "Invalid video_quality parameter, must be an integer!"}, status=400)
|
||||
|
||||
if request.data.get("audio_quality"):
|
||||
try:
|
||||
audio_quality = int(request.data.get("audio_quality"))
|
||||
except (ValueError, TypeError):
|
||||
return Response({"error": "Invalid audio_quality parameter, must be an integer!"}, status=400)
|
||||
|
||||
# Advanced options (removed start_time and end_time)
|
||||
selected_videos = request.data.get("selected_videos")
|
||||
subtitles = request.data.get("subtitles")
|
||||
embed_subtitles = request.data.get("embed_subtitles", False)
|
||||
embed_thumbnail = request.data.get("embed_thumbnail", False)
|
||||
extract_audio = request.data.get("extract_audio", False)
|
||||
cookies = request.data.get("cookies")
|
||||
|
||||
if not url:
|
||||
return Response({"error": "URL is required"}, status=400)
|
||||
if not ext or not isinstance(ext, str):
|
||||
return Response({"error": "Extension must be a valid string"}, status=400)
|
||||
|
||||
# Ensure base tmp dir exists
|
||||
os.makedirs(settings.DOWNLOADER_TMP_DIR, exist_ok=True)
|
||||
tmpdir = tempfile.mkdtemp(prefix="downloader_", dir=settings.DOWNLOADER_TMP_DIR)
|
||||
|
||||
# First, check if this is a playlist
|
||||
ydl_info_options = {
|
||||
"quiet": True,
|
||||
"no_check_certificates": True,
|
||||
"extract_flat": False,
|
||||
}
|
||||
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_info_options) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
except Exception as e:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
return Response({"error": f"Failed to retrieve URL info: {str(e)}"}, status=400)
|
||||
|
||||
is_playlist = "entries" in info and info.get("entries") is not None
|
||||
|
||||
# Build format selector using optional quality caps
|
||||
if video_quality is not None and audio_quality is not None:
|
||||
format_selector = f"bv[height<={video_quality}]+ba[abr<={audio_quality}]/b"
|
||||
elif video_quality is not None:
|
||||
format_selector = f"bv[height<={video_quality}]+ba/b"
|
||||
elif audio_quality is not None:
|
||||
format_selector = f"bv+ba[abr<={audio_quality}]/b"
|
||||
else:
|
||||
format_selector = "b/bv+ba"
|
||||
|
||||
# Common ydl options
|
||||
ydl_options = {
|
||||
"format": format_selector,
|
||||
"merge_output_format": ext,
|
||||
"quiet": True,
|
||||
"no_check_certificates": True,
|
||||
"max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES,
|
||||
"postprocessors": [],
|
||||
}
|
||||
|
||||
# Handle cookies for age-restricted content
|
||||
if cookies:
|
||||
cookie_file = os.path.join(tmpdir, "cookies.txt")
|
||||
try:
|
||||
with open(cookie_file, "w") as f:
|
||||
f.write(cookies)
|
||||
ydl_options["cookiefile"] = cookie_file
|
||||
except Exception as e:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
return Response({"error": f"Invalid cookies format: {str(e)}"}, status=400)
|
||||
|
||||
# Subtitles
|
||||
if subtitles:
|
||||
if subtitles.lower() == "all":
|
||||
ydl_options["writesubtitles"] = True
|
||||
ydl_options["writeautomaticsub"] = True
|
||||
ydl_options["subtitleslangs"] = ["all"]
|
||||
else:
|
||||
ydl_options["writesubtitles"] = True
|
||||
ydl_options["subtitleslangs"] = [lang.strip() for lang in subtitles.split(",")]
|
||||
|
||||
# Embed subtitles (only for mkv/mp4)
|
||||
if embed_subtitles and subtitles:
|
||||
if ext in ["mkv", "mp4"]:
|
||||
ydl_options["postprocessors"].append({"key": "FFmpegEmbedSubtitle"})
|
||||
else:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
return Response({"error": "Subtitle embedding requires mkv or mp4 format"}, status=400)
|
||||
|
||||
# Embed thumbnail
|
||||
if embed_thumbnail:
|
||||
ydl_options["writethumbnail"] = True
|
||||
ydl_options["postprocessors"].append({"key": "EmbedThumbnail"})
|
||||
|
||||
# Extract audio only
|
||||
if extract_audio:
|
||||
ydl_options["postprocessors"].append({
|
||||
"key": "FFmpegExtractAudio",
|
||||
"preferredcodec": ext if ext in ["mp3", "m4a", "opus", "vorbis", "wav"] else "mp3",
|
||||
})
|
||||
|
||||
# Playlist items (use selected_videos parameter)
|
||||
if is_playlist and selected_videos:
|
||||
# Convert array of numbers to yt-dlp format string
|
||||
playlist_items_str = ",".join(str(num) for num in selected_videos)
|
||||
ydl_options["playlist_items"] = playlist_items_str
|
||||
|
||||
# Add remux postprocessor if not extracting audio
|
||||
if not extract_audio:
|
||||
ydl_options["postprocessors"].append(
|
||||
{"key": "FFmpegVideoRemuxer", "preferedformat": ext}
|
||||
)
|
||||
|
||||
_platform = (info.get('extractor') or urlparse(url).netloc).lower().replace('www.', '').split(':')[0]
|
||||
_user = request.user if request.user.is_authenticated else None
|
||||
_title = info.get('title', '')
|
||||
start_time = time.time()
|
||||
token = request.data.get("token")
|
||||
if not token:
|
||||
return Response({"error": "Token is required"}, status=400)
|
||||
|
||||
try:
|
||||
if is_playlist:
|
||||
# Handle playlist - create ZIP file
|
||||
ydl_options["outtmpl"] = os.path.join(tmpdir, "%(playlist_index)02d - %(title)s.%(ext)s")
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
||||
ydl.download([url])
|
||||
|
||||
# Create ZIP file
|
||||
zip_path = os.path.join(tmpdir, f"playlist.zip")
|
||||
downloaded_files = []
|
||||
|
||||
# Find all downloaded files
|
||||
for filename in os.listdir(tmpdir):
|
||||
if filename != "playlist.zip" and filename != "cookies.txt" and not filename.startswith("."):
|
||||
file_path = os.path.join(tmpdir, filename)
|
||||
if os.path.isfile(file_path):
|
||||
downloaded_files.append((filename, file_path))
|
||||
|
||||
if not downloaded_files:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
return Response({"error": "No files were downloaded from the playlist"}, status=400)
|
||||
|
||||
# Create ZIP
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for filename, file_path in downloaded_files:
|
||||
zipf.write(file_path, filename)
|
||||
|
||||
# Stats for database
|
||||
total_duration = 0
|
||||
total_size = os.path.getsize(zip_path)
|
||||
|
||||
# Try to get duration from info
|
||||
if info.get("entries"):
|
||||
for entry in info["entries"]:
|
||||
if entry and entry.get("duration"):
|
||||
total_duration += int(entry.get("duration", 0))
|
||||
|
||||
DownloaderRecord.objects.create(
|
||||
url=url,
|
||||
title=_title,
|
||||
platform=_platform,
|
||||
user=_user,
|
||||
format='zip',
|
||||
is_audio_only=bool(extract_audio),
|
||||
video_quality=video_quality,
|
||||
length_of_media=total_duration or None,
|
||||
file_size=total_size or None,
|
||||
processing_time=round(time.time() - start_time, 3),
|
||||
success=True,
|
||||
)
|
||||
data = signing.loads(token, salt="downloader-file-token", max_age=TOKEN_TTL)
|
||||
except signing.BadSignature:
|
||||
return Response({"error": "Invalid token"}, status=400)
|
||||
except signing.SignatureExpired:
|
||||
return Response({"error": "Token expired"}, status=400)
|
||||
|
||||
# Streaming response for ZIP
|
||||
def stream_and_cleanup_zip(zip_file_path: str, temp_dir: str, chunk_size: int = 8192):
|
||||
try:
|
||||
with open(zip_file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
file_path = data["file_path"]
|
||||
tmpdir = data["tmpdir"]
|
||||
|
||||
playlist_title = slugify(info.get("title", "playlist"))
|
||||
zip_filename = f"{playlist_title}.zip"
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content=stream_and_cleanup_zip(zip_path, tmpdir),
|
||||
content_type="application/zip",
|
||||
)
|
||||
response["Content-Length"] = str(total_size)
|
||||
response["Content-Disposition"] = f'attachment; filename="{zip_filename}"'
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
# Handle single video (existing logic)
|
||||
outtmpl = os.path.join(tmpdir, "download.%(ext)s")
|
||||
ydl_options["outtmpl"] = outtmpl
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
base = ydl.prepare_filename(info)
|
||||
file_path = base if base.endswith(f".{ext}") else os.path.splitext(base)[0] + f".{ext}"
|
||||
|
||||
# Stats before streaming
|
||||
duration = int((info or {}).get("duration") or 0)
|
||||
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||
|
||||
DownloaderRecord.objects.create(
|
||||
url=url,
|
||||
title=_title,
|
||||
platform=_platform,
|
||||
user=_user,
|
||||
format=ext,
|
||||
is_audio_only=bool(extract_audio),
|
||||
video_quality=video_quality,
|
||||
length_of_media=duration or None,
|
||||
file_size=size or None,
|
||||
processing_time=round(time.time() - start_time, 3),
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Streaming generator that deletes file & temp dir after send
|
||||
def stream_and_cleanup(path: str, temp_dir: str, chunk_size: int = 8192):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
safe_title = slugify(info.get("title") or "video")
|
||||
filename = f"{safe_title}.{ext}"
|
||||
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content=stream_and_cleanup(file_path, tmpdir),
|
||||
content_type=content_type,
|
||||
)
|
||||
if size:
|
||||
response["Content-Length"] = str(size)
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
DownloaderRecord.objects.create(
|
||||
url=url,
|
||||
title=_title,
|
||||
platform=_platform,
|
||||
user=_user,
|
||||
format=ext if not is_playlist else 'zip',
|
||||
is_audio_only=bool(extract_audio),
|
||||
video_quality=video_quality,
|
||||
processing_time=round(time.time() - start_time, 3),
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
)
|
||||
return Response({"error": str(e)}, status=400)
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return Response({"error": "File no longer available"}, status=400)
|
||||
|
||||
async def stream_and_cleanup(path: str, temp_dir: str, chunk_size: int = 8192):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = await asyncio.to_thread(f.read, chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content=stream_and_cleanup(file_path, tmpdir),
|
||||
content_type=data["content_type"] or "application/octet-stream",
|
||||
)
|
||||
if data["file_size"]:
|
||||
response["Content-Length"] = str(data["file_size"])
|
||||
response["Content-Disposition"] = f'attachment; filename="{data["filename"]}"'
|
||||
return response
|
||||
|
||||
|
||||
# ---------------- STATS FOR GRAPHS ----------------
|
||||
@@ -627,4 +302,5 @@ class DownloaderStats(APIView):
|
||||
responses={200: DownloaderStatsSerializer},
|
||||
)
|
||||
def get(self, request):
|
||||
return Response(DownloaderStatsSerializer(DownloaderRecord.objects.all()).data)
|
||||
return Response(DownloaderStatsSerializer(DownloaderRecord.objects.all()).data)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user