import requests import json import logging import time from typing import Optional, Dict, List, Any, Union, Tuple from urllib.parse import urljoin, urlparse from dataclasses import dataclass, asdict import urllib3 logger = logging.getLogger(__name__) @dataclass class JellyfinAuthResult: success: bool error: Optional[str] = None server_url: Optional[str] = None user_id: Optional[str] = None access_token: Optional[str] = None user_name: Optional[str] = None server_name: Optional[str] = None server_version: Optional[str] = None def to_dict(self) -> dict: return {k: v for k, v in asdict(self).items() if v is not None} class JellyfinService: """Service for interacting with Jellyfin media server with enhanced error handling.""" def __init__(self): """Initialize the Jellyfin service with proper session management.""" self.server_url: Optional[str] = None self.access_token: Optional[str] = None self.user_id: Optional[str] = None self.user_name: Optional[str] = None self.server_name: Optional[str] = None self.server_version: Optional[str] = None self.last_auth_time: float = 0 self.session = self._create_session() def _create_session(self) -> requests.Session: """Create a new requests session with default settings.""" session = requests.Session() session.max_redirects = 3 session.verify = False # Disable SSL verification for self-signed cert return session def _normalize_server_url(self, url: str) -> str: """Normalize and validate the server URL.""" if not url: raise ValueError("Server URL cannot be empty") # Ensure URL has a scheme if not url.startswith(('http://', 'https://')): url = f'https://{url}' # Parse and reconstruct URL to ensure it's valid parsed = urlparse(url) if not parsed.netloc: raise ValueError(f"Invalid server URL: {url}") # Reconstruct URL with proper scheme and netloc return f"{parsed.scheme}://{parsed.netloc}" def _test_connection(self, server_url: str) -> Tuple[bool, str, Dict]: """Test connection to Jellyfin server and get system info.""" urls = [ server_url, # Try the provided URL first 'http://localhost:8096', # Try localhost Jellyfin port 'http://samcloud:8096', # Try local hostname with Jellyfin port 'https://stream.samcloud.ca', # Try stream subdomain (HTTPS) 'https://jellyfin.samcloud.ca' # Try jellyfin subdomain (HTTPS) ] for url in urls: try: system_info_url = urljoin(url, 'System/Info/Public') logger.info(f"Testing connection to Jellyfin at: {system_info_url}") with requests.Session() as temp_session: response = temp_session.get(system_info_url, timeout=10, verify=False) if response.status_code == 200: system_info = response.json() self.server_url = url # Save the working URL return True, "Connection successful", system_info except requests.exceptions.RequestException as e: logger.warning(f"Failed to connect to {url}: {str(e)}") continue return False, "Could not connect to any Jellyfin server", {} def authenticate(self, server_url: str, username: str, password: str) -> JellyfinAuthResult: """ Authenticate with Jellyfin server with enhanced error handling. Args: server_url: The Jellyfin server URL username: Username for authentication password: Password for authentication Returns: JellyfinAuthResult with authentication status and details """ result = JellyfinAuthResult(success=False) try: # Normalize and validate server URL server_url = self._normalize_server_url(server_url) result.server_url = server_url # Test server connection and get system info connected, error_msg, system_info = self._test_connection(server_url) if not connected: result.error = f"Could not connect to server: {error_msg}" return result # Extract server information self.server_name = system_info.get('ServerName', 'Jellyfin Server') self.server_version = system_info.get('Version', 'Unknown') # Prepare authentication request auth_url = urljoin(server_url, 'Users/AuthenticateByName') auth_headers = { 'X-Emby-Authorization': ( 'MediaBrowser ' 'Client="SamCloud", ' 'Device="Web", ' 'DeviceId="samcloud-web-client", ' 'Version="1.0.0"' ), 'Content-Type': 'application/json', 'Accept': 'application/json' } auth_data = { 'Username': username, 'Pw': password } logger.info(f"Authenticating user '{username}' at {server_url}") # Use a fresh session for authentication with requests.Session() as auth_session: response = auth_session.post( auth_url, json=auth_data, headers=auth_headers, timeout=15, verify=True ) if response.status_code != 200: error_msg = "Authentication failed" try: error_data = response.json() error_msg = error_data.get('Message', error_msg) except: error_msg = f"HTTP {response.status_code}: {response.text[:200]}" result.error = error_msg logger.error(f"Authentication failed: {error_msg}") return result # Parse successful authentication auth_result = response.json() self.access_token = auth_result.get('AccessToken') user_data = auth_result.get('User', {}) self.user_id = user_data.get('Id') self.user_name = user_data.get('Name', username) if not self.access_token or not self.user_id: result.error = "Invalid authentication response from server" return result # Update session with auth token self.session = self._create_session() self.session.headers.update({ 'X-Emby-Token': self.access_token, 'X-Emby-Authorization': ( f'MediaBrowser ' f'Client="SamCloud", ' f'Token="{self.access_token}"' ) }) # Update last auth time self.last_auth_time = time.time() # Set success result result.success = True result.access_token = self.access_token result.user_id = self.user_id result.user_name = self.user_name result.server_name = self.server_name result.server_version = self.server_version logger.info(f"Successfully authenticated as {self.user_name}") return result except requests.exceptions.RequestException as e: logger.error(f"Request error during authentication: {str(e)}") result.error = f"Connection error: {str(e)}" return result except Exception as e: logger.error(f"Authentication error: {str(e)}", exc_info=True) result.error = f"Authentication failed: {str(e)}" return result 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, f'Users/{self.user_id}/Items') logger.info(f"Searching library at {url} with query: {query}") # Use longer timeout for search and retry once if it fails try: response = self.session.get(url, params=params, timeout=30) except requests.exceptions.Timeout: logger.warning("Search request timed out, retrying once...") response = self.session.get(url, params=params, timeout=30) if response.status_code == 200: result = response.json() logger.info(f"Search found {len(result.get('Items', []))} items") return result else: error_msg = f"Failed to search library: {response.status_code}" try: error_data = response.json() if 'Message' in error_data: error_msg = error_data['Message'] except: pass logger.error(error_msg) return {'Items': [], 'TotalRecordCount': 0, 'Error': error_msg} except requests.exceptions.Timeout as e: error_msg = "Search timed out - server may be busy" logger.error(f"{error_msg}: {str(e)}") return {'Items': [], 'TotalRecordCount': 0, 'Error': error_msg} except requests.exceptions.RequestException as e: error_msg = f"Connection error during search: {str(e)}" logger.error(error_msg) return {'Items': [], 'TotalRecordCount': 0, 'Error': error_msg} except Exception as e: error_msg = f"Error searching library: {str(e)}" logger.error(error_msg, exc_info=True) return {'Items': [], 'TotalRecordCount': 0, 'Error': error_msg} 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