345 lines
12 KiB
JavaScript
345 lines
12 KiB
JavaScript
// js/firebase/sync.js
|
|
import { database } from './config.js';
|
|
import {
|
|
ref,
|
|
get,
|
|
set,
|
|
update,
|
|
onValue,
|
|
off,
|
|
child,
|
|
remove,
|
|
runTransaction,
|
|
} from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js';
|
|
import { db } from '../db.js';
|
|
|
|
export class SyncManager {
|
|
constructor() {
|
|
this.user = null;
|
|
this.userRef = null;
|
|
this.unsubscribeFunctions = [];
|
|
this.isSyncing = false;
|
|
}
|
|
|
|
initialize(user) {
|
|
if (!database || !user) return;
|
|
this.user = user;
|
|
this.userRef = ref(database, `users/${user.uid}`);
|
|
|
|
console.log('Initializing SyncManager for user:', user.uid);
|
|
this.performInitialSync();
|
|
}
|
|
|
|
disconnect() {
|
|
if (this.userRef) {
|
|
// Remove listeners
|
|
this.unsubscribeFunctions.forEach((unsub) => unsub());
|
|
this.unsubscribeFunctions = [];
|
|
}
|
|
this.user = null;
|
|
this.userRef = null;
|
|
console.log('SyncManager disconnected');
|
|
}
|
|
|
|
async performInitialSync() {
|
|
if (this.isSyncing) return;
|
|
this.isSyncing = true;
|
|
|
|
try {
|
|
console.log('Starting initial sync...');
|
|
|
|
// 1. Fetch Cloud Data
|
|
const snapshot = await get(this.userRef);
|
|
const cloudData = snapshot.val() || {};
|
|
|
|
// 2. Fetch Local Data
|
|
const localData = await db.exportData();
|
|
|
|
// 3. Merge Data (Union Strategy)
|
|
const mergedData = this.mergeData(localData, cloudData);
|
|
|
|
// 4. Update Cloud (if different)
|
|
// We optimize by just rewriting the whole node for simplicity in Phase 1,
|
|
// or we could diff. Rewriting is safer for "Initial Merge".
|
|
await update(this.userRef, mergedData);
|
|
|
|
// 5. Update Local (Import merged data)
|
|
// Convert Cloud Schema back to Local Schema for IndexedDB
|
|
const importData = {
|
|
favorites_tracks: mergedData.library?.tracks ? Object.values(mergedData.library.tracks) : [],
|
|
favorites_albums: mergedData.library?.albums ? Object.values(mergedData.library.albums) : [],
|
|
favorites_artists: mergedData.library?.artists ? Object.values(mergedData.library.artists) : [],
|
|
favorites_playlists: mergedData.library?.playlists ? Object.values(mergedData.library.playlists) : [],
|
|
history_tracks: mergedData.history?.recentTracks ? Object.values(mergedData.history.recentTracks) : [],
|
|
user_playlists: mergedData.user_playlists ? Object.values(mergedData.user_playlists) : [],
|
|
};
|
|
|
|
await db.importData(importData, true);
|
|
|
|
console.log('Initial sync complete.');
|
|
|
|
// 6. Setup Listeners for future changes
|
|
this.setupListeners();
|
|
} catch (error) {
|
|
console.error('Initial sync failed:', error);
|
|
} finally {
|
|
this.isSyncing = false;
|
|
}
|
|
}
|
|
|
|
mergeData(local, cloud) {
|
|
// Helper to merge lists of objects based on ID/UUID
|
|
// We assume 'favorites_*' structure from db.exportData()
|
|
|
|
const mergeStores = (localItems, cloudItems, idKey = 'id') => {
|
|
const map = new Map();
|
|
|
|
// Add all local items
|
|
if (Array.isArray(localItems)) {
|
|
localItems.forEach((item) => map.set(item[idKey], item));
|
|
} else if (localItems && typeof localItems === 'object') {
|
|
// Handle case where cloud stores as object keys
|
|
Object.values(localItems).forEach((item) => map.set(item[idKey], item));
|
|
}
|
|
|
|
// Add/Overwrite with cloud items (Union Strategy)
|
|
if (cloudItems) {
|
|
if (Array.isArray(cloudItems)) {
|
|
cloudItems.forEach((item) => map.set(item[idKey], item));
|
|
} else {
|
|
Object.keys(cloudItems).forEach((key) => {
|
|
const val = cloudItems[key];
|
|
if (typeof val === 'object') {
|
|
map.set(val[idKey] || key, val);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return Array.from(map.values());
|
|
};
|
|
|
|
const merged = {
|
|
library: {
|
|
tracks: this.arrayToObject(mergeStores(local.favorites_tracks, cloud.library?.tracks), 'id'),
|
|
albums: this.arrayToObject(mergeStores(local.favorites_albums, cloud.library?.albums), 'id'),
|
|
artists: this.arrayToObject(mergeStores(local.favorites_artists, cloud.library?.artists), 'id'),
|
|
playlists: this.arrayToObject(
|
|
mergeStores(local.favorites_playlists, cloud.library?.playlists, 'uuid'),
|
|
'uuid'
|
|
),
|
|
},
|
|
history: {
|
|
recentTracks: this.arrayToObject(
|
|
mergeStores(local.history_tracks, cloud.history?.recentTracks, 'timestamp'),
|
|
'timestamp'
|
|
),
|
|
},
|
|
user_playlists: this.arrayToObject(mergeStores(local.user_playlists, cloud.user_playlists), 'id'),
|
|
// Settings are NOT synced (device specific)
|
|
lastUpdated: Date.now(),
|
|
};
|
|
|
|
// Transform back to local structure for db.importData
|
|
return merged;
|
|
}
|
|
|
|
// Helper to convert array to object with keys
|
|
arrayToObject(arr, keyField) {
|
|
const obj = {};
|
|
arr.forEach((item) => {
|
|
if (item && item[keyField]) {
|
|
obj[item[keyField]] = item;
|
|
}
|
|
});
|
|
return obj;
|
|
}
|
|
|
|
setupListeners() {
|
|
// Listen for changes in library
|
|
const libraryRef = child(this.userRef, 'library');
|
|
|
|
const unsubLibrary = onValue(libraryRef, (snapshot) => {
|
|
if (this.isSyncing) return;
|
|
|
|
const val = snapshot.val();
|
|
if (val) {
|
|
const importData = {
|
|
favorites_tracks: val.tracks ? Object.values(val.tracks) : [],
|
|
favorites_albums: val.albums ? Object.values(val.albums) : [],
|
|
favorites_artists: val.artists ? Object.values(val.artists) : [],
|
|
favorites_playlists: val.playlists ? Object.values(val.playlists) : [],
|
|
};
|
|
db.importData(importData, true).then(() => {
|
|
// Notify UI to refresh
|
|
window.dispatchEvent(new Event('library-changed'));
|
|
});
|
|
}
|
|
});
|
|
|
|
this.unsubscribeFunctions.push(() => off(libraryRef, 'value', unsubLibrary));
|
|
|
|
// Listen for changes in history
|
|
const historyRef = child(this.userRef, 'history/recentTracks');
|
|
|
|
const unsubHistory = onValue(historyRef, (snapshot) => {
|
|
if (this.isSyncing) return;
|
|
|
|
const val = snapshot.val();
|
|
if (val) {
|
|
const importData = {
|
|
history_tracks: Object.values(val),
|
|
};
|
|
db.importData(importData, true).then(() => {
|
|
// Notify UI to refresh
|
|
window.dispatchEvent(new Event('history-changed'));
|
|
});
|
|
}
|
|
});
|
|
|
|
this.unsubscribeFunctions.push(() => off(historyRef, 'value', unsubHistory));
|
|
|
|
// Listen for changes in user playlists
|
|
const userPlaylistsRef = child(this.userRef, 'user_playlists');
|
|
|
|
const unsubUserPlaylists = onValue(userPlaylistsRef, (snapshot) => {
|
|
if (this.isSyncing) return;
|
|
|
|
const val = snapshot.val();
|
|
if (val) {
|
|
const importData = {
|
|
user_playlists: Object.values(val),
|
|
};
|
|
db.importData(importData, true).then(() => {
|
|
// Notify UI to refresh library
|
|
window.dispatchEvent(new Event('library-changed'));
|
|
});
|
|
}
|
|
});
|
|
|
|
this.unsubscribeFunctions.push(() => off(userPlaylistsRef, 'value', unsubUserPlaylists));
|
|
}
|
|
|
|
// --- Public API for Broadcasters ---
|
|
|
|
async syncLibraryItem(type, item, isAdded) {
|
|
if (!this.user || !this.userRef) return;
|
|
|
|
// type: 'track', 'album', 'artist', 'playlist'
|
|
// item: the object (minified preferably)
|
|
// isAdded: boolean
|
|
|
|
const categoryMap = {
|
|
track: 'tracks',
|
|
album: 'albums',
|
|
artist: 'artists',
|
|
playlist: 'playlists',
|
|
};
|
|
const category = categoryMap[type];
|
|
if (!category) return;
|
|
|
|
const id = type === 'playlist' ? item.uuid : item.id;
|
|
const path = `library/${category}/${id}`;
|
|
const itemRef = child(this.userRef, path);
|
|
|
|
if (isAdded) {
|
|
// Minify to ensure consistency and reduce bandwidth
|
|
// We use the db helper to ensure consistent structure
|
|
const minified = db._minifyItem(type, item);
|
|
// Ensure addedAt is present. If the passed item didn't have it (e.g. from player),
|
|
// we add it now. Ideally this matches local DB time, but a small diff is negligible.
|
|
const entry = {
|
|
...minified,
|
|
addedAt: item.addedAt || minified.addedAt || Date.now(),
|
|
};
|
|
await set(itemRef, entry);
|
|
} else {
|
|
await remove(itemRef);
|
|
}
|
|
}
|
|
|
|
async syncHistoryItem(track) {
|
|
if (!this.user || !this.userRef || !track.timestamp) return;
|
|
|
|
const itemRef = child(this.userRef, `history/recentTracks/${track.timestamp}`);
|
|
try {
|
|
await set(itemRef, track);
|
|
} catch (error) {
|
|
console.error('Failed to sync history item:', error);
|
|
}
|
|
}
|
|
|
|
async syncUserPlaylist(playlist, action) {
|
|
if (!this.user || !this.userRef) return;
|
|
|
|
const id = playlist.id;
|
|
const path = `user_playlists/${id}`;
|
|
const itemRef = child(this.userRef, path);
|
|
|
|
if (action === 'create' || action === 'update') {
|
|
await set(itemRef, playlist);
|
|
} else if (action === 'delete') {
|
|
await remove(itemRef);
|
|
}
|
|
}
|
|
|
|
async clearCloudData() {
|
|
if (!this.user || !this.userRef) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
await remove(this.userRef);
|
|
}
|
|
|
|
// Public Playlist API
|
|
|
|
async publishPlaylist(playlist) {
|
|
if (!this.user) throw new Error('Not authenticated');
|
|
|
|
const minified = db._minifyItem('playlist', playlist);
|
|
const playlistId = playlist.id || playlist.uuid;
|
|
|
|
if (!playlistId) throw new Error('Invalid playlist ID');
|
|
|
|
// Ensure playlist has necessary data
|
|
const publicData = {
|
|
...minified,
|
|
uid: this.user.uid,
|
|
originalId: playlistId,
|
|
publishedAt: Date.now(),
|
|
tracks: playlist.tracks ? playlist.tracks.map((t) => db._minifyItem('track', t)) : [],
|
|
};
|
|
|
|
// Use a global 'public_playlists' node
|
|
const publicRef = ref(database, `public_playlists/${playlistId}`);
|
|
await set(publicRef, publicData);
|
|
}
|
|
|
|
async unpublishPlaylist(playlistId) {
|
|
if (!this.user) throw new Error('Not authenticated');
|
|
const publicRef = ref(database, `public_playlists/${playlistId}`);
|
|
await remove(publicRef);
|
|
}
|
|
|
|
async getPublicPlaylist(playlistId) {
|
|
if (!database) {
|
|
console.warn('[Sync] Database not initialized, cannot fetch public playlist');
|
|
return null;
|
|
}
|
|
try {
|
|
const publicRef = ref(database, `public_playlists/${playlistId}`);
|
|
const snapshot = await get(publicRef);
|
|
if (!snapshot.exists()) {
|
|
console.warn(`[Sync] Public playlist ${playlistId} not found in database.`);
|
|
return null;
|
|
}
|
|
const data = snapshot.val();
|
|
console.log(`[Sync] Public playlist fetch for ${playlistId}: Found`);
|
|
return data;
|
|
} catch (error) {
|
|
console.error('[Sync] Failed to fetch public playlist:', error);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const syncManager = new SyncManager();
|