fixes, orval, downloader functioning again
This commit is contained in:
245
backend/thirdparty/downloader/consumers.py
vendored
Normal file
245
backend/thirdparty/downloader/consumers.py
vendored
Normal 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))
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
30
backend/thirdparty/downloader/migrations/0002_downloadjob.py
vendored
Normal file
30
backend/thirdparty/downloader/migrations/0002_downloadjob.py
vendored
Normal 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']},
|
||||
),
|
||||
]
|
||||
16
backend/thirdparty/downloader/migrations/0003_auto_20260420_2214.py
vendored
Normal file
16
backend/thirdparty/downloader/migrations/0003_auto_20260420_2214.py
vendored
Normal 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',
|
||||
),
|
||||
]
|
||||
6
backend/thirdparty/downloader/routing.py
vendored
Normal file
6
backend/thirdparty/downloader/routing.py
vendored
Normal 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()),
|
||||
]
|
||||
2
backend/thirdparty/downloader/urls.py
vendored
2
backend/thirdparty/downloader/urls.py
vendored
@@ -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"),
|
||||
]
|
||||
|
||||
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