Merge pull request #924 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-06-24 20:16:09 -07:00 committed by GitHub
commit b4dde43b45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1279 additions and 110 deletions

View file

@ -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:

9
.gitignore vendored
View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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():

View file

@ -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]

View file

@ -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",

View file

@ -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():

View file

@ -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

View file

@ -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",
]

View file

@ -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"]

103
core/sync/wishlist_readd.py Normal file
View file

@ -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"]

View file

@ -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

View file

@ -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"}],

View file

@ -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"

View file

@ -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

View file

@ -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():

View file

@ -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

View file

@ -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"}

View file

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

View file

@ -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",
],
},
{

View file

@ -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 ` +
`<button type="button" class="server-order-badge" onclick="_showServerOrder()" ` +
`title="These tracks match the source, but the playlist is in a different order on ${serverLabel}. Click to view the actual server order.">` +
`&#9888; out of order</button>`;
} 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
? `<img class="server-order-art" src="${esc(t.thumb)}" alt="" loading="lazy" onerror="this.outerHTML='<div class=&quot;server-order-art server-order-art-ph&quot;>&#9835;</div>'">`
: `<div class="server-order-art server-order-art-ph">&#9835;</div>`;
return `
<div class="server-order-row">
<span class="server-order-num">${i + 1}</span>
${art}
<div class="server-order-meta">
<span class="server-order-title">${esc(t.title || 'Unknown')}</span>
<span class="server-order-artist">${esc(t.artist || '')}</span>
</div>
</div>`;
}).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 ? `
<div class="server-order-foot">
<div class="server-order-foot-label">Align this playlist to the source order</div>
<div class="server-order-actions">
<button type="button" class="server-align-btn" onclick="_alignPlaylist(false)">
<span class="server-align-btn-t">Mirror source</span>
<span class="server-align-btn-d">reorder to match the source &middot; remove server-only tracks</span>
</button>
<button type="button" class="server-align-btn" onclick="_alignPlaylist(true)">
<span class="server-align-btn-t">Keep extras</span>
<span class="server-align-btn-d">reorder to match the source &middot; keep server-only tracks at the end</span>
</button>
</div>
<div class="server-order-foot-note">Missing tracks aren't added here &mdash; run a normal sync for those.</div>
</div>` : '';
const overlay = document.createElement('div');
overlay.id = 'server-order-modal';
overlay.className = 'server-order-overlay';
overlay.innerHTML = `
<div class="server-order-dialog" onclick="event.stopPropagation()">
<div class="server-order-head">
<div>
<div class="server-order-h1">${esc(serverLabel)} playlist order</div>
<div class="server-order-sub">the actual order on your server · source order stays the source of truth</div>
</div>
<button type="button" class="server-order-close" onclick="document.getElementById('server-order-modal').remove()">&times;</button>
</div>
<div class="server-order-list">${rows || '<div class="server-order-empty">No server tracks.</div>'}</div>
${alignFoot}
</div>`;
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 ? '&#10003; Re-added' : '&#10003; On wishlist';
}
if (typeof showToast === 'function') {
showToast(
data.added ? `Re-added "${data.name}" to wishlist` : `"${data.name}" is already on the wishlist`,
data.added ? 'success' : 'info',
);
}
} else {
if (el) { delete el.dataset.busy; el.classList.remove('is-busy'); }
if (typeof showToast === 'function') showToast((data && data.error) || 'Could not re-add to wishlist', 'error');
}
} catch (e) {
if (el) { delete el.dataset.busy; el.classList.remove('is-busy'); }
if (typeof showToast === 'function') showToast('Could not re-add to wishlist: ' + e.message, 'error');
}
}
async function openSyncDetailModal(entryId) {
try {
showLoadingOverlay('Loading sync details...');
@ -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 = '<span class="sync-dl-wishlist">→ Wishlist</span>';
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 = `<span class="sync-dl-unmatched" title="Couldn't be resolved to real metadata (wing-it fallback), so it was never added to the wishlist">Unmatched</span>`;
} else {
// Clickable: re-add this exact track to the wishlist with the
// same context the sync originally used.
dlDisplay = `<button type="button" class="sync-dl-wishlist sync-dl-wishlist-btn" `
+ `onclick="_readdSyncWishlist(${entryId}, ${i}, this)" `
+ `title="Re-add to wishlist with the original sync context">&rarr; Wishlist</button>`;
}
}
return `
<tr class="sync-detail-row ${statusClass}">

View file

@ -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;