465 lines
17 KiB
JavaScript
465 lines
17 KiB
JavaScript
// js/firebase/sync.js
|
|
import { database } from './config.js';
|
|
import {
|
|
ref,
|
|
get,
|
|
set,
|
|
update,
|
|
onValue,
|
|
off,
|
|
child,
|
|
remove,
|
|
runTransaction,
|
|
onChildAdded,
|
|
onChildChanged,
|
|
onChildRemoved,
|
|
} 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;
|
|
this.listenersSetup = 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;
|
|
this.listenersSetup = false;
|
|
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();
|
|
|
|
// Filter out deleted playlists from local data
|
|
if (localData.user_playlists && Array.isArray(localData.user_playlists)) {
|
|
const cloudPlaylists = cloudData.user_playlists || {};
|
|
localData.user_playlists = localData.user_playlists.filter((p) => {
|
|
const cloudP = cloudPlaylists[p.id];
|
|
return !cloudP || !cloudP.deleted;
|
|
});
|
|
}
|
|
|
|
// 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.');
|
|
} catch (error) {
|
|
console.error('Initial sync failed:', error);
|
|
} finally {
|
|
this.setupListeners();
|
|
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) {
|
|
const processItem = (item, key) => {
|
|
if (!item || typeof item !== 'object') return;
|
|
|
|
if (item.tracks && typeof item.tracks === 'object' && !Array.isArray(item.tracks)) {
|
|
item.tracks = Object.values(item.tracks);
|
|
}
|
|
|
|
const id = item[idKey] || key;
|
|
const localItem = map.get(id);
|
|
|
|
if (item.deleted) {
|
|
map.delete(id);
|
|
} else if (localItem) {
|
|
const localTime = localItem.updatedAt || 0;
|
|
const cloudTime = item.updatedAt || 0;
|
|
|
|
if (cloudTime > localTime) {
|
|
const localTracks = Array.isArray(localItem.tracks) ? localItem.tracks.length : 0;
|
|
const cloudTracks = Array.isArray(item.tracks) ? item.tracks.length : 0;
|
|
|
|
if (localTracks > 0 && cloudTracks === 0) {
|
|
} else {
|
|
map.set(id, item);
|
|
}
|
|
} else if (cloudTime === localTime) {
|
|
const localTracks = Array.isArray(localItem.tracks) ? localItem.tracks.length : 0;
|
|
const cloudTracks = Array.isArray(item.tracks) ? item.tracks.length : 0;
|
|
if (cloudTracks >= localTracks) {
|
|
map.set(id, item);
|
|
}
|
|
}
|
|
} else {
|
|
map.set(id, item);
|
|
}
|
|
};
|
|
|
|
if (Array.isArray(cloudItems)) {
|
|
cloudItems.forEach((item) => processItem(item));
|
|
} else {
|
|
Object.keys(cloudItems).forEach((key) => {
|
|
const val = cloudItems[key];
|
|
if (typeof val === 'object') {
|
|
processItem(val, key);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
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() {
|
|
if (!this.userRef || this.listenersSetup) return;
|
|
this.listenersSetup = true;
|
|
|
|
const setupLibraryListener = (nodeName, storeName) => {
|
|
const nodeRef = child(this.userRef, `library/${nodeName}`);
|
|
|
|
const handleAddOrChange = (snapshot) => {
|
|
if (this.isSyncing) return;
|
|
const val = snapshot.val();
|
|
if (val) {
|
|
const importData = {};
|
|
importData[storeName] = [val];
|
|
db.importData(importData, false).then((changed) => {
|
|
if (changed) window.dispatchEvent(new Event('library-changed'));
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRemove = (snapshot) => {
|
|
if (this.isSyncing) return;
|
|
const key = snapshot.key;
|
|
if (key) {
|
|
db.performTransaction(storeName, 'readwrite', (store) => store.delete(key)).then(() => {
|
|
window.dispatchEvent(new Event('library-changed'));
|
|
});
|
|
}
|
|
};
|
|
|
|
const unsubAdd = onChildAdded(nodeRef, handleAddOrChange);
|
|
const unsubChange = onChildChanged(nodeRef, handleAddOrChange);
|
|
const unsubRemove = onChildRemoved(nodeRef, handleRemove);
|
|
|
|
this.unsubscribeFunctions.push(() => off(nodeRef, 'child_added', unsubAdd));
|
|
this.unsubscribeFunctions.push(() => off(nodeRef, 'child_changed', unsubChange));
|
|
this.unsubscribeFunctions.push(() => off(nodeRef, 'child_removed', unsubRemove));
|
|
};
|
|
|
|
setupLibraryListener('tracks', 'favorites_tracks');
|
|
setupLibraryListener('albums', 'favorites_albums');
|
|
setupLibraryListener('artists', 'favorites_artists');
|
|
setupLibraryListener('playlists', 'favorites_playlists');
|
|
|
|
// Listen for changes in history
|
|
const historyRef = child(this.userRef, 'history/recentTracks');
|
|
|
|
const unsubHistoryAdd = onChildAdded(historyRef, (snapshot) => {
|
|
if (this.isSyncing) return;
|
|
const val = snapshot.val();
|
|
if (val) {
|
|
db.importData({ history_tracks: [val] }, false).then((changed) => {
|
|
if (changed) window.dispatchEvent(new Event('history-changed'));
|
|
});
|
|
}
|
|
});
|
|
this.unsubscribeFunctions.push(() => off(historyRef, 'child_added', unsubHistoryAdd));
|
|
|
|
// Listen for changes in user playlists
|
|
const userPlaylistsRef = child(this.userRef, 'user_playlists');
|
|
|
|
const handlePlaylistUpdate = (snapshot) => {
|
|
if (this.isSyncing) return;
|
|
|
|
const val = snapshot.val();
|
|
if (val && val.deleted) {
|
|
db.deletePlaylist(val.id).then(() => {
|
|
window.dispatchEvent(new Event('library-changed'));
|
|
});
|
|
return;
|
|
}
|
|
if (val) {
|
|
if (val.tracks && typeof val.tracks === 'object' && !Array.isArray(val.tracks)) {
|
|
val.tracks = Object.values(val.tracks);
|
|
}
|
|
|
|
const importData = {
|
|
user_playlists: [val],
|
|
};
|
|
db.importData(importData, false).then((changed) => {
|
|
// Notify UI to refresh library
|
|
if (changed) window.dispatchEvent(new Event('library-changed'));
|
|
});
|
|
}
|
|
};
|
|
|
|
const unsubChildAdded = onChildAdded(userPlaylistsRef, handlePlaylistUpdate);
|
|
const unsubChildChanged = onChildChanged(userPlaylistsRef, handlePlaylistUpdate);
|
|
const unsubChildRemoved = onChildRemoved(userPlaylistsRef, (snapshot) => {
|
|
if (this.isSyncing) return;
|
|
const key = snapshot.key;
|
|
if (key) {
|
|
db.deletePlaylist(key).then(() => {
|
|
window.dispatchEvent(new Event('library-changed'));
|
|
});
|
|
}
|
|
});
|
|
|
|
this.unsubscribeFunctions.push(() => {
|
|
off(userPlaylistsRef, 'child_added', unsubChildAdded);
|
|
off(userPlaylistsRef, 'child_changed', unsubChildChanged);
|
|
off(userPlaylistsRef, 'child_removed', unsubChildRemoved);
|
|
});
|
|
}
|
|
|
|
// --- 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, this.sanitizeForFirebase(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, this.sanitizeForFirebase(track));
|
|
|
|
const localHistory = await db.getHistory();
|
|
if (localHistory.length > 50) {
|
|
const toRemove = localHistory.slice(50);
|
|
const updates = {};
|
|
toRemove.forEach((t) => {
|
|
updates[`history/recentTracks/${t.timestamp}`] = null;
|
|
});
|
|
await update(this.userRef, updates);
|
|
}
|
|
} 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') {
|
|
const dataToSync = {
|
|
...playlist,
|
|
updatedAt: Date.now(),
|
|
};
|
|
await set(itemRef, this.sanitizeForFirebase(dataToSync));
|
|
} else if (action === 'delete') {
|
|
await update(itemRef, { deleted: true, updatedAt: Date.now() });
|
|
}
|
|
}
|
|
|
|
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, this.sanitizeForFirebase(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;
|
|
}
|
|
}
|
|
|
|
sanitizeForFirebase(obj) {
|
|
if (obj === undefined) return null;
|
|
if (obj === null) return null;
|
|
if (typeof obj !== 'object') return obj;
|
|
if (Array.isArray(obj)) {
|
|
return obj.map((v) => this.sanitizeForFirebase(v));
|
|
}
|
|
const newObj = {};
|
|
for (const key in obj) {
|
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
const val = this.sanitizeForFirebase(obj[key]);
|
|
if (val !== undefined) {
|
|
newObj[key] = val;
|
|
}
|
|
}
|
|
}
|
|
return newObj;
|
|
}
|
|
}
|
|
|
|
export const syncManager = new SyncManager();
|