Fix organize-by-playlist downloads: library entries, wishlist, and stale Spotify cache
Persist organize_by_playlist on mirrored playlists and run playlist-folder downloads from the auto-sync pipeline instead of the global wishlist phase. Register SoulSync library rows after playlist-folder post-processing, route failed organize batches to the wishlist correctly, and skip sync-time unmatched wishlist only when organize download handles retries. Invalidate stale playlist track caches on refresh (Spotify and Deezer ARL), re-mirror on refetch, and improve standalone playlist modals (re-analysis, Open in Mirrored). Add filesystem missing-track detection and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
117f52bb25
commit
9ff2e7084a
29 changed files with 1465 additions and 104 deletions
|
|
@ -28,7 +28,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -105,6 +105,8 @@ class AutomationDeps:
|
||||||
# --- Playlist pipeline entry points ---
|
# --- Playlist pipeline entry points ---
|
||||||
run_playlist_discovery_worker: Callable[..., Any]
|
run_playlist_discovery_worker: Callable[..., Any]
|
||||||
run_sync_task: Callable[..., Any]
|
run_sync_task: Callable[..., Any]
|
||||||
|
run_playlist_organize_download: Callable[..., Dict[str, Any]]
|
||||||
|
missing_download_executor: Any
|
||||||
load_sync_status_file: Callable[[], dict]
|
load_sync_status_file: Callable[[], dict]
|
||||||
get_deezer_client: Callable[[], Any]
|
get_deezer_client: Callable[[], Any]
|
||||||
parse_youtube_playlist: Callable[[str], Any]
|
parse_youtube_playlist: Callable[[str], Any]
|
||||||
|
|
|
||||||
|
|
@ -148,9 +148,45 @@ def run_sync_and_wishlist(
|
||||||
log_type='success' if sync_errors == 0 else 'warning',
|
log_type='success' if sync_errors == 0 else 'warning',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
organize_playlists = [pl for pl in playlists if pl.get('organize_by_playlist')]
|
||||||
|
organize_started = 0
|
||||||
|
if organize_playlists and hasattr(deps, 'run_playlist_organize_download'):
|
||||||
|
for pl in organize_playlists:
|
||||||
|
pl_id = pl.get('id')
|
||||||
|
if not pl_id:
|
||||||
|
continue
|
||||||
|
pl_name = pl.get('name', '')
|
||||||
|
try:
|
||||||
|
org_result = deps.run_playlist_organize_download(
|
||||||
|
mirrored_playlist_id=int(pl_id),
|
||||||
|
automation_id=automation_id,
|
||||||
|
)
|
||||||
|
if org_result.get('status') == 'started':
|
||||||
|
organize_started += 1
|
||||||
|
deps.update_progress(
|
||||||
|
automation_id,
|
||||||
|
log_line=f'Organize download started for "{pl_name}"',
|
||||||
|
log_type='success',
|
||||||
|
)
|
||||||
|
elif org_result.get('status') == 'skipped':
|
||||||
|
deps.update_progress(
|
||||||
|
automation_id,
|
||||||
|
log_line=f'Organize download skipped for "{pl_name}": {org_result.get("reason", "")}',
|
||||||
|
log_type='skip',
|
||||||
|
)
|
||||||
|
except Exception as org_err: # noqa: BLE001
|
||||||
|
deps.update_progress(
|
||||||
|
automation_id,
|
||||||
|
log_line=f'Organize download error for "{pl_name}": {org_err}',
|
||||||
|
log_type='warning',
|
||||||
|
)
|
||||||
|
|
||||||
|
all_organize = bool(playlists) and len(organize_playlists) == len(playlists)
|
||||||
|
effective_skip_wishlist = skip_wishlist or all_organize
|
||||||
|
|
||||||
wishlist_queued = run_wishlist_phase(
|
wishlist_queued = run_wishlist_phase(
|
||||||
deps, automation_id,
|
deps, automation_id,
|
||||||
skip=skip_wishlist,
|
skip=effective_skip_wishlist,
|
||||||
progress_pct=progress_end + 1,
|
progress_pct=progress_end + 1,
|
||||||
wishlist_phase_label=wishlist_phase_label,
|
wishlist_phase_label=wishlist_phase_label,
|
||||||
wishlist_phase_start_log=wishlist_phase_start_log,
|
wishlist_phase_start_log=wishlist_phase_start_log,
|
||||||
|
|
@ -161,6 +197,7 @@ def run_sync_and_wishlist(
|
||||||
'skipped': total_skipped,
|
'skipped': total_skipped,
|
||||||
'errors': sync_errors,
|
'errors': sync_errors,
|
||||||
'wishlist_queued': wishlist_queued,
|
'wishlist_queued': wishlist_queued,
|
||||||
|
'organize_downloads_started': organize_started,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,9 +180,11 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str
|
||||||
log_line=f'Starting sync: {len(tracks_json)} tracks',
|
log_line=f'Starting sync: {len(tracks_json)} tracks',
|
||||||
log_type='success',
|
log_type='success',
|
||||||
)
|
)
|
||||||
|
skip_wishlist_add = bool(pl.get('organize_by_playlist'))
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=deps.run_sync_task,
|
target=deps.run_sync_task,
|
||||||
args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')),
|
args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')),
|
||||||
|
kwargs={'skip_wishlist_add': skip_wishlist_add},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name=f'auto-sync-{playlist_id}',
|
name=f'auto-sync-{playlist_id}',
|
||||||
).start()
|
).start()
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,17 @@ async def _database_only_find_track(spotify_track, candidate_pool=None):
|
||||||
return None, 0.0
|
return None, 0.0
|
||||||
|
|
||||||
|
|
||||||
def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', deps: SyncDeps = None, sync_mode: str = 'replace'):
|
def run_sync_task(
|
||||||
|
playlist_id,
|
||||||
|
playlist_name,
|
||||||
|
tracks_json,
|
||||||
|
automation_id=None,
|
||||||
|
profile_id=1,
|
||||||
|
playlist_image_url='',
|
||||||
|
deps: SyncDeps = None,
|
||||||
|
sync_mode: str = 'replace',
|
||||||
|
skip_wishlist_add: bool = False,
|
||||||
|
):
|
||||||
"""The actual sync function that runs in the background thread."""
|
"""The actual sync function that runs in the background thread."""
|
||||||
sync_states = deps.sync_states
|
sync_states = deps.sync_states
|
||||||
sync_lock = deps.sync_lock
|
sync_lock = deps.sync_lock
|
||||||
|
|
@ -360,7 +370,14 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
||||||
# Wing It mode — skip wishlist for unmatched tracks
|
# Wing It mode — skip wishlist for unmatched tracks
|
||||||
with sync_lock:
|
with sync_lock:
|
||||||
is_wing_it = sync_states.get(playlist_id, {}).get('wing_it', False)
|
is_wing_it = sync_states.get(playlist_id, {}).get('wing_it', False)
|
||||||
|
sync_service._skip_unmatched_wishlist = is_wing_it or skip_wishlist_add
|
||||||
sync_service._skip_wishlist = is_wing_it
|
sync_service._skip_wishlist = is_wing_it
|
||||||
|
if skip_wishlist_add:
|
||||||
|
logger.info(
|
||||||
|
"[Organize by Playlist] Skipping sync-time wishlist for '%s' — "
|
||||||
|
"organize download + batch failure handling cover missing tracks",
|
||||||
|
playlist_name,
|
||||||
|
)
|
||||||
|
|
||||||
# Run the sync (this is a blocking call within this thread)
|
# Run the sync (this is a blocking call within this thread)
|
||||||
result = deps.run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id, sync_mode=sync_mode))
|
result = deps.run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id, sync_mode=sync_mode))
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,10 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
||||||
batch_is_album = False
|
batch_is_album = False
|
||||||
batch_profile_id = 1
|
batch_profile_id = 1
|
||||||
batch_source = 'spotify'
|
batch_source = 'spotify'
|
||||||
|
batch_playlist_folder_mode = False
|
||||||
|
batch_playlist_name = 'Unknown Playlist'
|
||||||
|
batch_playlist_id = playlist_id
|
||||||
|
batch_source_playlist_ref = ''
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
if batch_id in download_batches:
|
if batch_id in download_batches:
|
||||||
force_download_all = download_batches[batch_id].get('force_download_all', False)
|
force_download_all = download_batches[batch_id].get('force_download_all', False)
|
||||||
|
|
@ -352,6 +356,29 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
||||||
batch_artist_context = download_batches[batch_id].get('artist_context')
|
batch_artist_context = download_batches[batch_id].get('artist_context')
|
||||||
batch_profile_id = download_batches[batch_id].get('profile_id', 1) or 1
|
batch_profile_id = download_batches[batch_id].get('profile_id', 1) or 1
|
||||||
batch_source = download_batches[batch_id].get('batch_source', 'spotify') or 'spotify'
|
batch_source = download_batches[batch_id].get('batch_source', 'spotify') or 'spotify'
|
||||||
|
batch_playlist_folder_mode = download_batches[batch_id].get('playlist_folder_mode', False)
|
||||||
|
batch_playlist_name = download_batches[batch_id].get('playlist_name', 'Unknown Playlist')
|
||||||
|
batch_playlist_id = download_batches[batch_id].get('playlist_id', playlist_id)
|
||||||
|
batch_source_playlist_ref = (
|
||||||
|
download_batches[batch_id].get('source_playlist_ref') or ''
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
from core.downloads.playlist_folder import (
|
||||||
|
resolve_playlist_folder_mode_for_batch,
|
||||||
|
track_exists_in_playlist_folder_from_track_data,
|
||||||
|
)
|
||||||
|
effective_playlist_folder_mode, effective_playlist_name = resolve_playlist_folder_mode_for_batch(
|
||||||
|
db,
|
||||||
|
playlist_id=str(batch_playlist_id),
|
||||||
|
playlist_name=batch_playlist_name,
|
||||||
|
batch_playlist_folder_mode=batch_playlist_folder_mode,
|
||||||
|
profile_id=batch_profile_id,
|
||||||
|
)
|
||||||
|
if effective_playlist_folder_mode and not batch_playlist_folder_mode:
|
||||||
|
with tasks_lock:
|
||||||
|
if batch_id in download_batches:
|
||||||
|
download_batches[batch_id]['playlist_folder_mode'] = True
|
||||||
|
download_batches[batch_id]['playlist_name'] = effective_playlist_name
|
||||||
|
|
||||||
if force_download_all:
|
if force_download_all:
|
||||||
logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing")
|
logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing")
|
||||||
|
|
@ -445,6 +472,27 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if effective_playlist_folder_mode and not force_download_all:
|
||||||
|
if track_exists_in_playlist_folder_from_track_data(
|
||||||
|
effective_playlist_name,
|
||||||
|
track_data,
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
f"[Playlist Folder] '{track_name}' already on disk in playlist folder — skipping download"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
deps.check_and_remove_track_from_wishlist_by_metadata(track_data)
|
||||||
|
except Exception as _wl_err:
|
||||||
|
logger.debug(f"[Playlist Folder] Wishlist removal attempt failed: {_wl_err}")
|
||||||
|
analysis_results.append({
|
||||||
|
'track_index': track_index,
|
||||||
|
'track': track_data,
|
||||||
|
'found': True,
|
||||||
|
'confidence': 1.0,
|
||||||
|
'match_reason': '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")
|
||||||
|
|
@ -982,13 +1030,45 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
||||||
logger.info(f"[Wishlist] Added album context for: '{track_info.get('name')}' -> '{album_ctx['name']}'")
|
logger.info(f"[Wishlist] Added album context for: '{track_info.get('name')}' -> '{album_ctx['name']}'")
|
||||||
|
|
||||||
|
|
||||||
# Add playlist folder mode flag for sync page playlists
|
# Add playlist folder mode flag for sync page playlists and wishlist
|
||||||
if batch_playlist_folder_mode:
|
# 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')
|
||||||
|
if wl_pl_ref and hasattr(db, 'resolve_mirrored_playlist'):
|
||||||
|
wl_mirrored = db.resolve_mirrored_playlist(
|
||||||
|
wl_pl_ref,
|
||||||
|
profile_id=batch_profile_id,
|
||||||
|
)
|
||||||
|
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 task_pl_folder_mode:
|
||||||
track_info['_playlist_folder_mode'] = True
|
track_info['_playlist_folder_mode'] = True
|
||||||
track_info['_playlist_name'] = batch_playlist_name
|
track_info['_playlist_name'] = task_pl_name
|
||||||
logger.info(f"[Task Creation] Added playlist folder mode for: {track_info.get('name')} → {batch_playlist_name}")
|
if batch_source_playlist_ref:
|
||||||
|
track_info['source_info'] = {
|
||||||
|
'playlist_id': batch_source_playlist_ref,
|
||||||
|
'playlist_name': task_pl_name,
|
||||||
|
'source': batch_source,
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
f"[Task Creation] Added playlist folder mode for: "
|
||||||
|
f"{track_info.get('name')} → {task_pl_name}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"[Debug] Task Creation - playlist folder mode NOT enabled for: {track_info.get('name')}")
|
logger.debug(
|
||||||
|
f"[Debug] Task Creation - playlist folder mode NOT enabled for: "
|
||||||
|
f"{track_info.get('name')}"
|
||||||
|
)
|
||||||
|
|
||||||
download_tasks[task_id] = {
|
download_tasks[task_id] = {
|
||||||
'status': 'pending', 'track_info': track_info,
|
'status': 'pending', 'track_info': track_info,
|
||||||
|
|
|
||||||
123
core/downloads/playlist_folder.py
Normal file
123
core/downloads/playlist_folder.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Playlist-folder layout helpers for download analysis and existence checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from core.downloads.file_finder import AUDIO_EXTENSIONS
|
||||||
|
from core.imports.paths import (
|
||||||
|
_get_config_manager,
|
||||||
|
docker_resolve_path,
|
||||||
|
get_file_path_from_template,
|
||||||
|
sanitize_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_artist_name(artists: Any) -> str:
|
||||||
|
if not artists:
|
||||||
|
return ''
|
||||||
|
first = artists[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
return str(first.get('name', '') or '').strip()
|
||||||
|
return str(first).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_playlist_folder_paths(
|
||||||
|
playlist_name: str,
|
||||||
|
artist: str,
|
||||||
|
title: str,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Return absolute candidate paths for a track in playlist-folder layout."""
|
||||||
|
if not playlist_name or not title:
|
||||||
|
return []
|
||||||
|
|
||||||
|
artist_name = (artist or 'Unknown Artist').strip()
|
||||||
|
track_name = title.strip()
|
||||||
|
transfer_dir = docker_resolve_path(
|
||||||
|
_get_config_manager().get('soulseek.transfer_path', './Transfer')
|
||||||
|
)
|
||||||
|
|
||||||
|
template_context = {
|
||||||
|
'artist': artist_name,
|
||||||
|
'albumartist': artist_name,
|
||||||
|
'album': track_name,
|
||||||
|
'title': track_name,
|
||||||
|
'playlist_name': playlist_name,
|
||||||
|
'track_number': 1,
|
||||||
|
'disc_number': 1,
|
||||||
|
'year': '',
|
||||||
|
'quality': '',
|
||||||
|
'albumtype': '',
|
||||||
|
'_artists_list': [{'name': artist_name}],
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates: List[str] = []
|
||||||
|
folder_path, filename_base = get_file_path_from_template(template_context, 'playlist_path')
|
||||||
|
if folder_path and filename_base:
|
||||||
|
base = os.path.join(transfer_dir, folder_path, filename_base)
|
||||||
|
for ext in AUDIO_EXTENSIONS:
|
||||||
|
candidates.append(base + ext)
|
||||||
|
else:
|
||||||
|
playlist_name_sanitized = sanitize_filename(playlist_name)
|
||||||
|
playlist_dir = os.path.join(transfer_dir, playlist_name_sanitized)
|
||||||
|
artist_name_sanitized = sanitize_filename(artist_name)
|
||||||
|
track_name_sanitized = sanitize_filename(track_name)
|
||||||
|
stem = f'{artist_name_sanitized} - {track_name_sanitized}'
|
||||||
|
for ext in AUDIO_EXTENSIONS:
|
||||||
|
candidates.append(os.path.join(playlist_dir, stem + ext))
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def track_exists_in_playlist_folder(
|
||||||
|
playlist_name: str,
|
||||||
|
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):
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def track_exists_in_playlist_folder_from_track_data(
|
||||||
|
playlist_name: str,
|
||||||
|
track_data: Dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Check playlist-folder existence using Spotify-style track payload."""
|
||||||
|
title = track_data.get('name', '') or track_data.get('track_name', '')
|
||||||
|
artist = _first_artist_name(track_data.get('artists', []))
|
||||||
|
if not artist:
|
||||||
|
artist = str(track_data.get('artist_name', '') or '').strip()
|
||||||
|
return track_exists_in_playlist_folder(playlist_name, artist, title)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_playlist_folder_mode_for_batch(
|
||||||
|
db: Any,
|
||||||
|
*,
|
||||||
|
playlist_id: str,
|
||||||
|
playlist_name: str,
|
||||||
|
batch_playlist_folder_mode: bool,
|
||||||
|
profile_id: int = 1,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Merge batch flag with persisted mirrored-playlist preference."""
|
||||||
|
if batch_playlist_folder_mode:
|
||||||
|
return True, playlist_name
|
||||||
|
|
||||||
|
if not hasattr(db, 'resolve_mirrored_playlist'):
|
||||||
|
return False, playlist_name
|
||||||
|
|
||||||
|
mirrored = db.resolve_mirrored_playlist(playlist_id, profile_id=profile_id)
|
||||||
|
if mirrored and mirrored.get('organize_by_playlist'):
|
||||||
|
return True, mirrored.get('name') or playlist_name
|
||||||
|
return False, playlist_name
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'candidate_playlist_folder_paths',
|
||||||
|
'track_exists_in_playlist_folder',
|
||||||
|
'track_exists_in_playlist_folder_from_track_data',
|
||||||
|
'resolve_playlist_folder_mode_for_batch',
|
||||||
|
]
|
||||||
|
|
@ -563,6 +563,26 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
||||||
record_library_history_download(context)
|
record_library_history_download(context)
|
||||||
record_download_provenance(context)
|
record_download_provenance(context)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pf_album_info = build_import_album_info(context, force_album=False)
|
||||||
|
if not pf_album_info or not pf_album_info.get("album_name"):
|
||||||
|
pf_album_info = {
|
||||||
|
"is_album": True,
|
||||||
|
"album_name": playlist_name,
|
||||||
|
"track_number": track_info.get("track_number", 1) or 1,
|
||||||
|
"disc_number": track_info.get("disc_number", 1) or 1,
|
||||||
|
"clean_track_name": get_import_clean_title(
|
||||||
|
context,
|
||||||
|
default=get_import_original_search(context).get("title", "Unknown"),
|
||||||
|
),
|
||||||
|
"source": get_import_source(context) or "spotify",
|
||||||
|
}
|
||||||
|
elif not pf_album_info.get("is_album"):
|
||||||
|
pf_album_info["is_album"] = True
|
||||||
|
record_soulsync_library_entry(context, artist_context, pf_album_info)
|
||||||
|
except Exception as lib_err:
|
||||||
|
logger.error(f"[Playlist Folder] SoulSync library registration failed: {lib_err}")
|
||||||
|
|
||||||
task_id = context.get('task_id')
|
task_id = context.get('task_id')
|
||||||
batch_id = context.get('batch_id')
|
batch_id = context.get('batch_id')
|
||||||
if task_id and batch_id:
|
if task_id and batch_id:
|
||||||
|
|
|
||||||
174
core/playlists/organize_download.py
Normal file
174
core/playlists/organize_download.py
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
"""Download missing tracks into playlist-folder layout for mirrored playlists."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def mirrored_tracks_to_download_json(tracks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""Convert mirrored playlist rows to the payload expected by the download master."""
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for t in tracks:
|
||||||
|
extra = {}
|
||||||
|
if t.get('extra_data'):
|
||||||
|
try:
|
||||||
|
extra = json.loads(t['extra_data']) if isinstance(t['extra_data'], str) else t['extra_data']
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
extra = {}
|
||||||
|
|
||||||
|
if extra.get('discovered') and extra.get('matched_data'):
|
||||||
|
md = extra['matched_data']
|
||||||
|
album_raw = md.get('album', '')
|
||||||
|
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
||||||
|
entry = {
|
||||||
|
'name': md.get('name', ''),
|
||||||
|
'artists': md.get('artists', [{'name': t.get('artist_name', '')}]),
|
||||||
|
'album': album_obj,
|
||||||
|
'duration_ms': md.get('duration_ms', 0),
|
||||||
|
'id': md.get('id', ''),
|
||||||
|
}
|
||||||
|
if md.get('track_number'):
|
||||||
|
entry['track_number'] = md['track_number']
|
||||||
|
if md.get('disc_number'):
|
||||||
|
entry['disc_number'] = md['disc_number']
|
||||||
|
out.append(entry)
|
||||||
|
continue
|
||||||
|
|
||||||
|
hint = extra.get('spotify_hint', {})
|
||||||
|
track_image = (t.get('image_url') or '').strip()
|
||||||
|
album_obj = {
|
||||||
|
'name': (t.get('album_name') or '').strip(),
|
||||||
|
'images': [{'url': track_image, 'height': 300, 'width': 300}] if track_image else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if hint.get('id') and hint.get('name'):
|
||||||
|
hint_artists = hint.get('artists', [])
|
||||||
|
if hint_artists and isinstance(hint_artists[0], str):
|
||||||
|
hint_artists = [{'name': a} for a in hint_artists]
|
||||||
|
elif not hint_artists:
|
||||||
|
hint_artists = [{'name': t.get('artist_name', '')}]
|
||||||
|
out.append({
|
||||||
|
'name': hint['name'],
|
||||||
|
'artists': hint_artists,
|
||||||
|
'album': album_obj,
|
||||||
|
'duration_ms': t.get('duration_ms', 0),
|
||||||
|
'id': hint['id'],
|
||||||
|
})
|
||||||
|
elif t.get('source_track_id') and (t.get('track_name') or '').strip():
|
||||||
|
out.append({
|
||||||
|
'name': t['track_name'].strip(),
|
||||||
|
'artists': [{'name': (t.get('artist_name') or '').strip() or 'Unknown Artist'}],
|
||||||
|
'album': album_obj,
|
||||||
|
'duration_ms': t.get('duration_ms', 0),
|
||||||
|
'id': t['source_track_id'],
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def run_playlist_organize_download(
|
||||||
|
deps: Any,
|
||||||
|
*,
|
||||||
|
mirrored_playlist_id: int,
|
||||||
|
profile_id: int = 1,
|
||||||
|
get_batch_max_concurrent: Callable[[bool], int],
|
||||||
|
run_full_missing_tracks_process: Callable[..., Any],
|
||||||
|
record_sync_history_start: Optional[Callable[..., Any]] = None,
|
||||||
|
detect_sync_source: Optional[Callable[[str], str]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Queue a playlist-folder missing-tracks batch for one mirrored playlist."""
|
||||||
|
db = deps.get_database()
|
||||||
|
pl = db.get_mirrored_playlist(int(mirrored_playlist_id))
|
||||||
|
if not pl:
|
||||||
|
return {'status': 'error', 'reason': 'Playlist not found'}
|
||||||
|
|
||||||
|
source_playlist_ref = (pl.get('source_playlist_id') or '').strip()
|
||||||
|
source = (pl.get('source') or 'spotify').strip() or 'spotify'
|
||||||
|
|
||||||
|
tracks = db.get_mirrored_playlist_tracks(int(mirrored_playlist_id))
|
||||||
|
tracks_json = mirrored_tracks_to_download_json(tracks)
|
||||||
|
if not tracks_json:
|
||||||
|
return {'status': 'skipped', 'reason': 'No processable tracks'}
|
||||||
|
|
||||||
|
batch_id = str(uuid.uuid4())
|
||||||
|
playlist_id = str(mirrored_playlist_id)
|
||||||
|
playlist_name = pl.get('name', 'Unknown Playlist')
|
||||||
|
|
||||||
|
download_batches = deps.get_download_batches()
|
||||||
|
tasks_lock = deps.tasks_lock
|
||||||
|
|
||||||
|
with tasks_lock:
|
||||||
|
active_analysis = sum(
|
||||||
|
1 for batch in download_batches.values() if batch.get('phase') == 'analysis'
|
||||||
|
)
|
||||||
|
if active_analysis >= 3:
|
||||||
|
return {'status': 'error', 'reason': 'Too many analysis processes running'}
|
||||||
|
|
||||||
|
download_batches[batch_id] = {
|
||||||
|
'phase': 'analysis',
|
||||||
|
'playlist_id': playlist_id,
|
||||||
|
'playlist_name': playlist_name,
|
||||||
|
'queue': [],
|
||||||
|
'active_count': 0,
|
||||||
|
'max_concurrent': get_batch_max_concurrent(False),
|
||||||
|
'permanently_failed_tracks': [],
|
||||||
|
'cancelled_tracks': set(),
|
||||||
|
'queue_index': 0,
|
||||||
|
'analysis_total': len(tracks_json),
|
||||||
|
'profile_id': profile_id,
|
||||||
|
'analysis_processed': 0,
|
||||||
|
'analysis_results': [],
|
||||||
|
'force_download_all': False,
|
||||||
|
'ignore_manual_matches': False,
|
||||||
|
'playlist_folder_mode': True,
|
||||||
|
'is_album_download': False,
|
||||||
|
'album_context': None,
|
||||||
|
'artist_context': None,
|
||||||
|
'wing_it': False,
|
||||||
|
'batch_source': source,
|
||||||
|
'auto_initiated': False,
|
||||||
|
'organize_by_playlist': True,
|
||||||
|
'source_playlist_ref': source_playlist_ref,
|
||||||
|
'mirrored_playlist_id': int(mirrored_playlist_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
if record_sync_history_start:
|
||||||
|
try:
|
||||||
|
record_sync_history_start(
|
||||||
|
batch_id,
|
||||||
|
playlist_id,
|
||||||
|
playlist_name,
|
||||||
|
tracks_json,
|
||||||
|
False,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
True,
|
||||||
|
source_page='automation',
|
||||||
|
)
|
||||||
|
except Exception as hist_err:
|
||||||
|
logger.debug("organize download sync history: %s", hist_err)
|
||||||
|
|
||||||
|
deps.missing_download_executor.submit(
|
||||||
|
run_full_missing_tracks_process,
|
||||||
|
batch_id,
|
||||||
|
playlist_id,
|
||||||
|
tracks_json,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[Organize Download] Started batch %s for mirrored playlist %s (%s tracks)",
|
||||||
|
batch_id,
|
||||||
|
playlist_name,
|
||||||
|
len(tracks_json),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'status': 'started',
|
||||||
|
'batch_id': batch_id,
|
||||||
|
'track_count': len(tracks_json),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['mirrored_tracks_to_download_json', 'run_playlist_organize_download']
|
||||||
|
|
@ -394,12 +394,17 @@ def resolve_wishlist_source_type_for_batch(batch: Dict[str, Any]) -> str:
|
||||||
def build_wishlist_source_context(batch: Dict[str, Any], current_time: datetime | None = None) -> Dict[str, Any]:
|
def build_wishlist_source_context(batch: Dict[str, Any], current_time: datetime | None = None) -> Dict[str, Any]:
|
||||||
"""Build the source_context payload used when adding failed tracks back to the wishlist."""
|
"""Build the source_context payload used when adding failed tracks back to the wishlist."""
|
||||||
current_time = current_time or datetime.now()
|
current_time = current_time or datetime.now()
|
||||||
|
playlist_id = batch.get('source_playlist_ref') or batch.get('playlist_id')
|
||||||
context = {
|
context = {
|
||||||
'playlist_name': batch.get('playlist_name', 'Unknown Playlist'),
|
'playlist_name': batch.get('playlist_name', 'Unknown Playlist'),
|
||||||
'playlist_id': batch.get('playlist_id', None),
|
'playlist_id': playlist_id,
|
||||||
'added_from': 'webui_modal',
|
'added_from': 'webui_modal',
|
||||||
'timestamp': current_time.isoformat(),
|
'timestamp': current_time.isoformat(),
|
||||||
}
|
}
|
||||||
|
if batch.get('mirrored_playlist_id') is not None:
|
||||||
|
context['mirrored_playlist_id'] = batch.get('mirrored_playlist_id')
|
||||||
|
if batch.get('organize_by_playlist'):
|
||||||
|
context['organize_by_playlist'] = True
|
||||||
# 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.
|
||||||
|
|
|
||||||
|
|
@ -571,6 +571,7 @@ class MusicDatabase:
|
||||||
|
|
||||||
# Add explored_at to mirrored_playlists (migration)
|
# Add explored_at to mirrored_playlists (migration)
|
||||||
self._add_mirrored_playlist_explored_column(cursor)
|
self._add_mirrored_playlist_explored_column(cursor)
|
||||||
|
self._add_mirrored_playlist_organize_column(cursor)
|
||||||
|
|
||||||
# Add notification columns to automations (migration)
|
# Add notification columns to automations (migration)
|
||||||
self._add_automation_notify_columns(cursor)
|
self._add_automation_notify_columns(cursor)
|
||||||
|
|
@ -970,6 +971,19 @@ class MusicDatabase:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding explored_at column to mirrored_playlists: {e}")
|
logger.error(f"Error adding explored_at column to mirrored_playlists: {e}")
|
||||||
|
|
||||||
|
def _add_mirrored_playlist_organize_column(self, cursor):
|
||||||
|
"""Add organize_by_playlist preference for playlist-folder downloads."""
|
||||||
|
try:
|
||||||
|
cursor.execute("PRAGMA table_info(mirrored_playlists)")
|
||||||
|
cols = [c[1] for c in cursor.fetchall()]
|
||||||
|
if 'organize_by_playlist' not in cols:
|
||||||
|
cursor.execute(
|
||||||
|
"ALTER TABLE mirrored_playlists ADD COLUMN organize_by_playlist INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
logger.info("Added organize_by_playlist column to mirrored_playlists table")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding organize_by_playlist column to mirrored_playlists: {e}")
|
||||||
|
|
||||||
def _add_automation_notify_columns(self, cursor):
|
def _add_automation_notify_columns(self, cursor):
|
||||||
"""Add notification and result columns to automations table."""
|
"""Add notification and result columns to automations table."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -12171,7 +12185,11 @@ class MusicDatabase:
|
||||||
WHERE profile_id = ?
|
WHERE profile_id = ?
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
""", (profile_id,))
|
""", (profile_id,))
|
||||||
return [dict(row) for row in cursor.fetchall()]
|
return [
|
||||||
|
self._normalize_mirrored_playlist_row(row)
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
if row
|
||||||
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting mirrored playlists: {e}")
|
logger.error(f"Error getting mirrored playlists: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
@ -12198,11 +12216,80 @@ class MusicDatabase:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("SELECT * FROM mirrored_playlists WHERE id = ?", (playlist_id,))
|
cursor.execute("SELECT * FROM mirrored_playlists WHERE id = ?", (playlist_id,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
return dict(row) if row else None
|
return self._normalize_mirrored_playlist_row(row)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting mirrored playlist: {e}")
|
logger.error(f"Error getting mirrored playlist: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_mirrored_playlist_row(row) -> Optional[Dict]:
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
pl = dict(row)
|
||||||
|
pl['organize_by_playlist'] = bool(pl.get('organize_by_playlist', 0))
|
||||||
|
return pl
|
||||||
|
|
||||||
|
def get_mirrored_playlist_by_source(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
source_playlist_id: str,
|
||||||
|
profile_id: int = 1,
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""Return a mirrored playlist by upstream source id."""
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM mirrored_playlists
|
||||||
|
WHERE source = ? AND source_playlist_id = ? AND profile_id = ?
|
||||||
|
""",
|
||||||
|
(source, str(source_playlist_id), profile_id),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return self._normalize_mirrored_playlist_row(row)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting mirrored playlist by source: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_mirrored_playlist(
|
||||||
|
self,
|
||||||
|
playlist_ref: Any,
|
||||||
|
profile_id: int = 1,
|
||||||
|
*,
|
||||||
|
default_source: str = 'spotify',
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""Resolve a mirrored playlist from numeric id or upstream source id."""
|
||||||
|
if playlist_ref is None or playlist_ref == '':
|
||||||
|
return None
|
||||||
|
ref = str(playlist_ref).strip()
|
||||||
|
if ref.isdigit():
|
||||||
|
return self.get_mirrored_playlist(int(ref))
|
||||||
|
return self.get_mirrored_playlist_by_source(default_source, ref, profile_id)
|
||||||
|
|
||||||
|
def set_mirrored_playlist_organize_by_playlist(
|
||||||
|
self,
|
||||||
|
playlist_id: int,
|
||||||
|
enabled: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Persist whether downloads for this playlist use playlist-folder layout."""
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE mirrored_playlists
|
||||||
|
SET organize_by_playlist = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(1 if enabled else 0, playlist_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating organize_by_playlist for playlist {playlist_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]:
|
def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]:
|
||||||
"""Return all tracks for a mirrored playlist ordered by position."""
|
"""Return all tracks for a mirrored playlist ordered by position."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -357,8 +357,11 @@ class PlaylistSyncService:
|
||||||
|
|
||||||
# Auto-add unmatched tracks to wishlist (skip in Wing It mode)
|
# Auto-add unmatched tracks to wishlist (skip in Wing It mode)
|
||||||
wishlist_added_count = 0
|
wishlist_added_count = 0
|
||||||
if unmatched_tracks and getattr(self, '_skip_wishlist', False):
|
if unmatched_tracks and getattr(self, '_skip_unmatched_wishlist', False):
|
||||||
logger.info(f"[Wing It] Skipping wishlist for {len(unmatched_tracks)} unmatched tracks")
|
logger.info(
|
||||||
|
"Skipping sync-time wishlist for %s unmatched tracks (wing-it or organize-by-playlist)",
|
||||||
|
len(unmatched_tracks),
|
||||||
|
)
|
||||||
unmatched_tracks = [] # Clear so the loop below doesn't run
|
unmatched_tracks = [] # Clear so the loop below doesn't run
|
||||||
if unmatched_tracks:
|
if unmatched_tracks:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,8 @@ def _build_deps(engine, scan_mgr=None) -> AutomationDeps:
|
||||||
get_watchlist_scan_state=lambda: {},
|
get_watchlist_scan_state=lambda: {},
|
||||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||||
run_sync_task=lambda *a, **k: None,
|
run_sync_task=lambda *a, **k: None,
|
||||||
|
run_playlist_organize_download=lambda **k: {'status': 'skipped'},
|
||||||
|
missing_download_executor=None,
|
||||||
load_sync_status_file=lambda: {},
|
load_sync_status_file=lambda: {},
|
||||||
get_deezer_client=lambda: None,
|
get_deezer_client=lambda: None,
|
||||||
parse_youtube_playlist=lambda url: None,
|
parse_youtube_playlist=lambda url: None,
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ def _build_deps(**overrides) -> AutomationDeps:
|
||||||
get_watchlist_scan_state=lambda: {},
|
get_watchlist_scan_state=lambda: {},
|
||||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||||
run_sync_task=lambda *a, **k: None,
|
run_sync_task=lambda *a, **k: None,
|
||||||
|
run_playlist_organize_download=lambda **k: {'status': 'skipped'},
|
||||||
|
missing_download_executor=None,
|
||||||
load_sync_status_file=lambda: {},
|
load_sync_status_file=lambda: {},
|
||||||
get_deezer_client=lambda: None,
|
get_deezer_client=lambda: None,
|
||||||
parse_youtube_playlist=lambda url: None,
|
parse_youtube_playlist=lambda url: None,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ def _build_deps(**overrides) -> AutomationDeps:
|
||||||
get_watchlist_scan_state=lambda: {},
|
get_watchlist_scan_state=lambda: {},
|
||||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||||
run_sync_task=lambda *a, **k: None,
|
run_sync_task=lambda *a, **k: None,
|
||||||
|
run_playlist_organize_download=lambda **k: {'status': 'skipped'},
|
||||||
|
missing_download_executor=None,
|
||||||
load_sync_status_file=lambda: {},
|
load_sync_status_file=lambda: {},
|
||||||
get_deezer_client=lambda: None,
|
get_deezer_client=lambda: None,
|
||||||
parse_youtube_playlist=lambda url: None,
|
parse_youtube_playlist=lambda url: None,
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ def _build_deps(**overrides) -> AutomationDeps:
|
||||||
get_watchlist_scan_state=lambda: {},
|
get_watchlist_scan_state=lambda: {},
|
||||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||||
run_sync_task=lambda *a, **k: None,
|
run_sync_task=lambda *a, **k: None,
|
||||||
|
run_playlist_organize_download=lambda **k: {'status': 'skipped'},
|
||||||
|
missing_download_executor=None,
|
||||||
load_sync_status_file=lambda: {},
|
load_sync_status_file=lambda: {},
|
||||||
get_deezer_client=lambda: None,
|
get_deezer_client=lambda: None,
|
||||||
parse_youtube_playlist=lambda url: None,
|
parse_youtube_playlist=lambda url: None,
|
||||||
|
|
@ -737,6 +739,35 @@ class TestSyncPlaylist:
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
assert len(sync_calls) == 1
|
assert len(sync_calls) == 1
|
||||||
|
|
||||||
|
def test_organize_by_playlist_passes_skip_wishlist_add(self):
|
||||||
|
discovered_track = {
|
||||||
|
'extra_data': json.dumps({
|
||||||
|
'discovered': True,
|
||||||
|
'matched_data': {
|
||||||
|
'id': 'spot-1', 'name': 'Track', 'artists': [{'name': 'X'}],
|
||||||
|
'album': {'name': 'Album'}, 'duration_ms': 200000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'artist_name': 'X',
|
||||||
|
}
|
||||||
|
db = _StubDB(
|
||||||
|
playlists=[{'id': 1, 'name': 'P', 'organize_by_playlist': True}],
|
||||||
|
playlist_tracks={1: [discovered_track]},
|
||||||
|
)
|
||||||
|
sync_calls: List[tuple] = []
|
||||||
|
deps = _build_deps(
|
||||||
|
get_database=lambda: db,
|
||||||
|
run_sync_task=lambda *a, **k: sync_calls.append((a, k)),
|
||||||
|
)
|
||||||
|
auto_sync_playlist({'playlist_id': '1'}, deps)
|
||||||
|
for _ in range(50):
|
||||||
|
if sync_calls:
|
||||||
|
break
|
||||||
|
import time
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert sync_calls
|
||||||
|
assert sync_calls[0][1].get('skip_wishlist_add') is True
|
||||||
|
|
||||||
def test_unchanged_since_last_sync_returns_skipped(self):
|
def test_unchanged_since_last_sync_returns_skipped(self):
|
||||||
discovered_track = {
|
discovered_track = {
|
||||||
'extra_data': json.dumps({
|
'extra_data': json.dumps({
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ def _build_deps(**overrides: Any) -> AutomationDeps:
|
||||||
get_watchlist_scan_state=lambda: {},
|
get_watchlist_scan_state=lambda: {},
|
||||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||||
run_sync_task=lambda *a, **k: None,
|
run_sync_task=lambda *a, **k: None,
|
||||||
|
run_playlist_organize_download=lambda **k: {'status': 'skipped'},
|
||||||
|
missing_download_executor=None,
|
||||||
load_sync_status_file=lambda: {},
|
load_sync_status_file=lambda: {},
|
||||||
get_deezer_client=lambda: None,
|
get_deezer_client=lambda: None,
|
||||||
parse_youtube_playlist=lambda url: None,
|
parse_youtube_playlist=lambda url: None,
|
||||||
|
|
|
||||||
117
tests/automation/test_playlist_pipeline_folder_mode.py
Normal file
117
tests/automation/test_playlist_pipeline_folder_mode.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""Tests for organize-by-playlist integration in the playlist pipeline tail."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from core.automation.deps import AutomationDeps, AutomationState
|
||||||
|
from core.automation.handlers._pipeline_shared import run_sync_and_wishlist
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_deps(**overrides):
|
||||||
|
base = dict(
|
||||||
|
engine=MagicMock(),
|
||||||
|
state=AutomationState(),
|
||||||
|
config_manager=MagicMock(),
|
||||||
|
update_progress=lambda *a, **k: None,
|
||||||
|
logger=MagicMock(),
|
||||||
|
get_database=lambda: MagicMock(),
|
||||||
|
spotify_client=None,
|
||||||
|
tidal_client=None,
|
||||||
|
web_scan_manager=None,
|
||||||
|
process_wishlist_automatically=lambda **k: None,
|
||||||
|
process_watchlist_scan_automatically=lambda **k: None,
|
||||||
|
is_wishlist_actually_processing=lambda: False,
|
||||||
|
is_watchlist_actually_scanning=lambda: False,
|
||||||
|
get_watchlist_scan_state=lambda: {},
|
||||||
|
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||||
|
run_sync_task=lambda *a, **k: None,
|
||||||
|
run_playlist_organize_download=lambda **k: {'status': 'started', 'batch_id': 'b1'},
|
||||||
|
missing_download_executor=None,
|
||||||
|
load_sync_status_file=lambda: {},
|
||||||
|
get_deezer_client=lambda: None,
|
||||||
|
parse_youtube_playlist=lambda url: None,
|
||||||
|
get_sync_states=lambda: {},
|
||||||
|
set_db_update_automation_id=lambda v: None,
|
||||||
|
get_db_update_state=lambda: {},
|
||||||
|
db_update_lock=threading.Lock(),
|
||||||
|
db_update_executor=None,
|
||||||
|
run_db_update_task=lambda *a, **k: None,
|
||||||
|
run_deep_scan_task=lambda *a, **k: None,
|
||||||
|
get_duplicate_cleaner_state=lambda: {},
|
||||||
|
duplicate_cleaner_lock=threading.Lock(),
|
||||||
|
duplicate_cleaner_executor=None,
|
||||||
|
run_duplicate_cleaner=lambda: None,
|
||||||
|
get_quality_scanner_state=lambda: {},
|
||||||
|
quality_scanner_lock=threading.Lock(),
|
||||||
|
quality_scanner_executor=None,
|
||||||
|
run_quality_scanner=lambda *a, **k: None,
|
||||||
|
download_orchestrator=None,
|
||||||
|
run_async=lambda coro: None,
|
||||||
|
tasks_lock=threading.Lock(),
|
||||||
|
get_download_batches=lambda: {},
|
||||||
|
get_download_tasks=lambda: {},
|
||||||
|
sweep_empty_download_directories=lambda: 0,
|
||||||
|
get_staging_path=lambda: '/staging',
|
||||||
|
docker_resolve_path=lambda p: p,
|
||||||
|
get_current_profile_id=lambda: 1,
|
||||||
|
get_watchlist_scanner=lambda spc: None,
|
||||||
|
get_app=lambda: None,
|
||||||
|
get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}},
|
||||||
|
init_automation_progress=lambda *a, **k: None,
|
||||||
|
record_progress_history=lambda *a, **k: None,
|
||||||
|
build_personalized_manager=lambda: None,
|
||||||
|
)
|
||||||
|
base.update(overrides)
|
||||||
|
return AutomationDeps(**base) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_organize_playlists_skips_wishlist():
|
||||||
|
wishlist_calls = []
|
||||||
|
|
||||||
|
def sync_one(_pl):
|
||||||
|
return {'status': 'skipped', 'reason': 'unchanged'}
|
||||||
|
|
||||||
|
deps = _minimal_deps(
|
||||||
|
process_wishlist_automatically=lambda **k: wishlist_calls.append(k),
|
||||||
|
)
|
||||||
|
playlists = [
|
||||||
|
{'id': 1, 'name': 'A', 'organize_by_playlist': True},
|
||||||
|
]
|
||||||
|
result = run_sync_and_wishlist(
|
||||||
|
deps,
|
||||||
|
'auto-1',
|
||||||
|
playlists,
|
||||||
|
sync_one_fn=sync_one,
|
||||||
|
sync_id_for_fn=lambda pl: f'mirror_{pl["id"]}',
|
||||||
|
skip_wishlist=False,
|
||||||
|
)
|
||||||
|
assert result['wishlist_queued'] == 0
|
||||||
|
assert result['organize_downloads_started'] == 1
|
||||||
|
assert wishlist_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_playlists_still_runs_wishlist():
|
||||||
|
wishlist_calls = []
|
||||||
|
|
||||||
|
deps = _minimal_deps(
|
||||||
|
process_wishlist_automatically=lambda **k: wishlist_calls.append(1),
|
||||||
|
is_wishlist_actually_processing=lambda: False,
|
||||||
|
)
|
||||||
|
playlists = [
|
||||||
|
{'id': 1, 'name': 'Organized', 'organize_by_playlist': True},
|
||||||
|
{'id': 2, 'name': 'Normal', 'organize_by_playlist': False},
|
||||||
|
]
|
||||||
|
|
||||||
|
def sync_one(_pl):
|
||||||
|
return {'status': 'skipped'}
|
||||||
|
|
||||||
|
result = run_sync_and_wishlist(
|
||||||
|
deps,
|
||||||
|
None,
|
||||||
|
playlists,
|
||||||
|
sync_one_fn=sync_one,
|
||||||
|
sync_id_for_fn=lambda pl: f'mirror_{pl["id"]}',
|
||||||
|
)
|
||||||
|
assert result['organize_downloads_started'] == 1
|
||||||
|
assert result['wishlist_queued'] == 1
|
||||||
|
assert len(wishlist_calls) == 1
|
||||||
|
|
@ -46,6 +46,8 @@ def _build_deps(**overrides) -> AutomationDeps:
|
||||||
get_watchlist_scan_state=lambda: {},
|
get_watchlist_scan_state=lambda: {},
|
||||||
run_playlist_discovery_worker=lambda *a, **k: None,
|
run_playlist_discovery_worker=lambda *a, **k: None,
|
||||||
run_sync_task=lambda *a, **k: None,
|
run_sync_task=lambda *a, **k: None,
|
||||||
|
run_playlist_organize_download=lambda **k: {'status': 'skipped'},
|
||||||
|
missing_download_executor=None,
|
||||||
load_sync_status_file=lambda: {},
|
load_sync_status_file=lambda: {},
|
||||||
get_deezer_client=lambda: None,
|
get_deezer_client=lambda: None,
|
||||||
parse_youtube_playlist=lambda url: None,
|
parse_youtube_playlist=lambda url: None,
|
||||||
|
|
|
||||||
88
tests/downloads/test_playlist_folder_exists.py
Normal file
88
tests/downloads/test_playlist_folder_exists.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Tests for playlist-folder existence detection."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.downloads.playlist_folder import (
|
||||||
|
candidate_playlist_folder_paths,
|
||||||
|
resolve_playlist_folder_mode_for_batch,
|
||||||
|
track_exists_in_playlist_folder,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(self, mirrored=None):
|
||||||
|
self._mirrored = mirrored
|
||||||
|
|
||||||
|
def resolve_mirrored_playlist(self, playlist_ref, profile_id=1, default_source='spotify'):
|
||||||
|
return self._mirrored
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_exists_in_playlist_folder_finds_file(tmp_path):
|
||||||
|
playlist_dir = tmp_path / 'My Playlist'
|
||||||
|
playlist_dir.mkdir()
|
||||||
|
track_file = playlist_dir / 'Artist A - Song One.flac'
|
||||||
|
track_file.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=('', ''),
|
||||||
|
):
|
||||||
|
assert track_exists_in_playlist_folder('My Playlist', 'Artist A', '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)
|
||||||
|
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=('', ''),
|
||||||
|
):
|
||||||
|
assert not track_exists_in_playlist_folder('My Playlist', 'Artist A', 'Song One')
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_paths_template_layout(tmp_path):
|
||||||
|
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=('Cool Mix', 'Artist - Title'),
|
||||||
|
):
|
||||||
|
paths = candidate_playlist_folder_paths('Cool Mix', 'Artist', 'Title')
|
||||||
|
assert any(p.endswith('.flac') for p in paths)
|
||||||
|
assert all('Cool Mix' in p for p in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_playlist_folder_mode_from_mirrored():
|
||||||
|
db = _FakeDB(mirrored={
|
||||||
|
'id': 5,
|
||||||
|
'name': 'Rekordbox Set',
|
||||||
|
'organize_by_playlist': True,
|
||||||
|
})
|
||||||
|
enabled, name = resolve_playlist_folder_mode_for_batch(
|
||||||
|
db,
|
||||||
|
playlist_id='37i9dQZF1',
|
||||||
|
playlist_name='Other Name',
|
||||||
|
batch_playlist_folder_mode=False,
|
||||||
|
)
|
||||||
|
assert enabled is True
|
||||||
|
assert name == 'Rekordbox Set'
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_playlist_folder_mode_batch_flag():
|
||||||
|
db = _FakeDB()
|
||||||
|
enabled, name = resolve_playlist_folder_mode_for_batch(
|
||||||
|
db,
|
||||||
|
playlist_id='1',
|
||||||
|
playlist_name='Batch Name',
|
||||||
|
batch_playlist_folder_mode=True,
|
||||||
|
)
|
||||||
|
assert enabled is True
|
||||||
|
assert name == 'Batch Name'
|
||||||
|
|
@ -202,7 +202,12 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa
|
||||||
monkeypatch.setattr(import_pipeline, "emit_track_downloaded", lambda *args, **kwargs: None)
|
monkeypatch.setattr(import_pipeline, "emit_track_downloaded", lambda *args, **kwargs: None)
|
||||||
monkeypatch.setattr(import_pipeline, "record_library_history_download", lambda *args, **kwargs: None)
|
monkeypatch.setattr(import_pipeline, "record_library_history_download", lambda *args, **kwargs: None)
|
||||||
monkeypatch.setattr(import_pipeline, "record_download_provenance", lambda *args, **kwargs: None)
|
monkeypatch.setattr(import_pipeline, "record_download_provenance", lambda *args, **kwargs: None)
|
||||||
monkeypatch.setattr(import_pipeline, "record_soulsync_library_entry", lambda *args, **kwargs: None)
|
library_calls = []
|
||||||
|
|
||||||
|
def _record_library(context, artist_context, album_info):
|
||||||
|
library_calls.append((context, artist_context, album_info))
|
||||||
|
|
||||||
|
monkeypatch.setattr(import_pipeline, "record_soulsync_library_entry", _record_library)
|
||||||
monkeypatch.setattr(import_pipeline, "check_and_remove_from_wishlist", lambda *args, **kwargs: None)
|
monkeypatch.setattr(import_pipeline, "check_and_remove_from_wishlist", lambda *args, **kwargs: None)
|
||||||
monkeypatch.setattr(import_pipeline, "record_retag_download", lambda *args, **kwargs: None)
|
monkeypatch.setattr(import_pipeline, "record_retag_download", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
|
|
@ -221,6 +226,8 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa
|
||||||
)
|
)
|
||||||
|
|
||||||
assert seen["runtime"] is metadata_runtime
|
assert seen["runtime"] is metadata_runtime
|
||||||
|
assert len(library_calls) == 1
|
||||||
|
assert library_calls[0][2]["album_name"] == "Album"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,22 @@ def test_build_wishlist_source_context_minimal_batch_skips_album_provenance():
|
||||||
assert "artist_context" not in context
|
assert "artist_context" not in context
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_wishlist_source_context_uses_source_playlist_ref_for_organize_batches():
|
||||||
|
batch = {
|
||||||
|
"playlist_name": "Summer Mix",
|
||||||
|
"playlist_id": "42",
|
||||||
|
"source_playlist_ref": "spotifyPlaylistId123",
|
||||||
|
"mirrored_playlist_id": 42,
|
||||||
|
"organize_by_playlist": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
context = processing.build_wishlist_source_context(batch)
|
||||||
|
|
||||||
|
assert context["playlist_id"] == "spotifyPlaylistId123"
|
||||||
|
assert context["mirrored_playlist_id"] == 42
|
||||||
|
assert context["organize_by_playlist"] is True
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1084,6 +1084,8 @@ def _register_automation_handlers():
|
||||||
get_watchlist_scan_state=lambda: watchlist_scan_state,
|
get_watchlist_scan_state=lambda: watchlist_scan_state,
|
||||||
run_playlist_discovery_worker=_run_playlist_discovery_worker,
|
run_playlist_discovery_worker=_run_playlist_discovery_worker,
|
||||||
run_sync_task=_run_sync_task,
|
run_sync_task=_run_sync_task,
|
||||||
|
run_playlist_organize_download=_run_playlist_organize_download,
|
||||||
|
missing_download_executor=missing_download_executor,
|
||||||
load_sync_status_file=_load_sync_status_file,
|
load_sync_status_file=_load_sync_status_file,
|
||||||
get_deezer_client=_get_deezer_client,
|
get_deezer_client=_get_deezer_client,
|
||||||
parse_youtube_playlist=parse_youtube_playlist,
|
parse_youtube_playlist=parse_youtube_playlist,
|
||||||
|
|
@ -18788,6 +18790,23 @@ def start_missing_tracks_process(playlist_id):
|
||||||
if playlist_folder_mode:
|
if playlist_folder_mode:
|
||||||
logger.info(f"[Playlist Folder] Enabled for playlist: '{playlist_name}'")
|
logger.info(f"[Playlist Folder] Enabled for playlist: '{playlist_name}'")
|
||||||
|
|
||||||
|
# Persist organize-by-playlist preference on the mirrored playlist row
|
||||||
|
try:
|
||||||
|
profile_id = get_current_profile_id()
|
||||||
|
db_pref = get_database()
|
||||||
|
mirrored_pl = db_pref.resolve_mirrored_playlist(
|
||||||
|
playlist_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
default_source='spotify',
|
||||||
|
)
|
||||||
|
if mirrored_pl and mirrored_pl.get('id'):
|
||||||
|
db_pref.set_mirrored_playlist_organize_by_playlist(
|
||||||
|
int(mirrored_pl['id']),
|
||||||
|
bool(playlist_folder_mode),
|
||||||
|
)
|
||||||
|
except Exception as pref_err:
|
||||||
|
logger.debug(f"[Playlist Folder] Could not persist mirrored preference: {pref_err}")
|
||||||
|
|
||||||
# Limit concurrent analysis processes to prevent resource exhaustion
|
# Limit concurrent analysis processes to prevent resource exhaustion
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
active_analysis_count = sum(1 for batch in download_batches.values()
|
active_analysis_count = sum(1 for batch in download_batches.values()
|
||||||
|
|
@ -23615,11 +23634,38 @@ def _build_sync_deps():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', sync_mode='replace'):
|
def _run_sync_task(
|
||||||
|
playlist_id,
|
||||||
|
playlist_name,
|
||||||
|
tracks_json,
|
||||||
|
automation_id=None,
|
||||||
|
profile_id=1,
|
||||||
|
playlist_image_url='',
|
||||||
|
sync_mode='replace',
|
||||||
|
skip_wishlist_add=False,
|
||||||
|
):
|
||||||
return _discovery_sync.run_sync_task(
|
return _discovery_sync.run_sync_task(
|
||||||
playlist_id, playlist_name, tracks_json, automation_id, profile_id, playlist_image_url,
|
playlist_id, playlist_name, tracks_json, automation_id, profile_id, playlist_image_url,
|
||||||
_build_sync_deps(),
|
_build_sync_deps(),
|
||||||
sync_mode=sync_mode,
|
sync_mode=sync_mode,
|
||||||
|
skip_wishlist_add=skip_wishlist_add,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_playlist_organize_download(mirrored_playlist_id, automation_id=None, profile_id=None):
|
||||||
|
"""Start a playlist-folder missing-tracks batch for automation / pipeline."""
|
||||||
|
from core.playlists.organize_download import run_playlist_organize_download
|
||||||
|
|
||||||
|
if profile_id is None:
|
||||||
|
profile_id = get_current_profile_id()
|
||||||
|
return run_playlist_organize_download(
|
||||||
|
_automation_deps,
|
||||||
|
mirrored_playlist_id=int(mirrored_playlist_id),
|
||||||
|
profile_id=profile_id,
|
||||||
|
get_batch_max_concurrent=_get_batch_max_concurrent,
|
||||||
|
run_full_missing_tracks_process=_run_full_missing_tracks_process,
|
||||||
|
record_sync_history_start=_record_sync_history_start,
|
||||||
|
detect_sync_source=_downloads_history.detect_sync_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31664,6 +31710,55 @@ def update_mirrored_playlist_source_ref_endpoint(playlist_id):
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/mirrored-playlists/<int:playlist_id>/preferences', methods=['PATCH'])
|
||||||
|
def update_mirrored_playlist_preferences_endpoint(playlist_id):
|
||||||
|
"""Update per-playlist download preferences (e.g. organize by playlist folder)."""
|
||||||
|
try:
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if 'organize_by_playlist' not in data:
|
||||||
|
return jsonify({"error": "organize_by_playlist is required"}), 400
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
playlist = database.get_mirrored_playlist(playlist_id)
|
||||||
|
if not playlist:
|
||||||
|
return jsonify({"error": "Playlist not found"}), 404
|
||||||
|
|
||||||
|
enabled = bool(data.get('organize_by_playlist'))
|
||||||
|
ok = database.set_mirrored_playlist_organize_by_playlist(playlist_id, enabled)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"error": "Failed to update preferences"}), 500
|
||||||
|
|
||||||
|
updated = database.get_mirrored_playlist(playlist_id) or {}
|
||||||
|
return jsonify({"success": True, "playlist": updated})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating mirrored playlist preferences: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/mirrored-playlists/resolve', methods=['GET'])
|
||||||
|
def resolve_mirrored_playlist_endpoint():
|
||||||
|
"""Resolve mirrored playlist by numeric id or upstream source id (e.g. Spotify playlist id)."""
|
||||||
|
try:
|
||||||
|
playlist_ref = request.args.get('ref') or request.args.get('playlist_id')
|
||||||
|
source = request.args.get('source', 'spotify')
|
||||||
|
profile_id = get_current_profile_id()
|
||||||
|
if not playlist_ref:
|
||||||
|
return jsonify({"error": "ref or playlist_id query param required"}), 400
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
playlist = database.resolve_mirrored_playlist(
|
||||||
|
playlist_ref,
|
||||||
|
profile_id=profile_id,
|
||||||
|
default_source=source,
|
||||||
|
)
|
||||||
|
if not playlist:
|
||||||
|
return jsonify({"found": False, "playlist": None})
|
||||||
|
return jsonify({"found": True, "playlist": playlist})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error resolving mirrored playlist: {e}")
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
def _playlist_pipeline_state_key(playlist_id):
|
def _playlist_pipeline_state_key(playlist_id):
|
||||||
return f"mirrored_{int(playlist_id)}"
|
return f"mirrored_{int(playlist_id)}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,7 @@ function autoSyncWeeklyCardHtml(playlist, schedule) {
|
||||||
${_esc(playlist.name)}
|
${_esc(playlist.name)}
|
||||||
</div>
|
</div>
|
||||||
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks</div>
|
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks</div>
|
||||||
|
${autoSyncOrganizeToggleHtml(playlist)}
|
||||||
<div class="auto-sync-scheduled-timing">
|
<div class="auto-sync-scheduled-timing">
|
||||||
<span>${_esc(label)}</span>
|
<span>${_esc(label)}</span>
|
||||||
<small>${_esc(tz)}</small>
|
<small>${_esc(tz)}</small>
|
||||||
|
|
@ -1576,6 +1577,34 @@ function autoSyncAutomationCardHtml(auto, playlists) {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function autoSyncOrganizeToggleHtml(playlist) {
|
||||||
|
const checked = playlist.organize_by_playlist ? 'checked' : '';
|
||||||
|
return `
|
||||||
|
<label class="auto-sync-organize-toggle" onclick="event.stopPropagation();" title="Download missing tracks into a playlist-named folder (artist - track)">
|
||||||
|
<input type="checkbox" ${checked} onchange="setAutoSyncOrganizeByPlaylist(${playlist.id}, this.checked)">
|
||||||
|
<span>Organize by playlist</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAutoSyncOrganizeByPlaylist(playlistId, enabled) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ organize_by_playlist: !!enabled }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Failed to update preference');
|
||||||
|
const pl = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
|
||||||
|
if (pl) pl.organize_by_playlist = !!enabled;
|
||||||
|
showToast(enabled ? 'Auto-Sync will use playlist folders' : 'Auto-Sync will use standard download layout', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Error: ${err.message}`, 'error');
|
||||||
|
await refreshAutoSyncScheduleModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function autoSyncScheduledCardHtml(playlist, schedule) {
|
function autoSyncScheduledCardHtml(playlist, schedule) {
|
||||||
const enabled = schedule?.enabled !== false;
|
const enabled = schedule?.enabled !== false;
|
||||||
const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : '';
|
const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : '';
|
||||||
|
|
@ -1592,6 +1621,7 @@ function autoSyncScheduledCardHtml(playlist, schedule) {
|
||||||
${_esc(playlist.name)}
|
${_esc(playlist.name)}
|
||||||
</div>
|
</div>
|
||||||
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks</div>
|
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks</div>
|
||||||
|
${autoSyncOrganizeToggleHtml(playlist)}
|
||||||
<div class="auto-sync-scheduled-timing">
|
<div class="auto-sync-scheduled-timing">
|
||||||
<span>${_esc(autoSyncIntervalLabel(schedule?.hours || 24))}</span>
|
<span>${_esc(autoSyncIntervalLabel(schedule?.hours || 24))}</span>
|
||||||
${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''}
|
${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''}
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,7 @@ function handleServiceStatusUpdate(data) {
|
||||||
_isSoulsyncStandalone = isSoulsyncStandalone;
|
_isSoulsyncStandalone = isSoulsyncStandalone;
|
||||||
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
||||||
if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now.
|
if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now.
|
||||||
|
if (btn.classList.contains('soulsync-standalone-action')) return;
|
||||||
if (isSoulsyncStandalone) {
|
if (isSoulsyncStandalone) {
|
||||||
btn.dataset.hiddenByStandalone = '1';
|
btn.dataset.hiddenByStandalone = '1';
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
|
|
|
||||||
|
|
@ -2449,8 +2449,9 @@ async function startMissingTracksProcess(playlistId) {
|
||||||
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
|
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
|
||||||
|
|
||||||
// Check if playlist folder mode toggle is enabled (only for sync page playlists)
|
// Check if playlist folder mode toggle is enabled (only for sync page playlists)
|
||||||
const playlistFolderModeCheckbox = document.getElementById(`playlist-folder-mode-${playlistId}`);
|
const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function'
|
||||||
const playlistFolderMode = playlistFolderModeCheckbox ? playlistFolderModeCheckbox.checked : false;
|
? isPlaylistOrganizeEnabled(playlistId)
|
||||||
|
: (document.getElementById(`playlist-folder-mode-${playlistId}`)?.checked ?? false);
|
||||||
|
|
||||||
// Hide the force download toggle during processing
|
// Hide the force download toggle during processing
|
||||||
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
|
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
|
||||||
|
|
@ -4425,20 +4426,34 @@ async function startPlaylistSync(playlistId, syncModeOverride = null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we have the full track list before starting
|
// Ensure we have the full track list before starting
|
||||||
|
const playlistMeta = spotifyPlaylists.find(p => p.id === playlistId);
|
||||||
let tracks = playlistTrackCache[playlistId];
|
let tracks = playlistTrackCache[playlistId];
|
||||||
if (!tracks) {
|
const cacheStale = typeof playlistTrackCacheIsStale === 'function'
|
||||||
|
&& playlistTrackCacheIsStale(playlistId, playlistMeta);
|
||||||
|
if (!tracks || cacheStale) {
|
||||||
const trackFetchStart = Date.now();
|
const trackFetchStart = Date.now();
|
||||||
console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`);
|
console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] ${cacheStale ? 'Cache stale' : 'Cache miss'} - fetching tracks for playlist ${playlistId}`);
|
||||||
try {
|
try {
|
||||||
// Use the right endpoint based on playlist source
|
if (cacheStale && typeof invalidatePlaylistTrackCache === 'function') {
|
||||||
const fetchUrl = playlistId.startsWith('deezer_arl_')
|
invalidatePlaylistTrackCache(playlistId);
|
||||||
? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`
|
}
|
||||||
: `/api/spotify/playlist/${playlistId}`;
|
if (playlistId.startsWith('deezer_arl_') && typeof fetchAndCacheDeezerArlPlaylistTracks === 'function') {
|
||||||
const response = await fetch(fetchUrl);
|
const deezerId = playlistId.replace('deezer_arl_', '');
|
||||||
const fullPlaylist = await response.json();
|
const fullPlaylist = await fetchAndCacheDeezerArlPlaylistTracks(playlistId, deezerId);
|
||||||
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
tracks = fullPlaylist.tracks;
|
||||||
tracks = fullPlaylist.tracks;
|
} else if (typeof fetchAndCacheSpotifyPlaylistTracks === 'function' && !playlistId.startsWith('deezer_arl_')) {
|
||||||
playlistTrackCache[playlistId] = tracks; // Cache it
|
const fullPlaylist = await fetchAndCacheSpotifyPlaylistTracks(playlistId);
|
||||||
|
tracks = fullPlaylist.tracks;
|
||||||
|
} else {
|
||||||
|
const fetchUrl = playlistId.startsWith('deezer_arl_')
|
||||||
|
? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`
|
||||||
|
: `/api/spotify/playlist/${playlistId}`;
|
||||||
|
const response = await fetch(fetchUrl);
|
||||||
|
const fullPlaylist = await response.json();
|
||||||
|
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
||||||
|
tracks = fullPlaylist.tracks;
|
||||||
|
playlistTrackCache[playlistId] = tracks;
|
||||||
|
}
|
||||||
const trackFetchTime = Date.now() - trackFetchStart;
|
const trackFetchTime = Date.now() - trackFetchStart;
|
||||||
console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`);
|
console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -961,6 +961,322 @@ function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) {
|
||||||
// in artists.js but used broadly across the app.
|
// in artists.js but used broadly across the app.
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function playlistDetailsOrganizeCheckboxId(playlistRef) {
|
||||||
|
return `playlist-organize-${playlistRef}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map UI playlist ids (e.g. deezer_arl_123) to mirrored-playlist resolve refs. */
|
||||||
|
function normalizePlaylistOrganizeRef(playlistRef, source = 'spotify') {
|
||||||
|
const ref = String(playlistRef || '').trim();
|
||||||
|
if (source === 'deezer' && ref.startsWith('deezer_arl_')) {
|
||||||
|
return ref.slice('deezer_arl_'.length);
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playlistOrganizeToggleHtml(playlistRef, source = 'spotify') {
|
||||||
|
const safeRef = String(playlistRef).replace(/'/g, "\\'");
|
||||||
|
const safeSource = String(source).replace(/'/g, "\\'");
|
||||||
|
return `
|
||||||
|
<label class="playlist-modal-organize-toggle" title="Download into a playlist-named folder (Artist - Track) under your transfer path">
|
||||||
|
<input type="checkbox" id="${playlistDetailsOrganizeCheckboxId(playlistRef)}"
|
||||||
|
onchange="onPlaylistOrganizePreferenceChange('${safeRef}', this.checked, '${safeSource}')">
|
||||||
|
<span>Organize by playlist</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPlaylistOrganizeCheckboxes(playlistRef, enabled) {
|
||||||
|
const detailsCb = document.getElementById(playlistDetailsOrganizeCheckboxId(playlistRef));
|
||||||
|
const downloadMissingCb = document.getElementById(`playlist-folder-mode-${playlistRef}`);
|
||||||
|
if (detailsCb) detailsCb.checked = !!enabled;
|
||||||
|
if (downloadMissingCb) downloadMissingCb.checked = !!enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaylistOrganizeEnabled(playlistRef) {
|
||||||
|
const detailsCb = document.getElementById(playlistDetailsOrganizeCheckboxId(playlistRef));
|
||||||
|
if (detailsCb) return detailsCb.checked;
|
||||||
|
const downloadMissingCb = document.getElementById(`playlist-folder-mode-${playlistRef}`);
|
||||||
|
return downloadMissingCb ? downloadMissingCb.checked : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMirroredOrganizePreference(playlistRef, source = 'spotify') {
|
||||||
|
try {
|
||||||
|
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}`
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
return !!(data.found && data.playlist?.organize_by_playlist);
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('Could not load organize-by-playlist preference:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMirroredOrganizePreference(playlistRef, enabled, source = 'spotify') {
|
||||||
|
try {
|
||||||
|
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}`
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.found || !data.playlist?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const patchRes = await fetch(`/api/mirrored-playlists/${data.playlist.id}/preferences`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ organize_by_playlist: !!enabled }),
|
||||||
|
});
|
||||||
|
const patchData = await patchRes.json();
|
||||||
|
if (!patchRes.ok || patchData.error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
syncPlaylistOrganizeCheckboxes(playlistRef, !!enabled);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('Could not save organize-by-playlist preference:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlaylistOrganizePreferenceIntoModal(playlistRef, source = 'spotify') {
|
||||||
|
const enabled = await fetchMirroredOrganizePreference(playlistRef, source);
|
||||||
|
syncPlaylistOrganizeCheckboxes(playlistRef, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPlaylistOrganizePreferenceChange(playlistRef, enabled, source = 'spotify') {
|
||||||
|
syncPlaylistOrganizeCheckboxes(playlistRef, enabled);
|
||||||
|
const ok = await setMirroredOrganizePreference(playlistRef, enabled, source);
|
||||||
|
if (!ok) {
|
||||||
|
showToast('Could not save playlist folder preference (mirror this playlist first)', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMirroredOrganizePreference(playlistRef, source = 'spotify') {
|
||||||
|
await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playlistTrackCacheIsStale(playlistId, playlist) {
|
||||||
|
const cached = playlistTrackCache[playlistId];
|
||||||
|
if (!cached) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expected = playlist?.track_count;
|
||||||
|
if (expected != null && Number(expected) !== cached.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPlaylistDownloadProcess(playlistId) {
|
||||||
|
const proc = activeDownloadProcesses?.[playlistId];
|
||||||
|
if (!proc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (proc.poller) {
|
||||||
|
clearInterval(proc.poller);
|
||||||
|
proc.poller = null;
|
||||||
|
}
|
||||||
|
if (proc.modalElement) {
|
||||||
|
proc.modalElement.remove();
|
||||||
|
}
|
||||||
|
delete activeDownloadProcesses[playlistId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when Spotify list or track cache changed since the last Download Missing run. */
|
||||||
|
function isPlaylistDownloadProcessStale(playlistId, playlistMeta) {
|
||||||
|
if (typeof playlistTrackCacheIsStale === 'function'
|
||||||
|
&& playlistTrackCacheIsStale(playlistId, playlistMeta)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const proc = activeDownloadProcesses?.[playlistId];
|
||||||
|
if (!proc) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expected = playlistMeta?.track_count;
|
||||||
|
if (expected != null && Array.isArray(proc.tracks)
|
||||||
|
&& Number(expected) !== proc.tracks.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Refresh clears the track cache but can leave a completed modal from the old run.
|
||||||
|
if (!playlistTrackCache[playlistId] && proc.status === 'complete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidatePlaylistTrackCache(playlistId = null) {
|
||||||
|
if (playlistId) {
|
||||||
|
delete playlistTrackCache[playlistId];
|
||||||
|
if (currentModalPlaylistId === playlistId) {
|
||||||
|
currentPlaylistTracks = [];
|
||||||
|
}
|
||||||
|
clearPlaylistDownloadProcess(playlistId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
playlistTrackCache = {};
|
||||||
|
currentPlaylistTracks = [];
|
||||||
|
const ids = new Set();
|
||||||
|
if (typeof spotifyPlaylists !== 'undefined' && Array.isArray(spotifyPlaylists)) {
|
||||||
|
for (const p of spotifyPlaylists) {
|
||||||
|
if (p?.id) {
|
||||||
|
ids.add(p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof deezerArlPlaylists !== 'undefined' && Array.isArray(deezerArlPlaylists)) {
|
||||||
|
for (const p of deezerArlPlaylists) {
|
||||||
|
const arlId = String(p.id || '').startsWith('deezer_arl_')
|
||||||
|
? p.id
|
||||||
|
: `deezer_arl_${p.id}`;
|
||||||
|
ids.add(arlId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of ids) {
|
||||||
|
clearPlaylistDownloadProcess(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mirrorPlaylistTracksForSource(source, sourcePlaylistId, fullPlaylist) {
|
||||||
|
if (typeof mirrorPlaylist !== 'function' || !fullPlaylist?.tracks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mirrorPlaylist(source, sourcePlaylistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({
|
||||||
|
track_name: t.name,
|
||||||
|
artist_name: (t.artists && t.artists[0])
|
||||||
|
? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0])
|
||||||
|
: '',
|
||||||
|
album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '',
|
||||||
|
duration_ms: t.duration_ms || 0,
|
||||||
|
image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0]
|
||||||
|
? t.album.images[0].url
|
||||||
|
: null,
|
||||||
|
source_track_id: t.id || t.spotify_track_id || '',
|
||||||
|
})), {
|
||||||
|
description: fullPlaylist.description,
|
||||||
|
owner: fullPlaylist.owner,
|
||||||
|
image_url: fullPlaylist.image_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use mirrorPlaylistTracksForSource */
|
||||||
|
function mirrorSpotifyPlaylistTracks(playlistId, fullPlaylist) {
|
||||||
|
mirrorPlaylistTracksForSource('spotify', playlistId, fullPlaylist);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndCachePlaylistTracks(cacheKey, fetchUrl, mirrorSource, mirrorSourceId) {
|
||||||
|
const response = await fetch(fetchUrl);
|
||||||
|
const fullPlaylist = await response.json();
|
||||||
|
if (fullPlaylist.error) {
|
||||||
|
throw new Error(fullPlaylist.error);
|
||||||
|
}
|
||||||
|
playlistTrackCache[cacheKey] = fullPlaylist.tracks;
|
||||||
|
mirrorPlaylistTracksForSource(mirrorSource, mirrorSourceId, fullPlaylist);
|
||||||
|
return fullPlaylist;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndCacheSpotifyPlaylistTracks(playlistId) {
|
||||||
|
return fetchAndCachePlaylistTracks(
|
||||||
|
playlistId,
|
||||||
|
`/api/spotify/playlist/${playlistId}`,
|
||||||
|
'spotify',
|
||||||
|
playlistId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndCacheDeezerArlPlaylistTracks(arlPlaylistId, deezerPlaylistId) {
|
||||||
|
return fetchAndCachePlaylistTracks(
|
||||||
|
arlPlaylistId,
|
||||||
|
`/api/deezer/arl-playlist/${deezerPlaylistId}`,
|
||||||
|
'deezer',
|
||||||
|
deezerPlaylistId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Footer actions for Spotify/Deezer playlist details modals.
|
||||||
|
* Standalone SoulSync has no media-server playlist sync — show Mirrored + re-analysis instead.
|
||||||
|
*/
|
||||||
|
function playlistModalDownloadSyncFooterHtml(playlistId, options = {}) {
|
||||||
|
const {
|
||||||
|
hasCompletedProcess = false,
|
||||||
|
isSyncing = false,
|
||||||
|
source = 'spotify',
|
||||||
|
closeBeforeDownload = false,
|
||||||
|
} = options;
|
||||||
|
const openDm = closeBeforeDownload
|
||||||
|
? `closeDeezerArlPlaylistDetailsModal(); openDownloadMissingModal('${playlistId}')`
|
||||||
|
: `openDownloadMissingModal('${playlistId}')`;
|
||||||
|
const downloadBtns = hasCompletedProcess
|
||||||
|
? `<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="${openDm}">📊 View Last Results</button>
|
||||||
|
<button class="playlist-modal-btn playlist-modal-btn-tertiary soulsync-standalone-action" onclick="restartPlaylistDownloadMissing('${playlistId}')">🔄 Download Missing (New)</button>`
|
||||||
|
: `<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="${openDm}">📥 Download Missing Tracks</button>`;
|
||||||
|
|
||||||
|
if (_isSoulsyncStandalone) {
|
||||||
|
return `${downloadBtns}
|
||||||
|
<button class="playlist-modal-btn playlist-modal-btn-secondary soulsync-standalone-action" onclick="navigateToMirroredPlaylist('${playlistId}', '${source}')">📂 Open in Mirrored</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${downloadBtns}
|
||||||
|
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)">
|
||||||
|
<option value="replace" selected>Replace</option>
|
||||||
|
<option value="append">Append only</option>
|
||||||
|
</select>
|
||||||
|
<button id="sync-btn-${playlistId}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlistId}')" ${isSyncing ? 'disabled' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartPlaylistDownloadMissing(playlistId) {
|
||||||
|
const proc = activeDownloadProcesses[playlistId];
|
||||||
|
if (proc?.poller) {
|
||||||
|
clearInterval(proc.poller);
|
||||||
|
}
|
||||||
|
if (proc?.modalElement) {
|
||||||
|
proc.modalElement.remove();
|
||||||
|
}
|
||||||
|
delete activeDownloadProcesses[playlistId];
|
||||||
|
if (typeof closePlaylistDetailsModal === 'function') {
|
||||||
|
closePlaylistDetailsModal();
|
||||||
|
}
|
||||||
|
if (typeof closeDeezerArlPlaylistDetailsModal === 'function') {
|
||||||
|
closeDeezerArlPlaylistDetailsModal();
|
||||||
|
}
|
||||||
|
await openDownloadMissingModal(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToMirroredPlaylist(playlistRef, source = 'spotify') {
|
||||||
|
if (typeof navigateToPage === 'function') {
|
||||||
|
navigateToPage('sync');
|
||||||
|
}
|
||||||
|
const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]');
|
||||||
|
if (mirroredBtn) {
|
||||||
|
mirroredBtn.click();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}`
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.found || !data.playlist?.id) {
|
||||||
|
if (typeof loadMirroredPlaylists === 'function') {
|
||||||
|
await loadMirroredPlaylists();
|
||||||
|
}
|
||||||
|
showToast('Playlist not mirrored yet — open it once from Sync to create the mirror.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof loadMirroredPlaylists === 'function') {
|
||||||
|
await loadMirroredPlaylists();
|
||||||
|
}
|
||||||
|
if (typeof openMirroredPlaylistModal === 'function') {
|
||||||
|
await openMirroredPlaylistModal(data.playlist.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Could not open mirrored playlist: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') {
|
async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') {
|
||||||
if (showLoadingOverlayParam) {
|
if (showLoadingOverlayParam) {
|
||||||
showLoadingOverlay('Loading album...');
|
showLoadingOverlay('Loading album...');
|
||||||
|
|
@ -3200,6 +3516,7 @@ async function fetchAndUpdateServiceStatus() {
|
||||||
_isSoulsyncStandalone = isSoulsyncStandalone2;
|
_isSoulsyncStandalone = isSoulsyncStandalone2;
|
||||||
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
||||||
if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now.
|
if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now.
|
||||||
|
if (btn.classList.contains('soulsync-standalone-action')) return;
|
||||||
if (isSoulsyncStandalone2) {
|
if (isSoulsyncStandalone2) {
|
||||||
btn.dataset.hiddenByStandalone = '1';
|
btn.dataset.hiddenByStandalone = '1';
|
||||||
btn.style.display = 'none';
|
btn.style.display = 'none';
|
||||||
|
|
|
||||||
|
|
@ -12088,6 +12088,21 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-sync-organize-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-sync-organize-toggle input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.auto-sync-scheduled-timing {
|
.auto-sync-scheduled-timing {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -18728,6 +18743,24 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
.playlist-modal-footer-right {
|
.playlist-modal-footer-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-modal-organize-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-modal-organize-toggle input {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-modal-btn {
|
.playlist-modal-btn {
|
||||||
|
|
|
||||||
|
|
@ -2438,6 +2438,11 @@ async function loadDeezerArlPlaylists() {
|
||||||
throw new Error(error.error || 'Failed to fetch Deezer playlists');
|
throw new Error(error.error || 'Failed to fetch Deezer playlists');
|
||||||
}
|
}
|
||||||
deezerArlPlaylists = await response.json();
|
deezerArlPlaylists = await response.json();
|
||||||
|
if (typeof invalidatePlaylistTrackCache === 'function') {
|
||||||
|
invalidatePlaylistTrackCache();
|
||||||
|
} else {
|
||||||
|
playlistTrackCache = {};
|
||||||
|
}
|
||||||
renderDeezerArlPlaylists();
|
renderDeezerArlPlaylists();
|
||||||
deezerArlPlaylistsLoaded = true;
|
deezerArlPlaylistsLoaded = true;
|
||||||
|
|
||||||
|
|
@ -2525,25 +2530,25 @@ async function openDeezerArlPlaylistDetailsModal(event, playlistId) {
|
||||||
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
|
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (playlistTrackCache[arlPlaylistId]) {
|
const playlistMeta = { ...playlist, track_count: playlist.track_count ?? playlist.tracks?.length };
|
||||||
|
const cacheStale = typeof playlistTrackCacheIsStale === 'function'
|
||||||
|
&& playlistTrackCacheIsStale(arlPlaylistId, playlistMeta);
|
||||||
|
if (playlistTrackCache[arlPlaylistId] && !cacheStale) {
|
||||||
const fullPlaylist = { ...playlist, id: arlPlaylistId, tracks: playlistTrackCache[arlPlaylistId] };
|
const fullPlaylist = { ...playlist, id: arlPlaylistId, tracks: playlistTrackCache[arlPlaylistId] };
|
||||||
showDeezerArlPlaylistDetailsModal(fullPlaylist, playlistId);
|
showDeezerArlPlaylistDetailsModal(fullPlaylist, playlistId);
|
||||||
} else {
|
} else {
|
||||||
const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`);
|
if (cacheStale && typeof invalidatePlaylistTrackCache === 'function') {
|
||||||
const fullPlaylist = await response.json();
|
invalidatePlaylistTrackCache(arlPlaylistId);
|
||||||
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
}
|
||||||
|
const fullPlaylist = typeof fetchAndCacheDeezerArlPlaylistTracks === 'function'
|
||||||
playlistTrackCache[arlPlaylistId] = fullPlaylist.tracks;
|
? await fetchAndCacheDeezerArlPlaylistTracks(arlPlaylistId, playlistId)
|
||||||
|
: await (async () => {
|
||||||
// Auto-mirror
|
const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`);
|
||||||
mirrorPlaylist('deezer', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({
|
const data = await response.json();
|
||||||
track_name: t.name,
|
if (data.error) throw new Error(data.error);
|
||||||
artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '',
|
playlistTrackCache[arlPlaylistId] = data.tracks;
|
||||||
album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '',
|
return data;
|
||||||
duration_ms: t.duration_ms || 0,
|
})();
|
||||||
source_track_id: t.id || ''
|
|
||||||
})), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url });
|
|
||||||
|
|
||||||
showDeezerArlPlaylistDetailsModal({ ...fullPlaylist, id: arlPlaylistId }, playlistId);
|
showDeezerArlPlaylistDetailsModal({ ...fullPlaylist, id: arlPlaylistId }, playlistId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -2608,15 +2613,20 @@ function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="playlist-modal-footer">
|
<div class="playlist-modal-footer">
|
||||||
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeDeezerArlPlaylistDetailsModal()">Close</button>
|
<div class="playlist-modal-footer-left">
|
||||||
<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="closeDeezerArlPlaylistDetailsModal(); openDownloadMissingModal('${playlistId}')">
|
${typeof playlistOrganizeToggleHtml === 'function' ? playlistOrganizeToggleHtml(playlistId, 'deezer') : ''}
|
||||||
${hasCompletedProcess ? '📊 View Download Results' : '📥 Download Missing Tracks'}
|
</div>
|
||||||
</button>
|
<div class="playlist-modal-footer-right">
|
||||||
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)" ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>
|
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeDeezerArlPlaylistDetailsModal()">Close</button>
|
||||||
<option value="replace" selected>Replace</option>
|
${typeof playlistModalDownloadSyncFooterHtml === 'function'
|
||||||
<option value="append">Append only</option>
|
? playlistModalDownloadSyncFooterHtml(playlistId, {
|
||||||
</select>
|
hasCompletedProcess,
|
||||||
<button id="sync-btn-${playlistId}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlistId}')" ${isSyncing ? 'disabled' : ''} ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>
|
isSyncing,
|
||||||
|
source: 'deezer',
|
||||||
|
closeBeforeDownload: true,
|
||||||
|
})
|
||||||
|
: `<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="closeDeezerArlPlaylistDetailsModal(); openDownloadMissingModal('${playlistId}')">📥 Download Missing Tracks</button>`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -2633,6 +2643,9 @@ function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
if (typeof loadPlaylistOrganizePreferenceIntoModal === 'function') {
|
||||||
|
void loadPlaylistOrganizePreferenceIntoModal(playlistId, 'deezer');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDeezerArlPlaylistDetailsModal() {
|
function closeDeezerArlPlaylistDetailsModal() {
|
||||||
|
|
|
||||||
|
|
@ -1610,6 +1610,11 @@ async function loadSpotifyPlaylists() {
|
||||||
throw new Error(error.error || 'Failed to fetch playlists');
|
throw new Error(error.error || 'Failed to fetch playlists');
|
||||||
}
|
}
|
||||||
spotifyPlaylists = await response.json();
|
spotifyPlaylists = await response.json();
|
||||||
|
if (typeof invalidatePlaylistTrackCache === 'function') {
|
||||||
|
invalidatePlaylistTrackCache();
|
||||||
|
} else {
|
||||||
|
playlistTrackCache = {};
|
||||||
|
}
|
||||||
renderSpotifyPlaylists();
|
renderSpotifyPlaylists();
|
||||||
spotifyPlaylistsLoaded = true;
|
spotifyPlaylistsLoaded = true;
|
||||||
|
|
||||||
|
|
@ -1833,35 +1838,35 @@ async function openPlaylistDetailsModal(event, playlistId) {
|
||||||
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
|
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// --- CACHING LOGIC START ---
|
const cacheStale = typeof playlistTrackCacheIsStale === 'function'
|
||||||
if (playlistTrackCache[playlistId]) {
|
&& playlistTrackCacheIsStale(playlistId, playlist);
|
||||||
|
if (playlistTrackCache[playlistId] && !cacheStale) {
|
||||||
console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`);
|
console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`);
|
||||||
// Use the cached tracks instead of fetching
|
|
||||||
const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] };
|
const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] };
|
||||||
showPlaylistDetailsModal(fullPlaylist);
|
showPlaylistDetailsModal(fullPlaylist);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`);
|
if (cacheStale) {
|
||||||
// Fetch from the server if not in cache
|
console.log(`Cache STALE for playlist ${playlistId} — refetching tracks.`);
|
||||||
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
|
if (typeof invalidatePlaylistTrackCache === 'function') {
|
||||||
const fullPlaylist = await response.json();
|
invalidatePlaylistTrackCache(playlistId);
|
||||||
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
} else {
|
||||||
|
delete playlistTrackCache[playlistId];
|
||||||
// Store the fetched tracks in the cache
|
}
|
||||||
playlistTrackCache[playlistId] = fullPlaylist.tracks;
|
} else {
|
||||||
|
console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`);
|
||||||
|
}
|
||||||
|
const fullPlaylist = typeof fetchAndCacheSpotifyPlaylistTracks === 'function'
|
||||||
|
? await fetchAndCacheSpotifyPlaylistTracks(playlistId)
|
||||||
|
: await (async () => {
|
||||||
|
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
playlistTrackCache[playlistId] = data.tracks;
|
||||||
|
return data;
|
||||||
|
})();
|
||||||
console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`);
|
console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`);
|
||||||
|
|
||||||
// Auto-mirror this Spotify playlist
|
|
||||||
mirrorPlaylist('spotify', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({
|
|
||||||
track_name: t.name, artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '',
|
|
||||||
album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '',
|
|
||||||
duration_ms: t.duration_ms || 0,
|
|
||||||
image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null,
|
|
||||||
source_track_id: t.id || t.spotify_track_id || ''
|
|
||||||
})), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url });
|
|
||||||
|
|
||||||
showPlaylistDetailsModal(fullPlaylist);
|
showPlaylistDetailsModal(fullPlaylist);
|
||||||
}
|
}
|
||||||
// --- CACHING LOGIC END ---
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`Error: ${error.message}`, 'error');
|
showToast(`Error: ${error.message}`, 'error');
|
||||||
|
|
@ -1929,22 +1934,27 @@ function showPlaylistDetailsModal(playlist) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="playlist-modal-footer">
|
<div class="playlist-modal-footer">
|
||||||
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closePlaylistDetailsModal()">Close</button>
|
<div class="playlist-modal-footer-left">
|
||||||
<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="openDownloadMissingModal('${playlist.id}')">
|
${typeof playlistOrganizeToggleHtml === 'function' ? playlistOrganizeToggleHtml(playlist.id, 'spotify') : ''}
|
||||||
${hasCompletedProcess
|
</div>
|
||||||
? '📊 View Download Results'
|
<div class="playlist-modal-footer-right">
|
||||||
: '📥 Download Missing Tracks'}
|
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closePlaylistDetailsModal()">Close</button>
|
||||||
</button>
|
${typeof playlistModalDownloadSyncFooterHtml === 'function'
|
||||||
<select id="sync-mode-${playlist.id}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)" ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>
|
? playlistModalDownloadSyncFooterHtml(playlist.id, {
|
||||||
<option value="replace" selected>Replace</option>
|
hasCompletedProcess,
|
||||||
<option value="append">Append only</option>
|
isSyncing,
|
||||||
</select>
|
source: 'spotify',
|
||||||
<button id="sync-btn-${playlist.id}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlist.id}')" ${isSyncing ? 'disabled' : ''} ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>
|
})
|
||||||
|
: `<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="openDownloadMissingModal('${playlist.id}')">📥 Download Missing Tracks</button>`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
if (typeof loadPlaylistOrganizePreferenceIntoModal === 'function') {
|
||||||
|
void loadPlaylistOrganizePreferenceIntoModal(playlist.id, 'spotify');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePlaylistDetailsModal() {
|
function closePlaylistDetailsModal() {
|
||||||
|
|
@ -2184,19 +2194,36 @@ async function openDownloadMissingModal(playlistId) {
|
||||||
showLoadingOverlay('Loading playlist...');
|
showLoadingOverlay('Loading playlist...');
|
||||||
|
|
||||||
// **NEW**: Check if a process is already active for this playlist
|
// **NEW**: Check if a process is already active for this playlist
|
||||||
if (activeDownloadProcesses[playlistId]) {
|
const playlistMeta = spotifyPlaylists.find(p => p.id === playlistId);
|
||||||
|
const processStale = typeof isPlaylistDownloadProcessStale === 'function'
|
||||||
|
? isPlaylistDownloadProcessStale(playlistId, playlistMeta)
|
||||||
|
: (typeof playlistTrackCacheIsStale === 'function'
|
||||||
|
&& playlistTrackCacheIsStale(playlistId, playlistMeta));
|
||||||
|
|
||||||
|
if (activeDownloadProcesses[playlistId] && !processStale) {
|
||||||
console.log(`Modal for ${playlistId} already exists. Showing it.`);
|
console.log(`Modal for ${playlistId} already exists. Showing it.`);
|
||||||
closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal
|
closePlaylistDetailsModal();
|
||||||
const process = activeDownloadProcesses[playlistId];
|
const process = activeDownloadProcesses[playlistId];
|
||||||
if (process.modalElement) {
|
if (process.modalElement) {
|
||||||
// Show helpful message if it's a completed process
|
|
||||||
if (process.status === 'complete') {
|
if (process.status === 'complete') {
|
||||||
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
|
showToast('Showing previous results. Use "Download Missing (New)" for a fresh run.', 'info');
|
||||||
}
|
}
|
||||||
process.modalElement.style.display = 'flex';
|
process.modalElement.style.display = 'flex';
|
||||||
}
|
}
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
return; // Don't create a new one
|
return;
|
||||||
|
}
|
||||||
|
if (processStale && activeDownloadProcesses[playlistId]) {
|
||||||
|
if (typeof restartPlaylistDownloadMissing === 'function') {
|
||||||
|
await restartPlaylistDownloadMissing(playlistId);
|
||||||
|
hideLoadingOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof clearPlaylistDownloadProcess === 'function') {
|
||||||
|
clearPlaylistDownloadProcess(playlistId);
|
||||||
|
} else if (typeof invalidatePlaylistTrackCache === 'function') {
|
||||||
|
invalidatePlaylistTrackCache(playlistId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📥 Opening Download Missing Tracks modal for playlist: ${playlistId}`);
|
console.log(`📥 Opening Download Missing Tracks modal for playlist: ${playlistId}`);
|
||||||
|
|
@ -2210,16 +2237,29 @@ async function openDownloadMissingModal(playlistId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let tracks = playlistTrackCache[playlistId];
|
let tracks = playlistTrackCache[playlistId];
|
||||||
if (!tracks) {
|
const needFreshTracks = !tracks || (
|
||||||
|
typeof playlistTrackCacheIsStale === 'function'
|
||||||
|
&& playlistTrackCacheIsStale(playlistId, playlist)
|
||||||
|
);
|
||||||
|
if (needFreshTracks) {
|
||||||
try {
|
try {
|
||||||
const fetchUrl = playlistId.startsWith('deezer_arl_')
|
if (playlistId.startsWith('deezer_arl_')) {
|
||||||
? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`
|
const fetchUrl = `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`;
|
||||||
: `/api/spotify/playlist/${playlistId}`;
|
const response = await fetch(fetchUrl);
|
||||||
const response = await fetch(fetchUrl);
|
const fullPlaylist = await response.json();
|
||||||
const fullPlaylist = await response.json();
|
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
||||||
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
tracks = fullPlaylist.tracks;
|
||||||
tracks = fullPlaylist.tracks;
|
playlistTrackCache[playlistId] = tracks;
|
||||||
playlistTrackCache[playlistId] = tracks;
|
} else if (typeof fetchAndCacheSpotifyPlaylistTracks === 'function') {
|
||||||
|
const fullPlaylist = await fetchAndCacheSpotifyPlaylistTracks(playlistId);
|
||||||
|
tracks = fullPlaylist.tracks;
|
||||||
|
} else {
|
||||||
|
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
|
||||||
|
const fullPlaylist = await response.json();
|
||||||
|
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
|
||||||
|
tracks = fullPlaylist.tracks;
|
||||||
|
playlistTrackCache[playlistId] = tracks;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`Failed to fetch tracks: ${error.message}`, 'error');
|
showToast(`Failed to fetch tracks: ${error.message}`, 'error');
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
|
|
@ -2335,10 +2375,7 @@ async function openDownloadMissingModal(playlistId) {
|
||||||
<input type="checkbox" id="force-download-all-${playlistId}">
|
<input type="checkbox" id="force-download-all-${playlistId}">
|
||||||
<span>Force Download All</span>
|
<span>Force Download All</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="force-download-toggle">
|
<input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync" hidden>
|
||||||
<input type="checkbox" id="playlist-folder-mode-${playlistId}">
|
|
||||||
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="download-control-btn primary" id="begin-analysis-btn-${playlistId}" onclick="startMissingTracksProcess('${playlistId}')">
|
<button class="download-control-btn primary" id="begin-analysis-btn-${playlistId}" onclick="startMissingTracksProcess('${playlistId}')">
|
||||||
Begin Analysis
|
Begin Analysis
|
||||||
|
|
@ -2361,6 +2398,7 @@ async function openDownloadMissingModal(playlistId) {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
applyProgressiveTrackRendering(playlistId, tracks.length);
|
applyProgressiveTrackRendering(playlistId, tracks.length);
|
||||||
|
await applyMirroredOrganizePreference(playlistId, 'spotify');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue