Compare commits
2 Commits
659999f4fd
...
b3bc242e27
| Author | SHA1 | Date | |
|---|---|---|---|
| b3bc242e27 | |||
| cf08dbaf15 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
20
backend/configuration/management/commands/seed_app_config.py
Normal file
20
backend/configuration/management/commands/seed_app_config.py
Normal 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.")
|
||||||
@@ -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
|
||||||
|
|||||||
26
backend/configuration/migrations/0002_alter_vatrate_rate.py
Normal file
26
backend/configuration/migrations/0002_alter_vatrate_rate.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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%)"
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
245
backend/thirdparty/downloader/consumers.py
vendored
Normal file
245
backend/thirdparty/downloader/consumers.py
vendored
Normal 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))
|
||||||
@@ -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'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
30
backend/thirdparty/downloader/migrations/0002_downloadjob.py
vendored
Normal file
30
backend/thirdparty/downloader/migrations/0002_downloadjob.py
vendored
Normal 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']},
|
||||||
|
),
|
||||||
|
]
|
||||||
16
backend/thirdparty/downloader/migrations/0003_auto_20260420_2214.py
vendored
Normal file
16
backend/thirdparty/downloader/migrations/0003_auto_20260420_2214.py
vendored
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
6
backend/thirdparty/downloader/routing.py
vendored
Normal file
6
backend/thirdparty/downloader/routing.py
vendored
Normal 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()),
|
||||||
|
]
|
||||||
2
backend/thirdparty/downloader/urls.py
vendored
2
backend/thirdparty/downloader/urls.py
vendored
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
400
backend/thirdparty/downloader/views.py
vendored
400
backend/thirdparty/downloader/views.py
vendored
@@ -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,382 +231,59 @@ 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
|
|
||||||
- Use `selected_videos` to specify which videos to download (e.g., [1,3,5] or [1,2,3,4,5])
|
|
||||||
- If `selected_videos` is not provided, all videos in the playlist will be downloaded
|
|
||||||
|
|
||||||
**Quality Parameters (optional):**
|
|
||||||
- If not specified, yt-dlp will automatically select the best available quality.
|
|
||||||
- `video_quality`: Maximum video height in pixels (e.g., 1080, 720, 480).
|
|
||||||
- `audio_quality`: Maximum audio bitrate in kbps (e.g., 320, 192, 128).
|
|
||||||
|
|
||||||
**Format/Extension:**
|
|
||||||
- Any format supported by ffmpeg (mp4, mkv, webm, avi, mov, flv, m4a, mp3, etc.).
|
|
||||||
- Defaults to 'mp4' if not specified.
|
|
||||||
- The conversion is handled automatically by ffmpeg in the background.
|
|
||||||
|
|
||||||
**Advanced Options:**
|
|
||||||
- `subtitles`: Download subtitles (language codes like 'en,cs' or 'all')
|
|
||||||
- `embed_subtitles`: Embed subtitles into video file
|
|
||||||
- `embed_thumbnail`: Embed thumbnail as cover art
|
|
||||||
- `extract_audio`: Extract audio only (ignores video quality)
|
|
||||||
- `cookies`: Browser cookies for age-restricted content (Netscape format)
|
|
||||||
""",
|
|
||||||
request=inline_serializer(
|
|
||||||
name="DownloadRequest",
|
|
||||||
fields={
|
fields={
|
||||||
"url": serializers.URLField(help_text="Video/Playlist URL to download from supported platforms"),
|
"token": serializers.CharField(help_text="Signed token containing file info"),
|
||||||
"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:
|
|
||||||
with open(cookie_file, "w") as f:
|
|
||||||
f.write(cookies)
|
|
||||||
ydl_options["cookiefile"] = cookie_file
|
|
||||||
except Exception as e:
|
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
||||||
return Response({"error": f"Invalid cookies format: {str(e)}"}, status=400)
|
|
||||||
|
|
||||||
# Subtitles
|
|
||||||
if subtitles:
|
|
||||||
if subtitles.lower() == "all":
|
|
||||||
ydl_options["writesubtitles"] = True
|
|
||||||
ydl_options["writeautomaticsub"] = True
|
|
||||||
ydl_options["subtitleslangs"] = ["all"]
|
|
||||||
else:
|
|
||||||
ydl_options["writesubtitles"] = True
|
|
||||||
ydl_options["subtitleslangs"] = [lang.strip() for lang in subtitles.split(",")]
|
|
||||||
|
|
||||||
# Embed subtitles (only for mkv/mp4)
|
|
||||||
if embed_subtitles and subtitles:
|
|
||||||
if ext in ["mkv", "mp4"]:
|
|
||||||
ydl_options["postprocessors"].append({"key": "FFmpegEmbedSubtitle"})
|
|
||||||
else:
|
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
||||||
return Response({"error": "Subtitle embedding requires mkv or mp4 format"}, status=400)
|
|
||||||
|
|
||||||
# Embed thumbnail
|
|
||||||
if embed_thumbnail:
|
|
||||||
ydl_options["writethumbnail"] = True
|
|
||||||
ydl_options["postprocessors"].append({"key": "EmbedThumbnail"})
|
|
||||||
|
|
||||||
# Extract audio only
|
|
||||||
if extract_audio:
|
|
||||||
ydl_options["postprocessors"].append({
|
|
||||||
"key": "FFmpegExtractAudio",
|
|
||||||
"preferredcodec": ext if ext in ["mp3", "m4a", "opus", "vorbis", "wav"] else "mp3",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Playlist items (use selected_videos parameter)
|
|
||||||
if is_playlist and selected_videos:
|
|
||||||
# Convert array of numbers to yt-dlp format string
|
|
||||||
playlist_items_str = ",".join(str(num) for num in selected_videos)
|
|
||||||
ydl_options["playlist_items"] = playlist_items_str
|
|
||||||
|
|
||||||
# Add remux postprocessor if not extracting audio
|
|
||||||
if not extract_audio:
|
|
||||||
ydl_options["postprocessors"].append(
|
|
||||||
{"key": "FFmpegVideoRemuxer", "preferedformat": ext}
|
|
||||||
)
|
|
||||||
|
|
||||||
_platform = (info.get('extractor') or urlparse(url).netloc).lower().replace('www.', '').split(':')[0]
|
|
||||||
_user = request.user if request.user.is_authenticated else None
|
|
||||||
_title = info.get('title', '')
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
while True:
|
while True:
|
||||||
chunk = f.read(chunk_size)
|
chunk = await asyncio.to_thread(f.read, chunk_size)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
if os.path.exists(path):
|
|
||||||
os.remove(path)
|
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
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(
|
response = StreamingHttpResponse(
|
||||||
streaming_content=stream_and_cleanup(file_path, tmpdir),
|
streaming_content=stream_and_cleanup(file_path, tmpdir),
|
||||||
content_type=content_type,
|
content_type=data["content_type"] or "application/octet-stream",
|
||||||
)
|
)
|
||||||
if size:
|
if data["file_size"]:
|
||||||
response["Content-Length"] = str(size)
|
response["Content-Length"] = str(data["file_size"])
|
||||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
response["Content-Disposition"] = f'attachment; filename="{data["filename"]}"'
|
||||||
|
|
||||||
return response
|
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)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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})?$ */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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})?$ */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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%) */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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%) */
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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})?$ */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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})?$ */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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%) */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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%) */
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
546
frontend/src/components/downloader/Downloader.tsx
Normal file
546
frontend/src/components/downloader/Downloader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user