Commit graph

1749 commits

Author SHA1 Message Date
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
cc7d48b736 Release 2.7.4: bump version + What's New / version modal + PR description
Bumps _SOULSYNC_BASE_VERSION to 2.7.4 and refreshes the What's New panel +
version-modal highlight reel to the 2.7.4 set (re-identify #889 headline; #890
title-strip; #891 residual-folder cleanup; #886 AAC tier; #887 Spotify Free
status; #884 NZBGet; #885 tz; Sokhi import-cleanup batch), with 2.7.3 rolled into
the brief 'earlier versions' summary per the current-release-only convention.
2026-06-18 20:44:53 -07:00
BoulderBadgeDad
1ccc7b5e15 #889: fix re-identify modal header — clip the blurred bg in its own layer
The hero's overflow:hidden was clipping the header content, but removing it let the
blurred background + overlay cover (and steal clicks from) the source tabs below.
Move the decoration into .reid-hero-decor — an absolutely-positioned clip layer
that contains the blur and is pointer-events:none — so the header content (a sibling,
never clipped) shows in full AND the tabs stay clickable.
2026-06-18 16:57:24 -07:00
BoulderBadgeDad
c4c112d17e #889 Phase 5: wire the Re-identify button into the Enhanced library view
Adds a per-track ⇄ action (admin-only, alongside source-info/redownload/delete)
that opens the Re-identify modal seeded with the track's title/artist/album/art.
The loop is now live: click ⇄ → pick a release → file stages + hint writes →
auto-import re-files it under the chosen single/EP/album (and replaces the old
entry on success when 'replace' is ticked).

Double-gated: the button only renders for admins, and /api/reidentify/apply
re-checks is_admin server-side.
2026-06-18 15:39:41 -07:00
BoulderBadgeDad
f4c16ecc22 #889 Phase 4: the Re-identify modal + apply backend
The showpiece: a focused 'which release does this track belong to?' chooser.
Source tabs (default active), pre-seeded search, the same song surfaced across
single/EP/album with color-coded type badges, ISRC-ranked, replace-original
toggle (on by default). Glassy panel, blurred hero art, shimmer/spinner states,
hover-lift result cards — matched to the app's modal language.

Backend:
- core/imports/rematch_apply.py: pure staged_destination + build_reidentify_hint,
  injectable stage_file_for_reidentify (COPIES the file, never moves — original
  safe until re-import succeeds). 6 tests.
- POST /api/reidentify/apply (admin-only): resolve_hint_fields → stage file →
  create_hint → nudge the worker. Replace deletes the old row only on success.

Frontend: modal markup (index.html), full stylesheet (style.css), and the
openReidentifyModal/search/select/confirm flow (library.js). Not yet reachable
from a button — Phase 5 wires it.
2026-06-18 15:37:56 -07:00
BoulderBadgeDad
70ea7eabf6 Update style.css 2026-06-18 15:05:14 -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
56da2e105c #887: Spotify enrichment shows 'Running (Spotify Free)' for no-auth users, not 'Not Authenticated'
radoslav-orlov: with no Spotify auth, enrichment runs on the no-creds Spotify Free
source (prefer-free is on by default) and IS working — pending drains, the modal
shows RUNNING — but the dashboard header tooltip said 'Not Authenticated /
Connect Spotify in Settings'. Two causes:

- get_stats() only set using_free for the rate-limit / spent-budget bridges, not
  the plain no-auth-default-free case. The loop already computes the right signal
  (free_serving = _free_active(), True here) but it was a local var. Cache it on
  self each iteration and report it as using_free (no auth API call in the 2s
  status loop).
- The dashboard's Spotify updater checked notAuthenticated BEFORE bridgingFree, so
  even with using_free it showed Not Authenticated. notAuthenticated now excludes
  the bridging-free case; the LastFM/Genius/Tidal/Qobuz updaters (no free path)
  are unchanged.

5 seam tests for get_stats free/auth reporting; 67 enrichment/free tests pass.
2026-06-18 13:00:33 -07:00
BoulderBadgeDad
820ff20139 Settings UI: 'Match singles to their parent album' toggle (Library > Post-Processing)
Surfaces metadata_enhancement.single_to_album as a checkbox in the Post-Processing
> Core Features section, next to the cover-art settings (it's about getting the
right album cover). Default OFF, wired like the replaygain toggle (load '=== true',
save raw .checked) since the generic data-config binding defaults a missing key to
ON. Registered the default in settings.py DEFAULT_CONFIG + config.example.json.
2026-06-18 09:44:17 -07:00
BoulderBadgeDad
6c0d79a84a Release 2.7.3: bump version + What's New / version modal + docker-publish default tag
- web_server.py: _SOULSYNC_BASE_VERSION 2.7.2 -> 2.7.3
- helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS rewritten for 2.7.3
  (current release + rolled-down 2.7.2/2.7.1/2.7.0 summary)
- docker-publish.yml: workflow_dispatch default tag -> 2.7.3

2.7.3 = Quality Upgrade Finder + #867 Tidal discovery + #880/#879/#877/
#876/#874/#870/#868 fixes + the 'Track 01' track-number recovery.
2026-06-16 00:03:57 -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
BoulderBadgeDad
46be97b195 #876: group quarantine alternatives by target track-id + auto-clear siblings on approve
Multiple failed source attempts at one song each land in quarantine as
separate entries. Group them by the *intended* target (sidecar context
track_info isrc -> id -> uri, falling back to normalized artist|title for
legacy thin sidecars) — an exact relationship across siblings, since the
bad files' own tags differ but the target track is constant.

- core: quarantine_group_key() + find_quarantine_siblings() seams; list
  entries now carry group_key.
- approve endpoint: remove_siblings flag auto-deletes the other attempts
  once one is accepted (captured BEFORE approve restores the file out of
  quarantine, or the id lookup would resolve nothing). Scoped to the
  quarantine manager; download-modal chooser + version-mismatch fallback
  pass no flag and are unaffected.
- UI: multi-member groups render as a collapsible parent row (album art +
  'N alternatives'); singletons unchanged. Toast reports removed count.
- 11 tests incl. ordering regression for capture-before-approve.
2026-06-15 22:12:06 -07:00
BoulderBadgeDad
93af95d865 #876: show the real Quarantine tab count on open (not a stale 0)
The Quarantine tab badge was only populated by loadQuarantineList(), which runs
when the tab is clicked — so opening Library History showed a stale 0 until then.
Refresh the count on modal open via the existing /api/quarantine/list endpoint.
2026-06-15 21:37:42 -07:00
BoulderBadgeDad
e7814e0acf #877: Download Discography filters mirror Artist Detail (fix dead EPs + add Live/Comp/Featured)
The Download Discography modal exposed only Albums/EPs/Singles, its EPs toggle did
nothing, and Live/Compilations/Featured were missing — so you couldn't fine-filter
a bulk download the way Artist Detail lets you browse.

Root cause: the modal's endpoint (/api/artist/<id>/discography) used the base
get_artist_discography, which lumps EPs into singles, and the modal only read
{albums, singles} — so the EPs bucket was always empty (dead toggle). It also had
no content-type (Live/Compilation/Featured) classification at all.

- Backend: the endpoint now uses get_artist_detail_discography — the SAME split
  Artist Detail uses — and returns a separate `eps` list.
- Frontend: read `eps`; tag each card with data-is-live/compilation/featured via a
  new shared _classifyReleaseContent() (also adopted by the Artist Detail cards so
  the two can't drift); add Live/Compilations/Featured filter buttons; combined
  category+content filtering. The download payload is built from VISIBLE checked
  cards, so every toggle now actually changes what downloads.
- Regression test: get_artist_detail_discography splits an EP into the eps bucket.
2026-06-15 21:30:55 -07:00
BoulderBadgeDad
f2f0f5d849 Sidebar UI: frosted-glass header blur, centered nav badges, admin cleanup
- .sidebar-header: real frosted-glass blur of content scrolling behind it —
  made the background translucent (was an opaque base layer), added
  backdrop-filter blur, and raised the header above the nav (z-index) so nav
  items actually sit in its backdrop.
- .dl-nav-badge: vertically centered on the right (top:50% + translateY) instead
  of pinned to the top-right corner.
- Removed border-top-right-radius from .sidebar and .sidebar-header (square top).
- Hide the "My Accounts" + "My Settings" header buttons for admin profiles —
  both are inert for admin (every service is "Managed in Settings", and My
  Settings is an empty pointer note); kept for non-admins who get real UI.
2026-06-15 21:16:18 -07:00
BoulderBadgeDad
02d6af29ed #879: a failed settings load must never overwrite the saved config
Reported by @Lysticity: opening Settings reset the whole config to defaults. The
chain: GET /api/settings 500s (their env: ConfigManager missing redacted_config)
-> loadSettingsData() called response.json() WITHOUT checking response.ok, so the
error body {"error": ...} was treated as settings -> every field populated as
`settings.x?.y || ''` blanked to defaults -> autosave then wrote those defaults
over the real config.

Fix (settings.js): bail BEFORE touching any field when the response isn't ok / is
an error body, set window._settingsLoadFailed, and guard BOTH save paths
(debouncedAutoSaveSettings + saveSettings) on it. The flag clears on the next
successful load. So any load failure (500, lock, network) now leaves the saved
config untouched instead of wiping it.

The redacted_config method exists in all 2.7.x source + on dev (their 500 looks
like a stale/mismatched build), but the UI must not destroy config on ANY failed
load. Regression test pins redacted_config stays a callable method on the class
(its removal is exactly what 500s the endpoint).
2026-06-15 20:09:53 -07:00
dev
8bb749de9c feat(import): master toggle for quality-filtering on import + collapsible tile
Answers "does import respect quality?": yes — the pipeline already runs the
quality gate (check_quality_target) BEFORE AcoustID and quarantines files that
don't meet the profile (unless fallback/downsample is on). This adds an explicit
user switch over that behaviour.

- New config import.quality_filter_enabled (default True). When False,
  check_quality_target returns None early so EVERY file imports regardless of
  quality; the file is still probed and the library Quality Upgrade Scanner
  still flags below-profile tracks. Default preserves current behaviour.
- Settings → Library: the Import Settings group is now a collapsible tile
  (same pattern as Post-Processing) and gains the "Only import tracks that meet
  your quality profile" toggle at the top, alongside replace-lower-quality and
  folder-artist-override.
- settings.js populate/collect the new key; config schema default added.
- Tests: key-aware config stub (a blanket-False mock would wrongly disable the
  filter) + a new test pinning toggle-OFF = accept below-target file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:29:29 +02:00
dev
5b3061ee2e feat(quality): library quality check as a findings repair job
Replaces the wishlist-only Quality Scanner with a proper Library Maintenance
job that produces actionable findings — same model as the AcoustID/orphan
tools, per user request ("mach ein finding wie jedes anderes Tool").

- New core/repair_jobs/quality_upgrade_scanner.py: iterates DB library
  tracks, resolves each path via the shared resolver (now index-0 correct for
  relative library paths), probes REAL audio quality with the same
  probe_audio_quality the download import guard uses, and checks it against the
  user's v3 ranked targets via quality_meets_profile (strict — no extension
  guessing, no fallback). Below-profile tracks become 'quality_upgrade'
  findings with current vs target quality in details.
- repair_worker._fix_quality_upgrade: redownload (wishlist + delete file/row),
  delete (file + row), or ignore (dismiss in UI). Registered in _execute_fix
  dispatch + bulk fixable_types.
- Frontend (enrichment.js): 'Low Quality' type label, 'Upgrade' fix button, a
  3-way _promptQualityUpgradeAction modal (Re-download / Delete / Ignore),
  wired into both single-finding fix and bulk-fix (Ignore → dismiss inline).
- Tools "Quality Scanner" button now triggers Run Now of this job and points
  the user to Library Maintenance → Findings.

The old standalone /api/quality-scanner endpoints are left intact (unused by the
button) to avoid churn. Verified: job registers, fix handler dispatches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:03:24 +02:00
dev
cf8f562e61 feat(quarantine): show real audio quality in the review UI + path-resolve diag
- list_quarantine_entries now surfaces the probed quality (context._audio_quality,
  recorded before the gates) so each quarantine row shows what the file actually
  is when deciding to approve/delete. Rendered as a quality chip in the review UI.
- _resolve_library_file_path logs the searched base dirs once when it can't
  resolve a path, so a remaining mount/path mismatch is diagnosable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:53:52 +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
dev
b6be680d23 fix(imports): detect truncated downloads (real audio shorter than container)
The actual HiFi/Monochrome bug isn't silence padding — it's a TRUNCATED file:
the container claims the full length (e.g. 3:08) but only ~30s of audio
decodes. silencedetect finds nothing (there's no silent audio, just missing
audio) and ffmpeg's time= even reports 0 with no error, so the duration and
quality guards all pass.

Detect it by decoding and comparing the real audio length (astats sample
count / sample rate) against the container duration: reject when the real
audio covers < 85% of the claimed length. detect_broken_audio() runs this
truncation check first, then the silence-ratio check. Wire it into the guard
that runs at the integrity/length verification point.

Verified on the real file: 'only ~30s actually decodes of a 188s file (16%)';
a normal 180s file is not flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:14:11 +02:00
dev
c32fe219fe fix(imports): silence guard catches mostly-silent preview/truncated files
HiFi/Monochrome HLS assembly can produce a file with the correct container
duration but only ~30s of real audio + silence padding — the duration and
quality guards both pass, so nothing caught it until you listened. Add
core/imports/silence.py: ffmpeg silencedetect over the audio, reject when the
silent fraction exceeds 50%. Wire it into the post-download pipeline with the
same quarantine + next-candidate retry pattern as the quality guard
(trigger='silence'), and surface it via import_rejection_reason. Fails open
when ffmpeg/mutagen are unavailable so tooling problems never quarantine a
legit file.

Also mark 'quality filter' and 'silence guard' failures as recoverable
quarantine rows in the downloads UI (were shown as plain failures).

Verified end-to-end: a 30s-tone + 180s-silence FLAC is flagged '86% silence
(only ~30s audible of 210s)'; a 210s tone passes. 7 parser unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:35:21 +02:00
dev
fe78a3cdc3 feat(quality): derive per-source download tier from the global profile
Remove the per-source download-quality dropdowns (Tidal/HiFi/Qobuz/Deezer/
Amazon) — with the global ranked-targets system they were redundant and
conflicting. Add quality_tier_for_source(): picks the LOWEST source tier
that satisfies the user's top target (respects the quality ceiling, saves
bandwidth) or the source's max as best effort. Every source's search +
download + retry path now derives its tier from the global profile instead
of config_manager.get('<source>_download.quality').

Settings keep the per-source allow_fallback toggles; the quality selects are
replaced with a note pointing at Quality Profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:18:01 +02:00
dev
6046a814cb fix(ui): un-gate global quality profile; make unverified rows clickable
Quality Profile is now a global system driving every source, so stop hiding
it behind Soulseek being active — show it on the downloads tab regardless.
On the review queue, make Unverified rows row-clickable to open the audit/
info modal (matching Quarantine rows, which were already clickable); the
action buttons stopPropagation so they don't double-trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:02:22 +02:00
dev
e0c55342bc feat(quality): v3 ranked-targets UI editor (drag-to-reorder)
Replace the v2 per-tier quality UI (FLAC on/off + MP3 sliders + bit-depth
buttons) with a draggable ordered target list. Each row shows its rank +
label with move/delete; an add form picks format and, for lossless, bit
depth + min sample rate, or for lossy a minimum bitrate threshold (>=) so
VBR/mono files aren't falsely rejected. Persists v3 ranked_targets via the
existing /api/quality-profile. Presets + fallback toggle retained; help
text and tooltip rewritten for the new top-down source-gating model.

Verified: v3 profile round-trips UI shape -> DB -> load_profile_targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:41:38 +02:00
BoulderBadgeDad
09b97c5f63 #870: Deezer ARL 'resets itself' — test the SAVED token, not the redaction mask
The Deezer ARL field round-trips a redaction sentinel for a saved-but-untouched
secret (shown as dots). The save path already guards against the sentinel
overwriting the real token (ConfigManager.set), so the ARL was never actually
lost — but the connection TEST read the field value and sent the sentinel as the
token, so Deezer returned USER_ID=0 ('Invalid ARL token') after navigating away
and back. That false failure made it look like the ARL kept resetting.

Fix:
- ConfigManager.resolve_secret(key, posted): empty/sentinel posted value -> the
  stored value; a real string -> a genuine new secret. Reusable for any secret
  connection-test (single source of truth).
- /api/deezer-download/test now resolves the effective ARL via resolve_secret, so
  an untouched field tests the stored token.
- testDeezerDownloadConnection() strips the sentinel before sending (untouched ->
  empty -> backend uses the saved token).

Seam/regression tests for resolve_secret (sentinel/empty/none -> stored, real ->
passthrough, nothing stored -> empty). JS integrity 64 green.
2026-06-13 15:37:29 -07:00
BoulderBadgeDad
b393866782 Remove old auto-acting Quality Scanner tool (replaced by Quality Upgrade Finder job)
Phase 2 of the redesign. The tool that judged quality by extension and auto-dumped
matches into the wishlist is gone; quality scanning is now the reviewed
quality_upgrade repair job.

Removed:
- Frontend: Tools-page Quality Scanner card, its JS handlers/poller/socket listener,
  help tooltip + tour entry (webui index.html, core.js, helper.js, wishlist-tools.js).
- Backend: /api/quality-scanner/{start,status,stop} endpoints, the in-memory state +
  executor + 1s socket broadcast, the QualityScannerDeps/run_quality_scanner shim.
- core/discovery/quality_scanner.py: the auto-acting worker + deps class (the shared
  match/normalize helpers stay — the new job imports them).

Rewired:
- Automation 'start_quality_scan' action now triggers the quality_upgrade repair job
  via repair_worker.run_job_now() (AutomationDeps gains run_repair_job_now, drops the
  4 scanner fields). Action block's vestigial scope field removed (scope lives in the
  job's settings now). NOTE: the 'quality_scan_completed' trigger no longer fires (the
  repair job doesn't emit it).
- Updated all automation test _build_deps helpers + conftest tool-progress harness;
  deleted the obsolete worker test. 528 affected tests pass; 6123 collect cleanly.

QUALITY_TIERS / _get_quality_tier_from_extension kept (used elsewhere).
2026-06-13 12:14:45 -07:00
BoulderBadgeDad
ce92828290 #867 UX: open Tidal discovery modal in 'discovering' phase so the empty/loading modal isn't interactable
When the modal opens instantly (before data loads), it was rendered in the
'fresh' phase — showing clickable Start Discovery / Wing It buttons over an empty
table, even though discovery is already auto-starting. Open it in 'discovering'
instead: the footer becomes the non-interactive 'Discovering matches…' info line
and the progress text reads 'Starting discovery…' instead of 'Click Start
Discovery to begin…'. Only Close stays clickable while the table loads.
2026-06-13 11:03:40 -07:00
BoulderBadgeDad
ecc07c6811 #867 UX (real fix): render Tidal discovery modal BEFORE the blocking discovery-start POST
The prior UX commit removed a redundant frontend pre-fetch, but the modal was
still only opened at the END of openTidalDiscoveryModal — AFTER awaiting
/api/tidal/discovery/start, whose backend handler fetches the whole playlist
synchronously (Tidal sleeps 1s/page, ~10s) before responding. So the modal still
didn't appear for ~10s. Now open the modal first (with a 'Loading playlist from
Tidal…' note), then fire the discovery-start POST and begin polling; return early
so the shared open at the bottom is skipped for this path.
2026-06-13 10:58:29 -07:00
BoulderBadgeDad
77829622a7 #867 UX: open Tidal discovery modal instantly instead of blocking ~10s on a track pre-fetch
Clicking Discover on a fresh Tidal card awaited /api/tidal/playlist/<id> (which
paginates Tidal with a 1s sleep per page + rate-limit throttle, ~10s for a large
playlist) BEFORE opening the modal — and the backend discovery worker then
re-fetched the same playlist anyway. Now that the modal builds its rows from the
backend discovery results (#867), open it immediately and let discovery populate
it: no blocking pre-fetch, no redundant double-fetch of the playlist.
2026-06-13 10:41:42 -07:00
BoulderBadgeDad
846a9c75a0 #867: Tidal playlist discovery shows all tracks (was capped to ~21)
Two issues in the same path:
1. The shared discovery modal pre-renders one row per track from a
   separately-fetched frontend track list, then the poll dropped any backend
   result without a pre-rendered row (if (!row) return). When the frontend's
   track fetch came back rate-limited/partial (~21) while discovery's own fetch
   got all 59, the surplus results vanished. Now the modal CREATES a row for any
   result lacking one, so authoritative backend results drive the list (fixes
   all sources sharing the modal).
2. get_playlist hydrated a whole relationships page in one _get_tracks_batch
   call, but Tidal caps filter[id] at 20/request, silently truncating larger
   pages. Chunk to the cap like get_album_tracks already does.

Seam + regression tests (tests/test_tidal_playlist_batch_chunking.py).
2026-06-13 10:39:30 -07:00
BoulderBadgeDad
c7ca657d56 Release 2.7.2: bump version + What's New / version modal + docker-publish default tag
Single source of truth _SOULSYNC_BASE_VERSION -> 2.7.2 (drives UI, system-info,
update check, backup metadata). docker-publish workflow_dispatch default tag -> 2.7.2.
WHATS_NEW + VERSION_MODAL_SECTIONS rewritten for 2.7.2 (current release + brief
earlier summary, per convention).
2026-06-13 10:16:35 -07:00
BoulderBadgeDad
fb260baa48 HiFi instances: 'Restore Defaults' button (re-adds removed defaults, keeps customs) + bigger tap targets for the ✔/✖ controls (Sokhi) 2026-06-13 09:16:39 -07:00
BoulderBadgeDad
608efb1d85 Server playlists: M3U export now downloads the .m3u to the browser too (was only saving server-side) — matches the other Export-as-M3U buttons 2026-06-13 08:35:02 -07:00
BoulderBadgeDad
5a16d8ad53 Server playlists: 'Export M3U' button in the compare/editor toolbar — exports the server playlist's tracks via the shared M3U writer (Music Assistant etc.) 2026-06-13 08:20:18 -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
2428df1144 #857: custom in-container completed-downloads path for Torrent/Usenet sources (settings + UI; resolver already consumed the keys) 2026-06-13 07:15:02 -07:00
BoulderBadgeDad
15067b63ca Mirrored playlists: rename (✏️) button matches sibling buttons' hover-reveal styling 2026-06-13 07:01:18 -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
6366f72b7e #863: YT Artist column falls back to the matched artist when YouTube gave none (both render paths) — no more 'Unknown Artist' on matched rows 2026-06-13 00:06:41 -07:00
BoulderBadgeDad
f5787764d4 #859: DB-update stall watchdog + UI self-heal (no more wedged 'Starting...' / frozen bar) 2026-06-12 19:38:30 -07:00
BoulderBadgeDad
87621b7191 Playlists: Settings UI (path + symlink/copy + rebuild button) + rebuild endpoint
- Settings: 'Playlists Folder' path field (Unlock pattern, separate-root help
  text), a Symlinks/Copies selector, and a 'Rebuild playlist folders now' button
  (standard test-button style). Wired through PATH_INPUT_IDS / load / save, plus
  'playlists' added to the settings save allowlist so it persists.
- POST /api/playlists/materialize/rebuild → rebuild_organized_playlists_from_db:
  rebuilds every organize-by-playlist folder from CURRENT ownership, re-matching
  each track with check_track_exists (name, not IDs) so it self-heals after a
  reorganize / membership change. +1 test.

70 materialize tests + JS integrity pass; settings round-trip wiring verified.
2026-06-12 14:04:34 -07:00
BoulderBadgeDad
550fca0fe5 webui: sync organize-by-playlist toggles + stop dashboard poller 401-spam while locked
- The download-modal 'Organize by Playlist' toggle had no onchange, so flipping
  it never saved or synced the saved per-playlist preference. Add the handler
  (source auto-derived from the ref) so both controls read/write the one
  organize_by_playlist value — manual action persists, the other reflects it.
- loadDashboardSyncHistory polled /api/sync/history every 30s even while the
  launch-PIN/login gate was active, 401-spamming the log. Skip when locked, and
  on a 401 (stale session after a restart) surface the unlock screen so it
  self-heals instead of spamming.
2026-06-12 13:28:24 -07:00
BoulderBadgeDad
cbab4234ef Export: combine watchlist + library into one button with a scope selector
Per feedback — instead of two export buttons (one on the watchlist filter bar, one
in the library header), there's now a single "Export" button. The modal gains a
Watchlist | Library scope toggle at the top; switching scope re-fetches and shows/
hides the "library counts" option (library-only). One place, both rosters.

Also relaxed the two export endpoint wiring tests — they asserted an empty DB,
which is false in a shared test run (the artists table may already hold rows); now
they assert a valid JSON array + headers/columns instead. The endpoints are
unchanged and verified against real data.
2026-06-11 23:10:54 -07:00
BoulderBadgeDad
a789fb71c0 Library export: export the whole library roster too (corruption's request)
Extends the watchlist export to the full library. The exporter is now general
(core/exports/artist_export.py, renamed from watchlist_export) — adds tidal/qobuz
links and an extra_fields passthrough, so the library export also carries
lastfm/genius URLs + soul_id, and an optional "library counts" toggle adds owned
album/track counts per artist.

- GET /api/library/artists/export?format=&links=&contents= — pulls every artists
  row, normalizes onto the canonical *_artist_id keys, optionally GROUP-BY counts
  for album/track totals.
- The export modal is now openArtistExportModal(scope): "Export Library" button in
  the library header + the existing "Export" on the watchlist bar (a thin wrapper).
  Library mode shows the extra "library counts" toggle.

Tests (11): builder across formats + the new tidal/qobuz links + extra_fields
columns; watchlist + library endpoint wiring. 64 integrity green; ruff clean.
2026-06-11 22:59:21 -07:00
BoulderBadgeDad
f8652c106b Watchlist: export the roster to JSON / CSV / text (corruption's request)
An "Export" button on the watchlist filter bar opens a modal (same aesthetic as the
artist DB-record inspector) to export your whole watchlist roster — each artist's
name + source IDs (spotify / musicbrainz / deezer / discogs / itunes / amazon),
with an optional "external links" toggle that adds the discography URLs built from
those IDs. Live preview, copy, and download in the chosen format.

- core/exports/watchlist_export.py: pure builder (json/csv/txt + links, present-IDs
  only, deterministic columns) — the single source of truth, fully unit-tested.
- GET /api/watchlist/export?format=&links= shapes the roster + returns it (with
  X-Export-Count / X-Export-Ext headers for the modal).
- Frontend reuses the DB-record helpers (_jsonSyntaxHighlight / _arecCopy).

Tests (8): builder across json/csv/txt, links on/off, present-ids-only, empty +
bad-format fallback, mime/ext, and endpoint wiring. ruff clean; 64 integrity green.

Scoped to the watchlist for v1; library-wide export + a "library contents"
(owned albums/tracks) option are natural follow-ups.
2026-06-11 22:48:58 -07:00
BoulderBadgeDad
8118a2c6bd Add Empty Folder Cleaner library-maintenance job (corruption's request)
A maintenance job to keep the music library tidy — finds empty folders left behind
by imports/relocations/deletions (empty artist/album dirs, or dirs holding only OS
junk like .DS_Store/Thumbs.db) and removes them.

Safety is the focus (deleting directories is destructive):
- only TRULY empty folders are flagged — a folder with a cover image or any audio
  is never touched; only OS-junk files count as "no real content" (a setting),
- the library root + symlinked dirs are never removed,
- walks bottom-up so a parent left empty by its removable children cascades,
- the apply handler RE-CHECKS emptiness at delete time, so a folder that gained a
  file between scan and apply is left alone.

dir_is_removable + remove_empty_folder are pure/injectable seams. Wired through the
job registry, repair_worker apply handler (_fix_empty_folder), fixable-types, and
the findings UI. Opt-in (default off), weekly interval.

Tests (10): removable decision (empty / real-file / surviving-subdir / junk-only /
strict mode) + apply re-check (removes empty + junk, refuses content/root/symlink).
Repair + integrity suites green; ruff clean.
2026-06-11 22:11:26 -07:00
BoulderBadgeDad
d9cda0c31c Manage Profiles: make the login-password state visible (clarity)
The per-member login password was easy to miss — the lock button had no dedicated
styling and nothing showed who was actually stranded. Now, when login mode is on:

- a banner explains every member needs a login password (+ to use the lock button)
- each member row shows a status pill: "⚠ No login password" (red) or "🔒 Login
  ready" (green) — so you can see at a glance who can't sign in yet
- the lock button is properly styled (it had none) and pulses red when that member
  has no password, so the action you need is obvious

When login mode is off, none of this shows (no noise). Pure UI/clarity — no
behavior change.
2026-06-11 21:10:02 -07:00
BoulderBadgeDad
5b52d579c5 Login mode: enforce "every profile has a password" at every write-point (no gaps)
Invariant: while security.require_login is on, every profile must have a login
password or it's locked out. Previously only the admin's own anti-lockout existed,
so members could be stranded (created without a password, or login flipped on while
passwordless members existed). Closed all the write-points:

core/security/login_provisioning.py (pure policy, single source of truth):
- members_without_password(profiles) — non-admin profiles that can't sign in
- create_needs_password(require_login) / removing_password_strands(require_login)

Wired into web_server:
- create_profile: while login is on, a new member must be given a password (400
  otherwise) and it's set on creation.
- enable-login (settings save): refuses to turn login on while any member lacks a
  password — lists them — same shape as the existing admin anti-lockout.
- set-password: refuses to CLEAR a password while login is on (would strand them).

UI: Create Profile form gains a login-password field (alongside the optional PIN);
the Manage Profiles per-member password button (prior commit) covers existing
members + changes.

Tests: pure policy seam + endpoint enforcement (create blocked w/o password when
on, allowed w/ password, no friction when off, clear blocked when on). 442
profile/settings/auth tests green; ruff clean.
2026-06-11 19:48:50 -07:00