mirror of https://github.com/Nezreka/SoulSync.git
dev
main
fix/quarantine-source-dedup
release/2.5.3
fix/disable-beatport-features
johnbaumb-discover-redesign
1.0
1.1
1.2
1.3
1.4
1.5
1.6
1.7
1.8
1.9
2.0
2.1
2.2
2.3
2.4.0
2.4.1
2.4.2
2.5.0
2.5.1
2.5.2
2.5.3
2.5.4
2.5.5
2.5.6
2.5.7
2.5.9
2.6.0
2.6.1
v0.65
${ noResults }
1130 Commits (d8d25a4846064bc7abbd6dff558f4c6f2a2d53e2)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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.
|
4 weeks ago |
|
|
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 |
4 weeks ago |
|
|
95b1a8507b |
replaced onclick handlers with event listeners to resolve possible xss vector from single quotes
|
4 weeks ago |
|
|
ef3790d146 |
change hifi instance DELETE to use query string
|
4 weeks ago |
|
|
788b7011d0 |
fix hifi instance reorder and enable/disable
|
4 weeks ago |
|
|
6ae1cb471e |
user-editable hifi instances
|
4 weeks ago |
|
|
6cdcf778f3 |
Lift /api/automations/* into core/automation/
Routes moved to thin parse-args/jsonify handlers; logic now lives in three focused modules under core/automation/. 436 lines deleted from web_server.py; 53 added back as wrappers. Module split: - core/automation/api.py — CRUD + run + history helpers. Each function takes (database, automation_engine, ...) explicitly and returns (response_body, http_status). Includes signal cycle detection preflight checks for create + update. - core/automation/progress.py — owns the in-memory progress state dict + lock (mirroring the original web_server.py globals as module-level shared state so all callers see one view), init/update/history helpers, and the WebSocket emit loop. - core/automation/signals.py — collect_known_signals for the builder autocomplete. Out of scope (deferred): - _register_automation_handlers — the 23+ action handler closures stay in web_server.py because each one is tightly coupled to feature- specific implementations (wishlist, watchlist, library scan, etc.). - Worker functions (_process_wishlist_automatically, etc.) — belong with their feature lifts. - _run_sync_task / _run_playlist_discovery_worker — sync + discovery PRs. Behavior preserved 1:1: - Same route response shapes + status codes - Same JSON field hydration (trigger_config, action_config, notify_config, last_result, then_actions) - Same backward-compat: empty then_actions + notify_type set → synthesize then_actions from notify_type/notify_config - Same signal cycle detection behavior on create + update - Same system-automation protection on delete + duplicate - Same reschedule/cancel logic on toggle + bulk-toggle + update - Same progress state shape (status, progress, phase, current_item, log capped at 50, started_at/finished_at, action_type) - Same emit-on-finish socketio push from update_progress - Same emit loop semantics (1s tick, snapshot active states, reap finished after window) Pre-existing bugs preserved (will fix in follow-up PRs): - emit_progress_loop uses naive datetime.now() against tz-aware started_at/finished_at, so the timeout-zombie check raises TypeError → caught → never fires, and the cleanup-after-window check raises → caught → state is reaped on FIRST tick regardless of the window. Tests document this behavior so the next PR can flip them to the corrected expectation. Tests: 72 new under tests/automation/ (signals 10, progress 24, api 38). Full suite: 861 passing (was 789). Ruff clean. |
4 weeks ago |
|
|
e309370862 |
Source picker: rename Soulseek icon to "Basic Search"
That source icon hits /api/search — raw slskd file results, the same flow the UI historically labelled "Basic Search" before the source-icon row replaced the dropdown. Reverting the label avoids implying it returns Soulseek-flavoured metadata results in the same shape as the other source icons. Backend route + endpoint name unchanged; this is display-only. |
4 weeks ago |
|
|
fd7b56e58c |
Lift /api/search and /api/enhanced-search/* into core/search/
Routes moved to thin parse-args/jsonify handlers; logic now lives in six focused modules under core/search/. 720 lines deleted from web_server.py; 109 added back as wrappers; ~700 lines of new core code plus ~700 lines of tests. Module split: - core/search/cache.py — TTL+LRU cache for enhanced-search responses, keyed by (query, active_server, fallback_source, hydrabase_active, source_tag) so config changes don't poison stale entries. - core/search/sources.py — per-kind metadata search (artists/albums/ tracks) and the multi-kind ThreadPoolExecutor that fans them out. - core/search/library_check.py — library + wishlist presence check with Plex thumb URL resolution; profile-aware wishlist with legacy fallback for older DBs missing the profile_id column. - core/search/stream.py — single-track preview search; effective stream mode resolution, query-variant generation, retry walk, matching engine integration. - core/search/basic.py — flat Soulseek file search, quality-sorted. - core/search/orchestrator.py — main enhanced-search dispatch (short-query fast path, single-source bypass, hydrabase-primary fan out, alternate source list builder), NDJSON streaming generator for /source/<src>, and the SearchDeps dataclass that bundles the cross-cutting deps. Routes pass clients (spotify, hydrabase, hydrabase_worker, soulseek) and helpers (config_manager, fix_artist_image_url, _is_hydrabase_active, _get_metadata_fallback_*, _run_background_ comparison, run_async, dev_mode_enabled_provider) into core/search via a SearchDeps bundle built per-request. fix_artist_image_url stays in web_server.py because it touches 31 other call sites. Behavior preserved 1:1: - Same response shapes (db_artists, spotify_artists, spotify_albums, spotify_tracks, primary_source, metadata_source, alternate_sources, source_available) - Same NDJSON line ordering (artists/albums/tracks as they finish, plus done marker) - Same per-kind exception swallowing - Same hydrabase-worker mirror on dev mode - Same cache key shape (5-tuple) and TTL/LRU semantics - Same stream-track effective-mode resolution including the Soulseek-coerce-to-YouTube edge case - Same library-check Plex thumb URL rewriting and wishlist fallback for older DBs Tests: 94 new (cache TTL/LRU/key, sources happy/partial/all-fail, library presence with library + wishlist + thumbs, stream effective mode + query gen + retry, orchestrator client resolution + short query + single source + fan-out alternates + hydrabase primary + NDJSON drain). Full suite: 788 passing (was 694). Ruff clean. |
4 weeks ago |
|
|
f51b75da7e |
Lift /api/stats/* and /api/listening-stats/* into core/stats/
Stats route logic moves into core/stats/queries.py as pure-ish functions that take dependencies (database, image-url fixer, listening worker) as arguments. The 13 route handlers in web_server.py shrink to thin parse-args / jsonify wrappers. What moved to core/stats/queries.py: - stats_cached: 3-key metadata cache lookup + image url fix-up - stats_overview / timeline / genres / library_health / db_storage - stats_top_artists / top_albums / top_tracks: top-N + DB enrichment - stats_recent: listening_history readback - stats_resolve_track: title+artist -> file_path lookup for playback - listening_stats_sync: spawns daemon thread that runs worker._poll - listening_stats_status: stats payload, with None-worker fallback shape No behavior change. Same response shapes, same error handling, same silent-except on per-row enrichment failure. fix_artist_image_url stays in web_server.py and is passed through as a callback so we don't have to lift its config_manager / media-server dependencies in this PR. Adds tests/stats/test_stats_queries.py — 27 tests covering happy paths, edge cases, image-url plumbing, worker glue. Ruff clean. 694 tests pass (was 667 + 27 new). |
4 weeks ago |
|
|
02305096a3
|
Tighten metadata and import safety
- Normalize album import track display handling so queue labels and match rows stay consistent - Bound MusicBrainz caches and avoid caching transient lookup failures - Stop swallowing programmer errors in source enrichment helpers - Restore import config test seams without reintroducing lazy imports - Guard task completion calls and fix the Windows path test expectation - Keep file lock tracking from growing without bound |
4 weeks ago |
|
|
d04573f397
|
Fix single import source handling
- pass the selected manual match through singles import - keep the import context source-aware so artist and album stay correct - avoid treating non-Spotify IDs as wishlist Spotify IDs - make wishlist logging and local variable names source-neutral |
4 weeks ago |
|
|
f11b91a5c6 |
Service worker for cover art + PWA manifest
Addresses #365 (reported by JohnBaumb), parts 3 & 5. Client-side IDB / sessionStorage data cache (part 4) deferred to its own PR. Cover art on Library and Discover used to re-fetch from the source CDN on every page visit. Now a service worker caches images locally in CacheStorage with cache-first strategy — second visit serves art instantly with zero network round-trips. PWA manifest added so the app is installable to home screen / desktop. Service worker (`webui/static/sw.js`): - Cache-first for images: 10 known CDN hosts (Spotify, Last.fm, Apple, Deezer, Discogs, MusicBrainz CAA, YouTube thumbnails) plus the local `/api/image-proxy` endpoint plus same-origin .png/.jpg/ .webp/.gif/.svg paths. Cross-origin file-extension matches are refused so we don't accidentally cache trackers. - Stale-while-revalidate for `/static/*`: serve cached instantly, refresh in background. Combined with the existing `?v=static_v` cache-bust, deploys still ship live (different query → different cache entry, old ages out). - HTML / API / everything else: no caching, pass through. - Cache-versioned (CACHE_VERSION = 'v1'); activate handler wipes any cache whose name doesn't match the current version. - skipWaiting + clients.claim so deploys propagate to open tabs without requiring a full close-and-reopen. PWA manifest (`webui/static/manifest.json`): - Standalone display mode, theme color #1db954 (matches --accent-rgb). - Two icons (192, 512) with both `any` and `maskable` purpose, generated from favicon.png with aspect-preserving transparent padding so the existing logo lands inside the safe zone for OS-applied masks. Wiring: - `web_server.py` adds a `/sw.js` route that serves the file from root scope (a service worker only controls URLs at or below its served path; `/static/sw.js` would scope to `/static/*` only). `Cache-Control: no-cache` on the SW response so deploys propagate on next page load instead of being pinned by the 1yr static cache the rest of /static/ uses. - `webui/index.html` adds the manifest link, theme-color meta, and an apple-touch-icon for iOS. - `webui/static/init.js` registers the SW on `window.load`. Feature-detected — no-op on browsers without serviceWorker support or on non-secure origins (SW requires https or localhost). One bug caught + fixed during line-by-line self-review: `_staleWhileRevalidate` could return null to `respondWith()` when both the cache miss AND the network fetch failed (the `.catch(() => null)` collapsed the rejection to null, which then short-circuited through the falsy chain). Now explicitly awaits the network promise and falls back to `Response.error()` when it resolves to null — matches the `_cacheFirst` pattern. Browser-verified: sw.js registers, status "activated and is running" in DevTools. 603 tests pass. |
4 weeks ago |
|
|
b0e7dae7c6 |
Cache static assets 1y + cache discover GETs 5min
Addresses #365 (reported by JohnBaumb), parts 1 & 2 of the proposal. Service worker, client-side IDB/sessionStorage, and PWA manifest deferred to follow-up PRs. 1. Static asset cache (CSS/JS/icons/fonts). `SEND_FILE_MAX_AGE_DEFAULT` flipped from 0 to 31536000 (1 year) in production. Safe because every static URL is bust-tagged with `?v=static_v` (computed once per process start), so each server restart effectively invalidates every cached asset for every user. Within a single deploy, repeat page loads hit zero round-trips on static files — was a 304 round-trip per asset before. Dev override (`SOULSYNC_WEB_DEV_NO_CACHE=1`) keeps it at 0 so iterating on JS/CSS doesn't need a server restart between edits. Collateral fixes from the bump: - Music streaming endpoint (L16140): `response.headers.add('Cache-Control', 'no-cache')` → bracket-assign. Under the old max-age=0, send_file set `no-cache` and `.add()` duplicated harmlessly. Under the new max-age=31536000, `.add()` would APPEND a second Cache-Control value → two conflicting headers, browser-undefined behavior. Bracket-assign replaces. - Backup download endpoint (L25181): explicit `Cache-Control: no-store` on the response so DB backups don't inherit the new long max-age — sensitive content, must never cache. 2. Discover GET browser cache (5 min). New `@app.after_request` hook scoped to `/api/discover/` and `/api/discovery/` paths, GET method, 2xx responses only. Sets `Cache-Control: public, max-age=300`. Skipped when the endpoint already set its own Cache-Control. Toggling between Discover sections within 5 min serves from browser cache, no backend hit. Try/except wraps the hook body and logs a warning if anything throws — never let a header-tagging bug turn a successful response into a 500. (Logging instead of `pass` since silent except-pass is exactly the anti-pattern issue #369 is about.) Audited every other Cache-Control set site in web_server.py — only the two `send_file` callers needed adjustment. Range-branch streaming uses `Response()` directly, unaffected by the config change. 603 tests pass. |
4 weeks ago |
|
|
01b7d50311 |
Gate /api/settings endpoints behind admin profile
Closes #370 (reported by JohnBaumb). The /api/settings endpoint and three siblings (/log-level, /config-status, /verify) had no auth check — any logged-in profile could read or modify service tokens, OAuth secrets, and API keys. Cin's "minimum" suggestion from the issue: gate to admin profile. Added an `admin_only` decorator near `get_current_profile_id` that returns 403 when the current profile isn't admin (id=1). Applied to all four endpoints. Auth model note (documented in the decorator docstring): SoulSync's existing model is "trust local network" — single-admin / no-multi- profile installs default `get_current_profile_id()` to 1, so the gate is a no-op for solo users. The decorator is meaningful in multi-profile setups where non-admin sessions exist. Tightening to real per-request auth is out of scope. Did NOT consolidate with api/settings.py (Cin's "better" suggestion): that endpoint uses API-key auth (for external tools), the web_server.py copy uses session/profile auth (for the web UI). Different consumers, different auth models — merging would break one or the other. 603 tests pass. |
4 weeks ago |
|
|
dd4cf130d7 |
Socket.IO CORS: handle self-review nits
Six items from a Cin-style line-by-line pass on PR #383: - resolve_cors_origins: list of non-string entries (`[None, 123]`) now drops them instead of coercing to junk strings like `'None'`/`'123'`. - will_reject: backwards-compat shim removed. Production callers always pass `request.scheme` (Flask-guaranteed); the shim only existed for tests/non-Flask callers and made the production code path branchier than necessary. Tests now pass scheme explicitly. - maybe_log: redundant `if not origin` early-return dropped. will_reject handles missing origin (engineio's own behavior — server.py:207). - RejectionLogger.__init__: `int(dedup_cap)` wrapped in try/except so bad-type input falls back to DEFAULT_DEDUP_CAP instead of raising. - web_server.py: docstring on the before_request hook explains why the hook fires on every request (Flask doesn't scope before_request to a path prefix; the early-return string compare is the cheapest option). - settings.js: cors-origins URL regex tightened from `[^\s/]+` to `[^\s/?#]+` so query/fragment chars don't pass validation. Engineio would silently fail to match those anyway; better to flag at save. Test changes: - parametrize gained an explicit `scheme` column (12 cases updated). - New explicit case: scheme-mismatch rejects (engineio compares full `{scheme}://{host}` strings). - `test_will_reject_falls_back_to_host_only_when_no_scheme_info` deleted — the shim it tested is gone. - `test_will_reject_honors_x_forwarded_host` now passes scheme info. Net: -9 production lines, -3 test lines. Production code path is straight-line. 603 tests pass. |
4 weeks ago |
|
|
efd2960629 |
Merge remote-tracking branch 'origin/dev' into fix/socketio-cors-wildcard
# Conflicts: # webui/static/helper.js |
4 weeks ago |
|
|
22fda5dd94 |
Trim yt-dlp pin comment, drop misleading WHATS_NEW page link
Self-review nits on PR #384: - requirements.txt: 5-line comment for one pin → 1 line. Rationale lives in commit body and #367; no need to repeat in-tree. - helper.js: dropped `page: 'settings'` from the yt-dlp WHATS_NEW entry. Settings page has no yt-dlp UI; the link would have navigated users somewhere irrelevant. 553 tests pass. |
4 weeks ago |
|
|
77a781caba |
Pin yt-dlp in requirements.txt, drop pip install from entrypoint
Closes #367 (reported by JohnBaumb). The Docker entrypoint ran `pip install -U yt-dlp --quiet --no-cache-dir` on every container start. Three problems with that: - Non-deterministic startup: each restart could pick up a different yt-dlp version, making "works on my machine" debugging harder. - Network dependency at boot: PyPI being slow/unreachable gated the app coming up. - In-place upgrades inside running containers can race with active yt-dlp invocations and aren't a great pattern. Picked Option A from the issue: pin to an exact version in requirements.txt (`yt-dlp==2026.3.17`) and remove the entrypoint install entirely. yt-dlp comes baked into the image now via the existing `pip install -r requirements.txt` in the Dockerfile. Tradeoff: YouTube fixes ship via SoulSync releases now instead of "next container restart". The pin is documented inline with how to bump it. Net change: -3 entrypoint lines, requirements.txt pin tightened, WHATS_NEW '2.4.1' block opened (entries hidden until version bumps). 553 tests pass. |
4 weeks ago |
|
|
0f24739e27 |
Socket.IO CORS: polish — match engineio exactly, bound dedup, validate URLs
Self-review pass on the security fix uncovered five issues, all fixed
here:
1. will_reject scheme handling. Engineio compares full {scheme}://{host}
strings, not just hostnames. A TLS-terminating proxy can leave the
backend seeing http while the browser's Origin is https — engineio
rejects, but the original predictor said "allow" → no helpful log
line. Added request_scheme + forwarded_proto params, build full
candidate strings to match engineio.
2. EITHER-forwarded-header rule. Engineio adds the forwarded candidate
when EITHER X-Forwarded-Proto OR X-Forwarded-Host is present (it
falls back to HTTP_HOST for the missing one). The original predictor
only added it when forwarded_host was set — false negative for
misconfigs sending only X-Forwarded-Proto. Now mirrors engineio.
3. will_reject incorrectly rejected missing-Origin requests. Engineio
(server.py:207: `if origin: validate`) skips CORS validation when
no Origin header is sent — non-browser clients (curl etc.) are
intentionally permitted. The original code rejected them. Test was
asserting the wrong behavior. Both fixed.
4. RejectionLogger had unbounded dedup set growth. A hostile actor
opening connections from many distinct fake origins would fill
memory unboundedly. Capped at 100 unique origins (configurable);
when cap hit, one overflow notice is emitted and further rejections
are silently dropped until restart.
5. Lock pattern: the overflow log path called logger.warning() while
holding the dedup lock, inconsistent with the normal path. Fixed
to pick the message under the lock and log after release. Critical
section is now minimal and uniform.
Plus polish:
- Stale module docstring fixed (said "empty list" instead of "None").
- settings.js validates each cors_origins line against a URL regex on
save; toasts a one-shot warning if entries are malformed (resolver
silently filters them, but user gets feedback now).
- web_server.py wiring passes request.scheme + X-Forwarded-Proto so
the predictor has full proxy info.
Tests:
- 51 unit tests in tests/test_socketio_cors.py (was 45). New cases:
* scheme comparison (5 cases including TLS-terminating proxies)
* forwarded_proto-alone misconfig
* missing-origin matches engineio (was asserting wrong behavior)
* dedup cap with overflow + reset
* default cap is reasonable (uses public DEFAULT_DEDUP_CAP constant)
Engineio behavior independently verified by reading engineio/server.py
and engineio/base_server.py source. Predictor mirrors both files.
604 tests pass.
|
4 weeks ago |
|
|
013eebf350 |
Lock down Socket.IO CORS — same-origin default + opt-in allow-list
Closes #366 (reported by JohnBaumb). Socket.IO was initialized with `cors_allowed_origins='*'`, accepting WebSocket connections from any origin. A malicious site could open a WS to a user's local SoulSync instance and exfiltrate live progress / toast / activity events. This commit: - Defaults to engineio's same-origin behavior (`cors_allowed_origins=None`), which automatically honors X-Forwarded-Host so reverse proxies that send that header (Caddy / Traefik by default, properly-configured Nginx) work transparently. - Adds a `security.cors_origins` config setting + Settings → Security textarea where users behind unusual proxies / Electron wrappers / cross-origin integrations can whitelist their origin. Accepts comma or newline separated values; `*` on its own line opts back into the legacy wildcard with a startup-warning log. - Logs a clear warning the first time engineio rejects each unique origin, naming the rejected Origin and request Host and pointing users to the settings field. Without this, engineio silently 403s the upgrade and the user just sees a half-broken UI with no clue why. Threadsafe dedup so a hostile origin can't spam logs. Logic lives in `core/socketio_cors.py` (resolver, rejection predictor, dedup logger class, startup-status emitter) — pure functions, no Flask dependency. `web_server.py` adds 23 lines of wiring and imports. Important catch during review: my first pass used `cors_allowed_origins=[]` as the "secure default." Reading engineio's source revealed `[]` actually means "DISABLE CORS HANDLING" (engineio/server.py:202: `if cors_allowed_origins != []:`) — identical security to `'*'`. Fixed to use `None` (engineio's actual same-origin sentinel) and pinned with a regression test that asserts the resolver never returns `[]` for any input shape. Tests: - tests/test_socketio_cors.py — 45 unit tests covering 19 resolver shape cases (None, empty, whitespace, comma, newline, garbage types, lists), the `[]`-must-never-be-returned security regression, 12 rejection prediction cases, X-Forwarded-Host handling, dedup logger behavior, threadsafe race (8 threads × 50 hammers → exactly 1 warning), and startup-status emitter outputs. Frontend: - Settings → Security gains an "Allowed WebSocket Origins" textarea with help text explaining same-origin default + when to add a domain + the `*` opt-out. - helper.js — new '2.4.1' WHATS_NEW block (hidden until version bump) with a chill-voice entry describing the change. Conftest.py left at `'*'` — test environment, no security concern. 598 tests pass. |
4 weeks ago |
|
|
04ff287c72 |
Rewrite changelog entries in user voice
Trimmed the WHATS_NEW '2.4.0' block (27 entries) and the full VERSION_MODAL_SECTIONS array (23 sections) from the diagnostic-paragraph style I'd been defaulting to into something terse and casual: - Descriptions are 1-2 short sentences instead of multi-clause writeups. - Modal feature bullets capped at 3-7 short items each. - Stripped parenthetical credits from titles (no more "(kettui Review)", "(Images, Counts, Title Hints)" — those belong in git history, not UI). - Lowercase casual tone throughout description bodies. - No reporter handles in entry text. Net: 176 insertions / 194 deletions. helper.js parses, 553 tests pass. |
4 weeks ago |
|
|
7714b51a50 |
Lift version modal data into helper.js, delete /api/version-info
The version modal pulled its content from /api/version-info — a 295-line
hand-curated Python dict in web_server.py. The "What's New" panel pulled
its content from WHATS_NEW in helper.js. Same release notes, two files,
two languages, hand-edited at every release — drift was inevitable
(and happened: the kettui-fix entries I added recently differed in
detail between the two surfaces).
This commit makes helper.js the single editing surface:
- Adds VERSION_MODAL_SECTIONS const in helper.js right beside WHATS_NEW,
with a comment block documenting the relationship: WHATS_NEW is the
per-version detailed log used by the helper popover; VERSION_MODAL_SECTIONS
is the curated highlight reel shown by the sidebar version button. Both
edited at release time, both in the same file.
- Rewires showVersionInfo() in downloads.js to read from those consts
directly. No backend round-trip; the changelog content ships in the
same JS bundle the browser already loaded.
- Deletes the /api/version-info route and its 295-line version_data dict.
- Updates the line-39 comment to drop the now-stale "version-info endpoint"
reference.
Note: this is collocation, not true unification. WHATS_NEW and
VERSION_MODAL_SECTIONS are still two distinct structures with overlapping
content, linked by a comment convention rather than a shared schema. A
deeper refactor (e.g. a `featured` flag on WHATS_NEW entries that the
modal aggregates) was rejected as out-of-scope — the curated section
titles ("Earlier in v2.3", "Recent Fixes") aren't 1:1 mappable to
WHATS_NEW entries. Saving for a follow-up if the drift problem persists.
Risk audit:
- Load order: helper.js loads at line 7967, downloads.js at line 7873.
Both classic scripts execute synchronously before any clickable
interaction, so showVersionInfo (only invoked on the version-button
onclick) always sees both consts defined.
- populateVersionModal() unchanged — receives the same {title, subtitle,
sections: [{title, description, features, usage_note?}]} shape.
- Stale-cache window during deploy: old downloads.js hitting a 404 on
the deleted endpoint falls through to the existing catch + toast path
("Failed to load version information"). Cache-buster ?v=static_v
resolves on next page load.
553 tests pass. helper.js + downloads.js parse cleanly. No residual
references to /api/version-info anywhere in the repo.
|
4 weeks ago |
|
|
ac30e21b3d |
Sidebar version button: v2.3 → v2.4.0
Forgot to bump the hardcoded label in index.html during the 2.4.0 version commit. _getCurrentVersion() reads this textContent, so the What's New surfacing logic was still seeing 2.3. |
4 weeks ago |
|
|
8ed6ccbb4e |
Bump version to 2.4.0 for dev → main release
- _SOULSYNC_BASE_VERSION → 2.4.0 (was 2.39).
- Migrate WHATS_NEW key '2.40' → '2.4.0', strip unreleased flags off
the 27 entries shipping in this release, set release date.
- Replace parseFloat() version compare with proper int-tuple semver
comparator — parseFloat('2.4.0') and parseFloat('2.4.1') both return
2.4, which would have made future patch bumps invisible to the
What's New surfacing logic.
|
4 weeks ago |
|
|
37aefd2ff1 |
Reorganize queue: race + dedupe fixes from kettui review
Five issues kettui flagged on PR #377: - Worker race (reorganize_queue.py): _next_queued() picked an item and released the lock, then re-acquired to flip status='running'. A cancel() landing in that window marked the item cancelled but the worker still ran it. Replaced with _claim_next_or_wait() that picks AND flips under one lock acquisition. - Wakeup race (reorganize_queue.py): _wakeup.clear() after the empty check could lose an enqueue's _wakeup.set(), parking a freshly-queued album for up to 60 seconds. Replaced Lock + Event with a single threading.Condition; cond.wait() releases and re-acquires atomically on notify. - Bulk dedupe (reorganize_queue.py:enqueue_many): looped single-item enqueue, so a duplicate album_id later in the same batch could slip through if the worker finished the first copy before the loop reached the second. Now holds the lock for the whole batch and tracks a per-batch seen set, so intra-batch duplicates dedupe against each other and not just pre-existing items. - Preview button stuck disabled (library.js:loadReorganizePreview): early returns and thrown errors skipped the re-enable line. Moved state into a canApply flag committed in finally, so any exit path lands the button correctly. - DB helpers swallowing failures (music_database.py): get_album_display_meta and get_artist_albums_for_reorganize used to catch every Exception and return None / [], so a real DB outage masqueraded as "album not found" / "no albums". Now lets exceptions bubble; the route layer already wraps them as 500. Tests: - test_cancel_and_run_are_mutually_exclusive — hammers enqueue+cancel pairs and asserts the invariant that no successfully-cancelled item ever ran (catches regressions to the atomic pick). - test_enqueue_many_dedupes_batch_internal_duplicates — pins the intra-batch dedupe. - test_get_album_display_meta_propagates_db_errors and test_get_artist_albums_for_reorganize_propagates_db_errors — pin the bubble-up behavior. Changelog updated in helper.js and version modal. |
4 weeks ago |
|
|
d6094a3587 |
Library reorganize: FIFO queue with live status panel
Replaces the single-slot "one reorganize at a time, return 409 on collision" model with a per-user FIFO queue. Buttons stay clickable, "Reorganize All" is one backend call instead of an N-call JS loop, and a status panel mounted at the top of the artist actions bar shows live progress (active item, queued count, recent completions) with per-item cancel buttons. Backend - core/reorganize_queue.py: singleton queue + worker thread, dedupe-on- enqueue, cancel rules (queued cancellable, running not), enqueue_many for bulk operations, progress fan-out via update_active_progress - core/reorganize_runner.py: factory builds the worker's runner closure with injected dependencies. Reads config per-call so changing the download path in Settings takes effect on the next reorganize without a server restart - database/music_database.py: get_album_display_meta and get_artist_albums_for_reorganize — moves the SQL out of route handlers - web_server.py: thin enqueue/snapshot/cancel/clear endpoints, runner registration at module load. Old _reorganize_state globals + status endpoint deleted. Static-asset cache buster (?v=<server-start>) added so JS/CSS updates ship live without users clearing cache Frontend - webui/static/library.js: status panel mount, polling (1.5s when active, 8s when idle), expand/collapse, per-item cancel, debounced enhanced-view reload (one reload per artist batch instead of N). Per-album reorganize button paints with queued/running indicator and short-circuits to a toast when the album is already in queue - webui/static/style.css: panel + button styling matching the existing glass-UI accents - webui/static/helper.js + version modal: WHATS_NEW entry Tests (22 new) - tests/test_reorganize_queue.py (19 tests): FIFO order, dedupe, per-item source, cancel rules, continue-on-failure, snapshot shape, progress propagation, bulk enqueue - tests/test_reorganize_runner.py (4 tests): per-call config reads, setup-failure summary, dependency injection, progress fan-out - tests/test_reorganize_db_methods.py (7 tests): SQL JOIN behavior, ordering, fallback for blank strings, artist isolation Full suite 549 passed in 27s. |
4 weeks ago |
|
|
98c85f928e |
Merge remote-tracking branch 'origin/dev' into fix/reorganize-via-post-process-pipeline
# Conflicts: # webui/static/helper.js |
4 weeks ago |
|
|
7e1c4c26ec |
Reorganize: fix moved-count + status/total UX issues from PR #377 review
Four changes addressing kettui's PR #377 review comments: 1. **`_finalize_track` no longer over-counts on DB failure (🔴 bug).** The function previously bailed on DB-update failure but `_process_one_track` still incremented `summary['moved']` unconditionally — overstating how many tracks the UI knows are at their new locations. Fixed by: - `_finalize_track` now returns ``bool`` (True only when DB row was updated AND original was dealt with) - Caller checks the return; on False, records as a failed track with a clear message ("Track landed at new location but DB update failed — file is at both old and new paths until library scan re-indexes") - Existing `test_db_update_failure_leaves_original_in_place` now also asserts `moved == 0`, `failed == 1`, and that the error message names the cause 2. **`executeReorganize` toast no longer says "undefined tracks" (🐛 bug).** `/reorganize` doesn't return `result.total` anymore (the track count is determined server-side after planning), so the "Reorganizing undefined tracks..." string was meaningless. Now uses `result.message` from the backend instead. 3. **`_pollReorganizeStatus` distinguishes completed from skipped (🟡 risk).** Backend now propagates the orchestrator's status (`completed` / `no_source_id` / `no_album` / `no_tracks` / `setup_failed` / `error`) into `_reorganize_state['result_status']` so the frontend can warn appropriately. Two new helpers: - `_classifyReorganizeOutcome(state)` — returns 'success' only when `result_status === 'completed'` AND `failed === 0`; 'warning' otherwise - `_formatReorganizeResultMessage(state)` — returns a message specific to the outcome ("Reorganize skipped — album has no metadata source ID. Run enrichment first." for `no_source_id`, etc.) Zero-failure non-completed runs now show as warnings instead of green checkmarks. 4. **Bulk mode no longer counts skipped albums as succeeded (🟡 risk).** `_executeReorganizeAll`'s loop was treating any HTTP 200 response as success, ignoring the orchestrator's actual outcome for that album. Fixed by: - `_waitForReorganizeComplete()` now resolves with the final state object (was: void) - Loop checks `finalState.result_status === 'completed'` AND `finalState.failed === 0` before counting `succeeded++`; otherwise increments `skipped` (with a per-album warning toast) or `failed` accordingly - Final summary toast now reads "Reorganized N of M albums, K skipped, J failed" and only shows green when nothing was skipped or failed All four addressed in a single commit because they form one coherent UX-correctness fix — the bug bug (#1) and the count- overstatement bug (#4) both made the user see "everything succeeded" when reality was different. Together they make the UI honestly reflect what actually happened. Files: - core/library_reorganize.py — `_finalize_track` returns bool, `_process_one_track` reads it - web_server.py — `_reorganize_state['result_status']` populated from orchestrator's summary on success and on exception - webui/static/library.js — `_classifyReorganizeOutcome` / `_formatReorganizeResultMessage` helpers, single-album + bulk-mode flows both consume them - tests/test_library_reorganize_orchestrator.py — strengthened the existing DB-failure test to assert moved/failed counts Credit: kettui — four PR #377 review comments named all of these precisely with line numbers and severity. |
4 weeks ago |
|
|
cb67773998 |
Merge remote-tracking branch 'origin/dev' into fix/album-completeness-api-track-count
# Conflicts: # webui/static/helper.js |
1 month ago |
|
|
2b15260b88 |
Reorganize: route library files through the post-processing pipeline
Reported on Discord by winecountrygames. The library "Reorganize" tool
had several layered bugs that all traced to the same root cause: the
endpoint reinvented every wheel post-processing already turns — its own
template engine, its own disc-number resolution from file tags, its own
sidecar sweep, its own collision detection — and each had drifted from
the canonical path used by fresh downloads. Reported symptoms:
- 3-disc Aerosmith deluxe collapsed to a flat single-disc layout
- Half the tracks on other albums silently skipped, no error / no count
- Re-runs left empty leftover album folders cluttering the artist dir
Architecture: stop reinventing wheels. Route reorganize through exactly
the same pipeline downloads use. Per-album:
1. Fetch the canonical tracklist from a metadata source (Spotify /
iTunes / Deezer / Discogs / Hydrabase) using the album's stored
source IDs. New `core/library_reorganize.py::plan_album_reorganize`
does this — primary-source-first, fall through priority chain
unless the user picked a specific source in the modal (strict mode).
2. For each local track, find the matching API entry via a scored
candidate matcher. Score components: exact-title (100),
substring-with-length-ratio (40-90), track-number agreement (20).
Hard reject when the two titles have different version
differentiators (Remix vs no-remix means different recordings,
not annotation drift). Below threshold = unmatched, surfaced as
"not in source's tracklist, left in place" rather than silently
mis-routing.
3. Copy the file to a per-album staging directory, build the same
context dict the import flow builds (`spotify_album` /
`track_info` / etc. with `is_album_download=True` so the path
builder enters ALBUM mode, not SINGLE mode), call
`_post_process_matched_download(...)` — same function fresh
downloads use. Post-process handles tagging, multi-disc subfolder
decisions, sidecar regeneration, AcoustID verification.
4. Read `context['_final_processed_path']` to learn where it landed.
Update `tracks.file_path` in the DB BEFORE removing the original
(DB-update failure leaves the file at both locations, recoverable
via library scan; the reverse would orphan the row). Delete
per-track sidecars (post-process recreates them at the new
destination).
3 concurrent workers per album via ThreadPoolExecutor, matching the
download path's per-batch worker count. State mutations all guarded by
a single lock; staging filenames carry a UUID prefix so concurrent
copies of identically-named source files don't overwrite each other.
Source picker in the modal lets the user choose which source to read
the tracklist from. Two endpoints feed it:
- `/api/library/album/<id>/reorganize/sources` — sources for THIS
album that are both authed AND have a stored ID. For the per-
album modal.
- `/api/library/reorganize/sources` — all authed sources globally.
For the bulk "Reorganize All" modal where per-album ID coverage
varies.
When the user picks a specific source, the orchestrator runs in
`strict_source=True` mode (no fallback chain) — picking Spotify means
"use Spotify or fail", not "use Spotify and silently fall back."
Preview endpoint shares the same planning logic as apply via
`preview_album_reorganize` — the destination path comes from the same
`_build_final_path_for_track` post-process uses, so what you see in
the preview is exactly what you get on apply.
Empty destination folders (from earlier failed runs OR from the
current run when post-process creates a dir then fails AcoustID)
get cleaned up after each successful run: walk up to the artist
folder from any successful destination, prune empty album-sibling
folders one level deep. Bounded scope = won't touch unrelated user
dirs.
Web_server.py shrinks by ~450 net lines. The endpoint handler is now
a thin wrapper that builds injected callables (path resolver, post-
process function, DB updater, empty-dir cleaner), spawns a thread
that calls `reorganize_album()`, and returns. All actual logic lives
in `core/library_reorganize.py` where it's unit-testable without
spinning up Flask.
Frontend cleanup: the per-call template input in both reorganize
modals (per-album and bulk) was redundant — the backend always uses
the configured global download template. Removed the input and the
variables-grid reference UI it was for.
39 new unit tests pin every contract:
- source resolution (no_source_id when album has none, fallthrough
chain when primary returns nothing, strict mode bypasses fallback)
- matcher scoring (exact / substring / multi-disc disambiguation /
smart-quote tolerance / dash-vs-parens / bonus-track substring /
Remix-vs-original differentiator rejection / "Real" doesn't false-
match "Real Real Real" / track-number-only no longer fires)
- file safety (DB-update failure leaves original in place, post-
process failure leaves original in place, post-process exception
caught and original preserved, success removes original AND
updates DB in the right order)
- sidecar handling (per-track .lrc/.nfo deleted on success, kept on
failure; album-level cover.jpg/folder.jpg cleaned only when
directory has no remaining audio)
- staging cleanup (recreated between tracks because post-process
nukes it, dir cleaned up on success AND on failure)
- destination-dir prune (empty siblings removed, real album with
files preserved, no recursive sweep)
- source picker (only authed-with-stored-ID sources for per-album,
all authed sources for bulk; strict mode doesn't fall back)
- concurrency (3 workers in flight, state stays consistent under
races, stop_check cuts off pending tasks)
- preview parity (preview produces same destination as apply for
multi-disc; ALBUM mode not SINGLE mode; unmatched/no-path tracks
surfaced with reasons)
Limitations (deliberate punts, NOT in this PR):
- Renamed local titles on multi-disc albums where track_number
also disagrees: matcher returns nothing (track is "not in
source"). Fixable by using duration_ms as a tertiary signal.
- Per-track in-modal source switching with per-album track-count
hints (would need a second API call before opening the modal).
- UI status panel on the artist page during a run — currently
just toasts. Documented as a follow-up PR.
Files:
- core/library_reorganize.py — new module: plan_album_reorganize,
preview_album_reorganize, reorganize_album, available_sources_for_album,
authed_sources, _score_candidate, helpers for staging/post-
processing/finalizing, sidecar + dest-dir cleanup
- core/metadata_service.py — no changes; reused get_album_for_source,
get_album_tracks_for_source, get_source_priority,
get_client_for_source
- web_server.py — three endpoints (preview / apply / sources GETs)
are thin wrappers; -450 net lines
- tests/test_library_reorganize_orchestrator.py — 39 tests covering
every contract above
- webui/static/library.js — source picker UI in both modals; dead
template input + variables-grid removed
- webui/static/style.css — dropdown option styling fix (white-on-
white was unreadable)
Reported on Discord by winecountrygames — his bug report named the
trigger button (Enhanced view → Reorganize All) and both symptoms
(multi-disc collapse, half-album skip), which let the diagnosis go
straight to the architectural problem.
|
1 month ago |
|
|
252121ca96 |
Bump Spotify post-ban cooldown from 5 min to 30 min
Reported on Discord by winecountrygames — Spotify auth granted, then
re-banned for 4 hours within ~30 seconds, repeatedly. Trace from his
captured log:
< 12:05 [pre-log] Spotify ban active when log starts
15:21:27 First ban EXPIRED → 5-minute post-ban cooldown begins
15:26:27 Cooldown ends, spotify_client.is_authenticated() probe
allowed again → client initialized
15:26:59 First Spotify API call after cooldown — get_artist_albums
for an artist whose discography a background worker was
enriching — gets 429 immediately with no Retry-After
header → new ban activated for 14400s (4 hours)
Root cause: `_POST_BAN_COOLDOWN = 300` (5 minutes) is shorter than
Spotify's actual server-side memory of the previous offense. The
cooldown exists specifically to prevent the "ban expires → we probe →
re-ban" cycle (`spotify_client.py:65-68` documents that intent
explicitly), but the value was wrong: Spotify's server still
considered this user banned 5 minutes after our local ban window
ended, so the very first call after cooldown got slapped.
The 4-hour re-ban itself is correct behavior — `_BASE_MAX_RETRIES_BAN`
fires when spotipy reports "max retries", which means the client
exhausted its internal retry budget on 429s before raising. That's a
severe-ban signal and a long default is the right response.
Fix: bump `_POST_BAN_COOLDOWN` to 1800 seconds (30 min). This is the
smallest change that addresses the immediate "re-probe → re-ban" loop
in the report. 30 minutes is an empirical floor — long enough for
Spotify to actually clear its server-side memory in the cases we've
observed, short enough not to keep functional users locked out beyond
necessary. Can be revisited if reports persist.
What this PR does NOT fix (important context for the same user):
This bump only helps the "ban expires → we re-probe → re-ban" loop.
It does NOT help winecountrygames's other symptom — Spotify being
banned within 30 seconds of his FIRST EVER authorization (no prior
ban). That's a separate failure mode: on first auth, enrichment
workers immediately fan out across the user's library (250 artists
in his case), hammering Spotify endpoints with bulk get_artist_albums
calls before any rate-limit feedback can land. Spotify's hidden
per-endpoint daily quotas — which BoulderBadgeDad has empirically
documented but the global rate limiter doesn't see — flag the burst
and impose a multi-hour cooldown that LOOKS like a bot-detection ban
to us. A proper fix needs a fresh-auth ramp-up: start with very low
Spotify QPS for the first N minutes, scale up only if no rate-limit
feedback arrives. That's a separate PR.
Documented as additional follow-ups (NOT in this change):
- Adaptive cooldown that scales with the size of the previous ban —
a 4-hour MAX_RETRIES ban probably warrants a 1-hour cooldown,
while a 60-second Retry-After-honored ban can resume in 5 minutes.
The system already distinguishes these in `_set_global_rate_limit`,
it just doesn't propagate the distinction to cooldown duration.
- Probe-with-light-call pattern — make the first post-cooldown call
a single inexpensive endpoint (`current_user`) rather than
allowing a background worker's heavy `get_artist_albums` to be
the canary. Failed probe extends cooldown silently instead of
triggering a fresh 4-hour ban.
- Fresh-auth ramp-up (per the limitation above).
Files:
- core/spotify_client.py — `_POST_BAN_COOLDOWN` 300 → 1800. Comment
expanded to cite the report so the value isn't bumped back without
context.
- webui/static/helper.js — WHATS_NEW entry under 2.40 explaining
the change for affected users.
No tests added — the cooldown logic itself is unchanged, only the
constant. Tests asserting on a constant value are theater.
Reported on Discord by winecountrygames — his captured log made the
"ban-expires-to-re-ban" timing chain unambiguous.
|
1 month ago |
|
|
a9f827ef42 |
Reject Tidal streams that silently downgrade from the requested quality
Reported on Discord by Netti93: with Tidal configured for "HiRes only"
and "Allow Quality Fallback" disabled, tracks were still downloading
successfully — as m4a 320kbps files. Some "successful" downloads were
less than half the file size of the same track pulled via Tidarr/tiddl
from the same Tidal account.
Root cause: Tidal's API silently degrades to the best quality your
account + the track + your region permits. Setting
`session.audio_quality = Quality.hi_res_lossless` and calling
`track.get_stream()` on a track that's only available in AAC returns
an AAC stream with no error. The downloader wrote the m4a file to
disk, the ~7MB size sailed past the 100KB stub threshold, and the
download reported success.
The pre-existing "verify quality wasn't silently downgraded" block
only LOGGED a warning when this happened; it did not fail the tier.
Two knock-on effects:
- Users with "HiRes only, no fallback" got m4a files anyway, which
defeats the setting entirely.
- The worker-level fallback chain (hires → lossless → high → low)
couldn't advance past the first tier, because every tier
"succeeded" at whatever Tidal happened to serve.
Fix: after `track.get_stream()`, compare `stream.audio_quality`
against the tier we asked for using a rank-based ordering:
LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS
- Same tier or higher → accept (so the occasional Tidal upgrade
doesn't get rejected just because it's not an exact match).
- Lower tier → reject THIS tier. The loop `continue`s and the next
fallback tier is tried, or the whole download fails honestly
when the user has fallback disabled. The existing final-error
log already has a hint directing users to enable fallback if
they want automatic Lossless substitution.
- Unrecognized `audioQuality` value (e.g. a new Tidal tier we
haven't mapped) → reject conservatively, so the next fallback
tier gets a chance and the diagnostic log names the unknown
value.
Why the rank-based approach instead of strict equality:
Tidal's API doesn't technically promise an exact-tier match on
serving; on tracks that are flagged in its catalog as a higher
tier, it can serve higher than the session setting. Rejecting
higher-than-asked quality would be user-hostile. And the `HI_RES`
(legacy MQA) value — not in tidalapi's modern `Quality` enum but
possibly still present on old catalog entries — needs to rank
below `HI_RES_LOSSLESS`: users asking for true lossless HiRes
should reject MQA since MQA is a lossy format.
tidalapi's `Quality` enum is a `str` subclass whose VALUES (not
member names) match what the Tidal API returns in the
`audioQuality` field (e.g. `Quality.hi_res_lossless.value ==
'HI_RES_LOSSLESS'`, `Quality.low_320k.value == 'HIGH'`). Both
sides of the comparison are coerced to `str` before use, so the
check is robust to whichever tidalapi version exposes the served
quality as an enum or a plain string.
The check is extracted as `_verify_stream_tier(stream, q_info,
q_key) -> (ok, reason)` at module scope — a pure function with no
I/O, unit-tested independently. Ten tests: match, three upgrade
cases (LOSSLESS → HI_RES_LOSSLESS, LOSSLESS → HI_RES, LOW → any
higher), three downgrade cases (the reported HiRes → AAC, HiRes
Lossless → MQA HiRes, Lossless → AAC), one unrecognized-tier case,
and two defensive paths for older tidalapi builds without
`audio_quality` on the stream object and for QUALITY_MAP entries
that lack `tidal_quality` (e.g. tidalapi wasn't importable at
module load). Test stub updated to use uppercase `Quality` values
matching real tidalapi so case-sensitivity regressions get caught.
Also removed the old codec-string-based warning block — the new
tier check is strictly stronger, and keeping the warning around
would just be dead code waiting to drift out of sync.
Deliberately NOT tackling in this PR (documented as follow-ups):
- Bit-depth verification of HiRes FLAC files via mutagen. The
`stream.audio_quality` tier check catches the main "HiRes
requested, got AAC" case; bit-depth would only matter if Tidal
labeled a stream HI_RES_LOSSLESS but served a 16-bit FLAC
(`Stream.bit_depth` isn't reliable for this — tidalapi defaults
missing `bitDepth` fields to 16, so a trust-the-stream check
would spuriously reject valid HiRes whenever Tidal omits the
field). A proper fix runs mutagen post-download to inspect the
actual file, then decides whether to delete + retry the next
tier — a whole new failure mode with design trade-offs that
deserve their own PR. The support logs don't show this
happening.
- The "manual remap still says Not Found" symptom. Might be
downstream of this same bug (silent-AAC "success" hitting a
later rejection), might be a separate task-state issue. Not
guessing without logs from the retry path.
- Quality-aware stub threshold. 100KB is a reasonable floor for
real stub/preview detection and there's no evidence the
universal threshold is misfiring in the wild.
Field-verified status: desk-verified via unit tests and empirical
checks against a live tidalapi import (confirming the `Quality`
enum's str-subclass behavior). Not yet smoke-tested end-to-end
against a real Tidal account with a HiRes-only-no-fallback
setting — Netti93 or anyone else with that config should notice
either the fix working (non-HiRes tracks fail honestly with a
clear log line) or any regression before wider release.
Files:
- core/tidal_download_client.py — new `_verify_stream_tier` helper
and `_QUALITY_RANK` table at module scope, called in the
download loop after the stream is fetched and before any
bandwidth is spent. Removed the old inline codec-based warning
since the new check supersedes it.
- tests/test_tidal_stream_tier_verification.py — ten tests covering
match / upgrade / downgrade / unknown / defensive paths.
- tests/test_tidal_search_shortening.py — fake `Quality` values
brought in line with tidalapi's real values so both files share
a consistent stub regardless of pytest collection order.
- webui/static/helper.js — WHATS_NEW entry under 2.40 describing
the rank-based tier comparison.
Reported on Discord by Netti93 — the "same account works via
Tidarr" comparison narrowed the cause to SoulSync's download path
rather than an account/region issue.
|
1 month ago |
|
|
a60546929e |
Fix Album Completeness job reporting zero findings for every album
Reported by sassmastawillis: the Album Completeness maintenance job
scans 3127 albums in 0.1 seconds and reports 0 findings — for every
user, regardless of whether their library is actually complete.
Restoring an older DB surfaced 7 correct findings, so the code logic
works; the DB state is what's making everything look complete.
Root cause: `albums.track_count` is only ever written by server-sync
paths — Plex's `leafCount`/`childCount` and SoulSync standalone's
`len(tracks)`. It's the OBSERVED count of tracks SoulSync has indexed,
which is always exactly what `COUNT(tracks)` returns for that album.
The completeness job treated it as the EXPECTED total and compared it
against the observed count. They're equal by construction, so
`actual >= expected` is always true: skip, 0.1s scan, 0 findings.
Fix: new `api_track_count INTEGER` column on `albums`, written only by
metadata-source code paths. Populated in two places so the scan is
fast and the fallback is robust.
1. Enrichment workers — shared helper `set_album_api_track_count`
in `core/worker_utils.py`. Called by each worker's existing
`_update_album` method alongside its other album-column UPDATEs:
- spotify_worker: `album_obj.total_tracks` from the Spotify Album
dataclass (already in hand, zero new API calls)
- itunes_worker: same, from the iTunes Album dataclass
- deezer_worker: `nb_tracks` from full_data, falling back to
search_data when the full lookup didn't run
- discogs_worker: count of tracklist rows where `type_=='track'`
(Discogs tracklists interleave heading and index rows that
shouldn't count as songs)
Helper skips the write on zero/None/negative/non-numeric inputs
so a source lacking track info can't clobber a good value a
different source already wrote. Caller owns the transaction —
helper just queues an UPDATE on the caller's cursor without
committing, so it batches cleanly with each worker's existing
multi-UPDATE pattern.
Hydrabase worker deliberately not touched — it's a P2P mirror
that doesn't write album metadata to the local DB. Hydrabase-
primary users hit the fallback path below.
2. Album Completeness repair job — new `al.api_track_count` column
in the SELECT, read first in the scan loop. On miss (album never
enriched, or enrichment workers haven't run yet on a fresh
install), falls through to the existing `_get_expected_total()`
API lookup and persists the result via the same shared helper
(wrapped in connection/commit management since the repair job
runs outside a worker's batched transaction).
Also removed `al.track_count` from the scan's SELECT — now unused
since the observed count was the whole source of this bug, and
leaving a dead SELECT would invite a future engineer to re-introduce
the same comparison.
Help text on the job card was reworded so it honestly describes
current behavior ("counts cached during normal enrichment are used
when available; otherwise the job queries a metadata source
directly") rather than the old "active provider first, then others
as fallback" phrasing, which doesn't match how the cache actually
fills — any enrichment worker that runs can populate it, and the
last writer wins. Document-only follow-up if this edge case ever
bites in practice: add a `api_track_count_source` column so the
scan can prefer the configured primary source's count over others
(e.g. deluxe vs. standard edition mismatches). Not worth the
complexity today.
For existing users, the first completeness scan after upgrade is
fast to the extent their library is already enriched: the workers
already ran and populated `api_track_count` on their normal schedule.
For brand-new installs, the scan's fallback path handles the cold
start — slower, but correct, and subsequent scans are fast.
Does NOT affect:
- Download / post-processing / wishlist / sync code paths — none
of them read `track_count` for completeness semantics.
- Plex / Jellyfin / Navidrome / standalone sync — still write
`track_count` exactly as before; `api_track_count` is a separate
column they never touch.
- Other repair jobs.
- Any UI path — same finding schema, just correct counts now.
Files:
- database/music_database.py — idempotent migration adding
`api_track_count INTEGER DEFAULT NULL` to the existing album-column
check block.
- core/worker_utils.py — new `set_album_api_track_count` helper with
the documented skip-on-bad-input contract.
- core/spotify_worker.py, itunes_worker.py, deezer_worker.py,
discogs_worker.py — one-liner call from each `_update_album`.
- core/repair_jobs/album_completeness.py — scan uses the cache;
fallback path persists API-lookup results via the shared helper;
help text updated to match actual behavior.
- tests/test_worker_utils_album_track_count.py — 9 tests covering
the helper's write/skip contract + no-commit invariant.
- tests/test_album_completeness_job.py — 2 tests for the repair
job's fallback-path wrapper.
- webui/static/helper.js — WHATS_NEW entry.
Credit: sassmastawillis spotted the bug; the "restored older DB
finds 7 albums" signal pinpointed DB state over code logic and
made the diagnosis tractable.
|
1 month ago |
|
|
b3722449fc |
MusicBrainz: Fix artist images, total_tracks off-by-one, and Artist+Title queries
Three bugs from kettui's follow-up review pass on the MusicBrainz search PR, all fixed in one commit because they share UI context. 1. Missing artist images on MB artist results MusicBrainz doesn't store artist images directly. My earlier commit returned `image_url=None` on every artist result and trusted the frontend's lazy-loader — but the lazy-loader's `/api/artist/<id>/image? source=musicbrainz` endpoint had no handler for MusicBrainz, so it silently returned None and the emoji placeholder stayed. Fix plumbs the artist name through: - `renderCompactSection` stashes `data-artist-name` on artist cards. - `search.js` and `downloads.js` lazy-loaders pass `name=<artist>` as a query param. - `/api/artist/<id>/image` accepts an optional `name` param. - `metadata_service.get_artist_image_url` has a new `musicbrainz` branch: since MB has no artist art, it searches fallback sources (iTunes/Deezer by configured priority) for the artist name and returns the first image found. Verified live — Metallica/Kendrick Lamar/Daft Punk all resolve to Deezer artist images via the name lookup. 2. total_tracks off-by-one on tracks with a release `_recording_to_track` initialized `total_tracks = 1` and then summed media track-counts on top. For an 11-track album, it reported 12. An adapter-level regression introduced when the recording-projection helper was extracted during the main MB refactor. Fix: initialize at 0, sum normally. Standalone recordings with no release (can happen for uncredited remixes etc.) still report 1 via an explicit fallback — so the existing "single track" case isn't broken. 3. "Artist Album Title" queries buried specific albums in the discography list Bare-name queries like "The Beatles Abbey Road" used to resolve "The Beatles" as the artist and then browse their full discography — Abbey Road was buried alphabetically among 200+ releases instead of being the top result. Fix adds a title-hint extractor. When the query starts with the resolved artist name followed by more words, the trailing portion is treated as a title hint. Browse results are filtered to those whose release-group title contains the hint. If the filter matches nothing, falls back to text-search with the hint as the title (the "keep the old split-by-whitespace fallback" path kettui called for). If text- search also misses, shows the full discography rather than nothing. 10 new tests in tests/test_musicbrainz_search.py (46 total): - Title-hint extractor: basic match, case-insensitive, whitespace tolerance, bare-artist-no-hint, artist-not-prefix-no-hint, word- boundary required (no false splits on "Metallicasomething"). - Browse filtering by title hint. - Text-search fallback when the title hint matches nothing in browse. - Bare-artist queries return the full discography unfiltered. - total_tracks for single-release, multi-disc, and no-release cases. |
1 month ago |
|
|
a6359a2690 |
Add <img onerror> fallbacks for search result images
Self-audit catch: my earlier cover-art commit claimed 'the frontend's <img onerror> fallback handles 404s' — that was wrong. The enhanced search result images in shared-helpers.js renderCompactSection and all five gsearch-item/track templates in downloads.js render bare `<img src="...">` with no fallback. With the MusicBrainz adapter now emitting Cover Art Archive URLs deterministically (no HEAD probe), albums that don't have cover art would show the browser's broken-image icon instead of the emoji placeholder. Two fallback shapes: - shared-helpers.js renderCompactSection: the `<img>` sits inside a card with a sibling placeholder pattern. On error, replace the img's outerHTML with the placeholder div, matching the shape used when config.image is missing entirely. - downloads.js gsearch items: the `<img>` sits inside a `.gsearch-item-art` div whose default text content is the emoji fallback (🎤 / 💿 / 🎶 / 🎵). On error, set parentElement.textContent to the emoji, which wipes the img and shows the glyph. Same shape as the "no image_url" branch. Applies to every card type that renders a user-provided image URL so the fix covers all sources that might return 404s — MB is the most common offender but iTunes/Deezer/Discogs can all miss too. Tested against the live MB API: Metallica albums without CAA cover art now show the 💿 emoji instead of a broken-image icon. |
1 month ago |
|
|
2b7d6c8c7c |
Fix global search popover not scrolling when results overflow
The source-picker refactor introduced a new stable DOM structure inside
`#gsearch-results`:
<div id="gsearch-results"> <!-- max-height: 60vh, flex-col -->
<div id="gsearch-source-row" /> <!-- icon row, controller-rendered -->
<div id="gsearch-fallback-banner" />
<div id="gsearch-body" /> <!-- surface renders results here -->
</div>
But the companion CSS never landed. `#gsearch-body` had default block
layout, so when results exceeded the 60vh cap, they clipped silently
instead of scrolling. The old structure had `.gsearch-results-body`
with `overflow-y: auto; flex: 1` directly inside the panel; that rule
still exists but its selector now matches a nested div with no flex
parent, so `flex: 1` is a no-op and overflow doesn't trigger.
Fix: give the three stable children the right flex behaviour so the
body fills remaining space and scrolls.
- `#gsearch-source-row` and `#gsearch-fallback-banner` stay at natural
height (flex-shrink: 0).
- `#gsearch-body` grows (flex: 1 1 auto), can shrink below content
height (min-height: 0 — this is the critical bit, otherwise flex
items won't shrink below their intrinsic size and overflow never
triggers), and scrolls vertically.
Styled scrollbar matches the rest of the panel (4px, translucent thumb).
|
1 month ago |
|
|
394ac73877 |
MusicBrainz: Tests for new search behavior + WHATS_NEW entry
26 new unit tests in tests/test_musicbrainz_search.py covering: - Cover Art URL construction (release + release-group scope, empty MBID, unknown scope fallback) - Structured query splitting (hyphen, en-dash, em-dash, bare name, no false-positive splits on hyphens-inside-words) - Artist search: score filtering, strict=False call contract, exception handling, genre extraction from MB tags, mbid/name validation - Top-artist resolver: memoization by normalized query, sub-threshold returns None, negative-result caching, empty-query short-circuit - Album search routing: bare query → browse path, structured query → text path, no-artist-match falls back to text, text path score filter - Track search routing: browse path, dedupe-by-title across live/compilation variants, structured query → text path, text path score filter All mock the underlying MusicBrainzClient — no network calls. Also adds a WHATS_NEW entry under 2.40 explaining the three user-visible changes: Artists section now populates, album/track results match the searched artist instead of random title collisions, and search completes in ~3 seconds instead of 30+. |
1 month ago |
|
|
253e4d1e4a |
Fix Discover hero 'View Discography' 404ing on source-only artists
Clicking 'View Discography' on the Discover hero slideshow was calling
navigateToArtistDetail(id, name) without the third 'source' argument.
loadArtistDetailData then omits the `source` query param, so
/api/artist-detail falls through to a local DB lookup and returns 404
for artists that don't exist in the library — which is nearly every
hero artist, since they come from discover similar-artists.
Regression from the unification PR (
|
1 month ago |
|
|
527b51d69b |
Tighten Soulseek handoff + per-source request tokens after self-audit
Two bugs in the previous review-fix commits, found during a Cin-standard re-audit: A) Soulseek handoff stale state.query overrode the global widget's query The previous fix pre-set basicInput.value before clicking the Search page's Soulseek icon. But the click triggers onSoulseekSelected with the controller's CURRENT state.query — which is whatever the user last typed on /search, not the global widget's query. The Search page callback then ran `if (query) basicInput.value = query;` and overwrote the just-set value with the stale one before firing performDownloadsSearch. Fix: expose searchController as `_searchPageController` (mirrors `_searchPageRestoreOnEnter` already at module scope). Global widget's _gsNavigateToSearchPage syncs `_searchPageController.state.query` to its own query before clicking the icon. Also added a fallback for the case where the icon doesn't exist yet (controller still mid-init): swap sections + run performDownloadsSearch directly. B) Single _requestSeq token leaked loadingSources across sources The earlier "stale request" fix used one global _requestSeq. But when the user switched Spotify → Deezer mid-fetch, the Spotify abort's catch block bailed (1 !== 2), leaving 'spotify' in loadingSources forever — permanent spinner on the Spotify icon even though no fetch was running for it. Fix: per-source `_sourceRequestIds[src]` map. Same-source supersession bails (correct), cross-source supersession still clears the old source's loadingSources entry (correct). Bonus defensive: submitQuery now invalidates every per-source token and aborts the in-flight fetch when the query string changes. Catches the residual edge case where user clears the input — the in-flight fetch's settle would otherwise write stale data into the just-cleared state.sources. |
1 month ago |
|
|
325292ce5a |
Treat Soulseek as configurable in source picker (require slskd_url)
Cin flagged that Soulseek was always rendered as configured in the source picker, even on dev instances with no slskd set up — letting users click it and fire searches that could never succeed. Three coordinated changes: 1. web_server.py SERVICE_CONFIG_REGISTRY: add Soulseek entry requiring `slskd_url`. /api/settings/config-status now reports its real state alongside every other service. 2. shared-helpers.js _ALWAYS_CONFIGURED_SOURCES: drop 'soulseek'. The set is now just MusicBrainz + YouTube Music Videos (sources that genuinely don't need user creds). Soulseek goes through the normal config-status code path. 3. shared-helpers.js openSettingsForSource: special-case Soulseek to route to Settings → Downloads tab (where slskd URL field lives, gated behind the download-source-mode dropdown) and scroll to the #soulseek-url input. Every other source still routes to Connections and scrolls to its .stg-service card. Without this, Soulseek's "click to configure" landed on a Connections card that doesn't exist (Soulseek's URL/key fields are scoped to the download-source selection on the Downloads tab). |
1 month ago |
|
|
005c6ad73a |
Fix Soulseek handoff routing + stale-request flash on fast retype
Two AI-review findings from Cin (kettui) on the source-picker PR: 1. Soulseek handoff from global widget went through metadata flow _gsNavigateToSearchPage(query, 'soulseek') wrote the query into #enhanced-search-input and dispatched an input event. The Search page controller's activeSource was whatever its default was (spotify, deezer, etc.), so the debounced submitQuery ran the enhanced /api/enhanced-search flow instead of the raw Soulseek file search. The `src` parameter was effectively ignored. Fix: when src === 'soulseek', pre-fill #downloads-search-input directly and click the Search page's Soulseek icon. The icon click triggers the controller's onSoulseekSelected callback, which owns the section swap and re-runs performDownloadsSearch against the value we just wrote to the basic input. 2. Stale in-flight requests cleared loadingSources after fast retype createSearchController._fetchSource awaits the fetch result, then unconditionally mutates state.loadingSources / state.sources in the settle and catch blocks. When a user typed "abc" → fetch started → typed "abcd" before the first fetch returned, the second submitQuery aborted the first fetch and started its own. The first fetch's catch (AbortError) then ran and cleared loadingSources for that source — wiping the spinner the new request had just set, and causing a brief flash of empty/error state while the new fetch was still in flight. Fix: monotonic _requestSeq token. Each _fetchSource call captures the next value (++_requestSeq). Settle / catch blocks (and the YouTube NDJSON streaming loop) bail before mutating shared state if requestId !== _requestSeq. Existing abortCtrl behavior unchanged — this is a layered defense for the catch-clobber pattern that abort alone can't prevent. |
1 month ago |
|
|
ab7aeb302c |
Defer search-restore render so it survives nav-button click bubble
The navigate-back fix from the previous commit was being immediately undone by the document outside-click handler. Race: 1. Click on sidebar nav-button → button handler runs synchronously, eventually calling _searchPageRestoreOnEnter → _renderFromState → showDropdown removes `hidden` class 2. Click event bubbles up to document 3. Document outside-click handler sees dropdown is now visible, sees the click target is a nav-button (not inside the search wrapper or the source row), calls hideDropdown → instantly hidden again Fix: defer the _renderFromState call to setTimeout(0). The macrotask runs AFTER the click event finishes propagating, so by the time the dropdown becomes visible, the document outside-click handler has already short-circuited (it saw the dropdown still hidden). User reported having to delete + retype the last character of the query to force a re-render — which worked because the input event listener fires submitQuery, which routes through the controller without going through the deferred path. |
1 month ago |
|
|
258644fd9f |
Drop Show/Hide Results button + auto-restore cached results on navigate-back
Cin flagged two related UX issues during PR review: 1. The "Show Results / Hide Results" toggle next to the search bar served no real purpose — there was nothing else on the Search page worth seeing instead of results, so toggling visibility was always pointless overhead. 2. Navigating away from /search via a sidebar link dismissed the dropdown (the click was caught by the outside-click handler). Coming back left the input populated but the results hidden, requiring a Show Results click or a fresh search. The cached state was intact in the controller the whole time — just not rendered. Both fixed by the same direction: dropdown visibility becomes a pure function of query state, never user-toggleable. The closure now exposes `_searchPageRestoreOnEnter` so subsequent calls to `initializeSearchModeToggle` re-render from the controller's cached state instead of early-returning. Removes the button HTML, click handler, `updateToggleButtonState` function, the desktop + responsive CSS for `.enhanced-search-btn`, and the orphaned `.btn-icon` rule. Net -94 lines. |
1 month ago |
|
|
77d20e9aa8 |
Fix Clean Search History automation AttributeError on DownloadOrchestrator
The hourly `clean_search_history` automation was crashing with `'DownloadOrchestrator' object has no attribute 'base_url'`. The guard was written before the orchestrator refactor — `soulseek_client` is now a DownloadOrchestrator that wraps individual download clients, with the real Soulseek client sitting at `.soulseek`. Two other call sites in web_server.py (lines 2634, 3092) already used the correct `soulseek_client.soulseek.base_url` pattern with a getattr guard. This call site was missed during the refactor. Fix: reach through the orchestrator the same way the other sites do. |
1 month ago |
|
|
9f63280677 |
Extract source-picker into shared createSearchController factory
Both the Search page and the global search widget ran the same source- picker state machine (query, activeSource, per-query cache, fallbacks, loading set, configured-source discovery, NDJSON streaming for YouTube, default-source fall-forward). That was ~380 lines of near-duplicated logic split across search.js and downloads.js, which meant every bug fix or behavior tweak had to land twice and inevitably drifted. createSearchController in shared-helpers.js now owns all of that. Each surface passes per-surface wiring — a source-row DOM element, a CSS class prefix, and callbacks for Soulseek handoff + unconfigured-source redirect — and consumes the controller's state via an onStateChange callback. The surface files shrink to their actual responsibilities: results rendering, click handlers, and surface-specific visibility. Zero UX change. Every keystroke, icon click, cache hit, rate-limit fallback, and unconfigured-source redirect behaves identically to before — verified via full pytest suite (395 passed) and node --check on all three files. WHATS_NEW entry added under the 2.40 unified-search bucket. |
1 month ago |
|
|
481d3d940f |
Mobile responsiveness for source picker, aura, and library empty CTA
The new components shipped this PR (source icon row, fallback banner, glow aura, library-empty search CTA) had no responsive styling. On phones the rows ran fine via horizontal scroll but the chips wasted a lot of space per icon, the CTA could overflow on narrow screens, and the aura kept its desktop-sized ellipse for no benefit. At ≤768px (tablet/phone): - Enhanced source row: tighter padding, 24x24 glyphs, 72px chip min-width. - Global widget source row: even tighter, 20x20 glyphs, 62px chips. - Fallback banners scale down to match. - Aura shrinks to a 440x160 / 540x200 ellipse and a 180px-tall strip so it doesn't eat short mobile viewports. - Library empty CTA allows text wrap + reduced padding so the "Search online for "long artist name"" string doesn't break the layout on narrow screens. At ≤480px (phone): - Enhanced chips drop to 44px min-width. - Global widget chips drop to 40px. - Both hide the source-name label, showing icon + tooltip only — the full 8-source row now fits or scrolls minimally at that scale. - Aura narrows further to 140px tall. - Library CTA nudges down to 12px font. |
1 month ago |
|
|
30ab21c0e5 |
Global search bar: ambient accent-glow aura under the pill
Adds a subtle radial glow at the bottom of the viewport that emanates from the floating search bar, fades outward toward both window corners, and shrinks vertically as it moves away from the bar. Makes the bar easier to spot at a glance without a heavy full-width bar or a chrome strip. - New `.gsearch-aura` fixed element, 260px tall, full width, pointer events off. Radial-gradient with the accent color centered at the bottom middle; colour stops taper 620x230px by default, ramping to 820x280px and brighter when the bar is focused/active. - `_gsUpdateVisibility` hides the aura on /search alongside the bar via a simple `.hidden` class. - Focus handler adds `.active` to the aura in step with the bar; `_gsDeactivate` removes it. z-index 99990 (below the bar at 99998, above most page content). |
1 month ago |
|
|
dd20298df4 |
Library page empty state: offer to search metadata sources for the query
When a user types an artist name into the library search and gets no
hits, the old empty state just said "No artists found — try adjusting
your search or filters." Dead end for the common case of "I searched
for someone I don't own yet."
The empty state now detects when libraryPageState.currentSearch is
non-empty and swaps in a CTA that hands the query off to /search:
"kendrick" isn't in your library
They might be available on a connected metadata source.
[🔍 Search online for "kendrick" →]
Clicking the button navigates to /search, pre-fills the enhanced search
input, and dispatches an input event so the existing debounced search
fires automatically. Uses the same hand-off pattern _gsNavigateToSearchPage
already uses for Soulseek, so the Search page's source-picker flow
picks up naturally from there.
No change to the generic empty state (no query active) or to any other
library page behaviour.
|
1 month ago |
|
|
c605904a5c |
Source picker: dim unconfigured sources, redirect to Settings on click
The picker used to render every source whether or not the user had credentials for it. Clicking Discogs with no token, Hydrabase with no URL, or Spotify with nothing saved would fire a doomed fetch — at best a silent empty state, at worst a confusing fallback to another source. Now the picker reads /api/settings/config-status (the same endpoint the Settings → Connections page already uses for the green/yellow status dot) on init and dims icons whose service isn't set up. Clicking a dimmed icon navigates to Settings → Connections and scrolls to the relevant service card with a brief accent-coloured pulse to orient the user. Sources the backend's SERVICE_CONFIG_REGISTRY doesn't cover (musicbrainz, youtube_videos, soulseek) are permanently treated as configured — they need no user credentials, so dimming them would mislead. Extra guard: if the user's configured primary metadata source is itself unconfigured (Spotify saved as primary but no client_id yet), `_initDefaultSource` falls forward to the first configured source so the default active icon is never a "set up" chip. Shared helpers: - fetchSourceConfiguredMap() centralizes the config-status lookup for both surfaces. Falls back permissively if the endpoint fails so the picker never stops working over a network hiccup. - openSettingsForSource(src) navigates to Settings → Connections and scrolls to `[data-service=src]`, pulsing a 2.2s accent flash (.stg-service-flash) so the user doesn't lose their place. CSS: - .unconfigured: 42% opacity, 0.7 grayscale filter, subdued hover state with no transform/glow (feels "look but don't touch"), defensive override to kill brand glow if somehow active. - @keyframes stg-service-flash-anim for the scroll-to highlight. |
1 month ago |