mirror of
https://github.com/meeb/tubesync.git
synced 2025-06-22 13:06:34 +00:00
Merge pull request #756 from tcely/jellyfin-mediaserver
Jellyfin media server support
This commit is contained in:
commit
9e2d564336
@ -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(
|
||||||
|
@ -164,3 +164,89 @@ class PlexMediaServer(MediaServer):
|
|||||||
f'{response.status_code}. Check your media '
|
f'{response.status_code}. Check your media '
|
||||||
f'server details.')
|
f'server details.')
|
||||||
return True
|
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
tubesync/sync/migrations/0029_alter_mediaserver_fields.py
Normal file
33
tubesync/sync/migrations/0029_alter_mediaserver_fields.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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,
|
||||||
@ -1589,11 +1588,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'),
|
||||||
@ -1616,17 +1614,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">
|
||||||
|
@ -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,
|
||||||
@ -884,6 +884,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.'),
|
||||||
}
|
}
|
||||||
@ -898,11 +899,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
|
||||||
|
|
||||||
|
|
||||||
@ -913,13 +915,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
|
||||||
@ -933,6 +931,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)
|
||||||
|
|
||||||
@ -974,6 +974,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
|
||||||
@ -1034,9 +1035,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
|
||||||
|
Loading…
Reference in New Issue
Block a user