media servers and media server updating

This commit is contained in:
meeb 2020-12-12 20:11:50 +11:00
parent ba4423ac57
commit 09eb057392
19 changed files with 912 additions and 8 deletions

View File

@ -32,6 +32,7 @@
<li><a href="{% url 'sync:sources' %}"><i class="fas fa-fw fa-play"></i><span class="hide-on-med-and-down"> Sources</span></a></li>
<li><a href="{% url 'sync:media' %}"><i class="fas fa-fw fa-film"></i><span class="hide-on-med-and-down"> Media</span></a></li>
<li><a href="{% url 'sync:tasks' %}"><i class="far fa-fw fa-clock"></i><span class="hide-on-med-and-down"> Tasks</span></a></li>
<li><a href="{% url 'sync:mediaservers' %}"><i class="fas fa-fw fa-stream"></i><span class="hide-on-med-and-down"> Media Servers</span></a></li>
</ul>
</div>
</nav>

View File

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

View File

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

View File

@ -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 = _('<p>To connect your TubeSync sevrer to your Plex Media Server you will '
'need to enter the details of your Plex server below.</p>'
'<p>The <strong>host</strong> can be either an IP address or valid hostname.</p>'
'<p>The <strong>port</strong> number must be between 1 and 65536.</p>'
'<p>The <strong>token</strong> is a Plex access token to your Plex server. You can find '
'out how to get a Plex access token <a href="https://support.plex.tv/'
'articles/204059436-finding-an-authentication-token-x-plex-token/" '
'target="_blank">here</a>.</p>'
'<p>The <strong>libraries</strong> is a comma-separated list of Plex '
'library or section IDs, you can find out how to get your library or '
'section IDs <a href="https://support.plex.tv/articles/201242707-plex-'
'media-scanner-via-command-line/#toc-1" target="_blank">here</a>.</p>')
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: '<i class="fas fa-server"></i>',
}
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

View File

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

View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block headtitle %}Add a new {{ server_type_name }} media server{% endblock %}
{% block content %}
<div class="row no-margin-bottom">
<div class="col s12">
<h1>Add a {{ server_type_name }} media server</h1>
<p>
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.
</p>
{% if server_help %}{{ server_help|safe }}{% endif %}
</div>
</div>
<div class="row">
<form method="post" action="{% url 'sync:add-mediaserver' server_type='plex' %}" class="col s12 simpleform">
{% csrf_token %}
{% include 'simpleform.html' with form=form %}
<div class="row no-margin-bottom padding-top">
<div class="col s12">
<button class="btn" type="submit" name="action">Add {{ server_type_name }} server <i class="fas fa-fw fa-server"></i></button>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block headtitle %}Delete media server - {{ mediaserver }}{% endblock %}
{% block content %}
<div class="row no-margin-bottom">
<div class="col s12">
<h1>Delete <strong>{{ mediaserver }}</strong></h1>
<p>
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.
</p>
</div>
</div>
<div class="row">
<form method="post" action="{% url 'sync:delete-mediaserver' pk=mediaserver.pk %}" class="col s12 simpleform">
{% csrf_token %}
{% include 'simpleform.html' with form=form %}
<div class="row no-margin-bottom padding-top">
<div class="col s12">
<button class="btn" type="submit" name="action">Really delete media server <i class="fas fa-trash-alt"></i></button>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block headtitle %}Update media server - {{ mediaserver }}{% endblock %}
{% block content %}
<div class="row no-margin-bottom">
<div class="col s12">
<h1>Update <strong>{{ mediaserver }}</strong></h1>
<p>
You can use this form to update your media server details. The details will be
validated when you save the form.
</p>
{% if server_help %}{{ server_help|safe }}{% endif %}
</div>
</div>
<div class="row">
<form method="post" action="{% url 'sync:update-mediaserver' pk=mediaserver.pk %}" class="col s12 simpleform">
{% csrf_token %}
{% include 'simpleform.html' with form=form %}
<div class="row no-margin-bottom padding-top">
<div class="col s12">
<button class="btn" type="submit" name="action">Update media server <i class="fas fa-pen-square"></i></button>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends 'base.html' %}
{% block headtitle %}Media server - {{ mediaserver }}{% endblock %}
{% block content %}
<div class="row">
<div class="col s12">
<h1 class="truncate"><strong>{{ mediaserver.get_server_type_display }}</strong> server at <strong>{{ mediaserver.url }}</strong></h1>
</div>
</div>
{% include 'infobox.html' with message=message %}
<div class="row">
<div class="col s12">
<table class="striped">
<tr title="The source type">
<td class="hide-on-small-only">Type</td>
<td><span class="hide-on-med-and-up">Type<br></span><strong>{{ mediaserver.get_server_type_display }}</strong></td>
</tr>
<tr title="Name of the souce in TubeSync for your reference">
<td class="hide-on-small-only">Location</td>
<td><span class="hide-on-med-and-up">Location<br></span><strong>{{ mediaserver.url }}</strong></td>
</tr>
<tr title="Number of media items downloaded for the source">
<td class="hide-on-small-only">Use HTTPS</td>
<td><span class="hide-on-med-and-up">Use HTTPS<br></span><strong>{% if mediaserver.use_https %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
</tr>
<tr title="Number of media items downloaded for the source">
<td class="hide-on-small-only">Verify HTTPS</td>
<td><span class="hide-on-med-and-up">Verify HTTPS<br></span><strong>{% if mediaserver.verify_https %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
</tr>
{% for name, value in mediaserver.loaded_options.items %}
<tr title="Unique key of the source, such as the channel name or playlist ID">
<td class="hide-on-small-only">{{ name|title }}</td>
<td><span class="hide-on-med-and-up">{{ name|title }}<br></span><strong>{% if name in private_options %}{{ value|truncatechars:6 }} (hidden){% else %}{{ value }}{% endif %}</strong></td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="row no-margin-bottom padding-top">
<div class="col s12 l6 margin-bottom">
<a href="{% url 'sync:update-mediaserver' pk=mediaserver.pk %}" class="btn">Edit media server <i class="fas fa-pen-square"></i></a>
</div>
<div class="col s12 l6 margin-bottom">
<a href="{% url 'sync:delete-mediaserver' pk=mediaserver.pk %}" class="btn delete-button">Delete media server <i class="fas fa-trash-alt"></i></a>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block headtitle %}Media servers{% endblock %}
{% block content %}
<div class="row">
<div class="col s12">
<h1 class="truncate">Media servers</h1>
<p>
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.
</p>
</div>
</div>
{% include 'infobox.html' with message=message %}
<div class="row">
<div class="col s12 margin-bottom">
<a href="{% url 'sync:add-mediaserver' server_type='plex' %}" class="btn">Add a Plex media server <i class="fas fa-server"></i></a>
</div>
</div>
<div class="row no-margin-bottom">
<div class="col s12">
<div class="collection">
{% for mediaserver in mediaservers %}
<a href="{% url 'sync:mediaserver' pk=mediaserver.pk %}" class="collection-item">
{{ mediaserver.icon|safe }} <strong>{{ mediaserver.get_server_type_display }}</strong> server at <strong>{{ mediaserver.url }}</strong>
</a>
{% empty %}
<span class="collection-item no-items"><i class="fas fa-info-circle"></i> You haven't added any media servers.</span>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

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

View File

@ -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/<slug:server_type>',
AddMediaServerView.as_view(),
name='add-mediaserver'),
path('mediaserver/<int:pk>',
MediaServerView.as_view(),
name='mediaserver'),
path('mediaserver-delete/<int:pk>',
DeleteMediaServerView.as_view(),
name='delete-mediaserver'),
path('mediaserver-update/<int:pk>',
UpdateMediaServerView.as_view(),
name='update-mediaserver'),
]

View File

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