diff --git a/backend/Dockerfile b/backend/Dockerfile
index f077a3c..f69e6ce 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -2,6 +2,8 @@ FROM python:3.12-slim
WORKDIR /app
+RUN apt update && apt install ffmpeg -y
+
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
diff --git a/backend/account/models.py b/backend/account/models.py
index a01eebe..e52ef8b 100644
--- a/backend/account/models.py
+++ b/backend/account/models.py
@@ -1,8 +1,9 @@
import uuid
from django.db import models
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.db import models
from django.utils import timezone
@@ -61,6 +62,10 @@ class CustomUser(SoftDeleteModel, AbstractUser):
email_verified = models.BooleanField(default=False)
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)
is_active = models.BooleanField(default=False)
@@ -124,5 +129,32 @@ class CustomUser(SoftDeleteModel, AbstractUser):
self.groups.set([group])
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
diff --git a/backend/account/tasks.py b/backend/account/tasks.py
index d0e15c7..038998a 100644
--- a/backend/account/tasks.py
+++ b/backend/account/tasks.py
@@ -10,76 +10,115 @@ from .models import CustomUser
logger = get_task_logger(__name__)
-@shared_task
-def send_password_reset_email_task(user_id):
- try:
- user = CustomUser.objects.get(pk=user_id)
- except CustomUser.DoesNotExist:
- error_msg = f"Task send_password_reset_email has failed. Invalid User ID was sent."
- logger.error(error_msg)
- raise Exception(error_msg)
- uid = urlsafe_base64_encode(force_bytes(user.pk))
- token = password_reset_token.make_token(user)
- reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
- html_message = render_to_string(
- 'emails/password_reset.html',
- {'reset_url': reset_url}
- )
- if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
- logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
- send_email_with_context(
- recipients=user.email,
- subject="Obnova hesla",
- message=None,
- html_message=html_message
- )
+def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
+ """
+ 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]
-# Only email verification for user registration
+ 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:
+ ctx["user"] = _build_user_template_ctx(ctx["user"])
+ except Exception:
+ ctx["user"] = {}
+
+ message = render_to_string(template_name, ctx)
+ html_message = render_to_string(html_template_name, ctx)
+
+ try:
+ send_mail(
+ subject=subject,
+ 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':
+ logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
+ return True
+ except Exception as e:
+ logger.error(f"E-mail se neodeslal: {e}")
+ return False
+
+
+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
def send_email_verification_task(user_id):
try:
user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist:
- error_msg = f"Task send_email_verification_task has failed. Invalid User ID was sent."
- logger.error(error_msg)
- raise Exception(error_msg)
+ logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
+ return 0
+
uid = urlsafe_base64_encode(force_bytes(user.pk))
- token = account_activation_token.make_token(user)
- verification_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
- html_message = render_to_string(
- 'emails/email_verification.html',
- {'verification_url': verification_url}
- )
- if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
- logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
+ # {changed} generate and store a per-user token
+ token = user.generate_email_verification_token()
+ verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
+
+ context = {
+ "user": _build_user_template_ctx(user),
+ "action_url": verify_url,
+ "frontend_url": settings.FRONTEND_URL,
+ "cta_label": "Ověřit e‑mail",
+ }
+
send_email_with_context(
recipients=user.email,
- subject="Ověření e-mailu",
- message=None,
- html_message=html_message
+ subject="Ověření e‑mailu",
+ template_name="email/email_verification.txt",
+ html_template_name="email/email_verification.html",
+ context=context,
)
-
-
-def send_email_with_context(recipients, subject, message=None, html_message=None):
- """
- General function to send emails with a specific context.
- """
- if isinstance(recipients, str):
- recipients = [recipients]
- try:
- send_mail(
- subject=subject,
- message=message if message else '',
- from_email=None,
- recipient_list=recipients,
- 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
+
+@shared_task
+def send_email_test_task(email):
+ context = {
+ "action_url": settings.FRONTEND_URL,
+ "frontend_url": settings.FRONTEND_URL,
+ "cta_label": "Otevřít aplikaci",
+ }
+ send_email_with_context(
+ recipients=email,
+ subject="Testovací e‑mail",
+ template_name="email/test.txt",
+ html_template_name="email/test.html",
+ context=context,
+ )
\ No newline at end of file
diff --git a/backend/account/templates/emails/advertisment.html b/backend/account/templates/emails/advertisment.html
deleted file mode 100644
index c6d4c58..0000000
--- a/backend/account/templates/emails/advertisment.html
+++ /dev/null
@@ -1,128 +0,0 @@
-
-
-
-
-
-
- |
- Nabídka tvorby webových stránek
- |
-
-
- |
-
- Jsme malý tým, který se snaží prorazit a přinášet moderní řešení za férové ceny.
- Nabízíme také levný hosting a SSL zabezpečení zdarma.
-
-
- Dbáme na bezpečnost, používáme moderní frameworky
- a rozhodně nejsme součástí „gerontosaurů“ – PHP nepoužíváme.
-
- |
-
-
-
-
- |
- Balíčky
- |
-
-
-
-
-
- BASIC
-
- - Jednoduchá prezentační webová stránka
- - Moderní a responzivní design (PC, tablety, mobily)
- - Max. počet stránek: 5
- - Seřízení vlastní domény, a k tomuSSL certifikát zdarma
-
-
- Cena: 5 000 Kč (jednorázově) + 100 Kč / měsíc
-
- |
-
-
-
-
- STANDARD
-
- - Vše z balíčku BASIC
- - Kontaktní formulář (přijde vám poptávka na e-mail)
- - Priorita při vývoji (cca 2 týdny)
- - Základní SEO
- - Max. počet stránek: 10
-
-
- Cena: 7 500 Kč (jednorázově) + 250 Kč / měsíc
-
- |
-
-
-
-
- PREMIUM
-
- - Vše z balíčku STANDARD
- - Vaše firma na Google Maps díky plně nastavenému Google Business Profile
- - Pokročilé SEO
- - Měsíční report návštěvnosti
- - Možnost drobných úprav
- - Neomezený počet stránek
-
-
- Cena: od 9 500 Kč (jednorázově) + 400 Kč / měsíc
-
- |
-
-
-
-
- CUSTOM
-
- - Kompletně na míru
- - Možnost e-shopu a rezervačních systémů
- - Integrace API a platební brány
- - Pokročilé SEO a marketing
-
-
- Cena: dohodou
-
- |
-
-
- |
-
-
-
-
-
-
-
-
diff --git a/backend/account/templates/emails/email_verification.html b/backend/account/templates/emails/email_verification.html
index aa25060..0802339 100644
--- a/backend/account/templates/emails/email_verification.html
+++ b/backend/account/templates/emails/email_verification.html
@@ -1,19 +1,46 @@
-
+
-
-
- Ověření e-mailu
-
-
-
-
-
-
-
Ověření e-mailu
-
Ověřte svůj e-mail kliknutím na odkaz níže:
-
Ověřit e-mail
-
-
-
-
+
+
+
+
+
+
+ |
+ Ověření e‑mailu
+ |
+
+
+ |
+ {% 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 e‑mailovou adresu kliknutím na tlačítko níže.
+
+ {% if action_url and cta_label %}
+
+ Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz: {{ action_url }}
+ {% endif %}
+ |
+
+
+
+
+ |
+ Tento e‑mail byl odeslán z aplikace e‑tržnice.
+ |
+
+
+ |
+
+
+
diff --git a/backend/account/templates/emails/email_verification.txt b/backend/account/templates/emails/email_verification.txt
new file mode 100644
index 0000000..042e029
--- /dev/null
+++ b/backend/account/templates/emails/email_verification.txt
@@ -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 e‑mailovou adresu kliknutím na následující odkaz:
+
+{{ action_url }}
+
+Pokud jste účet nevytvořili vy, tento e‑mail ignorujte.
diff --git a/backend/account/templates/emails/password_reset.html b/backend/account/templates/emails/password_reset.html
index 18158a5..58d403f 100644
--- a/backend/account/templates/emails/password_reset.html
+++ b/backend/account/templates/emails/password_reset.html
@@ -1,19 +1,46 @@
-
+
-
-
- Obnova hesla
-
-
-
-
-
-
-
Obnova hesla
-
Pro obnovu hesla klikněte na následující odkaz:
-
Obnovit heslo
-
-
-
-
+
+
+
+
+
+
+ |
+ Obnova hesla
+ |
+
+
+ |
+ {% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
+ Dobrý den{% if name %} {{ name }}{% endif %},
+ {% endwith %}
+ Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento e‑mail ignorujte.
+
+ {% if action_url and cta_label %}
+
+ Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz: {{ action_url }}
+ {% endif %}
+ |
+
+
+
+
+ |
+ Tento e‑mail byl odeslán z aplikace e‑tržnice.
+ |
+
+
+ |
+
+
+
diff --git a/backend/account/templates/emails/password_reset.txt b/backend/account/templates/emails/password_reset.txt
new file mode 100644
index 0000000..27638d5
--- /dev/null
+++ b/backend/account/templates/emails/password_reset.txt
@@ -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 e‑mail, protože byla požádána obnova hesla k vašemu účtu.
+Pokud jste o změnu nepožádali, tento e‑mail ignorujte.
+
+Pro nastavení nového hesla použijte tento odkaz:
+{{ action_url }}
diff --git a/backend/account/templates/emails/test.html b/backend/account/templates/emails/test.html
new file mode 100644
index 0000000..6721080
--- /dev/null
+++ b/backend/account/templates/emails/test.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+ |
+ Testovací e‑mail
+ |
+
+
+ |
+ Dobrý den,
+ Toto je testovací e‑mail z aplikace e‑tržnice.
+
+ {% if action_url and cta_label %}
+
+ Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz: {{ action_url }}
+ {% endif %}
+ |
+
+
+
+
+ |
+ Tento e‑mail byl odeslán z aplikace e‑tržnice.
+ |
+
+
+ |
+
+
+
+
diff --git a/backend/account/templates/emails/test.txt b/backend/account/templates/emails/test.txt
new file mode 100644
index 0000000..79ecc54
--- /dev/null
+++ b/backend/account/templates/emails/test.txt
@@ -0,0 +1,6 @@
+Dobrý den,
+
+Toto je testovací e‑mail z aplikace e‑tržnice.
+
+Odkaz na aplikaci:
+{{ action_url }}
diff --git a/backend/env b/backend/env
new file mode 100644
index 0000000..28950cb
--- /dev/null
+++ b/backend/env
@@ -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
diff --git a/backend/thirdparty/downloader/admin.py b/backend/thirdparty/downloader/admin.py
index 1d24b47..8edccef 100644
--- a/backend/thirdparty/downloader/admin.py
+++ b/backend/thirdparty/downloader/admin.py
@@ -1,9 +1,10 @@
from django.contrib import admin
-from .models import DownloaderModel
+from .models import DownloaderRecord
-@admin.register(DownloaderModel)
-class DownloaderModelAdmin(admin.ModelAdmin):
- list_display = ("id", "status", "ext", "requested_format", "vcodec", "acodec", "is_audio_only", "download_time")
- list_filter = ("status", "ext", "vcodec", "acodec", "is_audio_only", "extractor")
- search_fields = ("title", "video_id", "url")
+@admin.register(DownloaderRecord)
+class DownloaderRecordAdmin(admin.ModelAdmin):
+ list_display = ("id", "url", "format", "length_of_media", "file_size", "download_time")
+ list_filter = ("format",)
+ search_fields = ("url",)
+ ordering = ("-download_time",)
readonly_fields = ("download_time",)
diff --git a/backend/thirdparty/downloader/migrations/0001_initial.py b/backend/thirdparty/downloader/migrations/0001_initial.py
new file mode 100644
index 0000000..33b2b97
--- /dev/null
+++ b/backend/thirdparty/downloader/migrations/0001_initial.py
@@ -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,
+ },
+ ),
+ ]
diff --git a/backend/thirdparty/downloader/migrations/__init__.py b/backend/thirdparty/downloader/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/thirdparty/downloader/models.py b/backend/thirdparty/downloader/models.py
index b1052a9..bbedec4 100644
--- a/backend/thirdparty/downloader/models.py
+++ b/backend/thirdparty/downloader/models.py
@@ -1,92 +1,15 @@
from django.db import models
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
-class DownloaderModel(models.Model):
+class DownloaderRecord(SoftDeleteModel):
url = models.URLField()
download_time = models.DateTimeField(auto_now_add=True)
- status = models.CharField(max_length=50)
+
+ format = models.CharField(max_length=50)
- # yt-dlp metadata (flattened for stats)
- 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)
+ length_of_media = models.IntegerField(help_text="Length of media in seconds")
+ file_size = models.BigIntegerField(help_text="File size in bytes")
- # client/context
- user = models.ForeignKey(getattr(settings, "AUTH_USER_MODEL", "auth.User"), on_delete=models.SET_NULL, null=True, blank=True)
- 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
\ No newline at end of file
diff --git a/backend/thirdparty/downloader/serializers.py b/backend/thirdparty/downloader/serializers.py
index e2ba20f..7af652a 100644
--- a/backend/thirdparty/downloader/serializers.py
+++ b/backend/thirdparty/downloader/serializers.py
@@ -1,69 +1,9 @@
from rest_framework import serializers
-from .models import DownloaderModel
-class DownloaderLogSerializer(serializers.ModelSerializer):
- # Optional raw yt-dlp info dict
- info = serializers.DictField(required=False)
-
- class Meta:
- model = DownloaderModel
- fields = (
- "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
- )
+class DownloaderStatsSerializer(serializers.Serializer):
+ total_downloads = serializers.IntegerField()
+ avg_length_of_media = serializers.FloatField(allow_null=True)
+ avg_file_size = serializers.FloatField(allow_null=True)
+ total_length_of_media = serializers.IntegerField(allow_null=True)
+ total_file_size = serializers.IntegerField(allow_null=True)
+ most_common_format = serializers.CharField(allow_null=True)
\ No newline at end of file
diff --git a/backend/thirdparty/downloader/urls.py b/backend/thirdparty/downloader/urls.py
index d42e04a..40ac942 100644
--- a/backend/thirdparty/downloader/urls.py
+++ b/backend/thirdparty/downloader/urls.py
@@ -1,13 +1,9 @@
from django.urls import path
-from .views import DownloaderFormatsView, DownloaderFileView, DownloaderStatsView
+from .views import Downloader, DownloaderStats
urlpatterns = [
# Probe formats for a URL (size-checked)
- path("formats/", DownloaderFormatsView.as_view(), name="downloader-formats"),
-
- # Download selected format (enforces size limit)
- path("download/", DownloaderFileView.as_view(), name="downloader-download"),
-
- # Aggregated statistics
- path("stats/", DownloaderStatsView.as_view(), name="downloader-stats"),
+ path("download/", Downloader.as_view(), name="downloader-download"),
+
+ path("stats/", DownloaderStats.as_view(), name="downloader-stats"),
]
diff --git a/backend/thirdparty/downloader/views.py b/backend/thirdparty/downloader/views.py
index 907efa7..cc9a77c 100644
--- a/backend/thirdparty/downloader/views.py
+++ b/backend/thirdparty/downloader/views.py
@@ -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 ----------------------
# Using inline_serializer to avoid creating new files.
-FormatOptionSchema = inline_serializer(
- name="FormatOption",
- fields={
- "format_id": serializers.CharField(allow_null=True),
- "ext": serializers.CharField(allow_null=True),
- "vcodec": serializers.CharField(allow_null=True),
- "acodec": serializers.CharField(allow_null=True),
- "fps": serializers.FloatField(allow_null=True),
- "tbr": serializers.FloatField(allow_null=True),
- "abr": serializers.FloatField(allow_null=True),
- "vbr": serializers.FloatField(allow_null=True),
- "asr": serializers.IntegerField(allow_null=True),
- "filesize": serializers.IntegerField(allow_null=True),
- "filesize_approx": serializers.IntegerField(allow_null=True),
- "estimated_size_bytes": serializers.IntegerField(allow_null=True),
- "size_ok": serializers.BooleanField(),
- "format_note": serializers.CharField(allow_null=True),
- "resolution": serializers.CharField(allow_null=True),
- "audio_only": serializers.BooleanField(),
- },
+import yt_dlp
+import tempfile
+import os
+import shutil
+
+from rest_framework import serializers
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated, AllowAny
+from drf_spectacular.utils import extend_schema, inline_serializer
+from drf_spectacular.types import OpenApiTypes
+from django.conf import settings
+from django.http import StreamingHttpResponse
+from django.utils.text import slugify
+# NEW: aggregations and timeseries helpers
+from django.db import models
+from django.utils import timezone
+from django.db.models.functions import TruncDay, TruncHour
+from .models import DownloaderRecord
+
+# 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(
- name="FormatsRequest",
- fields={"url": serializers.URLField()},
-)
+# Minimal mime map by extension
+MIME_BY_EXT = {
+ "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(
- 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."""
+class Downloader(APIView):
permission_classes = [AllowAny]
authentication_classes = []
@extend_schema(
tags=["downloader"],
- operation_id="downloader_formats",
- summary="List available formats for a media URL",
- description="Uses yt-dlp to extract formats and estimates size. Applies max size policy.",
- request=FormatsRequestSchema,
- responses={
- 200: FormatsResponseSchema,
- 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,
- }
- ],
+ summary="Get video info from URL",
+ parameters=[
+ inline_serializer(
+ name="VideoInfoParams",
+ fields={
+ "url": serializers.URLField(help_text="Video URL to analyze"),
},
- 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={
200: inline_serializer(
- name="DownloaderStats",
+ name="VideoInfoResponse",
fields={
- "top_ext": serializers.ListField(
- child=inline_serializer(name="ExtCount", fields={
- "ext": serializers.CharField(allow_null=True),
- "count": serializers.IntegerField(),
- })
- ),
- "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(),
- })
- ),
+ "title": serializers.CharField(),
+ "duration": serializers.IntegerField(allow_null=True),
+ "thumbnail": serializers.URLField(allow_null=True),
+ "video_resolutions": serializers.ListField(child=serializers.CharField()),
+ "audio_resolutions": serializers.ListField(child=serializers.CharField()),
},
- )
+ ),
+ 400: inline_serializer(
+ name="ErrorResponse",
+ fields={"error": serializers.CharField()},
+ ),
},
)
def get(self, request):
- """GET to retrieve aggregated downloader statistics."""
- top_ext = list(DownloaderModel.objects.values("ext").annotate(count=Count("id")).order_by("-count")[:10])
- top_formats = list(DownloaderModel.objects.values("requested_format").annotate(count=Count("id")).order_by("-count")[:10])
- 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])
- audio_vs_video = list(DownloaderModel.objects.values("is_audio_only").annotate(count=Count("id")).order_by("-count"))
- return Response({
- "top_ext": top_ext,
- "top_requested_format": top_formats,
- "top_vcodec": top_vcodec,
- "top_acodec": top_acodec,
- "audio_vs_video": audio_vs_video,
- })
+ url = request.data.get("url") or request.query_params.get("url")
+ if not url:
+ return Response({"error": "URL is required"}, status=400)
+
+ ydl_options = {
+ "quiet": True,
+ }
+ try:
+ with yt_dlp.YoutubeDL(ydl_options) as ydl:
+ info = ydl.extract_info(url, download=False)
+ except Exception:
+ 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)
\ No newline at end of file
diff --git a/backend/thirdparty/stripe/views.py b/backend/thirdparty/stripe/views.py
index f51939b..561d29d 100644
--- a/backend/thirdparty/stripe/views.py
+++ b/backend/thirdparty/stripe/views.py
@@ -5,6 +5,7 @@ from django.http import HttpResponse
from rest_framework import generics
from rest_framework.response import Response
from rest_framework.views import APIView
+from drf_spectacular.utils import extend_schema
from .models import Order
from .serializers import OrderSerializer
@@ -14,6 +15,9 @@ import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
class CreateCheckoutSessionView(APIView):
+ @extend_schema(
+ tags=["stripe"],
+ )
def post(self, request):
serializer = OrderSerializer(data=request.data) #obecný serializer
serializer.is_valid(raise_exception=True)
diff --git a/backend/thirdparty/trading212/views.py b/backend/thirdparty/trading212/views.py
index 2071929..b5c96d1 100644
--- a/backend/thirdparty/trading212/views.py
+++ b/backend/thirdparty/trading212/views.py
@@ -12,6 +12,7 @@ class Trading212AccountCashView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
+ tags=["trading212"],
summary="Get Trading212 account cash",
responses=Trading212AccountCashSerializer
)
diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py
index 1e389bc..f7ae721 100644
--- a/backend/vontor_cz/settings.py
+++ b/backend/vontor_cz/settings.py
@@ -17,18 +17,23 @@ from django.core.management.utils import get_random_secret_key
from django.db import OperationalError, connections
from datetime import timedelta
+import json
from dotenv import load_dotenv
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--------------
# v jiné app si to importneš skrz: from django.conf import settings
# a použiješ takto: settings.FRONTEND_URL
-FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
-FRONTEND_URL_DEV = os.getenv("FRONTEND_URL_DEV", "http://localhost:5173")
-print(f"FRONTEND_URL: {FRONTEND_URL}\nFRONTEND_URL_DEV: {FRONTEND_URL_DEV}\n")
-
+FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:9000")
+print(f"FRONTEND_URL: {FRONTEND_URL}\n")
#-------------------------BASE ⚙️------------------------
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -153,62 +158,96 @@ AUTHENTICATION_BACKENDS = [
ALLOWED_HOSTS = ["*"]
+from urllib.parse import urlparse
+parsed = urlparse(FRONTEND_URL)
+
CSRF_TRUSTED_ORIGINS = [
- 'https://domena.cz',
- "https://www.domena.cz",
- "http://localhost:3000", #react docker
- "http://localhost:5173" #react dev
+ f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
+
+ "http://192.168.67.98",
+ "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:
- CORS_ALLOWED_ORIGINS = [
- "http://localhost:5173",
- "http://localhost:3000",
- ]
+ CORS_ALLOWED_ORIGINS = [
+ f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
+
+ "http://localhost:5173",
+ "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:
- CORS_ALLOWED_ORIGINS = [
- "https://www.domena.cz",
- ]
+ CORS_ALLOWED_ORIGINS = [
+ "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_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("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS)
print("ALLOWED_HOSTS =", ALLOWED_HOSTS)
-
-
#--------------------------------END CORS + HOSTs 🌐🔐---------------------------------
#--------------------------------------SSL 🧾------------------------------------
-
-if os.getenv("SSL", "") == "True":
- USE_SSL = True
-else:
- USE_SSL = False
-
-
if USE_SSL is True:
- print("SSL turned on!")
- SESSION_COOKIE_SECURE = True
- CSRF_COOKIE_SECURE = True
- SECURE_SSL_REDIRECT = True
- SECURE_BROWSER_XSS_FILTER = True
- SECURE_CONTENT_TYPE_NOSNIFF = True
- USE_X_FORWARDED_HOST = True
- SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
+ print("SSL turned on!")
+ SESSION_COOKIE_SECURE = True
+ CSRF_COOKIE_SECURE = True
+ SECURE_SSL_REDIRECT = True
+ SECURE_BROWSER_XSS_FILTER = True
+ SECURE_CONTENT_TYPE_NOSNIFF = True
+ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
else:
- SESSION_COOKIE_SECURE = False
- CSRF_COOKIE_SECURE = False
- SECURE_SSL_REDIRECT = False
- SECURE_BROWSER_XSS_FILTER = False
- SECURE_CONTENT_TYPE_NOSNIFF = False
- USE_X_FORWARDED_HOST = False
-
+ SESSION_COOKIE_SECURE = False
+ CSRF_COOKIE_SECURE = False
+ SECURE_SSL_REDIRECT = False
+ SECURE_BROWSER_XSS_FILTER = False
+ SECURE_CONTENT_TYPE_NOSNIFF = False
print(f"\nUsing SSL: {USE_SSL}\n")
-
#--------------------------------END-SSL 🧾---------------------------------
@@ -218,59 +257,67 @@ print(f"\nUsing SSL: {USE_SSL}\n")
#-------------------------------------REST FRAMEWORK 🛠️------------------------------------
# ⬇️ Základní lifetime konfigurace
-ACCESS_TOKEN_LIFETIME = timedelta(minutes=15)
-REFRESH_TOKEN_LIFETIME = timedelta(days=1)
+ACCESS_TOKEN_LIFETIME = timedelta(minutes=60)
+REFRESH_TOKEN_LIFETIME = timedelta(days=5)
# ⬇️ Nastavení SIMPLE_JWT podle režimu
if DEBUG:
- SIMPLE_JWT = {
- "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
- "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
+ SIMPLE_JWT = {
+ "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
+ "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
- "AUTH_COOKIE": "access_token",
- "AUTH_COOKIE_SECURE": False, # není HTTPS
- "AUTH_COOKIE_HTTP_ONLY": True,
- "AUTH_COOKIE_PATH": "/",
- "AUTH_COOKIE_SAMESITE": "Lax", # není cross-site
+ "AUTH_COOKIE": "access_token",
+ "AUTH_COOKIE_REFRESH": "refresh_token",
- "ROTATE_REFRESH_TOKENS": True,
- "BLACKLIST_AFTER_ROTATION": True,
- }
+ "AUTH_COOKIE_DOMAIN": None,
+ "AUTH_COOKIE_SECURE": False,
+ "AUTH_COOKIE_HTTP_ONLY": True,
+ "AUTH_COOKIE_PATH": "/",
+ "AUTH_COOKIE_SAMESITE": "Lax",
+
+ "ROTATE_REFRESH_TOKENS": False,
+ "BLACKLIST_AFTER_ROTATION": False,
+ }
else:
- SIMPLE_JWT = {
- "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
- "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
+ SIMPLE_JWT = {
+ "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
+ "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
- "AUTH_COOKIE": "access_token",
- "AUTH_COOKIE_SECURE": True, # HTTPS only
- "AUTH_COOKIE_HTTP_ONLY": True,
- "AUTH_COOKIE_PATH": "/",
- "AUTH_COOKIE_SAMESITE": "None", # potřebné pro cross-origin
+ "AUTH_COOKIE": "access_token",
+ "AUTH_COOKIE_REFRESH": "refresh_token",
+ "AUTH_COOKIE_DOMAIN": None,
- "ROTATE_REFRESH_TOKENS": True,
- "BLACKLIST_AFTER_ROTATION": True,
- }
+ # Secure/SameSite based on HTTPS availability
+ "AUTH_COOKIE_SECURE": USE_SSL,
+ "AUTH_COOKIE_HTTP_ONLY": True,
+ "AUTH_COOKIE_PATH": "/",
+ "AUTH_COOKIE_SAMESITE": "None" if USE_SSL else "Lax",
+ "ROTATE_REFRESH_TOKENS": True,
+ "BLACKLIST_AFTER_ROTATION": True,
+ }
REST_FRAMEWORK = {
- "DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
- 'DEFAULT_AUTHENTICATION_CLASSES': (
- 'account.tokens.CookieJWTAuthentication',
- 'rest_framework.authentication.SessionAuthentication',
- ),
- 'DEFAULT_PERMISSION_CLASSES': (
- 'rest_framework.permissions.AllowAny',
- ),
- 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
+ "DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
+ 'DEFAULT_AUTHENTICATION_CLASSES': (
+ # In DEBUG keep Session + JWT + your cookie class for convenience
+ 'rest_framework.authentication.SessionAuthentication',
+ 'rest_framework_simplejwt.authentication.JWTAuthentication',
+ 'account.tokens.CookieJWTAuthentication',
+ ) if DEBUG else (
+ 'account.tokens.CookieJWTAuthentication',
+ ),
+ 'DEFAULT_PERMISSION_CLASSES': (
+ 'rest_framework.permissions.AllowAny',
+ ),
+ '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': {
- 'anon': '100/hour', # unauthenticated
- 'user': '2000/hour', # authenticated
- }
+ 'DEFAULT_THROTTLE_RATES': {
+ 'anon': '100/hour', # unauthenticated
+ 'user': '2000/hour', # authenticated
+ }
}
-
#--------------------------------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 = [
- # Middleware that allows your backend to accept requests from other domains (CORS)
- "corsheaders.middleware.CorsMiddleware",
- "django.middleware.common.CommonMiddleware",
+ "corsheaders.middleware.CorsMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ 'django.middleware.security.SecurityMiddleware',
+ 'whitenoise.middleware.WhiteNoiseMiddleware',
- 'django.middleware.security.SecurityMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
-
- #CUSTOM
- #'tools.middleware.CustomMaxUploadSizeMiddleware',
-
-
- 'whitenoise.middleware.WhiteNoiseMiddleware',# díky tomu funguje načítaní static files
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
#--------------------------------END MIDDLEWARE 🧩---------------------------------
@@ -410,56 +451,42 @@ else:
#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️---------------------------------
#-------------------------------------CELERY 📅------------------------------------
-
-# CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL")
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:
- import redis
- # test connection
- r = redis.Redis(host='localhost', port=6379, db=0)
- r.ping()
+ import redis
+ r = redis.Redis(host='localhost', port=6379, db=0)
+ r.ping()
except Exception:
- CELERY_BROKER_URL = 'memory://'
+ CELERY_BROKER_URL = 'memory://'
+ CELERY_ENABLED = False
-CELERY_ACCEPT_CONTENT = os.getenv("CELERY_ACCEPT_CONTENT")
-CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER")
-CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE")
+def _env_list(key: str, default: list[str]) -> list[str]:
+ v = os.getenv(key)
+ 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")
-# 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 📅------------------------------------
@@ -471,66 +498,57 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# říka že se úkladá do databáze, místo do cookie
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:
- # DEVELOPMENT
- DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.sqlite3', # Database engine
- 'NAME': BASE_DIR / 'db.sqlite3', # Path to the SQLite database file
- }
- }
+if USE_DOCKER_DB is False:
+ # DEV
+ DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': BASE_DIR / 'db.sqlite3',
+ }
+ }
else:
- #PRODUCTION
- DATABASES = {
- 'default': {
- 'ENGINE': os.getenv('DATABASE_ENGINE'),
- 'NAME': os.getenv('DATABASE_NAME'),
- 'USER': os.getenv('DATABASE_USER'),
- 'PASSWORD': os.getenv('DATABASE_PASSWORD'),
- 'HOST': os.getenv('DATABASE_HOST', "localhost"),
- 'PORT': os.getenv('DATABASE_PORT'),
- }
- }
+ # DOCKER/POSTGRES
+ DATABASES = {
+ 'default': {
+ 'ENGINE': os.getenv('DATABASE_ENGINE'),
+ 'NAME': os.getenv('POSTGRES_DB'),
+ 'USER': os.getenv('POSTGRES_USER'),
+ 'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
+ 'HOST': os.getenv('DATABASE_HOST'),
+ '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
#--------------------------------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 📧--------------------------------------
-if DEBUG:
- # DEVELOPMENT
- EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Use console backend for development
- # EMAILY SE BUDOU POSÍLAT DO KONZOLE!!!
-else:
- # PRODUCTION
- EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
+EMAIL_BACKEND = os.getenv(
+ "EMAIL_BACKEND",
+ 'django.core.mail.backends.console.EmailBackend' if DEBUG else 'django.core.mail.backends.smtp.EmailBackend'
+)
-EMAIL_HOST = os.getenv("EMAIL_HOST_DEV")
-EMAIL_PORT = int(os.getenv("EMAIL_PORT_DEV", 465))
-EMAIL_USE_TLS = True # ❌ Keep this OFF when using SSL
-EMAIL_USE_SSL = False # ✅ Must be True for port 465
-EMAIL_HOST_USER = os.getenv("EMAIL_USER_DEV")
-EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD_DEV")
-DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
-EMAIL_TIMEOUT = 10
-
-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------------------------")
+EMAIL_HOST = os.getenv("EMAIL_HOST")
+EMAIL_PORT = int(os.getenv("EMAIL_PORT", 465))
+EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "False") in ["True", "true", "1", True]
+EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "True") in ["True", "true", "1", True]
+EMAIL_HOST_USER = os.getenv("EMAIL_USER")
+EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD")
+DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
+EMAIL_TIMEOUT = 30 # seconds
+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 📧-------------------------------------
@@ -579,66 +597,59 @@ else:
print(f"\n-------------- USE_AWS: {USE_AWS} --------------")
if USE_AWS is False:
- # DEVELOPMENT
+ # Development: Use local file system storage for static files
+ STORAGES = {
+ "default": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ },
+ "staticfiles": {
+ "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
+ },
+ }
+ # Media and Static URL for local dev
+ MEDIA_URL = os.getenv("MEDIA_URL", "/media/")
+ MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
- # Development: Use local file system storage for static files
- STORAGES = {
- "default": {
- "BACKEND": "django.core.files.storage.FileSystemStorage",
- },
- "staticfiles": {
- "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
- },
- }
-
- # Media and Static URL for local dev
- MEDIA_URL = '/media/'
- MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
-
- STATIC_URL = '/static/'
-
- # Local folder for collected static files
- STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
-
+ STATIC_URL = '/static/'
+ STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
elif USE_AWS:
- # PRODUCTION
+ # PRODUCTION
- AWS_LOCATION = "static"
+ AWS_LOCATION = "static"
- # Production: Use S3 storage
- STORAGES = {
- "default": {
- "BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
- },
+ # Production: Use S3 storage
+ STORAGES = {
+ "default": {
+ "BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
+ },
- "staticfiles": {
- "BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
- },
- }
+ "staticfiles": {
+ "BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
+ },
+ }
- # Media and Static URL for AWS S3
- MEDIA_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/media/'
- STATIC_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/static/'
+ # Media and Static URL for AWS S3
+ MEDIA_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/media/'
+ STATIC_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/static/'
- CSRF_TRUSTED_ORIGINS.append(STATIC_URL)
+ CSRF_TRUSTED_ORIGINS.append(STATIC_URL)
- # Static files should be collected to a local directory and then uploaded to S3
- STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
+ # Static files should be collected to a local directory and then uploaded to S3
+ STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
- AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
- AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
- AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
- AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'us-east-1') # Default to 'us-east-1' if not set
- AWS_S3_SIGNATURE_VERSION = 's3v4' # Use AWS Signature Version 4
- AWS_S3_USE_SSL = True
- AWS_S3_FILE_OVERWRITE = True
- AWS_DEFAULT_ACL = None # Set to None to avoid setting a default ACL
+ AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
+ AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
+ AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
+ AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'us-east-1') # Default to 'us-east-1' if not set
+ AWS_S3_SIGNATURE_VERSION = 's3v4' # Use AWS Signature Version 4
+ AWS_S3_USE_SSL = True
+ AWS_S3_FILE_OVERWRITE = True
+ AWS_DEFAULT_ACL = None # Set to None to avoid setting a default ACL
print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------")
-
#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️---------------------------------
diff --git a/frontend/src/api/apps/Downloader.ts b/frontend/src/api/apps/Downloader.ts
index d1e3f55..45aa23a 100644
--- a/frontend/src/api/apps/Downloader.ts
+++ b/frontend/src/api/apps/Downloader.ts
@@ -1,73 +1,64 @@
import Client from "../Client";
-export type FormatOption = {
- format_id: string;
- ext: string | null;
- 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;
-};
+// Available output containers (must match backend)
+export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const;
+export type FormatExt = (typeof FORMAT_EXTS)[number];
-export type FormatsResponse = {
+export type InfoResponse = {
title: string | null;
duration: number | null;
- extractor: string | null;
- video_id: string | null;
- max_size_bytes: number;
- options: FormatOption[];
+ thumbnail: string | null;
+ video_resolutions: string[]; // e.g. ["2160p", "1440p", "1080p", ...]
+ audio_resolutions: string[]; // e.g. ["320kbps", "160kbps", ...]
};
-// Probe available formats for a URL (no auth required)
-export async function probeFormats(url: string): Promise {
- const res = await Client.public.post("/api/downloader/formats/", { url });
- return res.data as FormatsResponse;
+// GET info for a URL
+export async function fetchInfo(url: string): Promise {
+ const res = await Client.public.get("/api/downloader/download/", {
+ params: { url },
+ });
+ return res.data as InfoResponse;
}
-// Download selected format as a Blob and resolve filename from headers
-export async function downloadFormat(url: string, format_id: string): Promise<{ blob: Blob; filename: string }> {
+// POST to stream binary immediately; returns { blob, filename }
+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(
"/api/downloader/download/",
- { url, format_id },
+ {
+ url: args.url,
+ ext: args.ext,
+ video_quality,
+ audio_quality,
+ },
{ 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 xfn = res.headers?.["x-filename"] as string | undefined;
const filename =
parseContentDispositionFilename(cd) ||
(xfn && xfn.trim()) ||
- inferFilenameFromUrl(url, (res.headers?.["content-type"] as string | undefined)) ||
- "download.bin";
+ inferFilenameFromUrl(args.url, res.headers?.["content-type"] as string | undefined) ||
+ `download.${args.ext}`;
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
-function parseContentDispositionFilename(cd?: string): string | null {
+export function parseContentDispositionFilename(cd?: string): string | null {
if (!cd) return null;
- // filename*=UTF-8''encoded or filename="plain"
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
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";
}
-function contentTypeToExt(ct: string): string | null {
+function contentTypeToExt(ct?: string): string | null {
+ if (!ct) return null;
const map: Record = {
"video/mp4": "mp4",
- "audio/mpeg": "mp3",
- "audio/mp4": "m4a",
- "audio/aac": "aac",
- "audio/ogg": "ogg",
+ "video/x-matroska": "mkv",
"video/webm": "webm",
- "audio/webm": "webm",
+ "video/x-flv": "flv",
+ "video/quicktime": "mov",
+ "video/x-msvideo": "avi",
+ "video/ogg": "ogg",
"application/octet-stream": "bin",
};
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;
+}
diff --git a/frontend/src/pages/downloader/Downloader.tsx b/frontend/src/pages/downloader/Downloader.tsx
index 13c1649..2299767 100644
--- a/frontend/src/pages/downloader/Downloader.tsx
+++ b/frontend/src/pages/downloader/Downloader.tsx
@@ -1,59 +1,98 @@
-import { useState } from "react";
-import { probeFormats, downloadFormat, type FormatsResponse, type FormatOption } from "../../api/apps/Downloader";
+import { useEffect, useMemo, useState } from "react";
+import {
+ fetchInfo,
+ downloadImmediate,
+ FORMAT_EXTS,
+ type InfoResponse,
+ parseContentDispositionFilename,
+} from "../../api/apps/Downloader";
export default function Downloader() {
const [url, setUrl] = useState("");
const [probing, setProbing] = useState(false);
- const [downloadingId, setDownloadingId] = useState(null);
+ const [downloading, setDownloading] = useState(false);
const [error, setError] = useState(null);
- const [formats, setFormats] = useState(null);
+ const [info, setInfo] = useState(null);
+
+ const [ext, setExt] = useState("mp4");
+ const [videoRes, setVideoRes] = useState(undefined);
+ const [audioRes, setAudioRes] = useState(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) {
e.preventDefault();
setError(null);
- setFormats(null);
+ setInfo(null);
setProbing(true);
try {
- const res = await probeFormats(url);
- setFormats(res);
+ const res = await fetchInfo(url);
+ setInfo(res);
+ // reset selections from fresh info
+ setVideoRes(res.video_resolutions?.[0]);
+ setAudioRes(res.audio_resolutions?.[0]);
} 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 {
setProbing(false);
}
}
- async function onDownload(fmt: FormatOption) {
+ async function onDownload() {
setError(null);
- setDownloadingId(fmt.format_id);
+ setDownloading(true);
try {
- const { blob, filename } = await downloadFormat(url, fmt.format_id);
- const link = document.createElement("a");
+ const { blob, filename } = await downloadImmediate({
+ url,
+ ext,
+ videoResolution: videoRes,
+ audioResolution: audioRes,
+ });
+ const name = filename || parseContentDispositionFilename("") || `download.${ext}`;
const href = URL.createObjectURL(blob);
- link.href = href;
- link.download = filename || "download.bin";
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
+ const a = document.createElement("a");
+ a.href = href;
+ a.download = name;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
URL.revokeObjectURL(href);
} 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 {
- setDownloadingId(null);
+ setDownloading(false);
}
}
+ const canDownload = useMemo(
+ () => !!url && !!ext && !!videoRes && !!audioRes,
+ [url, ext, videoRes, audioRes]
+ );
+
return (
-
-
Downloader
+
+
Downloader
- {error && (
-
- {error}
-
- )}
+ {error &&
{error}
}
-