Merge pull request #711 from tcely/patch-17

Move things into `choices.py`
This commit is contained in:
meeb 2025-02-16 11:53:35 +11:00 committed by GitHub
commit 7d55bfdd83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 427 additions and 447 deletions

216
tubesync/sync/choices.py Normal file
View File

@ -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 <strong>https://www.youtube.com/CHANNELNAME</strong> '
'where <strong>CHANNELNAME</strong> 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 <strong>'
'https://www.youtube.com/channel/BiGLoNgUnIqUeId</strong> '
'where <strong>BiGLoNgUnIqUeId</strong> 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 <strong>https://www.youtube.com/playlist?list='
'BiGLoNgUnIqUeId</strong> where <strong>BiGLoNgUnIqUeId</strong> is the '
'unique ID of the playlist you want to add.'
),
),
)),
}

View File

@ -5,24 +5,6 @@ from django.db import connection, models
from django.utils.translation import gettext_lazy as _ 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 = namedtuple(
'CommaSepChoice', [ 'CommaSepChoice', [
'allow_all', 'allow_all',

View File

@ -2,6 +2,7 @@ import os
from pathlib import Path from pathlib import Path
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from common.logger import log from common.logger import log
from sync.choices import FileExtension
from sync.models import Source, Media from sync.models import Source, Media
@ -17,7 +18,7 @@ class Command(BaseCommand):
for s in Source.objects.all(): for s in Source.objects.all():
dirmap[s.directory_path] = s dirmap[s.directory_path] = s
log.info(f'Scanning sources...') 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(): for sourceroot, source in dirmap.items():
media = list(Media.objects.filter(source=source, downloaded=False, media = list(Media.objects.filter(source=source, downloaded=False,
skip=False)) skip=False))

View File

@ -5,6 +5,7 @@
''' '''
from .choices import Val, Fallback
from .utils import multi_key_sort from .utils import multi_key_sort
from django.conf import settings from django.conf import settings
@ -389,10 +390,10 @@ def get_best_video_format(media):
return True, best_match['id'] return True, best_match['id']
elif media.source.can_fallback: elif media.source.can_fallback:
# Allow the fallback if it meets requirements # 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): best_match['height'] >= fallback_hd_cutoff):
return False, best_match['id'] 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'] return False, best_match['id']
# Nope, failed to find match # Nope, failed to find match
return False, False return False, False

View File

@ -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'),
),
]

View File

@ -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, from .matching import (get_best_combined_format, get_best_audio_format,
get_best_video_format) get_best_video_format)
from .mediaservers import PlexMediaServer 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/') 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): class Source(models.Model):
''' '''
@ -35,87 +41,6 @@ class Source(models.Model):
or a YouTube playlist. 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( sponsorblock_categories = CommaSepChoiceField(
_(''), _(''),
max_length=128, max_length=128,
@ -143,60 +68,33 @@ class Source(models.Model):
) )
# Fontawesome icons used for the source on the front end # Fontawesome icons used for the source on the front end
ICONS = { ICONS = _srctype_dict('<i class="fab fa-youtube"></i>')
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: '<i class="fab fa-youtube"></i>',
SOURCE_TYPE_YOUTUBE_PLAYLIST: '<i class="fab fa-youtube"></i>',
}
# Format to use to display a URL for the source # Format to use to display a URL for the source
URLS = { URLS = dict(zip(
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}', YouTube_SourceType.values,
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}', (
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}', '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 # Format used to create indexable URLs
INDEX_URLS = { INDEX_URLS = dict(zip(
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}/{type}', YouTube_SourceType.values,
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}/{type}', (
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}', '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 # Callback functions to get a list of media from the source
INDEXERS = { INDEXERS = _srctype_dict(get_youtube_media_info)
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,
}
# Field names to find the media ID used as the key when storing media # Field names to find the media ID used as the key when storing media
KEY_FIELD = { KEY_FIELD = _srctype_dict('id')
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')
uuid = models.UUIDField( uuid = models.UUIDField(
_('uuid'), _('uuid'),
@ -222,8 +120,8 @@ class Source(models.Model):
_('source type'), _('source type'),
max_length=1, max_length=1,
db_index=True, db_index=True,
choices=SOURCE_TYPE_CHOICES, choices=YouTube_SourceType.choices,
default=SOURCE_TYPE_YOUTUBE_CHANNEL, default=YouTube_SourceType.CHANNEL,
help_text=_('Source type') help_text=_('Source type')
) )
key = models.CharField( key = models.CharField(
@ -312,8 +210,8 @@ class Source(models.Model):
) )
filter_seconds_min = models.BooleanField( filter_seconds_min = models.BooleanField(
_('filter seconds min/max'), _('filter seconds min/max'),
choices=FILTER_SECONDS_CHOICES, choices=FilterSeconds.choices,
default=True, default=Val(FilterSeconds.MIN),
help_text=_('When Filter Seconds is > 0, do we skip on minimum (video shorter than limit) or maximum (video ' help_text=_('When Filter Seconds is > 0, do we skip on minimum (video shorter than limit) or maximum (video '
'greater than maximum) video duration') 'greater than maximum) video duration')
) )
@ -331,24 +229,24 @@ class Source(models.Model):
_('source resolution'), _('source resolution'),
max_length=8, max_length=8,
db_index=True, db_index=True,
choices=SOURCE_RESOLUTION_CHOICES, choices=SourceResolution.choices,
default=SOURCE_RESOLUTION_1080P, default=SourceResolution.VIDEO_1080P,
help_text=_('Source resolution, desired video resolution to download') help_text=_('Source resolution, desired video resolution to download')
) )
source_vcodec = models.CharField( source_vcodec = models.CharField(
_('source video codec'), _('source video codec'),
max_length=8, max_length=8,
db_index=True, db_index=True,
choices=SOURCE_VCODEC_CHOICES, choices=list(reversed(YouTube_VideoCodec.choices[1:])),
default=SOURCE_VCODEC_VP9, default=YouTube_VideoCodec.VP9,
help_text=_('Source video codec, desired video encoding format to download (ignored if "resolution" is audio only)') help_text=_('Source video codec, desired video encoding format to download (ignored if "resolution" is audio only)')
) )
source_acodec = models.CharField( source_acodec = models.CharField(
_('source audio codec'), _('source audio codec'),
max_length=8, max_length=8,
db_index=True, db_index=True,
choices=SOURCE_ACODEC_CHOICES, choices=list(reversed(YouTube_AudioCodec.choices)),
default=SOURCE_ACODEC_OPUS, default=YouTube_AudioCodec.OPUS,
help_text=_('Source audio codec, desired audio encoding format to download') help_text=_('Source audio codec, desired audio encoding format to download')
) )
prefer_60fps = models.BooleanField( prefer_60fps = models.BooleanField(
@ -365,8 +263,8 @@ class Source(models.Model):
_('fallback'), _('fallback'),
max_length=1, max_length=1,
db_index=True, db_index=True,
choices=FALLBACK_CHOICES, choices=Fallback.choices,
default=FALLBACK_NEXT_BEST_HD, default=Fallback.NEXT_BEST_HD,
help_text=_('What do do when media in your source resolution and codecs is not available') help_text=_('What do do when media in your source resolution and codecs is not available')
) )
copy_channel_images = models.BooleanField( copy_channel_images = models.BooleanField(
@ -437,7 +335,11 @@ class Source(models.Model):
@property @property
def is_audio(self): 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 @property
def is_video(self): def is_video(self):
@ -469,14 +371,14 @@ class Source(models.Model):
depending on audio codec. depending on audio codec.
''' '''
if self.is_audio: if self.is_audio:
if self.source_acodec == self.SOURCE_ACODEC_MP4A: if self.source_acodec == Val(YouTube_AudioCodec.MP4A):
return self.EXTENSION_M4A return Val(FileExtension.M4A)
elif self.source_acodec == self.SOURCE_ACODEC_OPUS: elif self.source_acodec == Val(YouTube_AudioCodec.OPUS):
return self.EXTENSION_OGG return Val(FileExtension.OGG)
else: else:
raise ValueError('Unable to choose audio extension, uknown acodec') raise ValueError('Unable to choose audio extension, uknown acodec')
else: else:
return self.EXTENSION_MKV return Val(FileExtension.MKV)
@classmethod @classmethod
def create_url(obj, source_type, key): def create_url(obj, source_type, key):
@ -497,7 +399,7 @@ class Source(models.Model):
@property @property
def format_summary(self): def format_summary(self):
if self.source_resolution == Source.SOURCE_RESOLUTION_AUDIO: if self.is_audio:
vc = 'none' vc = 'none'
else: else:
vc = self.source_vcodec vc = self.source_vcodec
@ -514,7 +416,7 @@ class Source(models.Model):
@property @property
def type_directory_path(self): def type_directory_path(self):
if settings.SOURCE_DOWNLOAD_DIRECTORY_PREFIX: 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 return Path(settings.DOWNLOAD_AUDIO_DIR) / self.directory
else: else:
return Path(settings.DOWNLOAD_VIDEO_DIR) / self.directory return Path(settings.DOWNLOAD_VIDEO_DIR) / self.directory
@ -526,7 +428,7 @@ class Source(models.Model):
@property @property
def get_image_url(self): 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.') raise SuspiciousOperation('This source is a playlist so it doesn\'t have thumbnail.')
return get_youtube_channel_image_info(self.url) return get_youtube_channel_image_info(self.url)
@ -542,11 +444,11 @@ class Source(models.Model):
@property @property
def source_resolution_height(self): def source_resolution_height(self):
return self.RESOLUTION_MAP.get(self.source_resolution, 0) return SourceResolutionInteger.get(self.source_resolution, 0)
@property @property
def can_fallback(self): def can_fallback(self):
return self.fallback != self.FALLBACK_FAIL return self.fallback != Val(Fallback.FAIL)
@property @property
def example_media_format_dict(self): def example_media_format_dict(self):
@ -620,7 +522,7 @@ class Source(models.Model):
if self.index_videos: if self.index_videos:
entries += self.get_index('videos') entries += self.get_index('videos')
# Playlists do something different that I have yet to figure out # 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: if self.index_streams:
entries += self.get_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 # Format to use to display a URL for the media
URLS = { URLS = _srctype_dict('https://www.youtube.com/watch?v={key}')
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}',
}
# Callback functions to get a list of media from the source # Callback functions to get a list of media from the source
INDEXERS = { INDEXERS = _srctype_dict(get_youtube_media_info)
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,
}
# Maps standardised names to names used in source metdata # Maps standardised names to names used in source metdata
_same_name = lambda n, k=None: {k or n: _srctype_dict(n) }
METADATA_FIELDS = { METADATA_FIELDS = {
'upload_date': { **(_same_name('upload_date')),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date', **(_same_name('timestamp')),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'upload_date', **(_same_name('title')),
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date', **(_same_name('description')),
}, **(_same_name('duration')),
'timestamp': { **(_same_name('formats')),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'timestamp', **(_same_name('categories')),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'timestamp', **(_same_name('average_rating', 'rating')),
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'timestamp', **(_same_name('age_limit')),
}, **(_same_name('uploader')),
'title': { **(_same_name('like_count', 'upvotes')),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title', **(_same_name('dislike_count', 'downvotes')),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'title', **(_same_name('playlist_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: '<i class="far fa-question-circle" title="Unknown download state"></i>',
STATE_SCHEDULED: '<i class="far fa-clock" title="Scheduled to download"></i>',
STATE_DOWNLOADING: '<i class="fas fa-download" title="Downloading now"></i>',
STATE_DOWNLOADED: '<i class="far fa-check-circle" title="Downloaded"></i>',
STATE_SKIPPED: '<i class="fas fa-exclamation-circle" title="Skipped"></i>',
STATE_DISABLED_AT_SOURCE: '<i class="fas fa-stop-circle" title="Media downloading disabled at source"></i>',
STATE_ERROR: '<i class="fas fa-exclamation-triangle" title="Error downloading"></i>',
} }
STATE_ICONS = dict(zip(
MediaState.values,
(
'<i class="far fa-question-circle" title="Unknown download state"></i>',
'<i class="far fa-clock" title="Scheduled to download"></i>',
'<i class="fas fa-download" title="Downloading now"></i>',
'<i class="far fa-check-circle" title="Downloaded"></i>',
'<i class="fas fa-exclamation-circle" title="Skipped"></i>',
'<i class="fas fa-stop-circle" title="Media downloading disabled at source"></i>',
'<i class="fas fa-exclamation-triangle" title="Error downloading"></i>',
)
))
uuid = models.UUIDField( uuid = models.UUIDField(
_('uuid'), _('uuid'),
primary_key=True, primary_key=True,
@ -1028,12 +863,12 @@ class Media(models.Model):
resolution = self.downloaded_format.lower() resolution = self.downloaded_format.lower()
elif self.downloaded_height: elif self.downloaded_height:
resolution = f'{self.downloaded_height}p' resolution = f'{self.downloaded_height}p'
if self.downloaded_format != 'audio': if self.downloaded_format != Val(SourceResolution.AUDIO):
vcodec = self.downloaded_video_codec.lower() vcodec = self.downloaded_video_codec.lower()
fmt.append(vcodec) fmt.append(vcodec)
acodec = self.downloaded_audio_codec.lower() acodec = self.downloaded_audio_codec.lower()
fmt.append(acodec) fmt.append(acodec)
if self.downloaded_format != 'audio': if self.downloaded_format != Val(SourceResolution.AUDIO):
fps = str(self.downloaded_fps) fps = str(self.downloaded_fps)
fmt.append(f'{fps}fps') fmt.append(f'{fps}fps')
if self.downloaded_hdr: if self.downloaded_hdr:
@ -1357,19 +1192,19 @@ class Media(models.Model):
acodec = self.downloaded_audio_codec acodec = self.downloaded_audio_codec
if acodec is None: if acodec is None:
raise TypeError() # nothing here. raise TypeError() # nothing here.
acodec = acodec.lower() acodec = acodec.upper()
if acodec == "mp4a": if acodec == Val(YouTube_AudioCodec.MP4A):
return "audio/mp4" return "audio/mp4"
elif acodec == "opus": elif acodec == Val(YouTube_AudioCodec.OPUS):
return "audio/opus" return "audio/opus"
else: else:
# fall-fall-back. # fall-fall-back.
return 'audio/ogg' return 'audio/ogg'
vcodec = vcodec.lower() vcodec = vcodec.upper()
if vcodec == 'vp9': if vcodec == Val(YouTube_VideoCodec.AVC1):
return 'video/webm'
else:
return 'video/mp4' return 'video/mp4'
else:
return 'video/matroska'
@property @property
def nfoxml(self): def nfoxml(self):
@ -1390,7 +1225,7 @@ class Media(models.Model):
nfo.append(showtitle) nfo.append(showtitle)
# season = upload date year # season = upload date year
season = nfo.makeelement('season', {}) 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 # If it's a playlist, set season to 1
season.text = '1' season.text = '1'
else: else:
@ -1487,23 +1322,23 @@ class Media(models.Model):
def get_download_state(self, task=None): def get_download_state(self, task=None):
if self.downloaded: if self.downloaded:
return self.STATE_DOWNLOADED return Val(MediaState.DOWNLOADED)
if task: if task:
if task.locked_by_pid_running(): if task.locked_by_pid_running():
return self.STATE_DOWNLOADING return Val(MediaState.DOWNLOADING)
elif task.has_error(): elif task.has_error():
return self.STATE_ERROR return Val(MediaState.ERROR)
else: else:
return self.STATE_SCHEDULED return Val(MediaState.SCHEDULED)
if self.skip: if self.skip:
return self.STATE_SKIPPED return Val(MediaState.SKIPPED)
if not self.source.download_media: if not self.source.download_media:
return self.STATE_DISABLED_AT_SOURCE return Val(MediaState.DISABLED_AT_SOURCE)
return self.STATE_UNKNOWN return Val(MediaState.UNKNOWN)
def get_download_state_icon(self, task=None): def get_download_state_icon(self, task=None):
state = self.get_download_state(task) 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): def download_media(self):
format_str = self.get_format_str() format_str = self.get_format_str()
@ -1539,7 +1374,7 @@ class Media(models.Model):
return response return response
def calculate_episode_number(self): 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) sorted_media = Media.objects.filter(source=self.source)
else: else:
self_year = self.upload_date.year if self.upload_date else self.created.year 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. 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 = { ICONS = {
SERVER_TYPE_PLEX: '<i class="fas fa-server"></i>', Val(MediaServerType.PLEX): '<i class="fas fa-server"></i>',
} }
HANDLERS = { HANDLERS = {
SERVER_TYPE_PLEX: PlexMediaServer, Val(MediaServerType.PLEX): PlexMediaServer,
} }
server_type = models.CharField( server_type = models.CharField(
_('server type'), _('server type'),
max_length=1, max_length=1,
db_index=True, db_index=True,
choices=SERVER_TYPE_CHOICES, choices=MediaServerType.choices,
default=SERVER_TYPE_PLEX, default=MediaServerType.PLEX,
help_text=_('Server type') help_text=_('Server type')
) )
host = models.CharField( host = models.CharField(

View File

@ -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) get_media_metadata_task, get_media_download_task)
from .utils import delete_file, glob_quote from .utils import delete_file, glob_quote
from .filtering import filter_media from .filtering import filter_media
from .choices import Val, YouTube_SourceType
@receiver(pre_save, sender=Source) @receiver(pre_save, sender=Source)
@ -52,7 +53,7 @@ def source_post_save(sender, instance, created, **kwargs):
priority=0, priority=0,
verbose_name=verbose_name.format(instance.name) 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( download_source_images(
str(instance.pk), str(instance.pk),
priority=2, priority=2,

View File

@ -19,6 +19,9 @@ from .models import Source, Media
from .tasks import cleanup_old_media from .tasks import cleanup_old_media
from .filtering import filter_media from .filtering import filter_media
from .utils import filter_response 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): class FrontEndTestCase(TestCase):
@ -33,11 +36,6 @@ class FrontEndTestCase(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_validate_source(self): 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 = { test_sources = {
'youtube-channel': { 'youtube-channel': {
'valid': ( 'valid': (
@ -121,7 +119,7 @@ class FrontEndTestCase(TestCase):
} }
} }
c = Client() 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}') response = c.get(f'/source-validate/{source_type}')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = c.get('/source-validate/invalid') response = c.get('/source-validate/invalid')
@ -129,7 +127,7 @@ class FrontEndTestCase(TestCase):
for (source_type, tests) in test_sources.items(): for (source_type, tests) in test_sources.items():
for test, urls in tests.items(): for test, urls in tests.items():
for url in urls: 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} data = {'source_url': url, 'source_type': source_type_char}
response = c.post(f'/source-validate/{source_type}', data) response = c.post(f'/source-validate/{source_type}', data)
if test == 'valid': if test == 'valid':
@ -182,7 +180,7 @@ class FrontEndTestCase(TestCase):
'media_format': settings.MEDIA_FORMATSTR_DEFAULT, 'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
'download_cap': 0, 'download_cap': 0,
'filter_text': '.*', 'filter_text': '.*',
'filter_seconds_min': True, 'filter_seconds_min': int(True),
'index_schedule': 3600, 'index_schedule': 3600,
'delete_old_media': False, 'delete_old_media': False,
'days_to_keep': 14, 'days_to_keep': 14,
@ -231,23 +229,23 @@ class FrontEndTestCase(TestCase):
expected_categories) expected_categories)
# Update the source key # Update the source key
data = { data = {
'source_type': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, 'source_type': Val(YouTube_SourceType.CHANNEL),
'key': 'updatedkey', # changed 'key': 'updatedkey', # changed
'name': 'testname', 'name': 'testname',
'directory': 'testdirectory', 'directory': 'testdirectory',
'media_format': settings.MEDIA_FORMATSTR_DEFAULT, 'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
'download_cap': 0, 'download_cap': 0,
'filter_text': '.*', 'filter_text': '.*',
'filter_seconds_min': True, 'filter_seconds_min': int(True),
'index_schedule': Source.IndexSchedule.EVERY_HOUR, 'index_schedule': Val(IndexSchedule.EVERY_HOUR),
'delete_old_media': False, 'delete_old_media': False,
'days_to_keep': 14, 'days_to_keep': 14,
'source_resolution': Source.SOURCE_RESOLUTION_1080P, 'source_resolution': Val(SourceResolution.VIDEO_1080P),
'source_vcodec': Source.SOURCE_VCODEC_VP9, 'source_vcodec': Val(YouTube_VideoCodec.VP9),
'source_acodec': Source.SOURCE_ACODEC_OPUS, 'source_acodec': Val(YouTube_AudioCodec.OPUS),
'prefer_60fps': False, 'prefer_60fps': False,
'prefer_hdr': False, 'prefer_hdr': False,
'fallback': Source.FALLBACK_FAIL, 'fallback': Val(Fallback.FAIL),
'sponsorblock_categories': data_categories, 'sponsorblock_categories': data_categories,
'sub_langs': 'en', 'sub_langs': 'en',
} }
@ -268,23 +266,23 @@ class FrontEndTestCase(TestCase):
expected_categories) expected_categories)
# Update the source index schedule which should recreate the scheduled task # Update the source index schedule which should recreate the scheduled task
data = { data = {
'source_type': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, 'source_type': Val(YouTube_SourceType.CHANNEL),
'key': 'updatedkey', 'key': 'updatedkey',
'name': 'testname', 'name': 'testname',
'directory': 'testdirectory', 'directory': 'testdirectory',
'media_format': settings.MEDIA_FORMATSTR_DEFAULT, 'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
'download_cap': 0, 'download_cap': 0,
'filter_text': '.*', 'filter_text': '.*',
'filter_seconds_min': True, 'filter_seconds_min': int(True),
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed 'index_schedule': Val(IndexSchedule.EVERY_2_HOURS), # changed
'delete_old_media': False, 'delete_old_media': False,
'days_to_keep': 14, 'days_to_keep': 14,
'source_resolution': Source.SOURCE_RESOLUTION_1080P, 'source_resolution': Val(SourceResolution.VIDEO_1080P),
'source_vcodec': Source.SOURCE_VCODEC_VP9, 'source_vcodec': Val(YouTube_VideoCodec.VP9),
'source_acodec': Source.SOURCE_ACODEC_OPUS, 'source_acodec': Val(YouTube_AudioCodec.OPUS),
'prefer_60fps': False, 'prefer_60fps': False,
'prefer_hdr': False, 'prefer_hdr': False,
'fallback': Source.FALLBACK_FAIL, 'fallback': Val(Fallback.FAIL),
'sponsorblock_categories': data_categories, 'sponsorblock_categories': data_categories,
'sub_langs': 'en', 'sub_langs': 'en',
} }
@ -338,19 +336,19 @@ class FrontEndTestCase(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Add a test source # Add a test source
test_source = Source.objects.create( test_source = Source.objects.create(
source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, source_type=Val(YouTube_SourceType.CHANNEL),
key='testkey', key='testkey',
name='testname', name='testname',
directory='testdirectory', directory='testdirectory',
index_schedule=Source.IndexSchedule.EVERY_HOUR, index_schedule=Val(IndexSchedule.EVERY_HOUR),
delete_old_media=False, delete_old_media=False,
days_to_keep=14, days_to_keep=14,
source_resolution=Source.SOURCE_RESOLUTION_1080P, source_resolution=Val(SourceResolution.VIDEO_1080P),
source_vcodec=Source.SOURCE_VCODEC_VP9, source_vcodec=Val(YouTube_VideoCodec.VP9),
source_acodec=Source.SOURCE_ACODEC_OPUS, source_acodec=Val(YouTube_AudioCodec.OPUS),
prefer_60fps=False, prefer_60fps=False,
prefer_hdr=False, prefer_hdr=False,
fallback=Source.FALLBACK_FAIL fallback=Val(Fallback.FAIL)
) )
# Add some media # Add some media
test_minimal_metadata = ''' test_minimal_metadata = '''
@ -519,7 +517,7 @@ class FilepathTestCase(TestCase):
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Add a test source # Add a test source
self.source = Source.objects.create( self.source = Source.objects.create(
source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, source_type=Val(YouTube_SourceType.CHANNEL),
key='testkey', key='testkey',
name='testname', name='testname',
directory='testdirectory', directory='testdirectory',
@ -527,12 +525,12 @@ class FilepathTestCase(TestCase):
index_schedule=3600, index_schedule=3600,
delete_old_media=False, delete_old_media=False,
days_to_keep=14, days_to_keep=14,
source_resolution=Source.SOURCE_RESOLUTION_1080P, source_resolution=Val(SourceResolution.VIDEO_1080P),
source_vcodec=Source.SOURCE_VCODEC_VP9, source_vcodec=Val(YouTube_VideoCodec.VP9),
source_acodec=Source.SOURCE_ACODEC_OPUS, source_acodec=Val(YouTube_AudioCodec.OPUS),
prefer_60fps=False, prefer_60fps=False,
prefer_hdr=False, prefer_hdr=False,
fallback=Source.FALLBACK_FAIL fallback=Val(Fallback.FAIL)
) )
# Add some test media # Add some test media
self.media = Media.objects.create( self.media = Media.objects.create(
@ -658,11 +656,11 @@ class FilepathTestCase(TestCase):
self.assertTrue(isinstance(settings.SOURCE_DOWNLOAD_DIRECTORY_PREFIX, bool)) self.assertTrue(isinstance(settings.SOURCE_DOWNLOAD_DIRECTORY_PREFIX, bool))
# Test the default behavior for "True", forced "audio" or "video" parent directories for sources # Test the default behavior for "True", forced "audio" or "video" parent directories for sources
settings.SOURCE_DOWNLOAD_DIRECTORY_PREFIX = True 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) 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[-2], 'audio')
self.assertEqual(test_audio_prefix_path.parts[-1], 'testdirectory') 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) 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[-2], 'video')
self.assertEqual(test_video_prefix_path.parts[-1], 'testdirectory') self.assertEqual(test_video_prefix_path.parts[-1], 'testdirectory')
@ -679,7 +677,7 @@ class MediaTestCase(TestCase):
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Add a test source # Add a test source
self.source = Source.objects.create( self.source = Source.objects.create(
source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, source_type=Val(YouTube_SourceType.CHANNEL),
key='testkey', key='testkey',
name='testname', name='testname',
directory='testdirectory', directory='testdirectory',
@ -687,12 +685,12 @@ class MediaTestCase(TestCase):
index_schedule=3600, index_schedule=3600,
delete_old_media=False, delete_old_media=False,
days_to_keep=14, days_to_keep=14,
source_resolution=Source.SOURCE_RESOLUTION_1080P, source_resolution=Val(SourceResolution.VIDEO_1080P),
source_vcodec=Source.SOURCE_VCODEC_VP9, source_vcodec=Val(YouTube_VideoCodec.VP9),
source_acodec=Source.SOURCE_ACODEC_OPUS, source_acodec=Val(YouTube_AudioCodec.OPUS),
prefer_60fps=False, prefer_60fps=False,
prefer_hdr=False, prefer_hdr=False,
fallback=Source.FALLBACK_FAIL fallback=Val(Fallback.FAIL)
) )
# Add some test media # Add some test media
self.media = Media.objects.create( self.media = Media.objects.create(
@ -752,7 +750,7 @@ class MediaFilterTestCase(TestCase):
# logging.disable(logging.CRITICAL) # logging.disable(logging.CRITICAL)
# Add a test source # Add a test source
self.source = Source.objects.create( self.source = Source.objects.create(
source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, source_type=Val(YouTube_SourceType.CHANNEL),
key="testkey", key="testkey",
name="testname", name="testname",
directory="testdirectory", directory="testdirectory",
@ -760,12 +758,12 @@ class MediaFilterTestCase(TestCase):
index_schedule=3600, index_schedule=3600,
delete_old_media=False, delete_old_media=False,
days_to_keep=14, days_to_keep=14,
source_resolution=Source.SOURCE_RESOLUTION_1080P, source_resolution=Val(SourceResolution.VIDEO_1080P),
source_vcodec=Source.SOURCE_VCODEC_VP9, source_vcodec=Val(YouTube_VideoCodec.VP9),
source_acodec=Source.SOURCE_ACODEC_OPUS, source_acodec=Val(YouTube_AudioCodec.OPUS),
prefer_60fps=False, prefer_60fps=False,
prefer_hdr=False, prefer_hdr=False,
fallback=Source.FALLBACK_FAIL, fallback=Val(Fallback.FAIL),
) )
# Add some test media # Add some test media
self.media = Media.objects.create( self.media = Media.objects.create(
@ -922,19 +920,19 @@ class FormatMatchingTestCase(TestCase):
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Add a test source # Add a test source
self.source = Source.objects.create( self.source = Source.objects.create(
source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, source_type=Val(YouTube_SourceType.CHANNEL),
key='testkey', key='testkey',
name='testname', name='testname',
directory='testdirectory', directory='testdirectory',
index_schedule=3600, index_schedule=3600,
delete_old_media=False, delete_old_media=False,
days_to_keep=14, days_to_keep=14,
source_resolution=Source.SOURCE_RESOLUTION_1080P, source_resolution=Val(SourceResolution.VIDEO_1080P),
source_vcodec=Source.SOURCE_VCODEC_VP9, source_vcodec=Val(YouTube_VideoCodec.VP9),
source_acodec=Source.SOURCE_ACODEC_OPUS, source_acodec=Val(YouTube_AudioCodec.OPUS),
prefer_60fps=False, prefer_60fps=False,
prefer_hdr=False, prefer_hdr=False,
fallback=Source.FALLBACK_FAIL fallback=Val(Fallback.FAIL)
) )
# Add some media # Add some media
self.media = Media.objects.create( self.media = Media.objects.create(
@ -944,7 +942,7 @@ class FormatMatchingTestCase(TestCase):
) )
def test_combined_exact_format_matching(self): 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.metadata = all_test_metadata['boring']
self.media.save() self.media.save()
expected_matches = { expected_matches = {
@ -1074,7 +1072,7 @@ class FormatMatchingTestCase(TestCase):
self.assertEqual(match_type, expected_match_type) self.assertEqual(match_type, expected_match_type)
def test_audio_exact_format_matching(self): 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.metadata = all_test_metadata['boring']
self.media.save() self.media.save()
expected_matches = { expected_matches = {
@ -1220,7 +1218,7 @@ class FormatMatchingTestCase(TestCase):
self.assertEqual(match_type, expeceted_match_type) self.assertEqual(match_type, expeceted_match_type)
def test_video_exact_format_matching(self): 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 # Test no 60fps, no HDR metadata
self.media.metadata = all_test_metadata['boring'] self.media.metadata = all_test_metadata['boring']
self.media.save() self.media.save()
@ -1430,7 +1428,7 @@ class FormatMatchingTestCase(TestCase):
self.assertEqual(match_type, expeceted_match_type) self.assertEqual(match_type, expeceted_match_type)
def test_video_next_best_format_matching(self): 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 # Test no 60fps, no HDR metadata
self.media.metadata = all_test_metadata['boring'] self.media.metadata = all_test_metadata['boring']
self.media.save() self.media.save()
@ -1740,19 +1738,19 @@ class ResponseFilteringTestCase(TestCase):
logging.disable(logging.CRITICAL) logging.disable(logging.CRITICAL)
# Add a test source # Add a test source
self.source = Source.objects.create( self.source = Source.objects.create(
source_type=Source.SOURCE_TYPE_YOUTUBE_CHANNEL, source_type=Val(YouTube_SourceType.CHANNEL),
key='testkey', key='testkey',
name='testname', name='testname',
directory='testdirectory', directory='testdirectory',
index_schedule=3600, index_schedule=3600,
delete_old_media=False, delete_old_media=False,
days_to_keep=14, days_to_keep=14,
source_resolution=Source.SOURCE_RESOLUTION_1080P, source_resolution=Val(SourceResolution.VIDEO_1080P),
source_vcodec=Source.SOURCE_VCODEC_VP9, source_vcodec=Val(YouTube_VideoCodec.VP9),
source_acodec=Source.SOURCE_ACODEC_OPUS, source_acodec=Val(YouTube_AudioCodec.OPUS),
prefer_60fps=False, prefer_60fps=False,
prefer_hdr=False, prefer_hdr=False,
fallback=Source.FALLBACK_FAIL fallback=Val(Fallback.FAIL)
) )
# Add some media # Add some media
self.media = Media.objects.create( self.media = Media.objects.create(

View File

@ -31,6 +31,9 @@ from .utils import validate_url, delete_file
from .tasks import (map_task_to_instance, get_error_message, from .tasks import (map_task_to_instance, get_error_message,
get_source_completed_tasks, get_media_download_task, get_source_completed_tasks, get_media_download_task,
delete_task_by_media, index_source_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 signals
from . import youtube from . import youtube
@ -48,7 +51,7 @@ class DashboardView(TemplateView):
# Sources # Sources
data['num_sources'] = Source.objects.all().count() data['num_sources'] = Source.objects.all().count()
data['num_video_sources'] = Source.objects.filter( data['num_video_sources'] = Source.objects.filter(
~Q(source_resolution=Source.SOURCE_RESOLUTION_AUDIO) ~Q(source_resolution=Val(SourceResolution.AUDIO))
).count() ).count()
data['num_audio_sources'] = data['num_sources'] - data['num_video_sources'] data['num_audio_sources'] = data['num_sources'] - data['num_video_sources']
data['num_failed_sources'] = Source.objects.filter(has_failed=True).count() 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 ' 'invalid_url': _('Invalid URL, the URL must for a "{item}" must be in '
'the format of "{example}". The error was: {error}.'), 'the format of "{example}". The error was: {error}.'),
} }
source_types = { source_types = youtube_long_source_types
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, help_item = dict(YouTube_SourceType.choices)
'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID, help_texts = youtube_help.get('texts')
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST, help_examples = youtube_help.get('examples')
} validation_urls = youtube_validation_urls
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 <strong>https://www.youtube.com/CHANNELNAME</strong> '
'where <strong>CHANNELNAME</strong> 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 <strong>'
'https://www.youtube.com/channel/BiGLoNgUnIqUeId</strong> '
'where <strong>BiGLoNgUnIqUeId</strong> 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 <strong>https://www.youtube.com/playlist?list='
'BiGLoNgUnIqUeId</strong> where <strong>BiGLoNgUnIqUeId</strong> 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'
},
}
prepopulate_fields = { prepopulate_fields = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: ('source_type', 'key', 'name', 'directory'), Val(YouTube_SourceType.CHANNEL): ('source_type', 'key', 'name', 'directory'),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: ('source_type', 'key'), Val(YouTube_SourceType.CHANNEL_ID): ('source_type', 'key'),
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('source_type', 'key'), Val(YouTube_SourceType.PLAYLIST): ('source_type', 'key'),
} }
def __init__(self, *args, **kwargs): 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 # Perform extra validation on the URL, we need to extract the channel name or
# playlist ID and check they are valid # playlist ID and check they are valid
source_type = form.cleaned_data['source_type'] 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( form.add_error(
'source_type', 'source_type',
ValidationError(self.errors['invalid_source']) ValidationError(self.errors['invalid_source'])
@ -391,7 +327,7 @@ class AddSourceView(EditSourceMixin, CreateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
source_type = request.GET.get('source_type', '') 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 self.prepopulated_data['source_type'] = source_type
key = request.GET.get('key', '') key = request.GET.get('key', '')
if key: if key:
@ -962,13 +898,11 @@ class AddMediaServerView(FormView):
template_name = 'sync/mediaserver-add.html' template_name = 'sync/mediaserver-add.html'
server_types = { server_types = {
'plex': MediaServer.SERVER_TYPE_PLEX, 'plex': Val(MediaServerType.PLEX),
}
server_type_names = {
MediaServer.SERVER_TYPE_PLEX: _('Plex'),
} }
server_type_names = dict(MediaServerType.choices)
forms = { forms = {
MediaServer.SERVER_TYPE_PLEX: PlexMediaServerForm, Val(MediaServerType.PLEX): PlexMediaServerForm,
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1085,7 +1019,7 @@ class UpdateMediaServerView(FormView, SingleObjectMixin):
template_name = 'sync/mediaserver-update.html' template_name = 'sync/mediaserver-update.html'
model = MediaServer model = MediaServer
forms = { forms = {
MediaServer.SERVER_TYPE_PLEX: PlexMediaServerForm, Val(MediaServerType.PLEX): PlexMediaServerForm,
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):