Sync detail modal: click '→ Wishlist' to re-add a track with the original context

In the dashboard Recent Syncs detail modal, the '→ Wishlist' status on unmatched
tracks is now a button. Clicking it re-adds that exact track to the wishlist with
the SAME context the sync used (source_type='playlist' + the playlist's name/id +
failure_reason), so it's indistinguishable from the original auto-add.

- reconstruct_sync_track_data() (pure, tested): prefers the full cached track from
  tracks_json (by source_track_id, then index) so album art/full data carry over;
  falls back to the track_result fields; refuses non-'wishlist' rows and rows with
  no id (can't re-wishlist a matched/unidentifiable track).
- POST /api/sync/history/<id>/track/<i>/wishlist resolves the entry server-side and
  calls the wishlist service; idempotent (reports added vs already-on-wishlist).
- button shows a busy state then '✓ Re-added' / '✓ On wishlist'.

7 pure tests (full-track preference, id-vs-index match, fallback rebuild, non-
wishlist + out-of-range refusal). JS/PY/ruff clean.
pull/924/head
BoulderBadgeDad 4 days ago
parent 49d3c77808
commit e148f859e7

@ -0,0 +1,67 @@
"""Rebuild the track data the sync used to auto-wishlist an unmatched track, so the
sync-detail modal can re-add it with the EXACT same context (source_type='playlist'
+ the playlist's name/id). Pure — no I/O; the web route supplies the parsed sync
entry and calls the wishlist service.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def reconstruct_sync_track_data(
track_results: Optional[List[Dict[str, Any]]],
tracks: Optional[List[Dict[str, Any]]],
track_index: int,
) -> Optional[Dict[str, Any]]:
"""Return the ``spotify_track_data`` dict to re-add a synced track to the wishlist.
Prefers the FULL original track from the cached playlist tracks (``tracks``, i.e.
tracks_json) matched by the track_result's ``source_track_id``, then by index —
because that carries the full album object + images the original auto-add used.
Falls back to a minimal dict rebuilt from the track_result's own fields.
Returns None when the index is out of range, the row isn't a 'wishlist'
(unmatched, auto-added) track, or there's no id to key on — so a caller can't
re-wishlist a matched/downloaded track or an unidentifiable one.
"""
if not track_results or track_index < 0 or track_index >= len(track_results):
return None
tr = track_results[track_index] or {}
# Only rows the sync actually sent to the wishlist are re-addable.
if tr.get('download_status') != 'wishlist':
return None
sid = str(tr.get('source_track_id') or '')
tracks = tracks or []
# Prefer the full original track: same index if its id matches, else search by id.
full = None
if 0 <= track_index < len(tracks):
cand = tracks[track_index]
if isinstance(cand, dict) and (not sid or str(cand.get('id') or '') == sid):
full = cand
if full is None and sid:
full = next(
(t for t in tracks if isinstance(t, dict) and str(t.get('id') or '') == sid),
None,
)
if isinstance(full, dict) and full.get('id'):
return full
# Fallback: rebuild from the track_result fields (id required).
if not sid:
return None
album: Dict[str, Any] = {'name': tr.get('album') or ''}
if tr.get('image_url'):
album['images'] = [{'url': tr['image_url']}]
return {
'id': sid,
'name': tr.get('name') or '',
'artists': [{'name': tr.get('artist') or ''}],
'album': album,
'duration_ms': tr.get('duration_ms') or 0,
}
__all__ = ["reconstruct_sync_track_data"]

@ -0,0 +1,76 @@
"""Re-add a synced unmatched track to the wishlist with the original context.
reconstruct_sync_track_data rebuilds the spotify_track_data the sync used. It must
prefer the full cached track (with album images), fall back to the track_result
fields, and refuse anything that wasn't a 'wishlist' row.
"""
from __future__ import annotations
from core.sync.wishlist_readd import reconstruct_sync_track_data
def _tr(index, sid, status='wishlist', **kw):
return {"index": index, "source_track_id": sid, "download_status": status,
"name": kw.get("name", ""), "artist": kw.get("artist", ""),
"album": kw.get("album", ""), "image_url": kw.get("image_url", ""),
"duration_ms": kw.get("duration_ms", 0)}
def _full(sid, name="Song", with_images=True):
album = {"name": "Album"}
if with_images:
album["images"] = [{"url": "http://cdn/cover.jpg"}]
return {"id": sid, "name": name, "artists": [{"name": "Artist"}], "album": album,
"duration_ms": 200000, "popularity": 50}
def test_prefers_full_original_track_by_index():
trs = [_tr(0, "sp_a"), _tr(1, "sp_b")]
tracks = [_full("sp_a"), _full("sp_b", name="Other")]
out = reconstruct_sync_track_data(trs, tracks, 1)
assert out is tracks[1] # exact original (full album+images)
assert out["album"]["images"][0]["url"] == "http://cdn/cover.jpg"
def test_matches_by_id_when_index_misaligns():
# tracks_json in a different order than track_results -> match by source_track_id.
trs = [_tr(0, "sp_a"), _tr(1, "sp_b")]
tracks = [_full("sp_b"), _full("sp_a")] # reversed
out = reconstruct_sync_track_data(trs, tracks, 0)
assert out["id"] == "sp_a" # found by id, not by position
def test_falls_back_to_track_result_fields_when_no_full_track():
trs = [_tr(0, "sp_x", name="Real Love Baby", artist="Father John Misty",
album="Real Love Baby", image_url="http://img/x.jpg", duration_ms=188000)]
out = reconstruct_sync_track_data(trs, [], 0) # no tracks_json
assert out["id"] == "sp_x"
assert out["name"] == "Real Love Baby"
assert out["artists"] == [{"name": "Father John Misty"}]
assert out["album"]["name"] == "Real Love Baby"
assert out["album"]["images"] == [{"url": "http://img/x.jpg"}]
assert out["duration_ms"] == 188000
def test_fallback_without_image_omits_images():
out = reconstruct_sync_track_data([_tr(0, "sp_x", album="A")], [], 0)
assert "images" not in out["album"]
def test_refuses_non_wishlist_row():
# A matched/downloaded track must not be re-wishlistable.
trs = [_tr(0, "sp_a", status="completed")]
assert reconstruct_sync_track_data(trs, [_full("sp_a")], 0) is None
def test_refuses_out_of_range_or_empty():
assert reconstruct_sync_track_data([], [], 0) is None
assert reconstruct_sync_track_data(None, None, 0) is None
assert reconstruct_sync_track_data([_tr(0, "sp_a")], [], 5) is None
assert reconstruct_sync_track_data([_tr(0, "sp_a")], [], -1) is None
def test_refuses_when_no_id_and_no_full_track():
# A wishlist row with no source_track_id and no cached track is unidentifiable.
assert reconstruct_sync_track_data([_tr(0, "")], [], 0) is None

@ -19420,6 +19420,44 @@ def get_sync_history_entry(entry_id):
logger.error(f"Error getting sync history entry: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/sync/history/<int:entry_id>/track/<int:track_index>/wishlist', methods=['POST'])
def readd_sync_track_to_wishlist(entry_id, track_index):
"""Re-add a synced unmatched track to the wishlist with the SAME context the
sync originally used (source_type='playlist' + the playlist's name/id), so it
behaves identically to the auto-add. Only 'wishlist'-status rows are eligible."""
try:
db = MusicDatabase()
entry = db.get_sync_history_entry(entry_id)
if not entry:
return jsonify({"success": False, "error": "Sync entry not found"}), 404
tracks = json.loads(entry['tracks_json']) if entry.get('tracks_json') else []
track_results = json.loads(entry['track_results']) if entry.get('track_results') else []
from core.sync.wishlist_readd import reconstruct_sync_track_data
spotify_track_data = reconstruct_sync_track_data(track_results, tracks, track_index)
if not spotify_track_data:
return jsonify({"success": False, "error": "This track can't be re-added to the wishlist"}), 400
from core.wishlist_service import get_wishlist_service
added = get_wishlist_service().add_spotify_track_to_wishlist(
spotify_track_data=spotify_track_data,
failure_reason='Missing from media server after sync',
source_type='playlist',
source_context={
'playlist_name': entry.get('playlist_name'),
'playlist_id': entry.get('playlist_id'),
'sync_type': 'automatic_sync',
'timestamp': datetime.now().isoformat(),
},
)
return jsonify({"success": True, "added": bool(added),
"name": spotify_track_data.get('name', '')})
except Exception as e:
logger.error(f"Error re-adding synced track to wishlist (entry {entry_id}, track {track_index}): {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/sync/history/<int:entry_id>', methods=['DELETE'])
def delete_sync_history_entry_api(entry_id):
"""Delete a sync history entry."""

@ -2198,6 +2198,37 @@ function _relativeTime(dateStr) {
} catch (e) { return ''; }
}
// Re-add a synced unmatched track to the wishlist from the sync-detail modal, with
// the same context the original sync used (resolved server-side from the entry).
async function _readdSyncWishlist(entryId, index, el) {
if (el && el.dataset.busy) return;
if (el) { el.dataset.busy = '1'; el.classList.add('is-busy'); }
try {
const resp = await fetch(`/api/sync/history/${entryId}/track/${index}/wishlist`, { method: 'POST' });
const data = await resp.json();
if (data && data.success) {
if (el) {
el.classList.remove('is-busy');
el.classList.add('is-done');
el.disabled = true;
el.innerHTML = data.added ? '&#10003; Re-added' : '&#10003; On wishlist';
}
if (typeof showToast === 'function') {
showToast(
data.added ? `Re-added "${data.name}" to wishlist` : `"${data.name}" is already on the wishlist`,
data.added ? 'success' : 'info',
);
}
} else {
if (el) { delete el.dataset.busy; el.classList.remove('is-busy'); }
if (typeof showToast === 'function') showToast((data && data.error) || 'Could not re-add to wishlist', 'error');
}
} catch (e) {
if (el) { delete el.dataset.busy; el.classList.remove('is-busy'); }
if (typeof showToast === 'function') showToast('Could not re-add to wishlist: ' + e.message, 'error');
}
}
async function openSyncDetailModal(entryId) {
try {
showLoadingOverlay('Loading sync details...');
@ -2239,7 +2270,13 @@ async function openSyncDetailModal(entryId) {
else if (t.download_status === 'cancelled') dlIcon = '🚫';
let dlDisplay = dlIcon;
if (!dlDisplay && t.download_status === 'wishlist') dlDisplay = '<span class="sync-dl-wishlist">→ Wishlist</span>';
if (!dlDisplay && t.download_status === 'wishlist') {
// Clickable: re-add this exact track to the wishlist with the
// same context the sync originally used.
dlDisplay = `<button type="button" class="sync-dl-wishlist sync-dl-wishlist-btn" `
+ `onclick="_readdSyncWishlist(${entryId}, ${i}, this)" `
+ `title="Re-add to wishlist with the original sync context">&rarr; Wishlist</button>`;
}
return `
<tr class="sync-detail-row ${statusClass}">

@ -58023,6 +58023,21 @@ tr.tag-diff-same {
font-weight: 600;
white-space: nowrap;
}
/* clickable variant — re-add to wishlist */
.sync-dl-wishlist-btn {
border: 1px solid rgba(255, 183, 77, 0.35);
background: rgba(255, 183, 77, 0.1);
border-radius: 999px;
padding: 3px 9px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.sync-dl-wishlist-btn:hover { background: rgba(255, 183, 77, 0.22); border-color: rgba(255, 183, 77, 0.6); color: #ffce8a; }
.sync-dl-wishlist-btn.is-busy { opacity: 0.6; cursor: default; }
.sync-dl-wishlist-btn.is-done {
color: rgba(76, 175, 80, 0.95); border-color: rgba(76, 175, 80, 0.4);
background: rgba(76, 175, 80, 0.12); cursor: default;
}
.sync-detail-track {
font-weight: 500;

Loading…
Cancel
Save