Skip to content

Commit

Permalink
Merge pull request #756 from tcely/jellyfin-mediaserver
Browse files Browse the repository at this point in the history
Jellyfin media server support
  • Loading branch information
meeb authored Feb 24, 2025
2 parents 452222a + 9761017 commit 9e2d564
Show file tree
Hide file tree
Showing 8 changed files with 218 additions and 21 deletions.
47 changes: 47 additions & 0 deletions tubesync/sync/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
32 changes: 32 additions & 0 deletions tubesync/sync/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
86 changes: 86 additions & 0 deletions tubesync/sync/mediaservers.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,89 @@ def update(self):
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
33 changes: 33 additions & 0 deletions tubesync/sync/migrations/0029_alter_mediaserver_fields.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
12 changes: 5 additions & 7 deletions tubesync/sync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
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,
Expand Down Expand Up @@ -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'),
Expand All @@ -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')
)
Expand Down
2 changes: 1 addition & 1 deletion tubesync/sync/templates/sync/mediaserver-add.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ <h1>Add a {{ server_type_name }} media server</h1>
</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">
Expand Down
6 changes: 4 additions & 2 deletions tubesync/sync/templates/sync/mediaservers.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@ <h1 class="truncate">Media servers</h1>
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">
Expand Down
21 changes: 10 additions & 11 deletions tubesync/sync/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
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,
Expand Down Expand Up @@ -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.'),
}
Expand All @@ -898,11 +899,12 @@ def dispatch(self, request, *args, **kwargs):
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


Expand All @@ -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
Expand All @@ -933,6 +931,8 @@ def dispatch(self, request, *args, **kwargs):
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)

Expand Down Expand Up @@ -974,6 +974,7 @@ def form_valid(self, form):
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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 9e2d564

Please sign in to comment.