* fix(playlist): batch playlist writes past the GET URL limit (#1227)
Adding tracks failed past ~341 songs because the write path re-sent the
entire song list as createPlaylist.view?songId=<all> query params on a GET,
blowing past the server's ~8 KiB URL limit. Writes now append incrementally
via updatePlaylist.view?songIdToAdd=<batch> (and songIndexToRemove for
clears/removals) in 150-id batches, so there is no practical size cap. A
per-server in-memory membership cache removes the full getPlaylist refetch
on every dedup, fixing the "slow add on big playlists" report.
Layering detangle (keeps the new cache from adding dep-cruiser cycles):
- lib/api/subsonicPlaylists.ts is pure of the store again; cache invalidation
on write failure moved to the feature callers' catch blocks.
- membership cache extracted to the core layer (src/store/playlistMembershipStore.ts)
so offline/orbit/contextMenu/playlist read it directly instead of routing
through the @/features/playlist barrel.
- severed the offline -> playlist-barrel edge (pinnedOfflineSync name fallback
through the live playlist list was dead: nameless callers are all gated by
isSourcePinnedOffline, where offline meta already carries the name).
- confirmAddAllDuplicates moved into the playlist feature; contextMenu submenus
import the add/merge helpers via the playlist barrel, not deep paths.
dep-cruiser baseline regenerated: 742 -> 714 (net -28, all no-circular). The
churn in the baseline is path-shift of the frozen playerStore SCC (cover/
playback/orbit), not new coupling; the new playlist modules have zero violations.
* docs(changelog): record #1235 playlist URL-limit fix (changelog + credits)
* fix(playlist): seed membership cache from full list, not library-scoped view (#1235 review)
F1: runPlaylistLoad seeded the dedup membership cache from the
library-scope-filtered songs, so out-of-scope members looked "new" and
addTracksToPlaylistWithDedup/collectMergeSongIds could re-add them as
duplicates. Cache now holds the full unfiltered server list while the UI
still shows the filtered view; add a regression test.
Also documents the two accepted trade-offs flagged in review:
- F2: >batch updatePlaylist clears then appends non-atomically (URL-limit
workaround); a mid-step failure truncates server state, cache invalidation
lets the client re-read truth.
- F3: dedup read-modify-append isn't atomic across the await; rare missed
dedup on concurrent adds, self-heals on next load.
Retroactive changelog + credits for the already-merged feature-folder restructure (PR #1225). Adds a 1.50.0 Changed entry (internal restructure, no user-facing change; additional architecture credited to cucadmuh) and a contributions line under Psychotoxical. cucadmuh is intentionally not added to the settings credits list.
FE cutover for the D1 slice: libraryGetCatalogYearBounds and
libraryGetGenreAlbumCounts now call the generated commands.* bindings instead of
a hand-written invoke<T>('cmd', ...), unwrapping the specta Result union (rethrow
on error — behavior-preserving). The duplicated hand DTOs collapse into named
aliases of the generated CatalogYearBoundsDto / GenreAlbumCountDto, so the shape
lives in one place (the contract) and consumers stay unchanged.
Verified the guard end to end: a serde-rename of the Rust field (contract change,
Rust still compiles) regenerated bindings and made tsc fail at the FE consumer;
reverted. cargo + tsc now enforce this slice's contract at compile time.
The serverCapabilities cluster (catalog, context, resolve, storeView, types) is
lib-level infra: it imports only @/lib/server and is consumed by both lib and
feature code, so a feature home would invert the two lib consumers. Move it to
src/lib/serverCapabilities and repoint consumers.
Pure move + import updates, no behavior change. Baseline gains one lib->store
entry (storeView reads authStore, only now caught under lib/).
The five residual src/hooks/ hooks (useConnectionStatus, useCardGridMetrics,
useTracklistColumns, useNavidromeAdminRole, useAnalysisPerfListener) are all
domain-agnostic — none imports @/features — so they join the other shared hooks
in src/lib/hooks/ (with their two colocated tests). Consumers repointed;
relative-up imports in the moved files become the @/ alias. src/hooks/ is removed.
Pure move + import updates, no behavior change. The layering baseline gains five
lib->store entries (the three hooks that read authStore/devOfflineBrowseStore are
only now caught under lib/) — same documented debt class as the api move.
The legacy src/components/ dir held eight shared modals. Audited each: the five
generic primitives (Modal, ConfirmModal, GlobalConfirmModal, ExportPickerModal,
ThemeMigrationNotice) move to src/ui (the shared-primitive home). The three that
import @/features/* are feature-owned, so they move into their feature instead of
polluting ui with core->feature edges: SongInfoModal -> features/playback,
LicenseTextModal -> features/settings, PasteClipboardHandler -> features/share.
Pure move + import updates, no behavior change. The layering baseline is unchanged
(every relocated import lands in a legal direction: feature->ui/store/lib, or
cross-feature only via the barrel). src/components/ is now removed.
The seven remaining src/api/ modules (analysis, azuracast, bandsintown,
coverCache, migration, network, runtimeLogs) were the last IPC-layer files
outside src/lib/api/, where the rest of the FE IPC clients already live. Move
them (+ the colocated coverCache test) into lib/api and repoint every consumer;
relative-up imports in the moved files become the depth-independent @/ alias.
Pure move + import updates — no behavior change. The layering baseline is
regenerated in the same commit: the moved files' pre-existing store/cover edges
are only now caught under lib/, and the cover<->coverCache cycles are the same
edges re-keyed to the new path (E4 shrinks the allowlist later).
First vertical slice of the tauri-specta rollout: annotate two psysonic-library
browse-metadata commands (library_get_catalog_year_bounds,
library_get_genre_album_counts) with #[specta::specta] and derive specta::Type on
their DTOs (CatalogYearBoundsDto, GenreAlbumCountDto), then add them to
collect_commands!. bindings.ts now carries their typed signatures + DTO shapes
(serde camelCase carried through). Non-breaking: generate_handler! stays the live
handler and nothing imports the bindings yet.
Both DTOs are i64-free (i32/u32/String), so no BigInt handling is needed here —
that convention is decided when the first i64 DTO is annotated. Generated
src/generated is excluded from eslint (its runtime helper uses `any`); tsc still
type-checks bindings.ts.
Stand up the FE<->BE contract pipeline end to end with near-zero surface: a
tauri_specta::Builder collects one proven command (greet) and exports typed TS
bindings to src/generated/bindings.ts. The existing generate_handler! stays the
live invoke handler — no FE change yet.
Safety: the export runs only under #[cfg(debug_assertions)] (debug launch) and a
headless test, never in a release build, so a specta RC break can never block a
release cargo build; the committed bindings.ts is plain TypeScript for tsc. The
snapshot is committed (gitignore exception) so CI diffs catch contract drift.
Pinned deps: specta / tauri-specta =2.0.0-rc.25, specta-typescript =0.0.12 (the
latest mutually-consistent set; rc.21 + specta-typescript 0.0.9 no longer resolve).
Grow collect_commands! crate-by-crate next, starting with psysonic-library.
After a server URL edit, rewriteFrontendStoreKeysForRemap must repoint the old
index key to the new one across offline album keys, local-playback entries, and
the player queue (queueServerId + per-item refs), leaving unrelated keys
untouched. A same-key remap is a no-op.
Cover switchActiveServer end to end with the heavy deps mocked and the real orbit
+ auth stores driving assertions: unreachable aborts; host tears down via
endOrbitSession, guest via leaveOrbitSession, both reset the role; the old
server's queue is flushed, the active server rebinds, and a queue handoff is
marked. Closes the switchActiveServer QA.
Route a multi-track enqueue through the real enqueue action and the orbitRuntime
bulkGuard seam: over-threshold + accept commits the tracks, reject commits none,
and a single track bypasses the guard entirely.
Exercise the real hasLocalPlaybackUrl + subsonicNetworkGuard: a track with local
psysonic-local:// bytes (any tier) skips the reachability probe even when the
server is unreachable, while a non-local track stays gated by reachability.
Closes the local-bytes network-skip QA (review residual #1).
Register the real offlineMediaResolve into the mediaResolver seam and drive its
offline-vs-network routing through the actual decision inputs: offline-browse
active + local browse enabled reaches the local-bytes path, otherwise the
network path. Asserts which data source the seam call reaches. Closes the
offline-aware media resolution QA (review residual #2).
Guard shape drift when an old persisted blob rehydrates. Cover the
analysisStrategyStore v0 to v1 migrate (preserves strategy, clamps parallelism,
adds the per-server maps) plus a v1 passthrough, and add cold-start rehydrate
round-trips for localPlaybackStore (entries) and offlineStore (albums).
Pin the contract of the three core-feature seams the refactor rests on: before
registration each delegator returns its documented default (mediaResolver
network-only, orbit neutral snapshot + allow, bridge null / no-op); after
registerX(fake) it forwards to the registered impl with the given args and
propagates its result. Underpins the boot smoke and the scenario suite.
hasLocalPlaybackUrl mirrors resolvePlaybackUrl's local-source branch so the
network guard can skip the reachability probe for local tracks; its only other
test mocks it away. Cover it directly against real stores: per-tier hits
(library / favorite-auto / ephemeral), the no-bytes false case, index-key vs raw
profile-id resolution, and an equivalence guard against resolvePlaybackUrl's
local branch that locks the bit-identical claim.
Import every lazy(() => import('@/features/*/pages/*')) page up front and assert
a default-exported component, so a broken specifier fails at CI instead of only
when the route is first navigated to. A drift guard reads the real route sources
and fails if the loader table and the app fall out of sync, so a newly added
route cannot silently skip coverage.
Guard the boot-order invariant the three core-feature seams rely on: importing
the real app entry module runs the registration side effects (playback bridge
via MainApp, media resolver via the offline barrel, orbit runtime via AppShell's
orbit barrel), and the smoke asserts each seam is the registered implementation,
not its neutral default. Drops any of those imports later, and the matching
assertion fails at CI instead of silently at runtime.
Each seam gains a small behavior-free isRegistered() introspection so the guard
reads registration directly rather than coupling to store internals.
Encode the feature-folder layering contract as a CI gate: lib is the floor,
store/ui must not import features/app, cross-feature only via the barrel, no
cycles. dependency-cruiser resolves the @/ alias via tsconfig; tests excluded.
Land as a ratchet: the 738 current violations are seeded into
.dependency-cruiser-known-violations.json and ignored via --ignore-known, so
HEAD is green and any NEW violation fails CI. Regenerate the baseline as the
drain removes an exception; the count is the tracked residual debt.
Wired via `npm run dep:check` into a new dependency-cruiser job in eslint.yml
and the ci-ok required-check aggregate.
The 945-LOC lib/api/library.ts (cucadmuh's deliberate 'one thin file' library_*
command surface) exceeded the ~500 LOC ceiling. Split by its own PR-5 section
comments into library/{dto,reads,sync,stats,events}.ts + a private library/internal.ts
(the shared serverId↔indexKey helpers). lib/api/library.ts is now a barrel that
re-exports all of them, so @/lib/api/library stays the single import point — every
consumer (incl. the Settings LibraryTab) and all 11 vi.mock('@/lib/api/library')
test factories are unchanged. Pure reorganization, no logic change; tests pass
unmodified. Largest module is now dto.ts (466, pure types).
a11y-branch hold lifted. Pure share machinery (no @/features runtime imports) →
lib/share: shareLink, shareSearch, shareServerOriginLabel, copyEntityShareLink
(+tests). The two orchestrators that runtime-import offline/playback/orbit →
new features/share: applySharePaste, enqueueShareSearchPayload (+test); doc-only
barrel, consumers use deep paths. No low-layer consumes the orchestrators, so no
seam is needed — pure move, tests pass unmodified. Drains utils/share.
Only import lines changed in the PasteClipboardHandler consumer (logic untouched).
a11y-branch hold lifted (Frank: modals get re-refactored separately, no rebase to
protect). Both were only blocked by their modal consumers:
- licensesData (pure licenses.json lazy-loader) → features/settings/utils; main
consumer is the settings LicensesPanel. JSON dynamic import absolutized to
@/data/licenses.json.
- userMgmtHelpers (pure formatLastSeen, lib/format only) → lib/format; multi-feature
consumer set, same class as playlistDetailHelpers.
Drains utils/componentHelpers (dir removed). Only import lines changed in the
LicenseTextModal/SongInfoModal consumers (logic untouched); tests pass unmodified.
uploadArtistImage lived in lib/api/subsonicPlaylists but is artist-domain and
consumed only by features/artist/runArtistDetailActions. Relocated to
lib/api/subsonicArtists (its rightful API module); consumer repointed. Also fixes
a stale doc comment in features/randomMix/index.ts (mixRatingFilter now lives in
features/playback). No behavior change; tests pass unmodified.
cardGridLayout's only store dep was three grid-column constants it imported from
authStoreDefaults. Flipped ownership: the constants now live in lib/util/cardGridLayout
(pure layout config) and authStoreDefaults re-exports them (store→lib), so the auth
settings clamp/default and every existing consumer are unchanged. cardGridLayout is now
store-free and homes in lib/util alongside the other pure helpers — consumed cleanly by
ui/VirtualCardGrid + cover/layoutSizes. Resolves the documented store-free-lib block for
the card-grid layout helper. Pure move + constant relocation; tests pass unmodified.
The connection-reachability + Subsonic network-guard helpers were pinned low by
four lib/api consumers (subsonicLibrary/Playlists/Ratings/Scrobble) yet ran two
lower-layer→feature inversions. Both removed without a registry:
- devOfflineBrowseStore is a self-contained DEV-only toggle with zero offline-
feature coupling — it was only colocated there. Relocated features/offline/store
→ store/ (global). The @/features/offline barrel re-exports it so feature/UI
consumers are unchanged; the three lower-layer readers (subsonicNetworkGuard,
activeServerReachability, useConnectionStatus) now import it from @/store directly.
- subsonicNetworkGuard's only other feature dep was playback's resolvePlaybackUrl,
used solely for the psysonic-local:// skip check. Added hasLocalPlaybackUrl to the
existing M4 substrate store/localPlaybackResolve — it mirrors resolvePlaybackUrl's
local-source branch exactly (same profile resolution; the empty-serverId playback
fallback never applies in the guard), so the skip stays bit-identical.
network/ (subsonicNetworkGuard + activeServerReachability + tests) → lib/network,
now @/features-free. useConnectionStatus is now iron-rule-clean and stays in hooks/
(cross-cutting). Test mocks retargeted to the new seam modules.
Behavior-adjacent (covered by suite; default paths identical): the local-bytes skip
helper — flag for offline-playback runtime QA alongside the M4 media-resolver seam.
Two lower-layer→feature inversions removed:
- Detail-route predicates (isAlbumDetailPath/isArtistDetailPath/isComposerDetailPath)
were defined in features/album's browse store but are pure URL checks. Extracted
to lib/navigation/detailRoutePaths; the browse store re-exports them so the
@/features/album barrel surface is unchanged. This frees albumDetailNavigation of
its only @/features import, so it moves utils/navigation → lib/navigation (drains
utils/navigation). Also fixes the M5 isArtistDetailPath misplacement.
- mixRatingFilter's sole feature dep is playback's userRatingOverrides and its only
binding consumer is playback's buildInfiniteQueueCandidates, so it belongs in the
playback feature: utils/mix → features/playback/utils (drains utils/mix). The
earlier handoff note that lib/api/subsonicStarRating consumes it was stale — that
is only a code comment, verified no runtime import.
Pure moves; tests pass unmodified. The share cluster + switchActiveServer stay in
utils/ — they are imported by the a11y-HELD PasteClipboardHandler, so relocating
them would rewrite a do-not-touch file.
Per-file routing of the 9-file utils/componentHelpers grab-bag, each driven by
its verified consumer set:
- contextMenu{Actions,Helpers} → features/contextMenu/utils (sole consumer)
- queuePanelHelpers → features/queue/utils (sole consumer)
- nowPlayingHelpers → features/nowPlaying/utils; isRealArtistImage split out to
cover/ (consumed by cover/artistHero, a lower layer)
- appUpdaterHelpers(+test) + listReorder(+test) → lib/util (config/ + lib/hooks
consume them; pure)
- playlistDetailHelpers → lib/format (consumed by 3 features; pure)
- appShellHelpers → app/ (app-shell consumers only)
- userMgmtHelpers left in place: consumed by the a11y-HELD SongInfoModal, so a
move would rewrite a do-not-touch file (same block as licensesData).
Pure moves; tests pass unmodified.
Both one-time boot migrations are consumed only by app/MainApp; advancedModeMigration runtime-imports @/features/artist + @/features/playlist, so it is app-level orchestration (an inversion if kept in utils/ infra). Pure move.