diff --git a/core/downloads/master.py b/core/downloads/master.py index ca3e72aa..c08ff696 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -369,6 +369,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma from core.downloads.playlist_folder import ( resolve_playlist_folder_mode_for_batch, + resolve_wishlist_track_playlist_folder_mode, track_exists_in_playlist_folder_from_track_data, ) effective_playlist_folder_mode, effective_playlist_name, keep_playlist_folder_copies = ( @@ -517,6 +518,38 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma }) continue + # For wishlist tracks, check per-track playlist-folder existence regardless + # of force_download_all. Wishlist batches have force_download_all=True so + # the batch-level check above is skipped, but a file already on disk in the + # playlist folder must not be downloaded again (causes casing duplicates when + # the provider returns a differently-cased artist name on a subsequent run). + if playlist_id == 'wishlist': + _wl_pl_folder, _wl_pl_name = resolve_wishlist_track_playlist_folder_mode( + track_data.get('source_info'), + db, + profile_id=batch_profile_id, + default_playlist_name=batch_playlist_name, + ) + if _wl_pl_folder and track_exists_in_playlist_folder_from_track_data( + _wl_pl_name, track_data + ): + logger.info( + f"[Wishlist Folder] '{track_name}' already in playlist folder " + f"'{_wl_pl_name}' — skipping re-download" + ) + try: + deps.check_and_remove_track_from_wishlist_by_metadata(track_data) + except Exception as _wl_err: + logger.debug(f"[Wishlist Folder] Wishlist removal failed: {_wl_err}") + analysis_results.append({ + 'track_index': track_index, + 'track': track_data, + 'found': True, + 'confidence': 1.0, + 'match_reason': 'wishlist_playlist_folder_file', + }) + continue + # Skip database check if force download is enabled if force_download_all: logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing") @@ -987,6 +1020,18 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma task_id = str(uuid.uuid4()) track_info = res['track'].copy() + wishlist_track_pl_folder = False + wishlist_track_pl_name = batch_playlist_name + if playlist_id == 'wishlist': + wishlist_track_pl_folder, wishlist_track_pl_name = ( + resolve_wishlist_track_playlist_folder_mode( + track_info.get('source_info'), + db, + profile_id=batch_profile_id, + default_playlist_name=batch_playlist_name, + ) + ) + # Add explicit album context to track_info for artist album downloads if batch_is_album and batch_album_context and batch_artist_context: track_info['_explicit_album_context'] = batch_album_context @@ -1012,8 +1057,11 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma s_album = {'name': s_album} # Normalize string album to dict s_artists = spotify_data.get('artists', []) - # We need at least an album name and artist - if s_album and isinstance(s_album, dict) and s_album.get('name'): + # Album grouping for library paths — skip when playlist-folder layout applies. + if ( + s_album and isinstance(s_album, dict) and s_album.get('name') + and not wishlist_track_pl_folder + ): # Use pre-computed album-level artist for folder consistency. # All tracks from the same album get the same artist context, # preventing folder splits on collab albums (KPOP Demon Hunters, etc.) @@ -1060,25 +1108,9 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma # tracks tied to a mirrored playlist with organize_by_playlist enabled. task_pl_folder_mode = batch_playlist_folder_mode task_pl_name = batch_playlist_name - if not task_pl_folder_mode and playlist_id == 'wishlist': - wl_source = track_info.get('source_info') or {} - if isinstance(wl_source, str): - try: - wl_source = json.loads(wl_source) - except (json.JSONDecodeError, TypeError): - wl_source = {} - wl_pl_ref = wl_source.get('playlist_id') - wl_pl_name = wl_source.get('playlist_name') - wl_pl_source = wl_source.get('source') or 'spotify' - if wl_pl_ref and hasattr(db, 'resolve_mirrored_playlist'): - wl_mirrored = db.resolve_mirrored_playlist( - wl_pl_ref, - profile_id=batch_profile_id, - default_source=wl_pl_source, - ) - if wl_mirrored and wl_mirrored.get('organize_by_playlist'): - task_pl_folder_mode = True - task_pl_name = wl_pl_name or wl_mirrored.get('name') or batch_playlist_name + if not task_pl_folder_mode and playlist_id == 'wishlist' and wishlist_track_pl_folder: + task_pl_folder_mode = True + task_pl_name = wishlist_track_pl_name if task_pl_folder_mode: track_info['_playlist_folder_mode'] = True track_info['_playlist_name'] = task_pl_name diff --git a/core/downloads/playlist_folder.py b/core/downloads/playlist_folder.py index f9e96c3b..476c12cc 100644 --- a/core/downloads/playlist_folder.py +++ b/core/downloads/playlist_folder.py @@ -2,8 +2,9 @@ from __future__ import annotations +import json import os -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from core.downloads.file_finder import AUDIO_EXTENSIONS from core.imports.paths import ( @@ -75,10 +76,34 @@ def track_exists_in_playlist_folder( artist: str, title: str, ) -> bool: - """Return True if any audio file exists at the playlist-folder path for this track.""" - for path in candidate_playlist_folder_paths(playlist_name, artist, title): + """Return True if any audio file exists at the playlist-folder path for this track. + + Uses a case-insensitive fallback scan of the playlist directory so that + provider-casing differences (e.g. "HUGEL" vs "hugel") don't cause the same + file to be downloaded twice under a differently-cased filename. + """ + candidates = candidate_playlist_folder_paths(playlist_name, artist, title) + for path in candidates: if os.path.isfile(path): return True + + # Case-insensitive fallback: list the playlist directory and compare basenames + # after lowercasing. On Linux (case-sensitive fs) a file written as + # "HUGEL - Song.flac" is invisible to an exact match for "hugel - Song.flac". + checked_dirs: set = set() + for path in candidates: + parent = os.path.dirname(path) + if parent in checked_dirs or not os.path.isdir(parent): + continue + checked_dirs.add(parent) + target_lower = os.path.basename(path).lower() + try: + for fname in os.listdir(parent): + if fname.lower() == target_lower: + return True + except OSError: + pass + return False @@ -123,6 +148,58 @@ def track_exists_in_playlist_folder_from_track_data( return track_exists_in_playlist_folder(playlist_name, artist, title) +def resolve_wishlist_track_playlist_folder_mode( + wl_source: Any, + db: Any, + *, + profile_id: int = 1, + default_playlist_name: str = 'Unknown Playlist', +) -> Tuple[bool, str]: + """Resolve playlist-folder layout for a single wishlist track. + + Honors ``organize_by_playlist`` stored when the row was added from a download + modal, then falls back to the mirrored-playlist row (using ``playlist_source`` + or ``source`` for upstream lookup). + """ + if isinstance(wl_source, str): + try: + wl_source = json.loads(wl_source) + except (json.JSONDecodeError, TypeError): + wl_source = {} + if not isinstance(wl_source, dict): + return False, default_playlist_name + + playlist_name = wl_source.get('playlist_name') or default_playlist_name + + if wl_source.get('organize_by_playlist'): + return True, playlist_name + + wl_pl_ref = wl_source.get('playlist_id') + wl_pl_source = ( + wl_source.get('source') + or wl_source.get('playlist_source') + or 'spotify' + ) + if not wl_pl_ref or not hasattr(db, 'resolve_mirrored_playlist'): + return False, playlist_name + + refs_to_try: List[Tuple[str, str]] = [(str(wl_pl_ref).strip(), wl_pl_source)] + ui_ref = wl_source.get('ui_playlist_ref') + if ui_ref and str(ui_ref).strip() != str(wl_pl_ref).strip(): + refs_to_try.append((str(ui_ref).strip(), wl_pl_source)) + + for ref, src in refs_to_try: + if not ref: + continue + mirrored = db.resolve_mirrored_playlist( + ref, profile_id=profile_id, default_source=src or 'spotify' + ) + if mirrored and mirrored.get('organize_by_playlist'): + return True, playlist_name or mirrored.get('name') or default_playlist_name + + return False, playlist_name + + def resolve_playlist_folder_mode_for_batch( db: Any, *, @@ -166,5 +243,6 @@ __all__ = [ 'is_soulsync_standalone_server', 'track_exists_in_playlist_folder', 'track_exists_in_playlist_folder_from_track_data', + 'resolve_wishlist_track_playlist_folder_mode', 'resolve_playlist_folder_mode_for_batch', ] diff --git a/core/library/duplicate_cleaner.py b/core/library/duplicate_cleaner.py index 29fe4729..f07f1777 100644 --- a/core/library/duplicate_cleaner.py +++ b/core/library/duplicate_cleaner.py @@ -113,8 +113,10 @@ def _run_duplicate_cleaner(): if file_ext_lower not in audio_extensions: continue - # Group by directory and filename (without extension) - files_by_dir_and_name[root][file_name].append({ + # Group by directory and normalised filename (without extension, + # lower-cased so that casing variants like "HUGEL - Song.flac" + # and "hugel - Song.flac" are treated as the same track). + files_by_dir_and_name[root][file_name.lower()].append({ 'full_path': file_path, 'extension': file_ext_lower, 'size': os.path.getsize(file_path) diff --git a/core/wishlist/processing.py b/core/wishlist/processing.py index 2074921e..1e62e465 100644 --- a/core/wishlist/processing.py +++ b/core/wishlist/processing.py @@ -403,8 +403,12 @@ def build_wishlist_source_context(batch: Dict[str, Any], current_time: datetime } if batch.get('mirrored_playlist_id') is not None: context['mirrored_playlist_id'] = batch.get('mirrored_playlist_id') - if batch.get('organize_by_playlist'): + if batch.get('organize_by_playlist') or batch.get('playlist_folder_mode'): context['organize_by_playlist'] = True + batch_source = batch.get('batch_source') or batch.get('source_page') + if batch_source: + context['playlist_source'] = batch_source + context['source'] = batch_source # Preserve album-batch provenance so wishlist requeue has a real signal # for album-vs-single routing instead of relying on per-track album dicts # that may have been mangled by reconstruction fallbacks. diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index d6101d61..288bcd0b 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -1119,6 +1119,37 @@ def test_playlist_folder_mode_propagates(monkeypatch): assert info['_playlist_name'] == 'My Mix' +def test_wishlist_source_info_organize_by_playlist_enables_folder_mode(monkeypatch): + """Wishlist requeue honors organize_by_playlist saved from the download modal.""" + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + deps = _build_deps() + _seed_batch('Bwlf') + tracks = [{ + 'name': 'Song One', + 'artists': [{'name': 'Artist One'}], + 'source_info': { + 'playlist_id': '37i9dQZF1DX0XUsuxWHRQd', + 'playlist_name': 'Daily Mix', + 'organize_by_playlist': True, + 'playlist_source': 'spotify', + }, + 'spotify_data': { + 'album': {'id': 'album-1', 'name': 'Album One'}, + 'artists': [{'name': 'Artist One'}], + }, + }] + + mw.run_full_missing_tracks_process('Bwlf', 'wishlist', tracks, deps) + + task_id = download_batches['Bwlf']['queue'][0] + info = download_tasks[task_id]['track_info'] + assert info['_playlist_folder_mode'] is True + assert info['_playlist_name'] == 'Daily Mix' + assert '_is_explicit_album_download' not in info + + # --------------------------------------------------------------------------- # Hand-off to monitor + start_next_batch # --------------------------------------------------------------------------- diff --git a/tests/downloads/test_playlist_folder_exists.py b/tests/downloads/test_playlist_folder_exists.py index 5d0dfbe6..7bb50c1c 100644 --- a/tests/downloads/test_playlist_folder_exists.py +++ b/tests/downloads/test_playlist_folder_exists.py @@ -9,6 +9,7 @@ from core.downloads.playlist_folder import ( candidate_playlist_folder_paths, effective_keep_playlist_folder_copies, resolve_playlist_folder_mode_for_batch, + resolve_wishlist_track_playlist_folder_mode, track_exists_in_playlist_folder, ) @@ -37,6 +38,25 @@ def test_track_exists_in_playlist_folder_finds_file(tmp_path): assert track_exists_in_playlist_folder('My Playlist', 'Artist A', 'Song One') +def test_track_exists_in_playlist_folder_case_insensitive(tmp_path): + """File stored as 'HUGEL - Song.flac' must be detected when the lookup + uses lowercase 'hugel' — providers often return artist names with different + casing on different calls, which would cause spurious re-downloads.""" + playlist_dir = tmp_path / 'My Playlist' + playlist_dir.mkdir() + (playlist_dir / 'HUGEL - Song One.flac').write_bytes(b'x') + + with patch('core.downloads.playlist_folder._get_config_manager') as cfg: + cfg.return_value.get.return_value = str(tmp_path) + with patch('core.downloads.playlist_folder.docker_resolve_path', side_effect=lambda p: p): + with patch( + 'core.downloads.playlist_folder.get_file_path_from_template', + return_value=('', ''), + ): + # Lowercase artist lookup must still find the UPPER-CASE file + assert track_exists_in_playlist_folder('My Playlist', 'hugel', 'Song One') + + def test_track_exists_in_playlist_folder_missing(tmp_path): with patch('core.downloads.playlist_folder._get_config_manager') as cfg: cfg.return_value.get.return_value = str(tmp_path) @@ -129,3 +149,35 @@ def test_standalone_keep_copies_opt_out_honored(): 'keep_playlist_folder_copies_opt_out': True, } assert effective_keep_playlist_folder_copies(mirrored, 'soulsync') is False + + +def test_wishlist_organize_flag_in_source_info_enables_folder_mode(): + enabled, name = resolve_wishlist_track_playlist_folder_mode( + { + 'playlist_id': '37i9dQZF1DX', + 'playlist_name': 'Daily Mix', + 'organize_by_playlist': True, + 'playlist_source': 'spotify', + }, + _FakeDB(), + ) + assert enabled is True + assert name == 'Daily Mix' + + +def test_wishlist_resolves_mirrored_playlist_via_playlist_source(): + db = _FakeDB(mirrored={ + 'id': 9, + 'name': 'Summer Mix', + 'organize_by_playlist': True, + }) + enabled, name = resolve_wishlist_track_playlist_folder_mode( + { + 'playlist_id': '12345', + 'playlist_name': 'Summer Mix', + 'playlist_source': 'deezer', + }, + db, + ) + assert enabled is True + assert name == 'Summer Mix' diff --git a/tests/wishlist/test_processing.py b/tests/wishlist/test_processing.py index 28f7e3ec..7c8bd69e 100644 --- a/tests/wishlist/test_processing.py +++ b/tests/wishlist/test_processing.py @@ -193,6 +193,39 @@ def test_build_wishlist_source_context_uses_source_playlist_ref_for_organize_bat assert context["organize_by_playlist"] is True +def test_build_wishlist_source_context_playlist_folder_mode_sets_organize(): + """Batches that only carry ``playlist_folder_mode`` (e.g. Download Missing UI) + must produce a context with ``organize_by_playlist`` so the wishlist requeue + routing logic picks up the playlist-folder flag even without an explicit + ``organize_by_playlist`` key on the batch.""" + batch = { + "playlist_name": "Chill Vibes", + "playlist_id": "spId99", + "playlist_folder_mode": True, + "batch_source": "spotify", + } + + context = processing.build_wishlist_source_context(batch) + + assert context["organize_by_playlist"] is True + assert context["playlist_id"] == "spId99" + assert context.get("source") == "spotify" + + +def test_build_wishlist_source_context_no_organize_when_folder_mode_off(): + """When ``playlist_folder_mode`` is False and ``organize_by_playlist`` is + absent the flag must NOT appear in the resulting context.""" + batch = { + "playlist_name": "Workout", + "playlist_id": "spId00", + "playlist_folder_mode": False, + } + + context = processing.build_wishlist_source_context(batch) + + assert "organize_by_playlist" not in context + + def test_build_wishlist_source_context_preserves_album_context_for_album_batches(): """Album batches must carry album_context/artist_context through to the wishlist row so a later requeue has authoritative routing data instead diff --git a/tests/wishlist/test_routes.py b/tests/wishlist/test_routes.py index 8292aaba..a74f779b 100644 --- a/tests/wishlist/test_routes.py +++ b/tests/wishlist/test_routes.py @@ -435,6 +435,41 @@ def test_get_wishlist_cycle_returns_stored_value(): assert payload == {"cycle": "singles"} +def test_add_album_track_to_wishlist_preserves_playlist_modal_context(): + runtime, service, _db, _logger, _activity_calls = _build_runtime() + track = { + "id": "track-1", + "name": "Song One", + "artists": [{"name": "Artist One"}], + "duration_ms": 1234, + } + artist = {"id": "artist-1", "name": "Artist One"} + album = {"id": "album-1", "name": "Album One"} + + payload, status = add_album_track_to_wishlist( + runtime, + track=track, + artist=artist, + album=album, + source_type="playlist", + source_context={ + "playlist_id": "37i9dQZF1DX0XUsuxWHRQd", + "playlist_name": "Daily Mix", + "organize_by_playlist": True, + "added_from": "download_modal", + }, + ) + + assert status == 200 + assert payload["success"] is True + add_call = service.add_calls[0] + assert add_call["source_type"] == "playlist" + assert add_call["source_context"]["playlist_id"] == "37i9dQZF1DX0XUsuxWHRQd" + assert add_call["source_context"]["playlist_name"] == "Daily Mix" + assert add_call["source_context"]["organize_by_playlist"] is True + assert add_call["source_context"]["added_from"] == "download_modal" + + def test_add_album_track_to_wishlist_builds_spotify_payload_and_merges_context(): runtime, service, _db, _logger, _activity_calls = _build_runtime() track = { diff --git a/web_server.py b/web_server.py index b59b7cd0..3249e4e8 100644 --- a/web_server.py +++ b/web_server.py @@ -18810,6 +18810,7 @@ def start_missing_tracks_process(playlist_id): 'force_download_all': force_download_all, # Pass the force flag to the batch 'ignore_manual_matches': ignore_manual_matches, 'playlist_folder_mode': playlist_folder_mode, # Organize downloads by playlist folder + 'organize_by_playlist': bool(playlist_folder_mode), 'keep_playlist_folder_copies': keep_playlist_folder_copies and playlist_folder_mode, # Album context for artist album downloads (explicit folder structure) 'is_album_download': is_album_download, diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 2a4b9f99..5029dbdc 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -981,6 +981,15 @@ function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) { if (ref.startsWith('deezer_arl_')) { return 'deezer'; } + if (ref.startsWith('tidal_')) { + return 'tidal'; + } + if (ref.startsWith('youtube_')) { + return 'youtube'; + } + if (ref.startsWith('qobuz_')) { + return 'qobuz'; + } return 'spotify'; } @@ -1202,16 +1211,26 @@ async function onPlaylistKeepCopiesPreferenceChange(playlistRef, enabled, source } } -function onDownloadMissingOrganizeToggle(playlistId, enabled) { +async function onDownloadMissingOrganizeToggle(playlistId, enabled) { syncPlaylistOrganizeCheckboxes(playlistId, enabled, enabled && isSoulsyncStandaloneMode()); + const source = playlistOrganizeSourceForRef(playlistId); + const ok = await setMirroredOrganizePreference(playlistId, enabled, source); + if (!ok) { + showToast('Could not save playlist folder preference (mirror this playlist first)', 'warning'); + } } -function onDownloadMissingKeepCopiesToggle(playlistId, enabled) { +async function onDownloadMissingKeepCopiesToggle(playlistId, enabled) { const organizeCb = document.getElementById(`playlist-folder-mode-${playlistId}`); if (organizeCb && !organizeCb.checked) { organizeCb.checked = true; } syncPlaylistOrganizeCheckboxes(playlistId, true, enabled); + const source = playlistOrganizeSourceForRef(playlistId); + const ok = await setMirroredKeepCopiesPreference(playlistId, enabled, source); + if (!ok) { + showToast('Could not save keep folder copies preference (mirror this playlist first)', 'warning'); + } } async function applyMirroredOrganizePreference(playlistRef, source = null) { diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index d2f2db1a..0e70ab25 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -1315,6 +1315,83 @@ async function handleWishlistDownloadNow() { registerArtistDownload(artist, album, virtualPlaylistId, albumType); } +/** Playlist-modal ids that should NOT receive playlist-folder wishlist provenance. */ +const MODAL_ALBUM_WISHLIST_PREFIXES = [ + 'artist_album_', 'discover_album_', 'enhanced_search_album_', 'seasonal_album_', + 'spotify_library_', 'beatport_release_', 'discover_cache_', +]; +const MODAL_SINGLE_TRACK_WISHLIST_PREFIXES = [ + 'enhanced_search_track_', 'gsearch_track_', +]; +const MODAL_NON_PLAYLIST_WISHLIST_PREFIXES = [ + 'issue_download_', 'library_redownload_', 'redownload_', +]; + +function isModalPlaylistWishlistContext(playlistId) { + const id = String(playlistId || ''); + if (!id || id === 'wishlist' || id.startsWith('wishlist_')) { + return false; + } + if (MODAL_ALBUM_WISHLIST_PREFIXES.some((p) => id.startsWith(p))) { + return false; + } + if (MODAL_SINGLE_TRACK_WISHLIST_PREFIXES.some((p) => id.startsWith(p))) { + return false; + } + if (MODAL_NON_PLAYLIST_WISHLIST_PREFIXES.some((p) => id.startsWith(p))) { + return false; + } + return true; +} + +function resolveModalWishlistSourceType(playlistId) { + return isModalPlaylistWishlistContext(playlistId) ? 'playlist' : 'album'; +} + +/** + * Build source_context for wishlist rows added from a download modal. + * Playlist modals include playlist_id/name so wishlist requeue can use playlist-folder layout. + */ +function buildModalWishlistSourceContext(playlistId, process, trackAlbum, trackArtist, trackAlbumType) { + const timestamp = new Date().toISOString(); + if (!isModalPlaylistWishlistContext(playlistId)) { + return { + album_name: trackAlbum?.name, + artist_name: trackArtist?.name, + album_type: trackAlbumType || 'album', + added_from: 'download_modal', + timestamp, + }; + } + + const playlistName = process.playlist?.name || process.playlistName || 'Unknown Playlist'; + const organizeSource = typeof playlistOrganizeSourceForRef === 'function' + ? playlistOrganizeSourceForRef(playlistId) + : 'spotify'; + const resolveRef = typeof normalizePlaylistOrganizeRef === 'function' + ? normalizePlaylistOrganizeRef(playlistId, organizeSource) + : playlistId; + const organizeEnabled = typeof isPlaylistOrganizeEnabled === 'function' + ? isPlaylistOrganizeEnabled(playlistId) + : false; + + const context = { + playlist_name: playlistName, + playlist_id: resolveRef, + playlist_source: organizeSource, + source: organizeSource, + added_from: 'download_modal', + timestamp, + }; + if (organizeEnabled) { + context.organize_by_playlist = true; + } + if (resolveRef !== playlistId) { + context.ui_playlist_ref = playlistId; + } + return context; +} + /** * Add all tracks from any download modal to the wishlist * Universal handler for all modal types (artist albums, playlists, YouTube, Tidal, etc.) @@ -1351,8 +1428,12 @@ async function addModalTracksToWishlist(playlistId) { // not for playlists, so we must NOT use it as a blanket default. const processArtist = process.artist || null; const album = process.album || process.playlist || { name: 'Playlist', id: playlistId }; + const wishlistSourceType = resolveModalWishlistSourceType(playlistId); - console.log(`🔄 Adding ${tracks.length} tracks from "${album.name}" to wishlist (process artist: ${processArtist?.name || 'per-track'})`); + console.log( + `🔄 Adding ${tracks.length} tracks from "${album.name}" to wishlist ` + + `(source: ${wishlistSourceType}, process artist: ${processArtist?.name || 'per-track'})` + ); // Disable the button to prevent double-clicks const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); @@ -1394,7 +1475,7 @@ async function addModalTracksToWishlist(playlistId) { } }); } else { - formattedArtists = [{ name: artist.name }]; + formattedArtists = [{ name: 'Unknown Artist' }]; } const formattedTrack = { @@ -1447,6 +1528,14 @@ async function addModalTracksToWishlist(playlistId) { trackArtist = { name: 'Unknown Artist', id: null }; } + const sourceContext = buildModalWishlistSourceContext( + playlistId, + process, + trackAlbum, + trackArtist, + trackAlbumType, + ); + const response = await fetch('/api/add-album-to-wishlist', { method: 'POST', headers: { @@ -1456,12 +1545,8 @@ async function addModalTracksToWishlist(playlistId) { track: formattedTrack, artist: trackArtist, album: trackAlbum, - source_type: 'album', - source_context: { - album_name: trackAlbum.name, - artist_name: trackArtist.name, - album_type: trackAlbumType - } + source_type: wishlistSourceType, + source_context: sourceContext, }) });