matched album downloads

pull/15/head
Broque Thomas 9 months ago
parent 008ddcf0ed
commit 724f385768

@ -1444,64 +1444,147 @@ def search_match():
print(f"❌ Error in match search: {e}")
return jsonify({"error": str(e)}), 500
def _start_album_download_tasks(album_result, spotify_artist, spotify_album):
"""
This final version now fetches the official Spotify tracklist and uses it to
match and correct the metadata for each individual track before downloading,
ensuring perfect tagging and naming.
"""
print(f"🎵 Processing matched album download for '{spotify_album['name']}' with {len(album_result.get('tracks', []))} tracks.")
tracks_to_download = album_result.get('tracks', [])
if not tracks_to_download:
print("⚠️ Album result contained no tracks. Aborting.")
return 0
# --- THIS IS THE NEW LOGIC ---
# Fetch the official tracklist from Spotify ONCE for the entire album.
official_spotify_tracks = _get_spotify_album_tracks(spotify_album)
if not official_spotify_tracks:
print("⚠️ Could not fetch official tracklist from Spotify. Metadata may be inaccurate.")
# --- END OF NEW LOGIC ---
started_count = 0
for track_data in tracks_to_download:
try:
username = track_data.get('username') or album_result.get('username')
filename = track_data.get('filename')
size = track_data.get('size', 0)
if not username or not filename:
continue
# Pre-parse the filename to get a baseline for metadata
parsed_meta = _parse_filename_metadata(filename)
# --- THIS IS THE CRITICAL MATCHING STEP ---
# Match the parsed metadata against the official Spotify tracklist
corrected_meta = _match_track_to_spotify_title(parsed_meta, official_spotify_tracks)
# --- END OF CRITICAL STEP ---
# Create a clean context object using the CORRECTED metadata
individual_track_context = {
'username': username,
'filename': filename,
'size': size,
'title': corrected_meta.get('title'),
'artist': corrected_meta.get('artist') or spotify_artist['name'],
'album': spotify_album['name'],
'track_number': corrected_meta.get('track_number')
}
download_id = asyncio.run(soulseek_client.download(username, filename, size))
if download_id:
context_key = f"{username}::{filename}"
with matched_context_lock:
matched_downloads_context[context_key] = {
"spotify_artist": spotify_artist,
"spotify_album": spotify_album,
"original_search_result": individual_track_context, # Contains corrected data
"is_album_download": True
}
print(f" + Queued track: {filename} (Matched to: '{corrected_meta.get('title')}')")
started_count += 1
else:
print(f" - Failed to queue track: {filename}")
except Exception as e:
print(f"❌ Error processing track in album batch: {track_data.get('filename')}. Error: {e}")
continue
return started_count
@app.route('/api/download/matched', methods=['POST'])
def start_matched_download():
"""Start a matched download with Spotify metadata context"""
"""
Starts a matched download. For albums, it now delegates to the new
_start_album_download_tasks function to process each track individually,
perfectly mirroring the robust logic of downloads.py.
"""
try:
data = request.get_json()
search_result = data.get('search_result', {})
# Rename for clarity: this payload is either a single track or a full album object
download_payload = data.get('search_result', {})
spotify_artist = data.get('spotify_artist', {})
spotify_album = data.get('spotify_album', {})
if not search_result or not spotify_artist:
return jsonify({"success": False, "error": "Missing search result or artist data"}), 400
spotify_album = data.get('spotify_album', None) # Can be None for singles
username = search_result.get('username')
filename = search_result.get('filename')
size = search_result.get('size', 0)
if not download_payload or not spotify_artist:
return jsonify({"success": False, "error": "Missing download payload or artist data"}), 400
if not username or not filename:
return jsonify({"success": False, "error": "Missing username or filename in search result"}), 400
# Check if this is an album download (user selected an album in the modal)
# This is the most reliable way to determine the intent from the frontend.
is_album_download = bool(spotify_album and spotify_album.get('id'))
print(f"🎯 Starting matched download for: {filename} from {username}")
# --- THIS IS THE CRITICAL FIX ---
# Pre-parse the filename to get clean metadata BEFORE starting the download.
parsed_meta = _parse_filename_metadata(filename)
# Update the search_result with the clean, parsed data.
search_result['title'] = parsed_meta.get('title') or search_result.get('title')
search_result['artist'] = parsed_meta.get('artist') or search_result.get('artist')
search_result['album'] = parsed_meta.get('album') or search_result.get('album')
search_result['track_number'] = parsed_meta.get('track_number') or search_result.get('track_number')
# --- END OF FIX ---
if is_album_download:
# It's an album. The download_payload is the full album object.
# Delegate to the dedicated album processor.
started_count = _start_album_download_tasks(download_payload, spotify_artist, spotify_album)
if started_count > 0:
return jsonify({"success": True, "message": f"Queued {started_count} tracks for matched album download."})
else:
return jsonify({"success": False, "error": "Failed to queue any tracks from the album."}), 500
else:
# It's a single track. The download_payload is a single track object.
username = download_payload.get('username')
filename = download_payload.get('filename')
size = download_payload.get('size', 0)
download_id_from_client = asyncio.run(soulseek_client.download(username, filename, size))
if not username or not filename:
return jsonify({"success": False, "error": "Missing username or filename"}), 400
if download_id_from_client:
context_key = f"{username}::{filename}"
with matched_context_lock:
matched_downloads_context[context_key] = {
"spotify_artist": spotify_artist,
"spotify_album": spotify_album or None,
"original_search_result": search_result, # Now contains cleaned data
"is_album_download": bool(spotify_album)
}
# Pre-parse the single track's metadata
parsed_meta = _parse_filename_metadata(filename)
download_payload['title'] = parsed_meta.get('title') or download_payload.get('title')
download_payload['artist'] = parsed_meta.get('artist') or download_payload.get('artist')
download_id = asyncio.run(soulseek_client.download(username, filename, size))
print(f"✅ Context saved for matched download with key: {context_key}")
return jsonify({"success": True, "message": "Matched download started"})
else:
error_msg = 'Failed to start download via slskd API'
print(f"❌ Failed to start matched download: {error_msg}")
return jsonify({"success": False, "error": error_msg}), 500
if download_id:
context_key = f"{username}::{filename}"
with matched_context_lock:
matched_downloads_context[context_key] = {
"spotify_artist": spotify_artist,
"spotify_album": None, # Explicitly null for singles
"original_search_result": download_payload,
"is_album_download": False
}
return jsonify({"success": True, "message": "Matched download started"})
else:
return jsonify({"success": False, "error": "Failed to start download via slskd"}), 500
except Exception as e:
import traceback
traceback.print_exc()
print(f"❌ Error starting matched download: {e}")
return jsonify({"success": False, "error": str(e)}), 500
def _parse_filename_metadata(filename: str) -> dict:
"""
A direct port of the metadata parsing logic from the GUI's soulseek_client.py.
@ -1658,31 +1741,39 @@ def _search_track_in_album_context(original_search: dict, artist: dict) -> dict:
def _detect_album_info_web(context: dict, artist: dict) -> dict:
"""
This is a complete replacement that mirrors the multi-priority logic from downloads.py,
ensuring consistent and accurate album/single detection.
This is the final, corrected version that ensures the official Spotify track
number from the context is always prioritized for matched album downloads,
fixing the track numbering issue by mirroring the logic from downloads.py.
"""
try:
original_search = context.get("original_search_result", {})
spotify_album_context = context.get("spotify_album")
# Priority 1: User-provided album context from the matching modal
if spotify_album_context and spotify_album_context.get('id'):
print("✅ Using user-provided album context.")
is_album_download = context.get("is_album_download", False)
# --- THIS IS THE CRITICAL FIX ---
# If this is part of a matched album download, we TRUST the context data completely.
# This is the exact logic from downloads.py.
if is_album_download and spotify_album_context:
print("✅ Matched Album context found. Prioritizing pre-matched Spotify data.")
# We exclusively use the track number and title that were matched
# *before* the download started. We do not try to re-parse the filename.
track_number = original_search.get('track_number', 1)
clean_track_name = original_search.get('title', 'Unknown Track')
print(f" -> Using pre-matched Track #{track_number} and Title '{clean_track_name}'")
return {
'is_album': True,
'album_name': spotify_album_context['name'],
'track_number': _extract_track_number_from_filename(original_search.get('filename', ''), original_search.get('title')),
'clean_track_name': _clean_track_title(original_search.get('title', ''), artist['name']),
'track_number': track_number,
'clean_track_name': clean_track_name,
'album_image_url': spotify_album_context.get('image_url')
}
# Priority 2: Album-aware search using album name from Soulseek tags
if original_search.get('album'):
album_result = _search_track_in_album_context(original_search, artist)
if album_result:
return album_result
# Priority 3: Fallback to individual track search for clean metadata
# This fallback block handles single tracks. It was already working correctly.
# It performs a live Spotify search to determine if a single is part of an album.
print(" Single track context. Performing live Spotify search for album info.")
cleaned_title = _clean_track_title(original_search.get('title', ''), artist['name'])
query = f"artist:\"{artist['name']}\" track:\"{cleaned_title}\""
tracks = spotify_client.search_tracks(query, limit=1)
@ -1702,7 +1793,6 @@ def _detect_album_info_web(context: dict, artist: dict) -> dict:
album_type = api_album.get('album_type', 'single')
total_tracks = api_album.get('total_tracks', 1)
is_album = (album_type == 'album' and total_tracks > 1 and matching_engine.similarity_score(api_album.get('name'), best_match.name) < 0.9)
album_image_url = api_album.get('images', [{}])[0].get('url') if api_album.get('images') else None
return {
@ -1719,6 +1809,7 @@ def _detect_album_info_web(context: dict, artist: dict) -> dict:
def _cleanup_empty_directories(download_path, moved_file_path):
"""Cleans up empty directories after a file move, ignoring hidden files."""
import os
@ -1890,23 +1981,90 @@ def _download_cover_art(album_info: dict, target_dir: str):
print(f"❌ Error downloading cover.jpg: {e}")
# --- Post-Processing Logic ---
def _get_spotify_album_tracks(spotify_album: dict) -> list:
"""Fetches all tracks for a given Spotify album ID."""
if not spotify_album or not spotify_album.get('id'):
return []
try:
tracks_data = spotify_client.get_album_tracks(spotify_album['id'])
if tracks_data and 'items' in tracks_data:
return [{
'name': item.get('name'),
'track_number': item.get('track_number'),
'id': item.get('id')
} for item in tracks_data['items']]
return []
except Exception as e:
print(f"❌ Error fetching Spotify album tracks: {e}")
return []
def _post_process_matched_download(context_key, context, file_path):
def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) -> dict:
"""
Intelligently matches a Soulseek track to a track from the official Spotify
tracklist using track numbers and title similarity. Returns the matched Spotify track data.
"""
This is the new, robust post-processing function for matched downloads,
ported from downloads.py. It handles file organization, renaming, metadata
tagging, and cover art downloading for both singles and albums.
if not spotify_tracks:
return slsk_track_meta # Return original if no list to match against
# Priority 1: Match by track number
if slsk_track_meta.get('track_number'):
track_num = slsk_track_meta['track_number']
for sp_track in spotify_tracks:
if sp_track.get('track_number') == track_num:
print(f"✅ Matched track by number ({track_num}): '{slsk_track_meta['title']}' -> '{sp_track['name']}'")
# Return a new dict with the corrected title and number
return {
'title': sp_track['name'],
'artist': slsk_track_meta.get('artist'),
'album': slsk_track_meta.get('album'),
'track_number': sp_track['track_number']
}
# Priority 2: Match by title similarity (if track number fails)
best_match = None
best_score = 0.6 # Require a decent similarity
for sp_track in spotify_tracks:
score = matching_engine.similarity_score(
matching_engine.normalize_string(slsk_track_meta.get('title', '')),
matching_engine.normalize_string(sp_track.get('name', ''))
)
if score > best_score:
best_score = score
best_match = sp_track
NOTE: The primary fixes for the reported issues are in the helper functions
this function calls: `_detect_album_info_web` and the file finder used in the
`/api/downloads/status` route.
if best_match:
print(f"✅ Matched track by title similarity ({best_score:.2f}): '{slsk_track_meta['title']}' -> '{best_match['name']}'")
return {
'title': best_match['name'],
'artist': slsk_track_meta.get('artist'),
'album': slsk_track_meta.get('album'),
'track_number': best_match['track_number']
}
print(f"⚠️ Could not confidently match track '{slsk_track_meta['title']}'. Using original metadata.")
return slsk_track_meta # Fallback to original
# --- Post-Processing Logic ---
def _post_process_matched_download(context_key, context, file_path):
"""
This is the final, corrected post-processing function. It now mirrors the
GUI's logic by trusting the pre-matched context for album downloads, which
solves the track numbering issue.
"""
try:
import os
import shutil
import time
from pathlib import Path
# --- GUI PARITY FIX: Add a delay to prevent file lock race conditions ---
# The GUI app waits 1 second to ensure the file handle is released by
# the download client before attempting to move or modify it.
print(f"⏳ Waiting 1 second for file handle release for: {os.path.basename(file_path)}")
time.sleep(1)
# --- END OF FIX ---
print(f"🎯 Starting robust post-processing for: {context_key}")
spotify_artist = context.get("spotify_artist")
@ -1914,26 +2072,37 @@ def _post_process_matched_download(context_key, context, file_path):
print(f"❌ Post-processing failed: Missing spotify_artist context.")
return
is_album_download = context.get("is_album_download", False)
if is_album_download:
# For matched album downloads, we build album_info directly from the
# trusted context, bypassing the problematic _detect_album_info_web function.
print("✅ Matched Album context found. Building info directly from context.")
original_search = context.get("original_search_result", {})
spotify_album = context.get("spotify_album", {})
album_info = {
'is_album': True,
'album_name': spotify_album.get('name'),
'track_number': original_search.get('track_number', 1),
'clean_track_name': original_search.get('title', 'Unknown Track'),
'album_image_url': spotify_album.get('image_url')
}
else:
# For singles, we still need to detect if they belong to an album.
album_info = _detect_album_info_web(context, spotify_artist)
# 1. Get transfer path and create artist directory
transfer_dir = config_manager.get('soulseek.transfer_path', './Transfer')
artist_name_sanitized = _sanitize_filename(spotify_artist["name"])
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
os.makedirs(artist_dir, exist_ok=True)
# 2. Determine if it's a single or album track using the NEW, robust Spotify API logic
album_info = _detect_album_info_web(context, spotify_artist)
file_ext = os.path.splitext(file_path)[1]
# 3. Build the final path based on whether it's an album or single
# 2. Build the final path (this logic is now correct because album_info is correct)
if album_info and album_info['is_album']:
# --- ALBUM LOGIC ---
album_name = album_info['album_name']
album_name_sanitized = _sanitize_filename(album_info['album_name'])
final_track_name_sanitized = _sanitize_filename(album_info['clean_track_name'])
track_number = album_info['track_number']
final_track_name = album_info['clean_track_name']
album_name_sanitized = _sanitize_filename(album_name)
final_track_name_sanitized = _sanitize_filename(final_track_name)
album_folder_name = f"{artist_name_sanitized} - {album_name_sanitized}"
album_dir = os.path.join(artist_dir, album_folder_name)
@ -1941,37 +2110,24 @@ def _post_process_matched_download(context_key, context, file_path):
new_filename = f"{track_number:02d} - {final_track_name_sanitized}{file_ext}"
final_path = os.path.join(album_dir, new_filename)
print(f"📁 Determined album path: {final_path}")
else:
# --- SINGLE LOGIC ---
final_track_name = album_info['clean_track_name']
final_track_name_sanitized = _sanitize_filename(final_track_name)
final_track_name_sanitized = _sanitize_filename(album_info['clean_track_name'])
single_folder_name = f"{artist_name_sanitized} - {final_track_name_sanitized}"
single_dir = os.path.join(artist_dir, single_folder_name)
os.makedirs(single_dir, exist_ok=True)
new_filename = f"{final_track_name_sanitized}{file_ext}"
final_path = os.path.join(single_dir, new_filename)
print(f"🎵 Determined single path: {final_path}")
# 4. Enhance metadata BEFORE moving the file
print(f"✍️ Attempting metadata enhancement on: {file_path}")
# 3. Enhance metadata, move file, download art, and cleanup
_enhance_file_metadata(file_path, context, spotify_artist, album_info)
# 5. Move the file to its new, organized location
print(f"🚚 Moving '{os.path.basename(file_path)}' to '{final_path}'")
if os.path.exists(final_path):
print(f"⚠️ Destination file exists, overwriting: {final_path}")
os.remove(final_path)
shutil.move(file_path, final_path)
# 6. Download cover.jpg to the final directory
final_directory = os.path.dirname(final_path)
_download_cover_art(album_info, final_directory)
_download_cover_art(album_info, os.path.dirname(final_path))
# 7. Clean up empty source directories
downloads_path = config_manager.get('soulseek.download_path', './downloads')
_cleanup_empty_directories(downloads_path, file_path)
@ -1984,6 +2140,7 @@ def _post_process_matched_download(context_key, context, file_path):
# Keep track of processed downloads to avoid re-processing
_processed_download_ids = set()

@ -3046,54 +3046,62 @@ async function confirmMatch() {
showToast('⚠️ Please select an artist first', 'error');
return;
}
if (currentMatchingData.isAlbumDownload && !currentMatchingData.selectedAlbum) {
showToast('⚠️ Please select an album first', 'error');
return;
}
const confirmBtn = document.getElementById('confirm-match-btn');
const originalText = confirmBtn.textContent; // FIX: Declare outside try block
try {
console.log('🎯 Confirming match with:', {
artist: currentMatchingData.selectedArtist.name,
album: currentMatchingData.selectedAlbum?.name
});
// Disable confirm button to prevent double-clicks
const confirmBtn = document.getElementById('confirm-match-btn');
const originalText = confirmBtn.textContent;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Starting...';
// --- THIS IS THE CRITICAL FIX ---
// Determine the correct data to send. For albums, we send the full albumResult
// which contains the complete list of tracks.
const downloadPayload = currentMatchingData.isAlbumDownload
? currentMatchingData.albumResult
: currentMatchingData.searchResult;
// --- END OF FIX ---
const response = await fetch('/api/download/matched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_result: currentMatchingData.searchResult,
search_result: downloadPayload, // Send the correct payload
spotify_artist: currentMatchingData.selectedArtist,
spotify_album: currentMatchingData.selectedAlbum || null
})
});
const data = await response.json();
if (data.success) {
showToast(`🎯 Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success');
closeMatchingModal();
} else {
throw new Error(data.error || 'Failed to start matched download');
}
} catch (error) {
console.error('Error starting matched download:', error);
showToast(`❌ Error starting matched download: ${error.message}`, 'error');
// Re-enable confirm button
const confirmBtn = document.getElementById('confirm-match-btn');
// Re-enable confirm button on failure
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
}
function matchedDownloadTrack(trackIndex) {
const results = window.currentSearchResults;
if (!results || !results[trackIndex]) {

Loading…
Cancel
Save