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/.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 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/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/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/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/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/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/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/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/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/core/sync/wishlist_readd.py b/core/sync/wishlist_readd.py new file mode 100644 index 00000000..7414c943 --- /dev/null +++ b/core/sync/wishlist_readd.py @@ -0,0 +1,103 @@ +"""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 + +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 wishlist-add ``spotify_track_data`` for re-adding a synced track. + + 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. + + 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 {} + if tr.get('download_status') != 'wishlist': + 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: + payload = build_original_tracks_map(tracks).get(sid) + if payload: + return payload + + # 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__ = ["normalize_wishlist_track", "build_original_tracks_map", "reconstruct_sync_track_data"] 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/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" 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 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/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/tests/test_sync_wishlist_readd.py b/tests/test_sync_wishlist_readd.py new file mode 100644 index 00000000..227e9784 --- /dev/null +++ b/tests/test_sync_wishlist_readd.py @@ -0,0 +1,114 @@ +"""Re-add a synced unmatched track to the wishlist with the EXACT auto-add payload. + +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 ( + build_original_tracks_map, + normalize_wishlist_track, + 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", "album_type": "album", "total_tracks": 12} + if with_images: + 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} + + +# ── 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 == build_original_tracks_map(tracks)["sp_b"] + assert out["album"]["images"][0]["url"] == "http://cdn/cover.jpg" # cover carries through + + +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")] # tracks_json reversed + out = reconstruct_sync_track_data(trs, tracks, 0) # row 0 -> sp_a + assert out["id"] == "sp_a" + + +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) + assert out["id"] == "sp_x" + assert out["name"] == "Real Love Baby" + assert out["artists"] == [{"name": "Father John Misty"}] + assert out["album"]["images"] == [{"url": "http://img/x.jpg"}] + assert out["album"]["name"] == "Real Love Baby" + + +def test_refuses_non_wishlist_row(): + 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(): + 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/web_server.py b/web_server.py index a282de9a..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).""" @@ -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.""" @@ -19697,7 +19735,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 +19767,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,12 +19789,68 @@ 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}") 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() + client = media_server_engine.client(active_server) if active_server else None + 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 + + # 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)] + 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) + if ordered is None: + return jsonify({"success": False, + "error": "Playlist changed on the server — reload and try again"}), 409 + + if active_server == 'navidrome': + ok = client.rewrite_playlist_order(playlist_id, playlist_name, ordered) + 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 + 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/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", ], }, { diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 4e2633a9..4b351460 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,105 @@ 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) => { + const art = t.thumb + ? `` + : `
`; + return ` +
+ ${i + 1} + ${art} +
+ ${esc(t.title || 'Unknown')} + ${esc(t.artist || '')} +
+
`; + }).join(''); + + // 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' || serverType === 'jellyfin'; + const alignFoot = canAlign ? ` +
+
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'; + overlay.innerHTML = ` +
+
+
+
${esc(serverLabel)} playlist order
+
the actual order on your server · source order stays the source of truth
+
+ +
+
${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); @@ -2082,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...'); @@ -2123,7 +2270,20 @@ 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') { + // 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 = `Unmatched`; + } else { + // 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 47a0e66c..2b79e54b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -58023,6 +58023,28 @@ 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); + 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; @@ -58561,6 +58583,82 @@ 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(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(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: 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: 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 { + 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; 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: 7px 8px; border-radius: 10px; + transition: background 0.12s ease; +} +.server-order-row:hover { background: rgba(255, 255, 255, 0.04); } +.server-order-num { + 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-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); } +.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; text-transform: uppercase;