Commit graph

3893 commits

Author SHA1 Message Date
BoulderBadgeDad
222653036b
Merge pull request #834 from Nezreka/dev
Dev
2026-06-09 23:32:10 -07:00
BoulderBadgeDad
e16216fc2d Tests: register watchlist-history.js in script-split integrity (#831 follow-up)
The #831 watchlist work added the standalone webui/static/watchlist-history.js
(loaded in index.html) but didn't add it to NON_SPLIT_JS, so the onclick-
coverage test couldn't see its functions and failed on openWatchlistHistoryModal
referenced in the History button. Register it alongside its sibling
origin-history.js — same fix the registry comment was added for.
2026-06-09 23:17:19 -07:00
BoulderBadgeDad
931167f197 Release 2.6.9: version bump + docker-publish default + What's New changelog
Bumps _SOULSYNC_BASE_VERSION 2.6.8 → 2.6.9, the docker-publish workflow's
default version tag, and adds the 2.6.9 What's New entry (15 items, security
fixes first: #832 launch-PIN enforcement and the settings-secret leak, then
#833/#831/#830/#829/#828/#827/#825/#824/#823/#740, Spotify (no auth), multi-
artist tags, decimal-volume dedup).
2026-06-09 23:03:49 -07:00
BoulderBadgeDad
8983299130 Security: stop GET /api/settings from shipping decrypted secrets to the browser
Found during the #832 audit: GET /api/settings returned dict(config_data) — and
config_data is DECRYPTED in memory — so every API key, OAuth secret, Plex/
Jellyfin token, and service password went to the browser in cleartext. Fernet
"encrypted at rest" protects a leaked DB file; it does nothing once the API
hands the plaintext to the client (devtools, HAR captures, an XSS, a screen
share, or a non-PIN'd LAN viewer).

Fix (centralized in ConfigManager):
- redacted_config() deep-copies config and replaces every _SENSITIVE_PATHS value
  that's actually set with REDACTED_SENTINEL; unset secrets stay empty so the UI
  still shows "not configured". Dict-valued secrets (tidal/qobuz OAuth sessions)
  collapse to the sentinel too. GET /api/settings now serves this copy.
- set() ignores a write of REDACTED_SENTINEL to a sensitive path, so the masked
  placeholder round-tripped by an unchanged settings form can never overwrite
  the real secret. A real value still saves; an empty value still clears.

Frontend: secret inputs are type=password, so the sentinel renders as dots
(looks like a saved secret). _wireRedactedSecrets() clears the mask on focus so
editing types fresh rather than onto the sentinel, and re-masks on blur if left
untouched — so an unchanged secret round-trips the sentinel (kept), an edited
one saves the new value, and a deliberately emptied one clears.

Tests: every sensitive path masks; unset stays empty; dict secrets mask; live
config not mutated; sentinel round-trip keeps the real secret; real value
overwrites; empty clears; sentinel on a non-secret path writes normally.
9 new tests; 518 config-touching tests pass (1 pre-existing soundcloud mock
failure, unrelated — fails identically on a clean tree).
2026-06-09 22:50:18 -07:00
BoulderBadgeDad
0c1dd6c2a9 Delete: resolve the real on-disk file when DB metadata uses curly quotes (#833)
the-hang-man: tracks with an apostrophe (e.g. "I'm Upset") deleted the DB row
but left the file. The library DB stored the title with U+2019 (the curly form
Spotify/Apple metadata uses) while the file was written to disk with U+0027
(ASCII). _resolve_library_file_path compared the curly path byte-for-byte via
os.path.exists, missed every time, and reported "could not be deleted".

Fix: resolve confusable-tolerantly. New core/library/path_resolve.find_on_disk
descends the path component by component, taking an exact match when present and
otherwise folding a small set of typographic look-alikes (curly vs straight
quotes, en/em dash, ellipsis, nbsp) for the comparison ONLY — it never renames,
just finds the file that's actually there. Exact matches always win per
component, so paths that already resolved are byte-for-byte unaffected. This
also fixes existing mismatched files (no re-import) and every caller of
_resolve_library_file_path (sidecar cleanup, dead-file checks, streaming), not
just delete.

Case is deliberately NOT folded: a case-sensitive dataset (ext4/ZFS) can hold
names differing only by case, and folding could resolve the wrong file. The
reported failure is purely typographic.

Tests: real temp-file fixtures exercising the actual byte mismatch — curly-DB →
ascii-disk resolves, exact still works, confusable in a folder component, exact
wins when both encodings present, genuinely-different name does NOT collide,
missing file → None. 10 new tests; 949 resolver-adjacent tests pass.
2026-06-09 22:28:53 -07:00
BoulderBadgeDad
66724186b1 Security: enforce the launch PIN server-side, not just a client overlay (#832)
Beckid: the admin launch PIN was a CLIENT-SIDE overlay only. `launch_pin_required`
just told the frontend to draw a fixed div over the app — removing it (Safari
"Hide Distracting Items", devtools, or any non-browser client like curl) gave
full unauthenticated access to every /api/* endpoint, because the server never
checked it. Anyone who reverse-proxies SoulSync publicly was wide open.

Fix: a before_request gate (_enforce_launch_pin) that rejects every request from
an unverified session while security.require_pin_on_launch is on. The decision
is a pure, unit-tested helper (core/security/launch_lock.request_is_locked) so
the allow/deny matrix can't silently regress. Allowed while locked: the page
shell + static assets, the unlock flow (current/list/select/verify/reset/logout),
and the public REST API /api/v1/ (its own @require_api_key governs it) — EXCEPT
/api/v1/api-keys-internal*, the "no auth required" key-management endpoints,
which stay locked so an attacker can't mint an API key and walk in the side door.
Everything else (data, settings, profile create/edit/delete/set-pin, socket.io)
is blocked.

A blocked top-level browser navigation (deep link / refresh on a sub-page like
/dashboard) is redirected to the root lock screen instead of dumping raw JSON —
detected via Sec-Fetch-Mode: navigate / Accept: text/html (is_html_navigation).
Programmatic fetch/XHR still get the JSON 401 so the frontend can react.

Also fixed the verified flag: get_current_profile POPPED launch_pin_verified
(one page load), but an enforced gate needs it to persist — now READ, so
verification lasts the session (until logout/expiry). No-ops entirely when
require_pin_on_launch is off (default).

Tests: full allow/deny matrix + navigation detection. 20 gate tests + 232
profile/security tests pass.
2026-06-09 22:19:14 -07:00
BoulderBadgeDad
111af5150e Watchlist page: hued action chips, meta chips, Global Settings reskin (#831 round 3)
Boulder: the cards are good but everything around them was basic — six
identical grey pill buttons, a plain header, and a dated Global Settings modal.

Action chips (artist-detail button language — tinted gradient + hover lift +
icon scale): Scan is the primary CTA with the accent gradient and a shimmer
sweep; the rest get per-hue identity (similar-artists blue, settings slate,
origins green, history amber, blocklist/cancel red). One .wl-chip base class
with a --chip-rgb variable per hue. Header count/timer become pill meta chips
(timer accent-tinted).

Chip-safe labels: the scan/update handlers set button.textContent, which would
wipe the new svg + shimmer children on first use — added _wlSetChipLabel()
(preserves icon/shimmer, swaps the text node) and converted all 11 writes.

Global Settings modal: emoji + inline-styled header replaced with the
origins/blocklist house-style head (title/sub/✕); option cards now show live
checked-state feedback (:has(:checked) accent ring + grayscale-dimmed icons
when off — also upgrades the per-artist config modal, same components); the
master-override toggle gets a CSS .enabled treatment instead of the hard-coded
green inline border the JS used to write.

All element ids/onclicks unchanged; JS syntax-checked; 131 watchlist tests pass.
2026-06-09 21:22:02 -07:00
BoulderBadgeDad
34e0503fad Watchlist scan deck v2: portrait-anchored hero, zero layout shift (#831 polish)
Boulder's screenshots: the v1 deck shifted around depending on what data had
arrived (the album row vanished entirely without art, leaving floating
"Processing…/Processing…" text), the images were small, and the feed header
floated in empty space. Redesigned in the artist-detail-page language:

- Big 148px square portrait (rounded, shadowed) anchors the left side, with
  the current album stamped as a 62px overlay badge in its corner — when art
  is missing, both keep their slot and show a glyph placeholder instead of
  collapsing, so the deck NEVER changes shape mid-scan.
- 24px artist name + uppercase accent phase line + a fixed-height
  "now checking" block (accent left rule) for album + track, with stable
  placeholders ("Looking for new releases…" / "—") instead of doubled
  "Processing…" text.
- The additions feed is an inset fixed-height panel (artist-page sidebar
  style): same size whether 0 or 10 tracks, empty state centered.
- JS: hide the artist photo when the CURRENT artist has none (previously the
  prior artist's photo lingered), cleaner placeholder copy.
2026-06-09 20:57:27 -07:00
BoulderBadgeDad
9f12bdfef6 Watchlist: bespoke live scan deck + persistent per-run Scan History (#831 round 2)
Boulder: the live display was a cramped ~600px box showing a fraction of the
data the scan already tracks, with no animation and no history.

Live scan deck (replaces the three-column box, full width):
- Header: pulsing live dot, "x / y artists" progress text, and two live
  counter chips (found / added) that pop when they change.
- Animated progress bar (artist index / total) with a shimmer sweep.
- Stage: artist avatar with accent glow + name + readable phase line
  ("Checking album 2 of 5"), album art + album + current track.
- "Added to wishlist this run" feed: taller, bigger art, slide-in animation
  that plays once per new track (feed re-renders only when it changes).
- All data was already in scan_state (current_artist_index, total_artists,
  tracks_found/added_this_scan, current_phase) — just never displayed. The
  legacy fullscreen-modal markup shares element ids and lacks the new ones,
  so it keeps working untouched.

Scan History (persistent):
- New watchlist_scan_runs table — one row per run (status, timestamps,
  artists/found/added counts) + the full track ledger JSON. Saved at scan
  completion AND cancellation; idempotent on run_id; pruned to the last 100
  runs. Wishlist rows erode as tracks download, so this is the durable record.
- GET /api/watchlist/scan/history (runs) + /history/<run_id>/tracks (ledger).
- New History button on the Watchlist page → modal in the origins/blocklist
  house style: run cards (date, cancelled chip, artists/found/added stats)
  expanding into the Added / Skipped track lists with art and badges.

Tests: save+fetch with ledger, idempotent re-save, prune keeps newest,
unknown-run empty, cancelled runs recorded. 398 watchlist/wishlist/history
tests pass; JS syntax-checked; all rendered strings escaped.
2026-06-09 20:35:16 -07:00
BoulderBadgeDad
e8cde40d22 Watchlist: show WHICH tracks a scan found/added + group Download Origins (#831)
Tacobell444 (#707 follow-up): the scan summary said "New tracks: 19 • Added to
wishlist: 10" with no way to see which tracks those were — you had to scan your
wishlist and guess what was new.

Scan ledger: the scanner now records a per-run scan_track_events list (track,
artist, album, thumb, status added|skipped — skipped = found-new but declined
by add_to_wishlist: already queued or blocklisted; capped at 500). The status
endpoint already serializes scan_state, so the payload flows free. The
completed (and cancelled) scan summary on the Watchlist page gets a
"Show tracks" toggle expanding a styled list — Added section + Skipped section
with badges, reusing the live-feed row styling.

Download Origins grouping: the modal now groups entries by what triggered them
(watchlist artist / playlist name) with collapsible headers + counts instead of
a flat list with a per-row badge. Entries arrive newest-first so groups order
themselves by their newest download. Same row markup, checkboxes/delete intact.

Provenance: watchlist adds now stamp scan_run_id into wishlist source_info, so
per-run grouping is queryable later (future "what did run X add" views).

Tests: per-run ledger seam test (added + skipped statuses, album/artist fields,
FIFO unchanged). 316 watchlist/wishlist tests pass; JS syntax-checked.
2026-06-09 20:14:02 -07:00
BoulderBadgeDad
bcd69c8baa Multi-artist tags: Search → Download Now finally knows its metadata source (Netti93)
Third round of the multi-artist report. The earlier fixes (Deezer contributors
upgrade, _artists_list, feat_in_title/artist_separator) were all in place and
correct — but gated on source == 'deezer', and on the real Search → Download
Now path NOTHING carried the source: core/search/sources.py serialized tracks
with no source field, search.js's enrichedTrack didn't add one, so
get_import_source() resolved '' and the whole Deezer-specific block silently
skipped. Files were tagged with only the primary artist until a Retag (which
rebuilds context with the source set — exactly why retagging always fixed it).
The earlier tests passed because they set context['source'] directly — the one
field the real flow never had (same mock-drift as the #823 append tests).

Reproduced with Netti93's exact track (deezer 3966840171) through the real
extract_source_metadata: before — source '', artists ['August Burns Red'];
after — source 'deezer', contributors fetched, artists ['August Burns Red',
'Polaris'], title 'Sonic Salvation (feat. Polaris)' per feat_in_title.

Fix, three layers:
- core/search/sources.py: serialized tracks/albums/artists carry "source"
  (the canonical name the orchestrator already passes; '' when unnamed).
- core/imports/context.py get_import_source: also reads '_source' from the
  nested dicts (track_info/original_search/album/artist) — additionally fixes
  the discography/wishlist flows, which always passed '_source' that nothing
  read.
- search.js: enrichedTrack + the album-download path carry source through to
  the download task.

Tests: real-payload staging-shaped contexts (source in track_info, '_source'
shape, and the pre-fix sourceless shape staying safe — mocked Deezer client),
serializer source-field tests, resolver fallback tests; exact-shape serializer
tests updated for the new key. 1977 import/metadata/search tests pass (the
only 2 failures are the known soundcloud ones).
2026-06-09 17:20:16 -07:00
BoulderBadgeDad
e32e2e5e14 Sync: append mode actually dedupes — stop re-adding the whole playlist (#823)
carlosjfcasero round 2: after 6fa956d6 append stopped recreating the playlist,
but every sync re-appended ALL matched tracks — every track N times. His log
shows it plainly: "added 22 new tracks to 'Disney' (skipped 0 already present)"
on a playlist that already had those 22.

Root cause: the dedupe read `{t.id for t in get_playlist_tracks(...)}` — but
JellyfinTrack only defines `ratingKey`, never `id`, so the existing-ids set was
ALWAYS empty and everything looked new. NavidromeTrack is also ratingKey-only,
so the Navidrome append had the identical bug. Plex (plexapi ratingKey) was
fine. The existing tests were green because they mocked existing tracks as
SimpleNamespace(id=...) — encoding the same wrong assumption as the code.

Fix:
- New pure planner plan_playlist_append(current, desired) in
  core/sync/playlist_edit.py (next to the reconcile planner): order-preserving,
  drops already-present ids, dedupes within desired, stringifies (Emby numeric
  vs string safe).
- Jellyfin/Emby: existing ids fetched from the canonical
  /Playlists/{id}/Items endpoint (same as reconcile — works for Jellyfin GUIDs
  and Emby numeric ids), ratingKey fallback if that request fails.
- Navidrome: dedupe on ratingKey (the attribute that actually exists).

Tests: planner (skip-present incl. the reporter's unchanged-playlist case,
desired-order, dupes-within-desired, int/str ids, empties) + the append-mode
suite rewritten to pin the REAL shapes (raw Items dicts for Jellyfin,
ratingKey objects for Navidrome) + a new fallback-path test. 524
playlist/sync/jellyfin/navidrome tests pass.
2026-06-09 16:31:05 -07:00
BoulderBadgeDad
0939585620 Matcher: bracketed subtitles no longer read as different songs (#825)
carlosjfcasero round 2 (manual-add fix didn't help — different path). His log
pinned it: the mirrored sync auto-added 'Llamando a la tierra (Serenade From
the Stars)' by M-Clan every run even though his library has the song (stored
bare). Reproduced exactly: the subtitle restates no album context, so the #808
context strip keeps it, and the length-ratio penalty in
_calculate_track_confidence crushes the pair to 0.142 (needs 0.7). Sync →
"missing" → wishlist, forever; and the cleanup uses the SAME matcher, so it
deterministically never removed it. Self-reinforcing.

Fix at the matcher seam (benefits sync, cleanup, downloads, discography alike):
core/text/title_match.strip_subtitle_qualifiers(title, other) strips a
bracketed qualifier only when it (a) isn't restated in the other title, (b)
contains no version-marker token (EN + ES: live/remix/acoustic/version/
dueto/directo/vivo/...), and (c) introduces no new digit token ('(Pt. 2)',
'(2007)' stay different releases). Wired as a third comparison variant in
_calculate_track_confidence with its own length guard. Verified against his
log's other unmatched tracks: '(Live)' 0.15, '(Dueto 2007)' 0.179,
'(Versión 1988)' 0.167 all still correctly blocked — version qualifiers keep
their meaning; the M-Clan case goes 0.142 → 1.0 in both directions.

Also: sync's check_track_exists call now passes album= (cleanup already did),
enabling the album-aware fallback for multi-artist albums during sync.

Tests: tests/test_subtitle_qualifier_match.py — the reported case verbatim
(end-to-end through check_track_exists, both directions, batched candidate
path included), EN+ES version qualifiers still blocked, numeric guard,
'#769 Dani California' and '#808 OurVinyl' guards still hold. 1396
matcher/wishlist/sync tests pass.
2026-06-09 16:03:11 -07:00
BoulderBadgeDad
ca040d2c10 Discography UI: show WHY tracks were skipped, not a flat "No new tracks" (#830)
The per-album status only looked at added + the generic wishlist-skip count, so
anything else (other-artist credit, already owned, content-filtered) showed the
misleading "No new tracks" — which is what made Vicky-2418's artist-mismatch
skips look like "you already have it." The backend already streams the full
breakdown (tracks_skipped_artist/owned/filter); the UI just ignored it.

New shared _discogItemStatus(data) builds an accurate line from all the counts:
"4 by other artists", "13 already owned", "1 added, 2 already owned", etc.
Replaces the duplicated 4-line block at both render sites. Frontend only; JS
syntax + per-case output verified.
2026-06-09 14:53:30 -07:00
BoulderBadgeDad
db65e783c7 Discography: keep collab tracks credited as one combined string (#830)
Vicky-2418: Download Discography skipped some albums/singles as "No New Track"
even on a brand-new artist, while manual one-by-one worked. The log showed the
real reason wasn't ownership at all — it was "N skipped (artist mismatch)".

Root cause (confirmed against the iTunes API, not guessed): iTunes returns a
collab as ONE combined string. Narvent's "Miss You (Ambient Remix)" is credited
'TRVNSPORTER, Narvent & SKVLENT'. track_artist_matches did an exact full-string
compare ('trvnsporter, narvent & skvlent' == 'narvent' → False), so every
collaborator's discography entry was dropped — despite the #559 comment claiming
it "keeps features" (it didn't, because features arrive combined, not as a list).

Fix: match the requested artist as a COMPONENT of the credit — split on the
common separators (, & ; / feat ft featuring vs x), while still including the
full string so band names with internal separators ("Florence + the Machine")
match exactly. Component matching stays exact per name, so true contamination
(the #559 case — artist not credited at all) is still dropped, and substrings
("Drakeo the Ruler" ≠ "Drake") still don't match.

Also corrects an over-conservative #559 test that asserted "Drake & Future"
shouldn't match "Drake" — it should; Drake is a credited collaborator. The guard
drops uncredited artists, not legit collabs packed into one string.

Tests: the exact real case (Narvent in the combined credit) + each collaborator,
contamination still dropped, feat/ft/featuring/x forms, no substring false
positive. 36 filter tests + 747 discography/metadata tests pass.
2026-06-09 14:35:54 -07:00
BoulderBadgeDad
1d16ac7978 Downloads: reuse an album's existing folder so batches don't split it (#829)
Tacobell444: when tracks land in an album across multiple batches (a wishlist
run, the Album Completeness job, a missed track re-downloaded later), the folder
is rebuilt from API metadata each time — so when $albumtype or $year come back
blank/different on a later batch, the folder NAME changes and the album splits,
forcing a Reorganize.

Fix: build_final_path_for_track now checks whether the album already lives in a
single folder on disk and, if so, drops the new track there instead of a freshly
templated folder. Match (chosen): exact stored Spotify album id first, then a
STRICT >=0.85 name+artist match (vs the 0.7 used elsewhere) — a wrong match here
misplaces a file. New core/library/existing_album_folder.resolve_existing_album_folder
holds the logic; always-on with template fallback.

Safety rails: only returns a folder UNDER the transfer dir (never a read-only
library/NAS mount), only when the album lives in EXACTLY ONE folder (multiple =
disc subfolders, which DatabaseTrack can't disambiguate — those defer to the
template), and any failure falls through to the template path. Added
MusicDatabase.get_album_by_spotify_album_id for the id-first lookup.

Tests: single-folder reuse, no-match, below-threshold, multi-folder defer,
outside-transfer reject, id-first, missing transfer dir, no-files-on-disk.
8 tests; 1556 path/import/download tests pass (only the known soundcloud
failures remain).
2026-06-09 13:47:25 -07:00
BoulderBadgeDad
26368a80ab Dead File Cleaner: don't flag a whole library when paths just aren't reachable (#828)
macstainless: a Plex-on-macOS user running SoulSync in Docker had all 5,250
tracks flagged "dead" even though the files exist and play in Plex. Root cause:
the DB stores paths as Plex reported them (/Volumes/Core/Music/...), which don't
exist inside SoulSync's container. resolve_library_file_path() returns None for
"couldn't find it at any known base dir" — and for a mis-mounted library that's
EVERY track, not a deletion. The job treated None as "file deleted" and created
a finding per track.

Fix mirrors the existing transfer-folder abort: collect unresolvable tracks, and
if at least max_unresolved_fraction (default 0.5) of the library is unresolvable
once it's past min_tracks_for_guard (default 25), treat it as a path-mapping/
mount problem — abort with an actionable message (Docker mount / Settings →
Library → Music Paths) and create ZERO findings. A small fraction unresolvable
is still reported as genuine dead files, and tiny libraries (< min) report as
before. Both thresholds are configurable per the job's settings.

Tests: mass-unresolvable aborts with no findings; a lone dead file among real
ones is still reported; a small all-dead library still reports; thresholds
configurable. 54 repair tests pass.
2026-06-09 13:12:50 -07:00
BoulderBadgeDad
90174de4b2 Spotify: rename "Spotify Free" → "Spotify (no auth)", default enrichment to it
Per Boulder's calls on the new enrichment toggle:

- Naming: "Spotify Free" was misleading (it's a hybrid — pick it, connect an
  account, and sync still uses your official playlists). Relabel the user-facing
  strings to "Spotify (no auth)" — the real distinction is needs-credentials vs
  not. Internal value/key (spotify_free, _free_*) unchanged, so no migration.

- Default ON: metadata.spotify_free_enrichment now defaults True (worker + UI
  load both treat unset as on). So bulk enrichment runs on the no-auth path by
  default and the official account is reserved for interactive search/sync; turn
  the toggle off to enrich through the connected account. The toggle overrides
  auth for the worker (authed users still enrich via no-auth) — matching the
  intended model.

- Worker runs on the toggle alone: is_spotify_metadata_available() now honors
  _prefer_free (+ package installed), so the worker enriches via no-auth even
  with no account connected and no 'no-auth' source selected. Only fires on a
  client carrying the flag (the worker's own), so interactive/watchlist
  availability is unchanged.

- UI: moved the toggle from "Metadata Source" to the Spotify section next to the
  auth fields, always visible, on by default. Help notes the genre trade-off.

Tests: prefer_free makes metadata available without auth/source (and is inert
without the package); interactive availability unaffected. 218 Spotify tests pass.
2026-06-09 12:55:19 -07:00
BoulderBadgeDad
fd3ce8ba6e Settings: add "Use Spotify Free for background enrichment" toggle
User-facing opt-in for metadata.spotify_free_enrichment (the engine landed in
38461295). A checkbox in the Metadata Source frame, independent of the primary-
source dropdown, so a user with an official Spotify account connected can choose
to run the bulk enrichment worker on the no-creds Spotify Free source — sparing
their official API quota / dodging rate-limit bans for interactive search + sync.
Help text notes the trade-off (no artist genres from Free). Default off.

Wiring mirrors the existing spotify_free setting: saved in the metadata payload,
loaded into the checkbox, persisted via the generic metadata.* config loop (no
backend change). Auto-save already covers checkboxes in #settings-page.
2026-06-09 12:27:44 -07:00
BoulderBadgeDad
38461295c2 Spotify: enrichment can prefer Free, and the budget→Free bridge actually diverts
Two related fixes at one root cause. Every catalog method gated the official API
on `use_spotify = is_spotify_authenticated()` and only fell to Free *after*
official failed. So when the client was authed but should defer to Free —
specifically when the worker's daily real-API budget was spent — it kept hitting
the official API anyway (just stopped *counting* it). The budget "bridge" never
actually diverted; it only stopped pausing.

Root-cause fix: the official gate is now `is_spotify_authenticated() and not
self._free_active()` across all 8 catalog methods (search_artists/albums/tracks,
get_artist/album/album_tracks/track_details/artist_albums). No-auth and
rate-limited are unchanged (auth is already False there); the change only affects
the cases where auth is True but we deliberately defer to Free. The user-account
methods and the metadata-availability helper are untouched.

New opt-in: metadata.spotify_free_enrichment. When set, the worker puts
`_prefer_free` on its OWN client and _free_active() honors it (needs only the
package installed — the flag is the opt-in — not the 'Spotify Free' source
choice). So a connected user can run bulk enrichment on the no-creds source to
spare their official quota, while interactive search/resolve stay official-first
(they use a different client that never sets the flag). Default off.

Tests: _free_active honors prefer_free (and is inert without the package);
search_albums defers to Free — official .sp raises if touched — both under
prefer_free AND under budget-exhaustion (the divert that previously never
happened). 215 Spotify tests pass.
2026-06-09 12:24:57 -07:00
BoulderBadgeDad
4a6d6fc17b Spotify Free: close the album-search gap via the artist's discography
SpotipyFree has no album-name search (search_albums returned []), and
SpotifyClient.search_albums had no Free branch at all — so when Spotify Free was
the active source (no-auth / rate-limit ban / spent budget), album matching
couldn't use Spotify and dropped straight to the iTunes/Deezer fallback. The
enrichment worker's per-album matching was effectively blind on Free.

Fix — work the gap using ONLY the free source:
- spotify_free_metadata: new search_albums_via_artist(artist, album) resolves the
  best-matching artist, scans their discography (get_artist_albums_list — which
  Free DOES support), and ranks by album-name similarity. Pure rank_albums_by_name
  helper is unit-testable; the network method composes it.
- SpotifyClient.search_albums: add a Free branch mirroring search_artists/tracks
  (official → Free-if-active → iTunes/Deezer), plus optional artist/album params
  so the Free path has the names it needs. Bare-query callers skip it unchanged.
- spotify_worker: _process_album_individual passes artist+album so the bridge can
  match albums on Free.

Tests: rank ordering + limit, via-artist picks the right artist and ranks,
empty on missing artist/album/no-match, and SpotifyClient.search_albums returns
Free albums (not the iTunes fallback) when free is active. 210 Spotify tests pass.
2026-06-09 12:05:40 -07:00
BoulderBadgeDad
2bc86e3022 Settings: don't auto-save while on the Logs tab (#827)
Tacobell444: the Logs tab has no savable settings, but its live-viewer controls
(source picker, filters, auto-scroll) were tripping the settings auto-save —
each one POSTs /api/settings and logs "Settings saved successfully via Web UI",
flooding app.log and drowning out the logs the user is trying to read.

Fix: debouncedAutoSaveSettings bails when the active settings tab is 'logs'
(checked via .stg-tab.active). Purely frontend — no save is scheduled while on
that tab, so the backend never logs the save. Doesn't touch the existing
_suppressSettingsAutoSave form-population guard, and every other tab auto-saves
exactly as before; manual Save still works everywhere.
2026-06-09 11:26:26 -07:00
BoulderBadgeDad
8633386f00 Wishlist: manual "add to wishlist" now skips already-owned tracks (#825)
The manual album "Add to Wishlist" modal had NO ownership check at any layer —
the album view opened the modal without ownership info, the modal added every
track, and the backend (add_album_track_to_wishlist) added each unconditionally.
So adding an album you (partially) own dumped the owned tracks straight into the
wishlist (carlosjfcasero #825) — and the auto-cleanup doesn't reliably remove
them. The bulk discography path already dedups (full missing-track analysis);
this path didn't.

Backend (the reliable seam): add_album_track_to_wishlist now skips a track that
already exists in the library, gated on the same wishlist.allow_duplicate_tracks
toggle the watchlist scan + cleanup use — OFF → skip owned (returns
{success, skipped:true}), ON → add anyway. Default is ON, so default users are
unaffected; the quality re-download flow uses a different endpoint, so it's
untouched.

Frontend: handleAddToWishlist + addModalTracksToWishlist count skipped tracks
separately so the toast is honest ("Added 3 (5 already owned)" / "All N already
in your library") instead of falsely claiming owned tracks were added.

Tests: skips owned when duplicates off, adds missing when off, adds owned when
on (and doesn't even run the check then). 205 wishlist tests pass.
2026-06-09 10:55:45 -07:00
BoulderBadgeDad
8d133ecd60 Wishlist: serialize album-bundle downloads so they stop flooding the search pool (Sokhi #740)
Sokhi: "downloads searching for way too many tracks at once" — a wishlist run
that fanned out into ~one batch per album. Verified the actual search/download
concurrency IS capped at 3 (single shared missing_download_executor), so it
wasn't really hammering slskd — but the display showed ~20 "searching" and the
batch list was a mess.

Root cause: run_full_missing_tracks_process was supposed to "block its album-pool
worker for the whole search+download" (that's what the dedicated album_bundle_
executor is for), but it RETURNED the instant it had STARTED the downloads. So
the album pool only throttled the fast analysis phase — every album batch blew
through analysis and immediately dumped its tracks into the shared download pool,
all pre-marked 'searching'. The intended serialization never happened.

Fix: add serialize= to run_full_missing_tracks_process. Album-bundle batches
(dispatched on album_bundle_executor) pass serialize=True and now hold their pool
slot via _wait_for_batch_drain() until every task in the batch reaches a terminal
state — so only ~N albums are in flight at once. The wait is passive (downloads
are driven by the monitor + completion callbacks on other threads, so no
deadlock) and bails on shutdown, a removed batch, or a safety cap. The residual /
playlist / manual paths run on the SHARED pool and pass serialize=False (blocking
there would steal a real download worker), so they're unchanged.

Tests: _wait_for_batch_drain returns immediately when all-terminal, waits until
tasks finish, bails on shutdown, respects the cap, handles a missing batch. 975
download/wishlist tests pass (only the pre-existing soundcloud /app failures).
2026-06-09 10:06:53 -07:00
BoulderBadgeDad
6fa956d63a Sync: automated syncs honor the configured playlist sync mode instead of hardcoding 'replace' (#823)
carlosjfcasero: "append" sync mode still recreated the playlist (wiping image +
description) on both the sync-page auto-sync and the Playlist Pipeline. Root
cause: _run_sync_task defaulted sync_mode='replace', and every AUTOMATED caller
omits the mode — auto_sync_playlist (mirrored auto-sync + pipeline), the
iTunes-link sync, and Wing It. So those paths always replaced, ignoring the
user's chosen mode entirely. (Manual sync + the per-source discovery path already
passed a mode, which is why it only bit automated runs.)

Fix: when no mode is passed, _run_sync_task resolves the user's configured global
"Playlist sync mode" (normalize_sync_mode(None, playlist_sync.mode)) — the same
thing _submit_sync_task already does — instead of hardcoding 'replace'. The
global default is still 'replace', so users who never changed it are unaffected;
only those who set Append/Reconcile get the corrected behavior.

Tests: normalize_sync_mode(None,'append')→'append' (and 'replace' unchanged);
auto_sync_playlist must not force a mode (no sync_mode kwarg / no 7th positional)
so the resolution can happen. 896 sync/automation/discovery/playlist tests pass.
2026-06-09 08:49:46 -07:00
BoulderBadgeDad
a79816ad69 Full release dates: store + write yyyy-mm-dd end to end (#824 part 2)
Part 1 stopped existing full dates being destroyed; this adds first-class support
for full release dates so they can be set + persisted instead of truncated to a
year at the DB layer.

- Schema: new nullable `release_date TEXT` on the albums table (idempotent
  ALTER-ADD-COLUMN repair on startup + the live CREATE). NULL = year-only, every
  reader falls back to albums.year, so it ships safe/dormant.
- Tag writer: write_tags_to_file + build_tag_diff prefer db_data['release_date']
  (the full date) over the year int; _date_to_write writes the full date. When
  there's no release_date it's exactly Part-1 behavior (year, preserving an
  equally-specific existing file date).
- Retag read path: SELECT al.release_date in the tag-preview/write queries and
  thread it into _build_library_tag_db_data.
- Manual edit: release_date added to ALBUM_EDITABLE_FIELDS + a "Release Date"
  field (YYYY-MM-DD, validated client-side) in the album editor; the artist-album
  query returns it so existing values show. User-set dates are authoritative.
- Enrichment: Spotify + iTunes workers store the source's full release_date
  (YYYY-MM / YYYY-MM-DD) when present, only when empty — never clobbering a
  manual value.

Tests: writer uses release_date over year + overrides an existing file date;
falls back to year when absent; diff compares the full date. Migration verified
idempotent + enrichment no-clobber. 1435 tag/retag/db/library tests pass.
2026-06-08 23:32:42 -07:00
BoulderBadgeDad
319e90dead Tag writer: stop downgrading full release dates to just the year (#824)
Files tagged with a full date (e.g. 2023-11-03) were overwritten with only the
year on every enrichment/retag: the tag reader pulls the full TDRC/date string,
but the library DB stores `year` as an INTEGER, and the writer wrote str(year)
back unconditionally — truncating 2023-11-03 → 2023 (Tacobell444 #824).

Fix: new _date_to_write(existing, year) keeps the file's existing, more-specific
date when its year already matches what we'd write (the full date is correct and
consistent); it only writes the bare year when the file has no date or the year
genuinely differs (a real correction). Wired into the ID3 (TDRC), Vorbis (date)
and MP4 (©day) writers. build_tag_diff no longer flags the year as "changed"
when the years match, so the preview stops showing a phantom 2023-11-03 → 2023.

Tests: diff doesn't flag same-year full dates (but does flag a different year);
end-to-end write preserves 2023-11-03 against DB year 2023, and still corrects a
wrong year. 339 tag/retag tests pass.

NOTE (not fixed here): manually entering a NEW full date still can't persist —
the library albums/tracks tables store `year` as INT, so a yyyy-mm-dd entry is
truncated at the DB layer. Supporting manual full dates + enrichment fetching
full dates needs a release_date column (follow-up).
2026-06-08 23:15:31 -07:00
BoulderBadgeDad
e6bf7c26de Watchlist: stop treating different decimal-volume albums as duplicates (Sokhi — the real bug)
Sokhi's log showed the actual cause: with the wishlist "allow duplicates" toggle
on, is_track_missing_from_library skips a track when _albums_likely_match() says
the wanted album and a library album are the same — and it was matching albums
that differ ONLY by a decimal volume number:
  [AllowDup] Album match — skipping (wanted: '...Vol.5', library: '...Vol.5.5')
  [AllowDup] Album match — skipping (wanted: '...Vol.5.5', library: '...Vol.4.5')
His character-song CDs share track titles across volumes, so tracks from albums
he DOESN'T have got skipped because a same-titled track sits in a similarly-named
volume he DOES have — the partially-filled discography never completed.

Root cause: _normalize_album_for_match strips the dot ("Vol.5.5" -> "vol 5 5"),
and _VOLUME_MARKER_RE captured only a single trailing digit, so Vol.5, Vol.5.5
and Vol.4.5 all reduced to marker "5" and the volume-disagreement guard never
fired. Fix: capture the full multi-part number ((\d+(?:\s+\d+)*)) so "5" / "5 5"
/ "4 5" are distinct and the guard correctly rejects the match.

(Not lookback — the log confirms lookback=all was already honored.)

Tests: decimal/multi-part volumes (incl. the real CJK names) now block the
match; identical decimal volumes + naming-drift cases still match. 111 watchlist
tests pass.
2026-06-08 20:46:40 -07:00
BoulderBadgeDad
13dca2dea6 Cover Art Filler: write cover.jpg to the RESOLVED folder, not the raw DB path (Sokhi — the actual bug)
THE bug behind "embeds art but never writes cover.jpgs": _fix_missing_cover_art
passed `details['album_folder']` (= dirname of the raw DB path, e.g. Jellyfin's
/data/music/...) as the target folder. That path doesn't exist inside the
SoulSync container — only the resolved /app/... path does. So apply_art_to_album_
files' `os.path.isdir(target_dir)` was False and the ENTIRE cover.jpg block was
silently skipped: embedding still worked (it uses the resolved paths) and the DB
thumb updated, but the sidecar was never written. Exactly Sokhi's symptom + the
"Cover art already present — database thumbnail updated" toast (cover_written
stayed False).

Fix:
- _fix_missing_cover_art: derive the folder from the RESOLVED file
  (os.path.dirname(resolved[0])), never the raw album_folder.
- apply_art_to_album_files: bulletproof it — if the passed folder doesn't exist,
  fall back to the real directory of the files instead of silently skipping.

Tests: a non-existent folder still writes cover.jpg to the real file dir. 1348
cover/art/repair tests pass.
2026-06-08 18:25:50 -07:00
BoulderBadgeDad
2f3ade8acb Cover Art Filler: scan falls back to the raw file path when mapping fails (Sokhi #fix)
Root cause of Sokhi's endless 0-findings: the SCAN resolved each track's path
through the path-mapping layer with NO fallback, while the APPLY
(_fix_missing_cover_art) uses `_resolve_file_path(...) or p` — i.e. it falls
back to the raw DB path when mapping returns nothing. On his Docker setup the
mapping returns None, so the scan set has_local=False and skipped every album
(never looking at the folder), even though the apply WOULD have written the
cover.jpg from the raw path.

Fix: make the scan match the apply — if mapping returns nothing but the raw DB
path is a real file (container path == stored path), use it as-is. Now the scan
actually inspects the folder, sees the missing cover.jpg, and flags it; the
apply then writes it from the embedded art.

Tests: unresolved-path-but-real-file → flagged (sidecar_from_embedded); the
fallback does NOT fire for a non-existent path (media-server-only stays skipped).
Kept the [cover-diag] logging from the prior commit to confirm on Sokhi's run.
2026-06-08 17:32:38 -07:00
BoulderBadgeDad
d9a1fdb81f Cover Art Filler: log WHY albums are skipped (Sokhi — stop guessing the cover.jpg cause)
Sokhi's scans keep returning 0 findings and we've been guessing at the cause
across several fixes. Add a skip-reason breakdown + per-album decision dump so
the next scan log shows it definitively instead:

- [cover-diag] per-album (first 5): album, raw DB path, resolved path,
  has_local, embedded, sidecar, db_missing, cover_enabled, needs_fix.
- [cover-diag] skip breakdown in the summary: have_disk_art /
  no_local_db_has_art (path didn't resolve, DB has thumb) / no_art_source.

Leading hypothesis this will confirm: the file paths aren't resolving
(resolved=None → has_local=False), so the scan only ever looks at db_missing —
which flipped every album from flagged (before the DB-art fix populated thumbs)
to skipped (after). If so the real fix is path resolution, not the art logic.
No behavior change — logging only.
2026-06-08 17:21:07 -07:00
BoulderBadgeDad
a4a2135c19
Merge pull request #820 from failshell/fix/tidal-version-matching-and-rate-limit
fix(tidal): honour version field in matching and back off on rate limits
2026-06-08 16:25:51 -07:00
BoulderBadgeDad
5352db0a22 Cover Art Filler: write cover.jpg sidecars, even when files already have embedded art (Sokhi #813)
Sokhi: after the earlier flag fix, scans returned 0 matches — albums with
embedded art but no cover.jpg were treated as fully arted and skipped, so their
cover.jpg never got written.

Root cause: the scan used album_has_art_on_disk (True if EITHER embedded art OR
a cover.jpg exists), conflating the two. Now it checks them separately:
- a local album is flagged if it lacks embedded art, OR it lacks a cover.jpg
  sidecar AND cover.jpg writing is enabled (metadata_enhancement.cover_art_
  download — Boulder: "only scan for cover.jpgs when enabled").
- an album that has embedded art but no sidecar is fixable even when the API
  finds no art: the apply writes cover.jpg from the EXISTING embedded art.

apply_art_to_album_files now writes the cover.jpg sidecar by extracting the
album's own embedded art (new extract_embedded_art) — consistent with the
files, no API call — and only falls back to download_cover_art when there's
nothing embedded to extract. _fix_missing_cover_art no longer bails on a
missing artwork_url when sidecar_from_embedded is set.

Tests: scan flags embedded-but-no-cover.jpg (incl. when API finds nothing),
still skips albums with both, still flags artless albums; apply writes cover.jpg
from embedded art (no download), falls back to download when none, skips when a
sidecar already exists; extract_embedded_art unit tests. 1344 cover/art/repair
tests pass.
2026-06-08 15:35:55 -07:00
BoulderBadgeDad
c654deac17 Manual search link paste: clean error on unresolvable link, don't search the raw URL (#813 lock-in)
A parsed link is unambiguously a Tidal/Qobuz /track/ URL (no false positives),
so if its source isn't connected or the track can't be resolved, return a clear
400 ("Tidal isn't connected — … or search by name") instead of silently
running a useless search of the raw URL text. The frontend already surfaces the
400's error message in the modal.
2026-06-08 15:14:47 -07:00
BoulderBadgeDad
cea0e9d63c Manual download search: paste a Tidal/Qobuz track link to grab the exact version (#813)
When a track shows "Not found", the manual search now accepts a pasted Tidal or
Qobuz track link, not just a typed query (CubeComming: the fuzzy search misses
versions; he can find the track on Tidal but can't get it to appear).

How it works (robust, reuses the proven path): parse the link → (source,
track_id) → fetch the track via the source client's get_track → build a clean
"artist title (version)" query → run THAT source's normal search → bubble the
result whose id matches the link to the top. So the candidate is a normal,
already-downloadable streaming result — no hand-built download encoding — and
it downloads through the existing verified flow.

Degrades gracefully: if the source isn't connected or the link can't be
resolved, it falls back to a normal text search of the raw input — the user is
never worse off than typing it themselves. Scoped to Tidal + Qobuz (the
streaming sources that download by track id, with public track URLs); Soulseek
can't take a link (P2P, no ids), YouTube/SoundCloud are URL-native via a
different path (future).

- core/downloads/track_link.py: pure parse_download_track_link (tidal/qobuz
  /track/<id>, slug/region suffixes, scheme-less) + query_from_track_payload
  (per-source title/artist, Tidal version-append).
- manual-search endpoint: link detection → resolve → restrict to that source →
  id-match bubble.
- placeholder hint mentions pasting a link; maxlength 200→300 for long URLs.

Tests: 14 (parser shapes + payload extraction incl. remix version-append +
qobuz performer/album-artist fallback). JS valid.
2026-06-08 15:03:54 -07:00
BoulderBadgeDad
b2de64e87d Search: extend pasted-link resolution to Discogs (#813)
#775 already resolves pasted Spotify / Apple / MusicBrainz / Deezer links to an
exact artist/album/track on the Search page. Added Discogs to that set (the one
source in the request not already covered; Amazon left out per request).

- by_id resolver: discogs in SUPPORTED_SOURCES + _KNOWN_HOSTS; parse
  /artist/<id>-slug, /release/<id>-slug, /master/<id>-slug (master→album), and
  strip the slug to the leading numeric id. Discogs has no standalone track
  URLs (tracks live inside a release), so artist + album resolve.
- Fetch dispatch: discogs albums use get_album(include_tracks=False) like
  itunes/musicbrainz; artists use get_artist; both already return the common
  normalized card shape. Updated the not-a-link hint to mention Discogs.

Frontend needs no change — it adopts whatever source the resolver returns and
Discogs is already a search source. Tests: parse release/master/artist
(slug-stripped, scheme-less) + resolve release→get_album(numeric id) and
artist→get_artist. 43 by_id tests pass.
2026-06-08 14:37:48 -07:00
BoulderBadgeDad
df898b5212 Import: atomic tag saves so an interrupted/OOM save can't destroy the file (#819)
CubeComming: manual imports of large hi-res Qobuz FLACs came out as empty
shells — no audio, no tags, no length/bitrate. Root cause: mutagen's in-place
save() rewrites the file, and it's NOT atomic; if the process is interrupted or
OOM-killed mid-write (he also reported the memory-growth issue #802), the file
is left truncated — audio and tags gone.

save_audio_file (the chokepoint both the enrichment tag-write AND wipe_source_
tags route through) now saves atomically: copy the original to a temp in the
same dir, write the modified tags into the copy, verify it's still valid audio
(duration > 0), then os.replace() it in. The original is untouched until that
atomic swap, so a crash can only orphan the temp — never destroy the user's
file. Falls back to the plain in-place save (byte-identical to before) when the
atomic path can't run, so nothing is ever left worse off. tag_writer's
write_tags_to_file routes through the same helper.

Verified the atomic path works with REAL mutagen on a real FLAC (audio length
preserved 1.0s→1.0s, tag written, temp cleaned). Tests: replace-on-success,
original-survives-save-failure, corrupt-temp-rejected, no-filename-plain-save,
+ a real-FLAC round-trip (skips without ffmpeg). 2443 import/metadata/tag tests
pass.
2026-06-08 13:46:28 -07:00
BoulderBadgeDad
72c62aec45 CSS: fix dashboard hover-flicker (#816), Automations tile clutter (#816), and onboarding badge overlap (#817)
#816 hover-flicker — .dash-card:hover and .qa-tile:hover both did
transform: translateY(-Npx). Hovering a card's bottom edge lifted it off the
cursor → un-hover → drop → re-hover, an infinite rapid loop. Since every
dashboard card is a .dash-card, all of them flickered ("all elements
affected"). Removed the translateY lift; the hover's stronger shadow + border
glow already reads as "raised" without moving the hit box. (qa-tile has
overflow:hidden so a pseudo-element gap-buffer can't help — removal is the
clean, consistent fix.)

#816 Automations "looks strange" — the .qa-tile__flow decoration sits in the
bottom row directly behind the green "Open →" CTA; at 0.45 opacity the accent
nodes/line competed with the CTA (green-on-green clutter). Toned to 0.22 so it
reads as faint background texture; still brightens on hover.

#817 badge overlap — .helper-first-launch-tip was right:84px, only ~4px clear
of the ? float button's 8px pulse ring (button left edge ~72px from right), so
the "New here?" tip touched the button. Moved to right:96px.

CSS values/comments only — no structural changes (brace delta unchanged).
2026-06-08 11:29:00 -07:00
BoulderBadgeDad
cdee7b3550 Cover Art Filler: always write the cover.jpg sidecar (Sokhi)
Sokhi: filler works but doesn't write cover.jpg. Cause: the sidecar write
(download_cover_art) respects the import-time "Download cover.jpg to album
folder" toggle, while embedding ignores it — so with that toggle off, art
embeds but no sidecar is written.

A job literally called Cover Art Filler should produce the complete art when
you explicitly run it. download_cover_art gains force=True (bypasses the toggle)
and the filler's apply path passes it. The import pipeline still calls without
force, so it keeps honoring the user's setting.

Tests: filler passes force=True; existing cover-write mocks updated for the new
kwarg. 1732 art/cover/repair/import tests pass.
2026-06-08 11:07:00 -07:00
Jeff Theroux
3c06bd03c0
fix(tidal): honour version field in matching and back off on rate limits
Three related fixes to Tidal track matching and downloading:

1. version-field handling — Tidal stores remix/live/edit qualifiers in a
   dedicated `track.version` attribute (e.g. name="Emerge",
   version="Junkie XL Remix"), not in the track name. The qualifier
   filter and the matcher only looked at name/album, so the exact
   recording was discarded. Fold `version` into both the qualifier
   haystack and the candidate title passed to MusicMatchingEngine.

2. divergent-version penalty — once versions are visible, OTHER cuts of
   the same base become candidates ("(Shazam Remix)" vs "(southstar
   Remix)"). Neither title is a prefix of the other, so the prefix-based
   version check missed them and the raw ratio stayed high off the
   shared base. Apply a heavy penalty when both titles carry different
   version descriptors so the wrong cut can't outscore the threshold.

3. rate-limit backoff — the trackManifests endpoint is aggressively
   rate-limited; a bare request failed 429 instantly, burned the quality
   tier, re-queued the track and hammered again (a self-amplifying
   storm). Honour Retry-After / exponential backoff with a bounded retry
   count and shutdown-aware sleep.

Adds unit + end-to-end tests for all three.
2026-06-08 13:50:19 -04:00
BoulderBadgeDad
7d481ae02f Cover Art Filler: a local album with file art isn't "missing" just because the DB thumb is empty (Boulder)
Boulder (Plex): "flags every album, but everything has art." His albums show
art in the library (served from the embedded file art), but the DB thumb_url
cache column is empty — and the scan flagged on db_missing (empty thumb_url),
so every local album tripped it despite having perfectly good art in the files.

Now: a LOCAL album is flagged only when its files actually lack art
(disk_missing). An empty thumb_url is just a stale cache when the files have
art — not "missing cover art". db_missing still flags media-server-only albums
(no local files), where the DB thumb is the only art there is.

Tests: local+file-art+empty-thumb → NOT flagged (the bug); local+no-file-art →
still flagged; media-server-only+empty-thumb → still flagged.
2026-06-08 10:03:43 -07:00
BoulderBadgeDad
8277385051 Cover Art Filler: resolve the rep path before the disk-art check (flags-every-album)
The scan checked album_has_art_on_disk() on the RAW DB track path, while the
apply (_fix_missing_cover_art) resolves the path first. On any path-mapped
setup (docker mounts, a Plex/SoulSync path mismatch) the raw path isn't found,
disk art reads as "missing", and EVERY album gets flagged — then the apply
resolves the path, finds the art already there, and reports "already present".
Scan and apply disagreed purely because only the apply resolved paths.

Now the scan resolves the representative path the same way (resolve_library_
file_path, same transfer/download/config inputs the retag job uses) before
checking disk art. Unresolvable → treated as no-local-file (not claimed
disk-missing) so we never false-flag a file we simply can't reach.

Tests: disk check runs on the resolved path (thumb+art → not flagged);
unresolvable path → not flagged + art never checked on None.
2026-06-08 09:52:17 -07:00
BoulderBadgeDad
442ced3dbf Jellyfin/Emby: populate album thumb_url during the library scan (root cause of "flags every album")
JellyfinArtist sets self.thumb (→ artists.thumb_url on scan); JellyfinAlbum
never did. So for Jellyfin/Emby the library scan read getattr(album, 'thumb',
None) == None and albums.thumb_url stayed empty for the WHOLE library. Two
downstream effects: blank album art in the web UI, and the Cover Art Filler
flagging every album as "missing cover art" (db_missing = empty thumb_url) —
making the filler the only thing that ever populated the column, which is
backwards. It should come from the scan, like artist thumbs do.

Fix: JellyfinAlbum.thumb = /Items/<id>/Images/Primary (same shape as the artist
thumb, which already normalizes + displays via fix_artist_image_url). Now the
scan stores album thumbs, the UI shows art, and the filler only flags albums
that are genuinely missing art. Navidrome + Plex already set album thumbs.

Tests: album exposes the Primary-image thumb, None without an id, same shape as
the artist thumb.
2026-06-08 09:40:27 -07:00
BoulderBadgeDad
c83d4862e8 Cover art: stop crying "(read-only?)" when files are simply already arted (Sokhi/Boulder)
The real reason Sokhi's cover-art fix "didn't work" and Boulder saw the same
message on a WRITABLE Windows box: _fix_missing_cover_art reported
"Updated database thumbnail, but could not write art to files (read-only?)"
whenever embedded==0 AND cover_written==False — but the overwhelmingly common
cause of that is every file ALREADY having embedded art (skipped, not failed).
The message blamed read-only on a perfectly writable library, which sent us
chasing a read-only ghost across three commits. Sokhi's /fix returns 200
(success), so he was never hitting the genuine EROFS path at all.

Now the message is derived from the art_result breakdown:
- embedded/cover written → "Applied cover art: …"
- failed>0 (non-EROFS) → "… check file/folder permissions"
- skipped>0 (already arted) → "Cover art already present on all N file(s)"
- otherwise → "no file artwork was applied"
Genuine read-only (EROFS) still hard-fails with the clear mount message.

Tests: already-arted→present (not read-only), failed→permissions, EROFS→hard
fail, embedded→success. 453 cover/repair/art tests pass.
2026-06-08 09:06:37 -07:00
BoulderBadgeDad
41f4eeb91e Watchlist: log when the release-type filter skips an album/EP/single
Sokhi reported "14 singles, 0 albums" from a scan and setting lookback to "All"
didn't help — because it was never lookback. The cause is the per-artist
release-type filter: with "Albums" toggled off, _should_include_release drops
every 7+-track release (the full discography is still fetched, then filtered).
That skip was completely silent, so there was no way to see it in the log.

Now logs each skipped release with its type + the artist's albums/eps/singles
toggles, so "why didn't my albums get added" is answerable from app.log.
No behaviour change — diagnostics only.
2026-06-08 08:52:49 -07:00
BoulderBadgeDad
1ca14d1c19 Cover art: surface read-only on the cover.jpg sidecar write too (Sokhi, #804 follow-up)
Sokhi still hit the read-only error after the statvfs fix (6c3e285a). Root
cause was a gap that fix didn't cover: read_only_fs was only set from the
per-file EMBED write, but download_cover_art SWALLOWS its own cover.jpg EROFS
(logs "Error downloading cover.jpg" and returns). So when an album's tracks
already have embedded art, the embed loop is skipped, only the cover.jpg
sidecar write runs, its EROFS is swallowed, and the filler reported success
while spamming the log — exactly Sokhi's case.

Fix (no blast radius — download_cover_art still never re-raises, since its
import-pipeline callers aren't wrapped): on EROFS it now records
'_cover_read_only' on the passed context instead of just logging; apply_art_to_
album_files passes a capture dict and promotes that to read_only_fs. So a
cover-only read-only album now correctly surfaces the read-only message instead
of a silent success.

Tests: +1 — embed skipped (track already arted) + cover.jpg read-only →
read_only_fs True. 472 cover/art/repair tests pass.
2026-06-08 08:34:36 -07:00
BoulderBadgeDad
902eb38fb8 Downloads: fix collapsed-batch overflow (#814) + Retry Failed result feedback (#815)
#814 — the collapsed Batches rail (44px) hid .adl-batch-active and
.adl-batch-history-section but NOT the JS-rendered .adl-batch-summary line
("N batches · M downloading · …"), so it overflowed as clipped text. Added it
to the collapsed hide rule.

#815 — "Retry Failed" only toasted "Retrying N…" at the start and a generic
"Discovery complete!" at the end, with no sense of how many of the retried
tracks actually progressed. retryFailedMirroredDiscovery now stamps a baseline
(matches-before + retry count) on the state, and a shared completion toast
reports "Retry complete: X of N newly found[, Y still not found]" instead of
the generic message. Normal (non-retry) discovery still shows "Discovery
complete!".

JS syntax clean, 70 script-split/style tests pass.
2026-06-07 23:53:18 -07:00
BoulderBadgeDad
f4dbaea68b
Merge pull request #812 from Nezreka/dev
Dev
2026-06-07 23:38:26 -07:00
BoulderBadgeDad
40e3dac881 Sync: append mode preserves the playlist image like reconcile (#811)
#811 (reopen of #792, carlosjfcasero, Emby/Jellyfin): "append" mode clobbered
the playlist's custom image. The post-sync image push only excluded
'reconcile' — so append (which edits the playlist in place via
append_to_playlist) still re-pushed the source image over the user's poster
every sync. Now both in-place modes (reconcile + append) skip the image push;
only the destructive 'replace' (recreate-from-scratch) pushes it.

append_to_playlist + set_playlist_image were verified to NOT touch tracks or
description (image push only POSTs /Images/Primary), so this is the identity-
clobber fix for append.

Tests: append + reconcile preserve the image, replace still pushes it.
2026-06-07 23:25:42 -07:00
BoulderBadgeDad
1fba950284 Release 2.6.8: version bump + docker-publish default + What's New changelog
- _SOULSYNC_BASE_VERSION 2.6.7 → 2.6.8 (drives the UI version + release notes).
- docker-publish.yml workflow_dispatch default version_tag → 2.6.8 (the manual
  tagged-release publish; run the workflow to push :2.6.8 + announce).
- helper.js WHATS_NEW: new 2.6.8 block (18 entries) covering everything since
  2.6.7 — Blocklist, the #801 retry overhaul, Download Origins, Expired
  Download Cleaner, Lyrics Filler + re-tag lyrics, Spotify-token-in-DB deauth
  fix, #705 release-date gate, YouTube-OOTB, Navidrome #809, the dashboard/
  modal visual pass, #767 reorganize edition, #806 cover art, cover-art
  read-only handling, #804 import fixes, artist-page discography + #808,
  iTunes-id repair, torrent stall handling, paste-MBID match, and smaller fixes.
  Surfaces automatically in What's New + version release notes now that the
  build version is 2.6.8.
2026-06-07 23:18:21 -07:00