mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 19:54:26 +02:00
port desktop's search engine
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.
Reference in New Issue
Block a user