Commit graph

353 commits

Author SHA1 Message Date
BoulderBadgeDad
086d153d77 Multi-disc (#927): capture real disc number at media-server scan time
Every library track was stored with disc_number=1 because the Jellyfin/Plex/Navidrome scan parsed the track number but never the disc field. Multi-disc albums collapsed onto disc 1, so disc-2+ tracks were mis-filed (shown under disc 1) and flagged 'missing' — the frontend title-fallback band-aid couldn't recover it (breaks on iTunes title mismatches).

Now the shared insert_or_update_media_track reads the disc number (Jellyfin .discNumber=ParentIndexNumber, Navidrome .discNumber, Plex .parentIndex), floors to >=1, and stores it in the INSERT + UPDATE. The disc_number column is ensured on init (it was only added by a migration that doesn't run on fresh installs, so the new INSERT would have hard-failed for new users). The enhanced album view already carries disc_number through (SELECT * -> dict), so the display fixes itself once the column is populated — a re-scan backfills existing libraries. Seam-tested across Jellyfin/Navidrome/Plex shapes + the floor-to-1 + re-scan-update cases.
2026-06-25 15:40:17 -07:00
BoulderBadgeDad
3c33e31985
Merge pull request #928 from nick2000713/fix/post-processing-race-and-followups
Fix import-vs-quarantine race + opt-in rank-based download order + quality-settings UI cleanup
2026-06-25 14:52:53 -07:00
BoulderBadgeDad
9847d6f0a9 Wing It Pool: two-card landing (review + resolved), matching the Discovery Pool
Opens to the same category-card landing the Discovery Pool uses, with two cards: 'guesses to review' (unverified wing-it) and 'resolved manually' (ones you've Fixed) — click to drill in, Back to return. Previously it jumped straight to a single list.

To populate the resolved list, the /fix endpoint now stamps was_wing_it on the rewritten extra_data (the wing_it_fallback flag is otherwise lost on fix), and get_wing_it_pool gained a resolved flag + the stats return both counts. Fixing/re-matching from either card refreshes in place. Seam test updated for both states.
2026-06-25 14:15:42 -07:00
BoulderBadgeDad
602b035bad Wing It Pool: review + re-match tracks Wing It auto-matched
Wing It auto-matches tracks to the server library on a best-effort guess; those tracks are flagged wing_it_fallback in extra_data and count as 'discovered', so the Discovery Pool hides them — there was no way to see or audit the guesses. New 'Wing It Pool' button (next to Discovery Pool on the Mirrored Playlists tab) opens a modal listing them with a per-playlist filter + search; 'Fix Match' reuses the Discovery Pool's fix flow (/api/discovery-pool/fix), and a manual match drops the track from the pool on refresh.

No new table or provider hooks needed — the wing-it flag is already persisted, so this is a pure query (get_wing_it_pool / get_wing_it_pool_stats, cloning the failed-pool LIKE pattern) + a /api/wing-it-pool endpoint + a cloned modal. Found 81 wing-it tracks on a real library. Seam-tested (include unverified / exclude manual-matched / scope by playlist+profile).
2026-06-25 13:57:50 -07:00
dev
ab05508acc feat(quality): rank-based candidate ordering toggle for priority mode
Adds an opt-in `rank_candidates_by_quality` profile flag. When on, the
priority-mode download walk orders candidates by the ranked-target quality
(confidence/speed only break ties) instead of confidence-first. Default off
keeps the byte-for-byte old behaviour, so existing installs are unaffected.

Best-quality search mode is always quality-first regardless of the flag; the
toggle only affects priority mode. Search-time source selection is unchanged —
nothing is skipped, so a track can never go missing, only the order in which
copies are tried changes.

The version-mismatch force-import follows automatically: it accepts the
first-tried (= best-ordered) quarantined candidate, which is the highest-quality
one once the walk is quality-first. No change to its selection logic needed.

- core/quality/selection.py: load_rank_candidates_by_quality() (fail-closed).
- core/downloads/task_worker.py: _best_quality_ordering -> _candidate_ordering;
  quality-first when best_quality mode OR the toggle is on.
- database/music_database.py: default profile carries the flag (False).
- web_server.py: flag is preserved globally across preset apply/reset, like
  search_mode.
- core/imports/version_mismatch_fallback.py: comment clarified (no behaviour
  change).

Tests (TDD): load_rank_candidates_by_quality default/enabled/disabled/error;
_candidate_ordering across all mode+toggle combinations + fail-closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:00:57 +02:00
BoulderBadgeDad
9c91ba29bf Discover: listening-driven recommendations + mix (#913), Fresh Tape fix
#913 was silently producing 0 recs: similar_artists.source_artist_id is a SOURCE id (Spotify/etc.), but the scan keyed id->name by internal artists.id (resolved nothing), and the consensus ranker was fed the name-collapsed get_top_similar_artists (consensus could never fire). Fixed + elevated:

- id->name keyed by source-id columns; raw per-seed edges (real consensus); similarity_rank threaded into the score; recency-weighted seeds (recent plays boost lifetime favs)
- new 'Based On Your Listening' artist row (/api/discover/listening-recommendations) with 'because you listen to X' explanations
- new 'Your Listening Mix' track row: each rec's top tracks via a guarded, name-resolved Spotify/Deezer fetch (falls back to the discovery pool), stored as full render dicts so the row can't shrink on pool rotation
- pure tested core: similarity_from_rank, build_recency_weighted_seeds, to_mix_track, names_match (+ rank-aware grouping)

Fresh Tape (5-10 tracks): future-dated albums sorted to the top of get_discovery_recent_albums and ate the 50-album budget before the is_future_release skip ran. Add exclude_future_years + fetch a generous budget; downstream caps unchanged. Regression tested.

Also drop the per-track block 'X' from the compact playlist rows (wrong spot). Plan/audit in DISCOVER_BEST_IN_CLASS_PLAN.md.
2026-06-25 10:15:20 -07:00
BoulderBadgeDad
ed0a2079cf
Merge pull request #896 from nick2000713/feature/best-quality-search-mode
Global quality system: real-audio verification, best-quality search & quality profiles (please try...not ready to merge)
2026-06-24 20:27:38 -07:00
dev
d310b090e2 feat(quality): migrate old per-source Hi-Res preference into the profile (#896 #5)
PR #896 removed the per-source quality dropdowns; streaming sources now derive
their request tier from the global profile via quality_tier_for_source. Without
a migration, a user who had tidal_download/qobuz/hifi_download.quality on
'hires'/'hires_max' silently dropped to lossless (their migrated v2 'flac (any)'
top target resolves to the lossless tier).

_migrate_v2_to_v3 now seeds the 24-bit FLAC ladder at the top of ranked_targets
when such a Hi-Res source preference is detected — but only if the profile
doesn't already express 24-bit, so it never duplicates the ladder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:39:53 +02:00
dev
13bfdaeda2 feat(quality): re-port AAC opt-in tier (#886) onto ranked-targets
The 2.7.4 merge replaced the bucket-based quality filter with core.quality.model
and silently dropped #886's AAC handling. Restore it in the new model:

- v2_qualities_to_ranked_targets maps an enabled 'aac' tier to a format-only
  target (slskd AAC rarely carries a bitrate, so no min_bitrate gate); priority
  order (above MP3, below FLAC) comes from the caller's sort.
- soulseek filter_results_by_quality_preference drops AAC/.m4a candidates when
  the profile has no AAC target, so AAC stays OFF-by-default instead of slipping
  through via fallback.
- the three factory presets carry the legacy qualities dict again with the
  disabled aac tier (used by the settings UI + the #886 opt-in toggle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:33:53 +02:00
dev
5d5ed486c4 fix(quality): repair two PR #896 merge blockers
#1 DB init crash: the idx_lh_verification_status index was created before the
ALTER TABLE that adds the column, so a fresh library_history (every existing
install + clean checkouts) died on startup with "no such column:
verification_status". Move the index after the column migration.

#2 #652 quarantine loop returns: the rewritten
filter_results_by_quality_preference dropped the _drop_quarantined_sources()
pre-filter, letting a previously-quarantined (user, file) win the picker again
and re-quarantine forever. Re-wire it at the top of the method.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:24:54 +02:00
BoulderBadgeDad
f2f4f8ccee #910: add the per-track 'year' column the Full Refresh insert needs
Full Refresh INSERTs a per-track year (from file tags) into tracks.year, but that column
was only ever in the live INSERT — never in CREATE TABLE and never in a migration. So on
EVERY db (old and current — verified the shipped music_library.db lacks it too) every Full
Refresh track insert hard-failed with 'table tracks has no column named year', importing 0
tracks while artists/albums succeeded.

Fix (additive + nullable, nothing reads it but the writer):
- add year INTEGER to the tracks CREATE TABLE (new DBs)
- ALTER it onto existing tracks tables in _ensure_core_media_schema_columns (the repair
  backstop that already runs every init), right beside the file_size repair

Tests (tests/test_tracks_year_migration.py): fresh-DB has it, nullable, idempotent, ALTERs
onto an old year-less table, and a regression that the exact Full Refresh insert fails
before the repair and succeeds after.
2026-06-23 11:03:24 -07:00
nick2000713
63374b32f1 Merge remote-tracking branch 'nezreka/dev' into feature/best-quality-search-mode
# Conflicts:
#	core/hifi_client.py
2026-06-23 11:33:50 +02:00
BoulderBadgeDad
ab8f82af2e #903: re-export updates the same ListenBrainz playlist in place (no duplicates)
Re-running an export created a new LB playlist every time (LB keys on MBID, not name, and
create always mints a new one). Now remember which LB playlist a mirror was pushed to and
update it in place:

- listenbrainz_client: refactor batched-add into _add_tracks_in_batches; add
  get_playlist_track_count, delete_playlist, update_playlist (verify exists -> clear items via
  item/delete -> re-add -> edit title; reports gone=True if deleted on LB), and
  create_or_update_playlist (update when we have a prior MBID, else create; falls back to
  create if the remembered one was deleted). Stable URL/MBID across re-syncs.
- playlist_export_targets table + get/set_playlist_export_target: remember (mirror, target) -> LB MBID.
- export job consults/stores the target so push updates in place.

+6 mocked tests (clear+re-add same mbid, gone-fallback, create-or-update branches, delete). API
endpoints (item/delete, playlist/edit, playlist/delete, GET count) confirmed against LB docs;
live round-trip pending explicit auth.
2026-06-22 22:36:29 -07:00
BoulderBadgeDad
42ff13d517 #903: persistent recording-MBID cache + export orchestrator
Phase 3. Additive backbone for the export job:
- mb_recording_cache table (IF NOT EXISTS) + core/exports/recording_mbid_cache.py: persistent
  (artist,title)->recording_mbid cache, mirrors album_mbid_cache (lazy DB, error-degrades to
  miss). The MusicBrainz tail is ~1 req/s, so a resolved MBID is remembered once and reused
  across every export/playlist.
- core/exports/playlist_export.py: resolve_playlist_tracks(tracks, resolve_fn) — walks tracks,
  dedups repeated songs within a run (resolve once), builds the ordered pseudo-playlist, tallies
  live stats (resolved/unmatched/deduped/by_source). Pure (I/O injected via resolve_fn + progress
  callback), so dedup + accounting are unit-tested with no DB/network. 5 tests.

No wiring into runtime yet; nothing existing touched except the additive table.
2026-06-22 20:31:35 -07:00
BoulderBadgeDad
1ad80d77a6 #901: one-time backfill — stable ids for EXISTING file-import mirrored tracks
The mirror_playlist fix only assigns stable ids to newly-imported playlists, so a
user with an existing file-import playlist would still have empty-id rows (and dead
Find & Add matches) until a manual re-import. Add an idempotent startup backfill that
assigns the SAME stable id a fresh import would to any mirrored track missing one —
so existing matches start sticking with no re-import. Runs once per db/process (the
init is guarded), only touches empty-id rows (no-op afterward), native ids untouched.

Tests: backfill fills empty ids with the exact fresh-import id, is idempotent (2nd
run = 0), and leaves native ids alone.
2026-06-22 09:06:34 -07:00
BoulderBadgeDad
6e622d30f1 #901: give file-import playlist tracks a stable id so manual matches stick
A Find & Add on a file-import (CSV/M3U/TXT) playlist track was silently dropped and
the track re-appeared as 'extra' (radoslav-orlov). Root cause: unlike Spotify/YouTube
(native ids), file-import + iTunes-only tracks arrive with an EMPTY source_track_id —
and the whole manual-match system keys on it. _persist_find_and_add_match is a no-op
on an empty id, and find_manual_library_match_by_source_track_id returns None for one,
so the match can be neither recorded nor looked up. That's the youtube-vs-file
difference the reporter noticed.

Fix: stable_source_track_id() derives a DETERMINISTIC 'file:<hash>' id from the track
identity (artist|title|album, normalized) when there's no native id; mirror_playlist
assigns it so the SAME song gets the SAME id across re-imports/discovery — exactly
what the match lookup needs. Native ids are used verbatim; bonus: discovery extra_data
now survives a re-import for these tracks too.

Tests: helper (native passthrough, deterministic + case/field-insensitive, distinct
per song, empty-on-no-title, file: prefix); mirror_playlist integration (file tracks
get stable distinct ids, stable across re-import, native ids untouched). 319 playlist/
sync/discovery/mirrored tests green.
2026-06-22 08:53:24 -07:00
BoulderBadgeDad
15ea87a154 #897: surface the ignore-list on the wishlist page + stop blocking manual re-adds
Two issues behind #897:

1) Discoverability — the "Ignored" management modal (view/un-ignore/clear-all,
   shipped with #874) was only reachable from the wishlist *overview modal*
   footer, which most users never open. Add the same button to the wishlist
   page toolbar next to Cleanup / Clear All, wired to openWishlistIgnoreModal().

2) Manual re-add silently blocked (carlosjfcasero) — the album-modal "add to
   wishlist" endpoint passes source_type=album, but the ignore gate only
   bypasses+clears for source_type=manual, so re-adding a previously-cancelled
   track failed. We cannot just send manual: source_type drives Albums/Singles
   categorisation and repair_worker legitimately uses album too. Thread an
   explicit user_initiated flag (db.add_to_wishlist -> service -> album route)
   that bypasses+clears the ignore while preserving the real source_type.

Regression test pins both: an automatic source_type=album add stays blocked,
the user_initiated add goes through, clears the ignore, and keeps source_type=album.
2026-06-21 20:21:44 -07:00
BoulderBadgeDad
3496bb1800 Dedup: match artists across a leading "The" so "The X" and "X" don't download twice
_get_artist_variations only widened the candidate fetch by diacritics, so a
request for "The Black Eyed Peas" never pulled a library track filed under
"Black Eyed Peas" (or vice-versa) — it "failed to match" and re-downloaded a
duplicate. Toggle the leading "The" in both directions when widening the fetch;
the confidence scorer (50/50 title/artist, 0.882 across the "The" gap) still has
the final say, so this can only widen what gets fetched, never merge genuinely
different artists. Mid-word "The" (e.g. "Theory of a Deadman") is untouched.
2026-06-21 19:21:59 -07:00
dev
f9dfbd8fd5 Quality presets: remember per-preset edits + reset-to-defaults
Switching presets now restores the user's prior edits to that preset
instead of factory defaults. Edits are stashed per preset name under
the quality_profile_presets preference; 'custom'/unknown names are not
stashed. Adds a /reset endpoint + "Reset to defaults" UI link to drop a
preset's saved edits.

- DB: set_quality_profile stashes per-preset; get_quality_preset returns
  the customized form by default, _factory_quality_preset for the raw
  defaults; reset_quality_preset forgets a preset's edits.
- web_server: apply-preset carries the global search_mode across switches;
  new preset/<name>/reset endpoint.
- UI: target edits now save via debouncedSaveQualityProfile (profile-only,
  no full settings re-init/flicker); preset switch suppresses the global
  auto-save listener; help text + reset link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:35:45 +02:00
dev
b761229a00 merge: pull upstream/main (2.7.4) into feature/best-quality-search-mode
- Keep our v3 ranked-targets quality system (filter_and_rank, QualityTarget)
  in soulseek_client.py, settings.js, database presets, and index.html
- Take upstream removal of standalone quality-scanner code:
  QualityScannerDeps + run_quality_scanner moved to repair job
  (core/repair_jobs/quality_upgrade_scanner.py)
- Take upstream AAC-tier addition in database/music_database.py default profile
- Take upstream removal of /api/quality-scanner/* routes from web_server.py
- Remove test_discovery_quality_scanner.py (deleted upstream)
- 47 upstream commits absorbed (2.7.3 + 2.7.4 including re-identify flow,
  dead-folder cleanup, track-number prefix strip, and more)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 16:54:51 +02:00
BoulderBadgeDad
dbd8278a14 #889 Phase 1: re-identify hint store (DB table + pure create/find/consume seam)
A single-use, user-designated 'which release does this track belong to' answer.
Written when the user picks a release in the Re-identify modal and the file is
staged; the import flow will read it at the top of matching and consume it.

- rematch_hints table (additive, IF NOT EXISTS + indexes) keyed on staged_path
  with content_hash as a rename-proof fallback.
- core/imports/rematch_hints.py: pure DB seam over an injected cursor
  (create/find/consume/list) + a cheap size+head+tail file fingerprint.
- exempt_dedup baked into the hint (a re-identify must bypass dedup-skip);
  replace_track_id carried for deferred post-success cleanup.

Inert until wired (Phase 5) — nothing calls it yet. 9 seam tests.
2026-06-18 15:15:41 -07:00
BoulderBadgeDad
400b35d655 #886: AAC as an opt-in Soulseek quality tier (purely additive, off by default)
radoslav-orlov: add AAC as a download quality option. AAC is more efficient than
MP3, so it's useful for Soulseek/torrents (streaming sources pick their own
codec; Amazon — the AAC-heavy one — is down).

Additive by construction: every quality tier already defaults enabled=false and
the waterfall is built only from enabled tiers, so AAC ships OFF and the bucketer
routes a not-enabled AAC file to the 'other' bucket EXACTLY as today (where it was
silently dropped). Only a user who turns AAC on makes it a first-class tier,
ranked above MP3 / below FLAC (priority 1.5, min-kbps gate so junk AAC can't beat
a good MP3).

- music_database: aac tier (disabled) in the default profile + all 3 presets.
- soulseek_client: map .m4a -> 'aac' in both result parsers (was 'unknown' ->
  dropped); add the 'aac' bucket + a gated branch + a fallback size limit.
- settings UI: an 'AAC' tier toggle (unchecked) between FLAC and MP3; save
  defaults its priority to 1.5 so upgraded profiles rank it right on first save.

7 seam tests pinning the additive guarantee (aac absent/disabled -> dropped as
before; FLAC/MP3 selection unchanged; aac on -> selectable, below FLAC, above
MP3); 81 quality/soulseek tests pass, ruff clean. quality_upgrade left untouched
(its AAC handling is unchanged).
2026-06-18 13:45:52 -07:00
BoulderBadgeDad
48e86a1a58 #874: wishlist ignore-list — stop auto-retrying removed/cancelled tracks
A user who removes a wishlist track, or cancels an in-flight wishlist
download, would have it re-added on the next auto cycle (watchlist scan,
failed-track capture, or the cancel handler's own re-add), so the same
release downloaded -> failed/cancelled -> re-queued forever.

Adds a TTL'd skip-gate (30 days), softer than the blocklist: it expires
so the track is reconsidered later, and never blocks a manual
force-download — only the automatic re-queue.

- core/wishlist/ignore.py: pure TTL/normalization/display logic + a
  best-effort orchestrator (no DB handle, caller passes now).
- database/music_database.py: migration-safe wishlist_ignore table +
  add/check/remove/list(+purge)/clear methods, and the gate in
  add_to_wishlist beside the blocklist guard. Fail-open throughout — an
  ignore error can never block a legitimate add; a manual add bypasses
  the gate AND clears the ignore.
- routes.py: user remove (single/album/batch) records an ignore. Hooked
  at the route layer, NOT the DB remove, so success-cleanup never
  ignores (regression-tested).
- web_server.py: cancel now ignores + removes from the wishlist instead
  of re-adding for endless retry; three /api/wishlist/ignore-list*
  endpoints.
- downloads.js: 'Ignored' modal (view / un-ignore / clear all).
- 13 tests: pure logic, DB seam, gate (block/bypass/fail-open),
  route wiring, and the success-cleanup-does-not-ignore regression.
2026-06-15 22:50:39 -07:00
dev
b928f4df43 fix(downloads): always surface all unverified history on Downloads page
Adds a dedicated `get_library_history_unverified()` DB query that fetches
every library_history row with verification_status IN ('unverified',
'force_imported') with no recency cap. This is loaded unconditionally in
`build_unified_downloads_response` — not gated on `len(items) < limit` —
so historical unverified entries are never buried by a busy batch filling
the 200-row general limit, and entries from weeks/months ago aren't lost
in the 50-row recency-ordered history tail. Adds idx_lh_verification_status
for query performance and two regression tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:46:26 +02:00
dev
949070ce73 fix(quality): make "audiophile" preset truly 24-bit-only (no 16-bit/MP3)
The audiophile preset (fallback_enabled=False) still shipped a "FLAC 16-bit"
target in its ladder, so 16-bit FLAC matched and imported even though the name
implies hi-res-only. Split the ladder: audiophile now uses a strict 24-bit FLAC
list; balanced keeps 24-bit + 16-bit + MP3. Gives users a one-click strict
"24-bit only" profile that actually rejects 16-bit/lossy.

Not a matches_target bug — that correctly rejects 16-bit vs a 24-bit target;
the leak was the preset's target LIST including 16-bit (+ fallback accepting
off-list lossy like MP3-128).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:44:46 +02:00
dev
d484046523 feat(downloads): best-quality search mode + clearer fallback UI
Adds an opt-in search strategy toggle in the Quality Profile:

- priority (default): unchanged — first source in the hybrid chain that
  meets a quality target wins.
- best_quality: pool candidates from EVERY source per query and download
  them best→worst by actual audio quality; source order only breaks ties.

Implementation reuses existing plumbing so the retry system is untouched:
- engine.search_all_sources pools raw tracks across all configured,
  non-exhausted sources (no first-source short-circuit).
- candidates.order_candidates: new quality_first sort path — profile
  quality rank dominates, confidence/peer signals break ties. Priority
  path is byte-for-byte unchanged (regression-locked by tests).
- task_worker passes quality_first + targets through; skips the redundant
  hybrid-fallback block in best-quality mode (pool already covered it).
- Per-source retry budgets unchanged: a source that spends its budget is
  added to exhausted_download_sources and thus dropped from the whole
  pool. Independent of post_processing.retry_exhaustive.
- Query generator NOT touched.

Also clarifies the "Allow fallback" setting wording: it accepts OFF-LIST
quality as a last resort (not "walk down my list"), and notes that
lossy_copy.downsample_hires also bypasses the quality gate — the cause of
16-bit/MP3 files slipping through a 24-bit-only profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:46:01 +02:00
dev
62d5821d26 fix(downloads): unverified review actions always load + show real quality on completed
Unverified review actions (play/audit/approve/delete) only rendered for
persistent-history rows, so a freshly-completed unverified download — still a
live task without a 'history-<id>' task_id — showed no buttons until it aged
into history (Quarantine always worked because it uses the quarantine entry
id). Thread the library_history row id from import through to the live task
(add_library_history_entry now returns lastrowid -> context._history_id ->
task.history_id -> /api/downloads/all), and resolve verifHistoryId from it.

Also surface the real probed audio quality (mutagen-read from the file, e.g.
'FLAC 24bit') on completed rows as a chip, so you can see what was actually
downloaded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:11:17 +02:00
BoulderBadgeDad
afa07690f5 Find & Add: match a Spotify 'Title - Remix' query to the base-titled library track
wolf's report: Spotify shows 'Calma - Remix', Find & Add searches that literal
string, but the library stores the track as just 'Calma' (only the 3:58 duration
marks it the remix). The literal LIKE '%calma - remix%' misses, so it fell to the
OR-fuzzy fallback which floods on the common word 'remix' (20 unrelated '... remix'
hits). Dropping '- Remix' (searching 'Calma') finds it instantly.

Fix: search_tracks (and api_search_tracks) now retry on the BASE title — the part
before Spotify's ' - ' version separator — BEFORE the OR-fuzzy flood. So
'Calma - Remix' resolves to 'Calma' (or 'Calma (Remix)') and the noise fallback is
never reached when the base matches. New core.text.title_match.base_title_before_dash
(splits the first spaced ' - '; leaves bare hyphens like 'Up-Tight' alone).

Tests: pure helper (3) + real-DB integration reproducing the Calma case, the
parenthesized-remix variant, plain-title-unaffected, and no-flood (4). 64
search/match tests green.
2026-06-13 16:55:33 -07:00
nick2000713
2c91af0062 feat: global AudioQuality model with post-download quarantine + retry
Replaces the Soulseek-only bit-depth heuristic with a source-agnostic
quality system that works across all download sources.

## core/quality/model.py (new)
- AudioQuality dataclass: format, bitrate, sample_rate, bit_depth
- QualityTarget: one ranked entry in the user's priority list
- filter_and_rank(): source-neutral candidate ranking
- rank_candidate(): scores any AudioQuality against ranked_targets
- v2_qualities_to_ranked_targets(): migration helper

## core/download_plugins/types.py
- SearchResult gains sample_rate + bit_depth fields
- audio_quality property returns unified AudioQuality
- AlbumResult gets audio_quality aggregated from tracks

## core/soulseek_client.py
- Parses slskd attributes array (type 4=sample_rate, type 5=bit_depth)
- Real values instead of kbps heuristic
- filter_results_by_quality_preference() replaced by filter_and_rank()

## database/music_database.py
- Quality profile v3 with ranked_targets list
- Auto-migration v2 → v3 on load
- Presets (audiophile/balanced/space_saver) updated to v3

## core/imports/file_ops.py
- probe_audio_quality(): reads actual downloaded file via mutagen
  returns AudioQuality with ground-truth values

## core/imports/guards.py
- check_quality_target(): replaces check_flac_bit_depth
  checks all formats/sources against ranked_targets
- check_flac_bit_depth() kept as backwards-compat wrapper

## core/imports/pipeline.py
- Uses check_quality_target() instead of check_flac_bit_depth()
- Quality mismatch triggers _requeue_quarantined_task_for_retry('quality')
  so next-best candidate is tried before failing (same as AcoustID)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:22:08 +02:00
BoulderBadgeDad
6e7fd3ff5c M3U export: resolve paths via one bulk read instead of a per-artist search loop (fixes 'Export M3U hangs forever' under active enrichment/scan DB writes) 2026-06-13 08:55:46 -07:00
BoulderBadgeDad
651b904e92 Watchlist: per-artist 'auto-download' toggle (follow-only) — off = discover/surface releases but skip the wishlist add; default on 2026-06-13 08:07:20 -07:00
BoulderBadgeDad
ba5d62946a Mirrored playlists: custom name alias (overrides display + sync name, survives upstream refresh) — card rename button like the source-ref editor 2026-06-13 00:23:56 -07:00
BoulderBadgeDad
47889387ad Playlists: resolve synthetic mirrored batch refs (youtube_mirrored_<pk>/auto_mirror_<pk>) to PK 2026-06-12 17:27:35 -07:00
nick2000713
bf5affd03c resolve merge conflict in style.css 2026-06-11 18:21:04 +02:00
BoulderBadgeDad
fece771dd0 Security UI: show saved login password / recovery question state
After saving a password or recovery question, a refresh made the section look
unset (passwords are never echoed back to the browser), so it seemed like you had
to redo it. Now the saved state is reflected:

- "✓ A login password is set" appears when the admin has a password; the field
  becomes "Enter a new password to change it".
- "✓ Recovery question saved: <question>" appears, the saved question is pre-
  selected (preset or custom), and the answer field becomes "Enter a new answer to
  change it".
- Shown both on load (applyLoginSavedState from /api/profiles, which now includes
  recovery_question — not secret, already shown on the sign-in screen) and
  immediately after saving.

64 integrity tests pass.
2026-06-10 22:46:15 -07:00
BoulderBadgeDad
2bb9bc1357 Settings: reorganize Security into clear groups with visible prerequisites
The security section had grown into a flat pile of toggles with hidden
dependencies. Regrouped into three labelled cards so it reads top-to-bottom:

- 🔑 Lock with a PIN — set PIN (Step 1) → Require PIN
- 👤 User accounts (login) — Step 1 admin password → Step 2 recovery question →
  Step 3 Require login. The Step 3 toggle is now visually LOCKED (greyed +
  disabled + "set the admin password first" hint) until an admin password exists,
  so the anti-lockout rule is obvious instead of surfacing as a 400 on save. It
  unlocks the moment the password is saved.
- 🌐 Reverse proxy & remote access — the proxy toggle, with the auth-proxy header
  nested under it (indented), plus WebSocket origins.

- get_all_profiles/get_profile now expose has_password + has_recovery so the UI
  can reflect setup state; updateRequireLoginGate() drives the lock.
- New .security-subgroup/.security-subhead/.security-nested/.security-locked CSS.

All IDs + handlers preserved. Inert unless used; default install unaffected.
64 script-split integrity tests pass.
2026-06-10 22:38:10 -07:00
BoulderBadgeDad
613688a9ad Login recovery (DB + backend): security question to reset a forgotten password
Closes the forgot-login-password gap. A per-profile recovery question + answer lets
a locked-out user reset their own password.

- DB: additive recovery_question + recovery_answer_hash columns (idempotent
  migration). set/get-question/verify/has methods; answer is hashed (pbkdf2) and
  matched forgivingly (trim + lowercase + collapse whitespace). No recovery set →
  never verifies.
- Endpoints (allowlisted in the login gate so they work pre-auth):
  GET /api/auth/recovery-question?username= (generic 404 when absent),
  POST /api/auth/recovery-reset {username, answer, new_password} — brute-force
  limited; a correct answer sets the new password + authenticates the session.
  POST /api/profiles/<id>/set-recovery (admin or self) to configure it.

Tests: set/get/verify, forgiving match, hashed-not-plaintext, no-recovery-never-
verifies, full reset flow (wrong answer rejected + password intact; correct answer
resets), unknown-user 404. 25 tests pass. Next: the Settings + login-screen UI.
2026-06-10 22:24:54 -07:00
BoulderBadgeDad
8e1b678d6f Native login (increment 1/3): per-profile password DB layer
Opt-in username/password login — profiles become real accounts. This is the data
layer: a per-profile login password, kept SEPARATE from the quick-switch PIN
(different security purpose; a 4-digit PIN must not become the password guarding a
public instance).

- Additive migration: profiles.password_hash column (idempotent, metadata-flagged).
- set_profile_password / verify_profile_password / profile_has_password /
  get_profile_by_name (the login username = profile name, unique + case-insensitive).
- Security default: a profile with NO password is NOT loginable (verify returns
  False) — unlike the PIN where "no PIN = always valid". You can't authenticate to
  an account with no credential.

Tests: migration adds the column; set/verify; no-password-never-loginable; clearing;
name lookup; and password is fully independent of the PIN. 6 tests pass. Next:
the login endpoint + require_login gate (increment 2).
2026-06-10 21:57:44 -07:00
BoulderBadgeDad
27d738e7b1 Fix: Find & Add library search buried exact matches (case-sensitive ordering)
Reported via Find & Add (Billie Eilish "bad guy"): the track was in the library
and on Plex, but never showed in the modal's 20 results. Root cause (proven
against the real 307k-track DB): the search did `ORDER BY tracks.title`, which is
case-SENSITIVE in SQLite (BINARY collation sorts 'B' before 'b'). Billie's title
is lowercase "bad guy"; everyone else's is "Bad Guy", so all the capitalised ones
sorted first, filled the LIMIT, and her exact match landed at ~#25 — cut off.

- search_tracks now ranks by relevance: exact title match first (case-insensitive
  via unidecode_lower), then prefix, then alphabetical — so an exact match can't
  be sorted below the limit by a capital letter. Helps every caller.
- Added a rank-only `rank_artist` hint (never filters): Find & Add already knows
  the source track's artist, so it now passes it and the exact title+artist match
  floats to #1. Filtering was deliberately avoided — if the track is tagged under
  a slightly different artist on the server, a filter would re-hide it.

Verified on the real DB: title-only "bad guy" now surfaces Billie at #4 (was
>#20); with the artist hint she's #1. Seam tests: lowercase exact title isn't
buried; rank hint floats the match without filtering; exact title beats a
superstring title. 10 tests pass.
2026-06-10 17:23:13 -07:00
dev
97b40cbd43 feat(verification): review queue — listen/compare/approve/delete unverified downloads
- ⚠ Unverified filter rows gain actions: inline play (range-streamed from the
  history file path, server-side only), YouTube compare, Approve -> new
  human_verified status (tag + history + tracks; AcoustID scanner skips these
  entirely), Delete (file + entry)
- API: /api/verification/<id>/stream|approve|delete (path only from DB row)
- backfill: history rows with acoustid_result='fail' that exist at all were
  imported despite the failure = force_imported (covers pre-fix fallback
  imports like the user's 'My Ordinary Life')

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:28:31 +02:00
dev
41536384c3 fix(verification): persist status on ALL pipeline success exits + history backfill
The pipeline has three success exits (simple download, playlist folder mode,
main) but only the main one persisted the verification status — force-imported
playlist tracks got no tag, no history status, and never appeared in the
Unverified filter. Extracted _persist_verification_status() and call it at
every exit. One-time idempotent backfill derives status for existing history
rows from their recorded acoustid_result (pass->verified, skip->unverified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:28:31 +02:00
dev
2a11dc961a feat(verification): persist status into library_history, badge on Downloads completed list
The persistent Completed list is built from library_history (not live tasks),
so the badge never showed after a session ended. Column added (additive),
written at import, passed through _build_history_download_item, rendered by
_adlVerifBadge next to the status label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:28:31 +02:00
dev
8e6820dbdf feat(verification): status vocabulary, DB column, SOULSYNC_VERIFICATION tag
Also: evaluate() treats an empty expected artist as title-only comparison
(old scanner behaviour — a missing DB artist is no evidence of a wrong file),
and the thresholds are now defined once in the core and re-exported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:27:29 +02:00
BoulderBadgeDad
60b9fe10e9 Profiles: per-profile Tidal self-auth (playlists) — with a safe token-save redirect
Second service. Each profile connects its own Tidal; its playlist reads use that
account, everything else stays global. The gotcha vs Spotify: TidalClient loads
AND saves tokens to one global slot (tidal_tokens), so a naive per-profile client
would clobber the admin's tokens on refresh.

- get_tidal_client_for_profile builds a dedicated TidalClient seeded with the
  profile's tokens, refreshed via the shared/global app creds, and OVERRIDES its
  _save_tokens to persist to the PROFILE row — never the global slot. Admin
  (profile 1) + unconnected profiles use the global client unchanged. Cached per
  profile + evicted on (dis)connect.
- DB: set_profile_tidal_tokens / get_profile_tidal (encrypted); the OAuth callback
  now uses them + evicts the cached client.
- Wired the Tidal playlist reads (list + tracks) to the per-profile client; the
  module import line left intact.
- My Accounts: Tidal row (Connect via /auth/tidal?profile_id=, status, Disconnect).
  Connections API extended; disconnect made generic (/<service>/disconnect).
  Admin sees "managed in Settings" for every service.

Tests: per-profile token refresh writes to the profile and leaves the global
tidal_tokens untouched (the safety guarantee); connect status + disconnect;
admin/unconnected → global client. 22 endpoint tests pass.
2026-06-10 13:11:59 -07:00
BoulderBadgeDad
daee96f814 Profiles Phase 0: service-credential-sets foundation (data + resolver, dormant)
Groundwork for admin-created, per-profile-switchable credential sets ("pills")
across auth services (Spotify/Tidal/Deezer/Qobuz/Plex/Jellyfin/Navidrome).
Strictly additive and dormant — nothing reads it at runtime yet, so zero
behaviour change for existing installs.

- core/credentials/store.py: pure service registry + payload validation +
  stale-safe active-set selection (pick_active_credential falls back to None
  when a selected set was deleted, so a profile never breaks).
- migration service_credentials_v1: two new tables — service_credentials
  (admin-created named sets; payload Fernet-encrypted at rest) and
  profile_service_credentials (each profile's selected set per service).
- MusicDatabase CRUD: create/update/delete/list/get_service_credential
  (list never returns the payload; get decrypts for the resolver), plus
  set/get_profile_service_credential and resolve_profile_service_credential
  (returns the profile's active payload or None → caller uses global default).

Tests: 12 — pure validation + stale-safe selection, and real-temp-DB storage
proving encryption round-trips, payload never lists, dup(service,label)
rejected, per-profile/per-service resolution, and delete clearing dangling
selections to a clean fallback. 95 migration/DB tests still pass.
2026-06-10 00:27:48 -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
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
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
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
696119d5ac Expired Download Cleaner: retention-based cleanup of watchlist/playlist downloads (Boulder)
A Library Maintenance job that cleans up downloads tracked by Download Origins
once they pass a per-origin retention window — findings by default, opt-in
auto-delete.

A download is only ever proposed for deletion when ALL hold: older than its
origin's retention, NOT still in an actively-mirrored playlist / watched
artist, and played fewer than the keep-threshold (default 2 → "played more
than once is kept"). Only touches downloads recorded from the Download Origins
feature forward — never pre-existing or manual library.

- core/library/expired_cleanup.py: pure decision core (retention_cutoff,
  is_expired, select_expired) — no DB/clock, fully tested. play_count is the
  reliable listen signal (last_played is often unpopulated, so recency isn't
  used).
- ExpiredDownloadCleanerJob: gathers facts (play_count via a new
  get_origin_cleanup_candidates join; active-mirror via get_mirrored_playlists;
  watch via get_watchlist_artists) and either creates 'expired_download'
  findings or, with auto_delete on, deletes in-scan. Default OFF, both
  retentions default 'off'. Settings auto-render in the Library Maintenance
  panel (same as Cover Art / Lyrics / Re-tag).
- delete_origin_download(): shared delete (resolve path → remove file → drop
  track row → drop history row); a file that won't delete keeps its row +
  reports. Used by auto mode AND the _fix_expired_download apply handler.
- Frontend: type/action ('Delete')/result labels + finding detail render.

Tests: 9 on the pure brain (windows, off, per-origin, protected, play-count
threshold, bad age) + 7 on the job (no-op when off, findings, mirror/watch
protection, auto-delete, delete helper missing/real file). 185 repair/origin
tests pass.
2026-06-07 22:06:56 -07:00