From f8b86f666ef51f6c744cdf8a03bd7433cc582e66 Mon Sep 17 00:00:00 2001 From: Laurent DEFERT Date: Wed, 28 Dec 2022 17:16:39 +0100 Subject: [PATCH] twitch support --- tubesync/sync/admin.py | 6 +- .../sync/migrations/0018_twitch_support.py | 23 ++++++ tubesync/sync/models.py | 76 +++++++++++++++++-- tubesync/sync/signals.py | 23 ++++-- tubesync/sync/tasks.py | 1 + tubesync/sync/templates/sync/sources.html | 3 + tubesync/sync/utils.py | 58 ++++++++++++++ tubesync/sync/views.py | 42 +++++++++- tubesync/sync/youtube.py | 10 +++ 9 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 tubesync/sync/migrations/0018_twitch_support.py diff --git a/tubesync/sync/admin.py b/tubesync/sync/admin.py index 1e445b7d..584da4c0 100644 --- a/tubesync/sync/admin.py +++ b/tubesync/sync/admin.py @@ -16,10 +16,14 @@ class SourceAdmin(admin.ModelAdmin): class MediaAdmin(admin.ModelAdmin): ordering = ('-created',) - list_display = ('uuid', 'key', 'source', 'can_download', 'skip', 'downloaded') + list_display = ('uuid', 'key', 'source', 'can_download', 'skip', 'is_live', 'downloaded') readonly_fields = ('uuid', 'created') search_fields = ('uuid', 'source__key', 'key') + @admin.display(boolean=True) + def is_live(self, obj): + return obj.is_live + @admin.register(MediaServer) class MediaServerAdmin(admin.ModelAdmin): diff --git a/tubesync/sync/migrations/0018_twitch_support.py b/tubesync/sync/migrations/0018_twitch_support.py new file mode 100644 index 00000000..3fe52c7b --- /dev/null +++ b/tubesync/sync/migrations/0018_twitch_support.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.18 on 2023-03-28 15:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0017_alter_source_sponsorblock_categories'), + ] + + operations = [ + migrations.AddField( + model_name='media', + name='last_crawl', + field=models.DateTimeField(blank=True, db_index=True, help_text='Date and time the metadata of the media was last crawled', null=True, verbose_name='last crawl'), + ), + migrations.AlterField( + model_name='source', + name='source_type', + field=models.CharField(choices=[('c', 'YouTube channel'), ('i', 'YouTube channel by ID'), ('p', 'YouTube playlist'), ('t', 'Twitch channel')], db_index=True, default='c', help_text='Source type', max_length=1, verbose_name='source type'), + ), + ] diff --git a/tubesync/sync/models.py b/tubesync/sync/models.py index 35fcda1f..15b7ad3a 100644 --- a/tubesync/sync/models.py +++ b/tubesync/sync/models.py @@ -14,8 +14,9 @@ from common.errors import NoFormatException from common.utils import clean_filename from .youtube import (get_media_info as get_youtube_media_info, - download_media as download_youtube_media) -from .utils import seconds_to_timestr, parse_media_format + download_media as download_youtube_media, + get_twitch_media_info) +from .utils import seconds_to_timestr, parse_media_format, parse_twitch_media_format from .matching import (get_best_combined_format, get_best_audio_format, get_best_video_format) from .mediaservers import PlexMediaServer @@ -32,12 +33,14 @@ class Source(models.Model): SOURCE_TYPE_YOUTUBE_CHANNEL = 'c' SOURCE_TYPE_YOUTUBE_CHANNEL_ID = 'i' SOURCE_TYPE_YOUTUBE_PLAYLIST = 'p' + SOURCE_TYPE_TWITCH_CHANNEL = 't' SOURCE_TYPES = (SOURCE_TYPE_YOUTUBE_CHANNEL, SOURCE_TYPE_YOUTUBE_CHANNEL_ID, - SOURCE_TYPE_YOUTUBE_PLAYLIST) + SOURCE_TYPE_YOUTUBE_PLAYLIST, SOURCE_TYPE_TWITCH_CHANNEL) SOURCE_TYPE_CHOICES = ( (SOURCE_TYPE_YOUTUBE_CHANNEL, _('YouTube channel')), (SOURCE_TYPE_YOUTUBE_CHANNEL_ID, _('YouTube channel by ID')), (SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')), + (SOURCE_TYPE_TWITCH_CHANNEL, _('Twitch channel')), ) SOURCE_RESOLUTION_360P = '360p' @@ -151,30 +154,35 @@ class Source(models.Model): SOURCE_TYPE_YOUTUBE_CHANNEL: '', SOURCE_TYPE_YOUTUBE_CHANNEL_ID: '', SOURCE_TYPE_YOUTUBE_PLAYLIST: '', + SOURCE_TYPE_TWITCH_CHANNEL: '', } # Format to use to display a URL for the source URLS = { SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}', SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}', SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}', + SOURCE_TYPE_TWITCH_CHANNEL: 'https://www.twitch.tv/{key}/videos', } # Format used to create indexable URLs INDEX_URLS = { SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}/videos', SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}/videos', SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}', + SOURCE_TYPE_TWITCH_CHANNEL: 'https://www.twitch.tv/{key}/videos?filter=all&sort=time', } # Callback functions to get a list of media from the source INDEXERS = { SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info, SOURCE_TYPE_YOUTUBE_CHANNEL_ID: get_youtube_media_info, SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info, + SOURCE_TYPE_TWITCH_CHANNEL: get_twitch_media_info, } # Field names to find the media ID used as the key when storing media KEY_FIELD = { SOURCE_TYPE_YOUTUBE_CHANNEL: 'id', SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'id', SOURCE_TYPE_YOUTUBE_PLAYLIST: 'id', + SOURCE_TYPE_TWITCH_CHANNEL: 'id', } class CapChoices(models.IntegerChoices): @@ -546,12 +554,21 @@ class Media(models.Model): Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/watch?v={key}', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/watch?v={key}', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/watch?v={key}', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'https://www.twitch.tv/videos/{key}', } # Callback functions to get a list of media from the source INDEXERS = { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: get_youtube_media_info, Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: get_youtube_media_info, Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: get_youtube_media_info, + Source.SOURCE_TYPE_TWITCH_CHANNEL: get_youtube_media_info, + } + # Callback functions to get a list of media from the source + FORMAT_PARSER = { + Source.SOURCE_TYPE_YOUTUBE_CHANNEL: parse_media_format, + Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: parse_media_format, + Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: parse_media_format, + Source.SOURCE_TYPE_TWITCH_CHANNEL: parse_twitch_media_format, } # Maps standardised names to names used in source metdata METADATA_FIELDS = { @@ -559,16 +576,19 @@ class Media(models.Model): Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'upload_date', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'upload_date', }, 'title': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'title', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'title', }, 'thumbnail': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'thumbnail', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'thumbnail', }, 'description': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description', @@ -579,46 +599,61 @@ class Media(models.Model): Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'duration', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'duration', }, 'formats': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'formats', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'formats', }, 'categories': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'categories', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'categories', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'categories', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'categories', }, 'rating': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'average_rating', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'average_rating', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'average_rating', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'average_rating', }, 'age_limit': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'age_limit', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'age_limit', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'age_limit', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'age_limit', }, 'uploader': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'uploader', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'uploader', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'uploader', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'uploader', }, 'upvotes': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'like_count', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'like_count', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'like_count', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'like_count', }, 'downvotes': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'dislike_count', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'dislike_count', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'dislike_count', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'dislike_count', }, 'playlist_title': { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_title', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_title', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_title', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'playlist_title', + }, + 'is_live': { + Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'is_live', + Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'is_live', + Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'is_live', + Source.SOURCE_TYPE_TWITCH_CHANNEL: 'is_live', }, } STATE_UNKNOWN = 'unknown' @@ -626,6 +661,7 @@ class Media(models.Model): STATE_DOWNLOADING = 'downloading' STATE_DOWNLOADED = 'downloaded' STATE_SKIPPED = 'skipped' + STATE_LIVE = 'live' STATE_DISABLED_AT_SOURCE = 'source-disabled' STATE_ERROR = 'error' STATES = (STATE_UNKNOWN, STATE_SCHEDULED, STATE_DOWNLOADING, STATE_DOWNLOADED, @@ -638,6 +674,7 @@ class Media(models.Model): STATE_SKIPPED: '', STATE_DISABLED_AT_SOURCE: '', STATE_ERROR: '', + STATE_LIVE: '', } uuid = models.UUIDField( @@ -798,6 +835,13 @@ class Media(models.Model): null=True, help_text=_('Size of the downloaded media in bytes') ) + last_crawl = models.DateTimeField( + _('last crawl'), + db_index=True, + null=True, + blank=True, + help_text=_('Date and time the metadata of the media was last crawled') + ) def __str__(self): return self.key @@ -814,8 +858,9 @@ def get_metadata_field(self, field): return fields.get(self.source.source_type, '') def iter_formats(self): + parser = self.FORMAT_PARSER.get(self.source.source_type) for fmt in self.formats: - yield parse_media_format(fmt) + yield parser(fmt) def get_best_combined_format(self): return get_best_combined_format(self) @@ -1018,13 +1063,20 @@ def url(self): @property def description(self): + if self.source.source_type == Source.SOURCE_TYPE_TWITCH_CHANNEL: + chapters = self.loaded_metadata.get('chapters', []) + descriptions = [chapter['title'] for chapter in chapters] + return ' - '.join(descriptions) + field = self.get_metadata_field('description') - return self.loaded_metadata.get(field, '').strip() + description = self.loaded_metadata.get(field) or '' + return description.strip() @property def title(self): field = self.get_metadata_field('title') - return self.loaded_metadata.get(field, '').strip() + title = self.loaded_metadata.get(field) or '' + return title.strip() @property def slugtitle(self): @@ -1034,7 +1086,8 @@ def slugtitle(self): @property def thumbnail(self): field = self.get_metadata_field('thumbnail') - return self.loaded_metadata.get(field, '').strip() + thumbnail = self.loaded_metadata.get(field) or '' + return thumbnail.strip() @property def name(self): @@ -1207,6 +1260,13 @@ def content_type(self): else: return 'video/mp4' + @property + def is_live(self): + field = self.get_metadata_field('is_live') + is_live = self.loaded_metadata.get(field) + is_live = True if is_live else False + return is_live + @property def nfoxml(self): ''' @@ -1318,6 +1378,8 @@ def get_download_state(self, task=None): return self.STATE_SKIPPED if not self.source.download_media: return self.STATE_DISABLED_AT_SOURCE + if self.is_live: + return self.STATE_LIVE return self.STATE_UNKNOWN def get_download_state_icon(self, task=None): diff --git a/tubesync/sync/signals.py b/tubesync/sync/signals.py index 6800a2fa..8883f92d 100644 --- a/tubesync/sync/signals.py +++ b/tubesync/sync/signals.py @@ -1,8 +1,10 @@ +from datetime import timedelta import os from django.conf import settings from django.db.models.signals import pre_save, post_save, pre_delete, post_delete from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ +from django.utils import timezone from background_task.signals import task_failed from background_task.models import Task from common.logger import log @@ -150,20 +152,28 @@ def media_post_save(sender, instance, created, **kwargs): post_save.disconnect(media_post_save, sender=Media) instance.save() post_save.connect(media_post_save, sender=Media) - # If the media is missing metadata schedule it to be downloaded - if not instance.metadata: - log.info(f'Scheduling task to download metadata for: {instance.url}') + # If the media is missing metadata schedule it to be downloaded, + # or refresh it if it was live during last run + if not instance.metadata or instance.is_live: + kwargs = {} + if instance.is_live: + kwargs['schedule'] = timedelta(hours=1) + + log.info(f'Recheduling task to download metadata for live stream in 1h: {instance.url}') + else: + log.info(f'Scheduling task to download metadata for: {instance.url}') verbose_name = _('Downloading metadata for "{}"') download_media_metadata( str(instance.pk), priority=10, verbose_name=verbose_name.format(instance.pk), - remove_existing_tasks=True + remove_existing_tasks=True, + **kwargs ) # If the media is missing a thumbnail schedule it to be downloaded if not instance.thumb_file_exists: instance.thumb = None - if not instance.thumb: + if not instance.thumb and not instance.is_live: thumbnail_url = instance.thumbnail if thumbnail_url: log.info(f'Scheduling task to download thumbnail for: {instance.name} ' @@ -177,12 +187,13 @@ def media_post_save(sender, instance, created, **kwargs): verbose_name=verbose_name.format(instance.name), remove_existing_tasks=True ) + # If the media has not yet been downloaded schedule it to be downloaded if not instance.media_file_exists: instance.downloaded = False instance.media_file = None if (not instance.downloaded and instance.can_download and not instance.skip - and instance.source.download_media): + and instance.source.download_media and not instance.is_live): delete_task_by_media('sync.tasks.download_media', (str(instance.pk),)) verbose_name = _('Downloading media for "{}"') download_media( diff --git a/tubesync/sync/tasks.py b/tubesync/sync/tasks.py index def75295..409de4ef 100644 --- a/tubesync/sync/tasks.py +++ b/tubesync/sync/tasks.py @@ -229,6 +229,7 @@ def download_media_metadata(media_id): source = media.source metadata = media.index_metadata() media.metadata = json.dumps(metadata, default=json_serial) + media.last_crawl = timezone.now() upload_date = media.upload_date # Media must have a valid upload date if upload_date: diff --git a/tubesync/sync/templates/sync/sources.html b/tubesync/sync/templates/sync/sources.html index 0c9a2ee8..3c87cf0f 100644 --- a/tubesync/sync/templates/sync/sources.html +++ b/tubesync/sync/templates/sync/sources.html @@ -19,6 +19,9 @@

Sources

Add a YouTube playlist
+
+ Add a Twitch channel +
diff --git a/tubesync/sync/utils.py b/tubesync/sync/utils.py index dcf1e2f5..eb673179 100644 --- a/tubesync/sync/utils.py +++ b/tubesync/sync/utils.py @@ -199,3 +199,61 @@ def parse_media_format(format_dict): 'is_hls': is_hls, 'is_dash': is_dash, } + + +def parse_twitch_media_format(format_dict): + ''' + This parser primarily adapts the format dict returned by youtube-dl into a + standard form used by the matchers in matching.py. If youtube-dl changes + any internals, update it here. + ''' + vcodec_full = format_dict.get('vcodec', '') + vcodec_parts = vcodec_full.split('.') + if len(vcodec_parts) > 0: + vcodec = vcodec_parts[0].strip().upper() + else: + vcodec = None + if vcodec == 'NONE': + vcodec = None + acodec_full = format_dict.get('acodec', '') + acodec_parts = acodec_full.split('.') + if len(acodec_parts) > 0: + acodec = acodec_parts[0].strip().upper() + else: + acodec = None + if acodec == 'NONE': + acodec = None + try: + fps = int(format_dict.get('fps', 0)) + except (ValueError, TypeError): + fps = 0 + if format_dict.get('format_id').endswith('p60'): + fps = 60 + height = format_dict.get('height', 0) + try: + height = int(height) + except (ValueError, TypeError): + height = 0 + width = format_dict.get('width', 0) + try: + width = int(width) + except (ValueError, TypeError): + width = 0 + + format_full = format_dict.get('format_note', '').strip().upper() + return { + 'id': format_dict.get('format_id', ''), + 'format': f'{height}P', + 'format_verbose': format_dict.get('format', ''), + 'height': height, + 'width': width, + 'vcodec': vcodec, + 'fps': fps, + 'vbr': format_dict.get('tbr', 0), + 'acodec': acodec, + 'abr': format_dict.get('abr', 0), + 'is_60fps': fps > 50, + 'is_hdr': False, + 'is_hls': True, + 'is_dash': False, + } diff --git a/tubesync/sync/views.py b/tubesync/sync/views.py index 58bb465f..7f8136c4 100644 --- a/tubesync/sync/views.py +++ b/tubesync/sync/views.py @@ -157,11 +157,13 @@ class ValidateSourceView(FormView): 'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, 'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID, 'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST, + 'twitch-channel': Source.SOURCE_TYPE_TWITCH_CHANNEL, } help_item = { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _('YouTube channel'), Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: _('YouTube channel ID'), Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _('YouTube playlist'), + Source.SOURCE_TYPE_TWITCH_CHANNEL: _('Twitch channel'), } help_texts = { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _( @@ -183,13 +185,20 @@ class ValidateSourceView(FormView): 'BiGLoNgUnIqUeId where BiGLoNgUnIqUeId is the ' 'unique ID of the playlist you want to add.' ), + Source.SOURCE_TYPE_TWITCH_CHANNEL: _( + 'Enter a Twitch channel URL into the box below. The URL will be ' + 'in the format of https://www.twitch.tv/ChannelId/videos' + ' where ChannelId is the ' + 'is the channel you want to add.' + ), } help_examples = { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/google', Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: ('https://www.youtube.com/channel/' 'UCK8sQmJBp8GCxrOtXWBpyEA'), Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('https://www.youtube.com/playlist?list=' - 'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r') + 'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r'), + Source.SOURCE_TYPE_TWITCH_CHANNEL: ('https://www.twitch.tv/twitch/videos') } validation_urls = { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: { @@ -219,11 +228,21 @@ class ValidateSourceView(FormView): 'extract_key': ('qs_args', 'list'), 'example': 'https://www.youtube.com/playlist?list=PLAYLISTID' }, + Source.SOURCE_TYPE_TWITCH_CHANNEL: { + 'scheme': 'https', + 'domains': ('www.twitch.tv',), + 'path_regex': '^\/([^\/]+)(\/videos)?$', + 'path_must_not_match': (), + 'qs_args': [], + 'extract_key': ('path_regex', 0), + 'example': 'https://www.twitch.tv/twitch/videos' + }, } prepopulate_fields = { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: ('source_type', 'key', 'name', 'directory'), Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: ('source_type', 'key'), Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('source_type', 'key'), + Source.SOURCE_TYPE_TWITCH_CHANNEL: ('source_type', 'key', 'name', 'directory'), } def __init__(self, *args, **kwargs): @@ -305,13 +324,30 @@ class EditSourceMixin: 'errors or is empty. Check the table at the end of ' 'this page for valid media name variables'), 'dir_outside_dlroot': _('You cannot specify a directory outside of the ' - 'base directory (%BASEDIR%)') + 'base directory (%BASEDIR%)'), + 'not_twitch_vcodec': _('Twitch only supports AVC1 source video codec'), + 'not_twitch_acodec': _('Twitch only supports MP4A source audio codec') } def form_valid(self, form: Form): # Perform extra validation to make sure the media_format is valid obj = form.save(commit=False) - source_type = form.cleaned_data['media_format'] + source_type = form.cleaned_data['source_type'] + source_vcodec = form.cleaned_data['source_vcodec'] + source_acodec = form.cleaned_data['source_acodec'] + + if source_type == Source.SOURCE_TYPE_TWITCH_CHANNEL and source_vcodec != Source.SOURCE_VCODEC_AVC1: + form.add_error( + 'source_vcodec', + ValidationError(self.errors['not_twitch_vcodec']) + ) + + if source_type == Source.SOURCE_TYPE_TWITCH_CHANNEL and source_acodec != Source.SOURCE_ACODEC_MP4A: + form.add_error( + 'source_acodec', + ValidationError(self.errors['not_twitch_acodec']) + ) + example_media_file = obj.get_example_media_format() if example_media_file == '': diff --git a/tubesync/sync/youtube.py b/tubesync/sync/youtube.py index 00ae9583..e0183a8d 100644 --- a/tubesync/sync/youtube.py +++ b/tubesync/sync/youtube.py @@ -64,6 +64,16 @@ def get_media_info(url): return response +def get_twitch_media_info(url): + ''' + Format Twitch id's to match the id key used in urls + ''' + response = get_media_info(url) + for entry in response['entries']: + entry['id'] = entry['id'].lstrip('v') + return response + + def download_media(url, media_format, extension, output_file, info_json, sponsor_categories="all", embed_thumbnail=False, embed_metadata=False, skip_sponsors=True):