import warnings from xml.etree import ElementTree import requests from django.forms import ValidationError from urllib.parse import urlsplit, urlunsplit, urlencode from django.utils.translation import gettext_lazy as _ from common.logger import log from django.conf import settings class MediaServerError(Exception): ''' Raised when a back-end error occurs. ''' pass class MediaServer: TIMEOUT = 0 HELP = '' default_headers = {'User-Agent': 'TubeSync'} def __init__(self, mediaserver_instance): self.object = mediaserver_instance self.headers = dict(**self.default_headers) self.token = None def make_request_args(self, uri='/', token_header=None, headers={}, token_param=None, params={}): base_parts = urlsplit(self.object.url) if self.token is None: self.token = self.object.options['token'] or None if token_header and self.token: headers.update({token_header: self.token}) self.headers.update(headers) if token_param and self.token: params.update({token_param: self.token}) qs = urlencode(params) enable_verify = ( base_parts.scheme.endswith('s') and self.object.verify_https ) url = urlunsplit((base_parts.scheme, base_parts.netloc, uri, qs, '')) return (url, dict( headers=self.headers, verify=enable_verify, timeout=self.TIMEOUT, )) def make_request(self, uri='/', /, *, headers={}, params={}): ''' A very simple implementation is: url, kwargs = self.make_request_args(uri=uri, headers=headers, params=params) return requests.get(url, **kwargs) ''' raise NotImplementedError('MediaServer.make_request() must be implemented') def validate(self): ''' Called to check that the configured media server values are correct. ''' raise NotImplementedError('MediaServer.validate() must be implemented') def update(self): ''' Called after the `Media` instance has saved a downloaded file. ''' raise NotImplementedError('MediaServer.update() must be implemented') class PlexMediaServer(MediaServer): TIMEOUT = 5 HELP = _('
To connect your TubeSync sevrer to your Plex Media Server you will ' 'need to enter the details of your Plex server below.
' 'The host can be either an IP address or valid hostname.
' 'The port number must be between 1 and 65536.
' 'The token is a Plex access token to your Plex server. You can find ' 'out how to get a Plex access token here.
' 'The libraries is a comma-separated list of Plex ' 'library or section IDs, you can find out how to get your library or ' 'section IDs here or ' 'here
.') def make_request(self, uri='/', /, *, headers={}, params={}): url, kwargs = self.make_request_args(uri=uri, headers=headers, token_param='X-Plex-Token', params=params) log.debug(f'[plex media server] Making HTTP GET request to: {url}') if self.object.use_https and not kwargs['verify']: # If not validating SSL, given this is likely going to be for an internal # or private network, that Plex issues certs *.hash.plex.direct and that # the warning won't ever been sensibly seen in the HTTPS logs, hide it with warnings.catch_warnings(): warnings.simplefilter("ignore") return requests.get(url, **kwargs) return requests.get(url, **kwargs) def validate(self): ''' A Plex server requires a host, port, access token and a comma-separated list of library IDs. ''' # Check all the required values are present if not self.object.host: raise ValidationError('Plex Media Server requires a "host"') if not self.object.port: raise ValidationError('Plex Media Server requires a "port"') try: port = int(self.object.port) except (TypeError, ValueError): raise ValidationError('Plex Media Server "port" must be an integer') if port < 1 or port > 65535: raise ValidationError('Plex Media Server "port" must be between 1 ' 'and 65535') options = self.object.options if 'token' not in options: raise ValidationError('Plex Media Server requires a "token"') token = options['token'].strip() if 'token' not in options: raise ValidationError('Plex Media Server requires a "token"') if 'libraries' not in options: raise ValidationError('Plex Media Server requires a "libraries"') libraries = options['libraries'].strip().split(',') for position, library in enumerate(libraries): library = library.strip() try: int(library) except (TypeError, ValueError): raise ValidationError(f'Plex Media Server library ID "{library}" at ' f'position {position+1} must be an integer') # Test the details work by requesting a summary page from the Plex server try: response = self.make_request('/library/sections') except Exception as e: raise ValidationError(f'Failed to make a test connection to your Plex ' f'Media Server at "{self.object.host}:' f'{self.object.port}", the error was "{e}". Check ' 'your host and port are correct.') from e if response.status_code != 200: check_token = '' if 400 <= response.status_code < 500: check_token = (' A 4XX error could mean your access token is being ' 'rejected. Check your token is correct.') raise ValidationError(f'Your Plex Media Server returned an invalid HTTP ' f'status code, expected 200 but received ' f'{response.status_code}.' + check_token) try: parsed_response = ElementTree.fromstring(response.content) except Exception as e: raise ValidationError(f'Your Plex Media Server returned unexpected data, ' f'expected valid XML but parsing it as XML caused ' f'the error "{e}"') # Seems we have a valid library sections page, get the library IDs remote_libraries = {} try: for parent in parsed_response.iter('MediaContainer'): for d in parent: library_id = d.attrib['key'] library_name = d.attrib['title'] remote_libraries[library_id] = library_name except Exception as e: raise ValidationError(f'Your Plex Media Server returned unexpected data, ' f'the XML 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) for library_id in libraries: library_id = library_id.strip() if library_id not in remote_libraries: raise ValidationError(f'One or more of your specified library IDs do ' f'not exist on your Plex Media Server. Your ' f'valid libraries are: {remote_libraries_str}') # All good! return True def update(self): # For each section / library ID pop off a request to refresh it libraries = self.object.options.get('libraries', '') for library_id in libraries.split(','): library_id = library_id.strip() uri = f'/library/sections/{library_id}/refresh' response = self.make_request(uri) if response.status_code != 200: raise MediaServerError(f'Failed to refresh library "{library_id}" on ' f'Plex server "{self.object.url}", expected a ' f'200 status code but got ' 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 "API Key" token is required for API access. Your Jellyfin administrator can generate an "API Key" token for use with TubeSync for you.
' 'The libraries is a comma-separated list of library IDs in Jellyfin. Leave this blank to see a list.
') def make_request(self, uri='/', /, *, headers={}, params={}, data={}, json=None, method='GET'): assert method in {'GET', 'POST'}, f'Unimplemented method: {method}' headers.update({'Content-Type': 'application/json'}) url, kwargs = self.make_request_args(uri=uri, token_header='X-Emby-Token', headers=headers, params=params) # From the Emby source code; # this is the order in which the headers are tried: # X-Emby-Authorization: ('MediaBrowser'|'Emby') 'Token'=