Propagate playlist provenance and fix wishlist playlist-folder downloads.

Persist organize_by_playlist from Download Missing batches and modal
wishlist adds, save organize toggles to mirrored playlists, resolve
per-track playlist folders on wishlist requeue, skip re-downloads when
a file already exists (case-insensitive), and treat same-folder casing
variants as duplicates in the duplicate cleaner.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kekkokk 2026-06-04 15:48:39 +02:00
parent 8c4d6e34dc
commit d950da8573
11 changed files with 409 additions and 37 deletions

View file

@ -369,6 +369,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
from core.downloads.playlist_folder import ( from core.downloads.playlist_folder import (
resolve_playlist_folder_mode_for_batch, resolve_playlist_folder_mode_for_batch,
resolve_wishlist_track_playlist_folder_mode,
track_exists_in_playlist_folder_from_track_data, track_exists_in_playlist_folder_from_track_data,
) )
effective_playlist_folder_mode, effective_playlist_name, keep_playlist_folder_copies = ( 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 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 # Skip database check if force download is enabled
if force_download_all: if force_download_all:
logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing") 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()) task_id = str(uuid.uuid4())
track_info = res['track'].copy() 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 # Add explicit album context to track_info for artist album downloads
if batch_is_album and batch_album_context and batch_artist_context: if batch_is_album and batch_album_context and batch_artist_context:
track_info['_explicit_album_context'] = batch_album_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_album = {'name': s_album} # Normalize string album to dict
s_artists = spotify_data.get('artists', []) s_artists = spotify_data.get('artists', [])
# We need at least an album name and artist # Album grouping for library paths — skip when playlist-folder layout applies.
if s_album and isinstance(s_album, dict) and s_album.get('name'): 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. # Use pre-computed album-level artist for folder consistency.
# All tracks from the same album get the same artist context, # All tracks from the same album get the same artist context,
# preventing folder splits on collab albums (KPOP Demon Hunters, etc.) # 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. # tracks tied to a mirrored playlist with organize_by_playlist enabled.
task_pl_folder_mode = batch_playlist_folder_mode task_pl_folder_mode = batch_playlist_folder_mode
task_pl_name = batch_playlist_name task_pl_name = batch_playlist_name
if not task_pl_folder_mode and playlist_id == 'wishlist': if not task_pl_folder_mode and playlist_id == 'wishlist' and wishlist_track_pl_folder:
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_folder_mode = True
task_pl_name = wl_pl_name or wl_mirrored.get('name') or batch_playlist_name task_pl_name = wishlist_track_pl_name
if task_pl_folder_mode: if task_pl_folder_mode:
track_info['_playlist_folder_mode'] = True track_info['_playlist_folder_mode'] = True
track_info['_playlist_name'] = task_pl_name track_info['_playlist_name'] = task_pl_name

View file

@ -2,8 +2,9 @@
from __future__ import annotations from __future__ import annotations
import json
import os 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.downloads.file_finder import AUDIO_EXTENSIONS
from core.imports.paths import ( from core.imports.paths import (
@ -75,10 +76,34 @@ def track_exists_in_playlist_folder(
artist: str, artist: str,
title: str, title: str,
) -> bool: ) -> bool:
"""Return True if any audio file exists at the playlist-folder path for this track.""" """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):
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): if os.path.isfile(path):
return True 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 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) 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( def resolve_playlist_folder_mode_for_batch(
db: Any, db: Any,
*, *,
@ -166,5 +243,6 @@ __all__ = [
'is_soulsync_standalone_server', 'is_soulsync_standalone_server',
'track_exists_in_playlist_folder', 'track_exists_in_playlist_folder',
'track_exists_in_playlist_folder_from_track_data', 'track_exists_in_playlist_folder_from_track_data',
'resolve_wishlist_track_playlist_folder_mode',
'resolve_playlist_folder_mode_for_batch', 'resolve_playlist_folder_mode_for_batch',
] ]

View file

@ -113,8 +113,10 @@ def _run_duplicate_cleaner():
if file_ext_lower not in audio_extensions: if file_ext_lower not in audio_extensions:
continue continue
# Group by directory and filename (without extension) # Group by directory and normalised filename (without extension,
files_by_dir_and_name[root][file_name].append({ # 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, 'full_path': file_path,
'extension': file_ext_lower, 'extension': file_ext_lower,
'size': os.path.getsize(file_path) 'size': os.path.getsize(file_path)

View file

@ -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: if batch.get('mirrored_playlist_id') is not None:
context['mirrored_playlist_id'] = batch.get('mirrored_playlist_id') 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 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 # Preserve album-batch provenance so wishlist requeue has a real signal
# for album-vs-single routing instead of relying on per-track album dicts # for album-vs-single routing instead of relying on per-track album dicts
# that may have been mangled by reconstruction fallbacks. # that may have been mangled by reconstruction fallbacks.

View file

@ -1119,6 +1119,37 @@ def test_playlist_folder_mode_propagates(monkeypatch):
assert info['_playlist_name'] == 'My Mix' 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 # Hand-off to monitor + start_next_batch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -9,6 +9,7 @@ from core.downloads.playlist_folder import (
candidate_playlist_folder_paths, candidate_playlist_folder_paths,
effective_keep_playlist_folder_copies, effective_keep_playlist_folder_copies,
resolve_playlist_folder_mode_for_batch, resolve_playlist_folder_mode_for_batch,
resolve_wishlist_track_playlist_folder_mode,
track_exists_in_playlist_folder, 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') 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): def test_track_exists_in_playlist_folder_missing(tmp_path):
with patch('core.downloads.playlist_folder._get_config_manager') as cfg: with patch('core.downloads.playlist_folder._get_config_manager') as cfg:
cfg.return_value.get.return_value = str(tmp_path) 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, 'keep_playlist_folder_copies_opt_out': True,
} }
assert effective_keep_playlist_folder_copies(mirrored, 'soulsync') is False 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'

View file

@ -193,6 +193,39 @@ def test_build_wishlist_source_context_uses_source_playlist_ref_for_organize_bat
assert context["organize_by_playlist"] is True 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(): def test_build_wishlist_source_context_preserves_album_context_for_album_batches():
"""Album batches must carry album_context/artist_context through to the """Album batches must carry album_context/artist_context through to the
wishlist row so a later requeue has authoritative routing data instead wishlist row so a later requeue has authoritative routing data instead

View file

@ -435,6 +435,41 @@ def test_get_wishlist_cycle_returns_stored_value():
assert payload == {"cycle": "singles"} 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(): def test_add_album_track_to_wishlist_builds_spotify_payload_and_merges_context():
runtime, service, _db, _logger, _activity_calls = _build_runtime() runtime, service, _db, _logger, _activity_calls = _build_runtime()
track = { track = {

View file

@ -18810,6 +18810,7 @@ def start_missing_tracks_process(playlist_id):
'force_download_all': force_download_all, # Pass the force flag to the batch 'force_download_all': force_download_all, # Pass the force flag to the batch
'ignore_manual_matches': ignore_manual_matches, 'ignore_manual_matches': ignore_manual_matches,
'playlist_folder_mode': playlist_folder_mode, # Organize downloads by playlist folder '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, 'keep_playlist_folder_copies': keep_playlist_folder_copies and playlist_folder_mode,
# Album context for artist album downloads (explicit folder structure) # Album context for artist album downloads (explicit folder structure)
'is_album_download': is_album_download, 'is_album_download': is_album_download,

View file

@ -981,6 +981,15 @@ function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) {
if (ref.startsWith('deezer_arl_')) { if (ref.startsWith('deezer_arl_')) {
return 'deezer'; return 'deezer';
} }
if (ref.startsWith('tidal_')) {
return 'tidal';
}
if (ref.startsWith('youtube_')) {
return 'youtube';
}
if (ref.startsWith('qobuz_')) {
return 'qobuz';
}
return 'spotify'; 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()); 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}`); const organizeCb = document.getElementById(`playlist-folder-mode-${playlistId}`);
if (organizeCb && !organizeCb.checked) { if (organizeCb && !organizeCb.checked) {
organizeCb.checked = true; organizeCb.checked = true;
} }
syncPlaylistOrganizeCheckboxes(playlistId, true, enabled); 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) { async function applyMirroredOrganizePreference(playlistRef, source = null) {

View file

@ -1315,6 +1315,83 @@ async function handleWishlistDownloadNow() {
registerArtistDownload(artist, album, virtualPlaylistId, albumType); 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 * Add all tracks from any download modal to the wishlist
* Universal handler for all modal types (artist albums, playlists, YouTube, Tidal, etc.) * 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. // not for playlists, so we must NOT use it as a blanket default.
const processArtist = process.artist || null; const processArtist = process.artist || null;
const album = process.album || process.playlist || { name: 'Playlist', id: playlistId }; 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 // Disable the button to prevent double-clicks
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
@ -1394,7 +1475,7 @@ async function addModalTracksToWishlist(playlistId) {
} }
}); });
} else { } else {
formattedArtists = [{ name: artist.name }]; formattedArtists = [{ name: 'Unknown Artist' }];
} }
const formattedTrack = { const formattedTrack = {
@ -1447,6 +1528,14 @@ async function addModalTracksToWishlist(playlistId) {
trackArtist = { name: 'Unknown Artist', id: null }; trackArtist = { name: 'Unknown Artist', id: null };
} }
const sourceContext = buildModalWishlistSourceContext(
playlistId,
process,
trackAlbum,
trackArtist,
trackAlbumType,
);
const response = await fetch('/api/add-album-to-wishlist', { const response = await fetch('/api/add-album-to-wishlist', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -1456,12 +1545,8 @@ async function addModalTracksToWishlist(playlistId) {
track: formattedTrack, track: formattedTrack,
artist: trackArtist, artist: trackArtist,
album: trackAlbum, album: trackAlbum,
source_type: 'album', source_type: wishlistSourceType,
source_context: { source_context: sourceContext,
album_name: trackAlbum.name,
artist_name: trackArtist.name,
album_type: trackAlbumType
}
}) })
}); });