305 lines
11 KiB
Python
305 lines
11 KiB
Python
# ---------------------- Inline serializers for documentation only ----------------------
|
|
# Using inline_serializer to avoid creating new files.
|
|
|
|
import yt_dlp
|
|
import tempfile
|
|
import os
|
|
import shutil
|
|
|
|
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 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
|
|
|
|
# Allowed container formats for output/remux
|
|
FORMAT_CHOICES = ("mp4", "mkv", "webm", "flv", "mov", "avi", "ogg")
|
|
FORMAT_HELP = (
|
|
"Choose container format: "
|
|
"mp4 (H.264 + AAC, most compatible), "
|
|
"mkv (flexible, lossless container), "
|
|
"webm (VP9/AV1 + Opus), "
|
|
"flv (legacy), mov (Apple-friendly), "
|
|
"avi (older), ogg (mostly obsolete)."
|
|
)
|
|
|
|
# Minimal mime map by extension
|
|
MIME_BY_EXT = {
|
|
"mp4": "video/mp4",
|
|
"mkv": "video/x-matroska",
|
|
"webm": "video/webm",
|
|
"flv": "video/x-flv",
|
|
"mov": "video/quicktime",
|
|
"avi": "video/x-msvideo",
|
|
"ogg": "video/ogg",
|
|
}
|
|
|
|
class Downloader(APIView):
|
|
permission_classes = [AllowAny]
|
|
authentication_classes = []
|
|
|
|
@extend_schema(
|
|
tags=["downloader"],
|
|
summary="Get video info from URL",
|
|
parameters=[
|
|
inline_serializer(
|
|
name="VideoInfoParams",
|
|
fields={
|
|
"url": serializers.URLField(help_text="Video URL to analyze"),
|
|
},
|
|
)
|
|
],
|
|
responses={
|
|
200: inline_serializer(
|
|
name="VideoInfoResponse",
|
|
fields={
|
|
"title": serializers.CharField(),
|
|
"duration": serializers.IntegerField(allow_null=True),
|
|
"thumbnail": serializers.URLField(allow_null=True),
|
|
"video_resolutions": serializers.ListField(child=serializers.CharField()),
|
|
"audio_resolutions": serializers.ListField(child=serializers.CharField()),
|
|
},
|
|
),
|
|
400: inline_serializer(
|
|
name="ErrorResponse",
|
|
fields={"error": serializers.CharField()},
|
|
),
|
|
},
|
|
)
|
|
def get(self, request):
|
|
url = request.data.get("url") or request.query_params.get("url")
|
|
if not url:
|
|
return Response({"error": "URL is required"}, status=400)
|
|
|
|
ydl_options = {
|
|
"quiet": True,
|
|
}
|
|
try:
|
|
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
|
info = ydl.extract_info(url, download=False)
|
|
except Exception:
|
|
return Response({"error": "Failed to retrieve video info"}, status=400)
|
|
|
|
formats = info.get("formats", []) or []
|
|
|
|
# Video: collect unique heights and sort desc
|
|
heights = {
|
|
int(f.get("height"))
|
|
for f in formats
|
|
if f.get("vcodec") != "none" and isinstance(f.get("height"), int)
|
|
}
|
|
video_resolutions = [f"{h}p" for h in sorted(heights, reverse=True)]
|
|
|
|
# Audio: collect unique bitrates (abr kbps), fallback to tbr when abr missing
|
|
bitrates = set()
|
|
for f in formats:
|
|
if f.get("acodec") != "none" and f.get("vcodec") == "none":
|
|
abr = f.get("abr")
|
|
tbr = f.get("tbr")
|
|
val = None
|
|
if isinstance(abr, (int, float)):
|
|
val = int(abr)
|
|
elif isinstance(tbr, (int, float)):
|
|
val = int(tbr)
|
|
if val and val > 0:
|
|
bitrates.add(val)
|
|
|
|
audio_resolutions = [f"{b}kbps" for b in sorted(bitrates, reverse=True)]
|
|
|
|
return Response(
|
|
{
|
|
"title": info.get("title"),
|
|
"duration": info.get("duration"),
|
|
"thumbnail": info.get("thumbnail"),
|
|
"video_resolutions": video_resolutions,
|
|
"audio_resolutions": audio_resolutions,
|
|
},
|
|
status=200,
|
|
)
|
|
|
|
|
|
|
|
@extend_schema(
|
|
tags=["downloader"],
|
|
summary="Download video from URL",
|
|
request=inline_serializer(
|
|
name="DownloadRequest",
|
|
fields={
|
|
"url": serializers.URLField(help_text="Video URL to download"),
|
|
"ext": serializers.ChoiceField(
|
|
choices=FORMAT_CHOICES,
|
|
required=False,
|
|
default="mp4",
|
|
help_text=FORMAT_HELP,
|
|
),
|
|
"format": serializers.ChoiceField(
|
|
choices=FORMAT_CHOICES,
|
|
required=False,
|
|
help_text="Alias of 'ext' (deprecated)."
|
|
),
|
|
"video_quality": serializers.IntegerField(
|
|
required=True,
|
|
help_text="Target max video height (e.g. 1080)."
|
|
),
|
|
"audio_quality": serializers.IntegerField(
|
|
required=True,
|
|
help_text="Target max audio bitrate in kbps (e.g. 160)."
|
|
),
|
|
},
|
|
),
|
|
responses={
|
|
200: OpenApiTypes.BINARY,
|
|
400: inline_serializer(
|
|
name="DownloadErrorResponse",
|
|
fields={
|
|
"error": serializers.CharField(),
|
|
"allowed": serializers.ListField(child=serializers.CharField(), required=False),
|
|
},
|
|
),
|
|
},
|
|
)
|
|
def post(self, request):
|
|
url = request.data.get("url")
|
|
# Accept ext or legacy format param
|
|
ext = (request.data.get("ext") or request.data.get("format") or "mp4").lower()
|
|
|
|
try:
|
|
video_quality = int(request.data.get("video_quality")) # height, e.g., 1080
|
|
audio_quality = int(request.data.get("audio_quality")) # abr kbps, e.g., 160
|
|
except Exception:
|
|
return Response({"error": "Invalid quality parameters, not integers!"}, status=400)
|
|
|
|
if not url:
|
|
return Response({"error": "URL is required"}, status=400)
|
|
if ext not in FORMAT_CHOICES:
|
|
return Response({"error": f"Unsupported extension '{ext}'", "allowed": FORMAT_CHOICES}, 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)
|
|
outtmpl = os.path.join(tmpdir, "download.%(ext)s")
|
|
|
|
# Build a format selector using requested quality caps
|
|
# Example: "bv[height<=1080]+ba[abr<=160]/b"
|
|
video_part = f"bv[height<={video_quality}]" if video_quality else "bv*"
|
|
audio_part = f"ba[abr<={audio_quality}]" if audio_quality else "ba"
|
|
|
|
format_selector = f"{video_part}+{audio_part}/b"
|
|
|
|
|
|
ydl_options = {
|
|
"format": format_selector, # select by requested quality
|
|
"merge_output_format": ext, # container
|
|
"outtmpl": outtmpl, # temp dir
|
|
"quiet": True,
|
|
"max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES,
|
|
"socket_timeout": settings.DOWNLOADER_TIMEOUT,
|
|
# remux to container without re-encoding where possible
|
|
"postprocessors": [
|
|
{"key": "FFmpegVideoRemuxer", "preferedformat": ext}
|
|
],
|
|
}
|
|
|
|
file_path = ""
|
|
try:
|
|
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,
|
|
format=ext,
|
|
length_of_media=duration,
|
|
file_size=size,
|
|
)
|
|
|
|
# Streaming generator that deletes file & temp dir after send (or on abort)
|
|
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 = MIME_BY_EXT.get(ext, "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)
|
|
return Response({"error": str(e)}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- STATS FOR GRAPHS ----------------
|
|
|
|
from .serializers import DownloaderStatsSerializer
|
|
from django.db.models import Count, Avg, Sum
|
|
|
|
class DownloaderStats(APIView):
|
|
"""
|
|
Vrací agregované statistiky z tabulky DownloaderRecord.
|
|
"""
|
|
authentication_classes = []
|
|
permission_classes = [AllowAny]
|
|
@extend_schema(
|
|
tags=["downloader"],
|
|
summary="Get aggregated downloader statistics",
|
|
responses={200: DownloaderStatsSerializer},
|
|
)
|
|
def get(self, request):
|
|
# agregace číselných polí
|
|
agg = DownloaderRecord.objects.aggregate(
|
|
total_downloads=Count("id"),
|
|
avg_length_of_media=Avg("length_of_media"),
|
|
avg_file_size=Avg("file_size"),
|
|
total_length_of_media=Sum("length_of_media"),
|
|
total_file_size=Sum("file_size"),
|
|
)
|
|
|
|
# zjištění nejčastějšího formátu
|
|
most_common = (
|
|
DownloaderRecord.objects.values("format")
|
|
.annotate(count=Count("id"))
|
|
.order_by("-count")
|
|
.first()
|
|
)
|
|
|
|
agg["most_common_format"] = most_common["format"] if most_common else None
|
|
|
|
serializer = DownloaderStatsSerializer(agg)
|
|
return Response(serializer.data) |