From 8dd4f6e7316fe6a9d8b720a2b18bdcfcb5b0c676 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Thu, 30 Oct 2025 01:58:28 +0100 Subject: [PATCH] converter --- backend/Dockerfile | 2 + backend/account/models.py | 34 +- backend/account/tasks.py | 161 ++-- .../templates/emails/advertisment.html | 128 --- .../templates/emails/email_verification.html | 61 +- .../templates/emails/email_verification.txt | 7 + .../templates/emails/password_reset.html | 61 +- .../templates/emails/password_reset.txt | 7 + backend/account/templates/emails/test.html | 44 + backend/account/templates/emails/test.txt | 6 + backend/env | 59 ++ backend/thirdparty/downloader/admin.py | 13 +- .../downloader/migrations/0001_initial.py | 30 + .../downloader/migrations/__init__.py | 0 backend/thirdparty/downloader/models.py | 89 +- backend/thirdparty/downloader/serializers.py | 74 +- backend/thirdparty/downloader/urls.py | 12 +- backend/thirdparty/downloader/views.py | 818 ++++++------------ backend/thirdparty/stripe/views.py | 4 + backend/thirdparty/trading212/views.py | 1 + backend/vontor_cz/settings.py | 475 +++++----- frontend/src/api/apps/Downloader.ts | 112 +-- frontend/src/pages/downloader/Downloader.tsx | 230 +++-- 23 files changed, 1142 insertions(+), 1286 deletions(-) delete mode 100644 backend/account/templates/emails/advertisment.html create mode 100644 backend/account/templates/emails/email_verification.txt create mode 100644 backend/account/templates/emails/password_reset.txt create mode 100644 backend/account/templates/emails/test.html create mode 100644 backend/account/templates/emails/test.txt create mode 100644 backend/env create mode 100644 backend/thirdparty/downloader/migrations/0001_initial.py create mode 100644 backend/thirdparty/downloader/migrations/__init__.py 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 -

-
-
- - - - - - -
-

Máte zájem o některý z balíčků?

-

Stačí odpovědět na tento e-mail nebo mě kontaktovat:

-

- brunovontor@gmail.com
- +420 605 512 624
- vontor.cz -

-
- - - 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 %} + + + + +
+ + {{ 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 %} + + + + +
+ + {{ 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 %} + + + + +
+ + {{ 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}
} -
+ -
+ +
+
- {formats && ( + {info && (
-
-
Title: {formats.title || "-"}
-
Duration: {formats.duration ? `${Math.round(formats.duration)} s` : "-"}
-
Max size: {formatBytes(formats.max_size_bytes)}
-
- -
-
-
Format
-
Resolution
-
Type
-
Note
-
Est. size
-
-
-
- {formats.options.map((o) => ( -
-
{o.format_id}{o.ext ? `.${o.ext}` : ""}
-
{o.resolution || (o.audio_only ? "audio" : "-")}
-
{o.audio_only ? "Audio" : "Video"}
-
{o.format_note || "-"}
-
- {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)"} -
-
- -
-
- ))} +
+ {info.thumbnail && ( + {info.title + )} +
+
+ Title: {info.title || "-"} +
+
+ Duration:{" "} + {info.duration ? `${Math.round(info.duration)} s` : "-"} +
- {!formats.options.length && ( -
No formats available.
- )} +
+ + + + + +
)}
); -} - -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]}`; } \ No newline at end of file