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.
This commit is contained in:
2025-08-08 20:04:06 +00:00
committed by GitHub
parent f6a6ee5d2e
commit ba84271d78
13 changed files with 2102 additions and 113 deletions

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=0c32c05
NEXT_PUBLIC_COMMIT_SHA=0a0feb3

35
.vscode/launch.json vendored
View File

@@ -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": ["<node_internals>/**"],
"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": ["<node_internals>/**"]
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "openExternally",
"pattern": "http://localhost:40625"
}
}
]
}

View File

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

View File

View File

@@ -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<Song[]>([]);
@@ -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<string, boolean> = {};
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<string, boolean> = {};
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<string, boolean> = {};
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"
>
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
{album.coverArt && api ? (
{album.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
<Image
src={api.getCoverArtUrl(album.coverArt, 300)}
src={getNavidromeAPI()!.getCoverArtUrl(album.coverArt, 300)}
alt={album.name}
width={600}
height={600}
@@ -281,10 +305,10 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
<CardContent className="px-2">
<div className="flex items-center gap-3">
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
{song.coverArt && api ? (
{song.coverArt && !offline.isOfflineMode && getNavidromeAPI() ? (
<>
<Image
src={api.getCoverArtUrl(song.coverArt, 48)}
src={getNavidromeAPI()!.getCoverArtUrl(song.coverArt, 48)}
alt={song.title}
fill
className="object-cover"

View File

@@ -17,6 +17,7 @@ import {
} from "../../components/ui/context-menu"
import { useNavidrome } from "./NavidromeContext"
import { useOfflineNavidrome } from "./OfflineNavidromeProvider"
import Link from "next/link";
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
import { getNavidromeAPI } from "@/lib/navidrome";
@@ -44,6 +45,7 @@ export function AlbumArtwork({
...props
}: AlbumArtworkProps) {
const { api, isConnected } = useNavidrome();
const offline = useOfflineNavidrome();
const router = useRouter();
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
const { playlists, starItem, unstarItem } = useNavidrome();
@@ -129,7 +131,7 @@ export function AlbumArtwork({
<ContextMenuTrigger>
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
<div className="aspect-square relative group">
{album.coverArt && api ? (
{album.coverArt && api && !offline.isOfflineMode ? (
<Image
src={coverArtUrl}
alt={album.name}

View File

@@ -4,9 +4,9 @@ import { ScrollArea, ScrollBar } from '../components/ui/scroll-area';
import { Separator } from '../components/ui/separator';
import { Tabs, TabsContent } from '../components/ui/tabs';
import { AlbumArtwork } from './components/album-artwork';
import { useNavidrome } from './components/NavidromeContext';
import { useOfflineNavidrome } from './components/OfflineNavidromeProvider';
import { useEffect, useState, Suspense } from 'react';
import { Album } from '@/lib/navidrome';
import { Album, Song, getNavidromeAPI } from '@/lib/navidrome';
import { useNavidromeConfig } from './components/NavidromeConfigContext';
import { useSearchParams } from 'next/navigation';
import { useAudioPlayer } from './components/AudioPlayerContext';
@@ -14,52 +14,75 @@ import { SongRecommendations } from './components/SongRecommendations';
import { Skeleton } from '@/components/ui/skeleton';
import { useIsMobile } from '@/hooks/use-mobile';
import { UserProfile } from './components/UserProfile';
import { OfflineStatusIndicator } from './components/OfflineStatusIndicator';
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
function MusicPageContent() {
const { albums, isLoading, api, isConnected } = useNavidrome();
// Offline-first provider (falls back to offline data when not connected)
const offline = useOfflineNavidrome();
const { playAlbum, playTrack, shuffle, toggleShuffle, addToQueue } = useAudioPlayer();
const searchParams = useSearchParams();
const [allAlbums, setAllAlbums] = useState<Album[]>([]);
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
const [newestAlbums, setNewestAlbums] = useState<Album[]>([]);
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
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 (
<div className="p-6 pb-24 w-full">
{/* Connection status (offline indicator) */}
{!offline.isOfflineMode ? null : (
<div className="mb-4">
<OfflineStatusIndicator />
</div>
)}
{/* Offline empty state when nothing is cached */}
{offline.isOfflineMode && !albumsLoading && recentAlbums.length === 0 && newestAlbums.length === 0 && favoriteAlbums.length === 0 && (
<div className="mb-6 p-4 border rounded-lg bg-muted/30">
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
)}
{/* Song Recommendations Section */}
<div className="mb-8">
<SongRecommendations userName={userName} />
@@ -197,7 +236,7 @@ function MusicPageContent() {
<div className="relative">
<ScrollArea>
<div className="flex space-x-4 pb-4">
{isLoading ? (
{albumsLoading ? (
// Loading skeletons
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3">
@@ -284,7 +323,7 @@ function MusicPageContent() {
<div className="relative">
<ScrollArea>
<div className="flex space-x-4 pb-4">
{isLoading ? (
{albumsLoading ? (
// Loading skeletons
Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="w-[220px] shrink-0 space-y-3">

View File

@@ -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;
}
}

View File

@@ -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<void> {
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<void> {
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 });
@@ -161,14 +173,19 @@ class DownloadManager {
return this.sendMessage('GET_OFFLINE_STATS', {});
}
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');
}
async getOfflineItems(): Promise<{ albums: OfflineItem[]; songs: OfflineItem[] }> {
return this.sendMessage('GET_OFFLINE_ITEMS', {});
}
return `${config.serverUrl}/rest/stream?id=${songId}&u=${config.username}&p=${config.password}&c=mice&f=json`;
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 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<OfflineItem[]> => {
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({

View File

@@ -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<LibrarySyncProgress>({
phase: 'idle',
current: 0,
total: 0,
message: ''
});
const [stats, setStats] = useState<LibrarySyncStats>({
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<LibrarySyncOptions>(defaultSyncOptions);
const { config, isConnected } = useNavidromeConfig();
const api = useMemo(() => getNavidromeAPI(config), [config]);
const { toast } = useToast();
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(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<boolean>('autoSyncEnabled'),
offlineLibraryDB.getMetadata<LibrarySyncOptions>('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<LibrarySyncOptions> = {}) => {
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<LibrarySyncOptions>) => {
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)
};
}

655
lib/indexeddb.ts Normal file
View File

@@ -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<T = unknown> {
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<string, never>; // 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<boolean> {
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<IDBDatabase> {
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<T>(key: string, value: T): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(['metadata'], 'readwrite');
const store = transaction.objectStore('metadata');
const metadata: SyncMetadata<T> = {
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<T = unknown>(key: string): Promise<T | null> {
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<T> | undefined;
resolve(result ? (result.value as T) : null);
};
request.onerror = () => reject(request.error);
});
}
// Album operations
async storeAlbums(albums: OfflineAlbum[]): Promise<void> {
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<OfflineAlbum[]> {
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<OfflineAlbum | null> {
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<void> {
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<OfflineArtist[]> {
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<void> {
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<OfflineSong[]> {
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<OfflineSong[]>;
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<void> {
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<OfflinePlaylist[]> {
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<SyncOperation, 'id' | 'timestamp' | 'retryCount'>): Promise<void> {
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<SyncOperation[]> {
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<void> {
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<LibrarySyncStats> {
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<number>('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<boolean>('syncInProgress')) ?? false
};
}
async estimateStorageSize(): Promise<number> {
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<void> {
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<void> {
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<void> {
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();

View File

@@ -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);

View File

@@ -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-<songId>
// 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) });
}
}