Commit graph

3988 commits

Author SHA1 Message Date
BoulderBadgeDad
31ebe96f76 dev: add --lan flag to dev.py to expose the dev server on the network
Typing SOULSYNC_WEB_BIND_HOST=0.0.0.0 every launch is awkward. `python dev.py --lan`
sets it for you (binds 0.0.0.0 so other devices can reach it); plain `python dev.py`
stays localhost-only. Set before build_backend_env() so it propagates to both the
direct (Windows) and gunicorn backend modes.
2026-06-11 17:29:12 -07:00
BoulderBadgeDad
a8206012ae dev: make the dev-server bind host opt-in via SOULSYNC_WEB_BIND_HOST
The dev gunicorn config hard-bound 127.0.0.1:8008, so the dev server was
unreachable from any other device — even via the host's own LAN IP. Read the bind
host/port from SOULSYNC_WEB_BIND_HOST / SOULSYNC_WEB_BIND_PORT (same vars the
direct web_server.py run already uses), defaulting to the existing 127.0.0.1:8008.

Default behavior is unchanged (localhost-only), so other devs notice nothing.
To reach the dev server from another device on the LAN, opt in explicitly:
  SOULSYNC_WEB_BIND_HOST=0.0.0.0 python dev.py
2026-06-11 17:24:47 -07:00
BoulderBadgeDad
123eb6139f Artist detail: "DB Record" inspector — everything the DB knows about an artist
A small glowing button at the bottom-right of the artist hero (library artists
only) opens a programmer-style modal showing the COMPLETE artists DB row — every
source id + match status, cached bios / tags / similar / urls, soul_id, timestamps,
the lot (62 columns) — plus owned album/track counts.

- Backend: GET /api/artist/<id>/record returns the full row with JSON-text columns
  (genres, aliases, lastfm_tags/similar, discogs_urls, …) decoded into real
  arrays/objects, + album/track counts. 404 for non-library artists.
- Frontend: editor-themed modal (Tokyo-night tokens) with a Fields tab (copyable,
  filterable key/value rows) and a syntax-highlighted JSON tab. Copy-all-as-JSON,
  per-value copy (HTTP/Docker clipboard fallback), and Save .json. Esc / click-out
  to close. Helpers namespaced (_arecEsc) so they can't clobber the shared globals.

Tests: endpoint returns the full row with decoded JSON + counts; 404 for a missing
artist. 64 script-split integrity tests still green; ruff clean.
2026-06-11 16:57:48 -07:00
BoulderBadgeDad
ee4d514d60 CI: silence S110 on three intentional best-effort swallows (unblocks dev build)
The dev-nightly build runs `ruff check .` before "Build and push to GHCR" in the
same job, so the three S110 (try/except/pass) errors introduced since the last
green build (ce6ce4d) failed the lint step and SKIPPED the image push entirely —
every dev-nightly since #704 went red, so the dev image was never rebuilt and none
of the recent fixes (incl. the #852 WebSocket login-bypass fix) ever shipped to
the image users pull.

All three are deliberate best-effort swallows; annotate them with the repo's
existing `# noqa: S110 — <reason>` convention rather than adding dead logging:
- relocate.py: tag write is best-effort (re-import re-derives tags)
- acoustid_scanner.py: verification-status tag is optional context
- web_server.py: audio-duration probe falls through to 0

ruff check . + compileall now clean; pytest already passed in CI at ce6ce4d.
2026-06-11 16:00:07 -07:00
BoulderBadgeDad
26b27eb441 What's New + version modal: 2.7.1 content
Per the release convention: WHATS_NEW + VERSION_MODAL_SECTIONS carry only the
current release, with older cycles folded into the "Earlier" summary.

- WHATS_NEW '2.7.1': download verification & review (badge, persistence, review
  queue), the #852 websocket login-bypass fix, the acoustid Relocate action (#704),
  faster artist pages (#853), the LB-weekly un-wedge (#702), the torrent metaDL
  stall + orphan fix, and the smaller fixes (#851/#840/search auto-select) +
  contributor PRs (#845/#848/#850). 2.7.0 rolled into "Earlier versions".
- VERSION_MODAL_SECTIONS: verification & review leads, security fix section,
  fixes list, and an "Earlier in 2.7.0" aggregator replacing the 2.6.x one.
- Fixed the "Go to page" links: the downloads page id is 'active-downloads', not
  'downloads' — the old entries' links silently did nothing.
2026-06-11 15:50:20 -07:00
BoulderBadgeDad
572a7c05f0 Release: bump version to 2.7.1 + default the docker publish tag to 2.7.1
- _SOULSYNC_BASE_VERSION 2.7.0 → 2.7.1 (single source of truth; drives the UI
  version + commit-suffixed build string).
- docker-publish.yml workflow_dispatch default version_tag 2.7.0 → 2.7.1 (and the
  description example), so the manual tagged-release publish defaults to 2.7.1.
2026-06-11 15:45:39 -07:00
BoulderBadgeDad
826ac0b366 #853 follow-up: don't cache a partial Deezer discography on mid-pagination error
PR #853 added artist album-list caching to Deezer, but unlike the Spotify path it
had no equivalent of the truncated-fetch guard: Deezer paginates, and a transient/
malformed response on page 2+ (artist with >100 albums) broke the loop and cached
the PARTIAL list as the full discography — serving an incomplete album list from
cache until TTL.

Fix: track whether pagination finished cleanly. A malformed/empty-of-data response
mid-walk now clears a `complete` flag and the artist→album-LIST is cached only when
complete. Individual album entities still cache regardless (each is complete; we
just have fewer). A clean end (short page / empty page / reached limit) still caches
as before.

Tests: a page-1-ok / page-2-errors walk no longer caches (second call refetches
instead of serving a permanently-incomplete list); a clean two-page walk still
caches (happy path intact). 181 deezer/metadata tests green.
2026-06-11 15:35:36 -07:00
BoulderBadgeDad
814af2cdfb
Merge pull request #853 from ramonskie/artist-discography-cache
Cache artist album lists across metadata sources
2026-06-11 15:31:04 -07:00
BoulderBadgeDad
53c264ab50 Torrents: fix stall handling on "downloading metadata" + stop orphaning in qbit
noldevin: a magnet stuck "downloading metadata" ran 11h despite a 15-min stall
timeout, got cleared from SoulSync but left active in qbit, then re-grabbed as a
duplicate. Two bugs:

1. Stall never fired on metaDL. StallTracker reset its clock on any `downloaded`
   byte increase, but a metaDL torrent's byte counter still ticks up from DHT/peer
   protocol overhead while making no real progress — so the clock reset forever.
   Fix: the byte counter only counts once metadata is in (size>0). During the
   metadata phase (size==0) the only thing that counts as progress is *obtaining*
   metadata, so a magnet that can't even do that within the timeout is correctly
   flagged stalled. size=None preserves the old byte-only behavior (back-compat).

2. Orphaned in qbit. The monitor's stall exit removed the torrent, but the `error`
   exit and the 6h deadline exit only marked the download failed — leaving the
   torrent active in qbit, untracked here, so SoulSync re-grabbed the same dead
   torrent (qbit logs the duplicate-add). Fix: both terminal exits now run
   _cleanup_torrent (shared with the stall path), which removes+deletes (abandon)
   or pauses per the stall action — nothing is left orphaned.

Tests (10 new): metaDL byte-noise no longer resets the clock (stalls at timeout);
obtaining metadata resets it; real byte-progress still tracked after metadata;
_cleanup_torrent removes+delete_files on abandon / pauses on pause / no-ops on
empty hash or no adapter / swallows a client error. 151 torrent tests green.
2026-06-11 14:37:46 -07:00
ramonskie
76d3e25fd4 Cache artist album lists across metadata sources 2026-06-11 22:33:04 +02:00
BoulderBadgeDad
46eccbb237 #852: gate the WebSocket handshake — close the launch-PIN/login bypass
The #832 fix enforces the launch PIN / login via a Flask before_request hook, but
that hook does NOT run for the socketio handshake — empirically a normal endpoint
401s while /socket.io/ returns 200 with the gate on. So removing the client overlay
(Safari "Hide Distracting Items", devtools) + opening a socket streams live data
(downloads, logs, dashboard, notifications) completely unauthenticated.

Fix: the socketio connect handler now enforces the same check and returns False
(rejects the connection) when a gate is active and the session isn't verified.
Rejecting connect blocks every downstream WS event (subscribe/join), so all live
data is covered. core/security/ws_gate.is_ws_connection_blocked is the pure seam:
login mode (when on) > launch PIN > open, mirroring the HTTP gate exactly. Fails
OPEN on a config-read error, same as the HTTP gate.

Audited every other surface empirically with the gate on + unauthenticated: SSE
streams, catch-all pages, library/dashboard data, admin endpoints, search,
image-proxy, audio-stream (incl. a /etc/passwd traversal probe) all 401; /api/v1
key-gated. The WebSocket was the only hole.

Tests (10): pure gate logic (login>pin precedence, all on/off combos) + real
socketio.test_client integration — connect rejected when gate on + unauthenticated,
allowed when gate off or PIN verified.
2026-06-11 13:27:03 -07:00
BoulderBadgeDad
87e5e1fa23 #702: make mirrored-playlist cancel/reset/delete idempotent (un-wedge LB weekly sync)
Root cause (from the reporter's app.log): a ListenBrainz weekly playlist syncs
through the in-memory youtube_playlist_states discovery machine. When that live
state is lost — a Docker restart, or the discovery process ending while the user
waits for the media-server scan — the DB discover-download snapshot survives but
the live state is gone. Every recovery action (Cancel/Reset/Delete) then hit
`key not in states` and returned 404 "YouTube playlist not found" (hence the
confusing "Youtube" on a ListenBrainz playlist), leaving the playlist permanently
wedged with no way to dismiss or re-sync. Works for the maintainer because a
single session with no restart keeps the live state alive.

Fix — these are cleanup ops, so "the thing is already gone" is SUCCESS, not 404:
- cancel_sync core (shared by YouTube + ListenBrainz + Tidal/Deezer/Qobuz/...) →
  missing key returns idempotent success.
- reset_youtube_playlist / delete_youtube_playlist → same.
The playlist becomes recoverable: Cancel/Reset clears the dead state and the user
re-syncs fresh.

Tests: cancel_sync core (missing key = idempotent 200 not 404; present key still
cancels + clears the worker + reverts phase); endpoint-level idempotency for
cancel/reset/delete; updated the old test that locked the 404 wedge. 834 sync/
discovery tests green.
2026-06-11 12:55:55 -07:00
BoulderBadgeDad
9bf7881f7a #704: add "Relocate" fix for AcoustID mismatches — retag + restage for re-import
The 'retag' fix corrects a mismatched file's tags/DB but leaves it in the WRONG
artist/album folder, so the library shows the right title while the file sits under
the previous track. AcoustID yields only title+artist (no reliable album), so an
in-place move has no safe target.

New 'relocate' action: retag the file, move it into Staging, drop the stale tracks
row, and clean up the emptied folder. The auto-import worker (which watches Staging)
re-identifies it with full metadata and files it correctly — reusing the import
pipeline instead of guessing a destination.

- core/repair_jobs/relocate.py: pure, injectable orchestration (retag -> move ->
  drop row) + collision-safe staging_destination. Row is dropped only AFTER a
  successful move, so a failed move never orphans the library entry.
- _fix_acoustid_mismatch gains the 'relocate' branch (thin wrapper: resolve path,
  staging dir, drop-row closure, empty-parent cleanup).
- UI: "Relocate" button on the AcoustID-mismatch fix modal.

Tests (8): staging-dest collision suffixing; relocate happy path; tag-write failure
still relocates; FAILED move does NOT drop the row; no-tags skips write; a real
file move through safe_move_file; and a handler integration test (file moved to
staging + tracks row deleted end-to-end). Repair + integrity suites green.
2026-06-11 12:01:05 -07:00
BoulderBadgeDad
525222b04c #840: offer '&' as an artist tag separator (MusicBrainz/Picard style)
The artist-separator setting + backend join already supported any delimiter; just
add ' & ' to the dropdown. Safe by construction — artists are joined from the
source's artist LIST, never split, so a name containing '&' (Florence & The
Machine) is one entry and can't be mis-parsed. Closes the join half of #840.
2026-06-11 11:23:21 -07:00
BoulderBadgeDad
174adf2dc9 #845 tests: cover the verification_status migration backfill
Locks the one-time backfill that derives verification_status for pre-column
library_history rows from acoustid_result: pass->verified, skip->unverified,
fail->force_imported; never overwrites an existing status (NULL-only); leaves
no-acoustid rows NULL; idempotent across repeated inits. Exercises the real
_initialize_database path (clears the per-process init guard to re-trigger it).
2026-06-11 11:17:09 -07:00
BoulderBadgeDad
a207bd943b #845 tests: lift history-path resolver to core/ + seam-test the delete-safety
resolve_history_audio_path drives a DESTRUCTIVE delete (os.remove), but lived
endpoint-bound in web_server with zero tests. Lifted to core/matching/history_paths
with injected effects (exists / resolve_library_path / lookup_titled_paths) so the
fallback chain — and the collision-safety that stops delete() from removing the
wrong same-title file — is a clean importable seam. web_server now wraps it (DB
lookup + os.path.exists + prefix resolver injected); behavior preserved.

9 tests lock it: recorded-path hit, prefix-resolve fallback, single tracks-table
candidate, and the safety rules — multiple same-title candidates with NO artist ->
None (refuse to guess), artist filter picks only the matching path, artist named
but unmatched -> None, no-title/empty-lookup -> None. Full suite green (5906).
2026-06-11 11:07:25 -07:00
BoulderBadgeDad
89f843d223 #851: normalize '/' ':' '_' to spaces so slash-titles match underscore sources
The candidate matcher rejected valid downloads of titles with a '/' or ':' (e.g.
Sawano's "You See Big Girl / T:T") because the unified normalize() removed those
chars ("t:t" -> "tt") while keeping the '_' that source filenames substitute for
them ("T_T" -> "t_t") — the asymmetry tanked the similarity score. Now '/ \ : _'
all map to spaces before the strip, so "/ T:T" and "_ T_T" both normalize to "t t".

Verified on the real library: similarity for the Sawano pair 0.927 -> 1.000; only
348/40786 strings (0.85%, all containing those separators) change; worst-case
joined-variant match (e.g. "12:05" vs "1205") stays 0.889, well above the 0.70
title threshold — no match regressions. Fixes the matching half of #851.
2026-06-11 10:46:36 -07:00
BoulderBadgeDad
17440329c1 #845 follow-up: admin-gate the mutating verification-review endpoints
The merged PR left the review-queue's mutating endpoints ungated. Both now require
admin, matching the Phase 3 destructive-endpoint convention:

- /api/verification/<id>/delete (os.remove + drops the history row) — @admin_only,
  so a non-admin on a login/multi-profile instance can't delete library files.
- /api/verification/<id>/approve (flips verification_status + writes the tag) —
  @admin_only; also wrapped its DB writes in `with db._get_connection()` for
  rollback-on-error + codebase consistency (was a bare conn).

Read/playback endpoints (stream/play/compare/entry/config) stay open — the app's
LAN-read model. Tests: non-admin gets 403 on delete + approve; admin isn't blocked.
2026-06-11 10:43:40 -07:00
BoulderBadgeDad
eb35ba86fb
Merge pull request #845 from nick2000713/fix/import-folder-artist-override-optin
feat: import folder-artist override opt-in + verification pipeline review queue
2026-06-11 10:39:59 -07:00
BoulderBadgeDad
ce6ce4d8d6 Search: auto-select Spotify when "Spotify (no auth)" is the active source
On the Search page and the global search widget (both share createSearchController),
the source picker stayed empty when the active metadata source was Spotify-no-auth,
until you clicked Spotify manually.

Root cause: get_primary_source_status reports the no-auth composite as source
'spotify_free' (for display labelling). The controller's initActiveSource set
activeSource = 'spotify_free' (it's a valid SOURCE_LABELS entry), but the icon row
renders from SOURCE_ORDER, which only has 'spotify' — so no icon matched the active
source and nothing highlighted.

Fix: normalize 'spotify_free' -> 'spotify' when deriving the initial active source
(they're the same searchable source; the picker only has a Spotify icon). Now
no-auth auto-selects Spotify like plain Spotify does. One spot, fixes both surfaces.
2026-06-11 09:39:40 -07:00
BoulderBadgeDad
8bf03a7d5e Merge branch 'dev' of https://github.com/Nezreka/SoulSync into dev 2026-06-11 09:22:52 -07:00
BoulderBadgeDad
bec31db738
Merge pull request #850 from RollingBase/fix/sw-image-cache-interception-failure
SW: stop cover-art burst from hard-failing on first load
2026-06-11 09:22:36 -07:00
nick2000713
bf5affd03c resolve merge conflict in style.css 2026-06-11 18:21:04 +02:00
BoulderBadgeDad
3284af428d Discogs (#848 follow-up): tag collection album IDs for consistency
The Your Albums Discogs collection sync stored bare release_ids while
search/discography now store tagged ('r<id>') ones (#848). This didn't cause a live
bug — the pool dedups by normalized name, and discogs_release_id is only ever
re-fetched (which handles bare via release-first) — but it left the "type travels
with the ID" invariant half-applied. Now the collection sync tags its IDs too, so
every stored Discogs album ID is uniform and a future ID comparison can't be tripped
by mixed forms.

Collection items are always releases, so they're tagged 'r<id>'. Test locks the
stored value + that a tagged collection ID routes only to /releases (never /masters).
2026-06-11 08:33:26 -07:00
BoulderBadgeDad
f557d3b203
Merge pull request #848 from RollingBase/fix/discogs-master-release-id-collision
Discogs: fix master/release ID collision fetching the wrong album
2026-06-11 08:28:47 -07:00
nick2000713
17886477cb remove docs from PR 2026-06-11 17:08:20 +02:00
rollingbase
01ed54f4f2 SW: stop cover-art burst from hard-failing on first load
A discography page fires 70+ cover-art requests at once. Routed through
the service worker one-for-one, that burst overruns the browser's
per-host connection pool (~6); the overflow fetches reject, and the
cache-first strategy mapped each rejection to Response.error() — which
Firefox surfaces as NS_ERROR_INTERCEPTION_FAILED, a hard, *uncached*
image failure for that load. The page renders artless cards on first
visit and only "heals" on reload (cached images shrink the burst).

Fix the image path three ways:
- Cap concurrent image fetches (semaphore, 6) so the burst queues
  instead of saturating the connection pool — the actual first-load fix.
- Retry a rejected fetch once with a short backoff; most failures are
  transient connection-cap rejections that clear as the burst drains.
- On final failure return a benign 504 instead of Response.error(), so a
  dead image degrades to a normal broken image (recoverable next nav)
  rather than NS_ERROR_INTERCEPTION_FAILED.

Cache hits bypass the throttle entirely. Static-asset strategy is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:03:41 +02:00
rollingbase
d6d5ef22c9 Merge branch 'main' into fix/discogs-master-release-id-collision 2026-06-11 11:12:34 +02:00
BoulderBadgeDad
a0b6fddbc4
Merge pull request #846 from Nezreka/dev
Dev
2026-06-10 23:17:07 -07:00
BoulderBadgeDad
8968d87cc4 What's New + version modal: 2.7.0-only, summarize the rest
Going forward these only carry the current release plus one brief "earlier versions"
summary — no accumulating per-version backlog.

- WHATS_NEW: replaced the full 2.6.x→2.5.x backlog with a single '2.7.0' block
  (per-profile accounts, login/recovery/reverse-proxy, the fixes, artist-sync) + an
  "Earlier versions" one-liner. The "Older Versions" button auto-hides with one
  version, so the nav still works.
- VERSION_MODAL_SECTIONS: five curated 2.7.0 sections + a brief "Earlier in 2.6.x".
- Content drawn from the 2.7.0 pr_description. Added a convention comment at the top
  of WHATS_NEW. JS validated (string-aware brace/quote check clean); 64 integrity
  tests pass.
2026-06-10 23:05:01 -07:00
BoulderBadgeDad
654a5b4536 Release: bump version to 2.7.0 + default the docker publish tag to 2.7.0
- _SOULSYNC_BASE_VERSION 2.6.9 → 2.7.0 (single source of truth; drives the UI
  version + commit-suffixed build string).
- docker-publish.yml workflow_dispatch default version_tag 2.6.9 → 2.7.0 (and the
  description example), so the manual tagged-release publish defaults to 2.7.0.
2026-06-10 22:57:19 -07: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
09d4bbc530 Security UI: confirm-password field for admin password + recovery reset
Mirror the PIN setup's confirm step so a typo can't silently set a password you
can't reproduce. Both the Step 1 admin password (Settings) and the forgot-password
reset (login screen) now require entering it twice and reject a mismatch before
saving. 64 integrity tests pass.
2026-06-10 22:41:23 -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
5c80ee1010 Login recovery (UI): Settings setup + "Forgot password?" on the login screen
- Settings → Security: a recovery-question picker (5 presets + Custom) + answer
  field + Save, posting to /api/profiles/1/set-recovery. handleRecoveryQuestionChange
  reveals the custom box.
- Login screen: a "Forgot password?" link opens a recovery view — enter username →
  fetch your question → answer + new password → reset → reload signed in. Reuses the
  launch-PIN overlay styling/structure (entry + recovery views).

All inert unless login mode is on, so a default/LAN install never sees any of it.
64 script-split integrity tests pass (every new handler resolves).
2026-06-10 22:28:07 -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
9e40f5c12d tests: pin/login mode isolation — PIN gate unaffected when login off; both-off = unguarded
Pins the zero-impact guarantee: with require_login off (default), the launch PIN
still enforces exactly as before (the login deferral doesn't fire), and with both
off there's no gate at all (today's behavior).
2026-06-10 22:19:29 -07:00
BoulderBadgeDad
21dfbb39b0 Native login (increment 3/3): login screen, set-password, Settings toggle, logout
The UI that makes opt-in login usable. Off by default → your LAN setup is unchanged
(none of this appears unless security.require_login is on).

- Login screen overlay (reuses the launch-PIN styling): username + password →
  /api/auth/login → reload into the app. Shown when /api/profiles/current reports
  login_required (checked before profile selection).
- POST /api/profiles/<id>/set-password (admin, or self) to set/clear a login
  password, distinct from the PIN.
- Settings → Security: "Login password (admin account)" field + a "Require login"
  toggle (with the anti-lockout note). Wired into the existing settings load/save.
- Sign-out button in the profile bar, revealed only in login mode (login_mode flag
  on /api/profiles/current); soulsyncLogout() → /api/auth/logout → reload.

Tests: set-password sets/clears + verifies; /api/profiles/current signals
login_required. 20 login/password tests pass; 64 script-split integrity pass.

Remaining (small follow-up): a password field in the Manage Profiles edit form so
admins can set OTHER profiles' passwords from the UI (the endpoint already exists).
2026-06-10 22:10:33 -07:00
BoulderBadgeDad
92cbef90f9 Native login (increment 2/3): login/logout endpoints + require_login gate
The backend auth for opt-in username/password mode (security.require_login, default
off → zero change; the launch PIN + picker behave exactly as today).

- core/security/login_gate.py: pure gate (mirrors launch_lock) — when login mode is
  on, an unauthenticated session reaches only the page shell, /api/auth/login,
  /api/auth/logout, /api/profiles/current, /api/setup/status, and the key-authed
  /api/v1 API. Deliberately does NOT expose the profile list pre-auth (you type your
  name, not pick from a roster).
- _enforce_login before_request enforces it; _enforce_launch_pin no-ops when login
  mode is on (login replaces the shared PIN, per design).
- POST /api/auth/login (username = profile name, case-insensitive; brute-force
  limited per IP; generic error so names don't leak) + POST /api/auth/logout.
- Anti-lockout: the settings save refuses to turn ON login mode until the admin
  account has a password.

Tests: gate blocks→login→access→logout→blocked; case-insensitive username; wrong
password / passwordless profile / unknown user all 401 generically; login list not
exposed pre-auth; can't enable login without an admin password. 12 tests pass. Next:
the login screen + set-password UI + the toggle (increment 3).
2026-06-10 22:01:53 -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
fb8c8a71c6 Security UI: Settings toggles for reverse-proxy mode + auth-proxy header
Config is DB-backed (metadata.app_config) — there is no config.json — so the
reverse-proxy settings I added earlier had NO way to be set by a user and were
effectively dead. Added them to Settings → Security, next to the launch-PIN toggle:

- "Behind a reverse proxy" checkbox (security.trust_reverse_proxy) — help text notes
  it's for nginx/Caddy/Traefik+TLS, to leave OFF for direct/LAN http://, and that it
  needs a restart (applied at app init).
- "Auth proxy user header" field (security.auth_proxy_header) — e.g. Remote-User,
  with the must-strip-client-headers warning; blank = off.

Wired into the existing settings load + save; the save loop already persists every
key in the security object via config_manager.set, so no backend change needed.
Fixed Support/REVERSE-PROXY.md to point at Settings → Security instead of a
nonexistent config.json. Off by default → zero impact for direct users.

64 script-split integrity tests pass.
2026-06-10 21:17:26 -07:00
BoulderBadgeDad
86d0a0dd62 Security: trust a forward-auth proxy user header (Tier 3)
Lets SoulSync sit behind Authelia/Authentik/oauth2-proxy as the gatekeeper: when
security.auth_proxy_header names a header (e.g. Remote-User), a request carrying it
is treated as already-authenticated and passes the launch lock — the proxy did the
login (with 2FA).

- core/security/auth_proxy.py: trusted_proxy_user(get_header, header_name) — returns
  the user iff the configured header is present + non-empty; empty header name (the
  default) → always None → feature off.
- _enforce_launch_pin ORs it into pin_verified. OFF by default, so a direct install
  is unaffected AND a client-spoofed header does nothing unless the operator opted in.
- Doc'd in Support/REVERSE-PROXY.md with the must-strip-client-headers warning.

This is the lightweight Tier 3 (auth-proxy integration), not a full per-user login —
the proxy owns identity; SoulSync trusts it.

Tests: helper off/on/blank/exception-safe; integration — trusted header passes the
gate, no header is locked, and (the safety pin) a spoofed header is IGNORED when the
feature is off. 6 tests pass.
2026-06-10 20:57:48 -07:00
BoulderBadgeDad
5e5bc12e45 Security: add gated security headers in reverse-proxy mode (Tier 2)
Fold a conservative security-header set into the SAME opt-in proxy mode, so it's
zero-impact when off. When security.trust_reverse_proxy is on, an after_request
adds X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, and HSTS
(safe — only honoured over the proxy's HTTPS), via setdefault so it never clobbers
a header the proxy already set. No CSP (needs per-deploy tuning; better at the
proxy). When OFF (default), the after_request isn't registered → no headers added.

Tests: off adds none of the headers; on adds all three. Doc updated. 6 tests pass.
2026-06-10 20:48:51 -07:00
BoulderBadgeDad
0d1e949798 Security: brute-force limiter on the launch-PIN unlock (Tier 2)
A publicly-exposed instance gated only by the launch PIN was brute-forceable. Added
a lenient in-memory failed-attempt limiter (core/security/rate_limit.py): 10 wrong
PINs from one IP within 5 min → 429 with Retry-After, failures age out on their own
(self-heal, no persistent lockout), and a CORRECT entry clears that IP instantly.

Wired into /api/profiles/verify-launch-pin. By design it can only ever trigger on a
flood of WRONG PINs — correct entry, a couple of typos, or a no-PIN install are
never affected, so normal use sees no change. Keyed per-IP so an attacker can't
lock out a legit user.

Tests: limiter is lenient under threshold, trips on a flood, success clears it,
failures self-heal, per-IP isolation; endpoint returns 429 after 10 wrong PINs with
Retry-After. 6 tests pass.
2026-06-10 20:47:46 -07:00
BoulderBadgeDad
aa3aae695d Security: opt-in reverse-proxy mode (ProxyFix + Secure cookie) + nginx guide
Tier 1 of "secure behind a reverse proxy". STRICTLY opt-in so direct/LAN installs
are byte-for-byte unchanged.

- core/security/reverse_proxy.py: apply_reverse_proxy_mode(app, config_get) — a
  no-op unless security.trust_reverse_proxy=true. When OFF (default), the app is
  untouched: no ProxyFix, X-Forwarded-* stays UNtrusted (a direct client can't
  spoof its IP/scheme), session cookie keeps Flask defaults. When ON (operator is
  behind nginx/Caddy/Traefik with TLS): trust one proxy hop's X-Forwarded-*, and
  mark the session cookie Secure + SameSite=Lax. Any config error → safe no-op,
  never breaks startup.
- Wired once at app init.
- Support/REVERSE-PROXY.md: nginx (with the Socket.IO Upgrade headers people
  always miss) / Caddy / Traefik configs, the setting, and the "put auth in front
  (Authelia/Authentik/oauth2-proxy)" recommendation + the off-for-plain-HTTP note.

Tests: off (and missing-key, and a config exception) is a strict no-op — not
ProxyFix-wrapped, cookie defaults intact; on wraps ProxyFix + secures the cookie;
and the real web_server app is NOT in proxy mode by default. 5 tests pass.
2026-06-10 20:36:49 -07:00
BoulderBadgeDad
d82d02b921 Artist Sync: unify with deep scan — server-diff stale removal, scoped to one artist
Per the original intent, "Sync" is now a single-artist deep scan: it uses the SAME
reconciliation source as the whole-library deep scan instead of a separate
disk-existence check.

- Phase 1 already calls the deep-scan worker's _process_artist_with_content; now it
  passes seen_track_ids so the pull collects the server's current track IDs for the
  artist (existing + new), exactly as the library deep scan does.
- Phase 2 stale = (artist's DB tracks for this server) − seen, then
  delete_stale_tracks(server_source) — identical mechanism to deep scan, scoped to
  one artist. The old os.path.exists disk check (which could mass-delete on an
  unreachable mount) is gone.
- Removal only runs when the server pull SUCCEEDED — no trustworthy 'seen' set
  (no server, unreachable, or a failed pull) → skip, never delete. The
  is_implausible_stale_removal guard (>50% unseen) stays as the same safety net
  deep scan has for a flaky response. @admin_only retained.

Tests rewritten for the server-diff model: removes only tracks the server no longer
has; guard skips when most are unseen; a failed pull skips removal entirely;
admin-only. 8 tests pass.
2026-06-10 19:43:25 -07:00
BoulderBadgeDad
4d1b9a5639 Artist Sync: guard stale-removal against an unreachable mount + gate it admin-only
The enhanced-tab "Sync" button's stale-removal phase deleted any track whose file
wasn't on disk, with NO guard — so if the music storage was momentarily
unavailable (sleeping NAS, dropped mount, unmounted Docker volume, WSL hiccup),
os.path.exists returned False for EVERY file and one click wiped the whole artist
(tracks + their now-"empty" albums) from the DB. The deep-scan path already had a
50%-stale safety net (#828); this endpoint never got one.

- New core/library/stale_guard.py: is_implausible_stale_removal(missing, total) —
  a tested rule (skip removal when missing > 50% of a >=5-track set), centralised
  so every stale-removal site can share it.
- sync_artist_library: if the guard trips, SKIP removal (delete nothing), return
  removal_skipped + warn; the frontend shows "storage may be offline — skipped"
  instead of silently deleting. Empty-album cleanup now also only runs on the
  non-skipped path and uses `album_id IS NOT NULL` (fixes the NOT IN-with-NULL
  no-op). Frontend also refreshes the view on additions, not just removals.
- @admin_only on the endpoint — it deletes tracks + albums but was ungated, while
  the sibling delete_album endpoint is gated.

Deep scan was already safe (different mechanism: server-diff + its own 50% guard).

Tests: guard unit rules; endpoint skips removal when all files missing (keeps the
tracks), removes only the genuinely-gone few otherwise, and 403s for non-admins.
7 new tests pass.
2026-06-10 19:33:44 -07:00
BoulderBadgeDad
5bc27e6268 #843 follow-up: key the no-state cache save by the FIRST artist
The #843 fallback saved the discovery-cache match using the client's
original_artist verbatim — but the client sends a joined "A, B, C" string, while
EVERY in-memory + sync path keys the cache by the first artist (artists[0]). So a
multi-artist fix (the reporter's exact "Cherrymoon Traxx, Hermol, SBM, BELS"
case) would have saved under a key the sync never looks up — the fix would
"succeed" with no error but silently never apply.

Reduce the client artist to the first (split on comma) in the no-state branch so
its cache key matches the in-memory/sync convention exactly. Single-artist tracks
are unaffected.

Test: no-state save now keys by the first artist, and a new test pins that the
no-state and in-memory paths produce an IDENTICAL cache key for the same
multi-artist track. 74 discovery tests pass.
2026-06-10 18:57:43 -07:00
BoulderBadgeDad
0afa3c9705 Fix: "Discovery state not found" when fixing a match after restart/import (#843)
The discovery FIX → Confirm flow 404'd with "Discovery state not found" whenever
the in-memory discovery state was gone — a server restart, or an imported
playlist that wasn't discovered in THIS process — even though the card is still
shown from persisted data (the reporter's log shows "Returning 0 stored ...
playlists for hydration", i.e. the in-memory states were empty).

The thing that actually makes a manual fix STICK is writing it to the discovery
cache (save_discovery_cache_match), keyed by the original track's name + artist —
which doesn't need the in-memory state at all. But the endpoint 404'd on the
missing state before reaching that write, so the fix was dead after a restart.

- update_discovery_match (core/discovery/endpoints.py) now only does the in-memory
  result update when the state exists; the durable discovery-cache write always
  runs, falling back to client-provided original_name/original_artist when there's
  no in-memory state. With neither a state nor originals it still 404s (unchanged).
- The FIX confirm (wishlist-tools.js) now sends original_name/original_artist
  (from the source track it already has) so the backend can key the cache.

Covers all sources that share the helper (tidal/deezer/qobuz/spotify-public).
Tests: no-state-but-originals saves the cache + returns success; no-state-no-
originals still 404s; existing with-state path unchanged. 73 discovery tests pass.
2026-06-10 18:49:32 -07:00
BoulderBadgeDad
a15fe15842 Fix: auto-sync capped public Spotify playlists at 100 tracks (#838)
Regression in 2.6.9. The spotify_public source adapter (used by auto-sync /
refresh_mirrored) called scrape_spotify_embed() directly — the embed widget only
exposes ~100 tracks — instead of fetch_spotify_public(), the wrapper the rest of
the app uses, which pulls the full list via the paginated public API and only
falls back to the embed on failure. So initial discovery got the whole playlist
but every auto-sync re-fetch truncated it to 100.

Switched the adapter to fetch_spotify_public (same return shape — drop-in). Albums
still resolve via the embed (already whole); on any failure it falls back to the
embed exactly as before.

Test: the adapter returns all 150 tracks when the full fetch yields 150 (was
capped at 100). 29 adapter tests pass.
2026-06-10 18:31:43 -07:00