Introduces .dockerignore, production Dockerfile and nginx config for frontend, and refactors docker-compose.yml for multi-service deployment. Updates backend and frontend code to support public API tagging, improves refund handling, adds test email endpoint, and migrates Orval config to TypeScript. Removes unused frontend Dockerfile and updates dependencies for React Query and Orval.
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", "public"],
|
|
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", "public"],
|
|
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", "public"],
|
|
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) |