diff --git a/core/sync/wishlist_readd.py b/core/sync/wishlist_readd.py new file mode 100644 index 00000000..08fdf90f --- /dev/null +++ b/core/sync/wishlist_readd.py @@ -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"] diff --git a/tests/test_sync_wishlist_readd.py b/tests/test_sync_wishlist_readd.py new file mode 100644 index 00000000..6e048234 --- /dev/null +++ b/tests/test_sync_wishlist_readd.py @@ -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 diff --git a/web_server.py b/web_server.py index 594df17a..c58a5266 100644 --- a/web_server.py +++ b/web_server.py @@ -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//track//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/', methods=['DELETE']) def delete_sync_history_entry_api(entry_id): """Delete a sync history entry.""" diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 143f656a..2a02993a 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -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 ? '✓ Re-added' : '✓ 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 = '→ Wishlist'; + if (!dlDisplay && t.download_status === 'wishlist') { + // Clickable: re-add this exact track to the wishlist with the + // same context the sync originally used. + dlDisplay = ``; + } return ` diff --git a/webui/static/style.css b/webui/static/style.css index 24fe21f1..abe7827b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;