okay
This commit is contained in:
@@ -1,54 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-28 00:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DownloaderModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField()),
|
||||
('download_time', models.DateTimeField(auto_now_add=True)),
|
||||
('status', models.CharField(max_length=50)),
|
||||
('requested_format', models.CharField(blank=True, db_index=True, max_length=100, null=True)),
|
||||
('format_id', models.CharField(blank=True, db_index=True, max_length=50, null=True)),
|
||||
('ext', models.CharField(blank=True, db_index=True, max_length=20, null=True)),
|
||||
('vcodec', models.CharField(blank=True, db_index=True, max_length=50, null=True)),
|
||||
('acodec', models.CharField(blank=True, db_index=True, max_length=50, null=True)),
|
||||
('width', models.IntegerField(blank=True, null=True)),
|
||||
('height', models.IntegerField(blank=True, null=True)),
|
||||
('fps', models.FloatField(blank=True, null=True)),
|
||||
('abr', models.FloatField(blank=True, null=True)),
|
||||
('vbr', models.FloatField(blank=True, null=True)),
|
||||
('tbr', models.FloatField(blank=True, null=True)),
|
||||
('asr', models.IntegerField(blank=True, null=True)),
|
||||
('filesize', models.BigIntegerField(blank=True, null=True)),
|
||||
('duration', models.FloatField(blank=True, null=True)),
|
||||
('title', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('extractor', models.CharField(blank=True, db_index=True, max_length=100, null=True)),
|
||||
('extractor_key', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('video_id', models.CharField(blank=True, db_index=True, max_length=128, null=True)),
|
||||
('webpage_url', models.URLField(blank=True, null=True)),
|
||||
('is_audio_only', models.BooleanField(db_index=True, default=False)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True, null=True)),
|
||||
('error_message', models.TextField(blank=True, null=True)),
|
||||
('raw_info', models.JSONField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['download_time'], name='downloader__downloa_ef522e_idx'), models.Index(fields=['ext', 'is_audio_only'], name='downloader__ext_2aa7af_idx'), models.Index(fields=['requested_format'], name='downloader__request_f4048b_idx'), models.Index(fields=['extractor'], name='downloader__extract_b39777_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
13
backend/thirdparty/downloader/urls.py
vendored
13
backend/thirdparty/downloader/urls.py
vendored
@@ -1,14 +1,13 @@
|
||||
from django.urls import path
|
||||
from .views import DownloaderLogView, DownloaderStatsView
|
||||
from .views import DownloaderFormatsView, DownloaderFileView
|
||||
from .views import DownloaderFormatsView, DownloaderFileView, DownloaderStatsView
|
||||
|
||||
urlpatterns = [
|
||||
# Probe formats for a URL (size-checked)
|
||||
path("api/downloader/formats/", DownloaderFormatsView.as_view(), name="downloader-formats"),
|
||||
path("formats/", DownloaderFormatsView.as_view(), name="downloader-formats"),
|
||||
|
||||
# Download selected format (enforces size limit)
|
||||
path("api/downloader/download/", DownloaderFileView.as_view(), name="downloader-download"),
|
||||
path("download/", DownloaderFileView.as_view(), name="downloader-download"),
|
||||
|
||||
# Aggregated statistics
|
||||
path("api/downloader/stats/", DownloaderStatsView.as_view(), name="downloader-stats"),
|
||||
# Legacy helper
|
||||
path("api/downloader/logs/", DownloaderLogView.as_view(), name="downloader-log"),
|
||||
path("stats/", DownloaderStatsView.as_view(), name="downloader-stats"),
|
||||
]
|
||||
|
||||
62
backend/thirdparty/downloader/views.py
vendored
62
backend/thirdparty/downloader/views.py
vendored
@@ -9,6 +9,7 @@ from django.http import StreamingHttpResponse, JsonResponse
|
||||
from django.utils.text import slugify
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
# docs + schema helpers
|
||||
from rest_framework import serializers
|
||||
@@ -26,6 +27,7 @@ import math
|
||||
import json
|
||||
import tempfile
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote as urlquote
|
||||
|
||||
from .models import DownloaderModel
|
||||
from .serializers import DownloaderLogSerializer
|
||||
@@ -141,9 +143,30 @@ def _client_meta(request) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
|
||||
user = getattr(request, "user", None)
|
||||
return user, ip, ua
|
||||
|
||||
# Safe logger: swallow DB errors if table is missing/not migrated yet
|
||||
def _log_safely(*, info, requested_format, status: str, url: str, user, ip_address: str, user_agent: str, error_message: str | None = None):
|
||||
try:
|
||||
DownloaderModel.from_ydl_info(
|
||||
info=info,
|
||||
requested_format=requested_format,
|
||||
status=status,
|
||||
url=url,
|
||||
user=user,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
error_message=error_message,
|
||||
)
|
||||
except (OperationalError, ProgrammingError):
|
||||
# migrations not applied or table missing – ignore
|
||||
pass
|
||||
except Exception:
|
||||
# never break the request on logging failures
|
||||
pass
|
||||
|
||||
class DownloaderFormatsView(APIView):
|
||||
"""Probe media URL and return available formats with estimated sizes and limit flags."""
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
@extend_schema(
|
||||
tags=["downloader"],
|
||||
@@ -224,7 +247,7 @@ class DownloaderFormatsView(APIView):
|
||||
except Exception as e:
|
||||
# log probe error
|
||||
user, ip, ua = _client_meta(request)
|
||||
DownloaderModel.from_ydl_info(
|
||||
_log_safely(
|
||||
info={"webpage_url": url},
|
||||
requested_format=None,
|
||||
status="probe_error",
|
||||
@@ -258,7 +281,7 @@ class DownloaderFormatsView(APIView):
|
||||
|
||||
# Log probe
|
||||
user, ip, ua = _client_meta(request)
|
||||
DownloaderModel.from_ydl_info(
|
||||
_log_safely(
|
||||
info=info,
|
||||
requested_format=None,
|
||||
status="probe_ok",
|
||||
@@ -280,6 +303,7 @@ class DownloaderFormatsView(APIView):
|
||||
class DownloaderFileView(APIView):
|
||||
"""Download selected format if under max size, then stream the file back."""
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
@extend_schema(
|
||||
tags=["downloader"],
|
||||
@@ -288,7 +312,7 @@ class DownloaderFileView(APIView):
|
||||
description="Downloads with a strict max filesize guard and streams as application/octet-stream.",
|
||||
request=DownloadRequestSchema,
|
||||
responses={
|
||||
200: OpenApiResponse(response=OpenApiTypes.BINARY, media_type="application/octet-stream"),
|
||||
200: OpenApiTypes.BINARY, # was OpenApiResponse(..., media_type="application/octet-stream")
|
||||
400: OpenApiResponse(response=ErrorResponseSchema),
|
||||
413: OpenApiResponse(response=ErrorResponseSchema),
|
||||
500: OpenApiResponse(response=ErrorResponseSchema),
|
||||
@@ -334,7 +358,7 @@ class DownloaderFileView(APIView):
|
||||
info = ydl.extract_info(url, download=False)
|
||||
except Exception as e:
|
||||
user, ip, ua = _client_meta(request)
|
||||
DownloaderModel.from_ydl_info(
|
||||
_log_safely(
|
||||
info={"webpage_url": url},
|
||||
requested_format=fmt_id,
|
||||
status="precheck_error",
|
||||
@@ -358,7 +382,7 @@ class DownloaderFileView(APIView):
|
||||
est_size = _estimate_size_bytes(selected, duration)
|
||||
if est_size is not None and est_size > max_bytes:
|
||||
user, ip, ua = _client_meta(request)
|
||||
DownloaderModel.from_ydl_info(
|
||||
_log_safely(
|
||||
info=selected,
|
||||
requested_format=fmt_id,
|
||||
status="blocked_by_size",
|
||||
@@ -400,7 +424,7 @@ class DownloaderFileView(APIView):
|
||||
filepath = result.get("requested_downloads", [{}])[0].get("filepath") or result.get("_filename")
|
||||
except Exception as e:
|
||||
user, ip, ua = _client_meta(request)
|
||||
DownloaderModel.from_ydl_info(
|
||||
_log_safely(
|
||||
info=selected,
|
||||
requested_format=fmt_id,
|
||||
status="download_error",
|
||||
@@ -425,7 +449,7 @@ class DownloaderFileView(APIView):
|
||||
try:
|
||||
selected_info = dict(selected)
|
||||
selected_info["filesize"] = os.path.getsize(filepath)
|
||||
DownloaderModel.from_ydl_info(
|
||||
_log_safely(
|
||||
info=selected_info,
|
||||
requested_format=fmt_id,
|
||||
status="success",
|
||||
@@ -451,7 +475,13 @@ class DownloaderFileView(APIView):
|
||||
pass
|
||||
|
||||
resp = StreamingHttpResponse(file_generator(filepath), content_type="application/octet-stream")
|
||||
resp["Content-Disposition"] = f'attachment; filename="{safe_name}"'
|
||||
# Include both plain and RFC 5987 encoded filename
|
||||
resp["Content-Disposition"] = (
|
||||
f'attachment; filename="{safe_name}"; filename*=UTF-8\'\'{urlquote(safe_name)}'
|
||||
)
|
||||
# Expose headers so the browser can read them via XHR/fetch
|
||||
resp["X-Filename"] = safe_name
|
||||
resp["Access-Control-Expose-Headers"] = "Content-Disposition, X-Filename, Content-Length, Content-Type"
|
||||
try:
|
||||
resp["Content-Length"] = str(os.path.getsize(filepath))
|
||||
except Exception:
|
||||
@@ -521,19 +551,3 @@ class DownloaderStatsView(APIView):
|
||||
"top_acodec": top_acodec,
|
||||
"audio_vs_video": audio_vs_video,
|
||||
})
|
||||
|
||||
|
||||
# Minimal placeholder so existing URL doesn't break; prefer using automatic logs above.
|
||||
class DownloaderLogView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@extend_schema(
|
||||
tags=["downloader"],
|
||||
operation_id="downloader_log_helper",
|
||||
summary="Deprecated helper",
|
||||
description="Use /api/downloader/formats/ then /api/downloader/download/.",
|
||||
responses={200: inline_serializer(name="LogHelper", fields={"detail": serializers.CharField()})},
|
||||
)
|
||||
def post(self, request):
|
||||
"""POST to the deprecated log helper endpoint."""
|
||||
return Response({"detail": "Use /api/downloader/formats/ then /api/downloader/download/."}, status=200)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-28 00:13
|
||||
# Generated by Django 5.2.7 on 2025-10-28 22:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
Reference in New Issue
Block a user