diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py new file mode 100644 index 00000000..f9614299 --- /dev/null +++ b/tubesync/sync/choices.py @@ -0,0 +1,216 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from copy import deepcopy + + +DOMAINS = dict({ + 'youtube': frozenset({ + 'youtube.com', + 'm.youtube.com', + 'www.youtube.com', + }), +}) + + +def Val(*args): + results = list( + a.value if isinstance(a, models.enums.Choices) else a for a in args + ) + return results.pop(0) if 1 == len(results) else (*results,) + + +class CapChoices(models.IntegerChoices): + CAP_NOCAP = 0, _('No cap') + CAP_7DAYS = 604800, _('1 week (7 days)') + CAP_30DAYS = 2592000, _('1 month (30 days)') + CAP_90DAYS = 7776000, _('3 months (90 days)') + CAP_6MONTHS = 15552000, _('6 months (180 days)') + CAP_1YEAR = 31536000, _('1 year (365 days)') + CAP_2YEARs = 63072000, _('2 years (730 days)') + CAP_3YEARs = 94608000, _('3 years (1095 days)') + CAP_5YEARs = 157680000, _('5 years (1825 days)') + CAP_10YEARS = 315360000, _('10 years (3650 days)') + + +class Fallback(models.TextChoices): + FAIL = 'f', _('Fail, do not download any media') + NEXT_BEST = 'n', _('Get next best resolution or codec instead') + NEXT_BEST_HD = 'h', _('Get next best resolution but at least HD') + + +class FileExtension(models.TextChoices): + M4A = 'm4a', _('MPEG-4 Part 14 (MP4) Audio Container') + OGG = 'ogg', _('Ogg Container') + MKV = 'mkv', _('Matroska Multimedia Container') + + +class FilterSeconds(models.IntegerChoices): + MIN = True, _('Minimum Length') + MAX = False, _('Maximum Length') + + +class IndexSchedule(models.IntegerChoices): + EVERY_HOUR = 3600, _('Every hour') + EVERY_2_HOURS = 7200, _('Every 2 hours') + EVERY_3_HOURS = 10800, _('Every 3 hours') + EVERY_4_HOURS = 14400, _('Every 4 hours') + EVERY_5_HOURS = 18000, _('Every 5 hours') + EVERY_6_HOURS = 21600, _('Every 6 hours') + EVERY_12_HOURS = 43200, _('Every 12 hours') + EVERY_24_HOURS = 86400, _('Every 24 hours') + EVERY_3_DAYS = 259200, _('Every 3 days') + EVERY_7_DAYS = 604800, _('Every 7 days') + NEVER = 0, _('Never') + + +class MediaServerType(models.TextChoices): + PLEX = 'p', _('Plex') + + +class MediaState(models.TextChoices): + UNKNOWN = 'unknown' + SCHEDULED = 'scheduled' + DOWNLOADING = 'downloading' + DOWNLOADED = 'downloaded' + SKIPPED = 'skipped' + DISABLED_AT_SOURCE = 'source-disabled' + ERROR = 'error' + + +class SourceResolution(models.TextChoices): + AUDIO = 'audio', _('Audio only') + VIDEO_360P = '360p', _('360p (SD)') + VIDEO_480P = '480p', _('480p (SD)') + VIDEO_720P = '720p', _('720p (HD)') + VIDEO_1080P = '1080p', _('1080p (Full HD)') + VIDEO_1440P = '1440p', _('1440p (2K)') + VIDEO_2160P = '2160p', _('4320p (8K)') + VIDEO_4320P = '4320p', _('4320p (8K)') + + @classmethod + def _integer_mapping(cls): + int_height = lambda s: int(s[:-1], base=10) + video = list(filter(lambda s: s.endswith('0p'), cls.values)) + return dict(zip( video, map(int_height, video) )) + + +# as stolen from: +# - https://wiki.sponsor.ajay.app/w/Types +# - https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/postprocessor/sponsorblock.py +# +# The spacing is a little odd, it is for easy copy/paste selection. +# Please don't change it. +# Every possible category fits in a string < 128 characters +class SponsorBlock_Category(models.TextChoices): + SPONSOR = 'sponsor', _( 'Sponsor' ) + INTRO = 'intro', _( 'Intermission/Intro Animation' ) + OUTRO = 'outro', _( 'Endcards/Credits' ) + SELFPROMO = 'selfpromo', _( 'Unpaid/Self Promotion' ) + PREVIEW = 'preview', _( 'Preview/Recap' ) + FILLER = 'filler', _( 'Filler Tangent' ) + INTERACTION = 'interaction', _( 'Interaction Reminder' ) + MUSIC_OFFTOPIC = 'music_offtopic', _( 'Non-Music Section' ) + + +class YouTube_SourceType(models.TextChoices): + CHANNEL = 'c', _('YouTube channel') + CHANNEL_ID = 'i', _('YouTube channel by ID') + PLAYLIST = 'p', _('YouTube playlist') + + @classmethod + def _validation_urls(cls): + defaults = { + 'scheme': 'https', + 'domains': DOMAINS['youtube'], + 'qs_args': [], + } + update_and_return = lambda c, d: c.update(d) or c + return dict(zip( + cls.values, + ( + update_and_return(deepcopy(defaults), { + 'path_regex': r'^\/(c\/)?([^\/]+)(\/videos)?$', + 'path_must_not_match': ('/playlist', '/c/playlist'), + 'extract_key': ('path_regex', 1), + 'example': 'https://www.youtube.com/SOMECHANNEL', + }), + update_and_return(deepcopy(defaults), { + 'path_regex': r'^\/channel\/([^\/]+)(\/videos)?$', + 'path_must_not_match': ('/playlist', '/c/playlist'), + 'extract_key': ('path_regex', 0), + 'example': 'https://www.youtube.com/channel/CHANNELID', + }), + update_and_return(deepcopy(defaults), { + 'path_regex': r'^\/(playlist|watch)$', + 'path_must_not_match': (), + 'qs_args': ('list',), + 'extract_key': ('qs_args', 'list'), + 'example': 'https://www.youtube.com/playlist?list=PLAYLISTID', + }), + ), + )) + + @classmethod + def _long_type_mapping(cls): + return dict(zip( + ( + 'youtube-channel', + 'youtube-channel-id', + 'youtube-playlist', + ), + cls.values, + )) + + +class YouTube_AudioCodec(models.TextChoices): + OPUS = 'OPUS', _('OPUS') + MP4A = 'MP4A', _('MP4A') + + +class YouTube_VideoCodec(models.TextChoices): + AV1 = 'AV1', _('AV1') + VP9 = 'VP9', _('VP9') + AVC1 = 'AVC1', _('AVC1 (H.264)') + + +SourceResolutionInteger = SourceResolution._integer_mapping() +youtube_long_source_types = YouTube_SourceType._long_type_mapping() +youtube_validation_urls = YouTube_SourceType._validation_urls() +youtube_help = { + 'examples': dict(zip( + YouTube_SourceType.values, + ( + ('https://www.youtube.com/google'), + ('https://www.youtube.com/channel/' + 'UCK8sQmJBp8GCxrOtXWBpyEA'), + ('https://www.youtube.com/playlist?list=' + 'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r'), + ), + )), + 'texts': dict(zip( + YouTube_SourceType.values, + ( + _( + 'Enter a YouTube channel URL into the box below. A channel URL will be in ' + 'the format of https://www.youtube.com/CHANNELNAME ' + 'where CHANNELNAME is the name of the channel you want ' + 'to add.' + ), + _( + 'Enter a YouTube channel URL by channel ID into the box below. A channel ' + 'URL by channel ID will be in the format of ' + 'https://www.youtube.com/channel/BiGLoNgUnIqUeId ' + 'where BiGLoNgUnIqUeId is the ID of the channel you want ' + 'to add.' + ), + _( + 'Enter a YouTube playlist URL into the box below. A playlist URL will be ' + 'in the format of https://www.youtube.com/playlist?list=' + 'BiGLoNgUnIqUeId where BiGLoNgUnIqUeId is the ' + 'unique ID of the playlist you want to add.' + ), + ), + )), +} + diff --git a/tubesync/sync/fields.py b/tubesync/sync/fields.py index 5dfde5b4..2910b7cc 100644 --- a/tubesync/sync/fields.py +++ b/tubesync/sync/fields.py @@ -5,24 +5,6 @@ from django.db import connection, models from django.utils.translation import gettext_lazy as _ -# as stolen from: -# - https://wiki.sponsor.ajay.app/w/Types -# - https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/postprocessor/sponsorblock.py -# -# The spacing is a little odd, it is for easy copy/paste selection. -# Please don't change it. -# Every possible category fits in a string < 128 characters -class SponsorBlock_Category(models.TextChoices): - SPONSOR = 'sponsor', _( 'Sponsor' ) - INTRO = 'intro', _( 'Intermission/Intro Animation' ) - OUTRO = 'outro', _( 'Endcards/Credits' ) - SELFPROMO = 'selfpromo', _( 'Unpaid/Self Promotion' ) - PREVIEW = 'preview', _( 'Preview/Recap' ) - FILLER = 'filler', _( 'Filler Tangent' ) - INTERACTION = 'interaction', _( 'Interaction Reminder' ) - MUSIC_OFFTOPIC = 'music_offtopic', _( 'Non-Music Section' ) - - CommaSepChoice = namedtuple( 'CommaSepChoice', [ 'allow_all', diff --git a/tubesync/sync/management/commands/import-existing-media.py b/tubesync/sync/management/commands/import-existing-media.py index 9a524129..6b723e70 100644 --- a/tubesync/sync/management/commands/import-existing-media.py +++ b/tubesync/sync/management/commands/import-existing-media.py @@ -2,6 +2,7 @@ import os from pathlib import Path from django.core.management.base import BaseCommand, CommandError from common.logger import log +from sync.choices import FileExtension from sync.models import Source, Media @@ -17,7 +18,7 @@ class Command(BaseCommand): for s in Source.objects.all(): dirmap[s.directory_path] = s log.info(f'Scanning sources...') - file_extensions = list(Source.EXTENSIONS) + self.extra_extensions + file_extensions = list(FileExtension.values) + self.extra_extensions for sourceroot, source in dirmap.items(): media = list(Media.objects.filter(source=source, downloaded=False, skip=False)) diff --git a/tubesync/sync/matching.py b/tubesync/sync/matching.py index 5993a4d1..9390e6fa 100644 --- a/tubesync/sync/matching.py +++ b/tubesync/sync/matching.py @@ -5,6 +5,7 @@ ''' +from .choices import Val, Fallback from .utils import multi_key_sort from django.conf import settings @@ -389,10 +390,10 @@ def get_best_video_format(media): return True, best_match['id'] elif media.source.can_fallback: # Allow the fallback if it meets requirements - if (media.source.fallback == media.source.FALLBACK_NEXT_BEST_HD and + if (media.source.fallback == Val(Fallback.NEXT_BEST_HD) and best_match['height'] >= fallback_hd_cutoff): return False, best_match['id'] - elif media.source.fallback == media.source.FALLBACK_NEXT_BEST: + elif media.source.fallback == Val(Fallback.NEXT_BEST): return False, best_match['id'] # Nope, failed to find match return False, False diff --git a/tubesync/sync/migrations/0028_alter_source_source_resolution.py b/tubesync/sync/migrations/0028_alter_source_source_resolution.py new file mode 100644 index 00000000..d3535892 --- /dev/null +++ b/tubesync/sync/migrations/0028_alter_source_source_resolution.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.25 on 2025-02-12 18:31 + +from django.db import migrations, models + +class Migration(migrations.Migration): + dependencies = [ + ('sync', '0027_alter_source_sponsorblock_categories'), + ] + + operations = [ + migrations.AlterField( + model_name='source', + name='source_resolution', + field=models.CharField(choices=[('audio', 'Audio only'), ('360p', '360p (SD)'), ('480p', '480p (SD)'), ('720p', '720p (HD)'), ('1080p', '1080p (Full HD)'), ('1440p', '1440p (2K)'), ('2160p', '4320p (8K)'), ('4320p', '4320p (8K)')], db_index=True, default='1080p', help_text='Source resolution, desired video resolution to download', max_length=8, verbose_name='source resolution'), + ), + ] + diff --git a/tubesync/sync/models.py b/tubesync/sync/models.py index c914534a..d8a43649 100644 --- a/tubesync/sync/models.py +++ b/tubesync/sync/models.py @@ -25,9 +25,15 @@ from .utils import (seconds_to_timestr, parse_media_format, filter_response, from .matching import (get_best_combined_format, get_best_audio_format, get_best_video_format) from .mediaservers import PlexMediaServer -from .fields import CommaSepChoiceField, SponsorBlock_Category +from .fields import CommaSepChoiceField +from .choices import (Val, CapChoices, Fallback, FileExtension, + FilterSeconds, IndexSchedule, MediaServerType, + MediaState, SourceResolution, SourceResolutionInteger, + SponsorBlock_Category, YouTube_AudioCodec, + YouTube_SourceType, YouTube_VideoCodec) media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT), base_url='/media-data/') +_srctype_dict = lambda n: dict(zip( YouTube_SourceType.values, (n,) * len(YouTube_SourceType.values) )) class Source(models.Model): ''' @@ -35,87 +41,6 @@ class Source(models.Model): or a YouTube playlist. ''' - SOURCE_TYPE_YOUTUBE_CHANNEL = 'c' - SOURCE_TYPE_YOUTUBE_CHANNEL_ID = 'i' - SOURCE_TYPE_YOUTUBE_PLAYLIST = 'p' - SOURCE_TYPES = (SOURCE_TYPE_YOUTUBE_CHANNEL, SOURCE_TYPE_YOUTUBE_CHANNEL_ID, - SOURCE_TYPE_YOUTUBE_PLAYLIST) - 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_RESOLUTION_360P = '360p' - SOURCE_RESOLUTION_480P = '480p' - SOURCE_RESOLUTION_720P = '720p' - SOURCE_RESOLUTION_1080P = '1080p' - SOURCE_RESOLUTION_1440P = '1440p' - SOURCE_RESOLUTION_2160P = '2160p' - SOURCE_RESOLUTION_4320P = '4320p' - SOURCE_RESOLUTION_AUDIO = 'audio' - SOURCE_RESOLUTIONS = (SOURCE_RESOLUTION_360P, SOURCE_RESOLUTION_480P, - SOURCE_RESOLUTION_720P, SOURCE_RESOLUTION_1080P, - SOURCE_RESOLUTION_1440P, SOURCE_RESOLUTION_2160P, - SOURCE_RESOLUTION_4320P, SOURCE_RESOLUTION_AUDIO) - SOURCE_RESOLUTION_CHOICES = ( - (SOURCE_RESOLUTION_360P, _('360p (SD)')), - (SOURCE_RESOLUTION_480P, _('480p (SD)')), - (SOURCE_RESOLUTION_720P, _('720p (HD)')), - (SOURCE_RESOLUTION_1080P, _('1080p (Full HD)')), - (SOURCE_RESOLUTION_1440P, _('1440p (2K)')), - (SOURCE_RESOLUTION_2160P, _('2160p (4K)')), - (SOURCE_RESOLUTION_4320P, _('4320p (8K)')), - (SOURCE_RESOLUTION_AUDIO, _('Audio only')), - ) - RESOLUTION_MAP = { - SOURCE_RESOLUTION_360P: 360, - SOURCE_RESOLUTION_480P: 480, - SOURCE_RESOLUTION_720P: 720, - SOURCE_RESOLUTION_1080P: 1080, - SOURCE_RESOLUTION_1440P: 1440, - SOURCE_RESOLUTION_2160P: 2160, - SOURCE_RESOLUTION_4320P: 4320, - } - - SOURCE_VCODEC_AVC1 = 'AVC1' - SOURCE_VCODEC_VP9 = 'VP9' - SOURCE_VCODECS = (SOURCE_VCODEC_AVC1, SOURCE_VCODEC_VP9) - SOURCE_VCODECS_PRIORITY = (SOURCE_VCODEC_VP9, SOURCE_VCODEC_AVC1) - SOURCE_VCODEC_CHOICES = ( - (SOURCE_VCODEC_AVC1, _('AVC1 (H.264)')), - (SOURCE_VCODEC_VP9, _('VP9')), - ) - - SOURCE_ACODEC_MP4A = 'MP4A' - SOURCE_ACODEC_OPUS = 'OPUS' - SOURCE_ACODECS = (SOURCE_ACODEC_MP4A, SOURCE_ACODEC_OPUS) - SOURCE_ACODEC_PRIORITY = (SOURCE_ACODEC_OPUS, SOURCE_ACODEC_MP4A) - SOURCE_ACODEC_CHOICES = ( - (SOURCE_ACODEC_MP4A, _('MP4A')), - (SOURCE_ACODEC_OPUS, _('OPUS')), - ) - - FALLBACK_FAIL = 'f' - FALLBACK_NEXT_BEST = 'n' - FALLBACK_NEXT_BEST_HD = 'h' - FALLBACKS = (FALLBACK_FAIL, FALLBACK_NEXT_BEST, FALLBACK_NEXT_BEST_HD) - FALLBACK_CHOICES = ( - (FALLBACK_FAIL, _('Fail, do not download any media')), - (FALLBACK_NEXT_BEST, _('Get next best resolution or codec instead')), - (FALLBACK_NEXT_BEST_HD, _('Get next best resolution but at least HD')) - ) - - FILTER_SECONDS_CHOICES = ( - (True, _('Minimum Length')), - (False, _('Maximum Length')), - ) - - EXTENSION_M4A = 'm4a' - EXTENSION_OGG = 'ogg' - EXTENSION_MKV = 'mkv' - EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV) - sponsorblock_categories = CommaSepChoiceField( _(''), max_length=128, @@ -143,60 +68,33 @@ class Source(models.Model): ) # Fontawesome icons used for the source on the front end - ICONS = { - SOURCE_TYPE_YOUTUBE_CHANNEL: '', - SOURCE_TYPE_YOUTUBE_CHANNEL_ID: '', - SOURCE_TYPE_YOUTUBE_PLAYLIST: '', - } + ICONS = _srctype_dict('') + # 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}', - } + URLS = dict(zip( + YouTube_SourceType.values, + ( + 'https://www.youtube.com/c/{key}', + 'https://www.youtube.com/channel/{key}', + 'https://www.youtube.com/playlist?list={key}', + ), + )) + # Format used to create indexable URLs - INDEX_URLS = { - SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}/{type}', - SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}/{type}', - SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}', - } + INDEX_URLS = dict(zip( + YouTube_SourceType.values, + ( + 'https://www.youtube.com/c/{key}/{type}', + 'https://www.youtube.com/channel/{key}/{type}', + 'https://www.youtube.com/playlist?list={key}', + ), + )) + # 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, - } + INDEXERS = _srctype_dict(get_youtube_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', - } - - class CapChoices(models.IntegerChoices): - CAP_NOCAP = 0, _('No cap') - CAP_7DAYS = 604800, _('1 week (7 days)') - CAP_30DAYS = 2592000, _('1 month (30 days)') - CAP_90DAYS = 7776000, _('3 months (90 days)') - CAP_6MONTHS = 15552000, _('6 months (180 days)') - CAP_1YEAR = 31536000, _('1 year (365 days)') - CAP_2YEARs = 63072000, _('2 years (730 days)') - CAP_3YEARs = 94608000, _('3 years (1095 days)') - CAP_5YEARs = 157680000, _('5 years (1825 days)') - CAP_10YEARS = 315360000, _('10 years (3650 days)') - - class IndexSchedule(models.IntegerChoices): - EVERY_HOUR = 3600, _('Every hour') - EVERY_2_HOURS = 7200, _('Every 2 hours') - EVERY_3_HOURS = 10800, _('Every 3 hours') - EVERY_4_HOURS = 14400, _('Every 4 hours') - EVERY_5_HOURS = 18000, _('Every 5 hours') - EVERY_6_HOURS = 21600, _('Every 6 hours') - EVERY_12_HOURS = 43200, _('Every 12 hours') - EVERY_24_HOURS = 86400, _('Every 24 hours') - EVERY_3_DAYS = 259200, _('Every 3 days') - EVERY_7_DAYS = 604800, _('Every 7 days') - NEVER = 0, _('Never') + KEY_FIELD = _srctype_dict('id') uuid = models.UUIDField( _('uuid'), @@ -222,8 +120,8 @@ class Source(models.Model): _('source type'), max_length=1, db_index=True, - choices=SOURCE_TYPE_CHOICES, - default=SOURCE_TYPE_YOUTUBE_CHANNEL, + choices=YouTube_SourceType.choices, + default=YouTube_SourceType.CHANNEL, help_text=_('Source type') ) key = models.CharField( @@ -312,8 +210,8 @@ class Source(models.Model): ) filter_seconds_min = models.BooleanField( _('filter seconds min/max'), - choices=FILTER_SECONDS_CHOICES, - default=True, + choices=FilterSeconds.choices, + default=Val(FilterSeconds.MIN), help_text=_('When Filter Seconds is > 0, do we skip on minimum (video shorter than limit) or maximum (video ' 'greater than maximum) video duration') ) @@ -331,24 +229,24 @@ class Source(models.Model): _('source resolution'), max_length=8, db_index=True, - choices=SOURCE_RESOLUTION_CHOICES, - default=SOURCE_RESOLUTION_1080P, + choices=SourceResolution.choices, + default=SourceResolution.VIDEO_1080P, help_text=_('Source resolution, desired video resolution to download') ) source_vcodec = models.CharField( _('source video codec'), max_length=8, db_index=True, - choices=SOURCE_VCODEC_CHOICES, - default=SOURCE_VCODEC_VP9, + choices=list(reversed(YouTube_VideoCodec.choices[1:])), + default=YouTube_VideoCodec.VP9, help_text=_('Source video codec, desired video encoding format to download (ignored if "resolution" is audio only)') ) source_acodec = models.CharField( _('source audio codec'), max_length=8, db_index=True, - choices=SOURCE_ACODEC_CHOICES, - default=SOURCE_ACODEC_OPUS, + choices=list(reversed(YouTube_AudioCodec.choices)), + default=YouTube_AudioCodec.OPUS, help_text=_('Source audio codec, desired audio encoding format to download') ) prefer_60fps = models.BooleanField( @@ -365,8 +263,8 @@ class Source(models.Model): _('fallback'), max_length=1, db_index=True, - choices=FALLBACK_CHOICES, - default=FALLBACK_NEXT_BEST_HD, + choices=Fallback.choices, + default=Fallback.NEXT_BEST_HD, help_text=_('What do do when media in your source resolution and codecs is not available') ) copy_channel_images = models.BooleanField( @@ -437,7 +335,11 @@ class Source(models.Model): @property def is_audio(self): - return self.source_resolution == self.SOURCE_RESOLUTION_AUDIO + return self.source_resolution == SourceResolution.AUDIO.value + + @property + def is_playlist(self): + return self.source_type == YouTube_SourceType.PLAYLIST.value @property def is_video(self): @@ -469,14 +371,14 @@ class Source(models.Model): depending on audio codec. ''' if self.is_audio: - if self.source_acodec == self.SOURCE_ACODEC_MP4A: - return self.EXTENSION_M4A - elif self.source_acodec == self.SOURCE_ACODEC_OPUS: - return self.EXTENSION_OGG + if self.source_acodec == Val(YouTube_AudioCodec.MP4A): + return Val(FileExtension.M4A) + elif self.source_acodec == Val(YouTube_AudioCodec.OPUS): + return Val(FileExtension.OGG) else: raise ValueError('Unable to choose audio extension, uknown acodec') else: - return self.EXTENSION_MKV + return Val(FileExtension.MKV) @classmethod def create_url(obj, source_type, key): @@ -497,7 +399,7 @@ class Source(models.Model): @property def format_summary(self): - if self.source_resolution == Source.SOURCE_RESOLUTION_AUDIO: + if self.is_audio: vc = 'none' else: vc = self.source_vcodec @@ -514,7 +416,7 @@ class Source(models.Model): @property def type_directory_path(self): if settings.SOURCE_DOWNLOAD_DIRECTORY_PREFIX: - if self.source_resolution == self.SOURCE_RESOLUTION_AUDIO: + if self.is_audio: return Path(settings.DOWNLOAD_AUDIO_DIR) / self.directory else: return Path(settings.DOWNLOAD_VIDEO_DIR) / self.directory @@ -526,7 +428,7 @@ class Source(models.Model): @property def get_image_url(self): - if self.source_type == self.SOURCE_TYPE_YOUTUBE_PLAYLIST: + if self.is_playlist: raise SuspiciousOperation('This source is a playlist so it doesn\'t have thumbnail.') return get_youtube_channel_image_info(self.url) @@ -542,11 +444,11 @@ class Source(models.Model): @property def source_resolution_height(self): - return self.RESOLUTION_MAP.get(self.source_resolution, 0) + return SourceResolutionInteger.get(self.source_resolution, 0) @property def can_fallback(self): - return self.fallback != self.FALLBACK_FAIL + return self.fallback != Val(Fallback.FAIL) @property def example_media_format_dict(self): @@ -620,7 +522,7 @@ class Source(models.Model): if self.index_videos: entries += self.get_index('videos') # Playlists do something different that I have yet to figure out - if self.source_type != Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: + if not self.is_playlist: if self.index_streams: entries += self.get_index('streams') @@ -646,109 +548,42 @@ class Media(models.Model): ''' # Format to use to display a URL for the media - URLS = { - 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}', - } + URLS = _srctype_dict('https://www.youtube.com/watch?v={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, - } + INDEXERS = _srctype_dict(get_youtube_media_info) + # Maps standardised names to names used in source metdata + _same_name = lambda n, k=None: {k or n: _srctype_dict(n) } METADATA_FIELDS = { - 'upload_date': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'upload_date', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date', - }, - 'timestamp': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'timestamp', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'timestamp', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'timestamp', - }, - 'title': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'title', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title', - }, - 'thumbnail': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'thumbnail', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail', - }, - 'description': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'description', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'description', - }, - 'duration': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'duration', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration', - }, - 'formats': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'formats', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats', - }, - 'categories': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'categories', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'categories', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'categories', - }, - 'rating': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'average_rating', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'average_rating', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: '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', - }, - 'uploader': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'uploader', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'uploader', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'uploader', - }, - 'upvotes': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'like_count', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'like_count', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: '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', - }, - 'playlist_title': { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'playlist_title', - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'playlist_title', - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'playlist_title', - }, - } - STATE_UNKNOWN = 'unknown' - STATE_SCHEDULED = 'scheduled' - STATE_DOWNLOADING = 'downloading' - STATE_DOWNLOADED = 'downloaded' - STATE_SKIPPED = 'skipped' - STATE_DISABLED_AT_SOURCE = 'source-disabled' - STATE_ERROR = 'error' - STATES = (STATE_UNKNOWN, STATE_SCHEDULED, STATE_DOWNLOADING, STATE_DOWNLOADED, - STATE_SKIPPED, STATE_DISABLED_AT_SOURCE, STATE_ERROR) - STATE_ICONS = { - STATE_UNKNOWN: '', - STATE_SCHEDULED: '', - STATE_DOWNLOADING: '', - STATE_DOWNLOADED: '', - STATE_SKIPPED: '', - STATE_DISABLED_AT_SOURCE: '', - STATE_ERROR: '', + **(_same_name('upload_date')), + **(_same_name('timestamp')), + **(_same_name('title')), + **(_same_name('description')), + **(_same_name('duration')), + **(_same_name('formats')), + **(_same_name('categories')), + **(_same_name('average_rating', 'rating')), + **(_same_name('age_limit')), + **(_same_name('uploader')), + **(_same_name('like_count', 'upvotes')), + **(_same_name('dislike_count', 'downvotes')), + **(_same_name('playlist_title')), } + STATE_ICONS = dict(zip( + MediaState.values, + ( + '', + '', + '', + '', + '', + '', + '', + ) + )) + uuid = models.UUIDField( _('uuid'), primary_key=True, @@ -1028,12 +863,12 @@ class Media(models.Model): resolution = self.downloaded_format.lower() elif self.downloaded_height: resolution = f'{self.downloaded_height}p' - if self.downloaded_format != 'audio': + if self.downloaded_format != Val(SourceResolution.AUDIO): vcodec = self.downloaded_video_codec.lower() fmt.append(vcodec) acodec = self.downloaded_audio_codec.lower() fmt.append(acodec) - if self.downloaded_format != 'audio': + if self.downloaded_format != Val(SourceResolution.AUDIO): fps = str(self.downloaded_fps) fmt.append(f'{fps}fps') if self.downloaded_hdr: @@ -1357,19 +1192,19 @@ class Media(models.Model): acodec = self.downloaded_audio_codec if acodec is None: raise TypeError() # nothing here. - acodec = acodec.lower() - if acodec == "mp4a": + acodec = acodec.upper() + if acodec == Val(YouTube_AudioCodec.MP4A): return "audio/mp4" - elif acodec == "opus": + elif acodec == Val(YouTube_AudioCodec.OPUS): return "audio/opus" else: # fall-fall-back. return 'audio/ogg' - vcodec = vcodec.lower() - if vcodec == 'vp9': - return 'video/webm' - else: + vcodec = vcodec.upper() + if vcodec == Val(YouTube_VideoCodec.AVC1): return 'video/mp4' + else: + return 'video/matroska' @property def nfoxml(self): @@ -1390,7 +1225,7 @@ class Media(models.Model): nfo.append(showtitle) # season = upload date year season = nfo.makeelement('season', {}) - if self.source.source_type == Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: + if self.source.is_playlist: # If it's a playlist, set season to 1 season.text = '1' else: @@ -1487,23 +1322,23 @@ class Media(models.Model): def get_download_state(self, task=None): if self.downloaded: - return self.STATE_DOWNLOADED + return Val(MediaState.DOWNLOADED) if task: if task.locked_by_pid_running(): - return self.STATE_DOWNLOADING + return Val(MediaState.DOWNLOADING) elif task.has_error(): - return self.STATE_ERROR + return Val(MediaState.ERROR) else: - return self.STATE_SCHEDULED + return Val(MediaState.SCHEDULED) if self.skip: - return self.STATE_SKIPPED + return Val(MediaState.SKIPPED) if not self.source.download_media: - return self.STATE_DISABLED_AT_SOURCE - return self.STATE_UNKNOWN + return Val(MediaState.DISABLED_AT_SOURCE) + return Val(MediaState.UNKNOWN) def get_download_state_icon(self, task=None): state = self.get_download_state(task) - return self.STATE_ICONS.get(state, self.STATE_ICONS[self.STATE_UNKNOWN]) + return self.STATE_ICONS.get(state, self.STATE_ICONS[Val(MediaState.UNKNOWN)]) def download_media(self): format_str = self.get_format_str() @@ -1539,7 +1374,7 @@ class Media(models.Model): return response def calculate_episode_number(self): - if self.source.source_type == Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: + if self.source.is_playlist: sorted_media = Media.objects.filter(source=self.source) else: self_year = self.upload_date.year if self.upload_date else self.created.year @@ -1643,24 +1478,19 @@ class MediaServer(models.Model): A remote media server, such as a Plex server. ''' - SERVER_TYPE_PLEX = 'p' - SERVER_TYPES = (SERVER_TYPE_PLEX,) - SERVER_TYPE_CHOICES = ( - (SERVER_TYPE_PLEX, _('Plex')), - ) ICONS = { - SERVER_TYPE_PLEX: '', + Val(MediaServerType.PLEX): '', } HANDLERS = { - SERVER_TYPE_PLEX: PlexMediaServer, + Val(MediaServerType.PLEX): PlexMediaServer, } server_type = models.CharField( _('server type'), max_length=1, db_index=True, - choices=SERVER_TYPE_CHOICES, - default=SERVER_TYPE_PLEX, + choices=MediaServerType.choices, + default=MediaServerType.PLEX, help_text=_('Server type') ) host = models.CharField( diff --git a/tubesync/sync/signals.py b/tubesync/sync/signals.py index e019e188..ce847881 100644 --- a/tubesync/sync/signals.py +++ b/tubesync/sync/signals.py @@ -17,6 +17,7 @@ from .tasks import (delete_task_by_source, delete_task_by_media, index_source_ta get_media_metadata_task, get_media_download_task) from .utils import delete_file, glob_quote from .filtering import filter_media +from .choices import Val, YouTube_SourceType @receiver(pre_save, sender=Source) @@ -52,7 +53,7 @@ def source_post_save(sender, instance, created, **kwargs): priority=0, verbose_name=verbose_name.format(instance.name) ) - if instance.source_type != Source.SOURCE_TYPE_YOUTUBE_PLAYLIST and instance.copy_channel_images: + if instance.source_type != Val(YouTube_SourceType.PLAYLIST) and instance.copy_channel_images: download_source_images( str(instance.pk), priority=2, diff --git a/tubesync/sync/tests.py b/tubesync/sync/tests.py index a123a585..1c99e82b 100644 --- a/tubesync/sync/tests.py +++ b/tubesync/sync/tests.py @@ -19,6 +19,9 @@ from .models import Source, Media from .tasks import cleanup_old_media from .filtering import filter_media from .utils import filter_response +from .choices import (Val, Fallback, IndexSchedule, SourceResolution, + YouTube_AudioCodec, YouTube_VideoCodec, + YouTube_SourceType, youtube_long_source_types) class FrontEndTestCase(TestCase): @@ -33,11 +36,6 @@ class FrontEndTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_validate_source(self): - test_source_types = { - 'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, - 'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID, - 'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST, - } test_sources = { 'youtube-channel': { 'valid': ( @@ -121,7 +119,7 @@ class FrontEndTestCase(TestCase): } } c = Client() - for source_type in test_sources.keys(): + for source_type in youtube_long_source_types.keys(): response = c.get(f'/source-validate/{source_type}') self.assertEqual(response.status_code, 200) response = c.get('/source-validate/invalid') @@ -129,7 +127,7 @@ class FrontEndTestCase(TestCase): for (source_type, tests) in test_sources.items(): for test, urls in tests.items(): for url in urls: - source_type_char = test_source_types.get(source_type) + source_type_char = youtube_long_source_types.get(source_type) data = {'source_url': url, 'source_type': source_type_char} response = c.post(f'/source-validate/{source_type}', data) if test == 'valid': @@ -182,7 +180,7 @@ class FrontEndTestCase(TestCase): 'media_format': settings.MEDIA_FORMATSTR_DEFAULT, 'download_cap': 0, 'filter_text': '.*', - 'filter_seconds_min': True, + 'filter_seconds_min': int(True), 'index_schedule': 3600, 'delete_old_media': False, 'days_to_keep': 14, @@ -231,23 +229,23 @@ class FrontEndTestCase(TestCase): expected_categories) # Update the source key data = { - 'source_type': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, + 'source_type': Val(YouTube_SourceType.CHANNEL), 'key': 'updatedkey', # changed 'name': 'testname', 'directory': 'testdirectory', 'media_format': settings.MEDIA_FORMATSTR_DEFAULT, 'download_cap': 0, 'filter_text': '.*', - 'filter_seconds_min': True, - 'index_schedule': Source.IndexSchedule.EVERY_HOUR, + 'filter_seconds_min': int(True), + 'index_schedule': Val(IndexSchedule.EVERY_HOUR), 'delete_old_media': False, 'days_to_keep': 14, - 'source_resolution': Source.SOURCE_RESOLUTION_1080P, - 'source_vcodec': Source.SOURCE_VCODEC_VP9, - 'source_acodec': Source.SOURCE_ACODEC_OPUS, + 'source_resolution': Val(SourceResolution.VIDEO_1080P), + 'source_vcodec': Val(YouTube_VideoCodec.VP9), + 'source_acodec': Val(YouTube_AudioCodec.OPUS), 'prefer_60fps': False, 'prefer_hdr': False, - 'fallback': Source.FALLBACK_FAIL, + 'fallback': Val(Fallback.FAIL), 'sponsorblock_categories': data_categories, 'sub_langs': 'en', } @@ -268,23 +266,23 @@ class FrontEndTestCase(TestCase): expected_categories) # Update the source index schedule which should recreate the scheduled task data = { - 'source_type': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, + 'source_type': Val(YouTube_SourceType.CHANNEL), 'key': 'updatedkey', 'name': 'testname', 'directory': 'testdirectory', 'media_format': settings.MEDIA_FORMATSTR_DEFAULT, 'download_cap': 0, 'filter_text': '.*', - 'filter_seconds_min': True, - 'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed + 'filter_seconds_min': int(True), + 'index_schedule': Val(IndexSchedule.EVERY_2_HOURS), # changed 'delete_old_media': False, 'days_to_keep': 14, - 'source_resolution': Source.SOURCE_RESOLUTION_1080P, - 'source_vcodec': Source.SOURCE_VCODEC_VP9, - 'source_acodec': Source.SOURCE_ACODEC_OPUS, + 'source_resolution': Val(SourceResolution.VIDEO_1080P), + 'source_vcodec': Val(YouTube_VideoCodec.VP9), + 'source_acodec': Val(YouTube_AudioCodec.OPUS), 'prefer_60fps': False, 'prefer_hdr': False, - 'fallback': Source.FALLBACK_FAIL, + 'fallback': Val(Fallback.FAIL), 'sponsorblock_categories': data_categories, 'sub_langs': 'en', } @@ -338,19 +336,19 @@ class FrontEndTestCase(TestCase): self.assertEqual(response.status_code, 200) # Add a test source test_source = Source.objects.create( - source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, + source_type=Val(YouTube_SourceType.CHANNEL), key='testkey', name='testname', directory='testdirectory', - index_schedule=Source.IndexSchedule.EVERY_HOUR, + index_schedule=Val(IndexSchedule.EVERY_HOUR), delete_old_media=False, days_to_keep=14, - source_resolution=Source.SOURCE_RESOLUTION_1080P, - source_vcodec=Source.SOURCE_VCODEC_VP9, - source_acodec=Source.SOURCE_ACODEC_OPUS, + source_resolution=Val(SourceResolution.VIDEO_1080P), + source_vcodec=Val(YouTube_VideoCodec.VP9), + source_acodec=Val(YouTube_AudioCodec.OPUS), prefer_60fps=False, prefer_hdr=False, - fallback=Source.FALLBACK_FAIL + fallback=Val(Fallback.FAIL) ) # Add some media test_minimal_metadata = ''' @@ -519,7 +517,7 @@ class FilepathTestCase(TestCase): logging.disable(logging.CRITICAL) # Add a test source self.source = Source.objects.create( - source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, + source_type=Val(YouTube_SourceType.CHANNEL), key='testkey', name='testname', directory='testdirectory', @@ -527,12 +525,12 @@ class FilepathTestCase(TestCase): index_schedule=3600, delete_old_media=False, days_to_keep=14, - source_resolution=Source.SOURCE_RESOLUTION_1080P, - source_vcodec=Source.SOURCE_VCODEC_VP9, - source_acodec=Source.SOURCE_ACODEC_OPUS, + source_resolution=Val(SourceResolution.VIDEO_1080P), + source_vcodec=Val(YouTube_VideoCodec.VP9), + source_acodec=Val(YouTube_AudioCodec.OPUS), prefer_60fps=False, prefer_hdr=False, - fallback=Source.FALLBACK_FAIL + fallback=Val(Fallback.FAIL) ) # Add some test media self.media = Media.objects.create( @@ -658,11 +656,11 @@ class FilepathTestCase(TestCase): self.assertTrue(isinstance(settings.SOURCE_DOWNLOAD_DIRECTORY_PREFIX, bool)) # Test the default behavior for "True", forced "audio" or "video" parent directories for sources settings.SOURCE_DOWNLOAD_DIRECTORY_PREFIX = True - self.source.source_resolution = Source.SOURCE_RESOLUTION_AUDIO + self.source.source_resolution = Val(SourceResolution.AUDIO) test_audio_prefix_path = Path(self.source.directory_path) self.assertEqual(test_audio_prefix_path.parts[-2], 'audio') self.assertEqual(test_audio_prefix_path.parts[-1], 'testdirectory') - self.source.source_resolution = Source.SOURCE_RESOLUTION_1080P + self.source.source_resolution = Val(SourceResolution.VIDEO_1080P) test_video_prefix_path = Path(self.source.directory_path) self.assertEqual(test_video_prefix_path.parts[-2], 'video') self.assertEqual(test_video_prefix_path.parts[-1], 'testdirectory') @@ -679,7 +677,7 @@ class MediaTestCase(TestCase): logging.disable(logging.CRITICAL) # Add a test source self.source = Source.objects.create( - source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, + source_type=Val(YouTube_SourceType.CHANNEL), key='testkey', name='testname', directory='testdirectory', @@ -687,12 +685,12 @@ class MediaTestCase(TestCase): index_schedule=3600, delete_old_media=False, days_to_keep=14, - source_resolution=Source.SOURCE_RESOLUTION_1080P, - source_vcodec=Source.SOURCE_VCODEC_VP9, - source_acodec=Source.SOURCE_ACODEC_OPUS, + source_resolution=Val(SourceResolution.VIDEO_1080P), + source_vcodec=Val(YouTube_VideoCodec.VP9), + source_acodec=Val(YouTube_AudioCodec.OPUS), prefer_60fps=False, prefer_hdr=False, - fallback=Source.FALLBACK_FAIL + fallback=Val(Fallback.FAIL) ) # Add some test media self.media = Media.objects.create( @@ -752,7 +750,7 @@ class MediaFilterTestCase(TestCase): # logging.disable(logging.CRITICAL) # Add a test source self.source = Source.objects.create( - source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, + source_type=Val(YouTube_SourceType.CHANNEL), key="testkey", name="testname", directory="testdirectory", @@ -760,12 +758,12 @@ class MediaFilterTestCase(TestCase): index_schedule=3600, delete_old_media=False, days_to_keep=14, - source_resolution=Source.SOURCE_RESOLUTION_1080P, - source_vcodec=Source.SOURCE_VCODEC_VP9, - source_acodec=Source.SOURCE_ACODEC_OPUS, + source_resolution=Val(SourceResolution.VIDEO_1080P), + source_vcodec=Val(YouTube_VideoCodec.VP9), + source_acodec=Val(YouTube_AudioCodec.OPUS), prefer_60fps=False, prefer_hdr=False, - fallback=Source.FALLBACK_FAIL, + fallback=Val(Fallback.FAIL), ) # Add some test media self.media = Media.objects.create( @@ -922,19 +920,19 @@ class FormatMatchingTestCase(TestCase): logging.disable(logging.CRITICAL) # Add a test source self.source = Source.objects.create( - source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, + source_type=Val(YouTube_SourceType.CHANNEL), key='testkey', name='testname', directory='testdirectory', index_schedule=3600, delete_old_media=False, days_to_keep=14, - source_resolution=Source.SOURCE_RESOLUTION_1080P, - source_vcodec=Source.SOURCE_VCODEC_VP9, - source_acodec=Source.SOURCE_ACODEC_OPUS, + source_resolution=Val(SourceResolution.VIDEO_1080P), + source_vcodec=Val(YouTube_VideoCodec.VP9), + source_acodec=Val(YouTube_AudioCodec.OPUS), prefer_60fps=False, prefer_hdr=False, - fallback=Source.FALLBACK_FAIL + fallback=Val(Fallback.FAIL) ) # Add some media self.media = Media.objects.create( @@ -944,7 +942,7 @@ class FormatMatchingTestCase(TestCase): ) def test_combined_exact_format_matching(self): - self.source.fallback = Source.FALLBACK_FAIL + self.source.fallback = Val(Fallback.FAIL) self.media.metadata = all_test_metadata['boring'] self.media.save() expected_matches = { @@ -1074,7 +1072,7 @@ class FormatMatchingTestCase(TestCase): self.assertEqual(match_type, expected_match_type) def test_audio_exact_format_matching(self): - self.source.fallback = Source.FALLBACK_FAIL + self.source.fallback = Val(Fallback.FAIL) self.media.metadata = all_test_metadata['boring'] self.media.save() expected_matches = { @@ -1220,7 +1218,7 @@ class FormatMatchingTestCase(TestCase): self.assertEqual(match_type, expeceted_match_type) def test_video_exact_format_matching(self): - self.source.fallback = Source.FALLBACK_FAIL + self.source.fallback = Val(Fallback.FAIL) # Test no 60fps, no HDR metadata self.media.metadata = all_test_metadata['boring'] self.media.save() @@ -1430,7 +1428,7 @@ class FormatMatchingTestCase(TestCase): self.assertEqual(match_type, expeceted_match_type) def test_video_next_best_format_matching(self): - self.source.fallback = Source.FALLBACK_NEXT_BEST + self.source.fallback = Val(Fallback.NEXT_BEST) # Test no 60fps, no HDR metadata self.media.metadata = all_test_metadata['boring'] self.media.save() @@ -1740,19 +1738,19 @@ class ResponseFilteringTestCase(TestCase): logging.disable(logging.CRITICAL) # Add a test source self.source = Source.objects.create( - source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, + source_type=Val(YouTube_SourceType.CHANNEL), key='testkey', name='testname', directory='testdirectory', index_schedule=3600, delete_old_media=False, days_to_keep=14, - source_resolution=Source.SOURCE_RESOLUTION_1080P, - source_vcodec=Source.SOURCE_VCODEC_VP9, - source_acodec=Source.SOURCE_ACODEC_OPUS, + source_resolution=Val(SourceResolution.VIDEO_1080P), + source_vcodec=Val(YouTube_VideoCodec.VP9), + source_acodec=Val(YouTube_AudioCodec.OPUS), prefer_60fps=False, prefer_hdr=False, - fallback=Source.FALLBACK_FAIL + fallback=Val(Fallback.FAIL) ) # Add some media self.media = Media.objects.create( diff --git a/tubesync/sync/views.py b/tubesync/sync/views.py index 84514262..53c46f73 100644 --- a/tubesync/sync/views.py +++ b/tubesync/sync/views.py @@ -31,6 +31,9 @@ from .utils import validate_url, delete_file from .tasks import (map_task_to_instance, get_error_message, get_source_completed_tasks, get_media_download_task, delete_task_by_media, index_source_task) +from .choices import (Val, MediaServerType, SourceResolution, + YouTube_SourceType, youtube_long_source_types, + youtube_help, youtube_validation_urls) from . import signals from . import youtube @@ -48,7 +51,7 @@ class DashboardView(TemplateView): # Sources data['num_sources'] = Source.objects.all().count() data['num_video_sources'] = Source.objects.filter( - ~Q(source_resolution=Source.SOURCE_RESOLUTION_AUDIO) + ~Q(source_resolution=Val(SourceResolution.AUDIO)) ).count() data['num_audio_sources'] = data['num_sources'] - data['num_video_sources'] data['num_failed_sources'] = Source.objects.filter(has_failed=True).count() @@ -161,82 +164,15 @@ class ValidateSourceView(FormView): 'invalid_url': _('Invalid URL, the URL must for a "{item}" must be in ' 'the format of "{example}". The error was: {error}.'), } - source_types = { - 'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, - 'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID, - 'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST, - } - 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'), - } - help_texts = { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _( - 'Enter a YouTube channel URL into the box below. A channel URL will be in ' - 'the format of https://www.youtube.com/CHANNELNAME ' - 'where CHANNELNAME is the name of the channel you want ' - 'to add.' - ), - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: _( - 'Enter a YouTube channel URL by channel ID into the box below. A channel ' - 'URL by channel ID will be in the format of ' - 'https://www.youtube.com/channel/BiGLoNgUnIqUeId ' - 'where BiGLoNgUnIqUeId is the ID of the channel you want ' - 'to add.' - ), - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _( - 'Enter a YouTube playlist URL into the box below. A playlist URL will be ' - 'in the format of https://www.youtube.com/playlist?list=' - 'BiGLoNgUnIqUeId where BiGLoNgUnIqUeId is the ' - 'unique ID of the playlist 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') - } - _youtube_domains = frozenset({ - 'youtube.com', - 'm.youtube.com', - 'www.youtube.com', - }) - validation_urls = { - Source.SOURCE_TYPE_YOUTUBE_CHANNEL: { - 'scheme': 'https', - 'domains': _youtube_domains, - 'path_regex': '^\/(c\/)?([^\/]+)(\/videos)?$', - 'path_must_not_match': ('/playlist', '/c/playlist'), - 'qs_args': [], - 'extract_key': ('path_regex', 1), - 'example': 'https://www.youtube.com/SOMECHANNEL' - }, - Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: { - 'scheme': 'https', - 'domains': _youtube_domains, - 'path_regex': '^\/channel\/([^\/]+)(\/videos)?$', - 'path_must_not_match': ('/playlist', '/c/playlist'), - 'qs_args': [], - 'extract_key': ('path_regex', 0), - 'example': 'https://www.youtube.com/channel/CHANNELID' - }, - Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: { - 'scheme': 'https', - 'domains': _youtube_domains, - 'path_regex': '^\/(playlist|watch)$', - 'path_must_not_match': (), - 'qs_args': ('list',), - 'extract_key': ('qs_args', 'list'), - 'example': 'https://www.youtube.com/playlist?list=PLAYLISTID' - }, - } + source_types = youtube_long_source_types + help_item = dict(YouTube_SourceType.choices) + help_texts = youtube_help.get('texts') + help_examples = youtube_help.get('examples') + validation_urls = youtube_validation_urls 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'), + Val(YouTube_SourceType.CHANNEL): ('source_type', 'key', 'name', 'directory'), + Val(YouTube_SourceType.CHANNEL_ID): ('source_type', 'key'), + Val(YouTube_SourceType.PLAYLIST): ('source_type', 'key'), } def __init__(self, *args, **kwargs): @@ -269,7 +205,7 @@ class ValidateSourceView(FormView): # Perform extra validation on the URL, we need to extract the channel name or # playlist ID and check they are valid source_type = form.cleaned_data['source_type'] - if source_type not in self.source_types.values(): + if source_type not in YouTube_SourceType.values: form.add_error( 'source_type', ValidationError(self.errors['invalid_source']) @@ -391,7 +327,7 @@ class AddSourceView(EditSourceMixin, CreateView): def dispatch(self, request, *args, **kwargs): source_type = request.GET.get('source_type', '') - if source_type and source_type in Source.SOURCE_TYPES: + if source_type and source_type in YouTube_SourceType.values: self.prepopulated_data['source_type'] = source_type key = request.GET.get('key', '') if key: @@ -962,13 +898,11 @@ class AddMediaServerView(FormView): template_name = 'sync/mediaserver-add.html' server_types = { - 'plex': MediaServer.SERVER_TYPE_PLEX, - } - server_type_names = { - MediaServer.SERVER_TYPE_PLEX: _('Plex'), + 'plex': Val(MediaServerType.PLEX), } + server_type_names = dict(MediaServerType.choices) forms = { - MediaServer.SERVER_TYPE_PLEX: PlexMediaServerForm, + Val(MediaServerType.PLEX): PlexMediaServerForm, } def __init__(self, *args, **kwargs): @@ -1085,7 +1019,7 @@ class UpdateMediaServerView(FormView, SingleObjectMixin): template_name = 'sync/mediaserver-update.html' model = MediaServer forms = { - MediaServer.SERVER_TYPE_PLEX: PlexMediaServerForm, + Val(MediaServerType.PLEX): PlexMediaServerForm, } def __init__(self, *args, **kwargs):