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 @@