artist image search

This commit is contained in:
Boof2015
2026-07-31 01:26:36 -04:00
parent fe7c5a70a4
commit a42e78017c
44 changed files with 4705 additions and 112 deletions
+51
View File
@@ -0,0 +1,51 @@
import { create } from 'zustand';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import { useSettingsStore } from './settingsStore';
interface ArtistImageState {
/** True while a sweep is draining the queue. */
running: boolean;
/** Artists resolved so far in the current sweep — one per provider request. */
processed: number;
/** Artists queued when the sweep started. 0 means "not counted yet". */
total: number;
/** Artists with no portrait from any source, refreshed when a sweep settles. */
missing: number;
beginSweep: (total: number) => void;
advanceSweep: (by?: number) => void;
endSweep: () => void;
refreshMissing: () => Promise<void>;
}
/**
* Observable progress for the artist-image sweep. The coordinator itself is a
* plain module (it runs without any React tree mounted), so it pushes into this
* store rather than owning the state — Settings just subscribes.
*/
export const useArtistImageStore = create<ArtistImageState>((set, get) => ({
running: false,
processed: 0,
total: 0,
missing: 0,
beginSweep: (total) => set({ running: true, processed: 0, total }),
advanceSweep: (by = 1) => set((state) => ({ processed: state.processed + by })),
endSweep: () => {
set({ running: false, processed: 0, total: 0 });
void get().refreshMissing();
},
refreshMissing: async () => {
try {
const stats = await AstraLibraryData.getArtistImageStats(
useSettingsStore.getState().artistGroupingMode,
Date.now()
);
set({ missing: stats.missing });
} catch {
// A stale count is not worth surfacing an error for.
}
},
}));
+13
View File
@@ -17,6 +17,7 @@ import {
type ScanResult,
} from '@/library/scanner';
import { endScanService, reportScanProgress } from '@/library/scanService';
import { requeueMissingArtistImages } from '@/library/artistImageLookup';
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';
@@ -191,6 +192,7 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
artists: 0,
};
let anchorGeneration = 0;
let artistImageRefreshTimer: ReturnType<typeof setTimeout> | null = null;
// Re-entrancy guards, per list and per direction, so a backward refill, a forward
// page and a different view's load never block one another — the single shared flag
@@ -391,6 +393,10 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
} finally {
try {
await get().refresh();
// A scan is the user asking Astra to look at their library again, so it
// also re-opens artist-image lookups that previously found no match.
// New artists queue on their own; these would never retry otherwise.
await requeueMissingArtistImages();
} finally {
if (activeScanCancellation === cancellation) activeScanCancellation = null;
set({
@@ -490,6 +496,13 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
set({ sectionAnchors: [] });
void get().refresh();
});
AstraLibraryData.addListener('onArtistImagesChanged', () => {
if (artistImageRefreshTimer) clearTimeout(artistImageRefreshTimer);
artistImageRefreshTimer = setTimeout(() => {
artistImageRefreshTimer = null;
void get().refresh();
}, 300);
});
useSettingsStore.subscribe((next, previous) => {
if (next.artistGroupingMode !== previous.artistGroupingMode) {
anchorGeneration += 1;
+39
View File
@@ -14,6 +14,7 @@ import {
resumeListeningHistoryTracking,
} from '@/audio/listeningHistoryTracker';
import { notifyListeningHistoryChanged } from '@/listeningStats/events';
import type { ArtistImageAutoPolicy } from '@/types/artistImages';
/**
* Persisted app preferences. SQLite (settings table) is the source of truth — this
@@ -29,6 +30,8 @@ const LYRICS_VISIBLE_KEY = 'lyrics_visible';
const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion';
const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode';
const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled';
const ARTIST_IMAGE_AUTO_POLICY_KEY = 'artist_image_auto_policy';
const ARTIST_IMAGE_DISCLOSURE_KEY = 'artist_image_disclosure_seen';
/** Which visualizer the now-playing scope stage shows. */
export type ScopeMode = 'spectrum' | 'scope';
@@ -56,6 +59,10 @@ function parseBoolean(value: string | null): boolean {
return value === 'true';
}
function parseArtistImageAutoPolicy(value: string | null): ArtistImageAutoPolicy {
return value === 'off' || value === 'any' ? value : 'wifi';
}
interface SettingsStore {
artistGroupingMode: ArtistGroupingMode;
/** Show 1-track albums in the Albums view (desktop parity default: hidden). */
@@ -68,6 +75,8 @@ interface SettingsStore {
nowPlayingCompanion: NowPlayingCompanion;
homeGreetingTextMode: HomeGreetingTextMode;
listeningHistoryEnabled: boolean;
artistImageAutoPolicy: ArtistImageAutoPolicy;
artistImageDisclosureSeen: boolean;
loaded: boolean;
load: () => Promise<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
@@ -79,6 +88,8 @@ interface SettingsStore {
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>;
setListeningHistoryEnabled: (enabled: boolean) => Promise<void>;
setArtistImageAutoPolicy: (policy: ArtistImageAutoPolicy) => Promise<void>;
acknowledgeArtistImageDisclosure: () => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@@ -91,6 +102,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
nowPlayingCompanion: 'queue',
homeGreetingTextMode: 'messages',
listeningHistoryEnabled: true,
artistImageAutoPolicy: 'wifi',
artistImageDisclosureSeen: false,
loaded: false,
load: async () => {
@@ -106,6 +119,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
NOW_PLAYING_COMPANION_KEY,
HOME_GREETING_TEXT_MODE_KEY,
LISTENING_HISTORY_ENABLED_KEY,
ARTIST_IMAGE_AUTO_POLICY_KEY,
ARTIST_IMAGE_DISCLOSURE_KEY,
]);
const grouping = values[ARTIST_GROUPING_KEY] ?? null;
const includeSingles = values[INCLUDE_SINGLES_KEY] ?? null;
@@ -116,6 +131,10 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
const nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null;
const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null;
const listeningHistoryEnabled = values[LISTENING_HISTORY_ENABLED_KEY] !== '0';
const artistImageAutoPolicy = parseArtistImageAutoPolicy(
values[ARTIST_IMAGE_AUTO_POLICY_KEY] ?? null
);
const artistImageDisclosureSeen = values[ARTIST_IMAGE_DISCLOSURE_KEY] === '1';
set({
artistGroupingMode: parseGroupingMode(grouping),
includeSingles: parseBoolean(includeSingles),
@@ -126,6 +145,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode),
listeningHistoryEnabled,
artistImageAutoPolicy,
artistImageDisclosureSeen,
loaded: true,
});
},
@@ -194,4 +215,22 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
throw error;
}
},
setArtistImageAutoPolicy: async (policy) => {
const previous = get().artistImageAutoPolicy;
if (previous === policy) return;
set({ artistImageAutoPolicy: policy });
try {
await AstraLibraryData.setSettings({ [ARTIST_IMAGE_AUTO_POLICY_KEY]: policy });
} catch (error) {
set({ artistImageAutoPolicy: previous });
throw error;
}
},
acknowledgeArtistImageDisclosure: async () => {
if (get().artistImageDisclosureSeen) return;
await AstraLibraryData.setSettings({ [ARTIST_IMAGE_DISCLOSURE_KEY]: '1' });
set({ artistImageDisclosureSeen: true });
},
}));