mirror of https://github.com/Nezreka/SoulSync.git
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.
327 lines
16 KiB
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)}"
|
|
})
|
|
|