soulsync/core/streaming/prepare.py
BoulderBadgeDad 07d09d7d0e Stream button: the player never learns its stream is ready (2.6.5 regression)
Since the per-listener stream sessions refactor (Phase 3b), every browser gets
its own stream session — but the 1s 'tool:stream' socket broadcast still read
the legacy GLOBAL state (the DEFAULT session no real browser uses), so it told
every client "stopped" forever. The frontend skipped HTTP polling whenever the
WebSocket was up, so it only ever saw that wrong broadcast: the backend prep
downloaded the track, moved it into the session's stream folder and sat at
"ready" while the mini player showed nothing. Proxy users whose WebSockets
don't connect fell back to HTTP polling (session-correct) and streamed fine —
which is why this hid so well.

Fix: stream status is inherently per-listener, so stop pretending a global
broadcast can carry it —
- web_server.py: remove the 'tool:stream' emit from the tool-progress loop
  (the broadcast thread has no request context; it can only ever see DEFAULT)
- media-player.js: the status poller always polls /api/stream/status (resolves
  the caller's own session from the cookie); drop the dead broadcast handler
- core.js: unwire the 'tool:stream' socket listener

Observability fix that made this undebuggable: core/streaming/prepare.py used
getLogger(__name__) — outside the soulsync.* namespace where handlers attach —
so every prep log line (including failures) vanished from app.log. Moved to
get_logger("streaming.prepare") + a regression test locking the namespace.

34 streaming tests pass; ruff clean; web_server compiles; JS syntax-checked.
2026-06-06 12:43:20 -07:00

330 lines
16 KiB
Python

"""Streaming preparation worker.
`prepare_stream_task(track_data, deps)` is the function the stream
executor submits to fetch a track from Soulseek/YouTube/etc and stage
it in the local Stream/ folder for the browser audio player.
1. Reset stream state to 'loading' with the new track info.
2. Clear any prior file from the Stream/ folder (only one stream lives
there at a time).
3. Spin up a fresh asyncio event loop and `download_orchestrator.download()`
the track.
4. Poll `download_orchestrator.get_all_downloads()` every 1.5 s to track
progress, with separate handling for queued vs actively downloading
states. Queue timeout = 15 s; overall timeout = 60 s.
5. On completion (state ~ 'succeeded' or progress >= 100% AND bytes
transferred match expected size), find the downloaded file with retry
logic, move it into Stream/, signal completion to the slskd API, and
mark stream_state as 'ready' with the file path.
6. On any error/timeout/cancel: stream_state goes to 'error' or
'stopped' with an explanatory message.
7. Finally: tear down the event loop cleanly.
The original mutated `stream_state` as a module global. Here it's
exposed through the `PrepareStreamDeps` proxy as a Python property so
the lifted body keeps the same `name[key] = value` syntax. The setter
fires only if the function reassigns (currently it only mutates in
place via .update() and key assignment).
"""
from __future__ import annotations
import asyncio
import glob
import os
import shutil
import time
from dataclasses import dataclass
from typing import Any, Callable
from utils.logging_config import get_logger
# Must live under the soulsync.* namespace — handlers are only attached there,
# so a bare getLogger(__name__) ("core.streaming.prepare") logged into the void
# and made stream-prep failures invisible in app.log.
logger = get_logger("streaming.prepare")
@dataclass
class PrepareStreamDeps:
"""Bundle of cross-cutting deps the stream-prep worker needs."""
config_manager: Any
download_orchestrator: Any
stream_lock: Any # threading.Lock
project_root: str # absolute path to web_server.py's directory
docker_resolve_path: Callable[[str], str]
find_streaming_download_in_all_downloads: Callable
find_downloaded_file: Callable
extract_filename: Callable[[str], str]
cleanup_empty_directories: Callable
_get_stream_state: Callable[[], dict]
_set_stream_state: Callable[[dict], None]
@property
def stream_state(self) -> dict:
return self._get_stream_state()
@stream_state.setter
def stream_state(self, value: dict) -> None:
self._set_stream_state(value)
def prepare_stream_task(track_data, deps: PrepareStreamDeps):
"""
Background streaming task that downloads track to Stream folder and updates global state.
Enhanced version with robust error handling matching the GUI StreamingThread.
"""
loop = None
queue_start_time = None
actively_downloading = False
last_progress_sent = 0.0
try:
logger.info(f"Starting stream preparation for: {track_data.get('filename')}")
# Update state to loading
with deps.stream_lock:
deps.stream_state.update({
"status": "loading",
"progress": 0,
"track_info": track_data,
"file_path": None,
"error_message": None
})
# Get paths
download_path = deps.docker_resolve_path(deps.config_manager.get('soulseek.download_path', './downloads'))
project_root = deps.project_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)
logger.info(f"Cleared old stream file: {existing_file}")
except Exception as e:
logger.error(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(deps.download_orchestrator.download(
track_data.get('username'),
track_data.get('filename'),
track_data.get('size', 0)
))
if not download_result:
with deps.stream_lock:
deps.stream_state.update({
"status": "error",
"error_message": "Failed to initiate download - uploader may be offline"
})
return
logger.info("Download initiated for streaming")
# Enhanced monitoring with queue timeout detection (matching GUI)
max_wait_time = 60 # Increased timeout
poll_interval = 1.5 # More frequent polling
queue_timeout = 15 # Queue timeout like GUI
wait_count = 0
while wait_count * poll_interval < max_wait_time:
wait_count += 1
# Check download progress via orchestrator (works for Soulseek and YouTube)
api_progress = None
download_state = None
download_status = None
try:
# Use orchestrator's get_all_downloads() which works for both sources
all_downloads = loop.run_until_complete(deps.download_orchestrator.get_all_downloads())
download_status = deps.find_streaming_download_in_all_downloads(all_downloads, 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', '')
logger.info(f"API Download - State: {original_state}, Progress: {api_progress:.1f}%")
# Track queue state timing (matching GUI logic)
is_queued = ('queued' in download_state or 'initializing' in download_state)
is_downloading = ('inprogress' in download_state or 'transferring' in download_state)
# Verify bytes match before trusting state/progress
_stream_expected = download_status.get('size', 0)
_stream_transferred = download_status.get('bytesTransferred', 0)
_bytes_ok = _stream_expected <= 0 or _stream_transferred >= _stream_expected
is_completed = ('succeeded' in download_state or api_progress >= 100) and _bytes_ok
# Handle queue state timing
if is_queued and queue_start_time is None:
queue_start_time = time.time()
logger.info(f"Download entered queue state: {original_state}")
with deps.stream_lock:
deps.stream_state["status"] = "queued"
elif is_downloading and not actively_downloading:
actively_downloading = True
queue_start_time = None # Reset queue timer
logger.info(f"Download started actively downloading: {original_state}")
with deps.stream_lock:
deps.stream_state["status"] = "loading"
# Check for queue timeout (matching GUI)
if is_queued and queue_start_time:
queue_elapsed = time.time() - queue_start_time
if queue_elapsed > queue_timeout:
logger.error(f"⏰ Queue timeout after {queue_elapsed:.1f}s - download stuck in queue")
with deps.stream_lock:
deps.stream_state.update({
"status": "error",
"error_message": "Queue timeout - uploader not responding. Try another source."
})
return
# Update progress
with deps.stream_lock:
if api_progress != last_progress_sent:
deps.stream_state["progress"] = api_progress
last_progress_sent = api_progress
# Check if download is complete
if is_completed:
logger.info(f"Download completed via API status: {original_state}")
# Wait for file to stabilise on disk before moving
found_file = deps.find_downloaded_file(download_path, track_data)
if found_file:
_prev_sz = -1
for _sc in range(4):
try:
_cur_sz = os.path.getsize(found_file)
except OSError:
_cur_sz = -1
if _cur_sz == _prev_sz and _cur_sz > 0:
break
_prev_sz = _cur_sz
time.sleep(1.5)
# Re-find in case it wasn't found on first try
if not found_file:
found_file = deps.find_downloaded_file(download_path, track_data)
# Retry file search a few times (matching GUI logic)
retry_attempts = 5
for attempt in range(retry_attempts):
if found_file:
break
logger.warning(f"File not found yet, attempt {attempt + 1}/{retry_attempts}")
time.sleep(1)
found_file = deps.find_downloaded_file(download_path, track_data)
if found_file:
logger.debug(f"Found downloaded file: {found_file}")
# Move file to Stream folder
original_filename = deps.extract_filename(found_file)
stream_path = os.path.join(stream_folder, original_filename)
try:
shutil.move(found_file, stream_path)
logger.debug(f"Moved file to stream folder: {stream_path}")
# Clean up empty directories (matching GUI)
deps.cleanup_empty_directories(download_path, found_file)
# Update state to ready
with deps.stream_lock:
deps.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 and track_data.get('username'):
success = loop.run_until_complete(
deps.download_orchestrator.signal_download_completion(
download_id, track_data.get('username'), remove=True)
)
if success:
logger.debug(f"Cleaned up download {download_id} from API")
except Exception as e:
logger.error(f"Error cleaning up download: {e}")
logger.info(f"Stream file ready for playback: {stream_path}")
return # Success!
except Exception as e:
logger.error(f"Error moving file to stream folder: {e}")
with deps.stream_lock:
deps.stream_state.update({
"status": "error",
"error_message": f"Failed to prepare stream file: {e}"
})
return
else:
logger.error("Could not find downloaded file after completion")
with deps.stream_lock:
deps.stream_state.update({
"status": "error",
"error_message": "Download completed but file not found"
})
return
else:
# No transfer found in API - may still be initializing
logger.debug(f"No transfer found in API yet... (elapsed: {wait_count * poll_interval}s)")
except Exception as e:
logger.error(f"Error checking download progress: {e}")
# Continue to next iteration if API call fails
# Wait before next poll
time.sleep(poll_interval)
# If we get here, download timed out
logger.warning(f"Download timed out after {max_wait_time}s")
with deps.stream_lock:
deps.stream_state.update({
"status": "error",
"error_message": "Download timed out - try a different source"
})
except asyncio.CancelledError:
logger.warning("Stream task cancelled")
with deps.stream_lock:
deps.stream_state.update({
"status": "stopped",
"error_message": None
})
finally:
if loop:
try:
# Clean up any pending tasks
pending = asyncio.all_tasks(loop)
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()
except Exception as e:
logger.error(f"Error cleaning up streaming event loop: {e}")
except Exception as e:
logger.error(f"Stream preparation failed: {e}")
with deps.stream_lock:
deps.stream_state.update({
"status": "error",
"error_message": f"Streaming error: {str(e)}"
})