Commit graph

1349 commits

Author SHA1 Message Date
Broque Thomas
52ee406a6c Fix quarantine action button escaping 2026-05-17 21:47:56 -07:00
Broque Thomas
94f6c950cb Polish manual library match tool card 2026-05-17 20:32:18 -07:00
Broque Thomas
42f4aa5eac Add manual library track matching 2026-05-17 20:27:05 -07:00
Broque Thomas
3b62bcab0c Add missing-track import from existing library files
Show actionable missing album tracks in the enhanced library from canonical metadata, with a practical Manage flow for Add to Library or I Have This.

Implement I Have This as a non-destructive copy/import path: copy the chosen existing file, run normal post-processing with the missing track context, insert the real library row, and inherit album identity tags from target siblings so Navidrome does not split albums.

Improve the modal with selectable search results, visible import progress, disabled controls during import, and missing-track row styling.
2026-05-17 13:58:11 -07:00
Broque Thomas
e132f1e295 chore: bump version to 2.5.4
- _SOULSYNC_BASE_VERSION in web_server.py
- WHATS_NEW key + date in helper.js (strips unreleased flag from Amazon entries)
- fallback version string in helper.js
2026-05-16 23:09:23 -07:00
Broque Thomas
42a833fcb2 Amazon Music: UI badges, enrichment match chips, watchlist linking, metadata cache
- Artist cards, hero section, and enhanced view now show Amazon Music badges
  when amazon_id is populated (AMAZON_LOGO_URL constant, orange #FF9900 brand)
- Enhanced view artist and album match status rows include amazon_match_status
  chip with click-to-rematch via openManualMatchModal
- getServiceUrl: added amazon (album/track ASIN → music.amazon.com) and fixed
  missing discogs entries; serviceLabels adds tidal/qobuz/amazon
- Enhanced view enhanced-artist-id-badges includes amazon_id entry
- DB SELECTs for library artists list and artist detail now return amazon_id;
  both response dicts include the field
- watchlist_artists migration adds amazon_artist_id column
- Watchlist config GET: amazon_artist_id in SELECT/WHERE/response (index 18)
- Watchlist artists list response includes amazon_artist_id
- link-provider endpoint: amazon added to valid_providers and col_map
- _populateLinkedProviderSection: amazonId param + Amazon Music source row
- Watchlist card source badges render Amazon pill (watchlist-source-amazon CSS)
- _openSourceSearch labels map includes amazon
- service_search: amazon_worker injected via init(); _search_service amazon branch
  uses search_artists/albums/tracks, same {id,name,image,extra} return shape
- _SERVICE_ID_COLUMNS: amazon → amazon_id for artist/album/track
- _init_service_search call passes amazon_worker_obj
- amazon_client._fetch_album_metas: 5-minute TTL cache per ASIN — cached hits
  skip _rate_limit() and HTTP call entirely; fixes ~10s artist detail load
- registry.py: removed amazon from METADATA_SOURCE_PRIORITY and
  METADATA_SOURCE_LABELS — T2Tunes has no discography API, cannot serve as a
  primary metadata source; Amazon remains a download source + ASIN enricher
- Settings metadata source dropdown and help text updated accordingly
2026-05-16 22:52:27 -07:00
Broque Thomas
5450f4ac5e Wire Amazon Music enrichment worker into dashboard UI
Adds full parity with Deezer/Qobuz/Tidal/Discogs in every dashboard
UI layer — orb button, live tooltip, WebSocket push, rate speedometer.

- webui/index.html: Amazon enrichment orb button after Discogs
- webui/static/amazon.svg: local icon (a + smile, same pattern as
  hydrabase.png — avoids external URL dependency)
- webui/static/style.css: Amazon button/spinner/tooltip CSS with
  FF9900 brand color; added to mobile tooltip suppress list
- webui/static/worker-orbs.js: Amazon orb in WORKER_DEFS [255,153,0]
- webui/static/api-monitor.js: Amazon in rate gauge services list,
  label, and color map
- webui/static/enrichment.js: updateAmazonEnrichmentStatusFromData,
  toggleAmazonEnrichment, DOMContentLoaded init + 2s poll
- webui/static/core.js: socket.on enrichment:amazon-enrichment listener
- web_server.py: amazon-enrichment added to _emit_enrichment_status_loop
  workers dict so WebSocket pushes fire every 2s
2026-05-16 17:43:38 -07:00
Broque Thomas
4fce832ae1 Add Amazon Music enrichment worker
Background worker matching library artists/albums/tracks to Amazon ASINs
via T2Tunes search. Follows same 6-tier priority queue as Deezer/iTunes/
Spotify/Qobuz/Tidal workers. Backfills artist thumbnails from album cover
stand-ins (T2Tunes exposes no direct artist images).

- core/amazon_worker.py: new AmazonWorker class with full parity
- database/music_database.py: expand _add_amazon_columns to cover
  amazon_id/amazon_match_status/amazon_last_attempted on artists,
  albums, and tracks (was artists-only)
- web_server.py: import, init, register in enrichment panel, add to
  scan pause/resume dicts and rate monitor key map
- helper.js: WHATS_NEW 2.5.3 entry for enrichment worker
2026-05-16 17:30:48 -07:00
Broque Thomas
51e00d4ebf Fix Amazon Music search quality: images, dedup, explicit stripping, album/artist clicks
- All search_raw calls switched from single-type to types="track,album" — T2Tunes only
  returns results when both types are requested together
- _fetch_album_metas: parallel fetch (up to 5 workers) of album cover art via
  album_metadata(asin) — T2Tunes search results carry no image URLs
- search_tracks: populates image_url, release_date, total_tracks from album meta
- search_artists: strips feat. credits via _primary_artist() so "Artist feat. X" and
  "Artist ft. Y" collapse to one "Artist" entry; uses album cover as artist image
  stand-in (same approach as iTunes — T2Tunes has no artist images)
- search_albums: name-based dedup (display_name + artist key) instead of ASIN-based;
  populates image_url, release_date, total_tracks from album meta (cap 10 ASIN fetches)
- _strip_edition(): strips [Explicit]/(Explicit) from track/album names — explicit is
  the default version; Clean/Edited/Censored labels kept as-is so they stay distinct
- get_album(): applies _strip_edition to name and _primary_artist to artist so
  MusicBrainz preflight matching doesn't fail on "[Explicit]" album names
- get_album_tracks(): populates track_number and disc_number from T2TunesStreamInfo
  instead of hardcoding None — fixes track ordering in multi-track album downloads
- get_artist() / get_artist_albums(): _unslugify() converts slug artist IDs back to
  search names; _primary_artist() in comparison handles feat-annotated results
- SOURCE_ONLY_ARTIST_SOURCES: added "amazon" so artist detail page doesn't 404
- build_source_only_artist_detail: added amazon_client param + dispatch branch
- web_server.py: resolve amazon_client in _build_source_only_artist_detail wrapper;
  add source_override=="amazon" branch in get_spotify_album_tracks endpoint
- 77 tests covering all above paths; all pass
2026-05-16 15:55:15 -07:00
Broque Thomas
d39679951b Wire Amazon Music into enhanced search and global search source picker
- Add 'amazon' to VALID_SOURCES (and transitively VALID_STREAM_SOURCES)
  in core/search/orchestrator.py so the backend accepts it as a
  requested source without returning 400
- Add resolve_client('amazon') case — mirrors musicbrainz pattern,
  gets the cached AmazonClient from the metadata registry
- Add 'amazon' to _alternate_sources() so it appears as a tab when
  another source is primary (always available, no credentials)
- Add SERVICE_CONFIG_REGISTRY entry 'amazon': {'always': True} so
  /api/settings/config-status reports it as configured
- Add SOURCE_LABELS['amazon'] and SOURCE_ORDER entry in
  shared-helpers.js so both enhanced search and global search show
  the Amazon Music tab
- Add 'amazon' to _ALWAYS_CONFIGURED_SOURCES so the picker never
  dims the tab (no credentials required)
- Add .enh-tab-amazon.active CSS (Amazon orange #FF9900)
- 3530 tests pass
2026-05-16 14:18:18 -07:00
Broque Thomas
1f579cede8 Add Amazon Music as a primary metadata source
Wires AmazonClient into the metadata source registry following the
exact same pattern as DeezerClient. No existing source paths touched.

- Add get_album_metadata / get_artist_info / get_artist_albums_list
  aliases to AmazonClient (mirrors DeezerClient interface aliases)
- Register amazon in METADATA_SOURCE_PRIORITY and METADATA_SOURCE_LABELS
- Add _get_amazon_factory() + get_amazon_client() to registry.py
- Add amazon branch to get_client_for_source(); thread amazon_client_factory
  kwarg through get_primary_client() and get_primary_source_status()
- Re-export get_amazon_client from the core.metadata_service shim
- Add Amazon Music option to Settings metadata source dropdown
- 3530 tests pass
2026-05-16 13:52:26 -07:00
Broque Thomas
791e3630ff fix(amazon): wire amazon into all streaming-source guards
`validation.py` had amazon absent from `_streaming_sources`, causing
Amazon TrackResult objects (bitrate=None, size=0) to fall through to
the Soulseek P2P code path and get rejected by
`filter_results_by_quality_preference`. Every album track was marked
not found.

Fix: add 'amazon' to every streaming-source guard tuple/set that was
previously missing it:
- core/downloads/validation.py — primary bug fix (quality-filter bypass)
- core/downloads/status.py — _STREAMING_SOURCE_NAMES frozenset
- core/downloads/task_worker.py — hybrid fallback client map
- core/imports/side_effects.py — || filename→stream-id extraction
- web_server.py — is_streaming_source, transfer list display,
  candidate source label, _try_source_reuse, _store_batch_source
- tests/test_download_plugin_conformance.py — registry count + parametrize

Also updates the 2.5.3 What's New entry to drop the stale
"not yet wired" disclaimer.
2026-05-16 10:24:13 -07:00
Broque Thomas
fa73c41ef6 Wire Amazon Music as a first-class download source
Follows the exact same standard as Tidal, Qobuz, HiFi, and Deezer.

registry.py — import + register AmazonDownloadClient as 'amazon'.

amazon_download_client.py — read amazon_download.quality / allow_fallback
from config on init; pass quality as preferred_codec to AmazonClient;
_download_sync codec waterfall respects allow_fallback flag.

download_orchestrator.py — reload_settings() updates preferred_codec +
allow_fallback on the live client after a settings save. 'amazon' added
to _streaming_sources so search_and_download_best routes it correctly.

api_call_tracker.py — 'amazon' registered in RATE_LIMITS (120/min),
SERVICE_LABELS, and SERVICE_ORDER so API call monitoring shows Amazon.

web_server.py — 'amazon_download' added to the settings service loop.
'amazon' added to serverless_sources (no slskd probe needed). Streaming
file-finder extended to handle amazon username + ||asin||title encoding
(extension-less fuzzy match, same as Tidal/Qobuz/HiFi). New endpoint:
GET /api/amazon/test-connection → checks T2Tunes proxy status.

webui/index.html — amazon-download-settings-container: quality dropdown
(flac/opus/eac3), allow-fallback checkbox, test-connection button.

webui/static/settings.js — 'Amazon Music' added to HYBRID_SOURCES,
_hybridSourceEnabled, allSources mode list, loadSettings(), saveSettings()
payload, updateDownloadSourceUI() show/hide + auto-test. New
testAmazonConnection() function.
2026-05-16 09:40:50 -07:00
Broque Thomas
85984d4174 Amazon Music provider: metadata client + download source (T2Tunes)
core/amazon_client.py — T2Tunes-backed metadata client following the
DeezerClient/iTunesClient contract. Exposes search_tracks, search_artists,
search_albums, get_track_details, get_album, get_album_tracks, get_artist,
get_artist_albums, get_track_features. T2TunesStreamInfo dataclass captures
the hex decryption key returned by the proxy (CENC/AES-128). Handles the
"stremeable" API typo. 0.5 s rate-limit guard + api_call_tracker.

core/amazon_download_client.py — DownloadSourcePlugin backed by the above
client. Codec waterfall: FLAC → Opus → EAC3. Downloads the encrypted MP4
container, decrypts with ffmpeg -decryption_key, yields the native audio
file (.flac / .opus / .eac3). Not yet wired into the app source registry —
validated in isolation only; see tests/tools/.

tools/t2tunes_probe.py + tools/t2tunes_media_plan.py — standalone CLI tools
used for live API exploration during development.

tests/tools/test_amazon_client.py — 72 unit tests (all mocked).
tests/tools/test_amazon_download_client.py — 52 unit tests (all mocked).
124 tests pass.
2026-05-16 07:46:41 -07:00
Broque Thomas
877d0e7d81 Personalized pipeline: auto-refresh stale snapshots after watchlist scan
Snapshots now track when their source data changes. Watchlist scan
emits stale flags on the playlists whose underlying pool just got
refreshed; the next pipeline run sees the flag and regenerates the
snapshot before syncing, so the server playlist never lags the source.

Schema:
- new `is_stale INTEGER NOT NULL DEFAULT 0` column on
  `personalized_playlists`, plus an idempotent ADD COLUMN migration
  in `ensure_personalized_schema` for installs created before this PR.
- `PlaylistRecord.is_stale: bool = False` exposed on the dataclass so
  callers can branch on freshness without re-querying.

Manager:
- new `mark_kinds_stale(kinds, profile_id=None)` flips the flag in
  bulk for a list of kinds (used by upstream data refreshers).
- `_persist_snapshot` clears `is_stale = 0` on successful refresh.
- SELECT statements + `_row_to_record` updated to read the column
  (with tuple-form length guard for safety).

Pipeline:
- `_build_payloads_for_kinds` now branches: refresh_first=True OR
  `existing.is_stale` -> refresh_playlist, else read existing
  snapshot. So the auto-refresh kicks in without needing the user to
  toggle the refresh-each-run option.

Watchlist scanner emits stale flags at three sites:
- after `update_discovery_pool_timestamp` -> marks pool-fed kinds
  stale: hidden_gems, discovery_shuffle, popular_picks, time_machine,
  genre_playlist, daily_mix.
- after release_radar `save_curated_playlist` -> marks `fresh_tape`.
- after discovery_weekly `save_curated_playlist` -> marks `archives`.

All three calls go through a module-level `_mark_personalized_kinds_stale`
helper that builds a PersonalizedPlaylistManager with `deps=None` (only
DB access is needed for the flag update — no generator dispatch). Each
call is wrapped in try/except so a flag failure can never abort the
scan itself.

Tests:
- new `TestStaleFlag` class in `test_personalized_manager.py` (6
  tests): default-false, single-kind flip, multi-kind, profile
  scoping, refresh-clears, empty-list noop.
- two new pipeline tests pin the auto-refresh dispatch:
  `test_stale_snapshot_auto_refreshes_even_without_refresh_first`
  and `test_non_stale_snapshot_skips_refresh`.
- existing stub-manager `SimpleNamespace` returns gained
  `is_stale=False` so the new attribute read doesn't AttributeError.

Full suite: 3391 pass.

User-facing WHATS_NEW entry added under 2.5.2 (above the prior
pipeline auto-sync entry) describing the auto-refresh behavior.
2026-05-15 20:53:03 -07:00
Broque Thomas
66390e685a Personalized pipeline picker: full-width column layout + label overrides
The picker was rendering as a narrow centered column overlapping the
description text because:
1. The outer `.config-row` defaults to flex-direction:row with the
   label on the left and the input on the right at fixed width — works
   for a select / textbox, breaks for a tall scrolling multi-select.
2. Inner `<label>` rows in the picker were inheriting
   `.placed-block-config label` (uppercase / 50px min-width /
   letter-spacing 0.5px) so each row turned into a 50-pixel-wide
   uppercase chip.

Fixes:
- Outer wrapper switched to `flex-direction:column;align-items:stretch`
  + `width:100%;box-sizing:border-box` on the picker div.
- Inner row + section-header inline styles override font-size,
  text-transform, letter-spacing, and min-width so the picker rows
  render at normal text size with proper full-width alignment.

Variant rows indent under their kind header at 20px so the visual
grouping is obvious.
2026-05-15 19:48:06 -07:00
Broque Thomas
e1f0810df5 Personalized pipeline: UI multi-select picker for kinds + variants
The action was registered + the block declared, but the automation
builder's per-action config renderer didn't have a case for
`personalized_pipeline` so users only saw the bare card with the
generic delay-minutes input — no way to select which playlists to
sync. This commit adds the multi-select picker.

Backend:
- `core/personalized/api.list_kinds(manager=...)` now optionally
  takes a manager and includes the resolved variant list per kind
  (calls each spec's variant_resolver(deps) when present). Singleton
  kinds get an empty `variants` list. Variant-bearing kinds
  (time_machine / genre_playlist / daily_mix / seasonal_mix) get
  their full enumerated set.
- `web_server.py` `/api/personalized/kinds` route now passes a built
  manager so the variants list lands in the response.

Frontend:
- `webui/static/stats-automations.js` `_renderBlockConfigFields`
  gains a `personalized_pipeline` branch that renders a scrollable
  multi-select picker:
  - Singletons (Hidden Gems, Discovery Shuffle, Popular Picks,
    Fresh Tape, The Archives) = one checkbox row per kind
  - Variant kinds = a section header + one checkbox row per variant
    (e.g. Time Machine: 1960s/1970s/.../2020s; Seasonal: halloween/
    christmas/valentines/summer/spring/autumn)
  - Pre-checks rows that match the existing `kinds` config on edit
- New `_autoLoadPersonalizedKinds(slotKey)` fetches `/api/personalized/kinds`
  (cached after first load), renders the picker DOM, and pre-checks
  saved selections via `data-kind` / `data-variant` attributes on
  the checkboxes.
- `_renderBuilderCanvas` calls the loader for any `cfg-*-kinds-picker`
  it finds in the freshly-rendered slots.
- The save-time `_collectActionConfig` walks the picker's checked
  inputs (matched by `data-kind` attribute) and emits
  `{kinds: [{kind, variant?}, ...], refresh_first, skip_wishlist}`
  in the same shape the handler expects.

Tests:
- `tests/automation/test_automation_blocks.py::_FIELD_TYPES` adds
  'personalized_playlist_select' so the block-shape regression test
  accepts the new field type. (Test was failing because it whitelists
  every field type used across all blocks.)
- 189 automation + personalized API tests pass; full suite intact.
2026-05-15 19:33:34 -07:00
Broque Thomas
cc44254bf9 Personalized playlist pipeline: auto-sync discover-page playlists
Follow-up to the personalized-playlists standardization PR. New
`personalized_pipeline` automation action syncs selected discover-
page playlists (Hidden Gems / Discovery Shuffle / Time Machine /
Genre / Daily Mix / Fresh Tape / The Archives / Seasonal Mix) to
the active media server + queues missing tracks for download.

Same pattern as the existing mirrored `playlist_pipeline` but two
phases instead of four — no REFRESH (no external source to re-pull)
and no DISCOVER (manager-backed snapshots are already metadata-
matched). Pipeline shape:

    SNAPSHOT → SYNC → WISHLIST

Where SNAPSHOT either reads the persisted track list from
`PersonalizedPlaylistManager` (default) or refreshes it first when
`refresh_first=true` (cron use case: regenerate Hidden Gems nightly
and sync the fresh set).

Shared helper extraction:

PHASE 3 (SYNC loop) + PHASE 4 (WISHLIST tail) lifted out of mirrored
`playlist_pipeline` into `core/automation/handlers/_pipeline_shared.py`
as `run_sync_and_wishlist(deps, automation_id, playlists, sync_one_fn,
sync_id_for_fn, ...)`. Both pipelines call it. Mirrored injects
`auto_sync_playlist` as the per-playlist sync function; personalized
injects a thin wrapper that launches `_run_sync_task` directly with
a pre-built tracks_json. Same sync-state polling / progress emission
/ status counting / wishlist trigger logic — 0 duplication.

Files added:
- core/automation/handlers/_pipeline_shared.py
- core/automation/handlers/personalized_pipeline.py
- tests/automation/test_handlers_personalized_pipeline.py

Files changed:
- core/automation/handlers/playlist_pipeline.py: PHASE 3+4 replaced
  with shared helper call (~100 lines deleted, 1 helper invocation
  added; behavior identical).
- core/automation/deps.py: new `build_personalized_manager` field
  (lazy builder so the pipeline gets a fresh PersonalizedPlaylistManager
  per run).
- core/automation/handlers/__init__.py + registration.py: register
  `personalized_pipeline` action with the shared `pipeline_running`
  guard so it can't overlap mirrored.
- core/automation/blocks.py: new `personalized_pipeline` block
  declaration with config_fields (kinds multi-select, refresh_first,
  skip_wishlist).
- web_server.py: thread `_build_personalized_manager` into
  AutomationDeps construction.
- All 5 automation test fixtures: `_build_deps` adds
  `build_personalized_manager=lambda: None` stub.
- tests/automation/test_handler_registration.py:
  EXPECTED_ACTION_NAMES + EXPECTED_GUARDED_ACTIONS gain
  `personalized_pipeline`.

Trigger schema:

    {
      "_automation_id": "...",
      "kinds": [
        {"kind": "hidden_gems"},
        {"kind": "time_machine", "variant": "1980s"},
        {"kind": "seasonal_mix", "variant": "halloween"}
      ],
      "refresh_first": false,
      "skip_wishlist": false
    }

Tests (14 new, 178 automation total):
- _track_to_sync_shape: basic shape, source ID fallback chain,
  no-id returns empty string
- empty config / non-list kinds / empty kinds list all return
  error + clear pipeline_running flag
- _build_payloads_for_kinds: skips invalid entries, skips kinds
  with no tracks, refresh_first vs ensure dispatch, payload shape
  + sync_id format, manager exception swallowed continues
- _sync_personalized_playlist: launches background thread + returns
  status='started'
- happy path: stubbed sync_states drives helper to completion, flag
  cleaned up

Full suite: 3383 passed.

Note: the trigger UI block declares config_fields but the frontend
doesn't yet render the `personalized_playlist_select` multi-select
type — usable today via API; polished UI ships in a follow-up
frontend PR.
2026-05-15 18:41:08 -07:00
Broque Thomas
9cf1fe492b Personalized playlists (5/5): WHATS_NEW entry
User-facing summary of the standardization work — all 8 personalized
discover-page playlists unified behind one storage layer, manager,
and REST surface. Prerequisite for the playlist pipeline integration
landing in the next PR.
2026-05-15 17:26:04 -07:00
Broque Thomas
19a18ba992 Dashboard activity feed: stop showing 'NaNmo ago'
Recent activity items on the dashboard all rendered 'NaNmo ago'
because the formatter parsed `activity.time` (a human label like
'Now' / 'Just now') with `new Date(...)` -> Invalid Date -> NaN
arithmetic -> 'NaNmo ago'.

Backend (`core/runtime_state.add_activity_item`) has always emitted
`activity.timestamp` (Unix epoch seconds) alongside the label.
Frontend now uses the epoch for relative-time formatting via a new
local `_activityTimeAgo` helper:
- typeof timestamp === 'number' -> diff against Date.now() in ms
- < 60s -> 'Just now'
- < 60m -> 'Nm ago'
- < 24h -> 'Nh ago'
- < 30d -> 'Nd ago'
- otherwise 'Nmo ago'
- falls back to the literal `activity.time` label only when no
  timestamp is present (legacy items / future shapes)

Both call sites in api-monitor.js (initial render + timestamp-only
refresh path) updated to the new helper.
2026-05-15 14:02:00 -07:00
Broque Thomas
d9529fc801 Token leak round 2: artist endpoint + playlist sync + URL-encoded redaction
The first token-leak fix scrubbed the artwork URL fixer's own log
calls. This catches three more sites that ALSO leaked tokens, plus
one upstream gap that let URL-encoded tokens slip through the
redactor.

Three sites in `web_server.py` (artist endpoint at line 8765-8773):

- "Artist image before fix: '...'" -- logged the raw image_url with
  the auth token in plain form.
- "Artist image after fix: '...'" -- logged the URL-encoded form
  after it had been wrapped in the image proxy
  (`/api/image-proxy?url=<percent-encoded-token>`).
- "Final artist data being sent: {...}" -- dumped the entire
  artist_info dict on every render, including the image_url field.

All three were dev-time debug noise. Removed entirely. The "No
artist image URL found" warning at line 8770 stays (no URL, just
the artist name).

One site in `core/discovery/sync.py:402`:

- "[PLAYLIST IMAGE] image_url=..." -- logged the playlist poster URL
  during sync. Same auth-token leak risk for Plex / Jellyfin
  playlists. Changed to log only `has_image=True/False`.

Upstream gap in `_redact_url_secrets`:

- The original regex only matched plain query params (`?key=value`).
  When an auth-bearing URL gets wrapped inside another URL's query
  string (our `/api/image-proxy?url=<encoded>` flow) the auth params
  end up percent-encoded -- `%3FX-Plex-Token%3D...` -- and slipped
  through.
- New second pattern catches the URL-encoded form. Both passes run
  on every redact call; idempotent.

Verified manually:
  /api/image-proxy?url=...%3FX-Plex-Token%3DABC...
  -> /api/image-proxy?url=...%3FX-Plex-Token%3D***REDACTED***

6 artwork tests pass.
2026-05-15 09:33:15 -07:00
Broque Thomas
2fe1926074 Stop leaking Plex / Jellyfin / Navidrome tokens into app.log
The artwork URL normalizer was logging the full constructed media-
server URL on every cover-art lookup at INFO level, including the
auth query params (X-Plex-Token / X-Emby-Token / Subsonic t+s+p).
Those lines pile up in app.log on disk -- anyone with read access to
the log file gains full read access to the user's media server.

Also dropped the noisy per-call "Plex/Jellyfin/Navidrome config -
base_url: ..., token: ..." INFO lines that fired on every thumbnail.
Even the truncated `token[:10]` form is enough partial-known-plaintext
to be uncomfortable to leak.

- New `_redact_url_secrets` helper masks the values of X-Plex-Token,
  X-Emby-Token, api_key, apikey, Subsonic t / s / p, generic token /
  password query params. Regex anchored on `?` or `&` boundary so
  short keys like `t` don't false-match inside `format=Jpg`.
- "Fixed URL: ..." log calls moved from INFO to DEBUG so they don't
  persist by default, and the URL passed in is run through the
  redactor first.
- Per-call "Plex config - ..." / "Jellyfin config - ..." /
  "Navidrome config - ..." INFO lines removed entirely. Config
  inspection has dedicated UI; per-thumbnail spam belongs to no one.
- Error-path logging (line 149) also routed through the redactor in
  case the failing URL had auth params attached.

Users with existing app.log files containing the leaked tokens
should rotate / wipe the log. Plex tokens can be regenerated by
signing out of all devices in Plex settings; Jellyfin api_keys can
be revoked from the dashboard; Navidrome users should rotate the
account password.
2026-05-15 09:23:53 -07:00
Broque Thomas
b42cafa150 AcoustID + quarantine modal: three bug fixes (closes #607, closes #608)
Issue #607 (AfonsoG6) -- two AcoustID problems:

1. Live recordings false-quarantining as "Version mismatch: expected
   '... (Live at Venue)' (live) but file is '...' (original)" because
   MusicBrainz often stores the recording entity with a bare title --
   the venue / live annotation lives on the release entity, not the
   recording. The audio fingerprint correctly identifies the live
   recording, but the title-text comparison flagged it as wrong.

   New pure helper `core/matching/version_mismatch.py:is_acceptable_version_mismatch`
   accepts the mismatch only when:
     - One-sided AND involves 'live': exactly one side is 'live' and
       the other is bare 'original'. Two-sided mismatches stay strict.
     - Fingerprint score >= 0.85 (stricter than the existing 0.80
       minimum -- escape valve only fires when AcoustID is more
       confident than its own threshold).
     - Bare title similarity >= 0.70.
     - Artist similarity >= 0.60.

   Other version markers (instrumental, remix, acoustic, demo, etc)
   stay strict -- those have distinct fingerprints AND MB always
   annotates them in the recording title. The existing
   test_acoustid_version_mismatch.py suite passes unchanged.

2. Audio-mismatch failure message reported "identified as '' by ''
   (artist=100%)" when AcoustID returned multiple recordings -- prior
   code mixed `recordings[0]`'s strings (which can be empty) with
   `best_rec`'s scores. Now uses `matched_title` / `matched_artist`
   consistently in both the high-confidence-skip path and the final
   fail message.

Issue #608 (AfonsoG6) -- quarantine modal:

3. Approve / Delete buttons silently no-op'd when the filename
   contained an apostrophe -- the unescaped quote broke the inline JS
   in the onclick handler. Now wraps the id via
   `escapeHtml(JSON.stringify(id))`, which round-trips quotes /
   backslashes / unicode / newlines safely through the HTML attribute
   to JS string boundary.

4. Bonus UX: quarantine entry expanded view now shows source uploader
   (username) and original soulseek filename when the sidecar carries
   that context -- helps trace which uploader the bad file came from.
   Backend exposes `source_username` + `source_filename` fields from
   `sidecar.context.original_search_result`. Degrades to '' on legacy
   thin sidecars.

Tests:
- 23 new boundary tests in tests/matching/test_version_mismatch.py
  pin every shape: equal versions trivial, one-sided live both
  directions, threshold floors (each just below default -> reject),
  two-sided strict, non-live one-sided strict (covers exact
  test_instrumental_returned_for_vocal_request_fails scenario),
  custom-threshold overrides.
- 4 existing test_acoustid_version_mismatch.py tests pass unchanged.
- 507 AcoustID / matching / imports tests pass.
2026-05-15 08:55:06 -07:00
Broque Thomas
b05ba5d498 Reorganize: optional embedded-tag mode (closes #592)
Adds an opt-in alternative metadata source for reorganize. The
existing API path (query Spotify / iTunes / Deezer / Discogs /
Hydrabase for the canonical tracklist) stays the default and is
unchanged. The new tag mode reads each file's embedded tags as the
source of truth instead -- useful for well-enriched libraries where
API drift can produce inconsistent renames, and avoids API calls
entirely.

- New pure helper `core/library/reorganize_tag_source.py` adapts the
  output of `read_embedded_tags` (the same mutagen path the audit-
  trail modal uses) to the `api_album` / `api_track` shapes that
  `_build_post_process_context` already consumes. Handles ID3-style
  "5/12" track + disc shapes, multi-value Artists tags, year
  normalization across 5 date formats, releasetype canonical tokens,
  multi-artist string splits across 9 separators.
- `plan_album_reorganize` accepts `metadata_source: 'api' | 'tags'`
  (default 'api') and `resolve_file_path_fn`. Tag mode branches into
  a new `_plan_from_tags` that reads each track's file and produces
  per-item `api_album` + `api_track` instead of a shared one.
- `_run_post_process_for_track` accepts a per-item `api_album`
  override so each file's own album metadata flows through post-
  process (not a single shared dict).
- `total_discs` in tag mode honors the `totaldiscs` tag and the
  trailing `/N` of an ID3 `discnumber = "1/2"`. Partial-album
  reorganize still routes into the correct `Disc N/` subfolder when
  the tag knows the total even if not all discs are present locally.
- Bare `discnumber = "1"` no longer poisons `total_discs` -- it
  carries no total signal.
- `reorganize_album` surfaces a tag-mode-specific error when no
  files are readable, instead of the API-mode "run enrichment first"
  message which would mislead in tag mode.
- `QueueItem.metadata_source` field, `enqueue` / `enqueue_many`
  pass-through, runner injects `item.metadata_source` into
  `reorganize_album`.
- `web_server.py` endpoints accept `mode` body param. Falls back to
  the `library.reorganize_metadata_source` config setting, then to
  'api'. Strict allowlist (api / tags) -- anything else falls back.
- Frontend: per-album modal + reorganize-all modal both grow a new
  "Metadata Mode" dropdown above the source picker. Tag mode hides
  the source picker (irrelevant). Choice persisted in localStorage.
  Both preview + execute fetches send `mode` in body.

Tests:
- 49 boundary tests on the pure helper pin every shape: ID3 "5/12",
  multi-artist split, year normalization, releasetype validation,
  total_discs precedence, defensive paths.
- 6 planner-level integration tests pin the wiring: tag-mode with
  good tags, partial-disc with totaldiscs tag, file missing,
  some-match-some-fail, defensive resolve_file_path_fn=None,
  API-mode regression guard.
- All 3171 tests pass; 52 existing reorganize tests unchanged.
2026-05-15 07:56:18 -07:00
Broque Thomas
f52e056827 Update style.css 2026-05-14 20:24:51 -07:00
Broque Thomas
2ccada088d Dashboard: cursor-following accent blob + darker cards
Two-layer accent glow that follows the cursor across the bento grid:

- Soft halo (1280px, blur 48) lerps toward target with a delay; bright
  inner core (540px, blur 18, screen-blended) lerps faster.
- Both layers gently pulse on different rhythms so the blob feels alive
  even when stationary.
- Target = cursor position when hovering any .dash-card; otherwise the
  grid center (idle resting position). On leaving cards/gap, blob waits
  1.5s before drifting back to center -- a small dwell that lets it
  feel intentional rather than skittish.
- Card backgrounds darkened to near-black with stronger borders for
  contrast against the accent glow.

Performance:
- requestAnimationFrame loop runs only while the blob is moving and
  idles when settled at the target.
- Two-pass per frame: read all getBoundingClientRect() first, then
  write CSS vars in a second pass -- one layout flush per frame
  instead of one per card.
- IntersectionObserver snaps to grid center the first time the
  dashboard becomes visible (handles the case where home page is
  hidden at attach time).

Honors the existing reduce-effects setting:
- CSS hides both blob layers via body.reduce-effects.
- JS MutationObserver on body class kills the rAF loop when toggled
  on; re-snaps to center and restarts when toggled off.
- prefers-reduced-motion media query disables the pulse animations.
2026-05-14 20:19:58 -07:00
Broque Thomas
acce083675 Dashboard bento grid redesign + responsive breakpoints
Replaces the old stacked dashboard with a bento grid: services, stats,
library, syncs, tools, activity, enrichment each live in their own card.

- 3-col on desktop (>=1500px), 2-col on laptop, 2-col tighter on tablet,
  1-col stack on mobile (<700px). Sub-grids inside each card adapt at
  every breakpoint (service tiles 3-2-1, stat cards 3-2, gauge tiles
  10-5-4-3-2).
- Cards use the user's accent color for glow + hover border + CTA icons
  (was hardcoded per-card hues).
- Mount fade-up with per-card stagger; subtle bloom drift; reduced-motion
  honored.
- Enrichment row collapses the per-service gauge tile (hides the 3-stat
  row, scales the gauge SVG to fill the tile width) so all 10 services
  fit on one row at desktop.
- Recent syncs stacks vertically inside its bento card instead of
  overflowing horizontally.
- Every existing id, button, and JS hook preserved -- no behavior change,
  pure visual + responsive overhaul.
2026-05-14 19:16:46 -07:00
Broque Thomas
544cdb49fd Bump version to 2.5.3 2026-05-14 16:28:51 -07:00
Broque Thomas
2f284efa57 Retag now re-embeds LYRICS tag instead of leaving it empty
Discord report (netti93). The download flow runs `enhance_file_metadata`
(clears all tags) then `generate_lrc_file` (writes .lrc sidecar AND
embeds USLT). The retag flow only ran the first half — `enhance_file_metadata`
cleared USLT and there was no follow-up to restore it.

Two coordinated fixes (no new setting per kettui scope discipline —
user described it as "might even be an idea," consistency was the
load-bearing ask).

Fix 1 — retag calls generate_lrc_file after enhance

`core/library/retag.py:execute_retag` now invokes
`deps.generate_lrc_file` right after the `enhance_file_metadata`
call, mirroring the download pipeline. New `generate_lrc_file`
field on `RetagDeps`, defaults to None for backward compat with
any test caller that builds RetagDeps without it. Web_server's
`_build_retag_deps()` factory wires in the real
`core.metadata.lyrics.generate_lrc_file`.

Placement matters — runs BEFORE `safe_move_file` so the helper
sees the audio file at its current path with its existing sidecar
(which retag hasn't moved yet). After the embed, the audio file
gets moved with USLT now present; the sidecar move step that
follows is unaffected.

Fix 2 — create_lrc_file re-embeds from existing sidecar

`core/lyrics_client.py:create_lrc_file` used to early-return True
when an .lrc / .txt sidecar already existed (skipping the LRClib
fetch). For the retag case the sidecar is already there, so the
shortcut hit and USLT was never re-written. Now the helper reads
the existing sidecar and calls `_embed_lyrics` with its content
before returning. Empty / unreadable sidecars short-circuit
silently — defensive, no crash. Download flow unaffected because
no sidecar exists at fetch time.

7 boundary tests pin: existing .lrc triggers re-embed, existing
.txt triggers re-embed, empty sidecar skips embed, unreadable
sidecar swallows error, no sidecar falls through to LRClib (download
path regression guard), RetagDeps.generate_lrc_file field accepted,
field optional for backward compat.

Full suite: 3120 passed.
2026-05-14 15:52:05 -07:00
Broque Thomas
30f017d1f0 Stop writing TRCK as "6/0" when album total_tracks is unknown
Discord report (netti93): downloaded album tracks were tagged with
TRCK = "6/0" instead of "6/13" when source data was incomplete. The
retag tool wrote correct "6/13" because core/tag_writer.py already
handled the case.

Trace: core/metadata/enrichment.py:105 formatted unconditionally as
f"{track_number}/{total_tracks}" and many album-dict construction
sites pass total_tracks: 0 (per types.py, 0 means "unknown" — not a
real count). That 0 propagated straight to disk.

Fix at the consumer boundary so every album-dict constructor stays
unchanged. Lifted to pure helper
core/metadata/track_number_format.py:format_track_number_tag that
drops the /N suffix when total is 0 / None / negative — emits just
"6" instead. Matches retag's behavior + ID3 spec convention (TRCK
can be "N" or "N/M"). MP4 trkn tuple gets the same treatment via
format_track_number_tuple returning (6, 0) per spec's "unknown
total" marker.

Wired into all three format-write sites in enrichment.py: ID3 (TRCK),
Vorbis (tracknumber), MP4 (trkn). When source data has correct
total_tracks (album downloads via the metadata-source pipeline,
retag flow), behavior unchanged — still writes "6/13".

16 boundary tests pin every shape: known total / zero total / none
total / none track / zero track / negative inputs / string coercion
/ unparseable strings / floats truncate.

Full suite: 3113 passed.
2026-05-14 15:25:16 -07:00
Broque Thomas
9cc09118bf AcoustID scanner: multi-candidate match + duration guard + multi-value retag
Closes #587. Three coordinated fixes per codex's diagnosis. AcoustID
verification gate left intact — these fixes target the upstream
scanner false-positive surface plus a separate retag-path gap.

Bug 1 — scanner used recordings[0] as authoritative

`core/repair_jobs/acoustid_scanner.py:_scan_file` only checked the
top fingerprint match's metadata. AcoustID often returns multiple
recordings per fingerprint (sample collisions, multi-MB-record
cases) and the wrong-credited recording can outrank the right-
credited one. Foxxify case 2 (Nana / Nana): top match credited the
wrong artist while a lower-ranked candidate matched the user's
expected metadata exactly.

Lifted the verifier's all-candidates check to a shared pure helper
`core/matching/acoustid_candidates.py:find_matching_recording`. Both
verifier and scanner can now ask "given these candidates, does ANY
of them match expected (title, artist)?" with the same contract.
Scanner suppresses the finding when any candidate matches.

Bug 2 — no duration check guards against fingerprint hash collisions

Foxxify case 3: 17-minute mashup edit fingerprinted to a 5-minute
late-70s Japanese hiphop track (different songs, fingerprint hash
collision on a sampled section). Scanner had no signal to detect
this and would have recommended retagging the 17-min file as the
5-min track.

`duration_mismatches_strongly` in the same helper module flags drifts
beyond max(60s, 35%). Scanner now skips findings when the candidate's
duration disagrees strongly with the file's expected duration. Loaded
duration via the existing tracks SQL (added `t.duration` to the
SELECT). Returns False when either side is unknown — no behavior
change for older rows without duration data.

Bug 3 — scanner retag bypassed multi-value ARTISTS tag setting

`core/repair_worker.py:_fix_wrong_song` called `write_tags_to_file`
with single-string artist updates. The writer only wrote TPE1
(single string) and never read the user's
`metadata_enhancement.tags.write_multi_artist` config. Multi-value
ARTISTS tags got stripped on every retag, contradicting the
post-download enrichment pipeline's behavior.

Per codex's pick (option B over routing through enhance_file_metadata),
extended `write_tags_to_file` with an optional `artists_list`
parameter. Each format-specific writer respects the config flag the
same way enrichment.py does:
- ID3: TPE1 stays as joined display string + TXXX:Artists multi-value
- Vorbis/Opus/FLAC: `artist` display string + `artists` multi-value key
- MP4: \xa9ART as list when on, single string when off

Scanner retag derives the per-artist list by splitting AcoustID's
credit through the existing `split_artist_credit` helper (same
separators the matching layer already uses).

Backward compatible: callers that don't pass `artists_list` get the
exact same single-string write as before. No regression for the
write_artist_image button or any other tag_writer caller.

15 tests on the candidate helper + duration guard.
13 tests on the tag_writer multi-value path (write/skip/single/
no-list cases for FLAC + the config-gate helper).
4 new scanner regression tests pinning lower-ranked candidate
suppression, no-suppression when no candidate matches, duration
mismatch skip, no-skip when duration matches.

Existing scanner tests updated for the new 11-column SQL select
(added duration column to fake schema + test row tuples).

Full suite: 3097 passed. Ruff clean.
2026-05-14 14:09:38 -07:00
Broque Thomas
0aa18b0180 Cross-script artist aliases: include canonical name + non-strict fallback
Closes #586. Follow-up to #442 — Cyrillic / kanji canonical names
weren't bridging cross-script comparisons. Reporter case: "Dmitry
Yablonsky" tracks quarantined as audio mismatch with file identified
as "Русская филармония, Дмитрий Яблонский" (4% artist sim) even
though the Cyrillic spelling is just the Russian transliteration.

Codex diagnosed three layered bugs in the alias resolution chain.
This fixes all three.

Bug 1 — fetch_artist_aliases ignores canonical name + sort-name

`core/musicbrainz_service.py:fetch_artist_aliases` only read
`data['aliases']`. For artists where MB's canonical `name` IS the
cross-script form (and the Latin spelling lives only in aliases —
or vice versa), the missing direction never made it into the
returned list. Fix: include both `data['name']` and `data['sort-name']`
alongside the explicit alias entries (deduped, also pulls each
alias entry's sort-name when present).

Bug 2 — lookup_artist_aliases ran search in strict mode only

Strict mode queries `artist:"..."` only and skips MB's alias and
sortname indexes. Cross-script searches found nothing under strict
because the user's Latin input never matches a Cyrillic canonical
name in the artist index. Fix: lifted the search-and-score logic
to a private helper `_search_and_score_artists(name, strict=)` and
fall back to non-strict when strict returns empty OR all results
fail the trust gate. Non-strict (bare query) hits all indexes.

Bug 3 — trust gate weighted local similarity 70%

Combined score = local_sim * 0.7 + mb_score/100 * 0.3. Cross-script
pairs have local sim ~0 → combined ~0.30 → below the 0.85 threshold
→ cached as empty even when MB's own confidence was 100. Fix: added
an MB-only escape — when MB score is >= 95 AND the result is
unambiguous (top result's MB score leads the runner-up by >= 5),
accept regardless of local similarity. The existing combined-score
path stays intact for same-script matches (#442 Hiroyuki Sawano
case still passes via that path).

12 new tests pin every layer:
- fetch_artist_aliases canonical-name inclusion + dedup against
  alias entries + missing-canonical handling + exception path
- strict-then-non-strict fallback (empty-strict + low-strict-score)
- trust gate MB-only escape + low-confidence rejection + ambiguity
  rejection (two artists same MB score) + same-script regression
- end-to-end reporter scenario with the real `artist_names_match`
  helper proving the bridge works for "Русская филармония, Дмитрий
  Яблонский" vs expected "Dmitry Yablonsky"

Existing alias tests in `test_artist_alias_service.py` updated to
reflect: canonical name now appears in `fetch_artist_aliases`
output, lookup makes 2 search calls (strict + non-strict fallback)
on first cache miss instead of 1.

Full suite: 3065 passed.
2026-05-14 13:07:15 -07:00
Broque Thomas
e7ecaca3fd Fix MTV Unplugged & live-album false-quarantine pipeline
Closes #589. Tracks from MTV Unplugged / Live At / unplugged albums
consistently failed AcoustID verification with "Version mismatch:
expected (live) but file is (original)". Two upstream bugs fed into
the false positive — the AcoustID gate itself was correctly catching
the wrong file Tidal had selected. Codex diagnosed all three layers,
this fixes the two upstream causes and leaves the verifier alone.

Bug 1 — album-scoped library check false-misses owned albums

`core/downloads/master.py:184` scored "Shy Away (MTV Unplugged Live)"
(source title from playlist) vs "Shy Away" (local DB stored title)
with raw string similarity. Massive length asymmetry → ~0.3 → below
the 0.7 threshold → marked missing. Combined with the
`allow_duplicates and batch_is_album` short-circuit that disables
the global fallback for album downloads, the user's already-owned
album re-triggered every track for download. Explains the screenshot
showing "0 found / 7 missing" on an album the user manually placed.

New pure helper `core/matching/album_context_title.py:strip_redundant_album_suffix`
strips trailing parenthetical / bracket / dash suffixes whose tokens
are fully subsumed by the album context — at least one version
marker (live / unplugged / acoustic / session / concert / tour)
overlapping with the album, and every other token is either a
known marker, a year, a tolerated noise word, or a word from the
album title. Album-context-implied "live" added when the album
mentions unplugged / concert / tour / session.

Wired into the album-confirmed scope ONLY (not global matching).
Compares both raw and normalized source titles per album track and
takes the max similarity, so the helper returning the input
unchanged (when album doesn't imply version context) preserves
the pre-fix behavior.

Bug 2 — Tidal qualifier filter only ran on fallback searches

`core/tidal_download_client.py:345` set `is_fallback = attempt_idx > 0`
and only filtered when `is_fallback and required_qualifiers`. Primary
search returned all results unfiltered, so a query for "Shy Away
(MTV Unplugged Live)" could accept the studio cut if Tidal happened
to rank it first. Now the qualifier filter applies to BOTH primary
and fallback search attempts — log message updated to indicate
which path triggered.

Bug 3 — qualifier check ignored album.name

The legacy `_track_name_contains_qualifiers` only inspected the
track name. For concert / unplugged releases the live signal
typically lives in the album title, not the track title. New
`_track_matches_qualifiers` accepts a track object and inspects
both `track.name` AND `track.album.name`. Legacy helper preserved
to keep its existing test contract.

AcoustID version-mismatch gate at core/acoustid_verification.py
left intact — it correctly catches genuinely-wrong files that slip
through upstream filters. The In My Feelings (Instrumental) test
that pins this behavior continues to pass.

19 tests on the album-context helper covering MTV Unplugged
variants, dash/parens/brackets suffix shapes, year tolerance,
plural-form markers, the implied-live set, anti-regression cases
(instrumental/remix on a studio album must NOT be stripped),
empty/none defensive paths.

13 tests on the Tidal qualifier helper covering legacy
track-name-only behavior preserved, qualifier in track name alone,
qualifier in album name alone (the MTV Unplugged scenario),
multi-qualifier requirements, no-qualifiers always passes,
defensive against missing track.album, word-boundary avoiding
substring false-matches, _extract_qualifiers picking up live +
unplugged from the user's exact reporter query.

Full suite: 3053 passed.
2026-05-14 12:14:31 -07:00
Broque Thomas
c9d4b02a02 Fix Deezer contributors tagging silently dropping for cache-polluted tracks
Closes #588. Contributing-artist tagging worked for some tracks but
silently dropped them for others — most reproducibly when the album
had been fetched before the per-track post-process ran.

Trace: get_track_details cache check used `track_position in cached`
as the "full payload" sentinel. Both `/track/<id>` AND
`/album/<id>/tracks` set track_position. Only `/track/<id>` sets the
`contributors` array. When album-tracks data hit the cache first,
get_track_details returned the partial record →
_build_enhanced_track found no contributors → metadata-source
contributors-upgrade silently fell back to single-artist.

Reporter's case (Andrea Botez - Sacrifice): the album fetch logged
"Retrieved 4 tracks for album 673558211" before the post-process,
which cached all 4 tracks as partial records. The contributors-
upgrade then hit the partial cache and the upgrade log line never
fired because len(upgraded) was never > 1.

Lifted cache-validity to a pure helper `_is_full_track_payload` that
requires BOTH `track_position` AND `contributors` key presence. Empty
list `[]` is valid — single-artist tracks fetched via `/track/<id>`
carry it explicitly. Partial cache hits fall through to a fresh
`/track/<id>` fetch, which writes the full payload back to cache.

11 boundary tests pin every shape: full payload, single-artist with
empty contributors list, partial album-tracks shape, search-result
shape, none/non-dict, and the cache-hit/cache-miss/api-failure paths
on get_track_details (including the exact reporter-scenario
regression).

Full suite: 3021 passed.
2026-05-14 11:10:51 -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
Broque Thomas
d0d65946c8 Polish quarantine UI — fold into Library History modal as third tab
Standalone Quarantine button + modal felt out of place — duplicated
the chrome of the existing Library History modal but with worse
styling and behavior. Folded the quarantine list into the existing
modal as a third tab next to Downloads + Server Imports.

UI changes:
- Removed the standalone Quarantine button on the Downloads page
  header and the standalone modal HTML
- Added third tab to library-history-tabs with a count badge
- loadLibraryHistory dispatches to loadQuarantineList when the
  quarantine tab is active
- Quarantine entries render as library-history-entry cards using
  the exact same class chrome as Downloads + Imports (thumb
  placeholder, title + meta, badge, relative time via
  formatHistoryTime, expandable details panel)
- Per-row actions styled as lh-audit-btn to match the existing
  Audit button look
- Approve / Recover / Delete now use the themed showConfirmDialog
  + showToast — no more native browser alert / confirm

Backend endpoints + pure helpers + tests unchanged from f4cff78f.
WHATS_NEW entry rewritten to reflect the actual final UX.
2026-05-14 08:50:24 -07:00
Broque Thomas
f4cff78f13 Quarantine management — list, approve, delete, recover
Closes #584. Quarantined files used to sit in ss_quarantine/ with a
thin sidecar — no UI, no recovery, no way to see what got dropped.
This adds the management surface the user needs without going to the
filesystem.

UI: new "Quarantine" button on the downloads page header opens a
modal with every quarantined file (filename, expected track/artist,
reason, when, size). Three actions per row:

- Approve (one-click): restores the file, re-runs the post-process
  pipeline with ONLY the failing check skipped, lands in the library
  with full tags + lyrics + scan
- Recover (legacy fallback): moves to Staging for thin-sidecar
  entries that lack the embedded context Approve needs
- Delete: permanent removal of file + sidecar

Per-check bypass: context['_skip_quarantine_check'] = 'integrity' /
'acoustid' / 'bit_depth'. Skips ONLY the named check — other quality
gates stay live. No blanket bypass-all flag.

Sidecar expansion: move_to_quarantine now persists the full
json-serializable context via serialize_quarantine_context (drops
non-JSON-safe values, walks nested dicts/lists/sets, str-coerces
unknown objects) plus the trigger name. Existing thin sidecars are
detected and routed to Recover instead of Approve.

Pure helpers in core/imports/quarantine.py: list_quarantine_entries
/ delete_quarantine_entry / approve_quarantine_entry /
recover_to_staging / serialize_quarantine_context. 27 tests pin
every shape: orphan files / orphan sidecars / corrupt sidecars /
collision-safe filename restoration / full-context vs thin-sidecar
dispatch / json round-trip safety.

Four new endpoints in web_server.py — thin glue around the helpers:
GET /api/quarantine/list, DELETE /api/quarantine/<id>,
POST /api/quarantine/<id>/approve, POST /api/quarantine/<id>/recover.

Download modal status differentiates "🛡️ Quarantined" from
" Failed" so recoverable files are visible at a glance — checked
against the error_message text, no schema change needed.

Pipeline changes are three minimal per-check conditionals at the
existing quarantine sites in core/imports/pipeline.py. Each
move_to_quarantine call now passes its trigger name so the sidecar
records which check fired.

Full suite: 2992 passed.
2026-05-14 08:06:19 -07:00
Broque Thomas
177bd85355 Configurable duration tolerance for downloaded-file integrity check
Previously hardcoded at 3s (5s for tracks >10min) — files drifting
past that got quarantined with no user override. Live recordings,
alternate masterings, and some legitimate uploads routinely drift
further.

New setting `post_processing.duration_tolerance_seconds`. Default 0
means "use auto-scaled defaults" (unchanged behavior for users who
don't touch it). Positive value overrides the per-track defaults.
Capped at 60s — past that the check is effectively off.

Logic lifted to pure helper `resolve_duration_tolerance` in
file_integrity.py. Coerces every plausible input (None / empty /
zero / negative / unparseable / above-cap / numeric string / float)
to either a float override or None for auto. 12 tests pin every
shape.

Wired into `core/imports/pipeline.py` at the integrity-check call
site — runs for ALL matched downloads (Soulseek / Tidal / Qobuz /
HiFi / YouTube / Deezer-direct) since they all share that pipeline.
Settings UI input under Settings → Metadata → Post-Processing.
2026-05-14 06:53:36 -07:00
Broque Thomas
0769fcd5cc Fix Soulseek downloads losing collab artist tags
Soulseek matched-download contexts populate `original_search_result`
with `artist` (singular string) and no `artists` list — the full
multi-artist array lives on `track_info` (the matched Spotify track
object). `extract_source_metadata` only read `original_search.artists`,
so the Soulseek path always fell through to the single-artist branch
and TPE1 ended up with the primary artist only. Deezer-direct
downloads were unaffected because their context populates
`original_search.artists` as a proper list.

Lifted artist resolution into a pure helper
`core/metadata/artist_resolution.py:resolve_track_artists` that walks
`original_search.artists` → `track_info.artists` → `artist_dict.name`
fallback chain. Normalizes mixed list-item shapes (Spotify-style
dicts, bare strings, anything else stringified) and drops empty
entries.

13 new tests pin the resolution order, fallback chain, mixed-shape
normalization, whitespace stripping, and empty/none handling. The
existing `_artists_list` no-fall-through test in
`test_multi_artist_tag_settings.py` was updated to reflect the new
contract (always populated; multi-value write still gated on
`len > 1`) plus a new regression test for the Soulseek shape.

Composes with the existing Deezer per-track upgrade (still fires when
single-artist + track_id available) and feat_in_title /
artist_separator settings (still drive the joined ARTIST string
downstream).
2026-05-13 22:11:20 -07:00
Broque Thomas
831ddc97d8 Polish Download Missing modal tracklist
Pure CSS tune-up scoped to .download-missing-modal-content. Column
layout, table semantics, and every JS hook (#match-*, #download-*,
.track-*, .download-tracks-tbody-*) untouched.

Adds:
- Hairline row dividers + cleaner cell padding
- Hover gets accent gradient sweep + 3px inset edge bar
- Monospace track numbers (glow accent on row hover) + monospace
  tabular duration
- Status cells in both columns get uppercase micro-caps with a
  leading colored dot + soft glow halo (green/amber/blue/orange/red),
  pulses while checking + downloading
- Artist column centered
- Soft scrollbar
2026-05-13 18:38:02 -07:00
Broque Thomas
e407504e03 Fix search source picker defaulting to Spotify regardless of config
Enhanced search + global search popover always opened with the
Spotify icon active even when the user's primary metadata source
was Deezer / iTunes / Discogs / etc.

Trace: shared-helpers.js createSearchController reads
/status.metadata_source to pick the initial active icon, then
gates with SOURCE_LABELS[src]. Backend returns metadata_source
as a dict ({source, connected, response_time, ...}) — used
elsewhere for connection-state display — so SOURCE_LABELS[<dict>]
was always undefined, the guard never fired, and activeSource
silently stayed at the hardcoded 'spotify' default.

Fix reads .source off the dict (with fallback to plain-string for
forward compat). Other consumers already used ?.source — this was
the only stale call site.
2026-05-13 15:36:48 -07:00
Broque Thomas
fcad5d4b18 Drop duplicate Download History button from Downloads batch panel header
Audit-trail PR added two buttons to the Downloads page — one always
visible next to the 'Batches' panel title, one inside the collapsible
'Recent History' header. User wants only the Recent History one.

Removes the panel-header button + the unused
.adl-batch-panel-header-actions style. Recent History button +
the original Dashboard button remain.
2026-05-13 15:11:07 -07:00
BoulderBadgeDad
c77aa61fdf
Merge pull request #530 from dlynas/feat/explicit-badges
feat: add explicit badges to discography modal and artist-detail cards
2026-05-13 15:04:14 -07:00
BoulderBadgeDad
c6cc8a9923
Merge pull request #528 from dlynas/feat/wrap-discog-modal-text
feat: wrap discog modal card titles instead of truncating
2026-05-13 15:01:47 -07:00
BoulderBadgeDad
dddf761d0b
Merge pull request #388 from kettui/feature/vite-webapp
Lay the groundwork for webui React transition
2026-05-13 14:38:55 -07:00
Broque Thomas
fc366184b2 Raise discography limit from 50 to 200
Discord report: prolific artists (Bach, Beatles complete box,
deep dance/electronic catalogues) only showed ~50 entries in the
"Download Discography" modal.

`MetadataLookupOptions(limit=50, max_pages=0)` was hardcoded at
three call sites. Spotify's `max_pages=0` already paginates
through everything (per-page is clamped to 10 internally), so
Spotify-primary users were unaffected. But Deezer / iTunes /
Discogs / Hydrabase all honor the outer `limit` as a hard cap,
so non-Spotify users were silently clipped.

Bump `limit` to 200 at all three call sites — matches iTunes's
and Discogs's own internal caps and covers near-everyone's full
catalogue. Spotify behavior unchanged.

- web_server.py:9221 — discography endpoint (modal)
- web_server.py:8700 — artist-detail discography view
- core/artist_source_detail.py:129 — source-specific artist detail
2026-05-13 13:50:51 -07:00
Antti Kettunen
59eb8b75b0
Move shared shell chrome into bridge
Keep the page chrome sync helpers in shell-bridge.js so React and legacy routing share one implementation.

This preserves the sidebar breadcrumb and discover download bar behavior without shadowing the legacy shell helpers in init.js.
2026-05-13 22:26:26 +03:00
Antti Kettunen
82115011b2
Remove duplicate page-id bridge API
- keep getCurrentPageId off the legacy shell bridge surface
- leave page-id lookup on the router side where it is actually used
- align the bridge tests and type definitions with the slimmer API
2026-05-13 22:26:26 +03:00
Antti Kettunen
50c2d6882c
Keep Issues and artist detail history stable
- Route Issues to the React host even while the shell is still booting
- Ignore stale bootstrap work when navigation changes mid-load
- Clear artist-detail state when leaving the page so browser back can reach Library
- Add smoke coverage for the artist-detail back-navigation path
2026-05-13 22:26:24 +03:00
Antti Kettunen
8d6ab4eb74
Restore shell sync on browser history
- Re-sync the active shell page on popstate
- Keep React routes like /issues on the React host after back/forward navigation
- Preserve the existing legacy page activation path for non-React routes
2026-05-13 22:26:24 +03:00
Antti Kettunen
538bb9344b
Add workflow actions bridge to shell
- Expose SoulSyncWorkflowActions from the shell bridge
- Route album download and wishlist actions to the legacy modal helpers
- Fall back to showToast for workflow notifications
- Unblock the issue modal download button by wiring the real host contract
2026-05-13 22:26:23 +03:00
Antti Kettunen
fac4e1ba1a
Sync issues routing and shell URLs
- Move issue detail selection into route search so the modal is deep-linkable and back-button friendly.
- Normalize issue category and detail params before they reach the loader.
- Keep the legacy shell URL in sync for React-owned home pages.
- Preserve the legacy issues-tour hooks on the React issues page.
- Add Escape handling, focus trapping, and focus restore to the issue detail modal.
- Add route and helper coverage for the new search-state behavior.
2026-05-13 22:26:23 +03:00
Antti Kettunen
a9976c54ae
Centralize shell bridge glue 2026-05-13 22:26:23 +03:00
Antti Kettunen
48aec3f6f3
Remove legacy issues shell code
- Delete the static issues page renderer and detail modal helpers
- Keep the React issues route as the only implementation
- Drop the dead mobile CSS and troubleshooter hook that only targeted the removed shell
2026-05-13 22:26:22 +03:00
Antti Kettunen
ad590fb3db
Reduce legacy route flicker
Keep React-owned pages out of the legacy page activator during initial bootstrap, and switch the visible React host before paint when the shell mounts.

That removes the refresh flash on /issues while preserving the legacy-page behavior and browser-history stability.

Verified with the router tests and the issues smoke suite.
2026-05-13 22:26:22 +03:00
Antti Kettunen
972910261b
Keep shell bootstrap aligned with profile selection
- re-render the React shell when legacy profile bootstrap selects or refreshes a profile
- keep the initial page fallback so direct loads still activate the legacy shell chrome
- preserve the smoke coverage for direct loads and browser history
2026-05-13 22:26:22 +03:00
Antti Kettunen
a0384d9dc8
Restore legacy profile page aliases
- normalize old downloads and artists page ids back to search
- keep home-page and access checks aligned with the current route ids
- let profile edit forms save modern ids while still reading old rows
2026-05-13 22:26:21 +03:00
Antti Kettunen
3be028f97b
Keep profile editor page access complete
- reuse the create-form page controls when rendering edit forms
- preserve existing home_page and allowed_pages IDs that the old whitelist hid
- keep mandatory pages checked so saves do not drop them
2026-05-13 22:26:21 +03:00
Antti Kettunen
86bcad491f
Post-rebase cleanup 2026-05-13 22:26:21 +03:00
Antti Kettunen
577e4bdace
Migrate issue domain to React
- Mount a React-owned issue domain host and bridge report issue actions through it
- Add typed issue creation helpers, report payload types, and shared album workflow launchers
- Expand issue detail UI parity with metadata, links, track details, and admin actions
- Remove legacy static issue modal/list/detail code and update tests for the React bridge
2026-05-13 22:26:20 +03:00
Antti Kettunen
43db30608d
Add initial webui page migration analysis 2026-05-13 22:26:20 +03:00
Antti Kettunen
d98dcd8606
Initial Vite app scaffolding & issues page impl
- File-based routing with tanstack router
  - Persist top-level navigation state in url, even for most legacy pages
  - Striving for an intuitive and simple folder structure where
    route-related code is colocated, but the amount of files is still
    kept to a minimum
- Replace native fetch with `ky`
  - Familiar api, but more polished
2026-05-13 22:24:46 +03:00
Broque Thomas
89246a7304 Write artist.jpg to artist folder so Navidrome shows real photos
Closes #572 (rhwc).

Navidrome has no API for setting an artist image — it reads
`artist.jpg` (or `folder.jpg`) from the artist folder during
library scans. SoulSync's `update_artist_poster` for Navidrome
was a no-op, so users only ever saw album-art-derived thumbnails
as the artist photo.

- new "Write Artist Image" button on artist detail page
- POST /api/artist/<id>/write-image-to-disk derives the artist
  folder from any track's resolved file_path (reuses
  _resolve_library_file_path so docker mount translation +
  library.music_paths probes from #558 apply), fetches the photo
  from the configured metadata source priority chain, downloads
  with content-type validation, writes atomically via
  `<filename>.tmp + os.replace`
- when active server is Navidrome, triggers a library scan
  immediately so the file is picked up
- respects existing artist.jpg (frontend prompts before
  overwriting) so user-supplied photos aren't clobbered
- works for plex / jellyfin too as a fallback layer — both
  servers also read artist.jpg from disk

26 tests pin the pure helpers in core/library/artist_image.py:
folder derivation (trailing sep / empty / non-string), URL
picking (missing attr / whitespace / non-string), download
(non-image content-type / 404 / timeout / empty body), atomic
write (replace / temp-cleanup-on-failure / overwrite guard /
missing folder).
2026-05-13 11:48:09 -07:00
Broque Thomas
641c72d7f1 Bump version to 2.5.2 2026-05-13 09:55:08 -07:00
Broque Thomas
6ce185491d Add per-download Audit Trail modal to Library History
- new "Audit" button on each download row in the library history
  modal opens a second modal visualizing the download lifecycle as
  an interactive horizontal stepper (request → source → match →
  verify → process → place) with click-to-expand detail cards
- hero header with album art + track title + meta line + status
  pills (source / quality / acoustid result)
- three tabs: Lifecycle / Tags / Lyrics
- Tags tab reads the audio file live via mutagen at audit-open
  time via new GET /api/library/history/<id>/file-tags endpoint;
  file is the single source of truth so background enrichment
  writes (audiodb / lastfm / genius / replaygain / lyrics fetch)
  show up too. flat key/value rows stacked vertically (label-above-
  value) so long MBIDs / URLs / joined genre lists wrap cleanly.
  source IDs grouped per-service into 2-col sub-card grid.
- Lyrics tab renders the full transcript with dimmed timecodes.
- post-processing step infers observable changes from source-vs-
  final state (format conversion, file rename via tag template,
  folder template).
- "Download History" button also added to the Downloads page batch
  panel header so it's reachable outside the dashboard.
- mobile responsive: tabs + stepper scroll horizontally, modal
  goes full-screen, hero stacks below 480px.

19 helper tests pin the mutagen reader: id3 (TIT2/TPE1/TALB + TXXX
+ USLT + APIC), vorbis (FLAC dict + _id/_url passthrough), file
metadata (format / bitrate / duration), defensive paths (empty /
missing file / mutagen returns None / mutagen raises), stringify
edge cases (list / tuple / int / frame-with-text / whitespace).
2026-05-13 09:50:24 -07:00
Broque Thomas
46206b3240 Pin type='track' / type='artist' collision case for album-type normalizer 2026-05-12 21:15:58 -07:00
Broque Thomas
5eae24b8bb Fix $albumtype defaulting to album for non-Spotify sources
- legacy duck-typed builder only checked the `album_type` key; deezer
  uses `record_type`, tidal uses `type` (uppercase), some flattened
  musicbrainz shapes use `primary-type` — all defaulted to album, so
  EPs and singles ended up filed under Album/ in user templates that
  reference $albumtype
- widen lookup to album_type / record_type / type / primary-type and
  route through new pure `_normalize_album_type` helper that
  case-folds + validates against the canonical token set
  (album / single / ep / compilation), unknown → album
- typed-converter path (spotify / deezer / itunes / discogs / mb /
  hydrabase / qobuz) unchanged — those were already correct

Discord report (CAL).
2026-05-12 21:09:16 -07:00
dlynas
42bee21c9f feat: add explicit badges to discography modal and artist-detail cards
Adds an explicit field to the Album dataclass in core/metadata/types.py
and the client-level Album dataclasses in deezer_client.py,
itunes_client.py, and hydrabase_client.py (the legacy discography path
reads from client objects, not typed dicts).

Deezer extracts explicit_lyrics (int→bool), iTunes extracts
collectionExplicitness ('explicit' string), Hydrabase forwards the
explicit field from the server response. Spotify, Discogs, MusicBrainz,
Qobuz, and Tidal have no explicit signal and stay None.

The flag threads through both builder functions in discography.py and
renders as a small "E" badge next to explicit titles in the discography
download modal and artist-detail page cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:35:08 -04:00
dlynas
503f07b9fc feat: wrap discog modal card titles instead of truncating
Card titles in the discography modal now display their full text
across multiple lines rather than being cut off with an ellipsis.
Artwork and the selection checkbox are pinned to the top of the card
so they align with the first line of text when titles wrap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:35:08 -04:00
Broque Thomas
1715e4d52f Bump version to 2.5.1 2026-05-12 19:55:06 -07:00
Broque Thomas
b9feed1a67 Add min delay between slskd searches (Bell Canada anti-abuse fix)
- new soulseek.search_min_delay_seconds knob forces a gap between
  consecutive searches; smooths the burst pattern that trips ISP
  anti-abuse (Reddit report: Bell Canada cuts the WAN after rapid
  peer-connection spikes) even when the existing 35/220 sliding-window
  cap isn't hit
- throttle math lifted to a pure compute_search_wait_seconds helper so
  the gate logic is testable independent of asyncio.sleep + the
  singleton client
- new field on settings → connections → soulseek; default 0 = disabled
  so existing users see no change

15 helper-boundary tests pin defaults / no-throttle, sliding-window
cap (legacy), min-delay (the new burst-smoother), max-of-both gates,
and defensive paths.
2026-05-12 19:11:12 -07:00
Broque Thomas
6233860d66 Fix Copy Debug Info music_source + surface missing services
- music_source / spotify_connected / spotify_rate_limited were reading
  a non-existent 'spotify' key on _status_cache and silently falling
  through to the missing-value default (always 'unknown' / False).
  Routed through the canonical accessors get_primary_source +
  get_spotify_status now.
- added hydrabase_connected, youtube_available, hifi_instance_count,
  and always_available_metadata_sources so the debug dump reflects
  the full service surface
- removed a local re-import of get_spotify_status that was making
  python 3.12 treat the name as function-scoped, breaking the new
  lambda above it (NameError on free variable) — module-level import
  already exists

11 endpoint-level tests pin music_source / spotify_* / hydrabase_* /
youtube_available / always_available_metadata_sources / hifi_instance_count
and the defensive fall-through paths when each lookup raises.
2026-05-12 16:47:55 -07:00
Broque Thomas
4892baf8d4 Skip already-owned tracks during download discography
- new track_already_owned helper wraps db.check_track_exists at
  the same confidence threshold the discography backfill repair job
  uses (0.7) — name+artist+album, format-agnostic so blasphemy-mode
  libraries (flac → mp3 + delete original) match correctly
- endpoint runs the check after the artist + content-type filters and
  before add_to_wishlist, so a second discography click on the same
  artist no longer re-queues every track that already downloaded
- per-album response carries a new tracks_skipped_owned counter
  alongside the existing artist/content/wishlist skip categories

Discord report (Skowl).
2026-05-12 15:10:41 -07:00
Broque Thomas
d4ad5bf57f Filter cross-artist + content-type tracks during download discography
- drop tracks where the requested artist isn't named in track.artists
  (keeps features, drops compilation / appears_on contamination)
- honor watchlist.global_include_live/remixes/acoustic/instrumentals
  the same way the discography backfill repair job already does
- surface per-album skip counts in the ndjson stream (artist mismatch
  + content filter) so the ui can show what was filtered

Closes #559.
2026-05-12 14:38:17 -07:00
Broque Thomas
56ae10693b Album Completeness: surface diagnostic when resolver can't find album folder
GitHub issue #558: clicking Auto-Fill / Fix Selected on the Album
Completeness findings page returned a flat "Could not determine album
folder from existing tracks" error with no diagnostic. Reporter is on
Navidrome on Docker — the path resolver in
`core/library/path_resolver.py` couldn't find any of the album's tracks
on disk because Navidrome's Subsonic API doesn't expose filesystem
library paths the way Plex's API does (probed in #476). Default
settings → `library.music_paths` empty → no base directories to probe →
silent None. User had no signal about what to configure.

Not a regression of #476 — that fix targeted Plex auto-discovery and
worked correctly for it. Navidrome was never covered because the
protocol gives the resolver nothing to probe.

Fix scoped to the diagnostic surface, not auto-magic discovery:

- Added `resolve_library_file_path_with_diagnostic` returning
  `(resolved, ResolveAttempt)`. ResolveAttempt records what the resolver
  tried — `raw_path_existed`, `base_dirs_tried`, `had_config_manager`,
  `had_plex_client`. Pure data, no rendering opinions.
- Legacy `resolve_library_file_path` becomes a thin wrapper that
  drops the attempt; every existing call site is unchanged.
- `RepairWorker._fix_incomplete_album` now uses the diagnostic helper
  and renders a multi-part error via `_build_unresolvable_album_folder_error`:
  names the active media server, shows one sample DB-recorded path,
  lists every base directory the resolver actually probed, and points
  the user at Settings → Library → Music Paths as the actionable fix.
- Distinguishes empty-base-dirs vs tried-and-failed cases so the user
  knows whether to add a mount or fix the existing one.
- No auto-probing of common Docker conventions (`/music`, `/media`, etc).
  Speculative — could resolve to wrong dirs on the suffix-walk if a
  conventional path happens to contain a partial collision. User stays
  in control.

12 new tests:
- 7 in `tests/library/test_path_resolver.py`: tuple-shape contract,
  raw-path-existed short-circuit, base-dirs listed even on walk
  failure, had-flags reflect caller inputs, no-base-dirs returns
  None with empty attempt, legacy `resolve_library_file_path`
  delegates correctly across happy / suffix-walk / failure paths.
- 8 in `tests/test_repair_worker_unresolvable_folder_error.py`:
  active server name in error, sample DB path verbatim, base dirs
  listed, empty-base-dirs phrased differently, Settings hint always
  present, defensive against None attempt / missing sample / missing
  config_manager.

Full pytest sweep: 2774 passed.
2026-05-12 14:04:15 -07:00
Broque Thomas
698ecc99f0 Import history: Clear History button now sweeps stuck 'processing' rows
Reported: Clear History button on the Import page left zombie rows
behind. Every survivor showed "⧗ Processing" status from 2-9 days ago.

Trace: `_record_in_progress` inserts a `status='processing'` row up-front
so the UI can render the in-flight import while it runs; `_finalize_result`
updates it to `completed`/`failed` when the import finishes. When the
worker is killed mid-import (server restart, crash), the row never gets
finalized — stays at `processing` forever. The clear-history endpoint's
SQL `DELETE ... WHERE status IN (...)` listed every terminal status but
omitted `processing`, so zombies survived every click.

Fix: add `processing` to the delete list, but guard against nuking
genuinely-live imports by intersecting against the worker's
`_snapshot_active()` map — any folder hash currently registered in
`_active_imports` is excluded from the delete via an `AND folder_hash
NOT IN (...)` clause. `pending_review` deliberately left out so user
still has to approve/reject those explicitly.

One endpoint touched (`/api/auto-import/clear-completed` in
web_server.py). No worker changes — guard reuses the existing
`_snapshot_active()` method that the UI poller already calls.

5 new tests in `tests/imports/test_auto_import_clear_completed_endpoint.py`:
- Zombie `processing` rows swept, live `processing` row preserved
  (folder_hash currently in `_active_imports` survives)
- Response count matches actual delete count
- Empty active-set branch (unparameterized DELETE) — pinned because
  an empty SQL `IN ()` would be a syntax error
- Worker-unavailable returns 500 (pre-existing guard not regressed)
- `pending_review` rows always survive — never auto-swept

Full pytest sweep: 2758 passed (one pre-existing flaky timing test
on `test_import_singles_parallel.py` failed under full-suite CPU load,
passes in isolation in 2.95s — unrelated to this change).
2026-05-12 12:53:37 -07:00
Broque Thomas
3af2d34cee Auto-import: fall through to other metadata sources when primary returns no match
Discord report: 16 Bandcamp indie albums sat in staging because
auto-import couldn't identify them, but the manual search bar at the
bottom of the Import Music tab found the same albums fine. Trace:
`_search_metadata_source` only queried `get_primary_source()` — single
source, no fallback. Meanwhile `search_import_albums` (manual search bar)
already iterated `get_source_priority(get_primary_source())` and broke
on the first source with results. Asymmetric behavior, same album: manual
worked, auto-import didn't.

Fix: lift `_search_metadata_source` to use the same source-chain pattern.
Try primary first; if it returns nothing OR scores below the 0.4
threshold, fall through to the next source in priority order. First
source producing a strong-enough match wins. Result dict carries the
`source` that actually matched (not the primary name) so downstream
`_match_tracks` calls the right client. Defensive per-source try/except
so a rate-limited or auth-failed source doesn't abort the chain.
Unconfigured sources (client=None) silently skipped.

Cin-shape lift: scoring math extracted to pure `_score_album_search_result`
helper so the weight tweaks (album 50% / artist 20% / track-count 30%)
are pinned at the function boundary, independent of the orchestrator
(per-source iteration, exception containment, threshold check). Weight
constants exposed at module level (`_ALBUM_NAME_WEIGHT`,
`_ARTIST_NAME_WEIGHT`, `_TRACK_COUNT_WEIGHT`) — greppable, bumpable in
one place. Pre-extraction these were magic numbers inline.

27 new tests:
- 9 integration tests in `test_auto_import_multi_source_fallback.py`:
  primary-success path unchanged (no fallback fires, only primary client
  called), primary-empty falls through, primary-weak-score falls through,
  first fallback success stops the chain (no wasted API calls on
  remaining sources), all-sources-fail returns None, per-source
  exception contained, unconfigured-source skipped, result `source`
  field reflects winning source, `identification_confidence` from
  winning source.
- 18 helper tests in `test_album_search_scoring.py`: weights sum to
  1.0, album weight dominant (invariant pin), perfect-match returns 1.0,
  per-component contribution (album / artist / track-count), Bandcamp
  vs streaming track-count mismatch (7-files vs 4-tracks case still
  scores ~0.87 above threshold), zero-track-count and zero-file
  guards, huge-mismatch non-negative guard, list-of-strings artist
  shape, missing `.name` / `.artists` / `None` total_tracks edge cases.

Backwards compatible: single-source users see no change — chain just
has one entry. Existing test `test_search_metadata_source_extracts_artist_id_from_dict_artist`
needed one extra patch line for `get_source_priority`.

Full pytest sweep: 2754 passed.
2026-05-12 12:32:18 -07:00
Broque Thomas
d5de724f9b Multi-artist Deezer upgrade + double-append guard hardening
Two follow-ups to the multi-artist tag settings PR:

1. Deezer contributors upgrade — closes the "known limitation"
   flagged in the prior commit. Deezer's `/search` endpoint only
   returns the primary artist for each track; the full contributors
   array (feat., remix collaborators, producers credited as artists)
   lives on `/track/<id>` and gets parsed by `_build_enhanced_track`.
   Without the upgrade Deezer-sourced tracks never got multi-artist
   tags even with the right settings on.

   Fix in `core/metadata/source.py`: when source==deezer AND the
   search response had a single artist AND a track_id is available,
   fetch full track details via `get_deezer_client().get_track_details`
   and replace `all_artists` with the upgraded list.

   - One extra API call per affected Deezer track
   - Skipped when search already returned multiple (no-op fast path)
   - Skipped for non-Deezer sources (Spotify/Tidal/iTunes search
     responses already include all artists)
   - Skipped when no track_id is available
   - Defensive try/except: on /track/<id> failure (network error,
     deezer client unavailable), fall through to the search-result
     list — never lose the data we already had

2. Double-append guard hardened with a word-boundary regex.
   Prior commit checked for `"feat." not in title.lower() and "(ft."
   not in title.lower()` — too narrow. Source platforms produce
   wildly different feat-marker conventions: "(feat. X)", "(Feat X)",
   "(FEAT X)", "(Featuring X)", "[feat. X]", "ft. X" (no parens),
   "FT. X", etc. Any of these as the SOURCE title would cause a
   double-append: `"Track (Feat X) (feat. Y)"`.

   Replaced with `re.search(r'\b(?:feat|feat\.|featuring|ft|ft\.)\b',
   title, IGNORECASE)`. Word-boundary regex catches every common
   variant. Substring matches like "Aftermath" containing `ft`
   correctly fall through to the append path (pinned by a regression
   test).

16 new tests (29 total in the file):
- 9 parametrized variants of the double-append guard
- 1 substring guard ("Aftermath")
- 6 Deezer upgrade scenarios (fires when expected, doesn't fire
  for non-Deezer / multi-artist search / no track_id, defensive
  fall-through on failure, no false-positive when /track/<id>
  confirms single artist)

Full pytest 2727 passed.
2026-05-11 15:30:23 -07:00
Broque Thomas
c11a5b7eab Multi-artist tag settings: implement artist_separator + feat_in_title + populate _artists_list
Three settings on Settings → Metadata → Tags were partially or
completely unimplemented. Reporter (Netti93) traced each one.

(1) `write_multi_artist` only "worked" because of a never-populated
    `_artists_list` field. `core/metadata/source.py` built
    `metadata["artist"]` as a hardcoded ", "-joined string but never
    assigned `metadata["_artists_list"]`. `core/metadata/enrichment.py`
    line 107 reads that field and gates the multi-value tag write
    on `len(_artists_list) > 1` — always saw an empty list, silently
    no-op'd the write.

(2) `artist_separator` (default ", ") was referenced in the UI +
    settings.js save path but ZERO Python code read the value. Every
    multi-artist track ended up with hardcoded ", " regardless of
    what the user picked.

(3) `feat_in_title` (when true: pull featured artists into the title
    as " (feat. X, Y)" and leave only primary in the ARTIST tag —
    Picard convention) had no implementation at all.

Fix in source.py:

* Populate `_artists_list` from the search response's artists array
* Read `feat_in_title` and `artist_separator` configs
* When `feat_in_title=True` and >1 artist: ARTIST = primary only,
  append "(feat. X, Y)" to title with double-append guard
* Else: ARTIST = artists joined with `artist_separator`
* Single-artist case unaffected by either setting

Double-append guard uses a word-boundary regex catching all common
"feat" variants source platforms produce — `feat`, `feat.`,
`featuring`, `ft`, `ft.` — case-insensitive. Substring matches
(e.g. "Aftermath" containing "ft") correctly fall through to the
append path.

Fix in enrichment.py ID3 branch:

* TPE1 stays as the display string (with separator or primary-only
  per the user's settings)
* Multi-value list goes to a separate `TXXX:Artists` frame (Picard
  convention) when `write_multi_artist` is on
* Pre-fix the ID3 path wrote TPE1 twice — single-string then list
  — and the second `add` overwrote the first, clobbering both the
  configured separator AND the feat_in_title semantics. Vorbis path
  was already correct (separate "artist" + "artists" keys).

Known limitation (flagged in WHATS_NEW): Deezer's `/search` endpoint
only returns the primary artist. The full contributors array lives
on `/track/<id>`. Enrichment uses search-result data so Deezer-
sourced tracks may still get only the primary artist until a follow-
up commit wires the per-track contributors fetch into the enrichment
flow. Spotify, Tidal, and iTunes search responses include all
artists so they work now.

23 new tests in `tests/metadata/test_multi_artist_tag_settings.py`:

* `_artists_list` populated for multi/single/no-artist cases
* `artist_separator` drives ARTIST string (default ", " + custom
  ";" + custom "; " + " & ")
* Single-artist case unaffected by either setting
* `feat_in_title=True` pulls featured to title, leaves primary in
  ARTIST
* `feat_in_title` no-op for single artist
* Double-append guard recognizes 9 source-title variants ("(feat.
  X)", "(Feat. X)", "(FEAT X)", "(feat X)", "(Featuring X)",
  "[feat. X]", "ft. X", "(ft X)", "FT. X")
* Substring guard test pins "Aftermath" doesn't false-positive
* Combined-settings precedence: feat_in_title wins ARTIST string
  but `_artists_list` carries everyone for multi-value tag

Full pytest 2711 passed.
2026-05-11 15:16:42 -07:00
Broque Thomas
fc573a5f19 AudioDB worker: stop infinite loop on direct-ID lookup failure (#553)
Track enrichment was stuck in a constant retry loop. Logs showed
nothing but `Read timed out. (read timeout=10)` from
`lookup_track_by_id` repeating against the same track ID. AudioDB
itself was being hammered nonstop with no progress.

Cause: when an entity already has `audiodb_id` populated (from a
manual match or earlier scan) but `audiodb_match_status` is still
NULL — an inconsistent state some import paths can leave behind —
the worker tries a direct ID lookup. If that lookup fails (returns
None on timeout, which AudioDB's `track.php` endpoint hits
frequently because it's slow), the prior code logged "preserving
manual match" and returned WITHOUT marking status. Row stayed NULL
→ queue's NULL-status filter picked it up next tick → tried direct
lookup → timed out → returned → infinite loop.

The "preserve manual match" intent was correct: don't fall through
to the name-search path because that could overwrite a manually-set
`audiodb_id` with a wrong guess. Bug was the missing `_mark_status`
call before the early return.

Fix:

* `_process_item` direct-lookup-failure branch now calls
  `_mark_status(item_type, item_id, 'error')` before returning. The
  existing `audiodb_id` is preserved (column not touched). Queue's
  NULL-status filter no longer re-picks the row.

* `_get_next_item` retry-cutoff queue priorities (4/5/6) extended
  from `audiodb_match_status = 'not_found'` to
  `audiodb_match_status IN ('not_found', 'error')`. Same `retry_days`
  window. Transient AudioDB outages still recover automatically;
  permanently-broken IDs eventually get re-attempted once a month
  rather than staying errored forever.

5 new tests in `tests/test_audiodb_worker_stuck_track.py` use a real
SQLite DB (not mocks) so the SQL queries are actually exercised:

  - lookup-returns-None marks status='error' (no infinite loop)
  - lookup-raises-exception marks status='error' (defensive)
  - lookup-success preserves the existing match-success path
  - error-status row past retry-cutoff gets picked up again
  - error-status row within cutoff stays skipped (loop prevention
    works)

Only triggers for entities in the inconsistent `audiodb_id` set +
`match_status` NULL state. Happy path and already-matched /
already-not-found rows unchanged. Full pytest 2698 passed.

Closes #553.
2026-05-11 14:18:55 -07:00
Broque Thomas
decb62dcc9 Docker: pre-bake /app/Staging + writability audit (fix restart loop)
Discord report: container refused to start after pulling latest.
Logs showed `mkdir: cannot create directory '/app/Staging':
Permission denied`. `set -e` in entrypoint.sh then aborted the script
and the container restart-looped.

Root cause traced to commit 70e1750 (2026-05-08, image-bloat fix):
the Dockerfile chown was changed from `chown -R /app` to a scoped
chown on specific subdirs to avoid a redundant layer that was
duplicating the entire /app tree. Side effects:

1. `/app` itself went from soulsync:soulsync (via the recursive walk)
   to root:root (Docker WORKDIR default — never re-chowned).
2. `/app/Staging` was the only runtime mount-point dir NOT pre-baked
   into the image — every other bind-mountable dir (config, logs,
   downloads, Transfer, MusicVideos, scripts) was in the Dockerfile's
   `mkdir -p` + `chown` list. Staging was left to the entrypoint.

On rootless Docker / Podman where in-container "root" maps to a host
UID, the entrypoint mkdir on `/app/Staging` could fail with EACCES
depending on the bind-mount path's host ownership.

Fix has three parts:

1. **Dockerfile** — added `/app/Staging` to the runtime mkdir +
   scoped chown list. Closes the asymmetry with the other bind-
   mountable dirs. Image now ships with the directory pre-baked
   owned soulsync:soulsync so the entrypoint mkdir is a guaranteed
   no-op even when bind-mount perms are weird.

2. **entrypoint.sh mkdir + chown** — both now have `|| true` so any
   future bind-mount permission quirk surfaces as a log line, not
   a `set -e` crash + restart loop. Previously only the chown had
   the `|| true` suffix; mkdir was bare.

3. **entrypoint.sh writability audit** — new loop at the end of
   the setup phase runs `gosu soulsync test -w "$dir"` against
   every bind-mountable dir. When a dir isn't writable by the
   soulsync user, logs a loud warning with the exact host-side
   `chown` command needed to fix it. Catches the underlying bind-
   mount perm issue that the restart-loop fix would otherwise mask
   (container starts but auto-import / downloads write into
   unwritable dirs and fail silently). This is the diagnostic that
   would have surfaced the root cause without needing the user to
   share a container-restart screenshot.

Zero behavior change for users whose containers were already
starting fine. Defensive against the rootless/podman config that
broke after the image-bloat refactor.

Verified shell syntax with `bash -n entrypoint.sh`. Full pytest
2693 passed (no Python touched).
2026-05-11 13:35:31 -07:00
Broque Thomas
4fb9f38798 Your Albums: selectable wishlist modal + Tidal album resolution
Two-part fix to the Your Albums "Download Missing" flow on Discover.

Part A — UX redesign

The prior `downloadMissingYourAlbums()` ran a per-album loop that
fired direct-download tasks via `openDownloadMissingModalForYouTube`.
Reported as silently failing — "Queuing 2/2" toast with no actual
transfer activity. Even when downloads worked, bypassing the
wishlist meant no retry / dedup / rate-limit / source-fallback
handling.

Replaced with a selectable-grid modal mirroring the Download
Discography pattern from the library page. Click the download
button → opens a checkbox grid showing every missing album (cover,
title, artist, year, track count, source) → user picks what they
actually want → click "Add to Wishlist" → each album's tracks get
resolved + queued through the existing wishlist auto-download
processor. NDJSON progress stream renders ✓/✗ per album.

New JS helpers:
- `_openYourAlbumsBatchModal(missingAlbums)` — builds the modal
- `_renderYourAlbumsBatchCard(row, index)` — per-album card
- `_yourAlbumsBatchSelectAll(select)` — bulk toggle
- `_updateYourAlbumsBatchFooterCount()` — live count + button text
- `_closeYourAlbumsBatchModal()` — overlay teardown
- `_startYourAlbumsBatchAddToWishlist()` — submit handler, NDJSON
  progress consumer
- `_yourAlbumsPickSource(album)` — picks the single best source-id
  per row (priority: spotify → deezer → tidal → discogs)

Reuses the `.discog-*` CSS classes from the library Download
Discography modal — no new CSS. Reuses the existing
`/api/artist/<id>/download-discography` endpoint. The endpoint's URL
artist_id param is functionally unused (per-album payload carries
everything — verified by reading the endpoint body), so the modal
posts with placeholder `your-albums` and gets multi-artist
resolution for free without backend changes.

Part B — Tidal album resolution

Reported as the original bug: clicking download on Tidal-only albums
did nothing because `/api/discover/album/<source>/<album_id>` had no
`tidal` branch and `tidal_client` had no `get_album_tracks` method.

`core/tidal_client.py`: new `get_album_tracks(album_id, limit=None)`
method. Two-phase: cursor-walk
`/v2/albums/<id>/relationships/items?include=items` for track refs +
position metadata (`meta.trackNumber` + `meta.volumeNumber`),
batch-hydrate via existing `_get_tracks_batch` for artist/album
names. Returns `Track` objects with `track_number` and `disc_number`
attached. Sort by (disc, track) so multi-disc compilations render in
album order.

`web_server.py`: new `'tidal'` source branch in
`/api/discover/album/<source>/<album_id>`. Resolves album metadata
via `get_album`, tracks via `get_album_tracks`, cover art via inline
`?include=coverArt` lookup. Same response shape as Spotify/Deezer
branches.

`webui/static/discover.js`:
- `tidal_album_id` added to `trySources` for the single-album click
  flow (`openYourAlbumDownload`)
- Same source picker drives the new batch modal
- Virtual-id generation includes `tidal_album_id` so Tidal-only
  albums get stable identifiers across discover-album-* / your-
  albums-* contexts

10 new tests in `tests/test_tidal_album_tracks.py` pin:
- Single-page walk + hydration
- Multi-page cursor chain
- Multi-disc sort order (disc 1 → 2 in track order each)
- `limit` short-circuit at page boundary
- No-token short-circuit (no API call)
- HTTP error returns empty
- 429 raises (propagates to `rate_limited` decorator for retry)
- Forward-compat type filter (skips non-track entries)
- Partial-batch hydration failure containment
- Empty-album short-circuit (no batch call)

Full pytest: 2693 passed.
2026-05-11 12:36:16 -07:00
Broque Thomas
7a23d60f28 AcoustID scanner: file-tag fallback for legacy compilation tracks
Follow-up to the prior compilation-album scanner fix. That patch
made the scanner read `tracks.track_artist` (per-track artist
column) via COALESCE so compilation tracks would compare against
the right value. But tracks downloaded BEFORE the `track_artist`
column existed have track_artist=NULL — COALESCE falls back to
album artist (the curator) and the wrong-comparison case returns.

Fix: explicit 3-tier resolution in `_scan_file`:

  1. DB `tracks.track_artist` if populated → trust it. Respects
     manual edits from the enhanced library view (user who curated
     the DB value but didn't re-tag the file gets their edit
     respected, not overridden by stale file tag).

  2. File's ARTIST tag via mutagen if present → use it. Tidal /
     Spotify / Deezer all write the per-track artist into the
     audio file at download time regardless of SoulSync's DB
     schema, so it's ground truth even when the DB column is
     stale or NULL. File is already open for fingerprinting so
     mutagen tag-read is essentially free.

  3. Album artist → final fallback for files without proper ARTIST
     tags AND no DB track_artist. Existing pre-fix behavior.

`_load_db_tracks` SELECT now surfaces `track_artist` (raw, may be
empty/NULL via NULLIF) and `album_artist` separately in addition
to the COALESCE'd `artist` field — so `_scan_file` can tell the
difference between 'DB has a curated value' and 'DB fell back to
album artist'. Without this distinction, the file-tag fallback
would create false positives when DB is curated but file is stale.

5 new tests (11 total in the file) pin:

  - File-tag-trumps-DB resolves the legacy NULL case (DB says
    'Andromedik' (album curator), file says 'Eclypse', AcoustID
    says 'Eclypse' → no flag)
  - Tag-missing falls back to album artist (preserves existing
    genuine-mismatch contract — file without tag + AcoustID
    mismatch still flags)
  - Mutagen exception swallowed (debug log, fall-through)
  - File-tag matches DB → no behavioral change
  - DB curated value trumps stale file tag (false-positive guard
    — user edited DB without re-tagging file shouldn't get flagged)

Two existing test fixtures (`_make_context` callers) updated to
the new 10-column row shape.

SQL behavior verified empirically against real SQLite: NULL and
empty-string both flow through NULLIF → None in Python →
file-tag-fallback path. Modern populated values trump file tag.
2026-05-11 11:39:19 -07:00
Broque Thomas
f4c433c151 Tidal: rewire favorite albums + artists to V2 user-collection endpoints
Discord: Discover → Your Albums (and Your Artists) was returning
nothing for Tidal users regardless of how many albums/artists they'd
favorited. Audit found `get_favorite_albums` and `get_favorite_artists`
called the deprecated `/v2/favorites?filter[type]=ALBUMS|ARTISTS`
endpoint — that endpoint returns 404 for personal favorites because
it's scoped to collections the third-party app created itself. The
V1 fallback (`/v1/users/<id>/favorites/...`) is also dead because
modern OAuth tokens carry `collection.read` instead of the legacy
`r_usr` scope V1 demands (returns 403).

Same root cause as the favorited-tracks fix from #502.

Fix: rewire to the working V2 user-collection endpoints —
`/v2/userCollectionAlbums/me/relationships/items` and
`/v2/userCollectionArtists/me/relationships/items` — using the
same cursor-paginated pattern shipped for tracks.

Architecture:

* ID enumeration lifted into a generic
  `_iter_collection_resource_ids(path, expected_type, max_ids)`
  helper so tracks / albums / artists all share one walker. Three
  thin wrappers preserve the per-resource public surface
  (`_iter_collection_track_ids`, `_iter_collection_album_ids`,
  `_iter_collection_artist_ids`). Net deduped ~80 lines that would
  otherwise be three near-identical copies.

* Batch hydration via `/v2/{albums|artists}?filter[id]=...&include=...`
  with extended JSON:API include semantics. One request returns up
  to 20 albums + their artists + cover artworks all in `included[]`
  (or 20 artists + their profile artworks). Three static helpers
  parse the response:
    - `_build_included_maps(included)` → indexes the array by type
      so per-resource lookup is O(1) per relationship ref
    - `_first_artist_name(rels, artists_map)` → resolves primary
      artist from relationships block; '' on missing/unknown
    - `_first_artwork_url(rel, artworks_map)` → picks `files[0]`
      (Tidal returns artwork files largest-first, so this gets the
      highest-resolution variant — typically 1280×1280)

* Public methods (`get_favorite_albums`, `get_favorite_artists`)
  preserve the prior return shape — list of dicts matching what
  `database.upsert_liked_album` / `upsert_liked_artist` consume —
  so the discover aggregator path in `web_server.py` stays
  byte-identical. No caller changes needed.

* Deleted ~240 lines of dead code: the V2-favorites paths AND the
  V1 fallback paths from the old method bodies. Both are dead
  against modern OAuth tokens.

24 new tests in `tests/test_tidal_favorite_albums_artists.py` pin:

* Cursor-walker dispatch (album/artist iters pass correct path +
  expected_type to the generic walker)
* Included-map building (groups by type, skips items missing id)
* Artist + artwork relationship resolution (full + missing rels +
  unknown id + no files cases)
* Batch hydration parse for albums (full attributes, missing
  relationships fall through to defaults, type-filter excludes
  non-album entries, `filter[id]` param is comma-joined)
* Batch hydration parse for artists (same shape coverage)
* End-to-end orchestrator behavior (walk → batch → return,
  empty-input short-circuits without API call, BATCH_SIZE chunking
  on 41 IDs → 20/20/1, exception-from-iter returns [])

Endpoint paths empirically verified against live Tidal API:
`userCollectionArtists/me/relationships/items` returned 200 + 5
real artist refs for the test account. `userCollectionAlbums/...`
returned 200 + empty (account has 0 album favorites currently)
but the response shape is correct. The deprecated
`/v2/favorites?filter[type]=ALBUMS` returned 404. The V1
`/v1/users/<id>/favorites/albums` returned 403 with explicit
"Token is missing required scope. Required scopes: r_usr" message.

WHATS_NEW entry under existing '2.5.1' block.

Full pytest: 2678 passed.
2026-05-11 10:03:27 -07:00
Broque Thomas
e1d3c59bdc WHATS_NEW: move append-mode entry to 2.5.1 block 2026-05-11 08:15:47 -07:00
Broque Thomas
6fe85f2f37 Server playlist sync: append mode (preserve user-added tracks)
Discord report (CJFC, 2026-04-26): syncing a Spotify playlist to the
server overwrote anything manually added to the server-side playlist.
The fix adds a per-sync mode picker next to the Sync button on the
playlist details modal — Replace (default, current delete-recreate
behavior) or Append only (preserves existing tracks, only adds new
ones). Useful when the source platform caps playlist size and the
user is manually building beyond it on the server.

Implementation:

* New `append_to_playlist(name, tracks)` method on Plex / Jellyfin /
  Navidrome clients. Each uses the server's NATIVE append API:
    - Plex: `existing_playlist.addItems(new_tracks)`
    - Jellyfin: `POST /Playlists/<id>/Items?Ids=...&UserId=...`
    - Navidrome: Subsonic `updatePlaylist?songIdToAdd=...`
  Falls back to `create_playlist` when the playlist doesn't exist
  yet (first sync). No delete-recreate, no backup playlist created
  (preserves playlist creation date + metadata + non-soulsync-managed
  tracks).
* Dedup-by-server-native-id (ratingKey for Plex, GUID for Jellyfin,
  song-id for Navidrome) — never re-adds a track already on the
  playlist. Server-native identity, not fuzzy title+artist match,
  so it can't false-collide.
* `sync_service.sync_playlist` accepts `sync_mode='replace'|'append'`
  kwarg. Single if/else branch dispatches to `append_to_playlist` or
  `update_playlist`. Threaded through `core/discovery/sync.run_sync_task`
  and the `/api/sync/start` HTTP handler. Validation on the API rejects
  unknown mode strings (defaults to 'replace').
* Frontend: per-playlist `<select id="sync-mode-${id}">` rendered next
  to the Sync button in both modal renderers (sync-spotify.js for
  Spotify playlists, sync-services.js for Deezer ARL playlists).
  `startPlaylistSync` reads the select at click time; missing select
  (other callers like discover.js) defaults to 'replace' so backward
  compat preserved without per-call-site updates.
* SoulSync standalone has no playlist methods at all and the modal
  hides the Sync button entirely on it via `_isSoulsyncStandalone` —
  dispatch never reaches that path, no defensive fallback needed.

15 new tests pin per-server append behavior:
  - missing playlist → create_playlist delegation
  - dedup filtering (existing IDs skipped, only new tracks added)
  - empty new-track set short-circuits without API call
  - failure paths return False without raising
  - contract listing (KNOWN_PER_SERVER_METHODS includes
    'append_to_playlist'; Plex / Jellyfin / Navidrome all implement)

Plus tests/discovery/test_discovery_sync.py fake `sync_playlist`
fixture got `sync_mode='replace'` default to match the new signature
(was breaking after the kwarg add; now passing).

WHATS_NEW entry under new '2.6.0' block (hidden by
`_getLatestWhatsNewVersion` until next release bump).

Closes CJFC discord request.
2026-05-10 22:52:11 -07:00
Broque Thomas
1d6e213b16 version bump 2026-05-10 21:49:51 -07:00
Broque Thomas
f28f9808db Tidal: surface Favorite Tracks as virtual playlist (issue #502)
Adds the user's Tidal favorited tracks ("My Collection" in the Tidal
app) as a virtual playlist alongside their real playlists, mirroring
how Spotify's "Liked Songs" is treated.

Reporter (yug1900) located the working endpoint after the prior
`/v2/favorites?filter[type]=TRACKS` attempt returned empty data —
that endpoint is scoped to collections the third-party app created
itself, not personal favorites. Real endpoint:

    GET /v2/userCollectionTracks/me/relationships/items
        ?countryCode=US&locale=en-US&include=items

Cursor-paginated (20 per page, follow `links.next` with
`page[cursor]=...` until exhausted). Response only carries
track-level attributes — artist + album NAMES come back as
relationship-link stubs, not embedded data.

Implementation:

* Two-phase fetch — `_iter_collection_track_ids` walks the cursor
  chain to enumerate every track id (cheap, IDs only), then
  `get_collection_tracks` batch-hydrates 20 IDs at a time through
  the existing `_get_tracks_batch` helper which already knows how
  to `include=artists,albums`. No duplication of the JSON:API
  artist/album parse, no new dataclass shape.
* Virtual playlist `tidal-favorites` appended to the end of
  `/api/tidal/playlists`. ID intentionally has no colon —
  sync-services.js renderer interpolates IDs into CSS selectors
  via template literals (`#tidal-card-${p.id} .foo`) and a `:`
  would parse as a CSS pseudo-class operator.
* `tidal_client.get_playlist("tidal-favorites")` recognizes the
  virtual id and dispatches to the collection path internally, so
  every per-id consumer gets it for free: detail endpoint, mirror
  auto-refresh automation, "build Spotify discovery from Tidal
  playlist" flow.

OAuth scope expansion:

* Added `collection.read` to both OAuth flows (the
  `core/tidal_client.py::authenticate` standalone path AND the
  `web_server.py::auth_tidal` web flow — they were independent
  scope strings that both needed updating).
* Added `prompt=consent` to both flows — without it Tidal silently
  returns a token carrying only the ORIGINAL scope set even after
  re-authentication, because Tidal treats the existing
  authorization as still valid.
* New `disconnect()` method + `POST /api/tidal/disconnect`
  endpoint + Disconnect button next to Authenticate in Settings →
  Connections → Tidal — required for users whose existing token
  predates the scope expansion (forces a clean grant).

Reconnect-needed UI hint:

* `_collection_needs_reconnect` flag set on 401/403 from the
  collection endpoint, cleared on next successful walk, NOT set
  on 5xx (transient server errors must not falsely tell the user
  to reconnect).
* Listing endpoint reads the flag and surfaces a placeholder card
  titled "Favorite Tracks (reconnect Tidal to enable)" with a
  description pointing at Settings, so the user has something
  visible to act on instead of a silently missing row.

Diagnostic logging — collection request URL + response status +
first 300 bytes of body now logged at info level so future "why
is my collection empty" reports can be diagnosed from app.log
without needing live reproduction.

22 new tests pin: cursor walk (full chain, max-ids cap mid-page +
at page boundary), auth gates (no token / 401 / 403 all bail
clean), reconnect-flag lifecycle (set on 401/403, cleared on next
successful walk, NOT set on 5xx), forward-compat type filter
(non-track entries skipped), count helper, batch hydration
delegation + chunking at the 20-per-batch cap, partial-batch
failure containment, virtual-id dispatch (real playlist ids still
flow through the normal path).

Closes #502.
2026-05-10 21:36:22 -07:00
Broque Thomas
b5b6673216 Reorganize: hint at Unknown Artist Fixer for placeholder-metadata rows
Phase B of foxxify discord report. Pre-#524 manual-import bug left
some albums in the library with `artist=Unknown Artist` and `album.title
= <numeric album_id>`. Reorganize couldn't place them (no usable
metadata source ID) and emitted a generic "run enrichment first" hint
that doesn't apply — enrichment can't fix these rows. The right tool
is the existing `Fix Unknown Artists` repair job (reads file tags,
re-resolves metadata, re-tags + moves files).

Discoverability gap, not a logic gap. Reorganize now detects the bad-
metadata shape (Unknown Artist OR album.title that's a 6+ digit
numeric id) and emits a clear "run the Fix Unknown Artists repair
job" hint at both reason-emit sites (planner + executor). No
duplication of fixer logic.

WHATS_NEW entry covers both Phase A (orphan-format sibling handling,
already committed in d944a16) and Phase B since they ship in the same
PR for the same reporter.

20 new tests pin helpers + reason routing.
2026-05-10 20:16:28 -07:00
Broque Thomas
812db1fbbf AcoustID scanner: prefer track_artist for compilation albums
Discord report (Skowl): downloaded a compilation album ("High Tea
Music: Vol 1") where every track has a different artist (Eclypse,
Andromedik, T & Sugah, Gourski, etc.) and the AcoustID scanner
flagged every single track as Wrong Song. The file tags had the
correct per-track artist (e.g. "Eclypse" for "City Lights"), but
the scanner compared against the album-level artist ("Andromedik",
the curator). Raw similarity 12% → Wrong Song flag.

# Why the prior multi-value fix didn't help

Foxxify's case (just-merged PR): AcoustID returned multi-value
credit "Okayracer, aldrch & poptropicaslutz!" — primary IS in the
credit. Splitting found it.

Skowl's case: both sides single-value but DIFFERENT artists.
Splitter has nothing to find — Eclypse simply isn't in "Andromedik".
Different bug.

# Cause

Scanner SQL at `core/repair_jobs/acoustid_scanner.py:281` joined
the `artists` table via `tracks.artist_id` which points at the
ALBUM artist (the curator/label-name applied to every row in a
compilation). The `tracks.track_artist` column already holds the
correct per-track artist for compilations — populated by every
server-scan path (Plex `originalTitle`, Jellyfin `ArtistItems`,
Navidrome per-track `artist`) AND the auto-import / direct-download
post-process flow (`record_soulsync_library_entry` writes it when
different from album artist). Scanner just wasn't reading it.

# Fix

```sql
SELECT t.id, t.title,
       COALESCE(NULLIF(t.track_artist, ''), ar.name) AS artist,
       ...
```

Prefers per-track artist when populated, falls back to album artist
for legacy rows / single-artist albums where `track_artist` is NULL.
`NULLIF(t.track_artist, '')` handles the empty-string-instead-of-null
case some legacy rows might have.

# Composes with Foxxify's multi-value fix

For the rare compilation track where AcoustID ALSO returns a
multi-value credit (e.g. compilation track has multiple credited
performers), both paths work together — `track_artist` gives the
correct expected primary, then the helper splits the credit and
finds it.

# Tests added (2)

- `test_load_db_tracks_prefers_track_artist_for_compilation` —
  reporter's exact case: track with `track_artist='Eclypse'` AND
  `artist_id` pointing at album artist 'Andromedik' resolves to
  'Eclypse'. Second track with NULL `track_artist` falls back to
  album artist 'Andromedik' (single-artist + legacy compat).
- `test_load_db_tracks_falls_back_when_track_artist_empty_string`
  — empty string in `track_artist` (some legacy rows) → NULLIF
  returns NULL → COALESCE falls back to album artist.

Both use a real SQLite DB so the COALESCE/NULLIF logic + JOIN
runs against actual schema (SimpleNamespace fakes can't simulate
JOINs).

# Verification

- 6/6 scanner tests pass (2 new + 4 existing)
- 2586 full suite passes (+2 from prior commit)
- Ruff clean
2026-05-10 19:44:57 -07:00
Broque Thomas
df304eb016 AcoustID scanner: handle multi-value artist credits
Discord report (Foxxify): the AcoustID scanner repair job flagged
multi-artist tracks as Wrong Song because AcoustID returns the
FULL credit ("Okayracer, aldrch & poptropicaslutz!") while the
library DB carries only the primary artist ("Okayracer"). Raw
SequenceMatcher similarity scored ~43% — well below the 60%
threshold — so the scanner created a finding even though the
audio was correct. User couldn't fix without lowering the global
artist threshold to ~30% (which would let real mismatches through).

# Fix

Extended the shared `core/matching/artist_aliases.py::artist_names_match`
helper (originally lifted for #441) with credit-token splitting.
When the actual artist string contains common separators —

- punctuation: `,`  `&`  `;`  `/`  `+`
- keywords (whitespace-bounded): `feat.` `ft.` `featuring` `with`
  `vs.` `x`

— the helper splits into individual contributors and checks each
against the expected artist. Primary-in-credit cases now resolve
at 100% instead of 43%.

Two pattern groups because punctuation separators don't need
surrounding whitespace, but keyword separators MUST be
whitespace-bounded — otherwise we'd split artists with `x` /
`with` etc. in their names ("JAY-X" → "JAY-" / "" issue).

Composes with the existing alias path: cross-script multi-artist
credits ("Hiroyuki Sawano" expected, "澤野弘之, FeaturedJp"
actual) work via alias-token-against-credit-token compare.

# Wire-in

Scanner at `core/repair_jobs/acoustid_scanner.py:202` replaces
the raw `SequenceMatcher` call with `artist_names_match`. Pass
RAW artist strings (not pre-normalised by `_normalize`) so the
splitter can recognise separators — `_normalize` strips ALL
punctuation, which destroyed the very tokens the splitter needs.

The AcoustID post-download verifier (`core/acoustid_verification.py`)
already routes through `_alias_aware_artist_sim` which calls the
same helper — gets the multi-value benefit automatically without
a separate wire-in.

# New `split_artist_credit` exported helper

Pure-function helper for callers who want token-level access to
the credit list (debugging, UI, future per-token enrichment). Same
splitter logic, exposed as a top-level function.

# Tests added (14)

`tests/matching/test_artist_aliases.py` (+11):
- `TestSplitArtistCredit` — parametrised across 12 credit-string
  formats (comma, ampersand, semicolon, slash, plus, feat./ft./
  featuring, with, vs., x, single-token, empty), drops empty
  tokens, strips per-token whitespace
- `TestMultiValueCreditMatching` — reporter's exact case
  (Okayracer in 3-artist credit → 100%), primary in middle/end of
  credit, genuine-mismatch still fails, single-token actual falls
  through to direct compare, multi-value composes with aliases,
  threshold still respected

`tests/test_acoustid_scanner.py` (+3):
- Reporter's case end-to-end through `_scan_file` — fingerprint
  99% / title 100% / multi-artist credit → no finding created
- Genuine artist mismatch still creates finding (no false
  suppression of real mismatches)
- `JobResultStub` minimal scaffold for the integration tests

# Verification

- 14 new tests pass (49 helper + 5 scanner total in their files)
- 110 matching + scanner tests pass total
- 2584 full suite passes (+25 from baseline 2559)
- Ruff clean
- Reporter's exact case (Okayracer in `Okayracer, aldrch &
  poptropicaslutz!`) now scores 100% match → no Wrong Song flag
2026-05-10 19:17:59 -07:00
Broque Thomas
80cf16339c Deezer cover art: upgrade CDN URL to 1900×1900 (was embedding 1000×1000)
Discord report (Tim): downloaded cover art via Deezer metadata
source came out visibly blurry in Navidrome / on phones — large
displays exposed the limited resolution.

# Cause

Deezer's API returns `cover_xl` URLs at 1000×1000. The underlying
CDN actually serves up to 1900×1900 by rewriting the size segment
in the URL path (same trick the iTunes mzstatic + Spotify scdn
upgrades already use). SoulSync wasn't doing the rewrite — every
Deezer-sourced cover got embedded at 1000×1000 regardless of how
much higher resolution the CDN had available.

# Verified empirically

```
$ for size in 1000 1400 1800 1900 2000; do curl -I "...{size}x{size}-..."; done
1000: 200 OK  106 KB
1400: 200 OK  198 KB
1800: 200 OK  331 KB
1900: 200 OK  371 KB
2000: 403 Forbidden
```

1900 is the safe ceiling. Above that the CDN returns 403. CDN
serves source-native bytes when source < target (smaller-source
albums get same bytes whether we ask for 1000 or 1900), so asking
for 1900 universally is safe.

# Fix

New `_upgrade_deezer_cover_url(url, target_size=1900)` helper in
`core/deezer_client.py`. Pure function, mirrors the
`_upgrade_spotify_image_url` pattern that already lives in
`core/spotify_client.py`. Defensive on every input shape:

- Empty / None → returned as-is
- Non-Deezer URL (no `dzcdn`) → returned as-is
- No size segment in URL → returned as-is
- Already at/above target → returned as-is (idempotent, never
  downgrades)

Applied at both cover-download sites:

- `core/metadata/artwork.py::download_cover_art` — auto post-process
  flow. Mirrors the existing iTunes mzstatic upgrade right above it.
- `core/tag_writer.py::download_cover_art` — enhanced library view's
  "Write Tags to File" feature.

# Scope discipline

- Helper applied at the DOWNLOAD boundary, not the source extraction
  point in `deezer_client.py`. Means cached entries in the metadata
  cache + DB row `image_url` columns keep the original 1000×1000 URL
  Deezer's API returned. Future CDN behavior changes only affect the
  download path, not stored data.
- Pre-existing `prefer_caa_art` toggle (Settings → Library →
  Post-Processing) untouched — orthogonal workaround for users who
  want even higher quality (MusicBrainz Cover Art Archive, often
  3000×3000+).
- iTunes / Spotify upgrade paths untouched — they already worked.

# Tests added (16)

`tests/metadata/test_deezer_cover_url_upgrade.py`:

- Standard upgrade: default target 1900 on cover URL, alternate
  dzcdn host (`e-cdns-images.dzcdn.net` vs `cdn-images.dzcdn.net`),
  artist picture URLs (same path pattern), 500×500 source upgrades
  too
- Custom target size: smaller target = no-op (never downgrade),
  larger target works
- Idempotent: already at/above target returned unchanged
- Defensive on non-Deezer URLs: parametrised across 5 hosts
  (Spotify scdn, iTunes mzstatic, MB CAA, Last.fm, random) — all
  returned untouched
- Defensive on malformed Deezer URL (no size segment) → returned
  as-is
- Empty / None handling

# Verification

- 16/16 helper tests pass
- 560/560 metadata + imports tests pass (no regression)
- 2559 full suite passes
- Ruff clean
2026-05-10 18:15:40 -07:00
Broque Thomas
80e9398e16 WHATS_NEW: cross-script artist names no longer quarantine files (#442) 2026-05-10 16:47:31 -07:00
Broque Thomas
c02d51d60d Plex: trigger_library_scan + is_library_scanning use auto-detected section — fixes #535
# Bug

Plex servers with the music library named anything other than "Music"
(Música, Musique, Musik, Musica, 音乐, موسيقى, etc.) hit this error
after every import cycle:

    soulsync.plex_client - ERROR - Failed to trigger library scan
    for 'Music': Invalid library section: Music
    soulsync.web_scan_manager - ERROR - Failed to initiate PLEX
    library scan via web

Side effect: `wishlist.processing` kept reporting "Missing from
media server after sync" for tracks that DID import correctly, so
they got perpetually re-added to the wishlist.

# Root cause

`_find_music_library` correctly auto-detects the music section by
`section.type == 'artist'` and stores it on `self.music_library` —
works for any locale because the type is language-neutral. Read
methods (`get_artists`, etc.) route through `_get_music_sections`
which returns `[self.music_library]`, so they never had the bug.

But `trigger_library_scan` and `is_library_scanning` ignored
`self.music_library` and called
`self.server.library.section(library_name)` directly with the
hardcoded `"Music"` default. `server.library.section('Music')`
raises `NotFound` on any server whose section isn't literally
named "Music".

# Fix

Both methods now prefer `self.music_library` first, fall back to
literal `library_name` lookup only when auto-detection hasn't
populated the cached reference (test fixtures, edge cases).

`is_library_scanning`'s activity-feed match also corrected to
filter by the resolved section's actual title — the prior code
matched `library_name.lower() in activity_title.lower()` which
defaults to "music" and would never match activities for
non-English sections.

`trigger_library_scan`'s success log line now surfaces the actual
section title (`Música`) instead of the unused `library_name`
default ("Music") — confusing when debugging on non-English servers.

# Tests added (13)

`tests/media_server/test_plex_non_english_section_name.py`:

- `test_uses_auto_detected_section_regardless_of_locale` — parametrised
  across 6 locale variants (Música, Musique, Musik, Musica, 音乐, موسيقى).
  Each verifies trigger_library_scan calls the auto-detected
  section's `update()`, NOT a literal-name fallback. Stub raises
  AssertionError on `server.library.section()` so a regression that
  re-introduces the fallback fails loudly.
- `test_falls_back_to_literal_lookup_when_no_auto_detection` —
  backward compat: music_library=None → literal lookup as before.
- `test_explicit_library_name_arg_used_only_when_no_auto_detection` —
  auto-detected wins over explicit kwarg when both available.
- `test_logs_correct_section_label_on_success` — log line surfaces
  resolved section title.
- 4 symmetric tests for is_library_scanning covering refreshing-attr
  check, activity-feed title match, no-match for unrelated sections,
  fallback path.

# Verification

- 13 new tests pass
- 84/84 media_server tests pass (no regression in the existing
  Plex / Jellyfin / Navidrome suite)
- 2458 full suite passes (+13 from baseline)
- Ruff clean
2026-05-10 10:22:32 -07:00
Broque Thomas
402d851cac Deezer search: drop advanced-syntax at endpoint, free-text + rerank wins
Live-API verification revealed advanced-syntax queries hurt more
than they help on this endpoint. Switching the import-modal Deezer
search back to free-text + local rerank.

# What live testing showed

Hit Deezer's public API with both query forms for the issue #534
case (`Dirty White Boy` + `Foreigner`):

**Free-text (`q=Dirty White Boy Foreigner`):**
- Returns 21 results
- Real Foreigner Head Games studio cut at #1
- Live versions at #2-10
- Karaoke / cover variants at #11-15

**Advanced (`q=track:"Dirty White Boy" artist:"Foreigner"`):**
- Returns 12 results
- "(2008 Remaster)" at #1 — canonical Head Games cut MISSING from
  top 8 entirely
- Live + alt-album versions follow

Advanced syntax DOES filter karaoke at the API level (none in the
12-result set vs. 5 at positions 11-15 in free-text), but it has
its own ranking bias that surfaces remasters / "Best Of" cuts
ahead of the canonical recording. Net regression for the user-
facing goal.

# Fix

1. Endpoint reverts to free-text query with local rerank applied.
2. Local rerank gains "remaster" / "remastered" / "reissue"
   patterns under VARIANT_TAG_PATTERNS (soft 0.4× penalty — user
   may want them but they shouldn't outrank the original).
3. Client kwarg support (`track=` / `artist=` / `album=`) preserved
   for future opt-in callers (e.g. exact-match flows where API-
   level filtering matters more than ranking).

# Verified end-to-end against live Deezer API

Re-ran the exact #534 case through the live API + new rerank.
Top 15 results post-rerank:

1. Dirty White Boy — Foreigner — Head Games  ← REAL CUT AT TOP
2-10. Various Live versions
11-15. Karaoke / cover / tribute variants  ← BURIED

Real Foreigner Head Games studio cut at #1, exactly the user's
ask.

# Tests

- `test_relevance.py` — variant tag patterns extended; existing
  tests still pass (50 tests).
- `test_search_match_endpoints.py::test_joins_track_and_artist_into_free_text_query`
  — replaces `test_passes_track_and_artist_as_kwargs`; verifies
  endpoint sends free-text join, NOT field-scoped kwargs (the
  prior test asserted the wrong direction now).
- Karaoke-burying assertion at the endpoint still pins the
  user-visible behaviour.
- Client kwarg path tests untouched (still pin advanced-syntax
  construction for future opt-in callers).

# Verification

- 75 relevance + endpoint + query tests pass
- 2445 full suite passes
- Ruff clean
- Live Deezer API shows real cut at #1 post-rerank
2026-05-10 09:36:48 -07:00
Broque Thomas
59992d42a8 Deezer search: free-text fallback when advanced query returns 0
Defensive followup to the relevance fix. Deezer's advanced search
syntax (`artist:"X"`) is documented as substring match, but in
practice it's brittle on artist name variants ("Foreigner [US]",
"The Foreigner") and on tracks indexed under non-canonical title
spellings. When the advanced query returns nothing, we'd previously
land at "No matches" — a regression vs. pre-fix behaviour where
free-text would have returned a less-relevant but non-empty set.

Fix: when the advanced query returns 0 results AND the caller used
field-scoped kwargs, fall back to a free-text join of the same
kwargs and re-query. Caller-side rerank still tightens whatever the
fallback returns, so the worst-case post-fix behaviour is the
pre-fix behaviour — never strictly worse.

Pulled the cache + parse + store dance into a private helper
(`_search_tracks_with_query`) so the orchestration can call it
twice (advanced → fallback) without code duplication. Single API
call when the advanced query has results — no wasted requests.

Diagnostic logger.debug fires when the fallback triggers so we can
see in production whether it's happening (and to which queries).

# Tests added (4)

- `test_falls_back_to_free_text_when_advanced_empty` — advanced
  query returns 0, free-text returns hits; client returns the
  free-text hits + both API calls fire.
- `test_no_fallback_when_advanced_query_has_results` — single hit
  on advanced query → no second API call.
- `test_no_fallback_when_legacy_free_text_call` — legacy callers
  already exhausted the only path; empty result is final.
- `test_no_fallback_when_query_unchanged` — empty kwargs path
  doesn't trigger the fallback branch (used_advanced=False).

# Existing tests updated

The 4 prior `TestSearchTracksQueryWiring` + `TestSearchTracksCacheKey`
tests were stubbing `_api_get` to return empty `{'data': []}` and
asserting `assert_called_once`. With the new fallback, those stubs
trigger a second API call and the assertions break — even though
the FIRST call construction is what the tests cared about. Updated
the stubs to return one fake hit so the fallback doesn't fire, and
switched to `call_args_list[0]` for first-call inspection.

# Verification

- 18/18 deezer query tests pass (14 prior + 4 new)
- 2445 full suite passes (+4 from prior commit)
- Ruff clean
2026-05-10 09:16:13 -07:00
Broque Thomas
1cc37081a6 Fix Deezer search relevance — issue #534
# Background

User reported (#534) that the import-modal "Search for Match" dialog
returned irrelevant results when Deezer was the metadata source.
Searching `Dirty White Boy` + `Foreigner` returned 5+ karaoke /
"originally performed by" / "in the style of" / "re-recorded" /
tribute-band results ranked above the actual Foreigner studio cut
from Head Games. User had to scroll past the junk every time, or
fall back to iTunes search which is much slower.

# Root cause — two layers

1. **Endpoint joined `track + artist` into free-text query.**
   `/api/deezer/search_tracks` was passing `q=Dirty White Boy Foreigner`
   to Deezer's `/search/track` API. Deezer fuzzy-matches that
   string across title / lyrics / artist / album / contributors and
   orders by global popularity — anything that appears across many
   compilations outranks the canonical recording.

2. **No local rerank.** None of the search-modal endpoints applied
   any post-filtering. Deezer's API order shipped straight to the
   user.

# Fix — same architectural shape Cin would build

## Layer 1: field-scoped query at the client boundary

`core/deezer_client.py::search_tracks()` now accepts optional
`track`, `artist`, `album` kwargs. When provided, builds Deezer's
advanced search syntax: `q=track:"X" artist:"Y" album:"Z"`. Massive
relevance improvement because each term matches the right field
instead of fuzzy-matching everywhere.

Backward compat preserved: legacy free-text `query=` callers still
work unchanged. Field-scoped path takes precedence when both are
provided. Empty input fast-fails without an API call. Embedded
double-quotes stripped (Deezer's syntax has no escape mechanism).

## Layer 2: provider-neutral relevance reranker

New `core/metadata/relevance.py` module — pure-function rerank over
the canonical `Track` dataclass. Composable scoring:

- **Cover/karaoke patterns** (multiplier 0.05, effectively buries):
  matches "karaoke", "originally performed by", "in the style of",
  "made famous by", "tribute", "vocal version", "backing track",
  "cover version", "re-recorded", "cover by", etc. across title,
  album, AND artist fields. Catches the screenshot's exact junk:
  artist credits like "Pop Music Workshop" / "The Karaoke Channel"
  / "Foreigner Tribute Band".
- **Variant tags** (multiplier 0.4): live / acoustic / demo /
  instrumental / remix / radio edit / club mix etc. — softer
  penalty since the user MAY want them. Skipped entirely when the
  expected_title contains the same tag (so searching
  "Track (Live)" still ranks Live versions first).
- **Exact artist boost** (multiplier 1.5): primary artist exactly
  matches expected_artist after normalisation. Single strongest
  signal for "this is the canonical recording".
- **Title + artist similarity** via SequenceMatcher (parentheticals
  + punctuation stripped before comparison).
- **Album-type weighting**: album=1.0 > single/ep=0.85 > compilation=0.7.
  Compilations are more likely tribute / karaoke repackages.

Each component is a standalone function so tests pin them
individually without standing up the full pipeline.

## Wired at three search-modal endpoints

- `/api/deezer/search_tracks` — uses both layers (field-scoped
  query + rerank).
- `/api/itunes/search_tracks` — uses rerank only (iTunes API has
  no advanced-syntax search, but karaoke / cover variants still
  leak through and need the local penalty).
- `/api/spotify/search_tracks` — already builds field-scoped
  `track:X artist:Y` query; rerank added as the consistency safety
  net so all three sources behave the same from the user's
  perspective.

Other Deezer call sites (matching engine, watchlist scanner,
auto-import single-track ID) deliberately not touched in this PR
— they have their own elaborate scoring pipelines tuned to their
specific contexts and aren't surfacing the user-reported issue.
Per Cin: "don't refactor beyond what the task requires."

# Tests

71 new tests across 3 files:

- `tests/metadata/test_relevance.py` (50 tests) — every scoring
  component pinned individually + the issue #534 screenshot
  reproduced as a regression test (real Foreigner cut wins after
  rerank, karaoke variants drop to bottom).
- `tests/metadata/test_deezer_search_query.py` (14 tests) —
  advanced-syntax query construction, field-scoped wiring at the
  client boundary, free-text path unchanged, kwargs win when
  ambiguous, limit clamping, cache key consistency.
- `tests/imports/test_search_match_endpoints.py` (7 tests) —
  end-to-end through Flask test client: Deezer endpoint passes
  kwargs not joined query; karaoke buried at bottom for all three
  sources; legacy query param still works without rerank.

# Verification

- 2441 full suite passes (+71 from baseline 2370)
- 0 failures (the prior watchdog flake fix held)
- Ruff clean across all changed files
- JS parses clean (`node -c webui/static/helper.js`)

# Architectural standards followed

- **Logic at the right boundary.** Query construction lives in the
  client (every caller benefits from one change). Rerank lives in
  a neutral module (`core/metadata/relevance.py`) over the
  canonical `Track` dataclass — works for any source, not Deezer-
  specific.
- **Explicit > implicit.** Every scoring rule has its own named
  function. Pattern tables are module-level constants tests can
  introspect.
- **Scope discipline.** Audited every Deezer search call site;
  fixed the user-reported one + the consistent siblings. Did NOT
  speculatively normalise every Deezer call across the codebase.
- **Backward compat.** Free-text `query=` callers untouched. Kwargs
  added to existing client method signature with safe defaults.
- **Tests pin contract at correct boundary.** Pure-function rerank
  tests don't mock anything; client-query tests stub at `_api_get`;
  endpoint tests run through the real Flask app.
2026-05-10 08:53:42 -07:00
Broque Thomas
abab663eb7 Auto-import: album duration = album total + conservative re-import UPDATE path
Two pre-existing parity gaps in `record_soulsync_library_entry` that
the prior parity commits left untouched. Both close real holes
between auto-import writes and what the soulsync_client deep scan
would have produced.

# Gap 1: Album duration was the first-imported track's duration

`record_soulsync_library_entry` is called once per track. The album
INSERT only fires for the FIRST track of a new album (subsequent
tracks find the album row already exists). The INSERT was passing
`duration_ms` — `track_info["duration_ms"]` — as the album's
`duration` column. That's the duration of one track, not the album
total. Compare to `SoulSyncAlbum.duration` in soulsync_client which
is `sum(t.duration for t in self._tracks)`.

Fix:
- Worker computes `album_total_duration_ms = sum(...)` across every
  matched track and threads it onto context as
  `album.duration_ms`.
- side_effects reads that value (or falls back to the per-track
  duration for legacy non-auto-import callers) and writes it as the
  album row's `duration`.

# Gap 2: Re-imports of the same artist/album were insert-only

When the SELECT-by-id or SELECT-by-name found an existing soulsync
artist or album row, the function skipped completely — no UPDATE
path. Meant: artist genres / thumb / source-id reflected ONLY
whatever the FIRST imported album supplied, never refreshing as
more albums by that artist landed. Ten more imports later, the
artist row still held whatever the first random import wrote.

Conservative fix: when an existing row matches, run an UPDATE that
fills only the columns whose current value is NULL or empty. Never
overwrites populated values — protects manual edits +
enrichment-worker writes the same way the scanner UPDATE path
preserves enrichment columns.

Implementation note: the empty-check happens in Python, NOT SQL.
Initial pass tried `COALESCE(NULLIF(col, ''), NULLIF(col, 0), ?)`
but SQLite's `NULLIF(text_col, 0)` returns the original text value
instead of NULL — different types, no coercion. So the SQL-only
conditional was unreliable on text columns. New helper does
`SELECT cols FROM table WHERE id`, compares each column in Python,
and emits UPDATE clauses only for the ones that need filling.

Allowlist defense: f-string column names go through
`_SOULSYNC_FILLABLE_COLUMNS` validation before interpolation.
Misuse adding new columns without an allowlist update fails closed
(logger.debug + skip).

# Tests added (4)

- `test_album_duration_uses_album_total_not_single_track` —
  album with single-track context carrying explicit
  `album.duration_ms = 2_500_000` writes 2_500_000 to the album row,
  not the per-track 200_000 fallback.
- `test_re_import_fills_empty_artist_fields` — first import lands
  artist with empty thumb + empty genres; second import for same
  artist with thumb + genres present updates the existing row.
- `test_re_import_does_not_clobber_populated_artist_fields` —
  first import writes rich genres + thumb; second import with
  worse / different metadata leaves the existing row untouched.
- `test_re_import_fills_empty_source_id_when_missing` — first
  import had no source artist ID; second import does — fills the
  empty `spotify_artist_id` column on the existing row.

# Verification

- 10/10 side-effects tests pass (including 4 new + 4 from prior
  parity commit + 2 history/provenance)
- 217 imports tests pass (no regression)
- 2369 full suite passes (+4 from prior, +22 PR-total from baseline 2347)
- 1 pre-existing flake (`test_watchdog_warns_about_stuck_workers`,
  passes in isolation, unrelated)
- Ruff clean
2026-05-09 21:19:35 -07:00
Broque Thomas
f628009ab4 Auto-import: aggregate GENRE tags onto artists row + harden ISRC/MBID types
Cin pre-review followup. Two small parity gaps the prior commits left
open:

# 1. Genre tags land on the standalone artists row

`soulsync_client._scan_transfer` aggregates the GENRE tag across every
track in an album and surfaces it on `SoulSyncAlbum.genres` (which the
DatabaseUpdateWorker writes to the artists+albums row). Auto-import
was hardcoding `'spotify_artist': {'genres': []}` so the imported
artists row landed with empty genres — felt hollow compared to a
Plex/Jellyfin scan, which both pull genres from their respective APIs.

Fix:
- `_read_file_tags` now reads the GENRE tag (mutagen easy mode handles
  MP3/FLAC/M4A consistently; some files carry multiple genres so it's
  always returned as a list).
- `_process_matches` aggregates genres from each matched file's tags
  into a deduped insertion-order list. Dedup is case-insensitive but
  preserves original casing — so "Hip-Hop, Rap, Trap" reads naturally
  in the JSON column instead of "hip-hop, rap, trap".
- Worker context's `spotify_artist['genres']` carries the aggregated
  list, which `record_soulsync_library_entry` already filters via
  `core.genre_filter.filter_genres` and writes to the artists row.

# 2. Defensive str() cast for ISRC + MBID

`_build_album_track_entry` already coerces ISRC + MBID to string today
(via `str(isrc) if isrc else ''`). But if a future metadata-source
client returns int / None for either ID, the worker would propagate
the wrong type and side_effects.py's `.strip()` would AttributeError.

Cheap insurance: explicit `str()` cast in the worker before assignment
to track_info. Future-proofs against client drift.

# Tests added (3, in test_auto_import_context_shape.py):

- `test_context_aggregates_genres_from_track_tags` — multi-file
  album with overlapping genre lists produces deduped, insertion-
  ordered, original-case-preserved result. Stubs `_read_file_tags`
  with monkeypatch so we don't need real audio.
- `test_context_genres_empty_when_no_tags` — files without GENRE
  tag → empty list. Standalone library write handles gracefully
  (genres column stays empty / NULL).
- `test_context_isrc_mbid_coerced_to_string` — hostile types
  (int 12345678, None, int 999) coerced to safe strings before
  reaching track_info.

# Verification

- 14/14 context-shape tests pass (11 prior + 3 new)
- 213 imports tests pass (no regression)
- 2365 full suite passes (+3 from prior, +18 PR-total)
- 1 pre-existing flake (`test_watchdog_warns_about_stuck_workers`,
  passes in isolation)
- Ruff clean
2026-05-09 20:15:49 -07:00
Broque Thomas
ec7da89434 Auto-import: surface artist source-id from metadata search response
Cin pre-review followup to the standalone library parity commit. The
prior commit fixed `spotify_artist['id']` from the wrong copy-paste
value (`identification['album_id']`) to read from
`identification['artist_id']`, but the identification dict produced
by `_search_metadata_source` and `_search_single_track` never set
`artist_id` — both extracted artist NAME from the search response
and discarded the source ID sitting right next to it. Net effect of
the prior commit: artists row source-id stayed NULL, just for a more
honest reason than before.

Now properly extracted:

- `_search_metadata_source` reads `best_result.artists[0]['id']`
  alongside the artist name and returns it on the identification dict
  as `artist_id`.
- `_search_single_track` does the same for single-track identification.
- `_identify_single`'s tag-based-confidence path forwards
  `result.get('artist_id')` so the artist source-id propagates even
  when high-confidence local tags override the search result's name.

Result: identification dict now carries `artist_id` whenever the
metadata source returned an artist with an ID. The worker context
already plumbs it onto `spotify_artist['id']` and
`spotify_album['artists'][0]['id']`, so the standalone library write
finally populates `<source>_artist_id` on the artists row.

Tests added (3, in `test_auto_import_context_shape.py`):

- `test_context_artist_id_uses_identification_artist_id` — when the
  identification dict carries `artist_id`, context propagates it
  onto `spotify_artist['id']` AND
  `spotify_album['artists'][0]['id']`. Pins that the prior copy-
  paste bug (artist['id'] = album_id) doesn't return.
- `test_context_artist_id_is_empty_when_identification_missing_it` —
  fallback case (filename-only identification): context gets empty
  string, NOT album_id. Honest failure mode.
- `test_search_metadata_source_extracts_artist_id_from_dict_artist`
  — black-box test of `_search_metadata_source`: feed it a
  spotify-shaped result with `artists[0]['id']` and verify
  identification dict carries it forward.

Verification:
- 11/11 context-shape tests pass (8 prior + 3 new)
- 210 imports tests pass (no regression)
- 2362 full suite passes (+3 from prior commit, +15 PR-total)
- 1 pre-existing flake (`test_watchdog_warns_about_stuck_workers`,
  passes in isolation)
- Ruff clean
2026-05-09 19:52:05 -07:00