feat: Implement InfiniteScroll component for loading more items on scroll feat: Create useListeningStreak hook to manage listening streak data and statistics feat: Develop useProgressiveAlbumLoading hook for progressive loading of albums feat: Implement background sync service worker for automatic data synchronization
539 lines
17 KiB
JavaScript
539 lines
17 KiB
JavaScript
// Background sync service worker for StillNavidrome
|
|
// This enhances the main service worker to support automatic background sync
|
|
|
|
// Cache version identifier - update when cache structure changes
|
|
const BACKGROUND_SYNC_CACHE = 'stillnavidrome-background-sync-v1';
|
|
|
|
// Interval for background sync (in minutes)
|
|
const SYNC_INTERVAL_MINUTES = 60;
|
|
|
|
// List of APIs to keep fresh in cache
|
|
const BACKGROUND_SYNC_APIS = [
|
|
'/api/getAlbums',
|
|
'/api/getArtists',
|
|
'/api/getPlaylists',
|
|
'/rest/getStarred',
|
|
'/rest/getPlayQueue',
|
|
'/rest/getRecentlyPlayed'
|
|
];
|
|
|
|
// Listen for the install event
|
|
self.addEventListener('install', (event) => {
|
|
console.log('[Background Sync] Service worker installing...');
|
|
event.waitUntil(
|
|
// Create cache for background sync
|
|
caches.open(BACKGROUND_SYNC_CACHE).then(cache => {
|
|
console.log('[Background Sync] Cache opened');
|
|
// Initial cache population would happen in the activate event
|
|
// to avoid any conflicts with the main service worker
|
|
})
|
|
);
|
|
});
|
|
|
|
// Listen for the activate event
|
|
self.addEventListener('activate', (event) => {
|
|
console.log('[Background Sync] Service worker activating...');
|
|
event.waitUntil(
|
|
caches.keys().then(cacheNames => {
|
|
return Promise.all(
|
|
cacheNames.map(cacheName => {
|
|
// Delete any old caches that don't match our current version
|
|
if (cacheName.startsWith('stillnavidrome-background-sync-') && cacheName !== BACKGROUND_SYNC_CACHE) {
|
|
console.log('[Background Sync] Deleting old cache:', cacheName);
|
|
return caches.delete(cacheName);
|
|
}
|
|
})
|
|
);
|
|
})
|
|
);
|
|
|
|
// Start background sync scheduler
|
|
initBackgroundSync();
|
|
});
|
|
|
|
// Initialize background sync scheduler
|
|
function initBackgroundSync() {
|
|
console.log('[Background Sync] Initializing background sync scheduler');
|
|
|
|
// Set up periodic sync if available (modern browsers)
|
|
if ('periodicSync' in self.registration) {
|
|
self.registration.periodicSync.register('background-library-sync', {
|
|
minInterval: SYNC_INTERVAL_MINUTES * 60 * 1000 // Convert to milliseconds
|
|
}).then(() => {
|
|
console.log('[Background Sync] Registered periodic sync');
|
|
}).catch(error => {
|
|
console.error('[Background Sync] Failed to register periodic sync:', error);
|
|
// Fall back to manual interval as backup
|
|
setupManualSyncInterval();
|
|
});
|
|
} else {
|
|
// Fall back to manual interval for browsers without periodicSync
|
|
console.log('[Background Sync] PeriodicSync not available, using manual interval');
|
|
setupManualSyncInterval();
|
|
}
|
|
}
|
|
|
|
// Set up manual sync interval as fallback
|
|
function setupManualSyncInterval() {
|
|
// Use service worker's setInterval (be careful with this in production)
|
|
setInterval(() => {
|
|
if (navigator.onLine) {
|
|
console.log('[Background Sync] Running manual background sync');
|
|
performBackgroundSync();
|
|
}
|
|
}, SYNC_INTERVAL_MINUTES * 60 * 1000); // Convert to milliseconds
|
|
}
|
|
|
|
// Listen for periodic sync events
|
|
self.addEventListener('periodicsync', (event) => {
|
|
if (event.tag === 'background-library-sync') {
|
|
console.log('[Background Sync] Periodic sync event triggered');
|
|
event.waitUntil(performBackgroundSync());
|
|
}
|
|
});
|
|
|
|
// Listen for message events (for manual sync triggers)
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'TRIGGER_BACKGROUND_SYNC') {
|
|
console.log('[Background Sync] Manual sync triggered from client');
|
|
event.waitUntil(performBackgroundSync().then(() => {
|
|
// Notify the client that sync is complete
|
|
if (event.ports && event.ports[0]) {
|
|
event.ports[0].postMessage({ type: 'BACKGROUND_SYNC_COMPLETE' });
|
|
}
|
|
}));
|
|
}
|
|
});
|
|
|
|
// Perform the actual background sync
|
|
async function performBackgroundSync() {
|
|
console.log('[Background Sync] Starting background sync');
|
|
|
|
// Check if we're online before attempting sync
|
|
if (!navigator.onLine) {
|
|
console.log('[Background Sync] Device is offline, skipping sync');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Get server config from IndexedDB
|
|
const config = await getNavidromeConfig();
|
|
|
|
if (!config || !config.serverUrl) {
|
|
console.log('[Background Sync] No server configuration found, skipping sync');
|
|
return;
|
|
}
|
|
|
|
// Get authentication token
|
|
const authToken = await getAuthToken(config);
|
|
|
|
if (!authToken) {
|
|
console.log('[Background Sync] Failed to get auth token, skipping sync');
|
|
return;
|
|
}
|
|
|
|
// Perform API requests to refresh cache
|
|
const apiResponses = await Promise.all(BACKGROUND_SYNC_APIS.map(apiPath => {
|
|
return refreshApiCache(config.serverUrl, apiPath, authToken);
|
|
}));
|
|
|
|
// Process recently played data to update listening streak
|
|
await updateListeningStreakData(apiResponses);
|
|
|
|
// Update last sync timestamp
|
|
await updateLastSyncTimestamp();
|
|
|
|
// Notify clients about successful sync
|
|
const clients = await self.clients.matchAll();
|
|
clients.forEach(client => {
|
|
client.postMessage({
|
|
type: 'BACKGROUND_SYNC_COMPLETE',
|
|
timestamp: Date.now()
|
|
});
|
|
});
|
|
|
|
console.log('[Background Sync] Background sync completed successfully');
|
|
} catch (error) {
|
|
console.error('[Background Sync] Error during background sync:', error);
|
|
}
|
|
}
|
|
|
|
// Get Navidrome config from IndexedDB
|
|
async function getNavidromeConfig() {
|
|
return new Promise((resolve) => {
|
|
// Try to get from localStorage first (simplest approach)
|
|
if (typeof self.localStorage !== 'undefined') {
|
|
try {
|
|
const configJson = self.localStorage.getItem('navidrome-config');
|
|
if (configJson) {
|
|
resolve(JSON.parse(configJson));
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.error('[Background Sync] Error reading from localStorage:', e);
|
|
}
|
|
}
|
|
|
|
// Fallback to IndexedDB
|
|
const request = indexedDB.open('stillnavidrome-offline', 1);
|
|
|
|
request.onerror = () => {
|
|
console.error('[Background Sync] Failed to open IndexedDB');
|
|
resolve(null);
|
|
};
|
|
|
|
request.onsuccess = (event) => {
|
|
const db = event.target.result;
|
|
const transaction = db.transaction(['metadata'], 'readonly');
|
|
const store = transaction.objectStore('metadata');
|
|
const getRequest = store.get('navidrome-config');
|
|
|
|
getRequest.onsuccess = () => {
|
|
resolve(getRequest.result ? getRequest.result.value : null);
|
|
};
|
|
|
|
getRequest.onerror = () => {
|
|
console.error('[Background Sync] Error getting config from IndexedDB');
|
|
resolve(null);
|
|
};
|
|
};
|
|
|
|
request.onupgradeneeded = () => {
|
|
// This shouldn't happen here - the DB should already be set up
|
|
console.error('[Background Sync] IndexedDB needs upgrade, skipping config retrieval');
|
|
resolve(null);
|
|
};
|
|
});
|
|
}
|
|
|
|
// Get authentication token for API requests
|
|
async function getAuthToken(config) {
|
|
try {
|
|
const response = await fetch(`${config.serverUrl}/rest/ping`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': 'Basic ' + btoa(`${config.username}:${config.password}`)
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Auth failed with status: ${response.status}`);
|
|
}
|
|
|
|
// Extract token from response
|
|
const data = await response.json();
|
|
return data.token || null;
|
|
} catch (error) {
|
|
console.error('[Background Sync] Authentication error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Refresh specific API cache
|
|
async function refreshApiCache(serverUrl, apiPath, authToken) {
|
|
try {
|
|
// Construct API URL
|
|
const apiUrl = `${serverUrl}${apiPath}`;
|
|
|
|
// Make the request with authentication
|
|
const response = await fetch(apiUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${authToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API request failed with status: ${response.status}`);
|
|
}
|
|
|
|
// Clone the response to store in cache
|
|
const responseToCache = response.clone();
|
|
|
|
// Open the cache and store the response
|
|
const cache = await caches.open(BACKGROUND_SYNC_CACHE);
|
|
await cache.put(apiUrl, responseToCache);
|
|
|
|
console.log(`[Background Sync] Successfully updated cache for: ${apiPath}`);
|
|
return response.json(); // Return parsed data for potential use
|
|
} catch (error) {
|
|
console.error(`[Background Sync] Failed to refresh cache for ${apiPath}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Process recently played data to update listening streak
|
|
async function updateListeningStreakData(apiResponses) {
|
|
try {
|
|
// Find the recently played response
|
|
const recentlyPlayedResponse = apiResponses.find(response =>
|
|
response && response.data && Array.isArray(response.data.song)
|
|
);
|
|
|
|
if (!recentlyPlayedResponse) {
|
|
console.log('[Background Sync] No recently played data found');
|
|
return;
|
|
}
|
|
|
|
const recentlyPlayed = recentlyPlayedResponse.data.song;
|
|
if (!recentlyPlayed || recentlyPlayed.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Get existing streak data
|
|
let streakData;
|
|
try {
|
|
const streakDataRaw = localStorage.getItem('navidrome-streak-data');
|
|
const streakStats = localStorage.getItem('navidrome-streak-stats');
|
|
|
|
if (streakDataRaw && streakStats) {
|
|
const dataMap = new Map();
|
|
const parsedData = JSON.parse(streakDataRaw);
|
|
|
|
// Reconstruct the streak data
|
|
Object.entries(parsedData).forEach(([key, value]) => {
|
|
dataMap.set(key, {
|
|
...value,
|
|
uniqueArtists: new Set(value.uniqueArtists),
|
|
uniqueAlbums: new Set(value.uniqueAlbums)
|
|
});
|
|
});
|
|
|
|
streakData = {
|
|
data: dataMap,
|
|
stats: JSON.parse(streakStats)
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.error('[Background Sync] Failed to parse existing streak data:', e);
|
|
return;
|
|
}
|
|
|
|
if (!streakData) {
|
|
console.log('[Background Sync] No existing streak data found');
|
|
return;
|
|
}
|
|
|
|
// Process recently played tracks
|
|
let updated = false;
|
|
recentlyPlayed.forEach(track => {
|
|
if (!track.played) return;
|
|
|
|
// Parse play date (format: 2023-10-15T14:32:45Z)
|
|
const playDate = new Date(track.played);
|
|
const dateKey = playDate.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
|
|
// If we already have data for this date, update it
|
|
let dayData = streakData.data.get(dateKey);
|
|
if (!dayData) {
|
|
// Create new day data
|
|
dayData = {
|
|
date: dateKey,
|
|
tracks: 0,
|
|
uniqueArtists: new Set(),
|
|
uniqueAlbums: new Set(),
|
|
totalListeningTime: 0
|
|
};
|
|
}
|
|
|
|
// Update day data with this track
|
|
dayData.tracks += 1;
|
|
dayData.uniqueArtists.add(track.artistId);
|
|
dayData.uniqueAlbums.add(track.albumId);
|
|
dayData.totalListeningTime += track.duration || 0;
|
|
|
|
// Update the map
|
|
streakData.data.set(dateKey, dayData);
|
|
updated = true;
|
|
});
|
|
|
|
// If we updated streak data, save it back
|
|
if (updated) {
|
|
// Convert Map to a plain object for serialization
|
|
const dataObject = {};
|
|
|
|
streakData.data.forEach((value, key) => {
|
|
dataObject[key] = {
|
|
...value,
|
|
uniqueArtists: Array.from(value.uniqueArtists),
|
|
uniqueAlbums: Array.from(value.uniqueAlbums)
|
|
};
|
|
});
|
|
|
|
// Update stats based on new data
|
|
const updatedStats = calculateStreakStats(streakData.data);
|
|
|
|
// Save back to localStorage
|
|
localStorage.setItem('navidrome-streak-data', JSON.stringify(dataObject));
|
|
localStorage.setItem('navidrome-streak-stats', JSON.stringify(updatedStats));
|
|
|
|
console.log('[Background Sync] Updated listening streak data');
|
|
}
|
|
} catch (error) {
|
|
console.error('[Background Sync] Failed to update listening streak data:', error);
|
|
}
|
|
}
|
|
|
|
// Calculate streak statistics based on data
|
|
function calculateStreakStats(streakData) {
|
|
const STREAK_THRESHOLD_TRACKS = 3;
|
|
const STREAK_THRESHOLD_TIME = 5 * 60; // 5 minutes
|
|
|
|
// Get active days (that meet threshold)
|
|
const activeDays = [];
|
|
streakData.forEach((dayData, dateKey) => {
|
|
if (dayData.tracks >= STREAK_THRESHOLD_TRACKS ||
|
|
dayData.totalListeningTime >= STREAK_THRESHOLD_TIME) {
|
|
activeDays.push(dateKey);
|
|
}
|
|
});
|
|
|
|
// Sort dates newest first
|
|
activeDays.sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
|
|
|
|
// Calculate current streak
|
|
let currentStreak = 0;
|
|
let checkDate = new Date();
|
|
|
|
// Keep checking consecutive days backward until streak breaks
|
|
while (true) {
|
|
const dateString = checkDate.toISOString().split('T')[0];
|
|
if (activeDays.includes(dateString)) {
|
|
currentStreak++;
|
|
checkDate.setDate(checkDate.getDate() - 1); // Go back one day
|
|
} else {
|
|
break; // Streak broken
|
|
}
|
|
}
|
|
|
|
// Get total active days
|
|
const totalDaysListened = activeDays.length;
|
|
|
|
// Get longest streak (requires analyzing all streaks)
|
|
let longestStreak = currentStreak;
|
|
let tempStreak = 0;
|
|
|
|
// Sort dates in ascending order for streak calculation
|
|
const ascDates = [...activeDays].sort();
|
|
|
|
for (let i = 0; i < ascDates.length; i++) {
|
|
const currentDate = new Date(ascDates[i]);
|
|
|
|
if (i > 0) {
|
|
const prevDate = new Date(ascDates[i-1]);
|
|
prevDate.setDate(prevDate.getDate() + 1);
|
|
|
|
// If dates are consecutive
|
|
if (currentDate.getTime() === prevDate.getTime()) {
|
|
tempStreak++;
|
|
} else {
|
|
// Streak broken
|
|
tempStreak = 1;
|
|
}
|
|
} else {
|
|
tempStreak = 1; // First active day
|
|
}
|
|
|
|
longestStreak = Math.max(longestStreak, tempStreak);
|
|
}
|
|
|
|
// Get last listened date
|
|
const lastListenedDate = activeDays.length > 0 ? activeDays[0] : null;
|
|
|
|
return {
|
|
currentStreak,
|
|
longestStreak,
|
|
totalDaysListened,
|
|
lastListenedDate
|
|
};
|
|
}
|
|
|
|
// Update the last sync timestamp in IndexedDB
|
|
async function updateLastSyncTimestamp() {
|
|
return new Promise((resolve, reject) => {
|
|
const timestamp = Date.now();
|
|
|
|
const request = indexedDB.open('stillnavidrome-offline', 1);
|
|
|
|
request.onerror = () => {
|
|
console.error('[Background Sync] Failed to open IndexedDB for timestamp update');
|
|
reject(new Error('Failed to open IndexedDB'));
|
|
};
|
|
|
|
request.onsuccess = (event) => {
|
|
const db = event.target.result;
|
|
const transaction = db.transaction(['metadata'], 'readwrite');
|
|
const store = transaction.objectStore('metadata');
|
|
|
|
const lastSyncData = {
|
|
key: 'background-sync-last-timestamp',
|
|
value: timestamp,
|
|
lastUpdated: timestamp
|
|
};
|
|
|
|
const putRequest = store.put(lastSyncData);
|
|
|
|
putRequest.onsuccess = () => {
|
|
console.log('[Background Sync] Updated last sync timestamp:', new Date(timestamp).toISOString());
|
|
resolve(timestamp);
|
|
};
|
|
|
|
putRequest.onerror = () => {
|
|
console.error('[Background Sync] Failed to update last sync timestamp');
|
|
reject(new Error('Failed to update timestamp'));
|
|
};
|
|
};
|
|
});
|
|
}
|
|
|
|
// Listen for fetch events to serve from cache when offline
|
|
self.addEventListener('fetch', (event) => {
|
|
// Only handle API requests that we're syncing in the background
|
|
const url = new URL(event.request.url);
|
|
const isBackgroundSyncApi = BACKGROUND_SYNC_APIS.some(api => url.pathname.includes(api));
|
|
|
|
if (isBackgroundSyncApi) {
|
|
event.respondWith(
|
|
caches.match(event.request).then(cachedResponse => {
|
|
// Return cached response if available
|
|
if (cachedResponse) {
|
|
// Always try to refresh cache in the background if online
|
|
if (navigator.onLine) {
|
|
event.waitUntil(
|
|
fetch(event.request).then(response => {
|
|
return caches.open(BACKGROUND_SYNC_CACHE).then(cache => {
|
|
cache.put(event.request, response.clone());
|
|
return response;
|
|
});
|
|
}).catch(error => {
|
|
console.log('[Background Sync] Background refresh failed, using cache:', error);
|
|
})
|
|
);
|
|
}
|
|
return cachedResponse;
|
|
}
|
|
|
|
// If no cache, try network and cache the result
|
|
return fetch(event.request).then(response => {
|
|
if (!response || response.status !== 200) {
|
|
return response;
|
|
}
|
|
|
|
// Clone the response to store in cache
|
|
const responseToCache = response.clone();
|
|
|
|
caches.open(BACKGROUND_SYNC_CACHE).then(cache => {
|
|
cache.put(event.request, responseToCache);
|
|
});
|
|
|
|
return response;
|
|
}).catch(error => {
|
|
console.error('[Background Sync] Fetch failed and no cache available:', error);
|
|
// Could return a custom offline response here
|
|
throw error;
|
|
});
|
|
})
|
|
);
|
|
}
|
|
});
|