diff --git a/tubesync/common/templates/base.html b/tubesync/common/templates/base.html
index 649d5350..93d1cab6 100644
--- a/tubesync/common/templates/base.html
+++ b/tubesync/common/templates/base.html
@@ -32,6 +32,7 @@
Sources
Media
Tasks
+ Media Servers
diff --git a/tubesync/sync/admin.py b/tubesync/sync/admin.py
index 7cf64c0a..5e347336 100644
--- a/tubesync/sync/admin.py
+++ b/tubesync/sync/admin.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from .models import Source, Media
+from .models import Source, Media, MediaServer
@admin.register(Source)
@@ -19,3 +19,11 @@ class MediaAdmin(admin.ModelAdmin):
list_display = ('uuid', 'key', 'source', 'can_download', 'skip', 'downloaded')
readonly_fields = ('uuid', 'created')
search_fields = ('uuid', 'source__key', 'key')
+
+
+@admin.register(MediaServer)
+class MediaServerAdmin(admin.ModelAdmin):
+
+ ordering = ('host', 'port')
+ list_display = ('pk', 'server_type', 'host', 'port', 'use_https', 'verify_https')
+ search_fields = ('host',)
diff --git a/tubesync/sync/forms.py b/tubesync/sync/forms.py
index 6a6c6492..c816a23b 100644
--- a/tubesync/sync/forms.py
+++ b/tubesync/sync/forms.py
@@ -42,3 +42,37 @@ class EnableMediaForm(forms.Form):
class ResetTasksForm(forms.Form):
pass
+
+
+class ConfirmDeleteMediaServerForm(forms.Form):
+
+ pass
+
+
+class PlexMediaServerForm(forms.Form):
+
+ host = forms.CharField(
+ label=_('Host name or IP address of the Plex server'),
+ required=True
+ )
+ port = forms.IntegerField(
+ label=_('Port number of the Plex server'),
+ required=True,
+ initial=32400
+ )
+ use_https = forms.BooleanField(
+ label=_('Connect over HTTPS'),
+ required=False,
+ initial=True,
+ )
+ verify_https = forms.BooleanField(
+ label=_('Verify the HTTPS certificate is valid if connecting over HTTPS'),
+ required=False
+ )
+ token = forms.CharField(
+ label=_('Plex token'),
+ required=True
+ )
+ libraries = forms.CharField(
+ label=_('Comma-separated list of Plex library IDs to update, such as "9" or "4,6"')
+ )
diff --git a/tubesync/sync/mediaservers.py b/tubesync/sync/mediaservers.py
new file mode 100644
index 00000000..61e292d7
--- /dev/null
+++ b/tubesync/sync/mediaservers.py
@@ -0,0 +1,164 @@
+import warnings
+from xml.etree import ElementTree
+import requests
+from django.forms import ValidationError
+from urllib.parse import urlsplit, urlunsplit, urlencode
+from django.utils.translation import gettext_lazy as _
+from common.logger import log
+
+
+class MediaServerError(Exception):
+ '''
+ Raised when a back-end error occurs.
+ '''
+ pass
+
+
+class MediaServer:
+
+ TIMEOUT = 0
+ HELP = ''
+
+ def __init__(self, mediaserver_instance):
+ self.object = mediaserver_instance
+
+ def validate(self):
+ raise NotImplementedError('MediaServer.validate() must be implemented')
+
+ def update(self):
+ raise NotImplementedError('MediaServer.update() must be implemented')
+
+
+class PlexMediaServer(MediaServer):
+
+ TIMEOUT = 5
+
+ HELP = _('To connect your TubeSync sevrer to your Plex Media Server you will '
+ 'need to enter the details of your Plex server below.
'
+ 'The host can be either an IP address or valid hostname.
'
+ 'The port number must be between 1 and 65536.
'
+ 'The token is a Plex access token to your Plex server. You can find '
+ 'out how to get a Plex access token here.
'
+ 'The libraries is a comma-separated list of Plex '
+ 'library or section IDs, you can find out how to get your library or '
+ 'section IDs here.
')
+
+ def make_request(self, uri='/', params={}):
+ headers = {'User-Agent': 'TubeSync'}
+ token = self.object.loaded_options['token']
+ params['X-Plex-Token'] = token
+ base_parts = urlsplit(self.object.url)
+ qs = urlencode(params)
+ url = urlunsplit((base_parts.scheme, base_parts.netloc, uri, qs, ''))
+ if self.object.verify_https:
+ log.debug(f'[plex media server] Making HTTP GET request to: {url}')
+ return requests.get(url, headers=headers, verify=True,
+ timeout=self.TIMEOUT)
+ else:
+ # If not validating SSL, given this is likely going to be for an internal
+ # or private network, that Plex issues certs *.hash.plex.direct and that
+ # the warning won't ever been sensibly seen in the HTTPS logs, hide it
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ return requests.get(url, headers=headers, verify=False,
+ timeout=self.TIMEOUT)
+
+ def validate(self):
+ '''
+ A Plex server requires a host, port, access token and a comma-separated
+ list if library IDs.
+ '''
+ # Check all the required values are present
+ if not self.object.host:
+ raise ValidationError('Plex Media Server requires a "host"')
+ if not self.object.port:
+ raise ValidationError('Plex Media Server requires a "port"')
+ try:
+ port = int(self.object.port)
+ except (TypeError, ValueError):
+ raise ValidationError('Plex Media Server "port" must be an integer')
+ if port < 1 or port > 65535:
+ raise ValidationError('Plex Media Server "port" must be between 1 '
+ 'and 65535')
+ options = self.object.loaded_options
+ if 'token' not in options:
+ raise ValidationError('Plex Media Server requires a "token"')
+ token = options['token'].strip()
+ if 'token' not in options:
+ raise ValidationError('Plex Media Server requires a "token"')
+ if 'libraries' not in options:
+ raise ValidationError('Plex Media Server requires a "libraries"')
+ libraries = options['libraries'].strip().split(',')
+ for position, library in enumerate(libraries):
+ library = library.strip()
+ try:
+ int(library)
+ except (TypeError, ValueError):
+ raise ValidationError(f'Plex Media Server library ID "{library}" at '
+ f'position {position+1} must be an integer')
+ # Test the details work by requesting a summary page from the Plex server
+ try:
+ response = self.make_request('/library/sections')
+ except Exception as e:
+ raise ValidationError(f'Failed to make a test connection to your Plex '
+ f'Media Server at "{self.object.host}:'
+ f'{self.object.port}", the error was "{e}". Check '
+ 'your host and port are correct.') from e
+ if response.status_code != 200:
+ check_token = ''
+ if 400 <= response.status_code < 500:
+ check_token = (' A 4XX error could mean your access token is being '
+ 'rejected. Check your token is correct.')
+ raise ValidationError(f'Your Plex Media Server returned an invalid HTTP '
+ f'status code, expected 200 but received '
+ f'{response.status_code}.' + check_token)
+ try:
+ parsed_response = ElementTree.fromstring(response.content)
+ except Exception as e:
+ raise ValidationError(f'Your Plex Media Server returned unexpected data, '
+ f'expected valid XML but parsing it as XML caused '
+ f'the error "{e}"')
+ # Seems we have a valid library sections page, get the library IDs
+ remote_libraries = {}
+ try:
+ for parent in parsed_response.getiterator('MediaContainer'):
+ for d in parent:
+ library_id = d.attrib['key']
+ library_name = d.attrib['title']
+ remote_libraries[library_id] = library_name
+ except Exception as e:
+ raise ValidationError(f'Your Plex Media Server returned unexpected data, '
+ f'the XML it returned could not be parsed and the '
+ f'error was "{e}"')
+ # Validate the library IDs
+ remote_libraries_desc = []
+ for remote_library_id, remote_library_name in remote_libraries.items():
+ remote_libraries_desc.append(f'"{remote_library_name}" with ID '
+ f'"{remote_library_id}"')
+ remote_libraries_str = ', '.join(remote_libraries_desc)
+ for library_id in libraries:
+ library_id = library_id.strip()
+ if library_id not in remote_libraries:
+ raise ValidationError(f'One or more of your specified library IDs do '
+ f'not exist on your Plex Media Server. Your '
+ f'valid libraries are: {remote_libraries_str}')
+ # All good!
+ return True
+
+ def update(self):
+ # For each section / library ID pop off a request to refresh it
+ libraries = self.object.loaded_options.get('libraries', '')
+ for library_id in libraries.split(','):
+ library_id = library_id.strip()
+ uri = f'/library/sections/{library_id}/refresh'
+ response = self.make_request(uri)
+ if response.status_code != 200:
+ raise MediaServerError(f'Failed to refresh library "{library_id}" on '
+ f'Plex server "{self.object.url}", expected a '
+ f'200 status code but got '
+ f'{response.status_code}. Check your media '
+ f'server details.')
+ return True
diff --git a/tubesync/sync/migrations/0002_mediaserver.py b/tubesync/sync/migrations/0002_mediaserver.py
new file mode 100644
index 00000000..c9636fdd
--- /dev/null
+++ b/tubesync/sync/migrations/0002_mediaserver.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.1.4 on 2020-12-11 09:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sync', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='MediaServer',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('host', models.CharField(help_text='Hostname or IP address of the media server', max_length=200, verbose_name='host')),
+ ('port', models.PositiveIntegerField(help_text='Port number of the media server', verbose_name='port')),
+ ('https', models.BooleanField(default=False, help_text='Connect to the media server over HTTPS', verbose_name='https')),
+ ('options', models.TextField(blank=True, help_text='JSON encoded options for the media server', null=True, verbose_name='options')),
+ ('valid', models.BooleanField(default=False, help_text='Media server details are valid and passed testing', verbose_name='valid')),
+ ],
+ options={
+ 'verbose_name': 'Media Server',
+ 'verbose_name_plural': 'Media Servers',
+ 'unique_together': {('host', 'port')},
+ },
+ ),
+ ]
diff --git a/tubesync/sync/migrations/0003_auto_20201211_0954.py b/tubesync/sync/migrations/0003_auto_20201211_0954.py
new file mode 100644
index 00000000..7b915ec6
--- /dev/null
+++ b/tubesync/sync/migrations/0003_auto_20201211_0954.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.1.4 on 2020-12-11 09:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sync', '0002_mediaserver'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='mediaserver',
+ name='host',
+ field=models.CharField(db_index=True, help_text='Hostname or IP address of the media server', max_length=200, verbose_name='host'),
+ ),
+ migrations.AlterField(
+ model_name='mediaserver',
+ name='port',
+ field=models.PositiveIntegerField(db_index=True, help_text='Port number of the media server', verbose_name='port'),
+ ),
+ ]
diff --git a/tubesync/sync/migrations/0004_mediaserver_server_type.py b/tubesync/sync/migrations/0004_mediaserver_server_type.py
new file mode 100644
index 00000000..98851d73
--- /dev/null
+++ b/tubesync/sync/migrations/0004_mediaserver_server_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1.4 on 2020-12-11 10:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sync', '0003_auto_20201211_0954'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='mediaserver',
+ name='server_type',
+ field=models.CharField(choices=[('p', 'Plex')], db_index=True, default='p', help_text='Server type', max_length=1, verbose_name='server type'),
+ ),
+ ]
diff --git a/tubesync/sync/migrations/0005_remove_mediaserver_valid.py b/tubesync/sync/migrations/0005_remove_mediaserver_valid.py
new file mode 100644
index 00000000..9b301552
--- /dev/null
+++ b/tubesync/sync/migrations/0005_remove_mediaserver_valid.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1.4 on 2020-12-11 10:12
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sync', '0004_mediaserver_server_type'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='mediaserver',
+ name='valid',
+ ),
+ ]
diff --git a/tubesync/sync/migrations/0006_auto_20201212_0356.py b/tubesync/sync/migrations/0006_auto_20201212_0356.py
new file mode 100644
index 00000000..e720ee6d
--- /dev/null
+++ b/tubesync/sync/migrations/0006_auto_20201212_0356.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.1.4 on 2020-12-12 03:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('sync', '0005_remove_mediaserver_valid'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='mediaserver',
+ name='https',
+ ),
+ migrations.AddField(
+ model_name='mediaserver',
+ name='use_https',
+ field=models.BooleanField(default=True, help_text='Connect to the media server over HTTPS', verbose_name='use https'),
+ ),
+ migrations.AddField(
+ model_name='mediaserver',
+ name='verify_https',
+ field=models.BooleanField(default=False, help_text='If connecting over HTTPS, verify the SSL certificate is valid', verbose_name='verify https'),
+ ),
+ ]
diff --git a/tubesync/sync/models.py b/tubesync/sync/models.py
index b2721f59..729b86cc 100644
--- a/tubesync/sync/models.py
+++ b/tubesync/sync/models.py
@@ -14,6 +14,7 @@ from .youtube import (get_media_info as get_youtube_media_info,
from .utils import seconds_to_timestr, parse_media_format
from .matching import (get_best_combined_format, get_best_audio_format,
get_best_video_format)
+from .mediaservers import PlexMediaServer
media_file_storage = FileSystemStorage(location=str(settings.DOWNLOAD_ROOT))
@@ -796,3 +797,97 @@ class Media(models.Model):
str(self.filepath))
# Return the download paramaters
return format_str, self.source.extension
+
+
+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: '',
+ }
+ HANDLERS = {
+ SERVER_TYPE_PLEX: PlexMediaServer,
+ }
+
+ server_type = models.CharField(
+ _('server type'),
+ max_length=1,
+ db_index=True,
+ choices=SERVER_TYPE_CHOICES,
+ default=SERVER_TYPE_PLEX,
+ help_text=_('Server type')
+ )
+ host = models.CharField(
+ _('host'),
+ db_index=True,
+ max_length=200,
+ help_text=_('Hostname or IP address of the media server')
+ )
+ port = models.PositiveIntegerField(
+ _('port'),
+ db_index=True,
+ help_text=_('Port number of the media server')
+ )
+ use_https = models.BooleanField(
+ _('use https'),
+ default=True,
+ help_text=_('Connect to the media server over HTTPS')
+ )
+ verify_https = models.BooleanField(
+ _('verify https'),
+ default=False,
+ help_text=_('If connecting over HTTPS, verify the SSL certificate is valid')
+ )
+ options = models.TextField(
+ _('options'),
+ blank=True,
+ null=True,
+ help_text=_('JSON encoded options for the media server')
+ )
+
+ def __str__(self):
+ return f'{self.get_server_type_display()} server at {self.url}'
+
+ class Meta:
+ verbose_name = _('Media Server')
+ verbose_name_plural = _('Media Servers')
+ unique_together = (
+ ('host', 'port'),
+ )
+
+ @property
+ def url(self):
+ scheme = 'https' if self.use_https else 'http'
+ return f'{scheme}://{self.host.strip()}:{self.port}'
+
+ @property
+ def icon(self):
+ return self.ICONS.get(self.server_type)
+
+ @property
+ def handler(self):
+ handler_class = self.HANDLERS.get(self.server_type)
+ return handler_class(self)
+
+ @property
+ def loaded_options(self):
+ try:
+ return json.loads(self.options)
+ except Exception as e:
+ return {}
+
+ def validate(self):
+ return self.handler.validate()
+
+ def update(self):
+ return self.handler.update()
+
+ def get_help_html(self):
+ return self.handler.HELP
diff --git a/tubesync/sync/tasks.py b/tubesync/sync/tasks.py
index fcf2357a..a540ac09 100644
--- a/tubesync/sync/tasks.py
+++ b/tubesync/sync/tasks.py
@@ -20,7 +20,7 @@ from background_task import background
from background_task.models import Task, CompletedTask
from common.logger import log
from common.errors import NoMediaException, DownloadFailedException
-from .models import Source, Media
+from .models import Source, Media, MediaServer
from .utils import get_remote_image, resize_image_to_height, delete_file
@@ -312,6 +312,16 @@ def download_media(media_id):
else:
media.downloaded_format = 'audio'
media.save()
+ # Schedule a task to update media servers
+ for mediaserver in MediaServer.objects.all():
+ verbose_name = _('Request media server rescan for "{}"')
+ rescan_media_server(
+ str(mediaserver.pk),
+ queue=str(instance.source.pk),
+ priority=20,
+ verbose_name=verbose_name.format(mediaserver),
+ remove_existing_tasks=True
+ )
else:
# Expected file doesn't exist on disk
err = (f'Failed to download media: {media} (UUID: {media.pk}) to disk, '
@@ -319,3 +329,17 @@ def download_media(media_id):
log.error(err)
# Raising an error here triggers the task to be re-attempted (or fail)
raise DownloadFailedException(err)
+
+
+@background(schedule=0)
+def rescan_media_server(mediaserver_id):
+ '''
+ Attempts to request a media rescan on a remote media server.
+ '''
+ try:
+ mediaserver = MediaServer.objects.get(pk=media_id)
+ except MediaServer.DoesNotExist:
+ # Task triggered but the media server no longer exists, do nothing
+ return
+ # Request an rescan / update
+ mediaserver.update()
diff --git a/tubesync/sync/templates/sync/mediaserver-add.html b/tubesync/sync/templates/sync/mediaserver-add.html
new file mode 100644
index 00000000..4a7c69cf
--- /dev/null
+++ b/tubesync/sync/templates/sync/mediaserver-add.html
@@ -0,0 +1,27 @@
+{% extends 'base.html' %}
+
+{% block headtitle %}Add a new {{ server_type_name }} media server{% endblock %}
+
+{% block content %}
+
+
+
Add a {{ server_type_name }} media server
+
+ You can use this form to add a new {{ server_type_name }} media server. All media
+ servers added will be updated or refreshed every time some media is downloaded.
+
+ {% if server_help %}{{ server_help|safe }}{% endif %}
+
+
+
+{% endblock %}
diff --git a/tubesync/sync/templates/sync/mediaserver-delete.html b/tubesync/sync/templates/sync/mediaserver-delete.html
new file mode 100644
index 00000000..dcfeb247
--- /dev/null
+++ b/tubesync/sync/templates/sync/mediaserver-delete.html
@@ -0,0 +1,27 @@
+{% extends 'base.html' %}
+
+{% block headtitle %}Delete media server - {{ mediaserver }}{% endblock %}
+
+{% block content %}
+
+
+
Delete {{ mediaserver }}
+
+ Deleting a media server will stop it from being updated by TubeSync. This action
+ is permanent. You will have to manually re-add the media server details again if
+ you want to update it automatically in future again.
+
+
+
+
+{% endblock %}
diff --git a/tubesync/sync/templates/sync/mediaserver-update.html b/tubesync/sync/templates/sync/mediaserver-update.html
new file mode 100644
index 00000000..94102725
--- /dev/null
+++ b/tubesync/sync/templates/sync/mediaserver-update.html
@@ -0,0 +1,27 @@
+{% extends 'base.html' %}
+
+{% block headtitle %}Update media server - {{ mediaserver }}{% endblock %}
+
+{% block content %}
+
+
+
Update {{ mediaserver }}
+
+ You can use this form to update your media server details. The details will be
+ validated when you save the form.
+
+ {% if server_help %}{{ server_help|safe }}{% endif %}
+
+
+
+{% endblock %}
diff --git a/tubesync/sync/templates/sync/mediaserver.html b/tubesync/sync/templates/sync/mediaserver.html
new file mode 100644
index 00000000..23546eba
--- /dev/null
+++ b/tubesync/sync/templates/sync/mediaserver.html
@@ -0,0 +1,48 @@
+{% extends 'base.html' %}
+
+{% block headtitle %}Media server - {{ mediaserver }}{% endblock %}
+
+{% block content %}
+
+
+
{{ mediaserver.get_server_type_display }} server at {{ mediaserver.url }}
+
+
+{% include 'infobox.html' with message=message %}
+
+
+
+
+ Type |
+ Type {{ mediaserver.get_server_type_display }} |
+
+
+ Location |
+ Location {{ mediaserver.url }} |
+
+
+ Use HTTPS |
+ Use HTTPS {% if mediaserver.use_https %}{% else %}{% endif %} |
+
+
+ Verify HTTPS |
+ Verify HTTPS {% if mediaserver.verify_https %}{% else %}{% endif %} |
+
+ {% for name, value in mediaserver.loaded_options.items %}
+
+ {{ name|title }} |
+ {{ name|title }} {% if name in private_options %}{{ value|truncatechars:6 }} (hidden){% else %}{{ value }}{% endif %} |
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/tubesync/sync/templates/sync/mediaservers.html b/tubesync/sync/templates/sync/mediaservers.html
new file mode 100644
index 00000000..22bf2280
--- /dev/null
+++ b/tubesync/sync/templates/sync/mediaservers.html
@@ -0,0 +1,36 @@
+{% extends 'base.html' %}
+
+{% block headtitle %}Media servers{% endblock %}
+
+{% block content %}
+
+
+
Media servers
+
+ Media servers are services like Plex which you may be running on your network. If
+ you add your media server TubeSync will notify your media server to rescan or
+ refresh its libraries every time media is successfully downloaded. Currently,
+ TubeSync only supports Plex.
+
+
+
+{% include 'infobox.html' with message=message %}
+
+
+{% endblock %}
diff --git a/tubesync/sync/tests.py b/tubesync/sync/tests.py
index 8b5ddd1a..58fd3c72 100644
--- a/tubesync/sync/tests.py
+++ b/tubesync/sync/tests.py
@@ -269,22 +269,60 @@ class FrontEndTestCase(TestCase):
fallback=Source.FALLBACK_FAIL
)
# Add some media
+ test_minimal_metadata = '''
+ {
+ "thumbnail":"https://example.com/thumb.jpg",
+ "formats": [{
+ "format_id":"251",
+ "player_url":null,
+ "ext":"webm",
+ "format_note":"tiny",
+ "acodec":"opus",
+ "abr":160,
+ "asr":48000,
+ "filesize":6669827,
+ "fps":null,
+ "height":null,
+ "tbr":156.344,
+ "width":null,
+ "vcodec":"none",
+ "format":"251 - audio only (tiny)",
+ "protocol":"https"
+ },
+ {
+ "format_id":"248",
+ "player_url":null,
+ "ext":"webm",
+ "height":1080,
+ "format_note":"1080p",
+ "vcodec":"vp9",
+ "asr":null,
+ "filesize":63659748,
+ "fps":24,
+ "tbr":2747.461,
+ "width":1920,
+ "acodec":"none",
+ "format":"248 - 1920x1080 (1080p)",
+ "protocol":"https"
+ }]
+ }
+ '''
test_media1 = Media.objects.create(
key='mediakey1',
source=test_source,
- metadata='{"thumbnail":"https://example.com/thumb.jpg"}',
+ metadata=test_minimal_metadata
)
test_media1_pk = str(test_media1.pk)
test_media2 = Media.objects.create(
key='mediakey2',
source=test_source,
- metadata='{"thumbnail":"https://example.com/thumb.jpg"}',
+ metadata=test_minimal_metadata
)
test_media2_pk = str(test_media2.pk)
test_media3 = Media.objects.create(
key='mediakey3',
source=test_source,
- metadata='{"thumbnail":"https://example.com/thumb.jpg"}',
+ metadata=test_minimal_metadata
)
test_media3_pk = str(test_media3.pk)
# Check the tasks to fetch the media thumbnails have been scheduled
@@ -361,6 +399,13 @@ class FrontEndTestCase(TestCase):
self.assertEqual(response.status_code, 200)
+ def test_mediasevrers(self):
+ # Media servers overview page
+ c = Client()
+ response = c.get('/mediaservers')
+ self.assertEqual(response.status_code, 200)
+
+
metadata_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata.json'
metadata = open(metadata_filepath, 'rt').read()
metadata_hdr_filepath = settings.BASE_DIR / 'sync' / 'testdata' / 'metadata_hdr.json'
diff --git a/tubesync/sync/urls.py b/tubesync/sync/urls.py
index 639aca63..9632c3c6 100644
--- a/tubesync/sync/urls.py
+++ b/tubesync/sync/urls.py
@@ -2,7 +2,9 @@ from django.urls import path
from .views import (DashboardView, SourcesView, ValidateSourceView, AddSourceView,
SourceView, UpdateSourceView, DeleteSourceView, MediaView,
MediaThumbView, MediaItemView, MediaRedownloadView, MediaSkipView,
- MediaEnableView, TasksView, CompletedTasksView, ResetTasks)
+ MediaEnableView, TasksView, CompletedTasksView, ResetTasks,
+ MediaServersView, AddMediaServerView, MediaServerView,
+ DeleteMediaServerView, UpdateMediaServerView)
app_name = 'sync'
@@ -82,4 +84,26 @@ urlpatterns = [
ResetTasks.as_view(),
name='reset-tasks'),
+ # Media Server URLs
+
+ path('mediaservers',
+ MediaServersView.as_view(),
+ name='mediaservers'),
+
+ path('mediaserver-add/',
+ AddMediaServerView.as_view(),
+ name='add-mediaserver'),
+
+ path('mediaserver/',
+ MediaServerView.as_view(),
+ name='mediaserver'),
+
+ path('mediaserver-delete/',
+ DeleteMediaServerView.as_view(),
+ name='delete-mediaserver'),
+
+ path('mediaserver-update/',
+ UpdateMediaServerView.as_view(),
+ name='update-mediaserver'),
+
]
diff --git a/tubesync/sync/views.py b/tubesync/sync/views.py
index d05bb3b3..8f2bb2d8 100644
--- a/tubesync/sync/views.py
+++ b/tubesync/sync/views.py
@@ -1,3 +1,4 @@
+import json
from base64 import b64decode
from django.conf import settings
from django.http import Http404
@@ -7,6 +8,7 @@ from django.views.generic.edit import (FormView, FormMixin, CreateView, UpdateVi
from django.views.generic.detail import SingleObjectMixin
from django.http import HttpResponse
from django.urls import reverse_lazy
+from django.db import IntegrityError
from django.db.models import Q, Count, Sum
from django.forms import ValidationError
from django.utils.text import slugify
@@ -14,9 +16,10 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from common.utils import append_uri_params
from background_task.models import Task, CompletedTask
-from .models import Source, Media
+from .models import Source, Media, MediaServer
from .forms import (ValidateSourceForm, ConfirmDeleteSourceForm, RedownloadMediaForm,
- SkipMediaForm, EnableMediaForm, ResetTasksForm)
+ SkipMediaForm, EnableMediaForm, ResetTasksForm, PlexMediaServerForm,
+ ConfirmDeleteMediaServerForm)
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,
@@ -703,3 +706,230 @@ class ResetTasks(FormView):
def get_success_url(self):
url = reverse_lazy('sync:tasks')
return append_uri_params(url, {'message': 'reset'})
+
+
+class MediaServersView(ListView):
+ '''
+ List of media servers which have been added.
+ '''
+
+ template_name = 'sync/mediaservers.html'
+ context_object_name = 'mediaservers'
+ messages = {
+ 'deleted': _('Your selected media server has been deleted.'),
+ }
+
+ def __init__(self, *args, **kwargs):
+ self.message = None
+ super().__init__(*args, **kwargs)
+
+ def dispatch(self, request, *args, **kwargs):
+ message_key = request.GET.get('message', '')
+ self.message = self.messages.get(message_key, '')
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_queryset(self):
+ return MediaServer.objects.all().order_by('host')
+
+ def get_context_data(self, *args, **kwargs):
+ data = super().get_context_data(*args, **kwargs)
+ data['message'] = self.message
+ return data
+
+
+class AddMediaServerView(FormView):
+ '''
+ Adds a new media server. The form is switched out to whatever matches the
+ server type.
+ '''
+
+ template_name = 'sync/mediaserver-add.html'
+ server_types = {
+ 'plex': MediaServer.SERVER_TYPE_PLEX,
+ }
+ server_type_names = {
+ MediaServer.SERVER_TYPE_PLEX: _('Plex'),
+ }
+ forms = {
+ MediaServer.SERVER_TYPE_PLEX: PlexMediaServerForm,
+ }
+
+ def __init__(self, *args, **kwargs):
+ self.server_type = None
+ self.model_class = None
+ self.object = None
+ super().__init__(*args, **kwargs)
+
+ def dispatch(self, request, *args, **kwargs):
+ server_type_str = kwargs.get('server_type', '')
+ self.server_type = self.server_types.get(server_type_str)
+ if not self.server_type:
+ raise Http404
+ self.form_class = self.forms.get(self.server_type)
+ self.model_class = MediaServer(server_type=self.server_type)
+ return super().dispatch(request, *args, **kwargs)
+
+ def form_valid(self, form):
+ # Assign mandatory fields, bundle other fields into options
+ mediaserver = MediaServer(server_type=self.server_type)
+ options = {}
+ model_fields = [field.name for field in MediaServer._meta.fields]
+ for field_name, field_value in form.cleaned_data.items():
+ if field_name in model_fields:
+ setattr(mediaserver, field_name, field_value)
+ else:
+ options[field_name] = field_value
+ mediaserver.options = json.dumps(options)
+ # Test the media server details are valid
+ try:
+ mediaserver.validate()
+ except ValidationError as e:
+ form.add_error(None, e)
+ # Check if validation detected any errors
+ if form.errors:
+ return super().form_invalid(form)
+ # All good, try to save and return
+ try:
+ mediaserver.save()
+ except IntegrityError:
+ form.add_error(
+ None,
+ (f'A media server already exists with the host and port '
+ f'{mediaserver.host}:{mediaserver.port}')
+ )
+ # Check if saving caused any errors
+ if form.errors:
+ return super().form_invalid(form)
+ # All good!
+ self.object = mediaserver
+ return super().form_valid(form)
+
+ def get_context_data(self, *args, **kwargs):
+ data = super().get_context_data(*args, **kwargs)
+ data['server_type'] = self.server_type
+ data['server_type_name'] = self.server_type_names.get(self.server_type)
+ data['server_help'] = self.model_class.get_help_html()
+ return data
+
+ def get_success_url(self):
+ url = reverse_lazy('sync:mediaserver', kwargs={'pk': self.object.pk})
+ return append_uri_params(url, {'message': 'created'})
+
+
+class MediaServerView(DetailView):
+ '''
+ A single media server overview page.
+ '''
+
+ template_name = 'sync/mediaserver.html'
+ model = MediaServer
+ private_options = ('token',)
+ messages = {
+ 'created': _('Your media server has been successfully added'),
+ }
+
+ def __init__(self, *args, **kwargs):
+ self.message = None
+ super().__init__(*args, **kwargs)
+
+ def dispatch(self, request, *args, **kwargs):
+ message_key = request.GET.get('message', '')
+ self.message = self.messages.get(message_key, '')
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, *args, **kwargs):
+ data = super().get_context_data(*args, **kwargs)
+ data['message'] = self.message
+ data['private_options'] = self.private_options
+ return data
+
+
+class DeleteMediaServerView(DeleteView, FormMixin):
+ '''
+ Confirms deletion and then deletes a media server.
+ '''
+
+ template_name = 'sync/mediaserver-delete.html'
+ model = MediaServer
+ form_class = ConfirmDeleteMediaServerForm
+ context_object_name = 'mediaserver'
+
+ def get_success_url(self):
+ url = reverse_lazy('sync:mediaservers')
+ return append_uri_params(url, {'message': 'deleted'})
+
+
+class UpdateMediaServerView(FormView, SingleObjectMixin):
+ '''
+ Adds a new media server. The form is switched out to whatever matches the
+ server type.
+ '''
+
+ template_name = 'sync/mediaserver-update.html'
+ model = MediaServer
+ forms = {
+ MediaServer.SERVER_TYPE_PLEX: PlexMediaServerForm,
+ }
+
+ def __init__(self, *args, **kwargs):
+ self.object = None
+ super().__init__(*args, **kwargs)
+
+ def dispatch(self, request, *args, **kwargs):
+ self.object = self.get_object()
+ self.form_class = self.forms.get(self.object.server_type, None)
+ if not self.form_class:
+ raise Http404
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_initial(self):
+ initial = super().get_initial()
+ for field in self.object._meta.fields:
+ if field.name in self.form_class.declared_fields:
+ initial[field.name] = getattr(self.object, field.name)
+ for option_key, option_val in self.object.loaded_options.items():
+ if option_key in self.form_class.declared_fields:
+ initial[option_key] = option_val
+ return initial
+
+ def form_valid(self, form):
+ # Assign mandatory fields, bundle other fields into options
+ options = {}
+ model_fields = [field.name for field in MediaServer._meta.fields]
+ for field_name, field_value in form.cleaned_data.items():
+ if field_name in model_fields:
+ setattr(self.object, field_name, field_value)
+ else:
+ options[field_name] = field_value
+ self.object.options = json.dumps(options)
+ # Test the media server details are valid
+ try:
+ self.object.validate()
+ except ValidationError as e:
+ form.add_error(None, e)
+ # Check if validation detected any errors
+ if form.errors:
+ return super().form_invalid(form)
+ # All good, try to save and return
+ try:
+ self.object.save()
+ except IntegrityError:
+ form.add_error(
+ None,
+ (f'A media server already exists with the host and port '
+ f'{self.object.host}:{self.object.port}')
+ )
+ # Check if saving caused any errors
+ if form.errors:
+ return super().form_invalid(form)
+ # All good!
+ return super().form_valid(form)
+
+ def get_context_data(self, *args, **kwargs):
+ data = super().get_context_data(*args, **kwargs)
+ data['server_help'] = self.object.help_html
+ return data
+
+ def get_success_url(self):
+ url = reverse_lazy('sync:mediaserver', kwargs={'pk': self.object.pk})
+ return append_uri_params(url, {'message': 'updated'})
\ No newline at end of file