Merge pull request #780 from kekkokk/feature/organize-by-playlist-library

Fix organize-by-playlist: library registration, wishlist after failed downloads, and stale playlist cache
This commit is contained in:
BoulderBadgeDad 2026-06-03 20:33:18 -07:00 committed by GitHub
commit 0353d365d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2062 additions and 216 deletions

View file

@ -28,7 +28,7 @@ from __future__ import annotations
import threading
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from typing import Any, Callable, Dict, Optional
@dataclass
@ -105,6 +105,8 @@ class AutomationDeps:
# --- Playlist pipeline entry points ---
run_playlist_discovery_worker: 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]
get_deezer_client: Callable[[], Any]
parse_youtube_playlist: Callable[[str], Any]

View file

@ -148,9 +148,45 @@ def run_sync_and_wishlist(
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(
deps, automation_id,
skip=skip_wishlist,
skip=effective_skip_wishlist,
progress_pct=progress_end + 1,
wishlist_phase_label=wishlist_phase_label,
wishlist_phase_start_log=wishlist_phase_start_log,
@ -161,6 +197,7 @@ def run_sync_and_wishlist(
'skipped': total_skipped,
'errors': sync_errors,
'wishlist_queued': wishlist_queued,
'organize_downloads_started': organize_started,
}

View file

@ -145,13 +145,36 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str
tracks_hash = hashlib.md5(track_ids_str.encode()).hexdigest()
sync_id_key = f"auto_mirror_{playlist_id}"
# Full mirror identity (every source_track_id on the playlist). tracks_hash
# only covers tracks_json — if a new mirror row is skipped (no discovery /
# no source id), tracks_hash stays identical to the pre-add sync and we
# used to no-op with "unchanged" while the new song never hit wishlist.
mirror_ids_str = ','.join(
sorted(t.get('source_track_id', '') or '' for t in tracks if t.get('source_track_id'))
)
mirror_tracks_hash = hashlib.md5(mirror_ids_str.encode()).hexdigest() if mirror_ids_str else ''
event_data = config.get('_event_data') or {}
try:
tracks_added = int(event_data.get('added') or 0)
except (TypeError, ValueError):
tracks_added = 0
force_sync = tracks_added > 0 or skipped_count > 0
try:
sync_statuses = deps.load_sync_status_file()
last_status = sync_statuses.get(sync_id_key, {})
last_hash = last_status.get('tracks_hash', '')
last_mirror_hash = last_status.get('mirror_tracks_hash', '')
last_matched = last_status.get('matched_tracks', -1)
if last_hash == tracks_hash and last_matched >= len(tracks_json):
mirror_changed = bool(mirror_tracks_hash) and mirror_tracks_hash != last_mirror_hash
if (
not force_sync
and not mirror_changed
and last_hash == tracks_hash
and last_matched >= len(tracks_json)
):
# Exact same tracks, all matched last time — nothing to do.
deps.update_progress(
auto_id,
@ -162,6 +185,21 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str
'status': 'skipped',
'reason': f'All {len(tracks_json)} tracks unchanged since last sync',
}
if force_sync and last_hash == tracks_hash and last_matched >= len(tracks_json):
deps.update_progress(
auto_id,
log_line=(
f'Forcing sync: playlist changed ({tracks_added} added) or '
f'{skipped_count} track(s) need discovery'
),
log_type='info',
)
elif mirror_changed:
deps.update_progress(
auto_id,
log_line='Mirror track list changed — running sync',
log_type='info',
)
except Exception as e:
deps.logger.debug("mirror sync last-status read: %s", e)
@ -180,9 +218,11 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str
log_line=f'Starting sync: {len(tracks_json)} tracks',
log_type='success',
)
skip_wishlist_add = bool(pl.get('organize_by_playlist'))
threading.Thread(
target=deps.run_sync_task,
args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')),
kwargs={'skip_wishlist_add': skip_wishlist_add},
daemon=True,
name=f'auto-sync-{playlist_id}',
).start()

View file

@ -47,6 +47,90 @@ class SyncDeps:
update_and_save_sync_status: Callable
sync_states: dict
sync_lock: Any # threading.Lock
# Optional: post-sync download follow-up for mirrored-playlist automations.
process_wishlist_automatically: Callable[..., Any] | None = None
run_playlist_organize_download: Callable[..., Any] | None = None
is_wishlist_actually_processing: Callable[[], bool] | None = None
def _post_sync_automation_followup(
deps: SyncDeps,
*,
automation_id: str,
playlist_id: str,
skip_wishlist_add: bool,
result: Any,
) -> None:
"""Queue downloads after an automation sync finishes.
Sync Playlist runs in a background thread and returns immediately, so a
separate scheduled "Process Wishlist" action often runs on an empty wishlist.
Organize-by-playlist skips sync-time wishlist adds and expects a folder
download batch instead that only ran in Playlist Pipeline before this hook.
"""
if not automation_id or not str(playlist_id).startswith('auto_mirror_'):
return
try:
mirrored_id = int(str(playlist_id).replace('auto_mirror_', '', 1))
except ValueError:
return
failed = int(getattr(result, 'failed_tracks', 0) or 0)
wishlist_added = int(getattr(result, 'wishlist_added_count', 0) or 0)
if skip_wishlist_add:
org_fn = deps.run_playlist_organize_download
if failed <= 0:
return
if not org_fn:
logger.warning(
"Organize-by-playlist sync left %s missing tracks but organize download is unavailable",
failed,
)
deps.update_automation_progress(
automation_id,
log_line=f'{failed} missing — enable Playlist Pipeline or disable Organize by Playlist',
log_type='warning',
)
return
org_result = org_fn(mirrored_playlist_id=mirrored_id, automation_id=automation_id)
status = org_result.get('status', 'unknown') if isinstance(org_result, dict) else 'unknown'
reason = org_result.get('reason', '') if isinstance(org_result, dict) else ''
log_type = 'success' if status == 'started' else 'warning'
detail = f' ({reason})' if reason and status != 'started' else ''
deps.update_automation_progress(
automation_id,
log_line=f'Organize download {status} for {failed} missing track(s){detail}',
log_type=log_type,
)
return
if wishlist_added <= 0:
if failed > 0:
deps.update_automation_progress(
automation_id,
log_line=f'{failed} missing but none added to wishlist — check logs',
log_type='warning',
)
return
proc_fn = deps.process_wishlist_automatically
if not proc_fn:
return
is_busy = deps.is_wishlist_actually_processing
if is_busy and is_busy():
deps.update_automation_progress(
automation_id,
log_line=f'Added {wishlist_added} to wishlist; download worker already running',
log_type='info',
)
return
proc_fn(automation_id=automation_id)
deps.update_automation_progress(
automation_id,
log_line=f'Started wishlist download for {wishlist_added} track(s)',
log_type='success',
)
async def _database_only_find_track(spotify_track, candidate_pool=None):
@ -133,7 +217,17 @@ async def _database_only_find_track(spotify_track, candidate_pool=None):
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."""
sync_states = deps.sync_states
sync_lock = deps.sync_lock
@ -360,7 +454,14 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
# Wing It mode — skip wishlist for unmatched tracks
with sync_lock:
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
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)
result = deps.run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id, sync_mode=sync_mode))
@ -459,9 +560,21 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
matched = getattr(result, 'matched_tracks', 0)
total = getattr(result, 'total_tracks', 0)
failed = getattr(result, 'failed_tracks', 0)
wishlist_added = getattr(result, 'wishlist_added_count', 0) or 0
deps.update_automation_progress(automation_id, status='finished', progress=100,
phase='Sync complete',
log_line=f'Done: {matched}/{total} matched, {failed} failed', log_type='success')
log_line=(
f'Done: {matched}/{total} in library, {failed} missing'
+ (f', {wishlist_added} added to wishlist' if wishlist_added else '')
),
log_type='success')
_post_sync_automation_followup(
deps,
automation_id=automation_id,
playlist_id=playlist_id,
skip_wishlist_add=skip_wishlist_add,
result=result,
)
# Emit playlist_synced event for automation engine
try:
@ -480,12 +593,28 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
import hashlib as _hl
_track_ids_str = ','.join(sorted(t.get('id', '') for t in tracks_json))
_tracks_hash = _hl.md5(_track_ids_str.encode()).hexdigest()
_mirror_tracks_hash = None
if str(playlist_id).startswith('auto_mirror_'):
try:
_mp_id = int(str(playlist_id).replace('auto_mirror_', '', 1))
from database.music_database import MusicDatabase
_mtracks = MusicDatabase().get_mirrored_playlist_tracks(_mp_id)
_mids = ','.join(
sorted(t.get('source_track_id', '') or '' for t in _mtracks if t.get('source_track_id'))
)
_mirror_tracks_hash = _hl.md5(_mids.encode()).hexdigest() if _mids else ''
except Exception as e:
logger.debug("mirror_tracks_hash for sync status: %s", e)
snapshot_id = getattr(playlist, 'snapshot_id', None)
deps.update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id,
_status_kwargs = dict(
matched_tracks=getattr(result, 'matched_tracks', 0),
total_tracks=getattr(result, 'total_tracks', 0),
discovered_tracks=len(tracks_json),
tracks_hash=_tracks_hash)
tracks_hash=_tracks_hash,
)
if _mirror_tracks_hash is not None:
_status_kwargs['mirror_tracks_hash'] = _mirror_tracks_hash
deps.update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id, **_status_kwargs)
except Exception as e:
logger.error(f"SYNC FAILED for {playlist_id}: {e}")

View file

@ -343,6 +343,10 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
batch_is_album = False
batch_profile_id = 1
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:
if batch_id in download_batches:
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_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_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:
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
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
if force_download_all:
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']}'")
# Add playlist folder mode flag for sync page playlists
if batch_playlist_folder_mode:
# Add playlist folder mode flag for sync page playlists and wishlist
# 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_name'] = batch_playlist_name
logger.info(f"[Task Creation] Added playlist folder mode for: {track_info.get('name')}{batch_playlist_name}")
track_info['_playlist_name'] = task_pl_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:
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] = {
'status': 'pending', 'track_info': track_info,

View 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',
]

View file

@ -590,6 +590,26 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
record_library_history_download(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')
batch_id = context.get('batch_id')
if task_id and batch_id:

View 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']

View file

@ -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]:
"""Build the source_context payload used when adding failed tracks back to the wishlist."""
current_time = current_time or datetime.now()
playlist_id = batch.get('source_playlist_ref') or batch.get('playlist_id')
context = {
'playlist_name': batch.get('playlist_name', 'Unknown Playlist'),
'playlist_id': batch.get('playlist_id', None),
'playlist_id': playlist_id,
'added_from': 'webui_modal',
'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
# for album-vs-single routing instead of relying on per-track album dicts
# that may have been mangled by reconstruction fallbacks.

View file

@ -574,6 +574,7 @@ class MusicDatabase:
# Add explored_at to mirrored_playlists (migration)
self._add_mirrored_playlist_explored_column(cursor)
self._add_mirrored_playlist_organize_column(cursor)
# Add notification columns to automations (migration)
self._add_automation_notify_columns(cursor)
@ -1111,6 +1112,19 @@ class MusicDatabase:
except Exception as 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):
"""Add notification and result columns to automations table."""
try:
@ -12414,7 +12428,11 @@ class MusicDatabase:
WHERE profile_id = ?
ORDER BY updated_at DESC
""", (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:
logger.error(f"Error getting mirrored playlists: {e}")
return []
@ -12441,11 +12459,80 @@ class MusicDatabase:
cursor = conn.cursor()
cursor.execute("SELECT * FROM mirrored_playlists WHERE id = ?", (playlist_id,))
row = cursor.fetchone()
return dict(row) if row else None
return self._normalize_mirrored_playlist_row(row)
except Exception as e:
logger.error(f"Error getting mirrored playlist: {e}")
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]:
"""Return all tracks for a mirrored playlist ordered by position."""
try:

View file

@ -115,6 +115,12 @@ class PlaylistSyncService:
logger.error("Navidrome client not provided to sync service")
return None, "navidrome"
return client, "navidrome"
elif active_server == "soulsync":
client = self._media_client('soulsync')
if not client:
logger.error("SoulSync library client not provided to sync service")
return None, "soulsync"
return client, "soulsync"
else: # Default to Plex
client = self._media_client('plex')
if profile_id and client:
@ -292,63 +298,92 @@ class PlaylistSyncService:
return self._create_error_result(playlist.name, ["Sync cancelled"])
media_client, server_type = self._get_active_media_client()
self._update_progress(playlist.name, f"Creating/updating {server_type.title()} playlist", "", 80, 5, 4,
total_tracks=total_tracks,
matched_tracks=len(matched_tracks),
failed_tracks=len(unmatched_tracks))
# Get the actual media server track objects
media_tracks = [r.plex_track for r in matched_tracks if r.plex_track] # plex_track is a generic name here
logger.info(f"Creating playlist with {len(media_tracks)} matched tracks")
unmatched_count = len(unmatched_tracks)
# Validate that all tracks have proper ratingKey attributes for playlist creation
valid_tracks = []
for i, track in enumerate(media_tracks):
if track and hasattr(track, 'ratingKey'):
valid_tracks.append(track)
logger.debug(f"Track {i+1} valid for playlist: '{track.title}' (ratingKey: {track.ratingKey})")
else:
logger.warning(f"Track {i+1} invalid for playlist: {track} (type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})")
logger.info(f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid {server_type.title()} objects with ratingKeys")
# Deduplicate by ratingKey — media servers silently drop duplicates,
# so count should reflect what actually ends up in the playlist
seen_keys = set()
deduped_tracks = []
for t in valid_tracks:
if t.ratingKey not in seen_keys:
seen_keys.add(t.ratingKey)
deduped_tracks.append(t)
if len(deduped_tracks) < len(valid_tracks):
logger.info(f"Deduplicated {len(valid_tracks) - len(deduped_tracks)} duplicate ratingKeys ({len(valid_tracks)}{len(deduped_tracks)} tracks)")
# Use the deduplicated tracks for the sync
plex_tracks = deduped_tracks
# Use active media server for playlist sync
media_client, server_type = self._get_active_media_client()
if not media_client:
logger.error("No active media client available for playlist sync")
sync_success = False
else:
logger.info(
f"Syncing playlist '{playlist.name}' to {server_type.upper()} server "
f"(mode: {sync_mode})"
# SoulSync standalone has no server playlists — only library match +
# wishlist for missing files. Previously we fell through to Plex here,
# showed "Creating/updating Plex playlist", playlist write failed, and
# failed_tracks was computed as total - 0 synced (= entire playlist).
if server_type == 'soulsync':
self._update_progress(
playlist.name,
"Standalone: library check complete",
"",
80, 5, 4,
total_tracks=total_tracks,
matched_tracks=len(matched_tracks),
failed_tracks=unmatched_count,
)
# sync_mode == 'append' preserves user-added tracks on the server
# playlist (CJFC discord report) — never deletes, only adds new
# ones via the per-server `append_to_playlist`. The sync UI
# hides the Sync button entirely on SoulSync standalone (which
# has no playlist methods), so every client that reaches this
# point implements both methods.
if sync_mode == 'append':
sync_success = media_client.append_to_playlist(playlist.name, valid_tracks)
sync_success = True
synced_tracks = len(matched_tracks)
failed_tracks = unmatched_count
logger.info(
f"Standalone playlist sync '{playlist.name}': "
f"{len(matched_tracks)} in library, {unmatched_count} missing"
)
else:
self._update_progress(
playlist.name,
f"Creating/updating {server_type.title()} playlist",
"",
80, 5, 4,
total_tracks=total_tracks,
matched_tracks=len(matched_tracks),
failed_tracks=unmatched_count,
)
# Get the actual media server track objects
media_tracks = [r.plex_track for r in matched_tracks if r.plex_track]
logger.info(f"Creating playlist with {len(media_tracks)} matched tracks")
# Validate that all tracks have proper ratingKey attributes for playlist creation
valid_tracks = []
for i, track in enumerate(media_tracks):
if track and hasattr(track, 'ratingKey'):
valid_tracks.append(track)
logger.debug(f"Track {i+1} valid for playlist: '{track.title}' (ratingKey: {track.ratingKey})")
else:
logger.warning(
f"Track {i+1} invalid for playlist: {track} "
f"(type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})"
)
logger.info(
f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid "
f"{server_type.title()} objects with ratingKeys"
)
# Deduplicate by ratingKey — media servers silently drop duplicates
seen_keys = set()
deduped_tracks = []
for t in valid_tracks:
if t.ratingKey not in seen_keys:
seen_keys.add(t.ratingKey)
deduped_tracks.append(t)
if len(deduped_tracks) < len(valid_tracks):
logger.info(
f"Deduplicated {len(valid_tracks) - len(deduped_tracks)} duplicate ratingKeys "
f"({len(valid_tracks)}{len(deduped_tracks)} tracks)"
)
plex_tracks = deduped_tracks
if not media_client:
logger.error("No active media client available for playlist sync")
sync_success = False
else:
sync_success = media_client.update_playlist(playlist.name, valid_tracks)
synced_tracks = len(plex_tracks) if sync_success else 0
failed_tracks = len(playlist.tracks) - synced_tracks - downloaded_tracks
logger.info(
f"Syncing playlist '{playlist.name}' to {server_type.upper()} server "
f"(mode: {sync_mode})"
)
if sync_mode == 'append':
sync_success = media_client.append_to_playlist(playlist.name, valid_tracks)
else:
sync_success = media_client.update_playlist(playlist.name, valid_tracks)
synced_tracks = len(plex_tracks) if sync_success else 0
# Not in library (for wishlist), not "total minus playlist size".
failed_tracks = unmatched_count
self._update_progress(playlist.name, "Sync completed", "", 100, 5, 5,
total_tracks=total_tracks,
@ -357,8 +392,11 @@ class PlaylistSyncService:
# Auto-add unmatched tracks to wishlist (skip in Wing It mode)
wishlist_added_count = 0
if unmatched_tracks and getattr(self, '_skip_wishlist', False):
logger.info(f"[Wing It] Skipping wishlist for {len(unmatched_tracks)} unmatched tracks")
if unmatched_tracks and getattr(self, '_skip_unmatched_wishlist', False):
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
if unmatched_tracks:
try:
@ -548,20 +586,13 @@ class PlaylistSyncService:
server_track_id = cached['server_track_id']
db_track_check = cache_db.get_track_by_id(server_track_id)
if db_track_check:
if server_type == "jellyfin":
class JellyfinTrackFromCache:
if server_type in ("jellyfin", "navidrome", "soulsync"):
class DbTrackFromCache:
def __init__(self, db_t):
self.ratingKey = db_t.id
self.title = db_t.title
self.id = db_t.id
actual_track = JellyfinTrackFromCache(db_track_check)
elif server_type == "navidrome":
class NavidromeTrackFromCache:
def __init__(self, db_t):
self.ratingKey = db_t.id
self.title = db_t.title
self.id = db_t.id
actual_track = NavidromeTrackFromCache(db_track_check)
actual_track = DbTrackFromCache(db_track_check)
else:
try:
actual_track = media_client.server.fetchItem(int(server_track_id))
@ -633,27 +664,19 @@ class PlaylistSyncService:
# Fetch the actual track object from active media server using the database track ID
try:
if server_type == "jellyfin":
# For Jellyfin, create a track object from database info (Jellyfin doesn't have fetchItem)
class JellyfinTrackFromDB:
def __init__(self, db_track):
self.ratingKey = db_track.id
self.title = db_track.title
self.id = db_track.id
actual_track = JellyfinTrackFromDB(db_track)
logger.debug(f"Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
return actual_track, confidence
elif server_type == "navidrome":
# For Navidrome, create a track object from database info (similar to Jellyfin)
class NavidromeTrackFromDB:
if server_type in ("jellyfin", "navidrome", "soulsync"):
# DB-backed servers — no remote fetchItem (SoulSync standalone included).
class DbTrackFromDB:
def __init__(self, db_track):
self.ratingKey = db_track.id
self.title = db_track.title
self.id = db_track.id
actual_track = NavidromeTrackFromDB(db_track)
logger.debug(f"Created Navidrome track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
actual_track = DbTrackFromDB(db_track)
logger.debug(
f"Created {server_type} track object for '{db_track.title}' "
f"(ID: {actual_track.ratingKey})"
)
return actual_track, confidence
else:
# For Plex, use the original fetchItem approach

View file

@ -126,6 +126,8 @@ def _build_deps(engine, scan_mgr=None) -> AutomationDeps:
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': 'skipped'},
missing_download_executor=None,
load_sync_status_file=lambda: {},
get_deezer_client=lambda: None,
parse_youtube_playlist=lambda url: None,

View file

@ -73,6 +73,8 @@ def _build_deps(**overrides) -> AutomationDeps:
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': 'skipped'},
missing_download_executor=None,
load_sync_status_file=lambda: {},
get_deezer_client=lambda: None,
parse_youtube_playlist=lambda url: None,

View file

@ -47,6 +47,8 @@ def _build_deps(**overrides) -> AutomationDeps:
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': 'skipped'},
missing_download_executor=None,
load_sync_status_file=lambda: {},
get_deezer_client=lambda: None,
parse_youtube_playlist=lambda url: None,

View file

@ -108,6 +108,8 @@ def _build_deps(**overrides) -> AutomationDeps:
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': 'skipped'},
missing_download_executor=None,
load_sync_status_file=lambda: {},
get_deezer_client=lambda: None,
parse_youtube_playlist=lambda url: None,
@ -737,8 +739,38 @@ class TestSyncPlaylist:
time.sleep(0.01)
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):
discovered_track = {
'source_track_id': 'spot-1',
'extra_data': json.dumps({
'discovered': True,
'matched_data': {
@ -758,7 +790,11 @@ class TestSyncPlaylist:
import hashlib
expected_hash = hashlib.md5('spot-1'.encode()).hexdigest()
sync_statuses = {
'auto_mirror_1': {'tracks_hash': expected_hash, 'matched_tracks': 1}
'auto_mirror_1': {
'tracks_hash': expected_hash,
'mirror_tracks_hash': expected_hash,
'matched_tracks': 1,
}
}
deps = _build_deps(
@ -769,6 +805,96 @@ class TestSyncPlaylist:
assert result['status'] == 'skipped'
assert 'unchanged' in result['reason']
def test_playlist_changed_event_bypasses_unchanged_skip(self):
discovered_track = {
'source_track_id': 'spot-1',
'extra_data': json.dumps({
'discovered': True,
'matched_data': {
'id': 'spot-1', 'name': 'T', 'artists': [{'name': 'X'}],
'album': {'name': 'A'}, 'duration_ms': 0,
},
}),
'artist_name': 'X',
}
db = _StubDB(
playlists=[{'id': 1, 'name': 'P'}],
playlist_tracks={1: [discovered_track]},
)
import hashlib
expected_hash = hashlib.md5('spot-1'.encode()).hexdigest()
sync_statuses = {
'auto_mirror_1': {
'tracks_hash': expected_hash,
'mirror_tracks_hash': expected_hash,
'matched_tracks': 1,
}
}
sync_calls: List[tuple] = []
deps = _build_deps(
get_database=lambda: db,
load_sync_status_file=lambda: sync_statuses,
run_sync_task=lambda *a, **k: sync_calls.append((a, k)),
)
result = auto_sync_playlist(
{'playlist_id': '1', '_event_data': {'added': '1', 'playlist_id': '1'}},
deps,
)
assert result['status'] == 'started'
import time
for _ in range(50):
if sync_calls:
break
time.sleep(0.01)
assert len(sync_calls) == 1
def test_new_mirror_row_with_skipped_track_bypasses_unchanged_skip(self):
"""New playlist row without discovery must not reuse the old tracks_hash skip."""
discovered_track = {
'source_track_id': 'spot-1',
'extra_data': json.dumps({
'discovered': True,
'matched_data': {
'id': 'spot-1', 'name': 'T', 'artists': [{'name': 'X'}],
'album': {'name': 'A'}, 'duration_ms': 0,
},
}),
'artist_name': 'X',
}
db = _StubDB(
playlists=[{'id': 1, 'name': 'P'}],
playlist_tracks={1: [discovered_track, {}]}, # second row not syncable
)
import hashlib
old_hash = hashlib.md5('spot-1'.encode()).hexdigest()
new_mirror_hash = hashlib.md5('spot-1,spot-new'.encode()).hexdigest()
sync_statuses = {
'auto_mirror_1': {
'tracks_hash': old_hash,
'mirror_tracks_hash': old_hash,
'matched_tracks': 1,
}
}
# Give the new mirror row a source id so mirror hash changes.
db.playlist_tracks[1][1]['source_track_id'] = 'spot-new'
sync_calls: List[tuple] = []
deps = _build_deps(
get_database=lambda: db,
load_sync_status_file=lambda: sync_statuses,
run_sync_task=lambda *a, **k: sync_calls.append((a, k)),
)
result = auto_sync_playlist({'playlist_id': '1'}, deps)
assert result['status'] == 'started'
assert result['skipped_tracks'] == '1'
import time
for _ in range(50):
if sync_calls:
break
time.sleep(0.01)
assert len(sync_calls) == 1
assert new_mirror_hash != old_hash
# ─── playlist_pipeline ───────────────────────────────────────────────

View file

@ -60,6 +60,8 @@ def _build_deps(**overrides: Any) -> AutomationDeps:
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': 'skipped'},
missing_download_executor=None,
load_sync_status_file=lambda: {},
get_deezer_client=lambda: None,
parse_youtube_playlist=lambda url: None,

View 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

View file

@ -46,6 +46,8 @@ def _build_deps(**overrides) -> AutomationDeps:
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': 'skipped'},
missing_download_executor=None,
load_sync_status_file=lambda: {},
get_deezer_client=lambda: None,
parse_youtube_playlist=lambda url: None,

View file

@ -20,6 +20,7 @@ class _FakeSyncResult:
failed_tracks: int = 1
synced_tracks: int = 4
total_tracks: int = 6
wishlist_added_count: int = 0
match_details: list = None
def __post_init__(self):
@ -139,6 +140,9 @@ def _build_deps(
update_automation_progress=None,
update_and_save_sync_status=None,
run_async=None,
process_wishlist_automatically=None,
run_playlist_organize_download=None,
is_wishlist_actually_processing=None,
):
return ds.SyncDeps(
config_manager=config or _FakeConfig(),
@ -154,6 +158,9 @@ def _build_deps(
update_and_save_sync_status=update_and_save_sync_status or (lambda *a, **kw: None),
sync_states=sync_states if sync_states is not None else {},
sync_lock=sync_lock or threading.Lock(),
process_wishlist_automatically=process_wishlist_automatically,
run_playlist_organize_download=run_playlist_organize_download,
is_wishlist_actually_processing=is_wishlist_actually_processing,
)
@ -445,6 +452,64 @@ def test_update_and_save_sync_status_called(patched_db):
assert kwargs.get('tracks_hash') # md5 hash present
# ---------------------------------------------------------------------------
# Post-sync automation follow-up
# ---------------------------------------------------------------------------
def test_post_sync_triggers_wishlist_processor_for_mirror_automation(patched_db):
wishlist_calls = []
result = _FakeSyncResult(
matched_tracks=5,
failed_tracks=2,
wishlist_added_count=2,
total_tracks=7,
)
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
deps = _build_deps(
sync_service=svc,
process_wishlist_automatically=lambda **kw: wishlist_calls.append(kw),
is_wishlist_actually_processing=lambda: False,
)
ds.run_sync_task(
'auto_mirror_42',
'Mirror',
[_track()],
automation_id='auto-1',
deps=deps,
)
assert len(wishlist_calls) == 1
assert wishlist_calls[0]['automation_id'] == 'auto-1'
def test_post_sync_starts_organize_download_when_skip_wishlist_add(patched_db):
org_calls = []
result = _FakeSyncResult(
matched_tracks=50,
failed_tracks=10,
total_tracks=60,
)
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
deps = _build_deps(
sync_service=svc,
run_playlist_organize_download=lambda **kw: org_calls.append(kw) or {'status': 'started'},
)
ds.run_sync_task(
'auto_mirror_7',
'Organized',
[_track()],
automation_id='auto-2',
deps=deps,
skip_wishlist_add=True,
)
assert len(org_calls) == 1
assert org_calls[0]['mirrored_playlist_id'] == 7
assert org_calls[0]['automation_id'] == 'auto-2'
# ---------------------------------------------------------------------------
# Cleanup (finally)
# ---------------------------------------------------------------------------

View 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'

View file

@ -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, "record_library_history_download", 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, "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 len(library_calls) == 1
assert library_calls[0][2]["album_name"] == "Album"
# ---------------------------------------------------------------------------

View file

@ -177,6 +177,22 @@ def test_build_wishlist_source_context_minimal_batch_skips_album_provenance():
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():
"""Album batches must carry album_context/artist_context through to the
wishlist row so a later requeue has authoritative routing data instead

View file

@ -1084,6 +1084,8 @@ def _register_automation_handlers():
get_watchlist_scan_state=lambda: watchlist_scan_state,
run_playlist_discovery_worker=_run_playlist_discovery_worker,
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,
get_deezer_client=_get_deezer_client,
parse_youtube_playlist=parse_youtube_playlist,
@ -18750,6 +18752,23 @@ def start_missing_tracks_process(playlist_id):
if playlist_folder_mode:
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
with tasks_lock:
active_analysis_count = sum(1 for batch in download_batches.values()
@ -18960,7 +18979,7 @@ def _update_and_save_sync_status(playlist_id, playlist_name, playlist_owner, sna
'last_synced': now.isoformat()
}
# Store match counts and track hash for smart-skip on scheduled syncs
for key in ('matched_tracks', 'total_tracks', 'discovered_tracks', 'tracks_hash'):
for key in ('matched_tracks', 'total_tracks', 'discovered_tracks', 'tracks_hash', 'mirror_tracks_hash'):
if key in kwargs:
status[key] = kwargs[key]
sync_statuses[playlist_id] = status
@ -23575,14 +23594,44 @@ def _build_sync_deps():
update_and_save_sync_status=_update_and_save_sync_status,
sync_states=sync_states,
sync_lock=sync_lock,
process_wishlist_automatically=_process_wishlist_automatically,
run_playlist_organize_download=_run_playlist_organize_download,
is_wishlist_actually_processing=is_wishlist_actually_processing,
)
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(
playlist_id, playlist_name, tracks_json, automation_id, profile_id, playlist_image_url,
_build_sync_deps(),
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,
)
@ -31655,6 +31704,55 @@ def update_mirrored_playlist_source_ref_endpoint(playlist_id):
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):
return f"mirrored_{int(playlist_id)}"

View file

@ -675,6 +675,7 @@ function autoSyncWeeklyCardHtml(playlist, schedule) {
${_esc(playlist.name)}
</div>
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} &middot; ${playlist.track_count || 0} tracks</div>
${autoSyncOrganizeToggleHtml(playlist)}
<div class="auto-sync-scheduled-timing">
<span>${_esc(label)}</span>
<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) {
const enabled = schedule?.enabled !== false;
const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : '';
@ -1592,6 +1621,7 @@ function autoSyncScheduledCardHtml(playlist, schedule) {
${_esc(playlist.name)}
</div>
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} &middot; ${playlist.track_count || 0} tracks</div>
${autoSyncOrganizeToggleHtml(playlist)}
<div class="auto-sync-scheduled-timing">
<span>${_esc(autoSyncIntervalLabel(schedule?.hours || 24))}</span>
${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''}

View file

@ -534,6 +534,7 @@ function handleServiceStatusUpdate(data) {
_isSoulsyncStandalone = isSoulsyncStandalone;
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.classList.contains('soulsync-standalone-action')) return;
if (isSoulsyncStandalone) {
btn.dataset.hiddenByStandalone = '1';
btn.style.display = 'none';

View file

@ -429,6 +429,9 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
}
process.modalElement.style.display = 'flex';
}
if (typeof refreshOrganizePreferenceForDownloadModal === 'function') {
await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId);
}
hideLoadingOverlay(); // Hide overlay when reopening existing modal
return;
}
@ -2449,8 +2452,9 @@ async function startMissingTracksProcess(playlistId) {
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
// Check if playlist folder mode toggle is enabled (only for sync page playlists)
const playlistFolderModeCheckbox = document.getElementById(`playlist-folder-mode-${playlistId}`);
const playlistFolderMode = playlistFolderModeCheckbox ? playlistFolderModeCheckbox.checked : false;
const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function'
? isPlaylistOrganizeEnabled(playlistId)
: (document.getElementById(`playlist-folder-mode-${playlistId}`)?.checked ?? false);
// Hide the force download toggle during processing
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
@ -4425,20 +4429,34 @@ async function startPlaylistSync(playlistId, syncModeOverride = null) {
}
// Ensure we have the full track list before starting
const playlistMeta = spotifyPlaylists.find(p => p.id === playlistId);
let tracks = playlistTrackCache[playlistId];
if (!tracks) {
const cacheStale = typeof playlistTrackCacheIsStale === 'function'
&& playlistTrackCacheIsStale(playlistId, playlistMeta);
if (!tracks || cacheStale) {
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 {
// Use the right endpoint based on playlist source
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; // Cache it
if (cacheStale && typeof invalidatePlaylistTrackCache === 'function') {
invalidatePlaylistTrackCache(playlistId);
}
if (playlistId.startsWith('deezer_arl_') && typeof fetchAndCacheDeezerArlPlaylistTracks === 'function') {
const deezerId = playlistId.replace('deezer_arl_', '');
const fullPlaylist = await fetchAndCacheDeezerArlPlaylistTracks(playlistId, deezerId);
tracks = fullPlaylist.tracks;
} else if (typeof fetchAndCacheSpotifyPlaylistTracks === 'function' && !playlistId.startsWith('deezer_arl_')) {
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;
console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`);
} catch (error) {

View file

@ -961,6 +961,357 @@ function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) {
// in artists.js but used broadly across the app.
// ----------------------------------------------------------------------------
function playlistDetailsOrganizeCheckboxId(playlistRef) {
return `playlist-organize-${playlistRef}`;
}
/** Infer mirrored-playlist API source from a UI playlist / virtual id. */
function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) {
if (explicitSource) {
return explicitSource;
}
const ref = String(playlistRef || '');
if (ref.startsWith('spotify_public_')) {
return 'spotify_public';
}
if (ref.startsWith('deezer_arl_')) {
return 'deezer';
}
return 'spotify';
}
/** 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);
}
if (source === 'spotify_public' && ref.startsWith('spotify_public_')) {
return ref.slice('spotify_public_'.length);
}
return ref;
}
function downloadMissingModalOrganizeCheckboxHtml(playlistId) {
return `
<label class="force-download-toggle">
<input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync">
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
</label>`;
}
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 = null) {
try {
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource);
const res = await fetch(
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}`
);
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 = null) {
try {
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource);
const res = await fetch(
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}`
);
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 = null) {
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
const enabled = await fetchMirroredOrganizePreference(playlistRef, resolvedSource);
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 = null) {
await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source);
}
/** Re-sync organize toggles when re-showing an existing Download Missing modal. */
async function refreshOrganizePreferenceForDownloadModal(playlistRef, source = null) {
await applyMirroredOrganizePreference(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 dmLabel = _isSoulsyncStandalone ? '📁 Download to Playlist Folder' : '📥 Download Missing Tracks';
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${_isSoulsyncStandalone ? ' soulsync-standalone-action' : ''}" onclick="${openDm}">${dmLabel}</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') {
if (showLoadingOverlayParam) {
showLoadingOverlay('Loading album...');
@ -974,6 +1325,9 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
if (typeof refreshOrganizePreferenceForDownloadModal === 'function') {
await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId);
}
if (showLoadingOverlayParam) {
hideLoadingOverlay();
}
@ -3200,6 +3554,7 @@ async function fetchAndUpdateServiceStatus() {
_isSoulsyncStandalone = isSoulsyncStandalone2;
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.classList.contains('soulsync-standalone-action')) return;
if (isSoulsyncStandalone2) {
btn.dataset.hiddenByStandalone = '1';
btn.style.display = 'none';

View file

@ -3554,6 +3554,13 @@ const _RESULT_DISPLAY_MAP = {
{ key: 'wishlist_queued', label: 'Wishlist Queued' },
{ key: 'duration_seconds', label: 'Duration (s)' },
],
'sync_playlist': [
{ key: 'matched_tracks', label: 'In Library' },
{ key: 'failed_tracks', label: 'Missing' },
{ key: 'wishlist_added_count', label: 'Added to Wishlist', hideZero: true },
{ key: 'total_tracks', label: 'Total Tracks' },
{ key: 'synced_tracks', label: 'On Server Playlist', hideZero: true },
],
};
function _renderResultStats(resultJson, actionType) {

View file

@ -12103,6 +12103,21 @@ body.helper-mode-active #dashboard-activity-feed:hover {
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 {
display: flex;
flex-wrap: wrap;
@ -18471,8 +18486,36 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.youtube-discovery-modal .modal-footer-left {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 14px;
flex: 1;
min-width: 0;
}
.youtube-discovery-modal .modal-footer-organize {
flex: none;
width: 100%;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.youtube-discovery-modal .modal-footer-organize .playlist-modal-organize-toggle {
width: 100%;
font-size: 14px;
}
.youtube-discovery-modal .modal-footer-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
width: 100%;
}
.youtube-discovery-modal .modal-footer-actions .modal-info {
flex: 1 1 100%;
margin-bottom: 4px;
}
.youtube-discovery-modal .modal-footer-right {
@ -18743,6 +18786,24 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.playlist-modal-footer-right {
display: flex;
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 {

View file

@ -1233,10 +1233,7 @@ function updateTidalModalButtons(urlHash, phase) {
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (!modal) return;
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
}
setDiscoveryModalFooterActions(urlHash, phase);
}
async function startTidalDownloadMissing(urlHash) {
@ -1314,7 +1311,7 @@ async function startTidalDownloadMissing(urlHash) {
}
}
async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks) {
async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks, options = {}) {
showLoadingOverlay('Loading Tidal playlist...');
// Check if a process is already active for this virtual playlist
if (activeDownloadProcesses[virtualPlaylistId]) {
@ -1326,6 +1323,10 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
}
process.modalElement.style.display = 'flex';
}
if (typeof refreshOrganizePreferenceForDownloadModal === 'function') {
await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId);
}
hideLoadingOverlay();
return;
}
@ -1464,10 +1465,12 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
<span>Force Download All</span>
</label>
<label class="force-download-toggle">
${typeof downloadMissingModalOrganizeCheckboxHtml === 'function'
? downloadMissingModalOrganizeCheckboxHtml(virtualPlaylistId)
: `<label class="force-download-toggle">
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
</label>
</label>`}
</div>
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
Begin Analysis
@ -1487,6 +1490,16 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
`;
applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length);
const orgSource = typeof playlistOrganizeSourceForRef === 'function'
? playlistOrganizeSourceForRef(virtualPlaylistId)
: 'spotify';
await applyMirroredOrganizePreference(virtualPlaylistId, orgSource);
if (options.forcePlaylistFolder) {
syncPlaylistOrganizeCheckboxes(virtualPlaylistId, true);
if (typeof setMirroredOrganizePreference === 'function') {
await setMirroredOrganizePreference(virtualPlaylistId, true, orgSource);
}
}
modal.style.display = 'flex';
hideLoadingOverlay();
}
@ -2366,7 +2379,7 @@ function updateQobuzModalButtons(urlHash, phase) {
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
setDiscoveryModalFooterActions(urlHash, phase);
}
}
@ -2438,6 +2451,11 @@ async function loadDeezerArlPlaylists() {
throw new Error(error.error || 'Failed to fetch Deezer playlists');
}
deezerArlPlaylists = await response.json();
if (typeof invalidatePlaylistTrackCache === 'function') {
invalidatePlaylistTrackCache();
} else {
playlistTrackCache = {};
}
renderDeezerArlPlaylists();
deezerArlPlaylistsLoaded = true;
@ -2525,25 +2543,25 @@ async function openDeezerArlPlaylistDetailsModal(event, playlistId) {
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
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] };
showDeezerArlPlaylistDetailsModal(fullPlaylist, playlistId);
} else {
const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
playlistTrackCache[arlPlaylistId] = fullPlaylist.tracks;
// Auto-mirror
mirrorPlaylist('deezer', 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,
source_track_id: t.id || ''
})), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url });
if (cacheStale && typeof invalidatePlaylistTrackCache === 'function') {
invalidatePlaylistTrackCache(arlPlaylistId);
}
const fullPlaylist = typeof fetchAndCacheDeezerArlPlaylistTracks === 'function'
? await fetchAndCacheDeezerArlPlaylistTracks(arlPlaylistId, playlistId)
: await (async () => {
const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`);
const data = await response.json();
if (data.error) throw new Error(data.error);
playlistTrackCache[arlPlaylistId] = data.tracks;
return data;
})();
showDeezerArlPlaylistDetailsModal({ ...fullPlaylist, id: arlPlaylistId }, playlistId);
}
} catch (error) {
@ -2608,15 +2626,20 @@ function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) {
</div>
<div class="playlist-modal-footer">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeDeezerArlPlaylistDetailsModal()">Close</button>
<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="closeDeezerArlPlaylistDetailsModal(); openDownloadMissingModal('${playlistId}')">
${hasCompletedProcess ? '📊 View Download Results' : '📥 Download Missing Tracks'}
</button>
<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"' : ''}>
<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' : ''} ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>
<div class="playlist-modal-footer-left">
${typeof playlistOrganizeToggleHtml === 'function' ? playlistOrganizeToggleHtml(playlistId, 'deezer') : ''}
</div>
<div class="playlist-modal-footer-right">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeDeezerArlPlaylistDetailsModal()">Close</button>
${typeof playlistModalDownloadSyncFooterHtml === 'function'
? playlistModalDownloadSyncFooterHtml(playlistId, {
hasCompletedProcess,
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>
`;
@ -2633,6 +2656,9 @@ function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) {
}
modal.style.display = 'flex';
if (typeof loadPlaylistOrganizePreferenceIntoModal === 'function') {
void loadPlaylistOrganizePreferenceIntoModal(playlistId, 'deezer');
}
}
function closeDeezerArlPlaylistDetailsModal() {
@ -3596,7 +3622,7 @@ function updateDeezerModalButtons(urlHash, phase) {
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
setDiscoveryModalFooterActions(urlHash, phase);
}
}
@ -5621,7 +5647,7 @@ function updateBeatportModalButtons(urlHash, phase) {
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
setDiscoveryModalFooterActions(urlHash, phase);
}
}
@ -7582,11 +7608,11 @@ function updateSpotifyPublicModalButtons(urlHash, phase) {
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
setDiscoveryModalFooterActions(urlHash, phase);
}
}
async function startSpotifyPublicDownloadMissing(urlHash) {
async function startSpotifyPublicDownloadMissing(urlHash, forcePlaylistFolder = false) {
try {
console.log('🔍 Starting download missing tracks for Spotify public playlist:', urlHash);
@ -7648,7 +7674,9 @@ async function startSpotifyPublicDownloadMissing(urlHash) {
discoveryModal.classList.add('hidden');
}
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks);
await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks, {
forcePlaylistFolder: !!forcePlaylistFolder,
});
} catch (error) {
console.error('Error starting Spotify public download missing:', error);
@ -8606,7 +8634,7 @@ function updateITunesLinkModalButtons(urlHash, phase) {
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
setDiscoveryModalFooterActions(urlHash, phase);
}
}
@ -9431,7 +9459,7 @@ function openYouTubeDiscoveryModal(urlHash) {
<div class="modal-footer">
<div class="modal-footer-left">
${getModalActionButtons(urlHash, state.phase, state)}
${buildDiscoveryModalFooterLeftHtml(urlHash, state.phase, state)}
</div>
<div class="modal-footer-right">
<button class="modal-btn modal-btn-secondary" onclick="closeYouTubeDiscoveryModal('${urlHash}')">🏠 Close</button>
@ -9556,6 +9584,48 @@ function openYouTubeDiscoveryModal(urlHash) {
}
console.log('✨ Created new modal with current state');
if (isSpotifyPublic && state.spotify_public_playlist_id && typeof loadPlaylistOrganizePreferenceIntoModal === 'function') {
void loadPlaylistOrganizePreferenceIntoModal(
`spotify_public_${state.spotify_public_playlist_id}`,
'spotify_public',
);
}
}
}
function discoveryModalOrganizeFooterHtml(state) {
if (!state || typeof playlistOrganizeToggleHtml !== 'function') {
return '';
}
if (state.is_spotify_public_playlist && state.spotify_public_playlist_id) {
const ref = `spotify_public_${state.spotify_public_playlist_id}`;
return `<div class="modal-footer-organize">${playlistOrganizeToggleHtml(ref, 'spotify_public')}</div>`;
}
return '';
}
function buildDiscoveryModalFooterLeftHtml(urlHash, phase, state) {
const organize = discoveryModalOrganizeFooterHtml(state);
const actions = getModalActionButtons(urlHash, phase, state);
return `${organize}<div class="modal-footer-actions">${actions}</div>`;
}
function setDiscoveryModalFooterActions(urlHash, phase, state = null) {
if (!state) {
state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
}
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
const footerLeft = modal?.querySelector('.modal-footer-left');
if (!footerLeft) {
return;
}
footerLeft.innerHTML = buildDiscoveryModalFooterLeftHtml(urlHash, phase, state);
if (state?.is_spotify_public_playlist && state.spotify_public_playlist_id
&& typeof loadPlaylistOrganizePreferenceIntoModal === 'function') {
void loadPlaylistOrganizePreferenceIntoModal(
`spotify_public_${state.spotify_public_playlist_id}`,
'spotify_public',
);
}
}
@ -9637,7 +9707,12 @@ function getModalActionButtons(urlHash, phase, state = null) {
} else if (isDeezer) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startDeezerDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isSpotifyPublic) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startSpotifyPublicDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
const folderArg = _isSoulsyncStandalone ? ', true' : '';
const label = _isSoulsyncStandalone
? '📁 Download to Playlist Folder'
: '🔍 Download Missing Tracks';
const extraClass = _isSoulsyncStandalone ? ' soulsync-standalone-action' : '';
buttons += `<button class="modal-btn modal-btn-primary${extraClass}" onclick="startSpotifyPublicDownloadMissing('${urlHash}'${folderArg})">${label}</button>`;
} else if (isITunesLink) {
buttons += `<button class="modal-btn modal-btn-primary" onclick="startITunesLinkDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isBeatport) {
@ -9806,7 +9881,12 @@ function getModalActionButtons(urlHash, phase, state = null) {
} else if (isQobuz) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startQobuzDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isSpotifyPublic) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startSpotifyPublicDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
const folderArg = _isSoulsyncStandalone ? ', true' : '';
const label = _isSoulsyncStandalone
? '📁 Download to Playlist Folder'
: '🔍 Download Missing Tracks';
const extraClass = _isSoulsyncStandalone ? ' soulsync-standalone-action' : '';
syncCompleteButtons += `<button class="modal-btn modal-btn-primary${extraClass}" onclick="startSpotifyPublicDownloadMissing('${urlHash}'${folderArg})">${label}</button>`;
} else if (isITunesLink) {
syncCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startITunesLinkDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isBeatport) {
@ -9862,7 +9942,12 @@ function getModalActionButtons(urlHash, phase, state = null) {
} else if (isDeezer) {
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startDeezerDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isSpotifyPublic) {
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startSpotifyPublicDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
const folderArg = _isSoulsyncStandalone ? ', true' : '';
const label = _isSoulsyncStandalone
? '📁 Download to Playlist Folder'
: '🔍 Download Missing Tracks';
const extraClass = _isSoulsyncStandalone ? ' soulsync-standalone-action' : '';
dlCompleteButtons += `<button class="modal-btn modal-btn-primary${extraClass}" onclick="startSpotifyPublicDownloadMissing('${urlHash}'${folderArg})">${label}</button>`;
} else if (isITunesLink) {
dlCompleteButtons += `<button class="modal-btn modal-btn-primary" onclick="startITunesLinkDownloadMissing('${urlHash}')">🔍 Download Missing Tracks</button>`;
} else if (isBeatport) {
@ -10105,18 +10190,17 @@ function updateYouTubeDiscoveryModal(urlHash, status) {
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash];
if (state && state.phase === 'discovering') {
state.phase = 'discovered';
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
if (actionButtonsContainer) {
actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state);
console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`);
}
setDiscoveryModalFooterActions(urlHash, 'discovered', state);
console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`);
const descEl = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-description`);
if (descEl) descEl.textContent = 'Discovery complete! View the results below.';
} else if (state && state.phase === 'discovered') {
// Already discovered — ensure buttons are correct (e.g. after rehydration)
const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`);
const actionButtonsContainer = document.querySelector(
`#youtube-discovery-modal-${urlHash} .modal-footer-actions`,
);
if (actionButtonsContainer && actionButtonsContainer.querySelector('.modal-info')) {
actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state);
setDiscoveryModalFooterActions(urlHash, 'discovered', state);
}
}
}
@ -10613,7 +10697,7 @@ function updateYouTubeModalButtons(urlHash, phase) {
const footerLeft = modal.querySelector('.modal-footer-left');
if (footerLeft) {
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
setDiscoveryModalFooterActions(urlHash, phase);
}
}

View file

@ -1610,6 +1610,11 @@ async function loadSpotifyPlaylists() {
throw new Error(error.error || 'Failed to fetch playlists');
}
spotifyPlaylists = await response.json();
if (typeof invalidatePlaylistTrackCache === 'function') {
invalidatePlaylistTrackCache();
} else {
playlistTrackCache = {};
}
renderSpotifyPlaylists();
spotifyPlaylistsLoaded = true;
@ -1833,35 +1838,35 @@ async function openPlaylistDetailsModal(event, playlistId) {
showLoadingOverlay(`Loading playlist: ${playlist.name}...`);
try {
// --- CACHING LOGIC START ---
if (playlistTrackCache[playlistId]) {
const cacheStale = typeof playlistTrackCacheIsStale === 'function'
&& playlistTrackCacheIsStale(playlistId, playlist);
if (playlistTrackCache[playlistId] && !cacheStale) {
console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`);
// Use the cached tracks instead of fetching
const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] };
showPlaylistDetailsModal(fullPlaylist);
} else {
console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`);
// Fetch from the server if not in cache
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
// Store the fetched tracks in the cache
playlistTrackCache[playlistId] = fullPlaylist.tracks;
if (cacheStale) {
console.log(`Cache STALE for playlist ${playlistId} — refetching tracks.`);
if (typeof invalidatePlaylistTrackCache === 'function') {
invalidatePlaylistTrackCache(playlistId);
} else {
delete playlistTrackCache[playlistId];
}
} 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}.`);
// 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);
}
// --- CACHING LOGIC END ---
} catch (error) {
showToast(`Error: ${error.message}`, 'error');
@ -1929,22 +1934,27 @@ function showPlaylistDetailsModal(playlist) {
</div>
<div class="playlist-modal-footer">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closePlaylistDetailsModal()">Close</button>
<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="openDownloadMissingModal('${playlist.id}')">
${hasCompletedProcess
? '📊 View Download Results'
: '📥 Download Missing Tracks'}
</button>
<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"' : ''}>
<option value="replace" selected>Replace</option>
<option value="append">Append only</option>
</select>
<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>
<div class="playlist-modal-footer-left">
${typeof playlistOrganizeToggleHtml === 'function' ? playlistOrganizeToggleHtml(playlist.id, 'spotify') : ''}
</div>
<div class="playlist-modal-footer-right">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closePlaylistDetailsModal()">Close</button>
${typeof playlistModalDownloadSyncFooterHtml === 'function'
? playlistModalDownloadSyncFooterHtml(playlist.id, {
hasCompletedProcess,
isSyncing,
source: 'spotify',
})
: `<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="openDownloadMissingModal('${playlist.id}')">📥 Download Missing Tracks</button>`}
</div>
</div>
</div>
`;
modal.style.display = 'flex';
if (typeof loadPlaylistOrganizePreferenceIntoModal === 'function') {
void loadPlaylistOrganizePreferenceIntoModal(playlist.id, 'spotify');
}
}
function closePlaylistDetailsModal() {
@ -2184,19 +2194,39 @@ async function openDownloadMissingModal(playlistId) {
showLoadingOverlay('Loading 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.`);
closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal
closePlaylistDetailsModal();
const process = activeDownloadProcesses[playlistId];
if (process.modalElement) {
// Show helpful message if it's a completed process
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';
}
if (typeof refreshOrganizePreferenceForDownloadModal === 'function') {
await refreshOrganizePreferenceForDownloadModal(playlistId);
}
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}`);
@ -2210,16 +2240,29 @@ async function openDownloadMissingModal(playlistId) {
}
let tracks = playlistTrackCache[playlistId];
if (!tracks) {
const needFreshTracks = !tracks || (
typeof playlistTrackCacheIsStale === 'function'
&& playlistTrackCacheIsStale(playlistId, playlist)
);
if (needFreshTracks) {
try {
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;
if (playlistId.startsWith('deezer_arl_')) {
const fetchUrl = `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`;
const response = await fetch(fetchUrl);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.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) {
showToast(`Failed to fetch tracks: ${error.message}`, 'error');
hideLoadingOverlay();
@ -2335,10 +2378,9 @@ async function openDownloadMissingModal(playlistId) {
<input type="checkbox" id="force-download-all-${playlistId}">
<span>Force Download All</span>
</label>
<label class="force-download-toggle">
<input type="checkbox" id="playlist-folder-mode-${playlistId}">
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
</label>
${typeof downloadMissingModalOrganizeCheckboxHtml === 'function'
? downloadMissingModalOrganizeCheckboxHtml(playlistId)
: `<input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync">`}
</div>
<button class="download-control-btn primary" id="begin-analysis-btn-${playlistId}" onclick="startMissingTracksProcess('${playlistId}')">
Begin Analysis
@ -2361,6 +2403,7 @@ async function openDownloadMissingModal(playlistId) {
`;
applyProgressiveTrackRendering(playlistId, tracks.length);
await applyMirroredOrganizePreference(playlistId);
modal.style.display = 'flex';
hideLoadingOverlay();
}