kv-music/js/db.js

365 lines
14 KiB
JavaScript

export class MusicDatabase {
constructor() {
this.dbName = 'MonochromeDB';
this.version = 5;
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('favorites_mixes')) {
const store = db.createObjectStore('favorites_mixes', { keyPath: 'id' });
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 });
}
if (!db.objectStoreNames.contains('user_playlists')) {
const store = db.createObjectStore('user_playlists', { keyPath: 'id' });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
};
});
}
// 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 plural = type === 'mix' ? 'mixes' : `${type}s`;
const storeName = `favorites_${plural}`;
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 plural = type === 'mix' ? 'mixes' : `${type}s`;
const storeName = `favorites_${plural}`;
try {
const result = await this.performTransaction(storeName, 'readonly', (store) => store.get(id));
return !!result;
} catch (e) {
return false;
}
}
async getFavorites(type) {
const plural = type === 'mix' ? 'mixes' : `${type}s`;
const storeName = `favorites_${plural}`;
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,
vibrantColor: item.album.vibrantColor || null
} : null,
// Fallback date
streamStartDate: item.streamStartDate || null,
// Keep version if exists
version: item.version || null
};
}
if (type === 'album') {
return {
...base,
title: item.title,
cover: item.cover,
releaseDate: item.releaseDate || null,
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 || null,
numberOfTracks: item.numberOfTracks
};
}
if (type === 'artist') {
return {
...base,
name: item.name,
picture: item.picture || item.image || null // Handle both just in case
};
}
if (type === 'playlist') {
return {
uuid: item.uuid || item.id,
addedAt: item.addedAt || item.createdAt || null,
title: item.title || item.name,
// UI checks squareImage || image || uuid
image: item.image || item.squareImage || item.cover || null,
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0),
user: item.user ? { name: item.user.name } : null
};
}
if (type === 'mix') {
return {
id: item.id,
addedAt: item.addedAt,
title: item.title,
subTitle: item.subTitle,
description: item.description,
mixType: item.mixType,
cover: item.cover
};
}
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 mixes = await this.getFavorites('mix');
const history = await this.getHistory();
const userPlaylists = await this.getPlaylists();
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)),
favorites_mixes: mixes.map(m => this._minifyItem('mix', m)),
history_tracks: history.map(t => this._minifyItem('track', t)),
user_playlists: userPlaylists
};
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('favorites_mixes', data.favorites_mixes);
await importStore('history_tracks', data.history_tracks);
if (data.user_playlists) {
await importStore('user_playlists', data.user_playlists);
}
}
// User Playlists API
async createPlaylist(name, tracks = [], cover = '') {
const id = crypto.randomUUID();
const playlist = {
id: id,
name: name,
tracks: tracks.map(t => this._minifyItem('track', t)),
cover: cover,
createdAt: Date.now()
};
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist;
}
async addTrackToPlaylist(playlistId, track) {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
if (!playlist) throw new Error('Playlist not found');
playlist.tracks = playlist.tracks || [];
const minifiedTrack = this._minifyItem('track', track);
if (playlist.tracks.some(t => t.id === track.id)) return;
playlist.tracks.push(minifiedTrack);
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist;
}
async removeTrackFromPlaylist(playlistId, trackId) {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
if (!playlist) throw new Error('Playlist not found');
playlist.tracks = playlist.tracks || [];
playlist.tracks = playlist.tracks.filter(t => t.id !== trackId);
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist;
}
async deletePlaylist(playlistId) {
await this.performTransaction('user_playlists', 'readwrite', (store) => store.delete(playlistId));
}
async getPlaylist(playlistId) {
return await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
}
async getPlaylists() {
const db = await this.open();
return new Promise((resolve, reject) => {
const transaction = db.transaction('user_playlists', 'readonly');
const store = transaction.objectStore('user_playlists');
const index = store.index('createdAt');
const request = index.getAll();
request.onsuccess = () => {
resolve(request.result.reverse()); // Newest first
};
request.onerror = () => reject(request.error);
});
}
async updatePlaylistName(playlistId, newName) {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
if (!playlist) throw new Error('Playlist not found');
playlist.name = newName;
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist;
}
async updatePlaylistTracks(playlistId, tracks) {
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
if (!playlist) throw new Error('Playlist not found');
playlist.tracks = tracks;
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
return playlist;
}
}
export const db = new MusicDatabase();