@@ -1372,8 +1372,8 @@ msgid "Presets" msgstr "Пресеты" #: tools/media/models.py:100 -msgid "Only MP3, OGG, and WAV audio files are allowed." -msgstr "Разрешены только аудиофайлы MP3, OGG и WAV." +msgid "Only MP3, OGG, WAV and WEBA audio files are allowed." +msgstr "Разрешены только аудиофайлы MP3, OGG, WAV и WEBA." #: tools/media/models.py:108 msgid "Transcription" @@ -1388,10 +1388,8 @@ msgid "Voices" msgstr "Голоса" #: tools/media/routes/v1.py:74 -#, fuzzy -#| msgid "Voice not found." msgid "Voice not found" -msgstr "Голос не найден." +msgstr "Голос не найден" #: tools/public_api/exceptions.py:7 msgid "Upgrade token limit on your api-key" @@ -1439,6 +1437,22 @@ msgstr "Отсутствует обязательный параметр: 'messa msgid "Model not found" msgstr "Модель не найдена" +#: tools/public_api/views/voice.py:40 +msgid "Your voice has been uploaded successfully" +msgstr "Ваш голос успешно загружен" + +#: tools/public_api/views/voice.py:72 +msgid "Voice title updated successfully" +msgstr "Название голоса успешно обновлено" + +#: tools/public_api/views/voice.py +msgid "Preset voices are shared and cannot be edited. Use your own voice id." +msgstr "Пресеты общие для всех — их нельзя редактировать. Укажите id своего голоса." + +#: tools/public_api/views/voice.py +msgid "Preset voices are shared and cannot be deleted. Use your own voice id." +msgstr "Пресеты общие для всех — их нельзя удалить. Укажите id своего голоса." + #~ msgid "" #~ "Error occured when create generation. It may cause NSFW-content not " #~ "allowed, retry again" @@ -14,6 +14,7 @@ class ContentTypes: AUDIO = 'audio' VIDEO = 'video' CODE = 'code' + VOICE = 'voice' class ContentTypeChoices(models.TextChoices): @@ -22,3 +23,4 @@ class ContentTypeChoices(models.TextChoices): AUDIO = ContentTypes.AUDIO, _(ContentTypes.AUDIO) VIDEO = ContentTypes.VIDEO, _(ContentTypes.VIDEO) CODE = ContentTypes.CODE, _(ContentTypes.CODE) + VOICE = ContentTypes.VOICE, _(ContentTypes.VOICE) @@ -0,0 +1,21 @@ +# Generated by Django 5.0.11 on 2026-04-06 07:12 + +import django.core.validators +import django_minio_backend.models +import tools.media.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('media', '0007_preset_metadata_voice_transcription'), + ] + + operations = [ + migrations.AlterField( + model_name='voice', + name='file', + field=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', 'weba'), message='Only MP3, OGG, WAV, and WEBA audio files are allowed.')], verbose_name='File'), + ), + ] @@ -0,0 +1,31 @@ +# Generated by Django 5.0.11 on 2026-04-07 16:35 + +import django.core.validators +import django_minio_backend.models +import tools.media.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('media', '0008_alter_voice_file'), + ] + + operations = [ + migrations.RemoveField( + model_name='voice', + name='uid', + ), + migrations.AddField( + model_name='voice', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + preserve_default=False, + ), + migrations.AlterField( + model_name='voice', + name='file', + field=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', 'weba'), message='Only MP3, OGG, WAV and WEBA audio files are allowed.')], verbose_name='File'), + ), + ] @@ -1,5 +1,4 @@ from typing import List -from uuid import UUID from asgiref.sync import sync_to_async from django.core.exceptions import ValidationError @@ -59,8 +58,8 @@ async def list_voices(request): 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() +async def delete_voice(request, voice_id: int): + await Voice.objects.filter(pk=voice_id, user=request.auth).adelete() return 204, None @@ -70,9 +69,9 @@ async def delete_voice(request, voice_id: UUID): auth=AsyncAuthBearer(), response={200: VoiceSchema, 400: str, 404: str}, ) -async def update_voice_title(request, voice_id: UUID, body: UpdateVoiceSchema): +async def update_voice_title(request, voice_id: int, body: UpdateVoiceSchema): try: - voice = await Voice.objects.aget(uid=voice_id, user=request.auth) + voice = await Voice.objects.aget(pk=voice_id, user=request.auth) except Voice.DoesNotExist: raise HttpError(404, _('Voice not found')) voice.title = body.title @@ -53,6 +53,6 @@ class PresetAdmin(admin.ModelAdmin): @admin.register(Voice) class VoiceAdmin(admin.ModelAdmin): - list_display = ('uid', 'title', 'user') + list_display = ('id', 'title', 'user') raw_id_fields = ('user',) search_fields = ('title',) @@ -236,14 +236,13 @@ class ModelAudiosAPIVIew(MediaAPIView): if not (request.FILES.get('file') or request.data.get('file')): try: if voice_id := request.data.pop('voice_id', None): - voice = Voice.objects.get(uid=voice_id, user=request.user) + voice = Voice.objects.get(pk=voice_id, user=request.user) transcription = voice.transcription elif preset_id := request.data.pop('preset_id', None): voice = Preset.objects.get(uid=preset_id) transcription = voice.metadata.get('transcription', '') else: - voice = Preset.objects.get(slug='russian_1') - transcription = voice.metadata.get('transcription', '') + return super().post(request, model, *args, **kwargs) except (Voice.DoesNotExist, Preset.DoesNotExist): return Response( {'detail': _('Voice not found.')}, @@ -271,7 +270,7 @@ class ModelVoiceCloneAPIView(MediaAPIView): if not (request.FILES.get('file') or request.data.get('file')): try: if voice_id := request.data.pop('voice_id', None): - voice = Voice.objects.get(uid=voice_id, user=request.user) + voice = Voice.objects.get(pk=voice_id, user=request.user) transcription = voice.transcription elif preset_id := request.data.pop('preset_id', None): voice = Preset.objects.get(uid=preset_id) @@ -89,15 +89,17 @@ def voice_file_upload(instance: 'Voice', filename: str): return f'voice_{instance.user.pk}_{time.time():.0f}.{filename.split(".")[-1]}' -class Voice(BaseModel): +class Voice(models.Model): + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Создан')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Изменён')) 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.'), + allowed_extensions=('mp3', 'ogg', 'wav', 'weba'), + message=_('Only MP3, OGG, WAV and WEBA audio files are allowed.'), ) ], verbose_name=_('File'), @@ -109,19 +111,7 @@ class Voice(BaseModel): 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 + self.title = (Path(self.file.name).stem or _('Unknown file'))[:50] return super().save(*args, **kwargs) def __str__(self) -> str: @@ -13,7 +13,7 @@ class UpdateVoiceSchema(ModelSchema): class VoiceSchema(ModelSchema): class Meta: model = Voice - fields = ('uid', 'title', 'file', 'transcription') + fields = ('id', 'title', 'file', 'transcription') class PresetSchema(ModelSchema): @@ -5,7 +5,9 @@ from .ml_service import ( AudioView, VideoView, CodeView, + VoiceView, ParamView, OpenAICompatibleAPIView ) from .user import UserInfoAPIView +from .voice import PublicVoiceViewSet @@ -20,6 +20,7 @@ from ml_model.models import NeuronModel from ml_model.selectors.ml_models_selector import NeuronModelSelector from ml_model.selectors.param_selector import ParamSelector from ml_model.serializers import ModelParameterSerializer +from tools.media.models import Preset, Voice from tools.public_api.models import APIStore from tools.public_api.selectors.api_key import APIKeySelector from tools.public_api.views.base import BaseGenerationView @@ -41,6 +42,34 @@ class AudioView(BaseGenerationView): output_content_type = ContentTypes.AUDIO description = 'Get Audio Generation from model in URL slug. Only POST Requests.' + def post(self, request, model_slug, *args, **kwargs): + if not (request.FILES.get('file') or request.data.get('file')): + api_key_value = request.headers.get('Authorization') + if (split_api_key := api_key_value.split())[0] == 'Bearer': + api_key_value = split_api_key[-1] + user = APIKeySelector.get_user_by_key(key_value=api_key_value) + voice_id = str(request.data.pop('voice_id', '')) + try: + if not voice_id: + return super().post(request, model_slug, *args, **kwargs) + elif voice_id.isdigit(): + voice = Voice.objects.get(pk=voice_id, user=user) + # transcription = voice.transcription + else: + voice = Preset.objects.get(uid=voice_id) + # transcription = voice.metadata.get('transcription', '') + except (Voice.DoesNotExist, Preset.DoesNotExist): + return Response( + {'detail': _('Voice not found.')}, + status=HTTP_400_BAD_REQUEST, + ) + request.data.update( + { + 'file': voice.file, # 'info': {'transcription': transcription, **request.data['info']} + } + ) + return super().post(request, model_slug, *args, **kwargs) + class VideoView(BaseGenerationView): output_content_type = ContentTypes.VIDEO @@ -52,6 +81,40 @@ class CodeView(BaseGenerationView): description = 'Get Code Generation from model in URL slug. Only POST Requests.' +class VoiceView(BaseGenerationView): + output_content_type = ContentTypes.VOICE + description = 'Get Voice Generation from model in URL slug. Only POST Requests.' + + def post(self, request, model_slug, *args, **kwargs): + if not (request.FILES.get('file') or request.data.get('file')): + api_key_value = request.headers.get('Authorization') + if (split_api_key := api_key_value.split())[0] == 'Bearer': + api_key_value = split_api_key[-1] + user = APIKeySelector.get_user_by_key(key_value=api_key_value) + voice_id = str(request.data.pop('voice_id', '')) + try: + if not voice_id: + voice = Preset.objects.get(slug='russian_1') + # transcription = voice.metadata.get('transcription', '') + elif voice_id.isdigit(): + voice = Voice.objects.get(pk=voice_id, user=user) + # transcription = voice.transcription + else: + voice = Preset.objects.get(uid=voice_id) + # transcription = voice.metadata.get('transcription', '') + except (Voice.DoesNotExist, Preset.DoesNotExist): + return Response( + {'detail': _('Voice not found.')}, + status=HTTP_400_BAD_REQUEST, + ) + request.data.update( + { + 'file': voice.file, # 'info': {'transcription': transcription, **request.data['info']} + } + ) + return super().post(request, model_slug, *args, **kwargs) + + class OpenAICompatibleAPIView(BaseGenerationView): def post(self, request: Request, *args, **kwargs): data = request.data.copy() @@ -118,7 +181,7 @@ class OpenAICompatibleAPIView(BaseGenerationView): model_slug = ( NeuronModel.objects.filter( Q(model_modelversions__slug=data['info']['version']) | Q(slug=data['info']['version']), - category__slug='chat-bots' + category__slug='chat-bots', ) .get() .slug @@ -0,0 +1,99 @@ +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.viewsets import ViewSet + +from tools.media.models import Preset, PresetKind, Voice +from tools.public_api.permissions import HasAPIKey +from tools.public_api.serializers import ( + VoiceListItemSerializer, + VoiceTitleUpdateSerializer, + VoiceUploadSerializer, +) +from tools.public_api.selectors.api_key import APIKeySelector + + +class PublicVoiceViewSet(ViewSet): + authentication_classes = [] + permission_classes = (HasAPIKey,) + + def _get_api_user(self, request): + api_key_value = request.headers.get('Authorization', '') + if api_key_value.startswith('Bearer '): + api_key_value = api_key_value.split(' ', 1)[1] + return APIKeySelector.get_user_by_key(key_value=api_key_value) + + def create(self, request, *args, **kwargs): + user = self._get_api_user(request) + serializer = VoiceUploadSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data + voice = Voice( + user=user, + title=payload.get('title'), + # transcription=payload.get('transcription'), + file=payload['file'], + ) + try: + voice.full_clean() + voice.save() + except ValidationError as exc: + return Response({'detail': ';\n'.join(exc.messages)}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {'detail': _('Your voice has been uploaded successfully')}, status=status.HTTP_201_CREATED + ) + + def list(self, request, *args, **kwargs): + user = self._get_api_user(request) + items = [] + + for voice in Voice.objects.filter(user=user): + items.append( + { + 'id': str(voice.pk), + 'title': voice.title, + 'file': voice.file.url if voice.file else None, + # 'transcription': voice.transcription, + } + ) + + for preset in Preset.objects.filter(kind=PresetKind.VOICE): + items.append( + { + 'id': str(preset.uid), + 'title': preset.title, + 'file': preset.file.url if preset.file else None, + # 'transcription': preset.metadata.get('transcription'), + } + ) + + return Response(VoiceListItemSerializer(items, many=True).data, status=status.HTTP_200_OK) + + def partial_update(self, request, voice_id: str, *args, **kwargs): + user = self._get_api_user(request) + if not voice_id.isdigit(): + return Response( + {'detail': _('Preset voices are shared and cannot be edited. Use your own voice id.')}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = VoiceTitleUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + updated = Voice.objects.filter(pk=voice_id, user=user).update( + title=serializer.validated_data['title'] + ) + if not updated: + return Response({'detail': _('Voice not found')}, status=status.HTTP_404_NOT_FOUND) + return Response({'detail': _('Voice title updated successfully')}, status=status.HTTP_200_OK) + + def destroy(self, request, voice_id: str, *args, **kwargs): + user = self._get_api_user(request) + if not voice_id.isdigit(): + return Response( + {'detail': _('Preset voices are shared and cannot be deleted. Use your own voice id.')}, + status=status.HTTP_400_BAD_REQUEST, + ) + deleted, _deleted_rows = Voice.objects.filter(pk=voice_id, user=user).delete() + if not deleted: + return Response({'detail': _('Voice not found')}, status=status.HTTP_404_NOT_FOUND) + return Response(status=status.HTTP_204_NO_CONTENT) @@ -8,6 +8,9 @@ __all__ = [ 'APIKeyCreateSerializer', 'APIKeyUpdateSerializer', 'APIKeyDeleteSerializer', + 'VoiceUploadSerializer', + 'VoiceTitleUpdateSerializer', + 'VoiceListItemSerializer', ] @@ -44,3 +47,20 @@ class APIKeyDeleteSerializer(serializers.ModelSerializer): class Meta: model = APIKey fields = ('name',) + + +class VoiceUploadSerializer(serializers.Serializer): + file = serializers.FileField() + title = serializers.CharField(max_length=50, required=False, allow_null=True, allow_blank=True) + # transcription = serializers.CharField(required=False, allow_null=True, allow_blank=True) + + +class VoiceTitleUpdateSerializer(serializers.Serializer): + title = serializers.CharField(max_length=50) + + +class VoiceListItemSerializer(serializers.Serializer): + id = serializers.CharField() + title = serializers.CharField(allow_null=True, allow_blank=True) + file = serializers.CharField() + # transcription = serializers.CharField(allow_null=True, allow_blank=True) @@ -4,8 +4,16 @@ from tools.public_api import views urlpatterns = [ path('api-key', views.APIKeyView.as_view()), path('me', views.UserInfoAPIView.as_view()), + path( + 'voices', + views.PublicVoiceViewSet.as_view({'get': 'list', 'post': 'create'}), + ), + path( + 'voices/', + views.PublicVoiceViewSet.as_view({'patch': 'partial_update', 'delete': 'destroy'}), + ), path('openai/chat/completions', views.OpenAICompatibleAPIView.as_view()), - path('openai/v1/chat/completions', views.OpenAICompatibleAPIView.as_view()) + path('openai/v1/chat/completions', views.OpenAICompatibleAPIView.as_view()), ] for view in ( @@ -14,6 +22,7 @@ for view in ( views.AudioView, views.VideoView, views.CodeView, + views.VoiceView ): urlpatterns.extend( [