Commit graph

2992 commits

Author SHA1 Message Date
Antti Kettunen
fac4e1ba1a
Sync issues routing and shell URLs
- Move issue detail selection into route search so the modal is deep-linkable and back-button friendly.
- Normalize issue category and detail params before they reach the loader.
- Keep the legacy shell URL in sync for React-owned home pages.
- Preserve the legacy issues-tour hooks on the React issues page.
- Add Escape handling, focus trapping, and focus restore to the issue detail modal.
- Add route and helper coverage for the new search-state behavior.
2026-05-13 22:26:23 +03:00
Antti Kettunen
a2495aaba7
Format & lint React code 2026-05-13 22:26:23 +03:00
Antti Kettunen
018a554f35
Add dev launcher and update dev docs
- Introduce dev.sh as the local backend + Vite launcher
- Document the separate backend/frontend development flow
- Note that the dev Gunicorn config restarts Python on file changes
- Note that Vite hot reloads React changes in webui
2026-05-13 22:26:23 +03:00
Antti Kettunen
d1e95a0558
Add clarifying docs for hybrid webui rendering setup 2026-05-13 22:26:23 +03:00
Antti Kettunen
a9976c54ae
Centralize shell bridge glue 2026-05-13 22:26:23 +03:00
Antti Kettunen
147a09035c
Remove stale initial page rendering hooks
- Drop unused _resolve_webui_initial_* helpers from web_server.py.
- Remove template-side initial_nav_page and initial_client_page conditionals.
- Keep Vite asset injection and runtime page activation in the client.
2026-05-13 22:26:22 +03:00
Antti Kettunen
1b06b9dc33
Stub scrollTo in Vitest setup
- Silence jsdom's not-implemented warning during router-driven tests
- Keep the fix in test bootstrap only so runtime behavior stays unchanged
2026-05-13 22:26:22 +03:00
Antti Kettunen
484741db5c
Trim issue modal styling
- Remove duplicate button base styles from the issue detail modal CSS
- Keep only the layout and state-specific variants that the shared primitives still need
- Let the shared Button and TextArea own the common control styling
2026-05-13 22:26:22 +03:00
Antti Kettunen
9572035837
Add shared select and button primitives
- Keep Select thin and native, with options supplied as children
- Add a simple shared Button for form actions
- Use both primitives in the issues page and report modal
2026-05-13 22:26:22 +03:00
Antti Kettunen
a19514dae9
Add shared form primitives for issues
- Introduce reusable input, textarea, card, button, and action components
- Use them in the issue report composer as the reference implementation
- Keep the TanStack form logic at the usage site and add focused regression coverage
2026-05-13 22:26:22 +03:00
Antti Kettunen
48aec3f6f3
Remove legacy issues shell code
- Delete the static issues page renderer and detail modal helpers
- Keep the React issues route as the only implementation
- Drop the dead mobile CSS and troubleshooter hook that only targeted the removed shell
2026-05-13 22:26:22 +03:00
Antti Kettunen
ad590fb3db
Reduce legacy route flicker
Keep React-owned pages out of the legacy page activator during initial bootstrap, and switch the visible React host before paint when the shell mounts.

That removes the refresh flash on /issues while preserving the legacy-page behavior and browser-history stability.

Verified with the router tests and the issues smoke suite.
2026-05-13 22:26:22 +03:00
Antti Kettunen
972910261b
Keep shell bootstrap aligned with profile selection
- re-render the React shell when legacy profile bootstrap selects or refreshes a profile
- keep the initial page fallback so direct loads still activate the legacy shell chrome
- preserve the smoke coverage for direct loads and browser history
2026-05-13 22:26:22 +03:00
Antti Kettunen
40199e4f6a
Update webui dependencies 2026-05-13 22:26:21 +03:00
Antti Kettunen
a0384d9dc8
Restore legacy profile page aliases
- normalize old downloads and artists page ids back to search
- keep home-page and access checks aligned with the current route ids
- let profile edit forms save modern ids while still reading old rows
2026-05-13 22:26:21 +03:00
Antti Kettunen
3be028f97b
Keep profile editor page access complete
- reuse the create-form page controls when rendering edit forms
- preserve existing home_page and allowed_pages IDs that the old whitelist hid
- keep mandatory pages checked so saves do not drop them
2026-05-13 22:26:21 +03:00
Antti Kettunen
f5d70768c1
Redirect root to profile home
- send / through the configured profile home page
- keep the router regression test in sync with the redirect
- preserve the legacy shell fallback for non-router bootstrap
2026-05-13 22:26:21 +03:00
Antti Kettunen
86bcad491f
Post-rebase cleanup 2026-05-13 22:26:21 +03:00
Antti Kettunen
686bfcc749
Drop server-rendered webui page state
Remove the Flask route-to-page helpers and stop passing initial active-page flags into the shell template.

The web UI now renders static page and nav markup, while the client-side shell remains responsible for establishing active page state after load. This keeps the hybrid Flask + Vite asset setup intact while reducing duplicated route/page ownership logic in the backend template layer.

Also added a previously missing /stream path to the spa exclusions
2026-05-13 22:26:21 +03:00
Antti Kettunen
736f243d5c
Simplify webui Vite asset injection 2026-05-13 22:26:21 +03:00
Antti Kettunen
ed8ff46c9c
Build webui assets in multi-stage Docker image
Add a Node-based webui builder stage that installs frontend dependencies and runs the Vite production build, then copies the generated static/dist assets into the final runtime image.

Move Python dependency installation into a separate venv build stage so compiler and development packages stay out of the runtime image. Also ignore local webui node_modules, Vite cache, and dist output from the Docker build context.
2026-05-13 22:26:21 +03:00
Antti Kettunen
d65ecbe464
Adopt TanStack Form for issue reporting
- Add @tanstack/react-form to the web UI dependencies

- Move the report issue composer fields and submit validation onto TanStack Form

- Route submit and server errors through form error state while keeping React Query for mutation execution

- Extend issue route coverage for preserving custom report titles across category changes
2026-05-13 22:26:20 +03:00
Antti Kettunen
577e4bdace
Migrate issue domain to React
- Mount a React-owned issue domain host and bridge report issue actions through it
- Add typed issue creation helpers, report payload types, and shared album workflow launchers
- Expand issue detail UI parity with metadata, links, track details, and admin actions
- Remove legacy static issue modal/list/detail code and update tests for the React bridge
2026-05-13 22:26:20 +03:00
Antti Kettunen
43db30608d
Add initial webui page migration analysis 2026-05-13 22:26:20 +03:00
Antti Kettunen
d98dcd8606
Initial Vite app scaffolding & issues page impl
- File-based routing with tanstack router
  - Persist top-level navigation state in url, even for most legacy pages
  - Striving for an intuitive and simple folder structure where
    route-related code is colocated, but the amount of files is still
    kept to a minimum
- Replace native fetch with `ky`
  - Familiar api, but more polished
2026-05-13 22:24:46 +03:00
BoulderBadgeDad
63f313d0c2
Merge pull request #574 from Nezreka/feature/write-artist-jpg-to-folder-for-navidrome
Write artist.jpg to artist folder so Navidrome shows real photos
2026-05-13 11:54:22 -07:00
Broque Thomas
fdda64963f Drop platform-biased trailing-backslash test for derive_artist_folder
POSIX os.path.dirname doesn't treat '\' as separator, so the
assertion 'Drake' in result fails on Linux CI even though the
function's rstrip removes the trailing backslash correctly.
The forward-slash test already covers the trim contract.
2026-05-13 11:52:04 -07:00
Broque Thomas
89246a7304 Write artist.jpg to artist folder so Navidrome shows real photos
Closes #572 (rhwc).

Navidrome has no API for setting an artist image — it reads
`artist.jpg` (or `folder.jpg`) from the artist folder during
library scans. SoulSync's `update_artist_poster` for Navidrome
was a no-op, so users only ever saw album-art-derived thumbnails
as the artist photo.

- new "Write Artist Image" button on artist detail page
- POST /api/artist/<id>/write-image-to-disk derives the artist
  folder from any track's resolved file_path (reuses
  _resolve_library_file_path so docker mount translation +
  library.music_paths probes from #558 apply), fetches the photo
  from the configured metadata source priority chain, downloads
  with content-type validation, writes atomically via
  `<filename>.tmp + os.replace`
- when active server is Navidrome, triggers a library scan
  immediately so the file is picked up
- respects existing artist.jpg (frontend prompts before
  overwriting) so user-supplied photos aren't clobbered
- works for plex / jellyfin too as a fallback layer — both
  servers also read artist.jpg from disk

26 tests pin the pure helpers in core/library/artist_image.py:
folder derivation (trailing sep / empty / non-string), URL
picking (missing attr / whitespace / non-string), download
(non-image content-type / 404 / timeout / empty body), atomic
write (replace / temp-cleanup-on-failure / overwrite guard /
missing folder).
2026-05-13 11:48:09 -07:00
BoulderBadgeDad
6390b09f70
Merge pull request #573 from Nezreka/feature/download-audit-trail-modal
Add per-download Audit Trail modal to Library History
2026-05-13 09:58:47 -07:00
Broque Thomas
641c72d7f1 Bump version to 2.5.2 2026-05-13 09:55:08 -07:00
Broque Thomas
6ce185491d Add per-download Audit Trail modal to Library History
- new "Audit" button on each download row in the library history
  modal opens a second modal visualizing the download lifecycle as
  an interactive horizontal stepper (request → source → match →
  verify → process → place) with click-to-expand detail cards
- hero header with album art + track title + meta line + status
  pills (source / quality / acoustid result)
- three tabs: Lifecycle / Tags / Lyrics
- Tags tab reads the audio file live via mutagen at audit-open
  time via new GET /api/library/history/<id>/file-tags endpoint;
  file is the single source of truth so background enrichment
  writes (audiodb / lastfm / genius / replaygain / lyrics fetch)
  show up too. flat key/value rows stacked vertically (label-above-
  value) so long MBIDs / URLs / joined genre lists wrap cleanly.
  source IDs grouped per-service into 2-col sub-card grid.
- Lyrics tab renders the full transcript with dimmed timecodes.
- post-processing step infers observable changes from source-vs-
  final state (format conversion, file rename via tag template,
  folder template).
- "Download History" button also added to the Downloads page batch
  panel header so it's reachable outside the dashboard.
- mobile responsive: tabs + stepper scroll horizontally, modal
  goes full-screen, hero stacks below 480px.

19 helper tests pin the mutagen reader: id3 (TIT2/TPE1/TALB + TXXX
+ USLT + APIC), vorbis (FLAC dict + _id/_url passthrough), file
metadata (format / bitrate / duration), defensive paths (empty /
missing file / mutagen returns None / mutagen raises), stringify
edge cases (list / tuple / int / frame-with-text / whitespace).
2026-05-13 09:50:24 -07:00
BoulderBadgeDad
253c7676d6
Merge pull request #570 from Nezreka/fix/album-type-fallback-legacy-path
Fix/album type fallback legacy path
2026-05-12 21:21:29 -07:00
Broque Thomas
46206b3240 Pin type='track' / type='artist' collision case for album-type normalizer 2026-05-12 21:15:58 -07:00
Broque Thomas
5eae24b8bb Fix $albumtype defaulting to album for non-Spotify sources
- legacy duck-typed builder only checked the `album_type` key; deezer
  uses `record_type`, tidal uses `type` (uppercase), some flattened
  musicbrainz shapes use `primary-type` — all defaulted to album, so
  EPs and singles ended up filed under Album/ in user templates that
  reference $albumtype
- widen lookup to album_type / record_type / type / primary-type and
  route through new pure `_normalize_album_type` helper that
  case-folds + validates against the canonical token set
  (album / single / ep / compilation), unknown → album
- typed-converter path (spotify / deezer / itunes / discogs / mb /
  hydrabase / qobuz) unchanged — those were already correct

Discord report (CAL).
2026-05-12 21:09:16 -07:00
BoulderBadgeDad
09597eb6db
Merge pull request #568 from Nezreka/release/2.5.1
Release/2.5.1
2026-05-12 20:04:21 -07:00
Broque Thomas
72b3e2195a Bump docker-publish workflow default version_tag to 2.5.1 2026-05-12 19:57:14 -07:00
Broque Thomas
1715e4d52f Bump version to 2.5.1 2026-05-12 19:55:06 -07:00
BoulderBadgeDad
e57f5675f9
Merge pull request #567 from Nezreka/feature/configurable-slskd-search-rate-limit
Add min delay between slskd searches (Bell Canada anti-abuse fix)
2026-05-12 19:15:51 -07:00
Broque Thomas
b9feed1a67 Add min delay between slskd searches (Bell Canada anti-abuse fix)
- new soulseek.search_min_delay_seconds knob forces a gap between
  consecutive searches; smooths the burst pattern that trips ISP
  anti-abuse (Reddit report: Bell Canada cuts the WAN after rapid
  peer-connection spikes) even when the existing 35/220 sliding-window
  cap isn't hit
- throttle math lifted to a pure compute_search_wait_seconds helper so
  the gate logic is testable independent of asyncio.sleep + the
  singleton client
- new field on settings → connections → soulseek; default 0 = disabled
  so existing users see no change

15 helper-boundary tests pin defaults / no-throttle, sliding-window
cap (legacy), min-delay (the new burst-smoother), max-of-both gates,
and defensive paths.
2026-05-12 19:11:12 -07:00
BoulderBadgeDad
e0bf595a35
Merge pull request #566 from Nezreka/fix/debug-info-music-source-and-missing-services
Fix Copy Debug Info music_source + surface missing services
2026-05-12 16:53:20 -07:00
Broque Thomas
6233860d66 Fix Copy Debug Info music_source + surface missing services
- music_source / spotify_connected / spotify_rate_limited were reading
  a non-existent 'spotify' key on _status_cache and silently falling
  through to the missing-value default (always 'unknown' / False).
  Routed through the canonical accessors get_primary_source +
  get_spotify_status now.
- added hydrabase_connected, youtube_available, hifi_instance_count,
  and always_available_metadata_sources so the debug dump reflects
  the full service surface
- removed a local re-import of get_spotify_status that was making
  python 3.12 treat the name as function-scoped, breaking the new
  lambda above it (NameError on free variable) — module-level import
  already exists

11 endpoint-level tests pin music_source / spotify_* / hydrabase_* /
youtube_available / always_available_metadata_sources / hifi_instance_count
and the defensive fall-through paths when each lookup raises.
2026-05-12 16:47:55 -07:00
BoulderBadgeDad
7ee01b80b7
Merge pull request #564 from Nezreka/fix/download-discography-skip-already-owned-tracks
Skip already-owned tracks during download discography
2026-05-12 15:19:50 -07:00
Broque Thomas
4892baf8d4 Skip already-owned tracks during download discography
- new track_already_owned helper wraps db.check_track_exists at
  the same confidence threshold the discography backfill repair job
  uses (0.7) — name+artist+album, format-agnostic so blasphemy-mode
  libraries (flac → mp3 + delete original) match correctly
- endpoint runs the check after the artist + content-type filters and
  before add_to_wishlist, so a second discography click on the same
  artist no longer re-queues every track that already downloaded
- per-album response carries a new tracks_skipped_owned counter
  alongside the existing artist/content/wishlist skip categories

Discord report (Skowl).
2026-05-12 15:10:41 -07:00
BoulderBadgeDad
fb1795cf6c
Merge pull request #563 from Nezreka/fix/download-discography-cross-artist-tracks-issue-559
Filter cross-artist + content-type tracks during download discography
2026-05-12 14:42:34 -07:00
Broque Thomas
d4ad5bf57f Filter cross-artist + content-type tracks during download discography
- drop tracks where the requested artist isn't named in track.artists
  (keeps features, drops compilation / appears_on contamination)
- honor watchlist.global_include_live/remixes/acoustic/instrumentals
  the same way the discography backfill repair job already does
- surface per-album skip counts in the ndjson stream (artist mismatch
  + content filter) so the ui can show what was filtered

Closes #559.
2026-05-12 14:38:17 -07:00
BoulderBadgeDad
e0d3b8220a
Merge pull request #562 from Nezreka/fix/album-completeness-error-message-navidrome-issue-558
Album Completeness: surface diagnostic when resolver can't find album…
2026-05-12 14:17:33 -07:00
Broque Thomas
7e70bbaaed Repair worker: log debug instead of bare pass on active-server lookup
Ruff S110 (try-except-pass) on the lookup inside
`_build_unresolvable_album_folder_error`. Swallowed exception is benign
(some test stubs don't expose `get_active_media_server` and we fall
back to 'unknown'), but ruff is right that bare pass is a smell.
Logger is the existing repair_worker logger, so this matches the same
"debug-log on optional-input failure" pattern used in
`core/library/path_resolver.py:_collect_base_dirs`.
2026-05-12 14:14:19 -07:00
Broque Thomas
56ae10693b Album Completeness: surface diagnostic when resolver can't find album folder
GitHub issue #558: clicking Auto-Fill / Fix Selected on the Album
Completeness findings page returned a flat "Could not determine album
folder from existing tracks" error with no diagnostic. Reporter is on
Navidrome on Docker — the path resolver in
`core/library/path_resolver.py` couldn't find any of the album's tracks
on disk because Navidrome's Subsonic API doesn't expose filesystem
library paths the way Plex's API does (probed in #476). Default
settings → `library.music_paths` empty → no base directories to probe →
silent None. User had no signal about what to configure.

Not a regression of #476 — that fix targeted Plex auto-discovery and
worked correctly for it. Navidrome was never covered because the
protocol gives the resolver nothing to probe.

Fix scoped to the diagnostic surface, not auto-magic discovery:

- Added `resolve_library_file_path_with_diagnostic` returning
  `(resolved, ResolveAttempt)`. ResolveAttempt records what the resolver
  tried — `raw_path_existed`, `base_dirs_tried`, `had_config_manager`,
  `had_plex_client`. Pure data, no rendering opinions.
- Legacy `resolve_library_file_path` becomes a thin wrapper that
  drops the attempt; every existing call site is unchanged.
- `RepairWorker._fix_incomplete_album` now uses the diagnostic helper
  and renders a multi-part error via `_build_unresolvable_album_folder_error`:
  names the active media server, shows one sample DB-recorded path,
  lists every base directory the resolver actually probed, and points
  the user at Settings → Library → Music Paths as the actionable fix.
- Distinguishes empty-base-dirs vs tried-and-failed cases so the user
  knows whether to add a mount or fix the existing one.
- No auto-probing of common Docker conventions (`/music`, `/media`, etc).
  Speculative — could resolve to wrong dirs on the suffix-walk if a
  conventional path happens to contain a partial collision. User stays
  in control.

12 new tests:
- 7 in `tests/library/test_path_resolver.py`: tuple-shape contract,
  raw-path-existed short-circuit, base-dirs listed even on walk
  failure, had-flags reflect caller inputs, no-base-dirs returns
  None with empty attempt, legacy `resolve_library_file_path`
  delegates correctly across happy / suffix-walk / failure paths.
- 8 in `tests/test_repair_worker_unresolvable_folder_error.py`:
  active server name in error, sample DB path verbatim, base dirs
  listed, empty-base-dirs phrased differently, Settings hint always
  present, defensive against None attempt / missing sample / missing
  config_manager.

Full pytest sweep: 2774 passed.
2026-05-12 14:04:15 -07:00
BoulderBadgeDad
d10546a9bc
Merge pull request #561 from Nezreka/fix/auto-import-multi-source-fallback
Fix/auto import multi source fallback
2026-05-12 13:17:31 -07:00
Broque Thomas
698ecc99f0 Import history: Clear History button now sweeps stuck 'processing' rows
Reported: Clear History button on the Import page left zombie rows
behind. Every survivor showed "⧗ Processing" status from 2-9 days ago.

Trace: `_record_in_progress` inserts a `status='processing'` row up-front
so the UI can render the in-flight import while it runs; `_finalize_result`
updates it to `completed`/`failed` when the import finishes. When the
worker is killed mid-import (server restart, crash), the row never gets
finalized — stays at `processing` forever. The clear-history endpoint's
SQL `DELETE ... WHERE status IN (...)` listed every terminal status but
omitted `processing`, so zombies survived every click.

Fix: add `processing` to the delete list, but guard against nuking
genuinely-live imports by intersecting against the worker's
`_snapshot_active()` map — any folder hash currently registered in
`_active_imports` is excluded from the delete via an `AND folder_hash
NOT IN (...)` clause. `pending_review` deliberately left out so user
still has to approve/reject those explicitly.

One endpoint touched (`/api/auto-import/clear-completed` in
web_server.py). No worker changes — guard reuses the existing
`_snapshot_active()` method that the UI poller already calls.

5 new tests in `tests/imports/test_auto_import_clear_completed_endpoint.py`:
- Zombie `processing` rows swept, live `processing` row preserved
  (folder_hash currently in `_active_imports` survives)
- Response count matches actual delete count
- Empty active-set branch (unparameterized DELETE) — pinned because
  an empty SQL `IN ()` would be a syntax error
- Worker-unavailable returns 500 (pre-existing guard not regressed)
- `pending_review` rows always survive — never auto-swept

Full pytest sweep: 2758 passed (one pre-existing flaky timing test
on `test_import_singles_parallel.py` failed under full-suite CPU load,
passes in isolation in 2.95s — unrelated to this change).
2026-05-12 12:53:37 -07:00