Commit graph

7 commits

Author SHA1 Message Date
BoulderBadgeDad
0df1d55ed6 Sync: re-resolve a manual match against live Plex when its stored key went stale
wolf39us: a Find & Add manual match got dropped on auto-sync (60->57) with a 404
fetching the stored Plex ratingKey. Root cause: Plex re-keys tracks on a metadata
refresh, and the SoulSync DB id IS that ratingKey — so until a SoulSync rescan BOTH
the durable match's library_track_id AND the file-path self-heal (which reads the
same DB) land on the dead key, fetchItem 404s, the track falls through to fuzzy
(also the dead key) and is dropped.

Fix: when both DB-side lookups miss on Plex, re-resolve the manual match against
LIVE Plex by the matched track's metadata, disambiguated by the stored file path
so the user's EXACT chosen track wins among multiple versions; return the current
live track and heal the stored id + sync cache. A manual match is now never dropped
or silently re-matched — it 'always overrides' as asked. Scoped to Plex (DB-backed
servers materialize off the DB and don't have this re-key problem).

Seam tests: file-path picks the right version over a live alternate, basename
fallback for server-vs-local paths, no-file-match falls back to top hit (never
drop), no results/empty title -> None, and a broken client never raises.
2026-06-22 08:47:24 -07:00
BoulderBadgeDad
1782e36c78 Test the REAL Plex/Jellyfin sync matcher's durable-match fallback (#895)
Closes the gap from cf2b4d7d: that commit unit-tested the DB-only matcher twin but
not services.sync_service._find_track_in_media_server — the actual path a connected
Plex/Jellyfin user hits. Drive it directly (Jellyfin server-type, so no Plex fetchItem
mocking): durable manual match is honored when the volatile cache is wiped, and a
stale library id self-heals via the stored file path.
2026-06-21 18:18:58 -07:00
BoulderBadgeDad
939c660498 Fix #792: 'reconcile' playlist sync mode (edit in place, keep image/description)
Replace mode (default) deletes + recreates the server playlist every sync,
which wipes its custom image, description, and identity. Add an opt-in
'reconcile' sync mode that edits the existing playlist in place — adds the
tracks now in the source, removes the ones gone — without destroying the
object, so the user's custom art/description survive.

- Pure planner plan_playlist_reconcile(current, desired) -> {add, remove}.
- Per-client reconcile_playlist: Plex addItems/removeItems on the same object;
  Navidrome Subsonic updatePlaylist delta (songIdToAdd / descending
  songIndexToRemove); Jellyfin add + remove-by-PlaylistItemId on /Playlists/{id}/Items.
- sync_service: reconcile branch with a replace FALLBACK (if a server's in-place
  edit is unavailable/fails, sync still succeeds destructively — logged loudly).
- Default stays 'replace' (no behavior change). New Settings > Playlist sync mode
  picker (replace/reconcile/append) backed by playlist_sync.mode; per-request
  sync_mode still overrides.
- Reconcile skips the post-sync source-image push so a custom poster isn't
  re-clobbered (the bug).

Tests: planner (add/remove/dedupe/order/empty) + reconcile-or-replace dispatch
(success / false-fallback / exception-fallback / no-method). Per-server in-place
API calls need dev validation against real Plex/Jellyfin/Navidrome.

NOTE: opt-in only; default behavior unchanged.
2026-06-04 15:15:49 -07:00
BoulderBadgeDad
3b155411c2 Fix #787: Find & Add now records a durable manual match that survives a rescan
Find & Add on the playlist-sync page only wrote sync_match_cache, which is
DELETEd wholesale after every DB scan — so the source->library pairing (and
the user's manual matches) reverted to 'extra'/red-dot on the next shallow
scan. The three match stores (sync_match_cache, manual_library_track_matches,
discovery extra_data) were disconnected and all pointed at tracks.id, which a
rescan re-keys (esp. Jellyfin/Navidrome GUIDs).

Unify the match so it's one durable fact, recorded once, honored everywhere:
- Find & Add also writes a durable manual_library_track_matches row (one-way;
  the manual-match tool has no playlist to act on, so no reverse). Carries the
  library file path.
- New library_file_path column (idempotent migration) + find_track_id_by_file_path:
  re-resolve a stale library_track_id after a rescan re-keys the track, and
  self-heal the row.
- The sync compare display's override lookup now falls back to the durable
  manual match (resolve_durable_match_server_id) when sync_match_cache misses —
  so the pairing persists across a scan instead of reverting to a red dot.
  Purely additive: only adds matches when the cache returns nothing.

Tests: durable resolver (valid / stale-reresolve+self-heal / no-match / not-in-
playlist / missing-methods), file_path persistence + find_track_id_by_file_path.
2026-06-04 13:46:24 -07:00
Broque Thomas
feb6778af4 Address Cin review: extract helpers, indexed pool fetch, tidy nits
Three changes folded into one perf+cleanup pass:

1. Indexed fast path for the per-artist pool fetch. The previous
   `search_tracks(artist=name)` call hit
   `unidecode_lower(artists.name) LIKE ?`, a function-in-WHERE that
   can't use `idx_artists_name`. New `MusicDatabase.get_artist_tracks_indexed`
   does a two-step lookup: exact-name match (indexed) plus a
   case-insensitive fallback, then `tracks WHERE artist_id IN (...)`
   via `idx_tracks_artist_id`. Drops per-artist fetch from seconds to
   milliseconds for the common case. The sync helper falls back to
   the old LIKE-based `search_tracks` only when the indexed lookup
   finds nothing, preserving diacritic recall and `tracks.track_artist`
   feature-artist matches with zero regression.

2. Public text-normalization helper. Lifted the body of
   `MusicDatabase._normalize_for_comparison` into
   `core/text/normalize.py:normalize_for_comparison` so callers outside
   the database layer (matching engine, sync pool, future import-side
   comparisons) don't reach across the module boundary into a
   leading-underscore "private" method. The DB method now delegates,
   so existing internal call sites stay untouched. Sync's lazy pool
   now imports the public helper.

3. Artist-name walker extracted. `_artist_name` at module level in
   `services/sync_service.py` replaces two near-identical inline
   str-or-dict-or-fallback walkers (one in `sync_playlist`, one in
   `_find_track_in_media_server`). Returns `''` for None instead of
   the literal string `'None'`.

Plus three small tidies from the same review:

- `_POOL_FETCH_LIMIT = 10000` constant in place of the literal at the
  pool-fetch call site.
- Trimmed the verbose docstring + comment block on the pool helper.
- Set-intersection predicate for the trigger-shape reset in
  `core/automation/api.py` instead of a two-line `or` chain.

Also removed the duplicate `_get_active_media_client()` call at
sync_service.py:212/214 — pre-existing wart that was sitting in the
same block I was editing.

Tests: 21 new tests across `tests/database/`, `tests/sync/`, and
`tests/text/`, plus updates to the existing pool tests to cover the
new fast/fallback split. Full suite stays green (3953 passing).
2026-05-24 23:33:55 -07:00
Broque Thomas
687bb0ca2c Add tests for next_run reset and lazy candidate pool
`tests/automation/test_automation_api.py` gains three update_automation
tests covering the schedule-shape reset:
- trigger_config change blanks next_run
- trigger_type change blanks next_run
- non-trigger field (name) leaves next_run alone

`tests/sync/test_sync_candidate_pool.py` is new — nine tests for the
lazy artist track pool in PlaylistSyncService:
- candidate_pool=None disables pooling and skips the DB call
- first lookup for an artist fetches and caches
- second lookup for the same artist reuses the cache (zero DB calls)
- empty result still cached so the next call short-circuits without SQL
- defensive None return coerced to []
- search_tracks exception returns None and does NOT poison the cache
- pool key is normalized so casing variants share a single fetch
- different artists get separate pool entries
- server_source plumbing survives the trip to search_tracks

All assertions go through fakes / MagicMock — no real DB, no
web_server.py import, no AST-parsing.
2026-05-24 22:58:48 -07:00
Broque Thomas
083355ec8c Persist Find & Add selections as permanent server-playlist match overrides
Closes #585. When a Spotify source track had a versioned suffix not
present in the local file ("Iron Man - 2012 - Remaster" vs "Iron Man"),
the auto-matcher missed the pair. User could click Find & Add to pick
the right local file — that worked, file got added to the Plex
playlist — but the source track stayed in Missing while the added
file appeared in Extra, because the matcher kept no record of the
user-confirmed pairing. On the next sync the source track re-tried
to download.

Fix: every Find & Add selection now writes a (spotify_track_id →
server_track_id) override into sync_match_cache at confidence=1.0.
The matching algorithm runs an override pass BEFORE the existing
exact and fuzzy passes, so any user-confirmed pair short-circuits
straight to "matched" without going through title normalization.
Covers every mismatch class — dash-suffix remasters, covers /
karaoke, alt masters, cross-language titles, typo'd local files.

- core/sync/match_overrides.py (new) — pure helpers
  resolve_match_overrides + record_manual_match. 18 boundary tests
  pin: cache hits, cache misses falling through to normal matching,
  stale-cache (server track removed) handled gracefully, str/int
  id coercion, partial cache hits, defensive against non-dict
  inputs and DB exceptions.
- web_server.py — get_server_playlist_tracks runs the override
  pre-pass before exact/fuzzy matching. server_playlist_add_track
  accepts source_track_id + source_title + source_artist and
  persists the override after every successful add (Plex / Jellyfin
  / Navidrome). source_track_id added to source_tracks payload so
  the frontend has it.
- webui/static/pages-extra.js — _serverSelectTrack sends
  source_track_id + source_title + source_artist when adding a
  track from a mirrored playlist context.
- Sync match cache schema unchanged — already had UNIQUE
  (spotify_track_id, server_source) which fits the override
  semantics perfectly. Manual overrides distinguished from
  auto-discovered matches by confidence=1.0.

Full suite: 3010 passed.
2026-05-14 09:39:24 -07:00