Commit graph

1444 commits

Author SHA1 Message Date
BoulderBadgeDad
8236b95db0 fix: don't pass the 'custom' cookies sentinel to yt-dlp (unsupported browser error)
The #902 'paste cookies.txt' feature added a 'custom' sentinel value for
youtube.cookies_browser, but that feature wasn't merged to this branch — and ~7 call sites
(core/youtube_client.py x5, core/video/youtube.py, web_server.py) pass cookies_browser raw to
yt-dlp's cookiesfrombrowser, which rejects 'custom' ('ERROR: unsupported browser: custom') and
broke YouTube download/enrichment. Sanitize 'custom' -> '' (no browser) at every site:
youtube_client reads via a walrus filter, the other two guard the condition. 'custom' now
means 'no browser cookies' here (the cookiefile feature isn't on this branch). Latent on dev
too — only _youtube_cookie_opts was fixed there.
2026-06-22 23:41:37 -07:00
BoulderBadgeDad
91eae710b4 video automations: post-download scan chain (parity with music's batch→scan→update)
Mirrors the music flow: a finished download batch → refresh the media server → (after
it indexes) pull the new media into the DB — so a downloaded movie/episode shows as
owned without waiting for the 6h scheduled scan.

Two event-based system automations (owned_by='video'):
  - 'Auto-Scan Video After Downloads' (video_batch_complete → video_scan_server)
  - 'Auto-Update Video Database After Scan' (video_library_scan_completed → video_update_database)

Pieces:
- core/video/download_events.py: a callback registry (core/video can't import the
  engine — isolation). The monitor publishes batch-complete; web_server bridges it to
  automation_engine.emit('video_batch_complete', …), like music's web_scan_manager.
- download_monitor: fires the batch-complete event once, when the last in-flight
  download finishes (none queued/downloading/searching left).
- video_scan_server handler (stage 1): refresh server, wait a debounce for indexing,
  then emit 'video_library_scan_completed' (mirrors music's time-based completion).
- video_update_database handler (stage 2): incremental read (newest-first, stop after
  25 consecutive known — same as music).
- blocks: 2 video triggers + 2 video actions (scope='video'); registration; seeds.

kettui: seam tests for both handlers (refresh/wait/emit, incremental read, error paths),
the event registry (idempotent register, isolated failures), and the monitor (fires once
on last completion, never while work remains). 298 automation tests + isolation green.
2026-06-20 14:47:00 -07:00
BoulderBadgeDad
40149d09f7 video automations: 'Scan Video Library' — the first video twin (shared engine)
The video side gets its OWN automations at music-side parity, kept separate so
nothing on the music side breaks. First twin: Scan Video Library — tells the media
server to rescan the user's SELECTED video sections (movies/TV, never music), then
reads the result into video.db so freshly-downloaded media shows as owned.

Architecture (scope tags + video twins on the shared engine):
- Handler core/automation/handlers/video_scan_library.py — pure function with
  injected I/O (server_refresh / run_video_scan); production lazily binds
  refresh_video_server_sections() + the video scanner. Owns its own progress.
  Lives on the SHARED automation side so it may import core.video (isolation only
  forbids core/video & api/video from importing music, not the reverse).
- blocks.py gains a 'scope' tag ('both' generic / 'video' video-only / absent=music)
  + blocks_for_scope(). The music /api/automations/blocks now filters out video
  blocks; new isolated /api/video/automations/blocks serves the video palette.
- automation_engine seeds 'Scan Video Library' (owned_by='video', schedule 6h) so
  it appears ONLY on the video Automations page; ensure_system_automations now
  honours owned_by + action_config. Music page excludes owned_by='video' rows.

kettui: seam-level tests for every handler path (happy/no-server/scan-error/never-
raises/mode), scope filtering (music excludes video, video gets generics, music
parity preserved), seeding (owned_by + mode), registration drift guard. 39 new
tests; full automation suite (288) + isolation guards green.
2026-06-19 19:38:18 -07:00
BoulderBadgeDad
530d6f181f Video enrichment: add the 4 new workers to the dashboard header
- Dashboard header now shows fanart.tv / OpenSubtitles / YouTube Votes / SponsorBlock
  buttons alongside TMDB/TVDB/OMDb/YouTube (same chip + spinner + tooltip + click
  pause/resume + worker-orb animation).
- Socket status loop now iterates ALL engine workers (matchers + backfill) instead
  of a hardcoded 3, so new buttons get live 2s status with no extra wiring.
- video-enrichment.js SERVICES + video-worker-orbs.js WORKER_DEFS extended.
  header-actions already flex-wraps, so 8 buttons reflow cleanly.
2026-06-18 08:04:53 -07:00
BoulderBadgeDad
3a24fc24b1 YouTube worker: push status over the socket like the others (stop polling)
The date enricher was the odd one out — the dashboard polled
/api/video/enrichment/youtube/status every 3s (flooding the access log) instead
of using the shared WebSocket the other three workers push on. Now the existing
_emit_video_enrichment_status_loop also emits 'enrichment:youtube' (same stats
shape), and video-enrichment.js binds it on the socket alongside tmdb/tvdb/omdb.
Removed the bespoke polling loop. One-time prime fetch on load stays (instant
initial state); everything after is socket-driven. Consistent with the others.
2026-06-17 11:59:39 -07:00
BoulderBadgeDad
80dd2ff21c Video side: own server connection (Plex/Jellyfin), music-style picker, isolated
Give the video side its OWN server connection — pre-filled from music but stored
separately in video.db, fully isolated (video never writes music config/state).

- Effective config helpers (video_plex_config / video_jellyfin_config): video's
  own creds when set, else inherited read-only from music. resolve_video_server +
  _build_source + watch-link/poster/dashboard all use these (own db threaded in).
- Server Connection UI mirrors music's server picker (toggle = select + configure),
  scoped to Plex/Jellyfin, at the bottom of the Connections tab.
- Jellyfin: independent client built from video's creds; explicit USER picker like
  music (list users → that user's libraries); honors the pick, admin fallback.
- Honest connection diagnostics (reachable vs 401 vs no-users) instead of a vague
  "auth failed".
- Auto-save on change with toasts; the shared Save button is intercepted on the
  video side so it saves video settings (and can't fire a music save).
- Enrichment status now PUSHES over the socket like music (no browser polling /
  access-log flood); config save only rebuilds workers when an API key changed.
- Seam tests for effective-config inheritance/override + isolation guard.
2026-06-15 16:42:22 -07:00
BoulderBadgeDad
401a9be0ec video side: live dashboard via isolated /api/video blueprint
First wire from video.db -> UI, kettui-style.
- api/video/ : isolated Flask blueprint (registered at /api/video with one
  additive line in web_server.py). Reads only video.db; imports nothing from
  the music API or DB.
- GET /api/video/dashboard -> VideoDatabase.dashboard_stats(): live library/
  download/watchlist/wishlist counts (real 0s on an empty DB).
- video-dashboard.js now fetches it and fills the stat cards + Watchlist/
  Wishlist header badges (formatted bytes/speed); falls back to zeros on error.
  uptime/memory stay at markup defaults for now (not video-domain).
- Tests: dashboard_stats counts (empty + populated), endpoint returns zeroed
  JSON via a Flask test client, blueprint exposes the route, and the video API
  imports nothing from music. 93 video/integrity tests green.
2026-06-13 22:40:48 -07: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
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
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
c62074d54a #865: resolve pasted SoundCloud links (incl. unlisted/private share URLs) via direct yt-dlp resolve; manual-search forces the SoundCloud source 2026-06-13 00:41:47 -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
c72e83bc2f #863: move YouTube artist recovery out of the (synchronous) parse into the async discovery worker — parse is fast again, no 120s timeout risk 2026-06-12 23:46:29 -07:00
BoulderBadgeDad
0093af89d2 #863: parallelize YouTube artist-recovery (5-worker pool) so the whole playlist resolves within the budget, not just the first ~dozen 2026-06-12 23:36:31 -07:00
BoulderBadgeDad
0a6325a87b #863: recover YouTube track artist via per-video uploader/channel when flat extraction returns title-only (budget-bounded) 2026-06-12 21:38:34 -07:00
BoulderBadgeDad
4f24c2af6d #863: derive YouTube track artist from music fields / -Topic channel / 'Artist - Title' instead of the playlist owner 2026-06-12 20:14:55 -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
6e86bac6eb M3U: skip the auto-save no-op when export is disabled (fixes ~30s analysis jam)
The download modal auto-saves an M3U on every render (save_to_disk, no force).
When m3u_export.enabled is off it writes nothing — but only AFTER ~30s of
per-track DB search + fuzzy matching, which it then discards; fired repeatedly
during analysis it jammed the batch (0 tasks, user cancels). Bail out at the top
of generate_playlist_m3u for exactly that case (save_to_disk and not force and
not enabled). Manual 'Export as M3U' sends force=True and content-only requests
send save_to_disk=False — both unaffected.

Pre-existing bug, unrelated to the playlist-folder feature, but it was blocking
the discovery->download flow.
2026-06-12 16:35:11 -07:00
BoulderBadgeDad
d160c486ec Playlists: mirror-update trigger prunes removed tracks (the other half)
Symmetric to the post-download reconcile (which handles ADDITIONS): when a
playlist's membership is re-synced (the mirror step — scheduled refresh or the
manual mirror endpoint), rebuild its folder from current membership WITH prune
IF it's organize-by-playlist. So a track that just LEFT the playlist has its
symlink cleaned up the instant membership changes, not only on the next download.

Factored a shared _rebuild_one_from_db (used by the manual 'Rebuild' button and
the mirror hook) + rebuild_mirrored_playlist_if_organized. Gated to organized
playlists, non-fatal at both mirror call sites.

Now the invariant 'folder = the playlist's current owned members' holds on every
change: additions caught at download, removals caught at mirror. 2 new tests
(removed track pruned; non-organized skipped). 985 + 277 tests pass.
2026-06-12 15:49:13 -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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
BoulderBadgeDad
1517794e23 Fix: manual Find & Add recreated the Jellyfin/Emby playlist (#837)
Automations + auto-sync respect 'append' mode and preserve a server playlist's
description + cover image, but manually matching a missing track ("Find & add")
recreated the whole playlist and wiped them.

Root cause: the add-track endpoint's Jellyfin branch called
`update_playlist(<entire track list>)`, which deletes + recreates the playlist on
Jellyfin/Emby. Switched it to the purpose-built `append_to_playlist([the one
found track])` — the same in-place, dedupe-safe op the 'append' sync mode already
uses — so the playlist (and its description/image) is preserved and only the
missing track is added. append_to_playlist reads `.id` off the track, so the
endpoint now sets it (it previously only set ratingKey).

Plex (in-place addItems) and Navidrome (in-place Subsonic updatePlaylist) were
already non-destructive; Emby routes through the jellyfin branch, so this covers
it too.

Tests: the add-track endpoint appends in place and never calls update_playlist;
a link-to-existing-track touches nothing. 18 tests pass (incl. the existing
append-mode suite).
2026-06-10 16:55:08 -07:00
dev
5896f2dcc6 fix: eager config load + check acoustid.enabled for verification pill
On fresh page load the Downloads pill now immediately reflects whether
Download Verification is enabled (calls _verifLoadConfig in
loadActiveDownloadsPage instead of only on first filter click).

Also changed /api/verification/config to check the `acoustid.enabled`
toggle rather than the raw api_key string — matches the UI setting
"Enable Download Verification".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:28:31 +02: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
BoulderBadgeDad
783839349c Automations: per-profile playlist source reads in auto-sync (part 2)
The playlist source registry built the Spotify/Tidal adapters with a client
GETTER (resolved fresh on every read), but web_server passed `lambda: <global
client>`. Swapped those to get_spotify_client_for_profile /
get_tidal_client_for_profile.

Combined with part 1 (the engine running each automation as its owner), an
auto-sync pipeline now reads its source playlist through the OWNER's account:
- interactive sync → the user's session profile,
- background automation → the automation owner (via core.profile_context),
- admin / profile 1 → the global client, so the admin's existing auto-sync
  pipelines pull exactly as before.

The adapters re-resolve per read, so a singleton registry is fine. Deezer/Qobuz
getters left global (their playlist login is tangled with downloads — deferred).

Tests: the Spotify/Tidal source adapters resolve the global client under admin
and re-resolve through the profile context per call (unconnected → safe global
fallback). 27 endpoint/profile tests pass.
2026-06-10 15:45:45 -07:00