diff --git a/web_server.py b/web_server.py index 8f5e6a08..280135f4 100644 --- a/web_server.py +++ b/web_server.py @@ -119,41 +119,192 @@ def run_service_test(service, test_config): def run_detection(server_type): """ - Performs network detection for a given server type (plex, jellyfin, slskd). - This is a blocking function that scans the network. + Performs comprehensive network detection for a given server type (plex, jellyfin, slskd). + This implements the same scanning logic as the GUI's detection threads. """ - # This is a simplified version of the logic in your QThreads. - # A full implementation would be more extensive. - # For demonstration, we'll check common local addresses. - print(f"Running detection for {server_type}...") - common_ips = ["localhost", "127.0.0.1"] - ports = { - 'plex': 32400, - 'jellyfin': 8096, - 'slskd': 5030 - } - port = ports.get(server_type) - if not port: + print(f"Running comprehensive detection for {server_type}...") + + def get_network_info(): + """Get comprehensive network information with subnet detection""" + try: + # Get local IP using socket method + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + + # Try to get actual subnet mask + try: + if platform.system() == "Windows": + # Windows: Use netsh to get subnet info + result = subprocess.run(['netsh', 'interface', 'ip', 'show', 'config'], + capture_output=True, text=True, timeout=3) + # Parse output for subnet mask (simplified) + subnet_mask = "255.255.255.0" # Default fallback + else: + # Linux/Mac: Try to parse network interfaces + result = subprocess.run(['ip', 'route', 'show'], + capture_output=True, text=True, timeout=3) + subnet_mask = "255.255.255.0" # Default fallback + except: + subnet_mask = "255.255.255.0" # Default /24 + + # Calculate network range + network = ipaddress.IPv4Network(f"{local_ip}/{subnet_mask}", strict=False) + return str(network.network_address), str(network.netmask), local_ip, network + + except Exception as e: + # Fallback to original method + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + + # Default to /24 network + network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False) + return str(network.network_address), "255.255.255.0", local_ip, network + + def test_plex_server(ip, port=32400): + """Test if a Plex server is running at the given IP and port""" + try: + url = f"http://{ip}:{port}/web/index.html" + response = requests.get(url, timeout=2, allow_redirects=True) + + # Check for Plex-specific indicators + if response.status_code == 200: + # Check if it's actually Plex + if 'plex' in response.text.lower() or 'X-Plex' in str(response.headers): + return f"http://{ip}:{port}" + + # Also try the API endpoint + api_url = f"http://{ip}:{port}/identity" + api_response = requests.get(api_url, timeout=1) + if api_response.status_code == 200 and 'MediaContainer' in api_response.text: + return f"http://{ip}:{port}" + + except: + pass return None - for ip in common_ips: - url = f"http://{ip}:{port}" + def test_jellyfin_server(ip, port=8096): + """Test if a Jellyfin server is running at the given IP and port""" try: - if server_type == 'slskd': - # slskd check is different - response = requests.get(f"{url}/api/v0/session", timeout=1) - if response.status_code in [200, 401]: - print(f"Found {server_type} at {url}") - return url - else: - response = requests.get(url, timeout=1) - # A simple 200 OK is a good sign - if response.status_code == 200: - print(f"Found {server_type} at {url}") - return url - except requests.RequestException: - continue - return None + # Try the system info endpoint first + url = f"http://{ip}:{port}/System/Info" + response = requests.get(url, timeout=2, allow_redirects=True) + + if response.status_code == 200: + # Check if response contains Jellyfin-specific content + if 'jellyfin' in response.text.lower() or 'ServerName' in response.text: + return f"http://{ip}:{port}" + + # Also try the web interface + web_url = f"http://{ip}:{port}/web/index.html" + web_response = requests.get(web_url, timeout=1) + if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower(): + return f"http://{ip}:{port}" + + except: + pass + return None + + def test_slskd_server(ip, port=5030): + """Test if a slskd server is running at the given IP and port""" + try: + # slskd specific API endpoint + url = f"http://{ip}:{port}/api/v0/session" + response = requests.get(url, timeout=2) + + # slskd returns 401 when not authenticated, which is still a valid response + if response.status_code in [200, 401]: + return f"http://{ip}:{port}" + + except: + pass + return None + + try: + network_addr, netmask, local_ip, network = get_network_info() + + # Select the appropriate test function + test_functions = { + 'plex': test_plex_server, + 'jellyfin': test_jellyfin_server, + 'slskd': test_slskd_server + } + + test_func = test_functions.get(server_type) + if not test_func: + return None + + # Priority 1: Test localhost first + print(f"Testing localhost for {server_type}...") + localhost_result = test_func("localhost") + if localhost_result: + print(f"Found {server_type} at localhost!") + return localhost_result + + # Priority 2: Test local IP + print(f"Testing local IP {local_ip} for {server_type}...") + local_result = test_func(local_ip) + if local_result: + print(f"Found {server_type} at {local_ip}!") + return local_result + + # Priority 3: Test common IPs (router gateway, etc.) + common_ips = [ + local_ip.rsplit('.', 1)[0] + '.1', # Typical gateway + local_ip.rsplit('.', 1)[0] + '.2', # Alternative gateway + local_ip.rsplit('.', 1)[0] + '.100', # Common static IP + ] + + print(f"Testing common IPs for {server_type}...") + for ip in common_ips: + print(f" Checking {ip}...") + result = test_func(ip) + if result: + print(f"Found {server_type} at {ip}!") + return result + + # Priority 4: Scan the network range (limited to reasonable size) + network_hosts = list(network.hosts()) + if len(network_hosts) > 50: + # Limit scan to reasonable size for performance + step = max(1, len(network_hosts) // 50) + network_hosts = network_hosts[::step] + + print(f"Scanning network range for {server_type} ({len(network_hosts)} hosts)...") + + # Use ThreadPoolExecutor for concurrent scanning (limited for web context) + with ThreadPoolExecutor(max_workers=5) as executor: + # Submit all tasks + future_to_ip = {executor.submit(test_func, str(ip)): str(ip) + for ip in network_hosts} + + try: + for future in as_completed(future_to_ip): + ip = future_to_ip[future] + try: + result = future.result() + if result: + print(f"Found {server_type} at {ip}!") + # Cancel all pending futures before returning + for f in future_to_ip: + if not f.done(): + f.cancel() + return result + except Exception as e: + print(f"Error testing {ip}: {e}") + continue + except Exception as e: + print(f"Error in concurrent scanning: {e}") + + print(f"No {server_type} server found on network") + return None + + except Exception as e: + print(f"Error during {server_type} detection: {e}") + return None # --- Web UI Routes --- @@ -395,6 +546,59 @@ def stream_stop(): # Placeholder: simulates stopping a stream return jsonify({"success": True}) +@app.route('/api/version-info', methods=['GET']) +def get_version_info(): + """ + Returns version information and release notes, matching the GUI's VersionInfoModal content. + This provides the same data that the GUI version modal displays. + """ + version_data = { + "version": "0.65", + "title": "What's New in SoulSync", + "subtitle": "Version 0.65 - Tidal Playlist Integration", + "sections": [ + { + "title": "🎵 Complete Tidal Playlist Integration", + "description": "Full Tidal playlist support with seamless workflow integration matching YouTube/Spotify functionality", + "features": [ + "• Native Tidal API client with OAuth 2.0 authentication and automatic token management", + "• Tidal playlist tab positioned between Spotify and YouTube with identical UI/UX patterns", + "• Advanced playlist card system with persistent state tracking across all phases", + "• Complete discovery workflow: discovering → discovered → syncing → downloading phases", + "• Intelligent track matching using existing Spotify-based algorithms for compatibility", + "• Smart modal routing with proper state persistence (close/cancel behavior)", + "• Full refresh functionality with comprehensive worker cleanup and modal management" + ], + "usage_note": "Configure Tidal in Settings → Connections, then discover and sync your Tidal playlists just like Spotify!" + }, + { + "title": "⚙️ Advanced Workflow Features", + "description": "Sophisticated state management and user experience improvements", + "features": [ + "• Identical workflow behavior across all playlist sources (Spotify, YouTube, Tidal)", + "• Smart refresh system that cancels all active operations and preserves playlist names", + "• Phase-aware card clicking: routes to discovery, sync progress, or download modals appropriately", + "• Proper modal state persistence: closing download modals preserves discovery state", + "• Cancel operations reset playlists to fresh state for updated playlist data", + "• Multi-server compatibility: works with both Plex and Jellyfin automatically" + ] + }, + { + "title": "🔧 Technical Implementation Details", + "description": "Robust architecture ensuring reliable playlist management across all sources", + "features": [ + "• Implemented comprehensive state tracking system with playlist card hub architecture", + "• Added PKCE (Proof Key for Code Exchange) OAuth flow for enhanced Tidal security", + "• Created unified modal system supporting YouTube, Spotify, and Tidal workflows", + "• Enhanced worker cancellation system for proper resource cleanup during operations", + "• JSON:API response parsing for Tidal's complex relationship-based data structure", + "• Future-ready architecture for additional music streaming service integrations" + ] + } + ] + } + return jsonify(version_data) + # --- Main Execution --- diff --git a/webui/index.html b/webui/index.html index 07238a85..fbdfcde1 100644 --- a/webui/index.html +++ b/webui/index.html @@ -463,6 +463,29 @@
+ + + \ No newline at end of file diff --git a/webui/static/script.js b/webui/static/script.js index 357f49fe..df57bcd5 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -993,8 +993,99 @@ function escapeHtml(text) { return div.innerHTML; } -function showVersionInfo() { - showToast('Version info modal not implemented yet', 'error'); +async function showVersionInfo() { + try { + console.log('Fetching version info...'); + + // Fetch version data from API + const response = await fetch('/api/version-info'); + if (!response.ok) { + throw new Error('Failed to fetch version info'); + } + + const versionData = await response.json(); + console.log('Version data received:', versionData); + + // Populate modal content + populateVersionModal(versionData); + + // Show modal + const modalOverlay = document.getElementById('version-modal-overlay'); + modalOverlay.classList.remove('hidden'); + + console.log('Version modal opened'); + + } catch (error) { + console.error('Error showing version info:', error); + showToast('Failed to load version information', 'error'); + } +} + +function closeVersionModal() { + const modalOverlay = document.getElementById('version-modal-overlay'); + modalOverlay.classList.add('hidden'); + console.log('Version modal closed'); +} + +function populateVersionModal(versionData) { + const container = document.getElementById('version-content-container'); + if (!container) { + console.error('Version content container not found'); + return; + } + + // Update header with dynamic data + const titleElement = document.querySelector('.version-modal-title'); + const subtitleElement = document.querySelector('.version-modal-subtitle'); + + if (titleElement) titleElement.textContent = versionData.title; + if (subtitleElement) subtitleElement.textContent = versionData.subtitle; + + // Clear existing content + container.innerHTML = ''; + + // Create sections + versionData.sections.forEach(section => { + const sectionDiv = document.createElement('div'); + sectionDiv.className = 'version-feature-section'; + + // Section title + const titleDiv = document.createElement('div'); + titleDiv.className = 'version-section-title'; + titleDiv.textContent = section.title; + sectionDiv.appendChild(titleDiv); + + // Section description + const descDiv = document.createElement('div'); + descDiv.className = 'version-section-description'; + descDiv.textContent = section.description; + sectionDiv.appendChild(descDiv); + + // Features list + const featuresList = document.createElement('ul'); + featuresList.className = 'version-feature-list'; + + section.features.forEach(feature => { + const featureItem = document.createElement('li'); + featureItem.className = 'version-feature-item'; + featureItem.textContent = feature; + featuresList.appendChild(featureItem); + }); + + sectionDiv.appendChild(featuresList); + + // Usage note (if present) + if (section.usage_note) { + const usageDiv = document.createElement('div'); + usageDiv.className = 'version-usage-note'; + usageDiv.textContent = `💡 ${section.usage_note}`; + sectionDiv.appendChild(usageDiv); + } + + container.appendChild(sectionDiv); + }); + + console.log('Version modal content populated'); } // =============================== @@ -1174,6 +1265,7 @@ window.navigateToPage = navigateToPage; window.openKofi = openKofi; window.copyAddress = copyAddress; window.showVersionInfo = showVersionInfo; +window.closeVersionModal = closeVersionModal; window.testConnection = testConnection; window.autoDetectPlex = autoDetectPlex; window.autoDetectJellyfin = autoDetectJellyfin; diff --git a/webui/static/style.css b/webui/static/style.css index d9af4f2f..06b29aca 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -392,7 +392,7 @@ body { .crypto-donation { background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.02) 30%, rgba(255, 255, 255, 0.04) 100%); border-top: 1px solid rgba(255, 255, 255, 0.08); - border-bottom-right-radius: 12px; + padding: 15px 0; } @@ -474,8 +474,8 @@ body { height: 45px; background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.02) 30%, rgba(255, 255, 255, 0.04) 100%); border-top: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - margin: 0 10px; + + margin: 0; display: flex; align-items: center; justify-content: center; @@ -494,6 +494,7 @@ body { border-radius: 4px; cursor: pointer; transition: all 0.2s ease; + border: 1px solid transparent; } .version-button:hover { @@ -508,7 +509,7 @@ body { /* Status Section - Matching status indicators */ .status-section { - height: 150px; + height: fit-content; background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.02) 30%, rgba(255, 255, 255, 0.04) 100%); border-top: 1px solid rgba(255, 255, 255, 0.08); border-bottom-right-radius: 12px; @@ -1428,4 +1429,203 @@ body { .settings-content { max-width: 900px; /* Wider settings forms */ } +} + +/* ===== VERSION MODAL STYLES ===== */ + +.version-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + transition: opacity 0.3s ease-in-out; +} + +.version-modal-overlay.hidden { + opacity: 0; + pointer-events: none; +} + +.version-modal { + background: #1a1a1a; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + width: 600px; + max-width: 90vw; + height: 500px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + transform: scale(1); + transition: transform 0.3s ease-in-out; +} + +.version-modal-overlay.hidden .version-modal { + transform: scale(0.9); +} + +/* Header */ +.version-modal-header { + padding: 30px 30px 15px 30px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + border-top-left-radius: 12px; + border-top-right-radius: 12px; + background: #1a1a1a; +} + +.version-modal-title { + color: #ffffff; + font-size: 18px; + font-weight: 700; + letter-spacing: -0.5px; + margin: 0 0 5px 0; + font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} + +.version-modal-subtitle { + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.1px; + margin: 0; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} + +/* Content Area */ +.version-modal-content { + flex: 1; + padding: 0; + overflow-y: auto; + background: #1a1a1a; +} + +.version-modal-content::-webkit-scrollbar { + width: 8px; +} + +.version-modal-content::-webkit-scrollbar-track { + background: #2a2a2a; + border-radius: 4px; +} + +.version-modal-content::-webkit-scrollbar-thumb { + background: #555555; + border-radius: 4px; +} + +.version-modal-content::-webkit-scrollbar-thumb:hover { + background: #666666; +} + +.version-content-container { + padding: 25px 30px; +} + +/* Feature Sections */ +.version-feature-section { + margin-bottom: 25px; + padding: 18px 20px; + border-left: 3px solid rgba(29, 185, 84, 0.4); + margin-left: 5px; + background: transparent; +} + +.version-section-title { + color: #1ed760; + font-size: 14px; + font-weight: 600; + letter-spacing: -0.2px; + margin: 0 0 8px 0; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} + +.version-section-description { + color: rgba(255, 255, 255, 0.8); + font-size: 11px; + line-height: 1.4; + margin: 0 0 12px 0; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} + +.version-feature-list { + list-style: none; + padding: 0; + margin: 0; +} + +.version-feature-item { + color: rgba(255, 255, 255, 0.7); + font-size: 10px; + line-height: 1.5; + padding: 2px 0 2px 8px; + margin: 2px 0; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} + +.version-usage-note { + color: #1ed760; + font-size: 10px; + line-height: 1.4; + margin: 8px 0 0 0; + padding: 8px 0; + font-style: italic; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} + +/* Footer */ +.version-modal-footer { + padding: 15px 30px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + background: rgba(255, 255, 255, 0.02); + display: flex; + justify-content: flex-end; +} + +.version-modal-close { + background: #1db954; + color: white; + border: none; + border-radius: 6px; + padding: 8px 25px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.1px; + cursor: pointer; + transition: background-color 0.2s ease; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; +} + +.version-modal-close:hover { + background: #1ed760; +} + +.version-modal-close:active { + background: #169c46; +} + +/* Animation for modal appearance */ +@keyframes modalFadeIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.version-modal-overlay:not(.hidden) .version-modal { + animation: modalFadeIn 0.3s ease-out; } \ No newline at end of file