mirror of https://github.com/Nezreka/SoulSync.git
main
dev
video
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
2.6.2
2.6.3
2.6.4
2.6.5
2.6.6
2.6.7
2.6.8
2.6.9
2.7.0
2.7.1
2.7.2
2.7.3
2.7.4
2.7.5
v0.65
${ noResults }
1581 Commits (aa2806180e00a09e205f41d088b8f73ea9fe6ccb)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
9b34d06b6d |
UI: migrate remaining compact button families to the .btn--sm tier
Continue the design-system unification (kettui UI-consistency item): migrate the five remaining compact button families onto the shared .btn .btn--sm primitive + color modifiers, and drop their bespoke base CSS (net -125 lines of CSS). - ya-header-btn (Your Albums/Artists, discover.js-injected) -> .btn .btn--sm .btn--secondary; ya-refresh/ya-settings/ya-viewall co-modifiers kept. - explorer-action-btn (Playlist Explorer) -> .btn--secondary / .btn--primary. - repair-bulk-btn -> .btn--secondary / .btn--primary / .btn--warning (fix-all). - enhanced-bulk-btn (Library bulk bar, library.js-injected) -> .btn--primary/ --secondary/--danger; class kept as a hook for the mobile.css size override + the .tag-write / .rg-analyze special colors. - profile-create-btn (init.js-injected) -> .btn .btn--block .btn--primary; class kept for the scoped .profile-edit-buttons flex:1 rule. mini-nav-btn deliberately left as a distinct icon-button archetype. |
3 weeks ago |
|
|
169c30fd5b |
UI: add .btn--sm/.btn--block/.btn--warning tier; migrate sync-history buttons
Formalize the compact 'toolbar' button tier as design-system modifiers (.btn--sm), plus a full-width (.btn--block) and amber caution (.btn--warning) modifier, so the many smaller per-page buttons can share the .btn primitive without being forced to the large default size. First adopter: the Sync page header buttons (.sync-history-btn) now use .btn .btn--sm .btn--secondary. The class is kept as a JS/onboarding selector hook; .auto-sync-manager-btn still tints Auto-Sync accent. |
3 weeks ago |
|
|
ae0968e1b0 |
UI: migrate watchlist/wishlist action buttons to the shared .btn primitive
The watchlist + wishlist header/overview buttons used a bespoke .watchlist-action-btn family (different padding/radius/font and white primary text) instead of the shared .btn design-system primitive. Migrate all 11 of them to .btn / .btn--primary / .btn--secondary / .btn--danger so they match the rest of the app, and drop the now-dead CSS. The .watchlist-batch-remove-btn / .wishlist-batch-remove-btn hook classes are kept on the remove buttons (their !important red overrides compose correctly over .btn--secondary). Static HTML only; no JS-injected usages, and mobile.css overrides target .playlist-modal-btn, not these. |
3 weeks ago |
|
|
a42f8ecc10 |
UI: move Downloads above Automations in the sidebar
Reorder the sidebar nav so Downloads sits between Wishlist and Automations. Mobile nav reuses the same .nav-button elements and the helper/onboarding references are selector-keyed, so no other changes are needed. |
3 weeks ago |
|
|
21426af7fe |
Tools: add Deep Scan option to the Database Updater
The Tools-page Database Updater dropdown only offered Incremental and
Full Refresh, even though the backend (/api/database/update with
deep_scan) and the dashboard Deep Scan button already supported a deep
scan. Wire the missing option into the Tools UI:
- Add a "Deep Scan" option to the #db-refresh-type dropdown.
- handleDbUpdateButtonClick now sends { deep_scan: true } for that
option (deep scan takes precedence server-side) and confirms first,
since deep scan removes stale entries — mirroring the dashboard flow.
Frontend-only; the progress/status handler already drives the bar from
the backend phase ("Deep scan: ...") and the help/docs copy already
described all three modes.
|
3 weeks ago |
|
|
d9a24d48c6 |
Fix: search results disappear when interacting with the media player (#732)
Search results live in an overlay dismissed by an outside-click handler whose allow-list omitted the floating media player. Clicking the mini player to open the now-playing modal (or clicking inside that modal) registered as an outside click and tore the results down, forcing a re-search. Add the media player containers (#media-player mini bar and #np-modal-overlay expanded modal) to the dismiss allow-lists in both the Search page (search.js) and the global search widget (downloads.js), which share the same outside-click pattern. Additive change: only adds exceptions, so every existing dismiss case is unchanged. |
3 weeks ago |
|
|
6129ea8508 |
UI consistency: normalize exception-page outer gutter to .page (40px)
Low-risk tidy-up for the full-bleed "exception" pages that aren't carded. Every page already gets a 40px gutter from .page, but the exception pages were piling on inconsistent extra padding (library +20px, active-downloads +28/32px, discover/docs +0) — giving accidental 60 / 68-72 / 40 effective gutters. Drop the redundant container padding on library and active-downloads so the single .page 40px gutter is the shared, intentional outer spacing across the full-width exception pages. discover (centered max-width) and docs (sidebar layout) keep their functional layout; library's mobile padding override is unaffected. |
3 weeks ago |
|
|
f57fc640b2 |
UI consistency (page shell 6/N): sync page adopts .page-shell card
Standardize the sync page's outer spacing to match the other pages. Like settings, its .sync-header and .sync-content-area were siblings directly under .page (no wrapper) — wrap both in a single .page-shell div so it becomes the floating card with consistent margin/padding. HTML-only change. Watch: .sync-content-area uses height:95% (grid) — fine against an auto-height card, but to be confirmed visually (library's full-height grid was the one that didn't fit a card). |
3 weeks ago |
|
|
079c169f8d |
UI consistency (tabs/cards): add .tab and .card primitives (no migration)
Add the canonical .tab (bordered rounded-pill filter tab) and .card ("glass"
content card) primitives as the documented design-system standard for new
markup and the React pages — modeled on the cleanest existing looks
(watchlist filter pill; dashboard service/stat card).
Deliberately NOT migrating the existing tab/card components onto them: the
current implementations are visually divergent and JS-coupled (active-state
toggled by class name, cards built in JS), so a blind consolidation risks
subtle regressions. These primitives let new/React code be consistent now;
the legacy components migrate when visually verifiable / in React.
Unused classes -> zero visual change to the current UI.
|
3 weeks ago |
|
|
dd5fe844d4 |
UI consistency (buttons 2/N): wishlist modal buttons -> .btn
Migrate the wishlist add-to-wishlist modal buttons onto the shared .btn primitive: primary -> .btn--primary, secondary -> .btn--secondary, the green download CTA -> new .btn--download modifier. Added a shared .btn.loading state (amber pulse, reusing the existing pulse-loading keyframe) since confirm-add-to-wishlist-btn toggles `loading` via JS (wishlist-tools.js). Removed the dead .wishlist-modal-btn* rules and re-scoped the mobile full-width override to `.wishlist-modal-actions .btn`. |
3 weeks ago |
|
|
eebc58d3ff |
UI consistency (buttons 1/N): add shared .btn primitive; migrate config-modal
Start of the button-consolidation pass (kettui's #1). The app had ~236 button classes / ~8-10 distinct looks with heavy near-duplication. Introduce a canonical .btn design-system primitive (base + .btn--primary / .btn--secondary / .btn--danger), modeled on the dominant existing look (accent-gradient primary, translucent ghost, semantic danger) and built on the accent CSS vars. New markup and the React pages should use this; existing per-page button classes will migrate onto it family by family. First family migrated: the config/settings modal buttons (.config-modal-btn*, 4 static uses, no JS refs) -> .btn .btn--primary / .btn--secondary. Removed the now-dead .config-modal-btn* rules and re-scoped its mobile full-width override to `.config-modal-actions .btn`. Visible change is minor by design (padding 28->24px, gradient direction normalized). Proof step for sign-off on the .btn look before rolling wider. |
3 weeks ago |
|
|
44faf44fca |
UI consistency (page shell 5/N): settings adopts .page-shell card
Settings was the one flat page with no single wrapper — its .dashboard-header and .settings-content sat as siblings directly under .page. Wrap both in a single .page-shell div so the page becomes a floating card with the header banner at the top, matching the dashboard structure. HTML-only change (no CSS: .settings-content keeps its minor `0 4px` inner padding). Library is intentionally NOT converted — its full-height artist grid + A-Z jump rail overflow a margin:20px card, so it stays flat as a documented exception (same category as search/discover/active-downloads). |
3 weeks ago |
|
|
45bbc99d94 |
UI consistency (page shell 3/N): playlist-explorer adopts .page-shell card
Convert the playlist-explorer page from a flat padded container to the .page-shell floating card. Drop its bespoke `padding: 24px 32px`; keep the full-height flex layout (display:flex / column / min-height:100%) since the explorer fills the viewport. Visible change by design. Watch: the full-height min-height:100% inside a margin:20px card may run slightly tall — to be confirmed visually. |
3 weeks ago |
|
|
def58a9907 |
UI consistency (page shell 2/N): automations adopts .page-shell card
First of the "flat -> card" conversions. The automations list view sat directly on the page background (.automations-container = bare padding) while its inner .dashboard-header is the same header dashboard uses. Adopt .page-shell so the page becomes a floating gradient card structurally identical to the dashboard (page-shell card > dashboard-header > content). - Drop .automations-container's bespoke `padding: 20px 24px` (card padding now from .page-shell); keep the class as the mobile/JS hook. - Add `page-shell` to the container in markup. Visible change by design (this page was not previously a card). Mobile keeps its existing .automations-container padding override. |
3 weeks ago |
|
|
d2a730a6aa |
UI consistency (page shell 1/2): extract shared .page-shell primitive
First step of the page-layout-shell standardization (kettui's UI-consistency point #1). The dashboard, tools, watchlist and wishlist pages each defined a byte-identical "card" container (padding 28px 24px 30px, margin 20px, gradient bg, radius 24px, border + border-top, layered shadow) under four different class names. Extract that into a single `.page-shell` primitive (modeled on the canonical dashboard/stats look) and have the four pages adopt it. Each keeps its bespoke class for page-specific extras and as a JS/mobile hook: - dashboard-container: keeps display:flex / column / gap:25px - watchlist/wishlist-page-container: keep position:relative - tools-page-container: no extras (box now fully from .page-shell) Zero visual change: computed styles are identical (declarations relocated, not altered), and mobile.css overrides still target the retained bespoke classes. Per-page themed headers (watchlist amber, etc.) are intentionally NOT touched. The class name is intended for reuse by the React pages too, so the primitive is shared across both stacks. Next (wave 2): migrate settings / automations / playlist-explorer / library onto .page-shell, which snaps their slightly-off spacing to canonical. |
3 weeks ago |
|
|
ff974c0b5c |
Standardize artist-detail hero action buttons
The four artist-hero buttons (Artist Radio, Watchlist, Download Discography, Enhance Quality) had drifted apart visually — different sizes, weights, radii, hover treatments, and ad-hoc colors. Unify them on the Download Discography look (the nicest of the set): accent-style gradient fill, matching border, light-tinted text, compact 7x16 / 12px / 700 sizing, and a translateY(-1px) + colored glow on hover. To keep them distinguishable, each carries its own hue within that shared recipe: - Artist Radio -> violet (#8b5cf6) - Watchlist -> theme accent (keeps its amber "watching" state) - Download Discography -> theme accent + shimmer (the primary action) - Enhance Quality -> cyan (#4fc3f7, its original signature color) Also: - Drop the shimmer from the three secondary buttons — four simultaneous shimmers were distracting; it now marks only the primary action. - Remove the `margin: 12px 0 4px` on `.discog-download-wrap` (now `margin: 0; display: inline-flex`) that pushed the discography button ~4px below its siblings in the centered flex row. - Include Artist Radio in the mobile button sizing override (was missing). |
4 weeks ago |
|
|
7145368d42 |
Basic search: visual overhaul + per-source picker in hybrid mode
Two things in this commit. Functional download / matched-download behaviour is untouched — same JS handlers, same routes for the download actions, same album-expand interaction. VISUAL REDESIGN - Glass search-bar card with accent radial wash + focus ring + pill primary search button - Source chip row above the search bar (see below) - Always-visible compact filter pill row (Type / Format / Sort) — pills carry both ``bs-filter-pill`` (new visual) and ``filter-btn`` (legacy class for ``resetFilters`` + ``applyFiltersAndSort`` in wishlist-tools.js to keep working) - Accent-tinted status pill matching the dashboard / auto-sync look - Album result cards: glass card with accent left-edge stripe, 52px brand-tinted cover icon, chevron expand indicator, pill action buttons (Download / Matched Album), accent glow on hover - Track result cards: glass row with accent stripe, 44px icon, pill action buttons (Stream / Download / Matched Download) - Multi-disc separators inside expanded album track lists styled with the accent treatment - Responsive: action button columns stack vertically below 900px New CSS lives in a self-contained ``webui/static/basic-search-v2.css`` sheet linked from index.html. Selectors are scoped to ``#basic-search-section`` for any class that already exists in style.css (``.album-result-card``, ``.album-icon``, ``.track-*``, etc.); the new ``bs-*`` prefixed classes for the search bar / filters / source row / status are unscoped because they only exist in the new markup. ``!important`` is used on the card-level rules to defeat the original unscoped ``.album-result-card`` etc. rules in style.css that would otherwise leak heavyweight padding / box-shadow / 56px icon styles into the new design. Also removed ``overflow: hidden`` from the original ``.album-result-card`` and ``.track-result-card`` rules in style.css — those two classes only render in ``downloads.js`` basic search results (verified via grep, two render sites only), so the removal can't impact any other UI. SOURCE PICKER (hybrid mode) - New ``GET /api/search/sources`` endpoint returns the list of active sources from the orchestrator's chain (or the single active source in single-source mode). - Frontend renders a chip row above the search bar. Click a chip to target that source for the next search; the chip's brand accent fills. - In single-source mode the lone chip is rendered as a dashed- border label so the user always knows what they're searching but can't accidentally try to switch to sources that aren't configured. - ``/api/search`` accepts an optional ``source`` body param. When set, ``core/search/basic.py:run_basic_search`` resolves the client directly via ``orchestrator.client(source)`` and calls its ``.search()`` instead of going through the hybrid chain. - Backwards compatible: omitting ``source`` falls through to the original ``orchestrator.search()`` call exactly as before. Unknown source names also fall back to the default — typo protection. TESTS (5 new + 6 pre-existing = 11 total in test_search_basic.py) - source param routes to specific client, NOT orchestrator chain - no source param preserves original orchestrator-default behaviour - unknown source name falls back to orchestrator default - ``run_basic_soulseek_search`` backwards-compat alias preserved - source-targeted path serialises albums + tracks correctly 101 search-suite tests pass. |
4 weeks ago |
|
|
258905ff5c |
Fix: duplicate tracks in albums with Japanese / CJK titles (#722)
Reporter @Sokhii: downloading the Mushoku Tensei Original
Soundtrack II via Apple Music metadata + Tidal download
produced duplicate library entries — same audio file landed
under multiple track positions in the album view.
Root cause (verified by direct probe + isolated repro):
``MusicMatchingEngine.normalize_string`` correctly skipped
unidecode for CJK text (kanji→pinyin would have produced
gibberish — see the inline comment at line 74-76), but then
ran ``re.sub(r'[^a-z0-9\s$]', '', text)`` which stripped EVERY
CJK character. Every Japanese title normalised to ``''``.
``similarity_score`` has an early-out guard
if not str1 or not str2: return 0.0
so EVERY CJK-vs-CJK title comparison returned 0.000.
Downstream effect: the matcher fell back to duration+artist
alone. For an OST album with 24 tracks all by the same artist
with similar durations, multiple iTunes track queries landed
on the SAME Tidal candidate. SoulSync wrote each download to
a different output filename (per the iTunes track position),
so on disk there were N copies of the same audio under
different track numbers. The user's library showed 34 entries
for an album with 24 actual tracks.
Probed iTunes album 1753240110 directly — 24 distinct tracks,
zero (disc, track_number) collisions, both US + JP storefronts.
So the duplicate origin was definitely downstream of metadata
fetch.
Fix: when CJK is detected upstream, the alphanumeric-strip step
also preserves CJK Unified Ideographs + radicals
(⺀-鿿), Hiragana + Katakana (-ヿ), Halfwidth
/ Fullwidth forms (-), and Hangul syllables
(가-). CJK titles now produce a comparable normalised
form instead of an empty string. ``similarity_score`` works as
intended:
'命の灯火' vs '命の灯火' → 1.000 (was 0.000)
'命の灯火' vs '無職転生' → 0.000 (was 0.000, but now from
actual char comparison
not from the empty-string
guard)
Latin-only normalisation is completely unchanged. ``has_cjk``
is False for Latin input, so both the CJK-lowercase branch AND
the new CJK-preserve strip branch are skipped — Latin titles
go through the original unidecode + lowercase + strip path
verbatim. Tested via 4 regression tests that pin the Latin
baseline (simple, unidecode target, $-preservation, identical
+ different similarity scores).
16 new unit tests in ``tests/test_matching_engine_cjk.py``:
- Kanji / Hiragana / Katakana / Hangul / Chinese all survive
- CJK-only strip still removes Latin punctuation in the
CJK branch
- Mixed Latin + CJK lowercases the Latin half
- Identical CJK titles → 1.0
- Disjoint CJK titles → near 0
- Partially overlapping CJK titles → midrange
- CJK doesn't falsely match unrelated Latin
- 4 Latin-baseline regression pins
- Real-world Mushoku Tensei OST scenario
371 text + imports + new CJK tests pass after the fix.
|
4 weeks ago |
|
|
6d54203710 |
Bump version to 2.6.4
- _SOULSYNC_BASE_VERSION → 2.6.4 - helper.js: '2.6.4' unreleased → 'May 28, 2026 — 2.6.4 release' - .github/workflows/docker-publish.yml default version_tag → 2.6.4 - pr_description.md: rewrite for 2.6.4 with #721 as the headline patch, 2.6.3 fixes carried forward unchanged (2.6.3 was bumped on dev but never reached main / docker, so 2.6.4 is the first release to ship this batch) |
4 weeks ago |
|
|
df675c7c9f |
Fix: Usenet bundle stuck on "downloading release" when SAB History flips before storage lands (#721)
Follow-up to the 2.6.3 queue→history handoff fix (#706). User @IamGroot60 reported in #721 that on 2.6.3 the bundle still gets stuck mid-flight: SoulSync UI sits on "Usenet downloading release 61%" forever, SAB History shows the job as Completed 2+ minutes ago, files are physically present in the slskd downloads folder but never copied into ``storage/album_bundle_staging/<batch>/``. Root cause: a second-stage gap in the SAB pipeline. SAB flips a job's ``status`` to ``Completed`` in History as soon as par2 + unrar finish, but its post-processing pipeline writes the final ``storage`` field a few seconds LATER (the move-to-final step). ``poll_album_download`` saw the first ``Completed`` read with ``save_path=None`` and bailed: if status.state in complete_states: return last_save_path # ← None at this point ``download_album_to_staging`` got ``save_path=None``, set ``result['error']`` and returned. The bundle was marked failed but the LAST progress emit before the failure was ``downloading progress=0.61``, so the UI froze on "61%" — the terminal ``failed`` emit never registered on the user's screen because the renderer holds the last-known progress. Fix - ``poll_album_download`` now tracks a separate transient counter for "complete state seen, save_path not yet set." Up to ``transient_miss_threshold`` (default 5) consecutive reads in that state are tolerated before the poll bails. SAB writes the ``storage`` field within 2-10 seconds of the History flip in practice — the default 5 × 2s = 10s window covers it. - When save_path eventually lands, return it normally. - When the threshold is exhausted with save_path still empty, emit terminal ``failed`` with an explicit message pointing at the missing save_path field — no more 6-hour silent spin. - Earlier ``downloading`` reads with a non-empty ``save_path`` (qBit / Transmission set this from the start of the download) remain "sticky" — if the eventual ``completed`` read has empty save_path, the cached one applies. So torrent flows aren't affected by the retry path. SAB adapter (``_parse_history_slot``) - Widened the save_path field fallback chain: storage → path → download_path → dirname → incomplete_path Covers SAB version differences (older builds populated ``path``) and forks that expose ``download_path`` or ``dirname``. ``incomplete_path`` is the last resort — SAB's in-progress dir before the final move — so the bundle plugin at least has a path to scan when nothing else lands. - Whitespace-only values are skipped. - Loud debug log when none of the known fields land — users on SAB versions / forks with novel field names need to see this in logs so we can grow ``_HISTORY_SAVE_PATH_KEYS``. Tests - ``test_album_bundle.py`` (3 new): - tolerates_completed_with_late_save_path_arrival — the #721 scenario; first Completed read has no save_path, third has it; poll returns the path normally - gives_up_when_completed_with_no_save_path_persists — past the threshold the poll fails loudly instead of silent-spinning - uses_save_path_from_earlier_downloading_emit_if_completed_lacks_one — sticky save_path keeps torrent flows working - ``test_usenet_client_adapters.py`` (6 new): - falls back to ``path`` when ``storage`` empty - falls back to ``download_path`` - prefers ``storage`` when multiple fields present - returns ``None`` when all fields empty (the #721 gap window) - ignores whitespace-only values - uses ``incomplete_path`` as last resort 132 album-bundle + usenet tests pass. Branch is on dev parented at 2.6.3 — user @IamGroot60 offered to test on dev, so this is a candidate cherry-pick for either a 2.6.4 hotfix or merge straight into dev for the next release. |
4 weeks ago |
|
|
a9608e1bcb |
Bump version to 2.6.3
- _SOULSYNC_BASE_VERSION → 2.6.3 - helper.js WHATS_NEW unreleased flag → 'May 27, 2026 — 2.6.3 release' Note: .github/workflows/docker-publish.yml default version_tag was also bumped to 2.6.3 locally, but .github is gitignored in this repo — workflow updates need to land via the GitHub UI separately. The workflow_dispatch input is overrideable at trigger time regardless of the default, so this isn't blocking. |
4 weeks ago |
|
|
5771c5ba77 |
Album-bundle staging: clean Soulseek copies + sweep orphans at startup
Two related leaks in ``storage/album_bundle_staging/<batch_id>/``:
1. **Soulseek bundle cleanup was excluded.** The per-batch cleanup
at the end of a bundle download gated on:
(album_bundle_source or '').lower() in ('torrent', 'usenet')
The comment justified it as "slskd keeps its own completed
folders" — but the Soulseek bundle path ALSO copies completed
files into the private staging dir (``soulseek_client.py:1599``,
``copy_audio_files_atomically(completed, Path(staging_dir))``)
for the per-track workers to claim. Those copies persisted
forever; long-running installs accumulated stale GB. Extended
the cleanup gate's allow-list to include ``soulseek`` so the
per-batch dir is removed on bundle completion — same code path
that already worked for torrent / usenet.
2. **No sweep for orphan dirs.** Any leftover ``<batch_id>``
subdir from a previous-session crash, an errored batch, or a
pre-fix Soulseek bundle stayed on disk forever. Added
``sweep_orphan_album_bundle_staging(staging_root, active_batch_ids)``
that runs ONCE at server startup, before any batch can register
a staging dir. Removes every ``<batch_id>``-shaped subdir
whose id isn't in the active set. Safe by construction:
- Only touches subdirs of the configured staging root.
- Name-shape check (``entry.name == _safe_batch_dirname(entry.name)``)
rejects hand-placed dirs like ``.git`` or stray docs.
- ``shutil.rmtree`` errors log + continue — sweep must not
crash app startup over a permission glitch.
- active_batch_ids normalised through ``_safe_batch_dirname``
so colon-bearing batch_ids match their on-disk form.
Wired into the web_server startup right after the stuck-flags
diagnostic so it fires before anything else touches batches.
Tests
- ``test_downloads_lifecycle.py`` gained one regression test
pinning that Soulseek bundles now have their staging dir
cleaned (sibling to the existing torrent test).
- ``test_album_bundle_staging_sweep.py`` (NEW, 11 tests)
covers: orphan removal with no actives, active dirs preserved,
special-char batch_id normalisation, no-op on missing /empty
/empty-string staging root, non-dir entries skipped, unsafe-
name dirs preserved (.git etc.), partial rmtree failure doesn't
abort the rest, listdir failure returns 0 cleanly, default
None active set, defensive against empty / None entries in
the active set.
488 downloads tests pass.
For users with an existing "clean up old files" automation pointed
at this dir: stop pointing it there if you want — the auto-cleanup
+ startup sweep cover it now. Or leave it as belt-and-suspenders
with a relaxed (1h+) mtime threshold so it can't race a mid-batch
download.
|
4 weeks ago |
|
|
4ae5aee528 |
Sync page: collapse tabs to brand-logo chips with active label pill
The sync-tabs row had 14 sources jostling for horizontal space — labels wrapped to 2 lines, the active pill ate disproportionate room, the whole strip felt cramped and would only get worse as more sources get added. Restyled the strip as circular brand-logo chips. Inactive tabs are 40px discs that show only the source's icon; the currently- active tab swells into a pill that reveals its label inline. Hover surfaces the source name as a native tooltip via the title attr. Each chip carries its source's brand color as a hover ring + active fill (Spotify green, Tidal orange, Qobuz blue, Deezer purple, iTunes coral, YouTube red, Beatport green, LB orange, Last.fm red, SSD teal). Three sources share a logo with another source (Spotify Link / Spotify, Deezer Link / Deezer, iTunes Link / no native iTunes but same logo family). Each "Link" variant carries a small chain-link badge bottom-right so the chip disambiguates without forcing the label to always be visible. CSS-only swap — same JS handlers, same .active class, same data-tab routing. HTML edit wraps each tab's label in a ``<span class="sync-tab-label">`` and adds ``data-link="true"`` to the Link variants so the CSS can target them. Responsive: chips collapse to 36px on laptop / tablet and 32px on mobile; the divider hides on mobile and gap tightens. |
4 weeks ago |
|
|
f976a6da53 |
Fix: Soulseek album-bundle downloads stuck on "failed" after slskd
finished the release (#715) Symptom (user @pavelcreates / @IamGroot60 on 2.6.2): - Click Download on an album in the search modal - slskd starts + completes every track of the release - 22+ minutes after the last completed download, batch flips to "failed" with no clear log line explaining why - Per-track Soulseek downloads on the same machine were fine Root cause: ``core/soulseek_client._resolve_downloaded_album_file`` probed three hard-coded candidate paths to locate each downloaded file in the slskd download dir: candidates = [ download_path / remote_filename, download_path / basename, download_path / *normalized_path_parts, ] On the common slskd config ``directories.downloads.username = true`` slskd writes files at ``<download_dir>/<username>/<filename>`` — none of the three candidates carry a username segment, so the resolver returned None for every file even though the file was physically present in a subdir one level deeper. ``_poll_album _bundle_downloads`` saw 0 completed_paths, kept spinning, and hit the master deadline (~30 min) before bailing the batch. Why per-track worked: ``web_server._find_completed_file_robust`` already does a recursive walk-by-basename + path-confirm against the remote directory components, so any layout slskd writes ends up resolved. The bundle path didn't go through it. Fix - Lifted the robust finder into ``core/downloads/file_finder.py`` as a pure function ``find_completed_audio_file(download_dir, api_filename, transfer_dir=None) -> (path, location)``. Zero globals; recursive walk; handles slskd dedup suffix ``_<10+digit-timestamp>``, YouTube / Tidal ``id||title`` encoded filenames, the AcoustID-quarantine subdir skip, basename collisions disambiguated by remote-path components, and a fuzzy-basename fallback above 0.85. - ``_resolve_downloaded_album_file`` keeps the three-candidate fast path (cheap probe for the slskd-flat default) but now delegates to the new helper when none hit, instead of giving up. - ``_poll_album_bundle_downloads`` tracks "slskd reports Completed but local resolver returns None" per key. When every remaining key has been in that state past a 45-second grace window, the poll exits early with an explicit error pointing at the likely ``soulseek.download_path`` mismatch instead of silently spinning until the master deadline. - ``web_server._find_completed_file_robust`` becomes a thin delegate so both callers share one finder. Legacy inline impl kept as ``_find_completed_file_robust_legacy`` for reference; to be removed next release. - Fixed misleading ``"(0 tracks, quality=)"`` log on the preflight- reuse path — was reading attrs off a None ``picked`` object. Tests (17 new in tests/downloads/test_file_finder.py) - Flat slskd layout - Username-prefixed (the #715 case) - Full remote tree preserved - Deeply nested username + tree - File genuinely missing returns None - Basename collision disambiguated by remote dirs - Single basename match wins regardless of dirs - slskd dedup suffix match - Short ``_<digits>`` (year) not treated as dedup - AcoustID quarantine subdir skipped - YouTube / Tidal ``id||title`` encoded filenames - transfer_dir fallback - Both dirs miss → (None, None) - Non-audio files ignored - Empty api_filename - Fuzzy match on punctuation variant - Fuzzy rejects below threshold 475 downloads tests pass after the lift. |
4 weeks ago |
|
|
b19c1ae8cc |
Auto-Sync sidebar: brand logo on each source-group header
The sidebar source-group headers (Spotify / Tidal / Qobuz / Deezer / YouTube / Last.fm Radio / ListenBrainz / iTunes Link / SoulSync Discovery / Spotify Link) only showed the source name in caps — the dashboard equalizer + connections panels both render the actual brand logo, so the sidebar reading as text-only felt disconnected. Added a small (18px) circular brand-logo chip to the left of each source-group title, sourced from the same URLs the dashboard equalizer avatars use. Dark glass backdrop + accent ring + drop-shadow on the logo so the chip stays legible against either light or dark marks; brightness(0) invert(1) applied to Tidal / Qobuz / iTunes-Link so their dark-foreground marks render as white silhouettes against the disc (same recipe the equalizer overrides use). Last.fm's square avatar PNG clips to a circle via object-fit: cover. Sources without a publicly available logo (Beatport, file imports) fall through to no-chip — the <img onerror> swap hides the broken image so the header still renders cleanly. |
4 weeks ago |
|
|
e296fbfadd |
Auto-Sync manager: full visual overhaul to match the dashboard vibe
The Auto-Sync manager modal had been carrying its original visual treatment forward unchanged while the rest of the app moved toward the glassy / accent-radial / gradient-border aesthetic the dashboard now sets. Restyled every surface inside the modal to match. Strategy: selector-based override layer appended at the end of ``webui/static/style.css`` — every selector in the new block already exists in the original CSS above; the new block wins on cascade order. Zero HTML / JS changes; functionality untouched. Delete the v2 block to revert. Surfaces restyled - Modal shell: glass + thin accent border + corner radial wash + inner top-edge highlight, matching the dashboard ``.dash-card`` architecture - Header: gradient-clipped title, accent-tinted eyebrow, hairline accent separator below, spinning-X close button - KPI summary tiles: dashboard-style gradient tiles, accent top-edge glow on hover, gradient stat numbers - Live monitor strip: accent-tinted glass card, status-colored borders (running = green, error = red) - Refresh / intro buttons: pill primary with accent fill + glow on hover (replaces the bare ghost button) - Tabs: underline-style with accent fill + soft radial glow on the active tab (replaces the pill-tab look) - Sidebar: glass panel, source groups as collapsible-feel cards, accent border on scheduled playlist tiles, accent ring on the filter input focus - Board: subtle accent radial spotlight backdrop; columns are glass cards with gradient headers + accent drag-over glow - Drop zones: animated dashed pill with accent radial wash; accent-tinted text on drag-over - Scheduled cards: accent left-edge stripe, gradient pill timing badges, pill "Run now" primary + rotating ghost X — health variants (failing / warning) re-tint the left-edge stripe - Run history rows: dashboard recent-activity aesthetic, accent hover lift, pill status badges with colored borders - Bulk schedule popover: glass card with accent border, pill buttons, red ghost for unschedule - Weekly editor: glass modal matching version-modal vibe, day-toggle pills (accent fill when active), pill save button - Empty / monitor-empty states: dashed glass card with subtle vibe - Soft accent-tinted scrollbars throughout the modal |
4 weeks ago |
|
|
6a619254df |
Auto-Sync: weekly board cards now match the hourly board
When the Weekly Board shipped, its scheduled-card visual diverged
from the hourly board's: weekly cards showed only the playlist
name + weekly label + timezone, while hourly cards already
carried a full action row (Run-now button, unschedule X,
next-run countdown, health badge). Two boards looking like
different apps.
Standardised the weekly card on the hourly shape so a day-column
drop produces the exact same affordances as an interval-bucket
drop:
- Health badge (warning ⚠ / failing !) when recent runs errored
- Source + track-count meta line under the name
- Timing line: weekly label + tz pill + next-run countdown
- Run-now button (disabled while pipeline running, same gating
logic the hourly card already had)
- Unschedule X — calls the weekly-specific helper, leaving
hourly schedules untouched
Click anywhere outside the buttons still opens the weekly editor
for changing days / time / tz. Weekly cards also become
draggable between day columns now — drop on a new column appends
the day to the schedule (matches the multi-day editor flow).
|
4 weeks ago |
|
|
a315192e9a |
Dashboard: round Last.fm avatar, invert dark-mark logos (Tidal/Qobuz/Discogs/Amazon)
Last.fm ships a square Twitter avatar; clip it to a circle so the disc reads as a uniform chip. Tidal / Qobuz / Discogs / Amazon ship dark-foreground marks that disappear against the dark glass avatar backdrop — invert to a white silhouette so the logo actually reads. The per-service accent drop-shadow still applies so the brand color cue is preserved as a glow around the white silhouette. |
4 weeks ago |
|
|
79465580ab |
Dashboard: equalizer bars now show real brand logos in the avatar disc
Initial-letter glyphs (SP / AM / DZ / ...) read as placeholder once the brand-logo equalizer disc was visualised — each chip should carry the service's actual mark. Wired the same logo URLs the header-action worker orbs already load (Spotify press asset, iTunes Wikimedia SVG, Deezer brandfetch symbol, Last.fm avatar, Genius logo, MusicBrainz Wikimedia SVG, AudioDB local PNG, Tidal / Qobuz / Discogs SVGRepo marks, Amazon local SVG) into a new _RATE_GAUGE_LOGOS map and rendered as an ``<img>`` inside the avatar disc. Visual details - Disc backdrop switched from a solid accent-gradient fill to a dark glass radial + accent-tinted ring + accent drop-shadow on the logo. The service color still anchors the chip without competing with the logo for contrast. - Logo sized at 75% of the disc for breathing room. Drop-shadow pops dark / multi-tone marks against the dark backdrop. - Avatar bumped to 34px / 28px / 26px across desktop / tablet / mobile so logos read clearly at every breakpoint. Resilience - ``<img onerror>`` swaps in an initial-letter glyph span on load failure (CDN drop, network blip). The ``.rate-eq-avatar --fallback`` variant restores the original accent-gradient disc look so the fallback chip still reads as branded. Asset - AudioDB ships no public logo URL — saved the existing header- action base64 PNG (~30 KB) to ``webui/static/audiodb.png`` so the equalizer can reference it as ``/static/audiodb.png`` like Amazon already does. |
4 weeks ago |
|
|
c845fa933f |
Dashboard: next-level polish on enrichment equalizer bars
Four upgrades that take the equalizer row from clean to vibey. All tied together by the same --eq-accent / --eq-glow CSS variables so future tweaks stay coherent across the four animation layers. 1. Brand-color avatar disc above each bar. Circular chip with a 2-3 letter glyph (SP / AM / DZ / LF / GN / MB / ADB / TD / QB / DC / AZ) and a radial gradient using the service's accent. Inner highlight + drop-shadow for depth; slow halo pulse when the worker is running. Anchors each capsule to its identity so the row reads as "these are your services" not "these are 11 anonymous bars." 2. Peak-flash detector. When ``cpm`` actually steps upward between socket updates (above a small jitter floor so near-zero noise doesn't trigger), the peak tip briefly flares white-hot, the fill flashes brighter, and the reflection puddle ripples — all on a 650ms one-shot the JS removes after fire. Mimics a hardware VU meter's peak- detect LED. Sells the "alive" feeling by tying bar movement to real call activity, not just continuous animation. 3. Rolling-counter number animation. The live count under the bar digit-animates from old→new with easeOutCubic over 520ms instead of snapping. Per-element animation handles tracked in a WeakMap so a fast second update cancels the prior RAF loop instead of fighting it. Premium-counter feel. 4. Glass-surface reflection puddle. Soft accent-colored blurred ellipse under each bar; opacity scales with the real (unclamped) rate via the --eq-glow variable so idle bars don't pollute the row with permanent ground-light. Rate-limited bars get a red puddle. Peak-flash briefly intensifies the puddle so the surface "ripples" with the call burst. Mounted on the host button (not the track) so it escapes the track's overflow clipping. Responsive: avatar disc shrinks to 26px at laptop/tablet, 24px at mobile. |
4 weeks ago |
|
|
74dcafd6e9 |
Dashboard: equalizer-bar redesign for Enrichment Services panel
The rate monitor on the dashboard used a 10-column grid of circular SVG speedometers. With 11 services configured (Amazon was the straw), the grid produced 10-in-row-1 + 1-orphan-in-row-2, breaking the dashboard's tile symmetry. Speedometers also wasted ~80% of their pixels on empty arc — most services sit at 0 cpm most of the time, so the row visually read as a wall of empty gauges. Replaced with a VU-meter / equalizer row: one vertical capsule per service, brand-color gradient filling from the bottom, bar height tracks ``calls/min ÷ limit``. Music-app native aesthetic, fits the existing accent-heavy glassy vibe, and symmetric by design at any service count — services slot into the flex row. Visual details - 4% sliver floor on idle bars so the row reads as "everything alive" instead of "8 dead gauges" — vibe over literal zero - Continuous shimmer scan when worker is running (vertical wash) - Slow breathing pulse on idle bars - Red gradient + faster pulse when rate-limited - White-hot peak tip glows in the service's accent color - Status pill below each bar (Running pulses green, Paused amber) - Big count number floats top-center of the track Behavior - Click any bar opens the same detail modal the speedometer used — no data-flow changes, no API changes, drop-in visual swap. - Renderer auto-detects the dashboard context (data-card="enrichment") and routes through the equalizer path; legacy speedometer code still ships for any non-dashboard mount. - Responsive: tightens at laptop/tablet breakpoints, wraps to 5-per-row on phones. |
4 weeks ago |
|
|
17607c2d83 |
Update style.css
|
4 weeks ago |
|
|
01a867e589 |
Auto-Sync: fix LB pipelines stuck on "Refreshing:" for 5+ minutes
Pipeline-driven Auto-Sync runs against any ListenBrainz playlist
(Weekly Jams, Weekly Exploration, Top Discoveries, etc.) would sit
on ``Refreshing: "<name>"`` with no UI updates for 5-7 minutes
before the pipeline progressed. Two real bugs stacked:
1. **Double discovery.** The refresh handler called
``_maybe_discover`` (matching engine, per-track Spotify/iTunes/
Deezer matches) inline for any source returning
``needs_discovery=True`` tracks. Phase 2 of the pipeline then
ran the SAME matching engine via ``run_playlist_discovery_worker``
on the same tracks. The refresh-side run blocked the loop with
zero progress emission; Phase 2's already has the timed
progress-poll pattern. So LB tracks discovered twice, the first
time silently.
Pipeline now sets ``skip_discovery=True`` on its refresh config.
The handler honors the flag and lets Phase 2 handle discovery
end-to-end. Standalone callers (Sync-page tab, registration
action) leave the flag unset so they still get matched_data
on refresh.
2. **No targeted LB refresh.** The LB adapter's ``refresh_playlist``
called ``manager.update_all_playlists()`` — the only refresh
entry-point the manager exposed — which re-pulls every cached
LB playlist's details from the API (~12+ round-trips) even
when only one playlist needed refreshing. Wasteful;
tax-on-everyone for one-playlist work.
Added ``LBManager.refresh_playlist(mbid)`` — reads the cached
playlist_type, fetches just that playlist's details, runs the
normal ``_update_playlist`` upsert path. Defaults type to
``user`` for un-cached mbids so new-playlist discovery still
works. Skips ``_cleanup_old_playlists`` and
``_ensure_rolling_mirrors_from_cache`` (wasted work for a
single-playlist refresh).
Also: killed a silent ``except Exception: pass`` in the LB
adapter's old refresh wrapper that was masking every LB API
failure as a stale-cache hit. Refresh errors now log with full
traceback at warning level and propagate ``None`` so the outer
handler at ``refresh_mirrored.py:104`` counts the error and
surfaces it to the run-history error tally.
Pinned with 12 new unit tests across:
- ``tests/test_listenbrainz_manager.py`` (8): targeted refresh
happy path, unauthenticated guard, empty-mbid guard, upstream
``None`` return, default playlist_type for unknown mbid,
exception propagation, cost guard skipping cleanup, skipped-
when-unchanged signal
- ``tests/test_playlist_sources_adapters.py`` (3): adapter uses
targeted call (not legacy), adapter returns ``None`` on manager
error (not silent swallow), adapter resolves synthetic series
ids before calling the manager
- ``tests/automation/test_handlers_playlist.py`` (1):
skip_discovery flag bypasses ``_maybe_discover`` end-to-end
|
4 weeks ago |
|
|
45ecf2730d |
Wishlist: harden Spotify backfill — poisoned tn=1 can't mask lean album
Residual per-track wishlist downloads (single tracks from different
albums, below the album-bundle threshold) were producing folders
without a year subfolder whenever the wishlist row carried a stale
``track_number=1`` from an older payload default.
Why: ``core/downloads/candidates.py`` had a single API-fetch branch
that served two concerns — resolving the track position AND
hydrating the lean ``spotify_album_context`` (release_date /
total_tracks / cover image) — gated entirely on track_number being
unresolved. When the wishlist row's ``track_number`` happened to
be 1 (a poisoned default rather than a real value), the gate
short-circuited and the album hydration the same call would have
done was skipped. Deezer-sourced discovery matches don't ship
release_date in their search-result album shape, so without the
backfill the folder lost its year.
The two concerns split:
- track_number resolution keeps its track_info → track object →
API precedence chain. track_info defaults still win.
- album hydration runs whenever release_date or total_tracks are
missing, independent of where (or whether) track_number was
resolved.
The single API round-trip still serves both — the cost contract
is preserved. The side-effect coupling is gone.
Lifted into ``core/downloads/track_metadata_backfill.py``
(``hydrate_download_metadata``) so the precedence chain is pinned
in isolation. 24 unit tests cover the precedence chain, the
poisoned-tn=1 regression case, defensive non-dict/None inputs,
the cost guard (API called at most once per invocation), and
disc_number resolution.
Also lands the upstream piece: ``core/wishlist/routes.py:_build_track_data``
no longer defaults ``track_number=1`` / ``disc_number=1`` /
``total_tracks=1`` / ``release_date=''`` when the library-modal add
payload omits them. Missing values now flow through as ``None`` so
the downstream pipeline can detect-and-recover instead of locking
to a fake position.
|
4 weeks ago |
|
|
997732ee63 |
Wishlist: fix three regressions causing all imports to land as track 01 with no year
Real-world regression triggered by the album-bundle work earlier in
2.6.3. Tracks with full Spotify metadata were importing as
``01 - <title>`` under ``Artist - Album/`` (no year), even when the
source filename carried the correct track number and Spotify's
release_date was available.
Investigation via DB inspection of stored wishlist rows:
```
"Never Gonna Give You Up" → track_number=None, release_date=""
"idfc" → track_number=1, release_date=""
"No Sleep Till Brooklyn" → track_number=1, release_date=""
```
Source-of-truth Spotify metadata had release_date AND real track
positions, but the wishlist row was poisoned. Three regressions
compounded the loss:
**Fix A — ``track_object_to_dict`` (``core/wishlist/payloads.py:295``)
preserved only album.name during Track→dict conversion.**
Pre-fix:
```python
album_name = "Unknown Album"
if hasattr(track_object, "album") and track_object.album:
if hasattr(track_object.album, "name"):
album_name = track_object.album.name
else:
album_name = str(track_object.album)
result = {
...
"album": {"name": album_name}, # ← release_date / images / etc. all dropped
...
}
```
When a wishlist payload arrived as a Track dataclass instead of a
raw spotify_data dict, the Track→dict conversion stripped
release_date, images, album_type, total_tracks, id, and album-level
artists. Every wishlist row added through this path landed in the
DB with ``album={'name': X}`` only.
Post-fix: three branches handle the three album shapes
- ``album_attr`` is a dict → ``dict(album_attr)`` preserves every key
- ``album_attr`` is a sub-object → pull all common Album-dataclass
attrs (id, release_date, album_type, total_tracks, images, ...)
- ``album_attr`` is a bare string → build a dict from the track
object's adjacent attrs (release_date, album_id, album_type, ...)
and surface ``image_url`` as ``album.images``
**Fix B — ``core/discovery/playlist.py:309`` only added
``track_number`` / ``disc_number`` keys when truthy.**
Pre-fix:
```python
matched_data = { 'id': ..., 'name': ..., ... } # no track_number / disc_number
if track_number:
matched_data['track_number'] = track_number
if disc_number:
matched_data['disc_number'] = disc_number
```
Deezer-sourced matches always hit this branch with ``track_number=None``
because the cache enrichment at line 304 reads ``_raw.get('track_number')``
literally, but Deezer's raw shape uses ``track_position``. So the key
was omitted from ``matched_data``, downstream consumers couldn't
distinguish "missing key" from "value is 1", and the chain silently
filled 1.
Post-fix: keys are ALWAYS present (None when unknown). Also adds a
``best_match.track_number`` fallback so the Track-dataclass-mapped
value (which DOES include ``track_position``→``track_number``
mapping) gets used when the cache lookup misses.
**Fix C — Pipeline only consulted ``album_info.track_number`` before
falling to the filename (``core/imports/pipeline.py:645``).**
VA-collection source files like ``417 Fountains of Wayne - Stacys
Mom.flac`` have a leading playlist-position number that isn't the
album track number. The previous chain (album_info → filename →
floor-1) couldn't recover the real position because the filename
extractor either returned 417 (wrong) or None (caught by the floor).
But the wishlist payload's ``track_info.spotify_data.track_number``
HAD the right answer all along — Spotify says Stacy's Mom is track
3 on Welcome Interstate Managers.
Post-fix: resolution chain extracted into ``core/imports/track_number.py:resolve_track_number``
as a pure function:
1. ``album_info.track_number`` (album-bundle dispatch authoritative)
2. ``track_info.track_number`` (per-track flow payload)
3. ``track_info.spotify_data.track_number`` (nested fallback)
4. ``extract_explicit_track_number(file_path)`` (filename, returns
0 when no numeric prefix — vs the default helper that returns 1)
5. Caller (pipeline) applies the final >=1 floor
Each step coerces to a positive int or falls through to the next.
Pure function = unit-testable in isolation = single place to fix
the rule.
**Test coverage (37 new tests):**
- ``tests/wishlist/test_payloads.py`` (+4) — Track→dict conversion
preserves full album dict (dict / object / string album shapes) +
None-track-number stays None.
- ``tests/discovery/test_discovery_playlist.py`` (+2) — matched_data
always includes track_number/disc_number keys (None when unknown)
+ falls back to best_match attrs when cache misses.
- ``tests/imports/test_track_number_resolver.py`` (+16) — every
resolution-chain branch pinned: album_info-wins, track_info
fallback, spotify_data nested, JSON-string parsing, garbage-string
fall-through, zero / negative / non-numeric / string-numeric
coercion, filename fallback, explicit extractor vs default
extractor semantics, defensive None inputs, VA-collection
filename behaviour, all-sources-missing → None.
1571 wider-suite tests pass (wishlist + imports + discovery +
downloads + metadata). Ruff clean.
**Migration note:** existing wishlist rows that were saved under
the OLD ``track_object_to_dict`` (with stripped album metadata) still
have ``release_date=''`` in the DB blob. Those won't self-heal — the
next attempt loads from the poisoned blob. Users can remove + re-add
those tracks to refresh, or wait for the next sync run that
re-discovers them with full metadata. No automatic migration shipped
in this PR (scope creep — the forward path is fixed, backfill is a
separate concern).
|
4 weeks ago |
|
|
6841128dc2 |
Wishlist: distinguish Queued from Analyzing for executor-pending batches
PR 4 of 4 in the wishlist-album-bundle issue series. UI fix only —
zero behavior change.
User's 26-track wishlist run rendered all 26 sub-batches as
"Analyzing..." simultaneously. Pre-fix the rows were created with
``phase='analysis'`` BEFORE being submitted to ``missing_download_executor``
(max_workers=3 by default), so 23 batches sat in the executor queue
visually identical to the 3 actually running. Misled users into
thinking SoulSync was processing 26 in parallel; really only 3 ever
ran at once with the rest waiting their turn.
Fix:
- Wishlist auto-flow submission sites now create batch rows with
``phase='queued'``.
- The master worker (``core/downloads/master.py:328``) already flipped
phase to ``'analysis'`` as its first action on entry — that
transition becomes the real signal that the executor picked the
batch up.
- ``core/downloads/status.py`` surfaces ``analysis_progress`` for
the ``queued`` phase too so the UI has the track count to render
"Queued — N tracks" instead of an empty card.
- Frontend (``webui/static/pages-extra.js``, ``downloads.js``) renders
"Queued ⏳" for ``phase='queued'`` distinct from the spinner-laden
"Analyzing..." for ``phase='analysis'``.
Scope choices:
- Only the auto-wishlist submission sites flipped this PR
(``core/wishlist/processing.py:860`` album sub-batches +
``core/wishlist/processing.py:907`` residual). The manual-wishlist
sites at ``:451`` and ``:627`` use the same executor + worker, but
those create a caller-allocated batch_id that the frontend polls
immediately — wanted to verify the manual-poll path handles
``queued`` cleanly before flipping those. Trivial follow-up.
- Other submission sites in album_bundle_dispatch / web_server.py /
task_worker.py left untouched — they don't go through the
executor-queue pattern that causes this UI confusion.
Tests:
- Updated ``test_process_wishlist_automatically_creates_batch_for_matching_tracks``
to assert ``phase='queued'`` on creation (was ``'analysis'``); explanatory
comment names the executor-pool reason.
- New ``test_queued_phase_surfaces_analysis_progress_for_ui_count`` in
``tests/downloads/test_downloads_status.py`` pinning the new
``queued ⊂ analysis_progress`` rendering contract.
- 884 tests pass across wishlist + downloads + imports suites.
- Ruff clean on changed Python files; JS syntax OK on changed
webui files.
PR 3 (sibling-completion gate) was investigated and dropped — the
"1/26 finalized" symptom turns out to be downstream of the
staging-match bug (PR 2's instrumentation will catch it on the
user's next reproduction run), not an independent sibling-gate bug.
The gate logic itself is correct.
|
4 weeks ago |
|
|
dd32e3bbe1 |
Wishlist: only engage album-bundle when multiple tracks from same album (PR 1/4)
Real-world wishlist case the original
|
4 weeks ago |
|
|
698c21c3ce |
Auto-Sync Weekly Board: weekday schedules in the UI (PR 3/4)
PR 3 of the schedule-types feature — see
``memory/project_auto_sync_schedule_types.md``. Backend
``next_run_at`` + ``weekly_time`` trigger handler landed in PRs 1-2.
This PR exposes them in the Auto-Sync manager so users can finally
schedule playlists by day-of-week + time instead of only hourly
intervals.
**UI layout:**
The Auto-Sync modal grows a ``Weekly Board`` tab between
``Hourly Board`` (renamed from ``Schedule Board``) and
``Automation Pipelines``. Same sidebar (mirrored playlists grouped
by source, with filter). Main panel is 7 day columns Mon-Sun
instead of 10 hour buckets. Drag a playlist onto a day column →
creates a single-day weekly schedule at the default time
(09:00 in the browser's IANA tz from
``Intl.DateTimeFormat().resolvedOptions().timeZone``). Click any
scheduled card → opens an editor popover for time, multi-day
toggles, tz override, and unschedule.
Multi-day schedules render under every matching column (Mon-Wed-Fri
schedule appears as three cards, one per column) — matches how
users think about "this playlist runs on Mon AND Wed AND Fri".
**Mutual exclusion:** one schedule per playlist. The save path on
either tab deletes any existing schedule of the OTHER kind before
installing the new one. Backend can technically run both as two
separate automation rows, but two cards under the same playlist
would surprise users and the engine has no merge semantic for
"daily-and-hourly".
**Pure-function helpers** (testable via node:test, matching the
existing ``tests/static/test_auto_sync.mjs`` pattern):
- ``detectBrowserTimezone()`` — Intl tz with UTC fallback for
browsers where Intl is absent.
- ``autoSyncWeeklyTrigger({time, days, tz})`` — defensive payload
builder: garbage time → 09:00, unrecognised days dropped,
missing tz → browser tz.
- ``autoSyncWeeklyFromTrigger(config)`` — inverse parser with
the same defensive shape. Empty days expands to every weekday
(matches ``next_run_at`` engine semantic). Returns null for
non-object configs so ``buildAutoSyncScheduleState`` can route
broken rows to automationPipelines instead of silently
bucketing them as every-day weekly.
- ``autoSyncWeeklyLabel(parsed)`` — sorted "Mon, Wed, Fri @
09:00" / collapses to "Daily @ HH:MM" for full-week / "Unscheduled"
for null. Canonical Mon-Sun ordering regardless of input order.
**Tests:** 26 new node:test cases across ``detectBrowserTimezone``
x1, ``autoSyncWeeklyTrigger`` x6, ``autoSyncWeeklyFromTrigger`` x6,
``autoSyncWeeklyLabel`` x5, and ``buildAutoSyncScheduleState``
weekly bucketing x5 (covering owned weekly_time → weeklySchedules,
hourly stays in playlistSchedules, non-owned falls through to
automationPipelines, legacy-named auto-sync rows still recognised,
garbage trigger_config falls through). All 62 node:test cases pass;
261 across the automation pytest suite still green (zero regression
on PRs 1-2's plumbing). Python wrapper at
``tests/test_auto_sync_js.py`` shells out cleanly.
**CSS** (themed to the existing Auto-Sync gradient + accent
variables):
- 7-column grid for the weekly board, narrower than the 10
hour-bucket layout.
- Editor popover with backdrop-blur, accent-tinted save / delete
buttons, hover states that pick up the user's accent color.
- ``scheduled-elsewhere`` state for playlists with an hourly
schedule visible on the weekly board (dashed border + opacity)
so the user knows a drop will replace, not stack.
**WHATS_NEW entry** under 2.6.3 unreleased — first user-visible
slice of the schedule-types feature.
PR 4 (Monthly UI tab) deferred until weekly proves wanted.
|
4 weeks ago |
|
|
f13d339584 |
Usenet album poll: tolerate SAB queue→history handoff, emit terminal failure (#706)
User reported usenet album downloads getting stuck on "downloading
release" while SABnzbd reported the job as complete. Container restart
did not help; reproducible on every usenet album download.
Three independent issues all causing the same symptom — the download
modal freezes mid-flow with no error surfaced to the user:
1. SAB queue → history transition window
SAB removes a slot from its queue BEFORE adding it to the history,
and on a busy server (par2 verify, unrar, multi-file move) that
window can span several poll iterations. The poll treated a single
None status as terminal failure ("disappeared from client") and
gave up. Now the poll tolerates up to ~10s of consecutive misses
(5 polls at the default 2s interval) before declaring the job gone.
2. SAB queue states like `Pp` were unmapped
`_SAB_QUEUE_STATE_MAP` didn't cover SAB's `Pp` (post-processing
summary), `Unpacking`, `Trying`, `Deleted`, or the `Prop_paused`
/ `Prop_failed` variants. Unmapped states fell through to the
default-'error' fallback, and the poll loop only treated explicit
'failed' / 'completed' as terminal — 'error' was neither, so the
loop spun until the 6-hour timeout. Map now covers every Status
value from SAB's `sabnzbd/api.py`, and the poll treats the default-
'error' fallback as a transient miss (warn-logged, retry within
the same tolerance window) so a brand-new unmapped state can't
infinite-loop the way `Pp` did here.
3. No terminal failure emit
The poll only logged on failure / timeout / disappeared — never
called the progress callback with 'failed', so the download modal
stayed at the last 'downloading' emit forever. Plumb a 'failed'
emit through every failure exit path so the UI flips out of the
downloading state when the poll gives up.
Plus:
4. SAB direct nzo_ids lookup instead of paging all-history
`_get_status_sync` was fetching the latest 50 history entries on
every poll and iterating to find the target nzo_id. On busy
servers (many recent downloads), the target job could roll past
the 50-entry window and look like a "disappeared" job. Replaced
with a targeted `mode=queue&nzo_ids=<id>` → `mode=history&nzo_ids=<id>`
chain. Falls back to the bulk path for SAB versions that pre-date
the nzo_ids filter — the transient-miss tolerance covers any
short-lived gap there too.
Implementation:
Lifted the album-bundle poll loop out of `usenet.py` and `torrent.py`
into `core/download_plugins/album_bundle.py:poll_album_download` —
near-duplicate implementations are now a single function with deps
injected so it's testable in isolation (kettui's extract-don't-AST-parse
standard; can't unit-test a `time.sleep` loop inside a plugin method).
The lifted helper takes:
- `get_status` callable bound to job_id, so the same loop works for
usenet UsenetStatus and torrent TorrentStatus shapes
- `complete_states` set so torrent's `{'seeding', 'completed'}` and
usenet's `{'completed'}` both Just Work
- `failed_states` set so torrent's `{'error'}` is terminal while
usenet's default-'error' fallback is transient
- `transient_miss_threshold` (default 5 ≈ 10s at 2s poll)
- `sleep` / `monotonic` injectables for deterministic tests
Per-track flows in both plugins gained the same transient-miss
tolerance inline — they don't use the emit pattern (update an
`active_downloads[id]` row dict via lock instead), so reusing the
helper would have required threading a no-op emit through. Inline
fix is small enough.
Tests:
- 11 new tests in `tests/test_album_bundle.py:poll_album_download`
cover the happy path, transient-miss tolerance with recovery,
hard-failure threshold, explicit-failed surface, timeout-emit,
default-'error' transient treatment, shutdown clean exit,
torrent's `seeding`-counts-as-complete, save_path captured across
iterations, and adapter-exception treated as transient miss.
- 521 download-suite tests pass (33 in test_album_bundle, others
pin existing torrent + usenet contracts).
- Ruff clean.
Closes #706.
|
4 weeks ago |
|
|
1d6ced286b |
Discogs: strip artist disambiguation suffixes at every name surface (#634)
Discogs uses two disambiguation conventions for duplicate artist names: - legacy `(N)` numeric suffix: "Bullet (2)", "Madonna (3)" - newer `*` asterisk suffix: "John Smith*", "Foo*" Both were leaking through to the UI on artist search and album search, and worse — through the import path into folder names on disk (reported: importing yielded folders literally named `Foo*`). The pre-existing cleanup only handled `(N)` and only at ONE site — `get_user_collection` (line 469) and one path inside `extract_track_from_release` (line 448 — `re.sub(r'\s*\(\d+\)$', '', artist_name)`). Every other surface (artist search, album search, album-track lookups, get_artist_albums feature matching) returned the raw Discogs string. Centralized into `_clean_discogs_artist_name(name)` at module top, with regex covering both suffixes including repeated forms (`Baz**`, `Foo (3)*`). Applied at six sites: - `Artist.from_discogs_artist` (artist search) - `Album.from_discogs_release` (album search — three fallbacks: array, string, title-split) - `Track.from_discogs_track` (track lookup — track-level + release-level fallback) - `extract_track_from_release` (replaces the inline `(N)`-only re.sub) - `get_user_collection` (existing site, now also strips `*`) - `get_artist_albums` (artist_name used for primary-vs-feature matching; cleaning prevents `Beyoncé*` from failing equality vs `Beyoncé`) - `get_album` (artists_list + per-track artists in the tracklist projection) Tests: - New `test_clean_discogs_artist_name` parametrized over 14 cases covering `(N)`, `*`, repeated `**`, combined `(N) *`, whitespace handling, empty/None defensive returns. - New `test_get_user_collection_strips_discogs_asterisk_disambiguation` pinning the asterisk path end-to-end through the collection import flow (sibling to the existing `(N)` test). - Existing 37 discogs tests still pass. Out of scope (separate issue): the same #634 report flagged track-count and year fields rendering as 0 / empty in Discogs album search. Both are inherent to Discogs `/database/search` response shape — search results don't carry `tracklist` (only release detail does) and `year` is often `0` in search payloads. Fixing requires lazy-fetching release detail per row, which hits the 25 req/min unauth limit hard. Not bundled here. |
4 weeks ago |
|
|
8dbbf13c61 |
Branch cleanup: lift manual-match helpers, fix length-pref ordering, profile-scope view toggle
Self-review pass on the prior three commits — kettui-style cleanup
that should have landed first time.
**Length-preference sort ordering (real bug):**
The `search_tracks_with_artist` stable sort that promoted length-known
recordings ran in `core/musicbrainz_search.py`, but the MB endpoint in
`web_server.py:search_musicbrainz_tracks` runs `rerank_tracks` after
it — which re-sorts by relevance score and dropped the length-pref
ordering down to tiebreaker-only. For canonical-same-song MB duplicates
that all score identically the tiebreaker survived, but the
order-of-operations was wrong.
Moved into `rerank_tracks` itself via a new `prefer_known_duration`
flag. Sort key sits between relevance score and the stable-order
tiebreaker so relevance still wins (length only decides ties, never
overrides a higher-relevance match). The MB endpoint opts in via
`prefer_known_duration=True`; Spotify / iTunes / Deezer callers stay
on the default-off path since their search results always include
length. Pinned with three new `TestRerankTracks` cases:
ties-promote-length, relevance-still-wins, default-off-unchanged.
**Route logic lifted to `core/discovery/manual_match.py`:**
Two pieces lived as inline route logic in `web_server.py` — the
`derive_manual_match_provider` fallback chain (payload.source →
active source → 'spotify') used by `update_youtube_discovery_match`,
and the `is_drifted_for_redo` predicate (cached provider differs from
active AND not manual_match) used by `prepare_mirrored_discovery`.
Per kettui's "extract logic from web_server.py, don't AST-parse it"
standard, both helpers now live in `core/discovery/manual_match.py`
with 12 dedicated unit tests covering fallback resolution order,
non-dict payload defenses, manual_match exemption from drift,
absent-provider legacy default, and edge cases.
Side benefits from the lift:
- `match_source` now derived once before the cache-save try block
instead of being duplicated in try + except (the except block existed
only because the original used `match_source` later — pre-computing
killed the duplication).
- `prepare_mirrored_discovery`'s `has_cached` check now reuses
`is_drifted_for_redo` with inverted polarity instead of restating
the field whitelist inline, so a future schema change only has to
land in one place.
- The mirrored-DB persist block now gates on `matched_data is not None`
to avoid a pre-existing latent NameError if the cache-save block
raised before matched_data construction.
**Enhanced toggle localStorage key now profile-scoped:**
`soulsync-library-view-mode` was global — two admin profiles would
share one preference. Wrapped in `_libraryViewModeKey()` which appends
`:${currentProfile.id}` when a profile is loaded, falls back to the
unsuffixed key otherwise (preserves pre-multi-profile saved values).
Tests:
- 12 new in `tests/discovery/test_manual_match.py` pinning both helpers.
- 3 new in `tests/metadata/test_relevance.py` pinning the
`prefer_known_duration` semantics.
- `test_search_tracks_with_artist_prefers_results_with_known_length`
renamed to `_does_not_resort_by_length` since the sort moved out of
this method. 664 tests pass across discovery + metadata suites.
|
4 weeks ago |
|
|
b67d13164a |
Library: persist Enhanced / Standard view toggle in localStorage
User feedback: the Enhanced view toggle on the artist detail page reset to Standard on every artist click, so admins who prefer Enhanced had to re-flip the toggle every single time. Persist the choice in localStorage and reapply on every artist navigation + page reload. - `toggleEnhancedView()` writes `soulsync-library-view-mode` to localStorage on every change. - `navigateToArtistDetail()` reads the saved value after the standard reset block runs; if `enhanced` AND `isEnhancedAdmin()` it calls `toggleEnhancedView(true)` after `loadArtistDetailData` kicks off. The brief Standard render is hidden as soon as the toggle flips. - Gated on `isEnhancedAdmin()` so non-admin profiles (which never see the toggle) can't end up with a stale Enhanced preference being applied silently. - Wrapped in try/catch since localStorage is unavailable in some private-browsing modes. No backend change; no DB migration needed. |
4 weeks ago |
|
|
39f582a690 |
Mirrored playlist: stop Playlist Pipeline from reverting manual Fix-popup matches
User reported that manually mapping a mirrored-playlist track via the
Fix popup (either by search or by pasting an MBID) worked end-to-end
once — match saved, library track downloaded — but the next Playlist
Pipeline run flipped the track back to "Provider Changed" and forced
them to re-do the manual map every cycle.
Three independent issues were combining to cause this:
1. Hardcoded `provider: 'spotify'` on manual-fix save
`update_youtube_discovery_match` (the endpoint the Fix popup posts
to, also used by mirrored playlists since the frontend routes
`platform === 'mirrored'` through the YouTube endpoint) always
stamped the cached match as Spotify-provided. The Fix-popup cascade
actually queries the user's primary metadata source first and falls
back to Spotify / Deezer / iTunes / MusicBrainz — so a user on
MusicBrainz primary picking an MB result still had it saved as
`provider: 'spotify'`. The next prepare-discovery call (which
compares cached_provider to the active source) then immediately
classified the match as drifted and pending re-discovery. Fixed by
deriving `match_source` from `spotify_track.get('source')` (every
*_search_tracks endpoint stamps `source` on results) with a fallback
to `_get_active_discovery_source()` for the MBID-paste path (which
uses the lean flat shape that doesn't carry source). `matched_data['source']`
and the mirrored `extra_data['provider']` both now use the derived
value. `match_source` is also recomputed in the cache-save except
handler so the downstream mirrored-DB save still has it.
2. Discovery worker re-queueing manual matches as "incomplete"
`run_playlist_discovery_worker` in `core/discovery/playlist.py`
re-adds any track to `undiscovered_tracks` when its `matched_data`
lacks `track_number` or `album.id` / `album.release_date`. The
check was designed as a legacy-fix backfill for old discoveries
that lost those fields to a Track-dataclass stripping bug. But
manual fixes from the popup are *intentionally* lean — search-
result rows don't include `track_number` (none of the search
endpoints return it), and the MBID-lookup flat shape doesn't
carry `album.id` / `release_date` (the recording lookup returns
only `album.name`). So every manual match looked "incomplete" and
got re-discovered every pipeline run, overwriting the user's pick
with whatever the auto-search ranked first. Manual matches now
short-circuit ahead of the incomplete-data branch.
3. `prepare_mirrored_discovery` ignored the `manual_match` flag
Independent of the provider-stamping fix above, the prepare-
discovery endpoint that powers the mirrored-playlist UI did its
own `cached_provider != current_provider` check and didn't honour
manual_match either. Defence in depth — even if a future code
path stamps the wrong provider on a manual match, the flag now
anchors it as cached. `has_cached` also extended so manual
matches with off-provider stamps still count toward the cached
tally for phase classification.
Tests:
- new `test_manual_match_skipped_even_when_matched_data_incomplete`
in `tests/discovery/test_discovery_playlist.py` pins the worker
short-circuit using a realistic MB-shape matched_data (album dict
without id / release_date, no top-level track_number). 16 existing
tests still green; 848 across discovery / metadata / automation
suites pass.
|
4 weeks ago |
|
|
acc5eb77ea |
Fix popup: anchor artist field in MB search to stop title-collision covers
`/api/musicbrainz/search_tracks` powers the Fix popup's auto-search
cascade for users on MusicBrainz as primary. When both track + artist
fields were filled, `search_tracks_with_artist` always took the bare
keyword path (`<track> <artist>` joined as one query string). MB's
recording-search scorer weights title matches far above artist matches,
so for "Coffee Break" + "Zeds Dead" the top results were Emapea / The
Vidalias / West One Orchestra's "Coffee Break" — three unrelated cover-
title collisions ahead of the canonical Zeds Dead recording. The
endpoint's `rerank_tracks` pass can't fix this when the right answer
is below the API's 50-result cutoff.
Both-fields mode now uses a strict field-scoped Lucene query first
(`recording:"<t>" AND artist:"<a>"`) which anchors the artist and
prunes title-collision covers at the source. `min_score=0` because the
field-scoped query is itself precise; rerank still does final ordering.
Bare query stays as the fallback when strict returns nothing — covers
the diacritic / alias cases the original `strict=False` path was added
for ("Bjork" query vs canonical "Björk" artist where Lucene phrase
match never hits the recording).
Single-field mode (track-only or artist-only) is unchanged: still bare-
query directly, since there's no artist value to anchor.
Also stable-sort results to prefer entries with non-zero `duration_ms`.
MB has multiple recordings per song (single release, album release,
remasters, compilations) and not every recording carries length data.
Without the preference sort, the user sees a 0:00 row first while a
sibling recording with the real 3:04 sits two rows below — matches the
report where MBID-paste lookup of the canonical recording (length 3:04)
contradicted the search-result's 0:00 row for the same song.
Tests:
- new `test_search_tracks_with_artist_strict_first_when_both_fields`
pins the strict=True call when both fields present
- new `test_search_tracks_with_artist_falls_back_to_bare_when_strict_empty`
pins the Björk-style fall-through path
- new `test_search_tracks_with_artist_prefers_results_with_known_length`
pins the length-preference sort
- existing `..._keeps_low_score_for_rerank` updated to side_effect so
the bare-fallback path is exercised; behaviour pinned identically
- existing `..._uses_bare_query_mode` renamed + repurposed for strict-
first; old name's behaviour no longer accurate
|
4 weeks ago |
|
|
4555ff7eb9 |
Wishlist modal: surface most-advanced live phase, not least-complete
The sibling-merge aggregator from
|
4 weeks ago |
|
|
7f751202d2 |
Wishlist modal: merge sibling sub-batches into one status response
Phase 1c.2.1 splits each wishlist run across multiple
``download_batches`` rows (per-album bundle dispatch). The
download-missing modal opens against the original batch_id
allocated by ``start_manual_wishlist_download_batch`` /
``process_wishlist_automatically``. Pre-fix that batch_id was
just one sibling among N, so the modal went stale as soon as the
primary sub-batch finished — subsequent albums downloaded fine
but no live status reached the UI.
Fix: backend merges every sibling sub-batch's tasks +
analysis_results into the response keyed under the originally-
requested batch_id. Modal sees one unified view of the whole run
without knowing about the split. Frontend untouched.
Architecture (Kettui standards):
- ``core/downloads/wishlist_aggregator.py`` — pure
``merge_wishlist_run_status(primary, siblings)`` helper.
No IO, no runtime state, no globals. Lifted out of
``status.py`` so the merge contract can be pinned via unit
tests without standing up the live ``download_batches`` /
``download_tasks`` state.
- ``core/downloads/status.py``'s ``build_batched_status`` now
pre-indexes ``download_batches`` by ``wishlist_run_id`` inside
the existing ``tasks_lock`` snapshot, then runs the merge
helper whenever a requested batch has a sibling.
Merge rules pinned by 12 tests:
- ``track_index`` re-indexed globally 0..N-1 across the merged
``analysis_results`` so the modal's ``data-track-index`` DOM
keys don't collide between siblings. Tasks' ``track_index``
follows the same remap so the analysis-results ↔ tasks
cross-reference stays intact.
- ``task_id`` is uuid per task — no collision concern.
- Phase: error is sticky; otherwise the LEAST-complete
pre-terminal phase wins (analysis < album_downloading <
downloading). All-complete returns ``complete``; mixed
complete + active returns ``downloading`` so the modal stays
alive until every sibling lands.
- ``album_bundle``: picks whichever sibling currently has an
active bundle download (state in
``{searching, downloading, downloading_release, staging}``).
Falls back to the first non-empty bundle so a completed run
still shows a progress bar.
- ``analysis_progress`` summed across siblings.
- ``active_count`` summed; ``max_concurrent`` keeps primary's
value as the representative.
- ``playlist_id`` + ``playlist_name`` preserved from the primary
(the row the modal originally opened against).
Legacy single-batch wishlist runs (no ``wishlist_run_id`` on the
batch) skip the merge entirely — passthrough. Back-compat by
absence.
1108 tests across downloads + wishlist + automation + imports +
playlist-sources + lb-series suites green. 12 new aggregator
tests pin the merge contract.
Closes the open UX gap from the Phase 1c.2.1 ship — modal now
tracks every sibling sub-batch's progress for the full duration
of the wishlist run.
|
4 weeks ago |
|
|
c002014f10 |
Wishlist: reify run id + gate cycle toggle on last-sibling completion
Phase 1c.2.1 splits each wishlist invocation into per-album sub- batches so the album-bundle dispatch can engage once per album. Side effect: the completion handler ``finalize_auto_wishlist_completion`` ran end-of-run logic (cycle toggle + state reset + automation event emit) once per BATCH, so a 2-album run fired the cycle toggle twice + emitted two ``wishlist_processing_completed`` events. The cycle landed at the right value either way but the state machine had become per-batch instead of per-run. Fix: reify "wishlist run" as a first-class concept via a shared ``wishlist_run_id`` UUID. Generated once per wishlist invocation in both the auto- and manual-wishlist paths, stamped on every sub-batch row in ``download_batches``. ``finalize_auto_wishlist_completion`` now reads the completing batch's ``wishlist_run_id`` and, when present, scans ``download_batches`` for siblings still in pre-terminal phases. If any sibling is still active, the per-batch summary records but the cycle toggle + state reset + automation emit are deferred. Only the last completing sibling fires the run-level finalization. Legacy single-batch runs (no run_id field) keep their toggle-immediately behavior — back-compat by absence. The run_id also lays groundwork for frontend grouping (one logical row in the Downloads view per wishlist run instead of N sibling rows), but that UX work is deferred. 3 new tests in ``test_processing.py`` pin: defer-when-siblings- active, toggle-when-last-sibling-done, back-compat-without-run_id. 1 new assertion in ``test_automation.py`` confirms all sub-batches of one auto-wishlist invocation share the same run_id. 309 tests across wishlist + automation suites green. Notes: dispatch concurrency unchanged — sub-batches still run via the shared download worker pool. Slskd serializes per-uploader at its own layer (same uploader = automatic queue, different uploaders = legit parallel), so SoulSync-side serial enforcement would duplicate work the right layer already handles. |
4 weeks ago |
|
|
c3b88e6963 |
Wishlist albums cycle: split into per-album bundle batches
Auto-wishlist's "albums" cycle used to dump every missing album track into one batch and run per-track Soulseek / Prowlarr searches for each (~50 searches for a typical scan). The album-bundle dispatch (introduced in 2.5.9 for explicit album downloads) was gated on ``is_album_download=True`` + populated ``album_context``/``artist_context``, none of which the wishlist batch ever set — so wishlist runs always took the per-track flow even when 12 missing tracks all belonged to the same album. Fix: split wishlist albums-cycle tracks into per-album sub-batches at submission time. Each sub-batch carries its own album context, trips the existing dispatch gate, and engages one slskd / torrent / usenet album-bundle search per album. Tracks the helper can't group (no album metadata, no artist) fall through to a residual per-track batch. - New ``core/wishlist/album_grouping.py``: ``group_wishlist_tracks_by_album(tracks)`` returns ``WishlistGroupingResult(album_groups, residual_tracks)``. Pure function — extracts album_id (or name-normalized fallback) + primary artist + album context from each track's nested spotify_data, buckets, and threshold-promotes. Independent of runtime state so it can be unit-tested without the wishlist executor. - ``core/wishlist/processing.py``: when ``current_cycle == 'albums'``, run the grouping helper, submit one batch per album with ``is_album_download=True`` + the group's album/artist context, then a single residual batch for orphans. Singles cycle path unchanged. - 9 new tests in ``test_album_grouping.py`` pin the bucketing contract (empty / single album / multi album / orphan / threshold / nested payloads / no-id fallback / no artist). - 2 new tests in ``test_automation.py`` exercise the per-album split end-to-end through ``process_wishlist_automatically``: multi-album batch → two sub-batches each with album context; mixed orphan + real album → one bundle batch + one residual. 1099 tests across wishlist + imports + downloads + automation + playlist-sources + staging-provenance + track-number-repair suites green. WHATS_NEW entry added under 2.6.3. Now when an auto-wishlist scan finds 12 missing tracks from Ryoto's "Cha-La Head-Cha-La", it runs ONE slskd / Prowlarr album-bundle search for the release instead of 12 per-track searches. |
4 weeks ago |
|
|
85426a210c |
Fix album-bundle downloads landing every track as track 1
Soulseek album-bundle (and any other release-staging path) was
importing every file with ``track_number=1`` because the staging
metadata reader used the auto-import-flavor filename extractor:
``extract_track_number_from_filename`` returns 1 when the basename
has no ``NN -`` prefix. That's the right default for the loose
auto-import flow (single file in, no upstream metadata to lean
on), but completely wrong for staging-cache reads:
- For an album-bundle download the user has authoritative track
numbers in the Spotify track list flowing through to
``track_info`` for each task.
- ``try_staging_match`` in ``core/downloads/staging.py`` was
meant to use those numbers when the staged file's own metadata
doesn't have them.
- But the staging cache populated ``track_number=1`` for every
untagged bare-title file (e.g. ``Cha-La Head-Cha-La.flac``), the
album-bundle resolution branch reads file-side first, sees 1,
and short-circuits the rest of the chain.
Fix:
- New ``extract_explicit_track_number`` in
``core/imports/filename.py`` — strict variant that returns
``0`` when no numeric prefix is visible. Docstring explicitly
contrasts with the legacy 1-defaulting helper so future
callers pick the right one.
- ``read_staging_file_metadata`` in ``core/imports/staging.py``
now uses the strict extractor, so the staging file dict
carries ``track_number=0`` ("unknown") instead of ``1`` for
untagged bare-title files.
- The legacy ``extract_track_number_from_filename`` keeps its
1-default behavior so auto-import callers + the post-process
template fallbacks are unchanged; it's now implemented in
terms of the strict variant.
- Tag-side parsing also tightened to require ``> 0`` before
overriding the filename-derived value.
3 new tests pin the contracts:
- ``test_extract_explicit_track_number_returns_zero_when_no_prefix``
- ``test_read_staging_file_metadata_returns_zero_track_when_unknown``
- existing ``test_extract_track_number_from_filename_handles_common_patterns``
now explicitly comments why bare filenames keep returning 1.
758 tests across imports + downloads + repair + staging-provenance
suites green. WHATS_NEW entry added under 2.6.3.
Reported against an album-bundle download of Ryoto's
"Cha-La Head-Cha-La" where slskd staged 15 untagged FLAC files
named after the song titles only.
|
4 weeks ago |
|
|
80a88a62ac |
Auto-Sync sidebar: improve playlist card readability
The mirrored-playlist cards in the Auto-Sync schedule modal's sidebar were truncating long names with ellipsis on a single line + rendering meta info at 10px, which made entries like "Top Missed Recordings of 2024 for Nezreka" or "ListenBrainz Weekly Exploration" unreadable. - Name wraps to multiple lines instead of ellipsis-truncating (sidebar is narrow; truncation hid critical disambiguating text like the year / week / username). - Bumped name 12px → 13px, meta 10px → 11px with brighter color (0.4 → 0.55 alpha). - Bumped card padding 10px/12px → 12px/14px + spacing 6px → 8px so multi-line entries have breathing room. - Pinned the leading status dot to the first text line via ``margin-top`` so multi-line names flow underneath rather than push the dot off-center. |
4 weeks ago |