diff --git a/tubesync/sync/choices.py b/tubesync/sync/choices.py index f9614299..f0c6e45a 100644 --- a/tubesync/sync/choices.py +++ b/tubesync/sync/choices.py @@ -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' diff --git a/tubesync/sync/forms.py b/tubesync/sync/forms.py index c816a23b..3d795a5f 100644 --- a/tubesync/sync/forms.py +++ b/tubesync/sync/forms.py @@ -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( diff --git a/tubesync/sync/mediaservers.py b/tubesync/sync/mediaservers.py index f28fe063..3b8e558e 100644 --- a/tubesync/sync/mediaservers.py +++ b/tubesync/sync/mediaservers.py @@ -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 = _('

To connect your TubeSync server to your Jellyfin Media Server, please enter the details below.

' + '

The host can be either an IP address or a valid hostname.

' + '

The port should be between 1 and 65536.

' + '

The token is required for API access. You can generate a token in your Jellyfin user profile settings.

' + '

The libraries is a comma-separated list of library IDs in Jellyfin.

') + + 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 diff --git a/tubesync/sync/migrations/0029_alter_mediaserver_fields.py b/tubesync/sync/migrations/0029_alter_mediaserver_fields.py new file mode 100644 index 00000000..b7363430 --- /dev/null +++ b/tubesync/sync/migrations/0029_alter_mediaserver_fields.py @@ -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'), + ), + ] diff --git a/tubesync/sync/models.py b/tubesync/sync/models.py index df57665d..4db52870 100644 --- a/tubesync/sync/models.py +++ b/tubesync/sync/models.py @@ -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, @@ -1589,11 +1588,10 @@ class MediaServer(models.Model): ''' ICONS = { + Val(MediaServerType.JELLYFIN): '', Val(MediaServerType.PLEX): '', } - 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') ) diff --git a/tubesync/sync/templates/sync/mediaserver-add.html b/tubesync/sync/templates/sync/mediaserver-add.html index 4a7c69cf..e0c4dc13 100644 --- a/tubesync/sync/templates/sync/mediaserver-add.html +++ b/tubesync/sync/templates/sync/mediaserver-add.html @@ -14,7 +14,7 @@

Add a {{ server_type_name }} media server

-
+ {% csrf_token %} {% include 'simpleform.html' with form=form %}
diff --git a/tubesync/sync/templates/sync/mediaservers.html b/tubesync/sync/templates/sync/mediaservers.html index 22bf2280..469a1dd1 100644 --- a/tubesync/sync/templates/sync/mediaservers.html +++ b/tubesync/sync/templates/sync/mediaservers.html @@ -10,16 +10,18 @@

Media servers

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.

{% include 'infobox.html' with message=message %} +{% for mst in media_server_types %}
- Add a Plex media server + Add a {{ mst.label }} media server
+{% endfor %}
diff --git a/tubesync/sync/views.py b/tubesync/sync/views.py index 09fa5025..cbba2f4d 100644 --- a/tubesync/sync/views.py +++ b/tubesync/sync/views.py @@ -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, @@ -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 @@ 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 @@ -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 @@ 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) @@ -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 @@ -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