media servers and media server updating
This commit is contained in:
parent
ba4423ac57
commit
09eb057392
|
@ -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>
|
||||
|
|
|
@ -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',)
|
||||
|
|
|
@ -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"')
|
||||
)
|
||||
|
|
|
@ -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
|
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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'
|
||||
|
|
|
@ -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'),
|
||||
|
||||
]
|
||||
|
|
|
@ -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'})
|
Loading…
Reference in New Issue