fixes, orval, downloader functioning again

This commit is contained in:
2026-04-21 00:47:10 +02:00
parent 659999f4fd
commit cf08dbaf15
93 changed files with 1662 additions and 1333 deletions

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2026-01-17 01:37
# Generated by Django 5.2.7 on 2026-04-20 17:54
from django.db import migrations, models
@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('state', models.CharField(choices=[('CREATED', 'cz#Vytvořeno'), ('FINALIZED', 'cz#Dokončeno'), ('SHIPPED', 'cz#Odesláno'), ('DELIVERED', 'cz#Doručeno'), ('CANCELLED', 'cz#Zrušeno'), ('ERROR', 'cz#Chyba')], default='CREATED', max_length=20)),
('state', models.CharField(choices=[('CREATED', 'Vytvořeno'), ('FINALIZED', 'Dokončeno'), ('SHIPPED', 'Odesláno'), ('DELIVERED', 'Doručeno'), ('CANCELLED', 'Zrušeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20)),
('order_id', models.CharField(blank=True, help_text='Deutsche Post order ID from API', max_length=50, null=True)),
('customer_ekp', models.CharField(blank=True, max_length=20, null=True)),
('recipient_name', models.CharField(max_length=200)),
@@ -43,6 +43,8 @@ class Migration(migrations.Migration):
('tracking_url', models.URLField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Raw API response data')),
('last_error', models.TextField(blank=True, help_text='Last API error message')),
('label_pdf', models.FileField(blank=True, help_text='Shipping label PDF', null=True, upload_to='deutschepost/labels/')),
('label_size', models.CharField(choices=[('A4', 'A4 (210x297mm)'), ('A5', 'A5 (148x210mm)'), ('A6', 'A6 (105x148mm)')], default='A4', max_length=10)),
],
),
migrations.CreateModel(
@@ -50,12 +52,14 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('CREATED', 'cz#Vytvořeno'), ('PROCESSING', 'cz#Zpracovává se'), ('COMPLETED', 'cz#Dokončeno'), ('ERROR', 'cz#Chyba')], default='CREATED', max_length=20)),
('status', models.CharField(choices=[('CREATED', 'Vytvořeno'), ('PROCESSING', 'Zpracovává se'), ('COMPLETED', 'Dokončeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20)),
('bulk_order_id', models.CharField(blank=True, help_text='Deutsche Post bulk order ID from API', max_length=50, null=True)),
('bulk_order_type', models.CharField(default='MIXED_BAG', help_text='MIXED_BAG, etc.', max_length=20)),
('description', models.CharField(blank=True, max_length=255)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Raw API response data')),
('last_error', models.TextField(blank=True, help_text='Last API error message')),
('bulk_label_pdf', models.FileField(blank=True, help_text='Bulk shipment label PDF', null=True, upload_to='deutschepost/bulk_labels/')),
('paperwork_pdf', models.FileField(blank=True, help_text='Bulk shipment paperwork PDF', null=True, upload_to='deutschepost/paperwork/')),
('deutschepost_orders', models.ManyToManyField(blank=True, related_name='bulk_orders', to='deutschepost.deutschepostorder')),
],
),

View File

@@ -1,43 +0,0 @@
# Generated by Django 5.2.7 on 2026-01-24 22:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('deutschepost', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='deutschepostbulkorder',
name='bulk_label_pdf',
field=models.FileField(blank=True, help_text='Bulk shipment label PDF', null=True, upload_to='deutschepost/bulk_labels/'),
),
migrations.AddField(
model_name='deutschepostbulkorder',
name='paperwork_pdf',
field=models.FileField(blank=True, help_text='Bulk shipment paperwork PDF', null=True, upload_to='deutschepost/paperwork/'),
),
migrations.AddField(
model_name='deutschepostorder',
name='label_pdf',
field=models.FileField(blank=True, help_text='Shipping label PDF', null=True, upload_to='deutschepost/labels/'),
),
migrations.AddField(
model_name='deutschepostorder',
name='label_size',
field=models.CharField(choices=[('A4', 'A4 (210x297mm)'), ('A5', 'A5 (148x210mm)'), ('A6', 'A6 (105x148mm)')], default='A4', max_length=10),
),
migrations.AlterField(
model_name='deutschepostbulkorder',
name='status',
field=models.CharField(choices=[('CREATED', 'Vytvořeno'), ('PROCESSING', 'Zpracovává se'), ('COMPLETED', 'Dokončeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20),
),
migrations.AlterField(
model_name='deutschepostorder',
name='state',
field=models.CharField(choices=[('CREATED', 'Vytvořeno'), ('FINALIZED', 'Dokončeno'), ('SHIPPED', 'Odesláno'), ('DELIVERED', 'Doručeno'), ('CANCELLED', 'Zrušeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20),
),
]

View File

@@ -0,0 +1,245 @@
import asyncio
import json
import mimetypes
import os
import shutil
import tempfile
import zipfile
from urllib.parse import urlparse
import yt_dlp
from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings
from django.core import signing
from django.utils.text import slugify
YDL_BASE = {
"quiet": True,
"no_check_certificates": True,
"js_runtimes": {"node": {}},
"remote_components": {"ejs:github"},
}
TOKEN_TTL = 600 # seconds until signed download token expires
class DownloaderConsumer(AsyncWebsocketConsumer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.tmpdir = None
async def connect(self):
await self.accept()
async def disconnect(self, code):
if self.tmpdir and os.path.exists(self.tmpdir):
shutil.rmtree(self.tmpdir, ignore_errors=True)
self.tmpdir = None
async def receive(self, text_data):
try:
params = json.loads(text_data)
except (json.JSONDecodeError, TypeError):
await self._send({"type": "error", "message": "Invalid JSON"})
return
url = params.get("url")
if not url:
await self._send({"type": "error", "message": "URL is required"})
return
ext = params.get("ext", "mp4")
if not ext or not isinstance(ext, str):
await self._send({"type": "error", "message": "Invalid extension"})
return
os.makedirs(settings.DOWNLOADER_TMP_DIR, exist_ok=True)
self.tmpdir = tempfile.mkdtemp(prefix="dl_ws_", dir=settings.DOWNLOADER_TMP_DIR)
await self._send({"type": "status", "message": "Starting…"})
loop = asyncio.get_event_loop()
def progress_hook(d):
status = d.get("status")
if status == "downloading":
percent = (d.get("_percent_str") or "").strip()
speed = (d.get("_speed_str") or "").strip()
eta = (d.get("_eta_str") or "").strip()
downloaded = d.get("downloaded_bytes", 0)
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
pct_num = round(downloaded / total * 100, 1) if total else None
asyncio.run_coroutine_threadsafe(
self._send({
"type": "progress",
"message": f"Downloading: {percent} at {speed} ETA {eta}",
"percent": pct_num,
"speed": speed,
"eta": eta,
}),
loop,
)
elif status == "finished":
asyncio.run_coroutine_threadsafe(
self._send({"type": "status", "message": "Post-processing…"}),
loop,
)
try:
result = await asyncio.to_thread(
self._run_download,
url,
ext,
params.get("video_quality"),
params.get("audio_quality"),
params.get("selected_videos"),
params.get("subtitles"),
bool(params.get("embed_subtitles", False)),
bool(params.get("embed_thumbnail", False)),
bool(params.get("extract_audio", False)),
params.get("cookies"),
progress_hook,
)
except Exception as exc:
if self.tmpdir:
shutil.rmtree(self.tmpdir, ignore_errors=True)
self.tmpdir = None
await self._send({"type": "error", "message": str(exc)})
return
# Sign a token containing the file info — no DB needed.
token = signing.dumps(
{
"file_path": result["file_path"],
"filename": result["filename"],
"content_type": result["content_type"],
"file_size": result["file_size"],
"tmpdir": self.tmpdir,
},
salt="downloader-file-token",
)
# Consumer no longer owns cleanup — the HTTP serve view will clean up.
self.tmpdir = None
await self._send({
"type": "done",
"token": token,
"filename": result["filename"],
"file_size": result["file_size"],
})
# ------------------------------------------------------------------
def _run_download(self, url, ext, video_quality, audio_quality,
selected_videos, subtitles, embed_subtitles,
embed_thumbnail, extract_audio, cookies, progress_hook):
"""Runs synchronously inside a thread pool worker."""
tmpdir = self.tmpdir
# Format selector
if video_quality and audio_quality:
fmt = f"bv[height<={video_quality}]+ba[abr<={audio_quality}]/b"
elif video_quality:
fmt = f"bv[height<={video_quality}]+ba/b"
elif audio_quality:
fmt = f"bv+ba[abr<={audio_quality}]/b"
else:
fmt = "b/bv+ba"
ydl_opts = {
**YDL_BASE,
"format": fmt,
"merge_output_format": ext,
"max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES,
"postprocessors": [],
"progress_hooks": [progress_hook],
}
if cookies:
cookie_file = os.path.join(tmpdir, "cookies.txt")
with open(cookie_file, "w") as f:
f.write(cookies)
ydl_opts["cookiefile"] = cookie_file
if subtitles:
if subtitles.lower() == "all":
ydl_opts.update({
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": ["all"],
})
else:
ydl_opts.update({
"writesubtitles": True,
"subtitleslangs": [l.strip() for l in subtitles.split(",")],
})
if embed_subtitles and subtitles and ext in ("mkv", "mp4"):
ydl_opts["postprocessors"].append({"key": "FFmpegEmbedSubtitle"})
if embed_thumbnail:
ydl_opts["writethumbnail"] = True
ydl_opts["postprocessors"].append({"key": "EmbedThumbnail"})
if extract_audio:
ydl_opts["postprocessors"].append({
"key": "FFmpegExtractAudio",
"preferredcodec": ext if ext in ("mp3", "m4a", "opus", "vorbis", "wav") else "mp3",
})
else:
ydl_opts["postprocessors"].append({"key": "FFmpegVideoRemuxer", "preferedformat": ext})
# Probe to detect playlist
probe_opts = {**YDL_BASE, "extract_flat": False}
with yt_dlp.YoutubeDL(probe_opts) as ydl:
info = ydl.extract_info(url, download=False)
is_playlist = "entries" in info and info.get("entries") is not None
if is_playlist:
if selected_videos:
ydl_opts["playlist_items"] = ",".join(str(n) for n in selected_videos)
ydl_opts["outtmpl"] = os.path.join(tmpdir, "%(playlist_index)02d - %(title)s.%(ext)s")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
zip_path = os.path.join(tmpdir, "playlist.zip")
files = [
(fn, os.path.join(tmpdir, fn))
for fn in os.listdir(tmpdir)
if fn not in ("playlist.zip", "cookies.txt")
and not fn.startswith(".")
and os.path.isfile(os.path.join(tmpdir, fn))
]
if not files:
raise RuntimeError("No files were downloaded from the playlist")
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for fn, fp in files:
zf.write(fp, fn)
playlist_title = slugify(info.get("title", "playlist"))
return {
"file_path": zip_path,
"filename": f"{playlist_title}.zip",
"content_type": "application/zip",
"file_size": os.path.getsize(zip_path),
}
else:
ydl_opts["outtmpl"] = os.path.join(tmpdir, "download.%(ext)s")
with yt_dlp.YoutubeDL(ydl_opts) 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}"
safe_title = slugify(info.get("title") or "video")
filename = f"{safe_title}.{ext}"
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return {
"file_path": file_path,
"filename": filename,
"content_type": content_type,
"file_size": os.path.getsize(file_path) if os.path.exists(file_path) else 0,
}
async def _send(self, data: dict):
await self.send(text_data=json.dumps(data))

View File

@@ -1,5 +1,7 @@
# Generated by Django 5.2.7 on 2025-12-18 15:11
# Generated by Django 5.2.7 on 2026-04-20 17:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
@@ -8,6 +10,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
@@ -18,13 +21,21 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('url', models.URLField()),
('download_time', models.DateTimeField(auto_now_add=True)),
('title', models.CharField(blank=True, default='', max_length=500)),
('platform', models.CharField(blank=True, default='', help_text='e.g. youtube, tiktok, vimeo', max_length=100)),
('format', models.CharField(max_length=50)),
('length_of_media', models.IntegerField(help_text='Length of media in seconds')),
('file_size', models.BigIntegerField(help_text='File size in bytes')),
('video_quality', models.IntegerField(blank=True, help_text='Video height in pixels (e.g. 1080). Null for audio-only.', null=True)),
('is_audio_only', models.BooleanField(default=False)),
('length_of_media', models.IntegerField(blank=True, help_text='Length of media in seconds', null=True)),
('file_size', models.BigIntegerField(blank=True, help_text='File size in bytes', null=True)),
('processing_time', models.FloatField(blank=True, help_text='Server-side processing time in seconds', null=True)),
('success', models.BooleanField(default=True)),
('error_message', models.TextField(blank=True, default='')),
('download_time', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='downloads', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
'ordering': ['-download_time'],
},
),
]

View File

@@ -1,70 +0,0 @@
# Generated by Django 5.2.7 on 2026-04-19 21:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('downloader', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='downloaderrecord',
options={'ordering': ['-download_time']},
),
migrations.AddField(
model_name='downloaderrecord',
name='error_message',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='downloaderrecord',
name='is_audio_only',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='downloaderrecord',
name='platform',
field=models.CharField(blank=True, default='', help_text='e.g. youtube, tiktok, vimeo', max_length=100),
),
migrations.AddField(
model_name='downloaderrecord',
name='processing_time',
field=models.FloatField(blank=True, help_text='Server-side processing time in seconds', null=True),
),
migrations.AddField(
model_name='downloaderrecord',
name='success',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='downloaderrecord',
name='title',
field=models.CharField(blank=True, default='', max_length=500),
),
migrations.AddField(
model_name='downloaderrecord',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='downloads', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='downloaderrecord',
name='video_quality',
field=models.IntegerField(blank=True, help_text='Video height in pixels (e.g. 1080). Null for audio-only.', null=True),
),
migrations.AlterField(
model_name='downloaderrecord',
name='file_size',
field=models.BigIntegerField(blank=True, help_text='File size in bytes', null=True),
),
migrations.AlterField(
model_name='downloaderrecord',
name='length_of_media',
field=models.IntegerField(blank=True, help_text='Length of media in seconds', null=True),
),
]

View File

@@ -0,0 +1,30 @@
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('downloader', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='DownloadJob',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(
choices=[('pending', 'Pending'), ('processing', 'Processing'), ('done', 'Done'), ('error', 'Error')],
default='pending', max_length=20,
)),
('message', models.CharField(blank=True, default='', max_length=500)),
('file_path', models.CharField(blank=True, default='', max_length=1000)),
('filename', models.CharField(blank=True, default='', max_length=500)),
('content_type', models.CharField(blank=True, default='application/octet-stream', max_length=100)),
('file_size', models.BigIntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={'ordering': ['-created_at']},
),
]

View File

@@ -0,0 +1,16 @@
# Generated by Django 5.2.7 on 2026-04-20 20:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('downloader', '0002_downloadjob'),
]
operations = [
migrations.DeleteModel(
name='DownloadJob',
),
]

View File

@@ -0,0 +1,6 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"ws/downloader/$", consumers.DownloaderConsumer.as_asgi()),
]

View File

@@ -4,6 +4,6 @@ from .views import Downloader, DownloaderStats
urlpatterns = [
# Probe formats for a URL (size-checked)
path("download/", Downloader.as_view(), name="downloader-download"),
path("download/file/", Downloader.as_view(), name="downloader-file"),
path("stats/", DownloaderStats.as_view(), name="downloader-stats"),
]

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2025-12-18 15:11
# Generated by Django 5.2.7 on 2026-04-20 17:54
import django.db.models.deletion
import django.utils.timezone

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2025-12-18 15:11
# Generated by Django 5.2.7 on 2026-04-20 17:54
from django.db import migrations, models

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.7 on 2025-12-18 15:11
# Generated by Django 5.2.7 on 2026-04-20 17:54
import django.core.validators
from django.db import migrations, models
@@ -17,7 +17,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('state', models.CharField(choices=[('WAITING_FOR_ORDERING_SHIPMENT', 'cz#Čeká na objednání zásilkovny'), ('PENDING', 'cz#Podáno'), ('SENDED', 'cz#Odesláno'), ('ARRIVED', 'cz#Doručeno'), ('CANCELED', 'cz#Zrušeno'), ('RETURNING', 'cz#Posláno zpátky'), ('RETURNED', 'cz#Vráceno')], default='PENDING', max_length=35)),
('state', models.CharField(choices=[('WAITING_FOR_ORDERING_SHIPMENT', 'Čeká na objednání zásilkovny'), ('PENDING', 'Podáno'), ('SENDED', 'Odesláno'), ('ARRIVED', 'Doručeno'), ('CANCELED', 'Zrušeno'), ('RETURNING', 'Posláno zpátky'), ('RETURNED', 'Vráceno')], default='PENDING', max_length=35)),
('addressId', models.IntegerField(blank=True, help_text='ID adresy/pointu, ve Widgetu zásilkovny který si vybere uživatel.', null=True)),
('packet_id', models.IntegerField(blank=True, help_text='Číslo zásilky v Packetě (vraceno od API od Packety)', null=True)),
('barcode', models.CharField(blank=True, help_text='Čárový kód zásilky od Packety', max_length=64, null=True)),

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.2.7 on 2026-01-24 22:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('zasilkovna', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='zasilkovnapacket',
name='state',
field=models.CharField(choices=[('WAITING_FOR_ORDERING_SHIPMENT', 'Čeká na objednání zásilkovny'), ('PENDING', 'Podáno'), ('SENDED', 'Odesláno'), ('ARRIVED', 'Doručeno'), ('CANCELED', 'Zrušeno'), ('RETURNING', 'Posláno zpátky'), ('RETURNED', 'Vráceno')], default='PENDING', max_length=35),
),
]