From 4c47c010769a3ec3c506217786077c5e98cb0400 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 08:43:52 -0700 Subject: [PATCH 01/14] #922: import search labelled Spotify Free users' primary source as 'Deezer' A Spotify Free (no-auth) user saw 'Showing Discogs results - not from your primary source (Deezer)' on the manual album-import search. Root cause: get_primary_source() deliberately downgrades an unauthenticated Spotify to the working fallback (deezer) so client routing always yields a usable client - and the import payload reused that FUNCTIONAL value for the LABEL. The free source has no album-name search (SpotifyFreeMetadataClient.search_albums() returns []), so falling back for results is correct; only the label was wrong. Fix: get_primary_source_label() preserves the user's configured intent (Spotify Free reads as 'spotify') without touching client routing or the search chain. The import album/track/suggestions payloads now return the label; the functional source still drives the hydrabase-enqueue + fallback chain. Banner now reads 'not from your primary source (Spotify)'. Tests: seam tests for get_primary_source_label + route regression pinning the label/functional decoupling; updated 4 existing import-route tests. --- core/imports/routes.py | 12 ++- core/imports/staging.py | 6 ++ core/metadata/registry.py | 18 +++++ core/metadata_service.py | 2 + tests/imports/test_import_routes.py | 17 ++-- tests/test_import_primary_source_label.py | 94 +++++++++++++++++++++++ 6 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 tests/test_import_primary_source_label.py diff --git a/core/imports/routes.py b/core/imports/routes.py index c5f49943..39674bba 100644 --- a/core/imports/routes.py +++ b/core/imports/routes.py @@ -16,6 +16,7 @@ from core.imports.staging import ( AUDIO_EXTENSIONS, get_import_suggestions_cache, get_primary_source as _get_primary_source, + get_primary_source_label as _get_primary_source_label, get_staging_path as _get_staging_path, read_staging_file_metadata as _read_staging_file_metadata, refresh_import_suggestions_cache as _refresh_import_suggestions_cache, @@ -48,6 +49,7 @@ class ImportRouteRuntime: read_staging_file_metadata: Callable[[str, str], Dict[str, Any]] = _read_staging_file_metadata read_tags: Callable[[str], Any] = _default_read_tags get_primary_source: Callable[[], str] = _get_primary_source + get_primary_source_label: Callable[[], str] = _get_primary_source_label search_import_albums: Callable[..., list] = _search_import_albums search_import_tracks: Callable[..., list] = _search_import_tracks build_album_import_match_payload: Callable[..., Dict[str, Any]] = build_album_import_match_payload @@ -222,7 +224,7 @@ def staging_suggestions() -> tuple[Dict[str, Any], int]: "success": True, "suggestions": cache["suggestions"], "ready": cache["built"], - "primary_source": _get_primary_source(), + "primary_source": _get_primary_source_label(), }, 200 @@ -239,7 +241,10 @@ def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> t runtime.hydrabase_worker.enqueue(query, "albums") albums = runtime.search_import_albums(query, limit=limit) - return {"success": True, "albums": albums, "primary_source": primary_source}, 200 + # The label names the user's CONFIGURED source (Spotify Free reads as + # 'spotify', not the deezer fallback the functional source downgrades to). + return {"success": True, "albums": albums, + "primary_source": runtime.get_primary_source_label()}, 200 except Exception as exc: runtime.logger.error("Error searching albums for import: %s", exc) return {"success": False, "error": str(exc)}, 500 @@ -385,7 +390,8 @@ def search_tracks(runtime: ImportRouteRuntime, query: str, limit: int = 10) -> t runtime.hydrabase_worker.enqueue(query, "tracks") tracks = runtime.search_import_tracks(query, limit=limit) - return {"success": True, "tracks": tracks, "primary_source": primary_source}, 200 + return {"success": True, "tracks": tracks, + "primary_source": runtime.get_primary_source_label()}, 200 except Exception as exc: runtime.logger.error("Error searching tracks for import: %s", exc) return {"success": False, "error": str(exc)}, 500 diff --git a/core/imports/staging.py b/core/imports/staging.py index 76a42beb..ec124058 100644 --- a/core/imports/staging.py +++ b/core/imports/staging.py @@ -56,6 +56,12 @@ def get_primary_source() -> str: return _get_primary_source() +def get_primary_source_label() -> str: + from core.metadata_service import get_primary_source_label as _get_primary_source_label + + return _get_primary_source_label() + + def get_source_priority(preferred_source: str): from core.metadata_service import get_source_priority as _get_source_priority diff --git a/core/metadata/registry.py b/core/metadata/registry.py index 0d6035e7..a6c6ea39 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -368,6 +368,24 @@ def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = return source +def get_primary_source_label() -> str: + """Configured primary source for UI that *names* "your primary source" (the + import-search fallback banner, etc.). + + Identical to ``get_primary_source()`` except it does NOT downgrade a no-auth + Spotify Free user to the working fallback: Spotify Free (fallback_source= + 'spotify' + metadata.spotify_free) is reported as 'spotify', because that IS + their configured source — even though free-text album search itself has no + free-path implementation and legitimately falls back to another provider. + ``get_primary_source()`` keeps the downgrade so client routing always yields a + usable client; labels want the user's actual intent, not the fallback.""" + _default = METADATA_SOURCE_PRIORITY[0] + source = _get_config_value("metadata.fallback_source", _default) or _default + if source == "spotify" and _get_config_value("metadata.spotify_free", False): + return "spotify" + return get_primary_source() + + def get_spotify_disconnect_source(configured_source: Optional[str] = None) -> str: """Return the active metadata source after Spotify is disconnected.""" _default = METADATA_SOURCE_PRIORITY[0] diff --git a/core/metadata_service.py b/core/metadata_service.py index a0bdadc1..7c390746 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -51,6 +51,7 @@ from core.metadata.registry import ( get_itunes_client, get_primary_client, get_primary_source, + get_primary_source_label, get_spotify_client_for_profile, get_registered_runtime_client, get_source_priority, @@ -117,6 +118,7 @@ __all__ = [ "get_musicmap_similar_artists", "get_primary_client", "get_primary_source", + "get_primary_source_label", "get_spotify_client_for_profile", "get_registered_runtime_client", "get_spotify_client", diff --git a/tests/imports/test_import_routes.py b/tests/imports/test_import_routes.py index 2e1b080d..34409e9c 100644 --- a/tests/imports/test_import_routes.py +++ b/tests/imports/test_import_routes.py @@ -207,7 +207,7 @@ def test_staging_suggestions_returns_cache_payload(monkeypatch): "get_import_suggestions_cache", lambda: {"suggestions": [{"album": "Album"}], "built": True}, ) - monkeypatch.setattr(import_routes, "_get_primary_source", lambda: "deezer") + monkeypatch.setattr(import_routes, "_get_primary_source_label", lambda: "deezer") payload, status = staging_suggestions() @@ -296,6 +296,7 @@ def test_search_albums_enqueues_hydrabase_and_caps_limit(): calls = [] runtime = ImportRouteRuntime( get_primary_source=lambda: "hydrabase", + get_primary_source_label=lambda: "hydrabase", hydrabase_worker=worker, dev_mode_enabled=True, search_import_albums=lambda query, limit: calls.append((query, limit)) or [{"id": "album-1"}], @@ -327,10 +328,15 @@ def test_search_albums_exposes_primary_source_when_chain_falls_back(): # serves results from a different source, the response must carry both # `primary_source` (what the user configured) and per-album `source` # (what actually served the result) so the UI can warn the user. + # + # The configured source for the BANNER is the label, NOT the functional + # source (issue #922): a Spotify Free user's functional source downgrades to + # the deezer fallback, but the banner must still name what they configured. runtime = ImportRouteRuntime( - get_primary_source=lambda: "musicbrainz", + get_primary_source=lambda: "deezer", # functional (downgraded fallback) + get_primary_source_label=lambda: "spotify", # configured intent (Spotify Free) search_import_albums=lambda query, limit: [ - {"id": "deezer-1", "name": "Album", "source": "deezer"}, + {"id": "discogs-1", "name": "Album", "source": "discogs"}, ], logger=_FakeLogger(), ) @@ -339,8 +345,8 @@ def test_search_albums_exposes_primary_source_when_chain_falls_back(): assert status == 200 assert payload["success"] is True - assert payload["primary_source"] == "musicbrainz" - assert payload["albums"][0]["source"] == "deezer" + assert payload["primary_source"] == "spotify" # label, not the deezer fallback + assert payload["albums"][0]["source"] == "discogs" def test_search_tracks_enqueues_hydrabase_and_caps_limit(): @@ -348,6 +354,7 @@ def test_search_tracks_enqueues_hydrabase_and_caps_limit(): calls = [] runtime = ImportRouteRuntime( get_primary_source=lambda: "hydrabase", + get_primary_source_label=lambda: "hydrabase", hydrabase_worker=worker, dev_mode_enabled=True, search_import_tracks=lambda query, limit: calls.append((query, limit)) or [{"id": "track-1"}], diff --git a/tests/test_import_primary_source_label.py b/tests/test_import_primary_source_label.py new file mode 100644 index 00000000..083b311a --- /dev/null +++ b/tests/test_import_primary_source_label.py @@ -0,0 +1,94 @@ +"""The import-search 'primary source' label must name the user's CONFIGURED source. + +Bug #922: a Spotify Free (no-auth) user saw "Showing Discogs results - not from your +primary source (Deezer)" on the manual album-import search. Root cause: get_primary_source() +deliberately downgrades an unauthenticated Spotify to the working fallback (deezer) so +client routing always yields a usable client — and the import payload reused that +FUNCTIONAL value for the LABEL. The free source has no album-name search (SpotifyFree +.search_albums() returns []), so falling back for results is correct; only the label was +wrong. get_primary_source_label() preserves the configured intent (Spotify Free reads as +'spotify') without touching client routing, and the import route returns the label. +""" + +from __future__ import annotations + +import core.metadata.registry as registry +from core.imports.routes import ImportRouteRuntime, search_albums + + +class _AuthedSpotify: + def is_spotify_authenticated(self): + return True + + +class _UnauthedSpotify: + """No-auth Spotify (free tier): officially unauthenticated.""" + + def is_spotify_authenticated(self): + return False + + +def _patch_cfg(monkeypatch, cfg, *, client=None): + monkeypatch.setattr(registry, "_get_config_value", lambda k, d=None: cfg.get(k, d)) + monkeypatch.setattr(registry, "get_spotify_client", lambda client_factory=None: client) + + +# --- get_primary_source_label seam ------------------------------------------- + +def test_label_spotify_free_reads_as_spotify(monkeypatch): + """THE FIX: no-auth Spotify Free is labelled 'spotify', not the deezer fallback.""" + _patch_cfg( + monkeypatch, + {"metadata.fallback_source": "spotify", "metadata.spotify_free": True}, + client=_UnauthedSpotify(), + ) + assert registry.get_primary_source_label() == "spotify" + + +def test_label_spotify_authed_reads_as_spotify(monkeypatch): + _patch_cfg( + monkeypatch, + {"metadata.fallback_source": "spotify", "metadata.spotify_free": False}, + client=_AuthedSpotify(), + ) + assert registry.get_primary_source_label() == "spotify" + + +def test_label_spotify_unauthed_no_free_downgrades(monkeypatch): + """Spotify configured but neither authed nor free → genuinely on the fallback, + so the label honestly reports the working default (not a misleading 'spotify').""" + _patch_cfg( + monkeypatch, + {"metadata.fallback_source": "spotify", "metadata.spotify_free": False}, + client=_UnauthedSpotify(), + ) + label = registry.get_primary_source_label() + assert label == registry.METADATA_SOURCE_PRIORITY[0] # deezer default + assert label != "spotify" + + +def test_label_non_spotify_source_unchanged(monkeypatch): + _patch_cfg( + monkeypatch, + {"metadata.fallback_source": "deezer", "metadata.spotify_free": False}, + ) + assert registry.get_primary_source_label() == "deezer" + + +# --- import route regression: label decoupled from functional source ---------- + +def test_search_albums_payload_uses_label_not_functional_source(): + """REGRESSION (#922): the payload's primary_source is the LABEL ('spotify'), + even though the functional source the search chain used downgraded to 'deezer'.""" + runtime = ImportRouteRuntime( + get_primary_source=lambda: "deezer", # functional (downgraded) + get_primary_source_label=lambda: "spotify", # configured intent + search_import_albums=lambda q, limit=12: [{"name": "X", "source": "discogs"}], + hydrabase_worker=None, + dev_mode_enabled=False, + ) + payload, status = search_albums(runtime, "some album") + assert status == 200 + assert payload["primary_source"] == "spotify" + # The functional source is still free to differ (the chain genuinely used a fallback). + assert runtime.get_primary_source() == "deezer" From 0f2d6ddb636386733a0a318552d7d061a118d59d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 08:57:54 -0700 Subject: [PATCH 02/14] gitignore: ignore ALL database/*.db (+ -shm/-wal/backup), not just music_library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit video_library.db wasn't covered, so it showed up as untracked and could be committed by accident (live user data). Generalize the database patterns to a glob so every current/future app DB is auto-ignored. database/*.py sources are unaffected. (config/youtube_cookies.txt was already ignored — it only slipped through earlier because it had been force-staged.) --- .gitignore | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 6e78b278..ac448636 100644 --- a/.gitignore +++ b/.gitignore @@ -13,10 +13,11 @@ __pycache__/ # User-specific files (auto-created by the app if missing) config/config.json config/youtube_cookies.txt -database/music_library.db -database/music_library.db-shm -database/music_library.db-wal -database/music_library.db.backup_* +# All app databases are live user data — never commit (music_library, video_library, …) +database/*.db +database/*.db-shm +database/*.db-wal +database/*.db.backup_* database/api_call_history.json storage/image_cache/ logs/*.log From 462a722cce36bff2423f4ebbef3364374aa75ea1 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 09:37:14 -0700 Subject: [PATCH 03/14] #918 follow-up: self-heal stale truncated iTunes album-tracks cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The limit=200 fix only helped FRESH fetches. The metadata cache is persistent (SQLite, 30-day TTL), so any album whose tracklist was cached at 50 BEFORE the fix keeps returning 50 from cache in every window that loads it (line returned the cached entry without revalidating) — which is why the Standard-view add-album modal still showed 50 while a freshly-fetched album in the download window showed the full list. Same album, different cache state. Fix: get_album_tracks now marks freshly-fetched entries '_complete'. On a cache hit, a legacy entry without that flag is revalidated against the album's known trackCount (from collection metadata, unaffected by the bug) and re-fetched if short. The '_complete' flag makes the heal one-time and avoids a re-fetch loop on region-restricted albums where available tracks < trackCount. Tests: stale-truncated -> refetch+heal; _complete -> trusted; legacy-complete and unknown-trackCount -> trusted (no regression). Fresh fetch carries _complete. --- core/itunes_client.py | 24 +++++- tests/test_itunes_album_tracks_limit.py | 110 ++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/core/itunes_client.py b/core/itunes_client.py index 0d34b6db..cdf54f5b 100644 --- a/core/itunes_client.py +++ b/core/itunes_client.py @@ -739,7 +739,23 @@ class iTunesClient: cache = get_metadata_cache() cached = cache.get_entity('itunes', 'album', f"{album_id}_tracks") if cached and cached.get('items'): - return cached + # #918 follow-up: a tracks entry cached BEFORE the limit=200 fix is truncated + # to 50 and survives in the persistent cache (30-day TTL), so every window that + # loads this album from cache still shows 50 — not just the one path that was + # re-fetched fresh. Self-heal: entries written by the fixed fetch carry + # `_complete`; a legacy entry without it is re-validated against the album's + # known trackCount and re-fetched if it's short. (trackCount comes from the + # collection metadata and is unaffected by the tracks-limit bug.) + if cached.get('_complete'): + return cached + album_meta = cache.get_entity('itunes', 'album', str(album_id)) + expected = (album_meta or {}).get('trackCount') + if not (isinstance(expected, int) and expected > len(cached['items'])): + return cached + logger.info( + "iTunes album %s tracks cache looks truncated (%d cached < %d trackCount) — refetching", + album_id, len(cached['items']), expected, + ) # #918: the iTunes Lookup API returns only 50 related entities unless `limit` is # passed (max 200), so albums >50 tracks were truncated in the download window. @@ -851,7 +867,11 @@ class iTunesClient: 'items': tracks, 'total': len(tracks), 'limit': len(tracks), - 'next': None + 'next': None, + # Marks this entry as fetched with the limit=200 query (#918) so the + # stale-cache self-heal above trusts it and never re-fetches in a loop — + # important for region-restricted albums where len(tracks) < trackCount. + '_complete': True, } # Cache the album tracks listing diff --git a/tests/test_itunes_album_tracks_limit.py b/tests/test_itunes_album_tracks_limit.py index 1fa36e40..fdb921f9 100644 --- a/tests/test_itunes_album_tracks_limit.py +++ b/tests/test_itunes_album_tracks_limit.py @@ -44,3 +44,113 @@ def test_get_album_tracks_requests_limit_200(monkeypatch): assert captured.get('entity') == 'song' assert captured.get('id') == '123' assert result is not None + assert result.get('_complete') is True # fresh fetch is marked complete + + +# ── #918 follow-up: self-heal a stale truncated cache ───────────────────────── +# The metadata cache is persistent (30-day TTL), so a tracks entry written before +# the limit=200 fix stays truncated at 50 and is served to EVERY window (e.g. the +# Standard-view add-album modal) until it expires. get_album_tracks must detect a +# legacy entry shorter than the album's known trackCount and re-fetch. + +class _StubCache: + """Holds entities so cache hits/stores can be asserted.""" + def __init__(self, entities=None): + self.entities = dict(entities or {}) + self.stored = {} + + def get_entity(self, source, entity_type, entity_id): + return self.entities.get((source, entity_type, entity_id)) + + def store_entity(self, source, entity_type, entity_id, data): + self.stored[(source, entity_type, entity_id)] = data + + def store_entities_bulk(self, *a, **k): + pass + + +def _collection(track_count): + return {'wrapperType': 'collection', 'collectionId': 123, 'collectionName': 'Big OST', + 'artistName': 'Composer', 'trackCount': track_count, 'artworkUrl100': 'http://x/100x100bb.jpg'} + + +def _track(n): + return {'wrapperType': 'track', 'kind': 'song', 'trackId': n, 'trackName': f'T{n}', + 'trackNumber': n, 'discNumber': 1, 'artistName': 'Composer', 'trackTimeMillis': 1000} + + +def _full_results(n): + return [_collection(n)] + [_track(i) for i in range(1, n + 1)] + + +def _items(n): + return [{'id': str(i)} for i in range(n)] + + +def test_stale_truncated_legacy_cache_is_refetched(monkeypatch): + """A 50-item legacy entry (no _complete) for a 70-track album → re-fetch full.""" + client = ic.iTunesClient(country='US') + cache = _StubCache({ + ('itunes', 'album', '123_tracks'): {'items': _items(50), 'total': 50}, # legacy, truncated + ('itunes', 'album', '123'): _collection(70), # real trackCount=70 + }) + monkeypatch.setattr(ic, 'get_metadata_cache', lambda: cache) + calls = {'n': 0} + + def fake_lookup(**params): + calls['n'] += 1 + return _full_results(70) + + monkeypatch.setattr(client, '_lookup', fake_lookup) + + result = client.get_album_tracks('123') + + assert calls['n'] == 1 # re-fetched (didn't trust the stale 50) + assert len(result['items']) == 70 + assert result['_complete'] is True + assert cache.stored[('itunes', 'album', '123_tracks')]['_complete'] is True # healed in cache + + +def test_complete_cache_is_trusted_no_refetch(monkeypatch): + """A _complete entry is returned as-is even if shorter than trackCount + (region-restricted album) — must NOT loop re-fetching.""" + client = ic.iTunesClient(country='US') + complete = {'items': _items(50), 'total': 50, '_complete': True} + cache = _StubCache({ + ('itunes', 'album', '123_tracks'): complete, + ('itunes', 'album', '123'): _collection(70), + }) + monkeypatch.setattr(ic, 'get_metadata_cache', lambda: cache) + + def boom(**params): + raise AssertionError('must not re-fetch a _complete entry') + + monkeypatch.setattr(client, '_lookup', boom) + + assert client.get_album_tracks('123') is complete + + +def test_legacy_complete_cache_not_refetched(monkeypatch): + """A legacy entry whose length already meets trackCount is fine — no re-fetch.""" + client = ic.iTunesClient(country='US') + legacy = {'items': _items(30), 'total': 30} # no _complete, but complete by count + cache = _StubCache({ + ('itunes', 'album', '123_tracks'): legacy, + ('itunes', 'album', '123'): _collection(30), + }) + monkeypatch.setattr(ic, 'get_metadata_cache', lambda: cache) + monkeypatch.setattr(client, '_lookup', lambda **p: (_ for _ in ()).throw(AssertionError('no refetch'))) + + assert client.get_album_tracks('123') is legacy + + +def test_legacy_cache_without_album_meta_is_trusted(monkeypatch): + """trackCount unknown (album meta not cached) → trust the cache, don't re-fetch + (safe fallback; no regression for direct get_album_tracks callers).""" + client = ic.iTunesClient(country='US') + legacy = {'items': _items(50), 'total': 50} # no _complete, no album meta + cache = _StubCache({('itunes', 'album', '123_tracks'): legacy}) + monkeypatch.setattr(ic, 'get_metadata_cache', lambda: cache) + monkeypatch.setattr(client, '_lookup', lambda **p: (_ for _ in ()).throw(AssertionError('no refetch'))) + + assert client.get_album_tracks('123') is legacy From ecd2500c39bda7b556e7b75c79c2b5764c9c856f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 12:15:47 -0700 Subject: [PATCH 04/14] Server playlist editor: surface 'accurate but out of order' + read-only server-order view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor renders the server column in SOURCE order (reconcile_playlist pairs each server track to its source row), so a reordered-but-same-membership playlist read as '5 matched / in sync' when Navidrome's real order actually differed — the reorder never reaching the server was invisible. - compute_order_status() (pure, tested): matched tracks' server positions must be strictly ascending in source order; uses RELATIVE order so missing/extra tracks never false-flag. reconcile entries now carry server_index (additive). - endpoint returns order_status + server_order (the server's actual sequence). - editor shows an amber 'out of order' badge on the server column when membership matches but sequence differs, opening a read-only modal of the real server order. One-way: source order stays the source of truth; no server-side editing. Tests reproduce the reported 'Real Love Baby moved to #2' case + guard against false-flagging on missing/extra. The actual 'sync order' WRITE is a separate follow-up (membership/extra semantics + live identity-preservation test pending). --- core/sync/playlist_reconcile.py | 35 ++++++++++++++++- tests/test_playlist_reconcile.py | 66 +++++++++++++++++++++++++++++++- web_server.py | 19 ++++++++- webui/static/pages-extra.js | 55 +++++++++++++++++++++++++- webui/static/style.css | 47 +++++++++++++++++++++++ 5 files changed, 218 insertions(+), 4 deletions(-) diff --git a/core/sync/playlist_reconcile.py b/core/sync/playlist_reconcile.py index eb402b6d..4cea1b0a 100644 --- a/core/sync/playlist_reconcile.py +++ b/core/sync/playlist_reconcile.py @@ -112,6 +112,7 @@ def reconcile_playlist( 'match_status': 'matched', 'confidence': 1.0, 'override': True, + 'server_index': j, # position in the server playlist (for order-status) }) continue @@ -134,6 +135,7 @@ def reconcile_playlist( 'server_track': server_tracks[best_idx], 'match_status': 'matched', 'confidence': 1.0, + 'server_index': best_idx, }) else: idx = len(combined) @@ -142,6 +144,7 @@ def reconcile_playlist( 'server_track': None, 'match_status': 'missing', 'confidence': 0.0, + 'server_index': None, }) # Carry the canonical artist for the fuzzy pass. unmatched_source.append((idx, src_entry, _canon_artist or src_artist)) @@ -168,6 +171,7 @@ def reconcile_playlist( 'server_track': server_tracks[best_j], 'match_status': 'matched', 'confidence': round(best_score, 3), + 'server_index': best_j, } # Extra: server tracks no source claimed. @@ -178,6 +182,7 @@ def reconcile_playlist( 'server_track': svr, 'match_status': 'extra', 'confidence': 0.0, + 'server_index': j, }) # #766: a source row with no art of its own (e.g. a YouTube source, which @@ -194,4 +199,32 @@ def reconcile_playlist( return combined -__all__ = ["reconcile_playlist", "norm_title"] +def compute_order_status(combined: List[Dict[str, Any]]) -> Dict[str, Any]: + """Whether the server's MATCHED tracks sit in the same *relative* order as the + source. Pure. + + Reads each matched entry's ``server_index`` (its position in the server + playlist) off the combined view — which is already in source order — and checks + that sequence is strictly ascending. Relative order is used on purpose: missing + and extra tracks shift absolute positions, so comparing positions directly would + false-flag any playlist that isn't a perfect 1:1. The model is one-way (source + order is truth; the server may have drifted), so we only ever report whether the + server is *behind* the source order — never the reverse. + + Returns ``{matched, in_order, out_of_order}``. ``out_of_order`` is True only when + there are >= 2 matched tracks AND their server positions aren't ascending — so a + playlist with 0 or 1 matches (nothing to compare) is never flagged. + """ + positions = [ + e['server_index'] for e in combined + if e.get('match_status') == 'matched' and e.get('server_index') is not None + ] + in_order = all(a < b for a, b in zip(positions, positions[1:], strict=False)) + return { + 'matched': len(positions), + 'in_order': in_order, + 'out_of_order': len(positions) >= 2 and not in_order, + } + + +__all__ = ["reconcile_playlist", "compute_order_status", "norm_title"] diff --git a/tests/test_playlist_reconcile.py b/tests/test_playlist_reconcile.py index 0966f74c..2d9f99df 100644 --- a/tests/test_playlist_reconcile.py +++ b/tests/test_playlist_reconcile.py @@ -8,7 +8,7 @@ source_track_id echo (Bug B), and parity with the original three-pass behavior from __future__ import annotations -from core.sync.playlist_reconcile import norm_title, reconcile_playlist +from core.sync.playlist_reconcile import compute_order_status, norm_title, reconcile_playlist def _src(name, artist, sid="", **kw): @@ -160,3 +160,67 @@ def test_norm_title_helper_parity(): assert norm_title("Stay (feat. X)") == "stay" assert norm_title("Song (2019 Remaster)") == "song" assert norm_title("Album (Deluxe Edition)") == "album" + + +# ── order status: server playlist accurate-but-out-of-order detection ───────── +# The editor renders the server column in SOURCE order, so a reordered-but-same- +# membership playlist used to read "in sync" when the real Navidrome order differed. +# compute_order_status surfaces that drift (one-way: source order is truth). + +def test_reconcile_attaches_server_index_to_matched(): + source = [_src("Yellow", "Coldplay", "s1")] + server = [_svr("Filler", "X", "nv0"), _svr("Yellow", "Coldplay", "nv1")] + combined = reconcile_playlist(source, server) + matched = [c for c in combined if c["match_status"] == "matched"][0] + assert matched["server_index"] == 1 # Yellow is at server position 1 + + +def test_in_order_when_server_matches_source_sequence(): + titles = ["Mandinka", "Real Love Baby", "Liquid Indian", "Heaven or Las Vegas", "hospital beach"] + source = [_src(t, "A", f"s{i}") for i, t in enumerate(titles)] + server = [_svr(t, "A", f"nv{i}") for i, t in enumerate(titles)] # same order + status = compute_order_status(reconcile_playlist(source, server)) + assert status == {"matched": 5, "in_order": True, "out_of_order": False} + + +def test_out_of_order_reproduces_real_love_baby_case(): + # Source (Spotify): Real Love Baby at position 2. Server (Navidrome): still last. + src_titles = ["Mandinka", "Real Love Baby", "Liquid Indian", "Heaven or Las Vegas", "hospital beach"] + svr_titles = ["Mandinka", "Liquid Indian", "Heaven or Las Vegas", "hospital beach", "Real Love Baby"] + source = [_src(t, "A", f"s{i}") for i, t in enumerate(src_titles)] + server = [_svr(t, "A", f"nv{i}") for i, t in enumerate(svr_titles)] + status = compute_order_status(reconcile_playlist(source, server)) + assert status["matched"] == 5 + assert status["out_of_order"] is True # the bug: looked synced, wasn't + + +def test_missing_tracks_do_not_false_flag_out_of_order(): + # 2 tracks missing on the server, but the present ones are in the right relative + # order -> NOT out of order (membership is a separate axis). + source = [_src(t, "A", f"s{i}") for i, t in enumerate(["one", "two", "three", "four"])] + server = [_svr("one", "A", "nv0"), _svr("three", "A", "nv1")] # two/four missing, order ok + status = compute_order_status(reconcile_playlist(source, server)) + assert status["matched"] == 2 + assert status["out_of_order"] is False + + +def test_missing_and_shuffled_still_flags_out_of_order(): + source = [_src(t, "A", f"s{i}") for i, t in enumerate(["one", "two", "three", "four"])] + server = [_svr("three", "A", "nv0"), _svr("one", "A", "nv1")] # present pair is reversed + status = compute_order_status(reconcile_playlist(source, server)) + assert status["matched"] == 2 + assert status["out_of_order"] is True + + +def test_extras_ignored_for_order(): + # An extra server track (not in source) must not affect the order verdict. + source = [_src("a", "A", "s0"), _src("b", "A", "s1")] + server = [_svr("a", "A", "nv0"), _svr("zzz extra", "A", "nv1"), _svr("b", "A", "nv2")] + status = compute_order_status(reconcile_playlist(source, server)) + assert status["matched"] == 2 and status["out_of_order"] is False + + +def test_fewer_than_two_matches_never_out_of_order(): + assert compute_order_status([])["out_of_order"] is False + one = reconcile_playlist([_src("a", "A", "s0")], [_svr("a", "A", "nv0")]) + assert compute_order_status(one)["out_of_order"] is False diff --git a/web_server.py b/web_server.py index a282de9a..d6c6b991 100644 --- a/web_server.py +++ b/web_server.py @@ -19697,7 +19697,7 @@ def get_server_playlist_tracks(playlist_id): # core.sync.playlist_reconcile (pure + tested) — fixes #768 (YouTube # "Artist - Title" sources now match, and source_track_id is echoed # back so manual "Find & add" overrides persist). - from core.sync.playlist_reconcile import reconcile_playlist + from core.sync.playlist_reconcile import compute_order_status, reconcile_playlist # Pass 0: User-confirmed match overrides from sync_match_cache. # When a user previously picked a local file via "Find & Add", @@ -19729,6 +19729,21 @@ def get_server_playlist_tracks(playlist_id): combined = reconcile_playlist(source_tracks, server_tracks, _override_pairs) + # Order status: the editor renders the server column in SOURCE order, so a + # reordered-but-same-membership playlist reads "in sync" when Navidrome's real + # order differs. Surface that (one-way: source order is truth). `server_order` + # is the server's ACTUAL sequence, for the read-only "view server order" view. + order_status = compute_order_status(combined) + server_order = [ + { + "title": t.get("title"), + "artist": t.get("artist"), + "thumb": t.get("thumb"), + "id": t.get("id"), + } + for t in server_tracks if isinstance(t, dict) + ] + return jsonify({ "success": True, "server_type": active_server, @@ -19736,6 +19751,8 @@ def get_server_playlist_tracks(playlist_id): "tracks": combined, "server_track_count": len(server_tracks), "source_track_count": len(source_tracks), + "order_status": order_status, + "server_order": server_order, }) except Exception as e: logger.error(f"Error getting server playlist tracks: {e}") diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 4e2633a9..6db43b79 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -1424,6 +1424,11 @@ async function _openServerCompareView(playlistId, playlistName, mirroredPlaylist _serverEditorState.tracks = data.tracks || []; _serverEditorState.serverType = data.server_type; + // Order status: the columns render in SOURCE order, so a same-tracks-but- + // reordered server playlist looks in-sync. order_status flags that drift; + // server_order is the server's ACTUAL sequence for the read-only view. + _serverEditorState.orderStatus = data.order_status || null; + _serverEditorState.serverOrder = data.server_order || []; const tracks = _serverEditorState.tracks; const serverLabel = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : 'Server'; @@ -1456,7 +1461,19 @@ async function _openServerCompareView(playlistId, playlistName, mirroredPlaylist if (srcCountEl) srcCountEl.textContent = `${data.source_track_count || 0} tracks`; if (svrIconEl) svrIconEl.textContent = serverIconMap[data.server_type] || '💻'; if (svrLabelEl) svrLabelEl.textContent = serverLabel; - if (svrCountEl) svrCountEl.textContent = `${data.server_track_count || 0} tracks`; + if (svrCountEl) { + const os = _serverEditorState.orderStatus; + if (os && os.out_of_order) { + // Accurate membership but different order than the source. Read-only: + // source order is the source of truth; the badge opens the real order. + svrCountEl.innerHTML = `${data.server_track_count || 0} tracks ` + + ``; + } else { + svrCountEl.textContent = `${data.server_track_count || 0} tracks`; + } + } // Render columns _renderCompareColumns(tracks); @@ -1498,6 +1515,42 @@ function _updateCompareStats(tracks) { if (footer) footer.textContent = `${matched}/${matched + missing} matched${extra > 0 ? ` · ${extra} extra on server` : ''}`; } +// Read-only view of the server playlist's ACTUAL order. Source order is the source +// of truth; this just lets the user SEE how the server currently differs (no editing). +function _showServerOrder() { + const esc = typeof _esc === 'function' ? _esc : s => s; + const order = (_serverEditorState && _serverEditorState.serverOrder) || []; + const serverType = (_serverEditorState && _serverEditorState.serverType) || 'server'; + const serverLabel = serverType.charAt(0).toUpperCase() + serverType.slice(1); + + document.getElementById('server-order-modal')?.remove(); + const rows = order.map((t, i) => ` +
+ ${i + 1} +
+ ${esc(t.title || 'Unknown')} + ${esc(t.artist || '')} +
+
`).join(''); + + const overlay = document.createElement('div'); + overlay.id = 'server-order-modal'; + overlay.className = 'server-order-overlay'; + overlay.innerHTML = ` +
+
+
+
${esc(serverLabel)} playlist order
+
the actual order on your server · source order stays the source of truth
+
+ +
+
${rows || '
No server tracks.
'}
+
`; + overlay.onclick = () => overlay.remove(); + document.body.appendChild(overlay); +} + function _formatDurationMs(ms) { if (!ms) return ''; const s = Math.round(ms / 1000); diff --git a/webui/static/style.css b/webui/static/style.css index 47a0e66c..bda1c974 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -58561,6 +58561,53 @@ tr.tag-diff-same { .server-editor-stat-num.missing { color: #ef5350; } .server-editor-stat-num.extra { color: #ffb74d; } +/* "out of order" badge — server playlist has the right tracks but a different + sequence than the source. Opens a read-only view of the real server order. */ +.server-order-badge { + margin-left: 8px; padding: 2px 9px; border-radius: 999px; cursor: pointer; + font-size: 11px; font-weight: 700; letter-spacing: 0.2px; white-space: nowrap; + color: #ffb74d; background: rgba(255, 183, 77, 0.12); + border: 1px solid rgba(255, 183, 77, 0.4); + transition: background 0.15s ease; +} +.server-order-badge:hover { background: rgba(255, 183, 77, 0.22); } + +/* read-only server-order modal */ +.server-order-overlay { + position: fixed; inset: 0; z-index: 10000; display: flex; + align-items: center; justify-content: center; padding: 24px; + background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(3px); +} +.server-order-dialog { + width: min(440px, 100%); max-height: 80vh; display: flex; flex-direction: column; + background: #1a1c22; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 14px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6); overflow: hidden; +} +.server-order-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; + padding: 16px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} +.server-order-h1 { font-size: 15px; font-weight: 800; color: #fff; } +.server-order-sub { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); margin-top: 3px; } +.server-order-close { + background: none; border: none; color: rgba(255, 255, 255, 0.5); font-size: 22px; + line-height: 1; cursor: pointer; padding: 0 4px; +} +.server-order-close:hover { color: #fff; } +.server-order-list { overflow-y: auto; padding: 8px 8px 12px; } +.server-order-row { + display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 8px; +} +.server-order-row:nth-child(odd) { background: rgba(255, 255, 255, 0.02); } +.server-order-num { + flex: 0 0 24px; text-align: right; font-size: 12px; font-weight: 700; + color: rgba(255, 255, 255, 0.35); +} +.server-order-meta { min-width: 0; display: flex; flex-direction: column; } +.server-order-title { font-size: 13px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.server-order-artist { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.server-order-empty { padding: 20px; text-align: center; color: rgba(255, 255, 255, 0.4); font-size: 13px; } + .server-editor-stat-label { font-size: 9px; text-transform: uppercase; From 606d1f951de673dad05d517bbf8673cfb30c6e28 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 12:58:01 -0700 Subject: [PATCH 05/14] Align playlists: reorder a server playlist to the source order (Navidrome) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 'Align playlists' action to the out-of-order modal — a dedicated, order-only write path that does NOT touch the normal sync. Subsonic has no per-track move, so it overwrites the song list in source order via createPlaylist + playlistId (same primitive replace-mode uses; identity/id preserved). - plan_align_rewrite() (pure, tested): matched server ids in source order; every one must already be in the playlist (never injects a track); extras either dropped ('Mirror source') or parked at the end ('Keep extras'); returns None on stale data so a vanished track can't be written. - navidrome rewrite_playlist_order() primitive (raw ordered ids). - /api/server/playlist//align: validates ids are in the live playlist, then rewrites. Navidrome-only for now (Plex/Jellyfin reorder = follow-up). - modal gets two explained options; missing tracks are NOT added (normal sync's job) and that's stated. Metadata-free by design — it only reshuffles existing server ids, so there's no sync-parity surface. Open: confirm createPlaylist+playlistId preserves the playlist comment/image on a live Navidrome (same risk as replace mode); add a re-apply step if it doesn't. --- core/navidrome_client.py | 28 +++++++++++++++++++ core/sync/playlist_edit.py | 31 ++++++++++++++++++++ tests/test_playlist_edit.py | 43 ++++++++++++++++++++++++++++ web_server.py | 45 +++++++++++++++++++++++++++++ webui/static/pages-extra.js | 56 +++++++++++++++++++++++++++++++++++++ webui/static/style.css | 14 ++++++++++ 6 files changed, 217 insertions(+) diff --git a/core/navidrome_client.py b/core/navidrome_client.py index e042788a..98536950 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -1022,6 +1022,34 @@ class NavidromeClient(MediaServerClient): logger.error(f"Error {'updating' if playlist_id else 'creating'} Navidrome playlist '{name}': {e}") return False + def rewrite_playlist_order(self, playlist_id: str, name: str, ordered_song_ids) -> bool: + """Rewrite a playlist's tracks to an exact ordered id list (Subsonic has no + per-track move — the only reorder primitive is overwriting the whole song + list). Overwrites in place via createPlaylist + playlistId, so the playlist + identity (id/name) survives. Used ONLY by the 'Align playlists' path with + ids already present in the playlist — never adds a new track. + + NOTE: like every createPlaylist+playlistId overwrite (same as replace-mode + sync), Navidrome may not carry the playlist's comment forward — the caller + re-applies it if needed.""" + if not self.ensure_connection(): + return False + ids = [str(i) for i in (ordered_song_ids or []) if str(i)] + if not ids: + logger.warning(f"rewrite_playlist_order: no song ids for '{name}'") + return False + try: + params = {'name': name, 'songId': ids, 'playlistId': playlist_id} + response = self._make_request('createPlaylist', params) + if response and response.get('status') == 'ok': + logger.info(f"Aligned Navidrome playlist '{name}' order ({len(ids)} tracks)") + return True + logger.error(f"rewrite_playlist_order failed for '{name}'") + return False + except Exception as e: + logger.error(f"Error rewriting Navidrome playlist order '{name}': {e}") + return False + def copy_playlist(self, source_name: str, target_name: str) -> bool: """Copy a playlist to create a backup""" if not self.ensure_connection(): diff --git a/core/sync/playlist_edit.py b/core/sync/playlist_edit.py index a3423302..aacb7e7e 100644 --- a/core/sync/playlist_edit.py +++ b/core/sync/playlist_edit.py @@ -137,6 +137,36 @@ def plan_playlist_append( return out +def plan_align_rewrite(current_ids, matched_ids, keep_extras: bool = False): + """Plan the ordered server-track id list to rewrite a playlist as, to align its + ORDER to the source. Pure — no I/O, no metadata. Used only by the "Align + playlists" path (never the normal sync); it reshuffles tracks already on the + server by id and never adds one. + + ``matched_ids`` — server ids of the source-matched tracks, IN SOURCE ORDER + (the desired sequence). Every one MUST already be in the + playlist — align never injects a track that isn't there. + ``current_ids`` — the playlist's current server ids, in current server order. + ``keep_extras`` — True: server tracks not in ``matched_ids`` (extras) are + appended after the aligned block, in their current order. + False: extras are dropped (server mirrors the source). + + Returns the ordered id list, or ``None`` if any matched id isn't currently in + the playlist (stale editor data — the caller should reject and have the user + reload rather than write a list referencing a vanished track). + """ + current = [str(c) for c in current_ids] + current_set = set(current) + matched = [str(m) for m in matched_ids] + if any(m not in current_set for m in matched): + return None + ordered = list(matched) + if keep_extras: + matched_set = set(matched) + ordered.extend(c for c in current if c not in matched_set) + return ordered + + VALID_SYNC_MODES = ("replace", "append", "reconcile") @@ -158,6 +188,7 @@ __all__ = [ "remove_one_occurrence", "plan_playlist_reconcile", "plan_playlist_append", + "plan_align_rewrite", "normalize_sync_mode", "VALID_SYNC_MODES", ] diff --git a/tests/test_playlist_edit.py b/tests/test_playlist_edit.py index 7c402992..8c3fa749 100644 --- a/tests/test_playlist_edit.py +++ b/tests/test_playlist_edit.py @@ -4,12 +4,55 @@ from __future__ import annotations from core.sync.playlist_edit import ( normalize_sync_mode, + plan_align_rewrite, plan_playlist_add, plan_playlist_reconcile, remove_one_occurrence, ) +# ── plan_align_rewrite: "Align playlists" ordered rewrite (order-only) ───────── + +def test_align_mirror_reorders_and_drops_extras(): + # Server: [A, C, B, X(extra)]; source order wants [A, B, C]. Mirror => A,B,C, X dropped. + out = plan_align_rewrite(current_ids=["A", "C", "B", "X"], matched_ids=["A", "B", "C"], keep_extras=False) + assert out == ["A", "B", "C"] + + +def test_align_keep_extras_parks_them_at_end(): + out = plan_align_rewrite(current_ids=["A", "C", "B", "X"], matched_ids=["A", "B", "C"], keep_extras=True) + assert out == ["A", "B", "C", "X"] # X kept, after the aligned block + + +def test_align_keep_extras_preserves_extra_current_order(): + out = plan_align_rewrite(current_ids=["X1", "A", "X2", "B"], matched_ids=["A", "B"], keep_extras=True) + assert out == ["A", "B", "X1", "X2"] # extras in their existing server order + + +def test_align_rejects_when_matched_id_not_in_playlist(): + # Stale editor data: a matched id that's no longer on the server -> None (reject). + assert plan_align_rewrite(current_ids=["A", "B"], matched_ids=["A", "GONE"]) is None + + +def test_align_never_injects_foreign_track(): + # Every output id must already be in the playlist (order-only, never adds). + out = plan_align_rewrite(current_ids=["A", "B", "C"], matched_ids=["C", "A", "B"], keep_extras=True) + assert set(out) <= {"A", "B", "C"} + assert out == ["C", "A", "B"] # pure reorder, full membership + + +def test_align_handles_partial_membership_order_only(): + # Server is missing nothing relevant; matched is a subset (some source tracks + # missing on server). Mirror keeps only the present matched, in source order. + out = plan_align_rewrite(current_ids=["B", "A"], matched_ids=["A", "B"], keep_extras=False) + assert out == ["A", "B"] + + +def test_align_ids_coerced_to_str(): + out = plan_align_rewrite(current_ids=[1, 2, 3], matched_ids=[3, 1], keep_extras=True) + assert out == ["3", "1", "2"] + + # ── plan_playlist_add: link must not duplicate ──────────────────────────── def test_link_to_existing_track_does_not_insert(): diff --git a/web_server.py b/web_server.py index d6c6b991..6855d969 100644 --- a/web_server.py +++ b/web_server.py @@ -19759,6 +19759,51 @@ def get_server_playlist_tracks(playlist_id): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/server/playlist//align', methods=['POST']) +def server_playlist_align(playlist_id): + """Align a server playlist's ORDER to the source ('Align playlists'). + + Order-only and metadata-free: the client sends the matched server-track ids in + SOURCE order (`matched_ids`) plus the extras choice (`keep_extras`); the server + validates every id is currently in the playlist (so this can only reorder/drop + tracks already there — never inject one) and rewrites the playlist in that order + via the overwrite primitive. Does NOT touch membership-completeness — missing + tracks are the normal sync's job. Navidrome only for now.""" + try: + data = request.get_json() or {} + playlist_name = (data.get('playlist_name') or '').strip() + matched_ids = data.get('matched_ids') or [] + keep_extras = bool(data.get('keep_extras', False)) + if not playlist_name: + return jsonify({"success": False, "error": "playlist_name required"}), 400 + if not matched_ids: + return jsonify({"success": False, "error": "no matched tracks to align"}), 400 + + active_server = config_manager.get_active_media_server() + if active_server != 'navidrome' or not media_server_engine.client('navidrome'): + return jsonify({"success": False, + "error": "Align is only supported on Navidrome right now"}), 400 + + client = media_server_engine.client('navidrome') + current_tracks = client.get_playlist_tracks(playlist_id) or [] + current_ids = [str(t.ratingKey) for t in current_tracks if getattr(t, 'ratingKey', None)] + + from core.sync.playlist_edit import plan_align_rewrite + ordered = plan_align_rewrite(current_ids, matched_ids, keep_extras=keep_extras) + if ordered is None: + return jsonify({"success": False, + "error": "Playlist changed on the server — reload and try again"}), 409 + + ok = client.rewrite_playlist_order(playlist_id, playlist_name, ordered) + if not ok: + return jsonify({"success": False, "error": "Failed to rewrite playlist order"}), 500 + return jsonify({"success": True, "track_count": len(ordered), + "kept_extras": keep_extras}) + except Exception as e: + logger.error(f"Error aligning server playlist '{playlist_id}': {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/server/playlist//replace-track', methods=['POST']) def server_playlist_replace_track(playlist_id): """Replace a track in a server playlist. Rebuilds the playlist with the swap.""" diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 6db43b79..9ccc3579 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -1533,6 +1533,25 @@ function _showServerOrder() { `).join(''); + // Align actions (Navidrome only for now) — reorder the server playlist to the + // source order. Two choices for server-only "extra" tracks. Order-only: it never + // adds the missing tracks (that's the normal sync's job). + const alignFoot = serverType === 'navidrome' ? ` +
+
Align this playlist to the source order
+
+ + +
+
Missing tracks aren't added here — run a normal sync for those.
+
` : ''; + const overlay = document.createElement('div'); overlay.id = 'server-order-modal'; overlay.className = 'server-order-overlay'; @@ -1546,11 +1565,48 @@ function _showServerOrder() {
${rows || '
No server tracks.
'}
+ ${alignFoot} `; overlay.onclick = () => overlay.remove(); document.body.appendChild(overlay); } +// Align the server playlist's ORDER to the source (the "Align playlists" action). +// Sends the matched server-track ids in SOURCE order + the extras choice; the +// backend validates they're all in the playlist and rewrites. Order-only. +async function _alignPlaylist(keepExtras) { + const st = _serverEditorState; + if (!st || !st.playlistId) return; + const matchedIds = (Array.isArray(st.tracks) ? st.tracks : []) + .filter(t => t.match_status === 'matched' && t.server_track && t.server_track.id != null) + .map(t => String(t.server_track.id)); + if (!matchedIds.length) { + if (typeof showToast === 'function') showToast('Nothing to align', 'warning'); + return; + } + try { + const resp = await fetch(`/api/server/playlist/${st.playlistId}/align`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_name: st.playlistName || '', + matched_ids: matchedIds, + keep_extras: !!keepExtras, + }), + }); + const data = await resp.json(); + if (data && data.success) { + if (typeof showToast === 'function') showToast(`Playlist order aligned (${data.track_count} tracks)`, 'success'); + document.getElementById('server-order-modal')?.remove(); + _serverEditorRefresh(); + } else { + if (typeof showToast === 'function') showToast((data && data.error) || 'Align failed', 'error'); + } + } catch (e) { + if (typeof showToast === 'function') showToast('Align failed: ' + e.message, 'error'); + } +} + function _formatDurationMs(ms) { if (!ms) return ''; const s = Math.round(ms / 1000); diff --git a/webui/static/style.css b/webui/static/style.css index bda1c974..3aeefc48 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -58607,6 +58607,20 @@ tr.tag-diff-same { .server-order-title { font-size: 13px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .server-order-artist { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .server-order-empty { padding: 20px; text-align: center; color: rgba(255, 255, 255, 0.4); font-size: 13px; } +.server-order-foot { padding: 14px 16px 16px; border-top: 1px solid rgba(255, 255, 255, 0.07); } +.server-order-foot-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; + color: rgba(255, 255, 255, 0.5); margin-bottom: 10px; } +.server-order-actions { display: flex; gap: 10px; } +.server-align-btn { + flex: 1; display: flex; flex-direction: column; gap: 3px; text-align: left; + padding: 10px 12px; border-radius: 10px; cursor: pointer; + background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.12); + transition: background 0.15s ease, border-color 0.15s ease; +} +.server-align-btn:hover { background: rgba(76, 175, 80, 0.14); border-color: rgba(76, 175, 80, 0.5); } +.server-align-btn-t { font-size: 13px; font-weight: 700; color: #fff; } +.server-align-btn-d { font-size: 11px; color: rgba(255, 255, 255, 0.5); line-height: 1.35; } +.server-order-foot-note { font-size: 11px; color: rgba(255, 255, 255, 0.4); margin-top: 9px; } .server-editor-stat-label { font-size: 9px; From 8afbfbfeabc60b0ab39efb06de9c3a7fe41173d7 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 13:03:10 -0700 Subject: [PATCH 06/14] Align modal: pin footer so the Align buttons aren't clipped on long playlists The server-order list wasn't flex-shrinking, so a long tracklist pushed the align footer past the dialog's 80vh cap and overflow:hidden clipped it. Make the list flex:1/min-height:0 (scrolls) and the footer flex:0 0 auto (always visible). --- webui/static/style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webui/static/style.css b/webui/static/style.css index 3aeefc48..0f63c94a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -58594,7 +58594,7 @@ tr.tag-diff-same { line-height: 1; cursor: pointer; padding: 0 4px; } .server-order-close:hover { color: #fff; } -.server-order-list { overflow-y: auto; padding: 8px 8px 12px; } +.server-order-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 8px 8px 12px; } .server-order-row { display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 8px; } @@ -58607,7 +58607,7 @@ tr.tag-diff-same { .server-order-title { font-size: 13px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .server-order-artist { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .server-order-empty { padding: 20px; text-align: center; color: rgba(255, 255, 255, 0.4); font-size: 13px; } -.server-order-foot { padding: 14px 16px 16px; border-top: 1px solid rgba(255, 255, 255, 0.07); } +.server-order-foot { flex: 0 0 auto; padding: 14px 16px 16px; border-top: 1px solid rgba(255, 255, 255, 0.07); } .server-order-foot-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; color: rgba(255, 255, 255, 0.5); margin-bottom: 10px; } .server-order-actions { display: flex; gap: 10px; } From bac5da917786779bef58b4e2c801830f30441ebd Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 13:12:09 -0700 Subject: [PATCH 07/14] Align playlists: add Plex support + cover art + modal redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The align buttons were gated to Navidrome, so Plex users (the actual tester) never saw them. Plex reorders in place via plexapi moveItem/removeItems — preserves the playlist's poster/summary/ratingKey (no delete-recreate), same spirit as Navidrome's overwrite. - plex_client.reorder_playlist(): moves each desired track into sequence, removes any current item not in the ordered list (Mirror drops extras; Keep includes them). get_playlist_track_ids() feeds the shared tested plan_align_rewrite. - /align endpoint dispatches navidrome + plex; reuses the pure planner for both. - frontend gate opened to navidrome|plex. - modal redesigned: cover art per row, gradient header, pop/fade animation, hover rows, real polish (was a plain numbered list). plexapi moveItem/removeItems signatures verified against the installed version. --- core/plex_client.py | 82 +++++++++++++++++++++++++++++++++++++ web_server.py | 21 ++++++---- webui/static/pages-extra.js | 19 ++++++--- webui/static/style.css | 49 ++++++++++++++-------- 4 files changed, 141 insertions(+), 30 deletions(-) diff --git a/core/plex_client.py b/core/plex_client.py index 29310ab9..39c81b8a 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -714,6 +714,88 @@ class PlexClient(MediaServerClient): logger.error(f"Error reconciling Plex playlist '{playlist_name}': {e}") return False + def get_playlist_track_ids(self, playlist_id, playlist_name: str = "") -> List[str]: + """The playlist's current track ratingKeys, in current order. [] if missing.""" + if not self.ensure_connection(): + return [] + try: + playlist = None + try: + playlist = self.server.fetchItem(int(playlist_id)) + except Exception: + playlist = None + if playlist is None and playlist_name: + try: + playlist = self.server.playlist(playlist_name) + except Exception: + playlist = None + if playlist is None: + return [] + return [str(i.ratingKey) for i in playlist.items() if hasattr(i, 'ratingKey')] + except Exception as e: + logger.error(f"Error getting Plex playlist track ids '{playlist_name}': {e}") + return [] + + def reorder_playlist(self, playlist_id, playlist_name: str, ordered_ids) -> bool: + """In-place reorder a playlist to an exact ordered ratingKey list ('Align + playlists'). Moves items into sequence and removes any current item NOT in + ``ordered_ids`` (so 'Mirror source' drops extras; 'Keep extras' includes + them in the list). Uses moveItem/removeItems so the playlist's poster, + summary and ratingKey survive — never deletes/recreates. Order-only: every + id in ``ordered_ids`` is already in the playlist (the caller validated).""" + if not self.ensure_connection(): + return False + try: + playlist = None + try: + playlist = self.server.fetchItem(int(playlist_id)) + except Exception as e: + logger.debug("Plex reorder fetchItem failed: %s", e) + if playlist is None and playlist_name: + try: + playlist = self.server.playlist(playlist_name) + except Exception as e: + logger.debug("Plex reorder by-name failed: %s", e) + if playlist is None: + logger.error(f"Plex reorder: playlist not found (id={playlist_id}, name='{playlist_name}')") + return False + + ordered = [str(i) for i in (ordered_ids or []) if str(i)] + ordered_set = set(ordered) + items = [i for i in playlist.items() if hasattr(i, 'ratingKey')] + by_rk = {str(i.ratingKey): i for i in items} + + # Drop items not in the desired list (extras, for Mirror source). + to_remove = [i for i in items if str(i.ratingKey) not in ordered_set] + if to_remove: + try: + playlist.removeItems(to_remove) + except (AttributeError, TypeError): + for it in to_remove: + try: + playlist.removeItem(it) + except Exception as one_err: + logger.debug("Plex reorder: removeItem failed: %s", one_err) + + # Move each desired item into place: first to the front (after=None), + # then each subsequent one after its predecessor. + prev = None + for sid in ordered: + it = by_rk.get(sid) + if it is None: + continue + try: + playlist.moveItem(it, after=prev) + except Exception as mv_err: + logger.warning(f"Plex reorder moveItem failed for {sid}: {mv_err}") + return False + prev = it + logger.info(f"Aligned Plex playlist '{playlist_name}' order ({len(ordered)} tracks)") + return True + except Exception as e: + logger.error(f"Error reordering Plex playlist '{playlist_name}': {e}") + return False + def update_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool: if not self.ensure_connection(): return False diff --git a/web_server.py b/web_server.py index 6855d969..b2296eec 100644 --- a/web_server.py +++ b/web_server.py @@ -19780,13 +19780,17 @@ def server_playlist_align(playlist_id): return jsonify({"success": False, "error": "no matched tracks to align"}), 400 active_server = config_manager.get_active_media_server() - if active_server != 'navidrome' or not media_server_engine.client('navidrome'): + client = media_server_engine.client(active_server) if active_server else None + if active_server not in ('navidrome', 'plex') or not client: return jsonify({"success": False, - "error": "Align is only supported on Navidrome right now"}), 400 + "error": "Align isn't supported on this server yet"}), 400 - client = media_server_engine.client('navidrome') - current_tracks = client.get_playlist_tracks(playlist_id) or [] - current_ids = [str(t.ratingKey) for t in current_tracks if getattr(t, 'ratingKey', None)] + # Current playlist track ids (in current server order) — per server. + if active_server == 'navidrome': + current_tracks = client.get_playlist_tracks(playlist_id) or [] + current_ids = [str(t.ratingKey) for t in current_tracks if getattr(t, 'ratingKey', None)] + else: # plex + current_ids = client.get_playlist_track_ids(playlist_id, playlist_name) from core.sync.playlist_edit import plan_align_rewrite ordered = plan_align_rewrite(current_ids, matched_ids, keep_extras=keep_extras) @@ -19794,9 +19798,12 @@ def server_playlist_align(playlist_id): return jsonify({"success": False, "error": "Playlist changed on the server — reload and try again"}), 409 - ok = client.rewrite_playlist_order(playlist_id, playlist_name, ordered) + if active_server == 'navidrome': + ok = client.rewrite_playlist_order(playlist_id, playlist_name, ordered) + else: # plex — in-place moveItem/removeItems reorder + ok = client.reorder_playlist(playlist_id, playlist_name, ordered) if not ok: - return jsonify({"success": False, "error": "Failed to rewrite playlist order"}), 500 + return jsonify({"success": False, "error": "Failed to reorder playlist"}), 500 return jsonify({"success": True, "track_count": len(ordered), "kept_extras": keep_extras}) except Exception as e: diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 9ccc3579..9581a379 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -1524,19 +1524,26 @@ function _showServerOrder() { const serverLabel = serverType.charAt(0).toUpperCase() + serverType.slice(1); document.getElementById('server-order-modal')?.remove(); - const rows = order.map((t, i) => ` + const rows = order.map((t, i) => { + const art = t.thumb + ? `` + : `
`; + return `
${i + 1} + ${art}
${esc(t.title || 'Unknown')} ${esc(t.artist || '')}
-
`).join(''); + `; + }).join(''); - // Align actions (Navidrome only for now) — reorder the server playlist to the - // source order. Two choices for server-only "extra" tracks. Order-only: it never - // adds the missing tracks (that's the normal sync's job). - const alignFoot = serverType === 'navidrome' ? ` + // Align actions — reorder the server playlist to the source order. Two choices + // for server-only "extra" tracks. Order-only: never adds the missing tracks + // (that's the normal sync's job). Supported where reorder is implemented. + const canAlign = serverType === 'navidrome' || serverType === 'plex'; + const alignFoot = canAlign ? `
Align this playlist to the source order
diff --git a/webui/static/style.css b/webui/static/style.css index 0f63c94a..24fe21f1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -58576,35 +58576,50 @@ tr.tag-diff-same { .server-order-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 24px; - background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(3px); + background: rgba(8, 9, 13, 0.72); backdrop-filter: blur(6px); + animation: serverOrderFade 0.16s ease; } +@keyframes serverOrderFade { from { opacity: 0; } to { opacity: 1; } } .server-order-dialog { - width: min(440px, 100%); max-height: 80vh; display: flex; flex-direction: column; - background: #1a1c22; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 14px; - box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6); overflow: hidden; + width: min(460px, 100%); max-height: 82vh; display: flex; flex-direction: column; + background: linear-gradient(180deg, #20232b, #16181e); + border: 1px solid rgba(255, 255, 255, 0.09); border-radius: 18px; + box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.05); + overflow: hidden; animation: serverOrderPop 0.2s cubic-bezier(0.2, 0.7, 0.2, 1.1); } +@keyframes serverOrderPop { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: none; } } .server-order-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; - padding: 16px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.07); + padding: 18px 20px 15px; + background: linear-gradient(180deg, rgba(255, 183, 77, 0.08), transparent); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } -.server-order-h1 { font-size: 15px; font-weight: 800; color: #fff; } -.server-order-sub { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); margin-top: 3px; } +.server-order-h1 { font-size: 16px; font-weight: 800; color: #fff; letter-spacing: -0.01em; } +.server-order-sub { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); margin-top: 4px; line-height: 1.4; } .server-order-close { - background: none; border: none; color: rgba(255, 255, 255, 0.5); font-size: 22px; - line-height: 1; cursor: pointer; padding: 0 4px; + flex: 0 0 auto; width: 30px; height: 30px; border-radius: 8px; + background: rgba(255, 255, 255, 0.05); border: none; color: rgba(255, 255, 255, 0.55); + font-size: 20px; line-height: 1; cursor: pointer; transition: all 0.15s ease; } -.server-order-close:hover { color: #fff; } -.server-order-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 8px 8px 12px; } +.server-order-close:hover { color: #fff; background: rgba(255, 255, 255, 0.12); } +.server-order-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 8px 10px 12px; scrollbar-width: thin; } .server-order-row { - display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 8px; + display: flex; align-items: center; gap: 12px; padding: 7px 8px; border-radius: 10px; + transition: background 0.12s ease; } -.server-order-row:nth-child(odd) { background: rgba(255, 255, 255, 0.02); } +.server-order-row:hover { background: rgba(255, 255, 255, 0.04); } .server-order-num { - flex: 0 0 24px; text-align: right; font-size: 12px; font-weight: 700; - color: rgba(255, 255, 255, 0.35); + flex: 0 0 22px; text-align: right; font-size: 12px; font-weight: 800; + color: rgba(255, 255, 255, 0.3); font-variant-numeric: tabular-nums; } -.server-order-meta { min-width: 0; display: flex; flex-direction: column; } -.server-order-title { font-size: 13px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.server-order-art { + flex: 0 0 40px; width: 40px; height: 40px; border-radius: 7px; object-fit: cover; + background: rgba(255, 255, 255, 0.05); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} +.server-order-art-ph { display: flex; align-items: center; justify-content: center; + font-size: 17px; color: rgba(255, 255, 255, 0.3); } +.server-order-meta { min-width: 0; display: flex; flex-direction: column; gap: 1px; } +.server-order-title { font-size: 13.5px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .server-order-artist { font-size: 11.5px; color: rgba(255, 255, 255, 0.45); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .server-order-empty { padding: 20px; text-align: center; color: rgba(255, 255, 255, 0.4); font-size: 13px; } .server-order-foot { flex: 0 0 auto; padding: 14px 16px 16px; border-top: 1px solid rgba(255, 255, 255, 0.07); } From 49d3c77808921765ab0c3337390b4ef5b0a6a2b9 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 13:20:32 -0700 Subject: [PATCH 08/14] Align playlists: add Jellyfin support (in-place reorder via Move endpoint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes align across all three servers. Jellyfin reorders in place: DELETE the extra entries (Mirror) then POST /Playlists/{id}/Items/{entryId}/Move/{index} for each desired track in ascending order — so the playlist's poster/name/Id survive (no delete-recreate), same as Plex/Navidrome. Mirrors the existing reconcile path's entry-id handling (PlaylistItemId via /Playlists/{id}/Items). - jellyfin reorder_playlist() + get_playlist_track_ids(); reuses the shared, tested plan_align_rewrite planner (no new pure logic). - /align endpoint + frontend gate now cover navidrome|plex|jellyfin. UNTESTED LIVE: no Jellyfin instance to verify against (same status as the Navidrome path). Plex is the only one confirmed working end-to-end so far. --- core/jellyfin_client.py | 75 +++++++++++++++++++++++++++++++++++++ web_server.py | 8 ++-- webui/static/pages-extra.js | 2 +- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py index f24020d5..be4a7a26 100644 --- a/core/jellyfin_client.py +++ b/core/jellyfin_client.py @@ -1711,6 +1711,81 @@ class JellyfinClient(MediaServerClient): logger.error(f"Error reconciling Jellyfin playlist '{playlist_name}': {e}") return False + def get_playlist_track_ids(self, playlist_id: str) -> List[str]: + """The playlist's current track ids (Item Ids), in current order. [] on miss.""" + if not self.ensure_connection(): + return [] + try: + resp = self._make_request(f'/Playlists/{playlist_id}/Items', {'UserId': self.user_id}) + if not resp: + return [] + return [str(i.get('Id')) for i in resp.get('Items', []) if i.get('Id')] + except Exception as e: + logger.error(f"Error getting Jellyfin playlist track ids '{playlist_id}': {e}") + return [] + + def reorder_playlist(self, playlist_id, playlist_name: str, ordered_ids) -> bool: + """In-place reorder a playlist to an exact ordered track-id list ('Align + playlists'). Removes any current entry whose track NOT in ``ordered_ids`` + ('Mirror source' drops extras; 'Keep extras' keeps them in the list), then + moves each desired track to its target index via the Jellyfin Move endpoint. + Operates on the existing playlist (DELETE EntryIds + Items/{entryId}/Move/{i}) + so its poster, name and Id survive — no delete/recreate.""" + if not self.ensure_connection(): + return False + try: + import requests + ordered = [str(i) for i in (ordered_ids or []) if str(i)] + ordered_set = set(ordered) + + # Entries carry both the track Id and the PlaylistItemId (entry id); + # move/remove operate on the entry id. + entries = [] # (track_id, entry_id) in current order + resp = self._make_request(f'/Playlists/{playlist_id}/Items', {'UserId': self.user_id}) + if resp: + for item in resp.get('Items', []): + tid = str(item.get('Id') or '') + eid = str(item.get('PlaylistItemId') or '') + if tid: + entries.append((tid, eid)) + if not entries: + logger.error(f"Jellyfin reorder: no entries for playlist {playlist_id}") + return False + + by_tid = {tid: eid for tid, eid in entries} + hdr = {'X-Emby-Token': self.api_key} + + # Drop entries not in the desired list (extras, for Mirror source). + extra_eids = [eid for tid, eid in entries if tid not in ordered_set and eid] + for i in range(0, len(extra_eids), 100): + batch = extra_eids[i:i + 100] + r = requests.delete( + f"{self.base_url}/Playlists/{playlist_id}/Items", + params={'EntryIds': ','.join(batch)}, headers=hdr, timeout=30, + ) + if r.status_code not in (200, 204): + logger.error(f"Jellyfin reorder remove failed: HTTP {r.status_code}") + return False + + # Move each desired track to its target index, ascending — each move + # lands the item exactly at index i without disturbing 0..i-1. + for idx, tid in enumerate(ordered): + eid = by_tid.get(tid) + if not eid: + continue + r = requests.post( + f"{self.base_url}/Playlists/{playlist_id}/Items/{eid}/Move/{idx}", + headers=hdr, timeout=30, + ) + if r.status_code not in (200, 204): + logger.warning(f"Jellyfin reorder move failed for {tid}: HTTP {r.status_code}") + return False + logger.info(f"Aligned Jellyfin playlist '{playlist_name}' order ({len(ordered)} tracks)") + return True + except Exception as e: + logger.error(f"Error reordering Jellyfin playlist '{playlist_name}': {e}") + return False + def update_playlist(self, playlist_name: str, tracks) -> bool: """Update an existing playlist or create it if it doesn't exist""" if not self.ensure_connection(): diff --git a/web_server.py b/web_server.py index b2296eec..594df17a 100644 --- a/web_server.py +++ b/web_server.py @@ -19781,7 +19781,7 @@ def server_playlist_align(playlist_id): active_server = config_manager.get_active_media_server() client = media_server_engine.client(active_server) if active_server else None - if active_server not in ('navidrome', 'plex') or not client: + if active_server not in ('navidrome', 'plex', 'jellyfin') or not client: return jsonify({"success": False, "error": "Align isn't supported on this server yet"}), 400 @@ -19789,8 +19789,10 @@ def server_playlist_align(playlist_id): if active_server == 'navidrome': current_tracks = client.get_playlist_tracks(playlist_id) or [] current_ids = [str(t.ratingKey) for t in current_tracks if getattr(t, 'ratingKey', None)] - else: # plex + elif active_server == 'plex': current_ids = client.get_playlist_track_ids(playlist_id, playlist_name) + else: # jellyfin + current_ids = client.get_playlist_track_ids(playlist_id) from core.sync.playlist_edit import plan_align_rewrite ordered = plan_align_rewrite(current_ids, matched_ids, keep_extras=keep_extras) @@ -19800,7 +19802,7 @@ def server_playlist_align(playlist_id): if active_server == 'navidrome': ok = client.rewrite_playlist_order(playlist_id, playlist_name, ordered) - else: # plex — in-place moveItem/removeItems reorder + else: # plex / jellyfin — in-place reorder ok = client.reorder_playlist(playlist_id, playlist_name, ordered) if not ok: return jsonify({"success": False, "error": "Failed to reorder playlist"}), 500 diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 9581a379..143f656a 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -1542,7 +1542,7 @@ function _showServerOrder() { // Align actions — reorder the server playlist to the source order. Two choices // for server-only "extra" tracks. Order-only: never adds the missing tracks // (that's the normal sync's job). Supported where reorder is implemented. - const canAlign = serverType === 'navidrome' || serverType === 'plex'; + const canAlign = serverType === 'navidrome' || serverType === 'plex' || serverType === 'jellyfin'; const alignFoot = canAlign ? `
Align this playlist to the source order
From e148f859e7e2ba419d12eec05ab3e6bba0737d4b Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 16:09:55 -0700 Subject: [PATCH 09/14] =?UTF-8?q?Sync=20detail=20modal:=20click=20'?= =?UTF-8?q?=E2=86=92=20Wishlist'=20to=20re-add=20a=20track=20with=20the=20?= =?UTF-8?q?original=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//track//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. --- core/sync/wishlist_readd.py | 67 +++++++++++++++++++++++++++ tests/test_sync_wishlist_readd.py | 76 +++++++++++++++++++++++++++++++ web_server.py | 38 ++++++++++++++++ webui/static/pages-extra.js | 39 +++++++++++++++- webui/static/style.css | 15 ++++++ 5 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 core/sync/wishlist_readd.py create mode 100644 tests/test_sync_wishlist_readd.py 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; From 5cad2c02b036abdf32a46704cadd3850b4a3b2df Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 16:20:07 -0700 Subject: [PATCH 10/14] Sync wishlist re-add: carry the cover image through (real parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-add showed no album/single art. Cause: reconstruct returned the full track from tracks_json AS-IS — and some syncs store tracks_json lean (no album.images), so the re-added wishlist entry had an empty album.images even though the track's cover was sitting right there in the track_result's image_url. Fix: always backfill album.images from the track_result's image_url when the album has none (and copy the dict so tracks_json isn't mutated). Real album art is kept when present; the 250px thumb only fills a gap. Verified against a real sync row in all three cases (full / lean tracks_json / no tracks_json) — album.images now populated in every one. The wishlist card reads album.images, so the cover shows. --- core/sync/wishlist_readd.py | 56 ++++++++++++++++++++----------- tests/test_sync_wishlist_readd.py | 31 +++++++++++++++-- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/core/sync/wishlist_readd.py b/core/sync/wishlist_readd.py index 08fdf90f..80cf0b81 100644 --- a/core/sync/wishlist_readd.py +++ b/core/sync/wishlist_readd.py @@ -35,33 +35,49 @@ def reconstruct_sync_track_data( 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 + # Base: the FULL original track (best fidelity — full album object, ids, source, + # artists) by index if its id matches, else by id search. Copied so we never + # mutate the caller's tracks list. + base: Optional[Dict[str, Any]] = 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( + base = dict(cand) + if base is None and sid: + match = 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 + if match is not None: + base = dict(match) - # 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, - } + # No full track in the cache — rebuild a minimal dict from the track_result. + if not (isinstance(base, dict) and base.get('id')): + if not sid: + return None + base = { + 'id': sid, + 'name': tr.get('name') or '', + 'artists': [{'name': tr.get('artist') or ''}], + 'album': {'name': tr.get('album') or ''}, + 'duration_ms': tr.get('duration_ms') or 0, + } + + # Ensure the cover carries through. The wishlist display reads + # spotify_data.album.images; tracks_json is sometimes stored WITHOUT images, so + # backfill from the track_result's image_url (the album art the sync extracted) + # whenever the album has none. This is what makes the re-add reach full parity + # with the original auto-add's appearance. + album = base.get('album') + if not isinstance(album, dict): + album = {'name': base.get('name', '')} + else: + album = dict(album) + img = tr.get('image_url') + if img and not album.get('images'): + album['images'] = [{'url': img}] + base['album'] = album + return base __all__ = ["reconstruct_sync_track_data"] diff --git a/tests/test_sync_wishlist_readd.py b/tests/test_sync_wishlist_readd.py index 6e048234..130a866a 100644 --- a/tests/test_sync_wishlist_readd.py +++ b/tests/test_sync_wishlist_readd.py @@ -29,8 +29,35 @@ 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" + assert out["id"] == "sp_b" # the full original track + assert out["album"]["images"][0]["url"] == "http://cdn/cover.jpg" # its own art kept + assert out is not tracks[1] # a copy, not the source entry + + +def test_backfills_album_image_when_full_track_has_none(): + # THE BUG: tracks_json stored without album.images, but the track_result has the + # cover. The re-add must carry the image through (parity with the auto-add). + trs = [_tr(0, "sp_a", image_url="http://img/cover250.jpg")] + tracks = [_full("sp_a", with_images=False)] # full data but NO images + out = reconstruct_sync_track_data(trs, tracks, 0) + assert out["id"] == "sp_a" + assert out["album"]["images"] == [{"url": "http://img/cover250.jpg"}] + + +def test_full_track_with_images_is_not_overwritten(): + # When the cached track already has real album art, keep it (don't downgrade to + # the 250px track_result thumb). + trs = [_tr(0, "sp_a", image_url="http://img/thumb.jpg")] + tracks = [_full("sp_a", with_images=True)] + out = reconstruct_sync_track_data(trs, tracks, 0) + assert out["album"]["images"][0]["url"] == "http://cdn/cover.jpg" # original kept + + +def test_does_not_mutate_source_tracks_list(): + trs = [_tr(0, "sp_a", image_url="http://img/x.jpg")] + tracks = [_full("sp_a", with_images=False)] + reconstruct_sync_track_data(trs, tracks, 0) + assert "images" not in tracks[0]["album"] # source untouched def test_matches_by_id_when_index_misaligns(): From d0966ec26292d3493912f6371fae02cee9eea047 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 16:41:40 -0700 Subject: [PATCH 11/14] Sync wishlist re-add: build the IDENTICAL payload the auto-add uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (the real one): the auto-add passes original_tracks_map[id] — tracks_json run through a specific normalization (album->dict with images/album_type/total_tracks/ release_date, artists->dicts). My re-add hand-rolled a different shape, so the stored spotify_data didn't match and the wishlist's nebula (which reads spotify_data.album. images[0].url) had no cover, plus album/single classification could differ. Fix: extract that normalization into one shared build_original_tracks_map() and use it in BOTH the live sync (core.discovery.sync) and the re-add. The re-add now resolves the track by source_track_id through the same map — byte-identical payload. Verified on a real sync row: re-add payload == live-sync payload, album.images present. (The shared normalizer is also copy-safe, fixing a latent tracks_json mutation in the old inline version.) Fallback (track absent from tracks_json) rebuilds through the same normalizer with the cover seeded from the row's image_url. 10 tests incl. a direct parity assertion. --- core/discovery/sync.py | 34 ++------ core/sync/wishlist_readd.py | 129 +++++++++++++++++------------- tests/test_sync_wishlist_readd.py | 108 +++++++++++++------------ 3 files changed, 132 insertions(+), 139 deletions(-) diff --git a/core/discovery/sync.py b/core/discovery/sync.py index cee281d6..ee6da03e 100644 --- a/core/discovery/sync.py +++ b/core/discovery/sync.py @@ -307,35 +307,11 @@ def run_sync_task( # This avoids needing to re-fetch it from Spotify logger.info("Converting JSON tracks to SpotifyTrack objects...") - # Store original track data with full album objects (for wishlist with cover art) - # Normalize formats for wishlist: album must be dict {'name': ...}, artists must be [{'name': ...}] - # Important: copy data — don't mutate tracks_json since SpotifyTrack expects List[str] artists - original_tracks_map = {} - for t in tracks_json: - track_id = t.get('id', '') - if track_id: - normalized = dict(t) - # Normalize album to dict format, preserving images and metadata - raw_album = normalized.get('album', '') - if isinstance(raw_album, str): - normalized['album'] = { - 'name': raw_album or normalized.get('name', 'Unknown Album'), - 'images': [], 'album_type': 'single', 'total_tracks': 1, 'release_date': '' - } - elif not isinstance(raw_album, dict): - normalized['album'] = { - 'name': str(raw_album) if raw_album else normalized.get('name', 'Unknown Album'), - 'images': [], 'album_type': 'single', 'total_tracks': 1, 'release_date': '' - } - else: - # Dict — ensure required keys exist - raw_album.setdefault('name', 'Unknown Album') - raw_album.setdefault('images', []) - # Normalize artists to list of dicts - raw_artists = normalized.get('artists', []) - if raw_artists and isinstance(raw_artists[0], str): - normalized['artists'] = [{'name': a} for a in raw_artists] - original_tracks_map[track_id] = normalized + # Store original track data with full album objects (for wishlist with cover art). + # Shared with the sync-detail "re-add to wishlist" action so both build the + # IDENTICAL payload (album→dict + images, artists→dicts). Copy-safe. + from core.sync.wishlist_readd import build_original_tracks_map + original_tracks_map = build_original_tracks_map(tracks_json) tracks = [] for i, t in enumerate(tracks_json): diff --git a/core/sync/wishlist_readd.py b/core/sync/wishlist_readd.py index 80cf0b81..6658352e 100644 --- a/core/sync/wishlist_readd.py +++ b/core/sync/wishlist_readd.py @@ -1,7 +1,9 @@ -"""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. +"""Build the wishlist-add payload for a synced track — the SINGLE source of truth +shared by the live sync (core.discovery.sync) and the sync-detail "re-add to +wishlist" action, so a re-add is byte-for-byte the same payload the auto-add used. + +Pure — no I/O. The web route supplies the parsed sync entry and calls the wishlist +service; the live sync calls build_original_tracks_map directly. """ from __future__ import annotations @@ -9,75 +11,88 @@ from __future__ import annotations from typing import Any, Dict, List, Optional +def normalize_wishlist_track(track: Dict[str, Any]) -> Dict[str, Any]: + """Normalize ONE tracks_json track into the wishlist-add shape: album coerced to + a dict (preserving images + album_type/total_tracks/release_date), artists to a + list of dicts. Copy-safe — never mutates the input.""" + normalized = dict(track) + raw_album = normalized.get('album', '') + if isinstance(raw_album, dict): + album = dict(raw_album) + album.setdefault('name', 'Unknown Album') + album.setdefault('images', []) + normalized['album'] = album + else: + name = raw_album if isinstance(raw_album, str) else (str(raw_album) if raw_album else '') + normalized['album'] = { + 'name': name or normalized.get('name', 'Unknown Album'), + 'images': [], 'album_type': 'single', 'total_tracks': 1, 'release_date': '', + } + raw_artists = normalized.get('artists', []) + if raw_artists and isinstance(raw_artists[0], str): + normalized['artists'] = [{'name': a} for a in raw_artists] + return normalized + + +def build_original_tracks_map(tracks_json: Optional[List[Dict[str, Any]]]) -> Dict[str, Dict[str, Any]]: + """``{track_id: normalized_track}`` for a sync's tracks_json — the full-fidelity + map the live sync builds before auto-wishlisting unmatched tracks. One source of + truth so the re-add matches the auto-add exactly.""" + out: Dict[str, Dict[str, Any]] = {} + for t in tracks_json or []: + if not isinstance(t, dict): + continue + track_id = t.get('id', '') + if track_id: + out[str(track_id)] = normalize_wishlist_track(t) + return out + + 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. + """Return the wishlist-add ``spotify_track_data`` for re-adding a synced track. - 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. + Resolves the track the SAME way the auto-add did: the normalized tracks_json + entry (``build_original_tracks_map``), looked up by the track_result's + ``source_track_id`` — so the payload (full album object + images + album_type + + total_tracks + artists-as-dicts) is identical to the original auto-add. - 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. + Only 'wishlist' rows are eligible. Falls back to a normalized rebuild from the + track_result's own fields (with the album cover from its image_url) when the + cached track is missing. None when ineligible or unidentifiable. """ 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 [] - # Base: the FULL original track (best fidelity — full album object, ids, source, - # artists) by index if its id matches, else by id search. Copied so we never - # mutate the caller's tracks list. - base: Optional[Dict[str, Any]] = 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): - base = dict(cand) - if base is None and sid: - match = next( - (t for t in tracks if isinstance(t, dict) and str(t.get('id') or '') == sid), - None, - ) - if match is not None: - base = dict(match) + # Primary: the exact normalized track the auto-add used. + if sid: + payload = build_original_tracks_map(tracks).get(sid) + if payload: + return payload - # No full track in the cache — rebuild a minimal dict from the track_result. - if not (isinstance(base, dict) and base.get('id')): - if not sid: - return None - base = { - 'id': sid, - 'name': tr.get('name') or '', - 'artists': [{'name': tr.get('artist') or ''}], - 'album': {'name': tr.get('album') or ''}, - 'duration_ms': tr.get('duration_ms') or 0, - } - - # Ensure the cover carries through. The wishlist display reads - # spotify_data.album.images; tracks_json is sometimes stored WITHOUT images, so - # backfill from the track_result's image_url (the album art the sync extracted) - # whenever the album has none. This is what makes the re-add reach full parity - # with the original auto-add's appearance. - album = base.get('album') - if not isinstance(album, dict): - album = {'name': base.get('name', '')} - else: - album = dict(album) - img = tr.get('image_url') - if img and not album.get('images'): - album['images'] = [{'url': img}] - base['album'] = album - return base + # Fallback: rebuild from the track_result fields, through the SAME normalizer so + # the shape matches, and seed the album cover from the row's image_url. + if not sid: + return None + album: Dict[str, Any] = {'name': tr.get('album') or '', 'album_type': 'single', + 'total_tracks': 1, 'release_date': ''} + if tr.get('image_url'): + album['images'] = [{'url': tr['image_url']}] + return normalize_wishlist_track({ + '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"] +__all__ = ["normalize_wishlist_track", "build_original_tracks_map", "reconstruct_sync_track_data"] diff --git a/tests/test_sync_wishlist_readd.py b/tests/test_sync_wishlist_readd.py index 130a866a..b794ed10 100644 --- a/tests/test_sync_wishlist_readd.py +++ b/tests/test_sync_wishlist_readd.py @@ -1,13 +1,17 @@ -"""Re-add a synced unmatched track to the wishlist with the original context. +"""Re-add a synced unmatched track to the wishlist with the EXACT auto-add payload. -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. +The live sync and the sync-detail re-add must build the identical wishlist payload +(via build_original_tracks_map), so a re-added track is indistinguishable from the +original auto-add — including its album cover, album_type, and artist shape. """ from __future__ import annotations -from core.sync.wishlist_readd import reconstruct_sync_track_data +from core.sync.wishlist_readd import ( + build_original_tracks_map, + normalize_wishlist_track, + reconstruct_sync_track_data, +) def _tr(index, sid, status='wishlist', **kw): @@ -18,75 +22,69 @@ def _tr(index, sid, status='wishlist', **kw): def _full(sid, name="Song", with_images=True): - album = {"name": "Album"} + album = {"name": "Album", "album_type": "album", "total_tracks": 12} if with_images: - album["images"] = [{"url": "http://cdn/cover.jpg"}] + album["images"] = [{"url": "http://cdn/cover.jpg", "height": 640, "width": 640}] return {"id": sid, "name": name, "artists": [{"name": "Artist"}], "album": album, "duration_ms": 200000, "popularity": 50} -def test_prefers_full_original_track_by_index(): +# ── normalize_wishlist_track ────────────────────────────────────────────────── + +def test_normalize_string_album_to_dict(): + out = normalize_wishlist_track({"id": "a", "name": "T", "album": "My Single", "artists": ["X"]}) + assert out["album"] == {"name": "My Single", "images": [], "album_type": "single", + "total_tracks": 1, "release_date": ""} + assert out["artists"] == [{"name": "X"}] # strings -> dicts + + +def test_normalize_dict_album_preserves_images_and_type(): + out = normalize_wishlist_track(_full("a")) + assert out["album"]["images"][0]["url"] == "http://cdn/cover.jpg" + assert out["album"]["album_type"] == "album" + assert out["album"]["total_tracks"] == 12 + + +def test_normalize_is_copy_safe(): + src = {"id": "a", "album": {"name": "A"}, "artists": ["X"]} + normalize_wishlist_track(src) + assert "images" not in src["album"] # source untouched + assert src["artists"] == ["X"] + + +# ── reconstruct: parity with the live auto-add ──────────────────────────────── + +def test_payload_is_identical_to_live_sync_map(): + # THE PARITY GUARANTEE: re-add payload == what build_original_tracks_map (used by + # the live sync) produces for the same track. 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["id"] == "sp_b" # the full original track - assert out["album"]["images"][0]["url"] == "http://cdn/cover.jpg" # its own art kept - assert out is not tracks[1] # a copy, not the source entry + assert out == build_original_tracks_map(tracks)["sp_b"] + assert out["album"]["images"][0]["url"] == "http://cdn/cover.jpg" # cover carries through -def test_backfills_album_image_when_full_track_has_none(): - # THE BUG: tracks_json stored without album.images, but the track_result has the - # cover. The re-add must carry the image through (parity with the auto-add). - trs = [_tr(0, "sp_a", image_url="http://img/cover250.jpg")] - tracks = [_full("sp_a", with_images=False)] # full data but NO images - out = reconstruct_sync_track_data(trs, tracks, 0) - assert out["id"] == "sp_a" - assert out["album"]["images"] == [{"url": "http://img/cover250.jpg"}] - - -def test_full_track_with_images_is_not_overwritten(): - # When the cached track already has real album art, keep it (don't downgrade to - # the 250px track_result thumb). - trs = [_tr(0, "sp_a", image_url="http://img/thumb.jpg")] - tracks = [_full("sp_a", with_images=True)] - out = reconstruct_sync_track_data(trs, tracks, 0) - assert out["album"]["images"][0]["url"] == "http://cdn/cover.jpg" # original kept - - -def test_does_not_mutate_source_tracks_list(): - trs = [_tr(0, "sp_a", image_url="http://img/x.jpg")] - tracks = [_full("sp_a", with_images=False)] - reconstruct_sync_track_data(trs, tracks, 0) - assert "images" not in tracks[0]["album"] # source untouched - - -def test_matches_by_id_when_index_misaligns(): - # tracks_json in a different order than track_results -> match by source_track_id. +def test_resolves_by_source_track_id_not_position(): 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 + tracks = [_full("sp_b"), _full("sp_a")] # tracks_json reversed + out = reconstruct_sync_track_data(trs, tracks, 0) # row 0 -> sp_a + assert out["id"] == "sp_a" -def test_falls_back_to_track_result_fields_when_no_full_track(): +def test_fallback_rebuilds_with_cover_when_track_missing(): + # Track not in tracks_json -> rebuild from the row, seed cover from image_url, + # run through the same normalizer (album dict + artists dicts). 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 + out = reconstruct_sync_track_data(trs, [], 0) 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"] + assert out["album"]["name"] == "Real Love Baby" 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 @@ -99,5 +97,9 @@ def test_refuses_out_of_range_or_empty(): 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 + + +def test_build_map_skips_idless_and_non_dicts(): + m = build_original_tracks_map([_full("a"), {"name": "no id"}, "garbage", None]) + assert set(m.keys()) == {"a"} From 5b7f99c30bcb01dea0d8e5f737436d4b8a4c01df Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 16:51:43 -0700 Subject: [PATCH 12/14] Sync wishlist re-add: skip wing-it stubs (match the sync), no clickable button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'sami matar' was a wing-it FALLBACK stub — a placeholder the discovery pipeline makes when it can't resolve a track to real metadata (no album, no cover). The live sync explicitly skips wing_it_* ids for the wishlist (no metadata to act on), but my re-add didn't — so it stored a coverless, single-classified placeholder. That's why: sync didn't add it, no images, marked single. Fix (parity): reconstruct refuses ids starting 'wing_it_'. Frontend renders the '-> Wishlist' status as plain, non-clickable text for wing-it rows (with a tooltip) since they were never actually wishlisted. Real tracks keep the working button + the byte-identical-payload re-add from the prior fix. --- core/sync/wishlist_readd.py | 5 +++++ tests/test_sync_wishlist_readd.py | 9 +++++++++ webui/static/pages-extra.js | 17 ++++++++++++----- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/core/sync/wishlist_readd.py b/core/sync/wishlist_readd.py index 6658352e..7414c943 100644 --- a/core/sync/wishlist_readd.py +++ b/core/sync/wishlist_readd.py @@ -71,6 +71,11 @@ def reconstruct_sync_track_data( return None sid = str(tr.get('source_track_id') or '') + # Wing-it fallback stubs have no real metadata (no album/cover) — the live sync + # SKIPS them for the wishlist (services/sync_service.py), so the re-add must too, + # rather than store a coverless, mis-classified placeholder. + if sid.startswith('wing_it_'): + return None # Primary: the exact normalized track the auto-add used. if sid: diff --git a/tests/test_sync_wishlist_readd.py b/tests/test_sync_wishlist_readd.py index b794ed10..227e9784 100644 --- a/tests/test_sync_wishlist_readd.py +++ b/tests/test_sync_wishlist_readd.py @@ -100,6 +100,15 @@ def test_refuses_when_no_id_and_no_full_track(): assert reconstruct_sync_track_data([_tr(0, "")], [], 0) is None +def test_refuses_wing_it_stub(): + # Wing-it fallback stubs have no real metadata; the sync skips them, so must we — + # even if a full (stub) track happens to be in tracks_json. + trs = [_tr(0, "wing_it_abc123", name="Sami Matar", artist="X")] + assert reconstruct_sync_track_data(trs, [], 0) is None + stub = {"id": "wing_it_abc123", "name": "Sami Matar", "album": "Sami Matar", "artists": ["X"]} + assert reconstruct_sync_track_data(trs, [stub], 0) is None + + def test_build_map_skips_idless_and_non_dicts(): m = build_original_tracks_map([_full("a"), {"name": "no id"}, "garbage", None]) assert set(m.keys()) == {"a"} diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 2a02993a..b82dac7a 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2271,11 +2271,18 @@ async function openSyncDetailModal(entryId) { let dlDisplay = dlIcon; if (!dlDisplay && t.download_status === 'wishlist') { - // Clickable: re-add this exact track to the wishlist with the - // same context the sync originally used. - dlDisplay = ``; + // Wing-it fallback stubs (no real metadata) were never actually + // wishlisted by the sync — show them as plain, non-clickable. + const isWingIt = String(t.source_track_id || '').startsWith('wing_it_'); + if (isWingIt) { + dlDisplay = `→ Wishlist`; + } else { + // Clickable: re-add this exact track to the wishlist with the + // same context the sync originally used. + dlDisplay = ``; + } } return ` From 79101e18478322f835ace736b29839b482df4a84 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 18:30:49 -0700 Subject: [PATCH 13/14] =?UTF-8?q?Sync=20detail:=20label=20wing-it=20rows?= =?UTF-8?q?=20'Unmatched',=20not=20'=E2=86=92=20Wishlist'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wing-it fallback track shows download_status='wishlist' (the sync stamps that on every unmatched track) but was never actually added — the sync skips wing_it_* for the wishlist. Showing '→ Wishlist' implied it was wishlisted. Now those rows read a muted, non-actionable 'Unmatched' instead. Real wishlisted tracks keep the amber '→ Wishlist' re-add button. --- webui/static/pages-extra.js | 2 +- webui/static/style.css | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index b82dac7a..4b351460 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2275,7 +2275,7 @@ async function openSyncDetailModal(entryId) { // wishlisted by the sync — show them as plain, non-clickable. const isWingIt = String(t.source_track_id || '').startsWith('wing_it_'); if (isWingIt) { - dlDisplay = `→ Wishlist`; + dlDisplay = `Unmatched`; } else { // Clickable: re-add this exact track to the wishlist with the // same context the sync originally used. diff --git a/webui/static/style.css b/webui/static/style.css index abe7827b..2b79e54b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -58023,6 +58023,13 @@ tr.tag-diff-same { font-weight: 600; white-space: nowrap; } +/* unmatched (wing-it fallback) — never wishlisted; muted, non-actionable */ +.sync-dl-unmatched { + font-size: 9px; + color: rgba(255, 255, 255, 0.4); + 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); From f010fbc48791194423199f7bbb3463e6b954e285 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 24 Jun 2026 19:02:44 -0700 Subject: [PATCH 14/14] Release 2.7.8: version bump + What's New / version modal + PR description + docker-publish default tag - _SOULSYNC_BASE_VERSION -> 2.7.8; docker-publish default version_tag -> 2.7.8 - pr_description.md rewritten for 2.7.8 (align playlists + re-add-to-wishlist-from-sync features, the #922 Spotify-Free label fix, and the #918 iTunes-cache self-heal follow-up) - WHATS_NEW: replaced the 2.7.7 block with 2.7.8 (current release + brief 'earlier versions') - VERSION_MODAL_SECTIONS: promoted the 2.7.8 highlights, rolled 2.7.7 into an 'Earlier' aggregator --- .github/workflows/docker-publish.yml | 4 +- pr_description.md | 42 ++++++++---------- web_server.py | 2 +- webui/static/helper.js | 65 +++++++++++++--------------- 4 files changed, 51 insertions(+), 62 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 48dac197..a08b63a5 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,9 +9,9 @@ on: workflow_dispatch: inputs: version_tag: - description: 'Version tag (e.g. 2.7.7)' + description: 'Version tag (e.g. 2.7.8)' required: true - default: '2.7.7' + default: '2.7.8' jobs: build-and-push: diff --git a/pr_description.md b/pr_description.md index 22a15ec1..3b270a6e 100644 --- a/pr_description.md +++ b/pr_description.md @@ -1,44 +1,38 @@ -# soulsync 2.7.7 — `dev` → `main` +# soulsync 2.7.8 — `dev` → `main` -a fix-heavy patch on top of 2.7.6 — a big sweep of reported issues, the start of listening-driven recommendations, and a metadata-parity fix that stops downloads from needing a manual reorganize afterward. +a feature patch on top of 2.7.7 — playlists can now be put back in order on the server, you can re-wishlist a missed track straight from sync history, plus a couple of reported fixes. --- ## what's new -### downloads now tag + path like reorganize does (#915) -the headline fix. when you add or redownload music, post-processing used to backfill missing album data from **spotify only** — so an iTunes/deezer-primary user kept a "lean" context and the path **dropped the `$year`** while the release date defaulted to `YYYY-01-01`. you'd then run a reorganize to fix it every time. now post-processing (and redownload) pull the full album from your **primary metadata source** — the exact same place reorganize/enrich read — so the year, real release date, and album type are right the first time. covers the add/download flow and single-track redownload (iTunes + deezer). +### align playlists — server order, not just contents +the server-playlist editor only ever cared about *which* tracks were on the server, never their order — and it rendered the server column in the source's order, so a playlist with the right tracks in the wrong sequence read as "in sync" when it wasn't. now it tells the truth: +- an **"out of order"** badge appears when the tracks match but the sequence differs (relative order, so missing/extra tracks don't false-flag it), and a **read-only view** shows the server's *actual* order with cover art. +- a new **"Align playlists"** action reorders the server playlist to match the source — **Plex** (in-place via moveItem), **Navidrome** (ordered rewrite), and **Jellyfin** (Move endpoint), all of which preserve the playlist's identity/poster. two choices for server-only extras: **mirror source** (drop them) or **keep extras** (park them at the end). it's order-only — it never adds the missing tracks (that's a normal sync's job) and never touches metadata, just reshuffles ids already on the server. -### listening-driven recommendations — foundation (#913) -the start of "discover based on what you actually listen to." during the watchlist scan, soulsync now ranks artists you'd love but don't own — seeded from your top-played artists, scored by **consensus** (who's similar to *many* of your favorites), play weight, and similarity strength — and builds a candidate track list from them. generated and stored now; the discover row + synced playlist come next. - -### jellyfin stops indexing half-written tracks -multi-disc tracks landing with "no disc" in jellyfin turned out to be a write race: a cross-filesystem move (downloads volume → library volume) wrote the file to its final path **incrementally**, and jellyfin's real-time watcher could catch it mid-write and cache incomplete metadata. now the final placement is **atomic** — copy to a hidden temp sibling, then an atomic rename — so a watcher only ever sees the complete file. +### re-add to wishlist from sync history +in the dashboard's **Recent Syncs → details**, the "→ Wishlist" status on an unmatched track is now a button — click it to re-add that exact track to the wishlist with the **same context the sync used** (source playlist, cover art, everything), so it's indistinguishable from the original auto-add. the re-add and the live sync now build the *identical* payload from one shared path, so the cover and album/single classification carry through. wing-it fallback stubs (tracks that couldn't be resolved to real metadata) are correctly shown as **"Unmatched"** and aren't re-addable — matching what the sync itself does. ### fixes -- **navidrome playlists doubling (#905)** — every resync re-added the whole playlist (a 4-song list grew to 12). reconcile read the server's current tracks via a missing attribute, so it always thought the playlist was empty. fixed; also pushes a deduped list. -- **youtube playlists capped at ~100 (#908)** — a yt-dlp/youtube regression truncated big playlists (Liked Music came back as 104). worked around to page past it (~200) until the upstream fix lands. -- **album redownload grabbed the wrong edition (#911)** — it did a fresh search instead of using the album's matched source id, so a 66-track OST could redownload as a 19-track single. now uses the canonical matched source. -- **iTunes albums over 50 tracks truncated (#918)** — the iTunes lookup defaulted to 50 entities; now requests the full album. -- **enhanced view showed multi-disc tracks as missing (#916)** — owned disc-2+ tracks (stored as disc 1) no longer flag as "missing"; matched by title like reorganize. -- **reorganize vs "(feat. X)" (#914)** — a bare local title now matches an iTunes track titled "Song (feat. Artist)" instead of reporting it not-in-tracklist. -- **"I have this" dropped the year (#917)** — it rebuilt a yearless path and copied into a new folder; now reuses the album's existing folder. -- **full refresh imported 0 tracks (#910)** — every track insert failed on a missing `year` column; added it + a migration so older DBs self-heal. -- **youtube discovery "Unknown Artist" (#909)** — when youtube hands back only a title, the matched artist now backfills the column instead of leaving "Unknown Artist". -- **empty folder cleaner toggle did nothing (#912)** — the "also remove image/sidecar-only folders" option read the wrong config key; now honored. +- **import search said "Deezer" for Spotify Free users (#922)** — manual album-import told no-auth Spotify users that Deezer was their primary source. the functional source legitimately downgrades to a working fallback (the free path has no album-name search), but the *label* should name what you configured. now it reads "Spotify." +- **iTunes albums >50 tracks could still truncate (#918 follow-up)** — the limit=200 fix only helped fresh fetches; albums cached at 50 before the fix kept serving 50 from the persistent cache. now a cached tracklist shorter than the album's known track count self-heals on next load. + +### under the hood +- `.gitignore` now covers **all** `database/*.db` (+ wal/shm/backup), not just `music_library` — so the video db and any future db can't be committed by accident. --- ## a brief recap of what came before -2.7.6 went the *other* way with playlists — exporting them TO listenbrainz — plus youtube liked-music sync, a deep-scan data-loss guard (#904), and dashboard performance work. 2.7.5 was matching & artwork accuracy + M3U import; 2.7.4 re-identify; 2.7.3 the Quality Upgrade Finder; 2.7.2 playlist-folder mirroring; 2.7.1 download verification; 2.7.0 made multi-user real. +2.7.7 was a fix-heavy patch — the metadata-parity fix so downloads tag + path right without a manual reorganize (#915), the listening-recs foundation (#913), jellyfin atomic writes, and a big reported-issue sweep (#905/#908–#912/#914/#916–#918). 2.7.6 exported playlists TO listenbrainz + youtube liked-music sync; 2.7.5 matching & artwork accuracy; 2.7.4 re-identify; 2.7.3 the Quality Upgrade Finder; 2.7.2 playlist-folder mirroring; 2.7.1 download verification; 2.7.0 made multi-user real. --- ## tests -additive + fail-safe — new behavior is guarded or scoped, nothing existing rewired. new seam/regression suites across the `year`-column migration (#910), the navidrome reconcile fix (#905 — reverting the one-char change flips the tests red), feat-matching (#914), the multi-disc not-missing logic (#916), the iTunes full-album limit (#918, proven live against the real API), the "I have this" year recovery (#917), the primary-source backfill (#915), the listening-recs core (#913), and atomic file placement. relevant suites green; `ruff check` clean repo-wide. +additive + scoped — the new write paths are their own routes that don't touch the normal sync. new seam/regression suites for the order-status detection (incl. the reported "moved to #2" case + missing/extra false-flag guards), the pure align-rewrite planner (mirror vs keep-extras, never-injects-a-foreign-track, stale-data rejection), the sync re-add payload (a direct parity assertion that the re-add == the live-sync payload, plus the wing-it skip), and the `get_primary_source_label` fix (#922). iTunes self-heal proven against the real persistent-cache shape. relevant suites green; `ruff check` clean. ## post-merge -- [ ] tag `v2.7.7` on `main` -- [ ] docker-publish with `version_tag: 2.7.7` +- [ ] tag `v2.7.8` on `main` +- [ ] docker-publish with `version_tag: 2.7.8` - [ ] discord announce (auto-fired by the workflow) -- [ ] reply on the issue batch (#905 / #908 / #909 / #910 / #911 / #912 / #913 / #914 / #915 / #916 / #917 / #918) +- [ ] reply on #922 and the #918 follow-up diff --git a/web_server.py b/web_server.py index c58a5266..bcd38861 100644 --- a/web_server.py +++ b/web_server.py @@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, system-info, update check, etc. # Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release. -_SOULSYNC_BASE_VERSION = "2.7.7" +_SOULSYNC_BASE_VERSION = "2.7.8" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" diff --git a/webui/static/helper.js b/webui/static/helper.js index 98f77c85..9c7b84ad 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3404,22 +3404,13 @@ function closeHelperSearch() { const WHATS_NEW = { // Convention: keep only the CURRENT release here, plus a single brief // "Earlier versions" summary entry. Don't accumulate old per-version blocks. - '2.7.7': [ - { date: 'June 2026 — 2.7.7 release' }, - { title: 'Downloads tag + path like Reorganize (#915)', desc: 'adding/redownloading music used to backfill missing album data from Spotify ONLY — so an iTunes/Deezer-primary user kept a "lean" context, the path dropped the $year and the date defaulted to YYYY-01-01 until you ran a reorganize. now post-processing AND redownload pull the full album from your PRIMARY metadata source (the same place reorganize/enrich read), so the year, real release date and album type are right the first time.', page: 'downloads' }, - { title: 'Listening-driven recommendations — foundation (#913)', desc: 'the start of "discover based on what you actually listen to". during the watchlist scan, soulsync now ranks artists you\'d love but don\'t own — seeded from your top-played artists, scored by consensus (similar to MANY of your favorites), play weight and similarity — and builds a candidate track list. generated + stored now; the discover row + synced playlist come next.', page: 'discover' }, - { title: 'Jellyfin stops indexing half-written tracks', desc: 'multi-disc tracks landing with "no disc" in jellyfin was a write race — a cross-filesystem move wrote the file to its final path incrementally and jellyfin caught it mid-write. final placement is now atomic (temp sibling + atomic rename), so a watcher only ever sees the COMPLETE file.', page: 'downloads' }, - { title: 'Navidrome playlists doubling (#905)', desc: 'every resync re-added the whole playlist (a 4-song list grew to 12) — reconcile read the server\'s current tracks via a missing attribute, so it always thought the playlist was empty. fixed, plus a deduped push.', page: 'sync' }, - { title: 'YouTube playlists capped at ~100 (#908)', desc: 'a yt-dlp/youtube regression truncated big playlists (Liked Music came back as 104). worked around to page past it (~200) until the upstream fix lands.', page: 'sync' }, - { title: 'Album redownload grabbed the wrong edition (#911)', desc: 'redownload did a fresh search instead of using the album\'s matched source id, so a 66-track OST could come back as a 19-track single. now uses the canonical matched source.', page: 'library' }, - { title: 'iTunes albums over 50 tracks truncated (#918)', desc: 'the iTunes lookup defaulted to 50 entities, cutting big albums off in the download window. now requests the full album.', page: 'library' }, - { title: 'Enhanced view showed multi-disc tracks as missing (#916)', desc: 'owned disc-2+ tracks (stored as disc 1) no longer flag as "missing" — matched by title like reorganize.', page: 'library' }, - { title: 'Reorganize vs "(feat. X)" (#914)', desc: 'a bare local title now matches an iTunes track titled "Song (feat. Artist)" instead of being reported not-in-tracklist.', page: 'library' }, - { title: '"I have this" dropped the year (#917)', desc: 'it rebuilt a yearless path and copied into a NEW folder; now reuses the album\'s existing folder.', page: 'library' }, - { title: 'Full Refresh imported 0 tracks (#910)', desc: 'every track insert failed on a missing year column; added it + a migration so older DBs self-heal.', page: 'settings' }, - { title: 'YouTube discovery "Unknown Artist" (#909)', desc: 'when youtube hands back only a title, the matched artist now backfills the column instead of leaving "Unknown Artist".', page: 'sync' }, - { title: 'Empty Folder Cleaner toggle did nothing (#912)', desc: 'the "also remove image/sidecar-only folders" option read the wrong config key; now honored.', page: 'settings' }, - { title: 'Earlier versions', desc: '2.7.6 went the OTHER way with playlists — exporting them TO listenbrainz (#903) — plus youtube liked-music sync (#902), a deep-scan data-loss guard (#904), and dashboard perf. 2.7.5 was matching & artwork accuracy + M3U import; 2.7.4 re-identify; 2.7.3 the Quality Upgrade Finder; 2.7.2 playlist-folder mirroring; 2.7.1 download verification; 2.7.0 made multi-user real.' }, + '2.7.8': [ + { date: 'June 2026 — 2.7.8 release' }, + { title: 'Align playlists — fix the order on the server', desc: 'the server-playlist editor only cared about WHICH tracks were on the server, never their order — and it drew the server column in the source\'s order, so a right-tracks-wrong-sequence playlist read as "in sync" when it wasn\'t. now an "out of order" badge appears when the tracks match but the sequence differs (relative order, so missing/extra don\'t false-flag), with a read-only view of the server\'s ACTUAL order. and a new "Align playlists" action reorders the server to match the source — Plex, Navidrome and Jellyfin, all preserving the playlist\'s identity/poster. order-only: never adds missing tracks, never touches metadata.', page: 'sync' }, + { title: 'Re-add to wishlist from sync history', desc: 'in Recent Syncs → details, the "→ Wishlist" status on an unmatched track is now a button — click it to re-add that exact track with the SAME context the sync used (source playlist, cover art, everything). the re-add and the live sync build the identical payload from one shared path, so the cover and album/single classification carry through. wing-it stubs (couldn\'t be resolved) show as "Unmatched" and aren\'t re-addable, matching the sync.', page: 'dashboard' }, + { title: 'Import search said "Deezer" for Spotify Free (#922)', desc: 'manual album-import told no-auth Spotify users that Deezer was their primary source. the functional source legitimately falls back (the free path has no album-name search), but the label should name what you configured — now it reads "Spotify".', page: 'import' }, + { title: 'iTunes albums over 50 still truncating (#918 follow-up)', desc: 'the limit=200 fix only helped fresh fetches; albums cached at 50 before it kept serving 50 from the persistent cache. now a cached tracklist shorter than the album\'s known track count self-heals on next load.', page: 'library' }, + { title: 'Earlier versions', desc: '2.7.7 was fix-heavy — downloads tag + path right without a manual reorganize (#915), the listening-recs foundation (#913), jellyfin atomic writes, and a big reported-issue sweep (#905/#908–#912/#914/#916–#918). 2.7.6 exported playlists TO listenbrainz + youtube liked-music sync; 2.7.5 matching & artwork accuracy; 2.7.4 re-identify; 2.7.3 the Quality Upgrade Finder; 2.7.2 playlist-folder mirroring; 2.7.1 download verification; 2.7.0 made multi-user real.' }, ], }; @@ -3450,34 +3441,38 @@ const WHATS_NEW = { // usage_note?: 'optional hint shown at the bottom' } const VERSION_MODAL_SECTIONS = [ { - title: "Downloads tag + path like Reorganize (#915)", - description: "the headline fix — adding or redownloading music now gets the year, release date and album type right the FIRST time, instead of needing a manual reorganize after.", + title: "Align playlists — fix the order on the server", + description: "the headline — the server-playlist editor can now put a playlist back in the SOURCE's order, not just sync which tracks are on it.", features: [ - "post-processing used to backfill missing album data from Spotify ONLY — so an iTunes/Deezer-primary user kept a \"lean\" context, the path dropped the $year, and the release date defaulted to YYYY-01-01", - "now post-processing AND single-track redownload pull the full album from your PRIMARY metadata source — the exact same place reorganize / manual enrich read", - "so the $year folder, real release date and album type land correctly on the first pass (iTunes + Deezer)", + "an \"out of order\" badge when the tracks match but the sequence differs (relative order, so missing/extra tracks don't false-flag it), plus a read-only view of the server's ACTUAL order with cover art", + "a new \"Align playlists\" action reorders the server to match the source — Plex, Navidrome and Jellyfin, all preserving the playlist's identity/poster", + "two choices for server-only extras (mirror source = drop them, or keep extras at the end); order-only — never adds missing tracks, never touches metadata", ], }, { - title: "Listening-driven recommendations — foundation (#913)", - description: "the start of \"discover based on what you actually listen to.\"", + title: "Re-add to wishlist from sync history", + description: "in Recent Syncs → details, re-wishlist a track the sync couldn't place — with the exact same context.", features: [ - "during the watchlist scan, soulsync ranks artists you'd love but don't own — seeded from your top-played artists", - "scored by consensus (similar to MANY of your favorites), play weight, and similarity strength — not just \"appears in a list\"", - "generated + stored now; the discover row + a synced playlist come next", + "the \"→ Wishlist\" status on an unmatched track is now a button; click it to re-add that exact track with the SAME context the sync used (source playlist, cover art, everything)", + "the re-add and the live sync build the identical payload from one shared path, so the cover and album/single classification carry through", + "wing-it stubs (couldn't be resolved to real metadata) show as \"Unmatched\" and aren't re-addable, matching what the sync does", ], }, { - title: "A big batch of fixes", - description: "a fix-heavy cycle — playlists, downloads, multi-disc and the enhanced view.", + title: "Recent fixes", + description: "a couple of reported fixes.", features: [ - "jellyfin \"no disc\" tracks — a cross-filesystem move wrote the file to its final path incrementally and jellyfin caught it mid-write; final placement is now atomic, so a watcher only ever sees the complete file", - "#905 — navidrome playlists doubling every resync (reconcile thought the playlist was empty); fixed + a deduped push", - "#908 — youtube playlists capped at ~100 (a yt-dlp/youtube regression); worked around to ~200 until upstream lands", - "#911 — album redownload grabbed the wrong edition (fresh search vs the matched source id); now uses the canonical source", - "#918 — iTunes albums over 50 tracks were truncated in the download window; now requests the full album", - "#916 — the enhanced view flagged multi-disc tracks as missing; now matched by title like reorganize", - "#914 #917 #910 #909 #912 — reorganize vs \"(feat. X)\", \"I have this\" dropping the year, Full Refresh importing 0 tracks, youtube \"Unknown Artist\", and the Empty Folder Cleaner toggle that did nothing", + "#922 — manual import told Spotify Free users their primary source was \"Deezer\"; the search legitimately falls back (the free path can't search albums by name), but the label now reads \"Spotify\"", + "#918 follow-up — iTunes albums over 50 tracks could still show 50 from a stale cache; a cached tracklist shorter than the album's known track count now self-heals on next load", + ], + }, + { + title: "Earlier in 2.7.7", + description: "2.7.7 was fix-heavy — downloads tag + path right the first time without a manual reorganize (#915), the listening-recs foundation (#913), jellyfin atomic writes, and a big reported-issue sweep.", + features: [ + "#915 — post-processing + redownload now pull the full album from your PRIMARY metadata source, so the $year, real release date and album type land right the first time", + "#913 — listening-driven recommendations: ranks artists you'd love but don't own, scored by consensus from your top-played", + "#905/#908/#911/#918/#916/#914/#917/#910/#909/#912 — navidrome playlists doubling, youtube ~100 cap, wrong redownload edition, iTunes 50-track truncation, multi-disc shown missing, and the rest of the batch", ], }, {