mirror of https://github.com/Nezreka/SoulSync.git
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
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
|
||||
Loading…
Reference in new issue