import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { songArtId } from "../artwork.js"; import { auth, markAuthFailed } from "../auth.svelte.js"; import { getOnlineCoverArt, getSourceApi, isAuthenticationError, isNetworkError, type LyricsResponse, type Playlist, queuedSongState, type Song, songAlbumArtist, songArtist, songCache, } from "../client.svelte.js"; import { lib } from "../library.svelte.js"; import { updateBackgroundSync } from "../pwa.svelte.js"; import { effectiveAutoSyncMode, settings } from "../settings.svelte.js"; import { sourceStorageKey, sources } from "../sources.svelte.js"; import { ui } from "../ui.svelte.js"; import { IndexedDbStore, type QueueableMethod, type QueuedRequest, } from "./indexedDbStore.svelte.js"; import { OPFSStorage } from "./opfsStorage.js"; import { backgroundSyncRequestMessage, isAutoSyncEligible, updateAppBadge, } from "./sync.js"; export const store = new IndexedDbStore(); const storage = new OPFSStorage(); export const offlineImages = new SvelteMap(); const missingImages = new Set(); const pendingArtDownloads = new Map>(); const maxConcurrentDownloads = 4; let offlineDataVersion = 0; let wipingOfflineData = false; const sourceDataVersions = new Map(); const wipingSourceIds = new Set(); const isDataCurrent = ( sourceId: string, appVersion: number, sourceVersion: number, ) => appVersion === offlineDataVersion && sourceVersion === (sourceDataVersions.get(sourceId) ?? 0) && !wipingOfflineData && !wipingSourceIds.has(sourceId); type DownloadBatch = { songs: Song[]; onBeforeDownload?: () => Promise; }; const resolveSyncedPlaylistId = ( id: string | undefined, syncedPlaylistIds: Map, sourceId: string, ): string | undefined => id !== undefined ? (syncedPlaylistIds.get(sourceStorageKey(sourceId, id)) ?? id) : undefined; const songArtistId = (s: Song) => s.albumArtistId || s.albumArtists?.[0]?.id || s.artistId || songAlbumArtist(s) || "unknown_artist"; const publishCachedArt = (artId: string, blob: Blob) => { if (!offlineImages.has(artId)) { offlineImages.set(artId, URL.createObjectURL(blob)); } missingImages.delete(artId); }; export const cacheArt = async ( artId: string, size: number, sourceId = sources.activeId, ) => { if (!artId || !sourceId || wipingOfflineData || wipingSourceIds.has(sourceId)) return; const appVersion = offlineDataVersion; const sourceVersion = sourceDataVersions.get(sourceId) ?? 0; const imageKey = sourceStorageKey(sourceId, artId); const key = `art_${encodeURIComponent(artId)}_${size}`; const pendingKey = `${imageKey}:${size}`; const pending = pendingArtDownloads.get(pendingKey); if (pending) return pending; const task = (async () => { try { let blob = await storage.readBlob(sourceId, key); if (!isDataCurrent(sourceId, appVersion, sourceVersion)) return; if (!blob) { const coverUrl = getOnlineCoverArt(artId, size, sourceId); if (!coverUrl) return; const coverRes = await fetch(coverUrl, { cache: "no-store" }); if (!coverRes.ok) return; blob = await coverRes.blob(); if (!isDataCurrent(sourceId, appVersion, sourceVersion)) return; await storage.writeBlob(sourceId, key, blob); } if (!isDataCurrent(sourceId, appVersion, sourceVersion)) return; publishCachedArt(imageKey, blob); } catch (err) { console.warn(`failed to cache ${size}px cover art:`, artId, err); } finally { pendingArtDownloads.delete(pendingKey); } })(); pendingArtDownloads.set(pendingKey, task); return task; }; class OfflineManager { networkOnline = $state( typeof navigator === "undefined" ? true : navigator.onLine, ); private sourceOnline = new SvelteMap(); dbInitialized = $state(false); activeDownloadIds = new SvelteSet(); private loadingKeys = new Set(); private downloadAbortController: AbortController | null = null; private downloadQueuePromise: Promise | null = null; private sourceDownloadControllers = new Map(); private activeDownloadPromises = new Map>>(); queuedDownloadIds = new SvelteSet(); private downloadBatchQueue: DownloadBatch[] = []; private reconnectTimer: ReturnType | null = null; private activeQueueSync: Promise | null = null; private downloadSets = new SvelteMap< string, { songs: SvelteSet; albums: SvelteSet; artists: SvelteSet; playlists: SvelteSet; partialPlaylists: SvelteSet; } >(); downloadProgress = $state({ downloading: false, total: 0, current: 0, }); queuedRequests = $state([]); constructor() { if (typeof window !== "undefined") { window.addEventListener("online", () => { this.setNetworkOnline(true); void this.tryReconnect(); }); window.addEventListener("offline", () => this.setNetworkOnline(false)); navigator.serviceWorker?.addEventListener("message", (event) => { if (event.data?.type !== backgroundSyncRequestMessage) return; void this.tryReconnect().then(() => this.autoSyncQueuedRequests()); }); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && this.isOfflineMode) { void this.tryReconnect(); } }); } } setOnline(online: boolean, sourceId = sources.activeId) { if (!sourceId) return; if (this.sourceOnline.get(sourceId) === online) { if ( sourceId !== sources.activeId || online || this.reconnectTimer !== null || !auth.loggedIn || !this.networkOnline ) return; this.reconnectTimer = setInterval(() => void this.tryReconnect(), 30_000); return; } this.sourceOnline.set(sourceId, online); if (sourceId !== sources.activeId) return; if (online) { this.stopReconnectTimer(); void this.loadSyncQueue(); } else if (this.reconnectTimer === null && this.networkOnline) { this.reconnectTimer = setInterval(() => void this.tryReconnect(), 30_000); } } private setNetworkOnline(online: boolean) { if (this.networkOnline === online) return; this.networkOnline = online; if (!online) { this.stopReconnectTimer(); for (const sourceId of this.sourceOnline.keys()) this.sourceOnline.set(sourceId, false); } } setOffline(sourceId = sources.activeId) { this.setOnline(false, sourceId); if (sourceId === sources.activeId && auth.authFailed) this.stopReconnectTimer(); } setConnected(sourceId = sources.activeId) { this.setOnline(true, sourceId); } private stopReconnectTimer() { if (this.reconnectTimer === null) return; clearInterval(this.reconnectTimer); this.reconnectTimer = null; } private async tryReconnect() { const sourceId = sources.activeId; if ( !this.networkOnline || auth.busy || auth.authFailed || !auth.loggedIn || !sourceId ) return; try { await getSourceApi(sourceId).forcePing(); if (sources.activeId !== sourceId || auth.sourceId !== sourceId) return; this.setConnected(sourceId); } catch (err) { if (sources.activeId !== sourceId || auth.sourceId !== sourceId) return; if (isAuthenticationError(err)) { if (!auth.loggedIn && !auth.autoLogin) { this.setConnected(sourceId); return; } markAuthFailed(); } else if (!isNetworkError(err)) { this.setConnected(sourceId); } } } get isNetworkAvailable() { return this.networkOnline; } isSourceOffline(sourceId = sources.activeId) { return ( !sourceId || !this.networkOnline || this.sourceOnline.get(sourceId) !== true ); } get isOfflineMode() { return this.isSourceOffline(); } private getDownloadSet(sourceId: string) { let state = this.downloadSets.get(sourceId); if (!state) { state = { songs: new SvelteSet(), albums: new SvelteSet(), artists: new SvelteSet(), playlists: new SvelteSet(), partialPlaylists: new SvelteSet(), }; this.downloadSets.set(sourceId, state); } return state; } get downloadCounts() { const state = sources.activeId ? this.downloadSets.get(sources.activeId) : undefined; return { songs: state?.songs.size ?? 0, albums: state?.albums.size ?? 0, artists: state?.artists.size ?? 0, playlists: state?.playlists.size ?? 0, }; } private currentSource(sourceId?: string) { return sourceId ?? sources.activeId; } isSongDownloaded(song: Song) { return ( !!song.sourceId && this.getDownloadSet(song.sourceId).songs.has(song.id) ); } isAlbumDownloaded(id: string, sourceId = this.currentSource()) { return !!sourceId && this.getDownloadSet(sourceId).albums.has(id); } isArtistDownloaded(id: string, sourceId = this.currentSource()) { return !!sourceId && this.getDownloadSet(sourceId).artists.has(id); } isPlaylistDownloaded(id: string, sourceId = this.currentSource()) { return !!sourceId && this.getDownloadSet(sourceId).playlists.has(id); } isPlaylistPartiallyDownloaded(id: string, sourceId = this.currentSource()) { return !!sourceId && this.getDownloadSet(sourceId).partialPlaylists.has(id); } isDownloadActive(song: Song) { return ( !!song.sourceId && this.activeDownloadIds.has(sourceStorageKey(song.sourceId, song.id)) ); } isDownloadQueued(song: Song) { return ( !!song.sourceId && this.queuedDownloadIds.has(sourceStorageKey(song.sourceId, song.id)) ); } async setSource(sourceId: string) { if (!sources.items.some((source) => source.id === sourceId)) return; await this.refreshDownloadsList(sourceId); await this.loadSyncQueue(); } async init(sourceId = sources.activeId) { try { await store.init(); if (sourceId) { await store.migrateLegacy(sourceId); await storage.migrateLegacy(sourceId); } this.dbInitialized = true; await Promise.all( sources.items.map((source) => this.refreshDownloadsList(source.id)), ); await Promise.all( sources.items.map((source) => this.purgeOldDownloads(source.id)), ); await this.loadSyncQueue(); this.scheduleBackgroundSync(); } catch (err) { console.error("failed to initialize offline manager:", err); } } private async ensurePersistentStorage() { if (!navigator.storage?.persist) return; try { if (await navigator.storage.persisted?.()) return; await navigator.storage.persist(); } catch (err) { console.warn("failed to request persistent storage:", err); } } async refreshPlaylistCache(playlists: Playlist[]) { if (!settings.downloads || !this.isNetworkAvailable) return; await Promise.all( playlists .filter( (playlist): playlist is Playlist & { sourceId: string } => !playlist.id.startsWith("local_playlist_") && !!playlist.sourceId, ) .map((playlist) => getSourceApi(playlist.sourceId) .getPlaylist(playlist.id) .catch((err: unknown) => { console.error( "failed to refresh cached playlist:", playlist.id, err, ); }), ), ); } async loadSyncQueue() { if (!this.dbInitialized) return; try { this.queuedRequests = await store.getUnsyncedChanges(); for (const song of songCache.values()) { const state = queuedSongState(song.sourceId).get(song.id); song.starred = state?.starred !== undefined ? state.starred || undefined : song._origStarred; song.userRating = state?.userRating ?? song._origUserRating; } } catch (err) { console.error("failed to load request queue:", err); } } private scheduleBackgroundSync() { const mode = effectiveAutoSyncMode(); if ( mode !== "none" && this.queuedRequests.some((req) => isAutoSyncEligible(req.method, mode)) ) updateBackgroundSync(mode); } async refreshDownloadsList(sourceId = this.currentSource()) { if (!this.dbInitialized) return; if (!sourceId) return; try { const [songIds, albumIds, artistIds, playlistIds] = await Promise.all([ store.getAllIds("songs", sourceId), store.getAllIds("albums", sourceId), store.getAllIds("artists", sourceId), store.getAllIds("playlists", sourceId), ]); const downloadedSongIds = new Set(songIds); const downloadedPlaylistIds: string[] = []; const partialPlaylistIds: string[] = []; for (const id of playlistIds) { const playlist = await store.getPlaylist(id, sourceId); if (!playlist?.entry?.length) continue; if (playlist.entry.every((song) => downloadedSongIds.has(song.id))) { downloadedPlaylistIds.push(id); } else if ( playlist.entry.some((song) => downloadedSongIds.has(song.id)) ) { partialPlaylistIds.push(id); } } this.downloadSets.set(sourceId, { songs: new SvelteSet(songIds), albums: new SvelteSet(albumIds), artists: new SvelteSet(artistIds), playlists: new SvelteSet(downloadedPlaylistIds), partialPlaylists: new SvelteSet(partialPlaylistIds), }); } catch (err) { console.error("failed to refresh downloads list:", err); } } async loadLocalArt( id: string, size: number, sourceId = this.currentSource(), ) { if (!sourceId) return; const imageKey = sourceStorageKey(sourceId, id); if (offlineImages.has(imageKey) || this.loadingKeys.has(imageKey)) return; const appVersion = offlineDataVersion; const sourceVersion = sourceDataVersions.get(sourceId) ?? 0; this.loadingKeys.add(imageKey); try { const key = `art_${encodeURIComponent(id)}_${size}`; let blob = await storage.readBlob(sourceId, key); if (!blob) { const altSize = size === 512 ? 128 : 512; const altKey = `art_${encodeURIComponent(id)}_${altSize}`; blob = await storage.readBlob(sourceId, altKey); } if (!isDataCurrent(sourceId, appVersion, sourceVersion)) return; if (blob) { publishCachedArt(imageKey, blob); } else { missingImages.add(imageKey); } } catch (err) { console.warn("failed to load local art:", id, size, err); if (isDataCurrent(sourceId, appVersion, sourceVersion)) missingImages.add(imageKey); } finally { this.loadingKeys.delete(imageKey); } } private touchSong(song: Song) { song.accessed = Date.now(); store.putSong(song, song.sourceId).catch(() => {}); } async getAudioUrl(song: Song): Promise { if (!settings.downloads || !song.sourceId) return null; const sourceId = song.sourceId; const appVersion = offlineDataVersion; const sourceVersion = sourceDataVersions.get(sourceId) ?? 0; if (!isDataCurrent(sourceId, appVersion, sourceVersion)) return null; if (!this.isSongDownloaded(song)) { return null; } try { const key = `song_${encodeURIComponent(song.id)}`; const blob = await storage.readBlob(sourceId, key); if (blob && isDataCurrent(sourceId, appVersion, sourceVersion)) { this.touchSong(song); const type = song.contentType || blob.type || "audio/mpeg"; return URL.createObjectURL( type === blob.type ? blob : new Blob([blob], { type }), ); } } catch (err) { console.warn("failed to load local audio:", song.id, err); } return null; } private async downloadSingleTrack( song: Song, signal: AbortSignal, ): Promise { const sourceId = song.sourceId; if (!sourceId) throw new Error("Source required for download"); const sourceApi = getSourceApi(sourceId); const artId = songArtId(song); const artistId = songArtistId(song); const artistName = songAlbumArtist(song) || "Unknown Artist"; let audioBlob: Blob; try { try { const res = await fetch(sourceApi.download(song.id), { cache: "no-store", signal, }); if (!res.ok) throw new Error("download endpoint returned non-ok status"); audioBlob = await res.blob(); } catch (err) { if (signal.aborted) throw err; console.warn( "failed to download raw audio, trying stream endpoint fallback:", err, ); const res = await fetch(sourceApi.stream(song.id), { cache: "no-store", signal, }); if (!res.ok) throw new Error(`failed to fetch song stream for ${song.title}`); audioBlob = await res.blob(); } } catch (err) { if (isNetworkError(err)) this.setOffline(sourceId); throw err; } const audioKey = `song_${encodeURIComponent(song.id)}`; let audioWritten = false; let songStored = false; try { await storage.writeBlob(sourceId, audioKey, audioBlob); audioWritten = true; if (audioBlob.type && audioBlob.type !== "application/octet-stream") { song.contentType = audioBlob.type; } let lyricsData: LyricsResponse | null = null; try { lyricsData = await sourceApi.getLyricsBySongId(song.id); const lyricsField = lyricsData?.lyrics; const rawLyrics = (typeof lyricsField === "object" ? lyricsField?.value : lyricsField) ?? ""; const structuredLyrics = lyricsData?.lyricsList?.structuredLyrics?.[0]; if (!rawLyrics && !structuredLyrics) { lyricsData = await sourceApi.getLyrics(songArtist(song), song.title); } } catch (err) { console.warn("failed to prefetch lyrics for song:", song.title, err); try { lyricsData = await sourceApi.getLyrics(songArtist(song), song.title); } catch {} } if (lyricsData) song.lyrics = lyricsData; song.accessed = Date.now(); await store.putSong(song, sourceId); songStored = true; if (song.albumId) { await store.putAlbum( { id: song.albumId, name: song.album, coverArt: artId || undefined, artistId, artistName, sourceId, }, sourceId, ); } const artist = lib.artists.find( (item) => item.id === artistId && item.sourceId === sourceId, ); await store.putArtist( artist ?? { id: artistId, name: artistName, sourceId, }, sourceId, ); if (artId) await cacheArt(artId, 512, sourceId); if (artist?.coverArt) await cacheArt(artist.coverArt, 128, sourceId); const state = this.getDownloadSet(sourceId); state.songs.add(song.id); if (song.albumId) state.albums.add(song.albumId); state.artists.add(artistId); } catch (err) { if (songStored) { await this.removeSingleTrack(song.id, sourceId).catch((cleanupErr) => console.warn( "failed to clean up failed song metadata:", song.id, cleanupErr, ), ); } if (audioWritten) { await storage .deleteBlob(sourceId, audioKey) .catch((cleanupErr) => console.warn( "failed to clean up failed song download:", song.id, cleanupErr, ), ); } throw err; } } private async cleanupArt(artId: string, sourceId: string): Promise { await Promise.all( [128, 512].map((size) => storage.deleteBlob( sourceId, `art_${encodeURIComponent(artId)}_${size}`, ), ), ); const imageKey = sourceStorageKey(sourceId, artId); const url = offlineImages.get(imageKey); if (url) URL.revokeObjectURL(url); offlineImages.delete(imageKey); missingImages.delete(imageKey); } private resetDownloadProgress() { this.downloadProgress = { downloading: false, current: 0, total: 0, }; this.downloadAbortController = null; this.sourceDownloadControllers.clear(); this.queuedDownloadIds.clear(); this.downloadBatchQueue = []; } private async fetchStoredSongs( songIds: string[] | undefined, sourceId: string, ) { if (!songIds?.length) return []; const songs = await Promise.all( songIds.map((id) => store.getSong(id, sourceId)), ); return songs.filter((song: Song | null): song is Song => song !== null); } private async purgeOldDownloads(sourceId: string) { if (!settings.downloadsAutoDelete || settings.downloadsAutoDeleteDays <= 0) return; const cutoff = Date.now() - settings.downloadsAutoDeleteDays * 86400000; const songs = await store.getAllSongs(sourceId); const staleIds = songs .filter((song: Song) => song.accessed && song.accessed < cutoff) .map((song: Song) => song.id); if (!staleIds.length) return; await this.removeSongsBatch(staleIds, sourceId); } private async removeSingleTrack(id: string, sourceId: string): Promise { const song = await store.getSong(id, sourceId); if (!song) return; await storage.deleteBlob(sourceId, `song_${encodeURIComponent(id)}`); await store.deleteSongs([id], sourceId); const allSongs = await store.getAllSongs(sourceId); const artId = songArtId(song); const artistId = songArtistId(song); if (song.albumId && !allSongs.some((s) => s.albumId === song.albumId)) { await store.deleteAlbum(song.albumId, sourceId); } if (artId && !allSongs.some((s) => songArtId(s) === artId)) { await this.cleanupArt(artId, sourceId); } if (!allSongs.some((s) => songArtistId(s) === artistId)) { const artist = await store.getArtist(artistId, sourceId); await store.deleteArtist(artistId, sourceId); if (artist?.coverArt) { await this.cleanupArt(artist.coverArt, sourceId); } } } cancelDownloads() { this.downloadAbortController?.abort(); for (const controller of this.sourceDownloadControllers.values()) controller.abort(); return this.downloadQueuePromise; } private sourceDownloadController(sourceId: string) { let controller = this.sourceDownloadControllers.get(sourceId); if (!controller || controller.signal.aborted) { controller = new AbortController(); this.sourceDownloadControllers.set(sourceId, controller); } return controller; } private processDownloadQueue() { if (this.downloadQueuePromise) return this.downloadQueuePromise; this.downloadProgress.downloading = true; this.downloadAbortController = new AbortController(); const { signal } = this.downloadAbortController; const processing = (async () => { try { while (true) { while (this.downloadBatchQueue.length && !signal.aborted) { const batch = this.downloadBatchQueue.shift(); if (!batch) continue; await batch.onBeforeDownload?.(); let nextSongIndex = 0; await Promise.all( Array.from( { length: Math.min(maxConcurrentDownloads, batch.songs.length), }, async () => { while ( nextSongIndex < batch.songs.length && !signal.aborted ) { const song = batch.songs[nextSongIndex++]; const sourceId = song.sourceId; if (!sourceId) continue; const downloadKey = sourceStorageKey(sourceId, song.id); this.queuedDownloadIds.delete(downloadKey); if (wipingSourceIds.has(sourceId)) { this.downloadProgress.current += 1; continue; } const sourceSignal = this.sourceDownloadController(sourceId).signal; if (sourceSignal.aborted) { this.downloadProgress.current += 1; continue; } this.activeDownloadIds.add(downloadKey); const download = this.downloadSingleTrack( song, sourceSignal, ); let sourceDownloads = this.activeDownloadPromises.get(sourceId); if (!sourceDownloads) { sourceDownloads = new Set(); this.activeDownloadPromises.set( sourceId, sourceDownloads, ); } sourceDownloads.add(download); try { await download; } catch (err) { if (!signal.aborted && !sourceSignal.aborted) { console.error( "failed to download track:", song.title, err, ); } } finally { sourceDownloads.delete(download); if (!sourceDownloads.size) this.activeDownloadPromises.delete(sourceId); this.activeDownloadIds.delete(downloadKey); } this.downloadProgress.current += 1; } }, ), ); } if (signal.aborted) { this.downloadBatchQueue = []; break; } await Promise.all( sources.items.map((source) => this.refreshDownloadsList(source.id)), ); if (!this.downloadBatchQueue.length) break; } } finally { this.resetDownloadProgress(); this.downloadQueuePromise = null; } })(); this.downloadQueuePromise = processing; return processing; } private async downloadSongsBatch( songsOrFetcher: Song[] | (() => Promise), onBeforeDownload?: () => Promise, sourceId?: string, ) { if (!settings.downloads) return; if (!this.isNetworkAvailable) { console.warn("cannot download songs while offline"); return; } const sourceVersion = sourceId ? (sourceDataVersions.get(sourceId) ?? 0) : undefined; try { const fetchedSongs = typeof songsOrFetcher === "function" ? await songsOrFetcher() : songsOrFetcher; if ( sourceId && (sourceVersion !== (sourceDataVersions.get(sourceId) ?? 0) || wipingSourceIds.has(sourceId)) ) return; const list: Song[] = []; for (const song of fetchedSongs) { const sourceId = song.sourceId ?? this.currentSource(); if (!sourceId || wipingSourceIds.has(sourceId)) continue; song.sourceId = sourceId; const downloadKey = sourceStorageKey(sourceId, song.id); if ( this.isSongDownloaded(song) || this.activeDownloadIds.has(downloadKey) || this.queuedDownloadIds.has(downloadKey) ) continue; this.queuedDownloadIds.add(downloadKey); list.push(song); } if (!list.length) return; void this.ensurePersistentStorage(); this.downloadBatchQueue.push({ songs: list, onBeforeDownload }); this.downloadProgress.total += list.length; await this.processDownloadQueue(); } catch (err) { console.error("failed batch download:", err); } } private async removeSongsBatch( ids: string[], sourceId = this.currentSource(), ) { if (!ids.length) return; if (!sourceId) return; try { const idsToRemove = new Set(ids); const allSongs = await store.getAllSongs(sourceId); const songs = allSongs.filter((song) => idsToRemove.has(song.id)); if (!songs.length) return; const remainingSongs = allSongs.filter( (song) => !idsToRemove.has(song.id), ); await Promise.all( songs.map((song) => storage.deleteBlob(sourceId, `song_${encodeURIComponent(song.id)}`), ), ); await store.deleteSongs( songs.map((song) => song.id), sourceId, ); const remainingAlbumIds = new Set( remainingSongs.flatMap((song) => (song.albumId ? [song.albumId] : [])), ); const albumIds = new Set( songs.flatMap((song) => (song.albumId ? [song.albumId] : [])), ); await Promise.all( [...albumIds] .filter((id) => !remainingAlbumIds.has(id)) .map((id) => store.deleteAlbum(id, sourceId)), ); const remainingArtistIds = new Set( remainingSongs.map((song) => songArtistId(song)), ); const artistIds = [ ...new Set(songs.map((song) => songArtistId(song))), ].filter((id) => !remainingArtistIds.has(id)); const artists = await Promise.all( artistIds.map((id) => store.getArtist(id, sourceId)), ); await Promise.all( artistIds.map((id) => store.deleteArtist(id, sourceId)), ); const remainingArtIds = new Set( remainingSongs.map((song) => songArtId(song)).filter(Boolean), ); const artIds = new Set( songs.map((song) => songArtId(song)).filter(Boolean), ); for (const artist of artists) { if (artist?.coverArt) artIds.add(artist.coverArt); } await Promise.all( [...artIds] .filter((id) => !remainingArtIds.has(id)) .map((id) => this.cleanupArt(id, sourceId)), ); } catch (err) { console.error("failed batch removal:", err); } finally { await this.refreshDownloadsList(sourceId); } } downloadSongs(songs: Song[]) { return this.downloadSongsBatch(songs); } downloadSong(song: Song) { return this.downloadSongsBatch([song]); } removeSongs(ids: string[], sourceId = this.currentSource()) { return this.removeSongsBatch(ids, sourceId); } removeSong(id: string, sourceId = this.currentSource()) { return this.removeSongsBatch([id], sourceId); } downloadAlbum(albumId: string, sourceId = this.currentSource()) { if (!sourceId) return Promise.resolve(); return this.downloadSongsBatch( () => getSourceApi(sourceId).getAlbum(albumId), undefined, sourceId, ); } async removeAlbum(albumId: string, sourceId = this.currentSource()) { if (!sourceId) return; const songs = await store.getAllSongs(sourceId); await this.removeSongsBatch( songs.filter((s) => s.albumId === albumId).map((s) => s.id), sourceId, ); } downloadArtist(artistId: string, sourceId = this.currentSource()) { if (!sourceId) return Promise.resolve(); const sourceApi = getSourceApi(sourceId); return this.downloadSongsBatch( async () => { const albums = await sourceApi.getArtistAlbums(artistId); const allSongs: Song[] = []; for (const album of albums) { try { allSongs.push(...(await sourceApi.getAlbum(album.id))); } catch (err) { console.error( "failed to fetch artist album songs:", album.name, err, ); } } return allSongs; }, undefined, sourceId, ); } async removeArtist(artistId: string, sourceId = this.currentSource()) { if (!sourceId) return; const songs = await store.getAllSongs(sourceId); await this.removeSongsBatch( songs.filter((s) => songArtistId(s) === artistId).map((s) => s.id), sourceId, ); } downloadPlaylist(playlistId: string, sourceId = this.currentSource()) { if (!sourceId) return Promise.resolve(); const sourceApi = getSourceApi(sourceId); return this.downloadSongsBatch( () => sourceApi.getPlaylist(playlistId), async () => { const playlist = lib.playlists.find( (p) => p.id === playlistId && p.sourceId === sourceId, ); if (playlist?.coverArt) await cacheArt(playlist.coverArt, 128, sourceId); }, sourceId, ); } async removePlaylist(playlistId: string, sourceId = this.currentSource()) { if (!sourceId) return; const playlist = await store.getPlaylist(playlistId, sourceId); if (!playlist?.entry) return; await this.removeSongsBatch( playlist.entry.map((s) => s.id), sourceId, ); } async queueRequest( method: QueueableMethod, params: QueuedRequest["params"], isPost = false, sourceId = this.currentSource(), ) { if (!sourceId) return; const isLocalPlaylist = (method === "updatePlaylist" && ( params as Extract< QueuedRequest, { method: "updatePlaylist" } >["params"] ).playlistId?.startsWith("local_playlist_")) || (method === "deletePlaylist" && ( params as Extract< QueuedRequest, { method: "deletePlaylist" } >["params"] ).id?.startsWith("local_playlist_")); if (isLocalPlaylist) return; if (method === "star" || method === "unstar") { const favoriteParams = params as Extract< QueuedRequest, { method: "star" | "unstar" } >["params"]; const previous = [...this.queuedRequests] .reverse() .find( (req) => req.sourceId === sourceId && (req.method === "star" || req.method === "unstar") && req.params.id === favoriteParams.id && req.params.albumId === favoriteParams.albumId && req.params.artistId === favoriteParams.artistId, ); if (previous?.id != null) { await store.deleteUnsyncedChange(previous.id); if (favoriteParams.id) { const song = songCache.get(sourceStorageKey(sourceId, favoriteParams.id)) ?? (await store.getSong(favoriteParams.id, sourceId)); const originalStarred = song?._origStarred; if ( (method === "star" && originalStarred) || (method === "unstar" && !originalStarred) ) { await this.loadSyncQueue(); return; } } } } if (method === "setRating") { const ratingParams = params as Extract< QueuedRequest, { method: "setRating" } >["params"]; const previous = [...this.queuedRequests] .reverse() .find( (req) => req.sourceId === sourceId && req.method === "setRating" && req.params.id === ratingParams.id, ); if (previous?.id != null) { await store.deleteUnsyncedChange(previous.id); } const song = songCache.get(sourceStorageKey(sourceId, ratingParams.id)) ?? (await store.getSong(ratingParams.id, sourceId)); if ((song?._origUserRating ?? 0) === ratingParams.rating) { await this.loadSyncQueue(); return; } } const req = { method, params, sourceId, isPost, timestamp: Date.now(), } as QueuedRequest; await store.addUnsyncedChange(req); await this.loadSyncQueue(); this.scheduleBackgroundSync(); } async autoSyncQueuedRequests() { const mode = effectiveAutoSyncMode(); if (!this.isNetworkAvailable || mode === "none") return; await this.loadSyncQueue(); const idsToSync = this.queuedRequests .filter((req) => isAutoSyncEligible(req.method, mode)) .map((req) => req.id) .filter((id): id is number => id !== undefined); if (!idsToSync.length) return; await this.executeQueueSync(idsToSync); } async executeQueueSync(idsToSync: number[]) { while (this.activeQueueSync) await this.activeQueueSync; const task = this.performQueueSync(idsToSync); this.activeQueueSync = task; try { await task; } finally { if (this.activeQueueSync === task) this.activeQueueSync = null; } } private async performQueueSync(idsToSync: number[]) { if (!this.isNetworkAvailable || !this.queuedRequests.length) return; const syncSet = new Set(idsToSync); const syncedPlaylistIds = new Map(); const unavailableSources = new Set(); const sourceIds = new Set( this.queuedRequests .filter((req) => req.id != null && syncSet.has(req.id)) .map((req) => req.sourceId), ); await Promise.all( [...sourceIds].map(async (sourceId) => { if (!this.isSourceOffline(sourceId)) return; try { await getSourceApi(sourceId).forcePing(); this.setConnected(sourceId); } catch (err) { if (!isNetworkError(err)) console.warn("failed to reach source for queue sync:", err); } }), ); for (const req of this.queuedRequests) { if (req.id == null || !syncSet.has(req.id)) continue; if ( unavailableSources.has(req.sourceId) || this.isSourceOffline(req.sourceId) ) continue; try { const sourceApi = getSourceApi(req.sourceId); switch (req.method) { case "scrobble": await sourceApi.scrobble( req.params.id, req.params.submission, req.timestamp, ); break; case "star": await sourceApi.star(req.params); break; case "unstar": await sourceApi.unstar(req.params); break; case "setRating": await sourceApi.setRating(req.params.id, req.params.rating); break; case "createPlaylist": { const playlistId = resolveSyncedPlaylistId( req.params.playlistId, syncedPlaylistIds, req.sourceId, ); if (!req.params.isNew) { await sourceApi.createPlaylist( req.params.name, req.params.songId, playlistId, ); break; } const res = await sourceApi.createPlaylist( req.params.name, req.params.songId, ); const realId = res.playlist?.id; if (playlistId) { if (realId) { syncedPlaylistIds.set( sourceStorageKey(req.sourceId, playlistId), realId, ); } else { try { const playlists = await sourceApi.getPlaylists(); if (req.sourceId === sources.activeId) lib.playlists = playlists; } catch (err) { console.warn( "failed to refresh playlists after syncing local playlist:", err, ); } } await store .deletePlaylist(playlistId, req.sourceId) .catch((err) => console.warn( "failed to remove synced local playlist:", playlistId, err, ), ); if (req.sourceId === sources.activeId) lib.playlists = lib.playlists.filter( (p) => !(p.sourceId === req.sourceId && p.id === playlistId), ); if (!realId) break; void sourceApi.getPlaylist(realId).catch((err: unknown) => { console.error( "failed to refresh playlist after syncing local playlist:", realId, err, ); }); } break; } case "updatePlaylist": await sourceApi.updatePlaylist( resolveSyncedPlaylistId( req.params.playlistId, syncedPlaylistIds, req.sourceId, ) ?? req.params.playlistId, req.params.name, req.params.songIdToAdd, req.params.songIndexToRemove, req.params.public, ); break; case "deletePlaylist": await sourceApi.deletePlaylist( resolveSyncedPlaylistId( req.params.id, syncedPlaylistIds, req.sourceId, ) ?? req.params.id, ); break; } await store.deleteUnsyncedChange(req.id); } catch (err) { console.error("failed to sync offline request:", req, err); if (isNetworkError(err)) unavailableSources.add(req.sourceId); if (!this.isNetworkAvailable) break; } } await this.loadSyncQueue(); } async discardQueuedRequests(ids: number[]) { await Promise.all(ids.map((id) => store.deleteUnsyncedChange(id))); await this.loadSyncQueue(); } async createLocalPlaylistOffline( name?: string, songIds?: string[], playlistId?: string, sourceId = this.currentSource(), ) { if (!sourceId) return; try { if (playlistId) { const playlist = await store.getPlaylist(playlistId, sourceId); if (playlist) { playlist.entry = await this.fetchStoredSongs(songIds, sourceId); await store.putPlaylist(playlist, sourceId); ui.lastUpdatedPlaylistId = `${playlistId}:${Date.now()}`; return; } } if (name) { const tempId = playlistId || `local_playlist_${Date.now()}`; const newPlaylist: Playlist & { entry?: Song[] } = { id: tempId, sourceId, name, owner: auth.user, public: false, entry: await this.fetchStoredSongs(songIds, sourceId), }; await store.putPlaylist(newPlaylist, sourceId); if (sourceId === sources.activeId) lib.playlists = [...lib.playlists, newPlaylist]; } } catch (err) { console.error("failed to create/update local playlist offline:", err); } } async updateLocalPlaylistOffline( playlistId: string, name?: string, songIdToAdd?: string[], songIndexToRemove?: number[], isPublic?: boolean, sourceId = this.currentSource(), ) { if (!sourceId) return; try { const playlist = await store.getPlaylist(playlistId, sourceId); if (playlist) { if (name !== undefined) playlist.name = name; if (isPublic !== undefined) playlist.public = isPublic; let entries = playlist.entry || []; if (songIndexToRemove && songIndexToRemove.length > 0) { entries = entries.filter( (_: Song, i: number) => !songIndexToRemove.includes(i), ); } if (songIdToAdd && songIdToAdd.length > 0) { entries = [ ...entries, ...(await this.fetchStoredSongs(songIdToAdd, sourceId)), ]; } playlist.entry = entries; await store.putPlaylist(playlist, sourceId); ui.lastUpdatedPlaylistId = `${playlistId}:${Date.now()}`; if (playlistId.startsWith("local_playlist_")) { const createReq = this.queuedRequests.find( (r): r is Extract => r.method === "createPlaylist" && r.sourceId === sourceId && r.params?.playlistId === playlistId, ); if (createReq) { if (name !== undefined) createReq.params.name = name; let songIds = createReq.params.songId ? [...createReq.params.songId] : []; if (songIndexToRemove && songIndexToRemove.length > 0) { songIds = songIds.filter( (_, i) => !songIndexToRemove.includes(i), ); } if (songIdToAdd && songIdToAdd.length > 0) { songIds = [...songIds, ...songIdToAdd]; } createReq.params.songId = songIds; await store.putUnsyncedChange(createReq); await this.loadSyncQueue(); } } } } catch (err) { console.error("failed to update local playlist offline:", err); } } async deleteLocalPlaylistOffline( id: string, sourceId = this.currentSource(), ) { if (!sourceId) return; try { await store.deletePlaylist(id, sourceId); if (sourceId === sources.activeId) lib.playlists = lib.playlists.filter((p) => p.id !== id); if (id.startsWith("local_playlist_")) { const createReq = this.queuedRequests.find( (r): r is Extract => r.method === "createPlaylist" && r.sourceId === sourceId && r.params?.playlistId === id, ); if (createReq && createReq.id != null) { await store.deleteUnsyncedChange(createReq.id); await this.loadSyncQueue(); } } } catch (err) { console.error("failed to delete local playlist offline:", err); } } async removeSourceData(sourceId: string): Promise { wipingSourceIds.add(sourceId); sourceDataVersions.set( sourceId, (sourceDataVersions.get(sourceId) ?? 0) + 1, ); this.sourceDownloadControllers.get(sourceId)?.abort(); const activeDownloads = [ ...(this.activeDownloadPromises.get(sourceId) ?? []), ]; try { let removedQueuedDownloads = 0; this.downloadBatchQueue = this.downloadBatchQueue.flatMap((batch) => { const songs = batch.songs.filter((song) => { if (song.sourceId !== sourceId) return true; this.queuedDownloadIds.delete(sourceStorageKey(sourceId, song.id)); removedQueuedDownloads++; return false; }); return songs.length ? [{ ...batch, songs }] : []; }); this.downloadProgress.total = Math.max( this.downloadProgress.current, this.downloadProgress.total - removedQueuedDownloads, ); await Promise.allSettled(activeDownloads); const sourcePrefix = `${sourceId}:`; await Promise.allSettled( [...pendingArtDownloads.entries()] .filter(([key]) => key.startsWith(sourcePrefix)) .map(([, pending]) => pending), ); await Promise.all([ store.clearSourceData(sourceId), storage.clearSource(sourceId), globalThis.caches ?.open("getCoverArt") .then(async (cache) => { const requests = await cache.keys(); await Promise.all( requests .filter( (request) => new URL(request.url).searchParams.get( "__tinysub_source", ) === sourceId, ) .map((request) => cache.delete(request)), ); }) .catch((err: unknown) => { console.warn("failed to clear cached source artwork:", err); }), ]); for (const key of songCache.keys()) if (key.startsWith(sourcePrefix)) songCache.delete(key); for (const [key, url] of offlineImages) { if (!key.startsWith(sourcePrefix)) continue; URL.revokeObjectURL(url); offlineImages.delete(key); } for (const key of missingImages) if (key.startsWith(sourcePrefix)) missingImages.delete(key); this.downloadSets.delete(sourceId); this.sourceOnline.delete(sourceId); for (const key of this.activeDownloadIds) if (key.startsWith(sourcePrefix)) this.activeDownloadIds.delete(key); for (const key of this.queuedDownloadIds) if (key.startsWith(sourcePrefix)) this.queuedDownloadIds.delete(key); this.sourceDownloadControllers.delete(sourceId); this.activeDownloadPromises.delete(sourceId); await this.loadSyncQueue(); } finally { wipingSourceIds.delete(sourceId); } } async wipeOfflineData(): Promise { offlineDataVersion++; wipingOfflineData = true; try { await this.cancelDownloads(); await Promise.allSettled(pendingArtDownloads.values()); await Promise.all([ store.clearAllData(), globalThis.caches?.delete("getCoverArt"), ]); await this.loadSyncQueue(); await storage.clear(); for (const url of offlineImages.values()) { URL.revokeObjectURL(url); } offlineImages.clear(); missingImages.clear(); this.activeDownloadIds.clear(); this.downloadSets.clear(); this.resetDownloadProgress(); await this.refreshDownloadsList(); } finally { wipingOfflineData = false; } } } export const offlineManager = new OfflineManager(); $effect.root(() => { $effect(() => { const mode = effectiveAutoSyncMode(); updateAppBadge( offlineManager.queuedRequests.filter((req) => isAutoSyncEligible(req.method, mode), ).length, ); }); }); export const getCoverArtUrl = ( id: string, size = 128, sourceId = sources.activeId, ) => { if (!id || !sourceId) return ""; if (!settings.downloads) { return getOnlineCoverArt(id, size, sourceId); } const imageKey = sourceStorageKey(sourceId, id); const local = offlineImages.get(imageKey); if (local) return local; if (!offlineImages.has(imageKey) && !missingImages.has(imageKey)) { offlineManager.loadLocalArt(id, size > 128 ? 512 : 128, sourceId); } return getOnlineCoverArt(id, size, sourceId); };