initial db rewrite

This commit is contained in:
Boof2015
2026-07-24 02:18:37 -04:00
parent 26fba836f4
commit a10ecf4a0f
90 changed files with 12669 additions and 5173 deletions
+319 -130
View File
@@ -1,19 +1,10 @@
import { create } from 'zustand';
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
import { openLibraryDb } from '@/db/database';
import {
getAllTracks,
getRecentlyPlayedTracks,
getSetting,
getTrackCount,
markTrackPlayed,
setSetting,
} from '@/db/queries';
import { markLocalTracksStaleForRebuild } from '@/db/libraryMaintenance';
import { recomputeAlbumIdentity } from '@/library/albumIdentity';
import { buildAlbumList } from '@/library/albumSummary';
import { ensureArtworkThumbnails } from '@/library/artwork';
import { buildArtistList } from '@/library/artistGrouping';
AstraLibraryData,
type LibrarySectionAnchor,
type LibraryStatus,
} from '../../modules/astra-library-scanner';
import type { Album, Artist, DbTrack, LibraryFolder } from '@/types/library';
import {
addFolderViaPicker,
loadFolders,
@@ -26,13 +17,8 @@ import { endScanService, reportScanProgress } from '@/library/scanService';
import { ALBUM_SORT_LABELS, type AlbumSort } from '@/lib/albumSort';
import { ARTIST_SORT_LABELS, type ArtistSort } from '@/lib/artistSort';
import { TRACK_SORT_LABELS, type TrackSort } from '@/lib/trackSort';
import { usePlaylistStore } from './playlistStore';
import { useSettingsStore } from './settingsStore';
/**
* Library state — SQLite is the source of truth (no persist middleware);
* this store mirrors it in memory for the UI plus scan/UI state.
*/
type ViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
const VIEW_MODE_KEY = 'library_view_mode';
@@ -40,11 +26,8 @@ const TRACK_SORT_KEY = 'library_track_sort';
const ALBUM_SORT_KEY = 'library_album_sort';
const ARTIST_SORT_KEY = 'library_artist_sort';
const INCLUDE_COLLAB_ARTISTS_KEY = 'library_include_collab_artists';
// Bump when the album-identity algorithm changes to re-run the whole-library
// recompute at startup. '2' = the desktop three-tier grouping port (v15 schema).
const ALBUM_GROUPING_VERSION_KEY = 'album_grouping_version';
const ALBUM_GROUPING_VERSION = '2';
const PAGE_SIZE = 100;
const MAX_WINDOW_ITEMS = PAGE_SIZE * 5;
const VIEW_MODES: readonly ViewMode[] = ['tracks', 'albums', 'artists', 'playlists', 'folders'];
@@ -64,13 +47,8 @@ function parseArtistSort(value: string | null): ArtistSort | null {
return value !== null && value in ARTIST_SORT_LABELS ? (value as ArtistSort) : null;
}
/** Fire-and-forget settings write so view/sort switching stays synchronous. */
function persistSetting(key: string, value: string) {
void openLibraryDb()
.then((db) => setSetting(db, key, value))
.catch(() => {
// Losing a view preference write is harmless; never surface it.
});
void AstraLibraryData.setSettings({ [key]: value });
}
export type FolderWithCount = LibraryFolder & { track_count: number };
@@ -86,10 +64,14 @@ const IDLE_PROGRESS: ScanProgressState = { phase: 'idle', processed: 0, total: 0
interface LibraryStore {
initialized: boolean;
status: LibraryStatus;
recoveryNotice: string | null;
tracks: DbTrack[];
recentlyPlayedTracks: DbTrack[];
albums: Album[];
artists: Artist[];
homeAlbums: Album[];
homeArtists: Artist[];
folders: FolderWithCount[];
totalTrackCount: number;
viewMode: ViewMode;
@@ -98,11 +80,20 @@ interface LibraryStore {
artistSort: ArtistSort;
includeCollabArtists: boolean;
isScanning: boolean;
isPageLoading: boolean;
scanProgress: ScanProgressState;
scanError: string | null;
trackNextCursor: string | null;
albumNextCursor: string | null;
artistNextCursor: string | null;
sectionAnchors: LibrarySectionAnchor[];
initialize: () => Promise<void>;
refresh: () => Promise<void>;
loadNextTracks: () => Promise<void>;
loadNextAlbums: () => Promise<void>;
loadNextArtists: () => Promise<void>;
jumpToSection: (cursor: string) => Promise<void>;
recordTrackPlayed: (path: string) => Promise<void>;
recomputeArtists: () => void;
recomputeAlbums: () => void;
@@ -118,36 +109,117 @@ interface LibraryStore {
}
let initPromise: Promise<void> | null = null;
let nativeSubscriptionsInstalled = false;
function appendWindow<T>(
current: T[],
incoming: T[],
key: (item: T) => string
): T[] {
const known = new Set(current.map(key));
const merged = [...current, ...incoming.filter((item) => !known.has(key(item)))];
return merged.length > MAX_WINDOW_ITEMS ? merged.slice(merged.length - MAX_WINDOW_ITEMS) : merged;
}
export const useLibraryStore = create<LibraryStore>((set, get) => {
const onProgress = (progress: ScanProgress) => {
set({ scanProgress: progress });
// Mirror progress into the foreground-service notification (starts it on the
// first tick) so a big scan keeps running + stays visible when backgrounded.
void reportScanProgress(progress);
};
/** Shared scan wrapper: progress/error state + refresh, scans never overlap. */
const readTrackPage = (cursor: string | null) =>
AstraLibraryData.getTrackPage<DbTrack>(get().trackSort, cursor, PAGE_SIZE);
const readAlbumPage = (cursor: string | null) =>
AstraLibraryData.getAlbumPage<Album>(
get().albumSort,
useSettingsStore.getState().includeSingles,
cursor,
PAGE_SIZE
);
const readArtistPage = (cursor: string | null) =>
AstraLibraryData.getArtistPage<Artist>(
get().artistSort,
useSettingsStore.getState().artistGroupingMode,
get().includeCollabArtists,
cursor,
PAGE_SIZE
);
const resetTracks = async () => {
const page = await readTrackPage(null);
set({
tracks: page.items ?? [],
trackNextCursor: page.nextCursor ?? null,
totalTrackCount: page.totalCount ?? 0,
});
};
const resetAlbums = async () => {
const page = await readAlbumPage(null);
set({ albums: page.items ?? [], albumNextCursor: page.nextCursor ?? null });
};
const resetArtists = async () => {
const page = await readArtistPage(null);
set({ artists: page.items ?? [], artistNextCursor: page.nextCursor ?? null });
};
const resetSectionAnchors = async () => {
const state = get();
const sortable =
(state.viewMode === 'tracks' && (state.trackSort === 'artist' || state.trackSort === 'title')) ||
(state.viewMode === 'albums' && (state.albumSort === 'artist' || state.albumSort === 'name')) ||
(state.viewMode === 'artists' && state.artistSort === 'name');
if (!sortable) {
set({ sectionAnchors: [] });
return;
}
const sort =
state.viewMode === 'tracks'
? state.trackSort as 'artist' | 'title'
: state.viewMode === 'albums'
? state.albumSort as 'artist' | 'name'
: 'name';
set({
sectionAnchors: await AstraLibraryData.getSectionAnchors(
state.viewMode as 'tracks' | 'albums' | 'artists',
sort,
useSettingsStore.getState().includeSingles,
useSettingsStore.getState().artistGroupingMode,
state.includeCollabArtists
),
});
};
const runScan = async (scan: () => Promise<ScanResult | null>) => {
if (get().isScanning) return;
set({ isScanning: true, scanError: null, scanProgress: { ...IDLE_PROGRESS } });
try {
await scan();
} catch (err) {
set({ scanError: err instanceof Error ? err.message : String(err) });
} catch (error) {
set({ scanError: error instanceof Error ? error.message : String(error) });
} finally {
await get().refresh();
set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } });
endScanService();
try {
await get().refresh();
} finally {
set({ isScanning: false, scanProgress: { ...IDLE_PROGRESS } });
endScanService();
}
}
};
return {
initialized: false,
status: 'initializing',
recoveryNotice: null,
tracks: [],
recentlyPlayedTracks: [],
albums: [],
artists: [],
homeAlbums: [],
homeArtists: [],
folders: [],
totalTrackCount: 0,
viewMode: 'albums',
@@ -156,140 +228,261 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
artistSort: 'name',
includeCollabArtists: false,
isScanning: false,
isPageLoading: false,
scanProgress: { ...IDLE_PROGRESS },
scanError: null,
trackNextCursor: null,
albumNextCursor: null,
artistNextCursor: null,
sectionAnchors: [],
initialize: () => {
if (!initPromise) {
initPromise = (async () => {
const db = await openLibraryDb();
// Load the persisted grouping mode before the first refresh so the artist
// list is built correctly; recompute it whenever the mode changes later.
await useSettingsStore.getState().load();
useSettingsStore.subscribe((state, prev) => {
if (state.artistGroupingMode !== prev.artistGroupingMode) get().recomputeArtists();
if (state.includeSingles !== prev.includeSingles) get().recomputeAlbums();
});
// One-time backfill when the album-identity algorithm changes (e.g. the
// desktop three-tier grouping port): settle every track's identity key +
// display artist before the first refresh so first paint is grouped right.
if ((await getSetting(db, ALBUM_GROUPING_VERSION_KEY)) !== ALBUM_GROUPING_VERSION) {
await recomputeAlbumIdentity(db);
await setSetting(db, ALBUM_GROUPING_VERSION_KEY, ALBUM_GROUPING_VERSION);
}
// Restore view preferences before the first render of the library screen.
const [
savedViewMode,
savedTrackSort,
savedAlbumSort,
savedArtistSort,
savedIncludeCollabArtists,
] =
await Promise.all([
getSetting(db, VIEW_MODE_KEY),
getSetting(db, TRACK_SORT_KEY),
getSetting(db, ALBUM_SORT_KEY),
getSetting(db, ARTIST_SORT_KEY),
getSetting(db, INCLUDE_COLLAB_ARTISTS_KEY),
]);
const viewMode = parseViewMode(savedViewMode);
const trackSort = parseTrackSort(savedTrackSort);
const albumSort = parseAlbumSort(savedAlbumSort);
const artistSort = parseArtistSort(savedArtistSort);
const status = await AstraLibraryData.initialize();
set({
...(viewMode ? { viewMode } : null),
...(trackSort ? { trackSort } : null),
...(albumSort ? { albumSort } : null),
...(artistSort ? { artistSort } : null),
includeCollabArtists: savedIncludeCollabArtists === 'true',
status: status.status,
recoveryNotice: status.recoveryNotice,
totalTrackCount: status.trackCount,
});
if (status.status === 'fatalUserData') {
set({ initialized: true });
return;
}
await useSettingsStore.getState().load();
const values = await AstraLibraryData.getSettings([
VIEW_MODE_KEY,
TRACK_SORT_KEY,
ALBUM_SORT_KEY,
ARTIST_SORT_KEY,
INCLUDE_COLLAB_ARTISTS_KEY,
]);
const viewMode = parseViewMode(values[VIEW_MODE_KEY] ?? null);
const trackSort = parseTrackSort(values[TRACK_SORT_KEY] ?? null);
const albumSort = parseAlbumSort(values[ALBUM_SORT_KEY] ?? null);
const artistSort = parseArtistSort(values[ARTIST_SORT_KEY] ?? null);
set({
...(viewMode ? { viewMode } : {}),
...(trackSort ? { trackSort } : {}),
...(albumSort ? { albumSort } : {}),
...(artistSort ? { artistSort } : {}),
includeCollabArtists: values[INCLUDE_COLLAB_ARTISTS_KEY] === 'true',
});
if (!nativeSubscriptionsInstalled) {
nativeSubscriptionsInstalled = true;
AstraLibraryData.addListener('onLibraryStatus', (next) => {
set({
status: next.status,
recoveryNotice: next.recoveryNotice,
totalTrackCount: next.trackCount,
});
if (next.status === 'rebuilding' && !get().isScanning) {
void get().rebuildLocalIndex();
}
});
AstraLibraryData.addListener('onCatalogChanged', () => {
void get().refresh();
});
useSettingsStore.subscribe((next, previous) => {
if (next.artistGroupingMode !== previous.artistGroupingMode) {
void resetArtists();
void resetSectionAnchors();
}
if (next.includeSingles !== previous.includeSingles) {
void resetAlbums();
void resetSectionAnchors();
}
});
}
await get().refresh();
set({ initialized: true });
// One-time recovery: the v3 migration marks tracks stale (mtime = -1)
// whose non-ASCII tags were truncated by the pre-fix op-sqlite binding.
// Re-extract them now that binding is fixed. Fire-and-forget so startup
// isn't blocked; rescan manages its own progress + refresh.
const stale = await db.get<{ n: number }>('SELECT COUNT(*) AS n FROM tracks WHERE mtime = -1');
if ((stale?.n ?? 0) > 0) {
void get().rescan();
if (status.status === 'rebuilding' && !get().isScanning) {
void get().rebuildLocalIndex();
}
})().catch((err) => {
initPromise = null; // allow retry on genuine failure
throw err;
})().catch((error) => {
initPromise = null;
throw error;
});
}
return initPromise;
},
refresh: async () => {
const db = await openLibraryDb();
const [tracks, folders, totalTrackCount, recentlyPlayedTracks] = await Promise.all([
getAllTracks(db),
const viewMode = get().viewMode;
const [
trackPage,
albumPage,
artistPage,
homeAlbumPage,
homeArtistPage,
folders,
recentlyPlayedTracks,
] = await Promise.all([
viewMode === 'tracks' ? readTrackPage(null) : Promise.resolve(null),
viewMode === 'albums' ? readAlbumPage(null) : Promise.resolve(null),
viewMode === 'artists' ? readArtistPage(null) : Promise.resolve(null),
AstraLibraryData.getAlbumPage<Album>(
'recently_added',
useSettingsStore.getState().includeSingles,
null,
20
),
AstraLibraryData.getArtistPage<Artist>(
'name',
useSettingsStore.getState().artistGroupingMode,
get().includeCollabArtists,
null,
50
),
loadFolders(),
getTrackCount(db),
getRecentlyPlayedTracks(db),
AstraLibraryData.getRecentlyPlayed<DbTrack>(20),
]);
set({
...(trackPage ? {
tracks: trackPage.items ?? [],
trackNextCursor: trackPage.nextCursor ?? null,
totalTrackCount: trackPage.totalCount ?? get().totalTrackCount,
} : {}),
...(albumPage ? {
albums: albumPage.items ?? [],
albumNextCursor: albumPage.nextCursor ?? null,
} : {}),
...(artistPage ? {
artists: artistPage.items ?? [],
artistNextCursor: artistPage.nextCursor ?? null,
} : {}),
homeAlbums: homeAlbumPage.items ?? [],
homeArtists: homeArtistPage.items ?? [],
folders,
recentlyPlayedTracks,
});
await resetSectionAnchors();
},
loadNextTracks: async () => {
const cursor = get().trackNextCursor;
if (!cursor || get().isPageLoading) return;
set({ isPageLoading: true });
try {
await ensureArtworkThumbnails(tracks.map((track) => track.artwork_hash));
} catch {
// Missing thumbnails should not prevent the library itself from loading.
const page = await readTrackPage(cursor);
if (page.error === 'STALE_REVISION') return resetTracks();
set((state) => ({
tracks: appendWindow(state.tracks, page.items, (track) => track.path),
trackNextCursor: page.nextCursor,
}));
} finally {
set({ isPageLoading: false });
}
},
loadNextAlbums: async () => {
const cursor = get().albumNextCursor;
if (!cursor || get().isPageLoading) return;
set({ isPageLoading: true });
try {
const page = await readAlbumPage(cursor);
if (page.error === 'STALE_REVISION') return resetAlbums();
set((state) => ({
albums: appendWindow(state.albums, page.items, (album) => album.identity_key),
albumNextCursor: page.nextCursor,
}));
} finally {
set({ isPageLoading: false });
}
},
loadNextArtists: async () => {
const cursor = get().artistNextCursor;
if (!cursor || get().isPageLoading) return;
set({ isPageLoading: true });
try {
const page = await readArtistPage(cursor);
if (page.error === 'STALE_REVISION') return resetArtists();
set((state) => ({
artists: appendWindow(state.artists, page.items, (artist) => artist.artist),
artistNextCursor: page.nextCursor,
}));
} finally {
set({ isPageLoading: false });
}
},
jumpToSection: async (cursor) => {
const state = get();
set({ isPageLoading: true });
try {
if (state.viewMode === 'tracks') {
const page = await readTrackPage(cursor);
if (page.error === 'STALE_REVISION') return resetTracks();
set({
tracks: page.items,
trackNextCursor: page.nextCursor,
totalTrackCount: page.totalCount,
});
} else if (state.viewMode === 'albums') {
const page = await readAlbumPage(cursor);
if (page.error === 'STALE_REVISION') return resetAlbums();
set({ albums: page.items, albumNextCursor: page.nextCursor });
} else if (state.viewMode === 'artists') {
const page = await readArtistPage(cursor);
if (page.error === 'STALE_REVISION') return resetArtists();
set({ artists: page.items, artistNextCursor: page.nextCursor });
}
} finally {
set({ isPageLoading: false });
}
// Album + artist lists are derived in JS: albums for the desktop-parity
// display picks + singles eligibility, artists to honor the grouping mode.
const settings = useSettingsStore.getState();
const albums = buildAlbumList(tracks, { includeSingles: settings.includeSingles });
const artists = buildArtistList(tracks, settings.artistGroupingMode);
set({ tracks, recentlyPlayedTracks, albums, artists, folders, totalTrackCount });
// Playlist counts/missing states depend on tracks — keep them in step.
await usePlaylistStore.getState().refresh();
},
recordTrackPlayed: async (path) => {
const db = await openLibraryDb();
const recorded = await markTrackPlayed(db, path);
if (!recorded) return;
const recentlyPlayedTracks = await getRecentlyPlayedTracks(db);
set({ recentlyPlayedTracks });
await AstraLibraryData.recordTrackPlayed(path);
set({ recentlyPlayedTracks: await AstraLibraryData.getRecentlyPlayed<DbTrack>(20) });
},
// Rebuild the artist list from in-memory tracks (e.g. on grouping-mode change),
// without re-querying SQLite.
recomputeArtists: () =>
set((state) => ({
artists: buildArtistList(state.tracks, useSettingsStore.getState().artistGroupingMode),
})),
recomputeArtists: () => {
void resetArtists();
},
// Rebuild the album list from in-memory tracks (e.g. on singles-toggle change).
recomputeAlbums: () =>
set((state) => ({
albums: buildAlbumList(state.tracks, {
includeSingles: useSettingsStore.getState().includeSingles,
}),
})),
recomputeAlbums: () => {
void resetAlbums();
},
setViewMode: (viewMode) => {
set({ viewMode });
persistSetting(VIEW_MODE_KEY, viewMode);
if (viewMode === 'tracks' && get().tracks.length === 0) void resetTracks();
if (viewMode === 'albums' && get().albums.length === 0) void resetAlbums();
if (viewMode === 'artists' && get().artists.length === 0) void resetArtists();
void resetSectionAnchors();
},
setTrackSort: (trackSort) => {
set({ trackSort });
set({ trackSort, tracks: [], trackNextCursor: null });
persistSetting(TRACK_SORT_KEY, trackSort);
void resetTracks();
void resetSectionAnchors();
},
setAlbumSort: (albumSort) => {
set({ albumSort });
set({ albumSort, albums: [], albumNextCursor: null });
persistSetting(ALBUM_SORT_KEY, albumSort);
void resetAlbums();
void resetSectionAnchors();
},
setArtistSort: (artistSort) => {
set({ artistSort });
set({ artistSort, artists: [], artistNextCursor: null });
persistSetting(ARTIST_SORT_KEY, artistSort);
void resetArtists();
void resetSectionAnchors();
},
setIncludeCollabArtists: (includeCollabArtists) => {
set({ includeCollabArtists });
set({ includeCollabArtists, artists: [], artistNextCursor: null });
persistSetting(INCLUDE_COLLAB_ARTISTS_KEY, includeCollabArtists ? 'true' : 'false');
void resetArtists();
void resetSectionAnchors();
},
addFolder: () => runScan(() => addFolderViaPicker({ onProgress })),
@@ -303,10 +496,6 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
rescan: () => runScan(() => rescanAll({ callbacks: { onProgress } })),
rebuildLocalIndex: () => runScan(async () => {
const db = await openLibraryDb();
await markLocalTracksStaleForRebuild(db);
return rescanAll({ callbacks: { onProgress } });
}),
rebuildLocalIndex: () => runScan(() => rescanAll({ mode: 'full', callbacks: { onProgress } })),
};
});