mirror of
https://github.com/meeb/tubesync.git
synced 2025-06-21 20:46:36 +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):
|
||||
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'
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
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)
|
||||
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')
|
||||
)
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user