From 6c8ffee150ee37bf67bf201e7c36fd48f8ad5fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=84=82=CA=8F=E1=B4=87=E1=B4=85=20=F0=9F=84=B0=CA=99?= =?UTF-8?q?=E1=B4=85=E1=B4=9C=CA=9F=20=F0=9F=84=B0=E1=B4=8D=E1=B4=80?= =?UTF-8?q?=F0=9F=84=9D=20=E2=9C=A7?= Date: Sun, 15 Mar 2026 21:22:08 +0530 Subject: [PATCH 01/25] feat: add AssetType and AssetCodec choices for #1420 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 🄂ʏᴇᴅ 🄰ʙᴅᴜʟ 🄰ᴍᴀ🄝 ✧ --- tubesync/sync/choices.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index 80cf62139..94feac916 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -257,6 +257,19 @@ class YouTube_VideoCodec(models.TextChoices): AVC1 = 'AVC1', _('AVC1 (H.264)') +class AssetType(models.TextChoices): + THUMBNAIL = 'thumbnail', _('Thumbnail') + AUDIO = 'audio', _('Audio') + VIDEO = 'video', _('Video') + + +class AssetCodec(models.TextChoices): + JPEG = 'jpeg', _('JPEG') + OPUS = 'opus', _('Opus') + VP9 = 'vp9', _('VP9') + AV1 = 'av1', _('AV1') + + SourceResolutionInteger = SourceResolution._integer_mapping() youtube_long_source_types = YouTube_SourceType._long_type_mapping() youtube_validation_urls = YouTube_SourceType._validation_urls() From a70a85b024b9c263ccffe4a3fcee6834419c8f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=84=82=CA=8F=E1=B4=87=E1=B4=85=20=F0=9F=84=B0=CA=99?= =?UTF-8?q?=E1=B4=85=E1=B4=9C=CA=9F=20=F0=9F=84=B0=E1=B4=8D=E1=B4=80?= =?UTF-8?q?=F0=9F=84=9D=20=E2=9C=A7?= Date: Sun, 15 Mar 2026 21:27:40 +0530 Subject: [PATCH 02/25] feat: add Codec model for supported asset codecs for #1421 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 🄂ʏᴇᴅ 🄰ʙᴅᴜʟ 🄰ᴍᴀ🄝 ✧ --- tubesync/sync/models/__init__.py | 6 ++--- tubesync/sync/models/codec.py | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 tubesync/sync/models/codec.py diff --git a/tubesync/sync/models/__init__.py b/tubesync/sync/models/__init__.py index a850a4e03..bb13a6ff7 100644 --- a/tubesync/sync/models/__init__.py +++ b/tubesync/sync/models/__init__.py @@ -10,6 +10,7 @@ # The order starts with independent classes # then the classes that depend on them follow. +from .codec import Codec from .media_server import MediaServer from .source import Source @@ -19,7 +20,6 @@ __all__ = [ 'get_media_file_path', 'get_media_thumb_path', - 'media_file_storage', 'MediaServer', 'Source', + 'media_file_storage', 'Codec', 'MediaServer', 'Source', 'Media', 'Metadata', 'MetadataFormat', -] - +] \ No newline at end of file diff --git a/tubesync/sync/models/codec.py b/tubesync/sync/models/codec.py new file mode 100644 index 000000000..72b800c98 --- /dev/null +++ b/tubesync/sync/models/codec.py @@ -0,0 +1,42 @@ +import uuid +from django.db import models +from django.utils.translation import gettext_lazy as _ +from ..choices import AssetType, AssetCodec + + +class Codec(models.Model): + ''' + Codec is a supported codec for a given asset type. + ''' + + uuid = models.UUIDField( + _('uuid'), + primary_key=True, + editable=False, + default=uuid.uuid4, + help_text=_('UUID of the codec'), + ) + asset_type = models.CharField( + _('asset type'), + max_length=16, + db_index=True, + choices=AssetType.choices, + help_text=_('The type of asset this codec is used for'), + ) + codec = models.CharField( + _('codec'), + max_length=16, + db_index=True, + choices=AssetCodec.choices, + help_text=_('The codec name'), + ) + + def __str__(self): + return f'{self.asset_type} / {self.codec}' + + class Meta: + verbose_name = _('Codec') + verbose_name_plural = _('Codecs') + unique_together = ( + ('asset_type', 'codec'), + ) \ No newline at end of file From be466729088721c73e3445e423a9cc35fe154c91 Mon Sep 17 00:00:00 2001 From: tcely Date: Sun, 15 Mar 2026 12:59:15 -0400 Subject: [PATCH 03/25] Update tubesync/sync/choices.py Add `MP4A`, also known as: `M4A` --- tubesync/sync/choices.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index 94feac916..4de69389b 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -265,7 +265,8 @@ class AssetType(models.TextChoices): class AssetCodec(models.TextChoices): JPEG = 'jpeg', _('JPEG') - OPUS = 'opus', _('Opus') + OPUS = 'OPUS', _('Opus') + MP4A = 'MP4A', _('MP4A') VP9 = 'vp9', _('VP9') AV1 = 'av1', _('AV1') From 3240997867a52d4eaedb2b1a88f05b9061c7e8e2 Mon Sep 17 00:00:00 2001 From: tcely Date: Sun, 15 Mar 2026 13:02:02 -0400 Subject: [PATCH 04/25] Update tubesync/sync/choices.py Add `AVC1` from the `YouTube_VideoCodec` earlier in this file. --- tubesync/sync/choices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index 4de69389b..bcb8b18bf 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -268,7 +268,7 @@ class AssetCodec(models.TextChoices): OPUS = 'OPUS', _('Opus') MP4A = 'MP4A', _('MP4A') VP9 = 'vp9', _('VP9') - AV1 = 'av1', _('AV1') + AVC1 = 'AVC1', _('AVC1 (H.264)') SourceResolutionInteger = SourceResolution._integer_mapping() From d837e021da605dd3069cb01208c937d2b861c925 Mon Sep 17 00:00:00 2001 From: tcely Date: Sun, 15 Mar 2026 13:03:24 -0400 Subject: [PATCH 05/25] Update tubesync/sync/choices.py Add `AV1` from the `YouTube_VideoCodec` earlier in this file. --- tubesync/sync/choices.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index bcb8b18bf..f540d355f 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -267,7 +267,8 @@ class AssetCodec(models.TextChoices): JPEG = 'jpeg', _('JPEG') OPUS = 'OPUS', _('Opus') MP4A = 'MP4A', _('MP4A') - VP9 = 'vp9', _('VP9') + AV1 = 'AV1', _('AV1') + VP9 = 'VP9', _('VP9') AVC1 = 'AVC1', _('AVC1 (H.264)') From 941e355c083032f83dceb43d5923f099fc4df0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=84=82=CA=8F=E1=B4=87=E1=B4=85=20=F0=9F=84=B0=CA=99?= =?UTF-8?q?=E1=B4=85=E1=B4=9C=CA=9F=20=F0=9F=84=B0=E1=B4=8D=E1=B4=80?= =?UTF-8?q?=F0=9F=84=9D=20=E2=9C=A7?= Date: Sun, 15 Mar 2026 22:43:31 +0530 Subject: [PATCH 06/25] feat: add PNG thumbnail, SUBTITLE type and subtitle codecs to AssetType/AssetCodec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 🄂ʏᴇᴅ 🄰ʙᴅᴜʟ 🄰ᴍᴀ🄝 ✧ --- tubesync/sync/choices.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index f540d355f..a504798fa 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -261,15 +261,20 @@ class AssetType(models.TextChoices): THUMBNAIL = 'thumbnail', _('Thumbnail') AUDIO = 'audio', _('Audio') VIDEO = 'video', _('Video') + SUBTITLE = 'subtitle', _('Subtitle') class AssetCodec(models.TextChoices): JPEG = 'jpeg', _('JPEG') + PNG = 'png', _('PNG') OPUS = 'OPUS', _('Opus') MP4A = 'MP4A', _('MP4A') AV1 = 'AV1', _('AV1') VP9 = 'VP9', _('VP9') AVC1 = 'AVC1', _('AVC1 (H.264)') + VTT = 'vtt', _('WebVTT') + SRT = 'srt', _('SubRip') + ASS = 'ass', _('Advanced SubStation Alpha') SourceResolutionInteger = SourceResolution._integer_mapping() From b679f559a7c1eea02badf99f364576bc18bd6b4f Mon Sep 17 00:00:00 2001 From: tcely Date: Sun, 15 Mar 2026 13:32:52 -0400 Subject: [PATCH 07/25] Update tubesync/sync/choices.py Switch to `.jpg` and add `.webp` --- tubesync/sync/choices.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index a504798fa..1e4b2b834 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -265,7 +265,8 @@ class AssetType(models.TextChoices): class AssetCodec(models.TextChoices): - JPEG = 'jpeg', _('JPEG') + JPEG = 'jpg', _('JPEG') + WEBP = 'webp', _('WEBP') PNG = 'png', _('PNG') OPUS = 'OPUS', _('Opus') MP4A = 'MP4A', _('MP4A') From bd8b59353455e3249fee73569aa6dea051ecd00e Mon Sep 17 00:00:00 2001 From: tcely Date: Sun, 15 Mar 2026 13:33:35 -0400 Subject: [PATCH 08/25] Update tubesync/sync/choices.py Add `.ttml` --- tubesync/sync/choices.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index 1e4b2b834..279f498af 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -274,6 +274,7 @@ class AssetCodec(models.TextChoices): VP9 = 'VP9', _('VP9') AVC1 = 'AVC1', _('AVC1 (H.264)') VTT = 'vtt', _('WebVTT') + TTML = 'ttml', _('TTML') SRT = 'srt', _('SubRip') ASS = 'ass', _('Advanced SubStation Alpha') From 49ef52b0c48abcf3711a406c132568a23a47994b Mon Sep 17 00:00:00 2001 From: tcely Date: Sun, 15 Mar 2026 13:40:19 -0400 Subject: [PATCH 09/25] Update tubesync/sync/choices.py Switch to a text value aligned with the extension. --- tubesync/sync/choices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index 279f498af..38b721010 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -269,7 +269,7 @@ class AssetCodec(models.TextChoices): WEBP = 'webp', _('WEBP') PNG = 'png', _('PNG') OPUS = 'OPUS', _('Opus') - MP4A = 'MP4A', _('MP4A') + MP4A = 'M4A', _('MP4A') AV1 = 'AV1', _('AV1') VP9 = 'VP9', _('VP9') AVC1 = 'AVC1', _('AVC1 (H.264)') From 9fcda963e4a083ef7a12128ae17855240af54146 Mon Sep 17 00:00:00 2001 From: tcely Date: Tue, 14 Apr 2026 19:24:03 -0400 Subject: [PATCH 10/25] chore: unrestrict the `Codec` table --- tubesync/sync/choices.py | 15 --------------- tubesync/sync/models/__init__.py | 2 +- tubesync/sync/models/codec.py | 5 ++--- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index 38b721010..b11a0c657 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -264,21 +264,6 @@ class AssetType(models.TextChoices): SUBTITLE = 'subtitle', _('Subtitle') -class AssetCodec(models.TextChoices): - JPEG = 'jpg', _('JPEG') - WEBP = 'webp', _('WEBP') - PNG = 'png', _('PNG') - OPUS = 'OPUS', _('Opus') - MP4A = 'M4A', _('MP4A') - AV1 = 'AV1', _('AV1') - VP9 = 'VP9', _('VP9') - AVC1 = 'AVC1', _('AVC1 (H.264)') - VTT = 'vtt', _('WebVTT') - TTML = 'ttml', _('TTML') - SRT = 'srt', _('SubRip') - ASS = 'ass', _('Advanced SubStation Alpha') - - SourceResolutionInteger = SourceResolution._integer_mapping() youtube_long_source_types = YouTube_SourceType._long_type_mapping() youtube_validation_urls = YouTube_SourceType._validation_urls() diff --git a/tubesync/sync/models/__init__.py b/tubesync/sync/models/__init__.py index bb13a6ff7..69a7885ef 100644 --- a/tubesync/sync/models/__init__.py +++ b/tubesync/sync/models/__init__.py @@ -22,4 +22,4 @@ 'get_media_file_path', 'get_media_thumb_path', 'media_file_storage', 'Codec', 'MediaServer', 'Source', 'Media', 'Metadata', 'MetadataFormat', -] \ No newline at end of file +] diff --git a/tubesync/sync/models/codec.py b/tubesync/sync/models/codec.py index 72b800c98..f3d8e2397 100644 --- a/tubesync/sync/models/codec.py +++ b/tubesync/sync/models/codec.py @@ -1,7 +1,7 @@ import uuid from django.db import models from django.utils.translation import gettext_lazy as _ -from ..choices import AssetType, AssetCodec +from ..choices import AssetType class Codec(models.Model): @@ -27,7 +27,6 @@ class Codec(models.Model): _('codec'), max_length=16, db_index=True, - choices=AssetCodec.choices, help_text=_('The codec name'), ) @@ -39,4 +38,4 @@ class Meta: verbose_name_plural = _('Codecs') unique_together = ( ('asset_type', 'codec'), - ) \ No newline at end of file + ) From c6cd6f584e227ba1990a618a73127c3fb4568d2d Mon Sep 17 00:00:00 2001 From: tcely Date: Tue, 14 Apr 2026 20:48:07 -0400 Subject: [PATCH 11/25] fix: pin `huey` to version 2.6 Version 3 will require changes. --- Pipfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Pipfile b/Pipfile index 4c1df0817..2ca5b4b43 100644 --- a/Pipfile +++ b/Pipfile @@ -8,6 +8,7 @@ autopep8 = "*" [packages] django = "~=6.0.0" +huey = "~=2.6" django-huey = "*" django-sass-processor = {extras = ["management-command"], version = "*"} pillow = "*" From 5370f7024ec0b0518a296aa78d5bbf383a8bd082 Mon Sep 17 00:00:00 2001 From: tcely Date: Sat, 18 Apr 2026 06:30:54 -0400 Subject: [PATCH 12/25] Add the `description` field to `Codec` --- tubesync/sync/models/codec.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tubesync/sync/models/codec.py b/tubesync/sync/models/codec.py index f3d8e2397..a2dee57a6 100644 --- a/tubesync/sync/models/codec.py +++ b/tubesync/sync/models/codec.py @@ -27,7 +27,12 @@ class Codec(models.Model): _('codec'), max_length=16, db_index=True, - help_text=_('The codec name'), + help_text=_('The codec extension'), + ) + description = models.CharField( + _('description'), + max_length=128, + help_text=_('A text description / label for this codec'), ) def __str__(self): From 31423d85d3113ecf938fd530b75827016b39df82 Mon Sep 17 00:00:00 2001 From: tcely Date: Sat, 18 Apr 2026 06:35:23 -0400 Subject: [PATCH 13/25] Create 0038_codec.py --- tubesync/sync/migrations/0038_codec.py | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tubesync/sync/migrations/0038_codec.py diff --git a/tubesync/sync/migrations/0038_codec.py b/tubesync/sync/migrations/0038_codec.py new file mode 100644 index 000000000..557906047 --- /dev/null +++ b/tubesync/sync/migrations/0038_codec.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0.4 on 2026-04-18 01:42 + +import uuid +from django.db import migrations, models + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0037_alter_source_fallback'), + ] + + operations = [ + migrations.CreateModel( + name='Codec', + fields=[ + ('uuid', models.UUIDField( + default=uuid.uuid4, editable=False, help_text='UUID of the codec', primary_key=True, serialize=False, verbose_name='uuid'), + ), + ('asset_type', models.CharField( + choices=[ + ('thumbnail', 'Thumbnail'), + ('audio', 'Audio'), + ('video', 'Video'), + ('subtitle', 'Subtitle'), + ], + db_index=True, help_text='The type of asset this codec is used for', max_length=16, verbose_name='asset type'), + ), + ('codec', models.CharField( + db_index=True, help_text='The codec extension', max_length=16, verbose_name='codec'), + ), + ('description', models.CharField( + help_text='A text description / label for this codec', max_length=128, verbose_name='description'), + ), + ], + options={ + 'verbose_name': 'Codec', + 'verbose_name_plural': 'Codecs', + 'unique_together': {('asset_type', 'codec')}, + }, + ), + ] + From 52d9f1998935e3ad7e37a03c0fa7fd5e800ed8de Mon Sep 17 00:00:00 2001 From: tcely Date: Sat, 18 Apr 2026 10:59:06 -0400 Subject: [PATCH 14/25] chore: add subtitle codecs data to the migration --- tubesync/sync/migrations/0039_codec_data.py | 93 +++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tubesync/sync/migrations/0039_codec_data.py diff --git a/tubesync/sync/migrations/0039_codec_data.py b/tubesync/sync/migrations/0039_codec_data.py new file mode 100644 index 000000000..b09c5aa78 --- /dev/null +++ b/tubesync/sync/migrations/0039_codec_data.py @@ -0,0 +1,93 @@ +# data migration added manually by: tcely + +from uuid import UUID +from django.db import migrations + +subtitle_codecs = [ + { + 'uuid': UUID('07f0e7df-75e6-4b07-9430-5332e5207c67'), + 'codec': 'vtt', 'description': 'Web Video Text Tracks', + }, + { + 'uuid': UUID('426e8edc-daf2-48b3-b44d-a18fd7bb68d0'), + 'codec': 'ttml', 'description': 'Timed Text Markup Language', + }, + { + 'uuid': UUID('019b1fdd-a031-4491-99eb-fb3418b92ce2'), + 'codec': 'srt', 'description': 'SubRip Text', + }, + { + 'uuid': UUID('d73a36e5-372d-46d4-9f84-a9f6d19b6646'), + 'codec': 'ass', 'description': 'Advanced SubStation Alpha', + }, + { + 'uuid': UUID('bd0fb776-15e4-43a6-86dc-3c39a89a3cfe'), + 'codec': 'ssa', 'description': 'SubStation Alpha', + }, + { + 'uuid': UUID('3395bc25-2bc9-4e0d-9943-60e3d2572abb'), + 'codec': 'scc', 'description': 'Scenarist Closed Caption', + }, + { + 'uuid': UUID('4027ecfd-6c1c-43d0-b37c-add5f516b629'), + 'codec': 'sbv', 'description': 'SubViewer', + }, + { + 'uuid': UUID('383b89a9-06fc-48aa-bd77-f87cd4cee211'), + 'codec': 'json3', 'description': 'YouTube (proprietary) Timed Text Markup Language', + }, + { + 'uuid': UUID('475c7470-6af0-49f9-ad15-11ffc2eecd5e'), + 'codec': 'srv3', 'description': 'YouTube (proprietary) Timed Text Markup Language', + }, + { + 'uuid': UUID('1ccd5388-f64c-4ccf-9ec8-5474e9843c30'), + 'codec': 'srv2', 'description': 'YouTube (proprietary) Timed Text Markup Language', + }, + { + 'uuid': UUID('f15d5d2a-0a49-4146-8707-91d3bb1930bc'), + 'codec': 'srv1', 'description': 'YouTube (proprietary) Timed Text Markup Language', + } +] + +def create_codecs(query_set, arg_dict): + return query_set.bulk_create( + [ query_set.model(**d) for d in arg_dict ], + update_conflicts=True, + update_fields=['description'], + unique_fields=['asset_type', 'codec'], + ) + +def add_subtitle_codecs(manager, db_alias): + qs = manager.using(db_alias) + for d in subtitle_codecs: + d['asset_type'] = 'subtitle' + create_codecs(qs, subtitle_codecs) + +def forwards_func(apps, schema_editor): + # We get the model from the versioned app registry; + # if we directly import it, it'll be the wrong version. + Codec = apps.get_model("sync", "Codec") + db_alias = schema_editor.connection.alias + add_subtitle_codecs(Codec.objects, db_alias) + +def reverse_func(apps, schema_editor): + # Delete the rows added by `forwards_func`. + Codec = apps.get_model("sync", "Codec") + db_alias = schema_editor.connection.alias + # Delete the subtitle codecs. + Codec.objects.using(db_alias).filter( + asset_type='subtitle', + codec__in=[ d['codec'] for d in subtitle_codecs ], + ).delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0038_codec'), + ] + + operations = [ + migrations.RunPython(forwards_func, reverse_func), + ] + From a4401868fd587415d2157e6573b0a3eca7356a0e Mon Sep 17 00:00:00 2001 From: tcely Date: Sat, 18 Apr 2026 10:59:09 -0400 Subject: [PATCH 15/25] chore: add thumbnail codecs data to the migration --- tubesync/sync/migrations/0039_codec_data.py | 33 +++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tubesync/sync/migrations/0039_codec_data.py b/tubesync/sync/migrations/0039_codec_data.py index b09c5aa78..24a353abc 100644 --- a/tubesync/sync/migrations/0039_codec_data.py +++ b/tubesync/sync/migrations/0039_codec_data.py @@ -50,6 +50,21 @@ } ] +thumbnail_codecs = [ + { + 'uuid': UUID('8f0e3044-2d12-4b1c-9a05-39d29d8a493c'), + 'codec': 'jpg', 'description': 'Joint Photographic Experts Group (ISO/IEC 10918)', + }, + { + 'uuid': UUID('6c428761-aa82-4916-b571-6eb00dcdb5a6'), + 'codec': 'png', 'description': 'Portable Network Graphics', + }, + { + 'uuid': UUID('2f6771e9-9eb2-458e-9be6-f75b98b28758'), + 'codec': 'webp', 'description': 'WebP', + }, +] + def create_codecs(query_set, arg_dict): return query_set.bulk_create( [ query_set.model(**d) for d in arg_dict ], @@ -64,22 +79,36 @@ def add_subtitle_codecs(manager, db_alias): d['asset_type'] = 'subtitle' create_codecs(qs, subtitle_codecs) +def add_thumbnail_codecs(manager, db_alias): + qs = manager.using(db_alias) + for d in thumbnail_codecs: + d['asset_type'] = 'thumbnail' + create_codecs(qs, thumbnail_codecs) + def forwards_func(apps, schema_editor): # We get the model from the versioned app registry; # if we directly import it, it'll be the wrong version. Codec = apps.get_model("sync", "Codec") db_alias = schema_editor.connection.alias - add_subtitle_codecs(Codec.objects, db_alias) + manager = Codec.objects + add_subtitle_codecs(manager, db_alias) + add_thumbnail_codecs(manager, db_alias) def reverse_func(apps, schema_editor): # Delete the rows added by `forwards_func`. Codec = apps.get_model("sync", "Codec") db_alias = schema_editor.connection.alias + qs = Codec.objects.using(db_alias) # Delete the subtitle codecs. - Codec.objects.using(db_alias).filter( + qs.filter( asset_type='subtitle', codec__in=[ d['codec'] for d in subtitle_codecs ], ).delete() + # Delete the thumbnail codecs. + qs.filter( + asset_type='thumbnail', + codec__in=[ d['codec'] for d in thumbnail_codecs ], + ).delete() class Migration(migrations.Migration): From 6c031ab41f4e439691bdee945edd4fe73ec44eaa Mon Sep 17 00:00:00 2001 From: Jishanahmed AR Shaikh Date: Sun, 19 Apr 2026 12:57:07 +0530 Subject: [PATCH 16/25] feat: add Subtitle model (closes #1453) - Add Codec model and AssetType choices (from issue/1419/design-generic-models) - Add migrations 0038_codec, 0039_codec_data, 0040_subtitle - Register SubtitleAdmin in admin.py - Add tests: 6 example-based + 1 Hypothesis property test --- .gitignore | 3 + tubesync/sync/admin.py | 14 +++- tubesync/sync/migrations/0040_subtitle.py | 63 ++++++++++++++++ tubesync/sync/models/__init__.py | 4 +- tubesync/sync/models/codec.py | 1 + tubesync/sync/models/subtitle.py | 55 ++++++++++++++ tubesync/sync/tests/test_subtitle.py | 87 +++++++++++++++++++++++ 7 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 tubesync/sync/migrations/0040_subtitle.py create mode 100644 tubesync/sync/models/subtitle.py create mode 100644 tubesync/sync/tests/test_subtitle.py diff --git a/.gitignore b/.gitignore index 11b0fc5bd..9e1cd2dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,9 @@ Pipfile.lock # Ignore Jetbrains IDE files .idea/ +# Kiro IDE files (specs, configs, steering — not for the repo) +.kiro/ + # Task queue DBs /tubesync/tasks/*.db /tubesync/tasks/*.db-shm diff --git a/tubesync/sync/admin.py b/tubesync/sync/admin.py index b1e7dbaf3..96b33ae0d 100644 --- a/tubesync/sync/admin.py +++ b/tubesync/sync/admin.py @@ -1,10 +1,12 @@ from django.contrib import admin from .models import ( + Codec, Source, Media, Metadata, MetadataFormat, - MediaServer + MediaServer, + Subtitle, ) @@ -51,3 +53,13 @@ class MediaServerAdmin(admin.ModelAdmin): ordering = ('host', 'port') list_display = ('pk', 'server_type', 'host', 'port', 'use_https', 'verify_https') search_fields = ('host',) + + +@admin.register(Subtitle) +class SubtitleAdmin(admin.ModelAdmin): + + ordering = ('language', 'extension') + list_display = ('id', 'language', 'extension', 'original_language', 'machine_generated', 'codec') + list_filter = ('machine_generated',) + search_fields = ('language', 'extension', 'original_language') + diff --git a/tubesync/sync/migrations/0040_subtitle.py b/tubesync/sync/migrations/0040_subtitle.py new file mode 100644 index 000000000..d6ca1ac9b --- /dev/null +++ b/tubesync/sync/migrations/0040_subtitle.py @@ -0,0 +1,63 @@ +# Generated migration for the Subtitle model (issue #1453) + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0039_codec_data'), + ] + + operations = [ + migrations.CreateModel( + name='Subtitle', + fields=[ + ('id', models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID', + ), + ), + ('extension', models.CharField( + help_text='The file extension of the subtitle (e.g. vtt, srt)', + max_length=8, + verbose_name='extension', + ), + ), + ('language', models.CharField( + help_text='BCP-47 language tag of the subtitle track (e.g. en-US)', + max_length=16, + verbose_name='language', + ), + ), + ('original_language', models.CharField( + blank=False, + default=None, + help_text='BCP-47 language tag of the source language, or NULL if unknown', + max_length=16, + null=True, + verbose_name='original language', + ), + ), + ('machine_generated', models.BooleanField( + default=False, + help_text='Whether the subtitle was automatically generated', + verbose_name='machine generated', + ), + ), + ('codec', models.ForeignKey( + help_text='The codec used for this subtitle track', + null=True, + on_delete=models.deletion.SET_NULL, + related_name='subtitles', + to='sync.codec', + ), + ), + ], + options={ + 'verbose_name': 'Subtitle', + 'verbose_name_plural': 'Subtitles', + 'unique_together': {('language', 'extension')}, + }, + ), + ] + diff --git a/tubesync/sync/models/__init__.py b/tubesync/sync/models/__init__.py index 69a7885ef..3ecb83375 100644 --- a/tubesync/sync/models/__init__.py +++ b/tubesync/sync/models/__init__.py @@ -17,9 +17,11 @@ from .media import Media from .metadata import Metadata from .metadata_format import MetadataFormat +from .subtitle import Subtitle __all__ = [ 'get_media_file_path', 'get_media_thumb_path', 'media_file_storage', 'Codec', 'MediaServer', 'Source', - 'Media', 'Metadata', 'MetadataFormat', + 'Media', 'Metadata', 'MetadataFormat', 'Subtitle', ] + diff --git a/tubesync/sync/models/codec.py b/tubesync/sync/models/codec.py index a2dee57a6..3b5733498 100644 --- a/tubesync/sync/models/codec.py +++ b/tubesync/sync/models/codec.py @@ -44,3 +44,4 @@ class Meta: unique_together = ( ('asset_type', 'codec'), ) + diff --git a/tubesync/sync/models/subtitle.py b/tubesync/sync/models/subtitle.py new file mode 100644 index 000000000..23ce231a3 --- /dev/null +++ b/tubesync/sync/models/subtitle.py @@ -0,0 +1,55 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from .codec import Codec + + +class Subtitle(models.Model): + ''' + Subtitle is a subtitle track available for a media item. + ''' + + extension = models.CharField( + _('extension'), + max_length=8, + blank=False, + null=False, + help_text=_('The file extension of the subtitle (e.g. vtt, srt)'), + ) + language = models.CharField( + _('language'), + max_length=16, + blank=False, + null=False, + help_text=_('BCP-47 language tag of the subtitle track (e.g. en-US)'), + ) + original_language = models.CharField( + _('original language'), + max_length=16, + blank=False, + null=True, + default=None, + help_text=_('BCP-47 language tag of the source language, or NULL if unknown'), + ) + machine_generated = models.BooleanField( + _('machine generated'), + default=False, + help_text=_('Whether the subtitle was automatically generated'), + ) + codec = models.ForeignKey( + Codec, + on_delete=models.SET_NULL, + null=True, + related_name='subtitles', + help_text=_('The codec used for this subtitle track'), + ) + + class Meta: + verbose_name = _('Subtitle') + verbose_name_plural = _('Subtitles') + unique_together = ( + ('language', 'extension'), + ) + + def __str__(self): + return f'{self.language} ({self.extension})' + diff --git a/tubesync/sync/tests/test_subtitle.py b/tubesync/sync/tests/test_subtitle.py new file mode 100644 index 000000000..93dd20f95 --- /dev/null +++ b/tubesync/sync/tests/test_subtitle.py @@ -0,0 +1,87 @@ +import uuid +from django.db import IntegrityError +from django.test import TestCase +from hypothesis import given, settings +from hypothesis import strategies as st +from hypothesis.extra.django import TestCase as HypothesisTestCase +from sync.models import Codec, Subtitle + + +class SubtitleModelTestCase(TestCase): + '''Example-based tests for the Subtitle model.''' + + def _make_codec(self): + codec, _ = Codec.objects.get_or_create( + asset_type='subtitle', + codec='vtt', + defaults={ + 'uuid': uuid.uuid4(), + 'description': 'Web Video Text Tracks', + }, + ) + return codec + + def test_create_with_valid_fields(self): + # Req 8.1: create with valid fields and codec=None persists without error + subtitle = Subtitle.objects.create( + language='en-US', + extension='vtt', + original_language=None, + machine_generated=False, + codec=None, + ) + self.assertIsNotNone(subtitle.pk) + + def test_original_language_none_stores_null(self): + # Req 8.2 / 1.3: original_language=None stores NULL, not empty string + Subtitle.objects.create( + language='en-US', + extension='vtt', + original_language=None, + ) + subtitle = Subtitle.objects.get(language='en-US', extension='vtt') + self.assertIsNone(subtitle.original_language) + + def test_duplicate_language_extension_raises_integrity_error(self): + # Req 8.3 / 2.1 / 2.2: unique_together on (language, extension) + Subtitle.objects.create(language='en-US', extension='vtt') + with self.assertRaises(IntegrityError): + Subtitle.objects.create(language='en-US', extension='vtt') + + def test_str_representation(self): + # Req 8.4 / 3.1: __str__ returns '{language} ({extension})' + subtitle = Subtitle(language='en-US', extension='vtt') + self.assertEqual(str(subtitle), 'en-US (vtt)') + + def test_machine_generated_defaults_to_false(self): + # Req 1.4: machine_generated defaults to False + subtitle = Subtitle.objects.create(language='fr-FR', extension='srt') + self.assertFalse(subtitle.machine_generated) + + def test_codec_set_null_on_codec_deletion(self): + # Req 1.5: SET_NULL — deleting Codec sets subtitle.codec to None + codec = self._make_codec() + subtitle = Subtitle.objects.create( + language='es-US', + extension='vtt', + codec=codec, + ) + codec.delete() + subtitle.refresh_from_db() + self.assertIsNone(subtitle.codec) + + +class SubtitleStrPropertyTest(HypothesisTestCase): + '''Property-based test: __str__ format is universal (Property 1).''' + + @given( + language=st.text(min_size=1, max_size=16).filter(lambda s: s == s.strip()), + extension=st.text(min_size=1, max_size=8).filter(lambda s: s == s.strip()), + ) + @settings(max_examples=100) + def test_str_format_property(self, language, extension): + # Property 1: for any non-empty language and extension, + # str(Subtitle) == f'{language} ({extension})' + subtitle = Subtitle(language=language, extension=extension) + self.assertEqual(str(subtitle), f'{language} ({extension})') + From 79586723d1318eed975b8ff28be0c78a0778ac37 Mon Sep 17 00:00:00 2001 From: tcely Date: Sun, 19 Apr 2026 06:44:37 -0400 Subject: [PATCH 17/25] Add issue/1419/* branch to pull request triggers --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bb8028e52..38a72ad06 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,6 +16,7 @@ on: branches: - 'main' - 'test-*' + - 'issue/1419/*' types: - opened - reopened From acfce725c2971560c46b256d043489002e2c9e16 Mon Sep 17 00:00:00 2001 From: tcely Date: Sun, 19 Apr 2026 06:48:59 -0400 Subject: [PATCH 18/25] Add noqa comment to Codec import in admin.py --- tubesync/sync/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tubesync/sync/admin.py b/tubesync/sync/admin.py index 96b33ae0d..07f0f6a5f 100644 --- a/tubesync/sync/admin.py +++ b/tubesync/sync/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from .models import ( - Codec, + Codec, # noqa: F401 Source, Media, Metadata, From de9a21cc30856c769255ba9f998db0e447165356 Mon Sep 17 00:00:00 2001 From: tcely Date: Sun, 19 Apr 2026 06:53:57 -0400 Subject: [PATCH 19/25] Add hypothesis to dev-packages in Pipfile --- Pipfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Pipfile b/Pipfile index 5977da1a8..0e43673b2 100644 --- a/Pipfile +++ b/Pipfile @@ -5,6 +5,7 @@ verify_ssl = true [dev-packages] autopep8 = "*" +hypothesis = "*" pycodestyle = "*" [packages] From ddfc345120d5dbd890cc9ca399c59253d137d235 Mon Sep 17 00:00:00 2001 From: Jishanahmed AR Shaikh Date: Sun, 19 Apr 2026 17:13:42 +0530 Subject: [PATCH 20/25] fix: register CodecAdmin instead of silencing unused import --- tubesync/sync/admin.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tubesync/sync/admin.py b/tubesync/sync/admin.py index 07f0f6a5f..06ac8a61c 100644 --- a/tubesync/sync/admin.py +++ b/tubesync/sync/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from .models import ( - Codec, # noqa: F401 + Codec, Source, Media, Metadata, @@ -47,6 +47,16 @@ class MetadataFormatAdmin(admin.ModelAdmin): search_fields = ('uuid', 'metadata__uuid', 'metadata__media__uuid', 'key') +@admin.register(Codec) +class CodecAdmin(admin.ModelAdmin): + + ordering = ('asset_type', 'codec') + list_display = ('uuid', 'asset_type', 'codec', 'description') + list_filter = ('asset_type',) + readonly_fields = ('uuid',) + search_fields = ('codec', 'description') + + @admin.register(MediaServer) class MediaServerAdmin(admin.ModelAdmin): From 7294f8018b4784afcbaaf7d861e4baacc1d0924c Mon Sep 17 00:00:00 2001 From: Jishanahmed AR Shaikh Date: Sun, 19 Apr 2026 18:07:53 +0530 Subject: [PATCH 21/25] fix: apply tcely review suggestions - extension before language ordering and __str__ format --- tubesync/sync/admin.py | 6 +++--- tubesync/sync/models/subtitle.py | 2 +- tubesync/sync/tests/test_subtitle.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tubesync/sync/admin.py b/tubesync/sync/admin.py index 06ac8a61c..79fb84359 100644 --- a/tubesync/sync/admin.py +++ b/tubesync/sync/admin.py @@ -68,8 +68,8 @@ class MediaServerAdmin(admin.ModelAdmin): @admin.register(Subtitle) class SubtitleAdmin(admin.ModelAdmin): - ordering = ('language', 'extension') - list_display = ('id', 'language', 'extension', 'original_language', 'machine_generated', 'codec') + ordering = ('extension', 'language') + list_display = ('id', 'extension', 'original_language', 'language', 'machine_generated', 'codec') list_filter = ('machine_generated',) - search_fields = ('language', 'extension', 'original_language') + search_fields = ('extension', 'language', 'original_language') diff --git a/tubesync/sync/models/subtitle.py b/tubesync/sync/models/subtitle.py index 23ce231a3..c37453833 100644 --- a/tubesync/sync/models/subtitle.py +++ b/tubesync/sync/models/subtitle.py @@ -51,5 +51,5 @@ class Meta: ) def __str__(self): - return f'{self.language} ({self.extension})' + return f'{self.extension} / {self.language}' diff --git a/tubesync/sync/tests/test_subtitle.py b/tubesync/sync/tests/test_subtitle.py index 93dd20f95..e299b51f3 100644 --- a/tubesync/sync/tests/test_subtitle.py +++ b/tubesync/sync/tests/test_subtitle.py @@ -49,9 +49,9 @@ def test_duplicate_language_extension_raises_integrity_error(self): Subtitle.objects.create(language='en-US', extension='vtt') def test_str_representation(self): - # Req 8.4 / 3.1: __str__ returns '{language} ({extension})' + # Req 8.4 / 3.1: __str__ returns '{extension} / {language}' subtitle = Subtitle(language='en-US', extension='vtt') - self.assertEqual(str(subtitle), 'en-US (vtt)') + self.assertEqual(str(subtitle), 'vtt / en-US') def test_machine_generated_defaults_to_false(self): # Req 1.4: machine_generated defaults to False @@ -81,7 +81,7 @@ class SubtitleStrPropertyTest(HypothesisTestCase): @settings(max_examples=100) def test_str_format_property(self, language, extension): # Property 1: for any non-empty language and extension, - # str(Subtitle) == f'{language} ({extension})' + # str(Subtitle) == f'{extension} / {language}' subtitle = Subtitle(language=language, extension=extension) - self.assertEqual(str(subtitle), f'{language} ({extension})') + self.assertEqual(str(subtitle), f'{extension} / {language}') From 7a17db1fdece14afa1352337517b59bf95657514 Mon Sep 17 00:00:00 2001 From: tcely Date: Tue, 21 Apr 2026 23:37:12 -0400 Subject: [PATCH 22/25] chore(dependencies): unpin `huey` Removed huey package from dependencies. --- Pipfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Pipfile b/Pipfile index 0e43673b2..dfcfb2c23 100644 --- a/Pipfile +++ b/Pipfile @@ -10,7 +10,6 @@ pycodestyle = "*" [packages] django = "~=6.0.0" -huey = "~=2.6" django-huey = "*" django-sass-processor = {extras = ["management-command"], version = "*"} pillow = "*" From 19a0495cbc5f72ba33349dd8b12805b172fed4a7 Mon Sep 17 00:00:00 2001 From: tcely Date: Mon, 1 Jun 2026 06:36:09 -0400 Subject: [PATCH 23/25] chore: refactor asset_type choices to use AssetType enum --- tubesync/sync/migrations/0038_codec.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tubesync/sync/migrations/0038_codec.py b/tubesync/sync/migrations/0038_codec.py index 557906047..86f19aace 100644 --- a/tubesync/sync/migrations/0038_codec.py +++ b/tubesync/sync/migrations/0038_codec.py @@ -2,6 +2,7 @@ import uuid from django.db import migrations, models +from sync.choices import AssetType class Migration(migrations.Migration): @@ -17,12 +18,7 @@ class Migration(migrations.Migration): default=uuid.uuid4, editable=False, help_text='UUID of the codec', primary_key=True, serialize=False, verbose_name='uuid'), ), ('asset_type', models.CharField( - choices=[ - ('thumbnail', 'Thumbnail'), - ('audio', 'Audio'), - ('video', 'Video'), - ('subtitle', 'Subtitle'), - ], + choices=AssetType.choices, db_index=True, help_text='The type of asset this codec is used for', max_length=16, verbose_name='asset type'), ), ('codec', models.CharField( From a0f64c1f63db127ac2f919e4dba2350813526332 Mon Sep 17 00:00:00 2001 From: tcely Date: Mon, 1 Jun 2026 06:45:12 -0400 Subject: [PATCH 24/25] chore: refactor asset_type assignment to use AssetType enum --- tubesync/sync/migrations/0039_codec_data.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tubesync/sync/migrations/0039_codec_data.py b/tubesync/sync/migrations/0039_codec_data.py index 24a353abc..8c153c63f 100644 --- a/tubesync/sync/migrations/0039_codec_data.py +++ b/tubesync/sync/migrations/0039_codec_data.py @@ -2,6 +2,7 @@ from uuid import UUID from django.db import migrations +from sync.choices import AssetType subtitle_codecs = [ { @@ -76,13 +77,13 @@ def create_codecs(query_set, arg_dict): def add_subtitle_codecs(manager, db_alias): qs = manager.using(db_alias) for d in subtitle_codecs: - d['asset_type'] = 'subtitle' + d['asset_type'] = AssetType.SUBTITLE.value create_codecs(qs, subtitle_codecs) def add_thumbnail_codecs(manager, db_alias): qs = manager.using(db_alias) for d in thumbnail_codecs: - d['asset_type'] = 'thumbnail' + d['asset_type'] = AssetType.THUMBNAIL.value create_codecs(qs, thumbnail_codecs) def forwards_func(apps, schema_editor): @@ -101,12 +102,12 @@ def reverse_func(apps, schema_editor): qs = Codec.objects.using(db_alias) # Delete the subtitle codecs. qs.filter( - asset_type='subtitle', + asset_type=AssetType.SUBTITLE.value, codec__in=[ d['codec'] for d in subtitle_codecs ], ).delete() # Delete the thumbnail codecs. qs.filter( - asset_type='thumbnail', + asset_type=AssetType.THUMBNAIL.value, codec__in=[ d['codec'] for d in thumbnail_codecs ], ).delete() From 374a45c55a93c43f402b0c1de8cb554d98fd586e Mon Sep 17 00:00:00 2001 From: tcely Date: Mon, 1 Jun 2026 06:52:29 -0400 Subject: [PATCH 25/25] chore: update asset_type to use AssetType enum --- tubesync/sync/tests/test_subtitle.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tubesync/sync/tests/test_subtitle.py b/tubesync/sync/tests/test_subtitle.py index e299b51f3..6aa3566ef 100644 --- a/tubesync/sync/tests/test_subtitle.py +++ b/tubesync/sync/tests/test_subtitle.py @@ -4,6 +4,7 @@ from hypothesis import given, settings from hypothesis import strategies as st from hypothesis.extra.django import TestCase as HypothesisTestCase +from sync.choices import AssetType from sync.models import Codec, Subtitle @@ -12,7 +13,7 @@ class SubtitleModelTestCase(TestCase): def _make_codec(self): codec, _ = Codec.objects.get_or_create( - asset_type='subtitle', + asset_type=AssetType.SUBTITLE.value, codec='vtt', defaults={ 'uuid': uuid.uuid4(),