@@ -259,6 +259,7 @@ MINIO_PRIVATE_BUCKETS = [ 'air-profiles', 'air-models', 'air-media-presets', + 'air-voices', ] MINIO_STATIC_FILES_BUCKET = 'air-static' MINIO_PRIVATE_BUCKETS.append(MINIO_STATIC_FILES_BUCKET) @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-16 14:06+0300\n" +"POT-Creation-Date: 2026-04-02 11:06+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -190,7 +190,7 @@ msgstr "Дочерние Бизнес Аккаунты" #: authentication/models/business_group.py:8 ml_model/models.py:17 #: ml_model/models.py:37 ml_model/models.py:61 ml_model/models.py:268 -#: tools/chats/models.py:9 tools/media/models.py:41 +#: tools/chats/models.py:9 tools/media/models.py:54 tools/media/models.py:81 msgid "Title" msgstr "Название" @@ -208,6 +208,7 @@ msgstr "Бизнес Группы" #: authentication/models/user_vk.py:12 payments/admin.py:37 #: payments/admin.py:97 payments/models/invoice.py:15 #: payments/models/payment.py:26 payments/models/payment_plan.py:62 +#: tools/media/models.py:94 msgid "User" msgstr "Пользователь" @@ -498,6 +499,10 @@ msgstr "Вайтлист для отмены политик" msgid "Whitelists to cancel policies" msgstr "Вайтлисты для отмены политик" +#: authentication/routes/v1.py:29 +msgid "Invalid or expired refresh token" +msgstr "Неверный или истёкший refresh токен" + #: authentication/security.py:35 #, fuzzy #| msgid "Hidden" @@ -508,11 +513,17 @@ msgstr "Скрытый" msgid "Access token is expired" msgstr "Срок действия токена доступа истек" -#: authentication/security.py:61 +#: authentication/security.py:50 +#, fuzzy +#| msgid "Access token is expired" +msgid "Access token invalid" +msgstr "Срок действия токена доступа истек" + +#: authentication/security.py:63 msgid "User not found" msgstr "Пользователь не найден" -#: authentication/security.py:96 authentication/security.py:125 +#: authentication/security.py:98 authentication/security.py:126 msgid "Access token expired or does not exist" msgstr "Токен доступа просрочен или не существует" @@ -609,7 +620,7 @@ msgstr "" msgid "A %(model)s with fields %(fields)s already exists" msgstr "Уже существует %(model)s с полями %(fields)s" -#: messages/serializers.py:44 +#: messages/serializers.py:44 ml_model/exceptions.py:68 #, python-format msgid "The file size cannot exceed %(max_mb_size)d MB" msgstr "Файл не может быть размером больше %(max_mb_size)d мегабайт" @@ -623,28 +634,16 @@ msgstr "Версия %(version)s уже имеет входные данные msgid "Neuron Models" msgstr "Нейронные Модели" -#: ml_model/exceptions.py:18 +#: ml_model/exceptions.py:20 msgid "The model is currently disabled. Please try again later." msgstr "" "Модель в настоящее время неактивна. Пожалуйста, повторите попытку позже." -#: ml_model/exceptions.py:142 -msgid "Available only in paid plan" -msgstr "Доступно только в платном тарифе" - -#: ml_model/exceptions.py:119 -msgid "Service is currently unavailable due to high demand. Please try again later" -msgstr "Сервис временно недоступен из-за высокой нагрузки. Пожалуйста, попробуйте позже" - -#: ml_model/exceptions.py:135 -msgid "Image analysis error. Please try another image." -msgstr "Ошибка анализа изображения. Попробуйте другую картинку." - -#: ml_model/exceptions.py:23 +#: ml_model/exceptions.py:25 msgid "Your request was blocked by our moderation system" msgstr "Ваш запрос был заблокирован нашей системой модерации" -#: ml_model/exceptions.py:33 +#: ml_model/exceptions.py:35 #, python-format msgid "" "Image size %(cw)dx%(ch)d is not supported. Please rotate image to " @@ -653,18 +652,18 @@ msgstr "" "Размер изображения %(cw)dx%(ch)d не поддерживается. Пожалуйста, переверните " "до %(rw)dx%(rh)d" -#: ml_model/exceptions.py:36 +#: ml_model/exceptions.py:38 #, python-format msgid "Image size %(cw)sx%(ch)s is not supported. Required size: %(rw)sx%(rh)s" msgstr "" "Размер изображения %(cw)sx%(ch)s не поддерживается. Требуемый размер: " "%(rw)sx%(rh)s" -#: ml_model/exceptions.py:43 +#: ml_model/exceptions.py:45 msgid "The model is not responding" msgstr "Модель не отвечает" -#: ml_model/exceptions.py:52 +#: ml_model/exceptions.py:54 #, python-format msgid "" "The attached file format is not supported. Available formats: " @@ -677,46 +676,61 @@ msgstr "" msgid "The file may be corrupted. Please try another one." msgstr "Возможно, файл повреждён. Попробуйте загрузить другой файл." -#: ml_model/exceptions.py:58 +#: ml_model/exceptions.py:73 msgid "The length of the context has been exceeded." msgstr "Длина контекста превышена." -#: ml_model/exceptions.py:63 +#: ml_model/exceptions.py:78 msgid "Jinja template not found" msgstr "Jinja-шаблон не найден" -#: ml_model/exceptions.py:68 +#: ml_model/exceptions.py:83 msgid "There was an unknown error while rendering a template" msgstr "При рендеринге шаблона произошла неизвестная ошибка" -#: ml_model/exceptions.py:73 +#: ml_model/exceptions.py:88 msgid "The neuron model does not exist" msgstr "Нейронная модель не существует" -#: ml_model/exceptions.py:81 +#: ml_model/exceptions.py:96 #, python-format msgid "The %(file_type)s is not attached" msgstr "Файл (%(file_type)s) не прикреплен" -#: ml_model/exceptions.py:86 +#: ml_model/exceptions.py:101 msgid "No image content found in response. Try a different request" msgstr "В промпте отсутствует описание изображения. Попробуйте другой запрос" -#: ml_model/exceptions.py:91 +#: ml_model/exceptions.py:106 +msgid "Image analysis error. Please try another image." +msgstr "Ошибка анализа изображения. Попробуйте другую картинку." + +#: ml_model/exceptions.py:111 msgid "Use style type AUTO or GENERAL when a style preset is selected" msgstr "При выбранном стиле используйте тип стиля AUTO или GENERAL" -#: ml_model/exceptions.py:96 +#: ml_model/exceptions.py:116 msgid "Prediction interrupted. Please retry again" msgstr "Генерация прервана. Пожалуйста, повторите попытку еще раз" -#: ml_model/exceptions.py:111 +#: ml_model/exceptions.py:131 #, python-format msgid "Prompt is too long. Maximum length is %(max_length)s characters." msgstr "Промпт слишком длинный. Максимальная длина — %(max_length)s символов." +#: ml_model/exceptions.py:138 +msgid "" +"Service is currently unavailable due to high demand. Please try again later" +msgstr "" +"Сервис временно недоступен из-за высокой нагрузки. Пожалуйста, попробуйте " +"позже" + +#: ml_model/exceptions.py:143 +msgid "Available only in paid plan" +msgstr "Доступно только в платном тарифе" + #: ml_model/models.py:18 ml_model/models.py:38 ml_model/models.py:70 -#: ml_model/models.py:182 tools/media/models.py:43 +#: ml_model/models.py:182 tools/media/models.py:56 msgid "Slug" msgstr "Ярлык" @@ -998,17 +1012,21 @@ msgstr "Инструкции Моделей" msgid "no model by this id" msgstr "Не найдено моделей по этому ID" -#: ml_model/services/chatgpt.py:160 +#: ml_model/services/chatgpt.py:148 msgid "No matching version found" msgstr "Соответствующая версия не найдена" +#: ml_model/services/chatgpt_5_4.py:269 +msgid "Image is ready" +msgstr "Изображение готово" + #: ml_model/services/minimaxmusic.py:58 #: ml_model/services/minimaxmusic_lite.py:62 msgid "Lyrics is too long" msgstr "Текст песни слишком длинный" -#: ml_model/services/minio_service.py:36 ml_model/services/minio_service.py:54 -#: ml_model/services/minio_service.py:62 ml_model/services/minio_service.py:71 +#: ml_model/services/minio_service.py:37 ml_model/services/minio_service.py:55 +#: ml_model/services/minio_service.py:63 ml_model/services/minio_service.py:72 msgid "Unknown bucket destination" msgstr "Неизвестный бакет для загрузки" @@ -1026,9 +1044,6 @@ msgstr "" "Вы можете осуществлять поиск по e-mail пользователя, точному названию " "компании" -msgid "Image is ready" -msgstr "Изображение готово" - #: payments/admin.py:40 payments/admin.py:100 msgid "Missing" msgstr "Отсутствующий" @@ -1285,7 +1300,7 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" -#: tools/chats/apis.py:188 tools/media/apis.py:181 +#: tools/chats/apis.py:197 tools/media/apis.py:202 #: tools/public_api/views/base.py:100 msgid "" "An unexpected generation error has occurred. Please try again later or use a " @@ -1294,11 +1309,7 @@ msgstr "" "Произошла непредвиденная ошибка при генерации. Пожалуйста попробуйте позже " "или используйте другую модель" -#: tools/media/apis.py:168 -msgid "Temporary issues with the service, we are already working on a solution." -msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." - -#: tools/chats/apis.py:244 +#: tools/chats/apis.py:253 msgid "The message has already been deleted" msgstr "Сообщение уже было удалено" @@ -1311,18 +1322,57 @@ msgstr "Чат %(id)s" msgid "Chat" msgstr "Чат" -#: tools/media/models.py:48 +#: tools/media/apis.py:173 +msgid "" +"Temporary issues with the service, we are already working on a solution." +msgstr "Временные неполадки с сервисом, мы уже работаем над их решением." + +#: tools/media/apis.py:252 +msgid "Voice not found." +msgstr "Голос не найден." + +#: tools/media/models.py:45 +msgid "Voice clone store" +msgstr "Хранилище клонирования голоса" + +#: tools/media/models.py:46 +msgid "Voice clone stores" +msgstr "Хранилища клонирования голоса" + +#: tools/media/models.py:61 tools/media/models.py:91 msgid "File" msgstr "Файл" -#: tools/media/models.py:59 +#: tools/media/models.py:72 msgid "Preset" msgstr "Пресет" -#: tools/media/models.py:60 +#: tools/media/models.py:73 msgid "Presets" msgstr "Пресеты" +#: tools/media/models.py:88 +msgid "Only MP3, OGG, and WAV audio files are allowed." +msgstr "Разрешены только аудиофайлы MP3, OGG и WAV." + +#: tools/media/models.py:99 +msgid "Unknown file" +msgstr "Неизвестный файл" + +#: tools/media/models.py:118 +msgid "Voice" +msgstr "Голос" + +#: tools/media/models.py:119 +msgid "Voices" +msgstr "Голоса" + +#: tools/media/routes/v1.py:71 +#, fuzzy +#| msgid "Voice not found." +msgid "Voice not found" +msgstr "Голос не найден." + #: tools/public_api/exceptions.py:7 msgid "Upgrade token limit on your api-key" msgstr "Необходимо повысить лимит токенов у API-ключа" @@ -1369,10 +1419,6 @@ msgstr "Отсутствует обязательный параметр: 'messa msgid "Model not found" msgstr "Модель не найдена" -#: authentication/routes/v1.py:28 -msgid "Invalid or expired refresh token" -msgstr "Неверный или истёкший refresh токен" - #~ msgid "" #~ "Error occured when create generation. It may cause NSFW-content not " #~ "allowed, retry again" @@ -54,6 +54,7 @@ from ml_model.services.pruna_v import Pruna_V from ml_model.services.qwen import Qwen from ml_model.services.qwen_235B import Qwen_235B from ml_model.services.qwen_3_5 import Qwen_3_5 +from ml_model.services.qwen_3_tts import Qwen_3_Tts from ml_model.services.qwen_3_max_thinking import Qwen_3_Max_Thinking from ml_model.services.raifgpt import Raifgpt from ml_model.services.ray import Ray @@ -20,7 +20,8 @@ class MinIOService: 'air-errors', 'air-welcome-pic', 'air-messages', - 'air-media-presets' + 'air-media-presets', + 'air-voices', ] def __init__(self): @@ -0,0 +1,64 @@ +import time +from datetime import timedelta +from decimal import Decimal +from io import BytesIO +from typing import Any + +import requests +from django.core.files import File +from replicate.exceptions import ModelError + +from messages.models import Message +from ml_model.exceptions import GenerationException, RequestBlocked +from ml_model.services.base import SimpleService +from ml_model.tasks import replicate_run +from payments.exceptions.insufficient_balance import InsufficientBalance +from payments.selectors.payment_plan_selector import PaymentPlanSelector + + +class Qwen_3_Tts(SimpleService): + TOKENS_PER_1K_CHARS = Decimal('6') + + @classmethod + def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: + chars = len(content) + price = cls.TOKENS_PER_1K_CHARS * Decimal(chars) / Decimal(1000) + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def calculate_price(self, content: str) -> Decimal: + chars = len(content) + price = self.TOKENS_PER_1K_CHARS * Decimal(chars) / Decimal(1000) + return price.quantize(Decimal('0.1'), rounding='ROUND_UP') + + def save_results(self, content: str, t: timedelta, audio_url: str, save: bool = True) -> list[Message]: + msg = Message( + content=content, + content_object=self.store, + elapsed_time=t, + file=File(BytesIO(requests.get(audio_url).content), '.mp3'), + ) + if save: + return Message.objects.bulk_create([msg]) + return [msg] + + def make(self, input_message: Message, save: bool = True) -> list[Message]: + cost = self.calculate_price(input_message.content) + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < cost: + raise InsufficientBalance(balance, cost) + callback_data = { + 'text': input_message.content, + 'mode': 'voice_clone', + 'reference_audio': input_message.file.url, + } + + start_time = time.time() + try: + result = replicate_run('qwen/qwen3-tts', callback_data) + except ModelError as exc: + if any(error in str(exc) for error in ('E005', 'E006', 'sexual')): + raise RequestBlocked + raise GenerationException from exc + + process_time = timedelta(seconds=(time.time() - start_time)) + self.handle_invoice(input_message.content_object.model, content=input_message.content) + return self.save_results(input_message.content, process_time, result, save) @@ -0,0 +1,70 @@ +# Generated by Django 5.0.11 on 2026-04-02 07:40 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +import django_minio_backend.models +import tools.media.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('media', '0004_preset'), + ('ml_model', '0052_alter_modelinput_unique_together'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='preset', + name='id', + ), + migrations.AddField( + model_name='preset', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Создан'), + preserve_default=False, + ), + migrations.AddField( + model_name='preset', + name='uid', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + migrations.AddField( + model_name='preset', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Изменён'), + ), + migrations.CreateModel( + name='Voice', + fields=[ + ('uid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создан')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Изменён')), + ('title', models.CharField(blank=True, max_length=50, null=True, verbose_name='Title')), + ('file', models.FileField(storage=django_minio_backend.models.MinioBackend(bucket_name='air-voices'), upload_to=tools.media.models.voice_file_upload, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=('mp3', 'ogg', 'wav'), message='Only MP3, OGG, and WAV audio files are allowed.')], verbose_name='File')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uploaded_voices', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Voice', + 'verbose_name_plural': 'Voices', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='VoiceClone', + fields=[ + ('uid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='Идентификатор')), + ('model', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_model', related_query_name='%(app_label)s_%(class)ss_model', to='ml_model.neuronmodel', verbose_name='Нейронная модель')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_user', related_query_name='%(app_label)s_%(class)ss_user', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Voice clone store', + 'verbose_name_plural': 'Voice clone stores', + }, + ), + ] @@ -1,10 +1,17 @@ from typing import List +from uuid import UUID -from ninja import Router +from asgiref.sync import sync_to_async +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +from ninja import Router, File, UploadedFile +from ninja.errors import HttpError -from authentication.security import SyncAuthBearer +from authentication.security import SyncAuthBearer, AsyncAuthBearer from ml_model.models import NeuronModel from ml_model.schemas import NeuronModelLink +from tools.media.models import Preset, Voice +from tools.media.schemas import PresetSchema, VoiceSchema router = Router(auth=SyncAuthBearer(), tags=['media']) @@ -22,3 +29,56 @@ def get_links(request): ) .order_by('order') ) + + +@router.post('voices/', tags=['media/voices'], auth=AsyncAuthBearer(), response={201: None, 400: str}) +async def upload_voice(request, file: File[UploadedFile], title: str | None = None): + voice = Voice(user=request.auth, title=title, file=file) + try: + await sync_to_async(voice.full_clean)() + except ValidationError as exc: + raise HttpError(400, ';\n'.join(exc.messages)) + await voice.asave() + return 201, None + + +@router.get('voices/', tags=['media/voices'], auth=AsyncAuthBearer(), response=List[VoiceSchema]) +async def list_voices(request): + return [voice async for voice in Voice.objects.filter(user=request.auth)] + + +@router.delete( + 'voices/{voice_id}/', + tags=['media/voices'], + auth=AsyncAuthBearer(), + response={204: None, 404: str}, +) +async def delete_voice(request, voice_id: UUID): + await Voice.objects.filter(uid=voice_id, user=request.auth).adelete() + return 204, None + + +@router.patch( + 'voices/{voice_id}/', + tags=['media/voices'], + auth=AsyncAuthBearer(), + response={200: VoiceSchema, 400: str, 404: str}, +) +async def update_voice_title(request, voice_id: UUID, title: str): + try: + voice = await Voice.objects.aget(uid=voice_id, user=request.auth) + except Voice.DoesNotExist: + raise HttpError(404, _('Voice not found')) + voice.title = title + await voice.asave() + return voice + + +@router.get( + 'presets/', + tags=['media/presets'], + auth=AsyncAuthBearer(), + response=List[PresetSchema], +) +async def list_presets(request): + return [preset async for preset in Preset.objects.all()] @@ -2,7 +2,7 @@ from django.contrib import admin from messages.inlines import MessageInline -from .models import Audio, Image, Video, Preset +from .models import Audio, Image, Video, Preset, Voice, VoiceClone @admin.register(Image) @@ -35,7 +35,24 @@ class AudioAdmin(admin.ModelAdmin): list_per_page = 10 +@admin.register(VoiceClone) +class VoiceCloneAdmin(admin.ModelAdmin): + list_display = ('uid',) + raw_id_fields = ('user',) + inlines = [ + MessageInline, + ] + list_per_page = 10 + + @admin.register(Preset) class PresetAdmin(admin.ModelAdmin): list_display = ('title', 'slug') search_fields = ('title', 'slug') + + +@admin.register(Voice) +class VoiceAdmin(admin.ModelAdmin): + list_display = ('uid', 'title', 'user') + raw_id_fields = ('user',) + search_fields = ('title',) @@ -1,5 +1,6 @@ import logging import sys +from uuid import UUID from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -29,7 +30,7 @@ from ml_model.models import NeuronModel from ml_model.services.base import SimpleService from payments.exceptions.insufficient_balance import InsufficientBalance -from .models import Audio, Image, Video +from .models import Audio, Image, Video, VoiceClone, Voice, Preset logger = logging.getLogger(__name__) @@ -40,11 +41,12 @@ class GalleryAPIView(APIView): ] @property - def manager(self) -> Image | Video | Audio: + def manager(self) -> Image | Video | Audio | VoiceClone: return { 'images': Image, 'videos': Video, 'audios': Audio, + 'voice_clone': VoiceClone, }[self.kwargs['strategy']] @extend_schema( @@ -54,7 +56,7 @@ class GalleryAPIView(APIView): str, 'path', required=True, - enum=['images', 'videos', 'audios'], + enum=['images', 'videos', 'audios', 'voice_clone'], ), OpenApiParameter('limit', int, required=False), OpenApiParameter('offset', int, required=False), @@ -91,7 +93,7 @@ class MediaAPIView(APIView): IsAuthenticated, ] - manager: Image | Video | Audio | None = None + manager: Image | Video | Audio | VoiceClone | None = None @extend_schema( parameters=[ @@ -221,3 +223,34 @@ class ModelVideosAPIView(MediaAPIView): class ModelAudiosAPIVIew(MediaAPIView): manager = Audio + + +class ModelVoiceCloneAPIView(MediaAPIView): + manager = VoiceClone + + @extend_schema( + parameters=[ + OpenApiParameter('voice_id', UUID, 'query', required=False), + OpenApiParameter('preset_id', UUID, 'query', required=False), + OpenApiParameter('model', str, 'path', required=True), + ], + request=MessageSerializer, + responses={ + 201: MessageSerializer(many=True), + }, + ) + def post(self, request, model: str, *args, **kwargs): + try: + if voice_id := request.query_params.get('voice_id'): + voice = Voice.objects.get(uid=voice_id, user=request.user) + elif preset_id := request.query_params.get('preset_id'): + voice = Preset.objects.get(uid=preset_id) + else: + voice = Preset.objects.get(slug='russian_1') + except Voice.DoesNotExist: + return Response( + {'detail': _('Voice not found.')}, + status=HTTP_400_BAD_REQUEST, + ) + request.data.update({'file': voice.file}) + return super().post(request, model, *args, **kwargs) @@ -1,8 +1,15 @@ +import re +import time +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.core.validators import FileExtensionValidator from django.db import models from django.db.models import QuerySet from django_minio_backend import MinioBackend from django.utils.translation import gettext_lazy as _ +from core.models import BaseModel from messages.models import Message, MultipleStore @@ -33,11 +40,17 @@ class Audio(Gallery): verbose_name_plural = 'Хранилища аудио' +class VoiceClone(Gallery): + class Meta: + verbose_name = _('Voice clone store') + verbose_name_plural = _('Voice clone stores') + + def message_file_upload(instance: 'Preset', filename: str): return f'{filename}' -class Preset(models.Model): +class Preset(BaseModel): title = models.CharField(max_length=50, verbose_name=_('Title')) slug = models.SlugField( verbose_name=_('Slug'), @@ -58,3 +71,50 @@ class Preset(models.Model): class Meta: verbose_name = _('Preset') verbose_name_plural = _('Presets') + + +def voice_file_upload(instance: 'Voice', filename: str): + return f'voice_{instance.user.pk}_{time.time():.0f}.{filename.split(".")[-1]}' + + +class Voice(BaseModel): + title = models.CharField(max_length=50, verbose_name=_('Title'), blank=True, null=True) + file = models.FileField( + storage=MinioBackend(bucket_name='air-voices'), + upload_to=voice_file_upload, + validators=[ + FileExtensionValidator( + allowed_extensions=('mp3', 'ogg', 'wav'), + message=_('Only MP3, OGG, and WAV audio files are allowed.'), + ) + ], + verbose_name=_('File'), + ) + user = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, related_name='uploaded_voices', verbose_name=_('User') + ) + + def save(self, *args, **kwargs): + if not self.title: + self.title = Path(self.file.name).stem or _('Unknown file') + base_title = re.sub(r'\s\(\d+\)$', '', self.title).strip()[:40] + file_quantity = ( + self.__class__.objects.filter( + user=self.user, title__regex=rf'^{re.escape(base_title)}(?: \(\d+\))?$' + ) + .exclude(uid=self.uid) + .count() + ) + if file_quantity > 0: + self.title = f'{base_title} ({file_quantity + 1})' + else: + self.title = base_title + return super().save(*args, **kwargs) + + def __str__(self) -> str: + return self.title + + class Meta: + verbose_name = _('Voice') + verbose_name_plural = _('Voices') + ordering = ('-created_at',) @@ -0,0 +1,15 @@ +from ninja import ModelSchema + +from tools.media.models import Preset, Voice + + +class VoiceSchema(ModelSchema): + class Meta: + model = Voice + fields = ('uid', 'title', 'file') + + +class PresetSchema(ModelSchema): + class Meta: + model = Preset + fields = ('uid', 'title', 'file') \ No newline at end of file @@ -5,6 +5,7 @@ from .apis import ( ModelAudiosAPIVIew, ModelImagesAPIView, ModelVideosAPIView, + ModelVoiceCloneAPIView, ) urlpatterns = [ @@ -12,4 +13,5 @@ urlpatterns = [ path('image/', ModelImagesAPIView.as_view(), name='images'), path('video/', ModelVideosAPIView.as_view(), name='video'), path('audio/', ModelAudiosAPIVIew.as_view(), name='audio'), + path('voice_clone/', ModelVoiceCloneAPIView.as_view(), name='voice-clone'), ]