Merge pull request #756 from tcely/jellyfin-mediaserver

Jellyfin media server support
This commit is contained in:
meeb 2025-02-24 17:46:09 +11:00 committed by GitHub
commit 9e2d564336
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 218 additions and 21 deletions

View File

@ -65,8 +65,55 @@ class IndexSchedule(models.IntegerChoices):
class MediaServerType(models.TextChoices):
JELLYFIN = 'j', _('Jellyfin')
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):
UNKNOWN = 'unknown'

View File

@ -48,7 +48,39 @@ class ConfirmDeleteMediaServerForm(forms.Form):
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):
host = forms.CharField(

View File

@ -164,3 +164,89 @@ class PlexMediaServer(MediaServer):
f'{response.status_code}. Check your media '
f'server details.')
return True
class JellyfinMediaServer(MediaServer):
TIMEOUT = 5
HELP = _('<p>To connect your TubeSync server to your Jellyfin Media Server, please enter the details below.</p>'
'<p>The <strong>host</strong> can be either an IP address or a valid hostname.</p>'
'<p>The <strong>port</strong> should be between 1 and 65536.</p>'
'<p>The <strong>token</strong> is required for API access. You can generate a token in your Jellyfin user profile settings.</p>'
'<p>The <strong>libraries</strong> is a comma-separated list of library IDs in Jellyfin.</p>')
def make_request(self, uri='/', params={}):
headers = {
'User-Agent': 'TubeSync',
'X-Emby-Token': self.object.loaded_options['token'] # Jellyfin uses the same `X-Emby-Token` header as Emby
}
url = f'{self.object.url}{uri}'
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)
def validate(self):
if not self.object.host:
raise ValidationError('Jellyfin Media Server requires a "host"')
if not self.object.port:
raise ValidationError('Jellyfin Media Server requires a "port"')
try:
port = int(self.object.port)
if port < 1 or port > 65535:
raise ValidationError('Jellyfin Media Server "port" must be between 1 and 65535')
except (TypeError, ValueError):
raise ValidationError('Jellyfin Media Server "port" must be an integer')
options = self.object.loaded_options
if 'token' not in options:
raise ValidationError('Jellyfin Media Server requires a "token"')
if 'libraries' not in options:
raise ValidationError('Jellyfin Media Server requires a "libraries"')
# Test connection and fetch libraries
try:
response = self.make_request('/Library/MediaFolders', params={'Recursive': 'true', 'IncludeItemTypes': 'CollectionFolder'})
if response.status_code != 200:
raise ValidationError(f'Failed to connect to Jellyfin server: {response.status_code}')
data = response.json()
if 'Items' not in data:
raise ValidationError('Jellyfin Media Server returned unexpected data.')
except Exception as e:
raise ValidationError(f'Connection error: {e}')
# Seems we have a valid library sections page, get the library IDs
remote_libraries = {}
try:
for d in data['Items']:
library_id = d['Id']
library_name = d['Name']
remote_libraries[library_id] = library_name
except Exception as e:
raise ValidationError(f'Jellyfin Media Server returned unexpected data, '
f'the JSON 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)
libraries = options.get('libraries', '').split(',')
for library_id in map(str.strip, libraries):
if library_id not in remote_libraries:
raise ValidationError(f'One or more of your specified library IDs do '
f'not exist on your Jellyfin Media Server. Your '
f'valid libraries are: {remote_libraries_str}')
return True
def update(self):
libraries = self.object.loaded_options.get('libraries', '').split(',')
for library_id in map(str.strip, libraries):
uri = f'/Library/{library_id}/Refresh'
response = self.make_request(uri)
if response.status_code != 204: # 204 No Content is expected for successful refresh
raise MediaServerError(f'Failed to refresh Jellyfin library "{library_id}", status code: {response.status_code}')
return True

View File

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

View File

@ -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)
from .matching import (get_best_combined_format, get_best_audio_format,
get_best_video_format)
from .mediaservers import PlexMediaServer
from .fields import CommaSepChoiceField
from .choices import (Val, CapChoices, Fallback, FileExtension,
FilterSeconds, IndexSchedule, MediaServerType,
@ -1589,11 +1588,10 @@ class MediaServer(models.Model):
'''
ICONS = {
Val(MediaServerType.JELLYFIN): '<i class="fas fa-server"></i>',
Val(MediaServerType.PLEX): '<i class="fas fa-server"></i>',
}
HANDLERS = {
Val(MediaServerType.PLEX): PlexMediaServer,
}
HANDLERS = MediaServerType.handlers_dict()
server_type = models.CharField(
_('server type'),
@ -1616,17 +1614,17 @@ class MediaServer(models.Model):
)
use_https = models.BooleanField(
_('use https'),
default=True,
default=False,
help_text=_('Connect to the media server over HTTPS')
)
verify_https = models.BooleanField(
_('verify https'),
default=False,
default=True,
help_text=_('If connecting over HTTPS, verify the SSL certificate is valid')
)
options = models.TextField(
_('options'),
blank=True,
blank=False, # valid JSON only
null=True,
help_text=_('JSON encoded options for the media server')
)

View File

@ -14,7 +14,7 @@
</div>
</div>
<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 %}
{% include 'simpleform.html' with form=form %}
<div class="row no-margin-bottom padding-top">

View File

@ -10,16 +10,18 @@
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.
TubeSync only supports Jellyfin and Plex.
</p>
</div>
</div>
{% include 'infobox.html' with message=message %}
{% for mst in media_server_types %}
<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>
<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>
{% endfor %}
<div class="row no-margin-bottom">
<div class="col s12">
<div class="collection">

View File

@ -25,7 +25,7 @@ from common.utils import append_uri_params
from background_task.models import Task, CompletedTask
from .models import Source, Media, MediaServer
from .forms import (ValidateSourceForm, ConfirmDeleteSourceForm, RedownloadMediaForm,
SkipMediaForm, EnableMediaForm, ResetTasksForm, PlexMediaServerForm,
SkipMediaForm, EnableMediaForm, ResetTasksForm,
ConfirmDeleteMediaServerForm)
from .utils import validate_url, delete_file
from .tasks import (map_task_to_instance, get_error_message,
@ -884,6 +884,7 @@ class MediaServersView(ListView):
template_name = 'sync/mediaservers.html'
context_object_name = 'mediaservers'
types_object = MediaServerType
messages = {
'deleted': _('Your selected media server has been deleted.'),
}
@ -898,11 +899,12 @@ class MediaServersView(ListView):
return super().dispatch(request, *args, **kwargs)
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):
data = super().get_context_data(*args, **kwargs)
data['message'] = self.message
data['media_server_types'] = self.types_object.members_list()
return data
@ -913,13 +915,9 @@ class AddMediaServerView(FormView):
'''
template_name = 'sync/mediaserver-add.html'
server_types = {
'plex': Val(MediaServerType.PLEX),
}
server_types = MediaServerType.long_types()
server_type_names = dict(MediaServerType.choices)
forms = {
Val(MediaServerType.PLEX): PlexMediaServerForm,
}
forms = MediaServerType.forms_dict()
def __init__(self, *args, **kwargs):
self.server_type = None
@ -933,6 +931,8 @@ class AddMediaServerView(FormView):
if not self.server_type:
raise Http404
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)
return super().dispatch(request, *args, **kwargs)
@ -974,6 +974,7 @@ class AddMediaServerView(FormView):
def get_context_data(self, *args, **kwargs):
data = super().get_context_data(*args, **kwargs)
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_help'] = self.model_class.get_help_html()
return data
@ -1034,9 +1035,7 @@ class UpdateMediaServerView(FormView, SingleObjectMixin):
template_name = 'sync/mediaserver-update.html'
model = MediaServer
forms = {
Val(MediaServerType.PLEX): PlexMediaServerForm,
}
forms = MediaServerType.forms_dict()
def __init__(self, *args, **kwargs):
self.object = None