From 3839a1be2d27c4bf042313d7d746bf26259c9bc1 Mon Sep 17 00:00:00 2001 From: angel Date: Fri, 8 Aug 2025 20:04:06 +0000 Subject: [PATCH] feat: Implement offline library synchronization with IndexedDB - Added `useOfflineLibrarySync` hook for managing offline library sync operations. - Created `OfflineLibrarySync` component for UI integration. - Developed `offlineLibraryDB` for IndexedDB interactions, including storing and retrieving albums, artists, songs, and playlists. - Implemented sync operations for starred items, playlists, and scrobbling. - Added auto-sync functionality when coming back online. - Included metadata management for sync settings and statistics. - Enhanced error handling and user feedback through toasts. --- .env.local | 2 +- .vscode/launch.json | 35 +- app/components/CacheManagement.tsx | 8 +- app/components/OfflineLibrarySync.tsx | 0 app/components/SongRecommendations.tsx | 136 +++-- app/components/album-artwork.tsx | 4 +- app/page.tsx | 109 ++-- hooks/use-offline-audio-player.ts | 2 + hooks/use-offline-downloads.ts | 53 +- hooks/use-offline-library-sync.ts | 514 +++++++++++++++++++ lib/indexeddb.ts | 655 ++++++++++++++++++++++++ lib/navidrome.ts | 17 + public/sw.js | 680 +++++++++++++++++++++++++ 13 files changed, 2102 insertions(+), 113 deletions(-) create mode 100644 app/components/OfflineLibrarySync.tsx create mode 100644 hooks/use-offline-library-sync.ts create mode 100644 lib/indexeddb.ts diff --git a/.env.local b/.env.local index b43af3d..6f8a053 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=0c32c05 +NEXT_PUBLIC_COMMIT_SHA=0a0feb3 diff --git a/.vscode/launch.json b/.vscode/launch.json index 8e746ed..1547e10 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,7 +17,34 @@ "resolveSourceMapLocations": [ "${workspaceFolder}/**", "!**/node_modules/**" - ] + ], + "serverReadyAction": { + "action": "openExternally", + "pattern": "http://localhost:40625" + } + }, + { + "name": "Debug: Development (Verbose)", + "type": "node", + "request": "launch", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["run", "dev"], + "cwd": "${workspaceFolder}", + "env": { + "NODE_ENV": "development", + "DEBUG": "*", + "NEXT_TELEMETRY_DISABLED": "1" + }, + "console": "integratedTerminal", + "skipFiles": ["/**"], + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/**" + ], + "serverReadyAction": { + "action": "openExternally", + "pattern": "http://localhost:40625" + } }, { "name": "Debug: Next.js Production", @@ -32,7 +59,11 @@ "preLaunchTask": "Build: Production Build Only", "runtimeExecutable": "pnpm", "runtimeArgs": ["run", "start"], - "skipFiles": ["/**"] + "skipFiles": ["/**"], + "serverReadyAction": { + "action": "openExternally", + "pattern": "http://localhost:40625" + } } ] } diff --git a/app/components/CacheManagement.tsx b/app/components/CacheManagement.tsx index d4d7da6..ca7abd2 100644 --- a/app/components/CacheManagement.tsx +++ b/app/components/CacheManagement.tsx @@ -95,16 +95,16 @@ export function CacheManagement() { }); }; - const loadOfflineItems = useCallback(() => { + const loadOfflineItems = useCallback(async () => { if (isOfflineInitialized) { - const items = getOfflineItems(); + const items = await getOfflineItems(); setOfflineItems(items); } }, [isOfflineInitialized, getOfflineItems]); useEffect(() => { - loadCacheStats(); - loadOfflineItems(); + loadCacheStats(); + loadOfflineItems(); // Load offline mode settings const storedOfflineMode = localStorage.getItem('offline-mode-enabled'); diff --git a/app/components/OfflineLibrarySync.tsx b/app/components/OfflineLibrarySync.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/components/SongRecommendations.tsx b/app/components/SongRecommendations.tsx index 59dfa32..414ddd0 100644 --- a/app/components/SongRecommendations.tsx +++ b/app/components/SongRecommendations.tsx @@ -1,8 +1,8 @@ 'use client'; import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { Song, Album } from '@/lib/navidrome'; -import { useNavidrome } from '@/app/components/NavidromeContext'; +import { Song, Album, getNavidromeAPI } from '@/lib/navidrome'; +import { useOfflineNavidrome } from '@/app/components/OfflineNavidromeProvider'; import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; import { useIsMobile } from '@/hooks/use-mobile'; import { Button } from '@/components/ui/button'; @@ -17,7 +17,7 @@ interface SongRecommendationsProps { } export function SongRecommendations({ userName }: SongRecommendationsProps) { - const { api, isConnected } = useNavidrome(); + const offline = useOfflineNavidrome(); const { playTrack, shuffle, toggleShuffle } = useAudioPlayer(); const isMobile = useIsMobile(); const [recommendedSongs, setRecommendedSongs] = useState([]); @@ -42,66 +42,84 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) { useEffect(() => { const loadRecommendations = async () => { - if (!api || !isConnected) return; - setLoading(true); try { - // Get random albums for both mobile album view and desktop song extraction - const randomAlbums = await api.getAlbums('random', 10); + const api = getNavidromeAPI(); + const isOnline = !offline.isOfflineMode && !!api; - if (isMobile) { - // For mobile: show 6 random albums - setRecommendedAlbums(randomAlbums.slice(0, 6)); - } else { - // For desktop: extract songs from albums (original behavior) - const allSongs: Song[] = []; - - // Get songs from first few albums - for (let i = 0; i < Math.min(3, randomAlbums.length); i++) { - try { - const albumSongs = await api.getAlbumSongs(randomAlbums[i].id); - allSongs.push(...albumSongs); - } catch (error) { - console.error('Failed to get album songs:', error); + if (isOnline && api) { + // Online: use server-side recommendations + const randomAlbums = await api.getAlbums('random', 10); + if (isMobile) { + setRecommendedAlbums(randomAlbums.slice(0, 6)); + } else { + const allSongs: Song[] = []; + for (let i = 0; i < Math.min(3, randomAlbums.length); i++) { + try { + const albumSongs = await api.getAlbumSongs(randomAlbums[i].id); + allSongs.push(...albumSongs); + } catch (error) { + console.error('Failed to get album songs:', error); + } } + const shuffled = allSongs.sort(() => Math.random() - 0.5); + const recommendations = shuffled.slice(0, 6); + setRecommendedSongs(recommendations); + const states: Record = {}; + recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; }); + setSongStates(states); + } + } else { + // Offline: use cached library + const albums = await offline.getAlbums(false); + const shuffledAlbums = [...(albums || [])].sort(() => Math.random() - 0.5); + if (isMobile) { + setRecommendedAlbums(shuffledAlbums.slice(0, 6)); + } else { + const pick = shuffledAlbums.slice(0, 3); + const allSongs: Song[] = []; + for (const a of pick) { + try { + const songs = await offline.getSongs(a.id); + allSongs.push(...songs); + } catch (e) { + // ignore per-album errors + } + } + const recommendations = allSongs.sort(() => Math.random() - 0.5).slice(0, 6); + setRecommendedSongs(recommendations); + const states: Record = {}; + recommendations.forEach((song: Song) => { states[song.id] = !!song.starred; }); + setSongStates(states); } - - // Shuffle and limit to 6 songs - const shuffled = allSongs.sort(() => Math.random() - 0.5); - const recommendations = shuffled.slice(0, 6); - setRecommendedSongs(recommendations); - - // Initialize starred states for songs - const states: Record = {}; - recommendations.forEach((song: Song) => { - states[song.id] = !!song.starred; - }); - setSongStates(states); } } catch (error) { console.error('Failed to load recommendations:', error); + setRecommendedAlbums([]); + setRecommendedSongs([]); } finally { setLoading(false); } }; loadRecommendations(); - }, [api, isConnected, isMobile]); + }, [offline, isMobile]); const handlePlaySong = async (song: Song) => { - if (!api) return; - try { + const api = getNavidromeAPI(); + const url = api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`; + const coverArt = song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 64) : undefined; const track = { id: song.id, name: song.title, - url: api.getStreamUrl(song.id), + url, artist: song.artist || 'Unknown Artist', artistId: song.artistId || '', album: song.album || 'Unknown Album', albumId: song.albumId || '', duration: song.duration || 0, - coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined, + coverArt, starred: !!song.starred }; await playTrack(track, true); @@ -111,23 +129,29 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) { }; const handlePlayAlbum = async (album: Album) => { - if (!api) return; - try { - // Get album songs and play the first one - const albumSongs = await api.getAlbumSongs(album.id); + const api = getNavidromeAPI(); + let albumSongs: Song[] = []; + if (api) { + albumSongs = await api.getAlbumSongs(album.id); + } else { + albumSongs = await offline.getSongs(album.id); + } if (albumSongs.length > 0) { + const first = albumSongs[0]; + const url = api ? api.getStreamUrl(first.id) : `offline-song-${first.id}`; + const coverArt = first.coverArt && api ? api.getCoverArtUrl(first.coverArt, 64) : undefined; const track = { - id: albumSongs[0].id, - name: albumSongs[0].title, - url: api.getStreamUrl(albumSongs[0].id), - artist: albumSongs[0].artist || 'Unknown Artist', - artistId: albumSongs[0].artistId || '', - album: albumSongs[0].album || 'Unknown Album', - albumId: albumSongs[0].albumId || '', - duration: albumSongs[0].duration || 0, - coverArt: albumSongs[0].coverArt ? api.getCoverArtUrl(albumSongs[0].coverArt, 64) : undefined, - starred: !!albumSongs[0].starred + id: first.id, + name: first.title, + url, + artist: first.artist || 'Unknown Artist', + artistId: first.artistId || '', + album: first.album || 'Unknown Album', + albumId: first.albumId || '', + duration: first.duration || 0, + coverArt, + starred: !!first.starred }; await playTrack(track, true); } @@ -222,9 +246,9 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) { className="group cursor-pointer block" >
- {album.coverArt && api ? ( + {album.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? ( {album.name}
- {song.coverArt && api ? ( + {song.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? ( <> {song.title} handleClick()}>
- {album.coverArt && api ? ( + {album.coverArt && api && !offline.isOfflineMode ? ( {album.name}([]); const [recentAlbums, setRecentAlbums] = useState([]); const [newestAlbums, setNewestAlbums] = useState([]); const [favoriteAlbums, setFavoriteAlbums] = useState([]); + const [albumsLoading, setAlbumsLoading] = useState(true); const [favoritesLoading, setFavoritesLoading] = useState(true); const [shortcutProcessed, setShortcutProcessed] = useState(false); const isMobile = useIsMobile(); + // Load albums (offline-first) useEffect(() => { - if (albums.length > 0) { - // Split albums into recent and newest for display - const recent = albums.slice(0, Math.ceil(albums.length / 2)); - const newest = albums.slice(Math.ceil(albums.length / 2)); - setRecentAlbums(recent); - setNewestAlbums(newest); - } - }, [albums]); - - useEffect(() => { - const loadFavoriteAlbums = async () => { - if (!api || !isConnected) return; - - setFavoritesLoading(true); + let mounted = true; + const load = async () => { + setAlbumsLoading(true); try { - const starredAlbums = await api.getAlbums('starred', 20); // Limit to 20 for homepage - setFavoriteAlbums(starredAlbums); - } catch (error) { - console.error('Failed to load favorite albums:', error); + const list = await offline.getAlbums(false); + if (!mounted) return; + setAllAlbums(list || []); + // Split albums into two sections + const recent = list.slice(0, Math.ceil(list.length / 2)); + const newest = list.slice(Math.ceil(list.length / 2)); + setRecentAlbums(recent); + setNewestAlbums(newest); + } catch (e) { + console.error('Failed to load albums (offline-first):', e); + if (mounted) { + setAllAlbums([]); + setRecentAlbums([]); + setNewestAlbums([]); + } } finally { - setFavoritesLoading(false); + if (mounted) setAlbumsLoading(false); } }; + load(); + return () => { mounted = false; }; + }, [offline]); + useEffect(() => { + let mounted = true; + const loadFavoriteAlbums = async () => { + setFavoritesLoading(true); + try { + const starred = await offline.getAlbums(true); + if (mounted) setFavoriteAlbums((starred || []).slice(0, 20)); + } catch (error) { + console.error('Failed to load favorite albums (offline-first):', error); + if (mounted) setFavoriteAlbums([]); + } finally { + if (mounted) setFavoritesLoading(false); + } + }; loadFavoriteAlbums(); - }, [api, isConnected]); + return () => { mounted = false; }; + }, [offline]); // Handle PWA shortcuts useEffect(() => { const action = searchParams.get('action'); - if (!action || shortcutProcessed || !api || !isConnected) return; + if (!action || shortcutProcessed) return; const handleShortcuts = async () => { try { @@ -93,12 +116,13 @@ function MusicPageContent() { // Add remaining albums to queue for (let i = 1; i < shuffledAlbums.length; i++) { try { - const albumSongs = await api.getAlbumSongs(shuffledAlbums[i].id); - albumSongs.forEach(song => { + const songs = await offline.getSongs(shuffledAlbums[i].id); + const api = getNavidromeAPI(); + songs.forEach((song: Song) => { addToQueue({ id: song.id, name: song.title, - url: api.getStreamUrl(song.id), + url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`, artist: song.artist || 'Unknown Artist', artistId: song.artistId || '', album: song.album || 'Unknown Album', @@ -109,7 +133,7 @@ function MusicPageContent() { }); }); } catch (error) { - console.error('Failed to load album tracks:', error); + console.error('Failed to load album tracks (offline-first):', error); } } } @@ -131,12 +155,13 @@ function MusicPageContent() { // Add remaining albums to queue for (let i = 1; i < shuffledFavorites.length; i++) { try { - const albumSongs = await api.getAlbumSongs(shuffledFavorites[i].id); - albumSongs.forEach(song => { + const songs = await offline.getSongs(shuffledFavorites[i].id); + const api = getNavidromeAPI(); + songs.forEach((song: Song) => { addToQueue({ id: song.id, name: song.title, - url: api.getStreamUrl(song.id), + url: api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`, artist: song.artist || 'Unknown Artist', artistId: song.artistId || '', album: song.album || 'Unknown Album', @@ -147,7 +172,7 @@ function MusicPageContent() { }); }); } catch (error) { - console.error('Failed to load album tracks:', error); + console.error('Failed to load album tracks (offline-first):', error); } } } @@ -162,7 +187,7 @@ function MusicPageContent() { // Delay to ensure data is loaded const timeout = setTimeout(handleShortcuts, 1000); return () => clearTimeout(timeout); - }, [searchParams, api, isConnected, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue]); + }, [searchParams, recentAlbums, favoriteAlbums, shortcutProcessed, playAlbum, playTrack, shuffle, toggleShuffle, addToQueue, offline]); // Try to get user name from navidrome context, fallback to 'user' let userName = ''; @@ -175,6 +200,20 @@ function MusicPageContent() { return (
+ {/* Connection status (offline indicator) */} + {!offline.isOfflineMode ? null : ( +
+ +
+ )} + {/* Offline empty state when nothing is cached */} + {offline.isOfflineMode && !albumsLoading && recentAlbums.length === 0 && newestAlbums.length === 0 && favoriteAlbums.length === 0 && ( +
+

+ You are offline and no albums are cached yet. Download albums for offline use from an album page, or open Settings → Offline Library to sync your library. +

+
+ )} {/* Song Recommendations Section */}
@@ -197,7 +236,7 @@ function MusicPageContent() {
- {isLoading ? ( + {albumsLoading ? ( // Loading skeletons Array.from({ length: 10 }).map((_, i) => (
@@ -284,7 +323,7 @@ function MusicPageContent() {
- {isLoading ? ( + {albumsLoading ? ( // Loading skeletons Array.from({ length: 10 }).map((_, i) => (
diff --git a/hooks/use-offline-audio-player.ts b/hooks/use-offline-audio-player.ts index d325899..fae138d 100644 --- a/hooks/use-offline-audio-player.ts +++ b/hooks/use-offline-audio-player.ts @@ -46,6 +46,8 @@ export function useOfflineAudioPlayer() { if (offlineStatus) { track.isOffline = true; track.offlineUrl = `offline-song-${song.id}`; + // Prefer offline cached URL to avoid re-streaming even when online + track.url = track.offlineUrl; } } diff --git a/hooks/use-offline-downloads.ts b/hooks/use-offline-downloads.ts index 93cc748..a599db0 100644 --- a/hooks/use-offline-downloads.ts +++ b/hooks/use-offline-downloads.ts @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { Album, Song } from '@/lib/navidrome'; +import { Album, Song, getNavidromeAPI } from '@/lib/navidrome'; export interface DownloadProgress { completed: number; @@ -104,10 +104,14 @@ class DownloadManager { } }; - // Add stream URLs to songs + // Add direct download URLs to songs (use 'streamUrl' field name to keep SW compatibility) const songsWithUrls = songs.map(song => ({ ...song, - streamUrl: this.getStreamUrl(song.id) + streamUrl: this.getDownloadUrl(song.id), + offlineUrl: `offline-song-${song.id}`, + duration: song.duration, + bitRate: song.bitRate, + size: song.size })); this.worker!.postMessage({ @@ -120,7 +124,11 @@ class DownloadManager { async downloadSong(song: Song): Promise { const songWithUrl = { ...song, - streamUrl: this.getStreamUrl(song.id) + streamUrl: this.getDownloadUrl(song.id), + offlineUrl: `offline-song-${song.id}`, + duration: song.duration, + bitRate: song.bitRate, + size: song.size }; return this.sendMessage('DOWNLOAD_SONG', songWithUrl); @@ -129,7 +137,11 @@ class DownloadManager { async downloadQueue(songs: Song[]): Promise { const songsWithUrls = songs.map(song => ({ ...song, - streamUrl: this.getStreamUrl(song.id) + streamUrl: this.getDownloadUrl(song.id), + offlineUrl: `offline-song-${song.id}`, + duration: song.duration, + bitRate: song.bitRate, + size: song.size })); return this.sendMessage('DOWNLOAD_QUEUE', { songs: songsWithUrls }); @@ -160,15 +172,20 @@ class DownloadManager { async getOfflineStats(): Promise { return this.sendMessage('GET_OFFLINE_STATS', {}); } + + async getOfflineItems(): Promise<{ albums: OfflineItem[]; songs: OfflineItem[] }> { + return this.sendMessage('GET_OFFLINE_ITEMS', {}); + } - private getStreamUrl(songId: string): string { - // This should match your actual Navidrome stream URL format - const config = JSON.parse(localStorage.getItem('navidrome-config') || '{}'); - if (!config.serverUrl) { - throw new Error('Navidrome server not configured'); + private getDownloadUrl(songId: string): string { + const api = getNavidromeAPI(); + if (!api) throw new Error('Navidrome server not configured'); + // Use direct download to fetch original file; browser handles transcoding/decoding. + // Fall back to stream URL if the server does not allow downloads. + if (typeof (api as any).getDownloadUrl === 'function') { + return (api as any).getDownloadUrl(songId); } - - return `${config.serverUrl}/rest/stream?id=${songId}&u=${config.username}&p=${config.password}&c=mice&f=json`; + return api.getStreamUrl(songId); } // LocalStorage fallback for browsers without service worker support @@ -375,11 +392,19 @@ export function useOfflineDownloads() { } }, [isSupported]); - const getOfflineItems = useCallback((): OfflineItem[] => { + const getOfflineItems = useCallback(async (): Promise => { + if (isSupported) { + try { + const { albums, songs } = await downloadManager.getOfflineItems(); + return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt); + } catch (e) { + console.error('Failed to get offline items from SW, falling back:', e); + } + } const albums = downloadManager.getOfflineAlbums(); const songs = downloadManager.getOfflineSongs(); return [...albums, ...songs].sort((a, b) => b.downloadedAt - a.downloadedAt); - }, []); + }, [isSupported]); const clearDownloadProgress = useCallback(() => { setDownloadProgress({ diff --git a/hooks/use-offline-library-sync.ts b/hooks/use-offline-library-sync.ts new file mode 100644 index 0000000..2bd4961 --- /dev/null +++ b/hooks/use-offline-library-sync.ts @@ -0,0 +1,514 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { offlineLibraryDB, LibrarySyncStats, OfflineAlbum, OfflineArtist, OfflineSong, OfflinePlaylist } from '@/lib/indexeddb'; +import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext'; +import { useToast } from '@/hooks/use-toast'; +import { getNavidromeAPI, Song } from '@/lib/navidrome'; + +export interface LibrarySyncProgress { + phase: 'idle' | 'albums' | 'artists' | 'songs' | 'playlists' | 'operations' | 'complete' | 'error'; + current: number; + total: number; + message: string; +} + +export interface LibrarySyncOptions { + includeAlbums: boolean; + includeArtists: boolean; + includeSongs: boolean; + includePlaylists: boolean; + syncStarred: boolean; + maxSongs: number; // Limit to prevent overwhelming the database +} + +const defaultSyncOptions: LibrarySyncOptions = { + includeAlbums: true, + includeArtists: true, + includeSongs: true, + includePlaylists: true, + syncStarred: true, + maxSongs: 1000 // Default limit +}; + +export function useOfflineLibrarySync() { + const [isInitialized, setIsInitialized] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [syncProgress, setSyncProgress] = useState({ + phase: 'idle', + current: 0, + total: 0, + message: '' + }); + const [stats, setStats] = useState({ + albums: 0, + artists: 0, + songs: 0, + playlists: 0, + lastSync: null, + pendingOperations: 0, + storageSize: 0, + syncInProgress: false + }); + const [isOnline, setIsOnline] = useState(true); + const [autoSyncEnabled, setAutoSyncEnabled] = useState(false); + const [syncOptions, setSyncOptions] = useState(defaultSyncOptions); + + const { config, isConnected } = useNavidromeConfig(); + const api = useMemo(() => getNavidromeAPI(config), [config]); + const { toast } = useToast(); + const syncTimeoutRef = useRef(null); + + // Initialize the offline library database + useEffect(() => { + const initializeDB = async () => { + try { + const initialized = await offlineLibraryDB.initialize(); + setIsInitialized(initialized); + + if (initialized) { + await refreshStats(); + loadSyncSettings(); + } + } catch (error) { + console.error('Failed to initialize offline library:', error); + } + }; + + initializeDB(); + }, []); + + // Monitor online status + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + setIsOnline(navigator.onLine); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + // Auto-sync when coming back online + useEffect(() => { + if (isOnline && isConnected && autoSyncEnabled && !isSyncing) { + const pendingOpsSync = async () => { + try { + await syncPendingOperations(); + } catch (error) { + console.error('Auto-sync failed:', error); + } + }; + + // Delay auto-sync to avoid immediate trigger + syncTimeoutRef.current = setTimeout(pendingOpsSync, 2000); + } + + return () => { + if (syncTimeoutRef.current) { + clearTimeout(syncTimeoutRef.current); + } + }; + }, [isOnline, isConnected, autoSyncEnabled, isSyncing]); + + const loadSyncSettings = useCallback(async () => { + try { + const [autoSync, savedOptions] = await Promise.all([ + offlineLibraryDB.getMetadata('autoSyncEnabled'), + offlineLibraryDB.getMetadata('syncOptions') + ]); + + if (typeof autoSync === 'boolean') setAutoSyncEnabled(autoSync); + + if (savedOptions) { + setSyncOptions({ ...defaultSyncOptions, ...savedOptions }); + } + } catch (error) { + console.error('Failed to load sync settings:', error); + } + }, []); + + const refreshStats = useCallback(async () => { + if (!isInitialized) return; + + try { + const newStats = await offlineLibraryDB.getStats(); + setStats(newStats); + } catch (error) { + console.error('Failed to refresh stats:', error); + } + }, [isInitialized]); + + const updateSyncProgress = useCallback((phase: LibrarySyncProgress['phase'], current: number, total: number, message: string) => { + setSyncProgress({ phase, current, total, message }); + }, []); + + const syncLibraryFromServer = useCallback(async (options: Partial = {}) => { + if (!api || !isConnected || !isInitialized) { + throw new Error('Cannot sync: API not available or not connected'); + } + + if (isSyncing) { + throw new Error('Sync already in progress'); + } + + const actualOptions = { ...syncOptions, ...options }; + + try { + setIsSyncing(true); + await offlineLibraryDB.setMetadata('syncInProgress', true); + + updateSyncProgress('albums', 0, 0, 'Testing server connection...'); + + // Test connection first + const connected = await api.ping(); + if (!connected) { + throw new Error('No connection to Navidrome server'); + } + + let totalItems = 0; + let processedItems = 0; + + // Sync albums + if (actualOptions.includeAlbums) { + updateSyncProgress('albums', 0, 0, 'Fetching albums from server...'); + + const albums = await api.getAlbums('alphabeticalByName', 5000); + totalItems += albums.length; + + updateSyncProgress('albums', 0, albums.length, `Storing ${albums.length} albums...`); + + const mappedAlbums: OfflineAlbum[] = albums.map(album => ({ + ...album, + lastModified: Date.now(), + synced: true + })); + + await offlineLibraryDB.storeAlbums(mappedAlbums); + processedItems += albums.length; + + updateSyncProgress('albums', albums.length, albums.length, `Stored ${albums.length} albums`); + } + + // Sync artists + if (actualOptions.includeArtists) { + updateSyncProgress('artists', processedItems, totalItems, 'Fetching artists from server...'); + + const artists = await api.getArtists(); + totalItems += artists.length; + + updateSyncProgress('artists', 0, artists.length, `Storing ${artists.length} artists...`); + + const mappedArtists: OfflineArtist[] = artists.map(artist => ({ + ...artist, + lastModified: Date.now(), + synced: true + })); + + await offlineLibraryDB.storeArtists(mappedArtists); + processedItems += artists.length; + + updateSyncProgress('artists', artists.length, artists.length, `Stored ${artists.length} artists`); + } + + // Sync playlists + if (actualOptions.includePlaylists) { + updateSyncProgress('playlists', processedItems, totalItems, 'Fetching playlists from server...'); + + const playlists = await api.getPlaylists(); + totalItems += playlists.length; + + updateSyncProgress('playlists', 0, playlists.length, `Storing ${playlists.length} playlists...`); + + const mappedPlaylists: OfflinePlaylist[] = await Promise.all( + playlists.map(async (playlist) => { + try { + const playlistDetails = await api.getPlaylist(playlist.id); + return { + ...playlist, + songIds: (playlistDetails.songs || []).map((song: Song) => song.id), + lastModified: Date.now(), + synced: true + }; + } catch (error) { + console.warn(`Failed to get details for playlist ${playlist.id}:`, error); + return { + ...playlist, + songIds: [], + lastModified: Date.now(), + synced: true + }; + } + }) + ); + + await offlineLibraryDB.storePlaylists(mappedPlaylists); + processedItems += playlists.length; + + updateSyncProgress('playlists', playlists.length, playlists.length, `Stored ${playlists.length} playlists`); + } + + // Sync songs (limited to avoid overwhelming the database) + if (actualOptions.includeSongs) { + updateSyncProgress('songs', processedItems, totalItems, 'Fetching songs from server...'); + + const albums = await offlineLibraryDB.getAlbums(); + const albumsToSync = albums.slice(0, Math.floor(actualOptions.maxSongs / 10)); // Roughly 10 songs per album + + let songCount = 0; + updateSyncProgress('songs', 0, albumsToSync.length, `Processing songs for ${albumsToSync.length} albums...`); + + for (let i = 0; i < albumsToSync.length; i++) { + const album = albumsToSync[i]; + try { + const { songs } = await api.getAlbum(album.id); + + if (songCount + songs.length > actualOptions.maxSongs) { + const remaining = actualOptions.maxSongs - songCount; + if (remaining > 0) { + const limitedSongs = songs.slice(0, remaining); + const mappedSongs: OfflineSong[] = limitedSongs.map(song => ({ + ...song, + lastModified: Date.now(), + synced: true + })); + await offlineLibraryDB.storeSongs(mappedSongs); + songCount += limitedSongs.length; + } + break; + } + + const mappedSongs: OfflineSong[] = songs.map(song => ({ + ...song, + lastModified: Date.now(), + synced: true + })); + + await offlineLibraryDB.storeSongs(mappedSongs); + songCount += songs.length; + + updateSyncProgress('songs', i + 1, albumsToSync.length, `Processed ${i + 1}/${albumsToSync.length} albums (${songCount} songs)`); + } catch (error) { + console.warn(`Failed to sync songs for album ${album.id}:`, error); + } + } + + updateSyncProgress('songs', albumsToSync.length, albumsToSync.length, `Stored ${songCount} songs`); + } + + // Sync pending operations to server + updateSyncProgress('operations', 0, 0, 'Syncing pending operations...'); + await syncPendingOperations(); + + // Update sync timestamp + await offlineLibraryDB.setMetadata('lastSync', Date.now()); + + updateSyncProgress('complete', 100, 100, 'Library sync completed successfully'); + + toast({ + title: "Sync Complete", + description: `Successfully synced library data offline`, + }); + + } catch (error) { + console.error('Library sync failed:', error); + updateSyncProgress('error', 0, 0, `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + + toast({ + title: "Sync Failed", + description: error instanceof Error ? error.message : 'Unknown error occurred', + variant: "destructive" + }); + + throw error; + } finally { + setIsSyncing(false); + await offlineLibraryDB.setMetadata('syncInProgress', false); + await refreshStats(); + } + }, [api, isConnected, isInitialized, isSyncing, syncOptions, toast, updateSyncProgress, refreshStats]); + + const syncPendingOperations = useCallback(async () => { + if (!api || !isConnected || !isInitialized) { + return; + } + + try { + const operations = await offlineLibraryDB.getSyncOperations(); + + if (operations.length === 0) { + return; + } + + updateSyncProgress('operations', 0, operations.length, 'Syncing pending operations...'); + + for (let i = 0; i < operations.length; i++) { + const operation = operations[i]; + + try { + switch (operation.type) { + case 'star': + if (operation.entityType !== 'playlist') { + await api.star(operation.entityId, operation.entityType); + } + break; + case 'unstar': + if (operation.entityType !== 'playlist') { + await api.unstar(operation.entityId, operation.entityType); + } + break; + case 'scrobble': + await api.scrobble(operation.entityId); + break; + case 'create_playlist': + if ('name' in operation.data && typeof operation.data.name === 'string') { + await api.createPlaylist( + operation.data.name, + 'songIds' in operation.data ? operation.data.songIds : undefined + ); + } + break; + case 'update_playlist': + if ('name' in operation.data || 'comment' in operation.data || 'songIds' in operation.data) { + const d = operation.data as { name?: string; comment?: string; songIds?: string[] }; + await api.updatePlaylist(operation.entityId, d.name, d.comment, d.songIds); + } + break; + case 'delete_playlist': + await api.deletePlaylist(operation.entityId); + break; + } + + await offlineLibraryDB.removeSyncOperation(operation.id); + updateSyncProgress('operations', i + 1, operations.length, `Synced ${i + 1}/${operations.length} operations`); + + } catch (error) { + console.error(`Failed to sync operation ${operation.id}:`, error); + // Don't remove failed operations, they'll be retried later + } + } + + } catch (error) { + console.error('Failed to sync pending operations:', error); + } + }, [api, isConnected, isInitialized, updateSyncProgress]); + + const clearOfflineData = useCallback(async () => { + if (!isInitialized) return; + + try { + await offlineLibraryDB.clearAllData(); + await refreshStats(); + + toast({ + title: "Offline Data Cleared", + description: "All offline library data has been removed", + }); + } catch (error) { + console.error('Failed to clear offline data:', error); + toast({ + title: "Clear Failed", + description: "Failed to clear offline data", + variant: "destructive" + }); + } + }, [isInitialized, refreshStats, toast]); + + const updateAutoSync = useCallback(async (enabled: boolean) => { + setAutoSyncEnabled(enabled); + try { + await offlineLibraryDB.setMetadata('autoSyncEnabled', enabled); + } catch (error) { + console.error('Failed to save auto-sync setting:', error); + } + }, []); + + const updateSyncOptions = useCallback(async (newOptions: Partial) => { + const updatedOptions = { ...syncOptions, ...newOptions }; + setSyncOptions(updatedOptions); + + try { + await offlineLibraryDB.setMetadata('syncOptions', updatedOptions); + } catch (error) { + console.error('Failed to save sync options:', error); + } + }, [syncOptions]); + + // Offline-first operations + const starItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => { + if (!isInitialized) throw new Error('Offline library not initialized'); + + try { + await offlineLibraryDB.starItem(id, type); + await refreshStats(); + + // Try to sync immediately if online + if (isOnline && isConnected && api) { + try { + await api.star(id, type); + await offlineLibraryDB.removeSyncOperation(`star-${id}`); + } catch (error) { + console.log('Failed to sync star operation immediately, will retry later:', error); + } + } + } catch (error) { + console.error('Failed to star item:', error); + throw error; + } + }, [isInitialized, refreshStats, isOnline, isConnected, api]); + + const unstarItem = useCallback(async (id: string, type: 'song' | 'album' | 'artist') => { + if (!isInitialized) throw new Error('Offline library not initialized'); + + try { + await offlineLibraryDB.unstarItem(id, type); + await refreshStats(); + + // Try to sync immediately if online + if (isOnline && isConnected && api) { + try { + await api.unstar(id, type); + await offlineLibraryDB.removeSyncOperation(`unstar-${id}`); + } catch (error) { + console.log('Failed to sync unstar operation immediately, will retry later:', error); + } + } + } catch (error) { + console.error('Failed to unstar item:', error); + throw error; + } + }, [isInitialized, refreshStats, isOnline, isConnected, api]); + + return { + // State + isInitialized, + isSyncing, + syncProgress, + stats, + isOnline, + autoSyncEnabled, + syncOptions, + + // Actions + syncLibraryFromServer, + syncPendingOperations, + clearOfflineData, + updateAutoSync, + updateSyncOptions, + refreshStats, + starItem, + unstarItem, + + // Data access (for offline access) + getOfflineAlbums: () => offlineLibraryDB.getAlbums(), + getOfflineArtists: () => offlineLibraryDB.getArtists(), + getOfflineSongs: (albumId?: string) => offlineLibraryDB.getSongs(albumId), + getOfflinePlaylists: () => offlineLibraryDB.getPlaylists(), + getOfflineAlbum: (id: string) => offlineLibraryDB.getAlbum(id) + }; +} diff --git a/lib/indexeddb.ts b/lib/indexeddb.ts new file mode 100644 index 0000000..acbf4ed --- /dev/null +++ b/lib/indexeddb.ts @@ -0,0 +1,655 @@ +'use client'; + +export interface LibraryItem { + id: string; + lastModified: number; + synced: boolean; +} + +export interface OfflineAlbum extends LibraryItem { + name: string; + artist: string; + artistId: string; + coverArt?: string; + songCount: number; + duration: number; + playCount?: number; + created: string; + starred?: string; + year?: number; + genre?: string; +} + +export interface OfflineArtist extends LibraryItem { + name: string; + albumCount: number; + starred?: string; + coverArt?: string; +} + +export interface OfflineSong extends LibraryItem { + parent: string; + isDir: boolean; + title: string; + album: string; + artist: string; + track?: number; + year?: number; + genre?: string; + coverArt?: string; + size: number; + contentType: string; + suffix: string; + duration: number; + bitRate?: number; + path: string; + playCount?: number; + discNumber?: number; + created: string; + albumId: string; + artistId: string; + type: string; + starred?: string; +} + +export interface OfflinePlaylist extends LibraryItem { + name: string; + comment?: string; + owner: string; + public: boolean; + songCount: number; + duration: number; + created: string; + changed: string; + coverArt?: string; + songIds: string[]; +} + +export interface SyncMetadata { + key: string; + value: T; + lastUpdated: number; +} + +// Shape for queued operations' data payloads +export type SyncOperationData = + | { star: true } // star + | { star: false } // unstar + | { name: string; songIds?: string[] } // create_playlist + | { name?: string; comment?: string; songIds?: string[] } // update_playlist + | Record; // delete_playlist, scrobble, or empty + +export interface SyncOperation { + id: string; + type: 'star' | 'unstar' | 'create_playlist' | 'update_playlist' | 'delete_playlist' | 'scrobble'; + entityType: 'song' | 'album' | 'artist' | 'playlist'; + entityId: string; + data: SyncOperationData; + timestamp: number; + retryCount: number; +} + +export interface LibrarySyncStats { + albums: number; + artists: number; + songs: number; + playlists: number; + lastSync: Date | null; + pendingOperations: number; + storageSize: number; + syncInProgress: boolean; +} + +class OfflineLibraryDB { + private dbName = 'stillnavidrome-offline'; + private dbVersion = 2; + private db: IDBDatabase | null = null; + private isInitialized = false; + + async initialize(): Promise { + if (this.isInitialized && this.db) { + return true; + } + + if (!('indexedDB' in window)) { + console.warn('IndexedDB not supported'); + return false; + } + + try { + this.db = await this.openDatabase(); + this.isInitialized = true; + return true; + } catch (error) { + console.error('Failed to initialize offline library:', error); + return false; + } + } + + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.dbVersion); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Albums store + if (!db.objectStoreNames.contains('albums')) { + const albumsStore = db.createObjectStore('albums', { keyPath: 'id' }); + albumsStore.createIndex('artist', 'artist', { unique: false }); + albumsStore.createIndex('artistId', 'artistId', { unique: false }); + albumsStore.createIndex('starred', 'starred', { unique: false }); + albumsStore.createIndex('synced', 'synced', { unique: false }); + albumsStore.createIndex('lastModified', 'lastModified', { unique: false }); + } + + // Artists store + if (!db.objectStoreNames.contains('artists')) { + const artistsStore = db.createObjectStore('artists', { keyPath: 'id' }); + artistsStore.createIndex('name', 'name', { unique: false }); + artistsStore.createIndex('starred', 'starred', { unique: false }); + artistsStore.createIndex('synced', 'synced', { unique: false }); + artistsStore.createIndex('lastModified', 'lastModified', { unique: false }); + } + + // Songs store + if (!db.objectStoreNames.contains('songs')) { + const songsStore = db.createObjectStore('songs', { keyPath: 'id' }); + songsStore.createIndex('albumId', 'albumId', { unique: false }); + songsStore.createIndex('artistId', 'artistId', { unique: false }); + songsStore.createIndex('starred', 'starred', { unique: false }); + songsStore.createIndex('synced', 'synced', { unique: false }); + songsStore.createIndex('lastModified', 'lastModified', { unique: false }); + songsStore.createIndex('title', 'title', { unique: false }); + } + + // Playlists store + if (!db.objectStoreNames.contains('playlists')) { + const playlistsStore = db.createObjectStore('playlists', { keyPath: 'id' }); + playlistsStore.createIndex('name', 'name', { unique: false }); + playlistsStore.createIndex('owner', 'owner', { unique: false }); + playlistsStore.createIndex('synced', 'synced', { unique: false }); + playlistsStore.createIndex('lastModified', 'lastModified', { unique: false }); + } + + // Sync operations queue + if (!db.objectStoreNames.contains('syncQueue')) { + const syncStore = db.createObjectStore('syncQueue', { keyPath: 'id' }); + syncStore.createIndex('timestamp', 'timestamp', { unique: false }); + syncStore.createIndex('type', 'type', { unique: false }); + syncStore.createIndex('entityType', 'entityType', { unique: false }); + } + + // Metadata store for sync info and settings + if (!db.objectStoreNames.contains('metadata')) { + const metadataStore = db.createObjectStore('metadata', { keyPath: 'key' }); + } + }; + }); + } + + // Metadata operations + async setMetadata(key: string, value: T): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['metadata'], 'readwrite'); + const store = transaction.objectStore('metadata'); + + const metadata: SyncMetadata = { + key, + value, + lastUpdated: Date.now() + }; + + return new Promise((resolve, reject) => { + const request = store.put(metadata); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async getMetadata(key: string): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['metadata'], 'readonly'); + const store = transaction.objectStore('metadata'); + + return new Promise((resolve, reject) => { + const request = store.get(key); + request.onsuccess = () => { + const result = request.result as SyncMetadata | undefined; + resolve(result ? (result.value as T) : null); + }; + request.onerror = () => reject(request.error); + }); + } + + // Album operations + async storeAlbums(albums: OfflineAlbum[]): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['albums'], 'readwrite'); + const store = transaction.objectStore('albums'); + + return new Promise((resolve, reject) => { + let completed = 0; + const total = albums.length; + + if (total === 0) { + resolve(); + return; + } + + albums.forEach(album => { + const albumWithMeta = { + ...album, + lastModified: Date.now(), + synced: true + }; + + const request = store.put(albumWithMeta); + request.onsuccess = () => { + completed++; + if (completed === total) resolve(); + }; + request.onerror = () => reject(request.error); + }); + }); + } + + async getAlbums(starred?: boolean): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['albums'], 'readonly'); + const store = transaction.objectStore('albums'); + + return new Promise((resolve, reject) => { + const request = starred + ? store.index('starred').getAll(IDBKeyRange.only('starred')) + : store.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async getAlbum(id: string): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['albums'], 'readonly'); + const store = transaction.objectStore('albums'); + + return new Promise((resolve, reject) => { + const request = store.get(id); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); + } + + // Artist operations + async storeArtists(artists: OfflineArtist[]): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['artists'], 'readwrite'); + const store = transaction.objectStore('artists'); + + return new Promise((resolve, reject) => { + let completed = 0; + const total = artists.length; + + if (total === 0) { + resolve(); + return; + } + + artists.forEach(artist => { + const artistWithMeta = { + ...artist, + lastModified: Date.now(), + synced: true + }; + + const request = store.put(artistWithMeta); + request.onsuccess = () => { + completed++; + if (completed === total) resolve(); + }; + request.onerror = () => reject(request.error); + }); + }); + } + + async getArtists(starred?: boolean): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['artists'], 'readonly'); + const store = transaction.objectStore('artists'); + + return new Promise((resolve, reject) => { + const request = starred + ? store.index('starred').getAll(IDBKeyRange.only('starred')) + : store.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + // Song operations + async storeSongs(songs: OfflineSong[]): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['songs'], 'readwrite'); + const store = transaction.objectStore('songs'); + + return new Promise((resolve, reject) => { + let completed = 0; + const total = songs.length; + + if (total === 0) { + resolve(); + return; + } + + songs.forEach(song => { + const songWithMeta = { + ...song, + lastModified: Date.now(), + synced: true + }; + + const request = store.put(songWithMeta); + request.onsuccess = () => { + completed++; + if (completed === total) resolve(); + }; + request.onerror = () => reject(request.error); + }); + }); + } + + async getSongs(albumId?: string, starred?: boolean): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['songs'], 'readonly'); + const store = transaction.objectStore('songs'); + + return new Promise((resolve, reject) => { + let request: IDBRequest; + + if (albumId) { + request = store.index('albumId').getAll(IDBKeyRange.only(albumId)); + } else if (starred) { + request = store.index('starred').getAll(IDBKeyRange.only('starred')); + } else { + request = store.getAll(); + } + + request.onsuccess = () => resolve(request.result as OfflineSong[]); + request.onerror = () => reject(request.error); + }); + } + + // Playlist operations + async storePlaylists(playlists: OfflinePlaylist[]): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['playlists'], 'readwrite'); + const store = transaction.objectStore('playlists'); + + return new Promise((resolve, reject) => { + let completed = 0; + const total = playlists.length; + + if (total === 0) { + resolve(); + return; + } + + playlists.forEach(playlist => { + const playlistWithMeta = { + ...playlist, + lastModified: Date.now(), + synced: true + }; + + const request = store.put(playlistWithMeta); + request.onsuccess = () => { + completed++; + if (completed === total) resolve(); + }; + request.onerror = () => reject(request.error); + }); + }); + } + + async getPlaylists(): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['playlists'], 'readonly'); + const store = transaction.objectStore('playlists'); + + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + // Sync operations + async addSyncOperation(operation: Omit): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['syncQueue'], 'readwrite'); + const store = transaction.objectStore('syncQueue'); + + const syncOp: SyncOperation = { + ...operation, + id: crypto.randomUUID(), + timestamp: Date.now(), + retryCount: 0 + }; + + return new Promise((resolve, reject) => { + const request = store.add(syncOp); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async getSyncOperations(): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['syncQueue'], 'readonly'); + const store = transaction.objectStore('syncQueue'); + + return new Promise((resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async removeSyncOperation(id: string): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['syncQueue'], 'readwrite'); + const store = transaction.objectStore('syncQueue'); + + return new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // Statistics and management + async getStats(): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const [albums, artists, songs, playlists, syncOps, lastSyncNum] = await Promise.all([ + this.getAlbums(), + this.getArtists(), + this.getSongs(), + this.getPlaylists(), + this.getSyncOperations(), + this.getMetadata('lastSync') + ]); + + // Estimate storage size + const storageSize = await this.estimateStorageSize(); + + return { + albums: albums.length, + artists: artists.length, + songs: songs.length, + playlists: playlists.length, + lastSync: typeof lastSyncNum === 'number' ? new Date(lastSyncNum) : null, + pendingOperations: syncOps.length, + storageSize, + syncInProgress: (await this.getMetadata('syncInProgress')) ?? false + }; + } + + async estimateStorageSize(): Promise { + if (!this.db) return 0; + + try { + const estimate = await navigator.storage.estimate(); + return estimate.usage || 0; + } catch { + // Fallback estimation if storage API not available + const [albums, artists, songs, playlists] = await Promise.all([ + this.getAlbums(), + this.getArtists(), + this.getSongs(), + this.getPlaylists() + ]); + + // Rough estimation: average 2KB per item + return (albums.length + artists.length + songs.length + playlists.length) * 2048; + } + } + + async clearAllData(): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const transaction = this.db.transaction(['albums', 'artists', 'songs', 'playlists', 'syncQueue', 'metadata'], 'readwrite'); + + const stores = [ + transaction.objectStore('albums'), + transaction.objectStore('artists'), + transaction.objectStore('songs'), + transaction.objectStore('playlists'), + transaction.objectStore('syncQueue'), + transaction.objectStore('metadata') + ]; + + return new Promise((resolve, reject) => { + let completed = 0; + const total = stores.length; + + stores.forEach(store => { + const request = store.clear(); + request.onsuccess = () => { + completed++; + if (completed === total) resolve(); + }; + request.onerror = () => reject(request.error); + }); + }); + } + + // Star/unstar operations (offline-first) + async starItem(id: string, type: 'song' | 'album' | 'artist'): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const storeName = `${type}s`; + const transaction = this.db.transaction([storeName, 'syncQueue'], 'readwrite'); + const store = transaction.objectStore(storeName); + const syncStore = transaction.objectStore('syncQueue'); + + return new Promise((resolve, reject) => { + // Update the item locally first + const getRequest = store.get(id); + getRequest.onsuccess = () => { + const item = getRequest.result; + if (item) { + item.starred = 'starred'; + item.lastModified = Date.now(); + item.synced = false; + + const putRequest = store.put(item); + putRequest.onsuccess = () => { + // Add to sync queue + const syncOp: SyncOperation = { + id: crypto.randomUUID(), + type: 'star', + entityType: type, + entityId: id, + data: { star: true }, + timestamp: Date.now(), + retryCount: 0 + }; + + const syncRequest = syncStore.add(syncOp); + syncRequest.onsuccess = () => resolve(); + syncRequest.onerror = () => reject(syncRequest.error); + }; + putRequest.onerror = () => reject(putRequest.error); + } else { + reject(new Error(`${type} not found`)); + } + }; + getRequest.onerror = () => reject(getRequest.error); + }); + } + + async unstarItem(id: string, type: 'song' | 'album' | 'artist'): Promise { + if (!this.db) throw new Error('Database not initialized'); + + const storeName = `${type}s`; + const transaction = this.db.transaction([storeName, 'syncQueue'], 'readwrite'); + const store = transaction.objectStore(storeName); + const syncStore = transaction.objectStore('syncQueue'); + + return new Promise((resolve, reject) => { + const getRequest = store.get(id); + getRequest.onsuccess = () => { + const item = getRequest.result; + if (item) { + delete item.starred; + item.lastModified = Date.now(); + item.synced = false; + + const putRequest = store.put(item); + putRequest.onsuccess = () => { + const syncOp: SyncOperation = { + id: crypto.randomUUID(), + type: 'unstar', + entityType: type, + entityId: id, + data: { star: false }, + timestamp: Date.now(), + retryCount: 0 + }; + + const syncRequest = syncStore.add(syncOp); + syncRequest.onsuccess = () => resolve(); + syncRequest.onerror = () => reject(syncRequest.error); + }; + putRequest.onerror = () => reject(putRequest.error); + } else { + reject(new Error(`${type} not found`)); + } + }; + getRequest.onerror = () => reject(getRequest.error); + }); + } +} + +// Singleton instance +export const offlineLibraryDB = new OfflineLibraryDB(); \ No newline at end of file diff --git a/lib/navidrome.ts b/lib/navidrome.ts index f5909c6..f7bcfbe 100644 --- a/lib/navidrome.ts +++ b/lib/navidrome.ts @@ -330,6 +330,23 @@ class NavidromeAPI { return `${this.config.serverUrl}/rest/stream?${params.toString()}`; } + // Direct download URL (original file). Useful for offline caching where the browser can handle transcoding. + getDownloadUrl(songId: string): string { + const salt = this.generateSalt(); + const token = this.generateToken(this.config.password, salt); + + const params = new URLSearchParams({ + u: this.config.username, + t: token, + s: salt, + v: this.version, + c: this.clientName, + id: songId + }); + + return `${this.config.serverUrl}/rest/download?${params.toString()}`; + } + getCoverArtUrl(coverArtId: string, size?: number): string { const salt = this.generateSalt(); const token = this.generateToken(this.config.password, salt); diff --git a/public/sw.js b/public/sw.js index e69de29..5d7520e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -0,0 +1,680 @@ +/* + Service Worker for Mice (Navidrome client) + - App shell caching for offline load + - Audio download/cache for offline playback + - Image/runtime caching + - Message-based controls used by use-offline-downloads hook +*/ + +/* global self, caches, clients */ +const VERSION = 'v2'; +const APP_SHELL_CACHE = `mice-app-shell-${VERSION}`; +const AUDIO_CACHE = `mice-audio-${VERSION}`; +const IMAGE_CACHE = `mice-images-${VERSION}`; +const META_CACHE = `mice-meta-${VERSION}`; // stores small JSON manifests and indices + +// Core assets to precache (safe, static public files) +const APP_SHELL = [ + '/', + '/favicon.ico', + '/manifest.json', + '/icon-192.png', + '/icon-192-maskable.png', + '/icon-512.png', + '/icon-512-maskable.png', + '/apple-touch-icon.png', + '/apple-touch-icon-precomposed.png', +]; + +// Utility: post message back to a MessageChannel port safely +function replyPort(event, type, data) { + try { + if (event && event.ports && event.ports[0]) { + event.ports[0].postMessage({ type, data }); + } else if (self.clients && event.source && event.source.postMessage) { + // Fallback to client postMessage (won't carry response to specific channel) + event.source.postMessage({ type, data }); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('SW reply failed:', e); + } +} + +// Utility: fetch and put into a cache with basic error handling +async function fetchAndCache(request, cacheName) { + const cache = await caches.open(cacheName); + const req = typeof request === 'string' ? new Request(request) : request; + // Try normal fetch first to preserve CORS and headers; fall back to no-cors if it fails + let res = await fetch(req).catch(() => null); + if (!res) { + const reqNoCors = new Request(req, { mode: 'no-cors' }); + res = await fetch(reqNoCors).catch(() => null); + if (!res) throw new Error('Network failed'); + await cache.put(reqNoCors, res.clone()); + return res; + } + await cache.put(req, res.clone()); + return res; +} + +// Utility: put small JSON under META_CACHE at a logical URL key +async function putJSONMeta(keyUrl, obj) { + const cache = await caches.open(META_CACHE); + const res = new Response(JSON.stringify(obj), { + headers: { 'content-type': 'application/json', 'x-sw-meta': '1' }, + }); + await cache.put(new Request(keyUrl), res); +} + +async function getJSONMeta(keyUrl) { + const cache = await caches.open(META_CACHE); + const res = await cache.match(new Request(keyUrl)); + if (!res) return null; + try { + return await res.json(); + } catch { + return null; + } +} + +async function deleteMeta(keyUrl) { + const cache = await caches.open(META_CACHE); + await cache.delete(new Request(keyUrl)); +} + +// Manifest helpers +function albumManifestKey(albumId) { + return `/offline/albums/${encodeURIComponent(albumId)}`; +} +function songManifestKey(songId) { + return `/offline/songs/${encodeURIComponent(songId)}`; +} + +// Build cover art URL using the same auth tokens from media URL (stream or download) +function buildCoverArtUrlFromStream(streamUrl, coverArtId) { + try { + const u = new URL(streamUrl); + // copy params needed + const searchParams = new URLSearchParams(u.search); + const needed = new URLSearchParams({ + u: searchParams.get('u') || '', + t: searchParams.get('t') || '', + s: searchParams.get('s') || '', + v: searchParams.get('v') || '', + c: searchParams.get('c') || 'miceclient', + id: coverArtId || '', + }); + return `${u.origin}/rest/getCoverArt?${needed.toString()}`; + } catch { + return null; + } +} + +// Install: pre-cache app shell +self.addEventListener('install', (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(APP_SHELL_CACHE); + await cache.addAll(APP_SHELL.map((u) => new Request(u, { cache: 'reload' }))); + // Force activate new SW immediately + await self.skipWaiting(); + })() + ); +}); + +// Activate: clean old caches and claim clients +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((k) => ![APP_SHELL_CACHE, AUDIO_CACHE, IMAGE_CACHE, META_CACHE].includes(k)) + .map((k) => caches.delete(k)) + ); + await self.clients.claim(); + })() + ); +}); + +// Fetch strategy +self.addEventListener('fetch', (event) => { + const req = event.request; + const url = new URL(req.url); + + // Custom offline song mapping: /offline-song- + // Handle this EARLY, including Range requests, by mapping to the cached streamUrl + const offlineSongMatch = url.pathname.match(/^\/offline-song-([\w-]+)/); + if (offlineSongMatch) { + const songId = offlineSongMatch[1]; + event.respondWith( + (async () => { + const meta = await getJSONMeta(songManifestKey(songId)); + if (meta && meta.streamUrl) { + const cache = await caches.open(AUDIO_CACHE); + const match = await cache.match(new Request(meta.streamUrl)); + if (match) return match; + // Not cached yet: try to fetch now and cache, then return + try { + const res = await fetchAndCache(meta.streamUrl, AUDIO_CACHE); + return res; + } catch (e) { + return new Response('Offline song not available', { status: 404 }); + } + } + return new Response('Offline song not available', { status: 404 }); + })() + ); + return; + } + + // Handle HTTP Range requests for audio cached blobs (map offline-song to cached stream) + if (req.headers.get('range')) { + event.respondWith( + (async () => { + const cache = await caches.open(AUDIO_CACHE); + // Try direct match first + let cached = await cache.match(req); + if (cached) return cached; + // If this is an offline-song path, map to the original streamUrl + const offMatch = url.pathname.match(/^\/offline-song-([\w-]+)/); + if (offMatch) { + const meta = await getJSONMeta(songManifestKey(offMatch[1])); + if (meta && meta.streamUrl) { + cached = await cache.match(new Request(meta.streamUrl)); + if (cached) return cached; + } + } + // If not cached yet, fetch and cache normally; range will likely be handled by server + const res = await fetch(req); + cache.put(req, res.clone()).catch(() => {}); + return res; + })() + ); + return; + } + + // Navigation requests: network-first, fallback to cache + if (req.mode === 'navigate') { + event.respondWith( + (async () => { + try { + const fresh = await fetch(req); + const cache = await caches.open(APP_SHELL_CACHE); + cache.put(req, fresh.clone()).catch(() => {}); + return fresh; + } catch { + const cache = await caches.open(APP_SHELL_CACHE); + const cached = await cache.match(req); + if (cached) return cached; + // final fallback to index + return (await cache.match('/')) || Response.error(); + } + })() + ); + return; + } + + // Images: cache-first + if (req.destination === 'image') { + event.respondWith( + (async () => { + const cache = await caches.open(IMAGE_CACHE); + const cached = await cache.match(req); + if (cached) return cached; + try { + const res = await fetch(req); + cache.put(req, res.clone()).catch(() => {}); + return res; + } catch { + // fall back + return cached || Response.error(); + } + })() + ); + return; + } + + // Scripts, styles, fonts, and Next.js assets: cache-first for offline boot + if ( + req.destination === 'script' || + req.destination === 'style' || + req.destination === 'font' || + req.url.includes('/_next/') + ) { + event.respondWith( + (async () => { + const cache = await caches.open(APP_SHELL_CACHE); + const cached = await cache.match(req); + if (cached) return cached; + try { + const res = await fetch(req); + cache.put(req, res.clone()).catch(() => {}); + return res; + } catch { + return cached || Response.error(); + } + })() + ); + return; + } + + // Audio and media: cache-first (to support offline playback) + if (req.destination === 'audio' || /\/rest\/(stream|download)/.test(req.url)) { + event.respondWith( + (async () => { + const cache = await caches.open(AUDIO_CACHE); + const cached = await cache.match(req); + if (cached) return cached; + try { + // Try normal fetch; if CORS blocks, fall back to no-cors and still cache opaque + let res = await fetch(req); + if (!res || !res.ok) { + res = await fetch(new Request(req, { mode: 'no-cors' })); + } + cache.put(req, res.clone()).catch(() => {}); + return res; + } catch { + // Fallback: if this is /rest/stream with an id, try to serve cached by stored meta + try { + const u = new URL(req.url); + if (/\/rest\/(stream|download)/.test(u.pathname)) { + const id = u.searchParams.get('id'); + if (id) { + const meta = await getJSONMeta(songManifestKey(id)); + if (meta && meta.streamUrl) { + const alt = await cache.match(new Request(meta.streamUrl)); + if (alt) return alt; + } + } + } + } catch {} + return cached || Response.error(); + } + })() + ); + return; + } + + // Default: try network, fallback to cache + event.respondWith( + (async () => { + try { + return await fetch(req); + } catch { + const cache = await caches.open(APP_SHELL_CACHE); + const cached = await cache.match(req); + if (cached) return cached; + return Response.error(); + } + })() + ); +}); + +// Message handlers for offline downloads and controls +self.addEventListener('message', (event) => { + const { type, data } = event.data || {}; + switch (type) { + case 'DOWNLOAD_ALBUM': + handleDownloadAlbum(event, data); + break; + case 'DOWNLOAD_SONG': + handleDownloadSong(event, data); + break; + case 'DOWNLOAD_QUEUE': + handleDownloadQueue(event, data); + break; + case 'ENABLE_OFFLINE_MODE': + // Store a simple flag in META_CACHE + (async () => { + await putJSONMeta('/offline/settings', { ...data, updatedAt: Date.now() }); + replyPort(event, 'ENABLE_OFFLINE_MODE_OK', { ok: true }); + })(); + break; + case 'CHECK_OFFLINE_STATUS': + (async () => { + const { id, type: entityType } = data || {}; + let isAvailable = false; + if (entityType === 'album') { + const manifest = await getJSONMeta(albumManifestKey(id)); + isAvailable = !!manifest && Array.isArray(manifest.songIds) && manifest.songIds.length > 0; + } else if (entityType === 'song') { + const songMeta = await getJSONMeta(songManifestKey(id)); + if (songMeta && songMeta.streamUrl) { + const cache = await caches.open(AUDIO_CACHE); + const match = await cache.match(new Request(songMeta.streamUrl)); + isAvailable = !!match; + } + } + replyPort(event, 'CHECK_OFFLINE_STATUS_OK', { isAvailable }); + })(); + break; + case 'DELETE_OFFLINE_CONTENT': + (async () => { + try { + const { id, type: entityType } = data || {}; + if (entityType === 'album') { + const manifest = await getJSONMeta(albumManifestKey(id)); + if (manifest && Array.isArray(manifest.songIds)) { + const cache = await caches.open(AUDIO_CACHE); + for (const s of manifest.songIds) { + const songMeta = await getJSONMeta(songManifestKey(s)); + if (songMeta && songMeta.streamUrl) { + await cache.delete(new Request(songMeta.streamUrl)); + await deleteMeta(songManifestKey(s)); + } + } + } + await deleteMeta(albumManifestKey(id)); + } else if (entityType === 'song') { + const songMeta = await getJSONMeta(songManifestKey(id)); + if (songMeta && songMeta.streamUrl) { + const cache = await caches.open(AUDIO_CACHE); + await cache.delete(new Request(songMeta.streamUrl)); + } + await deleteMeta(songManifestKey(id)); + } + replyPort(event, 'DELETE_OFFLINE_CONTENT_OK', { ok: true }); + } catch (e) { + replyPort(event, 'DELETE_OFFLINE_CONTENT_ERROR', { error: String(e) }); + } + })(); + break; + case 'GET_OFFLINE_STATS': + (async () => { + try { + const audioCache = await caches.open(AUDIO_CACHE); + const imageCache = await caches.open(IMAGE_CACHE); + const audioReqs = await audioCache.keys(); + const imageReqs = await imageCache.keys(); + const totalItems = audioReqs.length + imageReqs.length; + // Size estimation is limited (opaque responses). We'll count items and attempt content-length. + let totalSize = 0; + let audioSize = 0; + let imageSize = 0; + async function sumCache(cache, reqs) { + let sum = 0; + for (const r of reqs) { + const res = await cache.match(r); + if (!res) continue; + const lenHeader = res.headers.get('content-length'); + const len = Number(lenHeader || '0'); + if (!isNaN(len) && len > 0) { + sum += len; + } else { + // Try estimate using song manifest bitrate and duration if available + try { + const u = new URL(r.url); + if (/\/rest\/stream/.test(u.pathname)) { + const id = u.searchParams.get('id'); + if (id) { + const meta = await getJSONMeta(songManifestKey(id)); + if (meta) { + if (meta.size && Number.isFinite(meta.size)) { + sum += Number(meta.size); + } else if (meta.duration) { + // If bitrate known, use it, else assume 192 kbps + const kbps = meta.bitRate || 192; + const bytes = Math.floor((kbps * 1000 / 8) * meta.duration); + sum += bytes; + } + } + } + } + } catch {} + } + } + return sum; + } + audioSize = await sumCache(audioCache, audioReqs); + imageSize = await sumCache(imageCache, imageReqs); + totalSize = audioSize + imageSize; + // Derive counts of albums/songs from manifests + const metaCache = await caches.open(META_CACHE); + const metaKeys = await metaCache.keys(); + const downloadedAlbums = metaKeys.filter((k) => /\/offline\/albums\//.test(k.url)).length; + const downloadedSongs = metaKeys.filter((k) => /\/offline\/songs\//.test(k.url)).length; + replyPort(event, 'GET_OFFLINE_STATS_OK', { + totalSize, + audioSize, + imageSize, + metaSize: 0, + downloadedAlbums, + downloadedSongs, + totalItems, + }); + } catch (e) { + replyPort(event, 'GET_OFFLINE_STATS_ERROR', { error: String(e) }); + } + })(); + break; + case 'GET_OFFLINE_ITEMS': + (async () => { + try { + const metaCache = await caches.open(META_CACHE); + const keys = await metaCache.keys(); + const albums = []; + const songs = []; + for (const req of keys) { + if (/\/offline\/albums\//.test(req.url)) { + const res = await metaCache.match(req); + if (res) { + const json = await res.json().catch(() => null); + if (json) { + albums.push({ + id: json.id, + type: 'album', + name: json.name, + artist: json.artist, + downloadedAt: json.downloadedAt || Date.now(), + }); + } + } + } else if (/\/offline\/songs\//.test(req.url)) { + const res = await metaCache.match(req); + if (res) { + const json = await res.json().catch(() => null); + if (json) { + songs.push({ + id: json.id, + type: 'song', + name: json.title, + artist: json.artist, + downloadedAt: json.downloadedAt || Date.now(), + }); + } + } + } + } + replyPort(event, 'GET_OFFLINE_ITEMS_OK', { albums, songs }); + } catch (e) { + replyPort(event, 'GET_OFFLINE_ITEMS_ERROR', { error: String(e) }); + } + })(); + break; + default: + // no-op + break; + } +}); + +async function handleDownloadAlbum(event, payload) { + try { + const { album, songs } = payload || {}; + if (!album || !Array.isArray(songs)) throw new Error('Invalid album payload'); + + const songIds = []; + let completed = 0; + const total = songs.length; + + for (const song of songs) { + songIds.push(song.id); + try { + if (!song.streamUrl) throw new Error('Missing streamUrl'); + try { + await fetchAndCache(song.streamUrl, AUDIO_CACHE); + } catch (err) { + try { + const u = new URL(song.streamUrl); + if (/\/rest\/download/.test(u.pathname)) { + u.pathname = u.pathname.replace('/rest/download', '/rest/stream'); + await fetchAndCache(u.toString(), AUDIO_CACHE); + song.streamUrl = u.toString(); + } else { + throw err; + } + } catch (e2) { + throw e2; + } + } + // Save per-song meta for quick lookup + await putJSONMeta(songManifestKey(song.id), { + id: song.id, + streamUrl: song.streamUrl, + albumId: song.albumId, + title: song.title, + artist: song.artist, + duration: song.duration, + bitRate: song.bitRate, + size: song.size, + downloadedAt: Date.now(), + }); + completed += 1; + replyPort(event, 'DOWNLOAD_PROGRESS', { + completed, + total, + failed: 0, + status: 'downloading', + currentSong: song.title, + }); + } catch (e) { + replyPort(event, 'DOWNLOAD_PROGRESS', { + completed, + total, + failed: 1, + status: 'downloading', + currentSong: song.title, + error: String(e), + }); + } + } + + // Save album manifest + await putJSONMeta(albumManifestKey(album.id), { + id: album.id, + name: album.name, + artist: album.artist, + songIds, + downloadedAt: Date.now(), + }); + + // Optionally cache cover art + try { + if (songs[0] && songs[0].streamUrl && (album.coverArt || songs[0].coverArt)) { + const coverArtUrl = buildCoverArtUrlFromStream(songs[0].streamUrl, album.coverArt || songs[0].coverArt); + if (coverArtUrl) await fetchAndCache(coverArtUrl, IMAGE_CACHE); + } + } catch { + // ignore cover art failures + } + + replyPort(event, 'DOWNLOAD_COMPLETE', { ok: true }); + } catch (e) { + replyPort(event, 'DOWNLOAD_ERROR', { error: String(e) }); + } +} + +async function handleDownloadSong(event, song) { + try { + if (!song || !song.id || !song.streamUrl) throw new Error('Invalid song payload'); + try { + await fetchAndCache(song.streamUrl, AUDIO_CACHE); + } catch (err) { + try { + const u = new URL(song.streamUrl); + if (/\/rest\/download/.test(u.pathname)) { + u.pathname = u.pathname.replace('/rest/download', '/rest/stream'); + await fetchAndCache(u.toString(), AUDIO_CACHE); + song.streamUrl = u.toString(); + } else { + throw err; + } + } catch (e2) { + throw e2; + } + } + await putJSONMeta(songManifestKey(song.id), { + id: song.id, + streamUrl: song.streamUrl, + albumId: song.albumId, + title: song.title, + artist: song.artist, + duration: song.duration, + bitRate: song.bitRate, + size: song.size, + downloadedAt: Date.now(), + }); + replyPort(event, 'DOWNLOAD_COMPLETE', { ok: true }); + } catch (e) { + replyPort(event, 'DOWNLOAD_ERROR', { error: String(e) }); + } +} + +async function handleDownloadQueue(event, payload) { + try { + const { songs } = payload || {}; + if (!Array.isArray(songs)) throw new Error('Invalid queue payload'); + let completed = 0; + const total = songs.length; + for (const song of songs) { + try { + if (!song.streamUrl) throw new Error('Missing streamUrl'); + try { + await fetchAndCache(song.streamUrl, AUDIO_CACHE); + } catch (err) { + const u = new URL(song.streamUrl); + if (/\/rest\/download/.test(u.pathname)) { + u.pathname = u.pathname.replace('/rest/download', '/rest/stream'); + await fetchAndCache(u.toString(), AUDIO_CACHE); + song.streamUrl = u.toString(); + } else { + throw err; + } + } + await putJSONMeta(songManifestKey(song.id), { + id: song.id, + streamUrl: song.streamUrl, + albumId: song.albumId, + title: song.title, + artist: song.artist, + duration: song.duration, + bitRate: song.bitRate, + size: song.size, + downloadedAt: Date.now(), + }); + completed += 1; + replyPort(event, 'DOWNLOAD_PROGRESS', { + completed, + total, + failed: 0, + status: 'downloading', + currentSong: song.title, + }); + } catch (e) { + replyPort(event, 'DOWNLOAD_PROGRESS', { + completed, + total, + failed: 1, + status: 'downloading', + currentSong: song?.title, + error: String(e), + }); + } + } + replyPort(event, 'DOWNLOAD_COMPLETE', { ok: true }); + } catch (e) { + replyPort(event, 'DOWNLOAD_ERROR', { error: String(e) }); + } +} +