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 %} +
    +
    +
    +
    + {% csrf_token %} + {% include 'simpleform.html' with form=form %} +
    +
    + +
    +
    +
    +
    +{% 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. +

    +
    +
    +
    +
    + {% csrf_token %} + {% include 'simpleform.html' with form=form %} +
    +
    + +
    +
    +
    +
    +{% 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 %} +
    +
    +
    +
    + {% csrf_token %} + {% include 'simpleform.html' with form=form %} +
    +
    + +
    +
    +
    +
    +{% 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 %} +
    +
    + + + + + + + + + + + + + + + + + + {% for name, value in mediaserver.loaded_options.items %} + + + + + {% endfor %} +
    TypeType
    {{ mediaserver.get_server_type_display }}
    LocationLocation
    {{ mediaserver.url }}
    Use HTTPSUse HTTPS
    {% if mediaserver.use_https %}{% else %}{% endif %}
    Verify HTTPSVerify HTTPS
    {% if mediaserver.verify_https %}{% else %}{% endif %}
    {{ name|title }}{{ name|title }}
    {% if name in private_options %}{{ value|truncatechars:6 }} (hidden){% else %}{{ value }}{% endif %}
    +
    +
    +
    +
    + Edit media server +
    +
    + Delete media server +
    +
    +{% 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 %} +
    +
    + Add a Plex media server +
    +
    +
    +
    +
    + {% for mediaserver in mediaservers %} + + {{ mediaserver.icon|safe }} {{ mediaserver.get_server_type_display }} server at {{ mediaserver.url }} + + {% empty %} + You haven't added any media servers. + {% endfor %} +
    +
    +
    +{% 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