port desktop's search engine

This commit is contained in:
Boof2015
2026-06-28 09:46:08 -04:00
parent 3ec3f5ecf2
commit dcdddb350d
19 changed files with 2944 additions and 223 deletions
+91
View File
@@ -0,0 +1,91 @@
// Persistence for the Last.fm service config. The desktop service serializes the
// whole `LastFmServiceConfig` (including each profile's offline queue) to a JSON
// config file via its `onConfigChange` callback. On mobile we do the same, but:
// - the config JSON (profiles + pending scrobbles + flags) → settings KV table
// - each profile's `sessionKey` → expo-secure-store (stripped from the JSON)
// On load we re-attach the session keys before handing the config to the service,
// so the ported service code (which reads `profile.sessionKey`) is unchanged.
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import type { LastFmServiceConfig } from '@/types/lastFm';
import {
deleteLastFmSessionKey,
getLastFmSessionKey,
setLastFmSessionKey,
} from './credentials';
const CONFIG_KEY = 'lastfm_config';
// Tracks which profile ids currently hold a secret, so a removed profile's
// session key can be purged from secure-store on the next persist.
const SECRET_IDS_KEY = 'lastfm_secret_profile_ids';
function parseStringArray(value: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
/** Load the persisted config (with session keys re-attached), or null if none. */
export async function loadLastFmConfig(): Promise<LastFmServiceConfig | null> {
const db = await openLibraryDb();
const json = await getSetting(db, CONFIG_KEY);
if (!json) return null;
let parsed: LastFmServiceConfig;
try {
parsed = JSON.parse(json) as LastFmServiceConfig;
} catch {
return null;
}
if (!parsed || !Array.isArray(parsed.profiles)) return null;
await Promise.all(
parsed.profiles.map(async (profile) => {
if (profile && typeof profile.id === 'string') {
profile.sessionKey = await getLastFmSessionKey(profile.id);
}
})
);
return parsed;
}
/** Persist the config: secrets to secure-store, everything else to the settings KV. */
export async function persistLastFmConfig(config: LastFmServiceConfig): Promise<void> {
const db = await openLibraryDb();
const previousSecretIds = parseStringArray(await getSetting(db, SECRET_IDS_KEY));
const currentSecretIds: string[] = [];
for (const profile of config.profiles) {
if (profile.sessionKey) {
await setLastFmSessionKey(profile.id, profile.sessionKey);
currentSecretIds.push(profile.id);
} else {
await deleteLastFmSessionKey(profile.id);
}
}
// Purge secrets for profiles that no longer exist (e.g. deleted custom profile).
for (const id of previousSecretIds) {
if (!config.profiles.some((profile) => profile.id === id)) {
await deleteLastFmSessionKey(id);
}
}
await setSetting(db, SECRET_IDS_KEY, JSON.stringify(currentSecretIds));
const sanitized: LastFmServiceConfig = {
enabled: config.enabled,
activeProfileId: config.activeProfileId,
profiles: config.profiles.map((profile) => ({
...profile,
sessionKey: null, // never written to plaintext SQLite
pendingScrobbles: profile.pendingScrobbles.map((item) => ({ ...item })),
})),
};
await setSetting(db, CONFIG_KEY, JSON.stringify(sanitized));
}
+13
View File
@@ -0,0 +1,13 @@
// Last.fm API credentials. Desktop reads these from `LASTFM_API_KEY` /
// `LASTFM_SHARED_SECRET` env vars (src/main/index.ts). Expo inlines `EXPO_PUBLIC_*`
// vars at build time, so put your registered Last.fm API application's key + secret
// in a (gitignored) `.env` file — see `.env.example`. Without them, the official
// Last.fm protocol is disabled (custom/AudioScrobbler/ListenBrainz still work, as
// they sign with their own credentials).
//
// Note: like every Last.fm desktop/mobile client, the "shared secret" ships inside
// the app. Last.fm's auth model accepts this — the session key (obtained per user
// via browser approval) is what authorizes scrobbles, and it lives in secure-store.
export const LASTFM_API_KEY = (process.env.EXPO_PUBLIC_LASTFM_API_KEY ?? '').trim();
export const LASTFM_SHARED_SECRET = (process.env.EXPO_PUBLIC_LASTFM_SHARED_SECRET ?? '').trim();
+25
View File
@@ -0,0 +1,25 @@
// Per-profile Last.fm session keys / tokens live in the Android Keystore
// (expo-secure-store), keyed by profile id — mirroring the M5 remote-source
// password pattern (src/services/remoteCredentials.ts). The rest of the scrobble
// config (profiles, offline queue) is plain JSON in the settings table; only the
// secret leaves SQLite.
import * as SecureStore from 'expo-secure-store';
function secretKey(profileId: string): string {
// SecureStore keys must be alphanumeric + ".-_" — sanitize the profile id.
const safe = profileId.replace(/[^a-zA-Z0-9._-]/g, '_');
return `lastfm_session_${safe}`;
}
export async function getLastFmSessionKey(profileId: string): Promise<string | null> {
return SecureStore.getItemAsync(secretKey(profileId));
}
export async function setLastFmSessionKey(profileId: string, sessionKey: string): Promise<void> {
await SecureStore.setItemAsync(secretKey(profileId), sessionKey);
}
export async function deleteLastFmSessionKey(profileId: string): Promise<void> {
await SecureStore.deleteItemAsync(secretKey(profileId));
}
+87
View File
@@ -0,0 +1,87 @@
// Module-singleton wiring for the Last.fm scrobble service. Replaces the desktop
// main/index.ts wiring (env key/secret, shell.openExternal, config persistence,
// status broadcast) — but in-process, since mobile has no main/renderer split.
//
// The settings store registers a status listener via `setLastFmStatusListener`;
// the feed hook (useLastFmScrobbler) calls `publishLastFmSnapshot` / `requestLastFmFlush`.
import * as WebBrowser from 'expo-web-browser';
import {
LASTFM_OFFICIAL_PROFILE_ID,
type LastFmServiceConfig,
type LastFmStatus,
} from '@/types/lastFm';
import { LASTFM_API_KEY, LASTFM_SHARED_SECRET } from './constants';
import { loadLastFmConfig, persistLastFmConfig } from './config';
import { LastFmService, type ScrobbleSnapshot } from './scrobbleService';
let service: LastFmService | null = null;
let initPromise: Promise<LastFmService> | null = null;
let statusListener: ((status: LastFmStatus) => void) | null = null;
let lastStatus: LastFmStatus | null = null;
const DEFAULT_CONFIG: LastFmServiceConfig = {
enabled: false,
activeProfileId: LASTFM_OFFICIAL_PROFILE_ID,
profiles: [],
};
/**
* Register the single status listener (the settings store). Immediately replays
* the most recent status so a late subscriber isn't stuck on null.
*/
export function setLastFmStatusListener(fn: ((status: LastFmStatus) => void) | null): void {
statusListener = fn;
if (fn && lastStatus) fn(lastStatus);
}
/** Construct + start the service once, loading persisted config. Idempotent. */
export function initLastFmService(): Promise<LastFmService> {
if (service) return Promise.resolve(service);
if (initPromise) return initPromise;
initPromise = (async () => {
const stored = await loadLastFmConfig().catch(() => null);
const instance = new LastFmService({
config: stored ?? DEFAULT_CONFIG,
apiKey: LASTFM_API_KEY,
sharedSecret: LASTFM_SHARED_SECRET,
// Fire-and-forget: openBrowserAsync resolves only when the tab is dismissed,
// so don't await it — beginAuth must return immediately to start auth polling.
openExternal: async (url: string) => {
void WebBrowser.openBrowserAsync(url);
},
onConfigChange: async (config) => {
await persistLastFmConfig(config);
},
onStatusChange: (status) => {
lastStatus = status;
statusListener?.(status);
},
});
service = instance;
lastStatus = instance.getStatus();
instance.start(); // drain any persisted offline queue on launch
return instance;
})();
return initPromise;
}
/** The live service. Throws if accessed before `initLastFmService` resolves. */
export function getLastFmService(): LastFmService {
if (!service) {
throw new Error('Last.fm service not initialized — call initLastFmService() first.');
}
return service;
}
/** Feed a playback snapshot to the timing state machine (no-op until initialized). */
export function publishLastFmSnapshot(snapshot: ScrobbleSnapshot | null): void {
service?.publishSnapshot(snapshot);
}
/** Ask the service to attempt an offline-queue flush now (foreground/connectivity resume). */
export function requestLastFmFlush(): void {
service?.requestFlush();
}
Binary file not shown.