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
+9 -15
View File
@@ -1,6 +1,5 @@
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings';
import {
DEFAULT_TARGET_LUFS,
type NormalizationSettings,
@@ -46,12 +45,11 @@ export const useAudioSettingsStore = create<AudioSettingsStore>((set, get) => ({
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const [enabled, target, rgEnabled, rgMode] = await Promise.all([
getSetting(db, NORMALIZATION_ENABLED_KEY),
getSetting(db, NORMALIZATION_TARGET_KEY),
getSetting(db, REPLAYGAIN_ENABLED_KEY),
getSetting(db, REPLAYGAIN_MODE_KEY),
getNativeSetting(NORMALIZATION_ENABLED_KEY),
getNativeSetting(NORMALIZATION_TARGET_KEY),
getNativeSetting(REPLAYGAIN_ENABLED_KEY),
getNativeSetting(REPLAYGAIN_MODE_KEY),
]);
const targetNum = Number(target);
set({
@@ -67,30 +65,26 @@ export const useAudioSettingsStore = create<AudioSettingsStore>((set, get) => ({
setNormalizationEnabled: async (enabled) => {
if (get().normalizationEnabled === enabled) return;
set({ normalizationEnabled: enabled });
const db = await openLibraryDb();
await setSetting(db, NORMALIZATION_ENABLED_KEY, enabled ? 'true' : 'false');
await setNativeSetting(NORMALIZATION_ENABLED_KEY, enabled ? 'true' : 'false');
},
setNormalizationTargetLufs: async (lufs) => {
const clamped = Math.max(-30, Math.min(-5, lufs));
if (get().normalizationTargetLufs === clamped) return;
set({ normalizationTargetLufs: clamped });
const db = await openLibraryDb();
await setSetting(db, NORMALIZATION_TARGET_KEY, String(clamped));
await setNativeSetting(NORMALIZATION_TARGET_KEY, String(clamped));
},
setReplayGainEnabled: async (enabled) => {
if (get().replayGainEnabled === enabled) return;
set({ replayGainEnabled: enabled });
const db = await openLibraryDb();
await setSetting(db, REPLAYGAIN_ENABLED_KEY, enabled ? 'true' : 'false');
await setNativeSetting(REPLAYGAIN_ENABLED_KEY, enabled ? 'true' : 'false');
},
setReplayGainMode: async (mode) => {
if (get().replayGainMode === mode) return;
set({ replayGainMode: mode });
const db = await openLibraryDb();
await setSetting(db, REPLAYGAIN_MODE_KEY, mode);
await setNativeSetting(REPLAYGAIN_MODE_KEY, mode);
},
asNormalizationSettings: () => {
+2 -5
View File
@@ -28,8 +28,7 @@ import {
setDesktopRemoteConnection,
setDesktopRemoteCredentials,
} from '@/services/desktopRemoteCredentials';
import { openLibraryDb } from '@/db/database';
import { clearPlaylistSyncBaselines } from '@/db/desktopSyncQueries';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy';
import { ensureDesktopRemoteCredentialsFresh } from '@/services/desktopRemoteSession';
@@ -718,9 +717,7 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
clearPairingPoll();
await clearDesktopRemotePairing();
// Sync baselines are meaningless against a different desktop.
void openLibraryDb()
.then((db) => clearPlaylistSyncBaselines(db))
.catch(() => {});
void AstraLibraryData.clearDesktopSyncBaselines().catch(() => {});
set({
connectionState: 'unpaired',
connection: null,
+6 -9
View File
@@ -5,8 +5,7 @@
import { AppState } from 'react-native';
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings';
import {
DesktopSyncUnsupportedError,
applyDesktopSyncConflictResolution,
@@ -80,17 +79,16 @@ export const useDesktopSyncStore = create<DesktopSyncStore>((set, get) => ({
hydrate: async () => {
try {
const db = await openLibraryDb();
const masterSetting = await getSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY);
const legacySetting = await getSetting(db, LEGACY_AUTO_SYNC_SETTING_KEY);
const masterSetting = await getNativeSetting(DESKTOP_SYNC_ENABLED_SETTING_KEY);
const legacySetting = await getNativeSetting(LEGACY_AUTO_SYNC_SETTING_KEY);
const enabled = decideDesktopSyncEnabled(masterSetting, legacySetting);
set({ desktopSyncEnabled: enabled });
if (masterSetting === null) {
await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0');
await setNativeSetting(DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0');
}
const connection = await getDesktopRemoteConnection();
if (connection) {
const stored = await getSetting(db, desktopSyncSettingKey(connection));
const stored = await getNativeSetting(desktopSyncSettingKey(connection));
const lastSyncAt = stored ? Number(stored) : NaN;
if (Number.isFinite(lastSyncAt) && lastSyncAt > 0) {
set({ lastSyncAt });
@@ -110,8 +108,7 @@ export const useDesktopSyncStore = create<DesktopSyncStore>((set, get) => ({
}
set({ desktopSyncEnabled: enabled });
try {
const db = await openLibraryDb();
await setSetting(db, DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0');
await setNativeSetting(DESKTOP_SYNC_ENABLED_SETTING_KEY, enabled ? '1' : '0');
} catch {
// The in-memory value still applies for this session.
}
+33 -40
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import type { AudioOutputRoute, EQBand, EQMode, EQPreset } from '@/types/audio';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import { getNativeSettings, setNativeSetting } from '@/db/nativeSettings';
import {
EQ_MAX_BANDS,
clampEQFrequency,
@@ -188,19 +188,15 @@ export const useEQStore = create<EQStore>((set, get) => {
assignments: devicePresetAssignments,
};
try {
const db = await openLibraryDb();
await Promise.all([
setSetting(db, ENABLED_KEY, enabled ? 'true' : 'false'),
setSetting(db, PREAMP_KEY, String(preamp)),
setSetting(db, BANDS_KEY, JSON.stringify(bands)),
setSetting(db, MODE_KEY, mode),
setSetting(db, GRAPHIC_GAINS_KEY, JSON.stringify(graphicGains)),
setSetting(db, ACTIVE_PRESET_KEY, activePresetId ?? ''),
setSetting(db, DEVICE_PRESETS_KEY, stringifyEQDevicePresetState(devicePresetState)),
setSetting(
db,
CUSTOM_PRESETS_KEY,
JSON.stringify(
await AstraLibraryData.setSettings({
[ENABLED_KEY]: enabled ? 'true' : 'false',
[PREAMP_KEY]: String(preamp),
[BANDS_KEY]: JSON.stringify(bands),
[MODE_KEY]: mode,
[GRAPHIC_GAINS_KEY]: JSON.stringify(graphicGains),
[ACTIVE_PRESET_KEY]: activePresetId ?? '',
[DEVICE_PRESETS_KEY]: stringifyEQDevicePresetState(devicePresetState),
[CUSTOM_PRESETS_KEY]: JSON.stringify(
custom.map((p) => ({
id: p.id,
name: p.name,
@@ -208,9 +204,8 @@ export const useEQStore = create<EQStore>((set, get) => {
bands: p.bands,
...(p.mode === 'graphic' ? { mode: p.mode, graphicGains: p.graphicGains } : {}),
}))
)
),
]);
),
});
} catch {
/* persistence failure is non-fatal */
}
@@ -239,28 +234,26 @@ export const useEQStore = create<EQStore>((set, get) => {
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const [
enabledRaw,
preampRaw,
bandsRaw,
modeRaw,
gainsRaw,
activeRaw,
customRaw,
devicePresetsRaw,
routeProfilesRaw,
] = await Promise.all([
getSetting(db, ENABLED_KEY),
getSetting(db, PREAMP_KEY),
getSetting(db, BANDS_KEY),
getSetting(db, MODE_KEY),
getSetting(db, GRAPHIC_GAINS_KEY),
getSetting(db, ACTIVE_PRESET_KEY),
getSetting(db, CUSTOM_PRESETS_KEY),
getSetting(db, DEVICE_PRESETS_KEY),
getSetting(db, ROUTE_PROFILES_KEY),
const values = await getNativeSettings([
ENABLED_KEY,
PREAMP_KEY,
BANDS_KEY,
MODE_KEY,
GRAPHIC_GAINS_KEY,
ACTIVE_PRESET_KEY,
CUSTOM_PRESETS_KEY,
DEVICE_PRESETS_KEY,
ROUTE_PROFILES_KEY,
]);
const enabledRaw = values[ENABLED_KEY];
const preampRaw = values[PREAMP_KEY];
const bandsRaw = values[BANDS_KEY];
const modeRaw = values[MODE_KEY];
const gainsRaw = values[GRAPHIC_GAINS_KEY];
const activeRaw = values[ACTIVE_PRESET_KEY];
const customRaw = values[CUSTOM_PRESETS_KEY];
const devicePresetsRaw = values[DEVICE_PRESETS_KEY];
const routeProfilesRaw = values[ROUTE_PROFILES_KEY];
const bands = parseBands(bandsRaw) ?? createDefaultBands();
const presets = [...createBuiltInPresets(), ...parseCustomPresets(customRaw)];
@@ -294,7 +287,7 @@ export const useEQStore = create<EQStore>((set, get) => {
// snapshots remain unread after this write and can no longer affect EQ.
if (devicePresetsRaw === null) {
try {
await setSetting(db, DEVICE_PRESETS_KEY, stringifyEQDevicePresetState(deviceState));
await setNativeSetting(DEVICE_PRESETS_KEY, stringifyEQDevicePresetState(deviceState));
} catch {
/* migration persistence failure is non-fatal and safe to retry */
}
+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 } })),
};
});
+5 -9
View File
@@ -1,6 +1,5 @@
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings';
import {
DEFAULT_LYRICS_DISPLAY_SETTINGS,
normalizeLyricsDisplaySettings,
@@ -42,8 +41,7 @@ function displaySettingsFromState(state: LyricsSettingsStore): LyricsDisplaySett
}
async function persistDisplaySettings(settings: LyricsDisplaySettings): Promise<void> {
const db = await openLibraryDb();
await setSetting(db, DISPLAY_SETTINGS_KEY, JSON.stringify(settings));
await setNativeSetting(DISPLAY_SETTINGS_KEY, JSON.stringify(settings));
}
let loadPromise: Promise<void> | null = null;
@@ -57,10 +55,9 @@ export const useLyricsSettingsStore = create<LyricsSettingsStore>((set, get) =>
if (get().loaded) return;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
const db = await openLibraryDb();
const [onlineValue, displayValue] = await Promise.all([
getSetting(db, ONLINE_LOOKUP_KEY),
getSetting(db, DISPLAY_SETTINGS_KEY),
getNativeSetting(ONLINE_LOOKUP_KEY),
getNativeSetting(DISPLAY_SETTINGS_KEY),
]);
let parsed: unknown = null;
try {
@@ -82,8 +79,7 @@ export const useLyricsSettingsStore = create<LyricsSettingsStore>((set, get) =>
setOnlineLookupEnabled: async (enabled) => {
await get().load();
set({ onlineLookupEnabled: enabled });
const db = await openLibraryDb();
await setSetting(db, ONLINE_LOOKUP_KEY, enabled ? 'true' : 'false');
await setNativeSetting(ONLINE_LOOKUP_KEY, enabled ? 'true' : 'false');
},
setWordTimingEnabled: async (enabled) => {
+7 -10
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getFolders, getSetting, setSetting } from '@/db/queries';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings';
/**
* First-run wizard gate. SQLite (settings table) is the source of truth, mirrored
@@ -24,8 +24,7 @@ export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const value = await getSetting(db, ONBOARDING_COMPLETE_KEY);
const value = await getNativeSetting(ONBOARDING_COMPLETE_KEY);
if (value !== null) {
set({ onboardingComplete: value === 'true', loaded: true });
return;
@@ -33,21 +32,19 @@ export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
// Flag never set: an install that already has library folders predates this
// wizard — treat it as onboarded (and persist) so the wizard never ambushes
// an upgrading user. A genuinely fresh install has no folders → show it.
const folders = await getFolders(db);
const folders = await AstraLibraryData.listFolders();
const complete = folders.length > 0;
if (complete) await setSetting(db, ONBOARDING_COMPLETE_KEY, 'true');
if (complete) await setNativeSetting(ONBOARDING_COMPLETE_KEY, 'true');
set({ onboardingComplete: complete, loaded: true });
},
markComplete: async () => {
set({ onboardingComplete: true });
const db = await openLibraryDb();
await setSetting(db, ONBOARDING_COMPLETE_KEY, 'true');
await setNativeSetting(ONBOARDING_COMPLETE_KEY, 'true');
},
reset: async () => {
set({ onboardingComplete: false });
const db = await openLibraryDb();
await setSetting(db, ONBOARDING_COMPLETE_KEY, 'false');
await setNativeSetting(ONBOARDING_COMPLETE_KEY, 'false');
},
}));
+3 -6
View File
@@ -1,6 +1,5 @@
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings';
const PLAYBACK_TARGET_KEY = 'playback_target';
@@ -23,15 +22,13 @@ export const usePlaybackTargetStore = create<PlaybackTargetStore>((set, get) =>
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const stored = await getSetting(db, PLAYBACK_TARGET_KEY);
const stored = await getNativeSetting(PLAYBACK_TARGET_KEY);
set({ target: parsePlaybackTarget(stored), loaded: true });
},
setTarget: async (target) => {
if (get().target === target && get().loaded) return;
set({ target, loaded: true });
const db = await openLibraryDb();
await setSetting(db, PLAYBACK_TARGET_KEY, target);
await setNativeSetting(PLAYBACK_TARGET_KEY, target);
},
}));
+135 -108
View File
@@ -1,13 +1,13 @@
import { create } from 'zustand';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import type { DbTrack } from '@/types/library';
import type { Playlist, PlaylistTrackEntry } from '@/types/playlist';
import type {
DynamicPlaylistPreview,
DynamicPlaylistRulesV1,
import {
createDefaultDynamicPlaylistRules,
normalizeDynamicPlaylistRules,
type DynamicPlaylistPreview,
type DynamicPlaylistRulesV1,
} from '@/shared/playlists/dynamicPlaylist';
import { openLibraryDb, type LibraryDatabase } from '@/db/database';
import { getAllTracks } from '@/db/queries';
import * as playlistDb from '@/db/playlistQueries';
import {
buildImportIndex,
decodedDocPath,
@@ -17,7 +17,9 @@ import {
pickAndParseM3u,
type M3uExportResult,
} from '@/library/playlistFiles';
import type { M3uExportEntry } from '@/lib/m3u';
import type { M3uExportEntry, M3uEntry } from '@/lib/m3u';
const ENTRY_PAGE_SIZE = 100;
export interface M3uImportSummary {
playlistId: number;
@@ -29,20 +31,18 @@ export interface M3uImportSummary {
ambiguous: number;
}
/**
* Playlists + favorites state SQLite is the source of truth (no persist);
* every mutation re-queries. libraryStore.refresh() chains into refresh() so
* scans and folder removals update counts/missing states.
*/
interface PlaylistStore {
playlists: Playlist[];
favoritePaths: Set<string>;
favoriteTracks: DbTrack[];
activePlaylistId: number | null;
activeEntries: PlaylistTrackEntry[];
activeEntriesTotal: number;
activeEntriesNextOffset: number | null;
refresh: () => Promise<void>;
openPlaylist: (id: number) => Promise<void>;
loadNextEntries: () => Promise<void>;
closePlaylist: () => void;
createPlaylist: (name: string) => Promise<Playlist>;
createDynamicPlaylist: (name: string, rules: DynamicPlaylistRulesV1) => Promise<Playlist>;
@@ -54,7 +54,6 @@ interface PlaylistStore {
addTracksToPlaylist: (id: number, tracks: DbTrack[]) => Promise<number>;
removeFromPlaylist: (id: number, trackPath: string) => Promise<void>;
moveTrack: (id: number, trackPath: string, direction: -1 | 1) => Promise<void>;
// Only the path is read; accepts a library DbTrack or the now-playing Track.
toggleFavorite: (track: { path: string }) => Promise<void>;
markPlayed: (id: number) => Promise<void>;
importM3u: () => Promise<M3uImportSummary | null>;
@@ -80,22 +79,45 @@ function entryToExportEntry(entry: PlaylistTrackEntry): M3uExportEntry {
};
}
function parseRules(raw: string): DynamicPlaylistRulesV1 {
try {
return normalizeDynamicPlaylistRules(JSON.parse(raw));
} catch {
return createDefaultDynamicPlaylistRules();
}
}
async function candidatesForImport(entry: M3uEntry): Promise<DbTrack[]> {
const exact = await AstraLibraryData.getTrack<DbTrack>(entry.path).catch(() => null);
if (exact) return [exact];
const term = entry.title?.trim() || entry.path.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || '';
return term ? AstraLibraryData.searchTracks<DbTrack>(term, 50) : [];
}
export const usePlaylistStore = create<PlaylistStore>((set, get) => {
const reloadActive = async (db: LibraryDatabase) => {
const reloadActive = async () => {
const id = get().activePlaylistId;
if (id == null) return;
const activeEntries = await playlistDb.getPlaylistEntries(db, id);
set({ activeEntries });
const page = await AstraLibraryData.getPlaylistEntries<PlaylistTrackEntry>(
id,
0,
ENTRY_PAGE_SIZE
);
set({
activeEntries: page.items,
activeEntriesTotal: page.totalCount,
activeEntriesNextOffset: page.nextOffset,
});
};
const refreshWith = async (db: LibraryDatabase) => {
const [playlists, favoritePathList, favoriteTracks] = await Promise.all([
playlistDb.getPlaylists(db),
playlistDb.getFavoritePaths(db),
playlistDb.getFavoriteTracks(db),
const refreshAll = async () => {
const [playlists, favoritePaths, favoriteTracks] = await Promise.all([
AstraLibraryData.listPlaylists<Playlist>(),
AstraLibraryData.getFavoritePaths(),
AstraLibraryData.getFavoriteTracks<DbTrack>(500),
]);
set({ playlists, favoritePaths: new Set(favoritePathList), favoriteTracks });
await reloadActive(db);
set({ playlists, favoritePaths: new Set(favoritePaths), favoriteTracks });
await reloadActive();
};
return {
@@ -104,69 +126,86 @@ export const usePlaylistStore = create<PlaylistStore>((set, get) => {
favoriteTracks: [],
activePlaylistId: null,
activeEntries: [],
activeEntriesTotal: 0,
activeEntriesNextOffset: null,
refresh: async () => {
const db = await openLibraryDb();
await refreshWith(db);
},
refresh: refreshAll,
openPlaylist: async (id) => {
const db = await openLibraryDb();
const activeEntries = await playlistDb.getPlaylistEntries(db, id);
set({ activePlaylistId: id, activeEntries });
set({ activePlaylistId: id, activeEntries: [], activeEntriesNextOffset: 0 });
await reloadActive();
},
closePlaylist: () => set({ activePlaylistId: null, activeEntries: [] }),
loadNextEntries: async () => {
const id = get().activePlaylistId;
const offset = get().activeEntriesNextOffset;
if (id == null || offset == null) return;
const page = await AstraLibraryData.getPlaylistEntries<PlaylistTrackEntry>(
id,
offset,
ENTRY_PAGE_SIZE
);
set((state) => ({
activeEntries: [...state.activeEntries, ...page.items],
activeEntriesTotal: page.totalCount,
activeEntriesNextOffset: page.nextOffset,
}));
},
closePlaylist: () =>
set({
activePlaylistId: null,
activeEntries: [],
activeEntriesTotal: 0,
activeEntriesNextOffset: null,
}),
createPlaylist: async (name) => {
const db = await openLibraryDb();
const playlist = await playlistDb.createPlaylist(db, name);
await refreshWith(db);
const playlist = await AstraLibraryData.createPlaylist<Playlist>(name, 'normal', null);
await refreshAll();
return playlist;
},
createDynamicPlaylist: async (name, rules) => {
const db = await openLibraryDb();
const playlist = await playlistDb.createDynamicPlaylist(db, name, rules);
await refreshWith(db);
const normalized = normalizeDynamicPlaylistRules(rules);
const playlist = await AstraLibraryData.createPlaylist<Playlist>(
name,
'dynamic',
JSON.stringify(normalized)
);
await refreshAll();
return playlist;
},
getDynamicPlaylistRules: async (id) => {
const db = await openLibraryDb();
return playlistDb.getDynamicPlaylistRules(db, id);
},
getDynamicPlaylistRules: async (id) =>
parseRules(await AstraLibraryData.getDynamicPlaylistRules(id)),
updateDynamicPlaylistRules: async (id, rules) => {
const db = await openLibraryDb();
await playlistDb.updateDynamicPlaylistRules(db, id, rules);
await refreshWith(db);
await AstraLibraryData.updateDynamicPlaylistRules(
id,
JSON.stringify(normalizeDynamicPlaylistRules(rules))
);
await refreshAll();
},
previewDynamicPlaylist: async (rules) => {
const db = await openLibraryDb();
return playlistDb.previewDynamicPlaylist(db, rules);
},
previewDynamicPlaylist: async (rules) =>
AstraLibraryData.previewDynamicPlaylist<DynamicPlaylistPreview>(
JSON.stringify(normalizeDynamicPlaylistRules(rules))
),
renamePlaylist: async (id, name) => {
const db = await openLibraryDb();
await playlistDb.renamePlaylist(db, id, name);
await refreshWith(db);
await AstraLibraryData.renamePlaylist(id, name);
await refreshAll();
},
deletePlaylist: async (id) => {
const db = await openLibraryDb();
await playlistDb.deletePlaylist(db, id);
if (get().activePlaylistId === id) {
set({ activePlaylistId: null, activeEntries: [] });
}
await refreshWith(db);
await AstraLibraryData.deletePlaylist(id);
if (get().activePlaylistId === id) get().closePlaylist();
await refreshAll();
},
addTracksToPlaylist: async (id, tracks) => {
const db = await openLibraryDb();
const inserted = await playlistDb.addPlaylistEntries(
db,
const inserted = await AstraLibraryData.addPlaylistEntries(
id,
tracks.map((track) => ({
trackPath: track.path,
@@ -175,60 +214,42 @@ export const usePlaylistStore = create<PlaylistStore>((set, get) => {
fallbackAlbum: track.album,
}))
);
await refreshWith(db);
await refreshAll();
return inserted;
},
removeFromPlaylist: async (id, trackPath) => {
const db = await openLibraryDb();
await playlistDb.removeFromPlaylist(db, id, trackPath);
await refreshWith(db);
await AstraLibraryData.removePlaylistEntry(id, trackPath);
await refreshAll();
},
moveTrack: async (id, trackPath, direction) => {
const db = await openLibraryDb();
await playlistDb.movePlaylistTrack(db, id, trackPath, direction);
await refreshWith(db);
await AstraLibraryData.movePlaylistEntry(id, trackPath, direction);
await refreshAll();
},
toggleFavorite: async (track) => {
const wasFavorite = get().favoritePaths.has(track.path);
// Optimistic Set swap (always a fresh Set — never mutate in place).
const favorite = !get().favoritePaths.has(track.path);
const optimistic = new Set(get().favoritePaths);
if (wasFavorite) {
optimistic.delete(track.path);
} else {
optimistic.add(track.path);
}
if (favorite) optimistic.add(track.path);
else optimistic.delete(track.path);
set({ favoritePaths: optimistic });
const db = await openLibraryDb();
if (wasFavorite) {
await playlistDb.removeFavorite(db, track.path);
} else {
await playlistDb.addFavorite(db, track.path);
}
const [favoritePathList, favoriteTracks] = await Promise.all([
playlistDb.getFavoritePaths(db),
playlistDb.getFavoriteTracks(db),
await AstraLibraryData.setFavorite(track.path, favorite);
const [paths, tracks] = await Promise.all([
AstraLibraryData.getFavoritePaths(),
AstraLibraryData.getFavoriteTracks<DbTrack>(500),
]);
set({ favoritePaths: new Set(favoritePathList), favoriteTracks });
set({ favoritePaths: new Set(paths), favoriteTracks: tracks });
},
markPlayed: async (id) => {
const db = await openLibraryDb();
await playlistDb.markPlaylistPlayed(db, id);
const playlists = await playlistDb.getPlaylists(db);
set({ playlists });
await AstraLibraryData.markPlaylistPlayed(id);
set({ playlists: await AstraLibraryData.listPlaylists<Playlist>() });
},
importM3u: async () => {
const picked = await pickAndParseM3u();
if (!picked) return null;
const db = await openLibraryDb();
const index = buildImportIndex(await getAllTracks(db));
const summary: Omit<M3uImportSummary, 'playlistId'> = {
name: picked.name,
total: picked.entries.length,
@@ -237,15 +258,13 @@ export const usePlaylistStore = create<PlaylistStore>((set, get) => {
missing: 0,
ambiguous: 0,
};
const inserts: playlistDb.PlaylistEntryInsert[] = [];
const inserts: Parameters<typeof AstraLibraryData.addPlaylistEntries>[1] = [];
for (const entry of picked.entries) {
const match = matchImportEntry(entry, index);
const candidates = await candidatesForImport(entry);
const match = matchImportEntry(entry, buildImportIndex(candidates));
if (match.kind === 'matched') {
if (match.via === 'path') {
summary.matchedByPath += 1;
} else {
summary.matchedByMetadata += 1;
}
if (match.via === 'path') summary.matchedByPath += 1;
else summary.matchedByMetadata += 1;
inserts.push({
trackPath: match.track.path,
fallbackTitle: match.track.title,
@@ -253,7 +272,6 @@ export const usePlaylistStore = create<PlaylistStore>((set, get) => {
fallbackAlbum: match.track.album,
});
} else {
// Preserve unmatched entries as "missing" rows (desktop model).
summary.missing += 1;
if (match.kind === 'ambiguous') summary.ambiguous += 1;
inserts.push({
@@ -263,15 +281,13 @@ export const usePlaylistStore = create<PlaylistStore>((set, get) => {
});
}
}
const playlist = await playlistDb.createPlaylist(db, picked.name);
await playlistDb.addPlaylistEntries(db, playlist.id, inserts);
await refreshWith(db);
const playlist = await AstraLibraryData.createPlaylist<Playlist>(picked.name, 'normal', null);
await AstraLibraryData.addPlaylistEntries(playlist.id, inserts);
await refreshAll();
return { ...summary, playlistId: playlist.id };
},
exportM3u: async (target) => {
const db = await openLibraryDb();
let name: string;
let entries: M3uExportEntry[];
if (target === 'favorites') {
@@ -279,8 +295,19 @@ export const usePlaylistStore = create<PlaylistStore>((set, get) => {
entries = get().favoriteTracks.map(trackToExportEntry);
} else {
name = get().playlists.find((playlist) => playlist.id === target)?.name ?? 'Playlist';
const playlistEntries = await playlistDb.getPlaylistEntries(db, target);
entries = playlistEntries.map(entryToExportEntry);
const all: PlaylistTrackEntry[] = [];
let offset = 0;
while (true) {
const page = await AstraLibraryData.getPlaylistEntries<PlaylistTrackEntry>(
target,
offset,
200
);
all.push(...page.items);
if (page.nextOffset == null) break;
offset = page.nextOffset;
}
entries = all.map(entryToExportEntry);
}
return exportPlaylistM3u(name, entries);
},
+55 -67
View File
@@ -6,29 +6,15 @@
// registry (services/remoteConfig) so the library UI and playback can build URLs.
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { deleteRemoteTracksBySource } from '@/db/queries';
import {
deleteFavoritesByPathPrefix,
deleteRemotePlaylistsBySource,
} from '@/db/playlistQueries';
import { recomputeAlbumIdentity } from '@/library/albumIdentity';
import {
deleteRemoteSource,
getRemoteSource,
getRemoteSources,
insertRemoteSource,
setRemoteSourceArtAuth,
setRemoteSourceAuth,
setRemoteSourceStatus,
setRemoteSourceSynced,
updateRemoteSource,
} from '@/db/remoteSourceQueries';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import { buildCoverArtUrlTemplate } from '@/services/remoteUrls';
import {
deleteRemoteSecret,
getRemoteSecret,
getRemoteSecureAuth,
setRemoteArtAuth,
setRemoteSecret,
setRemoteSecureAuth,
} from '@/services/remoteCredentials';
import {
clearResolvedRemoteConfig,
@@ -60,7 +46,10 @@ function errorMessage(error: unknown): string {
/** Hydrate the synchronous URL-building registry for one source (loads its secret). */
async function hydrateRegistry(source: RemoteSourceRow): Promise<RemoteConnectionConfig | null> {
const password = await getRemoteSecret(source.id);
const [password, auth] = await Promise.all([
getRemoteSecret(source.id),
getRemoteSecureAuth(source.id),
]);
if (password == null) return null;
setResolvedRemoteConfig({
id: source.id,
@@ -68,8 +57,8 @@ async function hydrateRegistry(source: RemoteSourceRow): Promise<RemoteConnectio
baseUrl: source.base_url,
username: source.username,
password,
accessToken: source.access_token ?? undefined,
userId: source.user_id ?? undefined,
accessToken: auth.accessToken ?? undefined,
userId: auth.userId ?? undefined,
});
await persistArtAuthIfNeeded(source);
return { baseUrl: source.base_url, username: source.username, password };
@@ -82,11 +71,10 @@ async function hydrateRegistry(source: RemoteSourceRow): Promise<RemoteConnectio
* Requires the source's config to already be in the registry.
*/
async function persistArtAuthIfNeeded(source: RemoteSourceRow): Promise<void> {
if (source.art_auth) return;
if ((await getRemoteSecureAuth(source.id)).artAuth) return;
const template = buildCoverArtUrlTemplate(source.id);
if (!template) return;
const db = await openLibraryDb();
await setRemoteSourceArtAuth(db, source.id, template);
await setRemoteArtAuth(source.id, template);
}
/** Ensure a usable Jellyfin token, authenticating + persisting it if missing. */
@@ -94,12 +82,12 @@ async function ensureJellyfinAuth(
source: RemoteSourceRow,
config: RemoteConnectionConfig
): Promise<JellyfinAuthContext> {
if (source.access_token && source.user_id) {
return { accessToken: source.access_token, userId: source.user_id };
const cached = await getRemoteSecureAuth(source.id);
if (cached.accessToken && cached.userId) {
return { accessToken: cached.accessToken, userId: cached.userId };
}
const auth = await authenticateJellyfin(config);
const db = await openLibraryDb();
await setRemoteSourceAuth(db, source.id, {
await setRemoteSecureAuth(source.id, {
accessToken: auth.accessToken,
userId: auth.userId,
deviceId: buildJellyfinDeviceId(config),
@@ -107,7 +95,7 @@ async function ensureJellyfinAuth(
updateResolvedRemoteAuth(source.id, auth);
// Token (re)issued — refresh the native Auto cover-art template so it isn't stale.
const artTemplate = buildCoverArtUrlTemplate(source.id);
if (artTemplate) await setRemoteSourceArtAuth(db, source.id, artTemplate);
if (artTemplate) await setRemoteArtAuth(source.id, artTemplate);
return auth;
}
@@ -128,6 +116,7 @@ interface RemoteSourcesStore {
}
let initPromise: Promise<void> | null = null;
let recoverySubscriptionInstalled = false;
export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
sources: [],
@@ -138,11 +127,21 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
if (get().initialized) return Promise.resolve();
if (!initPromise) {
initPromise = (async () => {
const db = await openLibraryDb();
const sources = await getRemoteSources(db);
const sources = await AstraLibraryData.listRemoteSources<RemoteSourceRow>();
// Populate the URL registry from cached config/token (no network on launch).
await Promise.all(sources.filter((s) => s.enabled).map((s) => hydrateRegistry(s)));
set({ sources, initialized: true });
if (!recoverySubscriptionInstalled) {
recoverySubscriptionInstalled = true;
AstraLibraryData.addListener('onLibraryStatus', (status) => {
if (status.status === 'rebuilding') {
void get().syncAll();
}
});
}
if (AstraLibraryData.getCurrentStatus().status === 'rebuilding') {
void get().syncAll();
}
// The library's initial refresh may have run before the registry was hydrated,
// leaving remote artwork URLs unresolved — refresh once more now that it's ready.
if (sources.length > 0) {
@@ -157,8 +156,7 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
},
refresh: async () => {
const db = await openLibraryDb();
set({ sources: await getRemoteSources(db) });
set({ sources: await AstraLibraryData.listRemoteSources<RemoteSourceRow>() });
},
testSource: async (input) => {
@@ -180,7 +178,6 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
},
createSource: async (input) => {
const db = await openLibraryDb();
const config: RemoteConnectionConfig = {
baseUrl: input.baseUrl,
username: input.username,
@@ -195,17 +192,17 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
auth = await authenticateJellyfin(config);
}
const row = await insertRemoteSource(db, {
type: input.type,
name: input.name,
baseUrl: input.baseUrl,
username: input.username,
enabled: input.enabled,
});
const row = await AstraLibraryData.createRemoteSource<RemoteSourceRow>(
input.type,
input.name,
input.baseUrl,
input.username,
input.enabled
);
await setRemoteSecret(row.id, input.password);
if (auth) {
await setRemoteSourceAuth(db, row.id, {
await setRemoteSecureAuth(row.id, {
accessToken: auth.accessToken,
userId: auth.userId,
deviceId: buildJellyfinDeviceId(config),
@@ -230,11 +227,10 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
},
updateSource: async (id, input) => {
const db = await openLibraryDb();
const existing = await getRemoteSource(db, id);
const existing = await AstraLibraryData.getRemoteSource<RemoteSourceRow>(id);
if (!existing) return;
await updateRemoteSource(db, id, {
await AstraLibraryData.updateRemoteSource(id, {
name: input.name,
base_url: input.baseUrl,
username: input.username,
@@ -244,33 +240,26 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
await setRemoteSecret(id, input.password);
}
const updated = await getRemoteSource(db, id);
const updated = await AstraLibraryData.getRemoteSource<RemoteSourceRow>(id);
if (updated) {
// Connection details may have changed → drop cached token + cover-art template,
// re-hydrate registry (which regenerates the template from the new credentials).
if (input.baseUrl || input.username || input.password) {
await setRemoteSourceAuth(db, id, { accessToken: null, userId: null, deviceId: null });
await setRemoteSourceArtAuth(db, id, null);
await setRemoteSecureAuth(id, {
accessToken: null,
userId: null,
deviceId: null,
});
await setRemoteArtAuth(id, null);
}
const fresh = (await getRemoteSource(db, id)) ?? updated;
const fresh = (await AstraLibraryData.getRemoteSource<RemoteSourceRow>(id)) ?? updated;
await hydrateRegistry(fresh);
}
await get().refresh();
},
deleteSource: async (id, purgeTracks) => {
const db = await openLibraryDb();
const source = await getRemoteSource(db, id);
if (purgeTracks && source) {
await deleteRemoteTracksBySource(db, source.type, id);
// Drop this source's synced playlists + favorites (favorites key on the
// `${type}://${id}/` path prefix).
await deleteRemotePlaylistsBySource(db, id);
await deleteFavoritesByPathPrefix(db, `${source.type}://${id}/`);
// Removals can regroup albums (compilation heuristic is cross-track).
await recomputeAlbumIdentity(db);
}
await deleteRemoteSource(db, id);
await AstraLibraryData.deleteRemoteSource(id, purgeTracks);
await deleteRemoteSecret(id);
clearResolvedRemoteConfig(id);
await get().refresh();
@@ -280,14 +269,13 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
},
syncSource: async (id) => {
const db = await openLibraryDb();
const source = await getRemoteSource(db, id);
const source = await AstraLibraryData.getRemoteSource<RemoteSourceRow>(id);
if (!source) return;
if (get().progressById[id]) return; // already syncing
const config = await hydrateRegistry(source);
if (!config) {
await setRemoteSourceStatus(db, id, 'error', 'Missing stored password.');
await AstraLibraryData.setRemoteSourceStatus(id, 'error', 'Missing stored password.');
await get().refresh();
return;
}
@@ -304,11 +292,11 @@ export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
if (source.type === 'jellyfin') {
authContext = await ensureJellyfinAuth(source, config);
}
await syncRemoteSource(db, source, config, { onProgress, authContext });
await setRemoteSourceSynced(db, id);
await syncRemoteSource(source, config, { onProgress, authContext });
await AstraLibraryData.setRemoteSourceStatus(id, 'ok', null);
await useLibraryStore.getState().refresh();
} catch (error) {
await setRemoteSourceStatus(db, id, 'error', errorMessage(error));
await AstraLibraryData.setRemoteSourceStatus(id, 'error', errorMessage(error));
} finally {
set((state) => ({ progressById: { ...state.progressById, [id]: null } }));
await get().refresh();
+27 -37
View File
@@ -1,6 +1,5 @@
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
import {
parseNowPlayingCompanion,
@@ -87,26 +86,25 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const [
grouping,
includeSingles,
scope,
scopeStageVisible,
scopeStyle,
lyricsVisible,
nowPlayingCompanion,
homeGreetingTextMode,
] = await Promise.all([
getSetting(db, ARTIST_GROUPING_KEY),
getSetting(db, INCLUDE_SINGLES_KEY),
getSetting(db, SCOPE_MODE_KEY),
getSetting(db, SCOPE_STAGE_VISIBLE_KEY),
getSetting(db, SCOPE_STYLE_KEY),
getSetting(db, LYRICS_VISIBLE_KEY),
getSetting(db, NOW_PLAYING_COMPANION_KEY),
getSetting(db, HOME_GREETING_TEXT_MODE_KEY),
await AstraLibraryData.initialize();
const values = await AstraLibraryData.getSettings([
ARTIST_GROUPING_KEY,
INCLUDE_SINGLES_KEY,
SCOPE_MODE_KEY,
SCOPE_STAGE_VISIBLE_KEY,
SCOPE_STYLE_KEY,
LYRICS_VISIBLE_KEY,
NOW_PLAYING_COMPANION_KEY,
HOME_GREETING_TEXT_MODE_KEY,
]);
const grouping = values[ARTIST_GROUPING_KEY] ?? null;
const includeSingles = values[INCLUDE_SINGLES_KEY] ?? null;
const scope = values[SCOPE_MODE_KEY] ?? null;
const scopeStageVisible = values[SCOPE_STAGE_VISIBLE_KEY] ?? null;
const scopeStyle = values[SCOPE_STYLE_KEY] ?? null;
const lyricsVisible = values[LYRICS_VISIBLE_KEY] ?? null;
const nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null;
const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null;
set({
artistGroupingMode: parseGroupingMode(grouping),
includeSingles: parseBoolean(includeSingles),
@@ -123,57 +121,49 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
setArtistGroupingMode: async (mode) => {
if (get().artistGroupingMode === mode) return;
set({ artistGroupingMode: mode });
const db = await openLibraryDb();
await setSetting(db, ARTIST_GROUPING_KEY, mode);
await AstraLibraryData.setSettings({ [ARTIST_GROUPING_KEY]: mode });
},
setIncludeSingles: async (include) => {
if (get().includeSingles === include) return;
set({ includeSingles: include });
const db = await openLibraryDb();
await setSetting(db, INCLUDE_SINGLES_KEY, include ? 'true' : 'false');
await AstraLibraryData.setSettings({ [INCLUDE_SINGLES_KEY]: include ? 'true' : 'false' });
},
setScopeMode: async (mode) => {
if (get().scopeMode === mode) return;
set({ scopeMode: mode });
const db = await openLibraryDb();
await setSetting(db, SCOPE_MODE_KEY, mode);
await AstraLibraryData.setSettings({ [SCOPE_MODE_KEY]: mode });
},
setScopeStageVisible: async (visible) => {
if (get().scopeStageVisible === visible) return;
set({ scopeStageVisible: visible });
const db = await openLibraryDb();
await setSetting(db, SCOPE_STAGE_VISIBLE_KEY, visible ? 'true' : 'false');
await AstraLibraryData.setSettings({ [SCOPE_STAGE_VISIBLE_KEY]: visible ? 'true' : 'false' });
},
setNowPlayingScopeStyle: async (style) => {
if (get().nowPlayingScopeStyle === style) return;
set({ nowPlayingScopeStyle: style });
const db = await openLibraryDb();
await setSetting(db, SCOPE_STYLE_KEY, style);
await AstraLibraryData.setSettings({ [SCOPE_STYLE_KEY]: style });
},
setLyricsVisible: async (visible) => {
if (get().lyricsVisible === visible) return;
set({ lyricsVisible: visible });
const db = await openLibraryDb();
await setSetting(db, LYRICS_VISIBLE_KEY, visible ? 'true' : 'false');
await AstraLibraryData.setSettings({ [LYRICS_VISIBLE_KEY]: visible ? 'true' : 'false' });
},
setNowPlayingCompanion: async (companion) => {
if (get().nowPlayingCompanion === companion) return;
set({ nowPlayingCompanion: companion });
const db = await openLibraryDb();
await setSetting(db, NOW_PLAYING_COMPANION_KEY, companion);
await AstraLibraryData.setSettings({ [NOW_PLAYING_COMPANION_KEY]: companion });
},
setHomeGreetingTextMode: async (mode) => {
const nextMode = parseHomeGreetingTextMode(mode);
if (get().homeGreetingTextMode === nextMode) return;
set({ homeGreetingTextMode: nextMode });
const db = await openLibraryDb();
await setSetting(db, HOME_GREETING_TEXT_MODE_KEY, nextMode);
await AstraLibraryData.setSettings({ [HOME_GREETING_TEXT_MODE_KEY]: nextMode });
},
}));
+3 -6
View File
@@ -1,7 +1,6 @@
import TrackPlayer from 'react-native-track-player';
import { create } from 'zustand';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings';
import { setPauseAtEndOfItem } from '@/audio/trackPlayerExtensions';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import {
@@ -37,8 +36,7 @@ function clearDeadlineTimer(): void {
}
async function persistTimer(timer: PersistedSleepTimerState | null): Promise<void> {
const db = await openLibraryDb();
await setSetting(db, SLEEP_TIMER_KEY, timer ? JSON.stringify(timer) : '');
await setNativeSetting(SLEEP_TIMER_KEY, timer ? JSON.stringify(timer) : '');
}
async function hasActivePhoneTrack(): Promise<boolean> {
@@ -77,8 +75,7 @@ export const useSleepTimerStore = create<SleepTimerStore>((set, get) => ({
if (get().hydrated) return;
if (hydrationPromise) return hydrationPromise;
hydrationPromise = (async () => {
const db = await openLibraryDb();
const raw = await getSetting(db, SLEEP_TIMER_KEY);
const raw = await getNativeSetting(SLEEP_TIMER_KEY);
let parsed: unknown = null;
try {
parsed = raw ? JSON.parse(raw) : null;
+7 -12
View File
@@ -1,8 +1,7 @@
import { Appearance } from 'react-native';
import { create } from 'zustand';
import { AstraSystemColors, type SystemPalette } from '../../modules/astra-system-colors';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings';
import { parseAccentId, DEFAULT_ACCENT, type AccentId } from '@/theme/accents';
import {
parseBaseTheme,
@@ -71,11 +70,10 @@ export const useThemeStore = create<ThemeStore>((set, get) => ({
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const [base, dark, accent] = await Promise.all([
getSetting(db, BASE_THEME_KEY),
getSetting(db, PREFERRED_DARK_KEY),
getSetting(db, ACCENT_KEY),
getNativeSetting(BASE_THEME_KEY),
getNativeSetting(PREFERRED_DARK_KEY),
getNativeSetting(ACCENT_KEY),
]);
if (get().materialYouAvailable) {
materialYouRamps = AstraSystemColors.getSystemPalette();
@@ -93,24 +91,21 @@ export const useThemeStore = create<ThemeStore>((set, get) => ({
if (get().baseTheme === id) return;
const inputs: ResolutionInputs = { ...get(), baseTheme: id };
set({ baseTheme: id, theme: recompute(inputs) });
const db = await openLibraryDb();
await setSetting(db, BASE_THEME_KEY, id);
await setNativeSetting(BASE_THEME_KEY, id);
},
setPreferredDark: async (id) => {
if (get().preferredDark === id) return;
const inputs: ResolutionInputs = { ...get(), preferredDark: id };
set({ preferredDark: id, theme: recompute(inputs) });
const db = await openLibraryDb();
await setSetting(db, PREFERRED_DARK_KEY, id);
await setNativeSetting(PREFERRED_DARK_KEY, id);
},
setAccent: async (id) => {
if (get().accentId === id) return;
const inputs: ResolutionInputs = { ...get(), accentId: id };
set({ accentId: id, theme: recompute(inputs) });
const db = await openLibraryDb();
await setSetting(db, ACCENT_KEY, id);
await setNativeSetting(ACCENT_KEY, id);
},
refreshSystemInputs: () => {