Add robust YouTube streaming support and improve format checks
Enhances streaming logic to better support YouTube as a download source, including improved filename handling, fuzzy file matching, and search query generation. Updates format checks in the frontend to skip them for YouTube (always MP3). Refactors backend to use a unified download status API for both Soulseek and YouTube, and improves service test messaging based on the active download mode.
This commit is contained in:
parent
88f08c2d0e
commit
61e822bf6f
3 changed files with 167 additions and 60 deletions
|
|
@ -577,7 +577,9 @@ class MusicMatchingEngine:
|
|||
|
||||
# Apply short title penalty AFTER calculating base confidence
|
||||
# This allows perfect matches to still pass, but penalizes weak artist matches
|
||||
if is_short_title and artist_score < 0.5:
|
||||
# For YouTube, skip penalty since artist matching is less reliable (searches are track-name-only)
|
||||
is_youtube = slskd_track.username == 'youtube'
|
||||
if is_short_title and artist_score < 0.5 and not is_youtube:
|
||||
# Heavy penalty but not complete rejection
|
||||
# Multiply by 0.4 (60% penalty) - still possible to pass if title+duration are perfect
|
||||
logger.debug(f"Short title '{spotify_cleaned_title}' with low artist match ({artist_score:.2f}) - applying 60% penalty")
|
||||
|
|
|
|||
204
web_server.py
204
web_server.py
|
|
@ -90,10 +90,18 @@ 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.
|
||||
|
||||
Special handling for YouTube: If the filename contains '||' (YouTube encoding format),
|
||||
treat it as a filename, not a path, to avoid splitting on '/' in video titles.
|
||||
"""
|
||||
if not full_path:
|
||||
return ""
|
||||
|
||||
|
||||
# YouTube filenames are encoded as "video_id||title" and may contain '/' in the title
|
||||
# Don't split these on path separators
|
||||
if '||' in full_path:
|
||||
return full_path
|
||||
|
||||
last_slash = max(full_path.rfind('/'), full_path.rfind('\\'))
|
||||
if last_slash != -1:
|
||||
return full_path[last_slash + 1:]
|
||||
|
|
@ -1127,14 +1135,15 @@ def _prepare_stream_task(track_data):
|
|||
while wait_count * poll_interval < max_wait_time:
|
||||
wait_count += 1
|
||||
|
||||
# Check download progress via slskd API
|
||||
# Check download progress via orchestrator (works for Soulseek and YouTube)
|
||||
api_progress = None
|
||||
download_state = None
|
||||
download_status = None
|
||||
|
||||
|
||||
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)
|
||||
# Use orchestrator's get_all_downloads() which works for both sources
|
||||
all_downloads = loop.run_until_complete(soulseek_client.get_all_downloads())
|
||||
download_status = _find_streaming_download_in_all_downloads(all_downloads, track_data)
|
||||
|
||||
if download_status:
|
||||
api_progress = download_status.get('percentComplete', 0)
|
||||
|
|
@ -1296,57 +1305,115 @@ def _prepare_stream_task(track_data):
|
|||
"error_message": f"Streaming error: {str(e)}"
|
||||
})
|
||||
|
||||
def _find_streaming_download_in_transfers(transfers_data, track_data):
|
||||
"""Find streaming download in transfer data using same logic as download queue"""
|
||||
def _find_streaming_download_in_all_downloads(all_downloads, track_data):
|
||||
"""
|
||||
Find streaming download in DownloadStatus list (works for both Soulseek and YouTube).
|
||||
Replaces the old _find_streaming_download_in_transfers function.
|
||||
"""
|
||||
try:
|
||||
if not transfers_data:
|
||||
if not all_downloads:
|
||||
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 = extract_filename(track_data.get('filename', ''))
|
||||
target_username = track_data.get('username', '')
|
||||
|
||||
for transfer in all_transfers:
|
||||
transfer_filename = extract_filename(transfer.get('filename', ''))
|
||||
transfer_username = transfer.get('username', '')
|
||||
|
||||
if (transfer_filename == target_filename and
|
||||
transfer_username == target_username):
|
||||
return transfer
|
||||
|
||||
|
||||
for download in all_downloads:
|
||||
download_filename = extract_filename(download.filename)
|
||||
download_username = download.username
|
||||
|
||||
if (download_filename == target_filename and
|
||||
download_username == target_username):
|
||||
# Convert DownloadStatus to dict format expected by caller
|
||||
return {
|
||||
'percentComplete': download.progress,
|
||||
'state': download.state,
|
||||
'size': download.size,
|
||||
'bytesTransferred': download.transferred,
|
||||
'averageSpeed': download.speed,
|
||||
}
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error finding streaming download in transfers: {e}")
|
||||
print(f"Error finding streaming download: {e}")
|
||||
return None
|
||||
|
||||
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 (works for Soulseek and YouTube)"""
|
||||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||||
target_filename = extract_filename(track_data.get('filename', ''))
|
||||
|
||||
|
||||
# YOUTUBE SUPPORT: Handle encoded filename format "video_id||title"
|
||||
# The file on disk will be "title.mp3", not "video_id||title"
|
||||
is_youtube = track_data.get('username') == 'youtube'
|
||||
target_filename_youtube = None
|
||||
if is_youtube and '||' in target_filename:
|
||||
_, title = target_filename.split('||', 1)
|
||||
# yt-dlp will create "Title.mp3" from "Title"
|
||||
target_filename_youtube = f"{title}.mp3"
|
||||
print(f"🎵 [YouTube Stream] Looking for file: {target_filename_youtube}")
|
||||
elif is_youtube:
|
||||
# Fallback: if YouTube but no encoded format, use as-is
|
||||
target_filename_youtube = target_filename
|
||||
print(f"🎵 [YouTube Stream] Using direct filename: {target_filename_youtube}")
|
||||
|
||||
try:
|
||||
# Walk through the downloads directory to find the file
|
||||
best_match = None
|
||||
best_similarity = 0.0
|
||||
|
||||
for root, dirs, files in os.walk(download_path):
|
||||
for file in files:
|
||||
# Skip non-audio files
|
||||
if os.path.splitext(file)[1].lower() not in audio_extensions:
|
||||
continue
|
||||
|
||||
file_path = os.path.join(root, file)
|
||||
|
||||
# Skip empty files
|
||||
try:
|
||||
if os.path.getsize(file_path) < 1024: # At least 1KB
|
||||
continue
|
||||
except:
|
||||
continue
|
||||
|
||||
# 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
|
||||
if is_youtube and target_filename_youtube:
|
||||
# For YouTube, use fuzzy matching (case-insensitive, flexible)
|
||||
# Because yt-dlp might sanitize the filename differently
|
||||
from difflib import SequenceMatcher
|
||||
similarity = SequenceMatcher(None,
|
||||
file.lower(),
|
||||
target_filename_youtube.lower()).ratio()
|
||||
|
||||
print(f"🔍 [YouTube Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}")
|
||||
|
||||
# Keep track of best match
|
||||
if similarity > best_similarity:
|
||||
best_similarity = similarity
|
||||
best_match = file_path
|
||||
|
||||
# If we have a very good match (95%+), use it immediately
|
||||
if similarity >= 0.95:
|
||||
print(f"✅ Found excellent match for streaming file: {file_path}")
|
||||
return file_path
|
||||
else:
|
||||
# For Soulseek, exact match
|
||||
if file == target_filename:
|
||||
print(f"✅ Found streaming file: {file_path}")
|
||||
return file_path
|
||||
|
||||
|
||||
# For YouTube, if we found a good enough match (80%+), use it
|
||||
if is_youtube and best_match and best_similarity >= 0.80:
|
||||
print(f"✅ Found good match ({best_similarity:.2f}) for YouTube streaming file: {best_match}")
|
||||
return best_match
|
||||
|
||||
print(f"❌ Could not find downloaded file: {target_filename}")
|
||||
if is_youtube:
|
||||
print(f" Looking for: {target_filename_youtube}")
|
||||
print(f" Best similarity: {best_similarity:.2f}")
|
||||
return None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error searching for downloaded file: {e}")
|
||||
return None
|
||||
|
|
@ -1446,13 +1513,30 @@ def run_service_test(service, test_config):
|
|||
except Exception as e:
|
||||
return False, f"Navidrome connection error: {str(e)}"
|
||||
elif service == "soulseek":
|
||||
temp_client = SoulseekClient()
|
||||
# Test the orchestrator's configured download source (not just Soulseek)
|
||||
download_mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
|
||||
async def check():
|
||||
return await temp_client.check_connection()
|
||||
return await soulseek_client.check_connection()
|
||||
|
||||
if asyncio.run(check()):
|
||||
return True, "Successfully connected to slskd."
|
||||
# Success message based on active mode
|
||||
mode_messages = {
|
||||
'soulseek': "Successfully connected to slskd.",
|
||||
'youtube': "YouTube download source ready.",
|
||||
'hybrid': "Download sources ready (Hybrid mode)."
|
||||
}
|
||||
message = mode_messages.get(download_mode, "Download source connected.")
|
||||
return True, message
|
||||
else:
|
||||
return False, "Could not connect to slskd. Check URL and API Key."
|
||||
# Failure message based on active mode
|
||||
mode_errors = {
|
||||
'soulseek': "Could not connect to slskd. Check URL and API Key.",
|
||||
'youtube': "YouTube download source not available.",
|
||||
'hybrid': "Could not connect to download sources. Check configuration."
|
||||
}
|
||||
error = mode_errors.get(download_mode, "Download source connection failed.")
|
||||
return False, error
|
||||
elif service == "listenbrainz":
|
||||
token = test_config.get('token', '')
|
||||
|
||||
|
|
@ -3394,21 +3478,40 @@ def stream_enhanced_search_track():
|
|||
'duration_ms': duration_ms
|
||||
})()
|
||||
|
||||
# Generate search queries - TRACK NAME ONLY (artist is used for matching, not searching)
|
||||
# This avoids Soulseek keyword filtering on artist names like "Kendrick Lamar"
|
||||
# Generate search queries based on download source mode
|
||||
# - Soulseek: Track name only (avoids keyword filtering on artist names)
|
||||
# - YouTube: Include artist name (provides context to find actual music)
|
||||
download_mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
search_queries = []
|
||||
import re
|
||||
|
||||
# Primary query: Full track name
|
||||
if track_name.strip():
|
||||
search_queries.append(track_name.strip())
|
||||
if download_mode == 'youtube' or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') == 'youtube'):
|
||||
# YouTube mode: Include artist for better context
|
||||
# Primary query: Artist + Track
|
||||
if artist_name and track_name:
|
||||
search_queries.append(f"{artist_name} {track_name}".strip())
|
||||
|
||||
# Cleaned query: Remove parentheses and brackets
|
||||
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
|
||||
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
|
||||
# Fallback: Artist + Cleaned track (remove parentheses/brackets)
|
||||
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
|
||||
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
|
||||
if cleaned_name and cleaned_name.lower() != track_name.lower():
|
||||
search_queries.append(f"{artist_name} {cleaned_name}".strip())
|
||||
|
||||
if cleaned_name and cleaned_name.lower() != track_name.lower():
|
||||
search_queries.append(cleaned_name.strip())
|
||||
logger.info(f"🔍 YouTube mode: Searching with artist + track name: {search_queries}")
|
||||
else:
|
||||
# Soulseek mode: Track name only to avoid keyword filtering
|
||||
# Primary query: Full track name
|
||||
if track_name.strip():
|
||||
search_queries.append(track_name.strip())
|
||||
|
||||
# Cleaned query: Remove parentheses and brackets
|
||||
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
|
||||
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
|
||||
|
||||
if cleaned_name and cleaned_name.lower() != track_name.lower():
|
||||
search_queries.append(cleaned_name.strip())
|
||||
|
||||
logger.info(f"🔍 Soulseek mode: Searching by track name only (will match with artist): {search_queries}")
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
unique_queries = []
|
||||
|
|
@ -3419,7 +3522,6 @@ def stream_enhanced_search_track():
|
|||
seen.add(query.lower())
|
||||
|
||||
search_queries = unique_queries
|
||||
logger.info(f"🔍 Searching by track name only (will match with artist): {search_queries}")
|
||||
|
||||
# Try queries sequentially until we find a good match
|
||||
for query_index, query in enumerate(search_queries):
|
||||
|
|
|
|||
|
|
@ -2940,8 +2940,9 @@ function initializeSearchModeToggle() {
|
|||
|
||||
const slskdResult = data.result;
|
||||
|
||||
// Check if audio format is supported
|
||||
if (slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) {
|
||||
// Check if audio format is supported (YouTube is always MP3, so skip check)
|
||||
const isYouTube = slskdResult.username === 'youtube';
|
||||
if (!isYouTube && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) {
|
||||
const format = getFileExtension(slskdResult.filename);
|
||||
hideLoadingOverlay();
|
||||
showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error');
|
||||
|
|
@ -9315,9 +9316,10 @@ async function streamTrack(index) {
|
|||
|
||||
const result = window.currentSearchResults[index];
|
||||
console.log(`🎵 Streaming track:`, result);
|
||||
|
||||
// Check for unsupported formats before streaming
|
||||
if (result.filename) {
|
||||
|
||||
// Check for unsupported formats before streaming (YouTube is always MP3, so skip check)
|
||||
const isYouTube = result.username === 'youtube';
|
||||
if (!isYouTube && result.filename) {
|
||||
const format = getFileExtension(result.filename);
|
||||
console.log(`🎵 [STREAM CHECK] File: ${result.filename}, Extension: ${format}`);
|
||||
|
||||
|
|
@ -9373,9 +9375,10 @@ async function streamAlbumTrack(albumIndex, trackIndex) {
|
|||
};
|
||||
|
||||
console.log(`🎵 Enhanced track data:`, trackData);
|
||||
|
||||
// Check for unsupported formats before streaming
|
||||
if (trackData.filename && !isAudioFormatSupported(trackData.filename)) {
|
||||
|
||||
// Check for unsupported formats before streaming (YouTube is always MP3, so skip check)
|
||||
const isYouTube = trackData.username === 'youtube';
|
||||
if (!isYouTube && trackData.filename && !isAudioFormatSupported(trackData.filename)) {
|
||||
const format = getFileExtension(trackData.filename);
|
||||
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue