converter

This commit is contained in:
2025-10-30 01:58:28 +01:00
parent dd9d076bd2
commit 8dd4f6e731
23 changed files with 1142 additions and 1286 deletions

View File

@@ -2,6 +2,8 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt update && apt install ffmpeg -y
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

View File

@@ -1,8 +1,9 @@
import uuid import uuid
from django.db import models from django.db import models
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator from django.core.validators import RegexValidator
from django.utils.crypto import get_random_string
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@@ -61,6 +62,10 @@ class CustomUser(SoftDeleteModel, AbstractUser):
email_verified = models.BooleanField(default=False) email_verified = models.BooleanField(default=False)
email = models.EmailField(unique=True, db_index=True) email = models.EmailField(unique=True, db_index=True)
# + fields for email verification flow
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
gdpr = models.BooleanField(default=False) gdpr = models.BooleanField(default=False)
is_active = models.BooleanField(default=False) is_active = models.BooleanField(default=False)
@@ -125,4 +130,31 @@ class CustomUser(SoftDeleteModel, AbstractUser):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def generate_email_verification_token(self, length: int = 48, save: bool = True) -> str:
token = get_random_string(length=length)
self.email_verification_token = token
self.email_verification_sent_at = timezone.now()
if save:
self.save(update_fields=["email_verification_token", "email_verification_sent_at"])
return token
def verify_email_token(self, token: str, max_age_hours: int = 48, save: bool = True) -> bool:
if not token or not self.email_verification_token:
return False
# optional expiry check
if self.email_verification_sent_at:
age = timezone.now() - self.email_verification_sent_at
if age > timedelta(hours=max_age_hours):
return False
if token != self.email_verification_token:
return False
if not self.email_verified:
self.email_verified = True
# clear token after success
self.email_verification_token = None
self.email_verification_sent_at = None
if save:
self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"])
return True

View File

@@ -10,76 +10,115 @@ from .models import CustomUser
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
@shared_task def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
def send_password_reset_email_task(user_id): """
General function to send emails with a specific context.
Supports rendering plain text and HTML templates.
Converts `user` in context to a plain dict to avoid template access to the model.
"""
if isinstance(recipients, str):
recipients = [recipients]
html_message = None
if template_name or html_template_name:
# Best effort to resolve both templates if only one provided
if not template_name and html_template_name:
template_name = html_template_name.replace(".html", ".txt")
if not html_template_name and template_name:
html_template_name = template_name.replace(".txt", ".html")
ctx = dict(context or {})
# Sanitize user if someone passes the model by mistake
if "user" in ctx and not isinstance(ctx["user"], dict):
try: try:
user = CustomUser.objects.get(pk=user_id) ctx["user"] = _build_user_template_ctx(ctx["user"])
except CustomUser.DoesNotExist: except Exception:
error_msg = f"Task send_password_reset_email has failed. Invalid User ID was sent." ctx["user"] = {}
logger.error(error_msg)
raise Exception(error_msg) message = render_to_string(template_name, ctx)
uid = urlsafe_base64_encode(force_bytes(user.pk)) html_message = render_to_string(html_template_name, ctx)
token = password_reset_token.make_token(user)
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}" try:
html_message = render_to_string( send_mail(
'emails/password_reset.html', subject=subject,
{'reset_url': reset_url} message=message or "",
from_email=None,
recipient_list=recipients,
fail_silently=False,
html_message=html_message,
) )
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend': if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU") logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
send_email_with_context( return True
recipients=user.email, except Exception as e:
subject="Obnova hesla", logger.error(f"E-mail se neodeslal: {e}")
message=None, return False
html_message=html_message
)
# Only email verification for user registration
def _build_user_template_ctx(user: CustomUser) -> dict:
"""
Return a plain dict for templates instead of passing the DB model.
Provides aliases to avoid template errors (firstname vs first_name).
Adds a backward-compatible key 'get_full_name' for templates using `user.get_full_name`.
"""
first_name = getattr(user, "first_name", "") or ""
last_name = getattr(user, "last_name", "") or ""
full_name = f"{first_name} {last_name}".strip()
return {
"id": user.pk,
"email": getattr(user, "email", "") or "",
"first_name": first_name,
"firstname": first_name, # alias for templates using `firstname`
"last_name": last_name,
"lastname": last_name, # alias for templates using `lastname`
"full_name": full_name,
"get_full_name": full_name, # compatibility for templates using method-style access
}
#----------------------------------------------------------------------------------------------------
# This function sends an email to the user for email verification after registration.
@shared_task @shared_task
def send_email_verification_task(user_id): def send_email_verification_task(user_id):
try: try:
user = CustomUser.objects.get(pk=user_id) user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist: except CustomUser.DoesNotExist:
error_msg = f"Task send_email_verification_task has failed. Invalid User ID was sent." logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
logger.error(error_msg) return 0
raise Exception(error_msg)
uid = urlsafe_base64_encode(force_bytes(user.pk)) uid = urlsafe_base64_encode(force_bytes(user.pk))
token = account_activation_token.make_token(user) # {changed} generate and store a per-user token
verification_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}" token = user.generate_email_verification_token()
html_message = render_to_string( verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
'emails/email_verification.html',
{'verification_url': verification_url} context = {
) "user": _build_user_template_ctx(user),
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend': "action_url": verify_url,
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU") "frontend_url": settings.FRONTEND_URL,
"cta_label": "Ověřit email",
}
send_email_with_context( send_email_with_context(
recipients=user.email, recipients=user.email,
subject="Ověření e-mailu", subject="Ověření emailu",
message=None, template_name="email/email_verification.txt",
html_message=html_message html_template_name="email/email_verification.html",
context=context,
) )
def send_email_with_context(recipients, subject, message=None, html_message=None): @shared_task
""" def send_email_test_task(email):
General function to send emails with a specific context. context = {
""" "action_url": settings.FRONTEND_URL,
if isinstance(recipients, str): "frontend_url": settings.FRONTEND_URL,
recipients = [recipients] "cta_label": "Otevřít aplikaci",
}
try: send_email_with_context(
send_mail( recipients=email,
subject=subject, subject="Testovací email",
message=message if message else '', template_name="email/test.txt",
from_email=None, html_template_name="email/test.html",
recipient_list=recipients, context=context,
fail_silently=False,
html_message=html_message
) )
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n", html_message if html_message else message, "\nKONEC OBSAHU")
return True
except Exception as e:
logger.error(f"E-mail se neodeslal: {e}")
return False

View File

@@ -1,128 +0,0 @@
<table style="background-color:#031D44; font-family:'Exo', Arial, sans-serif; width:100%;" align="center" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding:20px;">
<table border="0" cellspacing="20" cellpadding="0" style="max-width:600px; width:100%;" align="center">
<!-- Nadpis -->
<tr>
<td align="center" style="padding:20px; color:#ffffff; font-size:30px; font-weight:bold; border-radius:8px; text-decoration:underline;">
Nabídka tvorby webových stránek
</td>
</tr>
<tr>
<td style="color:#CAF0F8; border-radius:8px; font-size:18px; line-height:1.6;">
<p style="margin:0;">
Jsme <strong>malý tým</strong>, který se snaží prorazit a přinášet moderní řešení za férové ceny.
Nabízíme také <strong>levný hosting</strong> a <strong>SSL zabezpečení zdarma</strong>.
</p>
<p style="margin:10px 0 0;">
Dbáme na <strong>bezpečnost</strong>, používáme <strong>moderní frameworky</strong>
a rozhodně nejsme součástí „gerontosaurů“ <strong>PHP nepoužíváme</strong>.
</p>
</td>
</tr>
<!-- Balíčky Nadpis -->
<tr>
<td align="center" style="padding-top:30px; color:#ffffff; font-size:28px; font-weight:bold; text-decoration:underline;">
Balíčky
</td>
</tr>
<!-- Balíčky (jednotlivé) -->
<tr>
<td style="padding:20px; background:#3a8bb7; color:#CAF0F8; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
<h2 style="margin:0; color:#CAF0F8;">BASIC</h2>
<ul style="padding-left:20px; margin:10px 0;">
<li>Jednoduchá prezentační webová stránka</li>
<li>Moderní a responzivní design (PC, tablety, mobily)</li>
<li>Max. počet stránek: 5</li>
<li>Seřízení vlastní domény, a k tomu<span style="text-decoration: underline;">SSL certifikát zdarma</span></li>
</ul>
<p style="font-size:16px; background-color:#24719f; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
Cena: 5 000 Kč (jednorázově) + 100 Kč / měsíc
</p>
</td>
</tr>
<tr>
<td style="padding:20px; background:#70A288; color:#ffffff; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
<h2 style="margin:0; color:#ffffff;">STANDARD</h2>
<ul style="padding-left:20px; margin:10px 0;">
<li>Vše z balíčku BASIC</li>
<li>Kontaktní formulář (přijde vám poptávka na e-mail)</li>
<li>Priorita při vývoji (cca 2 týdny)</li>
<li>Základní SEO</li>
<li>Max. počet stránek: 10</li>
</ul>
<p style="font-size:16px; background-color:#508845; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
Cena: 7 500 Kč (jednorázově) + 250 Kč / měsíc
</p>
</td>
</tr>
<tr>
<td style="padding:20px; background:#87a9da; color:#031D44; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
<h2 style="margin:0; color:#031D44;">PREMIUM</h2>
<ul style="padding-left:20px; margin:10px 0;">
<li>Vše z balíčku STANDARD</li>
<li>Vaše firma na Google Maps díky plně nastavenému Google Business Profile</li>
<li>Pokročilé SEO</li>
<li>Měsíční report návštěvnosti</li>
<li>Možnost drobných úprav</li>
<li>Neomezený počet stránek</li>
</ul>
<p style="font-size:16px; background-color:#4c7bbd; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
Cena: od 9 500 Kč (jednorázově) + 400 Kč / měsíc
</p>
</td>
</tr>
<tr>
<td style="padding:20px; background:#04395E; color:#CAF0F8; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
<h2 style="margin:0; color:#CAF0F8;">CUSTOM</h2>
<ul style="padding-left:20px; margin:10px 0;">
<li>Kompletně na míru</li>
<li>Možnost e-shopu a rezervačních systémů</li>
<li>Integrace API a platební brány</li>
<li>Pokročilé SEO a marketing</li>
</ul>
<p style="font-size:16px; background-color:#216085; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
Cena: dohodou
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Footer -->
<table style="width:100%; background-color:#031D44; font-family:'Exo', Arial, sans-serif;" align="center" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding:20px; color:#CAF0F8;">
<p style="margin:0; font-size:20px; font-weight:bold;">Máte zájem o některý z balíčků?</p>
<p>Stačí odpovědět na tento e-mail nebo mě kontaktovat:</p>
<p>
<a href="mailto:brunovontor@gmail.com" style="color:#CAF0F8; text-decoration:underline;">brunovontor@gmail.com</a><br>
<a href="tel:+420605512624" style="color:#CAF0F8; text-decoration:underline;">+420 605 512 624</a><br>
<a href="https://vontor.cz" style="color:#CAF0F8; text-decoration:underline;">vontor.cz</a>
</p>
</td>
</tr>
</table>
<!-- Responsivní CSS -->
<style>
@media only screen and (max-width: 600px) {
table[class="responsive-table"] {
width: 100% !important;
}
td {
font-size: 16px !important;
padding: 10px !important;
}
h2 {
font-size: 20px !important;
}
}
</style>

View File

@@ -1,19 +1,46 @@
<!DOCTYPE html> <!doctype html>
<html lang="cs"> <html lang="cs">
<head> <body style="margin:0; padding:0; background-color:#f5f7fb;">
<meta charset="UTF-8"> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<title>Ověření e-mailu</title> <tr>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <td align="center" style="padding:24px;">
</head> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<body> <tr>
<div class="container mt-5"> <td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
<div class="card"> Ověření emailu
<div class="card-body"> </td>
<h2 class="card-title">Ověření e-mailu</h2> </tr>
<p class="card-text">Ověřte svůj e-mail kliknutím na odkaz níže:</p> <tr>
<a href="{{ verification_url }}" class="btn btn-success">Ověřit e-mail</a> <td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
</div> {% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
</div> <p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
</div> {% endwith %}
</body> <p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou emailovou adresu kliknutím na tlačítko níže.</p>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html> </html>

View File

@@ -0,0 +1,7 @@
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
Děkujeme za registraci. Prosíme, ověřte svou emailovou adresu kliknutím na následující odkaz:
{{ action_url }}
Pokud jste účet nevytvořili vy, tento email ignorujte.

View File

@@ -1,19 +1,46 @@
<!DOCTYPE html> <!doctype html>
<html lang="cs"> <html lang="cs">
<head> <body style="margin:0; padding:0; background-color:#f5f7fb;">
<meta charset="UTF-8"> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<title>Obnova hesla</title> <tr>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <td align="center" style="padding:24px;">
</head> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<body> <tr>
<div class="container mt-5"> <td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
<div class="card"> Obnova hesla
<div class="card-body"> </td>
<h2 class="card-title">Obnova hesla</h2> </tr>
<p class="card-text">Pro obnovu hesla klikněte na následující odkaz:</p> <tr>
<a href="{{ reset_url }}" class="btn btn-primary">Obnovit heslo</a> <td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
</div> {% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
</div> <p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
</div> {% endwith %}
</body> <p style="margin:0 0 12px 0;">Obdrželi jste tento email, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento email ignorujte.</p>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html> </html>

View File

@@ -0,0 +1,7 @@
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
Obdrželi jste tento email, protože byla požádána obnova hesla k vašemu účtu.
Pokud jste o změnu nepožádali, tento email ignorujte.
Pro nastavení nového hesla použijte tento odkaz:
{{ action_url }}

View File

@@ -0,0 +1,44 @@
<!doctype html>
<html lang="cs">
<body style="margin:0; padding:0; background-color:#f5f7fb;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<tr>
<td align="center" style="padding:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<tr>
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
Testovací email
</td>
</tr>
<tr>
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
<p style="margin:0 0 12px 0;">Dobrý den,</p>
<p style="margin:0 0 16px 0;">Toto je testovací email z aplikace etržnice.</p>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,6 @@
Dobrý den,
Toto je testovací email z aplikace etržnice.
Odkaz na aplikaci:
{{ action_url }}

59
backend/env Normal file
View File

@@ -0,0 +1,59 @@
# ------------------ NGINX ------------------
FRONTEND_URL=http://192.168.67.98
#FRONTEND_URL=http://localhost:5173
# ------------------ CORE ------------------
DEBUG=True
SSL=False
DJANGO_SECRET_KEY=CHANGE_ME_SECURE_RANDOM_KEY
# ------------------ DATABASE (Postgres in Docker) ------------------
USE_DOCKER_DB=True
DATABASE_ENGINE=django.db.backends.postgresql
DATABASE_HOST=db
DATABASE_PORT=5432
POSTGRES_DB=djangoDB
POSTGRES_USER=dockerDBuser
POSTGRES_PASSWORD=AWSJeMocDrahaZalezitost
# Legacy/unused (was: USE_PRODUCTION_DB) removed
# ------------------ MEDIA / STATIC ------------------
#MEDIA_URL=http://192.168.67.98/media/
# ------------------ REDIS / CACHING / CHANNELS ------------------
# Was REDIS=... (not used). Docker expects REDIS_PASSWORD.
REDIS_PASSWORD=passwd
# ------------------ CELERY ------------------
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
CELERY_ACCEPT_CONTENT=json
CELERY_TASK_SERIALIZER=json
CELERY_TIMEZONE=Europe/Prague
CELERY_BEAT_SCHEDULER=django_celery_beat.schedulers:DatabaseScheduler
# ------------------ EMAIL (dev/prod logic in settings) ------------------
EMAIL_HOST_DEV=kerio4.vitkovice.cz
EMAIL_PORT_DEV=465
EMAIL_USER_DEV=Test.django@vitkovice.cz
EMAIL_USER_PASSWORD_DEV=PRneAP0819b
# DEFAULT_FROM_EMAIL_DEV unused in settings; kept for reference
DEFAULT_FROM_EMAIL_DEV=Test.django@vitkovice.cz
# ------------------ AWS (disabled unless USE_AWS=True) ------------------
USE_AWS=False
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_STORAGE_BUCKET_NAME=
AWS_S3_REGION_NAME=eu-central-1
# ------------------ JWT / TOKENS (lifetimes defined in code) ------------------
# (No env vars needed; kept placeholder section)
# ------------------ MISC ------------------
# FRONTEND_URL_DEV not used; rely on FRONTEND_URL
# Add any extra custom vars below

View File

@@ -1,9 +1,10 @@
from django.contrib import admin from django.contrib import admin
from .models import DownloaderModel from .models import DownloaderRecord
@admin.register(DownloaderModel) @admin.register(DownloaderRecord)
class DownloaderModelAdmin(admin.ModelAdmin): class DownloaderRecordAdmin(admin.ModelAdmin):
list_display = ("id", "status", "ext", "requested_format", "vcodec", "acodec", "is_audio_only", "download_time") list_display = ("id", "url", "format", "length_of_media", "file_size", "download_time")
list_filter = ("status", "ext", "vcodec", "acodec", "is_audio_only", "extractor") list_filter = ("format",)
search_fields = ("title", "video_id", "url") search_fields = ("url",)
ordering = ("-download_time",)
readonly_fields = ("download_time",) readonly_fields = ("download_time",)

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2025-10-29 14:53
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DownloaderRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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)),
('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')),
],
options={
'abstract': False,
},
),
]

View File

View File

@@ -1,92 +1,15 @@
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from vontor_cz.models import SoftDeleteModel
# 7áznamy pro donwloader, co lidé nejvíc stahujou a v jakém formátu # 7áznamy pro donwloader, co lidé nejvíc stahujou a v jakém formátu
class DownloaderModel(models.Model): class DownloaderRecord(SoftDeleteModel):
url = models.URLField() url = models.URLField()
download_time = models.DateTimeField(auto_now_add=True) download_time = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=50)
# yt-dlp metadata (flattened for stats) format = models.CharField(max_length=50)
requested_format = models.CharField(max_length=100, blank=True, null=True, db_index=True)
format_id = models.CharField(max_length=50, blank=True, null=True, db_index=True)
ext = models.CharField(max_length=20, blank=True, null=True, db_index=True)
vcodec = models.CharField(max_length=50, blank=True, null=True, db_index=True)
acodec = models.CharField(max_length=50, blank=True, null=True, db_index=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) # audio bitrate
vbr = models.FloatField(blank=True, null=True) # video bitrate
tbr = models.FloatField(blank=True, null=True) # total bitrate
asr = models.IntegerField(blank=True, null=True) # audio sample rate
filesize = models.BigIntegerField(blank=True, null=True)
duration = models.FloatField(blank=True, null=True)
title = models.CharField(max_length=512, blank=True, null=True)
extractor = models.CharField(max_length=100, blank=True, null=True, db_index=True)
extractor_key = models.CharField(max_length=100, blank=True, null=True)
video_id = models.CharField(max_length=128, blank=True, null=True, db_index=True)
webpage_url = models.URLField(blank=True, null=True)
is_audio_only = models.BooleanField(default=False, db_index=True)
# client/context length_of_media = models.IntegerField(help_text="Length of media in seconds")
user = models.ForeignKey(getattr(settings, "AUTH_USER_MODEL", "auth.User"), on_delete=models.SET_NULL, null=True, blank=True) file_size = models.BigIntegerField(help_text="File size in bytes")
ip_address = models.GenericIPAddressField(blank=True, null=True)
user_agent = models.TextField(blank=True, null=True)
# diagnostics
error_message = models.TextField(blank=True, null=True)
# full raw yt-dlp info for future analysis
raw_info = models.JSONField(blank=True, null=True)
class Meta:
indexes = [
models.Index(fields=["download_time"]),
models.Index(fields=["ext", "is_audio_only"]),
models.Index(fields=["requested_format"]),
models.Index(fields=["extractor"]),
]
def __str__(self):
return f"DownloaderModel {self.id} - {self.status} at {self.download_time.strftime('%d-%m-%Y %H:%M:%S')}"
@classmethod
def from_ydl_info(cls, *, info: dict, requested_format: str | None = None, status: str = "success",
url: str | None = None, user=None, ip_address: str | None = None,
user_agent: str | None = None, error_message: str | None = None):
# Safe getters
def g(k, default=None):
return info.get(k, default)
instance = cls(
url=url or g("webpage_url") or g("original_url"),
status=status,
requested_format=requested_format,
format_id=g("format_id"),
ext=g("ext"),
vcodec=g("vcodec"),
acodec=g("acodec"),
width=g("width") if isinstance(g("width"), int) else None,
height=g("height") if isinstance(g("height"), int) else None,
fps=g("fps"),
abr=g("abr"),
vbr=g("vbr"),
tbr=g("tbr"),
asr=g("asr"),
filesize=g("filesize") or g("filesize_approx"),
duration=g("duration"),
title=g("title"),
extractor=g("extractor"),
extractor_key=g("extractor_key"),
video_id=g("id"),
webpage_url=g("webpage_url"),
is_audio_only=(g("vcodec") in (None, "none")),
user=user if getattr(user, "is_authenticated", False) else None,
ip_address=ip_address,
user_agent=user_agent,
error_message=error_message,
raw_info=info,
)
instance.save()
return instance

View File

@@ -1,69 +1,9 @@
from rest_framework import serializers from rest_framework import serializers
from .models import DownloaderModel
class DownloaderLogSerializer(serializers.ModelSerializer): class DownloaderStatsSerializer(serializers.Serializer):
# Optional raw yt-dlp info dict total_downloads = serializers.IntegerField()
info = serializers.DictField(required=False) avg_length_of_media = serializers.FloatField(allow_null=True)
avg_file_size = serializers.FloatField(allow_null=True)
class Meta: total_length_of_media = serializers.IntegerField(allow_null=True)
model = DownloaderModel total_file_size = serializers.IntegerField(allow_null=True)
fields = ( most_common_format = serializers.CharField(allow_null=True)
"id",
"url",
"status",
"requested_format",
"format_id",
"ext",
"vcodec",
"acodec",
"width",
"height",
"fps",
"abr",
"vbr",
"tbr",
"asr",
"filesize",
"duration",
"title",
"extractor",
"extractor_key",
"video_id",
"webpage_url",
"is_audio_only",
"error_message",
"raw_info",
"info", # virtual input
)
read_only_fields = ("id", "raw_info")
def create(self, validated_data):
info = validated_data.pop("info", None)
request = self.context.get("request")
user = getattr(request, "user", None) if request else None
ip_address = None
user_agent = None
if request:
xff = request.META.get("HTTP_X_FORWARDED_FOR")
ip_address = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR"))
user_agent = request.META.get("HTTP_USER_AGENT")
if info:
return DownloaderModel.from_ydl_info(
info=info,
requested_format=validated_data.get("requested_format"),
status=validated_data.get("status", "success"),
url=validated_data.get("url"),
user=user,
ip_address=ip_address,
user_agent=user_agent,
error_message=validated_data.get("error_message"),
)
# Fallback: create from flattened fields only
return DownloaderModel.objects.create(
user=user,
ip_address=ip_address,
user_agent=user_agent,
raw_info=None,
**validated_data
)

View File

@@ -1,13 +1,9 @@
from django.urls import path from django.urls import path
from .views import DownloaderFormatsView, DownloaderFileView, DownloaderStatsView from .views import Downloader, DownloaderStats
urlpatterns = [ urlpatterns = [
# Probe formats for a URL (size-checked) # Probe formats for a URL (size-checked)
path("formats/", DownloaderFormatsView.as_view(), name="downloader-formats"), path("download/", Downloader.as_view(), name="downloader-download"),
# Download selected format (enforces size limit) path("stats/", DownloaderStats.as_view(), name="downloader-stats"),
path("download/", DownloaderFileView.as_view(), name="downloader-download"),
# Aggregated statistics
path("stats/", DownloaderStatsView.as_view(), name="downloader-stats"),
] ]

View File

@@ -1,553 +1,305 @@
from django.shortcuts import render
from django.db.models import Count
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework import status
from django.conf import settings
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
from drf_spectacular.utils import (
extend_schema,
OpenApiExample,
OpenApiParameter,
OpenApiTypes,
OpenApiResponse,
inline_serializer,
)
import os
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
# ---------------------- Inline serializers for documentation only ---------------------- # ---------------------- Inline serializers for documentation only ----------------------
# Using inline_serializer to avoid creating new files. # Using inline_serializer to avoid creating new files.
FormatOptionSchema = inline_serializer( import yt_dlp
name="FormatOption", import tempfile
fields={ import os
"format_id": serializers.CharField(allow_null=True), import shutil
"ext": serializers.CharField(allow_null=True),
"vcodec": serializers.CharField(allow_null=True), from rest_framework import serializers
"acodec": serializers.CharField(allow_null=True), from rest_framework.views import APIView
"fps": serializers.FloatField(allow_null=True), from rest_framework.response import Response
"tbr": serializers.FloatField(allow_null=True), from rest_framework.permissions import IsAuthenticated, AllowAny
"abr": serializers.FloatField(allow_null=True), from drf_spectacular.utils import extend_schema, inline_serializer
"vbr": serializers.FloatField(allow_null=True), from drf_spectacular.types import OpenApiTypes
"asr": serializers.IntegerField(allow_null=True), from django.conf import settings
"filesize": serializers.IntegerField(allow_null=True), from django.http import StreamingHttpResponse
"filesize_approx": serializers.IntegerField(allow_null=True), from django.utils.text import slugify
"estimated_size_bytes": serializers.IntegerField(allow_null=True), # NEW: aggregations and timeseries helpers
"size_ok": serializers.BooleanField(), from django.db import models
"format_note": serializers.CharField(allow_null=True), from django.utils import timezone
"resolution": serializers.CharField(allow_null=True), from django.db.models.functions import TruncDay, TruncHour
"audio_only": serializers.BooleanField(), 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)."
) )
FormatsRequestSchema = inline_serializer( # Minimal mime map by extension
name="FormatsRequest", MIME_BY_EXT = {
fields={"url": serializers.URLField()}, "mp4": "video/mp4",
) "mkv": "video/x-matroska",
"webm": "video/webm",
"flv": "video/x-flv",
"mov": "video/quicktime",
"avi": "video/x-msvideo",
"ogg": "video/ogg",
}
FormatsResponseSchema = inline_serializer( class Downloader(APIView):
name="FormatsResponse",
fields={
"title": serializers.CharField(allow_null=True),
"duration": serializers.FloatField(allow_null=True),
"extractor": serializers.CharField(allow_null=True),
"video_id": serializers.CharField(allow_null=True),
"max_size_bytes": serializers.IntegerField(),
"options": serializers.ListField(child=FormatOptionSchema),
},
)
DownloadRequestSchema = inline_serializer(
name="DownloadRequest",
fields={
"url": serializers.URLField(),
"format_id": serializers.CharField(),
},
)
ErrorResponseSchema = inline_serializer(
name="ErrorResponse",
fields={
"detail": serializers.CharField(),
"error": serializers.CharField(required=False),
"estimated_size_bytes": serializers.IntegerField(required=False),
"max_bytes": serializers.IntegerField(required=False),
},
)
# ---------------------------------------------------------------------------------------
def _estimate_size_bytes(fmt: Dict[str, Any], duration: Optional[float]) -> Optional[int]:
"""Estimate (or return exact) size in bytes for a yt-dlp format."""
# Prefer exact sizes from yt-dlp
if fmt.get("filesize"):
return int(fmt["filesize"])
if fmt.get("filesize_approx"):
return int(fmt["filesize_approx"])
# Estimate via total bitrate (tbr is in Kbps)
if duration and fmt.get("tbr"):
try:
kbps = float(fmt["tbr"])
return int((kbps * 1000 / 8) * float(duration))
except Exception:
return None
return None
def _format_option(fmt: Dict[str, Any], duration: Optional[float], max_bytes: int) -> Dict[str, Any]:
"""Project yt-dlp format dict to a compact option object suitable for UI."""
est = _estimate_size_bytes(fmt, duration)
w = fmt.get("width")
h = fmt.get("height")
resolution = f"{w}x{h}" if w and h else None
return {
"format_id": fmt.get("format_id"),
"ext": fmt.get("ext"),
"vcodec": fmt.get("vcodec"),
"acodec": fmt.get("acodec"),
"fps": fmt.get("fps"),
"tbr": fmt.get("tbr"),
"abr": fmt.get("abr"),
"vbr": fmt.get("vbr"),
"asr": fmt.get("asr"),
"filesize": fmt.get("filesize"),
"filesize_approx": fmt.get("filesize_approx"),
"estimated_size_bytes": est,
"size_ok": (est is not None and est <= max_bytes),
"format_note": fmt.get("format_note"),
"resolution": resolution,
"audio_only": (fmt.get("vcodec") in (None, "none")),
}
def _client_meta(request) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
"""Extract current user, client IP and User-Agent."""
xff = request.META.get("HTTP_X_FORWARDED_FOR")
ip = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR"))
ua = request.META.get("HTTP_USER_AGENT")
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] permission_classes = [AllowAny]
authentication_classes = [] authentication_classes = []
@extend_schema( @extend_schema(
tags=["downloader"], tags=["downloader"],
operation_id="downloader_formats", summary="Get video info from URL",
summary="List available formats for a media URL", parameters=[
description="Uses yt-dlp to extract formats and estimates size. Applies max size policy.", inline_serializer(
request=FormatsRequestSchema, name="VideoInfoParams",
responses={ fields={
200: FormatsResponseSchema, "url": serializers.URLField(help_text="Video URL to analyze"),
400: OpenApiResponse(response=ErrorResponseSchema),
500: OpenApiResponse(response=ErrorResponseSchema),
}, },
examples=[ )
OpenApiExample(
"Formats request",
value={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
request_only=True,
),
OpenApiExample(
"Formats response (excerpt)",
value={
"title": "Example Title",
"duration": 213.0,
"extractor": "youtube",
"video_id": "dQw4w9WgXcQ",
"max_size_bytes": 209715200,
"options": [
{
"format_id": "140",
"ext": "m4a",
"vcodec": "none",
"acodec": "mp4a.40.2",
"fps": None,
"tbr": 128.0,
"abr": 128.0,
"vbr": None,
"asr": 44100,
"filesize": None,
"filesize_approx": 3342334,
"estimated_size_bytes": 3400000,
"size_ok": True,
"format_note": "tiny",
"resolution": None,
"audio_only": True,
}
], ],
},
response_only=True,
),
],
)
def post(self, request):
"""POST to probe a media URL and list available formats."""
try:
import yt_dlp
except Exception:
return Response({"detail": "yt-dlp not installed. pip install yt-dlp"}, status=500)
url = request.data.get("url")
if not url:
return Response({"detail": "Missing 'url'."}, status=400)
max_bytes = getattr(settings, "DOWNLOADER_MAX_SIZE_BYTES", 200 * 1024 * 1024)
ydl_opts = {
"skip_download": True,
"quiet": True,
"no_warnings": True,
"noprogress": True,
"ignoreerrors": True,
"socket_timeout": getattr(settings, "DOWNLOADER_TIMEOUT", 120),
"extract_flat": False,
"allow_playlist": False,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
except Exception as e:
# log probe error
user, ip, ua = _client_meta(request)
_log_safely(
info={"webpage_url": url},
requested_format=None,
status="probe_error",
url=url,
user=user,
ip_address=ip,
user_agent=ua,
error_message=str(e),
)
return Response({"detail": "Failed to extract formats", "error": str(e)}, status=400)
duration = info.get("duration")
formats = info.get("formats") or []
options: List[Dict[str, Any]] = []
for f in formats:
options.append(_format_option(f, duration, max_bytes))
# optional: sort by size then by resolution desc
def sort_key(o):
size = o["estimated_size_bytes"] if o["estimated_size_bytes"] is not None else math.inf
res = 0
if o["resolution"]:
try:
w, h = o["resolution"].split("x")
res = int(w) * int(h)
except Exception:
res = 0
return (size, -res)
options_sorted = sorted(options, key=sort_key)[:50]
# Log probe
user, ip, ua = _client_meta(request)
_log_safely(
info=info,
requested_format=None,
status="probe_ok",
url=url,
user=user,
ip_address=ip,
user_agent=ua,
)
return Response({
"title": info.get("title"),
"duration": duration,
"extractor": info.get("extractor"),
"video_id": info.get("id"),
"max_size_bytes": max_bytes,
"options": options_sorted,
})
class DownloaderFileView(APIView):
"""Download selected format if under max size, then stream the file back."""
permission_classes = [AllowAny]
authentication_classes = []
@extend_schema(
tags=["downloader"],
operation_id="downloader_download",
summary="Download a selected format and stream file",
description="Downloads with a strict max filesize guard and streams as application/octet-stream.",
request=DownloadRequestSchema,
responses={
200: OpenApiTypes.BINARY, # was OpenApiResponse(..., media_type="application/octet-stream")
400: OpenApiResponse(response=ErrorResponseSchema),
413: OpenApiResponse(response=ErrorResponseSchema),
500: OpenApiResponse(response=ErrorResponseSchema),
},
examples=[
OpenApiExample(
"Download request",
value={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "format_id": "140"},
request_only=True,
),
],
)
def post(self, request):
"""POST to download a media URL in the selected format."""
try:
import yt_dlp
except Exception:
return Response({"detail": "yt-dlp not installed. pip install yt-dlp"}, status=500)
url = request.data.get("url")
fmt_id = request.data.get("format_id")
if not url or not fmt_id:
return Response({"detail": "Missing 'url' or 'format_id'."}, status=400)
max_bytes = getattr(settings, "DOWNLOADER_MAX_SIZE_BYTES", 200 * 1024 * 1024)
timeout = getattr(settings, "DOWNLOADER_TIMEOUT", 120)
tmp_dir = getattr(settings, "DOWNLOADER_TMP_DIR", os.path.join(settings.BASE_DIR, "tmp", "downloader"))
os.makedirs(tmp_dir, exist_ok=True)
# First, extract info to check/estimate size
probe_opts = {
"skip_download": True,
"quiet": True,
"no_warnings": True,
"noprogress": True,
"ignoreerrors": True,
"socket_timeout": timeout,
"extract_flat": False,
"allow_playlist": False,
}
try:
with yt_dlp.YoutubeDL(probe_opts) as ydl:
info = ydl.extract_info(url, download=False)
except Exception as e:
user, ip, ua = _client_meta(request)
_log_safely(
info={"webpage_url": url},
requested_format=fmt_id,
status="precheck_error",
url=url,
user=user,
ip_address=ip,
user_agent=ua,
error_message=str(e),
)
return Response({"detail": "Failed to analyze media", "error": str(e)}, status=400)
duration = info.get("duration")
selected = None
for f in (info.get("formats") or []):
if str(f.get("format_id")) == str(fmt_id):
selected = f
break
if not selected:
return Response({"detail": f"format_id '{fmt_id}' not found"}, status=400)
# Enforce size policy
est_size = _estimate_size_bytes(selected, duration)
if est_size is not None and est_size > max_bytes:
user, ip, ua = _client_meta(request)
_log_safely(
info=selected,
requested_format=fmt_id,
status="blocked_by_size",
url=url,
user=user,
ip_address=ip,
user_agent=ua,
error_message=f"Estimated size {est_size} > max {max_bytes}",
)
return Response(
{"detail": "File too large for this server", "estimated_size_bytes": est_size, "max_bytes": max_bytes},
status=413,
)
# Now download with strict max_filesize guard
ydl_opts = {
"format": str(fmt_id),
"quiet": True,
"no_warnings": True,
"noprogress": True,
"socket_timeout": timeout,
"retries": 3,
"outtmpl": os.path.join(tmp_dir, "%(id)s.%(ext)s"),
"max_filesize": max_bytes, # hard cap during download
"concurrent_fragment_downloads": 1,
"http_chunk_size": 1024 * 1024, # 1MB chunks to reduce memory
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
# Will raise if max_filesize exceeded during transfer
result = ydl.extract_info(url, download=True)
# yt-dlp returns result for entries or single; get final info
if "requested_downloads" in result and result["requested_downloads"]:
rd = result["requested_downloads"][0]
filepath = rd.get("filepath") or rd.get("__final_filename")
else:
# fallback
filepath = result.get("requested_downloads", [{}])[0].get("filepath") or result.get("_filename")
except Exception as e:
user, ip, ua = _client_meta(request)
_log_safely(
info=selected,
requested_format=fmt_id,
status="download_error",
url=url,
user=user,
ip_address=ip,
user_agent=ua,
error_message=str(e),
)
return Response({"detail": "Download failed", "error": str(e)}, status=400)
if not filepath or not os.path.exists(filepath):
return Response({"detail": "Downloaded file not found"}, status=500)
# Build a safe filename
base_title = info.get("title") or "video"
ext = os.path.splitext(filepath)[1].lstrip(".") or (selected.get("ext") or "bin")
safe_name = f"{slugify(base_title)[:80]}.{ext}"
# Log success
user, ip, ua = _client_meta(request)
try:
selected_info = dict(selected)
selected_info["filesize"] = os.path.getsize(filepath)
_log_safely(
info=selected_info,
requested_format=fmt_id,
status="success",
url=url,
user=user,
ip_address=ip,
user_agent=ua,
)
except Exception:
pass
# Stream file and remove after sending
def file_generator(path: str):
with open(path, "rb") as f:
while True:
chunk = f.read(8192)
if not chunk:
break
yield chunk
try:
os.remove(path)
except Exception:
pass
resp = StreamingHttpResponse(file_generator(filepath), content_type="application/octet-stream")
# 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:
pass
resp["X-Content-Type-Options"] = "nosniff"
return resp
# Simple stats view (aggregations for UI charts)
class DownloaderStatsView(APIView):
permission_classes = [AllowAny]
@extend_schema(
tags=["downloader"],
operation_id="downloader_stats",
summary="Aggregated downloader statistics",
description="Returns top extensions, requested formats, codecs and audio/video split.",
responses={ responses={
200: inline_serializer( 200: inline_serializer(
name="DownloaderStats", name="VideoInfoResponse",
fields={ fields={
"top_ext": serializers.ListField( "title": serializers.CharField(),
child=inline_serializer(name="ExtCount", fields={ "duration": serializers.IntegerField(allow_null=True),
"ext": serializers.CharField(allow_null=True), "thumbnail": serializers.URLField(allow_null=True),
"count": serializers.IntegerField(), "video_resolutions": serializers.ListField(child=serializers.CharField()),
}) "audio_resolutions": serializers.ListField(child=serializers.CharField()),
),
"top_requested_format": serializers.ListField(
child=inline_serializer(name="RequestedFormatCount", fields={
"requested_format": serializers.CharField(allow_null=True),
"count": serializers.IntegerField(),
})
),
"top_vcodec": serializers.ListField(
child=inline_serializer(name="VCodecCount", fields={
"vcodec": serializers.CharField(allow_null=True),
"count": serializers.IntegerField(),
})
),
"top_acodec": serializers.ListField(
child=inline_serializer(name="ACodecCount", fields={
"acodec": serializers.CharField(allow_null=True),
"count": serializers.IntegerField(),
})
),
"audio_vs_video": serializers.ListField(
child=inline_serializer(name="AudioVsVideo", fields={
"is_audio_only": serializers.BooleanField(),
"count": serializers.IntegerField(),
})
),
}, },
) ),
400: inline_serializer(
name="ErrorResponse",
fields={"error": serializers.CharField()},
),
}, },
) )
def get(self, request): def get(self, request):
"""GET to retrieve aggregated downloader statistics.""" url = request.data.get("url") or request.query_params.get("url")
top_ext = list(DownloaderModel.objects.values("ext").annotate(count=Count("id")).order_by("-count")[:10]) if not url:
top_formats = list(DownloaderModel.objects.values("requested_format").annotate(count=Count("id")).order_by("-count")[:10]) return Response({"error": "URL is required"}, status=400)
top_vcodec = list(DownloaderModel.objects.values("vcodec").annotate(count=Count("id")).order_by("-count")[:10])
top_acodec = list(DownloaderModel.objects.values("acodec").annotate(count=Count("id")).order_by("-count")[:10]) ydl_options = {
audio_vs_video = list(DownloaderModel.objects.values("is_audio_only").annotate(count=Count("id")).order_by("-count")) "quiet": True,
return Response({ }
"top_ext": top_ext, try:
"top_requested_format": top_formats, with yt_dlp.YoutubeDL(ydl_options) as ydl:
"top_vcodec": top_vcodec, info = ydl.extract_info(url, download=False)
"top_acodec": top_acodec, except Exception:
"audio_vs_video": audio_vs_video, 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"],
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"],
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)

View File

@@ -5,6 +5,7 @@ from django.http import HttpResponse
from rest_framework import generics from rest_framework import generics
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from .models import Order from .models import Order
from .serializers import OrderSerializer from .serializers import OrderSerializer
@@ -14,6 +15,9 @@ import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY") stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
class CreateCheckoutSessionView(APIView): class CreateCheckoutSessionView(APIView):
@extend_schema(
tags=["stripe"],
)
def post(self, request): def post(self, request):
serializer = OrderSerializer(data=request.data) #obecný serializer serializer = OrderSerializer(data=request.data) #obecný serializer
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)

View File

@@ -12,6 +12,7 @@ class Trading212AccountCashView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@extend_schema( @extend_schema(
tags=["trading212"],
summary="Get Trading212 account cash", summary="Get Trading212 account cash",
responses=Trading212AccountCashSerializer responses=Trading212AccountCashSerializer
) )

View File

@@ -17,18 +17,23 @@ from django.core.management.utils import get_random_secret_key
from django.db import OperationalError, connections from django.db import OperationalError, connections
from datetime import timedelta from datetime import timedelta
import json
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné
# Robust boolean parser and SSL flag
def _env_bool(key: str, default: bool = False) -> bool:
return os.getenv(key, str(default)).strip().lower() in ("true", "1", "yes", "on")
USE_SSL = _env_bool("SSL", False)
#---------------- ENV VARIABLES USECASE-------------- #---------------- ENV VARIABLES USECASE--------------
# v jiné app si to importneš skrz: from django.conf import settings # v jiné app si to importneš skrz: from django.conf import settings
# a použiješ takto: settings.FRONTEND_URL # a použiješ takto: settings.FRONTEND_URL
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:9000")
FRONTEND_URL_DEV = os.getenv("FRONTEND_URL_DEV", "http://localhost:5173") print(f"FRONTEND_URL: {FRONTEND_URL}\n")
print(f"FRONTEND_URL: {FRONTEND_URL}\nFRONTEND_URL_DEV: {FRONTEND_URL_DEV}\n")
#-------------------------BASE ⚙️------------------------ #-------------------------BASE ⚙️------------------------
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -153,43 +158,81 @@ AUTHENTICATION_BACKENDS = [
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
from urllib.parse import urlparse
parsed = urlparse(FRONTEND_URL)
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
'https://domena.cz', f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
"https://www.domena.cz",
"http://localhost:3000", #react docker "http://192.168.67.98",
"http://localhost:5173" #react dev "https://itsolutions.vontor.cz",
"https://react.vontor.cz",
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:9000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:9000",
# server
"http://192.168.67.98",
"https://itsolutions.vontor.cz",
"https://react.vontor.cz",
# nginx docker (local)
"http://localhost",
"http://localhost:80",
"http://127.0.0.1",
] ]
if DEBUG: if DEBUG:
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
"http://localhost:5173", "http://localhost:5173",
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"http://localhost:9000",
"http://127.0.0.1:9000",
# server
"http://192.168.67.98",
"https://itsolutions.vontor.cz",
"https://react.vontor.cz",
# nginx docker (local)
"http://localhost",
"http://localhost:80",
"http://127.0.0.1",
] ]
else: else:
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
"https://www.domena.cz", "http://192.168.67.98",
"https://itsolutions.vontor.cz",
"https://react.vontor.cz",
"http://localhost:9000",
"http://127.0.0.1:9000",
] ]
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials
# Use Lax for http (local), None only when HTTPS is enabled
SESSION_COOKIE_SAMESITE = "None" if USE_SSL else "Lax"
CSRF_COOKIE_SAMESITE = "None" if USE_SSL else "Lax"
print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS) print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS)
print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS) print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS)
print("ALLOWED_HOSTS =", ALLOWED_HOSTS) print("ALLOWED_HOSTS =", ALLOWED_HOSTS)
#--------------------------------END CORS + HOSTs 🌐🔐--------------------------------- #--------------------------------END CORS + HOSTs 🌐🔐---------------------------------
#--------------------------------------SSL 🧾------------------------------------ #--------------------------------------SSL 🧾------------------------------------
if os.getenv("SSL", "") == "True":
USE_SSL = True
else:
USE_SSL = False
if USE_SSL is True: if USE_SSL is True:
print("SSL turned on!") print("SSL turned on!")
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
@@ -197,7 +240,6 @@ if USE_SSL is True:
SECURE_SSL_REDIRECT = True SECURE_SSL_REDIRECT = True
SECURE_BROWSER_XSS_FILTER = True SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
else: else:
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False
@@ -205,10 +247,7 @@ else:
SECURE_SSL_REDIRECT = False SECURE_SSL_REDIRECT = False
SECURE_BROWSER_XSS_FILTER = False SECURE_BROWSER_XSS_FILTER = False
SECURE_CONTENT_TYPE_NOSNIFF = False SECURE_CONTENT_TYPE_NOSNIFF = False
USE_X_FORWARDED_HOST = False
print(f"\nUsing SSL: {USE_SSL}\n") print(f"\nUsing SSL: {USE_SSL}\n")
#--------------------------------END-SSL 🧾--------------------------------- #--------------------------------END-SSL 🧾---------------------------------
@@ -218,8 +257,8 @@ print(f"\nUsing SSL: {USE_SSL}\n")
#-------------------------------------REST FRAMEWORK 🛠️------------------------------------ #-------------------------------------REST FRAMEWORK 🛠️------------------------------------
# ⬇️ Základní lifetime konfigurace # ⬇️ Základní lifetime konfigurace
ACCESS_TOKEN_LIFETIME = timedelta(minutes=15) ACCESS_TOKEN_LIFETIME = timedelta(minutes=60)
REFRESH_TOKEN_LIFETIME = timedelta(days=1) REFRESH_TOKEN_LIFETIME = timedelta(days=5)
# ⬇️ Nastavení SIMPLE_JWT podle režimu # ⬇️ Nastavení SIMPLE_JWT podle režimu
if DEBUG: if DEBUG:
@@ -228,13 +267,16 @@ if DEBUG:
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME, "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
"AUTH_COOKIE": "access_token", "AUTH_COOKIE": "access_token",
"AUTH_COOKIE_SECURE": False, # není HTTPS "AUTH_COOKIE_REFRESH": "refresh_token",
"AUTH_COOKIE_DOMAIN": None,
"AUTH_COOKIE_SECURE": False,
"AUTH_COOKIE_HTTP_ONLY": True, "AUTH_COOKIE_HTTP_ONLY": True,
"AUTH_COOKIE_PATH": "/", "AUTH_COOKIE_PATH": "/",
"AUTH_COOKIE_SAMESITE": "Lax", # není cross-site "AUTH_COOKIE_SAMESITE": "Lax",
"ROTATE_REFRESH_TOKENS": True, "ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": True, "BLACKLIST_AFTER_ROTATION": False,
} }
else: else:
SIMPLE_JWT = { SIMPLE_JWT = {
@@ -242,27 +284,33 @@ else:
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME, "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
"AUTH_COOKIE": "access_token", "AUTH_COOKIE": "access_token",
"AUTH_COOKIE_SECURE": True, # HTTPS only "AUTH_COOKIE_REFRESH": "refresh_token",
"AUTH_COOKIE_DOMAIN": None,
# Secure/SameSite based on HTTPS availability
"AUTH_COOKIE_SECURE": USE_SSL,
"AUTH_COOKIE_HTTP_ONLY": True, "AUTH_COOKIE_HTTP_ONLY": True,
"AUTH_COOKIE_PATH": "/", "AUTH_COOKIE_PATH": "/",
"AUTH_COOKIE_SAMESITE": "None", # potřebné pro cross-origin "AUTH_COOKIE_SAMESITE": "None" if USE_SSL else "Lax",
"ROTATE_REFRESH_TOKENS": True, "ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True, "BLACKLIST_AFTER_ROTATION": True,
} }
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel "DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'account.tokens.CookieJWTAuthentication', # In DEBUG keep Session + JWT + your cookie class for convenience
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
'rest_framework_simplejwt.authentication.JWTAuthentication',
'account.tokens.CookieJWTAuthentication',
) if DEBUG else (
'account.tokens.CookieJWTAuthentication',
), ),
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.AllowAny', 'rest_framework.permissions.AllowAny',
), ),
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
'DEFAULT_THROTTLE_RATES': { 'DEFAULT_THROTTLE_RATES': {
@@ -270,7 +318,6 @@ REST_FRAMEWORK = {
'user': '2000/hour', # authenticated 'user': '2000/hour', # authenticated
} }
} }
#--------------------------------END REST FRAMEWORK 🛠️------------------------------------- #--------------------------------END REST FRAMEWORK 🛠️-------------------------------------
@@ -340,23 +387,17 @@ INSTALLED_APPS = INSTALLED_APPS[:-1] + MY_CREATED_APPS + INSTALLED_APPS[-1:]
# Middleware is a framework of hooks into Django's request/response processing. # Middleware is a framework of hooks into Django's request/response processing.
MIDDLEWARE = [ MIDDLEWARE = [
# Middleware that allows your backend to accept requests from other domains (CORS)
"corsheaders.middleware.CorsMiddleware", "corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
#CUSTOM
#'tools.middleware.CustomMaxUploadSizeMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',# díky tomu funguje načítaní static files
] ]
#--------------------------------END MIDDLEWARE 🧩--------------------------------- #--------------------------------END MIDDLEWARE 🧩---------------------------------
@@ -410,56 +451,42 @@ else:
#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️--------------------------------- #--------------------------------END CACHE + CHANNELS(ws) 📡🗄️---------------------------------
#-------------------------------------CELERY 📅------------------------------------ #-------------------------------------CELERY 📅------------------------------------
# CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL") CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND") CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND")
# Control via env; default False in DEBUG, True otherwise
CELERY_ENABLED = _env_bool("CELERY_ENABLED", default=not DEBUG)
if DEBUG:
CELERY_ENABLED = False
try: try:
import redis import redis
# test connection
r = redis.Redis(host='localhost', port=6379, db=0) r = redis.Redis(host='localhost', port=6379, db=0)
r.ping() r.ping()
except Exception: except Exception:
CELERY_BROKER_URL = 'memory://' CELERY_BROKER_URL = 'memory://'
CELERY_ENABLED = False
CELERY_ACCEPT_CONTENT = os.getenv("CELERY_ACCEPT_CONTENT") def _env_list(key: str, default: list[str]) -> list[str]:
CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER") v = os.getenv(key)
CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE") if not v:
return default
try:
parsed = json.loads(v)
if isinstance(parsed, (list, tuple)):
return list(parsed)
if isinstance(parsed, str):
return [parsed]
except Exception:
pass
return [s.strip(" '\"") for s in v.strip("[]()").split(",") if s.strip()]
CELERY_ACCEPT_CONTENT = _env_list("CELERY_ACCEPT_CONTENT", ["json"])
CELERY_RESULT_ACCEPT_CONTENT = _env_list("CELERY_RESULT_ACCEPT_CONTENT", ["json"])
CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER", "json")
CELERY_RESULT_SERIALIZER = os.getenv("CELERY_RESULT_SERIALIZER", "json")
CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE", TIME_ZONE)
CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER") CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER")
# if DEBUG:
# CELERY_BROKER_URL = 'redis://localhost:6379/0'
# try:
# import redis
# # test connection
# r = redis.Redis(host='localhost', port=6379, db=0)
# r.ping()
# except Exception:
# CELERY_BROKER_URL = 'memory://'
# CELERY_ACCEPT_CONTENT = ['json']
# CELERY_TASK_SERIALIZER = 'json'
# CELERY_TIMEZONE = 'Europe/Prague'
# CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
# from celery.schedules import crontab
# CELERY_BEAT_SCHEDULE = {
# 'hard_delete_soft_deleted_monthly': {
# 'task': 'vontor_cz.tasks.hard_delete_soft_deleted_records',
# 'schedule': crontab(minute=0, hour=0, day_of_month=1), # každý první den v měsíci o půlnoci
# },
# 'delete_old_reservations_monthly': {
# 'task': 'account.tasks.delete_old_reservations',
# 'schedule': crontab(minute=0, hour=1, day_of_month=1), # každý první den v měsíci v 1:00 ráno
# },
# }
# else:
# # Nebo nastav dummy broker, aby se úlohy neodesílaly
# CELERY_BROKER_URL = 'memory://' # broker v paměti, pro testování bez Redis
#-------------------------------------END CELERY 📅------------------------------------ #-------------------------------------END CELERY 📅------------------------------------
@@ -471,66 +498,57 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# říka že se úkladá do databáze, místo do cookie # říka že se úkladá do databáze, místo do cookie
SESSION_ENGINE = 'django.contrib.sessions.backends.db' SESSION_ENGINE = 'django.contrib.sessions.backends.db'
USE_PRODUCTION_DB = os.getenv("USE_PRODUCTION_DB", "False") == "True" USE_DOCKER_DB = os.getenv("USE_DOCKER_DB", "False") in ["True", "true", "1", True]
if USE_PRODUCTION_DB is False: if USE_DOCKER_DB is False:
# DEVELOPMENT # DEV
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', # Database engine 'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3', # Path to the SQLite database file 'NAME': BASE_DIR / 'db.sqlite3',
} }
} }
else: else:
#PRODUCTION # DOCKER/POSTGRES
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': os.getenv('DATABASE_ENGINE'), 'ENGINE': os.getenv('DATABASE_ENGINE'),
'NAME': os.getenv('DATABASE_NAME'), 'NAME': os.getenv('POSTGRES_DB'),
'USER': os.getenv('DATABASE_USER'), 'USER': os.getenv('POSTGRES_USER'),
'PASSWORD': os.getenv('DATABASE_PASSWORD'), 'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
'HOST': os.getenv('DATABASE_HOST', "localhost"), 'HOST': os.getenv('DATABASE_HOST'),
'PORT': os.getenv('DATABASE_PORT'), 'PORT': os.getenv('DATABASE_PORT'),
} }
} }
print(f"\nUsing Docker DB: {USE_DOCKER_DB}\nDatabase settings: {DATABASES}\n")
AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser
#--------------------------------END DATABASE 💾--------------------------------- #--------------------------------END DATABASE 💾---------------------------------
#--------------------------------------PAGE SETTINGS -------------------------------------
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
# Configuration for Constance(variables)
CONSTANCE_CONFIG = {
'BITCOIN_WALLET': ('', 'Public BTC wallet address'),
'SUPPORT_EMAIL': ('admin@example.com', 'Support email'),
}
#--------------------------------------EMAIL 📧-------------------------------------- #--------------------------------------EMAIL 📧--------------------------------------
if DEBUG: EMAIL_BACKEND = os.getenv(
# DEVELOPMENT "EMAIL_BACKEND",
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Use console backend for development 'django.core.mail.backends.console.EmailBackend' if DEBUG else 'django.core.mail.backends.smtp.EmailBackend'
# EMAILY SE BUDOU POSÍLAT DO KONZOLE!!! )
else:
# PRODUCTION
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.getenv("EMAIL_HOST_DEV") EMAIL_HOST = os.getenv("EMAIL_HOST")
EMAIL_PORT = int(os.getenv("EMAIL_PORT_DEV", 465)) EMAIL_PORT = int(os.getenv("EMAIL_PORT", 465))
EMAIL_USE_TLS = True # ❌ Keep this OFF when using SSL EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "False") in ["True", "true", "1", True]
EMAIL_USE_SSL = False # ✅ Must be True for port 465 EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "True") in ["True", "true", "1", True]
EMAIL_HOST_USER = os.getenv("EMAIL_USER_DEV") EMAIL_HOST_USER = os.getenv("EMAIL_USER")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD_DEV") EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD")
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
EMAIL_TIMEOUT = 10 EMAIL_TIMEOUT = 30 # seconds
print("---------EMAIL----------\nEMAIL_HOST =", os.getenv("EMAIL_HOST_DEV"))
print("EMAIL_PORT =", os.getenv("EMAIL_PORT_DEV"))
print("EMAIL_USER =", os.getenv("EMAIL_USER_DEV"))
print("EMAIL_USER_PASSWORD =", os.getenv("EMAIL_USER_PASSWORD_DEV"), "\n------------------------")
print("---------EMAIL----------")
print("EMAIL_HOST =", EMAIL_HOST)
print("EMAIL_PORT =", EMAIL_PORT)
print("EMAIL_USE_TLS =", EMAIL_USE_TLS)
print("EMAIL_USE_SSL =", EMAIL_USE_SSL)
print("EMAIL_USER =", EMAIL_HOST_USER)
print("------------------------")
#----------------------------------EMAIL END 📧------------------------------------- #----------------------------------EMAIL END 📧-------------------------------------
@@ -579,9 +597,6 @@ else:
print(f"\n-------------- USE_AWS: {USE_AWS} --------------") print(f"\n-------------- USE_AWS: {USE_AWS} --------------")
if USE_AWS is False: if USE_AWS is False:
# DEVELOPMENT
# Development: Use local file system storage for static files # Development: Use local file system storage for static files
STORAGES = { STORAGES = {
"default": { "default": {
@@ -593,14 +608,11 @@ if USE_AWS is False:
} }
# Media and Static URL for local dev # Media and Static URL for local dev
MEDIA_URL = '/media/' MEDIA_URL = os.getenv("MEDIA_URL", "/media/")
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
STATIC_URL = '/static/' STATIC_URL = '/static/'
# Local folder for collected static files
STATIC_ROOT = BASE_DIR / 'collectedstaticfiles' STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
elif USE_AWS: elif USE_AWS:
# PRODUCTION # PRODUCTION
@@ -638,7 +650,6 @@ elif USE_AWS:
print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------") print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------")
#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️--------------------------------- #--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️---------------------------------

View File

@@ -1,73 +1,64 @@
import Client from "../Client"; import Client from "../Client";
export type FormatOption = { // Available output containers (must match backend)
format_id: string; export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const;
ext: string | null; export type FormatExt = (typeof FORMAT_EXTS)[number];
vcodec: string | null;
acodec: string | null;
fps: number | null;
tbr: number | null;
abr: number | null;
vbr: number | null;
asr: number | null;
filesize: number | null;
filesize_approx: number | null;
estimated_size_bytes: number | null;
size_ok: boolean;
format_note: string | null;
resolution: string | null; // e.g. "1920x1080"
audio_only: boolean;
};
export type FormatsResponse = { export type InfoResponse = {
title: string | null; title: string | null;
duration: number | null; duration: number | null;
extractor: string | null; thumbnail: string | null;
video_id: string | null; video_resolutions: string[]; // e.g. ["2160p", "1440p", "1080p", ...]
max_size_bytes: number; audio_resolutions: string[]; // e.g. ["320kbps", "160kbps", ...]
options: FormatOption[];
}; };
// Probe available formats for a URL (no auth required) // GET info for a URL
export async function probeFormats(url: string): Promise<FormatsResponse> { export async function fetchInfo(url: string): Promise<InfoResponse> {
const res = await Client.public.post("/api/downloader/formats/", { url }); const res = await Client.public.get("/api/downloader/download/", {
return res.data as FormatsResponse; params: { url },
});
return res.data as InfoResponse;
} }
// Download selected format as a Blob and resolve filename from headers // POST to stream binary immediately; returns { blob, filename }
export async function downloadFormat(url: string, format_id: string): Promise<{ blob: Blob; filename: string }> { export async function downloadImmediate(args: {
url: string;
ext: FormatExt;
videoResolution?: string | number; // "1080p" or 1080
audioResolution?: string | number; // "160kbps" or 160
}): Promise<{ blob: Blob; filename: string }> {
const video_quality = toHeight(args.videoResolution);
const audio_quality = toKbps(args.audioResolution);
if (video_quality == null || audio_quality == null) {
throw new Error("Please select both video and audio quality.");
}
const res = await Client.public.post( const res = await Client.public.post(
"/api/downloader/download/", "/api/downloader/download/",
{ url, format_id }, {
url: args.url,
ext: args.ext,
video_quality,
audio_quality,
},
{ responseType: "blob" } { responseType: "blob" }
); );
// Try to parse Content-Disposition filename first, then X-Filename (exposed by backend)
const cd = res.headers?.["content-disposition"] as string | undefined; const cd = res.headers?.["content-disposition"] as string | undefined;
const xfn = res.headers?.["x-filename"] as string | undefined; const xfn = res.headers?.["x-filename"] as string | undefined;
const filename = const filename =
parseContentDispositionFilename(cd) || parseContentDispositionFilename(cd) ||
(xfn && xfn.trim()) || (xfn && xfn.trim()) ||
inferFilenameFromUrl(url, (res.headers?.["content-type"] as string | undefined)) || inferFilenameFromUrl(args.url, res.headers?.["content-type"] as string | undefined) ||
"download.bin"; `download.${args.ext}`;
return { blob: res.data as Blob, filename }; return { blob: res.data as Blob, filename };
} }
// Deprecated types kept for compatibility if referenced elsewhere
export type Choices = { file_types: string[]; qualities: string[] };
export type DownloadJobResponse = {
id: string;
status: "pending" | "running" | "finished" | "failed";
detail?: string;
download_url?: string;
progress?: number;
};
// Helpers // Helpers
function parseContentDispositionFilename(cd?: string): string | null { export function parseContentDispositionFilename(cd?: string): string | null {
if (!cd) return null; if (!cd) return null;
// filename*=UTF-8''encoded or filename="plain"
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]); if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i); const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i);
@@ -89,16 +80,35 @@ function inferFilenameFromUrl(url: string, contentType?: string): string {
return "download.bin"; return "download.bin";
} }
function contentTypeToExt(ct: string): string | null { function contentTypeToExt(ct?: string): string | null {
if (!ct) return null;
const map: Record<string, string> = { const map: Record<string, string> = {
"video/mp4": "mp4", "video/mp4": "mp4",
"audio/mpeg": "mp3", "video/x-matroska": "mkv",
"audio/mp4": "m4a",
"audio/aac": "aac",
"audio/ogg": "ogg",
"video/webm": "webm", "video/webm": "webm",
"audio/webm": "webm", "video/x-flv": "flv",
"video/quicktime": "mov",
"video/x-msvideo": "avi",
"video/ogg": "ogg",
"application/octet-stream": "bin", "application/octet-stream": "bin",
}; };
return map[ct] || null; return map[ct] || null;
} }
function toHeight(v?: string | number): number | undefined {
if (typeof v === "number") return v || undefined;
if (!v) return undefined;
const m = /^(\d{2,4})p$/i.exec(v.trim());
if (m) return parseInt(m[1], 10);
const n = Number(v);
return Number.isFinite(n) ? (n as number) : undefined;
}
function toKbps(v?: string | number): number | undefined {
if (typeof v === "number") return v || undefined;
if (!v) return undefined;
const m = /^(\d{2,4})\s*kbps$/i.exec(v.trim());
if (m) return parseInt(m[1], 10);
const n = Number(v);
return Number.isFinite(n) ? (n as number) : undefined;
}

View File

@@ -1,59 +1,98 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { probeFormats, downloadFormat, type FormatsResponse, type FormatOption } from "../../api/apps/Downloader"; import {
fetchInfo,
downloadImmediate,
FORMAT_EXTS,
type InfoResponse,
parseContentDispositionFilename,
} from "../../api/apps/Downloader";
export default function Downloader() { export default function Downloader() {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [probing, setProbing] = useState(false); const [probing, setProbing] = useState(false);
const [downloadingId, setDownloadingId] = useState<string | null>(null); const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [formats, setFormats] = useState<FormatsResponse | null>(null); const [info, setInfo] = useState<InfoResponse | null>(null);
const [ext, setExt] = useState<typeof FORMAT_EXTS[number]>("mp4");
const [videoRes, setVideoRes] = useState<string | undefined>(undefined);
const [audioRes, setAudioRes] = useState<string | undefined>(undefined);
useEffect(() => {
if (info?.video_resolutions?.length && !videoRes) {
setVideoRes(info.video_resolutions[0]);
}
if (info?.audio_resolutions?.length && !audioRes) {
setAudioRes(info.audio_resolutions[0]);
}
}, [info]);
async function onProbe(e: React.FormEvent) { async function onProbe(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setFormats(null); setInfo(null);
setProbing(true); setProbing(true);
try { try {
const res = await probeFormats(url); const res = await fetchInfo(url);
setFormats(res); setInfo(res);
// reset selections from fresh info
setVideoRes(res.video_resolutions?.[0]);
setAudioRes(res.audio_resolutions?.[0]);
} catch (e: any) { } catch (e: any) {
setError(e?.response?.data?.detail || e?.message || "Failed to load formats."); setError(
e?.response?.data?.error ||
e?.response?.data?.detail ||
e?.message ||
"Failed to get info."
);
} finally { } finally {
setProbing(false); setProbing(false);
} }
} }
async function onDownload(fmt: FormatOption) { async function onDownload() {
setError(null); setError(null);
setDownloadingId(fmt.format_id); setDownloading(true);
try { try {
const { blob, filename } = await downloadFormat(url, fmt.format_id); const { blob, filename } = await downloadImmediate({
const link = document.createElement("a"); url,
ext,
videoResolution: videoRes,
audioResolution: audioRes,
});
const name = filename || parseContentDispositionFilename("") || `download.${ext}`;
const href = URL.createObjectURL(blob); const href = URL.createObjectURL(blob);
link.href = href; const a = document.createElement("a");
link.download = filename || "download.bin"; a.href = href;
document.body.appendChild(link); a.download = name;
link.click(); document.body.appendChild(a);
document.body.removeChild(link); a.click();
document.body.removeChild(a);
URL.revokeObjectURL(href); URL.revokeObjectURL(href);
} catch (e: any) { } catch (e: any) {
setError(e?.response?.data?.detail || e?.message || "Download failed."); setError(
e?.response?.data?.error ||
e?.response?.data?.detail ||
e?.message ||
"Download failed."
);
} finally { } finally {
setDownloadingId(null); setDownloading(false);
} }
} }
const canDownload = useMemo(
() => !!url && !!ext && !!videoRes && !!audioRes,
[url, ext, videoRes, audioRes]
);
return ( return (
<div className="max-w-3xl mx-auto p-4"> <div className="max-w-3xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-semibold mb-4">Downloader</h1> <h1 className="text-2xl font-semibold">Downloader</h1>
{error && ( {error && <div className="rounded border border-red-300 bg-red-50 text-red-700 p-2">{error}</div>}
<div className="mb-3 rounded border border-red-300 bg-red-50 text-red-700 p-2">
{error}
</div>
)}
<form onSubmit={onProbe} className="grid gap-3 mb-4"> <form onSubmit={onProbe} className="grid gap-3">
<label className="grid gap-1"> <label className="grid gap-1">
<span className="text-sm font-medium">URL</span> <span className="text-sm font-medium">URL</span>
<input <input
@@ -65,76 +104,103 @@ export default function Downloader() {
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</label> </label>
<div>
<div className="flex gap-2">
<button <button
type="submit" type="submit"
disabled={!url || probing} disabled={!url || probing}
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50" className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
> >
{probing ? "Probing..." : "Find formats"} {probing ? "Probing..." : "Get info"}
</button>
<button
type="button"
onClick={onDownload}
disabled={!canDownload || downloading}
className="px-3 py-2 rounded bg-emerald-600 text-white disabled:opacity-50"
>
{downloading ? "Downloading..." : "Download"}
</button> </button>
</div> </div>
</form> </form>
{formats && ( {info && (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-sm text-gray-700"> <div className="flex items-start gap-3">
<div><span className="font-medium">Title:</span> {formats.title || "-"}</div> {info.thumbnail && (
<div><span className="font-medium">Duration:</span> {formats.duration ? `${Math.round(formats.duration)} s` : "-"}</div> <img
<div><span className="font-medium">Max size:</span> {formatBytes(formats.max_size_bytes)}</div> src={info.thumbnail}
</div> alt={info.title || "thumbnail"}
className="w-40 h-24 object-cover rounded border"
<div className="border rounded overflow-hidden"> />
<div className="grid grid-cols-6 gap-2 p-2 bg-gray-50 text-sm font-medium">
<div>Format</div>
<div>Resolution</div>
<div>Type</div>
<div>Note</div>
<div>Est. size</div>
<div></div>
</div>
<div className="divide-y">
{formats.options.map((o) => (
<div key={o.format_id} className="grid grid-cols-6 gap-2 p-2 items-center text-sm">
<div className="truncate">{o.format_id}{o.ext ? `.${o.ext}` : ""}</div>
<div>{o.resolution || (o.audio_only ? "audio" : "-")}</div>
<div>{o.audio_only ? "Audio" : "Video"}</div>
<div className="truncate">{o.format_note || "-"}</div>
<div className={o.size_ok ? "text-gray-800" : "text-red-600"}>
{o.estimated_size_bytes ? formatBytes(o.estimated_size_bytes) : (o.filesize || o.filesize_approx) ? "~" + formatBytes((o.filesize || o.filesize_approx)!) : "?"}
{!o.size_ok && " (too big)"}
</div>
<div className="text-right">
<button
onClick={() => onDownload(o)}
disabled={!o.size_ok || downloadingId === o.format_id}
className="px-2 py-1 rounded bg-emerald-600 text-white disabled:opacity-50"
>
{downloadingId === o.format_id ? "Downloading..." : "Download"}
</button>
</div>
</div>
))}
</div>
</div>
{!formats.options.length && (
<div className="text-sm text-gray-600">No formats available.</div>
)} )}
<div className="text-sm text-gray-800 space-y-1">
<div>
<span className="font-medium">Title:</span> {info.title || "-"}
</div>
<div>
<span className="font-medium">Duration:</span>{" "}
{info.duration ? `${Math.round(info.duration)} s` : "-"}
</div>
</div>
</div>
<div className="grid md:grid-cols-3 gap-3">
<label className="grid gap-1">
<span className="text-sm font-medium">Container</span>
<select
value={ext}
onChange={(e) => setExt(e.target.value as any)}
className="border rounded p-2"
>
{FORMAT_EXTS.map((x) => (
<option key={x} value={x}>
{x.toUpperCase()}
</option>
))}
</select>
</label>
<label className="grid gap-1">
<span className="text-sm font-medium">Video resolution</span>
<select
value={videoRes || ""}
onChange={(e) => setVideoRes(e.target.value || undefined)}
className="border rounded p-2"
>
{info.video_resolutions?.length ? (
info.video_resolutions.map((r) => (
<option key={r} value={r}>
{r}
</option>
))
) : (
<option value="">-</option>
)}
</select>
</label>
<label className="grid gap-1">
<span className="text-sm font-medium">Audio bitrate</span>
<select
value={audioRes || ""}
onChange={(e) => setAudioRes(e.target.value || undefined)}
className="border rounded p-2"
>
{info.audio_resolutions?.length ? (
info.audio_resolutions.map((r) => (
<option key={r} value={r}>
{r}
</option>
))
) : (
<option value="">-</option>
)}
</select>
</label>
</div>
</div> </div>
)} )}
</div> </div>
); );
} }
function formatBytes(bytes?: number | null): string {
if (!bytes || bytes <= 0) return "-";
const units = ["B", "KB", "MB", "GB"];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}