Connect the UI for Jellyfin
* Add Jellyfin media server type * Add and use `forms_dict` and `long_types` methods * Add `JellyfinMediaServerForm` * Use `JellyfinMediaServer` * Add and use `MediaServerType.handlers_dict` method * Do not hard-code `plex` * Also map backwards in `MediaServerType.long_types` * Use the long server type for URLs * Loop over media server type names * Add `MediaServerType.long_type` property * Add `media_server_types` to template context * Use the `MediaServerType.members_list` class method * Port is already part of the URL
This commit is contained in:
1 parent
e25a9cd228
commit
976101781d
8 files changed
+143
-34
No files matched your search
@@ -65,8 +65,55 @@ class IndexSchedule(models.IntegerChoices):
|
|||||||
|
|
||||||
|
|
||||||
class MediaServerType(models.TextChoices):
|
class MediaServerType(models.TextChoices):
|
||||||
|
JELLYFIN = 'j', _('Jellyfin')
|
||||||
PLEX = 'p', _('Plex')
|
PLEX = 'p', _('Plex')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def forms_dict(cls):
|
||||||
|
from .forms import (
|
||||||
|
JellyfinMediaServerForm,
|
||||||
|
PlexMediaServerForm,
|
||||||
|
)
|
||||||
|
return dict(zip(
|
||||||
|
cls.values,
|
||||||
|
(
|
||||||
|
JellyfinMediaServerForm,
|
||||||
|
PlexMediaServerForm,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def handlers_dict(cls):
|
||||||
|
from .mediaservers import (
|
||||||
|
JellyfinMediaServer,
|
||||||
|
PlexMediaServer,
|
||||||
|
)
|
||||||
|
return dict(zip(
|
||||||
|
cls.values,
|
||||||
|
(
|
||||||
|
JellyfinMediaServer,
|
||||||
|
PlexMediaServer,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def long_type(self):
|
||||||
|
return self.long_types().get(self.value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def long_types(cls):
|
||||||
|
d = dict(zip(
|
||||||
|
list(map(cls.lower, cls.names)),
|
||||||
|
cls.values,
|
||||||
|
))
|
||||||
|
rd = dict(zip( d.values(), d.keys() ))
|
||||||
|
rd.update(d)
|
||||||
|
return rd
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def members_list(cls):
|
||||||
|
return list(cls.__members__.values())
|
||||||
|
|
||||||
|
|
||||||
class MediaState(models.TextChoices):
|
class MediaState(models.TextChoices):
|
||||||
UNKNOWN = 'unknown'
|
UNKNOWN = 'unknown'
|
||||||
|
|||||||
@@ -48,7 +48,39 @@ class ConfirmDeleteMediaServerForm(forms.Form):
|
|||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
_media_server_type_label = 'Jellyfin'
|
||||||
|
class JellyfinMediaServerForm(forms.Form):
|
||||||
|
|
||||||
|
host = forms.CharField(
|
||||||
|
label=_(f'Host name or IP address of the {_media_server_type_label} server'),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
port = forms.IntegerField(
|
||||||
|
label=_(f'Port number of the {_media_server_type_label} server'),
|
||||||
|
required=True,
|
||||||
|
initial=8096,
|
||||||
|
)
|
||||||
|
use_https = forms.BooleanField(
|
||||||
|
label=_('Connect over HTTPS'),
|
||||||
|
required=False,
|
||||||
|
initial=False,
|
||||||
|
)
|
||||||
|
verify_https = forms.BooleanField(
|
||||||
|
label=_('Verify the HTTPS certificate is valid if connecting over HTTPS'),
|
||||||
|
required=False,
|
||||||
|
initial=True,
|
||||||
|
)
|
||||||
|
token = forms.CharField(
|
||||||
|
label=_(f'{_media_server_type_label} token'),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
libraries = forms.CharField(
|
||||||
|
label=_(f'Comma-separated list of {_media_server_type_label} library IDs to update'),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_media_server_type_label = 'Plex'
|
||||||
class PlexMediaServerForm(forms.Form):
|
class PlexMediaServerForm(forms.Form):
|
||||||
|
|
||||||
host = forms.CharField(
|
host = forms.CharField(
|
||||||
|
|||||||
@@ -180,11 +180,10 @@ class JellyfinMediaServer(MediaServer):
|
|||||||
'User-Agent': 'TubeSync',
|
'User-Agent': 'TubeSync',
|
||||||
'X-Emby-Token': self.object.loaded_options['token'] # Jellyfin uses the same `X-Emby-Token` header as Emby
|
'X-Emby-Token': self.object.loaded_options['token'] # Jellyfin uses the same `X-Emby-Token` header as Emby
|
||||||
}
|
}
|
||||||
base_url = f"{self.object.url}:{self.object.port}" if self.object.port else self.object.url
|
|
||||||
url = f"{base_url}{uri}"
|
url = f'{self.object.url}{uri}'
|
||||||
|
|
||||||
log.debug(f'[jellyfin media server] Making HTTP GET request to: {url}')
|
log.debug(f'[jellyfin media server] Making HTTP GET request to: {url}')
|
||||||
|
|
||||||
return requests.get(url, headers=headers, verify=self.object.verify_https, timeout=self.TIMEOUT)
|
return requests.get(url, headers=headers, verify=self.object.verify_https, timeout=self.TIMEOUT)
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
@@ -192,20 +191,20 @@ class JellyfinMediaServer(MediaServer):
|
|||||||
raise ValidationError('Jellyfin Media Server requires a "host"')
|
raise ValidationError('Jellyfin Media Server requires a "host"')
|
||||||
if not self.object.port:
|
if not self.object.port:
|
||||||
raise ValidationError('Jellyfin Media Server requires a "port"')
|
raise ValidationError('Jellyfin Media Server requires a "port"')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
port = int(self.object.port)
|
port = int(self.object.port)
|
||||||
if port < 1 or port > 65535:
|
if port < 1 or port > 65535:
|
||||||
raise ValidationError('Jellyfin Media Server "port" must be between 1 and 65535')
|
raise ValidationError('Jellyfin Media Server "port" must be between 1 and 65535')
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise ValidationError('Jellyfin Media Server "port" must be an integer')
|
raise ValidationError('Jellyfin Media Server "port" must be an integer')
|
||||||
|
|
||||||
options = self.object.loaded_options
|
options = self.object.loaded_options
|
||||||
if 'token' not in options:
|
if 'token' not in options:
|
||||||
raise ValidationError('Jellyfin Media Server requires a "token"')
|
raise ValidationError('Jellyfin Media Server requires a "token"')
|
||||||
if 'libraries' not in options:
|
if 'libraries' not in options:
|
||||||
raise ValidationError('Jellyfin Media Server requires a "libraries"')
|
raise ValidationError('Jellyfin Media Server requires a "libraries"')
|
||||||
|
|
||||||
# Test connection and fetch libraries
|
# Test connection and fetch libraries
|
||||||
try:
|
try:
|
||||||
response = self.make_request('/Library/MediaFolders', params={'Recursive': 'true', 'IncludeItemTypes': 'CollectionFolder'})
|
response = self.make_request('/Library/MediaFolders', params={'Recursive': 'true', 'IncludeItemTypes': 'CollectionFolder'})
|
||||||
@@ -234,19 +233,18 @@ class JellyfinMediaServer(MediaServer):
|
|||||||
remote_libraries_desc.append(f'"{remote_library_name}" with ID '
|
remote_libraries_desc.append(f'"{remote_library_name}" with ID '
|
||||||
f'"{remote_library_id}"')
|
f'"{remote_library_id}"')
|
||||||
remote_libraries_str = ', '.join(remote_libraries_desc)
|
remote_libraries_str = ', '.join(remote_libraries_desc)
|
||||||
for library_id in libraries:
|
libraries = options.get('libraries', '').split(',')
|
||||||
library_id = library_id.strip()
|
for library_id in map(str.strip, libraries):
|
||||||
if library_id not in remote_libraries:
|
if library_id not in remote_libraries:
|
||||||
raise ValidationError(f'One or more of your specified library IDs do '
|
raise ValidationError(f'One or more of your specified library IDs do '
|
||||||
f'not exist on your Plex Media Server. Your '
|
f'not exist on your Jellyfin Media Server. Your '
|
||||||
f'valid libraries are: {remote_libraries_str}')
|
f'valid libraries are: {remote_libraries_str}')
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
libraries = self.object.loaded_options.get('libraries', '').split(',')
|
libraries = self.object.loaded_options.get('libraries', '').split(',')
|
||||||
for library_id in libraries:
|
for library_id in map(str.strip, libraries):
|
||||||
library_id = library_id.strip()
|
|
||||||
uri = f'/Library/{library_id}/Refresh'
|
uri = f'/Library/{library_id}/Refresh'
|
||||||
response = self.make_request(uri)
|
response = self.make_request(uri)
|
||||||
if response.status_code != 204: # 204 No Content is expected for successful refresh
|
if response.status_code != 204: # 204 No Content is expected for successful refresh
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 3.2.25 on 2025-02-22 03:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0028_alter_source_source_resolution'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='mediaserver',
|
||||||
|
name='options',
|
||||||
|
field=models.TextField(help_text='JSON encoded options for the media server', null=True, verbose_name='options'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='mediaserver',
|
||||||
|
name='server_type',
|
||||||
|
field=models.CharField(choices=[('j', 'Jellyfin'), ('p', 'Plex')], db_index=True, default='p', help_text='Server type', max_length=1, verbose_name='server type'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='mediaserver',
|
||||||
|
name='use_https',
|
||||||
|
field=models.BooleanField(default=False, help_text='Connect to the media server over HTTPS', verbose_name='use https'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='mediaserver',
|
||||||
|
name='verify_https',
|
||||||
|
field=models.BooleanField(default=True, help_text='If connecting over HTTPS, verify the SSL certificate is valid', verbose_name='verify https'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -24,7 +24,6 @@ from .utils import (seconds_to_timestr, parse_media_format, filter_response,
|
|||||||
write_text_file, mkdir_p, directory_and_stem, glob_quote)
|
write_text_file, mkdir_p, directory_and_stem, glob_quote)
|
||||||
from .matching import (get_best_combined_format, get_best_audio_format,
|
from .matching import (get_best_combined_format, get_best_audio_format,
|
||||||
get_best_video_format)
|
get_best_video_format)
|
||||||
from .mediaservers import PlexMediaServer
|
|
||||||
from .fields import CommaSepChoiceField
|
from .fields import CommaSepChoiceField
|
||||||
from .choices import (Val, CapChoices, Fallback, FileExtension,
|
from .choices import (Val, CapChoices, Fallback, FileExtension,
|
||||||
FilterSeconds, IndexSchedule, MediaServerType,
|
FilterSeconds, IndexSchedule, MediaServerType,
|
||||||
@@ -1553,11 +1552,10 @@ class MediaServer(models.Model):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
ICONS = {
|
ICONS = {
|
||||||
|
Val(MediaServerType.JELLYFIN): '<i class="fas fa-server"></i>',
|
||||||
Val(MediaServerType.PLEX): '<i class="fas fa-server"></i>',
|
Val(MediaServerType.PLEX): '<i class="fas fa-server"></i>',
|
||||||
}
|
}
|
||||||
HANDLERS = {
|
HANDLERS = MediaServerType.handlers_dict()
|
||||||
Val(MediaServerType.PLEX): PlexMediaServer,
|
|
||||||
}
|
|
||||||
|
|
||||||
server_type = models.CharField(
|
server_type = models.CharField(
|
||||||
_('server type'),
|
_('server type'),
|
||||||
@@ -1580,17 +1578,17 @@ class MediaServer(models.Model):
|
|||||||
)
|
)
|
||||||
use_https = models.BooleanField(
|
use_https = models.BooleanField(
|
||||||
_('use https'),
|
_('use https'),
|
||||||
default=True,
|
default=False,
|
||||||
help_text=_('Connect to the media server over HTTPS')
|
help_text=_('Connect to the media server over HTTPS')
|
||||||
)
|
)
|
||||||
verify_https = models.BooleanField(
|
verify_https = models.BooleanField(
|
||||||
_('verify https'),
|
_('verify https'),
|
||||||
default=False,
|
default=True,
|
||||||
help_text=_('If connecting over HTTPS, verify the SSL certificate is valid')
|
help_text=_('If connecting over HTTPS, verify the SSL certificate is valid')
|
||||||
)
|
)
|
||||||
options = models.TextField(
|
options = models.TextField(
|
||||||
_('options'),
|
_('options'),
|
||||||
blank=True,
|
blank=False, # valid JSON only
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_('JSON encoded options for the media server')
|
help_text=_('JSON encoded options for the media server')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<form method="post" action="{% url 'sync:add-mediaserver' server_type='plex' %}" class="col s12 simpleform">
|
<form method="post" action="{% url 'sync:add-mediaserver' server_type=server_type_long %}" class="col s12 simpleform">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% include 'simpleform.html' with form=form %}
|
{% include 'simpleform.html' with form=form %}
|
||||||
<div class="row no-margin-bottom padding-top">
|
<div class="row no-margin-bottom padding-top">
|
||||||
|
|||||||
@@ -10,16 +10,18 @@
|
|||||||
Media servers are services like Plex which you may be running on your network. If
|
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
|
you add your media server TubeSync will notify your media server to rescan or
|
||||||
refresh its libraries every time media is successfully downloaded. Currently,
|
refresh its libraries every time media is successfully downloaded. Currently,
|
||||||
TubeSync only supports Plex.
|
TubeSync only supports Jellyfin and Plex.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% include 'infobox.html' with message=message %}
|
{% include 'infobox.html' with message=message %}
|
||||||
|
{% for mst in media_server_types %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col s12 margin-bottom">
|
<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>
|
<a href="{% url 'sync:add-mediaserver' server_type=mst.long_type %}" class="btn">Add a {{ mst.label }} media server <i class="fas fa-server"></i></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
<div class="row no-margin-bottom">
|
<div class="row no-margin-bottom">
|
||||||
<div class="col s12">
|
<div class="col s12">
|
||||||
<div class="collection">
|
<div class="collection">
|
||||||
|
|||||||
+10
-11
@@ -25,7 +25,7 @@ from common.utils import append_uri_params
|
|||||||
from background_task.models import Task, CompletedTask
|
from background_task.models import Task, CompletedTask
|
||||||
from .models import Source, Media, MediaServer
|
from .models import Source, Media, MediaServer
|
||||||
from .forms import (ValidateSourceForm, ConfirmDeleteSourceForm, RedownloadMediaForm,
|
from .forms import (ValidateSourceForm, ConfirmDeleteSourceForm, RedownloadMediaForm,
|
||||||
SkipMediaForm, EnableMediaForm, ResetTasksForm, PlexMediaServerForm,
|
SkipMediaForm, EnableMediaForm, ResetTasksForm,
|
||||||
ConfirmDeleteMediaServerForm)
|
ConfirmDeleteMediaServerForm)
|
||||||
from .utils import validate_url, delete_file
|
from .utils import validate_url, delete_file
|
||||||
from .tasks import (map_task_to_instance, get_error_message,
|
from .tasks import (map_task_to_instance, get_error_message,
|
||||||
@@ -879,6 +879,7 @@ class MediaServersView(ListView):
|
|||||||
|
|
||||||
template_name = 'sync/mediaservers.html'
|
template_name = 'sync/mediaservers.html'
|
||||||
context_object_name = 'mediaservers'
|
context_object_name = 'mediaservers'
|
||||||
|
types_object = MediaServerType
|
||||||
messages = {
|
messages = {
|
||||||
'deleted': _('Your selected media server has been deleted.'),
|
'deleted': _('Your selected media server has been deleted.'),
|
||||||
}
|
}
|
||||||
@@ -893,11 +894,12 @@ class MediaServersView(ListView):
|
|||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return MediaServer.objects.all().order_by('host')
|
return MediaServer.objects.all().order_by('host', 'port')
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
data = super().get_context_data(*args, **kwargs)
|
data = super().get_context_data(*args, **kwargs)
|
||||||
data['message'] = self.message
|
data['message'] = self.message
|
||||||
|
data['media_server_types'] = self.types_object.members_list()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -908,13 +910,9 @@ class AddMediaServerView(FormView):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
template_name = 'sync/mediaserver-add.html'
|
template_name = 'sync/mediaserver-add.html'
|
||||||
server_types = {
|
server_types = MediaServerType.long_types()
|
||||||
'plex': Val(MediaServerType.PLEX),
|
|
||||||
}
|
|
||||||
server_type_names = dict(MediaServerType.choices)
|
server_type_names = dict(MediaServerType.choices)
|
||||||
forms = {
|
forms = MediaServerType.forms_dict()
|
||||||
Val(MediaServerType.PLEX): PlexMediaServerForm,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.server_type = None
|
self.server_type = None
|
||||||
@@ -928,6 +926,8 @@ class AddMediaServerView(FormView):
|
|||||||
if not self.server_type:
|
if not self.server_type:
|
||||||
raise Http404
|
raise Http404
|
||||||
self.form_class = self.forms.get(self.server_type)
|
self.form_class = self.forms.get(self.server_type)
|
||||||
|
if not self.form_class:
|
||||||
|
raise Http404
|
||||||
self.model_class = MediaServer(server_type=self.server_type)
|
self.model_class = MediaServer(server_type=self.server_type)
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
@@ -969,6 +969,7 @@ class AddMediaServerView(FormView):
|
|||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
data = super().get_context_data(*args, **kwargs)
|
data = super().get_context_data(*args, **kwargs)
|
||||||
data['server_type'] = self.server_type
|
data['server_type'] = self.server_type
|
||||||
|
data['server_type_long'] = self.server_types.get(self.server_type)
|
||||||
data['server_type_name'] = self.server_type_names.get(self.server_type)
|
data['server_type_name'] = self.server_type_names.get(self.server_type)
|
||||||
data['server_help'] = self.model_class.get_help_html()
|
data['server_help'] = self.model_class.get_help_html()
|
||||||
return data
|
return data
|
||||||
@@ -1029,9 +1030,7 @@ class UpdateMediaServerView(FormView, SingleObjectMixin):
|
|||||||
|
|
||||||
template_name = 'sync/mediaserver-update.html'
|
template_name = 'sync/mediaserver-update.html'
|
||||||
model = MediaServer
|
model = MediaServer
|
||||||
forms = {
|
forms = MediaServerType.forms_dict()
|
||||||
Val(MediaServerType.PLEX): PlexMediaServerForm,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.object = None
|
self.object = None
|
||||||
|
|||||||
Reference in new issue
Block a user