From d5041e0f7908719f48d84a59acc34968e68cc0d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 15:00:40 +0000 Subject: [PATCH] Create database CRUD operations for all modules (settings, search, downloads, music) Co-authored-by: leinelissen <10154841+leinelissen@users.noreply.github.com> --- src/store/downloads/db.ts | 193 +++++++++++++++ src/store/music/db.ts | 499 ++++++++++++++++++++++++++++++++++++++ src/store/search/db.ts | 100 ++++++++ src/store/settings/db.ts | 154 ++++++++++++ 4 files changed, 946 insertions(+) create mode 100644 src/store/downloads/db.ts create mode 100644 src/store/music/db.ts create mode 100644 src/store/search/db.ts create mode 100644 src/store/settings/db.ts diff --git a/src/store/downloads/db.ts b/src/store/downloads/db.ts new file mode 100644 index 0000000..81d4da0 --- /dev/null +++ b/src/store/downloads/db.ts @@ -0,0 +1,193 @@ +import { db, sqliteDb } from '@/store/db'; +import { downloads } from '@/store/db/schema/downloads'; +import { eq } from 'drizzle-orm'; + +export interface Download { + sourceId: string; + id: string; + hash: string | null; + filename: string | null; + mimetype: string | null; + progress: number | null; + isFailed: boolean; + isComplete: boolean; + metadataJson: string | null; + createdAt: number; + updatedAt: number; +} + +export interface DownloadMetadata { + size?: number; + error?: string; +} + +/** + * Get all downloads for a source + */ +export async function getAllDownloads(sourceId: string): Promise { + const result = await db + .select() + .from(downloads) + .where(eq(downloads.sourceId, sourceId)); + + return result as Download[]; +} + +/** + * Get a single download by id + */ +export async function getDownload(id: string): Promise { + const result = await db + .select() + .from(downloads) + .where(eq(downloads.id, id)) + .limit(1); + + return result[0] as Download | undefined; +} + +/** + * Initialize a download + */ +export async function initializeDownload( + sourceId: string, + id: string, + hash?: string, + filename?: string, + mimetype?: string +): Promise { + const now = Date.now(); + + await db.insert(downloads).values({ + sourceId, + id, + hash: hash || null, + filename: filename || null, + mimetype: mimetype || null, + progress: 0, + isFailed: false, + isComplete: false, + metadataJson: null, + createdAt: now, + updatedAt: now, + }).onConflictDoUpdate({ + target: downloads.id, + set: { + hash: hash || null, + filename: filename || null, + mimetype: mimetype || null, + progress: 0, + isFailed: false, + isComplete: false, + updatedAt: now, + }, + }); + + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * Update download progress + */ +export async function updateDownloadProgress( + id: string, + progress: number, + metadata?: DownloadMetadata +): Promise { + const updates: any = { + progress, + updatedAt: Date.now(), + }; + + if (metadata) { + updates.metadataJson = JSON.stringify(metadata); + } + + await db.update(downloads) + .set(updates) + .where(eq(downloads.id, id)); + + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * Mark download as complete + */ +export async function completeDownload( + id: string, + hash?: string, + filename?: string +): Promise { + const updates: any = { + isComplete: true, + isFailed: false, + progress: 100, + updatedAt: Date.now(), + }; + + if (hash) updates.hash = hash; + if (filename) updates.filename = filename; + + await db.update(downloads) + .set(updates) + .where(eq(downloads.id, id)); + + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * Mark download as failed + */ +export async function failDownload(id: string, error?: string): Promise { + const metadata = error ? JSON.stringify({ error }) : null; + + await db.update(downloads) + .set({ + isFailed: true, + isComplete: false, + progress: 0, + metadataJson: metadata, + updatedAt: Date.now(), + }) + .where(eq(downloads.id, id)); + + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * Remove a download + */ +export async function removeDownload(id: string): Promise { + await db.delete(downloads).where(eq(downloads.id, id)); + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * Parse download metadata + */ +export function parseDownloadMetadata(download: Download): DownloadMetadata { + if (!download.metadataJson) { + return {}; + } + try { + return JSON.parse(download.metadataJson); + } catch { + return {}; + } +} + +/** + * Get download with parsed metadata + */ +export interface DownloadWithMetadata extends Download { + size?: number; + error?: string; +} + +export function enrichDownload(download: Download): DownloadWithMetadata { + const metadata = parseDownloadMetadata(download); + return { + ...download, + ...metadata, + }; +} diff --git a/src/store/music/db.ts b/src/store/music/db.ts new file mode 100644 index 0000000..13c6679 --- /dev/null +++ b/src/store/music/db.ts @@ -0,0 +1,499 @@ +import { db, sqliteDb } from '@/store/db'; +import { albums } from '@/store/db/schema/albums'; +import { artists } from '@/store/db/schema/artists'; +import { tracks } from '@/store/db/schema/tracks'; +import { playlists } from '@/store/db/schema/playlists'; +import { albumArtists } from '@/store/db/schema/album-artists'; +import { trackArtists } from '@/store/db/schema/track-artists'; +import { playlistTracks } from '@/store/db/schema/playlist-tracks'; +import { albumSimilar } from '@/store/db/schema/album-similar'; +import { eq, and, desc, inArray } from 'drizzle-orm'; +import type { Album, AlbumTrack, MusicArtist, Playlist } from './types'; + +/** + * ALBUMS + */ + +export async function getAllAlbums(sourceId: string) { + const result = await db.select().from(albums).where(eq(albums.sourceId, sourceId)); + return result.map(album => enrichAlbum(album)); +} + +export async function getAlbum(id: string) { + const result = await db.select().from(albums).where(eq(albums.id, id)).limit(1); + if (!result[0]) return undefined; + return enrichAlbum(result[0]); +} + +export async function getRecentAlbums(sourceId: string, limit: number = 50) { + const result = await db + .select() + .from(albums) + .where(eq(albums.sourceId, sourceId)) + .orderBy(desc(albums.dateCreated)) + .limit(limit); + return result.map(album => enrichAlbum(album)); +} + +export async function upsertAlbum(sourceId: string, album: Album) { + const now = Date.now(); + const metadata = extractAlbumMetadata(album); + + await db.insert(albums).values({ + sourceId, + id: album.Id, + name: album.Name, + productionYear: album.ProductionYear || null, + isFolder: album.IsFolder, + albumArtist: album.AlbumArtist || null, + dateCreated: album.DateCreated ? new Date(album.DateCreated).getTime() : now, + lastRefreshed: now, + metadataJson: JSON.stringify(metadata), + createdAt: now, + updatedAt: now, + }).onConflictDoUpdate({ + target: albums.id, + set: { + name: album.Name, + productionYear: album.ProductionYear || null, + albumArtist: album.AlbumArtist || null, + lastRefreshed: now, + metadataJson: JSON.stringify(metadata), + updatedAt: now, + }, + }); + + // Update album-artist relations + await updateAlbumArtists(sourceId, album.Id, album.AlbumArtists); + + sqliteDb.flushPendingReactiveQueries(); +} + +export async function upsertAlbums(sourceId: string, albumList: Album[]) { + for (const album of albumList) { + await upsertAlbum(sourceId, album); + } +} + +export async function getSimilarAlbums(albumId: string) { + const relations = await db + .select() + .from(albumSimilar) + .where(eq(albumSimilar.albumId, albumId)); + + if (relations.length === 0) return []; + + const similarIds = relations.map(r => r.similarAlbumId); + const result = await db + .select() + .from(albums) + .where(inArray(albums.id, similarIds)); + + return result.map(album => enrichAlbum(album)); +} + +export async function setSimilarAlbums(sourceId: string, albumId: string, similarAlbumIds: string[]) { + // Delete existing relations + await db.delete(albumSimilar).where(eq(albumSimilar.albumId, albumId)); + + // Insert new relations + if (similarAlbumIds.length > 0) { + await db.insert(albumSimilar).values( + similarAlbumIds.map(similarId => ({ + sourceId, + albumId, + similarAlbumId: similarId, + })) + ); + } + + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * ARTISTS + */ + +export async function getAllArtists(sourceId: string) { + const result = await db.select().from(artists).where(eq(artists.sourceId, sourceId)); + return result.map(artist => enrichArtist(artist)); +} + +export async function upsertArtist(sourceId: string, artist: MusicArtist) { + const now = Date.now(); + const metadata = extractArtistMetadata(artist); + + await db.insert(artists).values({ + sourceId, + id: artist.Id, + name: artist.Name, + isFolder: artist.IsFolder, + metadataJson: JSON.stringify(metadata), + createdAt: now, + updatedAt: now, + }).onConflictDoUpdate({ + target: artists.id, + set: { + name: artist.Name, + metadataJson: JSON.stringify(metadata), + updatedAt: now, + }, + }); + + sqliteDb.flushPendingReactiveQueries(); +} + +export async function upsertArtists(sourceId: string, artistList: MusicArtist[]) { + for (const artist of artistList) { + await upsertArtist(sourceId, artist); + } +} + +/** + * TRACKS + */ + +export async function getTracksByAlbum(albumId: string) { + const result = await db + .select() + .from(tracks) + .where(eq(tracks.albumId, albumId)); + return result.map(track => enrichTrack(track)); +} + +export async function getTracksByPlaylist(playlistId: string) { + const relations = await db + .select() + .from(playlistTracks) + .where(eq(playlistTracks.playlistId, playlistId)); + + if (relations.length === 0) return []; + + const trackIds = relations.map(r => r.trackId); + const result = await db + .select() + .from(tracks) + .where(inArray(tracks.id, trackIds)); + + // Sort by position in playlist + const trackMap = new Map(result.map(t => [t.id, t])); + return relations + .sort((a, b) => (a.position || 0) - (b.position || 0)) + .map(r => trackMap.get(r.trackId)) + .filter(Boolean) + .map(track => enrichTrack(track!)); +} + +export async function getTrack(id: string) { + const result = await db.select().from(tracks).where(eq(tracks.id, id)).limit(1); + if (!result[0]) return undefined; + return enrichTrack(result[0]); +} + +export async function upsertTrack(sourceId: string, track: AlbumTrack) { + const now = Date.now(); + const metadata = extractTrackMetadata(track); + + await db.insert(tracks).values({ + sourceId, + id: track.Id, + name: track.Name, + albumId: track.AlbumId || null, + album: track.Album || null, + albumArtist: track.AlbumArtist || null, + productionYear: track.ProductionYear || null, + indexNumber: track.IndexNumber || null, + parentIndexNumber: track.ParentIndexNumber || null, + hasLyrics: track.HasLyrics || false, + runTimeTicks: track.RunTimeTicks || null, + lyrics: track.Lyrics ? JSON.stringify(track.Lyrics) : null, + metadataJson: JSON.stringify(metadata), + createdAt: now, + updatedAt: now, + }).onConflictDoUpdate({ + target: tracks.id, + set: { + name: track.Name, + album: track.Album || null, + albumArtist: track.AlbumArtist || null, + hasLyrics: track.HasLyrics || false, + lyrics: track.Lyrics ? JSON.stringify(track.Lyrics) : null, + metadataJson: JSON.stringify(metadata), + updatedAt: now, + }, + }); + + // Update track-artist relations + await updateTrackArtists(sourceId, track.Id, track.ArtistItems); + + sqliteDb.flushPendingReactiveQueries(); +} + +export async function upsertTracks(sourceId: string, trackList: AlbumTrack[]) { + for (const track of trackList) { + await upsertTrack(sourceId, track); + } +} + +export async function updateTrackCodec(trackId: string, codec: any) { + const track = await getTrack(trackId); + if (!track) return; + + const metadata = track.metadataJson ? JSON.parse(track.metadataJson) : {}; + metadata.Codec = codec; + + await db.update(tracks) + .set({ + metadataJson: JSON.stringify(metadata), + updatedAt: Date.now(), + }) + .where(eq(tracks.id, trackId)); + + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * PLAYLISTS + */ + +export async function getAllPlaylists(sourceId: string) { + const result = await db.select().from(playlists).where(eq(playlists.sourceId, sourceId)); + return result.map(playlist => enrichPlaylist(playlist)); +} + +export async function getPlaylist(id: string) { + const result = await db.select().from(playlists).where(eq(playlists.id, id)).limit(1); + if (!result[0]) return undefined; + return enrichPlaylist(result[0]); +} + +export async function upsertPlaylist(sourceId: string, playlist: Playlist) { + const now = Date.now(); + const metadata = extractPlaylistMetadata(playlist); + + await db.insert(playlists).values({ + sourceId, + id: playlist.Id, + name: playlist.Name, + canDelete: playlist.CanDelete, + childCount: playlist.ChildCount || null, + lastRefreshed: now, + metadataJson: JSON.stringify(metadata), + createdAt: now, + updatedAt: now, + }).onConflictDoUpdate({ + target: playlists.id, + set: { + name: playlist.Name, + canDelete: playlist.CanDelete, + childCount: playlist.ChildCount || null, + lastRefreshed: now, + metadataJson: JSON.stringify(metadata), + updatedAt: now, + }, + }); + + sqliteDb.flushPendingReactiveQueries(); +} + +export async function upsertPlaylists(sourceId: string, playlistList: Playlist[]) { + for (const playlist of playlistList) { + await upsertPlaylist(sourceId, playlist); + } +} + +export async function setPlaylistTracks(sourceId: string, playlistId: string, trackIds: string[]) { + // Delete existing relations + await db.delete(playlistTracks).where( + and( + eq(playlistTracks.sourceId, sourceId), + eq(playlistTracks.playlistId, playlistId) + ) + ); + + // Insert new relations with positions + if (trackIds.length > 0) { + await db.insert(playlistTracks).values( + trackIds.map((trackId, index) => ({ + sourceId, + playlistId, + trackId, + position: index, + })) + ); + } + + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * HELPER FUNCTIONS + */ + +async function updateAlbumArtists(sourceId: string, albumId: string, albumArtistsList: any[]) { + // Delete existing relations + await db.delete(albumArtists).where( + and( + eq(albumArtists.sourceId, sourceId), + eq(albumArtists.albumId, albumId) + ) + ); + + // Insert new relations + if (albumArtistsList && albumArtistsList.length > 0) { + await db.insert(albumArtists).values( + albumArtistsList.map((artist, index) => ({ + sourceId, + albumId, + artistId: artist.Id, + orderIndex: index, + })) + ); + } +} + +async function updateTrackArtists(sourceId: string, trackId: string, artistItems: any[]) { + // Delete existing relations + await db.delete(trackArtists).where( + and( + eq(trackArtists.sourceId, sourceId), + eq(trackArtists.trackId, trackId) + ) + ); + + // Insert new relations + if (artistItems && artistItems.length > 0) { + await db.insert(trackArtists).values( + artistItems.map((artist, index) => ({ + sourceId, + trackId, + artistId: artist.Id, + orderIndex: index, + })) + ); + } +} + +function extractAlbumMetadata(album: Album) { + return { + ServerId: album.ServerId, + SortName: album.SortName, + RunTimeTicks: album.RunTimeTicks, + Type: album.Type, + UserData: album.UserData, + PrimaryImageAspectRatio: album.PrimaryImageAspectRatio, + Artists: album.Artists, + ArtistItems: album.ArtistItems, + AlbumArtists: album.AlbumArtists, + ImageTags: album.ImageTags, + BackdropImageTags: album.BackdropImageTags, + LocationType: album.LocationType, + Overview: album.Overview, + PrimaryImageItemId: album.PrimaryImageItemId, + }; +} + +function enrichAlbum(dbAlbum: any): Album { + const metadata = dbAlbum.metadataJson ? JSON.parse(dbAlbum.metadataJson) : {}; + return { + Id: dbAlbum.id, + Name: dbAlbum.name, + ProductionYear: dbAlbum.productionYear, + IsFolder: dbAlbum.isFolder, + AlbumArtist: dbAlbum.albumArtist, + DateCreated: dbAlbum.dateCreated ? new Date(dbAlbum.dateCreated).toISOString() : new Date().toISOString(), + lastRefreshed: dbAlbum.lastRefreshed, + ...metadata, + }; +} + +function extractArtistMetadata(artist: MusicArtist) { + return { + ServerId: artist.ServerId, + ChannelId: artist.ChannelId, + RunTimeTicks: artist.RunTimeTicks, + Type: artist.Type, + UserData: artist.UserData, + ImageTags: artist.ImageTags, + BackdropImageTags: artist.BackdropImageTags, + ImageBlurHashes: artist.ImageBlurHashes, + LocationType: artist.LocationType, + Overview: artist.Overview, + }; +} + +function enrichArtist(dbArtist: any): MusicArtist { + const metadata = dbArtist.metadataJson ? JSON.parse(dbArtist.metadataJson) : {}; + return { + Id: dbArtist.id, + Name: dbArtist.name, + IsFolder: dbArtist.isFolder, + ...metadata, + }; +} + +function extractTrackMetadata(track: AlbumTrack) { + return { + ServerId: track.ServerId, + Type: track.Type, + UserData: track.UserData, + Artists: track.Artists, + ArtistItems: track.ArtistItems, + AlbumPrimaryImageTag: track.AlbumPrimaryImageTag, + AlbumArtists: track.AlbumArtists, + ImageTags: track.ImageTags, + BackdropImageTags: track.BackdropImageTags, + LocationType: track.LocationType, + MediaType: track.MediaType, + MediaStreams: track.MediaStreams, + Codec: track.Codec, + }; +} + +function enrichTrack(dbTrack: any): AlbumTrack { + const metadata = dbTrack.metadataJson ? JSON.parse(dbTrack.metadataJson) : {}; + const lyrics = dbTrack.lyrics ? JSON.parse(dbTrack.lyrics) : undefined; + + return { + Id: dbTrack.id, + Name: dbTrack.name, + AlbumId: dbTrack.albumId, + Album: dbTrack.album, + AlbumArtist: dbTrack.albumArtist, + ProductionYear: dbTrack.productionYear, + IndexNumber: dbTrack.indexNumber, + ParentIndexNumber: dbTrack.parentIndexNumber, + HasLyrics: dbTrack.hasLyrics, + RunTimeTicks: dbTrack.runTimeTicks, + Lyrics: lyrics, + ...metadata, + }; +} + +function extractPlaylistMetadata(playlist: Playlist) { + return { + ServerId: playlist.ServerId, + SortName: playlist.SortName, + ChannelId: playlist.ChannelId, + RunTimeTicks: playlist.RunTimeTicks, + Type: playlist.Type, + UserData: playlist.UserData, + PrimaryImageAspectRatio: playlist.PrimaryImageAspectRatio, + ImageTags: playlist.ImageTags, + BackdropImageTags: playlist.BackdropImageTags, + LocationType: playlist.LocationType, + MediaType: playlist.MediaType, + }; +} + +function enrichPlaylist(dbPlaylist: any): Playlist { + const metadata = dbPlaylist.metadataJson ? JSON.parse(dbPlaylist.metadataJson) : {}; + return { + Id: dbPlaylist.id, + Name: dbPlaylist.name, + CanDelete: dbPlaylist.canDelete, + ChildCount: dbPlaylist.childCount, + lastRefreshed: dbPlaylist.lastRefreshed, + ...metadata, + }; +} diff --git a/src/store/search/db.ts b/src/store/search/db.ts new file mode 100644 index 0000000..712ca53 --- /dev/null +++ b/src/store/search/db.ts @@ -0,0 +1,100 @@ +import { db, sqliteDb } from '@/store/db'; +import { searchQueries } from '@/store/db/schema/search-queries'; +import { desc, eq } from 'drizzle-orm'; + +type SearchType = 'Audio' | 'MusicAlbum' | 'MusicArtist' | 'Playlist'; + +export interface SearchQuery { + sourceId: string; + id: string; + query: string; + timestamp: number; + localPlaybackOnly: boolean; + metadataJson: string | null; + createdAt: number; + updatedAt: number; +} + +export interface SearchQueryDisplay { + query: string; + filters: SearchType[]; + localPlaybackOnly: boolean; + timestamp: number; +} + +/** + * Get search history for a source (ordered by timestamp desc, limit 10) + */ +export async function getSearchHistory(sourceId: string): Promise { + const result = await db + .select() + .from(searchQueries) + .where(eq(searchQueries.sourceId, sourceId)) + .orderBy(desc(searchQueries.timestamp)) + .limit(10); + + return result as SearchQuery[]; +} + +/** + * Add a search query to history + */ +export async function addSearchQuery( + sourceId: string, + query: string, + filters: SearchType[], + localPlaybackOnly: boolean +): Promise { + const now = Date.now(); + const id = `${sourceId}-${query}-${filters.sort().join(',')}-${localPlaybackOnly}`; + const metadata = { filters }; + + // Delete existing query with same parameters (to move it to top) + await db.delete(searchQueries).where(eq(searchQueries.id, id)); + + // Insert new query + await db.insert(searchQueries).values({ + sourceId, + id, + query, + timestamp: now, + localPlaybackOnly, + metadataJson: JSON.stringify(metadata), + createdAt: now, + updatedAt: now, + }); + + // Keep only last 10 queries for this source + const allQueries = await getSearchHistory(sourceId); + if (allQueries.length > 10) { + const idsToDelete = allQueries.slice(10).map(q => q.id); + for (const id of idsToDelete) { + await db.delete(searchQueries).where(eq(searchQueries.id, id)); + } + } + + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * Clear all search history for a source + */ +export async function clearSearchHistory(sourceId: string): Promise { + await db.delete(searchQueries).where(eq(searchQueries.sourceId, sourceId)); + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * Parse search queries to display format + */ +export function parseSearchQueries(queries: SearchQuery[]): SearchQueryDisplay[] { + return queries.map(q => { + const metadata = q.metadataJson ? JSON.parse(q.metadataJson) : { filters: [] }; + return { + query: q.query, + filters: metadata.filters || [], + localPlaybackOnly: q.localPlaybackOnly, + timestamp: q.timestamp, + }; + }); +} diff --git a/src/store/settings/db.ts b/src/store/settings/db.ts new file mode 100644 index 0000000..304c781 --- /dev/null +++ b/src/store/settings/db.ts @@ -0,0 +1,154 @@ +import { db, sqliteDb } from '@/store/db'; +import { appSettings } from '@/store/db/schema/app-settings'; +import { sources } from '@/store/db/schema/sources'; +import { eq } from 'drizzle-orm'; +import { ColorScheme } from './types'; + +export interface AppSettings { + id: number; + bitrate: number; + isOnboardingComplete: boolean; + hasReceivedErrorReportingAlert: boolean; + enablePlaybackReporting: boolean; + colorScheme: ColorScheme; + createdAt: number; + updatedAt: number; +} + +export interface SourceCredentials { + id: string; + uri: string; + userId: string | null; + accessToken: string | null; + deviceId: string | null; + type: string; +} + +/** + * Get app settings (single row, id=1) + */ +export async function getAppSettings(): Promise { + const result = await db.select().from(appSettings).where(eq(appSettings.id, 1)).limit(1); + return result[0] as AppSettings | undefined; +} + +/** + * Initialize app settings with defaults if not exists + */ +export async function initializeAppSettings(): Promise { + const existing = await getAppSettings(); + if (existing) { + return existing; + } + + const now = Date.now(); + const defaults: typeof appSettings.$inferInsert = { + id: 1, + bitrate: 140000000, + isOnboardingComplete: false, + hasReceivedErrorReportingAlert: false, + enablePlaybackReporting: true, + colorScheme: ColorScheme.System, + createdAt: now, + updatedAt: now, + }; + + await db.insert(appSettings).values(defaults); + sqliteDb.flushPendingReactiveQueries(); + return defaults as AppSettings; +} + +/** + * Update app settings + */ +export async function updateAppSettings(updates: Partial>): Promise { + await db.update(appSettings) + .set({ ...updates, updatedAt: Date.now() }) + .where(eq(appSettings.id, 1)); + sqliteDb.flushPendingReactiveQueries(); +} + +/** + * Set bitrate + */ +export async function setBitrate(bitrate: number): Promise { + await updateAppSettings({ bitrate }); +} + +/** + * Set onboarding status + */ +export async function setOnboardingStatus(isOnboardingComplete: boolean): Promise { + await updateAppSettings({ isOnboardingComplete }); +} + +/** + * Set error reporting alert received + */ +export async function setReceivedErrorReportingAlert(): Promise { + await updateAppSettings({ hasReceivedErrorReportingAlert: true }); +} + +/** + * Set enable playback reporting + */ +export async function setEnablePlaybackReporting(enablePlaybackReporting: boolean): Promise { + await updateAppSettings({ enablePlaybackReporting }); +} + +/** + * Set color scheme + */ +export async function setColorScheme(colorScheme: ColorScheme): Promise { + await updateAppSettings({ colorScheme }); +} + +/** + * Get active source (credentials) + */ +export async function getActiveSource(): Promise { + const result = await db.select().from(sources).limit(1); + return result[0] as SourceCredentials | undefined; +} + +/** + * Set Jellyfin/Emby credentials + */ +export async function setCredentials(credentials: { + uri: string; + user_id: string; + access_token: string; + device_id: string; + type: 'jellyfin' | 'emby'; +}): Promise { + const now = Date.now(); + const sourceType = credentials.type === 'jellyfin' ? 'jellyfin.v1' : 'emby.v1'; + + // Use device_id as the source id for consistency + const sourceId = credentials.device_id; + + await db.insert(sources) + .values({ + id: sourceId, + uri: credentials.uri, + userId: credentials.user_id, + accessToken: credentials.access_token, + deviceId: credentials.device_id, + type: sourceType, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: sources.id, + set: { + uri: credentials.uri, + userId: credentials.user_id, + accessToken: credentials.access_token, + deviceId: credentials.device_id, + type: sourceType, + updatedAt: now, + }, + }); + + sqliteDb.flushPendingReactiveQueries(); +}