This commit is contained in:
2025-10-29 00:58:37 +01:00
parent 73da41b514
commit dd9d076bd2
33 changed files with 1172 additions and 385 deletions

View File

@@ -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')],
},
),
]

View File

@@ -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"),
]

View File

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