import requests import json import logging from typing import Optional, Dict, List, Any, Union from urllib.parse import urljoin logger = logging.getLogger(__name__) class JellyfinService: """Service for interacting with Jellyfin media server.""" def __init__(self): """Initialize the Jellyfin service.""" self.server_url: Optional[str] = None self.access_token: Optional[str] = None self.user_id: Optional[str] = None self.session = requests.Session() # Don't set default headers - they may interfere with authentication def authenticate(self, server_url: str, username: str, password: str) -> Dict[str, Any]: """ Authenticate with Jellyfin server. Args: server_url: The Jellyfin server URL username: Username for authentication password: Password for authentication Returns: Dict with authentication result """ try: # Clean up and auto-detect protocol for server URL server_url = self._normalize_server_url(server_url) self.server_url = server_url logger.info(f"Attempting to connect to Jellyfin at: {server_url}") # Test server connectivity first try: system_info_url = urljoin(server_url, 'System/Info/Public') logger.info(f"Testing connectivity to: {system_info_url}") response = self.session.get(system_info_url, timeout=15) if response.status_code != 200: return { 'success': False, 'error': f'Unable to connect to Jellyfin server at {server_url}. Status: {response.status_code}' } logger.info("Server connectivity test successful") except requests.exceptions.RequestException as e: logger.error(f"Connectivity test failed: {str(e)}") return { 'success': False, 'error': f'Connection failed: {str(e)}' } # Authenticate with a fresh request (don't use session to avoid header conflicts) auth_url = urljoin(server_url, 'Users/AuthenticateByName') auth_data = { 'Username': username, 'Pw': password } # Clear any existing session headers that might interfere self.session.headers.clear() # Use exact headers that work in curl auth_headers = { 'X-Emby-Authorization': f'MediaBrowser Client="SamCloudMusic", Device="Web", DeviceId="samcloud-web-client", Version="1.0.0"', 'Content-Type': 'application/json' } logger.info(f"Authenticating user '{username}' at: {auth_url}") # Use requests directly instead of session to avoid any header interference response = requests.post(auth_url, json=auth_data, headers=auth_headers, timeout=15) if response.status_code == 200: try: auth_result = response.json() self.access_token = auth_result.get('AccessToken') if not self.access_token: return { 'success': False, 'error': 'Authentication response missing access token' } user_data = auth_result.get('User', {}) self.user_id = user_data.get('Id') if not self.user_id: return { 'success': False, 'error': 'Authentication response missing user ID' } # Update session headers with auth token self.session.headers.update({ 'X-Emby-Token': self.access_token, 'X-Emby-Authorization': f'MediaBrowser Client="SamCloudMusic", Device="Web", DeviceId="samcloud-web-client", Version="1.0.0", Token="{self.access_token}"' }) logger.info(f"Successfully authenticated with Jellyfin as {username}") return { 'success': True, 'user': user_data, 'server_info': { 'name': auth_result.get('ServerName', 'Jellyfin Server'), 'version': auth_result.get('Version', 'Unknown'), 'url': server_url } } except (ValueError, KeyError) as e: logger.error(f"Invalid JSON response from Jellyfin auth: {str(e)}") return { 'success': False, 'error': 'Invalid response from Jellyfin server' } else: error_msg = f'Authentication failed with status {response.status_code}' try: if response.headers.get('content-type', '').startswith('application/json'): error_detail = response.json() if 'ErrorMessage' in error_detail: error_msg = error_detail['ErrorMessage'] except: pass if response.status_code == 401: error_msg = 'Authentication failed - Invalid username or password' elif response.status_code == 404: error_msg = 'Jellyfin server not found at this URL' elif response.status_code == 403: error_msg = 'Access denied - Check user permissions' logger.error(f"Authentication failed: {error_msg}") return { 'success': False, 'error': error_msg } except requests.exceptions.Timeout: return { 'success': False, 'error': 'Connection timeout - server may be unreachable' } except requests.exceptions.RequestException as e: logger.error(f"Request error during authentication: {str(e)}") return { 'success': False, 'error': f'Connection error: {str(e)}' } except Exception as e: logger.error(f"Unexpected authentication error: {str(e)}") return { 'success': False, 'error': f'Authentication failed: {str(e)}' } def _normalize_server_url(self, server_url: str) -> str: """Normalize server URL with proper protocol detection.""" # Remove any trailing slashes server_url = server_url.rstrip('/') # If no protocol specified, try HTTPS first, then HTTP if not server_url.startswith(('http://', 'https://')): # Try HTTPS first for security https_url = f'https://{server_url}' try: logger.info(f"Testing HTTPS connection to: {https_url}") response = self.session.get(f'{https_url}/System/Info/Public', timeout=10) if response.status_code == 200: logger.info(f"HTTPS connection successful to: {https_url}") return https_url except Exception as e: logger.info(f"HTTPS failed for {server_url}: {str(e)}") # Fall back to HTTP http_url = f'http://{server_url}' try: logger.info(f"Testing HTTP connection to: {http_url}") response = self.session.get(f'{http_url}/System/Info/Public', timeout=10) if response.status_code == 200: logger.info(f"HTTP connection successful to: {http_url}") return http_url except Exception as e: logger.info(f"HTTP also failed for {server_url}: {str(e)}") # If both fail, return HTTPS as default logger.warning(f"Both HTTPS and HTTP failed for: {server_url}, defaulting to HTTPS") return https_url return server_url def get_music_libraries(self) -> List[Dict[str, Any]]: """Get all music libraries from Jellyfin.""" if not self.access_token or not self.server_url or not self.user_id: return [] try: url = urljoin(self.server_url, f'Users/{self.user_id}/Views') response = self.session.get(url, timeout=10) if response.status_code == 200: libraries = response.json().get('Items', []) music_libraries = [ lib for lib in libraries if lib.get('CollectionType') == 'music' ] return music_libraries else: logger.error(f"Failed to get libraries: {response.status_code}") return [] except Exception as e: logger.error(f"Error getting libraries: {str(e)}") return [] def get_library_items(self, library_id: Optional[str] = None, item_type: str = 'Audio', limit: int = 200, start_index: int = 0) -> Dict[str, Any]: """ Get items from music library. Args: library_id: Optional library ID to filter by item_type: Type of items (Audio, MusicAlbum, MusicArtist) limit: Number of items to return start_index: Starting index for pagination Returns: Dict with items and metadata """ if not self.access_token or not self.server_url or not self.user_id: logger.warning("Cannot get library items: Not authenticated") return {'Items': [], 'TotalRecordCount': 0} try: params = { 'UserId': self.user_id, 'IncludeItemTypes': item_type, 'Recursive': 'true', 'Fields': 'BasicSyncInfo,PrimaryImageAspectRatio,ProductionYear,Status,EndDate', 'ImageTypeLimit': '1', 'EnableImageTypes': 'Primary,Backdrop,Thumb', 'StartIndex': start_index, 'Limit': limit, 'SortBy': 'SortName', 'SortOrder': 'Ascending' } if library_id: params['ParentId'] = library_id url = urljoin(self.server_url, f'Users/{self.user_id}/Items') logger.info(f"Fetching library items from: {url} with params: {params}") response = self.session.get(url, params=params, timeout=15) if response.status_code == 200: result = response.json() logger.info(f"Successfully retrieved {len(result.get('Items', []))} library items") return result else: logger.error(f"Failed to get library items: {response.status_code}") # Try to extract error message if available error_msg = "Unknown error" try: error_data = response.json() error_msg = error_data.get('ErrorMessage', f"Status code: {response.status_code}") except: error_msg = f"Status code: {response.status_code}" logger.error(f"Jellyfin API error: {error_msg}") return {'Items': [], 'TotalRecordCount': 0, 'Error': error_msg} except Exception as e: logger.error(f"Error getting library items: {str(e)}", exc_info=True) return {'Items': [], 'TotalRecordCount': 0, 'Error': str(e)} def get_audio_stream_url(self, item_id: str) -> Optional[str]: """Get direct stream URL for an audio item.""" if not self.access_token or not self.server_url or not self.user_id: return None try: # Get playback info first playback_url = urljoin(self.server_url, f'Items/{item_id}/PlaybackInfo') playback_params = { 'UserId': self.user_id, 'DeviceId': 'samcloud-music-web', 'MaxStreamingBitrate': 320000, 'Container': 'mp3,flac,m4a,ogg', 'AudioCodec': 'mp3,flac,aac,ogg', 'TranscodingContainer': 'mp3', 'TranscodingProtocol': 'http' } response = self.session.post(playback_url, json=playback_params, timeout=10) if response.status_code == 200: playback_info = response.json() media_sources = playback_info.get('MediaSources', []) if media_sources: media_source = media_sources[0] if media_source.get('SupportsDirectStream'): # Direct stream stream_url = urljoin( self.server_url, f'Audio/{item_id}/stream?api_key={self.access_token}' ) else: # Transcoded stream stream_url = urljoin( self.server_url, f'Audio/{item_id}/universal?UserId={self.user_id}&DeviceId=samcloud-music-web&MaxStreamingBitrate=320000&Container=mp3&api_key={self.access_token}' ) return stream_url # Fallback to universal audio endpoint return urljoin( self.server_url, f'Audio/{item_id}/universal?UserId={self.user_id}&DeviceId=samcloud-music-web&MaxStreamingBitrate=320000&Container=mp3&api_key={self.access_token}' ) except Exception as e: logger.error(f"Error getting stream URL: {str(e)}") return None def get_image_url(self, item_id: str, image_type: str = 'Primary') -> Optional[str]: """Get image URL for an item.""" if not self.access_token or not self.server_url: return None try: return urljoin( self.server_url, f'Items/{item_id}/Images/{image_type}?api_key={self.access_token}' ) except Exception as e: logger.error(f"Error getting image URL: {str(e)}") return None def search_library(self, query: str, limit: int = 50) -> Dict[str, Any]: """Search the music library.""" if not self.access_token or not self.server_url or not self.user_id: return {'Items': [], 'TotalRecordCount': 0} try: params = { 'UserId': self.user_id, 'SearchTerm': query, 'IncludeItemTypes': 'Audio,MusicAlbum,MusicArtist', 'Recursive': 'true', 'Fields': 'BasicSyncInfo,PrimaryImageAspectRatio', 'ImageTypeLimit': '1', 'EnableImageTypes': 'Primary,Backdrop,Thumb', 'Limit': limit } url = urljoin(self.server_url, 'Users/{}/Items'.format(self.user_id)) response = self.session.get(url, params=params, timeout=10) if response.status_code == 200: return response.json() else: logger.error(f"Failed to search library: {response.status_code}") return {'Items': [], 'TotalRecordCount': 0} except Exception as e: logger.error(f"Error searching library: {str(e)}") return {'Items': [], 'TotalRecordCount': 0} def is_authenticated(self) -> bool: """Check if the service is authenticated.""" return self.access_token is not None and self.user_id is not None