Improve Docker path handling and filename extraction

Added helper functions to resolve Windows paths for Docker containers and to extract filenames in a cross-platform manner. Updated usage throughout web_server.py and soulseek_client.py to ensure correct path mapping and filename handling. Modified docker-compose.yml to use named volumes and mount the E: drive for better Docker compatibility.
This commit is contained in:
Broque Thomas 2025-09-11 17:46:49 -07:00
parent 008653a69c
commit 24b789660a
3 changed files with 77 additions and 32 deletions

View file

@ -1,6 +1,7 @@
import requests import requests
import asyncio import asyncio
import aiohttp import aiohttp
import os
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from dataclasses import dataclass from dataclasses import dataclass
import time import time
@ -228,7 +229,17 @@ class SoulseekClient:
self.base_url = slskd_url.rstrip('/') self.base_url = slskd_url.rstrip('/')
self.api_key = config.get('api_key', '') self.api_key = config.get('api_key', '')
self.download_path = Path(config.get('download_path', './downloads'))
# Handle download path with Docker translation
download_path_str = config.get('download_path', './downloads')
if os.path.exists('/.dockerenv') and len(download_path_str) >= 3 and download_path_str[1] == ':' and download_path_str[0].isalpha():
# Convert Windows path (E:/path) to WSL mount path (/mnt/e/path)
drive_letter = download_path_str[0].lower()
rest_of_path = download_path_str[2:].replace('\\', '/') # Remove E: and convert backslashes
download_path_str = f"/host/mnt/{drive_letter}{rest_of_path}"
logger.info(f"Docker detected, using {download_path_str} for downloads")
self.download_path = Path(download_path_str)
self.download_path.mkdir(parents=True, exist_ok=True) self.download_path.mkdir(parents=True, exist_ok=True)
logger.info(f"Soulseek client configured with slskd at {self.base_url}") logger.info(f"Soulseek client configured with slskd at {self.base_url}")

View file

@ -11,13 +11,13 @@ services:
volumes: volumes:
# Persistent data volumes # Persistent data volumes
- ./config:/app/config - ./config:/app/config
- ./database:/app/database
- ./logs:/app/logs - ./logs:/app/logs
# Soulseek download and transfer directories (mapped to host E: drive paths) - ./downloads:/app/downloads
- /mnt/e/Broque Projects/newMusic/downloads:/app/downloads - ./Transfer:/app/Transfer
- /mnt/e/Broque Projects/newMusic/transfer:/app/transfer # Use named volume for database persistence (separate from host database)
# Stream folder for temporary playback files - soulsync_database:/app/database
- /mnt/e/Broque Projects/newMusic/stream:/app/stream # Mount only E: drive through WSL for better performance
- /mnt/e:/host/mnt/e:rw
# Optional: Mount your music library for Plex/Jellyfin access # Optional: Mount your music library for Plex/Jellyfin access
- /path/to/your/music:/music:ro - /path/to/your/music:/music:ro
environment: environment:
@ -48,6 +48,11 @@ services:
cpus: '0.5' cpus: '0.5'
memory: 512M memory: 512M
# Named volumes for persistent data
volumes:
soulsync_database:
driver: local
# Optional: Add external network for communication with other containers # Optional: Add external network for communication with other containers
networks: networks:
default: default:

View file

@ -62,6 +62,33 @@ app = Flask(
static_folder=os.path.join(base_dir, 'webui', 'static') static_folder=os.path.join(base_dir, 'webui', 'static')
) )
# --- Docker Helper Functions ---
def docker_resolve_path(path_str):
"""
Resolve absolute paths for Docker container access
In Docker, Windows drive paths (E:/) need to be mapped to WSL mount points (/mnt/e/)
"""
if os.path.exists('/.dockerenv') and len(path_str) >= 3 and path_str[1] == ':' and path_str[0].isalpha():
# Convert Windows path (E:/path) to WSL mount path (/mnt/e/path)
drive_letter = path_str[0].lower()
rest_of_path = path_str[2:].replace('\\', '/') # Remove E: and convert backslashes
return f"/host/mnt/{drive_letter}{rest_of_path}"
return path_str
def extract_filename(full_path):
"""
Extract filename by working backwards from the end until we hit a separator.
This is cross-platform compatible and handles both Windows and Unix path separators.
"""
if not full_path:
return ""
last_slash = max(full_path.rfind('/'), full_path.rfind('\\'))
if last_slash != -1:
return full_path[last_slash + 1:]
else:
return full_path
# --- Initialize Core Application Components --- # --- Initialize Core Application Components ---
print("🚀 Initializing SoulSync services for Web UI...") print("🚀 Initializing SoulSync services for Web UI...")
try: try:
@ -174,7 +201,7 @@ def get_cached_transfer_data():
file_info['username'] = username file_info['username'] = username
all_transfers.append(file_info) all_transfers.append(file_info)
for transfer in all_transfers: for transfer in all_transfers:
key = f"{transfer.get('username')}::{os.path.basename(transfer.get('filename', ''))}" key = f"{transfer.get('username')}::{extract_filename(transfer.get('filename', ''))}"
live_transfers_lookup[key] = transfer live_transfers_lookup[key] = transfer
# Update cache # Update cache
@ -275,7 +302,7 @@ class WebUIDownloadMonitor:
for directory in user_data['directories']: for directory in user_data['directories']:
if 'files' in directory: if 'files' in directory:
for file_info in directory['files']: for file_info in directory['files']:
key = f"{username}::{os.path.basename(file_info.get('filename', ''))}" key = f"{username}::{extract_filename(file_info.get('filename', ''))}"
live_transfers[key] = file_info live_transfers[key] = file_info
return live_transfers return live_transfers
except Exception as e: except Exception as e:
@ -298,7 +325,7 @@ class WebUIDownloadMonitor:
if not task_filename or not task_username: if not task_filename or not task_username:
return False return False
lookup_key = f"{task_username}::{os.path.basename(task_filename)}" lookup_key = f"{task_username}::{extract_filename(task_filename)}"
live_info = live_transfers_lookup.get(lookup_key) live_info = live_transfers_lookup.get(lookup_key)
if not live_info: if not live_info:
@ -776,7 +803,7 @@ def _prepare_stream_task(track_data):
}) })
# Get paths # Get paths
download_path = config_manager.get('soulseek.download_path', './downloads') download_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
project_root = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(os.path.abspath(__file__))
stream_folder = os.path.join(project_root, 'Stream') stream_folder = os.path.join(project_root, 'Stream')
@ -898,7 +925,7 @@ def _prepare_stream_task(track_data):
print(f"✓ Found downloaded file: {found_file}") print(f"✓ Found downloaded file: {found_file}")
# Move file to Stream folder # Move file to Stream folder
original_filename = os.path.basename(found_file) original_filename = extract_filename(found_file)
stream_path = os.path.join(stream_folder, original_filename) stream_path = os.path.join(stream_folder, original_filename)
try: try:
@ -1008,11 +1035,11 @@ def _find_streaming_download_in_transfers(transfers_data, track_data):
all_transfers.extend(directory['files']) all_transfers.extend(directory['files'])
# Look for our specific file by filename and username # Look for our specific file by filename and username
target_filename = os.path.basename(track_data.get('filename', '')) target_filename = extract_filename(track_data.get('filename', ''))
target_username = track_data.get('username', '') target_username = track_data.get('username', '')
for transfer in all_transfers: for transfer in all_transfers:
transfer_filename = os.path.basename(transfer.get('filename', '')) transfer_filename = extract_filename(transfer.get('filename', ''))
transfer_username = transfer.get('username', '') transfer_username = transfer.get('username', '')
if (transfer_filename == target_filename and if (transfer_filename == target_filename and
@ -1027,7 +1054,7 @@ def _find_streaming_download_in_transfers(transfers_data, track_data):
def _find_downloaded_file(download_path, track_data): def _find_downloaded_file(download_path, track_data):
"""Find the downloaded audio file in the downloads directory tree""" """Find the downloaded audio file in the downloads directory tree"""
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
target_filename = os.path.basename(track_data.get('filename', '')) target_filename = extract_filename(track_data.get('filename', ''))
try: try:
# Walk through the downloads directory to find the file # Walk through the downloads directory to find the file
@ -1956,14 +1983,15 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None):
return best_match_path, highest_similarity return best_match_path, highest_similarity
target_basename = os.path.basename(api_filename) # Extract filename using the helper function
target_basename = extract_filename(api_filename)
normalized_target = normalize_for_finding(target_basename) normalized_target = normalize_for_finding(target_basename)
# First search in downloads directory # First search in downloads directory
best_downloads_path, downloads_similarity = search_in_directory(download_dir, 'downloads') best_downloads_path, downloads_similarity = search_in_directory(download_dir, 'downloads')
# Use a high confidence threshold for fuzzy matches to avoid incorrect files # Use a reasonable confidence threshold for fuzzy matches
if downloads_similarity > 0.85: if downloads_similarity > 0.60:
location = 'downloads' location = 'downloads'
if downloads_similarity == 1.0: if downloads_similarity == 1.0:
print(f"✅ Found exact match in downloads: {best_downloads_path}") print(f"✅ Found exact match in downloads: {best_downloads_path}")
@ -1977,7 +2005,7 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None):
print(f"🔍 File not found in downloads, checking transfer folder...") print(f"🔍 File not found in downloads, checking transfer folder...")
best_transfer_path, transfer_similarity = search_in_directory(transfer_dir, 'transfer') best_transfer_path, transfer_similarity = search_in_directory(transfer_dir, 'transfer')
if transfer_similarity > 0.85: if transfer_similarity > 0.60:
location = 'transfer' location = 'transfer'
if transfer_similarity == 1.0: if transfer_similarity == 1.0:
print(f"✅ Found exact match in transfer: {best_transfer_path}") print(f"✅ Found exact match in transfer: {best_transfer_path}")
@ -2032,7 +2060,7 @@ def get_download_status():
context = matched_downloads_context.get(context_key) context = matched_downloads_context.get(context_key)
if context and context_key not in _processed_download_ids: if context and context_key not in _processed_download_ids:
download_dir = config_manager.get('soulseek.download_path', './downloads') download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
# Use the new robust file finder (only search downloads for post-processing candidates) # Use the new robust file finder (only search downloads for post-processing candidates)
found_result = _find_completed_file_robust(download_dir, filename_from_api) found_result = _find_completed_file_robust(download_dir, filename_from_api)
found_path = found_result[0] if found_result and found_result[0] else None found_path = found_result[0] if found_result and found_result[0] else None
@ -4553,7 +4581,7 @@ def _post_process_matched_download_with_verification(context_key, context, file_
album_name_sanitized = clean_album_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_') album_name_sanitized = clean_album_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
track_name_sanitized = clean_track_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_') track_name_sanitized = clean_track_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
transfer_dir = config_manager.get('soulseek.transfer_path', './transfers') transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './transfers'))
artist_dir = os.path.join(transfer_dir, artist_name_sanitized) artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
album_folder_name = f"{clean_album_name} ({spotify_album.get('release_date', '').split('-')[0] if spotify_album.get('release_date') else 'Unknown'})" album_folder_name = f"{clean_album_name} ({spotify_album.get('release_date', '').split('-')[0] if spotify_album.get('release_date') else 'Unknown'})"
album_folder_name = album_folder_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_') album_folder_name = album_folder_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
@ -4569,7 +4597,7 @@ def _post_process_matched_download_with_verification(context_key, context, file_
track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track') track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track')
track_name_sanitized = track_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_') track_name_sanitized = track_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
transfer_dir = config_manager.get('soulseek.transfer_path', './transfers') transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './transfers'))
artist_name_sanitized = spotify_artist.name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_') artist_name_sanitized = spotify_artist.name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
artist_dir = os.path.join(transfer_dir, artist_name_sanitized) artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
single_dir = os.path.join(artist_dir, "Singles") single_dir = os.path.join(artist_dir, "Singles")
@ -4739,7 +4767,7 @@ def _post_process_matched_download(context_key, context, file_path):
print(f"🔗 ✅ Album grouping complete!\n") print(f"🔗 ✅ Album grouping complete!\n")
# 1. Get transfer path and create artist directory # 1. Get transfer path and create artist directory
transfer_dir = config_manager.get('soulseek.transfer_path', './Transfer') transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
artist_name_sanitized = _sanitize_filename(spotify_artist["name"]) artist_name_sanitized = _sanitize_filename(spotify_artist["name"])
artist_dir = os.path.join(transfer_dir, artist_name_sanitized) artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
os.makedirs(artist_dir, exist_ok=True) os.makedirs(artist_dir, exist_ok=True)
@ -4861,7 +4889,7 @@ def _post_process_matched_download(context_key, context, file_path):
_download_cover_art(album_info, os.path.dirname(final_path)) _download_cover_art(album_info, os.path.dirname(final_path))
downloads_path = config_manager.get('soulseek.download_path', './downloads') downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
_cleanup_empty_directories(downloads_path, file_path) _cleanup_empty_directories(downloads_path, file_path)
print(f"✅ Post-processing complete for: {final_path}") print(f"✅ Post-processing complete for: {final_path}")
@ -6529,11 +6557,12 @@ def _run_post_processing_worker(task_id, batch_id):
_on_download_completed(batch_id, task_id, success=False) _on_download_completed(batch_id, task_id, success=False)
return return
download_dir = config_manager.get('soulseek.download_path', './downloads') download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
transfer_dir = config_manager.get('soulseek.transfer_path', './transfer') transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './transfer'))
# Try to get context for generating the correct final filename # Try to get context for generating the correct final filename
context_key = f"{task_username}::{os.path.basename(task_filename)}" task_basename = extract_filename(task_filename)
context_key = f"{task_username}::{task_basename}"
expected_final_filename = None expected_final_filename = None
print(f"🔍 [Post-Processing] Looking up context with key: {context_key}") print(f"🔍 [Post-Processing] Looking up context with key: {context_key}")
@ -6570,7 +6599,7 @@ def _run_post_processing_worker(task_id, batch_id):
else: else:
print(f"❌ [Post-Processing] No context found for key: {context_key}") print(f"❌ [Post-Processing] No context found for key: {context_key}")
# Try fuzzy matching with similar keys containing the filename # Try fuzzy matching with similar keys containing the filename
similar_keys = [k for k in matched_downloads_context.keys() if os.path.basename(task_filename) in k] similar_keys = [k for k in matched_downloads_context.keys() if task_basename in k]
if similar_keys: if similar_keys:
# Use the first similar key found # Use the first similar key found
fuzzy_key = similar_keys[0] fuzzy_key = similar_keys[0]
@ -6600,7 +6629,7 @@ def _run_post_processing_worker(task_id, batch_id):
import traceback import traceback
traceback.print_exc() traceback.print_exc()
else: else:
print(f"🔍 [Post-Processing] No similar keys found containing '{os.path.basename(task_filename)}'") print(f"🔍 [Post-Processing] No similar keys found containing '{task_basename}'")
# Show a sample of what keys actually exist for debugging # Show a sample of what keys actually exist for debugging
sample_keys = list(matched_downloads_context.keys())[:5] sample_keys = list(matched_downloads_context.keys())[:5]
print(f"🔍 [Post-Processing] Sample of existing keys: {sample_keys}") print(f"🔍 [Post-Processing] Sample of existing keys: {sample_keys}")
@ -6610,7 +6639,7 @@ def _run_post_processing_worker(task_id, batch_id):
file_location = None file_location = None
for retry_count in range(3): for retry_count in range(3):
print(f"🔍 [Post-Processing] Attempt {retry_count + 1}/3 to find file") print(f"🔍 [Post-Processing] Attempt {retry_count + 1}/3 to find file")
print(f"🔍 [Post-Processing] Original filename: {os.path.basename(task_filename)}") print(f"🔍 [Post-Processing] Original filename: {task_basename}")
if expected_final_filename: if expected_final_filename:
print(f"🔍 [Post-Processing] Expected final filename: {expected_final_filename}") print(f"🔍 [Post-Processing] Expected final filename: {expected_final_filename}")
else: else:
@ -6745,7 +6774,7 @@ def _run_post_processing_worker(task_id, batch_id):
# File found in downloads folder - attempt post-processing # File found in downloads folder - attempt post-processing
try: try:
# Create context for post-processing (similar to existing matched download logic) # Create context for post-processing (similar to existing matched download logic)
context_key = f"{task_username}::{os.path.basename(task_filename)}" context_key = f"{task_username}::{task_basename}"
# Check if this download has matched context for post-processing # Check if this download has matched context for post-processing
with matched_context_lock: with matched_context_lock:
@ -7371,7 +7400,7 @@ def _build_batch_status_data(batch_id, batch, live_transfers_lookup):
task_filename = task.get('filename') or task['track_info'].get('filename') task_filename = task.get('filename') or task['track_info'].get('filename')
task_username = task.get('username') or task['track_info'].get('username') task_username = task.get('username') or task['track_info'].get('username')
if task_filename and task_username: if task_filename and task_username:
lookup_key = f"{task_username}::{os.path.basename(task_filename)}" lookup_key = f"{task_username}::{extract_filename(task_filename)}"
if lookup_key in live_transfers_lookup: if lookup_key in live_transfers_lookup:
live_info = live_transfers_lookup[lookup_key] live_info = live_transfers_lookup[lookup_key]