Compare commits

...

2 Commits

Author SHA1 Message Date
b3bc242e27 Update Downloader.tsx 2026-04-21 00:49:54 +02:00
cf08dbaf15 fixes, orval, downloader functioning again 2026-04-21 00:47:10 +02:00
93 changed files with 1662 additions and 1333 deletions

2
.gitignore vendored
View File

@@ -68,6 +68,8 @@ frontend/.env.*.prod
frontend/.env.*.test frontend/.env.*.test
frontend/.env.*.dev frontend/.env.*.dev
frontend/.env.*.staging frontend/.env.*.staging
volumes/
frontend/.env.*.production frontend/.env.*.production
frontend/.env.*.development frontend/.env.*.development
frontend/.env.*.example frontend/.env.*.example

View File

@@ -12,6 +12,7 @@ RUN apt update && apt install -y \
ffmpeg \ ffmpeg \
ca-certificates \ ca-certificates \
curl \ curl \
libmagic1 \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt install -y nodejs \ && apt install -y nodejs \
&& update-ca-certificates \ && update-ca-certificates \

View File

@@ -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 account.models
import django.contrib.auth.validators import django.contrib.auth.validators
import django.core.validators import django.core.validators
import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -59,4 +61,16 @@ class Migration(migrations.Migration):
('active', account.models.ActiveUserManager()), ('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')},
},
),
] ]

View File

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

View File

@@ -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 from django.db import migrations, models

View File

@@ -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.core.validators
import django.db.models.deletion import django.db.models.deletion
@@ -14,9 +14,9 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('configuration', '0001_initial'), ('configuration', '0001_initial'),
('deutschepost', '0002_deutschepostbulkorder_bulk_label_pdf_and_more'), ('deutschepost', '0001_initial'),
('stripe', '0001_initial'), ('stripe', '0001_initial'),
('zasilkovna', '0002_alter_zasilkovnapacket_state'), ('zasilkovna', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]

View File

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

View File

@@ -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 import django.core.validators
from decimal import Decimal from decimal import Decimal

View File

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

View File

@@ -83,7 +83,7 @@ class VATRate(models.Model):
) )
rate = models.DecimalField( rate = models.DecimalField(
max_digits=5, max_digits=6,
decimal_places=4, # Allows rates like 19.5000% decimal_places=4, # Allows rates like 19.5000%
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('100'))], validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('100'))],
help_text="VAT rate as percentage (e.g. 19.00 for 19%)" help_text="VAT rate as percentage (e.g. 19.00 for 19%)"

View File

@@ -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 django.db.models.deletion
import vontor_cz.custom_fields
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -10,6 +11,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('hubs', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@@ -18,17 +20,29 @@ class Migration(migrations.Migration):
name='Chat', name='Chat',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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)), ('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)), ('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)), ('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( migrations.CreateModel(
name='Message', name='Message',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('content', models.TextField(blank=True)),
('is_edited', models.BooleanField(default=False)), ('is_edited', models.BooleanField(default=False)),
('edited_at', models.DateTimeField(blank=True, null=True)), ('edited_at', models.DateTimeField(blank=True, null=True)),
@@ -36,23 +50,33 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True)), ('updated_at', models.DateTimeField(auto_now=True)),
('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.chat')), ('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')), ('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( migrations.CreateModel(
name='MessageFile', name='MessageFile',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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/')), ('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)), ('media_type', models.CharField(choices=[('IMAGE', 'Image'), ('VIDEO', 'Video'), ('FILE', 'File')], default='FILE', max_length=20)),
('uploaded_at', models.DateTimeField(auto_now_add=True)), ('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')), ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='chat.message')),
], ],
options={
'abstract': False,
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='MessageHistory', name='MessageHistory',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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()), ('old_content', models.TextField()),
('archived_at', models.DateTimeField(auto_now_add=True)), ('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')), ('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', name='MessageReaction',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('emoji', models.CharField(max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='chat.message')), ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='chat.message')),

View File

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

View File

@@ -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 django.db.models.deletion
import vontor_cz.custom_fields import vontor_cz.custom_fields

View File

@@ -1,4 +1,3 @@
from turtle import color
import uuid import uuid
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings

View File

@@ -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 django.db.models.deletion
from django.conf import settings from django.conf import settings

View File

@@ -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 from django.db import migrations, models
@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('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)), ('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)), ('customer_ekp', models.CharField(blank=True, max_length=20, null=True)),
('recipient_name', models.CharField(max_length=200)), ('recipient_name', models.CharField(max_length=200)),
@@ -43,6 +43,8 @@ class Migration(migrations.Migration):
('tracking_url', models.URLField(blank=True, null=True)), ('tracking_url', models.URLField(blank=True, null=True)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Raw API response data')), ('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')), ('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( migrations.CreateModel(
@@ -50,12 +52,14 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('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_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)), ('bulk_order_type', models.CharField(default='MIXED_BAG', help_text='MIXED_BAG, etc.', max_length=20)),
('description', models.CharField(blank=True, max_length=255)), ('description', models.CharField(blank=True, max_length=255)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Raw API response data')), ('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')), ('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')), ('deutschepost_orders', models.ManyToManyField(blank=True, related_name='bulk_orders', to='deutschepost.deutschepostorder')),
], ],
), ),

View File

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

View File

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

View File

@@ -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 from django.db import migrations, models
@@ -8,6 +10,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
@@ -18,13 +21,21 @@ class Migration(migrations.Migration):
('is_deleted', models.BooleanField(default=False)), ('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)), ('deleted_at', models.DateTimeField(blank=True, null=True)),
('url', models.URLField()), ('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)), ('format', models.CharField(max_length=50)),
('length_of_media', models.IntegerField(help_text='Length of media in seconds')), ('video_quality', models.IntegerField(blank=True, help_text='Video height in pixels (e.g. 1080). Null for audio-only.', null=True)),
('file_size', models.BigIntegerField(help_text='File size in bytes')), ('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={ options={
'abstract': False, 'ordering': ['-download_time'],
}, },
), ),
] ]

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"ws/downloader/$", consumers.DownloaderConsumer.as_asgi()),
]

View File

@@ -4,6 +4,6 @@ from .views import Downloader, DownloaderStats
urlpatterns = [ urlpatterns = [
# Probe formats for a URL (size-checked) # Probe formats for a URL (size-checked)
path("download/", Downloader.as_view(), name="downloader-download"), 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"), path("stats/", DownloaderStats.as_view(), name="downloader-stats"),
] ]

View File

@@ -1,31 +1,27 @@
# ---------------------- Inline serializers for documentation only ---------------------- # ---------------------- Inline serializers for documentation only ----------------------
# Using inline_serializer to avoid creating new files. # Using inline_serializer to avoid creating new files.
import yt_dlp import asyncio
import tempfile
import os import os
import shutil import shutil
import mimetypes
import base64 import yt_dlp
import urllib.request
import zipfile
import time
import requests import requests
import base64
from urllib.parse import urlparse from urllib.parse import urlparse
from .consumers import TOKEN_TTL
from django.core import signing
from rest_framework import serializers from rest_framework import serializers
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response 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.utils import extend_schema, inline_serializer
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from django.conf import settings from django.conf import settings
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
from django.utils.text import slugify 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 from .models import DownloaderRecord
# Common container formats - user can provide any extension supported by ffmpeg # 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 "no_check_certificates": True, # Bypass SSL verification in Docker
"extract_flat": False, # Extract full info for playlists too "extract_flat": False, # Extract full info for playlists too
"ignoreerrors": False, # Don't ignore errors to get accurate info "ignoreerrors": False, # Don't ignore errors to get accurate info
"js_runtimes": {"node": {}},
"remote_components": {"ejs:github"},
} }
try: try:
@@ -233,381 +231,58 @@ class Downloader(APIView):
@extend_schema( @extend_schema(
tags=["downloader", "public"], tags=["downloader", "public"],
summary="Download video or playlist from URL", summary="Download file via signed token",
description=""" description="Serve a file using a signed token from WebSocket download. Token expires in 10 minutes.",
Download video/playlist with optional quality constraints and container format conversion. parameters=[
inline_serializer(
**For Playlists:** name="DownloadTokenParams",
- Returns a ZIP file containing all selected videos fields={
- Use `selected_videos` to specify which videos to download (e.g., [1,3,5] or [1,2,3,4,5]) "token": serializers.CharField(help_text="Signed token containing file info"),
- 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'"
),
},
),
responses={ responses={
200: OpenApiTypes.BINARY, 200: OpenApiTypes.BINARY,
400: inline_serializer( 400: inline_serializer(name="TokenError", fields={"error": serializers.CharField()}),
name="DownloadErrorResponse",
fields={
"error": serializers.CharField(),
},
),
}, },
) )
def post(self, request): def post(self, request):
url = request.data.get("url") token = request.data.get("token")
# Accept ext parameter, default to mp4 if not token:
ext = request.data.get("ext", "mp4") return Response({"error": "Token is required"}, status=400)
# 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: try:
with yt_dlp.YoutubeDL(ydl_info_options) as ydl: data = signing.loads(token, salt="downloader-file-token", max_age=TOKEN_TTL)
info = ydl.extract_info(url, download=False) except signing.BadSignature:
except Exception as e: return Response({"error": "Invalid token"}, status=400)
shutil.rmtree(tmpdir, ignore_errors=True) except signing.SignatureExpired:
return Response({"error": f"Failed to retrieve URL info: {str(e)}"}, status=400) return Response({"error": "Token expired"}, status=400)
is_playlist = "entries" in info and info.get("entries") is not None file_path = data["file_path"]
tmpdir = data["tmpdir"]
# Build format selector using optional quality caps if not file_path or not os.path.exists(file_path):
if video_quality is not None and audio_quality is not None: return Response({"error": "File no longer available"}, status=400)
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 async def stream_and_cleanup(path: str, temp_dir: str, chunk_size: int = 8192):
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: try:
with open(cookie_file, "w") as f: with open(path, "rb") as f:
f.write(cookies) while True:
ydl_options["cookiefile"] = cookie_file chunk = await asyncio.to_thread(f.read, chunk_size)
except Exception as e: if not chunk:
shutil.rmtree(tmpdir, ignore_errors=True) break
return Response({"error": f"Invalid cookies format: {str(e)}"}, status=400) yield chunk
finally:
# Subtitles shutil.rmtree(temp_dir, ignore_errors=True)
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()
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,
)
# 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)
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)
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 ---------------- # ---------------- STATS FOR GRAPHS ----------------
@@ -628,3 +303,4 @@ class DownloaderStats(APIView):
) )
def get(self, request): def get(self, request):
return Response(DownloaderStatsSerializer(DownloaderRecord.objects.all()).data) return Response(DownloaderStatsSerializer(DownloaderRecord.objects.all()).data)

View File

@@ -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.db.models.deletion
import django.utils.timezone import django.utils.timezone

View File

@@ -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 from django.db import migrations, models

View File

@@ -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 import django.core.validators
from django.db import migrations, models from django.db import migrations, models
@@ -17,7 +17,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)), ('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)), ('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)), ('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)), ('barcode', models.CharField(blank=True, help_text='Čárový kód zásilky od Packety', max_length=64, null=True)),

View File

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

View File

@@ -8,18 +8,24 @@ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
""" """
import os import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vontor_cz.settings')
from django.core.asgi import get_asgi_application 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.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack from channels.auth import AuthMiddlewareStack
import social.chat.routing import social.chat.routing
from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vontor_cz.settings')
application = ProtocolTypeRouter({ application = ProtocolTypeRouter({
"http": get_asgi_application(), "http": django_asgi_app,
"websocket": AuthMiddlewareStack( "websocket": AuthMiddlewareStack(
URLRouter( URLRouter(
social.chat.routing.websocket_urlpatterns downloader_ws + social.chat.routing.websocket_urlpatterns
) )
), ),
}) })

View File

@@ -20,7 +20,7 @@ services:
python manage.py migrate --noinput && python manage.py migrate --noinput &&
python manage.py collectstatic --clear --noinput && python manage.py collectstatic --clear --noinput &&
python manage.py seed_app_config && 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: ports:
- "8000:8000" - "8000:8000"
@@ -56,7 +56,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
command: celery -A trznice worker --loglevel=info command: celery -A vontor_cz worker --loglevel=info
restart: always restart: always
env_file: env_file:
- ./backend/.env - ./backend/.env
@@ -74,7 +74,7 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
command: celery -A trznice beat --loglevel=info command: celery -A vontor_cz beat --loglevel=info
restart: always restart: always
env_file: env_file:
- ./backend/.env - ./backend/.env
@@ -117,7 +117,27 @@ networks:
volumes: volumes:
redis-data: redis-data:
driver: local
driver_opts:
type: none
o: bind
device: ./volumes/redis
db-data: db-data:
driver: local
driver_opts:
type: none
o: bind
device: ./volumes/postgres
static-data: static-data:
driver: local
driver_opts:
type: none
o: bind
device: ./volumes/static
media-data: media-data:
driver: local
driver_opts:
type: none
o: bind
device: ./volumes/media

View File

@@ -1,6 +1,6 @@
import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import HomeLayout from "./layouts/HomeLayout"; import HomeLayout from "./layouts/HomeLayout";
import ChatLayout from "./layouts/ChatLayout"; import ChatLayout from "./layouts/social/Chat";
import Downloader from "./pages/downloader/Downloader"; import Downloader from "./pages/downloader/Downloader";
import Home from "./pages/home/home"; import Home from "./pages/home/home";
@@ -14,8 +14,6 @@ import PortfolioPage from "./pages/portfolio/PortfolioPage";
import ContactPage from "./pages/contact/ContactPage"; import ContactPage from "./pages/contact/ContactPage";
import ScrollToTop from "./components/common/ScrollToTop"; import ScrollToTop from "./components/common/ScrollToTop";
import AuthLayout from "./layouts/AuthLayout";
import LogoutPage from "./pages/social/account/Logout"; import LogoutPage from "./pages/social/account/Logout";
import LoginPage from "./pages/social/account/Login"; import LoginPage from "./pages/social/account/Login";
import RegisterPage from "./pages/social/account/Register"; import RegisterPage from "./pages/social/account/Register";
@@ -44,7 +42,7 @@ export default function App() {
</Route> </Route>
<Route path="auth/" element={<AuthLayout />}> <Route path="auth/" element={<PrivateRoute />}>
<Route path="login" element={<LoginPage />} /> <Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} /> <Route path="register" element={<RegisterPage />} />
<Route path="logout" element={<LogoutPage />} /> <Route path="logout" element={<LogoutPage />} />

View File

@@ -49,6 +49,7 @@ export const ApiSchemaRetrieveLang = {
hi: "hi", hi: "hi",
hr: "hr", hr: "hr",
hsb: "hsb", hsb: "hsb",
ht: "ht",
hu: "hu", hu: "hu",
hy: "hy", hy: "hy",
ia: "ia", ia: "ia",

View File

@@ -4,12 +4,12 @@
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { ShippingMethodEnum } from "./shippingMethodEnum"; import type { ShippingMethodEnum } from "./shippingMethodEnum";
import type { State1f6Enum } from "./state1f6Enum"; import type { StateF41Enum } from "./stateF41Enum";
import type { ZasilkovnaPacketRead } from "./zasilkovnaPacketRead"; import type { ZasilkovnaPacketRead } from "./zasilkovnaPacketRead";
export interface CarrierRead { export interface CarrierRead {
readonly shipping_method: ShippingMethodEnum; readonly shipping_method: ShippingMethodEnum;
readonly state: State1f6Enum; readonly state: StateF41Enum;
readonly zasilkovna: readonly ZasilkovnaPacketRead[]; readonly zasilkovna: readonly ZasilkovnaPacketRead[];
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly shipping_price: string; readonly shipping_price: string;

View File

@@ -12,7 +12,7 @@ export interface CartItem {
readonly product_price: string; readonly product_price: string;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
quantity?: number; quantity?: number;
readonly subtotal: string; readonly subtotal: string;

View File

@@ -60,7 +60,7 @@ export interface DeutschePostOrder {
/** /**
* Weight in grams * Weight in grams
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
shipment_gross_weight: number; shipment_gross_weight: number;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */

View File

@@ -29,7 +29,7 @@ export interface DiscountCode {
active?: boolean; active?: boolean;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
* @nullable * @nullable
*/ */
usage_limit?: number | null; usage_limit?: number | null;

View File

@@ -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;
}

View File

@@ -44,9 +44,7 @@ export * from "./deutschePostOrder";
export * from "./deutschePostOrderStateEnum"; export * from "./deutschePostOrderStateEnum";
export * from "./deutschePostTracking"; export * from "./deutschePostTracking";
export * from "./discountCode"; export * from "./discountCode";
export * from "./downloadErrorResponse";
export * from "./downloaderStats"; export * from "./downloaderStats";
export * from "./downloadRequest";
export * from "./errorResponse"; export * from "./errorResponse";
export * from "./gopayCreatePayment201"; export * from "./gopayCreatePayment201";
export * from "./gopayGetStatus200"; export * from "./gopayGetStatus200";
@@ -133,12 +131,13 @@ export * from "./reviewSerializerPublic";
export * from "./roleEnum"; export * from "./roleEnum";
export * from "./shippingMethodEnum"; export * from "./shippingMethodEnum";
export * from "./siteConfiguration"; export * from "./siteConfiguration";
export * from "./state1f6Enum"; export * from "./state9b5Enum";
export * from "./stateCdfEnum"; export * from "./stateF41Enum";
export * from "./statusD4fEnum"; export * from "./status0b2Enum";
export * from "./tagAttach"; export * from "./tagAttach";
export * from "./tags"; export * from "./tags";
export * from "./timeseriesPoint"; export * from "./timeseriesPoint";
export * from "./tokenError";
export * from "./topUrl"; export * from "./topUrl";
export * from "./trackingURL"; export * from "./trackingURL";
export * from "./transferInit"; export * from "./transferInit";

View File

@@ -4,12 +4,12 @@
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { ShippingMethodEnum } from "./shippingMethodEnum"; import type { ShippingMethodEnum } from "./shippingMethodEnum";
import type { State1f6Enum } from "./state1f6Enum"; import type { StateF41Enum } from "./stateF41Enum";
import type { ZasilkovnaPacket } from "./zasilkovnaPacket"; import type { ZasilkovnaPacket } from "./zasilkovnaPacket";
export interface OrderCarrier { export interface OrderCarrier {
shipping_method?: ShippingMethodEnum; shipping_method?: ShippingMethodEnum;
readonly state: State1f6Enum; readonly state: StateF41Enum;
readonly zasilkovna: readonly ZasilkovnaPacket[]; readonly zasilkovna: readonly ZasilkovnaPacket[];
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly shipping_price: string; readonly shipping_price: string;

View File

@@ -3,11 +3,11 @@
* Do not edit manually. * Do not edit manually.
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { StatusD4fEnum } from "./statusD4fEnum"; import type { Status0b2Enum } from "./status0b2Enum";
export interface OrderMini { export interface OrderMini {
readonly id: number; readonly id: number;
readonly status: StatusD4fEnum; readonly status: Status0b2Enum;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly total_price: string; readonly total_price: string;
readonly created_at: Date; readonly created_at: Date;

View File

@@ -6,11 +6,11 @@
import type { CarrierRead } from "./carrierRead"; import type { CarrierRead } from "./carrierRead";
import type { OrderItemRead } from "./orderItemRead"; import type { OrderItemRead } from "./orderItemRead";
import type { PaymentRead } from "./paymentRead"; import type { PaymentRead } from "./paymentRead";
import type { StatusD4fEnum } from "./statusD4fEnum"; import type { Status0b2Enum } from "./status0b2Enum";
export interface OrderRead { export interface OrderRead {
readonly id: number; readonly id: number;
readonly status: StatusD4fEnum; readonly status: Status0b2Enum;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly total_price: string; readonly total_price: string;
/** Order currency - captured from site configuration at order creation and never changes */ /** Order currency - captured from site configuration at order creation and never changes */

View File

@@ -60,7 +60,7 @@ export interface PatchedDeutschePostOrder {
/** /**
* Weight in grams * Weight in grams
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
shipment_gross_weight?: number; shipment_gross_weight?: number;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */

View File

@@ -29,7 +29,7 @@ export interface PatchedDiscountCode {
active?: boolean; active?: boolean;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
* @nullable * @nullable
*/ */
usage_limit?: number | null; usage_limit?: number | null;

View File

@@ -6,11 +6,11 @@
import type { CarrierRead } from "./carrierRead"; import type { CarrierRead } from "./carrierRead";
import type { OrderItemRead } from "./orderItemRead"; import type { OrderItemRead } from "./orderItemRead";
import type { PaymentRead } from "./paymentRead"; import type { PaymentRead } from "./paymentRead";
import type { StatusD4fEnum } from "./statusD4fEnum"; import type { Status0b2Enum } from "./status0b2Enum";
export interface PatchedOrderRead { export interface PatchedOrderRead {
readonly id?: number; readonly id?: number;
readonly status?: StatusD4fEnum; readonly status?: Status0b2Enum;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly total_price?: string; readonly total_price?: string;
/** Order currency - captured from site configuration at order creation and never changes */ /** Order currency - captured from site configuration at order creation and never changes */

View File

@@ -26,7 +26,7 @@ export interface PatchedProduct {
url?: string; url?: string;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
stock?: number; stock?: number;
is_active?: boolean; is_active?: boolean;

View File

@@ -16,7 +16,7 @@ export interface PatchedVATRate {
name?: string; name?: string;
/** /**
* VAT rate as percentage (e.g. 19.00 for 19%) * 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; rate?: string;
/** VAT rate as decimal (e.g., 0.19 for 19%) */ /** VAT rate as decimal (e.g., 0.19 for 19%) */

View File

@@ -10,8 +10,8 @@ export interface PostVote {
post: number; post: number;
readonly user: number; readonly user: number;
/** /**
* @minimum -9223372036854776000 * @minimum -32768
* @maximum 9223372036854776000 * @maximum 32767
*/ */
vote: VoteEnum; vote: VoteEnum;
readonly created_at: Date; readonly created_at: Date;

View File

@@ -26,7 +26,7 @@ export interface Product {
url: string; url: string;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
stock?: number; stock?: number;
is_active?: boolean; is_active?: boolean;

View File

@@ -19,7 +19,7 @@ export interface ProductMiniForWishlist {
is_active?: boolean; is_active?: boolean;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
stock?: number; stock?: number;
} }

View File

@@ -13,9 +13,9 @@
* `RETURNING` - Posláno zpátky * `RETURNING` - Posláno zpátky
* `RETURNED` - Vráceno * `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", WAITING_FOR_ORDERING_SHIPMENT: "WAITING_FOR_ORDERING_SHIPMENT",
PENDING: "PENDING", PENDING: "PENDING",
SENDED: "SENDED", SENDED: "SENDED",

View File

@@ -10,9 +10,9 @@
* `delivered` - Doručeno * `delivered` - Doručeno
* `ready_to_pickup` - Připraveno k vyzvednutí * `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", ordered: "ordered",
shipped: "shipped", shipped: "shipped",
delivered: "delivered", delivered: "delivered",

View File

@@ -11,9 +11,9 @@
* `refunding` - Vrácení v procesu * `refunding` - Vrácení v procesu
* `refunded` - Vráceno * `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", created: "created",
cancelled: "cancelled", cancelled: "cancelled",
completed: "completed", completed: "completed",

View File

@@ -4,6 +4,6 @@
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
export interface DownloadErrorResponse { export interface TokenError {
error: string; error: string;
} }

View File

@@ -16,7 +16,7 @@ export interface VATRate {
name: string; name: string;
/** /**
* VAT rate as percentage (e.g. 19.00 for 19%) * 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; rate: string;
/** VAT rate as decimal (e.g., 0.19 for 19%) */ /** VAT rate as decimal (e.g., 0.19 for 19%) */

View File

@@ -3,15 +3,15 @@
* Do not edit manually. * Do not edit manually.
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { StateCdfEnum } from "./stateCdfEnum"; import type { State9b5Enum } from "./state9b5Enum";
export interface ZasilkovnaPacket { export interface ZasilkovnaPacket {
readonly id: number; readonly id: number;
readonly created_at: Date; readonly created_at: Date;
/** /**
* Číslo zásilky v Packetě (vraceno od API od Packety) * Číslo zásilky v Packetě (vraceno od API od Packety)
* @minimum -9223372036854776000 * @minimum -2147483648
* @maximum 9223372036854776000 * @maximum 2147483647
* @nullable * @nullable
*/ */
packet_id?: number | null; packet_id?: number | null;
@@ -20,7 +20,7 @@ export interface ZasilkovnaPacket {
* @nullable * @nullable
*/ */
readonly barcode: string | null; readonly barcode: string | null;
readonly state: StateCdfEnum; readonly state: State9b5Enum;
/** Hmotnost zásilky v gramech */ /** Hmotnost zásilky v gramech */
readonly weight: number; readonly weight: number;
/** Seznam 2 routing stringů pro vrácení zásilky */ /** Seznam 2 routing stringů pro vrácení zásilky */

View File

@@ -3,15 +3,15 @@
* Do not edit manually. * Do not edit manually.
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { StateCdfEnum } from "./stateCdfEnum"; import type { State9b5Enum } from "./state9b5Enum";
export interface ZasilkovnaPacketRead { export interface ZasilkovnaPacketRead {
readonly id: number; readonly id: number;
readonly created_at: Date; readonly created_at: Date;
/** /**
* Číslo zásilky v Packetě (vraceno od API od Packety) * Číslo zásilky v Packetě (vraceno od API od Packety)
* @minimum -9223372036854776000 * @minimum -2147483648
* @maximum 9223372036854776000 * @maximum 2147483647
* @nullable * @nullable
*/ */
packet_id?: number | null; packet_id?: number | null;
@@ -20,7 +20,7 @@ export interface ZasilkovnaPacketRead {
* @nullable * @nullable
*/ */
readonly barcode: string | null; readonly barcode: string | null;
readonly state: StateCdfEnum; readonly state: State9b5Enum;
/** Hmotnost zásilky v gramech */ /** Hmotnost zásilky v gramech */
readonly weight: number; readonly weight: number;
/** Seznam 2 routing stringů pro vrácení zásilky */ /** Seznam 2 routing stringů pro vrácení zásilky */

View File

@@ -20,11 +20,13 @@ import type {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import type { import type {
ApiDownloaderDownloadCreateParams,
ApiDownloaderDownloadFileCreateParams,
ApiDownloaderDownloadFileRetrieveParams,
ApiDownloaderDownloadRetrieveParams, ApiDownloaderDownloadRetrieveParams,
DownloadErrorResponse,
DownloadRequest,
DownloaderStats, DownloaderStats,
ErrorResponse, ErrorResponse,
TokenError,
VideoInfoResponse, VideoInfoResponse,
} from "./models"; } from "./models";
@@ -214,60 +216,35 @@ export function useApiDownloaderDownloadRetrieve<
} }
/** /**
* * Serve a file using a signed token from WebSocket download. Token expires in 10 minutes.
Download video/playlist with optional quality constraints and container format conversion. * @summary Download file via signed token
**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
*/ */
export const apiDownloaderDownloadCreate = ( export const apiDownloaderDownloadCreate = (
downloadRequest: DownloadRequest, params: ApiDownloaderDownloadCreateParams,
signal?: AbortSignal, signal?: AbortSignal,
) => { ) => {
return publicMutator<Blob>({ return publicMutator<Blob>({
url: `/api/downloader/download/`, url: `/api/downloader/download/`,
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, params,
data: downloadRequest,
signal, signal,
}); });
}; };
export const getApiDownloaderDownloadCreateMutationOptions = < export const getApiDownloaderDownloadCreateMutationOptions = <
TError = DownloadErrorResponse, TError = TokenError,
TContext = unknown, TContext = unknown,
>(options?: { >(options?: {
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>, Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
TError, TError,
{ data: DownloadRequest }, { params: ApiDownloaderDownloadCreateParams },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>, Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
TError, TError,
{ data: DownloadRequest }, { params: ApiDownloaderDownloadCreateParams },
TContext TContext
> => { > => {
const mutationKey = ["apiDownloaderDownloadCreate"]; const mutationKey = ["apiDownloaderDownloadCreate"];
@@ -281,11 +258,11 @@ export const getApiDownloaderDownloadCreateMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>, Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
{ data: DownloadRequest } { params: ApiDownloaderDownloadCreateParams }
> = (props) => { > = (props) => {
const { data } = props ?? {}; const { params } = props ?? {};
return apiDownloaderDownloadCreate(data); return apiDownloaderDownloadCreate(params);
}; };
return { mutationFn, ...mutationOptions }; return { mutationFn, ...mutationOptions };
@@ -294,21 +271,21 @@ export const getApiDownloaderDownloadCreateMutationOptions = <
export type ApiDownloaderDownloadCreateMutationResult = NonNullable< export type ApiDownloaderDownloadCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>> Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>
>; >;
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 = < export const useApiDownloaderDownloadCreate = <
TError = DownloadErrorResponse, TError = TokenError,
TContext = unknown, TContext = unknown,
>( >(
options?: { options?: {
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>, Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
TError, TError,
{ data: DownloadRequest }, { params: ApiDownloaderDownloadCreateParams },
TContext TContext
>; >;
}, },
@@ -316,7 +293,7 @@ export const useApiDownloaderDownloadCreate = <
): UseMutationResult< ): UseMutationResult<
Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>, Awaited<ReturnType<typeof apiDownloaderDownloadCreate>>,
TError, TError,
{ data: DownloadRequest }, { params: ApiDownloaderDownloadCreateParams },
TContext TContext
> => { > => {
return useMutation( return useMutation(
@@ -324,6 +301,279 @@ export const useApiDownloaderDownloadCreate = <
queryClient, 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<VideoInfoResponse>({
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<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError = ErrorResponse,
>(
params: ApiDownloaderDownloadFileRetrieveParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError,
TData
>
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getApiDownloaderDownloadFileRetrieveQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>
> = ({ signal }) => apiDownloaderDownloadFileRetrieve(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ApiDownloaderDownloadFileRetrieveQueryResult = NonNullable<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>
>;
export type ApiDownloaderDownloadFileRetrieveQueryError = ErrorResponse;
export function useApiDownloaderDownloadFileRetrieve<
TData = Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError = ErrorResponse,
>(
params: ApiDownloaderDownloadFileRetrieveParams,
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError,
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiDownloaderDownloadFileRetrieve<
TData = Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError = ErrorResponse,
>(
params: ApiDownloaderDownloadFileRetrieveParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError,
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>
>,
"initialData"
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useApiDownloaderDownloadFileRetrieve<
TData = Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError = ErrorResponse,
>(
params: ApiDownloaderDownloadFileRetrieveParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Get video info from URL
*/
export function useApiDownloaderDownloadFileRetrieve<
TData = Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError = ErrorResponse,
>(
params: ApiDownloaderDownloadFileRetrieveParams,
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileRetrieve>>,
TError,
TData
>
>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getApiDownloaderDownloadFileRetrieveQueryOptions(
params,
options,
);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
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<Blob>({
url: `/api/downloader/download/file/`,
method: "POST",
params,
signal,
});
};
export const getApiDownloaderDownloadFileCreateMutationOptions = <
TError = TokenError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
TError,
{ params: ApiDownloaderDownloadFileCreateParams },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
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<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
{ params: ApiDownloaderDownloadFileCreateParams }
> = (props) => {
const { params } = props ?? {};
return apiDownloaderDownloadFileCreate(params);
};
return { mutationFn, ...mutationOptions };
};
export type ApiDownloaderDownloadFileCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>
>;
export type ApiDownloaderDownloadFileCreateMutationError = TokenError;
/**
* @summary Download file via signed token
*/
export const useApiDownloaderDownloadFileCreate = <
TError = TokenError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
TError,
{ params: ApiDownloaderDownloadFileCreateParams },
TContext
>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof apiDownloaderDownloadFileCreate>>,
TError,
{ params: ApiDownloaderDownloadFileCreateParams },
TContext
> => {
return useMutation(
getApiDownloaderDownloadFileCreateMutationOptions(options),
queryClient,
);
};
/** /**
* Vrací agregované statistiky z tabulky DownloaderRecord. * Vrací agregované statistiky z tabulky DownloaderRecord.
* @summary Get aggregated downloader statistics * @summary Get aggregated downloader statistics

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -4,12 +4,12 @@
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { ShippingMethodEnum } from "./shippingMethodEnum"; import type { ShippingMethodEnum } from "./shippingMethodEnum";
import type { State1f6Enum } from "./state1f6Enum"; import type { StateF41Enum } from "./stateF41Enum";
import type { ZasilkovnaPacketRead } from "./zasilkovnaPacketRead"; import type { ZasilkovnaPacketRead } from "./zasilkovnaPacketRead";
export interface CarrierRead { export interface CarrierRead {
readonly shipping_method: ShippingMethodEnum; readonly shipping_method: ShippingMethodEnum;
readonly state: State1f6Enum; readonly state: StateF41Enum;
readonly zasilkovna: readonly ZasilkovnaPacketRead[]; readonly zasilkovna: readonly ZasilkovnaPacketRead[];
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly shipping_price: string; readonly shipping_price: string;

View File

@@ -12,7 +12,7 @@ export interface CartItem {
readonly product_price: string; readonly product_price: string;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
quantity?: number; quantity?: number;
readonly subtotal: string; readonly subtotal: string;

View File

@@ -60,7 +60,7 @@ export interface DeutschePostOrder {
/** /**
* Weight in grams * Weight in grams
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
shipment_gross_weight: number; shipment_gross_weight: number;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */

View File

@@ -29,7 +29,7 @@ export interface DiscountCode {
active?: boolean; active?: boolean;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
* @nullable * @nullable
*/ */
usage_limit?: number | null; usage_limit?: number | null;

View File

@@ -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;
}

View File

@@ -10,6 +10,9 @@ export * from "./apiCommerceDiscountCodesListParams";
export * from "./apiCommerceOrdersListParams"; export * from "./apiCommerceOrdersListParams";
export * from "./apiCommerceProductImagesListParams"; export * from "./apiCommerceProductImagesListParams";
export * from "./apiCommerceProductsListParams"; export * from "./apiCommerceProductsListParams";
export * from "./apiDownloaderDownloadCreateParams";
export * from "./apiDownloaderDownloadFileCreateParams";
export * from "./apiDownloaderDownloadFileRetrieveParams";
export * from "./apiDownloaderDownloadRetrieveParams"; export * from "./apiDownloaderDownloadRetrieveParams";
export * from "./apiChoicesRetrieve200"; export * from "./apiChoicesRetrieve200";
export * from "./apiChoicesRetrieve200Item"; export * from "./apiChoicesRetrieve200Item";
@@ -30,9 +33,7 @@ export * from "./deutschePostOrder";
export * from "./deutschePostOrderStateEnum"; export * from "./deutschePostOrderStateEnum";
export * from "./deutschePostTracking"; export * from "./deutschePostTracking";
export * from "./discountCode"; export * from "./discountCode";
export * from "./downloadErrorResponse";
export * from "./downloaderStats"; export * from "./downloaderStats";
export * from "./downloadRequest";
export * from "./errorResponse"; export * from "./errorResponse";
export * from "./hub"; export * from "./hub";
export * from "./hubPermission"; export * from "./hubPermission";
@@ -116,12 +117,13 @@ export * from "./reviewSerializerPublic";
export * from "./roleEnum"; export * from "./roleEnum";
export * from "./shippingMethodEnum"; export * from "./shippingMethodEnum";
export * from "./siteConfiguration"; export * from "./siteConfiguration";
export * from "./state1f6Enum"; export * from "./state9b5Enum";
export * from "./stateCdfEnum"; export * from "./stateF41Enum";
export * from "./statusD4fEnum"; export * from "./status0b2Enum";
export * from "./tagAttach"; export * from "./tagAttach";
export * from "./tags"; export * from "./tags";
export * from "./timeseriesPoint"; export * from "./timeseriesPoint";
export * from "./tokenError";
export * from "./topUrl"; export * from "./topUrl";
export * from "./trackingURL"; export * from "./trackingURL";
export * from "./transferInit"; export * from "./transferInit";

View File

@@ -4,12 +4,12 @@
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { ShippingMethodEnum } from "./shippingMethodEnum"; import type { ShippingMethodEnum } from "./shippingMethodEnum";
import type { State1f6Enum } from "./state1f6Enum"; import type { StateF41Enum } from "./stateF41Enum";
import type { ZasilkovnaPacket } from "./zasilkovnaPacket"; import type { ZasilkovnaPacket } from "./zasilkovnaPacket";
export interface OrderCarrier { export interface OrderCarrier {
shipping_method?: ShippingMethodEnum; shipping_method?: ShippingMethodEnum;
readonly state: State1f6Enum; readonly state: StateF41Enum;
readonly zasilkovna: readonly ZasilkovnaPacket[]; readonly zasilkovna: readonly ZasilkovnaPacket[];
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly shipping_price: string; readonly shipping_price: string;

View File

@@ -3,11 +3,11 @@
* Do not edit manually. * Do not edit manually.
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { StatusD4fEnum } from "./statusD4fEnum"; import type { Status0b2Enum } from "./status0b2Enum";
export interface OrderMini { export interface OrderMini {
readonly id: number; readonly id: number;
readonly status: StatusD4fEnum; readonly status: Status0b2Enum;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly total_price: string; readonly total_price: string;
readonly created_at: Date; readonly created_at: Date;

View File

@@ -6,11 +6,11 @@
import type { CarrierRead } from "./carrierRead"; import type { CarrierRead } from "./carrierRead";
import type { OrderItemRead } from "./orderItemRead"; import type { OrderItemRead } from "./orderItemRead";
import type { PaymentRead } from "./paymentRead"; import type { PaymentRead } from "./paymentRead";
import type { StatusD4fEnum } from "./statusD4fEnum"; import type { Status0b2Enum } from "./status0b2Enum";
export interface OrderRead { export interface OrderRead {
readonly id: number; readonly id: number;
readonly status: StatusD4fEnum; readonly status: Status0b2Enum;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly total_price: string; readonly total_price: string;
/** Order currency - captured from site configuration at order creation and never changes */ /** Order currency - captured from site configuration at order creation and never changes */

View File

@@ -60,7 +60,7 @@ export interface PatchedDeutschePostOrder {
/** /**
* Weight in grams * Weight in grams
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
shipment_gross_weight?: number; shipment_gross_weight?: number;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */

View File

@@ -29,7 +29,7 @@ export interface PatchedDiscountCode {
active?: boolean; active?: boolean;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
* @nullable * @nullable
*/ */
usage_limit?: number | null; usage_limit?: number | null;

View File

@@ -6,11 +6,11 @@
import type { CarrierRead } from "./carrierRead"; import type { CarrierRead } from "./carrierRead";
import type { OrderItemRead } from "./orderItemRead"; import type { OrderItemRead } from "./orderItemRead";
import type { PaymentRead } from "./paymentRead"; import type { PaymentRead } from "./paymentRead";
import type { StatusD4fEnum } from "./statusD4fEnum"; import type { Status0b2Enum } from "./status0b2Enum";
export interface PatchedOrderRead { export interface PatchedOrderRead {
readonly id?: number; readonly id?: number;
readonly status?: StatusD4fEnum; readonly status?: Status0b2Enum;
/** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */ /** @pattern ^-?\d{0,8}(?:\.\d{0,2})?$ */
readonly total_price?: string; readonly total_price?: string;
/** Order currency - captured from site configuration at order creation and never changes */ /** Order currency - captured from site configuration at order creation and never changes */

View File

@@ -26,7 +26,7 @@ export interface PatchedProduct {
url?: string; url?: string;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
stock?: number; stock?: number;
is_active?: boolean; is_active?: boolean;

View File

@@ -16,7 +16,7 @@ export interface PatchedVATRate {
name?: string; name?: string;
/** /**
* VAT rate as percentage (e.g. 19.00 for 19%) * 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; rate?: string;
/** VAT rate as decimal (e.g., 0.19 for 19%) */ /** VAT rate as decimal (e.g., 0.19 for 19%) */

View File

@@ -10,8 +10,8 @@ export interface PostVote {
post: number; post: number;
readonly user: number; readonly user: number;
/** /**
* @minimum -9223372036854776000 * @minimum -32768
* @maximum 9223372036854776000 * @maximum 32767
*/ */
vote: VoteEnum; vote: VoteEnum;
readonly created_at: Date; readonly created_at: Date;

View File

@@ -26,7 +26,7 @@ export interface Product {
url: string; url: string;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
stock?: number; stock?: number;
is_active?: boolean; is_active?: boolean;

View File

@@ -19,7 +19,7 @@ export interface ProductMiniForWishlist {
is_active?: boolean; is_active?: boolean;
/** /**
* @minimum 0 * @minimum 0
* @maximum 9223372036854776000 * @maximum 2147483647
*/ */
stock?: number; stock?: number;
} }

View File

@@ -13,9 +13,9 @@
* `RETURNING` - Posláno zpátky * `RETURNING` - Posláno zpátky
* `RETURNED` - Vráceno * `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", WAITING_FOR_ORDERING_SHIPMENT: "WAITING_FOR_ORDERING_SHIPMENT",
PENDING: "PENDING", PENDING: "PENDING",
SENDED: "SENDED", SENDED: "SENDED",

View File

@@ -10,9 +10,9 @@
* `delivered` - Doručeno * `delivered` - Doručeno
* `ready_to_pickup` - Připraveno k vyzvednutí * `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", ordered: "ordered",
shipped: "shipped", shipped: "shipped",
delivered: "delivered", delivered: "delivered",

View File

@@ -11,9 +11,9 @@
* `refunding` - Vrácení v procesu * `refunding` - Vrácení v procesu
* `refunded` - Vráceno * `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", created: "created",
cancelled: "cancelled", cancelled: "cancelled",
completed: "completed", completed: "completed",

View File

@@ -4,6 +4,6 @@
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
export interface DownloadErrorResponse { export interface TokenError {
error: string; error: string;
} }

View File

@@ -16,7 +16,7 @@ export interface VATRate {
name: string; name: string;
/** /**
* VAT rate as percentage (e.g. 19.00 for 19%) * 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; rate: string;
/** VAT rate as decimal (e.g., 0.19 for 19%) */ /** VAT rate as decimal (e.g., 0.19 for 19%) */

View File

@@ -3,15 +3,15 @@
* Do not edit manually. * Do not edit manually.
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { StateCdfEnum } from "./stateCdfEnum"; import type { State9b5Enum } from "./state9b5Enum";
export interface ZasilkovnaPacket { export interface ZasilkovnaPacket {
readonly id: number; readonly id: number;
readonly created_at: Date; readonly created_at: Date;
/** /**
* Číslo zásilky v Packetě (vraceno od API od Packety) * Číslo zásilky v Packetě (vraceno od API od Packety)
* @minimum -9223372036854776000 * @minimum -2147483648
* @maximum 9223372036854776000 * @maximum 2147483647
* @nullable * @nullable
*/ */
packet_id?: number | null; packet_id?: number | null;
@@ -20,7 +20,7 @@ export interface ZasilkovnaPacket {
* @nullable * @nullable
*/ */
readonly barcode: string | null; readonly barcode: string | null;
readonly state: StateCdfEnum; readonly state: State9b5Enum;
/** Hmotnost zásilky v gramech */ /** Hmotnost zásilky v gramech */
readonly weight: number; readonly weight: number;
/** Seznam 2 routing stringů pro vrácení zásilky */ /** Seznam 2 routing stringů pro vrácení zásilky */

View File

@@ -3,15 +3,15 @@
* Do not edit manually. * Do not edit manually.
* OpenAPI spec version: 0.0.0 * OpenAPI spec version: 0.0.0
*/ */
import type { StateCdfEnum } from "./stateCdfEnum"; import type { State9b5Enum } from "./state9b5Enum";
export interface ZasilkovnaPacketRead { export interface ZasilkovnaPacketRead {
readonly id: number; readonly id: number;
readonly created_at: Date; readonly created_at: Date;
/** /**
* Číslo zásilky v Packetě (vraceno od API od Packety) * Číslo zásilky v Packetě (vraceno od API od Packety)
* @minimum -9223372036854776000 * @minimum -2147483648
* @maximum 9223372036854776000 * @maximum 2147483647
* @nullable * @nullable
*/ */
packet_id?: number | null; packet_id?: number | null;
@@ -20,7 +20,7 @@ export interface ZasilkovnaPacketRead {
* @nullable * @nullable
*/ */
readonly barcode: string | null; readonly barcode: string | null;
readonly state: StateCdfEnum; readonly state: State9b5Enum;
/** Hmotnost zásilky v gramech */ /** Hmotnost zásilky v gramech */
readonly weight: number; readonly weight: number;
/** Seznam 2 routing stringů pro vrácení zásilky */ /** Seznam 2 routing stringů pro vrácení zásilky */

View File

@@ -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 (
<div className="mb-8 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl flex flex-col sm:flex-row items-center gap-4">
<div className="text-4xl select-none">🎁</div>
<div className="flex-1 text-center sm:text-left">
<p className="font-semibold text-gray-800">
Create a free account download files up to{' '}
<span className="text-blue-600 font-bold">2 GB per file!</span>
</p>
<p className="text-sm text-gray-500 mt-1">
Registered users get higher file size limits, even better than most tools out there.
</p>
</div>
<Link
to="/register"
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 whitespace-nowrap transition-colors"
>
Sign up free
</Link>
</div>
);
}

View File

@@ -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 | VideoInfoResponse>(null);
const [isLoading, setIsLoading] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadStatus, setDownloadStatus] = useState<string>('');
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [error, setError] = useState<null | { error: string }>(null);
const wsRef = useRef<WebSocket | null>(null);
// Basic download options
const [selectedVideoQuality, setSelectedVideoQuality] = useState<string>('');
const [selectedAudioQuality, setSelectedAudioQuality] = useState<string>('');
const [selectedExtension, setSelectedExtension] = useState<string>('mp4');
// Playlist selection
const [selectedVideos, setSelectedVideos] = useState<number[]>([]);
// Advanced options
const [subtitles, setSubtitles] = useState<string>('');
const [embedSubtitles, setEmbedSubtitles] = useState<boolean>(false);
const [embedThumbnail, setEmbedThumbnail] = useState<boolean>(false);
const [cookies, setCookies] = useState<string>('');
const [showAdvanced, setShowAdvanced] = useState<boolean>(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<string>();
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<string>();
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<void>((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 (
<div className="p-8 w-full max-w-4xl mx-auto">
<Ad />
<h1 className="text-2xl font-bold mb-6">Video Downloader</h1>
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<FaLink className="text-gray-500" />
<span className="text-sm font-medium">Video URL</span>
</div>
<input
type="text"
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
placeholder="Paste video URL here (YouTube, TikTok, Vimeo, etc.)"
className="w-full p-3 border rounded"
/>
</div>
<button
onClick={retrieveVideoInfo}
disabled={isLoading || !videoUrl}
className="px-6 py-3 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed mx-2 mb-4"
>
{isLoading ? 'Loading...' : 'Retrieve Options'}
</button>
{error && (
<div className="mt-6 mx-4 p-4 bg-red-100 text-red-700 rounded">
Error: {error.error}
</div>
)}
{videoInfo && (
<div className="mt-8 mx-4 p-6 border rounded">
{videoInfo.is_playlist ? (
<div>
<h2 className="text-xl font-semibold mb-4">
📋 {videoInfo.playlist_title || 'Playlist'}
</h2>
<p className="text-gray-600 mb-6">
{videoInfo.playlist_count} videos found
</p>
{/* Playlist Video Selection */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-3">
<h3 className="text-lg font-medium">Select Videos to Download:</h3>
<div className="flex gap-2">
<button
onClick={selectAllVideos}
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
Select All
</button>
<button
onClick={deselectAllVideos}
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Clear All
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto border rounded">
{videoInfo.videos.map((video, index) => {
const videoNumber = index + 1;
return (
<div key={video.id} className="p-3 border-b last:border-b-0 hover:bg-gray-50">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={selectedVideos.includes(videoNumber)}
onChange={() => toggleVideoSelection(videoNumber)}
className="mt-1 w-4 h-4"
/>
<div className="flex gap-3 flex-1">
{video.thumbnail && (
<img
src={video.thumbnail}
alt={video.title}
className="w-20 h-15 object-cover rounded flex-shrink-0"
/>
)}
<div className="flex-1">
<div className="font-medium">
{videoNumber}. {video.title}
</div>
{video.duration && (
<div className="text-sm text-gray-600">
Duration: {Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
Quality: {video.video_resolutions.join(', ') || 'N/A'} |
Audio: {video.audio_resolutions.join(', ') || 'N/A'}
</div>
</div>
</div>
</label>
</div>
);
})}
</div>
<div className="mt-3 text-sm text-gray-600">
{selectedVideos.length} of {videoInfo.videos.length} videos selected
</div>
</div>
</div>
) : (
<div>
<h2 className="text-xl font-semibold mb-4">
🎥 {videoInfo.videos[0]?.title || 'Video'}
</h2>
{videoInfo.videos[0]?.thumbnail && (
<img
src={videoInfo.videos[0].thumbnail}
alt={videoInfo.videos[0].title}
className="mt-4 w-1/3 max-w-sm rounded shadow mx-auto block"
/>
)}
{videoInfo.videos[0]?.duration && (
<p className="mt-4 text-gray-600 text-center">
Duration: {Math.floor(videoInfo.videos[0].duration / 60)}:{String(videoInfo.videos[0].duration % 60).padStart(2, '0')}
</p>
)}
</div>
)}
{/* Quality and Format Selection */}
<div className="mt-8 mx-2 grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Video Quality Dropdown */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaVideo className="text-gray-500" />
Video Quality (optional)
</label>
<select
value={selectedVideoQuality}
onChange={(e) => setSelectedVideoQuality(e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Best available</option>
{getAllVideoQualities().map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
</div>
{/* Audio Quality Dropdown */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaVolumeUp className="text-gray-500" />
Audio Quality (optional)
</label>
<select
value={selectedAudioQuality}
onChange={(e) => setSelectedAudioQuality(e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Best available</option>
{getAllAudioQualities().map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
</div>
{/* File Extension Dropdown */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaFile className="text-gray-500" />
File Format
</label>
<select
value={selectedExtension}
onChange={(e) => setSelectedExtension(e.target.value)}
className="w-full p-2 border rounded"
>
{FILE_EXTENSIONS.map((ext) => (
<option key={ext.value} value={ext.value}>
{ext.label}
</option>
))}
</select>
</div>
</div>
<button
onClick={handleDownload}
disabled={isDownloading || (videoInfo.is_playlist && selectedVideos.length === 0)}
className="mt-8 mx-2 px-6 py-3 bg-green-500 text-white rounded hover:bg-green-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{isDownloading ? downloadStatus || 'Downloading…' : (
videoInfo.is_playlist
? `Download Selected (${selectedVideos.length}) as ZIP`
: 'Download Video'
)}
</button>
{isDownloading && downloadStatus && (
<div className="mt-3 mx-2">
<div className="flex items-center gap-2 text-sm text-blue-600 mb-2">
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
{downloadStatus}
</div>
{downloadProgress !== null && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
/>
</div>
)}
</div>
)}
{videoInfo.is_playlist && selectedVideos.length === 0 && (
<p className="mt-4 mx-2 text-sm text-red-600">
Please select at least one video to download
</p>
)}
{/* Advanced Options Toggle */}
<div className="mt-10 mx-2 border-t pt-8">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-blue-500 hover:text-blue-700 font-medium flex items-center gap-2 mx-2"
>
{showAdvanced ? '▼' : '▶'} Advanced Options
</button>
{showAdvanced && (
<div className="mt-6 space-y-6 p-6 rounded mx-2">
{/* Subtitles */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaFont className="text-gray-500" />
Subtitles (optional)
</label>
<input
type="text"
value={subtitles}
onChange={(e) => setSubtitles(e.target.value)}
placeholder="e.g., 'en', 'en,cs', or 'all'"
className="w-full p-2 border rounded"
/>
<p className="text-xs text-gray-500 mt-1">
Language codes (e.g., 'en', 'cs') or 'all' for all available
</p>
</div>
{/* Checkboxes Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={embedSubtitles}
onChange={(e) => setEmbedSubtitles(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm">Embed Subtitles (mkv/mp4 only)</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={embedThumbnail}
onChange={(e) => setEmbedThumbnail(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm">Embed Thumbnail</span>
</label>
</div>
{/* Cookies for Age-Restricted Content */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaCookie className="text-gray-500" />
Cookies (for age-restricted content)
</label>
<textarea
value={cookies}
onChange={(e) => setCookies(e.target.value)}
placeholder="Paste cookies in Netscape format here..."
rows={4}
className="w-full p-2 border rounded font-mono text-xs"
/>
<p className="text-xs text-gray-500 mt-1">
Export cookies from your browser using extensions like "Get cookies.txt" or "cookies.txt".
Login to YouTube/Google first, then export cookies in Netscape format.
</p>
</div>
</div>
)}
</div>
</div>
)}
<Statistics />
</div>
);
}

View File

@@ -0,0 +1,73 @@
interface SliceData {
label: string;
count: number;
}
interface PiechartProps {
data: SliceData[];
title?: string;
}
const COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
];
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function slicePath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
const start = polarToCartesian(cx, cy, r, startAngle);
const end = polarToCartesian(cx, cy, r, endAngle);
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`;
}
export default function Piechart({ data, title }: PiechartProps) {
const total = data.reduce((sum, d) => sum + d.count, 0);
if (total === 0) return null;
let currentAngle = 0;
const slices = data.map((d, i) => {
const angle = (d.count / total) * 360;
const start = currentAngle;
const end = currentAngle + angle;
currentAngle = end;
return { ...d, startAngle: start, endAngle: end, color: COLORS[i % COLORS.length] };
});
return (
<div className="flex flex-col items-center gap-4">
{title && <h3 className="text-sm font-semibold text-gray-600 uppercase tracking-wide">{title}</h3>}
<div className="flex flex-col sm:flex-row items-center gap-6">
<svg width="180" height="180" viewBox="0 0 200 200">
{slices.map((s) => (
<path
key={s.label}
d={slicePath(100, 100, 90, s.startAngle, s.endAngle)}
fill={s.color}
stroke="white"
strokeWidth="2"
>
<title>{s.label}: {s.count}</title>
</path>
))}
</svg>
<ul className="space-y-2">
{slices.map((s) => (
<li key={s.label} className="flex items-center gap-2 text-sm">
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: s.color }}
/>
<span className="text-gray-700 capitalize">{s.label}</span>
<span className="text-gray-400 ml-1">({s.count})</span>
</li>
))}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useApiDownloaderStatsRetrieve } from '@/api/generated/public/downloader';
import Piechart from './Piechart';
function formatBytes(bytes: number | null): string {
if (bytes === null) return 'N/A';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatDuration(seconds: number | null): string {
if (seconds === null) return 'N/A';
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
export default function Statistics() {
const { data, isLoading, isError } = useApiDownloaderStatsRetrieve();
if (isLoading) {
return (
<div className="mt-12 border-t pt-10 text-center text-gray-400 text-sm animate-pulse">
Loading statistics...
</div>
);
}
if (isError || !data) return null;
const pieData = data.downloads_by_platform.map((p) => ({
label: p.platform,
count: p.count,
}));
const statCards = [
{ label: 'Total Downloads', value: data.total_downloads.toLocaleString() },
{ label: 'Success Rate', value: `${data.success_rate.toFixed(1)}%` },
{ label: 'Avg File Size', value: formatBytes(data.avg_file_size) },
{ label: 'Avg Duration', value: formatDuration(data.avg_length_of_media) },
{ label: 'Most Common Format', value: data.most_common_format?.toUpperCase() ?? 'N/A' },
{ label: 'Video Downloads', value: data.video_count.toLocaleString() },
{ label: 'Audio Only', value: data.audio_only_count.toLocaleString() },
{ label: 'Total Media Time', value: formatDuration(data.total_length_of_media) },
];
return (
<div className="mt-12 border-t pt-10">
<h2 className="text-xl font-bold mb-6 text-gray-800">Downloader Statistics</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-10">
{statCards.map((s) => (
<div key={s.label} className="p-4 bg-gray-50 rounded-lg border text-center">
<div className="text-2xl font-bold text-blue-600">{s.value}</div>
<div className="text-xs text-gray-500 mt-1">{s.label}</div>
</div>
))}
</div>
{pieData.length > 0 && (
<div className="flex justify-center">
<Piechart data={pieData} title="Downloads by Platform" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
export default function ChatLayout() {
return (
<div className="min-h-screen flex flex-col">
<header className="bg-gray-800 text-white p-4">
<h1 className="text-xl font-bold">Chat</h1>
</header>
<main className="flex-1 p-4">
nothing now
</main>
</div>
);
}

View File

@@ -1,452 +1,3 @@
import { useState } from 'react'; import Downloader from '@/components/downloader/Downloader';
import { apiDownloaderDownloadRetrieve, apiDownloaderDownloadCreate } from '@/api/generated/public/downloader';
import { type VideoInfoResponse, /*type VideoInfo*/ } from '@/api/generated/public/models';
import { FaLink, FaVideo, FaVolumeUp, FaFile, FaFont, FaCookie } from 'react-icons/fa';
// Common file extensions supported by ffmpeg export default Downloader;
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 | VideoInfoResponse>(null);
const [isLoading, setIsLoading] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [error, setError] = useState<null | { error: string }>(null);
// Basic download options
const [selectedVideoQuality, setSelectedVideoQuality] = useState<string>('');
const [selectedAudioQuality, setSelectedAudioQuality] = useState<string>('');
const [selectedExtension, setSelectedExtension] = useState<string>('mp4');
// Playlist selection
const [selectedVideos, setSelectedVideos] = useState<number[]>([]);
// Advanced options (removed start_time, end_time, playlist_items)
const [subtitles, setSubtitles] = useState<string>('');
const [embedSubtitles, setEmbedSubtitles] = useState<boolean>(false);
const [embedThumbnail, setEmbedThumbnail] = useState<boolean>(false);
const [cookies, setCookies] = useState<string>('');
const [showAdvanced, setShowAdvanced] = useState<boolean>(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([]);
};
// Get all available qualities from all videos for consistent UI
const getAllVideoQualities = () => {
if (!videoInfo?.videos) return [];
const allQualities = new Set<string>();
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; // Sort descending
});
};
const getAllAudioQualities = () => {
if (!videoInfo?.videos) return [];
const allQualities = new Set<string>();
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; // Sort descending
});
};
async function retrieveVideoInfo() {
setIsLoading(true);
setError(null);
setVideoInfo(null);
setSelectedVideos([]); // Reset selected videos
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) {
// Handle axios errors properly
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);
setError(null);
try {
// Parse quality values (remove 'p' and 'kbps' suffixes)
const videoQuality = selectedVideoQuality
? parseInt(selectedVideoQuality.replace('p', ''))
: undefined;
const audioQuality = selectedAudioQuality
? parseInt(selectedAudioQuality.replace('kbps', ''))
: undefined;
// Make the download request with all parameters
const response = await apiDownloaderDownloadCreate({
url: videoUrl,
ext: selectedExtension,
video_quality: videoQuality || null,
audio_quality: audioQuality || null,
// Playlist selection
selected_videos: videoInfo?.is_playlist && selectedVideos.length > 0 ? selectedVideos : null,
// Advanced options
subtitles: subtitles || null,
embed_subtitles: embedSubtitles,
embed_thumbnail: embedThumbnail,
cookies: cookies || null,
});
// The response should be a Blob, trigger download
const blob = new Blob([response as any], { type: 'application/octet-stream' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Better filename based on content type
if (videoInfo?.is_playlist) {
const playlistTitle = videoInfo.playlist_title || 'playlist';
a.download = `${playlistTitle.replace(/[<>:"/\\|?*]/g, '_').trim()}.zip`;
} else {
const title = videoInfo?.videos?.[0]?.title || 'video';
a.download = `${title.replace(/[<>:"/\\|?*]/g, '_').trim()}.${selectedExtension}`;
}
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message || 'Failed to download video';
setError({ error: errorMessage });
console.error('Download error:', err);
} finally {
setIsDownloading(false);
}
}
return (
<div className="p-8 w-full max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Video Downloader</h1>
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<FaLink className="text-gray-500" />
<span className="text-sm font-medium">Video URL</span>
</div>
<input
type="text"
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
placeholder="Paste video URL here (YouTube, TikTok, Vimeo, etc.)"
className="w-full p-3 border rounded"
/>
</div>
<button
onClick={retrieveVideoInfo}
disabled={isLoading || !videoUrl}
className="px-6 py-3 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed mx-2 mb-4"
>
{isLoading ? 'Loading...' : 'Retrieve Options'}
</button>
{error && (
<div className="mt-6 mx-4 p-4 bg-red-100 text-red-700 rounded">
Error: {error.error}
</div>
)}
{videoInfo && (
<div className="mt-8 mx-4 p-6 border rounded">
{videoInfo.is_playlist ? (
<div>
<h2 className="text-xl font-semibold mb-4">
📋 {videoInfo.playlist_title || 'Playlist'}
</h2>
<p className="text-gray-600 mb-6">
{videoInfo.playlist_count} videos found
</p>
{/* Playlist Video Selection */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-3">
<h3 className="text-lg font-medium">Select Videos to Download:</h3>
<div className="flex gap-2">
<button
onClick={selectAllVideos}
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
Select All
</button>
<button
onClick={deselectAllVideos}
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Clear All
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto border rounded">
{videoInfo.videos.map((video, index) => {
const videoNumber = index + 1;
return (
<div key={video.id} className="p-3 border-b last:border-b-0 hover:bg-gray-50">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={selectedVideos.includes(videoNumber)}
onChange={() => toggleVideoSelection(videoNumber)}
className="mt-1 w-4 h-4"
/>
<div className="flex gap-3 flex-1">
{video.thumbnail && (
<img
src={video.thumbnail}
alt={video.title}
className="w-20 h-15 object-cover rounded flex-shrink-0"
/>
)}
<div className="flex-1">
<div className="font-medium">
{videoNumber}. {video.title}
</div>
{video.duration && (
<div className="text-sm text-gray-600">
Duration: {Math.floor(video.duration / 60)}:{String(video.duration % 60).padStart(2, '0')}
</div>
)}
<div className="text-xs text-gray-500 mt-1">
Quality: {video.video_resolutions.join(', ') || 'N/A'} |
Audio: {video.audio_resolutions.join(', ') || 'N/A'}
</div>
</div>
</div>
</label>
</div>
);
})}
</div>
<div className="mt-3 text-sm text-gray-600">
{selectedVideos.length} of {videoInfo.videos.length} videos selected
</div>
</div>
</div>
) : (
<div>
<h2 className="text-xl font-semibold mb-4">
🎥 {videoInfo.videos[0]?.title || 'Video'}
</h2>
{videoInfo.videos[0]?.thumbnail && (
<img
src={videoInfo.videos[0].thumbnail}
alt={videoInfo.videos[0].title}
className="mt-4 w-1/3 max-w-sm rounded shadow mx-auto block"
/>
)}
{videoInfo.videos[0]?.duration && (
<p className="mt-4 text-gray-600 text-center">
Duration: {Math.floor(videoInfo.videos[0].duration / 60)}:{String(videoInfo.videos[0].duration % 60).padStart(2, '0')}
</p>
)}
</div>
)}
{/* Quality and Format Selection */}
<div className="mt-8 mx-2 grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Video Quality Dropdown */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaVideo className="text-gray-500" />
Video Quality (optional)
</label>
<select
value={selectedVideoQuality}
onChange={(e) => setSelectedVideoQuality(e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Best available</option>
{getAllVideoQualities().map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
</div>
{/* Audio Quality Dropdown */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaVolumeUp className="text-gray-500" />
Audio Quality (optional)
</label>
<select
value={selectedAudioQuality}
onChange={(e) => setSelectedAudioQuality(e.target.value)}
className="w-full p-2 border rounded"
>
<option value="">Best available</option>
{getAllAudioQualities().map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
</div>
{/* File Extension Dropdown */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaFile className="text-gray-500" />
File Format
</label>
<select
value={selectedExtension}
onChange={(e) => setSelectedExtension(e.target.value)}
className="w-full p-2 border rounded"
>
{FILE_EXTENSIONS.map((ext) => (
<option key={ext.value} value={ext.value}>
{ext.label}
</option>
))}
</select>
</div>
</div>
<button
onClick={handleDownload}
disabled={isDownloading || (videoInfo.is_playlist && selectedVideos.length === 0)}
className="mt-8 mx-2 px-6 py-3 bg-green-500 text-white rounded hover:bg-green-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{isDownloading ? 'Downloading...' : (
videoInfo.is_playlist
? `Download Selected (${selectedVideos.length}) as ZIP`
: 'Download Video'
)}
</button>
{videoInfo.is_playlist && selectedVideos.length === 0 && (
<p className="mt-4 mx-2 text-sm text-red-600">
Please select at least one video to download
</p>
)}
{/* Advanced Options Toggle */}
<div className="mt-10 mx-2 border-t pt-8">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-blue-500 hover:text-blue-700 font-medium flex items-center gap-2 mx-2"
>
{showAdvanced ? '▼' : '▶'} Advanced Options
</button>
{showAdvanced && (
<div className="mt-6 space-y-6 p-6 rounded mx-2">
{/* Subtitles */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaFont className="text-gray-500" />
Subtitles (optional)
</label>
<input
type="text"
value={subtitles}
onChange={(e) => setSubtitles(e.target.value)}
placeholder="e.g., 'en', 'en,cs', or 'all'"
className="w-full p-2 border rounded"
/>
<p className="text-xs text-gray-500 mt-1">
Language codes (e.g., 'en', 'cs') or 'all' for all available
</p>
</div>
{/* Checkboxes Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={embedSubtitles}
onChange={(e) => setEmbedSubtitles(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm">Embed Subtitles (mkv/mp4 only)</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={embedThumbnail}
onChange={(e) => setEmbedThumbnail(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm">Embed Thumbnail</span>
</label>
</div>
{/* Cookies for Age-Restricted Content */}
<div>
<label className="block text-sm font-medium mb-2 flex items-center gap-2">
<FaCookie className="text-gray-500" />
Cookies (for age-restricted content)
</label>
<textarea
value={cookies}
onChange={(e) => setCookies(e.target.value)}
placeholder="Paste cookies in Netscape format here..."
rows={4}
className="w-full p-2 border rounded font-mono text-xs"
/>
<p className="text-xs text-gray-500 mt-1">
Export cookies from your browser using extensions like "Get cookies.txt" or "cookies.txt".
Login to YouTube/Google first, then export cookies in Netscape format.
</p>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}