Commit graph

2634 commits

Author SHA1 Message Date
Broque Thomas
ddef904414 Match featured-artist tracks across discography completion
Discord-reported scenario: a single "Super Single" by Artist1 feat.
Artist2 is also on Artist1's "Super Album". When the album is fully
owned, Artist1's discography correctly shows the single as complete,
but Artist2's discography (where the same track also appears as a
single) shows it as missing.

Two layers needed for the fix:

Scanner: the Jellyfin/Emby path was keeping only ArtistItems[0],
which is almost always equal to the album artist — so the
distinguishing per-track credit was silently suppressed. Now joins
every ArtistItems entry with "; " and stores the value when there
are multiple credits OR when the single credit differs from the
album artist. Plex's originalTitle already carries the full multi-
artist tag, so Plex users benefit without needing the scanner change.

Scorer: _calculate_track_confidence now splits track_artist on the
common multi-artist delimiters real-world tags use (",", ";", "&",
"feat.", "ft.", "featuring", "vs.", "x") and scores each piece
independently against the search artist, taking the max along with
the whole-string similarity as the floor. Never reduces a score —
purely additive matching for previously-missed featured-artist
credits.

Adds 12 regression tests covering the reported scenario, primary-
artist back-compat, every delimiter variant (parametrized), no-
regression on exact match, and the scanner storing every ArtistItem.

Existing Jellyfin-scanned rows persist their old single-artist value
until the next library scan rewrites them; Plex rows benefit
immediately on next match without needing a rescan.
2026-04-30 19:31:11 -07:00
BoulderBadgeDad
cbafd07009
Merge pull request #451 from Nezreka/fix/match-against-track-artist
Match soundtrack tracks against per-track artist, fix dead fallback
2026-04-30 17:32:23 -07:00
Broque Thomas
f1ec62bad3 Add fallback negative-case test for track-artist matching
Re-enabling the previously-dead album-aware fallback could in
theory leak false positives if its 0.8 album-title floor were
ineffective. Pin the floor with a clearly-mismatched album hint
("Disney Hits" against "Ray of Light") and assert the search
returns no match. Distinct artist names with no shared words so
the main path actually fails through to the fallback (the prior
draft used "Different Artist" / "Real Artist" which both contain
"Artist" and scored above the main path's threshold, never
reaching the fallback at all).
2026-04-30 17:13:15 -07:00
Broque Thomas
345273df22 Match soundtrack tracks against per-track artist, fix dead fallback
Two bugs surfacing the same user-reported symptom: a Vaiana OST
track ("Where You Are" by Christopher Jackson) wouldn't match against
a Plex/Emby library because the album sits under the album artist
(Lin-Manuel Miranda).

Bug 1: the data was already there but scoring ignored it. The DB
schema has a tracks.track_artist column, the scanner populates it
from Plex's originalTitle and Jellyfin's ArtistItems[0], and the SQL
WHERE clause already searches it — but _rows_to_tracks dropped the
column on its way to the Python object, and _calculate_track_confidence
only scored against the album-artist JOIN. Candidates whose track-
artist matched got returned by the search and then immediately
filtered out by the low confidence score.

Fix: _rows_to_tracks now propagates row['track_artist'] onto the
returned object, and _calculate_track_confidence takes the better of
(album-artist similarity, track-artist similarity) so soundtracks
match through whichever credit the search query carries.

Bug 2: the album-aware fallback path constructed DatabaseTrack with
kwargs the dataclass doesn't accept (artist_name, album_title,
server_source). Every row TypeError'd, the outer except swallowed it
silently, and the fallback never matched anything since the column
was added — invisible because nothing logged it.

Fix: build DatabaseTrack with valid fields and attach the joined
columns afterwards, the same pattern _rows_to_tracks uses.

Adds 6 regression tests covering: track-artist match (the OST case),
album-artist still matches, scorer takes the better of the two,
defensive handling for tracks without track_artist, search-path
attribute propagation, and the previously-dead album-aware fallback.
2026-04-30 16:35:20 -07:00
BoulderBadgeDad
d8d25a4846
Merge pull request #450 from Nezreka/fix/automation-engine-handler-error-storage
Surface handler-returned errors in automation last_error
2026-04-30 15:51:38 -07:00
Broque Thomas
7698405f58 Surface handler-returned errors in automation last_error
The "Clean Search History" automation card kept showing a stale
'DownloadOrchestrator' object has no attribute 'base_url' error
even after the underlying handler bug was fixed in 77d20e9. Root
cause is in the engine, not that handler: AutomationEngine only
captured uncaught exceptions into last_error. Handlers that
report failure by RETURNING {'status': 'error', ...} were treated
as successful from the engine's perspective, so subsequent
gracefully-failing runs never updated the row to reflect the
current state.

Both the timer (run_automation) and event (_handle_event_trigger)
paths now extract the error string from a result whose status is
'error', falling through 'error' -> 'reason' -> 'message' -> a
placeholder so last_error is never None on actual failures
regardless of which key the handler chose. Existing behaviour for
raised exceptions and successful runs is preserved.

Also normalizes _auto_clean_search_history's return key from
'reason' to 'error' so older deployed engines that only check
the canonical key still see the failure.

Adds 7 regression tests covering every result shape the engine
might receive.
2026-04-30 15:45:28 -07:00
BoulderBadgeDad
05a4342ac8
Merge pull request #445 from kettui/refactor/remove-quality_scanner-spotify-prio
Refactor quality scanner to respect primary metadata provider
2026-04-30 14:49:50 -07:00
BoulderBadgeDad
09591ad089
Merge pull request #449 from Nezreka/fix/duplicate-detector-mount-paths
Filter same-physical-file duplicates from duplicate detector
2026-04-30 14:12:53 -07:00
BoulderBadgeDad
b62182a3f5
Merge pull request #448 from Nezreka/fix/test-fixture-real-db-write-prevention
Stop config retry tests from writing to the real DB
2026-04-30 14:09:48 -07:00
Broque Thomas
b9c8245c49 Stop config retry tests from writing to the real DB
The fixture used the wrong env var name (SOULSYNC_DB_PATH) when trying
to redirect ConfigManager at a tmp directory. ConfigManager actually
reads DATABASE_PATH (config/settings.py:49), so the test ConfigManager
loaded — and then saved — at the user's real database/music_library.db.
The retry stub in test_lock_errors_during_retries_log_at_debug_not_error
calls the real _save_to_database after its mocked failures, which then
clobbered the encrypted app_config row with the test fixture's stub
payload {"plex": {"base_url": "http://example.test"}}.

Three layers of fix so this can't happen again:
- Use the correct env var (DATABASE_PATH).
- Pin mgr.database_path / mgr.config_path on the instance after
  construction, so the test fixture's tmp paths win even if
  ConfigManager's resolution logic changes.
- Assert the resolved database_path is rooted under tmp_path before
  returning the fixture, so the test refuses to run if it would touch
  a non-tmp DB.
2026-04-30 14:07:00 -07:00
Broque Thomas
382e427117 Filter same-physical-file duplicates from duplicate detector
When users bind the same host music directory into both SoulSync
(e.g. /app/Transfer) and a media server like Plex (e.g.
/media/Music), both scans add a track row pointing at the same
physical file via different mount paths. The detector previously
flagged those as duplicate groups even though there's only one
file on disk.

New _is_same_physical_file helper filters pairs where:
- The trailing 3 path segments match (filename + album + artist
  folder), so they're the same release on disk.
- The leading mount roots actually differ.
- Durations agree within 1s when both rows carry duration data.

Adds 10 regression tests covering the reported scenario plus
edge cases (Windows separators, case differences, missing
durations, sibling-album false-positive guard).
2026-04-30 14:06:10 -07:00
BoulderBadgeDad
07d175ac60
Merge pull request #447 from Nezreka/fix/config-db-locking
Fix config DB lock spam on slow disks (#434)
2026-04-30 13:20:48 -07:00
Broque Thomas
4238aeb4d9 Add regression tests for config DB retry behaviour (#434)
Pin the new save-retry contract so future changes can't silently
re-introduce the spam reported in #434:

- Happy-path saves emit zero ERROR logs.
- Transient locks during retries log at DEBUG, not ERROR.
- Six attempts run before giving up, with the documented backoff
  schedule (0.2 + 0.5 + 1.0 + 2.0 + 4.0s).
- Genuine exhaustion logs a single ERROR and writes config.json.
- sqlite3.OperationalError("database is locked") routes to DEBUG;
  any other OperationalError still logs ERROR.
- _connect_db() actually applies WAL + busy_timeout + synchronous=NORMAL.

Also moves `import time` from inside _save_config to the module
top so the tests can monkeypatch sleep cleanly.
2026-04-30 13:17:41 -07:00
Broque Thomas
7b2324f52e Fix config DB lock spam on slow disks (#434)
User on Docker + HDDs saw "database is locked" errors on every
settings save. Two retries spaced 1 second apart isn't enough when
an enrichment worker is mid-commit on spinning-rust storage, and
each failed attempt logged at ERROR — multiplying the spam.

Three changes in config/settings.py:

- Centralized connection setup in a new _connect_db() helper that
  always sets PRAGMA journal_mode=WAL + busy_timeout=30000 +
  synchronous=NORMAL. NORMAL is the safe pairing with WAL and avoids
  the per-commit fsyncs that make FULL brutal on HDDs, shrinking the
  window the competing writer holds the lock.
- _save_to_database logs lock errors at DEBUG instead of ERROR. The
  retry loop owns the user-visible message; otherwise every retry
  spammed even when the next attempt succeeded.
- _save_config now retries 6 times with exponential backoff
  (0.2 + 0.5 + 1.0 + 2.0 + 4.0s ≈ 7.7s of sleep, on top of the 30s
  busy_timeout each attempt already runs internally) before logging
  a single error and falling back to config.json.

Closes #434.
2026-04-30 13:04:09 -07:00
BoulderBadgeDad
5646b54182
Merge pull request #446 from Nezreka/fix/bulk-discography-source-context
Fix bulk discography losing album source context (#399)
2026-04-30 12:46:21 -07:00
Broque Thomas
9534843edb Fix bulk discography losing album source context (#399)
The bulk download_discography endpoint picked one metadata client
based on the configured primary source and called .get_album() on
every album with that single client. Albums whose IDs came from a
fallback/provider-specific source (e.g. Deezer-formatted IDs surfaced
through Hydrabase) failed with "Album not found" because the primary
client couldn't resolve them.

Bulk now uses the same source-aware resolver
(core.metadata.album_tracks.get_artist_album_tracks) the working
individual-album endpoint already uses, so the resolver's source-chain
walk finds each album under whichever provider actually has it. Also
adds explicit Discogs and Hydrabase support (the old if/elif chain
silently 500'd for those primaries).

Frontend (library.js + pages-extra.js) now sends a richer
`{ albums: [{id, name, artist_name, source}] }` payload so each album
can be resolved through its own source. The legacy `album_ids` payload
still works as a fallback path.

Closes #399.
2026-04-30 12:42:46 -07:00
Antti Kettunen
fb190e16ca
Coerce wishlist track counts before category checks
- normalize album.total_tracks before comparing it in wishlist classification
- avoid mixed-type comparisons when provider payloads serialize track counts as strings
- add regression coverage for numeric strings and invalid values
2026-04-30 21:42:43 +03:00
Antti Kettunen
2bc8e8a27b
Preserve artwork in quality scanner wishlist handoff
- carry track-level album art through the quality scanner normalization path
- preserve artist artwork when provider results expose it
- keep album.image_url and album.images populated so the wishlist UI can render the cover consistently
- add a regression test covering provider payloads with image_url on both the track and artist
2026-04-30 21:42:16 +03:00
BoulderBadgeDad
7556e79dbd
Merge pull request #444 from Nezreka/refactor/lift-redownload-start
Lift redownload_start to core/library/redownload.py
2026-04-30 11:31:49 -07:00
Broque Thomas
b395e33820 Lift redownload_start to core/library/redownload.py
Body byte-identical to the original. Spotify proxy via registry,
iTunes/Deezer client shims wrap registry helpers,
_resolve_library_file_path, _attempt_download_with_candidates, and
missing_download_executor are injected via init() right after
_init_wishlist_failed where all three deps are already defined.

web_server.py: 35239 → 35063 (-176 lines).
2026-04-30 11:27:33 -07:00
Antti Kettunen
761fc29523
Mock streaming prep sleep in tests
- add an autouse fixture that patches stream-prep sleep to a no-op
- keep the polling branches covered while removing the long test delay
2026-04-30 21:26:28 +03:00
Antti Kettunen
c97a072f54
Refactor quality scanner to respect primary metadata provider
- search metadata providers in source-priority order for each generated query instead of caching one client for the whole scan
- keep the quality-scanner worker provider-neutral and preserve the no-provider error path
- update the quality-scanner tests and remove the obsolete web_server spotify_client injection
2026-04-30 21:25:39 +03:00
BoulderBadgeDad
eded0511e2
Merge pull request #443 from Nezreka/refactor/lift-process-failed-wishlist
Lift _process_failed_tracks_to_wishlist_exact to core/downloads/wishl…
2026-04-30 11:06:42 -07:00
Broque Thomas
599426dbaf Lift _process_failed_tracks_to_wishlist_exact to core/downloads/wishlist_failed.py
Body byte-identical to the original. Wishlist helpers come from
core.wishlist.* directly (aliased to the same names the body uses);
runtime state from core.runtime_state. automation_engine,
soulseek_client, and _sweep_empty_download_directories are injected
via init() right after _init_download_validation.

web_server.py: 35408 → 35239 (-169 lines).
2026-04-30 11:03:44 -07:00
BoulderBadgeDad
cacbb010e5
Merge pull request #441 from Nezreka/refactor/lift-get-valid-candidates
Lift get_valid_candidates to core/downloads/validation.py
2026-04-30 10:19:40 -07:00
Broque Thomas
c8bd9d85dd Lift get_valid_candidates to core/downloads/validation.py
Body byte-identical to the original. matching_engine and
soulseek_client are injected via init() right after _init_discover_hero
since both originals are constructed early in web_server.py boot
(L598/L610) and never rebound.

web_server.py: 35586 → 35408 (-178 lines).
2026-04-30 10:15:31 -07:00
BoulderBadgeDad
a7ac4e48a4
Merge pull request #440 from Nezreka/refactor/lift-discover-hero
Lift get_discover_hero to core/discovery/hero.py
2026-04-30 09:52:07 -07:00
Broque Thomas
181011d5be Lift get_discover_hero to core/discovery/hero.py
Body byte-identical to the original. Spotify proxy via registry,
_get_active_discovery_source and get_current_profile_id redefined
as stateless shims, _get_metadata_fallback_client injected via init()
because it composes multiple registry helpers wired in web_server.py.

web_server.py: 35753 → 35586 (-167 lines).
2026-04-30 09:47:06 -07:00
BoulderBadgeDad
7db049ce99
Merge pull request #439 from Nezreka/refactor/lift-discovery-scoring
Lift discovery scoring + tidal-track search to core/discovery/scoring.py
2026-04-30 09:18:47 -07:00
Broque Thomas
a4eccff4a5 Lift discovery scoring + tidal-track search to core/discovery/scoring.py
Both function bodies (_discovery_score_candidates and
_search_spotify_for_tidal_track) are byte-identical to the originals.
The shared matching_engine instance is injected via init() right after
_init_connection_test; the spotify proxy + _get_metadata_fallback_source
shim follow the same pattern used elsewhere.

web_server.py: 36019 → 35753 (-266 lines).
2026-04-30 09:08:47 -07:00
BoulderBadgeDad
7158130d5a
Merge pull request #438 from Nezreka/refactor/lift-debug-info
Lift get_debug_info to core/debug_info.py
2026-04-30 08:49:55 -07:00
Broque Thomas
de1b4c9b3c Lift get_debug_info to core/debug_info.py
Both function bodies byte-identical to the originals. The spotify
proxy resolves through core.metadata.registry; the tidal proxy is
backed by an injected getter so a Tidal re-auth that rebinds
web_server.tidal_client is visible. 13 state dicts and helpers are
injected via init() after _init_connection_test, when all deps
already exist.

web_server.py: 36260 → 36019 (-241 lines).
2026-04-30 08:44:34 -07:00
BoulderBadgeDad
afd5d98136
Merge pull request #435 from kettui/refactor/provider-agnostic-wishlist-schema
Make wishlist flow provider-agnostic
2026-04-29 22:51:18 -07:00
BoulderBadgeDad
a33689715b
Merge pull request #436 from Nezreka/refactor/lift-run-detection
Lift run_detection to core/connection_detect.py
2026-04-29 22:45:20 -07:00
Broque Thomas
0cacbd6b5e Lift run_detection to core/connection_detect.py
Body byte-identical to the original. Pure stdlib + requests, no
web_server-specific globals or runtime state — no init() needed.

web_server.py: 36500 → 36261 (-239 lines).
2026-04-29 22:40:34 -07:00
Antti Kettunen
fd30d2a0be
Rename wishlist lifecycle helper
- Switch the download lifecycle over to the neutral wishlist track helper name
- Keep the old Spotify helper as a compatibility alias for older callers
- Store track_data as the primary failed-download wishlist payload key and add regression coverage
2026-04-30 08:06:02 +03:00
Antti Kettunen
b1a9c1b458
Accept wishlist track_data aliases
- Let the wishlist service accept both track_data and spotify_track_data
- Preserve the backward-compatible wrapper while avoiding the keyword argument crash
- Add a regression test for the alias path
2026-04-30 07:59:43 +03:00
Antti Kettunen
7a9f074a70
Normalize wishlist UI copy
- Replace Spotify-only labels in the wishlist and matching surface with metadata/provider-neutral wording
- Keep the existing matching behavior intact while removing the most visible Spotify-first text
2026-04-30 07:54:17 +03:00
Antti Kettunen
0fa692f935
Make wishlist respect configured providers
- add neutral wishlist payload helpers while keeping legacy Spotify aliases
- route wishlist removal and classification through generic track data
- keep API and service compatibility for existing callers
2026-04-30 07:41:22 +03:00
BoulderBadgeDad
b981871fd6
Merge pull request #432 from Nezreka/refactor/lift-connection-test
Lift run_service_test to core/connection_test.py
2026-04-29 21:05:55 -07:00
Broque Thomas
32c57124fb Lift run_service_test to core/connection_test.py
Body byte-identical to the original. Five deps (soulseek_client,
qobuz_enrichment_worker, hydrabase_client, docker_resolve_url,
docker_resolve_path) are injected via init() right after the
register_runtime_clients block — that is the earliest point at which
hydrabase_client is guaranteed to exist.

web_server.py: 36833 → 36500 (-333 lines).
2026-04-29 21:00:23 -07:00
BoulderBadgeDad
167718d694
Merge pull request #431 from Nezreka/refactor/lift-duplicate-cleaner
Lift _run_duplicate_cleaner to core/library/duplicate_cleaner.py
2026-04-29 20:14:53 -07:00
Broque Thomas
8299dc211e Lift _run_duplicate_cleaner to core/library/duplicate_cleaner.py
Body byte-identical to the original. The shared state dict, lock,
docker_resolve_path helper, and automation engine are injected via
init() at the lift point, where all four originals are already defined.

web_server.py: 37015 → 36833 (-182 lines).
2026-04-29 20:10:22 -07:00
BoulderBadgeDad
8a46b7ef68
Merge pull request #430 from Nezreka/refactor/lift-search-service
Lift _search_service to core/library/service_search.py
2026-04-29 18:09:20 -07:00
Broque Thomas
dae7f21265 Lift _search_service to core/library/service_search.py
Lifts _search_service and its _detect_provider helper. Both bodies are
byte-identical to the originals. The nine enrichment worker handles
(spotify/itunes/mb/lastfm/genius/tidal/qobuz/discogs/audiodb) are
injected via init() right after qobuz is constructed, which is the
last worker to come up — and well before Flask starts accepting
requests, so the route handlers never see unbound workers.

web_server.py: 37245 → 37015 (-230 lines).
2026-04-29 18:06:23 -07:00
BoulderBadgeDad
423242a18a
Merge pull request #429 from Nezreka/refactor/lift-liked-artist-match
Lift liked-artist matching to core/artists/liked_match.py
2026-04-29 17:38:36 -07:00
Broque Thomas
0e237f14d4 Lift liked-artist matching to core/artists/liked_match.py
Lifts _match_liked_artists_to_all_sources and
_backfill_liked_artist_images. Both bodies are byte-identical to the
originals. Uses the same _SpotifyClientProxy + _get_*_client shim
pattern as core/artists/map.py so the bodies resolve their original
names without modification.

web_server.py: 37501 → 37245 (-256 lines).
2026-04-29 17:34:28 -07:00
BoulderBadgeDad
3714a46434
Merge pull request #428 from Nezreka/refactor/lift-download-monitor
Lift WebUIDownloadMonitor to core/downloads/monitor.py
2026-04-29 16:50:39 -07:00
Broque Thomas
9e8787c002 Lift WebUIDownloadMonitor to core/downloads/monitor.py
Class body byte-identical to original. Module-level IS_SHUTTING_DOWN
flag is mirrored from web_server's own flag in _shutdown_runtime_components
so the monitor loop still sees shutdown signals at the right moment.

Eight web_server-side helpers (_make_context_key, _on_download_completed,
_run_post_processing_worker, _download_track_worker,
_start_next_batch_of_downloads, _orphaned_download_keys,
missing_download_executor, soulseek_client) are injected via init() after
register_runtime_clients, when all symbols are defined and well before
Flask starts accepting requests.

web_server.py: 38220 → 37501 (-719 lines).
2026-04-29 16:28:52 -07:00
BoulderBadgeDad
e25daa6914
Merge pull request #427 from Nezreka/refactor/lift-artist-map
Lift artist map endpoints to core/artists/map.py
2026-04-29 15:51:27 -07:00