kv-music/js/db.js
Julien Maille 2a98654e54 feat: implement firebase synchronization for library and history
- Added Firebase authentication (Google) and Realtime Database sync
- Implemented 'Magic Link' configuration sharing
- Increased local and cloud history limit to 1000 tracks
- Refactored settings to support dynamic Firebase configuration
- Added firebase-setup.md documentation
2025-12-29 22:18:51 +01:00

261 lines
9.5 KiB
JavaScript

export class MusicDatabase {
constructor() {
this.dbName = 'MonochromeDB';
this.version = 3;
this.db = null;
}
async open() {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = (event) => {
console.error("Database error:", event.target.error);
reject(event.target.error);
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Favorites stores
if (!db.objectStoreNames.contains('favorites_tracks')) {
const store = db.createObjectStore('favorites_tracks', { keyPath: 'id' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
if (!db.objectStoreNames.contains('favorites_albums')) {
const store = db.createObjectStore('favorites_albums', { keyPath: 'id' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
if (!db.objectStoreNames.contains('favorites_artists')) {
const store = db.createObjectStore('favorites_artists', { keyPath: 'id' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
if (!db.objectStoreNames.contains('favorites_playlists')) {
const store = db.createObjectStore('favorites_playlists', { keyPath: 'uuid' });
store.createIndex('addedAt', 'addedAt', { unique: false });
}
if (!db.objectStoreNames.contains('history_tracks')) {
const store = db.createObjectStore('history_tracks', { keyPath: 'timestamp' });
store.createIndex('timestamp', 'timestamp', { unique: true });
}
};
});
}
// Generic Helper
async performTransaction(storeName, mode, callback) {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = callback(store);
transaction.oncomplete = () => {
resolve(request?.result);
};
transaction.onerror = (event) => {
reject(event.target.error);
};
});
}
// History API
async addToHistory(track) {
const storeName = 'history_tracks';
const minified = this._minifyItem('track', track);
// Use a unique timestamp even if called rapidly
// (though unlikely to be <1ms for playback start)
const entry = { ...minified, timestamp: Date.now() };
const db = await this.open();
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
// Add new entry
store.put(entry);
return entry;
}
async getHistory() {
const storeName = 'history_tracks';
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const index = store.index('timestamp');
const request = index.getAll();
request.onsuccess = () => {
// Return reversed (newest first)
resolve(request.result.reverse());
};
request.onerror = () => reject(request.error);
});
}
// Favorites API
async toggleFavorite(type, item) {
const storeName = `favorites_${type}s`; // tracks, albums, artists
const key = type === 'playlist' ? item.uuid : item.id;
const exists = await this.isFavorite(type, key);
if (exists) {
await this.performTransaction(storeName, 'readwrite', (store) => store.delete(key));
return false; // Removed
} else {
const minified = this._minifyItem(type, item);
const entry = { ...minified, addedAt: Date.now() };
await this.performTransaction(storeName, 'readwrite', (store) => store.put(entry));
return true; // Added
}
}
async isFavorite(type, id) {
const storeName = `favorites_${type}s`;
try {
const result = await this.performTransaction(storeName, 'readonly', (store) => store.get(id));
return !!result;
} catch (e) {
return false;
}
}
async getFavorites(type) {
const storeName = `favorites_${type}s`;
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const index = store.index('addedAt');
const request = index.getAll(); // Returns sorted by addedAt ascending
request.onsuccess = () => {
// Reverse to show newest first
resolve(request.result.reverse());
};
request.onerror = () => reject(request.error);
});
}
_minifyItem(type, item) {
if (!item) return item;
// Base properties to keep
const base = {
id: item.id,
addedAt: item.addedAt || null
};
if (type === 'track') {
return {
...base,
title: item.title,
duration: item.duration,
explicit: item.explicit,
// Keep minimal artist info
artists: item.artists?.map(a => ({ id: a.id, name: a.name })) || [],
// Keep minimal album info
album: item.album ? {
id: item.album.id,
cover: item.album.cover,
releaseDate: item.album.releaseDate || null
} : null,
// Fallback date
streamStartDate: item.streamStartDate,
// Keep version if exists
version: item.version || null
};
}
if (type === 'album') {
return {
...base,
title: item.title,
cover: item.cover,
releaseDate: item.releaseDate,
explicit: item.explicit,
// UI uses singular 'artist'
artist: item.artist ? { name: item.artist.name, id: item.artist.id } : (item.artists?.[0] ? { name: item.artists[0].name, id: item.artists[0].id } : null),
// Keep type and track count for UI labels
type: item.type,
numberOfTracks: item.numberOfTracks
};
}
if (type === 'artist') {
return {
...base,
name: item.name,
picture: item.picture || item.image // Handle both just in case
};
}
if (type === 'playlist') {
return {
uuid: item.uuid,
addedAt: item.addedAt,
title: item.title,
// UI checks squareImage || image || uuid
image: item.image || item.squareImage,
numberOfTracks: item.numberOfTracks,
user: item.user ? { name: item.user.name } : null
};
}
return item;
}
async exportData() {
const tracks = await this.getFavorites('track');
const albums = await this.getFavorites('album');
const artists = await this.getFavorites('artist');
const playlists = await this.getFavorites('playlist');
const history = await this.getHistory();
const data = {
favorites_tracks: tracks.map(t => this._minifyItem('track', t)),
favorites_albums: albums.map(a => this._minifyItem('album', a)),
favorites_artists: artists.map(a => this._minifyItem('artist', a)),
favorites_playlists: playlists.map(p => this._minifyItem('playlist', p)),
history_tracks: history.map(t => this._minifyItem('track', t))
};
return data;
}
async importData(data, clear = false) {
// Let's merge by put (replaces if ID exists).
const db = await this.open();
const importStore = async (storeName, items) => {
// If items is undefined, we skip this store (don't clear, don't update)
// This allows partial updates (e.g. library only)
if (items === undefined) return;
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
if (clear) {
store.clear();
}
for (const item of items) {
store.put(item);
}
};
await importStore('favorites_tracks', data.favorites_tracks);
await importStore('favorites_albums', data.favorites_albums);
await importStore('favorites_artists', data.favorites_artists);
await importStore('favorites_playlists', data.favorites_playlists);
await importStore('history_tracks', data.history_tracks);
}
}
export const db = new MusicDatabase();