23 KiB
Plan: Consolidate LibraryScanComplete Handling
Addresses refactoring catalog #7. Eliminates redundant direct LibraryScanComplete event listeners from components by making stores eagerly re-fetch data after invalidation, so the existing reactive controller subscription (requestUpdate()) delivers fresh data automatically.
Problem Analysis
The refactoring catalog describes 10+ components that each independently listen for LibraryScanComplete and re-fetch their data. It claims these listeners are redundant because "the stores already invalidate their caches and notify subscribers."
This claim is incorrect in the current architecture. Here is why:
- When
LibraryScanCompletefires,LibraryStore.invalidate()nulls out cached data (tracks,albums,artists) and callsnotify(). notify()triggers subscriber callbacks, which areLibraryController.host.requestUpdate()— a Lit re-render.- But
requestUpdate()only re-runsrender(), and components read from local@state()properties (e.g.,this.tracks,this.albums), not from the store. The local data is still stale. - Nobody calls the
loadTracks()/loadAlbums()methods again except the directLibraryScanCompletelistener.
The root cause: the stores use lazy-fetch — invalidate() clears the cache but does not re-fetch. The next getTracks() call will hit the backend, but nothing triggers that call except the component's own event listener.
The fix: make stores eagerly re-fetch after invalidation, so when the controller calls requestUpdate(), the store already has fresh data. Then refactor components to read data reactively from the store/controller instead of from local state populated by imperative load calls.
Guiding Principles
-
Incremental migration. The store change (eager refetch) is backwards-compatible. Components are migrated one by one from easiest to hardest. Both patterns (old imperative + new reactive) coexist during migration.
-
Preserve existing UX. Scroll restoration, selection clearing, loading indicators, and search filtering must work identically. No regressions.
-
Three categories of listeners. Not all
LibraryScanCompletelisteners are the same:- Data refresh listeners (8 components): re-fetch library/playlist data → these are what we're consolidating.
- UI status listeners (
config-page,library-manager): update scan progress UI and display metrics → these MUST keep their direct listeners since no store handles scan status. - Store-bypassing listeners (
playlist-picker): calls Go bindings directly → addressed separately.
-
Don't fight the
externalAlbums/externalTrackspattern. Parent-child data delegation (artist-details→cover-grid,genre-details→track-list) is a valid pattern. The parent gets migrated; the child already skips the scan listener when driven externally.
Phase 0: Store Eager-Refetch
0A. LibraryStore — add eager refetch after invalidation
File: frontend/src/store/library-store.ts
Change invalidate() to eagerly re-fetch all three data types after clearing the cache. The existing getTracks()/getAlbums()/getArtists() methods already handle concurrent-request coalescing (via waitFor*() helpers) and notify subscribers when loading starts/finishes.
// Before:
private invalidate(): void {
this.tracks = null;
this.albums = null;
this.artists = null;
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
this.notify();
}
// After:
private invalidate(): void {
this.tracks = null;
this.albums = null;
this.artists = null;
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
this.notify();
this.eagerRefetch();
}
private eagerRefetch(): void {
// Fire-and-forget. Each getter handles its own error/loading state
// and calls notify() when done, which triggers requestUpdate()
// on all subscribed controllers.
void this.getTracks();
void this.getAlbums();
void this.getArtists();
}
Why this works: After eagerRefetch(), the store is in a loading=true state. When the backend responses arrive, the cache is repopulated and notify() fires again (from the finally block in each getter). Controllers call requestUpdate(), and now any component reading from the store gets fresh data.
Why it's backwards-compatible: Components with direct listeners will still call their load*() methods. The store's waitFor*() helpers coalesce concurrent requests, so the eager fetch and the component's fetch share the same in-flight promise — no duplicate backend calls.
Scroll position reset note: The scrollPositions reset to 0 happens synchronously in invalidate(). This is correct — after a library scan, the content has changed and scroll positions are meaningless. Components that read scroll positions during their re-render will see 0.
0B. PlaylistStore — add eager refetch after invalidation
File: frontend/src/store/playlist-store.ts
Same pattern. The invalidate() method already exists and is called from multiple event handlers (not just LibraryScanComplete).
// Before:
invalidate(): void {
this.playlists = null;
this.scrollPosition = 0;
this.notify();
}
// After:
invalidate(): void {
this.playlists = null;
this.scrollPosition = 0;
this.notify();
void this.getPlaylists();
}
Note: PlaylistStore.invalidate() is public (called by PlaylistController.invalidate()). This eager refetch will also run for PlaylistCreated, PlaylistDeleted, PlaylistRenamed, PlaylistTracksChanged, and PlaylistsRestored events — which is desirable. Currently those events invalidate the cache and wait for a component to lazily re-fetch. Eager refetch means subscribers see fresh data faster.
0C. Verification
After Phase 0, both stores eagerly re-fetch on invalidation. Components with existing direct listeners still work (their fetches coalesce with the eager fetch). Components without listeners now get fresh data automatically through the controller subscription path, though they still need to read it reactively (Phase 1+).
Phase 1: Migrate genre-details and artist-details (LOW effort)
These are thin wrapper components that fetch data, filter/cache it, and pass it to a child via externalTracks/externalAlbums. The child already skips its own scan listener when receiving external data.
1A. genre-details.ts
File: frontend/src/components/genre-details/genre-details.ts
Current flow:
connectedCallback()→loadTracks()→libraryCtrl.getTracks()→ filter by genre →this.tracks = filteredLibraryScanComplete→loadTracks()again
New flow:
connectedCallback()→loadTracks()(initial load, unchanged)- Remove the direct
LibraryScanCompletelistener and its cancellation - Add reactive consumption in
willUpdate()orupdated(): when the store notifies (cache repopulated after eager refetch), the controller callsrequestUpdate(), triggering a re-render. InwillUpdate(), detect that the store's cached tracks have changed (or that loading finished) and re-run the genre filtering.
Implementation approach — use updated() to react to controller-triggered re-renders:
// Remove from connectedCallback:
// this.cancelScanComplete = EventsOn(Events.LibraryScanComplete, () => this.loadTracks());
// Remove from disconnectedCallback:
// this.cancelScanComplete?.();
// Remove the cancelScanComplete field.
// Add a version counter to detect store changes:
private lastStoreVersion = 0;
override updated() {
// The library controller's subscription calls requestUpdate() when the
// store notifies. Check if tracks have been refreshed since our last load.
const storeVersion = this.libraryCtrl.storeVersion;
if (storeVersion !== this.lastStoreVersion && !this.libraryCtrl.tracksLoading) {
this.lastStoreVersion = storeVersion;
this.loadTracks();
}
}
Alternative (simpler): Instead of a version counter, check if the store's cached tracks reference has changed. Since invalidate() sets tracks to null and the eager refetch populates a new array, we can compare object identity:
private lastTracksRef: library.Track[] | null = null;
override updated() {
const cached = this.libraryCtrl.cachedTracks;
if (cached !== null && cached !== this.lastTracksRef) {
this.lastTracksRef = cached;
this.loadTracks();
}
}
Decision: Use the reference-comparison approach. It's simpler, doesn't require adding version counters to the store, and leverages the fact that each eager refetch creates a new array instance.
However, there's a subtlety: updated() runs after every render, including renders triggered by the component's own @state() changes (like this.tracks being set). We need to ensure this doesn't create an infinite loop:
loadTracks()callslibraryCtrl.getTracks(), which if the cache is already populated returns the same reference.this.tracksis set to the filtered result, triggering a render.updated()runs, comparescachedTracks— same reference aslastTracksRef, so no re-load. Safe.
But the initial load path: connectedCallback() calls loadTracks() directly. At that point cachedTracks might be null (store hasn't loaded yet). After loadTracks() finishes, the store cache is populated, and lastTracksRef is set. Next requestUpdate() from the store won't trigger a re-load because the reference matches. Safe.
Required controller addition: Add a storeVersion or expose cachedTracks — the controller already exposes cachedTracks (line 72-74 of library-controller.ts). No changes needed to the controller.
1B. artist-details.ts
File: frontend/src/components/artist-details/artist-details.ts
Same approach. This component has a cache-then-fetch dual-load pattern (getAlbumsByArtistNameCached then getAlbumsByArtist). The scan-complete handler just calls loadAlbums().
Changes:
- Remove the direct
LibraryScanCompletelistener and its cancellation. - Add reference comparison in
updated():
private lastAlbumsRef: library.Album[] | null = null;
override updated() {
const cached = this.libraryCtrl.cachedAlbums;
if (cached !== null && cached !== this.lastAlbumsRef) {
this.lastAlbumsRef = cached;
this.loadAlbums();
}
}
Note: getAlbumsByArtist(artistId) is NOT cached by the store — it always hits the backend. But that's fine because loadAlbums() already handles this. The reference check on cachedAlbums (the full album list) serves as a proxy for "the library data has changed."
Phase 2: Migrate artists-view (LOW-MEDIUM effort)
File: frontend/src/components/artists-view/artists-view.ts
Current flow:
connectedCallback()→loadArtists()→libraryCtrl.getArtists()→this.artists = resultLibraryScanComplete→loadArtists()willUpdate()→recomputeArtistCaches()(filters by search term)render()readscachedGridEntries
New flow:
connectedCallback()→loadArtists()(initial load, unchanged)- Remove the direct
LibraryScanCompletelistener. - React to store changes in
updated():
private lastArtistsRef: library.Artist[] | null = null;
override updated() {
const cached = this.libraryCtrl.cachedArtists;
if (cached !== null && cached !== this.lastArtistsRef) {
this.lastArtistsRef = cached;
this.loadArtists();
}
}
Scroll position consideration: loadArtists() currently restores scroll position at the end. After a scan, scroll positions are reset to 0 by invalidate(). The restoringScroll flag and restoreScrollPosition() call in loadArtists() handle this correctly — they'll restore to position 0, which is a no-op visually.
Selection consideration: loadArtists() does not currently clear selection. After migration, selection could reference stale artist IDs. Consider adding this.selectedArtists.clear() at the top of loadArtists() if not already present. (This is a minor improvement, not a regression from the migration.)
Phase 3: Migrate genres-view (HIGH effort)
File: frontend/src/components/genres-view/genres-view.ts
This component derives genres from tracks — a transformation the store doesn't provide. The store exposes tracks, not genres.
Current flow:
loadGenres()→libraryCtrl.getTracks()→extractGenres(tracks)→this.genres = resultLibraryScanComplete→loadGenres()
New flow:
- Same
loadGenres()for initial load. - Remove the direct
LibraryScanCompletelistener. - React to store changes in
updated()usingcachedTracksreference comparison:
private lastTracksRef: library.Track[] | null = null;
override updated() {
// Existing updated() logic for search term, size properties, etc.
// stays unchanged. Add this at the end:
const cached = this.libraryCtrl.cachedTracks;
if (cached !== null && cached !== this.lastTracksRef) {
this.lastTracksRef = cached;
this.loadGenres();
}
}
Why not move genre extraction to the store? The store's job is to cache backend data, not derive view-specific aggregations. Genres are only needed by genres-view and genre-details. Adding genre derivation to the store would couple it to a specific UI concern. The component is the right place for this derivation.
Scroll/selection considerations: Same as artists-view. loadGenres() handles scroll restoration. Consider adding this.selectedGenres.clear() if not already present.
Phase 4: Migrate track-list (MEDIUM effort)
File: frontend/src/components/track-list/track-list.ts
This has a dual-source pattern (externalTracks vs store fetch). The scan listener is already conditionally registered:
if (this.externalTracks) {
this.tracks = this.externalTracks;
} else {
this.loadTracks();
this.cancelScanComplete = EventsOn(Events.LibraryScanComplete, () => this.loadTracks());
}
New flow:
- Keep the
externalTrackspath unchanged — when a parent provides tracks, the parent is responsible for refreshing (and the parent's migration in Phase 1/3 handles this). - For the standalone path (no
externalTracks):connectedCallback()→loadTracks()(initial load, unchanged)- Remove the
LibraryScanCompletelistener registration - Add reactive consumption in
updated(), guarded by!this.externalTracks:
private lastTracksRef: library.Track[] | null = null;
override updated() {
// ... existing updated() logic ...
if (!this.externalTracks) {
const cached = this.libraryCtrl.cachedTracks;
if (cached !== null && cached !== this.lastTracksRef) {
this.lastTracksRef = cached;
this.loadTracks();
}
}
}
Selection consideration: loadTracks() already clears selection. Safe.
Phase 5: Migrate playlist-view (HIGH effort)
File: frontend/src/components/playlist-view/playlist-view.ts
This component reshapes playlist.WithTracks[] into PlaylistEntry[] with an expanded boolean per entry. It has a refreshPlaylists() method that preserves expanded state across refetches.
Current flow:
loadPlaylists()→playlistCtrl.getPlaylists()→ map toPlaylistEntry[]→this.entries = resultLibraryScanComplete→loadPlaylists()
New flow:
connectedCallback()→loadPlaylists()(initial load, unchanged)- Remove the direct
LibraryScanCompletelistener. - React to store changes in
updated():
private lastPlaylistsRef: playlist.WithTracks[] | null = null;
override updated() {
// ... existing updated() logic ...
const cached = this.playlistCtrl.cachedPlaylists;
if (cached !== null && cached !== this.lastPlaylistsRef) {
this.lastPlaylistsRef = cached;
this.refreshPlaylists(); // preserves expanded state
}
}
Key choice: use refreshPlaylists() instead of loadPlaylists(). The refreshPlaylists() method preserves which playlists are expanded, providing a better UX after a scan completes. loadPlaylists() resets all to collapsed. The current scan-complete handler uses loadPlaylists() (collapsing everything), but since we're improving the architecture anyway, switching to refreshPlaylists() is a UX improvement.
Alternative consideration: If loadPlaylists() is preferred (to reset UI state after a scan), that works too. The choice is a UX decision, not a technical constraint.
Phase 6: Migrate cover-grid (VERY HIGH effort)
File: frontend/src/components/cover-grid/cover-grid.ts
The most complex component. Dual-source pattern, split-mode scroll management, sort/filter pipeline.
Current flow:
loadAlbums()→libraryCtrl.getAlbums()orexternalAlbums→this.albums = result- Only registers scan listener when
!this.externalAlbums LibraryScanComplete→loadAlbums()
New flow:
- Keep the
externalAlbumspath unchanged. - For the standalone path:
connectedCallback()→loadAlbums()(initial load, unchanged)- Remove the
LibraryScanCompletelistener registration - Add reactive consumption in
updated(), guarded by!this.externalAlbums:
private lastAlbumsRef: library.Album[] | null = null;
override updated() {
// ... existing updated() logic (size properties, wheel listener,
// grid layout, search term selection clearing) ...
if (!this.externalAlbums) {
const cached = this.libraryCtrl.cachedAlbums;
if (cached !== null && cached !== this.lastAlbumsRef) {
this.lastAlbumsRef = cached;
this.loadAlbums();
}
}
}
Split-mode consideration: If the album dropdown is open (expandedAlbumId !== null) when a scan completes, loadAlbums() will close it (resets expandedAlbumId and expandedTracks). This is the same behavior as the current direct listener. The split-mode transition logic in willUpdate() will handle the layout change.
Selection consideration: loadAlbums() already clears album selection. Safe.
Phase 7: Cleanup and Documentation
7A. Remove unused imports
After all data-refresh components are migrated, remove unused EventsOn and Events imports from migrated components (only if no other events are listened to in that component).
7B. Components that KEEP their direct listeners
These components are explicitly excluded from migration and should be documented:
| Component | Reason |
|---|---|
config-page.ts |
Uses event for UI status (scan progress, metrics display), not data refresh. No store handles scan status. |
library-manager.ts |
Same as config-page — UI status listener for scan progress/metrics. |
playlist-picker.ts |
Bypasses store entirely, calls GetAllPlaylists() directly for lightweight summary data. See refactoring catalog #19 for a future plan to route this through a store. |
7C. Update refactoring catalog
Mark item #7 as solved in .opencode/plans/refactoring-catalog.md.
Migration Order Summary
| Phase | Component(s) | Effort | Depends On |
|---|---|---|---|
| 0 | LibraryStore, PlaylistStore (eager refetch) |
Low | — |
| 1 | genre-details, artist-details |
Low | Phase 0 |
| 2 | artists-view |
Low-Medium | Phase 0 |
| 3 | genres-view |
High | Phase 0 |
| 4 | track-list |
Medium | Phase 0 |
| 5 | playlist-view |
High | Phase 0 |
| 6 | cover-grid |
Very High | Phase 0 |
| 7 | Cleanup + docs | Low | Phases 1-6 |
Each phase after 0 is independent of the others and can be done in any order. The ordering above goes from easiest to hardest as a recommended sequence.
Reactive Pattern: Reference Comparison
All component migrations use the same pattern to detect store data changes:
private lastDataRef: T[] | null = null;
override updated() {
const cached = this.controller.cachedData;
if (cached !== null && cached !== this.lastDataRef) {
this.lastDataRef = cached;
this.loadData(); // existing imperative load method
}
}
Why reference comparison instead of a version counter or dirty flag:
- Simplicity: No store API changes needed.
cachedTracks/cachedAlbums/cachedArtists/cachedPlaylistsare already exposed by controllers. - Correctness: Each backend fetch creates a new array instance.
invalidate()sets cache tonull. The reference comparison catches both "new data arrived" and "data was cleared and refetched." - No infinite loops: Setting
this.lastDataRef = cachedbefore callingloadData()prevents re-triggering. TheloadData()call may set local@state()which triggers anotherupdated(), but by thenlastDataRefmatchescachedand the guard short-circuits. - No store changes needed: The controllers already expose
cachedTracks,cachedAlbums,cachedArtists,cachedPlaylists.
Why not move everything into render(): Components do significant local work beyond just displaying store data — filtering, sorting, scroll restoration, selection management. Keeping the imperative loadData() call but triggering it reactively is the minimal change that achieves the goal.
Risk Assessment
| Risk | Mitigation |
|---|---|
| Double fetch on scan complete (eager + component listener during migration) | Store's waitFor*() helpers coalesce concurrent requests. Only one backend call actually fires. |
Infinite updated() loop |
Reference comparison with lastDataRef assignment prevents re-triggering. Each migration should be tested for this. |
| Stale selection after scan | loadData() methods already clear selection in most components. Verify for each migration. |
| Scroll position regression | invalidate() resets scroll to 0. loadData() methods handle scroll restoration. The 0 position means "start from top", which is correct after a scan. |
externalAlbums/externalTracks components don't refresh |
Parent components (artist-details, genre-details) are migrated first. They re-fetch and update the external* property, which triggers the child's willUpdate() change detection. |
playlist-picker left unmigrated |
Intentional. It uses a different API (GetAllPlaylists vs GetAllPlaylistsWithTracks). See catalog item #19. |
Testing Strategy
For each phase:
- Manual test: Trigger a library scan while each affected view is visible. Verify data refreshes without stale content.
- Manual test: Trigger a scan while a detail view is open (
genre-details,artist-details). Verify child components (track-list,cover-grid) refresh via the parent's external data update. - Manual test: Verify scroll position resets to top after scan.
- Manual test: Verify that adding/removing tracks from the library directory and scanning updates all views correctly.
- Verify no console errors — especially no infinite loop warnings or unhandled promise rejections.
- Run
pnpm exec tsc --noEmit— ensure no TypeScript errors after each phase.