Fix standalone mirrored playlist sync and post-sync downloads.
SoulSync standalone matches library tracks without Plex fetchItem, reports missing counts correctly, and skips server playlist writes. Automation re-syncs when the mirror grows; after sync finishes, starts organize download (organize-by-playlist) or wishlist processing. UI: Spotify URL playlist-folder controls, organize toggle layout in the discovery modal, reload organize preference when reopening Download Missing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9ff2e7084a
commit
0b1fdba2a1
12 changed files with 609 additions and 124 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -476,9 +560,21 @@ def run_sync_task(
|
|||
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:
|
||||
|
|
@ -497,12 +593,28 @@ def run_sync_task(
|
|||
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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -551,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))
|
||||
|
|
@ -618,27 +646,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
|
||||
|
|
|
|||
|
|
@ -770,6 +770,7 @@ class TestSyncPlaylist:
|
|||
|
||||
def test_unchanged_since_last_sync_returns_skipped(self):
|
||||
discovered_track = {
|
||||
'source_track_id': 'spot-1',
|
||||
'extra_data': json.dumps({
|
||||
'discovered': True,
|
||||
'matched_data': {
|
||||
|
|
@ -789,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(
|
||||
|
|
@ -800,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 ───────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -19017,7 +19017,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
|
||||
|
|
@ -23631,6 +23631,9 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -965,15 +965,41 @@ 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, "\\'");
|
||||
|
|
@ -1000,11 +1026,12 @@ function isPlaylistOrganizeEnabled(playlistRef) {
|
|||
return downloadMissingCb ? downloadMissingCb.checked : false;
|
||||
}
|
||||
|
||||
async function fetchMirroredOrganizePreference(playlistRef, source = 'spotify') {
|
||||
async function fetchMirroredOrganizePreference(playlistRef, source = null) {
|
||||
try {
|
||||
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source);
|
||||
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
|
||||
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource);
|
||||
const res = await fetch(
|
||||
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}`
|
||||
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}`
|
||||
);
|
||||
const data = await res.json();
|
||||
return !!(data.found && data.playlist?.organize_by_playlist);
|
||||
|
|
@ -1014,11 +1041,12 @@ async function fetchMirroredOrganizePreference(playlistRef, source = 'spotify')
|
|||
}
|
||||
}
|
||||
|
||||
async function setMirroredOrganizePreference(playlistRef, enabled, source = 'spotify') {
|
||||
async function setMirroredOrganizePreference(playlistRef, enabled, source = null) {
|
||||
try {
|
||||
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source);
|
||||
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
|
||||
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource);
|
||||
const res = await fetch(
|
||||
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}`
|
||||
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}`
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!data.found || !data.playlist?.id) {
|
||||
|
|
@ -1041,8 +1069,9 @@ async function setMirroredOrganizePreference(playlistRef, enabled, source = 'spo
|
|||
}
|
||||
}
|
||||
|
||||
async function loadPlaylistOrganizePreferenceIntoModal(playlistRef, source = 'spotify') {
|
||||
const enabled = await fetchMirroredOrganizePreference(playlistRef, source);
|
||||
async function loadPlaylistOrganizePreferenceIntoModal(playlistRef, source = null) {
|
||||
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
|
||||
const enabled = await fetchMirroredOrganizePreference(playlistRef, resolvedSource);
|
||||
syncPlaylistOrganizeCheckboxes(playlistRef, enabled);
|
||||
}
|
||||
|
||||
|
|
@ -1054,10 +1083,15 @@ async function onPlaylistOrganizePreferenceChange(playlistRef, enabled, source =
|
|||
}
|
||||
}
|
||||
|
||||
async function applyMirroredOrganizePreference(playlistRef, source = 'spotify') {
|
||||
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) {
|
||||
|
|
@ -1209,10 +1243,11 @@ function playlistModalDownloadSyncFooterHtml(playlistId, 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" onclick="${openDm}">📥 Download Missing Tracks</button>`;
|
||||
: `<button class="playlist-modal-btn playlist-modal-btn-tertiary${_isSoulsyncStandalone ? ' soulsync-standalone-action' : ''}" onclick="${openDm}">${dmLabel}</button>`;
|
||||
|
||||
if (_isSoulsyncStandalone) {
|
||||
return `${downloadBtns}
|
||||
|
|
@ -1290,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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -18471,8 +18471,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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3609,7 +3622,7 @@ function updateDeezerModalButtons(urlHash, phase) {
|
|||
|
||||
const footerLeft = modal.querySelector('.modal-footer-left');
|
||||
if (footerLeft) {
|
||||
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
|
||||
setDiscoveryModalFooterActions(urlHash, phase);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5634,7 +5647,7 @@ function updateBeatportModalButtons(urlHash, phase) {
|
|||
|
||||
const footerLeft = modal.querySelector('.modal-footer-left');
|
||||
if (footerLeft) {
|
||||
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
|
||||
setDiscoveryModalFooterActions(urlHash, phase);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7595,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);
|
||||
|
||||
|
|
@ -7661,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);
|
||||
|
|
@ -8619,7 +8634,7 @@ function updateITunesLinkModalButtons(urlHash, phase) {
|
|||
|
||||
const footerLeft = modal.querySelector('.modal-footer-left');
|
||||
if (footerLeft) {
|
||||
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
|
||||
setDiscoveryModalFooterActions(urlHash, phase);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -9444,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>
|
||||
|
|
@ -9569,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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -9650,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) {
|
||||
|
|
@ -9819,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) {
|
||||
|
|
@ -9875,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) {
|
||||
|
|
@ -10118,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10626,7 +10697,7 @@ function updateYouTubeModalButtons(urlHash, phase) {
|
|||
|
||||
const footerLeft = modal.querySelector('.modal-footer-left');
|
||||
if (footerLeft) {
|
||||
footerLeft.innerHTML = getModalActionButtons(urlHash, phase);
|
||||
setDiscoveryModalFooterActions(urlHash, phase);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2210,6 +2210,9 @@ async function openDownloadMissingModal(playlistId) {
|
|||
}
|
||||
process.modalElement.style.display = 'flex';
|
||||
}
|
||||
if (typeof refreshOrganizePreferenceForDownloadModal === 'function') {
|
||||
await refreshOrganizePreferenceForDownloadModal(playlistId);
|
||||
}
|
||||
hideLoadingOverlay();
|
||||
return;
|
||||
}
|
||||
|
|
@ -2375,7 +2378,9 @@ async function openDownloadMissingModal(playlistId) {
|
|||
<input type="checkbox" id="force-download-all-${playlistId}">
|
||||
<span>Force Download All</span>
|
||||
</label>
|
||||
<input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync" hidden>
|
||||
${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
|
||||
|
|
@ -2398,7 +2403,7 @@ async function openDownloadMissingModal(playlistId) {
|
|||
`;
|
||||
|
||||
applyProgressiveTrackRendering(playlistId, tracks.length);
|
||||
await applyMirroredOrganizePreference(playlistId, 'spotify');
|
||||
await applyMirroredOrganizePreference(playlistId);
|
||||
modal.style.display = 'flex';
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue