m5, subsonic/jellyfin support

This commit is contained in:
Boof2015
2026-06-27 22:03:37 -04:00
parent 0e3e46293c
commit 3ec3f5ecf2
37 changed files with 3322 additions and 35 deletions
+288
View File
@@ -0,0 +1,288 @@
// Remote sources (Subsonic/Jellyfin) — config, connection test, and catalog sync.
// SQLite (remote_sources) + expo-secure-store (passwords) are the source of truth;
// this store mirrors the rows in memory and tracks per-source sync progress.
//
// The decrypted config + cached Jellyfin token are pushed into the synchronous
// 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 {
deleteRemoteSource,
getRemoteSource,
getRemoteSources,
insertRemoteSource,
setRemoteSourceAuth,
setRemoteSourceStatus,
setRemoteSourceSynced,
updateRemoteSource,
} from '@/db/remoteSourceQueries';
import {
deleteRemoteSecret,
getRemoteSecret,
setRemoteSecret,
} from '@/services/remoteCredentials';
import {
clearResolvedRemoteConfig,
setResolvedRemoteConfig,
updateResolvedRemoteAuth,
} from '@/services/remoteConfig';
import {
authenticateJellyfin,
buildJellyfinDeviceId,
testJellyfinConnection,
type JellyfinAuthContext,
} from '@/services/jellyfin';
import { testSubsonicConnection } from '@/services/subsonic';
import { syncRemoteSource } from '@/library/remoteSync';
import { useLibraryStore } from '@/stores/libraryStore';
import type {
RemoteConnectionConfig,
RemoteSourceCreateInput,
RemoteSourceRow,
RemoteSourceTestInput,
RemoteSourceTestResult,
RemoteSourceUpdateInput,
RemoteSyncProgress,
} from '@/types/remote';
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** 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);
if (password == null) return null;
setResolvedRemoteConfig({
id: source.id,
type: source.type,
baseUrl: source.base_url,
username: source.username,
password,
accessToken: source.access_token ?? undefined,
userId: source.user_id ?? undefined,
});
return { baseUrl: source.base_url, username: source.username, password };
}
/** Ensure a usable Jellyfin token, authenticating + persisting it if missing. */
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 auth = await authenticateJellyfin(config);
const db = await openLibraryDb();
await setRemoteSourceAuth(db, source.id, {
accessToken: auth.accessToken,
userId: auth.userId,
deviceId: buildJellyfinDeviceId(config),
});
updateResolvedRemoteAuth(source.id, auth);
return auth;
}
interface RemoteSourcesStore {
sources: RemoteSourceRow[];
initialized: boolean;
/** Per-source live sync progress (null = not syncing). */
progressById: Record<number, RemoteSyncProgress | null>;
init: () => Promise<void>;
refresh: () => Promise<void>;
testSource: (input: RemoteSourceTestInput) => Promise<RemoteSourceTestResult>;
createSource: (input: RemoteSourceCreateInput) => Promise<RemoteSourceRow>;
updateSource: (id: number, input: RemoteSourceUpdateInput) => Promise<void>;
deleteSource: (id: number, purgeTracks: boolean) => Promise<void>;
syncSource: (id: number) => Promise<void>;
syncAll: () => Promise<void>;
}
export const useRemoteSourcesStore = create<RemoteSourcesStore>((set, get) => ({
sources: [],
initialized: false,
progressById: {},
init: async () => {
if (get().initialized) return;
const db = await openLibraryDb();
const sources = await getRemoteSources(db);
// 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 });
// 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) {
await useLibraryStore.getState().refresh();
}
},
refresh: async () => {
const db = await openLibraryDb();
set({ sources: await getRemoteSources(db) });
},
testSource: async (input) => {
try {
const config: RemoteConnectionConfig = {
baseUrl: input.baseUrl,
username: input.username,
password: input.password,
};
if (input.type === 'subsonic') {
await testSubsonicConnection(config);
} else {
await testJellyfinConnection(config);
}
return { ok: true, message: 'Connection successful.' };
} catch (error) {
return { ok: false, message: errorMessage(error) };
}
},
createSource: async (input) => {
const db = await openLibraryDb();
const config: RemoteConnectionConfig = {
baseUrl: input.baseUrl,
username: input.username,
password: input.password,
};
// Validate before persisting anything.
let auth: JellyfinAuthContext | null = null;
if (input.type === 'subsonic') {
await testSubsonicConnection(config);
} else {
auth = await authenticateJellyfin(config);
}
const row = await insertRemoteSource(db, {
type: input.type,
name: input.name,
baseUrl: input.baseUrl,
username: input.username,
enabled: input.enabled,
});
await setRemoteSecret(row.id, input.password);
if (auth) {
await setRemoteSourceAuth(db, row.id, {
accessToken: auth.accessToken,
userId: auth.userId,
deviceId: buildJellyfinDeviceId(config),
});
}
setResolvedRemoteConfig({
id: row.id,
type: row.type,
baseUrl: row.base_url,
username: row.username,
password: input.password,
accessToken: auth?.accessToken,
userId: auth?.userId,
});
await get().refresh();
// Kick off the first sync in the background (don't block the add flow).
void get().syncSource(row.id);
return row;
},
updateSource: async (id, input) => {
const db = await openLibraryDb();
const existing = await getRemoteSource(db, id);
if (!existing) return;
await updateRemoteSource(db, id, {
name: input.name,
base_url: input.baseUrl,
username: input.username,
enabled: input.enabled,
});
if (input.password) {
await setRemoteSecret(id, input.password);
}
const updated = await getRemoteSource(db, id);
if (updated) {
// Connection details may have changed → drop cached token, re-hydrate registry.
if (input.baseUrl || input.username || input.password) {
await setRemoteSourceAuth(db, id, { accessToken: null, userId: null, deviceId: null });
}
const fresh = (await getRemoteSource(db, 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}/`);
}
await deleteRemoteSource(db, id);
await deleteRemoteSecret(id);
clearResolvedRemoteConfig(id);
await get().refresh();
if (purgeTracks) {
await useLibraryStore.getState().refresh();
}
},
syncSource: async (id) => {
const db = await openLibraryDb();
const source = await getRemoteSource(db, 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 get().refresh();
return;
}
const onProgress = (progress: RemoteSyncProgress) => {
set((state) => ({ progressById: { ...state.progressById, [id]: progress } }));
};
set((state) => ({
progressById: { ...state.progressById, [id]: { phase: 'connecting', current: 0, total: 0, detail: null } },
}));
try {
let authContext: JellyfinAuthContext | undefined;
if (source.type === 'jellyfin') {
authContext = await ensureJellyfinAuth(source, config);
}
await syncRemoteSource(db, source, config, { onProgress, authContext });
await setRemoteSourceSynced(db, id);
await useLibraryStore.getState().refresh();
} catch (error) {
await setRemoteSourceStatus(db, id, 'error', errorMessage(error));
} finally {
set((state) => ({ progressById: { ...state.progressById, [id]: null } }));
await get().refresh();
}
},
syncAll: async () => {
const enabled = get().sources.filter((source) => source.enabled);
for (const source of enabled) {
await get().syncSource(source.id);
}
},
}));