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 _
# 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',

View File

@ -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))

View File

@ -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

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,
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: '<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>',
}
ICONS = _srctype_dict('<i class="fab fa-youtube"></i>')
# 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: '<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>',
**(_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,
(
'<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'),
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: '<i class="fas fa-server"></i>',
Val(MediaServerType.PLEX): '<i class="fas fa-server"></i>',
}
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(

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)
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,

View File

@ -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(

View File

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