initial db rewrite

This commit is contained in:
Boof2015
2026-07-24 02:18:37 -04:00
parent 26fba836f4
commit a10ecf4a0f
90 changed files with 12669 additions and 5173 deletions
+176 -159
View File
@@ -14,29 +14,7 @@
// DesktopSyncPlaylistConflict for the user to resolve. Timestamp LWW remains
// the fallback for pairs without a baseline yet.
import { openLibraryDb } from '@/db/database';
import { getAllTracks, setSetting } from '@/db/queries';
import { renamePlaylist } from '@/db/playlistQueries';
import {
adoptPlaylistSyncUid,
applySyncedFavoriteAdd,
applySyncedFavoriteRemove,
applySyncedPlaylistDelete,
clonePlaylistAsLocalCopy,
deletePlaylistSyncBaseline,
ensurePlaylistSyncUids,
getLocalSyncState,
getPlaylistSyncBaselines,
getSyncPlaylistEntries,
removeFavoriteTombstone,
removePlaylistTombstone,
replaceSyncedPlaylist,
resolvePendingFavorites,
upsertPendingFavorite,
upsertPlaylistSyncBaseline,
type LocalSyncPlaylist,
} from '@/db/desktopSyncQueries';
import { buildImportIndex, matchSyncEntry } from '@/library/playlistFiles';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import { normalizeSyncKeyPart } from '@/shared/sync/identity';
import { syncPlaylistToSnapshot } from '@/shared/sync/conflictPreview';
import { normalizeDynamicPlaylistRules } from '@/shared/playlists/dynamicPlaylist';
@@ -70,6 +48,48 @@ import { ensureDesktopRemoteCredentialsFresh } from './desktopRemoteSession';
const CLOCK_SKEW_WARN_MS = 5 * 60_000;
interface NativeLocalSyncFavorite extends SyncFavorite {
trackPaths: string[];
pending: boolean;
}
interface NativeLocalSyncPlaylist extends SyncPlaylist {
id: number;
}
interface NativeLocalSyncState {
favorites: NativeLocalSyncFavorite[];
favoriteTombstones: { key: string; deletedAt: number }[];
playlists: NativeLocalSyncPlaylist[];
playlistTombstones: { syncUid: string; deletedAt: number }[];
baselines: { syncUid: string; localUpdatedAt: number; remoteUpdatedAt: number }[];
}
interface DesktopSyncMutationPlan {
settings: Record<string, string>;
favoriteAdds: SyncFavorite[];
favoriteRemoves: (SyncFavorite & { trackPaths: string[]; deletedAt: number })[];
favoriteTombstoneRemovals: string[];
playlistAdoptions: { playlistId: number; syncUid: string }[];
playlistUpserts: SyncPlaylist[];
playlistDeletes: { syncUid: string; deletedAt: number }[];
playlistTombstoneRemovals: string[];
baselineUpserts: { syncUid: string; localUpdatedAt: number; remoteUpdatedAt: number }[];
baselineDeletes: string[];
}
interface NativeDesktopSyncApplyResult {
favoritesAdded: number;
favoritesPending: number;
favoritesRemoved: number;
playlistResults: {
syncUid: string;
status: 'created' | 'replaced' | 'deleted' | 'skipped-incompatible';
entriesMatched: number;
entriesFallback: number;
}[];
}
/** The paired desktop runs a protocol without /v1/sync/* — needs an update. */
export class DesktopSyncUnsupportedError extends Error {
constructor() {
@@ -214,11 +234,17 @@ async function runDesktopSyncOnce(): Promise<{
throw new DesktopSyncUnsupportedError();
}
const db = await openLibraryDb();
await ensurePlaylistSyncUids(db);
const index = buildImportIndex(await getAllTracks(db));
await resolvePendingFavorites(db, index);
const local = await getLocalSyncState(db);
const localState = await AstraLibraryData.getDesktopSyncState<NativeLocalSyncState>();
const local = {
favorites: new Map(localState.favorites.map((favorite) => [favorite.key, favorite])),
favoriteTombstones: new Map(
localState.favoriteTombstones.map((tombstone) => [tombstone.key, tombstone.deletedAt])
),
playlists: localState.playlists,
playlistTombstones: new Map(
localState.playlistTombstones.map((tombstone) => [tombstone.syncUid, tombstone.deletedAt])
),
};
const remote = await fetchDesktopSyncState(connection.baseUrl, token, connection.certificateFingerprint);
if (remote.syncFormat !== DESKTOP_SYNC_FORMAT) {
throw new DesktopSyncUnsupportedError();
@@ -251,7 +277,27 @@ async function runDesktopSyncOnce(): Promise<{
startedAt,
finishedAt: startedAt,
};
const baselines = await getPlaylistSyncBaselines(db);
const baselines = new Map(
localState.baselines.map((baseline) => [
baseline.syncUid,
{
localUpdatedAt: baseline.localUpdatedAt,
remoteUpdatedAt: baseline.remoteUpdatedAt,
},
])
);
const plan: DesktopSyncMutationPlan = {
settings: {},
favoriteAdds: [],
favoriteRemoves: [],
favoriteTombstoneRemovals: [],
playlistAdoptions: [],
playlistUpserts: [],
playlistDeletes: [],
playlistTombstoneRemovals: [],
baselineUpserts: [],
baselineDeletes: [],
};
// Baselines are recorded only for playlists that END this run in sync;
// push-dependent ones wait for the desktop's per-playlist apply result.
const baselinePlans: { uid: string; localUpdatedAt: number; remoteUpdatedAt: number; afterPush: boolean }[] = [];
@@ -261,8 +307,7 @@ async function runDesktopSyncOnce(): Promise<{
const remoteByUid = new Map(remote.playlists.map((playlist) => [playlist.syncUid, playlist]));
const remoteTombByUid = new Map(remote.playlistTombstones.map((tomb) => [tomb.syncUid, tomb.deletedAt]));
await db.transaction(async (tx) => {
// ── Favorites ────────────────────────────────────────────────────────────
// ── Favorites ────────────────────────────────────────────────────────────
const favoriteKeys = new Set<string>([
...local.favorites.keys(),
...local.favoriteTombstones.keys(),
@@ -281,25 +326,17 @@ async function runDesktopSyncOnce(): Promise<{
if (present) {
if (localTombAt !== null) {
await removeFavoriteTombstone(tx, key);
plan.favoriteTombstoneRemovals.push(key);
}
if (!localFav || localFav.pending) {
const match = matchSyncEntry(
{ title: bestAdd.title, artist: bestAdd.artist, album: bestAdd.album },
index
);
if (match.kind === 'matched') {
await applySyncedFavoriteAdd(tx, match.track.path, key, bestAdd.addedAt);
summary.favoritesAdded += 1;
} else if (!localFav || localFav.addedAt < bestAdd.addedAt) {
await upsertPendingFavorite(tx, {
if (!localFav || localFav.addedAt < bestAdd.addedAt || localFav.pending) {
plan.favoriteAdds.push({
key,
title: bestAdd.title,
artist: bestAdd.artist,
album: bestAdd.album,
addedAt: bestAdd.addedAt,
});
if (!localFav) summary.favoritesPending += 1;
}
}
if (!remoteFav) {
@@ -313,12 +350,27 @@ async function runDesktopSyncOnce(): Promise<{
}
} else if (bestDelAt !== null) {
if (localFav) {
await applySyncedFavoriteRemove(tx, localFav.trackPaths, key, bestDelAt);
if (!localFav.pending) summary.favoritesRemoved += 1;
plan.favoriteRemoves.push({
key,
title: localFav.title,
artist: localFav.artist,
album: localFav.album,
addedAt: localFav.addedAt,
trackPaths: localFav.trackPaths,
deletedAt: bestDelAt,
});
} else if (localTombAt === null || localTombAt < bestDelAt) {
// Record the peer's tombstone locally so the merge stays
// deterministic even if the desktop ever loses its copy.
await applySyncedFavoriteRemove(tx, [], key, bestDelAt);
plan.favoriteRemoves.push({
key,
title: '',
artist: '',
album: '',
addedAt: 0,
trackPaths: [],
deletedAt: bestDelAt,
});
}
if (remoteFav) {
payload.favoriteRemoves.push({ key, deletedAt: bestDelAt });
@@ -326,15 +378,15 @@ async function runDesktopSyncOnce(): Promise<{
}
}
// ── Playlists ────────────────────────────────────────────────────────────
// ── Playlists ────────────────────────────────────────────────────────────
const localByUid = new Map(local.playlists.map((playlist) => [playlist.syncUid, playlist]));
const skippedConflictUids = new Set<string>();
const localContentsFor = async (row: LocalSyncPlaylist): Promise<SyncPlaylistEntry[]> =>
row.kind === 'normal' ? getSyncPlaylistEntries(tx, row.id) : [];
const localContentsFor = async (row: NativeLocalSyncPlaylist): Promise<SyncPlaylistEntry[]> =>
row.kind === 'normal' ? row.entries ?? [] : [];
const contentsMatch = async (
localRow: LocalSyncPlaylist,
localRow: NativeLocalSyncPlaylist,
remoteRow: SyncPlaylist,
localEntries: SyncPlaylistEntry[]
): Promise<boolean> => {
@@ -347,7 +399,7 @@ async function runDesktopSyncOnce(): Promise<{
const buildConflict = (
kind: DesktopSyncPlaylistConflict['kind'],
localRow: LocalSyncPlaylist,
localRow: NativeLocalSyncPlaylist,
remoteRow: SyncPlaylist,
localEntries: SyncPlaylistEntry[]
): DesktopSyncPlaylistConflict => {
@@ -388,7 +440,7 @@ async function runDesktopSyncOnce(): Promise<{
if (local.playlistTombstones.has(remotePlaylist.syncUid)) continue;
const nameKey = normalizeSyncKeyPart(remotePlaylist.name);
if (!nameKey) continue;
let paired: LocalSyncPlaylist | null = null;
let paired: NativeLocalSyncPlaylist | null = null;
for (const candidate of local.playlists) {
if (candidate.syncUid === remotePlaylist.syncUid) continue;
if (remoteByUid.has(candidate.syncUid) || remoteTombByUid.has(candidate.syncUid)) continue;
@@ -398,7 +450,10 @@ async function runDesktopSyncOnce(): Promise<{
if (!paired) continue;
const pairedEntries = await localContentsFor(paired);
if (await contentsMatch(paired, remotePlaylist, pairedEntries)) {
await adoptPlaylistSyncUid(tx, paired.id, remotePlaylist.syncUid);
plan.playlistAdoptions.push({
playlistId: paired.id,
syncUid: remotePlaylist.syncUid,
});
localByUid.delete(paired.syncUid);
paired.syncUid = remotePlaylist.syncUid;
localByUid.set(remotePlaylist.syncUid, paired);
@@ -428,21 +483,20 @@ async function runDesktopSyncOnce(): Promise<{
// Deletion wins only when strictly newer than the newest edit.
if (bestTombAt !== null && (bestRowAt === null || bestTombAt > bestRowAt)) {
if (localRow) {
await applySyncedPlaylistDelete(tx, uid, bestTombAt);
summary.playlistsDeleted += 1;
plan.playlistDeletes.push({ syncUid: uid, deletedAt: bestTombAt });
} else if (localTombAt === null || localTombAt < bestTombAt) {
await applySyncedPlaylistDelete(tx, uid, bestTombAt);
plan.playlistDeletes.push({ syncUid: uid, deletedAt: bestTombAt });
}
await deletePlaylistSyncBaseline(tx, uid);
plan.baselineDeletes.push(uid);
if (remoteRow) {
payload.playlistDeletes.push({ syncUid: uid, deletedAt: bestTombAt });
}
continue;
}
const pushLocal = async (row: LocalSyncPlaylist) => {
const pushLocal = async (row: NativeLocalSyncPlaylist) => {
if (localTombAt !== null) {
await removePlaylistTombstone(tx, uid);
plan.playlistTombstoneRemovals.push(uid);
}
payload.playlistUpserts.push({
syncUid: uid,
@@ -451,7 +505,7 @@ async function runDesktopSyncOnce(): Promise<{
dynamicRules: row.dynamicRules,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
entries: row.kind === 'normal' ? await getSyncPlaylistEntries(tx, row.id) : null,
entries: row.kind === 'normal' ? row.entries ?? [] : null,
} satisfies SyncPlaylist);
baselinePlans.push({
uid,
@@ -462,19 +516,13 @@ async function runDesktopSyncOnce(): Promise<{
};
const applyRemote = async (row: SyncPlaylist) => {
const result = await replaceSyncedPlaylist(tx, row, index);
if (result.status === 'created') summary.playlistsCreated += 1;
else if (result.status === 'replaced') summary.playlistsReplaced += 1;
else summary.playlistsSkipped += 1;
summary.entriesFallback += result.entriesFallback;
if (result.status !== 'skipped-incompatible') {
baselinePlans.push({
uid,
localUpdatedAt: row.updatedAt,
remoteUpdatedAt: row.updatedAt,
afterPush: false,
});
}
plan.playlistUpserts.push(row);
baselinePlans.push({
uid,
localUpdatedAt: row.updatedAt,
remoteUpdatedAt: row.updatedAt,
afterPush: false,
});
};
// With a baseline, sync direction comes from WHICH side changed since
@@ -534,23 +582,25 @@ async function runDesktopSyncOnce(): Promise<{
});
}
}
});
// Baselines that don't depend on the push are valid as soon as the local
// transaction committed.
for (const plan of baselinePlans) {
if (plan.afterPush) continue;
const existing = baselines.get(plan.uid);
for (const baselinePlan of baselinePlans) {
if (baselinePlan.afterPush) continue;
const existing = baselines.get(baselinePlan.uid);
if (
existing &&
existing.localUpdatedAt === plan.localUpdatedAt &&
existing.remoteUpdatedAt === plan.remoteUpdatedAt
existing.localUpdatedAt === baselinePlan.localUpdatedAt &&
existing.remoteUpdatedAt === baselinePlan.remoteUpdatedAt
) {
continue;
}
await upsertPlaylistSyncBaseline(db, plan.uid, plan.localUpdatedAt, plan.remoteUpdatedAt);
plan.baselineUpserts.push({
syncUid: baselinePlan.uid,
localUpdatedAt: baselinePlan.localUpdatedAt,
remoteUpdatedAt: baselinePlan.remoteUpdatedAt,
});
}
let pushStatusByUid = new Map<string, string>();
const hasDiff =
payload.favoriteAdds.length > 0 ||
payload.favoriteRemoves.length > 0 ||
@@ -567,7 +617,7 @@ async function runDesktopSyncOnce(): Promise<{
summary.favoritesAdded += result.favorites.added;
summary.favoritesPending += result.favorites.pending;
summary.favoritesRemoved += result.favorites.removed;
const pushStatusByUid = new Map(result.playlists.map((entry) => [entry.syncUid, entry.status]));
pushStatusByUid = new Map(result.playlists.map((entry) => [entry.syncUid, entry.status]));
for (const playlistResult of result.playlists) {
if (playlistResult.status === 'created') summary.playlistsCreated += 1;
else if (playlistResult.status === 'replaced') summary.playlistsReplaced += 1;
@@ -577,15 +627,32 @@ async function runDesktopSyncOnce(): Promise<{
}
// Push-dependent baselines only count once the desktop confirmed the
// upsert; a failed/skipped push re-syncs naturally next run.
for (const plan of baselinePlans) {
if (!plan.afterPush) continue;
const status = pushStatusByUid.get(plan.uid);
for (const baselinePlan of baselinePlans) {
if (!baselinePlan.afterPush) continue;
const status = pushStatusByUid.get(baselinePlan.uid);
if (status !== 'created' && status !== 'replaced') continue;
await upsertPlaylistSyncBaseline(db, plan.uid, plan.localUpdatedAt, plan.remoteUpdatedAt);
plan.baselineUpserts.push({
syncUid: baselinePlan.uid,
localUpdatedAt: baselinePlan.localUpdatedAt,
remoteUpdatedAt: baselinePlan.remoteUpdatedAt,
});
}
}
await setSetting(db, desktopSyncSettingKey(connection), String(Date.now()));
plan.settings[desktopSyncSettingKey(connection)] = String(Date.now());
const applied = await AstraLibraryData.applyDesktopSyncPlan<NativeDesktopSyncApplyResult>(
plan as unknown as Record<string, unknown>
);
summary.favoritesAdded += applied.favoritesAdded;
summary.favoritesPending += applied.favoritesPending;
summary.favoritesRemoved += applied.favoritesRemoved;
for (const playlistResult of applied.playlistResults) {
if (playlistResult.status === 'created') summary.playlistsCreated += 1;
else if (playlistResult.status === 'replaced') summary.playlistsReplaced += 1;
else if (playlistResult.status === 'deleted') summary.playlistsDeleted += 1;
else summary.playlistsSkipped += 1;
summary.entriesFallback += playlistResult.entriesFallback;
}
await usePlaylistStore.getState().refresh();
summary.finishedAt = Date.now();
@@ -603,82 +670,32 @@ export async function applyDesktopSyncConflictResolution(
conflict: DesktopSyncPlaylistConflict,
resolution: DesktopSyncConflictResolution
): Promise<void> {
const db = await openLibraryDb();
const currentRow = await db.get<{ updated_at: number; name: string }>(
'SELECT updated_at, name FROM playlists WHERE id = ?',
[conflict.localPlaylistId]
);
if (!currentRow) {
// The local copy vanished since detection — nothing to choose between;
// the next sync settles whatever remains.
return;
}
switch (resolution) {
case 'desktop': {
if (conflict.kind === 'first-pairing') {
await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid);
}
// Local reads as unchanged, remote as changed → next run pulls desktop.
await upsertPlaylistSyncBaseline(db, conflict.syncUid, currentRow.updated_at, 0);
break;
let mergedPlaylist: SyncPlaylist | null = null;
if (resolution === 'merge') {
if (conflict.playlistKind !== 'normal') {
throw new Error('Dynamic playlists cannot be merged — keep one side instead.');
}
case 'phone': {
if (conflict.kind === 'first-pairing') {
await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid);
}
// Remote reads as unchanged (as of detection), local as changed → next
// run pushes the phone copy.
await upsertPlaylistSyncBaseline(db, conflict.syncUid, 0, conflict.remoteUpdatedAt);
break;
}
case 'both': {
const copyName = `${currentRow.name} (Phone)`;
if (conflict.kind === 'first-pairing') {
// Rename the local copy out of the collision; both lists then sync as
// independent playlists.
await renamePlaylist(db, conflict.localPlaylistId, copyName);
} else {
// Duplicate the local version under a fresh identity, then let the
// shared uid take the desktop version.
await clonePlaylistAsLocalCopy(db, conflict.localPlaylistId, copyName);
await upsertPlaylistSyncBaseline(db, conflict.syncUid, currentRow.updated_at, 0);
}
break;
}
case 'merge': {
if (conflict.playlistKind !== 'normal') {
throw new Error('Dynamic playlists cannot be merged — keep one side instead.');
}
const localEntries = await getSyncPlaylistEntries(db, conflict.localPlaylistId);
const remoteEntries = conflict.remote.entries ?? [];
const localIsNewer = conflict.localUpdatedAt >= conflict.remoteUpdatedAt;
const merged = mergePlaylistEntries(
const localEntries = conflict.local.entries ?? [];
const remoteEntries = conflict.remote.entries ?? [];
const localIsNewer = conflict.localUpdatedAt >= conflict.remoteUpdatedAt;
mergedPlaylist = {
syncUid: conflict.syncUid,
name: localIsNewer ? conflict.local.name : conflict.remote.name,
kind: 'normal',
dynamicRules: null,
createdAt: conflict.remote.createdAt,
updatedAt: Date.now(),
entries: mergePlaylistEntries(
localIsNewer ? localEntries : remoteEntries,
localIsNewer ? remoteEntries : localEntries
);
if (conflict.kind === 'first-pairing') {
await adoptPlaylistSyncUid(db, conflict.localPlaylistId, conflict.syncUid);
}
const index = buildImportIndex(await getAllTracks(db));
await replaceSyncedPlaylist(
db,
{
syncUid: conflict.syncUid,
name: localIsNewer ? currentRow.name : conflict.remote.name,
kind: 'normal',
dynamicRules: null,
createdAt: conflict.remote.createdAt,
updatedAt: Date.now(),
entries: merged,
},
index
);
// The merged list is a fresh local edit → next run pushes it.
await upsertPlaylistSyncBaseline(db, conflict.syncUid, 0, conflict.remoteUpdatedAt);
break;
}
),
};
}
await AstraLibraryData.resolveDesktopSyncConflict(
conflict as unknown as Record<string, unknown>,
resolution,
mergedPlaylist as unknown as Record<string, unknown> | null
);
await usePlaylistStore.getState().refresh();
}
+18 -6
View File
@@ -34,6 +34,10 @@ export interface JellyfinRequestOptions {
export interface JellyfinCatalogSyncOptions extends JellyfinRequestOptions {
authContext?: JellyfinAuthContext;
onProgress?: (progress: RemoteSyncProgress) => void;
/** Awaited once per server page so callers can stream directly to native storage. */
onTracksBatch?: (tracks: RemoteCatalogTrack[]) => Promise<void>;
/** Defaults to true for compatibility; sync orchestration disables collection. */
collectTracks?: boolean;
}
export interface JellyfinCatalogSyncResult {
@@ -454,7 +458,11 @@ export async function syncJellyfinCatalog(
options: JellyfinCatalogSyncOptions = {}
): Promise<JellyfinCatalogSyncResult> {
const authContext = options.authContext ?? (await authenticateJellyfin(config, options));
const byTrackId = new Map<string, RemoteCatalogTrack>();
const byTrackId = options.collectTracks === false
? null
: new Map<string, RemoteCatalogTrack>();
const seenTrackIds = new Set<string>();
let tracksScanned = 0;
let startIndex = 0;
let totalRecordCount: number | null = null;
@@ -482,11 +490,15 @@ export async function syncJellyfinCatalog(
}
const items = asArray<JellyfinAudioItem>(response.Items);
const trackBatch: RemoteCatalogTrack[] = [];
for (const item of items) {
const mapped = mapJellyfinItemToCatalogTrack(sourceId, item);
if (!mapped) continue;
byTrackId.set(mapped.source_track_id, mapped);
if (!mapped || !seenTrackIds.add(mapped.source_track_id)) continue;
byTrackId?.set(mapped.source_track_id, mapped);
trackBatch.push(mapped);
tracksScanned += 1;
}
if (trackBatch.length > 0) await options.onTracksBatch?.(trackBatch);
options.onProgress?.({
phase: 'items',
@@ -501,10 +513,10 @@ export async function syncJellyfinCatalog(
if (items.length < DEFAULT_PAGE_SIZE) break;
}
const tracks = Array.from(byTrackId.values());
const tracks = byTrackId ? Array.from(byTrackId.values()) : [];
return {
itemsScanned: totalRecordCount ?? tracks.length,
tracksScanned: tracks.length,
itemsScanned: totalRecordCount ?? tracksScanned,
tracksScanned,
tracks,
};
}
+5 -9
View File
@@ -6,8 +6,7 @@
// 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 { getNativeSetting, setNativeSetting } from '@/db/nativeSettings';
import type { LastFmServiceConfig } from '@/types/lastFm';
import {
deleteLastFmSessionKey,
@@ -32,8 +31,7 @@ function parseStringArray(value: string | null): string[] {
/** 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);
const json = await getNativeSetting(CONFIG_KEY);
if (!json) return null;
let parsed: LastFmServiceConfig;
@@ -57,9 +55,7 @@ export async function loadLastFmConfig(): Promise<LastFmServiceConfig | null> {
/** 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 previousSecretIds = parseStringArray(await getNativeSetting(SECRET_IDS_KEY));
const currentSecretIds: string[] = [];
for (const profile of config.profiles) {
@@ -76,7 +72,7 @@ export async function persistLastFmConfig(config: LastFmServiceConfig): Promise<
await deleteLastFmSessionKey(id);
}
}
await setSetting(db, SECRET_IDS_KEY, JSON.stringify(currentSecretIds));
await setNativeSetting(SECRET_IDS_KEY, JSON.stringify(currentSecretIds));
const sanitized: LastFmServiceConfig = {
enabled: config.enabled,
@@ -87,5 +83,5 @@ export async function persistLastFmConfig(config: LastFmServiceConfig): Promise<
pendingScrobbles: profile.pendingScrobbles.map((item) => ({ ...item })),
})),
};
await setSetting(db, CONFIG_KEY, JSON.stringify(sanitized));
await setNativeSetting(CONFIG_KEY, JSON.stringify(sanitized));
}
+1 -1
View File
@@ -9,7 +9,7 @@ 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}`;
return `astra_room_v1_lastfm_session_${safe}`;
}
export async function getLastFmSessionKey(profileId: string): Promise<string | null> {
+54 -5
View File
@@ -4,19 +4,68 @@
import * as SecureStore from 'expo-secure-store';
function secretKey(sourceId: number): string {
function secretKey(sourceId: number, field = 'password'): string {
// SecureStore keys must be alphanumeric + ".-_" — this satisfies that.
return `remote_secret_${sourceId}`;
return `astra_room_v1_remote_${field}_${sourceId}`;
}
export async function getRemoteSecret(sourceId: number): Promise<string | null> {
return SecureStore.getItemAsync(secretKey(sourceId));
return SecureStore.getItemAsync(secretKey(sourceId, 'password'));
}
export async function setRemoteSecret(sourceId: number, password: string): Promise<void> {
await SecureStore.setItemAsync(secretKey(sourceId), password);
await SecureStore.setItemAsync(secretKey(sourceId, 'password'), password);
}
export async function deleteRemoteSecret(sourceId: number): Promise<void> {
await SecureStore.deleteItemAsync(secretKey(sourceId));
await Promise.all(
['password', 'access_token', 'user_id', 'device_id', 'art_auth'].map((field) =>
SecureStore.deleteItemAsync(secretKey(sourceId, field))
)
);
}
export interface RemoteSecureAuth {
accessToken: string | null;
userId: string | null;
deviceId: string | null;
artAuth: string | null;
}
export async function getRemoteSecureAuth(sourceId: number): Promise<RemoteSecureAuth> {
const [accessToken, userId, deviceId, artAuth] = await Promise.all(
['access_token', 'user_id', 'device_id', 'art_auth'].map((field) =>
SecureStore.getItemAsync(secretKey(sourceId, field))
)
);
return { accessToken, userId, deviceId, artAuth };
}
async function setOptionalSecret(
sourceId: number,
field: string,
value: string | null,
): Promise<void> {
const key = secretKey(sourceId, field);
if (value == null) await SecureStore.deleteItemAsync(key);
else await SecureStore.setItemAsync(key, value);
}
export async function setRemoteSecureAuth(
sourceId: number,
auth: {
accessToken: string | null;
userId: string | null;
deviceId: string | null;
},
): Promise<void> {
await Promise.all([
setOptionalSecret(sourceId, 'access_token', auth.accessToken),
setOptionalSecret(sourceId, 'user_id', auth.userId),
setOptionalSecret(sourceId, 'device_id', auth.deviceId),
]);
}
export async function setRemoteArtAuth(sourceId: number, value: string | null): Promise<void> {
await setOptionalSecret(sourceId, 'art_auth', value);
}
+44 -36
View File
@@ -26,6 +26,10 @@ export interface SubsonicRequestOptions {
export interface SubsonicCatalogSyncOptions extends SubsonicRequestOptions {
onProgress?: (progress: RemoteSyncProgress) => void;
/** Awaited after each bounded batch so callers can stream directly to native storage. */
onTracksBatch?: (tracks: RemoteCatalogTrack[]) => Promise<void>;
/** Defaults to true for compatibility; sync orchestration disables collection. */
collectTracks?: boolean;
}
export interface SubsonicCatalogSyncResult {
@@ -419,13 +423,22 @@ export async function syncSubsonicCatalog(
options.onProgress?.({ phase: 'albums', current: 0, total: uniqueAlbumIds.length, detail: null });
let albumsProcessed = 0;
const albumSongLists = await runWithConcurrency(
uniqueAlbumIds,
MAX_SYNC_CONCURRENCY,
async (albumId): Promise<SubsonicAlbumSongs> => {
const albumResponse = await requestSubsonicJson(config, 'getAlbum', { id: albumId }, options);
const albumContainer = albumResponse.album as Record<string, unknown> | undefined;
if (!albumContainer || typeof albumContainer !== 'object') {
const byTrackId = options.collectTracks === false
? null
: new Map<string, RemoteCatalogTrack>();
const seenTrackIds = new Set<string>();
let tracksScanned = 0;
options.onProgress?.({ phase: 'tracks', current: 0, total: uniqueAlbumIds.length, detail: null });
let trackAlbumProcessed = 0;
const albumBatchSize = MAX_SYNC_CONCURRENCY * 6;
for (let start = 0; start < uniqueAlbumIds.length; start += albumBatchSize) {
const albumIds = uniqueAlbumIds.slice(start, start + albumBatchSize);
const albumSongLists = await runWithConcurrency(
albumIds,
MAX_SYNC_CONCURRENCY,
async (albumId): Promise<SubsonicAlbumSongs> => {
const albumResponse = await requestSubsonicJson(config, 'getAlbum', { id: albumId }, options);
const albumContainer = albumResponse.album as Record<string, unknown> | undefined;
albumsProcessed += 1;
options.onProgress?.({
phase: 'albums',
@@ -433,45 +446,40 @@ export async function syncSubsonicCatalog(
total: uniqueAlbumIds.length,
detail: albumId,
});
return { coverArtId: null, songs: [] };
if (!albumContainer || typeof albumContainer !== 'object') {
return { coverArtId: null, songs: [] };
}
return {
coverArtId: toTrimmedText(albumContainer.coverArt),
songs: asArray<SubsonicSong>(albumContainer.song),
};
}
albumsProcessed += 1;
);
const trackBatch: RemoteCatalogTrack[] = [];
for (const albumSongs of albumSongLists) {
for (const song of albumSongs.songs) {
const mapped = mapSongToCatalogTrack(sourceId, song, albumSongs.coverArtId);
if (!mapped || !seenTrackIds.add(mapped.source_track_id)) continue;
trackBatch.push(mapped);
byTrackId?.set(mapped.source_track_id, mapped);
tracksScanned += 1;
}
trackAlbumProcessed += 1;
options.onProgress?.({
phase: 'albums',
current: albumsProcessed,
phase: 'tracks',
current: trackAlbumProcessed,
total: uniqueAlbumIds.length,
detail: albumId,
detail: null,
});
return {
coverArtId: toTrimmedText(albumContainer.coverArt),
songs: asArray<SubsonicSong>(albumContainer.song),
};
}
);
const byTrackId = new Map<string, RemoteCatalogTrack>();
options.onProgress?.({ phase: 'tracks', current: 0, total: albumSongLists.length, detail: null });
let trackAlbumProcessed = 0;
for (const albumSongs of albumSongLists) {
for (const song of albumSongs.songs) {
const mapped = mapSongToCatalogTrack(sourceId, song, albumSongs.coverArtId);
if (!mapped) continue;
byTrackId.set(mapped.source_track_id, mapped);
}
trackAlbumProcessed += 1;
options.onProgress?.({
phase: 'tracks',
current: trackAlbumProcessed,
total: albumSongLists.length,
detail: null,
});
if (trackBatch.length > 0) await options.onTracksBatch?.(trackBatch);
}
const tracks = Array.from(byTrackId.values());
const tracks = byTrackId ? Array.from(byTrackId.values()) : [];
return {
artistsScanned: artistRefs.length,
albumsScanned: uniqueAlbumIds.length,
tracksScanned: tracks.length,
tracksScanned,
tracks,
};
}