From cf08dbaf150b0d1e8f142f789942d0706d4f32b8 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Tue, 21 Apr 2026 00:47:10 +0200 Subject: [PATCH] fixes, orval, downloader functioning again --- .gitignore | 2 + backend/Dockerfile | 1 + backend/account/migrations/0001_initial.py | 16 +- backend/account/migrations/0002_userblock.py | 27 - .../advertisement/migrations/0001_initial.py | 2 +- backend/commerce/migrations/0001_initial.py | 6 +- .../configuration/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/seed_app_config.py | 20 + .../configuration/migrations/0001_initial.py | 2 +- .../migrations/0002_alter_vatrate_rate.py | 26 + backend/configuration/models.py | 2 +- .../social/chat/migrations/0001_initial.py | 30 +- ..._type_chat_deleted_at_chat_hub_and_more.py | 98 ---- .../social/hubs/migrations/0001_initial.py | 2 +- backend/social/hubs/models.py | 1 - .../social/posts/migrations/0001_initial.py | 2 +- .../deutschepost/migrations/0001_initial.py | 10 +- ...hepostbulkorder_bulk_label_pdf_and_more.py | 43 -- backend/thirdparty/downloader/consumers.py | 245 ++++++++ .../downloader/migrations/0001_initial.py | 21 +- ...alter_downloaderrecord_options_and_more.py | 70 --- .../downloader/migrations/0002_downloadjob.py | 30 + .../migrations/0003_auto_20260420_2214.py | 16 + backend/thirdparty/downloader/routing.py | 6 + backend/thirdparty/downloader/urls.py | 2 +- backend/thirdparty/downloader/views.py | 430 ++------------ .../gopay/migrations/0001_initial.py | 2 +- .../stripe/migrations/0001_initial.py | 2 +- .../zasilkovna/migrations/0001_initial.py | 4 +- .../0002_alter_zasilkovnapacket_state.py | 18 - backend/vontor_cz/asgi.py | 14 +- docker-compose.yml | 26 +- frontend/src/App.tsx | 6 +- .../private/models/apiSchemaRetrieveLang.ts | 1 + .../generated/private/models/carrierRead.ts | 4 +- .../api/generated/private/models/cartItem.ts | 2 +- .../private/models/deutschePostOrder.ts | 2 +- .../generated/private/models/discountCode.ts | 2 +- .../private/models/downloadRequest.ts | 43 -- .../src/api/generated/private/models/index.ts | 9 +- .../generated/private/models/orderCarrier.ts | 4 +- .../api/generated/private/models/orderMini.ts | 4 +- .../api/generated/private/models/orderRead.ts | 4 +- .../models/patchedDeutschePostOrder.ts | 2 +- .../private/models/patchedDiscountCode.ts | 2 +- .../private/models/patchedOrderRead.ts | 4 +- .../private/models/patchedProduct.ts | 2 +- .../private/models/patchedVATRate.ts | 2 +- .../api/generated/private/models/postVote.ts | 4 +- .../api/generated/private/models/product.ts | 2 +- .../private/models/productMiniForWishlist.ts | 2 +- .../models/state9b5Enum.ts} | 4 +- .../models/stateF41Enum.ts} | 4 +- .../{statusD4fEnum.ts => status0b2Enum.ts} | 4 +- ...downloadErrorResponse.ts => tokenError.ts} | 2 +- .../api/generated/private/models/vATRate.ts | 2 +- .../private/models/zasilkovnaPacket.ts | 8 +- .../private/models/zasilkovnaPacketRead.ts | 8 +- .../src/api/generated/public/downloader.ts | 336 +++++++++-- .../apiDownloaderDownloadCreateParams.ts | 13 + .../apiDownloaderDownloadFileCreateParams.ts | 13 + ...apiDownloaderDownloadFileRetrieveParams.ts | 13 + .../generated/public/models/carrierRead.ts | 4 +- .../api/generated/public/models/cartItem.ts | 2 +- .../public/models/deutschePostOrder.ts | 2 +- .../generated/public/models/discountCode.ts | 2 +- .../public/models/downloadRequest.ts | 43 -- .../src/api/generated/public/models/index.ts | 12 +- .../generated/public/models/orderCarrier.ts | 4 +- .../api/generated/public/models/orderMini.ts | 4 +- .../api/generated/public/models/orderRead.ts | 4 +- .../public/models/patchedDeutschePostOrder.ts | 2 +- .../public/models/patchedDiscountCode.ts | 2 +- .../public/models/patchedOrderRead.ts | 4 +- .../generated/public/models/patchedProduct.ts | 2 +- .../generated/public/models/patchedVATRate.ts | 2 +- .../api/generated/public/models/postVote.ts | 4 +- .../api/generated/public/models/product.ts | 2 +- .../public/models/productMiniForWishlist.ts | 2 +- .../models/state9b5Enum.ts} | 4 +- .../models/stateF41Enum.ts} | 4 +- .../{statusD4fEnum.ts => status0b2Enum.ts} | 4 +- ...downloadErrorResponse.ts => tokenError.ts} | 2 +- .../api/generated/public/models/vATRate.ts | 2 +- .../public/models/zasilkovnaPacket.ts | 8 +- .../public/models/zasilkovnaPacketRead.ts | 8 +- frontend/src/components/downloader/Ad.tsx | 29 + .../src/components/downloader/Downloader.tsx | 546 ++++++++++++++++++ .../src/components/downloader/Piechart.tsx | 73 +++ .../src/components/downloader/Statistics.tsx | 68 +++ frontend/src/layouts/social/Chat.tsx | 13 + frontend/src/pages/downloader/Downloader.tsx | 453 +-------------- 93 files changed, 1662 insertions(+), 1333 deletions(-) delete mode 100644 backend/account/migrations/0002_userblock.py rename backups/backup-20260101-132533.sql => backend/configuration/management/__init__.py (100%) create mode 100644 backend/configuration/management/commands/__init__.py create mode 100644 backend/configuration/management/commands/seed_app_config.py create mode 100644 backend/configuration/migrations/0002_alter_vatrate_rate.py delete mode 100644 backend/social/chat/migrations/0002_chat_banner_chat_chat_type_chat_deleted_at_chat_hub_and_more.py delete mode 100644 backend/thirdparty/deutschepost/migrations/0002_deutschepostbulkorder_bulk_label_pdf_and_more.py create mode 100644 backend/thirdparty/downloader/consumers.py delete mode 100644 backend/thirdparty/downloader/migrations/0002_alter_downloaderrecord_options_and_more.py create mode 100644 backend/thirdparty/downloader/migrations/0002_downloadjob.py create mode 100644 backend/thirdparty/downloader/migrations/0003_auto_20260420_2214.py create mode 100644 backend/thirdparty/downloader/routing.py delete mode 100644 backend/thirdparty/zasilkovna/migrations/0002_alter_zasilkovnapacket_state.py delete mode 100644 frontend/src/api/generated/private/models/downloadRequest.ts rename frontend/src/api/generated/{public/models/stateCdfEnum.ts => private/models/state9b5Enum.ts} (83%) rename frontend/src/api/generated/{public/models/state1f6Enum.ts => private/models/stateF41Enum.ts} (77%) rename frontend/src/api/generated/private/models/{statusD4fEnum.ts => status0b2Enum.ts} (77%) rename frontend/src/api/generated/private/models/{downloadErrorResponse.ts => tokenError.ts} (74%) create mode 100644 frontend/src/api/generated/public/models/apiDownloaderDownloadCreateParams.ts create mode 100644 frontend/src/api/generated/public/models/apiDownloaderDownloadFileCreateParams.ts create mode 100644 frontend/src/api/generated/public/models/apiDownloaderDownloadFileRetrieveParams.ts delete mode 100644 frontend/src/api/generated/public/models/downloadRequest.ts rename frontend/src/api/generated/{private/models/stateCdfEnum.ts => public/models/state9b5Enum.ts} (83%) rename frontend/src/api/generated/{private/models/state1f6Enum.ts => public/models/stateF41Enum.ts} (77%) rename frontend/src/api/generated/public/models/{statusD4fEnum.ts => status0b2Enum.ts} (77%) rename frontend/src/api/generated/public/models/{downloadErrorResponse.ts => tokenError.ts} (74%) create mode 100644 frontend/src/components/downloader/Downloader.tsx diff --git a/.gitignore b/.gitignore index d4b60b3..f9a718d 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,8 @@ frontend/.env.*.prod frontend/.env.*.test frontend/.env.*.dev frontend/.env.*.staging + +volumes/ frontend/.env.*.production frontend/.env.*.development frontend/.env.*.example diff --git a/backend/Dockerfile b/backend/Dockerfile index 4ba5499..e119d9d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,6 +12,7 @@ RUN apt update && apt install -y \ ffmpeg \ ca-certificates \ curl \ + libmagic1 \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt install -y nodejs \ && update-ca-certificates \ diff --git a/backend/account/migrations/0001_initial.py b/backend/account/migrations/0001_initial.py index 3730b23..b44a964 100644 --- a/backend/account/migrations/0001_initial.py +++ b/backend/account/migrations/0001_initial.py @@ -1,9 +1,11 @@ -# Generated by Django 5.2.7 on 2026-01-24 22:44 +# Generated by Django 5.2.7 on 2026-04-20 17:54 import account.models import django.contrib.auth.validators import django.core.validators +import django.db.models.deletion import django.utils.timezone +from django.conf import settings from django.db import migrations, models @@ -59,4 +61,16 @@ class Migration(migrations.Migration): ('active', account.models.ActiveUserManager()), ], ), + migrations.CreateModel( + name='UserBlock', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('blocked_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocked_by', to=settings.AUTH_USER_MODEL)), + ('blocker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocking', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('blocker', 'blocked_user')}, + }, + ), ] diff --git a/backend/account/migrations/0002_userblock.py b/backend/account/migrations/0002_userblock.py deleted file mode 100644 index eead792..0000000 --- a/backend/account/migrations/0002_userblock.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.7 on 2026-04-19 21:51 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('account', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='UserBlock', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('blocked_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocked_by', to=settings.AUTH_USER_MODEL)), - ('blocker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocking', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'unique_together': {('blocker', 'blocked_user')}, - }, - ), - ] diff --git a/backend/advertisement/migrations/0001_initial.py b/backend/advertisement/migrations/0001_initial.py index c875f9a..ed64dcd 100644 --- a/backend/advertisement/migrations/0001_initial.py +++ b/backend/advertisement/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2026-01-24 22:44 +# Generated by Django 5.2.7 on 2026-04-20 17:54 from django.db import migrations, models diff --git a/backend/commerce/migrations/0001_initial.py b/backend/commerce/migrations/0001_initial.py index 2e13281..d1fc548 100644 --- a/backend/commerce/migrations/0001_initial.py +++ b/backend/commerce/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2026-04-19 21:51 +# Generated by Django 5.2.7 on 2026-04-20 17:54 import django.core.validators import django.db.models.deletion @@ -14,9 +14,9 @@ class Migration(migrations.Migration): dependencies = [ ('configuration', '0001_initial'), - ('deutschepost', '0002_deutschepostbulkorder_bulk_label_pdf_and_more'), + ('deutschepost', '0001_initial'), ('stripe', '0001_initial'), - ('zasilkovna', '0002_alter_zasilkovnapacket_state'), + ('zasilkovna', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] diff --git a/backups/backup-20260101-132533.sql b/backend/configuration/management/__init__.py similarity index 100% rename from backups/backup-20260101-132533.sql rename to backend/configuration/management/__init__.py diff --git a/backend/configuration/management/commands/__init__.py b/backend/configuration/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/configuration/management/commands/seed_app_config.py b/backend/configuration/management/commands/seed_app_config.py new file mode 100644 index 0000000..3a7e8b3 --- /dev/null +++ b/backend/configuration/management/commands/seed_app_config.py @@ -0,0 +1,20 @@ +from decimal import Decimal +from django.core.management.base import BaseCommand +from configuration.models import SiteConfiguration, VATRate + + +class Command(BaseCommand): + help = "Ensure SiteConfiguration singleton and default VAT rates exist." + + def handle(self, *args, **kwargs): + config, created = SiteConfiguration.objects.get_or_create(pk=1) + if created: + self.stdout.write(self.style.SUCCESS("Created default SiteConfiguration.")) + else: + self.stdout.write("SiteConfiguration already exists.") + + if not VATRate.objects.filter(is_active=True).exists(): + VATRate.objects.create(name="Standard", rate=Decimal('21.0000'), is_default=True, is_active=True) + self.stdout.write(self.style.SUCCESS("Created default VAT rate (21%).")) + else: + self.stdout.write("VAT rates already exist.") diff --git a/backend/configuration/migrations/0001_initial.py b/backend/configuration/migrations/0001_initial.py index d3f9406..4765444 100644 --- a/backend/configuration/migrations/0001_initial.py +++ b/backend/configuration/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2026-01-24 22:44 +# Generated by Django 5.2.7 on 2026-04-20 17:54 import django.core.validators from decimal import Decimal diff --git a/backend/configuration/migrations/0002_alter_vatrate_rate.py b/backend/configuration/migrations/0002_alter_vatrate_rate.py new file mode 100644 index 0000000..24d0336 --- /dev/null +++ b/backend/configuration/migrations/0002_alter_vatrate_rate.py @@ -0,0 +1,26 @@ +import django.core.validators +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('configuration', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='vatrate', + name='rate', + field=models.DecimalField( + decimal_places=4, + help_text='VAT rate as percentage (e.g. 19.00 for 19%)', + max_digits=6, + validators=[ + django.core.validators.MinValueValidator(Decimal('0')), + django.core.validators.MaxValueValidator(Decimal('100')), + ], + ), + ), + ] diff --git a/backend/configuration/models.py b/backend/configuration/models.py index a21d89b..92ed18b 100644 --- a/backend/configuration/models.py +++ b/backend/configuration/models.py @@ -83,7 +83,7 @@ class VATRate(models.Model): ) rate = models.DecimalField( - max_digits=5, + max_digits=6, decimal_places=4, # Allows rates like 19.5000% validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('100'))], help_text="VAT rate as percentage (e.g. 19.00 for 19%)" diff --git a/backend/social/chat/migrations/0001_initial.py b/backend/social/chat/migrations/0001_initial.py index 4663f07..fd3cf43 100644 --- a/backend/social/chat/migrations/0001_initial.py +++ b/backend/social/chat/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 5.2.7 on 2026-01-24 22:44 +# Generated by Django 5.2.7 on 2026-04-20 17:54 import django.db.models.deletion +import vontor_cz.custom_fields from django.conf import settings from django.db import migrations, models @@ -10,6 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('hubs', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -18,17 +20,29 @@ class Migration(migrations.Migration): name='Chat', 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)), + ('chat_type', models.CharField(choices=[('DM', 'Direct Message'), ('GROUP', 'Group')], default='GROUP', max_length=10)), + ('name', models.CharField(blank=True, max_length=255)), + ('icon', vontor_cz.custom_fields.WebPImageField(blank=True, null=True, upload_to='chat_icons/')), + ('banner', vontor_cz.custom_fields.WebPImageField(blank=True, null=True, upload_to='chat_banners/')), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), + ('hub', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hubs.hub')), ('members', models.ManyToManyField(blank=True, related_name='chats', to=settings.AUTH_USER_MODEL)), ('moderators', models.ManyToManyField(blank=True, related_name='moderated_chats', to=settings.AUTH_USER_MODEL)), ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_chats', to=settings.AUTH_USER_MODEL)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='Message', 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)), ('content', models.TextField(blank=True)), ('is_edited', models.BooleanField(default=False)), ('edited_at', models.DateTimeField(blank=True, null=True)), @@ -36,23 +50,33 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True)), ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.chat')), ('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replies', to='chat.message')), - ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='MessageFile', 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)), ('file', models.FileField(upload_to='chat_uploads/%Y/%m/%d/')), ('media_type', models.CharField(choices=[('IMAGE', 'Image'), ('VIDEO', 'Video'), ('FILE', 'File')], default='FILE', max_length=20)), ('uploaded_at', models.DateTimeField(auto_now_add=True)), ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='chat.message')), ], + options={ + 'abstract': False, + }, ), migrations.CreateModel( name='MessageHistory', 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)), ('old_content', models.TextField()), ('archived_at', models.DateTimeField(auto_now_add=True)), ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edit_history', to='chat.message')), @@ -65,6 +89,8 @@ class Migration(migrations.Migration): name='MessageReaction', 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)), ('emoji', models.CharField(max_length=10)), ('created_at', models.DateTimeField(auto_now_add=True)), ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='chat.message')), diff --git a/backend/social/chat/migrations/0002_chat_banner_chat_chat_type_chat_deleted_at_chat_hub_and_more.py b/backend/social/chat/migrations/0002_chat_banner_chat_chat_type_chat_deleted_at_chat_hub_and_more.py deleted file mode 100644 index 330032f..0000000 --- a/backend/social/chat/migrations/0002_chat_banner_chat_chat_type_chat_deleted_at_chat_hub_and_more.py +++ /dev/null @@ -1,98 +0,0 @@ -# Generated by Django 5.2.7 on 2026-04-19 21:51 - -import django.db.models.deletion -import vontor_cz.custom_fields -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('chat', '0001_initial'), - ('hubs', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='chat', - name='banner', - field=vontor_cz.custom_fields.WebPImageField(blank=True, null=True, upload_to='chat_banners/'), - ), - migrations.AddField( - model_name='chat', - name='chat_type', - field=models.CharField(choices=[('DM', 'Direct Message'), ('GROUP', 'Group')], default='GROUP', max_length=10), - ), - migrations.AddField( - model_name='chat', - name='deleted_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='chat', - name='hub', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hubs.hub'), - ), - migrations.AddField( - model_name='chat', - name='icon', - field=vontor_cz.custom_fields.WebPImageField(blank=True, null=True, upload_to='chat_icons/'), - ), - migrations.AddField( - model_name='chat', - name='is_deleted', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='chat', - name='name', - field=models.CharField(blank=True, max_length=255), - ), - migrations.AddField( - model_name='message', - name='deleted_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='message', - name='is_deleted', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='messagefile', - name='deleted_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='messagefile', - name='is_deleted', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='messagehistory', - name='deleted_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='messagehistory', - name='is_deleted', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='messagereaction', - name='deleted_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='messagereaction', - name='is_deleted', - field=models.BooleanField(default=False), - ), - migrations.AlterField( - model_name='message', - name='sender', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/backend/social/hubs/migrations/0001_initial.py b/backend/social/hubs/migrations/0001_initial.py index 44ac303..dd6a08e 100644 --- a/backend/social/hubs/migrations/0001_initial.py +++ b/backend/social/hubs/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2026-04-19 21:51 +# Generated by Django 5.2.7 on 2026-04-20 17:54 import django.db.models.deletion import vontor_cz.custom_fields diff --git a/backend/social/hubs/models.py b/backend/social/hubs/models.py index d43c924..9eb3d6b 100644 --- a/backend/social/hubs/models.py +++ b/backend/social/hubs/models.py @@ -1,4 +1,3 @@ -from turtle import color import uuid from django.db import models from django.conf import settings diff --git a/backend/social/posts/migrations/0001_initial.py b/backend/social/posts/migrations/0001_initial.py index 1530b76..f010009 100644 --- a/backend/social/posts/migrations/0001_initial.py +++ b/backend/social/posts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2026-04-19 21:51 +# Generated by Django 5.2.7 on 2026-04-20 17:54 import django.db.models.deletion from django.conf import settings diff --git a/backend/thirdparty/deutschepost/migrations/0001_initial.py b/backend/thirdparty/deutschepost/migrations/0001_initial.py index d8a25e6..7286e43 100644 --- a/backend/thirdparty/deutschepost/migrations/0001_initial.py +++ b/backend/thirdparty/deutschepost/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2026-01-17 01:37 +# Generated by Django 5.2.7 on 2026-04-20 17:54 from django.db import migrations, models @@ -16,7 +16,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), - ('state', models.CharField(choices=[('CREATED', 'cz#Vytvořeno'), ('FINALIZED', 'cz#Dokončeno'), ('SHIPPED', 'cz#Odesláno'), ('DELIVERED', 'cz#Doručeno'), ('CANCELLED', 'cz#Zrušeno'), ('ERROR', 'cz#Chyba')], default='CREATED', max_length=20)), + ('state', models.CharField(choices=[('CREATED', 'Vytvořeno'), ('FINALIZED', 'Dokončeno'), ('SHIPPED', 'Odesláno'), ('DELIVERED', 'Doručeno'), ('CANCELLED', 'Zrušeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20)), ('order_id', models.CharField(blank=True, help_text='Deutsche Post order ID from API', max_length=50, null=True)), ('customer_ekp', models.CharField(blank=True, max_length=20, null=True)), ('recipient_name', models.CharField(max_length=200)), @@ -43,6 +43,8 @@ class Migration(migrations.Migration): ('tracking_url', models.URLField(blank=True, null=True)), ('metadata', models.JSONField(blank=True, default=dict, help_text='Raw API response data')), ('last_error', models.TextField(blank=True, help_text='Last API error message')), + ('label_pdf', models.FileField(blank=True, help_text='Shipping label PDF', null=True, upload_to='deutschepost/labels/')), + ('label_size', models.CharField(choices=[('A4', 'A4 (210x297mm)'), ('A5', 'A5 (148x210mm)'), ('A6', 'A6 (105x148mm)')], default='A4', max_length=10)), ], ), migrations.CreateModel( @@ -50,12 +52,14 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), - ('status', models.CharField(choices=[('CREATED', 'cz#Vytvořeno'), ('PROCESSING', 'cz#Zpracovává se'), ('COMPLETED', 'cz#Dokončeno'), ('ERROR', 'cz#Chyba')], default='CREATED', max_length=20)), + ('status', models.CharField(choices=[('CREATED', 'Vytvořeno'), ('PROCESSING', 'Zpracovává se'), ('COMPLETED', 'Dokončeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20)), ('bulk_order_id', models.CharField(blank=True, help_text='Deutsche Post bulk order ID from API', max_length=50, null=True)), ('bulk_order_type', models.CharField(default='MIXED_BAG', help_text='MIXED_BAG, etc.', max_length=20)), ('description', models.CharField(blank=True, max_length=255)), ('metadata', models.JSONField(blank=True, default=dict, help_text='Raw API response data')), ('last_error', models.TextField(blank=True, help_text='Last API error message')), + ('bulk_label_pdf', models.FileField(blank=True, help_text='Bulk shipment label PDF', null=True, upload_to='deutschepost/bulk_labels/')), + ('paperwork_pdf', models.FileField(blank=True, help_text='Bulk shipment paperwork PDF', null=True, upload_to='deutschepost/paperwork/')), ('deutschepost_orders', models.ManyToManyField(blank=True, related_name='bulk_orders', to='deutschepost.deutschepostorder')), ], ), diff --git a/backend/thirdparty/deutschepost/migrations/0002_deutschepostbulkorder_bulk_label_pdf_and_more.py b/backend/thirdparty/deutschepost/migrations/0002_deutschepostbulkorder_bulk_label_pdf_and_more.py deleted file mode 100644 index a34517c..0000000 --- a/backend/thirdparty/deutschepost/migrations/0002_deutschepostbulkorder_bulk_label_pdf_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.2.7 on 2026-01-24 22:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('deutschepost', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='deutschepostbulkorder', - name='bulk_label_pdf', - field=models.FileField(blank=True, help_text='Bulk shipment label PDF', null=True, upload_to='deutschepost/bulk_labels/'), - ), - migrations.AddField( - model_name='deutschepostbulkorder', - name='paperwork_pdf', - field=models.FileField(blank=True, help_text='Bulk shipment paperwork PDF', null=True, upload_to='deutschepost/paperwork/'), - ), - migrations.AddField( - model_name='deutschepostorder', - name='label_pdf', - field=models.FileField(blank=True, help_text='Shipping label PDF', null=True, upload_to='deutschepost/labels/'), - ), - migrations.AddField( - model_name='deutschepostorder', - name='label_size', - field=models.CharField(choices=[('A4', 'A4 (210x297mm)'), ('A5', 'A5 (148x210mm)'), ('A6', 'A6 (105x148mm)')], default='A4', max_length=10), - ), - migrations.AlterField( - model_name='deutschepostbulkorder', - name='status', - field=models.CharField(choices=[('CREATED', 'Vytvořeno'), ('PROCESSING', 'Zpracovává se'), ('COMPLETED', 'Dokončeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20), - ), - migrations.AlterField( - model_name='deutschepostorder', - name='state', - field=models.CharField(choices=[('CREATED', 'Vytvořeno'), ('FINALIZED', 'Dokončeno'), ('SHIPPED', 'Odesláno'), ('DELIVERED', 'Doručeno'), ('CANCELLED', 'Zrušeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20), - ), - ] diff --git a/backend/thirdparty/downloader/consumers.py b/backend/thirdparty/downloader/consumers.py new file mode 100644 index 0000000..aefd91b --- /dev/null +++ b/backend/thirdparty/downloader/consumers.py @@ -0,0 +1,245 @@ +import asyncio +import json +import mimetypes +import os +import shutil +import tempfile +import zipfile +from urllib.parse import urlparse + +import yt_dlp +from channels.generic.websocket import AsyncWebsocketConsumer +from django.conf import settings +from django.core import signing +from django.utils.text import slugify + +YDL_BASE = { + "quiet": True, + "no_check_certificates": True, + "js_runtimes": {"node": {}}, + "remote_components": {"ejs:github"}, +} + +TOKEN_TTL = 600 # seconds until signed download token expires + + +class DownloaderConsumer(AsyncWebsocketConsumer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tmpdir = None + + async def connect(self): + await self.accept() + + async def disconnect(self, code): + if self.tmpdir and os.path.exists(self.tmpdir): + shutil.rmtree(self.tmpdir, ignore_errors=True) + self.tmpdir = None + + async def receive(self, text_data): + try: + params = json.loads(text_data) + except (json.JSONDecodeError, TypeError): + await self._send({"type": "error", "message": "Invalid JSON"}) + return + + url = params.get("url") + if not url: + await self._send({"type": "error", "message": "URL is required"}) + return + + ext = params.get("ext", "mp4") + if not ext or not isinstance(ext, str): + await self._send({"type": "error", "message": "Invalid extension"}) + return + + os.makedirs(settings.DOWNLOADER_TMP_DIR, exist_ok=True) + self.tmpdir = tempfile.mkdtemp(prefix="dl_ws_", dir=settings.DOWNLOADER_TMP_DIR) + + await self._send({"type": "status", "message": "Starting…"}) + + loop = asyncio.get_event_loop() + + def progress_hook(d): + status = d.get("status") + if status == "downloading": + percent = (d.get("_percent_str") or "").strip() + speed = (d.get("_speed_str") or "").strip() + eta = (d.get("_eta_str") or "").strip() + downloaded = d.get("downloaded_bytes", 0) + total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0 + pct_num = round(downloaded / total * 100, 1) if total else None + asyncio.run_coroutine_threadsafe( + self._send({ + "type": "progress", + "message": f"Downloading: {percent} at {speed} ETA {eta}", + "percent": pct_num, + "speed": speed, + "eta": eta, + }), + loop, + ) + elif status == "finished": + asyncio.run_coroutine_threadsafe( + self._send({"type": "status", "message": "Post-processing…"}), + loop, + ) + + try: + result = await asyncio.to_thread( + self._run_download, + url, + ext, + params.get("video_quality"), + params.get("audio_quality"), + params.get("selected_videos"), + params.get("subtitles"), + bool(params.get("embed_subtitles", False)), + bool(params.get("embed_thumbnail", False)), + bool(params.get("extract_audio", False)), + params.get("cookies"), + progress_hook, + ) + except Exception as exc: + if self.tmpdir: + shutil.rmtree(self.tmpdir, ignore_errors=True) + self.tmpdir = None + await self._send({"type": "error", "message": str(exc)}) + return + + # Sign a token containing the file info — no DB needed. + token = signing.dumps( + { + "file_path": result["file_path"], + "filename": result["filename"], + "content_type": result["content_type"], + "file_size": result["file_size"], + "tmpdir": self.tmpdir, + }, + salt="downloader-file-token", + ) + # Consumer no longer owns cleanup — the HTTP serve view will clean up. + self.tmpdir = None + + await self._send({ + "type": "done", + "token": token, + "filename": result["filename"], + "file_size": result["file_size"], + }) + + # ------------------------------------------------------------------ + def _run_download(self, url, ext, video_quality, audio_quality, + selected_videos, subtitles, embed_subtitles, + embed_thumbnail, extract_audio, cookies, progress_hook): + """Runs synchronously inside a thread pool worker.""" + tmpdir = self.tmpdir + + # Format selector + if video_quality and audio_quality: + fmt = f"bv[height<={video_quality}]+ba[abr<={audio_quality}]/b" + elif video_quality: + fmt = f"bv[height<={video_quality}]+ba/b" + elif audio_quality: + fmt = f"bv+ba[abr<={audio_quality}]/b" + else: + fmt = "b/bv+ba" + + ydl_opts = { + **YDL_BASE, + "format": fmt, + "merge_output_format": ext, + "max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES, + "postprocessors": [], + "progress_hooks": [progress_hook], + } + + if cookies: + cookie_file = os.path.join(tmpdir, "cookies.txt") + with open(cookie_file, "w") as f: + f.write(cookies) + ydl_opts["cookiefile"] = cookie_file + + if subtitles: + if subtitles.lower() == "all": + ydl_opts.update({ + "writesubtitles": True, + "writeautomaticsub": True, + "subtitleslangs": ["all"], + }) + else: + ydl_opts.update({ + "writesubtitles": True, + "subtitleslangs": [l.strip() for l in subtitles.split(",")], + }) + + if embed_subtitles and subtitles and ext in ("mkv", "mp4"): + ydl_opts["postprocessors"].append({"key": "FFmpegEmbedSubtitle"}) + + if embed_thumbnail: + ydl_opts["writethumbnail"] = True + ydl_opts["postprocessors"].append({"key": "EmbedThumbnail"}) + + if extract_audio: + ydl_opts["postprocessors"].append({ + "key": "FFmpegExtractAudio", + "preferredcodec": ext if ext in ("mp3", "m4a", "opus", "vorbis", "wav") else "mp3", + }) + else: + ydl_opts["postprocessors"].append({"key": "FFmpegVideoRemuxer", "preferedformat": ext}) + + # Probe to detect playlist + probe_opts = {**YDL_BASE, "extract_flat": False} + with yt_dlp.YoutubeDL(probe_opts) as ydl: + info = ydl.extract_info(url, download=False) + + is_playlist = "entries" in info and info.get("entries") is not None + + if is_playlist: + if selected_videos: + ydl_opts["playlist_items"] = ",".join(str(n) for n in selected_videos) + ydl_opts["outtmpl"] = os.path.join(tmpdir, "%(playlist_index)02d - %(title)s.%(ext)s") + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + + zip_path = os.path.join(tmpdir, "playlist.zip") + files = [ + (fn, os.path.join(tmpdir, fn)) + for fn in os.listdir(tmpdir) + if fn not in ("playlist.zip", "cookies.txt") + and not fn.startswith(".") + and os.path.isfile(os.path.join(tmpdir, fn)) + ] + if not files: + raise RuntimeError("No files were downloaded from the playlist") + + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for fn, fp in files: + zf.write(fp, fn) + + playlist_title = slugify(info.get("title", "playlist")) + return { + "file_path": zip_path, + "filename": f"{playlist_title}.zip", + "content_type": "application/zip", + "file_size": os.path.getsize(zip_path), + } + else: + ydl_opts["outtmpl"] = os.path.join(tmpdir, "download.%(ext)s") + with yt_dlp.YoutubeDL(ydl_opts) 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}" + + safe_title = slugify(info.get("title") or "video") + filename = f"{safe_title}.{ext}" + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + return { + "file_path": file_path, + "filename": filename, + "content_type": content_type, + "file_size": os.path.getsize(file_path) if os.path.exists(file_path) else 0, + } + + async def _send(self, data: dict): + await self.send(text_data=json.dumps(data)) diff --git a/backend/thirdparty/downloader/migrations/0001_initial.py b/backend/thirdparty/downloader/migrations/0001_initial.py index 3274053..2e56f8c 100644 --- a/backend/thirdparty/downloader/migrations/0001_initial.py +++ b/backend/thirdparty/downloader/migrations/0001_initial.py @@ -1,5 +1,7 @@ -# Generated by Django 5.2.7 on 2025-12-18 15:11 +# Generated by Django 5.2.7 on 2026-04-20 17:54 +import django.db.models.deletion +from django.conf import settings from django.db import migrations, models @@ -8,6 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -18,13 +21,21 @@ class Migration(migrations.Migration): ('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)), + ('title', models.CharField(blank=True, default='', max_length=500)), + ('platform', models.CharField(blank=True, default='', help_text='e.g. youtube, tiktok, vimeo', max_length=100)), ('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')), + ('video_quality', models.IntegerField(blank=True, help_text='Video height in pixels (e.g. 1080). Null for audio-only.', null=True)), + ('is_audio_only', models.BooleanField(default=False)), + ('length_of_media', models.IntegerField(blank=True, help_text='Length of media in seconds', null=True)), + ('file_size', models.BigIntegerField(blank=True, help_text='File size in bytes', null=True)), + ('processing_time', models.FloatField(blank=True, help_text='Server-side processing time in seconds', null=True)), + ('success', models.BooleanField(default=True)), + ('error_message', models.TextField(blank=True, default='')), + ('download_time', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='downloads', to=settings.AUTH_USER_MODEL)), ], options={ - 'abstract': False, + 'ordering': ['-download_time'], }, ), ] diff --git a/backend/thirdparty/downloader/migrations/0002_alter_downloaderrecord_options_and_more.py b/backend/thirdparty/downloader/migrations/0002_alter_downloaderrecord_options_and_more.py deleted file mode 100644 index c5d0971..0000000 --- a/backend/thirdparty/downloader/migrations/0002_alter_downloaderrecord_options_and_more.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 5.2.7 on 2026-04-19 21:51 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('downloader', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterModelOptions( - name='downloaderrecord', - options={'ordering': ['-download_time']}, - ), - migrations.AddField( - model_name='downloaderrecord', - name='error_message', - field=models.TextField(blank=True, default=''), - ), - migrations.AddField( - model_name='downloaderrecord', - name='is_audio_only', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='downloaderrecord', - name='platform', - field=models.CharField(blank=True, default='', help_text='e.g. youtube, tiktok, vimeo', max_length=100), - ), - migrations.AddField( - model_name='downloaderrecord', - name='processing_time', - field=models.FloatField(blank=True, help_text='Server-side processing time in seconds', null=True), - ), - migrations.AddField( - model_name='downloaderrecord', - name='success', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='downloaderrecord', - name='title', - field=models.CharField(blank=True, default='', max_length=500), - ), - migrations.AddField( - model_name='downloaderrecord', - name='user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='downloads', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='downloaderrecord', - name='video_quality', - field=models.IntegerField(blank=True, help_text='Video height in pixels (e.g. 1080). Null for audio-only.', null=True), - ), - migrations.AlterField( - model_name='downloaderrecord', - name='file_size', - field=models.BigIntegerField(blank=True, help_text='File size in bytes', null=True), - ), - migrations.AlterField( - model_name='downloaderrecord', - name='length_of_media', - field=models.IntegerField(blank=True, help_text='Length of media in seconds', null=True), - ), - ] diff --git a/backend/thirdparty/downloader/migrations/0002_downloadjob.py b/backend/thirdparty/downloader/migrations/0002_downloadjob.py new file mode 100644 index 0000000..1ffde9c --- /dev/null +++ b/backend/thirdparty/downloader/migrations/0002_downloadjob.py @@ -0,0 +1,30 @@ +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('downloader', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DownloadJob', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField( + choices=[('pending', 'Pending'), ('processing', 'Processing'), ('done', 'Done'), ('error', 'Error')], + default='pending', max_length=20, + )), + ('message', models.CharField(blank=True, default='', max_length=500)), + ('file_path', models.CharField(blank=True, default='', max_length=1000)), + ('filename', models.CharField(blank=True, default='', max_length=500)), + ('content_type', models.CharField(blank=True, default='application/octet-stream', max_length=100)), + ('file_size', models.BigIntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={'ordering': ['-created_at']}, + ), + ] diff --git a/backend/thirdparty/downloader/migrations/0003_auto_20260420_2214.py b/backend/thirdparty/downloader/migrations/0003_auto_20260420_2214.py new file mode 100644 index 0000000..64f3fbc --- /dev/null +++ b/backend/thirdparty/downloader/migrations/0003_auto_20260420_2214.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.7 on 2026-04-20 20:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('downloader', '0002_downloadjob'), + ] + + operations = [ + migrations.DeleteModel( + name='DownloadJob', + ), + ] diff --git a/backend/thirdparty/downloader/routing.py b/backend/thirdparty/downloader/routing.py new file mode 100644 index 0000000..b4c178c --- /dev/null +++ b/backend/thirdparty/downloader/routing.py @@ -0,0 +1,6 @@ +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + re_path(r"ws/downloader/$", consumers.DownloaderConsumer.as_asgi()), +] diff --git a/backend/thirdparty/downloader/urls.py b/backend/thirdparty/downloader/urls.py index 40ac942..93f7108 100644 --- a/backend/thirdparty/downloader/urls.py +++ b/backend/thirdparty/downloader/urls.py @@ -4,6 +4,6 @@ from .views import Downloader, DownloaderStats urlpatterns = [ # Probe formats for a URL (size-checked) path("download/", Downloader.as_view(), name="downloader-download"), - + path("download/file/", Downloader.as_view(), name="downloader-file"), path("stats/", DownloaderStats.as_view(), name="downloader-stats"), ] diff --git a/backend/thirdparty/downloader/views.py b/backend/thirdparty/downloader/views.py index 0eca42b..6938835 100644 --- a/backend/thirdparty/downloader/views.py +++ b/backend/thirdparty/downloader/views.py @@ -1,31 +1,27 @@ # ---------------------- Inline serializers for documentation only ---------------------- # Using inline_serializer to avoid creating new files. -import yt_dlp -import tempfile +import asyncio import os import shutil -import mimetypes -import base64 -import urllib.request -import zipfile -import time + +import yt_dlp import requests +import base64 from urllib.parse import urlparse +from .consumers import TOKEN_TTL + +from django.core import signing 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 rest_framework.permissions import 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 # Common container formats - user can provide any extension supported by ffmpeg @@ -122,6 +118,8 @@ class Downloader(APIView): "no_check_certificates": True, # Bypass SSL verification in Docker "extract_flat": False, # Extract full info for playlists too "ignoreerrors": False, # Don't ignore errors to get accurate info + "js_runtimes": {"node": {}}, + "remote_components": {"ejs:github"}, } try: @@ -233,381 +231,58 @@ class Downloader(APIView): @extend_schema( tags=["downloader", "public"], - summary="Download video or playlist from URL", - description=""" - Download video/playlist with optional quality constraints and container format conversion. - - **For Playlists:** - - Returns a ZIP file containing all selected videos - - Use `selected_videos` to specify which videos to download (e.g., [1,3,5] or [1,2,3,4,5]) - - If `selected_videos` is not provided, all videos in the playlist will be downloaded - - **Quality Parameters (optional):** - - If not specified, yt-dlp will automatically select the best available quality. - - `video_quality`: Maximum video height in pixels (e.g., 1080, 720, 480). - - `audio_quality`: Maximum audio bitrate in kbps (e.g., 320, 192, 128). - - **Format/Extension:** - - Any format supported by ffmpeg (mp4, mkv, webm, avi, mov, flv, m4a, mp3, etc.). - - Defaults to 'mp4' if not specified. - - The conversion is handled automatically by ffmpeg in the background. - - **Advanced Options:** - - `subtitles`: Download subtitles (language codes like 'en,cs' or 'all') - - `embed_subtitles`: Embed subtitles into video file - - `embed_thumbnail`: Embed thumbnail as cover art - - `extract_audio`: Extract audio only (ignores video quality) - - `cookies`: Browser cookies for age-restricted content (Netscape format) - """, - request=inline_serializer( - name="DownloadRequest", - fields={ - "url": serializers.URLField(help_text="Video/Playlist URL to download from supported platforms"), - "ext": serializers.CharField( - required=False, - default="mp4", - help_text=FORMAT_HELP, - ), - "video_quality": serializers.IntegerField( - required=False, - allow_null=True, - help_text="Optional: Target max video height in pixels (e.g. 1080, 720). If omitted, best quality is selected." - ), - "audio_quality": serializers.IntegerField( - required=False, - allow_null=True, - help_text="Optional: Target max audio bitrate in kbps (e.g. 320, 192, 128). If omitted, best quality is selected." - ), - "selected_videos": serializers.ListField( - child=serializers.IntegerField(), - required=False, - allow_null=True, - allow_empty=True, - help_text="For playlists: specify which videos to download as array of numbers (e.g., [1,3,5]). If omitted, all videos are downloaded." - ), - "subtitles": serializers.CharField( - required=False, - allow_null=True, - allow_blank=True, - help_text="Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles" - ), - "embed_subtitles": serializers.BooleanField( - required=False, - default=False, - help_text="Embed subtitles into the video file (requires mkv or mp4 container)" - ), - "embed_thumbnail": serializers.BooleanField( - required=False, - default=False, - help_text="Embed thumbnail as cover art in the file" - ), - "extract_audio": serializers.BooleanField( - required=False, - default=False, - help_text="Extract audio only, ignoring video quality settings" - ), - "cookies": serializers.CharField( - required=False, - allow_null=True, - allow_blank=True, - help_text="Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt'" - ), - }, - ), + summary="Download file via signed token", + description="Serve a file using a signed token from WebSocket download. Token expires in 10 minutes.", + parameters=[ + inline_serializer( + name="DownloadTokenParams", + fields={ + "token": serializers.CharField(help_text="Signed token containing file info"), + }, + ) + ], responses={ 200: OpenApiTypes.BINARY, - 400: inline_serializer( - name="DownloadErrorResponse", - fields={ - "error": serializers.CharField(), - }, - ), + 400: inline_serializer(name="TokenError", fields={"error": serializers.CharField()}), }, ) def post(self, request): - url = request.data.get("url") - # Accept ext parameter, default to mp4 - ext = request.data.get("ext", "mp4") - - # Optional quality parameters - only parse if provided - video_quality = None - audio_quality = None - - if request.data.get("video_quality"): - try: - video_quality = int(request.data.get("video_quality")) - except (ValueError, TypeError): - return Response({"error": "Invalid video_quality parameter, must be an integer!"}, status=400) - - if request.data.get("audio_quality"): - try: - audio_quality = int(request.data.get("audio_quality")) - except (ValueError, TypeError): - return Response({"error": "Invalid audio_quality parameter, must be an integer!"}, status=400) - - # Advanced options (removed start_time and end_time) - selected_videos = request.data.get("selected_videos") - subtitles = request.data.get("subtitles") - embed_subtitles = request.data.get("embed_subtitles", False) - embed_thumbnail = request.data.get("embed_thumbnail", False) - extract_audio = request.data.get("extract_audio", False) - cookies = request.data.get("cookies") - - if not url: - return Response({"error": "URL is required"}, status=400) - if not ext or not isinstance(ext, str): - return Response({"error": "Extension must be a valid string"}, 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) - - # First, check if this is a playlist - ydl_info_options = { - "quiet": True, - "no_check_certificates": True, - "extract_flat": False, - } - - try: - with yt_dlp.YoutubeDL(ydl_info_options) as ydl: - info = ydl.extract_info(url, download=False) - except Exception as e: - shutil.rmtree(tmpdir, ignore_errors=True) - return Response({"error": f"Failed to retrieve URL info: {str(e)}"}, status=400) - - is_playlist = "entries" in info and info.get("entries") is not None - - # Build format selector using optional quality caps - if video_quality is not None and audio_quality is not None: - format_selector = f"bv[height<={video_quality}]+ba[abr<={audio_quality}]/b" - elif video_quality is not None: - format_selector = f"bv[height<={video_quality}]+ba/b" - elif audio_quality is not None: - format_selector = f"bv+ba[abr<={audio_quality}]/b" - else: - format_selector = "b/bv+ba" - - # Common ydl options - ydl_options = { - "format": format_selector, - "merge_output_format": ext, - "quiet": True, - "no_check_certificates": True, - "max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES, - "postprocessors": [], - } - - # Handle cookies for age-restricted content - if cookies: - cookie_file = os.path.join(tmpdir, "cookies.txt") - try: - with open(cookie_file, "w") as f: - f.write(cookies) - ydl_options["cookiefile"] = cookie_file - except Exception as e: - shutil.rmtree(tmpdir, ignore_errors=True) - return Response({"error": f"Invalid cookies format: {str(e)}"}, status=400) - - # Subtitles - if subtitles: - if subtitles.lower() == "all": - ydl_options["writesubtitles"] = True - ydl_options["writeautomaticsub"] = True - ydl_options["subtitleslangs"] = ["all"] - else: - ydl_options["writesubtitles"] = True - ydl_options["subtitleslangs"] = [lang.strip() for lang in subtitles.split(",")] - - # Embed subtitles (only for mkv/mp4) - if embed_subtitles and subtitles: - if ext in ["mkv", "mp4"]: - ydl_options["postprocessors"].append({"key": "FFmpegEmbedSubtitle"}) - else: - shutil.rmtree(tmpdir, ignore_errors=True) - return Response({"error": "Subtitle embedding requires mkv or mp4 format"}, status=400) - - # Embed thumbnail - if embed_thumbnail: - ydl_options["writethumbnail"] = True - ydl_options["postprocessors"].append({"key": "EmbedThumbnail"}) - - # Extract audio only - if extract_audio: - ydl_options["postprocessors"].append({ - "key": "FFmpegExtractAudio", - "preferredcodec": ext if ext in ["mp3", "m4a", "opus", "vorbis", "wav"] else "mp3", - }) - - # Playlist items (use selected_videos parameter) - if is_playlist and selected_videos: - # Convert array of numbers to yt-dlp format string - playlist_items_str = ",".join(str(num) for num in selected_videos) - ydl_options["playlist_items"] = playlist_items_str - - # Add remux postprocessor if not extracting audio - if not extract_audio: - ydl_options["postprocessors"].append( - {"key": "FFmpegVideoRemuxer", "preferedformat": ext} - ) - - _platform = (info.get('extractor') or urlparse(url).netloc).lower().replace('www.', '').split(':')[0] - _user = request.user if request.user.is_authenticated else None - _title = info.get('title', '') - start_time = time.time() + token = request.data.get("token") + if not token: + return Response({"error": "Token is required"}, status=400) try: - if is_playlist: - # Handle playlist - create ZIP file - ydl_options["outtmpl"] = os.path.join(tmpdir, "%(playlist_index)02d - %(title)s.%(ext)s") - - with yt_dlp.YoutubeDL(ydl_options) as ydl: - ydl.download([url]) - - # Create ZIP file - zip_path = os.path.join(tmpdir, f"playlist.zip") - downloaded_files = [] - - # Find all downloaded files - for filename in os.listdir(tmpdir): - if filename != "playlist.zip" and filename != "cookies.txt" and not filename.startswith("."): - file_path = os.path.join(tmpdir, filename) - if os.path.isfile(file_path): - downloaded_files.append((filename, file_path)) - - if not downloaded_files: - shutil.rmtree(tmpdir, ignore_errors=True) - return Response({"error": "No files were downloaded from the playlist"}, status=400) - - # Create ZIP - with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: - for filename, file_path in downloaded_files: - zipf.write(file_path, filename) - - # Stats for database - total_duration = 0 - total_size = os.path.getsize(zip_path) - - # Try to get duration from info - if info.get("entries"): - for entry in info["entries"]: - if entry and entry.get("duration"): - total_duration += int(entry.get("duration", 0)) - - DownloaderRecord.objects.create( - url=url, - title=_title, - platform=_platform, - user=_user, - format='zip', - is_audio_only=bool(extract_audio), - video_quality=video_quality, - length_of_media=total_duration or None, - file_size=total_size or None, - processing_time=round(time.time() - start_time, 3), - success=True, - ) + data = signing.loads(token, salt="downloader-file-token", max_age=TOKEN_TTL) + except signing.BadSignature: + return Response({"error": "Invalid token"}, status=400) + except signing.SignatureExpired: + return Response({"error": "Token expired"}, status=400) - # Streaming response for ZIP - def stream_and_cleanup_zip(zip_file_path: str, temp_dir: str, chunk_size: int = 8192): - try: - with open(zip_file_path, "rb") as f: - while True: - chunk = f.read(chunk_size) - if not chunk: - break - yield chunk - finally: - shutil.rmtree(temp_dir, ignore_errors=True) + file_path = data["file_path"] + tmpdir = data["tmpdir"] - playlist_title = slugify(info.get("title", "playlist")) - zip_filename = f"{playlist_title}.zip" - - response = StreamingHttpResponse( - streaming_content=stream_and_cleanup_zip(zip_path, tmpdir), - content_type="application/zip", - ) - response["Content-Length"] = str(total_size) - response["Content-Disposition"] = f'attachment; filename="{zip_filename}"' - - return response - - else: - # Handle single video (existing logic) - outtmpl = os.path.join(tmpdir, "download.%(ext)s") - ydl_options["outtmpl"] = outtmpl - - 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, - title=_title, - platform=_platform, - user=_user, - format=ext, - is_audio_only=bool(extract_audio), - video_quality=video_quality, - length_of_media=duration or None, - file_size=size or None, - processing_time=round(time.time() - start_time, 3), - success=True, - ) - - # Streaming generator that deletes file & temp dir after send - 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 = mimetypes.guess_type(filename)[0] or "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) - DownloaderRecord.objects.create( - url=url, - title=_title, - platform=_platform, - user=_user, - format=ext if not is_playlist else 'zip', - is_audio_only=bool(extract_audio), - video_quality=video_quality, - processing_time=round(time.time() - start_time, 3), - success=False, - error_message=str(e), - ) - return Response({"error": str(e)}, status=400) - + if not file_path or not os.path.exists(file_path): + return Response({"error": "File no longer available"}, status=400) + async def stream_and_cleanup(path: str, temp_dir: str, chunk_size: int = 8192): + try: + with open(path, "rb") as f: + while True: + chunk = await asyncio.to_thread(f.read, chunk_size) + if not chunk: + break + yield chunk + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + response = StreamingHttpResponse( + streaming_content=stream_and_cleanup(file_path, tmpdir), + content_type=data["content_type"] or "application/octet-stream", + ) + if data["file_size"]: + response["Content-Length"] = str(data["file_size"]) + response["Content-Disposition"] = f'attachment; filename="{data["filename"]}"' + return response # ---------------- STATS FOR GRAPHS ---------------- @@ -627,4 +302,5 @@ class DownloaderStats(APIView): responses={200: DownloaderStatsSerializer}, ) def get(self, request): - return Response(DownloaderStatsSerializer(DownloaderRecord.objects.all()).data) \ No newline at end of file + return Response(DownloaderStatsSerializer(DownloaderRecord.objects.all()).data) + diff --git a/backend/thirdparty/gopay/migrations/0001_initial.py b/backend/thirdparty/gopay/migrations/0001_initial.py index 69dc43a..79d158c 100644 --- a/backend/thirdparty/gopay/migrations/0001_initial.py +++ b/backend/thirdparty/gopay/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-12-18 15:11 +# Generated by Django 5.2.7 on 2026-04-20 17:54 import django.db.models.deletion import django.utils.timezone diff --git a/backend/thirdparty/stripe/migrations/0001_initial.py b/backend/thirdparty/stripe/migrations/0001_initial.py index 871444c..2979fc8 100644 --- a/backend/thirdparty/stripe/migrations/0001_initial.py +++ b/backend/thirdparty/stripe/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-12-18 15:11 +# Generated by Django 5.2.7 on 2026-04-20 17:54 from django.db import migrations, models diff --git a/backend/thirdparty/zasilkovna/migrations/0001_initial.py b/backend/thirdparty/zasilkovna/migrations/0001_initial.py index 556f9ac..0e186c6 100644 --- a/backend/thirdparty/zasilkovna/migrations/0001_initial.py +++ b/backend/thirdparty/zasilkovna/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-12-18 15:11 +# Generated by Django 5.2.7 on 2026-04-20 17:54 import django.core.validators from django.db import migrations, models @@ -17,7 +17,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), - ('state', models.CharField(choices=[('WAITING_FOR_ORDERING_SHIPMENT', 'cz#Čeká na objednání zásilkovny'), ('PENDING', 'cz#Podáno'), ('SENDED', 'cz#Odesláno'), ('ARRIVED', 'cz#Doručeno'), ('CANCELED', 'cz#Zrušeno'), ('RETURNING', 'cz#Posláno zpátky'), ('RETURNED', 'cz#Vráceno')], default='PENDING', max_length=35)), + ('state', models.CharField(choices=[('WAITING_FOR_ORDERING_SHIPMENT', 'Čeká na objednání zásilkovny'), ('PENDING', 'Podáno'), ('SENDED', 'Odesláno'), ('ARRIVED', 'Doručeno'), ('CANCELED', 'Zrušeno'), ('RETURNING', 'Posláno zpátky'), ('RETURNED', 'Vráceno')], default='PENDING', max_length=35)), ('addressId', models.IntegerField(blank=True, help_text='ID adresy/pointu, ve Widgetu zásilkovny který si vybere uživatel.', null=True)), ('packet_id', models.IntegerField(blank=True, help_text='Číslo zásilky v Packetě (vraceno od API od Packety)', null=True)), ('barcode', models.CharField(blank=True, help_text='Čárový kód zásilky od Packety', max_length=64, null=True)), diff --git a/backend/thirdparty/zasilkovna/migrations/0002_alter_zasilkovnapacket_state.py b/backend/thirdparty/zasilkovna/migrations/0002_alter_zasilkovnapacket_state.py deleted file mode 100644 index 82cdbf6..0000000 --- a/backend/thirdparty/zasilkovna/migrations/0002_alter_zasilkovnapacket_state.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.7 on 2026-01-24 22:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('zasilkovna', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='zasilkovnapacket', - name='state', - field=models.CharField(choices=[('WAITING_FOR_ORDERING_SHIPMENT', 'Čeká na objednání zásilkovny'), ('PENDING', 'Podáno'), ('SENDED', 'Odesláno'), ('ARRIVED', 'Doručeno'), ('CANCELED', 'Zrušeno'), ('RETURNING', 'Posláno zpátky'), ('RETURNED', 'Vráceno')], default='PENDING', max_length=35), - ), - ] diff --git a/backend/vontor_cz/asgi.py b/backend/vontor_cz/asgi.py index cb64122..ea430fc 100644 --- a/backend/vontor_cz/asgi.py +++ b/backend/vontor_cz/asgi.py @@ -8,18 +8,24 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ """ import os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vontor_cz.settings') + from django.core.asgi import get_asgi_application + +# Initialize Django app registry before importing any models/routing. +django_asgi_app = get_asgi_application() + from channels.routing import ProtocolTypeRouter, URLRouter from channels.auth import AuthMiddlewareStack import social.chat.routing - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vontor_cz.settings') +from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws application = ProtocolTypeRouter({ - "http": get_asgi_application(), + "http": django_asgi_app, "websocket": AuthMiddlewareStack( URLRouter( - social.chat.routing.websocket_urlpatterns + downloader_ws + social.chat.routing.websocket_urlpatterns ) ), }) + diff --git a/docker-compose.yml b/docker-compose.yml index 338b8ed..7565f9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: python manage.py migrate --noinput && python manage.py collectstatic --clear --noinput && python manage.py seed_app_config && - gunicorn -k uvicorn.workers.UvicornWorker trznice.asgi:application --bind 0.0.0.0:8000" + gunicorn -k uvicorn.workers.UvicornWorker vontor_cz.asgi:application --bind 0.0.0.0:8000 --timeout 600 --graceful-timeout 30 --workers 2" ports: - "8000:8000" @@ -56,7 +56,7 @@ services: build: context: ./backend dockerfile: Dockerfile - command: celery -A trznice worker --loglevel=info + command: celery -A vontor_cz worker --loglevel=info restart: always env_file: - ./backend/.env @@ -74,7 +74,7 @@ services: build: context: ./backend dockerfile: Dockerfile - command: celery -A trznice beat --loglevel=info + command: celery -A vontor_cz beat --loglevel=info restart: always env_file: - ./backend/.env @@ -117,7 +117,27 @@ networks: volumes: redis-data: + driver: local + driver_opts: + type: none + o: bind + device: ./volumes/redis db-data: + driver: local + driver_opts: + type: none + o: bind + device: ./volumes/postgres static-data: + driver: local + driver_opts: + type: none + o: bind + device: ./volumes/static media-data: + driver: local + driver_opts: + type: none + o: bind + device: ./volumes/media diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 223c49d..56d234f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import HomeLayout from "./layouts/HomeLayout"; -import ChatLayout from "./layouts/ChatLayout"; +import ChatLayout from "./layouts/social/Chat"; import Downloader from "./pages/downloader/Downloader"; import Home from "./pages/home/home"; @@ -14,8 +14,6 @@ import PortfolioPage from "./pages/portfolio/PortfolioPage"; import ContactPage from "./pages/contact/ContactPage"; import ScrollToTop from "./components/common/ScrollToTop"; - -import AuthLayout from "./layouts/AuthLayout"; import LogoutPage from "./pages/social/account/Logout"; import LoginPage from "./pages/social/account/Login"; import RegisterPage from "./pages/social/account/Register"; @@ -44,7 +42,7 @@ export default function App() { - }> + }> } /> } /> } /> diff --git a/frontend/src/api/generated/private/models/apiSchemaRetrieveLang.ts b/frontend/src/api/generated/private/models/apiSchemaRetrieveLang.ts index e2d0586..ef20191 100644 --- a/frontend/src/api/generated/private/models/apiSchemaRetrieveLang.ts +++ b/frontend/src/api/generated/private/models/apiSchemaRetrieveLang.ts @@ -49,6 +49,7 @@ export const ApiSchemaRetrieveLang = { hi: "hi", hr: "hr", hsb: "hsb", + ht: "ht", hu: "hu", hy: "hy", ia: "ia", diff --git a/frontend/src/api/generated/private/models/carrierRead.ts b/frontend/src/api/generated/private/models/carrierRead.ts index 6ee1d79..45010e5 100644 --- a/frontend/src/api/generated/private/models/carrierRead.ts +++ b/frontend/src/api/generated/private/models/carrierRead.ts @@ -4,12 +4,12 @@ * OpenAPI spec version: 0.0.0 */ import type { ShippingMethodEnum } from "./shippingMethodEnum"; -import type { State1f6Enum } from "./state1f6Enum"; +import type { StateF41Enum } from "./stateF41Enum"; import type { ZasilkovnaPacketRead } from "./zasilkovnaPacketRead"; export interface CarrierRead { readonly shipping_method: ShippingMethodEnum; - readonly state: State1f6Enum; + readonly state: StateF41Enum; readonly zasilkovna: readonly ZasilkovnaPacketRead[]; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly shipping_price: string; diff --git a/frontend/src/api/generated/private/models/cartItem.ts b/frontend/src/api/generated/private/models/cartItem.ts index 5628399..48bdb39 100644 --- a/frontend/src/api/generated/private/models/cartItem.ts +++ b/frontend/src/api/generated/private/models/cartItem.ts @@ -12,7 +12,7 @@ export interface CartItem { readonly product_price: string; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ quantity?: number; readonly subtotal: string; diff --git a/frontend/src/api/generated/private/models/deutschePostOrder.ts b/frontend/src/api/generated/private/models/deutschePostOrder.ts index 1217134..c447327 100644 --- a/frontend/src/api/generated/private/models/deutschePostOrder.ts +++ b/frontend/src/api/generated/private/models/deutschePostOrder.ts @@ -60,7 +60,7 @@ export interface DeutschePostOrder { /** * Weight in grams * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ shipment_gross_weight: number; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ diff --git a/frontend/src/api/generated/private/models/discountCode.ts b/frontend/src/api/generated/private/models/discountCode.ts index 72ab4b1..2c4c1cb 100644 --- a/frontend/src/api/generated/private/models/discountCode.ts +++ b/frontend/src/api/generated/private/models/discountCode.ts @@ -29,7 +29,7 @@ export interface DiscountCode { active?: boolean; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 * @nullable */ usage_limit?: number | null; diff --git a/frontend/src/api/generated/private/models/downloadRequest.ts b/frontend/src/api/generated/private/models/downloadRequest.ts deleted file mode 100644 index ac37487..0000000 --- a/frontend/src/api/generated/private/models/downloadRequest.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Generated by orval v8.8.0 🍺 - * Do not edit manually. - * OpenAPI spec version: 0.0.0 - */ - -export interface DownloadRequest { - /** Video/Playlist URL to download from supported platforms */ - url: string; - /** Container format for the output file. Common formats: mp4 (H.264 + AAC, most compatible), mkv (flexible, lossless container), webm (VP9/AV1 + Opus), flv (legacy), mov (Apple-friendly), avi (older), ogg, m4a (audio only), mp3 (audio only). The extension will be validated by ffmpeg during conversion. */ - ext?: string; - /** - * Optional: Target max video height in pixels (e.g. 1080, 720). If omitted, best quality is selected. - * @nullable - */ - video_quality?: number | null; - /** - * Optional: Target max audio bitrate in kbps (e.g. 320, 192, 128). If omitted, best quality is selected. - * @nullable - */ - audio_quality?: number | null; - /** - * For playlists: specify which videos to download as array of numbers (e.g., [1,3,5]). If omitted, all videos are downloaded. - * @nullable - */ - selected_videos?: number[] | null; - /** - * Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles - * @nullable - */ - subtitles?: string | null; - /** Embed subtitles into the video file (requires mkv or mp4 container) */ - embed_subtitles?: boolean; - /** Embed thumbnail as cover art in the file */ - embed_thumbnail?: boolean; - /** Extract audio only, ignoring video quality settings */ - extract_audio?: boolean; - /** - * Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt' - * @nullable - */ - cookies?: string | null; -} diff --git a/frontend/src/api/generated/private/models/index.ts b/frontend/src/api/generated/private/models/index.ts index 1aa119a..faa1d30 100644 --- a/frontend/src/api/generated/private/models/index.ts +++ b/frontend/src/api/generated/private/models/index.ts @@ -44,9 +44,7 @@ export * from "./deutschePostOrder"; export * from "./deutschePostOrderStateEnum"; export * from "./deutschePostTracking"; export * from "./discountCode"; -export * from "./downloadErrorResponse"; export * from "./downloaderStats"; -export * from "./downloadRequest"; export * from "./errorResponse"; export * from "./gopayCreatePayment201"; export * from "./gopayGetStatus200"; @@ -133,12 +131,13 @@ export * from "./reviewSerializerPublic"; export * from "./roleEnum"; export * from "./shippingMethodEnum"; export * from "./siteConfiguration"; -export * from "./state1f6Enum"; -export * from "./stateCdfEnum"; -export * from "./statusD4fEnum"; +export * from "./state9b5Enum"; +export * from "./stateF41Enum"; +export * from "./status0b2Enum"; export * from "./tagAttach"; export * from "./tags"; export * from "./timeseriesPoint"; +export * from "./tokenError"; export * from "./topUrl"; export * from "./trackingURL"; export * from "./transferInit"; diff --git a/frontend/src/api/generated/private/models/orderCarrier.ts b/frontend/src/api/generated/private/models/orderCarrier.ts index 0a45a2a..5c173a5 100644 --- a/frontend/src/api/generated/private/models/orderCarrier.ts +++ b/frontend/src/api/generated/private/models/orderCarrier.ts @@ -4,12 +4,12 @@ * OpenAPI spec version: 0.0.0 */ import type { ShippingMethodEnum } from "./shippingMethodEnum"; -import type { State1f6Enum } from "./state1f6Enum"; +import type { StateF41Enum } from "./stateF41Enum"; import type { ZasilkovnaPacket } from "./zasilkovnaPacket"; export interface OrderCarrier { shipping_method?: ShippingMethodEnum; - readonly state: State1f6Enum; + readonly state: StateF41Enum; readonly zasilkovna: readonly ZasilkovnaPacket[]; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly shipping_price: string; diff --git a/frontend/src/api/generated/private/models/orderMini.ts b/frontend/src/api/generated/private/models/orderMini.ts index 281484a..c71b991 100644 --- a/frontend/src/api/generated/private/models/orderMini.ts +++ b/frontend/src/api/generated/private/models/orderMini.ts @@ -3,11 +3,11 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ -import type { StatusD4fEnum } from "./statusD4fEnum"; +import type { Status0b2Enum } from "./status0b2Enum"; export interface OrderMini { readonly id: number; - readonly status: StatusD4fEnum; + readonly status: Status0b2Enum; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly total_price: string; readonly created_at: Date; diff --git a/frontend/src/api/generated/private/models/orderRead.ts b/frontend/src/api/generated/private/models/orderRead.ts index 8e55a98..94da9c3 100644 --- a/frontend/src/api/generated/private/models/orderRead.ts +++ b/frontend/src/api/generated/private/models/orderRead.ts @@ -6,11 +6,11 @@ import type { CarrierRead } from "./carrierRead"; import type { OrderItemRead } from "./orderItemRead"; import type { PaymentRead } from "./paymentRead"; -import type { StatusD4fEnum } from "./statusD4fEnum"; +import type { Status0b2Enum } from "./status0b2Enum"; export interface OrderRead { readonly id: number; - readonly status: StatusD4fEnum; + readonly status: Status0b2Enum; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly total_price: string; /** Order currency - captured from site configuration at order creation and never changes */ diff --git a/frontend/src/api/generated/private/models/patchedDeutschePostOrder.ts b/frontend/src/api/generated/private/models/patchedDeutschePostOrder.ts index 989cffb..53a331c 100644 --- a/frontend/src/api/generated/private/models/patchedDeutschePostOrder.ts +++ b/frontend/src/api/generated/private/models/patchedDeutschePostOrder.ts @@ -60,7 +60,7 @@ export interface PatchedDeutschePostOrder { /** * Weight in grams * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ shipment_gross_weight?: number; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ diff --git a/frontend/src/api/generated/private/models/patchedDiscountCode.ts b/frontend/src/api/generated/private/models/patchedDiscountCode.ts index cc30648..33f37a4 100644 --- a/frontend/src/api/generated/private/models/patchedDiscountCode.ts +++ b/frontend/src/api/generated/private/models/patchedDiscountCode.ts @@ -29,7 +29,7 @@ export interface PatchedDiscountCode { active?: boolean; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 * @nullable */ usage_limit?: number | null; diff --git a/frontend/src/api/generated/private/models/patchedOrderRead.ts b/frontend/src/api/generated/private/models/patchedOrderRead.ts index 93b9317..4288216 100644 --- a/frontend/src/api/generated/private/models/patchedOrderRead.ts +++ b/frontend/src/api/generated/private/models/patchedOrderRead.ts @@ -6,11 +6,11 @@ import type { CarrierRead } from "./carrierRead"; import type { OrderItemRead } from "./orderItemRead"; import type { PaymentRead } from "./paymentRead"; -import type { StatusD4fEnum } from "./statusD4fEnum"; +import type { Status0b2Enum } from "./status0b2Enum"; export interface PatchedOrderRead { readonly id?: number; - readonly status?: StatusD4fEnum; + readonly status?: Status0b2Enum; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly total_price?: string; /** Order currency - captured from site configuration at order creation and never changes */ diff --git a/frontend/src/api/generated/private/models/patchedProduct.ts b/frontend/src/api/generated/private/models/patchedProduct.ts index befc283..f79ffe1 100644 --- a/frontend/src/api/generated/private/models/patchedProduct.ts +++ b/frontend/src/api/generated/private/models/patchedProduct.ts @@ -26,7 +26,7 @@ export interface PatchedProduct { url?: string; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ stock?: number; is_active?: boolean; diff --git a/frontend/src/api/generated/private/models/patchedVATRate.ts b/frontend/src/api/generated/private/models/patchedVATRate.ts index ec49382..182b34d 100644 --- a/frontend/src/api/generated/private/models/patchedVATRate.ts +++ b/frontend/src/api/generated/private/models/patchedVATRate.ts @@ -16,7 +16,7 @@ export interface PatchedVATRate { name?: string; /** * VAT rate as percentage (e.g. 19.00 for 19%) - * @pattern ^-?\d{0,1}(?:\.\d{0,4})?$ + * @pattern ^-?\d{0,2}(?:\.\d{0,4})?$ */ rate?: string; /** VAT rate as decimal (e.g., 0.19 for 19%) */ diff --git a/frontend/src/api/generated/private/models/postVote.ts b/frontend/src/api/generated/private/models/postVote.ts index 0bb97ba..a183ba5 100644 --- a/frontend/src/api/generated/private/models/postVote.ts +++ b/frontend/src/api/generated/private/models/postVote.ts @@ -10,8 +10,8 @@ export interface PostVote { post: number; readonly user: number; /** - * @minimum -9223372036854776000 - * @maximum 9223372036854776000 + * @minimum -32768 + * @maximum 32767 */ vote: VoteEnum; readonly created_at: Date; diff --git a/frontend/src/api/generated/private/models/product.ts b/frontend/src/api/generated/private/models/product.ts index febb8f8..4929a7a 100644 --- a/frontend/src/api/generated/private/models/product.ts +++ b/frontend/src/api/generated/private/models/product.ts @@ -26,7 +26,7 @@ export interface Product { url: string; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ stock?: number; is_active?: boolean; diff --git a/frontend/src/api/generated/private/models/productMiniForWishlist.ts b/frontend/src/api/generated/private/models/productMiniForWishlist.ts index 80c09dc..adb889f 100644 --- a/frontend/src/api/generated/private/models/productMiniForWishlist.ts +++ b/frontend/src/api/generated/private/models/productMiniForWishlist.ts @@ -19,7 +19,7 @@ export interface ProductMiniForWishlist { is_active?: boolean; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ stock?: number; } diff --git a/frontend/src/api/generated/public/models/stateCdfEnum.ts b/frontend/src/api/generated/private/models/state9b5Enum.ts similarity index 83% rename from frontend/src/api/generated/public/models/stateCdfEnum.ts rename to frontend/src/api/generated/private/models/state9b5Enum.ts index 749273f..d9977ef 100644 --- a/frontend/src/api/generated/public/models/stateCdfEnum.ts +++ b/frontend/src/api/generated/private/models/state9b5Enum.ts @@ -13,9 +13,9 @@ * `RETURNING` - Posláno zpátky * `RETURNED` - Vráceno */ -export type StateCdfEnum = (typeof StateCdfEnum)[keyof typeof StateCdfEnum]; +export type State9b5Enum = (typeof State9b5Enum)[keyof typeof State9b5Enum]; -export const StateCdfEnum = { +export const State9b5Enum = { WAITING_FOR_ORDERING_SHIPMENT: "WAITING_FOR_ORDERING_SHIPMENT", PENDING: "PENDING", SENDED: "SENDED", diff --git a/frontend/src/api/generated/public/models/state1f6Enum.ts b/frontend/src/api/generated/private/models/stateF41Enum.ts similarity index 77% rename from frontend/src/api/generated/public/models/state1f6Enum.ts rename to frontend/src/api/generated/private/models/stateF41Enum.ts index dcf03d9..b20c2d2 100644 --- a/frontend/src/api/generated/public/models/state1f6Enum.ts +++ b/frontend/src/api/generated/private/models/stateF41Enum.ts @@ -10,9 +10,9 @@ * `delivered` - Doručeno * `ready_to_pickup` - Připraveno k vyzvednutí */ -export type State1f6Enum = (typeof State1f6Enum)[keyof typeof State1f6Enum]; +export type StateF41Enum = (typeof StateF41Enum)[keyof typeof StateF41Enum]; -export const State1f6Enum = { +export const StateF41Enum = { ordered: "ordered", shipped: "shipped", delivered: "delivered", diff --git a/frontend/src/api/generated/private/models/statusD4fEnum.ts b/frontend/src/api/generated/private/models/status0b2Enum.ts similarity index 77% rename from frontend/src/api/generated/private/models/statusD4fEnum.ts rename to frontend/src/api/generated/private/models/status0b2Enum.ts index 7981f7d..b5721f6 100644 --- a/frontend/src/api/generated/private/models/statusD4fEnum.ts +++ b/frontend/src/api/generated/private/models/status0b2Enum.ts @@ -11,9 +11,9 @@ * `refunding` - Vrácení v procesu * `refunded` - Vráceno */ -export type StatusD4fEnum = (typeof StatusD4fEnum)[keyof typeof StatusD4fEnum]; +export type Status0b2Enum = (typeof Status0b2Enum)[keyof typeof Status0b2Enum]; -export const StatusD4fEnum = { +export const Status0b2Enum = { created: "created", cancelled: "cancelled", completed: "completed", diff --git a/frontend/src/api/generated/private/models/downloadErrorResponse.ts b/frontend/src/api/generated/private/models/tokenError.ts similarity index 74% rename from frontend/src/api/generated/private/models/downloadErrorResponse.ts rename to frontend/src/api/generated/private/models/tokenError.ts index a594850..dc04cf0 100644 --- a/frontend/src/api/generated/private/models/downloadErrorResponse.ts +++ b/frontend/src/api/generated/private/models/tokenError.ts @@ -4,6 +4,6 @@ * OpenAPI spec version: 0.0.0 */ -export interface DownloadErrorResponse { +export interface TokenError { error: string; } diff --git a/frontend/src/api/generated/private/models/vATRate.ts b/frontend/src/api/generated/private/models/vATRate.ts index 6e107da..76610fc 100644 --- a/frontend/src/api/generated/private/models/vATRate.ts +++ b/frontend/src/api/generated/private/models/vATRate.ts @@ -16,7 +16,7 @@ export interface VATRate { name: string; /** * VAT rate as percentage (e.g. 19.00 for 19%) - * @pattern ^-?\d{0,1}(?:\.\d{0,4})?$ + * @pattern ^-?\d{0,2}(?:\.\d{0,4})?$ */ rate: string; /** VAT rate as decimal (e.g., 0.19 for 19%) */ diff --git a/frontend/src/api/generated/private/models/zasilkovnaPacket.ts b/frontend/src/api/generated/private/models/zasilkovnaPacket.ts index 20abadc..dbb349c 100644 --- a/frontend/src/api/generated/private/models/zasilkovnaPacket.ts +++ b/frontend/src/api/generated/private/models/zasilkovnaPacket.ts @@ -3,15 +3,15 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ -import type { StateCdfEnum } from "./stateCdfEnum"; +import type { State9b5Enum } from "./state9b5Enum"; export interface ZasilkovnaPacket { readonly id: number; readonly created_at: Date; /** * Číslo zásilky v Packetě (vraceno od API od Packety) - * @minimum -9223372036854776000 - * @maximum 9223372036854776000 + * @minimum -2147483648 + * @maximum 2147483647 * @nullable */ packet_id?: number | null; @@ -20,7 +20,7 @@ export interface ZasilkovnaPacket { * @nullable */ readonly barcode: string | null; - readonly state: StateCdfEnum; + readonly state: State9b5Enum; /** Hmotnost zásilky v gramech */ readonly weight: number; /** Seznam 2 routing stringů pro vrácení zásilky */ diff --git a/frontend/src/api/generated/private/models/zasilkovnaPacketRead.ts b/frontend/src/api/generated/private/models/zasilkovnaPacketRead.ts index e05ebd7..f39cf16 100644 --- a/frontend/src/api/generated/private/models/zasilkovnaPacketRead.ts +++ b/frontend/src/api/generated/private/models/zasilkovnaPacketRead.ts @@ -3,15 +3,15 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ -import type { StateCdfEnum } from "./stateCdfEnum"; +import type { State9b5Enum } from "./state9b5Enum"; export interface ZasilkovnaPacketRead { readonly id: number; readonly created_at: Date; /** * Číslo zásilky v Packetě (vraceno od API od Packety) - * @minimum -9223372036854776000 - * @maximum 9223372036854776000 + * @minimum -2147483648 + * @maximum 2147483647 * @nullable */ packet_id?: number | null; @@ -20,7 +20,7 @@ export interface ZasilkovnaPacketRead { * @nullable */ readonly barcode: string | null; - readonly state: StateCdfEnum; + readonly state: State9b5Enum; /** Hmotnost zásilky v gramech */ readonly weight: number; /** Seznam 2 routing stringů pro vrácení zásilky */ diff --git a/frontend/src/api/generated/public/downloader.ts b/frontend/src/api/generated/public/downloader.ts index a39fbca..1f7702a 100644 --- a/frontend/src/api/generated/public/downloader.ts +++ b/frontend/src/api/generated/public/downloader.ts @@ -20,11 +20,13 @@ import type { } from "@tanstack/react-query"; import type { + ApiDownloaderDownloadCreateParams, + ApiDownloaderDownloadFileCreateParams, + ApiDownloaderDownloadFileRetrieveParams, ApiDownloaderDownloadRetrieveParams, - DownloadErrorResponse, - DownloadRequest, DownloaderStats, ErrorResponse, + TokenError, VideoInfoResponse, } from "./models"; @@ -214,60 +216,35 @@ export function useApiDownloaderDownloadRetrieve< } /** - * - Download video/playlist with optional quality constraints and container format conversion. - - **For Playlists:** - - Returns a ZIP file containing all selected videos - - Use `selected_videos` to specify which videos to download (e.g., [1,3,5] or [1,2,3,4,5]) - - If `selected_videos` is not provided, all videos in the playlist will be downloaded - - **Quality Parameters (optional):** - - If not specified, yt-dlp will automatically select the best available quality. - - `video_quality`: Maximum video height in pixels (e.g., 1080, 720, 480). - - `audio_quality`: Maximum audio bitrate in kbps (e.g., 320, 192, 128). - - **Format/Extension:** - - Any format supported by ffmpeg (mp4, mkv, webm, avi, mov, flv, m4a, mp3, etc.). - - Defaults to 'mp4' if not specified. - - The conversion is handled automatically by ffmpeg in the background. - - **Advanced Options:** - - `subtitles`: Download subtitles (language codes like 'en,cs' or 'all') - - `embed_subtitles`: Embed subtitles into video file - - `embed_thumbnail`: Embed thumbnail as cover art - - `extract_audio`: Extract audio only (ignores video quality) - - `cookies`: Browser cookies for age-restricted content (Netscape format) - - * @summary Download video or playlist from URL + * Serve a file using a signed token from WebSocket download. Token expires in 10 minutes. + * @summary Download file via signed token */ export const apiDownloaderDownloadCreate = ( - downloadRequest: DownloadRequest, + params: ApiDownloaderDownloadCreateParams, signal?: AbortSignal, ) => { return publicMutator({ url: `/api/downloader/download/`, method: "POST", - headers: { "Content-Type": "application/json" }, - data: downloadRequest, + params, signal, }); }; export const getApiDownloaderDownloadCreateMutationOptions = < - TError = DownloadErrorResponse, + TError = TokenError, TContext = unknown, >(options?: { mutation?: UseMutationOptions< Awaited>, TError, - { data: DownloadRequest }, + { params: ApiDownloaderDownloadCreateParams }, TContext >; }): UseMutationOptions< Awaited>, TError, - { data: DownloadRequest }, + { params: ApiDownloaderDownloadCreateParams }, TContext > => { const mutationKey = ["apiDownloaderDownloadCreate"]; @@ -281,11 +258,11 @@ export const getApiDownloaderDownloadCreateMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { data: DownloadRequest } + { params: ApiDownloaderDownloadCreateParams } > = (props) => { - const { data } = props ?? {}; + const { params } = props ?? {}; - return apiDownloaderDownloadCreate(data); + return apiDownloaderDownloadCreate(params); }; return { mutationFn, ...mutationOptions }; @@ -294,21 +271,21 @@ export const getApiDownloaderDownloadCreateMutationOptions = < export type ApiDownloaderDownloadCreateMutationResult = NonNullable< Awaited> >; -export type ApiDownloaderDownloadCreateMutationBody = DownloadRequest; -export type ApiDownloaderDownloadCreateMutationError = DownloadErrorResponse; + +export type ApiDownloaderDownloadCreateMutationError = TokenError; /** - * @summary Download video or playlist from URL + * @summary Download file via signed token */ export const useApiDownloaderDownloadCreate = < - TError = DownloadErrorResponse, + TError = TokenError, TContext = unknown, >( options?: { mutation?: UseMutationOptions< Awaited>, TError, - { data: DownloadRequest }, + { params: ApiDownloaderDownloadCreateParams }, TContext >; }, @@ -316,7 +293,7 @@ export const useApiDownloaderDownloadCreate = < ): UseMutationResult< Awaited>, TError, - { data: DownloadRequest }, + { params: ApiDownloaderDownloadCreateParams }, TContext > => { return useMutation( @@ -324,6 +301,279 @@ export const useApiDownloaderDownloadCreate = < queryClient, ); }; +/** + * + Fetch detailed information about a video or playlist from supported platforms. + + **Supported platforms:** YouTube, TikTok, Vimeo, Twitter, Instagram, Facebook, Reddit, and many more. + + **Returns:** + For single videos: + - Video title, duration, and thumbnail + - Available video qualities/resolutions + - Available audio formats + + For playlists: + - Array of videos with the same info structure as single videos + - Each video includes title, duration, thumbnail, and available qualities + + **Usage:** + ``` + GET /api/downloader/download/?url=https://youtube.com/watch?v=VIDEO_ID + GET /api/downloader/download/?url=https://youtube.com/playlist?list=PLAYLIST_ID + ``` + + * @summary Get video info from URL + */ +export const apiDownloaderDownloadFileRetrieve = ( + params: ApiDownloaderDownloadFileRetrieveParams, + signal?: AbortSignal, +) => { + return publicMutator({ + url: `/api/downloader/download/file/`, + method: "GET", + params, + signal, + }); +}; + +export const getApiDownloaderDownloadFileRetrieveQueryKey = ( + params?: ApiDownloaderDownloadFileRetrieveParams, +) => { + return [ + `/api/downloader/download/file/`, + ...(params ? [params] : []), + ] as const; +}; + +export const getApiDownloaderDownloadFileRetrieveQueryOptions = < + TData = Awaited>, + TError = ErrorResponse, +>( + params: ApiDownloaderDownloadFileRetrieveParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getApiDownloaderDownloadFileRetrieveQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => apiDownloaderDownloadFileRetrieve(params, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ApiDownloaderDownloadFileRetrieveQueryResult = NonNullable< + Awaited> +>; +export type ApiDownloaderDownloadFileRetrieveQueryError = ErrorResponse; + +export function useApiDownloaderDownloadFileRetrieve< + TData = Awaited>, + TError = ErrorResponse, +>( + params: ApiDownloaderDownloadFileRetrieveParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useApiDownloaderDownloadFileRetrieve< + TData = Awaited>, + TError = ErrorResponse, +>( + params: ApiDownloaderDownloadFileRetrieveParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useApiDownloaderDownloadFileRetrieve< + TData = Awaited>, + TError = ErrorResponse, +>( + params: ApiDownloaderDownloadFileRetrieveParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary Get video info from URL + */ + +export function useApiDownloaderDownloadFileRetrieve< + TData = Awaited>, + TError = ErrorResponse, +>( + params: ApiDownloaderDownloadFileRetrieveParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getApiDownloaderDownloadFileRetrieveQueryOptions( + params, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Serve a file using a signed token from WebSocket download. Token expires in 10 minutes. + * @summary Download file via signed token + */ +export const apiDownloaderDownloadFileCreate = ( + params: ApiDownloaderDownloadFileCreateParams, + signal?: AbortSignal, +) => { + return publicMutator({ + url: `/api/downloader/download/file/`, + method: "POST", + params, + signal, + }); +}; + +export const getApiDownloaderDownloadFileCreateMutationOptions = < + TError = TokenError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { params: ApiDownloaderDownloadFileCreateParams }, + TContext + >; +}): UseMutationOptions< + Awaited>, + TError, + { params: ApiDownloaderDownloadFileCreateParams }, + TContext +> => { + const mutationKey = ["apiDownloaderDownloadFileCreate"]; + const { mutation: mutationOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } }; + + const mutationFn: MutationFunction< + Awaited>, + { params: ApiDownloaderDownloadFileCreateParams } + > = (props) => { + const { params } = props ?? {}; + + return apiDownloaderDownloadFileCreate(params); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ApiDownloaderDownloadFileCreateMutationResult = NonNullable< + Awaited> +>; + +export type ApiDownloaderDownloadFileCreateMutationError = TokenError; + +/** + * @summary Download file via signed token + */ +export const useApiDownloaderDownloadFileCreate = < + TError = TokenError, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { params: ApiDownloaderDownloadFileCreateParams }, + TContext + >; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { params: ApiDownloaderDownloadFileCreateParams }, + TContext +> => { + return useMutation( + getApiDownloaderDownloadFileCreateMutationOptions(options), + queryClient, + ); +}; /** * Vrací agregované statistiky z tabulky DownloaderRecord. * @summary Get aggregated downloader statistics diff --git a/frontend/src/api/generated/public/models/apiDownloaderDownloadCreateParams.ts b/frontend/src/api/generated/public/models/apiDownloaderDownloadCreateParams.ts new file mode 100644 index 0000000..e3ca9d0 --- /dev/null +++ b/frontend/src/api/generated/public/models/apiDownloaderDownloadCreateParams.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiDownloaderDownloadCreateParams = { + /** + * Signed token containing file info + * @minLength 1 + */ + token: string; +}; diff --git a/frontend/src/api/generated/public/models/apiDownloaderDownloadFileCreateParams.ts b/frontend/src/api/generated/public/models/apiDownloaderDownloadFileCreateParams.ts new file mode 100644 index 0000000..004ed5f --- /dev/null +++ b/frontend/src/api/generated/public/models/apiDownloaderDownloadFileCreateParams.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiDownloaderDownloadFileCreateParams = { + /** + * Signed token containing file info + * @minLength 1 + */ + token: string; +}; diff --git a/frontend/src/api/generated/public/models/apiDownloaderDownloadFileRetrieveParams.ts b/frontend/src/api/generated/public/models/apiDownloaderDownloadFileRetrieveParams.ts new file mode 100644 index 0000000..b417b6b --- /dev/null +++ b/frontend/src/api/generated/public/models/apiDownloaderDownloadFileRetrieveParams.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export type ApiDownloaderDownloadFileRetrieveParams = { + /** + * Video/Playlist URL from YouTube, TikTok, Vimeo, etc. Must be a valid URL from a supported platform. + * @minLength 1 + */ + url: string; +}; diff --git a/frontend/src/api/generated/public/models/carrierRead.ts b/frontend/src/api/generated/public/models/carrierRead.ts index 6ee1d79..45010e5 100644 --- a/frontend/src/api/generated/public/models/carrierRead.ts +++ b/frontend/src/api/generated/public/models/carrierRead.ts @@ -4,12 +4,12 @@ * OpenAPI spec version: 0.0.0 */ import type { ShippingMethodEnum } from "./shippingMethodEnum"; -import type { State1f6Enum } from "./state1f6Enum"; +import type { StateF41Enum } from "./stateF41Enum"; import type { ZasilkovnaPacketRead } from "./zasilkovnaPacketRead"; export interface CarrierRead { readonly shipping_method: ShippingMethodEnum; - readonly state: State1f6Enum; + readonly state: StateF41Enum; readonly zasilkovna: readonly ZasilkovnaPacketRead[]; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly shipping_price: string; diff --git a/frontend/src/api/generated/public/models/cartItem.ts b/frontend/src/api/generated/public/models/cartItem.ts index 5628399..48bdb39 100644 --- a/frontend/src/api/generated/public/models/cartItem.ts +++ b/frontend/src/api/generated/public/models/cartItem.ts @@ -12,7 +12,7 @@ export interface CartItem { readonly product_price: string; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ quantity?: number; readonly subtotal: string; diff --git a/frontend/src/api/generated/public/models/deutschePostOrder.ts b/frontend/src/api/generated/public/models/deutschePostOrder.ts index 1217134..c447327 100644 --- a/frontend/src/api/generated/public/models/deutschePostOrder.ts +++ b/frontend/src/api/generated/public/models/deutschePostOrder.ts @@ -60,7 +60,7 @@ export interface DeutschePostOrder { /** * Weight in grams * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ shipment_gross_weight: number; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ diff --git a/frontend/src/api/generated/public/models/discountCode.ts b/frontend/src/api/generated/public/models/discountCode.ts index 72ab4b1..2c4c1cb 100644 --- a/frontend/src/api/generated/public/models/discountCode.ts +++ b/frontend/src/api/generated/public/models/discountCode.ts @@ -29,7 +29,7 @@ export interface DiscountCode { active?: boolean; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 * @nullable */ usage_limit?: number | null; diff --git a/frontend/src/api/generated/public/models/downloadRequest.ts b/frontend/src/api/generated/public/models/downloadRequest.ts deleted file mode 100644 index ac37487..0000000 --- a/frontend/src/api/generated/public/models/downloadRequest.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Generated by orval v8.8.0 🍺 - * Do not edit manually. - * OpenAPI spec version: 0.0.0 - */ - -export interface DownloadRequest { - /** Video/Playlist URL to download from supported platforms */ - url: string; - /** Container format for the output file. Common formats: mp4 (H.264 + AAC, most compatible), mkv (flexible, lossless container), webm (VP9/AV1 + Opus), flv (legacy), mov (Apple-friendly), avi (older), ogg, m4a (audio only), mp3 (audio only). The extension will be validated by ffmpeg during conversion. */ - ext?: string; - /** - * Optional: Target max video height in pixels (e.g. 1080, 720). If omitted, best quality is selected. - * @nullable - */ - video_quality?: number | null; - /** - * Optional: Target max audio bitrate in kbps (e.g. 320, 192, 128). If omitted, best quality is selected. - * @nullable - */ - audio_quality?: number | null; - /** - * For playlists: specify which videos to download as array of numbers (e.g., [1,3,5]). If omitted, all videos are downloaded. - * @nullable - */ - selected_videos?: number[] | null; - /** - * Language codes (e.g., 'en', 'cs', 'en,cs') or 'all' for all available subtitles - * @nullable - */ - subtitles?: string | null; - /** Embed subtitles into the video file (requires mkv or mp4 container) */ - embed_subtitles?: boolean; - /** Embed thumbnail as cover art in the file */ - embed_thumbnail?: boolean; - /** Extract audio only, ignoring video quality settings */ - extract_audio?: boolean; - /** - * Browser cookies in Netscape format for age-restricted content. Export from browser extensions like 'Get cookies.txt' - * @nullable - */ - cookies?: string | null; -} diff --git a/frontend/src/api/generated/public/models/index.ts b/frontend/src/api/generated/public/models/index.ts index 976747b..2d9804b 100644 --- a/frontend/src/api/generated/public/models/index.ts +++ b/frontend/src/api/generated/public/models/index.ts @@ -10,6 +10,9 @@ export * from "./apiCommerceDiscountCodesListParams"; export * from "./apiCommerceOrdersListParams"; export * from "./apiCommerceProductImagesListParams"; export * from "./apiCommerceProductsListParams"; +export * from "./apiDownloaderDownloadCreateParams"; +export * from "./apiDownloaderDownloadFileCreateParams"; +export * from "./apiDownloaderDownloadFileRetrieveParams"; export * from "./apiDownloaderDownloadRetrieveParams"; export * from "./apiChoicesRetrieve200"; export * from "./apiChoicesRetrieve200Item"; @@ -30,9 +33,7 @@ export * from "./deutschePostOrder"; export * from "./deutschePostOrderStateEnum"; export * from "./deutschePostTracking"; export * from "./discountCode"; -export * from "./downloadErrorResponse"; export * from "./downloaderStats"; -export * from "./downloadRequest"; export * from "./errorResponse"; export * from "./hub"; export * from "./hubPermission"; @@ -116,12 +117,13 @@ export * from "./reviewSerializerPublic"; export * from "./roleEnum"; export * from "./shippingMethodEnum"; export * from "./siteConfiguration"; -export * from "./state1f6Enum"; -export * from "./stateCdfEnum"; -export * from "./statusD4fEnum"; +export * from "./state9b5Enum"; +export * from "./stateF41Enum"; +export * from "./status0b2Enum"; export * from "./tagAttach"; export * from "./tags"; export * from "./timeseriesPoint"; +export * from "./tokenError"; export * from "./topUrl"; export * from "./trackingURL"; export * from "./transferInit"; diff --git a/frontend/src/api/generated/public/models/orderCarrier.ts b/frontend/src/api/generated/public/models/orderCarrier.ts index 0a45a2a..5c173a5 100644 --- a/frontend/src/api/generated/public/models/orderCarrier.ts +++ b/frontend/src/api/generated/public/models/orderCarrier.ts @@ -4,12 +4,12 @@ * OpenAPI spec version: 0.0.0 */ import type { ShippingMethodEnum } from "./shippingMethodEnum"; -import type { State1f6Enum } from "./state1f6Enum"; +import type { StateF41Enum } from "./stateF41Enum"; import type { ZasilkovnaPacket } from "./zasilkovnaPacket"; export interface OrderCarrier { shipping_method?: ShippingMethodEnum; - readonly state: State1f6Enum; + readonly state: StateF41Enum; readonly zasilkovna: readonly ZasilkovnaPacket[]; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly shipping_price: string; diff --git a/frontend/src/api/generated/public/models/orderMini.ts b/frontend/src/api/generated/public/models/orderMini.ts index 281484a..c71b991 100644 --- a/frontend/src/api/generated/public/models/orderMini.ts +++ b/frontend/src/api/generated/public/models/orderMini.ts @@ -3,11 +3,11 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ -import type { StatusD4fEnum } from "./statusD4fEnum"; +import type { Status0b2Enum } from "./status0b2Enum"; export interface OrderMini { readonly id: number; - readonly status: StatusD4fEnum; + readonly status: Status0b2Enum; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly total_price: string; readonly created_at: Date; diff --git a/frontend/src/api/generated/public/models/orderRead.ts b/frontend/src/api/generated/public/models/orderRead.ts index 8e55a98..94da9c3 100644 --- a/frontend/src/api/generated/public/models/orderRead.ts +++ b/frontend/src/api/generated/public/models/orderRead.ts @@ -6,11 +6,11 @@ import type { CarrierRead } from "./carrierRead"; import type { OrderItemRead } from "./orderItemRead"; import type { PaymentRead } from "./paymentRead"; -import type { StatusD4fEnum } from "./statusD4fEnum"; +import type { Status0b2Enum } from "./status0b2Enum"; export interface OrderRead { readonly id: number; - readonly status: StatusD4fEnum; + readonly status: Status0b2Enum; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly total_price: string; /** Order currency - captured from site configuration at order creation and never changes */ diff --git a/frontend/src/api/generated/public/models/patchedDeutschePostOrder.ts b/frontend/src/api/generated/public/models/patchedDeutschePostOrder.ts index 989cffb..53a331c 100644 --- a/frontend/src/api/generated/public/models/patchedDeutschePostOrder.ts +++ b/frontend/src/api/generated/public/models/patchedDeutschePostOrder.ts @@ -60,7 +60,7 @@ export interface PatchedDeutschePostOrder { /** * Weight in grams * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ shipment_gross_weight?: number; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ diff --git a/frontend/src/api/generated/public/models/patchedDiscountCode.ts b/frontend/src/api/generated/public/models/patchedDiscountCode.ts index cc30648..33f37a4 100644 --- a/frontend/src/api/generated/public/models/patchedDiscountCode.ts +++ b/frontend/src/api/generated/public/models/patchedDiscountCode.ts @@ -29,7 +29,7 @@ export interface PatchedDiscountCode { active?: boolean; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 * @nullable */ usage_limit?: number | null; diff --git a/frontend/src/api/generated/public/models/patchedOrderRead.ts b/frontend/src/api/generated/public/models/patchedOrderRead.ts index 93b9317..4288216 100644 --- a/frontend/src/api/generated/public/models/patchedOrderRead.ts +++ b/frontend/src/api/generated/public/models/patchedOrderRead.ts @@ -6,11 +6,11 @@ import type { CarrierRead } from "./carrierRead"; import type { OrderItemRead } from "./orderItemRead"; import type { PaymentRead } from "./paymentRead"; -import type { StatusD4fEnum } from "./statusD4fEnum"; +import type { Status0b2Enum } from "./status0b2Enum"; export interface PatchedOrderRead { readonly id?: number; - readonly status?: StatusD4fEnum; + readonly status?: Status0b2Enum; /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ readonly total_price?: string; /** Order currency - captured from site configuration at order creation and never changes */ diff --git a/frontend/src/api/generated/public/models/patchedProduct.ts b/frontend/src/api/generated/public/models/patchedProduct.ts index befc283..f79ffe1 100644 --- a/frontend/src/api/generated/public/models/patchedProduct.ts +++ b/frontend/src/api/generated/public/models/patchedProduct.ts @@ -26,7 +26,7 @@ export interface PatchedProduct { url?: string; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ stock?: number; is_active?: boolean; diff --git a/frontend/src/api/generated/public/models/patchedVATRate.ts b/frontend/src/api/generated/public/models/patchedVATRate.ts index ec49382..182b34d 100644 --- a/frontend/src/api/generated/public/models/patchedVATRate.ts +++ b/frontend/src/api/generated/public/models/patchedVATRate.ts @@ -16,7 +16,7 @@ export interface PatchedVATRate { name?: string; /** * VAT rate as percentage (e.g. 19.00 for 19%) - * @pattern ^-?\d{0,1}(?:\.\d{0,4})?$ + * @pattern ^-?\d{0,2}(?:\.\d{0,4})?$ */ rate?: string; /** VAT rate as decimal (e.g., 0.19 for 19%) */ diff --git a/frontend/src/api/generated/public/models/postVote.ts b/frontend/src/api/generated/public/models/postVote.ts index 0bb97ba..a183ba5 100644 --- a/frontend/src/api/generated/public/models/postVote.ts +++ b/frontend/src/api/generated/public/models/postVote.ts @@ -10,8 +10,8 @@ export interface PostVote { post: number; readonly user: number; /** - * @minimum -9223372036854776000 - * @maximum 9223372036854776000 + * @minimum -32768 + * @maximum 32767 */ vote: VoteEnum; readonly created_at: Date; diff --git a/frontend/src/api/generated/public/models/product.ts b/frontend/src/api/generated/public/models/product.ts index febb8f8..4929a7a 100644 --- a/frontend/src/api/generated/public/models/product.ts +++ b/frontend/src/api/generated/public/models/product.ts @@ -26,7 +26,7 @@ export interface Product { url: string; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ stock?: number; is_active?: boolean; diff --git a/frontend/src/api/generated/public/models/productMiniForWishlist.ts b/frontend/src/api/generated/public/models/productMiniForWishlist.ts index 80c09dc..adb889f 100644 --- a/frontend/src/api/generated/public/models/productMiniForWishlist.ts +++ b/frontend/src/api/generated/public/models/productMiniForWishlist.ts @@ -19,7 +19,7 @@ export interface ProductMiniForWishlist { is_active?: boolean; /** * @minimum 0 - * @maximum 9223372036854776000 + * @maximum 2147483647 */ stock?: number; } diff --git a/frontend/src/api/generated/private/models/stateCdfEnum.ts b/frontend/src/api/generated/public/models/state9b5Enum.ts similarity index 83% rename from frontend/src/api/generated/private/models/stateCdfEnum.ts rename to frontend/src/api/generated/public/models/state9b5Enum.ts index 749273f..d9977ef 100644 --- a/frontend/src/api/generated/private/models/stateCdfEnum.ts +++ b/frontend/src/api/generated/public/models/state9b5Enum.ts @@ -13,9 +13,9 @@ * `RETURNING` - Posláno zpátky * `RETURNED` - Vráceno */ -export type StateCdfEnum = (typeof StateCdfEnum)[keyof typeof StateCdfEnum]; +export type State9b5Enum = (typeof State9b5Enum)[keyof typeof State9b5Enum]; -export const StateCdfEnum = { +export const State9b5Enum = { WAITING_FOR_ORDERING_SHIPMENT: "WAITING_FOR_ORDERING_SHIPMENT", PENDING: "PENDING", SENDED: "SENDED", diff --git a/frontend/src/api/generated/private/models/state1f6Enum.ts b/frontend/src/api/generated/public/models/stateF41Enum.ts similarity index 77% rename from frontend/src/api/generated/private/models/state1f6Enum.ts rename to frontend/src/api/generated/public/models/stateF41Enum.ts index dcf03d9..b20c2d2 100644 --- a/frontend/src/api/generated/private/models/state1f6Enum.ts +++ b/frontend/src/api/generated/public/models/stateF41Enum.ts @@ -10,9 +10,9 @@ * `delivered` - Doručeno * `ready_to_pickup` - Připraveno k vyzvednutí */ -export type State1f6Enum = (typeof State1f6Enum)[keyof typeof State1f6Enum]; +export type StateF41Enum = (typeof StateF41Enum)[keyof typeof StateF41Enum]; -export const State1f6Enum = { +export const StateF41Enum = { ordered: "ordered", shipped: "shipped", delivered: "delivered", diff --git a/frontend/src/api/generated/public/models/statusD4fEnum.ts b/frontend/src/api/generated/public/models/status0b2Enum.ts similarity index 77% rename from frontend/src/api/generated/public/models/statusD4fEnum.ts rename to frontend/src/api/generated/public/models/status0b2Enum.ts index 7981f7d..b5721f6 100644 --- a/frontend/src/api/generated/public/models/statusD4fEnum.ts +++ b/frontend/src/api/generated/public/models/status0b2Enum.ts @@ -11,9 +11,9 @@ * `refunding` - Vrácení v procesu * `refunded` - Vráceno */ -export type StatusD4fEnum = (typeof StatusD4fEnum)[keyof typeof StatusD4fEnum]; +export type Status0b2Enum = (typeof Status0b2Enum)[keyof typeof Status0b2Enum]; -export const StatusD4fEnum = { +export const Status0b2Enum = { created: "created", cancelled: "cancelled", completed: "completed", diff --git a/frontend/src/api/generated/public/models/downloadErrorResponse.ts b/frontend/src/api/generated/public/models/tokenError.ts similarity index 74% rename from frontend/src/api/generated/public/models/downloadErrorResponse.ts rename to frontend/src/api/generated/public/models/tokenError.ts index a594850..dc04cf0 100644 --- a/frontend/src/api/generated/public/models/downloadErrorResponse.ts +++ b/frontend/src/api/generated/public/models/tokenError.ts @@ -4,6 +4,6 @@ * OpenAPI spec version: 0.0.0 */ -export interface DownloadErrorResponse { +export interface TokenError { error: string; } diff --git a/frontend/src/api/generated/public/models/vATRate.ts b/frontend/src/api/generated/public/models/vATRate.ts index 6e107da..76610fc 100644 --- a/frontend/src/api/generated/public/models/vATRate.ts +++ b/frontend/src/api/generated/public/models/vATRate.ts @@ -16,7 +16,7 @@ export interface VATRate { name: string; /** * VAT rate as percentage (e.g. 19.00 for 19%) - * @pattern ^-?\d{0,1}(?:\.\d{0,4})?$ + * @pattern ^-?\d{0,2}(?:\.\d{0,4})?$ */ rate: string; /** VAT rate as decimal (e.g., 0.19 for 19%) */ diff --git a/frontend/src/api/generated/public/models/zasilkovnaPacket.ts b/frontend/src/api/generated/public/models/zasilkovnaPacket.ts index 20abadc..dbb349c 100644 --- a/frontend/src/api/generated/public/models/zasilkovnaPacket.ts +++ b/frontend/src/api/generated/public/models/zasilkovnaPacket.ts @@ -3,15 +3,15 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ -import type { StateCdfEnum } from "./stateCdfEnum"; +import type { State9b5Enum } from "./state9b5Enum"; export interface ZasilkovnaPacket { readonly id: number; readonly created_at: Date; /** * Číslo zásilky v Packetě (vraceno od API od Packety) - * @minimum -9223372036854776000 - * @maximum 9223372036854776000 + * @minimum -2147483648 + * @maximum 2147483647 * @nullable */ packet_id?: number | null; @@ -20,7 +20,7 @@ export interface ZasilkovnaPacket { * @nullable */ readonly barcode: string | null; - readonly state: StateCdfEnum; + readonly state: State9b5Enum; /** Hmotnost zásilky v gramech */ readonly weight: number; /** Seznam 2 routing stringů pro vrácení zásilky */ diff --git a/frontend/src/api/generated/public/models/zasilkovnaPacketRead.ts b/frontend/src/api/generated/public/models/zasilkovnaPacketRead.ts index e05ebd7..f39cf16 100644 --- a/frontend/src/api/generated/public/models/zasilkovnaPacketRead.ts +++ b/frontend/src/api/generated/public/models/zasilkovnaPacketRead.ts @@ -3,15 +3,15 @@ * Do not edit manually. * OpenAPI spec version: 0.0.0 */ -import type { StateCdfEnum } from "./stateCdfEnum"; +import type { State9b5Enum } from "./state9b5Enum"; export interface ZasilkovnaPacketRead { readonly id: number; readonly created_at: Date; /** * Číslo zásilky v Packetě (vraceno od API od Packety) - * @minimum -9223372036854776000 - * @maximum 9223372036854776000 + * @minimum -2147483648 + * @maximum 2147483647 * @nullable */ packet_id?: number | null; @@ -20,7 +20,7 @@ export interface ZasilkovnaPacketRead { * @nullable */ readonly barcode: string | null; - readonly state: StateCdfEnum; + readonly state: State9b5Enum; /** Hmotnost zásilky v gramech */ readonly weight: number; /** Seznam 2 routing stringů pro vrácení zásilky */ diff --git a/frontend/src/components/downloader/Ad.tsx b/frontend/src/components/downloader/Ad.tsx index e69de29..c8b1c28 100644 --- a/frontend/src/components/downloader/Ad.tsx +++ b/frontend/src/components/downloader/Ad.tsx @@ -0,0 +1,29 @@ +import { Link } from 'react-router-dom'; +import { useAuth } from '@/context/AuthContext'; + +export default function Ad() { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading || isAuthenticated) return null; + + return ( +
+
🎁
+
+

+ Create a free account — download files up to{' '} + 2 GB per file! +

+

+ Registered users get higher file size limits, even better than most tools out there. +

+
+ + Sign up free + +
+ ); +} diff --git a/frontend/src/components/downloader/Downloader.tsx b/frontend/src/components/downloader/Downloader.tsx new file mode 100644 index 0000000..bd8ff80 --- /dev/null +++ b/frontend/src/components/downloader/Downloader.tsx @@ -0,0 +1,546 @@ +import { useState, useRef, useEffect } from 'react'; +import { + apiDownloaderDownloadRetrieve, +} from '@/api/generated/public/downloader'; +import { type VideoInfoResponse } from '@/api/generated/public/models'; +import { FaLink, FaVideo, FaVolumeUp, FaFile, FaFont, FaCookie } from 'react-icons/fa'; +import Ad from './Ad'; +import Statistics from './Statistics'; +import { publicApi } from '@/api/publicClient'; + +// Common file extensions supported by ffmpeg +const FILE_EXTENSIONS = [ + { value: 'mp4', label: 'MP4 (H.264 + AAC, most compatible)' }, + { value: 'mkv', label: 'MKV (Flexible, lossless container)' }, + { value: 'webm', label: 'WebM (VP9/AV1 + Opus)' }, + { value: 'avi', label: 'AVI (Older format)' }, + { value: 'mov', label: 'MOV (Apple-friendly)' }, + { value: 'flv', label: 'FLV (Legacy)' }, + { value: 'ogg', label: 'OGG (Audio/Video)' }, +]; + +export default function Downloader() { + const [videoUrl, setVideoUrl] = useState(''); + const [videoInfo, setVideoInfo] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [downloadStatus, setDownloadStatus] = useState(''); + const [downloadProgress, setDownloadProgress] = useState(null); + const [error, setError] = useState(null); + const wsRef = useRef(null); + + // Basic download options + const [selectedVideoQuality, setSelectedVideoQuality] = useState(''); + const [selectedAudioQuality, setSelectedAudioQuality] = useState(''); + const [selectedExtension, setSelectedExtension] = useState('mp4'); + + // Playlist selection + const [selectedVideos, setSelectedVideos] = useState([]); + + // Advanced options + const [subtitles, setSubtitles] = useState(''); + const [embedSubtitles, setEmbedSubtitles] = useState(false); + const [embedThumbnail, setEmbedThumbnail] = useState(false); + const [cookies, setCookies] = useState(''); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Helper functions for playlist selection + const toggleVideoSelection = (videoIndex: number) => { + setSelectedVideos(prev => + prev.includes(videoIndex) + ? prev.filter(i => i !== videoIndex) + : [...prev, videoIndex] + ); + }; + + const selectAllVideos = () => { + if (videoInfo?.videos) { + setSelectedVideos(videoInfo.videos.map((_, index) => index + 1)); + } + }; + + const deselectAllVideos = () => { + setSelectedVideos([]); + }; + + // Cleanup WebSocket on unmount + useEffect(() => { + return () => { + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, []); + const getAllVideoQualities = () => { + if (!videoInfo?.videos) return []; + const allQualities = new Set(); + videoInfo.videos.forEach(video => { + video.video_resolutions.forEach(quality => allQualities.add(quality)); + }); + return Array.from(allQualities).sort((a, b) => { + const aNum = parseInt(a.replace('p', '')); + const bNum = parseInt(b.replace('p', '')); + return bNum - aNum; + }); + }; + + const getAllAudioQualities = () => { + if (!videoInfo?.videos) return []; + const allQualities = new Set(); + videoInfo.videos.forEach(video => { + video.audio_resolutions.forEach(quality => allQualities.add(quality)); + }); + return Array.from(allQualities).sort((a, b) => { + const aNum = parseInt(a.replace('kbps', '')); + const bNum = parseInt(b.replace('kbps', '')); + return bNum - aNum; + }); + }; + + async function retrieveVideoInfo() { + setIsLoading(true); + setError(null); + setVideoInfo(null); + setSelectedVideos([]); + try { + const info = await apiDownloaderDownloadRetrieve({ url: videoUrl }); + setVideoInfo(info); + // If it's a playlist, select all videos by default + if (info.is_playlist && info.videos) { + setSelectedVideos(info.videos.map((_, index) => index + 1)); + } + + } catch (err: any) { + const errorMessage = err.response?.data?.error || err.message; + setError({ error: errorMessage }); + console.error('Retrieve video info error:', err); + + } finally { + setIsLoading(false); + } + } + + async function handleDownload() { + if (!videoUrl) { + setError({ error: 'Please enter a URL first' }); + return; + } + + setIsDownloading(true); + setDownloadStatus('Connecting…'); + setDownloadProgress(null); + setError(null); + + try { + const videoQuality = selectedVideoQuality + ? parseInt(selectedVideoQuality.replace('p', '')) + : undefined; + const audioQuality = selectedAudioQuality + ? parseInt(selectedAudioQuality.replace('kbps', '')) + : undefined; + + // Connect to WebSocket + const wsUrl = `ws://localhost:8000/ws/downloader/`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + await new Promise((resolve, reject) => { + ws.onopen = () => { + setDownloadStatus('Starting…'); + // Send download parameters + ws.send(JSON.stringify({ + url: videoUrl, + ext: selectedExtension, + video_quality: videoQuality, + audio_quality: audioQuality, + selected_videos: videoInfo?.is_playlist && selectedVideos.length > 0 ? selectedVideos : null, + subtitles: subtitles || null, + embed_subtitles: embedSubtitles, + embed_thumbnail: embedThumbnail, + cookies: cookies || null, + })); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'status') { + setDownloadStatus(data.message); + } else if (data.type === 'progress') { + setDownloadStatus(data.message); + setDownloadProgress(data.percent); + } else if (data.type === 'done') { + + setDownloadStatus('Finalizing download...'); + setDownloadProgress(100); + + // Přidáme responseType: 'blob', aby Axios/Fetch vrátil soubor správně + publicApi.post('/api/downloader/download/file/', + { token: data.token }, + { responseType: 'blob' } + ) + .then((response) => { + // 1. Vytvoření dočasné URL z přijatého Blobu + const url = window.URL.createObjectURL(new Blob([response.data])); + + // 2. Extrakce názvu souboru z hlaviček (pokud ho backend posílá) + let filename = `${data.filename || 'video'}.${selectedExtension}`; + + // 3. Vytvoření neviditelného odkazu pro spuštění stahování + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); // Zajišťuje stáhnutí místo otevření + document.body.appendChild(link); + + // 4. Simulace kliknutí + link.click(); + + // 5. Úklid po stažení + link.remove(); + window.URL.revokeObjectURL(url); + + setDownloadStatus('Download complete!'); + resolve(); + }) + .catch((err) => { + console.error('Failed to download blob:', err); + reject(new Error('Failed to retrieve the file after processing.')); + }); + + } else if (data.type === 'error') { + reject(new Error(data.message)); + } + }; + + ws.onerror = () => { + reject(new Error('WebSocket connection failed')); + }; + + ws.onclose = () => { + if (wsRef.current === ws) { + wsRef.current = null; + } + }; + }); + + } catch (err: any) { + const errorMessage = err.message || 'Failed to download video'; + setError({ error: errorMessage }); + } finally { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + setIsDownloading(false); + setDownloadStatus(''); + setDownloadProgress(null); + } + } + + return ( +
+ +

Video Downloader

+ +
+
+ + Video URL +
+ setVideoUrl(e.target.value)} + placeholder="Paste video URL here (YouTube, TikTok, Vimeo, etc.)" + className="w-full p-3 border rounded" + /> +
+ + + + {error && ( +
+ Error: {error.error} +
+ )} + + {videoInfo && ( +
+ {videoInfo.is_playlist ? ( +
+

+ 📋 {videoInfo.playlist_title || 'Playlist'} +

+

+ {videoInfo.playlist_count} videos found +

+ + {/* Playlist Video Selection */} +
+
+

Select Videos to Download:

+
+ + +
+
+ +
+ {videoInfo.videos.map((video, index) => { + const videoNumber = index + 1; + return ( +
+ +
+ ); + })} +
+ +
+ {selectedVideos.length} of {videoInfo.videos.length} videos selected +
+
+
+ ) : ( +
+

+ 🎥 {videoInfo.videos[0]?.title || 'Video'} +

+ + {videoInfo.videos[0]?.thumbnail && ( + {videoInfo.videos[0].title} + )} + + {videoInfo.videos[0]?.duration && ( +

+ Duration: {Math.floor(videoInfo.videos[0].duration / 60)}:{String(videoInfo.videos[0].duration % 60).padStart(2, '0')} +

+ )} +
+ )} + + {/* Quality and Format Selection */} +
+ {/* Video Quality Dropdown */} +
+ + +
+ + {/* Audio Quality Dropdown */} +
+ + +
+ + {/* File Extension Dropdown */} +
+ + +
+
+ + + + {isDownloading && downloadStatus && ( +
+
+ + + + + {downloadStatus} +
+ {downloadProgress !== null && ( +
+
+
+ )} +
+ )} + + {videoInfo.is_playlist && selectedVideos.length === 0 && ( +

+ Please select at least one video to download +

+ )} + + {/* Advanced Options Toggle */} +
+ + + {showAdvanced && ( +
+ {/* Subtitles */} +
+ + setSubtitles(e.target.value)} + placeholder="e.g., 'en', 'en,cs', or 'all'" + className="w-full p-2 border rounded" + /> +

+ Language codes (e.g., 'en', 'cs') or 'all' for all available +

+
+ + {/* Checkboxes Row */} +
+ + + +
+ + {/* Cookies for Age-Restricted Content */} +
+ +