album level search for wishlist albums/eps
This commit is contained in:
parent
258fd7a8ae
commit
28ca3f5ce5
2 changed files with 181 additions and 11 deletions
|
|
@ -928,7 +928,8 @@ class MusicMatchingEngine:
|
||||||
album_result: AlbumResult,
|
album_result: AlbumResult,
|
||||||
spotify_tracks: List[SpotifyTrack],
|
spotify_tracks: List[SpotifyTrack],
|
||||||
spotify_album_name: str,
|
spotify_album_name: str,
|
||||||
spotify_artist_name: str
|
spotify_artist_name: str,
|
||||||
|
expected_track_count: int = 0
|
||||||
) -> Tuple[float, Dict[str, TrackResult]]:
|
) -> Tuple[float, Dict[str, TrackResult]]:
|
||||||
"""
|
"""
|
||||||
Match an AlbumResult from Soulseek against a list of Spotify tracks.
|
Match an AlbumResult from Soulseek against a list of Spotify tracks.
|
||||||
|
|
@ -1034,23 +1035,28 @@ class MusicMatchingEngine:
|
||||||
# Calculate album confidence
|
# Calculate album confidence
|
||||||
match_ratio = len(track_mapping) / len(spotify_tracks) if spotify_tracks else 0.0
|
match_ratio = len(track_mapping) / len(spotify_tracks) if spotify_tracks else 0.0
|
||||||
avg_track_score = sum(matched_scores) / len(matched_scores) if matched_scores else 0.0
|
avg_track_score = sum(matched_scores) / len(matched_scores) if matched_scores else 0.0
|
||||||
track_count_ratio = (
|
|
||||||
min(album_result.track_count, len(spotify_tracks)) /
|
# Source completeness: prefer sources whose track count matches the full album,
|
||||||
max(album_result.track_count, len(spotify_tracks))
|
# not just the missing tracks. A complete 13/13 rip is more reliable than an 8-file folder.
|
||||||
) if spotify_tracks else 0.0
|
reference_count = expected_track_count if expected_track_count > 0 else len(spotify_tracks)
|
||||||
|
source_completeness = (
|
||||||
|
min(album_result.track_count, reference_count) /
|
||||||
|
max(album_result.track_count, reference_count)
|
||||||
|
) if reference_count > 0 else 0.0
|
||||||
|
|
||||||
album_confidence = (
|
album_confidence = (
|
||||||
(match_ratio * 0.40) +
|
(match_ratio * 0.40) +
|
||||||
(avg_track_score * 0.25) +
|
(avg_track_score * 0.25) +
|
||||||
(album_title_score * 0.20) +
|
(album_title_score * 0.15) +
|
||||||
(artist_score * 0.10) +
|
(artist_score * 0.10) +
|
||||||
(track_count_ratio * 0.05)
|
(source_completeness * 0.10)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Album match: '{album_result.album_title}' by {album_result.username} -> "
|
f"Album match: '{album_result.album_title}' by {album_result.username} -> "
|
||||||
f"confidence={album_confidence:.2f}, matched={len(track_mapping)}/{len(spotify_tracks)}, "
|
f"confidence={album_confidence:.2f}, matched={len(track_mapping)}/{len(spotify_tracks)}, "
|
||||||
f"title={album_title_score:.2f}, artist={artist_score:.2f}, tracks_avg={avg_track_score:.2f}"
|
f"title={album_title_score:.2f}, artist={artist_score:.2f}, tracks_avg={avg_track_score:.2f}, "
|
||||||
|
f"completeness={source_completeness:.2f} ({album_result.track_count}/{reference_count})"
|
||||||
)
|
)
|
||||||
|
|
||||||
return album_confidence, track_mapping
|
return album_confidence, track_mapping
|
||||||
|
|
@ -1092,7 +1098,8 @@ class MusicMatchingEngine:
|
||||||
logger.warning(f"Quality filter error for album '{album.album_title}': {e}")
|
logger.warning(f"Quality filter error for album '{album.album_title}': {e}")
|
||||||
|
|
||||||
confidence, mapping = self.match_album_result_to_spotify_tracks(
|
confidence, mapping = self.match_album_result_to_spotify_tracks(
|
||||||
album, spotify_tracks, spotify_album_name, spotify_artist_name
|
album, spotify_tracks, spotify_album_name, spotify_artist_name,
|
||||||
|
expected_track_count=expected_track_count
|
||||||
)
|
)
|
||||||
|
|
||||||
if confidence > best_confidence:
|
if confidence > best_confidence:
|
||||||
|
|
|
||||||
167
web_server.py
167
web_server.py
|
|
@ -11319,7 +11319,28 @@ def _ensure_spotify_track_format(track_info):
|
||||||
if isinstance(track_info.get('artists'), list) and len(track_info.get('artists', [])) > 0:
|
if isinstance(track_info.get('artists'), list) and len(track_info.get('artists', [])) > 0:
|
||||||
first_artist = track_info['artists'][0]
|
first_artist = track_info['artists'][0]
|
||||||
if isinstance(first_artist, dict) and 'name' in first_artist:
|
if isinstance(first_artist, dict) and 'name' in first_artist:
|
||||||
# Already has proper Spotify format
|
# Already has proper Spotify format — but ensure album has images
|
||||||
|
album = track_info.get('album')
|
||||||
|
if not isinstance(album, dict) or not album.get('images'):
|
||||||
|
# Album images missing at top level, check spotify_data
|
||||||
|
spotify_data = track_info.get('spotify_data', {})
|
||||||
|
if isinstance(spotify_data, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
spotify_data = json.loads(spotify_data)
|
||||||
|
except Exception:
|
||||||
|
spotify_data = {}
|
||||||
|
if isinstance(spotify_data, dict) and isinstance(spotify_data.get('album'), dict):
|
||||||
|
s_album = spotify_data['album']
|
||||||
|
if s_album.get('images'):
|
||||||
|
if not isinstance(album, dict):
|
||||||
|
track_info['album'] = {}
|
||||||
|
track_info['album']['images'] = s_album['images']
|
||||||
|
if 'name' not in track_info.get('album', {}):
|
||||||
|
track_info['album']['name'] = s_album.get('name', 'Unknown Album')
|
||||||
|
for field in ('id', 'album_type', 'total_tracks', 'release_date'):
|
||||||
|
if field in s_album and field not in track_info.get('album', {}):
|
||||||
|
track_info['album'][field] = s_album[field]
|
||||||
return track_info
|
return track_info
|
||||||
|
|
||||||
# Convert to proper Spotify format
|
# Convert to proper Spotify format
|
||||||
|
|
@ -11349,13 +11370,36 @@ def _ensure_spotify_track_format(track_info):
|
||||||
|
|
||||||
# Build album object with images if available
|
# Build album object with images if available
|
||||||
album_data = track_info.get('album', {})
|
album_data = track_info.get('album', {})
|
||||||
|
|
||||||
|
# Wishlist tracks store album data inside spotify_data, not at top level
|
||||||
|
# album_data may be a string (sanitized), empty, or a dict without images
|
||||||
|
if not album_data or not isinstance(album_data, dict) or not album_data.get('images'):
|
||||||
|
spotify_data = track_info.get('spotify_data', {})
|
||||||
|
if isinstance(spotify_data, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
spotify_data = json.loads(spotify_data)
|
||||||
|
except Exception:
|
||||||
|
spotify_data = {}
|
||||||
|
if isinstance(spotify_data, dict) and isinstance(spotify_data.get('album'), dict):
|
||||||
|
album_data = spotify_data['album']
|
||||||
|
|
||||||
if isinstance(album_data, dict):
|
if isinstance(album_data, dict):
|
||||||
album = {
|
album = {
|
||||||
'name': album_data.get('name', 'Unknown Album')
|
'name': album_data.get('name', 'Unknown Album')
|
||||||
}
|
}
|
||||||
# Preserve album images if present (important for ListenBrainz tracks)
|
# Preserve album images if present
|
||||||
if 'images' in album_data:
|
if 'images' in album_data:
|
||||||
album['images'] = album_data['images']
|
album['images'] = album_data['images']
|
||||||
|
# Preserve album ID, type, and total_tracks for proper wishlist grouping
|
||||||
|
if 'id' in album_data:
|
||||||
|
album['id'] = album_data['id']
|
||||||
|
if 'album_type' in album_data:
|
||||||
|
album['album_type'] = album_data['album_type']
|
||||||
|
if 'total_tracks' in album_data:
|
||||||
|
album['total_tracks'] = album_data['total_tracks']
|
||||||
|
if 'release_date' in album_data:
|
||||||
|
album['release_date'] = album_data['release_date']
|
||||||
else:
|
else:
|
||||||
album = {
|
album = {
|
||||||
'name': str(album_data) if album_data else 'Unknown Album'
|
'name': str(album_data) if album_data else 'Unknown Album'
|
||||||
|
|
@ -11721,6 +11765,19 @@ def _on_download_completed(batch_id, task_id, success=True):
|
||||||
print(f"⏰ [Stuck Detection] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
print(f"⏰ [Stuck Detection] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
||||||
task['status'] = 'failed'
|
task['status'] = 'failed'
|
||||||
task['error_message'] = f'Retry timeout after {task_age:.0f} seconds'
|
task['error_message'] = f'Retry timeout after {task_age:.0f} seconds'
|
||||||
|
# Add to permanently_failed_tracks so it gets re-added to wishlist
|
||||||
|
original_track_info = task.get('track_info', {})
|
||||||
|
track_info = {
|
||||||
|
'download_index': task.get('track_index', 0),
|
||||||
|
'table_index': task.get('track_index', 0),
|
||||||
|
'track_name': original_track_info.get('name', 'Unknown Track'),
|
||||||
|
'artist_name': _get_track_artist_name(original_track_info),
|
||||||
|
'retry_count': task.get('retry_count', 0),
|
||||||
|
'spotify_track': _ensure_spotify_track_format(original_track_info),
|
||||||
|
'failure_reason': f'Search timeout after {task_age:.0f} seconds',
|
||||||
|
'candidates': task.get('cached_candidates', [])
|
||||||
|
}
|
||||||
|
batch.get('permanently_failed_tracks', []).append(track_info)
|
||||||
finished_count += 1
|
finished_count += 1
|
||||||
else:
|
else:
|
||||||
retrying_count += 1
|
retrying_count += 1
|
||||||
|
|
@ -11800,6 +11857,94 @@ def _on_download_completed(batch_id, task_id, success=True):
|
||||||
print(f"🔄 [Batch Manager] Starting next batch for {batch_id}")
|
print(f"🔄 [Batch Manager] Starting next batch for {batch_id}")
|
||||||
_start_next_batch_of_downloads(batch_id)
|
_start_next_batch_of_downloads(batch_id)
|
||||||
|
|
||||||
|
def _attempt_wishlist_album_searches(batch_id, missing_tracks):
|
||||||
|
"""
|
||||||
|
Group wishlist tracks by album and run album-level search for each group.
|
||||||
|
Reuses _attempt_album_level_search for each album group with 2+ tracks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of:
|
||||||
|
- all_album_matched: List of (track_analysis_result, pre_assigned_candidate: TrackResult) tuples
|
||||||
|
- all_unmatched: List of track_analysis_results that need per-track search
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
# Group tracks by album
|
||||||
|
album_groups = {} # key -> list of track analysis results
|
||||||
|
album_meta = {} # key -> {album_ctx, artist_ctx}
|
||||||
|
no_album_tracks = []
|
||||||
|
|
||||||
|
for res in missing_tracks:
|
||||||
|
track_data = res.get('track', {})
|
||||||
|
spotify_data = track_data.get('spotify_data', {})
|
||||||
|
if isinstance(spotify_data, str):
|
||||||
|
try:
|
||||||
|
spotify_data = _json.loads(spotify_data)
|
||||||
|
except Exception:
|
||||||
|
spotify_data = {}
|
||||||
|
|
||||||
|
s_album = spotify_data.get('album') if spotify_data else None
|
||||||
|
s_artists = spotify_data.get('artists', []) if spotify_data else []
|
||||||
|
|
||||||
|
if not s_album or not s_album.get('name'):
|
||||||
|
no_album_tracks.append(res)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Grouping key: album ID preferred, fallback to artist+album name
|
||||||
|
album_id = s_album.get('id', '')
|
||||||
|
album_name = s_album.get('name', '')
|
||||||
|
artist_name = ''
|
||||||
|
if s_artists and len(s_artists) > 0:
|
||||||
|
a = s_artists[0]
|
||||||
|
artist_name = a.get('name', '') if isinstance(a, dict) else str(a)
|
||||||
|
|
||||||
|
group_key = album_id if album_id else f"{artist_name}::{album_name}"
|
||||||
|
|
||||||
|
if group_key not in album_groups:
|
||||||
|
album_groups[group_key] = []
|
||||||
|
album_meta[group_key] = {
|
||||||
|
'album_ctx': {
|
||||||
|
'name': album_name,
|
||||||
|
'id': album_id,
|
||||||
|
'album_type': s_album.get('album_type', 'album'),
|
||||||
|
'total_tracks': s_album.get('total_tracks', 0),
|
||||||
|
'release_date': s_album.get('release_date', ''),
|
||||||
|
'images': s_album.get('images', [])
|
||||||
|
},
|
||||||
|
'artist_ctx': {
|
||||||
|
'name': artist_name,
|
||||||
|
'id': s_artists[0].get('id', '') if s_artists and isinstance(s_artists[0], dict) else ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
album_groups[group_key].append(res)
|
||||||
|
|
||||||
|
# Process each album group
|
||||||
|
all_matched = []
|
||||||
|
all_unmatched = list(no_album_tracks) # Tracks with no album data go straight to per-track
|
||||||
|
|
||||||
|
for group_key, group_tracks in album_groups.items():
|
||||||
|
meta = album_meta[group_key]
|
||||||
|
album_ctx = meta['album_ctx']
|
||||||
|
artist_ctx = meta['artist_ctx']
|
||||||
|
album_type = album_ctx.get('album_type', 'album').lower()
|
||||||
|
|
||||||
|
# Skip singles and groups with < 2 tracks
|
||||||
|
if album_type == 'single' or len(group_tracks) < 2:
|
||||||
|
all_unmatched.extend(group_tracks)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"[Wishlist Album Search] Searching for '{artist_ctx['name']}' - '{album_ctx['name']}' ({len(group_tracks)} tracks)")
|
||||||
|
print(f"🎵 [Wishlist] Album search: '{artist_ctx['name']}' - '{album_ctx['name']}' ({len(group_tracks)} tracks)")
|
||||||
|
|
||||||
|
matched, unmatched = _attempt_album_level_search(batch_id, group_tracks, album_ctx, artist_ctx)
|
||||||
|
all_matched.extend(matched)
|
||||||
|
all_unmatched.extend(unmatched)
|
||||||
|
|
||||||
|
logger.info(f"[Wishlist Album Search] Total: {len(all_matched)} pre-matched, {len(all_unmatched)} per-track search")
|
||||||
|
return all_matched, all_unmatched
|
||||||
|
|
||||||
|
|
||||||
def _attempt_album_level_search(batch_id, missing_tracks, batch_album_context, batch_artist_context):
|
def _attempt_album_level_search(batch_id, missing_tracks, batch_album_context, batch_artist_context):
|
||||||
"""
|
"""
|
||||||
Attempt to find a complete album source on Soulseek before falling back to per-track search.
|
Attempt to find a complete album source on Soulseek before falling back to per-track search.
|
||||||
|
|
@ -12095,9 +12240,13 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
|
||||||
album_unmatched = missing_tracks
|
album_unmatched = missing_tracks
|
||||||
|
|
||||||
if batch_is_album and batch_album_context and batch_artist_context:
|
if batch_is_album and batch_album_context and batch_artist_context:
|
||||||
|
# Single album from artist page / discover page
|
||||||
album_matched, album_unmatched = _attempt_album_level_search(
|
album_matched, album_unmatched = _attempt_album_level_search(
|
||||||
batch_id, missing_tracks, batch_album_context, batch_artist_context
|
batch_id, missing_tracks, batch_album_context, batch_artist_context
|
||||||
)
|
)
|
||||||
|
elif playlist_id == 'wishlist':
|
||||||
|
# Wishlist albums cycle — group tracks by album, search each
|
||||||
|
album_matched, album_unmatched = _attempt_wishlist_album_searches(batch_id, missing_tracks)
|
||||||
|
|
||||||
# Now create download tasks under the lock
|
# Now create download tasks under the lock
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
|
|
@ -13313,6 +13462,7 @@ def _build_batch_status_data(batch_id, batch, live_transfers_lookup):
|
||||||
if task['status'] != 'post_processing':
|
if task['status'] != 'post_processing':
|
||||||
task_status['status'] = 'post_processing'
|
task_status['status'] = 'post_processing'
|
||||||
task['status'] = 'post_processing'
|
task['status'] = 'post_processing'
|
||||||
|
task['status_change_time'] = time.time() # Reset so stuck detector doesn't fire prematurely
|
||||||
print(f"🔄 Task {task_id} API reports 'Succeeded' - starting post-processing verification")
|
print(f"🔄 Task {task_id} API reports 'Succeeded' - starting post-processing verification")
|
||||||
|
|
||||||
# Submit post-processing worker to verify file and complete the task
|
# Submit post-processing worker to verify file and complete the task
|
||||||
|
|
@ -13907,6 +14057,19 @@ def _check_batch_completion_v2(batch_id):
|
||||||
print(f"⏰ [Stuck Detection V2] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
print(f"⏰ [Stuck Detection V2] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
||||||
task['status'] = 'failed'
|
task['status'] = 'failed'
|
||||||
task['error_message'] = f'Retry timeout after {task_age:.0f} seconds'
|
task['error_message'] = f'Retry timeout after {task_age:.0f} seconds'
|
||||||
|
# Add to permanently_failed_tracks so it gets re-added to wishlist
|
||||||
|
original_track_info = task.get('track_info', {})
|
||||||
|
track_info = {
|
||||||
|
'download_index': task.get('track_index', 0),
|
||||||
|
'table_index': task.get('track_index', 0),
|
||||||
|
'track_name': original_track_info.get('name', 'Unknown Track'),
|
||||||
|
'artist_name': _get_track_artist_name(original_track_info),
|
||||||
|
'retry_count': task.get('retry_count', 0),
|
||||||
|
'spotify_track': _ensure_spotify_track_format(original_track_info),
|
||||||
|
'failure_reason': f'Search timeout after {task_age:.0f} seconds',
|
||||||
|
'candidates': task.get('cached_candidates', [])
|
||||||
|
}
|
||||||
|
batch.get('permanently_failed_tracks', []).append(track_info)
|
||||||
finished_count += 1
|
finished_count += 1
|
||||||
else:
|
else:
|
||||||
retrying_count += 1
|
retrying_count += 1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue