'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) }; }