mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 12:40:15 +02:00
initial db rewrite
This commit is contained in:
@@ -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: () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 } })),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user