Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions edxval/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ThirdPartyTranscriptCredentialsState,
TranscriptPreference,
Video,
VideoAudioDescription,
VideoImage,
VideoTranscript,
)
Expand Down Expand Up @@ -110,6 +111,28 @@ def get_video(self, transcript):
verbose_name_plural = 'Video Transcripts'


@admin.register(VideoAudioDescription)
class VideoAudioDescriptionAdmin(admin.ModelAdmin):
""" Admin for VideoAudioDescription """
raw_id_fields = ('video',)
list_display = ('get_video', 'file_name', 'file_format')
search_fields = ('id', 'video__edx_video_id', 'file_name')

@admin.display(
description='Video',
ordering='video',
)
def get_video(self, audio_description):
""" get video """
return (
audio_description.video.edx_video_id
if getattr(audio_description, 'video', False) else ''
)
model = VideoAudioDescription
verbose_name = 'Video Audio Description'
verbose_name_plural = 'Video Audio Descriptions'


@admin.register(TranscriptPreference)
class TranscriptPreferenceAdmin(admin.ModelAdmin):
""" Admin for TranscriptPreference """
Expand Down
76 changes: 75 additions & 1 deletion edxval/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@
TranscriptPreference,
TranscriptProviderType,
Video,
VideoAudioDescription,
VideoImage,
VideoTranscript,
)
from edxval.serializers import TranscriptPreferenceSerializer, TranscriptSerializer, VideoSerializer
from edxval.serializers import (
AudioDescriptionSerializer,
TranscriptPreferenceSerializer,
TranscriptSerializer,
VideoSerializer,
)
from edxval.transcript_utils import Transcript
from edxval.utils import (
THIRD_PARTY_TRANSCRIPTION_PLANS,
AudioDescriptionFormat,
TranscriptFormat,
create_file_in_fs,
get_transcript_format,
Expand Down Expand Up @@ -441,6 +448,73 @@ def delete_video_transcript(video_id, language_code, provider=None):
logger.info('Transcript is removed for video "%s" and language code "%s"', video_id, language_code)


def create_or_update_video_audio_description(video_id, metadata, file_data=None):
"""
Create or update the audio description for a video.

Arguments:
video_id(unicode): edx_video_id of an existing Video.
metadata(dict): Dict with 'file_name' and 'file_format'.
file_data(InMemoryUploadedFile): Audio description file content.

Returns:
unicode: URL of the saved audio description, or None if video not found.
"""
file_format = metadata.get('file_format')
if file_format and file_format not in dict(AudioDescriptionFormat.CHOICES):
raise ValCannotCreateError(f'{file_format} is not a supported audio description format')

try:
video = Video.objects.get(edx_video_id=video_id)
except Video.DoesNotExist:
return None

audio_desc, __ = VideoAudioDescription.create_or_update(video, metadata, file_data)
return audio_desc.url()


def get_video_audio_description(video_id):
"""
Get audio description metadata for a video.

Returns:
dict: Serialized VideoAudioDescription data, or None.
"""
audio_desc = VideoAudioDescription.get_or_none(video_id)
if audio_desc is None:
return None
return AudioDescriptionSerializer(audio_desc).data


def get_video_audio_description_url(video_id):
"""
Get the URL for a video's audio description.

Returns:
unicode: URL string, or None.
"""
audio_desc = VideoAudioDescription.get_or_none(video_id)
if audio_desc:
return audio_desc.url()
return None


def delete_video_audio_description(video_id):
"""
Delete the audio description record and its file from storage.

Returns:
bool: True if deleted, False if nothing existed.
"""
audio_desc = VideoAudioDescription.get_or_none(video_id)
if audio_desc is None:
return False
audio_desc.audio_description_file.delete()
audio_desc.delete()
logger.info('Audio description removed for video "%s"', video_id)
return True


def get_3rd_party_transcription_plans():
"""
Retrieves 3rd party transcription plans.
Expand Down
78 changes: 78 additions & 0 deletions edxval/migrations/0005_videoaudiodescription.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Generated by Django 4.2.28 on 2026-04-10 09:27

from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import edxval.models
import model_utils.fields


class Migration(migrations.Migration):

dependencies = [
("edxval", "0004_add_edx_ai_translations_provider"),
]

operations = [
migrations.CreateModel(
name="VideoAudioDescription",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"audio_description_file",
edxval.models.CustomizableAudioDescriptionFileField(
blank=True, null=True
),
),
("file_name", models.CharField(max_length=255)),
(
"file_format",
models.CharField(
choices=[
("mp3", "MP3"),
("m4a", "M4A"),
("wav", "WAV"),
("aac", "AAC"),
("ogg", "OGG"),
],
max_length=16,
),
),
(
"video",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="audio_description",
to="edxval.video",
),
),
],
options={
"abstract": False,
},
),
]
115 changes: 115 additions & 0 deletions edxval/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
from model_utils.models import TimeStampedModel

from edxval.utils import (
AudioDescriptionFormat,
TranscriptFormat,
audio_description_path,
get_audio_description_storage,
get_video_image_storage,
get_video_transcript_storage,
validate_generated_images,
Expand Down Expand Up @@ -581,6 +584,118 @@ def __str__(self):
return f'{self.language_code} Transcript for {self.video.edx_video_id}'


class CustomizableAudioDescriptionFileField(models.FileField):
"""
Subclass of FileField for audio description files.

Mirrors CustomizableFileField but uses audio-description-specific
storage settings so the storage class / bucket are not hard-coded
in migrations.
"""

def __init__(self, *args, **kwargs):
kwargs.update(dict(
upload_to=audio_description_path,
storage=get_audio_description_storage(),
max_length=500,
blank=True,
null=True
))
super().__init__(*args, **kwargs)

def deconstruct(self):
"""
Remove `upload_to`, `storage`, and `max_length` to prevent unnecessary migrations.
"""
name, path, args, kwargs = super().deconstruct()
del kwargs['upload_to']
del kwargs['storage']
del kwargs['max_length']
return name, path, args, kwargs


class VideoAudioDescription(TimeStampedModel):
"""
Audio description file for a video (one per video).

Mirrors VideoTranscript: the actual file is stored via Django's
storage abstraction (FileSystemStorage locally, S3 in prod).

.. no_pii:
"""
video = models.OneToOneField(
Video,
related_name='audio_description',
on_delete=models.CASCADE,
)
audio_description_file = CustomizableAudioDescriptionFileField()
file_name = models.CharField(max_length=255)
file_format = models.CharField(max_length=16, choices=AudioDescriptionFormat.CHOICES)

def save_file(self, file_data, file_format, file_name=None):
"""
Save audio description content to storage.
"""
if not file_name:
file_name = f'{uuid4().hex}.{file_format}'

if file_data:
self.audio_description_file.save(file_name, file_data)
else:
self.audio_description_file.name = file_name

self.save()

@classmethod
def get_or_none(cls, video_id):
"""
Return the audio description for a given edx_video_id, or None.
"""
try:
return cls.objects.get(video__edx_video_id=video_id)
except cls.DoesNotExist:
return None

@classmethod
def create_or_update(cls, video, metadata, file_data=None):
"""
Create or replace the audio description for a video.

Returns a tuple of (audio_description, created).
"""
try:
audio_desc = cls.objects.get(video=video)
created = False
except cls.DoesNotExist:
audio_desc = cls(video=video)
created = True

for prop, value in metadata.items():
if prop in ['file_name', 'file_format'] and value:
setattr(audio_desc, prop, value)

try:
audio_desc.save_file(file_data, audio_desc.file_format, file_name=metadata.get('file_name'))
except Exception:
logger.exception(
'[VAL] Audio description save failed to storage for video_id "%s"',
video.edx_video_id,
)
raise

return audio_desc, created

def url(self):
"""
Return the URL for this audio description file.
"""
storage = get_audio_description_storage()
return storage.url(self.audio_description_file.name)

def __str__(self):
return f'Audio Description for {self.video.edx_video_id}'


class Cielo24Turnaround:
"""
Cielo24 turnarounds.
Expand Down
29 changes: 28 additions & 1 deletion edxval/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@
from rest_framework import serializers
from rest_framework.fields import DateTimeField, IntegerField

from edxval.models import CourseVideo, EncodedVideo, Profile, TranscriptPreference, Video, VideoImage, VideoTranscript
from edxval.models import (
CourseVideo,
EncodedVideo,
Profile,
TranscriptPreference,
Video,
VideoAudioDescription,
VideoImage,
VideoTranscript,
)


class EncodedVideoSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -94,6 +103,24 @@ def create(self, validated_data):
return VideoTranscript.create(**validated_data)


class AudioDescriptionSerializer(serializers.ModelSerializer):
"""
Serializer for VideoAudioDescription objects.
"""
class Meta:
model = VideoAudioDescription
fields = ('video_id', 'url', 'file_name', 'file_format')

video_id = serializers.SerializerMethodField()
url = serializers.SerializerMethodField()

def get_video_id(self, audio_description):
return audio_description.video.edx_video_id

def get_url(self, audio_description):
return audio_description.url()


class CourseSerializer(serializers.RelatedField):
"""
Field for CourseVideo
Expand Down
Loading
Loading