From 886c2e84c8e7a7c721423c70ad627a21d3c30693 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sat, 23 Aug 2025 15:03:25 -0700 Subject: [PATCH] stream functionality --- web_server.py | 335 ++++++++++++++++++++++++- webui/index.html | 3 + webui/static/script.js | 554 +++++++++++++++++++++++++++++++++-------- webui/static/style.css | 176 +++++++++++-- 4 files changed, 925 insertions(+), 143 deletions(-) diff --git a/web_server.py b/web_server.py index 02d2b942..2f389216 100644 --- a/web_server.py +++ b/web_server.py @@ -6,8 +6,13 @@ import socket import ipaddress import subprocess import platform +import threading +import time +import shutil +import glob +from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed -from flask import Flask, render_template, request, jsonify, redirect +from flask import Flask, render_template, request, jsonify, redirect, send_file # --- Core Application Imports --- # Import the same core clients and config manager used by the GUI app @@ -41,6 +46,221 @@ except Exception as e: print(f"🔴 FATAL: Error initializing service clients: {e}") spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = None +# --- Global Streaming State Management --- +# Thread-safe state tracking for streaming functionality +stream_state = { + "status": "stopped", # States: stopped, loading, queued, ready, error + "progress": 0, + "track_info": None, + "file_path": None, # Path to the audio file in the 'Stream' folder + "error_message": None +} +stream_lock = threading.Lock() # Prevent race conditions +stream_background_task = None +stream_executor = ThreadPoolExecutor(max_workers=1) # Only one stream at a time + +def _prepare_stream_task(track_data): + """ + Background streaming task that downloads track to Stream folder and updates global state. + This replicates the logic from StreamingThread.run() in the GUI app. + """ + try: + print(f"đŸŽĩ Starting stream preparation for: {track_data.get('filename')}") + + # Update state to loading + with stream_lock: + stream_state.update({ + "status": "loading", + "progress": 0, + "track_info": track_data, + "file_path": None, + "error_message": None + }) + + # Get paths + download_path = config_manager.get('soulseek.download_path', './downloads') + project_root = os.path.dirname(os.path.abspath(__file__)) # Web server root + stream_folder = os.path.join(project_root, 'Stream') + + # Ensure Stream directory exists + os.makedirs(stream_folder, exist_ok=True) + + # Clear any existing files in Stream folder (only one file at a time) + for existing_file in glob.glob(os.path.join(stream_folder, '*')): + try: + if os.path.isfile(existing_file): + os.remove(existing_file) + elif os.path.isdir(existing_file): + shutil.rmtree(existing_file) + print(f"đŸ—‘ī¸ Cleared old stream file: {existing_file}") + except Exception as e: + print(f"âš ī¸ Could not remove existing stream file: {e}") + + # Start the download using the same mechanism as regular downloads + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + download_result = loop.run_until_complete(soulseek_client.download( + track_data.get('username'), + track_data.get('filename'), + track_data.get('size', 0) + )) + + if not download_result: + with stream_lock: + stream_state.update({ + "status": "error", + "error_message": "Failed to initiate download" + }) + return + + # Poll for completion with progress updates + max_wait_time = 45 # Wait up to 45 seconds + poll_interval = 2 # Check every 2 seconds + + for wait_count in range(max_wait_time // poll_interval): + # Check download progress via slskd API + try: + transfers_data = loop.run_until_complete(soulseek_client._make_request('GET', 'transfers/downloads')) + download_status = _find_streaming_download_in_transfers(transfers_data, track_data) + + if download_status: + api_progress = download_status.get('percentComplete', 0) + download_state = download_status.get('state', '').lower() + original_state = download_status.get('state', '') + + # Update progress + with stream_lock: + stream_state["progress"] = api_progress + if 'queued' in download_state or 'initializing' in download_state: + stream_state["status"] = "queued" + elif 'inprogress' in download_state: + stream_state["status"] = "loading" + + # Check if download is complete + is_completed = ('Succeeded' in original_state or + ('Completed' in original_state and 'Errored' not in original_state) or + api_progress >= 100) + + if is_completed: + print(f"✓ Download completed via API status: {original_state}") + # Try to find the actual file + found_file = _find_downloaded_file(download_path, track_data) + + if found_file: + # Move file to Stream folder + original_filename = os.path.basename(found_file) + stream_path = os.path.join(stream_folder, original_filename) + + shutil.move(found_file, stream_path) + print(f"✓ Moved file to stream folder: {stream_path}") + + # Update state to ready + with stream_lock: + stream_state.update({ + "status": "ready", + "progress": 100, + "file_path": stream_path + }) + + # Clean up download from slskd API + try: + download_id = download_status.get('id', '') + if download_id: + success = loop.run_until_complete( + soulseek_client.signal_download_completion( + download_id, track_data.get('username'), remove=True) + ) + if success: + print(f"✓ Cleaned up download {download_id} from API") + except Exception as e: + print(f"âš ī¸ Error cleaning up download: {e}") + + return # Success! + else: + print("❌ Could not find downloaded file") + break + + except Exception as e: + print(f"âš ī¸ Error checking download progress: {e}") + + # Wait before next poll + time.sleep(poll_interval) + + # If we get here, download timed out + with stream_lock: + stream_state.update({ + "status": "error", + "error_message": "Download timed out" + }) + + finally: + loop.close() + + except Exception as e: + print(f"❌ Stream preparation failed: {e}") + with stream_lock: + stream_state.update({ + "status": "error", + "error_message": str(e) + }) + +def _find_streaming_download_in_transfers(transfers_data, track_data): + """Find streaming download in transfer data using same logic as download queue""" + try: + if not transfers_data: + return None + + # Flatten the transfers data structure + all_transfers = [] + for user_data in transfers_data: + if 'directories' in user_data: + for directory in user_data['directories']: + if 'files' in directory: + all_transfers.extend(directory['files']) + + # Look for our specific file by filename and username + target_filename = os.path.basename(track_data.get('filename', '')) + target_username = track_data.get('username', '') + + for transfer in all_transfers: + transfer_filename = os.path.basename(transfer.get('filename', '')) + transfer_username = transfer.get('username', '') + + if (transfer_filename == target_filename and + transfer_username == target_username): + return transfer + + return None + except Exception as e: + print(f"Error finding streaming download in transfers: {e}") + return None + +def _find_downloaded_file(download_path, track_data): + """Find the downloaded audio file in the downloads directory tree""" + audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} + target_filename = os.path.basename(track_data.get('filename', '')) + + try: + # Walk through the downloads directory to find the file + for root, dirs, files in os.walk(download_path): + for file in files: + # Check if this is our target file + if file == target_filename: + file_path = os.path.join(root, file) + # Verify it's an audio file and has content + if (os.path.splitext(file)[1].lower() in audio_extensions and + os.path.getsize(file_path) > 1024): # At least 1KB + return file_path + + print(f"❌ Could not find downloaded file: {target_filename}") + return None + + except Exception as e: + print(f"Error searching for downloaded file: {e}") + return None + # --- Refactored Logic from GUI Threads --- # This logic is extracted from your QThread classes to be used directly by Flask. @@ -678,25 +898,116 @@ def get_artists(): @app.route('/api/stream/start', methods=['POST']) def stream_start(): - # Placeholder: simulates starting a media stream + """Start streaming a track in the background""" + global stream_background_task + data = request.get_json() - print(f"Simulating stream start for: {data.get('filename')}") - return jsonify({"success": True, "track": data}) + if not data: + return jsonify({"success": False, "error": "No track data provided"}), 400 + + print(f"đŸŽĩ Web UI Stream request for: {data.get('filename')}") + + try: + # Stop any existing streaming task + if stream_background_task and not stream_background_task.done(): + stream_background_task.cancel() + + # Reset stream state + with stream_lock: + stream_state.update({ + "status": "stopped", + "progress": 0, + "track_info": None, + "file_path": None, + "error_message": None + }) + + # Start new background streaming task + stream_background_task = stream_executor.submit(_prepare_stream_task, data) + + return jsonify({"success": True, "message": "Streaming started"}) + + except Exception as e: + print(f"❌ Error starting stream: {e}") + return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/stream/status') def stream_status(): - # Placeholder: simulates stream status - return jsonify({"status": "playing", "progress": 50, "track": {"title": "Bohemian Rhapsody"}}) + """Get current streaming status and progress""" + try: + with stream_lock: + # Return copy of current stream state + return jsonify({ + "status": stream_state["status"], + "progress": stream_state["progress"], + "track_info": stream_state["track_info"], + "error_message": stream_state["error_message"] + }) + except Exception as e: + print(f"❌ Error getting stream status: {e}") + return jsonify({ + "status": "error", + "progress": 0, + "track_info": None, + "error_message": str(e) + }), 500 -@app.route('/api/stream/toggle', methods=['POST']) -def stream_toggle(): - # Placeholder: simulates toggling play/pause - return jsonify({"playing": False}) +@app.route('/stream/audio') +def stream_audio(): + """Serve the audio file from the Stream folder""" + try: + with stream_lock: + if stream_state["status"] != "ready" or not stream_state["file_path"]: + return jsonify({"error": "No audio file ready for streaming"}), 404 + + file_path = stream_state["file_path"] + + if not os.path.exists(file_path): + return jsonify({"error": "Audio file not found"}), 404 + + print(f"đŸŽĩ Serving audio file: {os.path.basename(file_path)}") + return send_file(file_path, as_attachment=False) + + except Exception as e: + print(f"❌ Error serving audio file: {e}") + return jsonify({"error": str(e)}), 500 @app.route('/api/stream/stop', methods=['POST']) def stream_stop(): - # Placeholder: simulates stopping a stream - return jsonify({"success": True}) + """Stop streaming and clean up""" + global stream_background_task + + try: + # Cancel background task + if stream_background_task and not stream_background_task.done(): + stream_background_task.cancel() + + # Clear Stream folder + project_root = os.path.dirname(os.path.abspath(__file__)) + stream_folder = os.path.join(project_root, 'Stream') + + if os.path.exists(stream_folder): + for filename in os.listdir(stream_folder): + file_path = os.path.join(stream_folder, filename) + if os.path.isfile(file_path): + os.remove(file_path) + print(f"đŸ—‘ī¸ Removed stream file: {filename}") + + # Reset stream state + with stream_lock: + stream_state.update({ + "status": "stopped", + "progress": 0, + "track_info": None, + "file_path": None, + "error_message": None + }) + + return jsonify({"success": True, "message": "Stream stopped"}) + + except Exception as e: + print(f"❌ Error stopping stream: {e}") + return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/version-info', methods=['GET']) def get_version_info(): diff --git a/webui/index.html b/webui/index.html index 13935d66..4a86f917 100644 --- a/webui/index.html +++ b/webui/index.html @@ -583,6 +583,9 @@
+ + +
+
@@ -1335,6 +1612,7 @@ function displayDownloadsResults(results) {
Shared by ${escapeHtml(result.username || 'Unknown')}
+
@@ -1448,6 +1726,66 @@ async function downloadAlbumTrack(albumIndex, trackIndex) { } } +// =============================== +// STREAMING WRAPPER FUNCTIONS +// =============================== + +async function streamTrack(index) { + // Stream a single track from search results + try { + console.log(`đŸŽĩ streamTrack called with index: ${index}`); + console.log(`đŸŽĩ window.currentSearchResults:`, window.currentSearchResults); + + if (!window.currentSearchResults || !window.currentSearchResults[index]) { + console.error(`❌ No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); + showToast('Track not found', 'error'); + return; + } + + const result = window.currentSearchResults[index]; + console.log(`đŸŽĩ Streaming track:`, result); + + await startStream(result); + + } catch (error) { + console.error('Track streaming error:', error); + showToast('Failed to start track stream', 'error'); + } +} + + +async function streamAlbumTrack(albumIndex, trackIndex) { + // Stream a specific track from an album + try { + console.log(`đŸŽĩ streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`); + console.log(`đŸŽĩ window.currentSearchResults:`, window.currentSearchResults); + + if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) { + console.error(`❌ No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); + showToast('Album not found', 'error'); + return; + } + + const album = window.currentSearchResults[albumIndex]; + console.log(`đŸŽĩ Album data:`, album); + + if (!album.tracks || !album.tracks[trackIndex]) { + console.error(`❌ No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`); + showToast('Track not found in album', 'error'); + return; + } + + const track = album.tracks[trackIndex]; + console.log(`đŸŽĩ Streaming album track:`, track); + + await startStream(track); + + } catch (error) { + console.error('Album track streaming error:', error); + showToast('Failed to start track stream', 'error'); + } +} + async function loadArtistsData() { try { const response = await fetch(API.artists); @@ -1797,6 +2135,8 @@ window.authenticateTidal = authenticateTidal; window.browsePath = browsePath; window.selectResult = selectResult; window.startStream = startStream; +window.streamTrack = streamTrack; +window.streamAlbumTrack = streamAlbumTrack; window.startDownload = startDownload; window.downloadTrack = downloadTrack; window.downloadAlbum = downloadAlbum; diff --git a/webui/static/style.css b/webui/static/style.css index e0b26e73..0739d53e 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -206,45 +206,80 @@ body { .media-header { display: flex; - align-items: center; - gap: 14px; - margin-bottom: 8px; + align-items: flex-start; + gap: 16px; + margin-bottom: 10px; } .media-info { flex: 1; min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 2px; } .track-title { font-family: 'Spotify Circular', 'SF Pro Display', -apple-system, sans-serif; - font-size: 14px; + font-size: 15px; font-weight: 700; color: #ffffff; - letter-spacing: -0.3px; - line-height: 1.2; - margin-bottom: 2px; + letter-spacing: -0.2px; + line-height: 1.3; cursor: pointer; white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; + margin: 0; + transition: color 0.2s ease; + position: relative; + width: 100%; +} + +.track-title .title-text { + display: inline-block; + transition: transform 0.3s ease; +} + +.track-title.scrolling .title-text { + animation: marquee-scroll 10s linear infinite; + animation-delay: 1.5s; +} + +.track-title.scrolling:hover .title-text { + animation-play-state: paused; +} + +@keyframes marquee-scroll { + 0% { + transform: translateX(0px); + } + 20% { + transform: translateX(0px); + } + 80% { + transform: translateX(var(--scroll-distance)); + } + 100% { + transform: translateX(var(--scroll-distance)); + } } .track-title:hover { color: #1ed760; - text-decoration: underline; } .artist-name { font-family: 'Spotify Circular', -apple-system, sans-serif; - font-size: 11px; - font-weight: 400; - color: #b3b3b3; + font-size: 12px; + font-weight: 500; + color: #a7a7a7; letter-spacing: 0.1px; - margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin: 0; + opacity: 0.9; } .play-button { @@ -280,26 +315,30 @@ body { } .media-expanded { - margin-top: 2px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); } .album-name { font-family: 'Spotify Circular', -apple-system, sans-serif; font-size: 11px; - font-weight: 400; - color: #a7a7a7; + font-weight: 500; + color: #999999; letter-spacing: 0.1px; - margin-bottom: 4px; + margin-bottom: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-style: italic; + opacity: 0.8; } .media-controls { display: flex; align-items: center; justify-content: space-between; - margin-top: 6px; + margin-top: 0px; } .volume-control { @@ -378,14 +417,15 @@ body { } .no-track-message { - color: #6a6a6a; - font-size: 12px; - font-weight: 400; + color: #888888; + font-size: 13px; + font-weight: 500; text-align: center; - padding: 20px 16px; - letter-spacing: 0.2px; + padding: 24px 20px; + letter-spacing: 0.1px; font-family: 'Spotify Circular', -apple-system, sans-serif; - line-height: 1.4; + line-height: 1.5; + opacity: 0.85; } /* Crypto Donation Section - Matching CryptoDonationWidget */ @@ -2575,4 +2615,92 @@ body { padding: 0; font-size: 14px; } + +/* =============================== + STREAMING BUTTON STYLES + =============================== */ + +/* Track stream buttons */ +.track-stream-btn { + background: linear-gradient(to bottom, rgba(29, 185, 84, 0.1), rgba(25, 155, 71, 0.2)); + border: 1px solid rgba(29, 185, 84, 0.4); + color: rgba(29, 185, 84, 0.9); + border-radius: 6px; + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; +} + +.track-stream-btn:hover { + background: linear-gradient(to bottom, rgba(29, 185, 84, 0.2), rgba(25, 155, 71, 0.3)); + border-color: rgba(29, 185, 84, 0.6); + color: rgba(29, 185, 84, 1); + transform: scale(1.05); +} + +/* Album stream buttons */ +.album-stream-btn { + background: linear-gradient(to bottom, rgba(29, 185, 84, 0.1), rgba(25, 155, 71, 0.2)); + border: 1px solid rgba(29, 185, 84, 0.4); + color: rgba(29, 185, 84, 0.9); + border-radius: 8px; + padding: 8px 16px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 8px; +} + +.album-stream-btn:hover { + background: linear-gradient(to bottom, rgba(29, 185, 84, 0.2), rgba(25, 155, 71, 0.3)); + border-color: rgba(29, 185, 84, 0.6); + color: rgba(29, 185, 84, 1); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(29, 185, 84, 0.2); +} + +/* Loading states for stream buttons */ +.track-stream-btn:disabled, +.album-stream-btn:disabled { + background: linear-gradient(to bottom, rgba(120, 120, 120, 0.2), rgba(100, 100, 100, 0.3)); + border-color: rgba(120, 120, 120, 0.3); + color: rgba(255, 255, 255, 0.4); + cursor: not-allowed; + transform: none; +} + +/* Active/playing states */ +.track-stream-btn.playing, +.album-stream-btn.playing { + background: linear-gradient(to bottom, rgba(29, 185, 84, 0.3), rgba(25, 155, 71, 0.4)); + border-color: rgba(29, 185, 84, 0.8); + color: #ffffff; + box-shadow: 0 0 8px rgba(29, 185, 84, 0.4); +} + +.track-stream-btn.loading, +.album-stream-btn.loading { + background: linear-gradient(to bottom, rgba(255, 193, 7, 0.2), rgba(255, 152, 0, 0.3)); + border-color: rgba(255, 193, 7, 0.5); + color: rgba(255, 193, 7, 0.9); + animation: pulse-loading 1.5s infinite; +} + +@keyframes pulse-loading { + 0% { opacity: 0.7; } + 50% { opacity: 1; } + 100% { opacity: 0.7; } +} + /* END OF CSS SNIPPET */ \ No newline at end of file