Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611import { 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<string, string>();const missingImages = new Set<string>();const pendingArtDownloads = new Map<string, Promise<void>>();const maxConcurrentDownloads = 4;let offlineDataVersion = 0;let wipingOfflineData = false;const sourceDataVersions = new Map<string, number>();const wipingSourceIds = new Set<string>();
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<void>;};const resolveSyncedPlaylistId = ( id: string | undefined, syncedPlaylistIds: Map<string, string>, 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<string, boolean>(); dbInitialized = $state(false); activeDownloadIds = new SvelteSet<string>(); private loadingKeys = new Set<string>(); private downloadAbortController: AbortController | null = null; private downloadQueuePromise: Promise<void> | null = null; private sourceDownloadControllers = new Map<string, AbortController>(); private activeDownloadPromises = new Map<string, Set<Promise<void>>>(); queuedDownloadIds = new SvelteSet<string>(); private downloadBatchQueue: DownloadBatch[] = []; private reconnectTimer: ReturnType<typeof setInterval> | null = null; private activeQueueSync: Promise<void> | null = null; private downloadSets = new SvelteMap< string, { songs: SvelteSet<string>; albums: SvelteSet<string>; artists: SvelteSet<string>; playlists: SvelteSet<string>; partialPlaylists: SvelteSet<string>; } >();
downloadProgress = $state({ downloading: false, total: 0, current: 0, });
queuedRequests = $state<QueuedRequest[]>([]);
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<string | null> { 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<void> { 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<void> { 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<void> { 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<Song[]>), onBeforeDownload?: () => Promise<void>, 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<string, string>(); const unavailableSources = new Set<string>(); 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<QueuedRequest, { method: "createPlaylist" }> => 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<QueuedRequest, { method: "createPlaylist" }> => 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<void> { 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<void> { 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);};