You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/streaming/prepare.py

327 lines
16 KiB

"""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 `soulseek_client.download()`
the track.
4. Poll `soulseek_client.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 logging
import os
import shutil
import time
from dataclasses import dataclass
from typing import Any, Callable
logger = logging.getLogger(__name__)
@dataclass
class PrepareStreamDeps:
"""Bundle of cross-cutting deps the stream-prep worker needs."""
config_manager: Any
soulseek_client: 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.soulseek_client.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.soulseek_client.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.soulseek_client.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)}"
})