Commit graph

2981 commits

Author SHA1 Message Date
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
Broque Thomas
3af2d34cee Auto-import: fall through to other metadata sources when primary returns no match
Discord report: 16 Bandcamp indie albums sat in staging because
auto-import couldn't identify them, but the manual search bar at the
bottom of the Import Music tab found the same albums fine. Trace:
`_search_metadata_source` only queried `get_primary_source()` — single
source, no fallback. Meanwhile `search_import_albums` (manual search bar)
already iterated `get_source_priority(get_primary_source())` and broke
on the first source with results. Asymmetric behavior, same album: manual
worked, auto-import didn't.

Fix: lift `_search_metadata_source` to use the same source-chain pattern.
Try primary first; if it returns nothing OR scores below the 0.4
threshold, fall through to the next source in priority order. First
source producing a strong-enough match wins. Result dict carries the
`source` that actually matched (not the primary name) so downstream
`_match_tracks` calls the right client. Defensive per-source try/except
so a rate-limited or auth-failed source doesn't abort the chain.
Unconfigured sources (client=None) silently skipped.

Cin-shape lift: scoring math extracted to pure `_score_album_search_result`
helper so the weight tweaks (album 50% / artist 20% / track-count 30%)
are pinned at the function boundary, independent of the orchestrator
(per-source iteration, exception containment, threshold check). Weight
constants exposed at module level (`_ALBUM_NAME_WEIGHT`,
`_ARTIST_NAME_WEIGHT`, `_TRACK_COUNT_WEIGHT`) — greppable, bumpable in
one place. Pre-extraction these were magic numbers inline.

27 new tests:
- 9 integration tests in `test_auto_import_multi_source_fallback.py`:
  primary-success path unchanged (no fallback fires, only primary client
  called), primary-empty falls through, primary-weak-score falls through,
  first fallback success stops the chain (no wasted API calls on
  remaining sources), all-sources-fail returns None, per-source
  exception contained, unconfigured-source skipped, result `source`
  field reflects winning source, `identification_confidence` from
  winning source.
- 18 helper tests in `test_album_search_scoring.py`: weights sum to
  1.0, album weight dominant (invariant pin), perfect-match returns 1.0,
  per-component contribution (album / artist / track-count), Bandcamp
  vs streaming track-count mismatch (7-files vs 4-tracks case still
  scores ~0.87 above threshold), zero-track-count and zero-file
  guards, huge-mismatch non-negative guard, list-of-strings artist
  shape, missing `.name` / `.artists` / `None` total_tracks edge cases.

Backwards compatible: single-source users see no change — chain just
has one entry. Existing test `test_search_metadata_source_extracts_artist_id_from_dict_artist`
needed one extra patch line for `get_source_priority`.

Full pytest sweep: 2754 passed.
2026-05-12 12:32:18 -07:00
BoulderBadgeDad
adadbf03bd
Merge pull request #557 from Nezreka/fix/multi-artist-tag-settings
Fix/multi artist tag settings
2026-05-11 15:56:54 -07:00
Broque Thomas
d5de724f9b Multi-artist Deezer upgrade + double-append guard hardening
Two follow-ups to the multi-artist tag settings PR:

1. Deezer contributors upgrade — closes the "known limitation"
   flagged in the prior commit. Deezer's `/search` endpoint only
   returns the primary artist for each track; the full contributors
   array (feat., remix collaborators, producers credited as artists)
   lives on `/track/<id>` and gets parsed by `_build_enhanced_track`.
   Without the upgrade Deezer-sourced tracks never got multi-artist
   tags even with the right settings on.

   Fix in `core/metadata/source.py`: when source==deezer AND the
   search response had a single artist AND a track_id is available,
   fetch full track details via `get_deezer_client().get_track_details`
   and replace `all_artists` with the upgraded list.

   - One extra API call per affected Deezer track
   - Skipped when search already returned multiple (no-op fast path)
   - Skipped for non-Deezer sources (Spotify/Tidal/iTunes search
     responses already include all artists)
   - Skipped when no track_id is available
   - Defensive try/except: on /track/<id> failure (network error,
     deezer client unavailable), fall through to the search-result
     list — never lose the data we already had

2. Double-append guard hardened with a word-boundary regex.
   Prior commit checked for `"feat." not in title.lower() and "(ft."
   not in title.lower()` — too narrow. Source platforms produce
   wildly different feat-marker conventions: "(feat. X)", "(Feat X)",
   "(FEAT X)", "(Featuring X)", "[feat. X]", "ft. X" (no parens),
   "FT. X", etc. Any of these as the SOURCE title would cause a
   double-append: `"Track (Feat X) (feat. Y)"`.

   Replaced with `re.search(r'\b(?:feat|feat\.|featuring|ft|ft\.)\b',
   title, IGNORECASE)`. Word-boundary regex catches every common
   variant. Substring matches like "Aftermath" containing `ft`
   correctly fall through to the append path (pinned by a regression
   test).

16 new tests (29 total in the file):
- 9 parametrized variants of the double-append guard
- 1 substring guard ("Aftermath")
- 6 Deezer upgrade scenarios (fires when expected, doesn't fire
  for non-Deezer / multi-artist search / no track_id, defensive
  fall-through on failure, no false-positive when /track/<id>
  confirms single artist)

Full pytest 2727 passed.
2026-05-11 15:30:23 -07:00
Broque Thomas
c11a5b7eab Multi-artist tag settings: implement artist_separator + feat_in_title + populate _artists_list
Three settings on Settings → Metadata → Tags were partially or
completely unimplemented. Reporter (Netti93) traced each one.

(1) `write_multi_artist` only "worked" because of a never-populated
    `_artists_list` field. `core/metadata/source.py` built
    `metadata["artist"]` as a hardcoded ", "-joined string but never
    assigned `metadata["_artists_list"]`. `core/metadata/enrichment.py`
    line 107 reads that field and gates the multi-value tag write
    on `len(_artists_list) > 1` — always saw an empty list, silently
    no-op'd the write.

(2) `artist_separator` (default ", ") was referenced in the UI +
    settings.js save path but ZERO Python code read the value. Every
    multi-artist track ended up with hardcoded ", " regardless of
    what the user picked.

(3) `feat_in_title` (when true: pull featured artists into the title
    as " (feat. X, Y)" and leave only primary in the ARTIST tag —
    Picard convention) had no implementation at all.

Fix in source.py:

* Populate `_artists_list` from the search response's artists array
* Read `feat_in_title` and `artist_separator` configs
* When `feat_in_title=True` and >1 artist: ARTIST = primary only,
  append "(feat. X, Y)" to title with double-append guard
* Else: ARTIST = artists joined with `artist_separator`
* Single-artist case unaffected by either setting

Double-append guard uses a word-boundary regex catching all common
"feat" variants source platforms produce — `feat`, `feat.`,
`featuring`, `ft`, `ft.` — case-insensitive. Substring matches
(e.g. "Aftermath" containing "ft") correctly fall through to the
append path.

Fix in enrichment.py ID3 branch:

* TPE1 stays as the display string (with separator or primary-only
  per the user's settings)
* Multi-value list goes to a separate `TXXX:Artists` frame (Picard
  convention) when `write_multi_artist` is on
* Pre-fix the ID3 path wrote TPE1 twice — single-string then list
  — and the second `add` overwrote the first, clobbering both the
  configured separator AND the feat_in_title semantics. Vorbis path
  was already correct (separate "artist" + "artists" keys).

Known limitation (flagged in WHATS_NEW): Deezer's `/search` endpoint
only returns the primary artist. The full contributors array lives
on `/track/<id>`. Enrichment uses search-result data so Deezer-
sourced tracks may still get only the primary artist until a follow-
up commit wires the per-track contributors fetch into the enrichment
flow. Spotify, Tidal, and iTunes search responses include all
artists so they work now.

23 new tests in `tests/metadata/test_multi_artist_tag_settings.py`:

* `_artists_list` populated for multi/single/no-artist cases
* `artist_separator` drives ARTIST string (default ", " + custom
  ";" + custom "; " + " & ")
* Single-artist case unaffected by either setting
* `feat_in_title=True` pulls featured to title, leaves primary in
  ARTIST
* `feat_in_title` no-op for single artist
* Double-append guard recognizes 9 source-title variants ("(feat.
  X)", "(Feat. X)", "(FEAT X)", "(feat X)", "(Featuring X)",
  "[feat. X]", "ft. X", "(ft X)", "FT. X")
* Substring guard test pins "Aftermath" doesn't false-positive
* Combined-settings precedence: feat_in_title wins ARTIST string
  but `_artists_list` carries everyone for multi-value tag

Full pytest 2711 passed.
2026-05-11 15:16:42 -07:00
BoulderBadgeDad
3ee7a140db
Merge pull request #555 from Nezreka/fix/audiodb-track-enrichment-stuck-issue-553
AudioDB worker: stop infinite loop on direct-ID lookup failure (#553)
2026-05-11 14:21:26 -07:00
Broque Thomas
fc573a5f19 AudioDB worker: stop infinite loop on direct-ID lookup failure (#553)
Track enrichment was stuck in a constant retry loop. Logs showed
nothing but `Read timed out. (read timeout=10)` from
`lookup_track_by_id` repeating against the same track ID. AudioDB
itself was being hammered nonstop with no progress.

Cause: when an entity already has `audiodb_id` populated (from a
manual match or earlier scan) but `audiodb_match_status` is still
NULL — an inconsistent state some import paths can leave behind —
the worker tries a direct ID lookup. If that lookup fails (returns
None on timeout, which AudioDB's `track.php` endpoint hits
frequently because it's slow), the prior code logged "preserving
manual match" and returned WITHOUT marking status. Row stayed NULL
→ queue's NULL-status filter picked it up next tick → tried direct
lookup → timed out → returned → infinite loop.

The "preserve manual match" intent was correct: don't fall through
to the name-search path because that could overwrite a manually-set
`audiodb_id` with a wrong guess. Bug was the missing `_mark_status`
call before the early return.

Fix:

* `_process_item` direct-lookup-failure branch now calls
  `_mark_status(item_type, item_id, 'error')` before returning. The
  existing `audiodb_id` is preserved (column not touched). Queue's
  NULL-status filter no longer re-picks the row.

* `_get_next_item` retry-cutoff queue priorities (4/5/6) extended
  from `audiodb_match_status = 'not_found'` to
  `audiodb_match_status IN ('not_found', 'error')`. Same `retry_days`
  window. Transient AudioDB outages still recover automatically;
  permanently-broken IDs eventually get re-attempted once a month
  rather than staying errored forever.

5 new tests in `tests/test_audiodb_worker_stuck_track.py` use a real
SQLite DB (not mocks) so the SQL queries are actually exercised:

  - lookup-returns-None marks status='error' (no infinite loop)
  - lookup-raises-exception marks status='error' (defensive)
  - lookup-success preserves the existing match-success path
  - error-status row past retry-cutoff gets picked up again
  - error-status row within cutoff stays skipped (loop prevention
    works)

Only triggers for entities in the inconsistent `audiodb_id` set +
`match_status` NULL state. Happy path and already-matched /
already-not-found rows unchanged. Full pytest 2698 passed.

Closes #553.
2026-05-11 14:18:55 -07:00
BoulderBadgeDad
655c185026
Merge pull request #554 from Nezreka/fix/docker-staging-dir-permission-loop
Docker: pre-bake /app/Staging + writability audit (fix restart loop)
2026-05-11 13:38:12 -07:00
Broque Thomas
decb62dcc9 Docker: pre-bake /app/Staging + writability audit (fix restart loop)
Discord report: container refused to start after pulling latest.
Logs showed `mkdir: cannot create directory '/app/Staging':
Permission denied`. `set -e` in entrypoint.sh then aborted the script
and the container restart-looped.

Root cause traced to commit 70e1750 (2026-05-08, image-bloat fix):
the Dockerfile chown was changed from `chown -R /app` to a scoped
chown on specific subdirs to avoid a redundant layer that was
duplicating the entire /app tree. Side effects:

1. `/app` itself went from soulsync:soulsync (via the recursive walk)
   to root:root (Docker WORKDIR default — never re-chowned).
2. `/app/Staging` was the only runtime mount-point dir NOT pre-baked
   into the image — every other bind-mountable dir (config, logs,
   downloads, Transfer, MusicVideos, scripts) was in the Dockerfile's
   `mkdir -p` + `chown` list. Staging was left to the entrypoint.

On rootless Docker / Podman where in-container "root" maps to a host
UID, the entrypoint mkdir on `/app/Staging` could fail with EACCES
depending on the bind-mount path's host ownership.

Fix has three parts:

1. **Dockerfile** — added `/app/Staging` to the runtime mkdir +
   scoped chown list. Closes the asymmetry with the other bind-
   mountable dirs. Image now ships with the directory pre-baked
   owned soulsync:soulsync so the entrypoint mkdir is a guaranteed
   no-op even when bind-mount perms are weird.

2. **entrypoint.sh mkdir + chown** — both now have `|| true` so any
   future bind-mount permission quirk surfaces as a log line, not
   a `set -e` crash + restart loop. Previously only the chown had
   the `|| true` suffix; mkdir was bare.

3. **entrypoint.sh writability audit** — new loop at the end of
   the setup phase runs `gosu soulsync test -w "$dir"` against
   every bind-mountable dir. When a dir isn't writable by the
   soulsync user, logs a loud warning with the exact host-side
   `chown` command needed to fix it. Catches the underlying bind-
   mount perm issue that the restart-loop fix would otherwise mask
   (container starts but auto-import / downloads write into
   unwritable dirs and fail silently). This is the diagnostic that
   would have surfaced the root cause without needing the user to
   share a container-restart screenshot.

Zero behavior change for users whose containers were already
starting fine. Defensive against the rootless/podman config that
broke after the image-bloat refactor.

Verified shell syntax with `bash -n entrypoint.sh`. Full pytest
2693 passed (no Python touched).
2026-05-11 13:35:31 -07:00
BoulderBadgeDad
e3a4b513fd
Merge pull request #538 from kettui/fix/repair-worker-server-source
Preserve server source during album fill
2026-05-11 13:01:58 -07:00
BoulderBadgeDad
ebde676651
Merge pull request #552 from Nezreka/fix/your-albums-tidal-download-and-view-all
Your Albums: selectable wishlist modal + Tidal album resolution
2026-05-11 12:39:00 -07:00
Broque Thomas
4fb9f38798 Your Albums: selectable wishlist modal + Tidal album resolution
Two-part fix to the Your Albums "Download Missing" flow on Discover.

Part A — UX redesign

The prior `downloadMissingYourAlbums()` ran a per-album loop that
fired direct-download tasks via `openDownloadMissingModalForYouTube`.
Reported as silently failing — "Queuing 2/2" toast with no actual
transfer activity. Even when downloads worked, bypassing the
wishlist meant no retry / dedup / rate-limit / source-fallback
handling.

Replaced with a selectable-grid modal mirroring the Download
Discography pattern from the library page. Click the download
button → opens a checkbox grid showing every missing album (cover,
title, artist, year, track count, source) → user picks what they
actually want → click "Add to Wishlist" → each album's tracks get
resolved + queued through the existing wishlist auto-download
processor. NDJSON progress stream renders ✓/✗ per album.

New JS helpers:
- `_openYourAlbumsBatchModal(missingAlbums)` — builds the modal
- `_renderYourAlbumsBatchCard(row, index)` — per-album card
- `_yourAlbumsBatchSelectAll(select)` — bulk toggle
- `_updateYourAlbumsBatchFooterCount()` — live count + button text
- `_closeYourAlbumsBatchModal()` — overlay teardown
- `_startYourAlbumsBatchAddToWishlist()` — submit handler, NDJSON
  progress consumer
- `_yourAlbumsPickSource(album)` — picks the single best source-id
  per row (priority: spotify → deezer → tidal → discogs)

Reuses the `.discog-*` CSS classes from the library Download
Discography modal — no new CSS. Reuses the existing
`/api/artist/<id>/download-discography` endpoint. The endpoint's URL
artist_id param is functionally unused (per-album payload carries
everything — verified by reading the endpoint body), so the modal
posts with placeholder `your-albums` and gets multi-artist
resolution for free without backend changes.

Part B — Tidal album resolution

Reported as the original bug: clicking download on Tidal-only albums
did nothing because `/api/discover/album/<source>/<album_id>` had no
`tidal` branch and `tidal_client` had no `get_album_tracks` method.

`core/tidal_client.py`: new `get_album_tracks(album_id, limit=None)`
method. Two-phase: cursor-walk
`/v2/albums/<id>/relationships/items?include=items` for track refs +
position metadata (`meta.trackNumber` + `meta.volumeNumber`),
batch-hydrate via existing `_get_tracks_batch` for artist/album
names. Returns `Track` objects with `track_number` and `disc_number`
attached. Sort by (disc, track) so multi-disc compilations render in
album order.

`web_server.py`: new `'tidal'` source branch in
`/api/discover/album/<source>/<album_id>`. Resolves album metadata
via `get_album`, tracks via `get_album_tracks`, cover art via inline
`?include=coverArt` lookup. Same response shape as Spotify/Deezer
branches.

`webui/static/discover.js`:
- `tidal_album_id` added to `trySources` for the single-album click
  flow (`openYourAlbumDownload`)
- Same source picker drives the new batch modal
- Virtual-id generation includes `tidal_album_id` so Tidal-only
  albums get stable identifiers across discover-album-* / your-
  albums-* contexts

10 new tests in `tests/test_tidal_album_tracks.py` pin:
- Single-page walk + hydration
- Multi-page cursor chain
- Multi-disc sort order (disc 1 → 2 in track order each)
- `limit` short-circuit at page boundary
- No-token short-circuit (no API call)
- HTTP error returns empty
- 429 raises (propagates to `rate_limited` decorator for retry)
- Forward-compat type filter (skips non-track entries)
- Partial-batch hydration failure containment
- Empty-album short-circuit (no batch call)

Full pytest: 2693 passed.
2026-05-11 12:36:16 -07:00