add full support for YouTube channels with no vanity name, resolves #9

This commit is contained in:
meeb 2020-12-18 17:43:58 +11:00
parent 55578f4de7
commit 68a62d8a7c
4 changed files with 86 additions and 22 deletions

View File

@ -28,10 +28,13 @@ class Source(models.Model):
''' '''
SOURCE_TYPE_YOUTUBE_CHANNEL = 'c' SOURCE_TYPE_YOUTUBE_CHANNEL = 'c'
SOURCE_TYPE_YOUTUBE_CHANNEL_ID = 'i'
SOURCE_TYPE_YOUTUBE_PLAYLIST = 'p' SOURCE_TYPE_YOUTUBE_PLAYLIST = 'p'
SOURCE_TYPES = (SOURCE_TYPE_YOUTUBE_CHANNEL, SOURCE_TYPE_YOUTUBE_PLAYLIST) SOURCE_TYPES = (SOURCE_TYPE_YOUTUBE_CHANNEL, SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
SOURCE_TYPE_YOUTUBE_PLAYLIST)
SOURCE_TYPE_CHOICES = ( SOURCE_TYPE_CHOICES = (
(SOURCE_TYPE_YOUTUBE_CHANNEL, _('YouTube channel')), (SOURCE_TYPE_YOUTUBE_CHANNEL, _('YouTube channel')),
(SOURCE_TYPE_YOUTUBE_CHANNEL_ID, _('YouTube channel by ID')),
(SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')), (SOURCE_TYPE_YOUTUBE_PLAYLIST, _('YouTube playlist')),
) )
@ -98,21 +101,25 @@ 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 = {
SOURCE_TYPE_YOUTUBE_CHANNEL: '<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>', 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 = {
SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}', SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/c/{key}',
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'https://www.youtube.com/channel/{key}',
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/playlist?list={key}', SOURCE_TYPE_YOUTUBE_PLAYLIST: '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 = {
SOURCE_TYPE_YOUTUBE_CHANNEL: 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, 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 = {
SOURCE_TYPE_YOUTUBE_CHANNEL: 'id', SOURCE_TYPE_YOUTUBE_CHANNEL: 'id',
SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'id',
SOURCE_TYPE_YOUTUBE_PLAYLIST: 'id', SOURCE_TYPE_YOUTUBE_PLAYLIST: 'id',
} }
@ -433,32 +440,39 @@ 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 = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: '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}', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'https://www.youtube.com/watch?v={key}',
} }
# Maps standardised names to names used in source metdata # Maps standardised names to names used in source metdata
METADATA_FIELDS = { METADATA_FIELDS = {
'upload_date': { 'upload_date': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'upload_date',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'upload_date',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'upload_date',
}, },
'title': { 'title': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'title',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'title',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'title',
}, },
'thumbnail': { 'thumbnail': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'thumbnail',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'thumbnail',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'thumbnail',
}, },
'description': { 'description': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'description',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'description',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'description', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'description',
}, },
'duration': { 'duration': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'duration',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'duration',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'duration',
}, },
'formats': { 'formats': {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats', Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'formats',
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: 'formats',
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats', Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: 'formats',
} }
} }

View File

@ -10,10 +10,13 @@
</div> </div>
{% include 'infobox.html' with message=message %} {% include 'infobox.html' with message=message %}
<div class="row"> <div class="row">
<div class="col s12 l6 margin-bottom"> <div class="col m12 xl4 margin-bottom">
<a href="{% url 'sync:validate-source' source_type='youtube-channel' %}" class="btn">Add a YouTube channel <i class="fab fa-youtube"></i></a> <a href="{% url 'sync:validate-source' source_type='youtube-channel' %}" class="btn">Add a YouTube channel <i class="fab fa-youtube"></i></a>
</div> </div>
<div class="col s12 l6 margin-bottom"> <div class="col m12 xl4 margin-bottom">
<a href="{% url 'sync:validate-source' source_type='youtube-channel-id' %}" class="btn">Add a YouTube channel by ID <i class="fab fa-youtube"></i></a>
</div>
<div class="col m12 xl4 margin-bottom">
<a href="{% url 'sync:validate-source' source_type='youtube-playlist' %}" class="btn">Add a YouTube playlist <i class="fab fa-youtube"></i></a> <a href="{% url 'sync:validate-source' source_type='youtube-playlist' %}" class="btn">Add a YouTube playlist <i class="fab fa-youtube"></i></a>
</div> </div>
</div> </div>

View File

@ -28,6 +28,7 @@ class FrontEndTestCase(TestCase):
def test_validate_source(self): def test_validate_source(self):
test_source_types = { test_source_types = {
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, 'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST, 'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
} }
test_sources = { test_sources = {
@ -36,8 +37,6 @@ class FrontEndTestCase(TestCase):
'https://www.youtube.com/testchannel', 'https://www.youtube.com/testchannel',
'https://www.youtube.com/c/testchannel', 'https://www.youtube.com/c/testchannel',
'https://www.youtube.com/c/testchannel/videos', 'https://www.youtube.com/c/testchannel/videos',
'https://www.youtube.com/channel/testchannel',
'https://www.youtube.com/channel/testchannel/videos',
), ),
'invalid_schema': ( 'invalid_schema': (
'http://www.youtube.com/c/playlist', 'http://www.youtube.com/c/playlist',
@ -53,13 +52,37 @@ class FrontEndTestCase(TestCase):
), ),
'invalid_is_playlist': ( 'invalid_is_playlist': (
'https://www.youtube.com/c/playlist', 'https://www.youtube.com/c/playlist',
'https://www.youtube.com/c/playlist', ),
'invalid_channel_with_id': (
'https://www.youtube.com/channel/channelid',
'https://www.youtube.com/channel/channelid/videos',
),
},
'youtube-channel-id': {
'valid': (
'https://www.youtube.com/channel/channelid',
'https://www.youtube.com/channel/channelid/videos',
),
'invalid_schema': (
'http://www.youtube.com/channel/channelid',
'ftp://www.youtube.com/channel/channelid',
),
'invalid_domain': (
'https://www.test.com/channel/channelid',
'https://www.example.com/channel/channelid',
),
'invalid_path': (
'https://www.youtube.com/test/invalid',
'https://www.youtube.com/channel/test/invalid',
),
'invalid_is_named_channel': (
'https://www.youtube.com/c/testname',
), ),
}, },
'youtube-playlist': { 'youtube-playlist': {
'valid': ( 'valid': (
'https://www.youtube.com/playlist?list=testplaylist' 'https://www.youtube.com/playlist?list=testplaylist',
'https://www.youtube.com/watch?v=testvideo&list=testplaylist' 'https://www.youtube.com/watch?v=testvideo&list=testplaylist',
), ),
'invalid_schema': ( 'invalid_schema': (
'http://www.youtube.com/playlist?list=testplaylist', 'http://www.youtube.com/playlist?list=testplaylist',
@ -76,6 +99,7 @@ class FrontEndTestCase(TestCase):
'invalid_is_channel': ( 'invalid_is_channel': (
'https://www.youtube.com/testchannel', 'https://www.youtube.com/testchannel',
'https://www.youtube.com/c/testchannel', 'https://www.youtube.com/c/testchannel',
'https://www.youtube.com/channel/testchannel',
), ),
} }
} }
@ -86,19 +110,21 @@ class FrontEndTestCase(TestCase):
response = c.get('/source-validate/invalid') response = c.get('/source-validate/invalid')
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
for (source_type, tests) in test_sources.items(): for (source_type, tests) in test_sources.items():
for test, field in tests.items(): for test, urls in tests.items():
source_type_char = test_source_types.get(source_type) for url in urls:
data = {'source_url': field, 'source_type': source_type_char} source_type_char = test_source_types.get(source_type)
response = c.post(f'/source-validate/{source_type}', data) data = {'source_url': url, 'source_type': source_type_char}
if test == 'valid': response = c.post(f'/source-validate/{source_type}', data)
# Valid source tests should bounce to /source-add if test == 'valid':
self.assertEqual(response.status_code, 302) # Valid source tests should bounce to /source-add
url_parts = urlsplit(response.url) self.assertEqual(response.status_code, 302)
self.assertEqual(url_parts.path, '/source-add') url_parts = urlsplit(response.url)
else: self.assertEqual(url_parts.path, '/source-add')
# Invalid source tests should reload the page with an error message else:
self.assertEqual(response.status_code, 200) # Invalid source tests should reload the page with an error
self.assertIn('<ul class="errorlist">', response.content.decode()) self.assertEqual(response.status_code, 200)
self.assertIn('<ul class="errorlist">',
response.content.decode())
def test_add_source_prepopulation(self): def test_add_source_prepopulation(self):
c = Client() c = Client()

View File

@ -128,10 +128,12 @@ class ValidateSourceView(FormView):
} }
source_types = { source_types = {
'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL, 'youtube-channel': Source.SOURCE_TYPE_YOUTUBE_CHANNEL,
'youtube-channel-id': Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID,
'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST, 'youtube-playlist': Source.SOURCE_TYPE_YOUTUBE_PLAYLIST,
} }
help_item = { help_item = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _('YouTube channel'), Source.SOURCE_TYPE_YOUTUBE_CHANNEL: _('YouTube channel'),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: _('YouTube channel ID'),
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _('YouTube playlist'), Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _('YouTube playlist'),
} }
help_texts = { help_texts = {
@ -141,6 +143,13 @@ class ValidateSourceView(FormView):
'where <strong>CHANNELNAME</strong> is the name of the channel you want ' 'where <strong>CHANNELNAME</strong> is the name of the channel you want '
'to add.' '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: _( Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: _(
'Enter a YouTube playlist URL into the box below. A playlist URL will be ' '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=' 'in the format of <strong>https://www.youtube.com/playlist?list='
@ -150,6 +159,8 @@ class ValidateSourceView(FormView):
} }
help_examples = { help_examples = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: 'https://www.youtube.com/google', 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=' Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('https://www.youtube.com/playlist?list='
'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r') 'PL590L5WQmH8dpP0RyH5pCfIaDEdt9nk7r')
} }
@ -157,12 +168,21 @@ class ValidateSourceView(FormView):
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: { Source.SOURCE_TYPE_YOUTUBE_CHANNEL: {
'scheme': 'https', 'scheme': 'https',
'domain': 'www.youtube.com', 'domain': 'www.youtube.com',
'path_regex': '^\/(c\/|channel\/)?([^\/]+)(\/videos)?$', 'path_regex': '^\/(c\/)?([^\/]+)(\/videos)?$',
'path_must_not_match': ('/playlist', '/c/playlist'), 'path_must_not_match': ('/playlist', '/c/playlist'),
'qs_args': [], 'qs_args': [],
'extract_key': ('path_regex', 1), 'extract_key': ('path_regex', 1),
'example': 'https://www.youtube.com/SOMECHANNEL' 'example': 'https://www.youtube.com/SOMECHANNEL'
}, },
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: {
'scheme': 'https',
'domain': 'www.youtube.com',
'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: { Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: {
'scheme': 'https', 'scheme': 'https',
'domain': 'www.youtube.com', 'domain': 'www.youtube.com',
@ -175,6 +195,7 @@ class ValidateSourceView(FormView):
} }
prepopulate_fields = { prepopulate_fields = {
Source.SOURCE_TYPE_YOUTUBE_CHANNEL: ('source_type', 'key', 'name', 'directory'), Source.SOURCE_TYPE_YOUTUBE_CHANNEL: ('source_type', 'key', 'name', 'directory'),
Source.SOURCE_TYPE_YOUTUBE_CHANNEL_ID: ('source_type', 'key'),
Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('source_type', 'key'), Source.SOURCE_TYPE_YOUTUBE_PLAYLIST: ('source_type', 'key'),
} }