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:
parent
8c4d6e34dc
commit
d950da8573
11 changed files with 409 additions and 37 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue