893 lines
35 KiB
JavaScript
893 lines
35 KiB
JavaScript
export class MusicDatabase {
|
|
constructor() {
|
|
this.dbName = 'MonochromeDB';
|
|
this.version = 11;
|
|
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;
|
|
|
|
// v10 introduced track_ratings (bad PR) - remove it
|
|
if (db.objectStoreNames.contains('track_ratings')) {
|
|
db.deleteObjectStore('track_ratings');
|
|
}
|
|
|
|
// 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_videos')) {
|
|
const store = db.createObjectStore('favorites_videos', { 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 });
|
|
}
|
|
if (!db.objectStoreNames.contains('user_folders')) {
|
|
const store = db.createObjectStore('user_folders', { keyPath: 'id' });
|
|
store.createIndex('createdAt', 'createdAt', { unique: false });
|
|
}
|
|
if (!db.objectStoreNames.contains('settings')) {
|
|
db.createObjectStore('settings');
|
|
}
|
|
if (!db.objectStoreNames.contains('pinned_items')) {
|
|
const store = db.createObjectStore('pinned_items', { keyPath: 'id' });
|
|
store.createIndex('pinnedAt', 'pinnedAt', { 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);
|
|
|
|
let result;
|
|
if (request) {
|
|
request.onsuccess = () => {
|
|
result = request.result;
|
|
};
|
|
}
|
|
|
|
transaction.oncomplete = () => {
|
|
resolve(result);
|
|
};
|
|
transaction.onerror = (event) => {
|
|
reject(event.target.error);
|
|
};
|
|
});
|
|
}
|
|
|
|
async getAll(storeName) {
|
|
return this.performTransaction(storeName, 'readonly', (store) => store.getAll());
|
|
}
|
|
|
|
// History API
|
|
async addToHistory(track) {
|
|
const storeName = 'history_tracks';
|
|
const minified = this._minifyItem(track.type || 'track', track);
|
|
|
|
const db = await this.open();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(storeName, 'readwrite');
|
|
const store = transaction.objectStore(storeName);
|
|
const index = store.index('timestamp');
|
|
|
|
const lastReq = index.openCursor(null, 'prev');
|
|
let lastTimestamp = 0;
|
|
|
|
lastReq.onsuccess = (e) => {
|
|
const cursor = e.target.result;
|
|
if (cursor && lastTimestamp === 0) {
|
|
lastTimestamp = cursor.value.timestamp;
|
|
}
|
|
|
|
const timestamp = Math.max(Date.now(), lastTimestamp + 1);
|
|
const entry = { ...minified, timestamp };
|
|
|
|
const dedupeReq = index.openCursor(null, 'prev');
|
|
dedupeReq.onsuccess = (e2) => {
|
|
const dedupeCursor = e2.target.result;
|
|
if (dedupeCursor) {
|
|
const trackInHistory = dedupeCursor.value;
|
|
if (trackInHistory.id === track.id) {
|
|
store.delete(dedupeCursor.primaryKey);
|
|
}
|
|
dedupeCursor.continue();
|
|
} else {
|
|
store.put(entry);
|
|
resolve(entry);
|
|
}
|
|
};
|
|
};
|
|
|
|
transaction.onerror = (e) => reject(e.target.error);
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
async clearHistory() {
|
|
const storeName = 'history_tracks';
|
|
const db = await this.open();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(storeName, 'readwrite');
|
|
const store = transaction.objectStore(storeName);
|
|
const request = store.clear();
|
|
|
|
request.onsuccess = () => resolve();
|
|
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));
|
|
window.dispatchEvent(new CustomEvent('favorites-changed'));
|
|
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));
|
|
window.dispatchEvent(new CustomEvent('favorites-changed'));
|
|
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 {
|
|
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 request = store.getAll();
|
|
|
|
request.onsuccess = () => {
|
|
const results = request.result;
|
|
results.sort((a, b) => {
|
|
const aTime = a.addedAt || 0;
|
|
const bTime = b.addedAt || 0;
|
|
return bTime - aTime; // Newest first
|
|
});
|
|
resolve(results);
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
_minifyItem(type, item) {
|
|
if (!item) return item;
|
|
const normalizedType = (type || '').toLowerCase();
|
|
|
|
// Base properties to keep
|
|
const base = {
|
|
id: item.id,
|
|
addedAt: item.addedAt || null,
|
|
};
|
|
|
|
if (normalizedType === 'track') {
|
|
return {
|
|
...base,
|
|
title: item.title || null,
|
|
duration: item.duration || null,
|
|
explicit: item.explicit || false,
|
|
// Keep minimal artist info
|
|
artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null,
|
|
artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [],
|
|
// Keep minimal album info
|
|
album: item.album
|
|
? {
|
|
id: item.album.id,
|
|
title: item.album.title || null,
|
|
cover: item.album.cover || null,
|
|
releaseDate: item.album.releaseDate || null,
|
|
vibrantColor: item.album.vibrantColor || null,
|
|
artist: item.album.artist || null,
|
|
numberOfTracks: item.album.numberOfTracks || null,
|
|
mediaMetadata: item.album.mediaMetadata ? { tags: item.album.mediaMetadata.tags } : null,
|
|
}
|
|
: null,
|
|
copyright: item.copyright || null,
|
|
isrc: item.isrc || null,
|
|
trackNumber: item.trackNumber || null,
|
|
// Fallback date
|
|
streamStartDate: item.streamStartDate || null,
|
|
// Keep version if exists
|
|
version: item.version || null,
|
|
// Keep mix info
|
|
mixes: item.mixes || null,
|
|
isTracker: item.isTracker || (item.id && String(item.id).startsWith('tracker-')),
|
|
trackerInfo: item.trackerInfo || null,
|
|
isPodcast: item.isPodcast || (item.id && String(item.id).startsWith('podcast_')) || null,
|
|
enclosureUrl: item.enclosureUrl || null,
|
|
enclosureType: item.enclosureType || null,
|
|
enclosureLength: item.enclosureLength || null,
|
|
audioUrl: item.remoteUrl || item.audioUrl || null,
|
|
remoteUrl: item.remoteUrl || null,
|
|
audioQuality: item.audioQuality || null,
|
|
mediaMetadata: item.mediaMetadata ? { tags: item.mediaMetadata.tags } : null,
|
|
};
|
|
}
|
|
|
|
if (normalizedType === 'video') {
|
|
return {
|
|
...base,
|
|
type: 'video',
|
|
title: item.title || null,
|
|
duration: item.duration || null,
|
|
image: item.image || item.cover || null,
|
|
artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null,
|
|
artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [],
|
|
album: item.album || { title: 'Video', cover: item.image || item.cover },
|
|
};
|
|
}
|
|
|
|
if (type === 'album') {
|
|
return {
|
|
...base,
|
|
title: item.title || null,
|
|
cover: item.cover || null,
|
|
releaseDate: item.releaseDate || null,
|
|
explicit: item.explicit || false,
|
|
// UI uses singular 'artist'
|
|
artist: item.artist
|
|
? { name: item.artist.name || null, id: item.artist.id }
|
|
: item.artists?.[0]
|
|
? { name: item.artists[0].name || null, id: item.artists[0].id }
|
|
: null,
|
|
// Keep type and track count for UI labels
|
|
type: item.type || null,
|
|
numberOfTracks: item.numberOfTracks || null,
|
|
};
|
|
}
|
|
|
|
if (type === 'artist') {
|
|
return {
|
|
...base,
|
|
name: item.name || null,
|
|
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 || null,
|
|
// 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 } : 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;
|
|
}
|
|
|
|
_minifyPinnedItem(item, type) {
|
|
if (!item) return null;
|
|
|
|
const id = item.id || item.uuid;
|
|
let name, cover, href, images;
|
|
|
|
switch (type) {
|
|
case 'album':
|
|
name = item.title;
|
|
cover = item.cover;
|
|
href = `/album/${id}`;
|
|
break;
|
|
case 'artist':
|
|
name = item.name;
|
|
cover = item.picture;
|
|
href = `/artist/${id}`;
|
|
break;
|
|
case 'playlist':
|
|
name = item.title || item.name;
|
|
cover = item.image || item.cover;
|
|
href = `/playlist/${id}`;
|
|
break;
|
|
case 'user-playlist':
|
|
name = item.name;
|
|
cover = item.cover;
|
|
images = item.images;
|
|
href = `/userplaylist/${id}`;
|
|
break;
|
|
default:
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: id,
|
|
type: type,
|
|
name: name,
|
|
cover: cover,
|
|
images: images,
|
|
href: href,
|
|
};
|
|
}
|
|
|
|
async togglePinned(item, type) {
|
|
const storeName = 'pinned_items';
|
|
const minifiedItem = this._minifyPinnedItem(item, type);
|
|
if (!minifiedItem) return;
|
|
|
|
const key = minifiedItem.id;
|
|
const exists = await this.isPinned(key);
|
|
|
|
if (exists) {
|
|
await this.performTransaction(storeName, 'readwrite', (store) => store.delete(key));
|
|
return false;
|
|
} else {
|
|
const allPinned = await this.getPinned();
|
|
if (allPinned.length >= 3) {
|
|
const oldest = allPinned.sort((a, b) => a.pinnedAt - b.pinnedAt)[0];
|
|
await this.performTransaction(storeName, 'readwrite', (store) => store.delete(oldest.id));
|
|
}
|
|
const entry = { ...minifiedItem, pinnedAt: Date.now() };
|
|
await this.performTransaction(storeName, 'readwrite', (store) => store.put(entry));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
async isPinned(id) {
|
|
const storeName = 'pinned_items';
|
|
try {
|
|
const result = await this.performTransaction(storeName, 'readonly', (store) => store.get(id));
|
|
return !!result;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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(true);
|
|
const userFolders = await this.getFolders();
|
|
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,
|
|
user_folders: userFolders,
|
|
};
|
|
return data;
|
|
}
|
|
|
|
async importData(data, clear = false) {
|
|
const db = await this.open();
|
|
|
|
// Safety check: if clear=true but all data is empty, skip to avoid wiping existing data
|
|
if (clear) {
|
|
const allEmpty = [
|
|
data.favorites_tracks,
|
|
data.favorites_albums,
|
|
data.favorites_artists,
|
|
data.favorites_playlists,
|
|
data.favorites_mixes,
|
|
data.history_tracks,
|
|
data.user_playlists,
|
|
data.user_folders,
|
|
].every((arr) => !arr || (Array.isArray(arr) ? arr.length === 0 : Object.keys(arr).length === 0));
|
|
|
|
if (allEmpty) {
|
|
console.warn('[importData] Aborting: clear=true but all import data is empty. Existing data preserved.');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const importStore = async (storeName, items) => {
|
|
if (items === undefined) return false;
|
|
|
|
let itemsArray = Array.isArray(items) ? items : Object.values(items || {});
|
|
|
|
console.log(`Importing to ${storeName}: ${itemsArray.length} items`);
|
|
|
|
if (itemsArray.length === 0) {
|
|
if (clear) {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(storeName, 'readwrite');
|
|
const store = transaction.objectStore(storeName);
|
|
|
|
const countReq = store.count();
|
|
countReq.onsuccess = () => {
|
|
if (countReq.result > 0) {
|
|
store.clear();
|
|
}
|
|
};
|
|
|
|
transaction.oncomplete = () => {
|
|
resolve(countReq.result > 0);
|
|
};
|
|
transaction.onerror = () => reject(transaction.error);
|
|
});
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(storeName, 'readwrite');
|
|
const store = transaction.objectStore(storeName);
|
|
|
|
if (clear) {
|
|
console.log(`[importData] Clearing ${storeName} before import`);
|
|
store.clear();
|
|
}
|
|
|
|
itemsArray.forEach((item) => {
|
|
if (item.id && typeof item.id === 'string' && !isNaN(item.id)) {
|
|
item.id = parseInt(item.id, 10);
|
|
}
|
|
if (item.album?.id && typeof item.album.id === 'string' && !isNaN(item.album.id)) {
|
|
item.album.id = parseInt(item.album.id, 10);
|
|
}
|
|
if (item.artists) {
|
|
item.artists.forEach((artist) => {
|
|
if (artist.id && typeof artist.id === 'string' && !isNaN(artist.id)) {
|
|
artist.id = parseInt(artist.id, 10);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Critical: Ensure key exists for IndexedDB store.put()
|
|
const keyPath = store.keyPath;
|
|
if (keyPath && !item[keyPath]) {
|
|
console.warn(`Item missing keyPath "${keyPath}" in ${storeName}, generating fallback.`);
|
|
if (keyPath === 'uuid') item.uuid = crypto.randomUUID();
|
|
else if (keyPath === 'id')
|
|
item.id = item.trackId || item.albumId || item.artistId || Date.now() + Math.random();
|
|
else if (keyPath === 'timestamp') item.timestamp = Date.now() + Math.random();
|
|
}
|
|
|
|
store.put(item);
|
|
});
|
|
|
|
transaction.oncomplete = () => {
|
|
console.log(`${storeName}: Imported ${itemsArray.length} items`);
|
|
resolve(true);
|
|
};
|
|
|
|
transaction.onerror = (event) => {
|
|
console.error(`${storeName}: Transaction error:`, event.target.error);
|
|
reject(transaction.error);
|
|
};
|
|
});
|
|
};
|
|
|
|
console.log('Starting import with data:', {
|
|
tracks: data.favorites_tracks?.length || 0,
|
|
albums: data.favorites_albums?.length || 0,
|
|
artists: data.favorites_artists?.length || 0,
|
|
playlists: data.favorites_playlists?.length || 0,
|
|
mixes: data.favorites_mixes?.length || 0,
|
|
history: data.history_tracks?.length || 0,
|
|
userPlaylists: data.user_playlists?.length || 0,
|
|
user_folders: data.user_folders?.length || 0,
|
|
});
|
|
|
|
const results = await Promise.all([
|
|
importStore('favorites_tracks', data.favorites_tracks),
|
|
importStore('favorites_albums', data.favorites_albums),
|
|
importStore('favorites_artists', data.favorites_artists),
|
|
importStore('favorites_playlists', data.favorites_playlists),
|
|
importStore('favorites_mixes', data.favorites_mixes),
|
|
importStore('history_tracks', data.history_tracks),
|
|
data.user_playlists ? importStore('user_playlists', data.user_playlists) : Promise.resolve(false),
|
|
data.user_folders ? importStore('user_folders', data.user_folders) : Promise.resolve(false),
|
|
]);
|
|
|
|
console.log('Import results:', results);
|
|
return results.some((r) => r);
|
|
}
|
|
|
|
_updatePlaylistMetadata(playlist) {
|
|
playlist.numberOfTracks = playlist.tracks ? playlist.tracks.length : 0;
|
|
|
|
if (!playlist.cover) {
|
|
const uniqueCovers = [];
|
|
const seenCovers = new Set();
|
|
const tracks = playlist.tracks || [];
|
|
for (const track of tracks) {
|
|
const cover = track.album?.cover;
|
|
if (cover && !seenCovers.has(cover)) {
|
|
seenCovers.add(cover);
|
|
uniqueCovers.push(cover);
|
|
if (uniqueCovers.length >= 4) break;
|
|
}
|
|
}
|
|
playlist.images = uniqueCovers;
|
|
}
|
|
return playlist;
|
|
}
|
|
|
|
_dispatchPlaylistSync(action, playlist) {
|
|
window.dispatchEvent(
|
|
new CustomEvent('sync-playlist-change', {
|
|
detail: { action, playlist },
|
|
})
|
|
);
|
|
}
|
|
|
|
// User Playlists API
|
|
async createPlaylist(name, tracks = [], cover = '', description = '') {
|
|
const id = crypto.randomUUID();
|
|
const playlist = {
|
|
id: id,
|
|
name: name,
|
|
tracks: tracks.map((t) => this._minifyItem(t.type || 'track', { ...t, addedAt: Date.now() })),
|
|
cover: cover,
|
|
description: description,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
numberOfTracks: tracks.length,
|
|
images: [], // Initialize images
|
|
};
|
|
this._updatePlaylistMetadata(playlist);
|
|
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
|
|
|
// TRIGGER SYNC
|
|
this._dispatchPlaylistSync('create', playlist);
|
|
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
|
|
|
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 trackWithDate = { ...track, addedAt: Date.now() };
|
|
const minifiedTrack = this._minifyItem(track.type || 'track', trackWithDate);
|
|
if (playlist.tracks.some((t) => t.id === track.id)) return;
|
|
playlist.tracks.push(minifiedTrack);
|
|
playlist.updatedAt = Date.now();
|
|
this._updatePlaylistMetadata(playlist);
|
|
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
|
|
|
this._dispatchPlaylistSync('update', playlist);
|
|
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
|
|
|
return playlist;
|
|
}
|
|
|
|
async addTracksToPlaylist(playlistId, tracks) {
|
|
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
|
|
if (!playlist) throw new Error('Playlist not found');
|
|
playlist.tracks = playlist.tracks || [];
|
|
|
|
let addedCount = 0;
|
|
for (const track of tracks) {
|
|
if (!playlist.tracks.some((t) => t.id === track.id)) {
|
|
const trackWithDate = { ...track, addedAt: Date.now() };
|
|
playlist.tracks.push(this._minifyItem(track.type || 'track', trackWithDate));
|
|
addedCount++;
|
|
}
|
|
}
|
|
|
|
if (addedCount > 0) {
|
|
playlist.updatedAt = Date.now();
|
|
this._updatePlaylistMetadata(playlist);
|
|
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
|
this._dispatchPlaylistSync('update', playlist);
|
|
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
|
}
|
|
|
|
return playlist;
|
|
}
|
|
|
|
async removeTrackFromPlaylist(playlistId, trackId, trackType = null) {
|
|
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) => {
|
|
if (trackType) {
|
|
return !(t.id == trackId && (t.type || 'track') === trackType);
|
|
}
|
|
return t.id != trackId;
|
|
});
|
|
playlist.updatedAt = Date.now();
|
|
this._updatePlaylistMetadata(playlist);
|
|
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
|
|
|
this._dispatchPlaylistSync('update', playlist);
|
|
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
|
|
|
return playlist;
|
|
}
|
|
|
|
async deletePlaylist(playlistId) {
|
|
await this.performTransaction('user_playlists', 'readwrite', (store) => store.delete(playlistId));
|
|
|
|
// TRIGGER SYNC (but for deleting)
|
|
this._dispatchPlaylistSync('delete', { id: playlistId });
|
|
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
|
}
|
|
|
|
async getPlaylist(playlistId) {
|
|
return await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
|
|
}
|
|
|
|
async updatePlaylist(playlist) {
|
|
playlist.updatedAt = Date.now();
|
|
this._updatePlaylistMetadata(playlist);
|
|
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
|
|
|
this._dispatchPlaylistSync('update', playlist);
|
|
|
|
return playlist;
|
|
}
|
|
|
|
async addPlaylistToFolder(folderId, playlistId) {
|
|
const folder = await this.getFolder(folderId);
|
|
if (!folder) throw new Error('Folder not found');
|
|
folder.playlists = folder.playlists || [];
|
|
if (!folder.playlists.includes(playlistId)) {
|
|
folder.playlists.push(playlistId);
|
|
folder.updatedAt = Date.now();
|
|
await this.performTransaction('user_folders', 'readwrite', (store) => store.put(folder));
|
|
}
|
|
return folder;
|
|
}
|
|
|
|
// User Folders API
|
|
async createFolder(name, cover = '') {
|
|
const id = crypto.randomUUID();
|
|
const folder = {
|
|
id: id,
|
|
name: name,
|
|
cover: cover,
|
|
playlists: [],
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
};
|
|
await this.performTransaction('user_folders', 'readwrite', (store) => store.put(folder));
|
|
return folder;
|
|
}
|
|
|
|
async getFolders() {
|
|
const db = await this.open();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction('user_folders', 'readonly');
|
|
const store = transaction.objectStore('user_folders');
|
|
const index = store.index('createdAt');
|
|
const request = index.getAll();
|
|
request.onsuccess = () => resolve(request.result.reverse());
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async getFolder(id) {
|
|
return await this.performTransaction('user_folders', 'readonly', (store) => store.get(id));
|
|
}
|
|
|
|
async deleteFolder(id) {
|
|
await this.performTransaction('user_folders', 'readwrite', (store) => store.delete(id));
|
|
}
|
|
|
|
async getPinned() {
|
|
const storeName = 'pinned_items';
|
|
const db = await this.open();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(storeName, 'readonly');
|
|
const store = transaction.objectStore(storeName);
|
|
const request = store.getAll();
|
|
|
|
request.onsuccess = () => {
|
|
const results = request.result;
|
|
results.sort((a, b) => b.pinnedAt - a.pinnedAt);
|
|
resolve(results);
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async getPlaylists(includeTracks = false) {
|
|
const db = await this.open();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction('user_playlists', 'readwrite'); // Changed to readwrite for lazy migration
|
|
const store = transaction.objectStore('user_playlists');
|
|
const index = store.index('createdAt');
|
|
const request = index.getAll();
|
|
request.onsuccess = () => {
|
|
const playlists = request.result.reverse(); // Newest first
|
|
const processedPlaylists = playlists.map((playlist) => {
|
|
let needsUpdate = false;
|
|
|
|
// Lazy migration for numberOfTracks
|
|
if (typeof playlist.numberOfTracks === 'undefined') {
|
|
playlist.numberOfTracks = playlist.tracks ? playlist.tracks.length : 0;
|
|
needsUpdate = true;
|
|
}
|
|
|
|
// Lazy migration for images (collage)
|
|
if (!playlist.cover && (!playlist.images || playlist.images.length === 0)) {
|
|
this._updatePlaylistMetadata(playlist);
|
|
needsUpdate = true;
|
|
}
|
|
|
|
if (needsUpdate) {
|
|
// We are in a readwrite transaction, so we can put back
|
|
try {
|
|
store.put(playlist);
|
|
} catch (e) {
|
|
console.warn('Failed to update playlist metadata', e);
|
|
}
|
|
}
|
|
|
|
if (includeTracks) {
|
|
return playlist;
|
|
}
|
|
|
|
// Return lightweight copy without tracks
|
|
const { tracks, ...minified } = playlist;
|
|
return minified;
|
|
});
|
|
resolve(processedPlaylists);
|
|
};
|
|
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;
|
|
playlist.updatedAt = Date.now();
|
|
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
|
return playlist;
|
|
}
|
|
|
|
async updatePlaylistDescription(playlistId, newDescription) {
|
|
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
|
|
if (!playlist) throw new Error('Playlist not found');
|
|
playlist.description = newDescription;
|
|
playlist.updatedAt = Date.now();
|
|
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
|
|
|
this._dispatchPlaylistSync('update', playlist);
|
|
|
|
return playlist;
|
|
}
|
|
|
|
async updatePlaylistTracks(playlistId, tracks) {
|
|
const db = await this.open();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction('user_playlists', 'readwrite');
|
|
const store = transaction.objectStore('user_playlists');
|
|
|
|
const getRequest = store.get(playlistId);
|
|
getRequest.onsuccess = () => {
|
|
const playlist = getRequest.result;
|
|
if (!playlist) {
|
|
reject(new Error('Playlist not found'));
|
|
return;
|
|
}
|
|
playlist.tracks = tracks;
|
|
playlist.updatedAt = Date.now();
|
|
this._updatePlaylistMetadata(playlist);
|
|
const putRequest = store.put(playlist);
|
|
putRequest.onsuccess = () => {
|
|
resolve(playlist);
|
|
};
|
|
putRequest.onerror = () => {
|
|
reject(putRequest.error);
|
|
};
|
|
};
|
|
getRequest.onerror = () => {
|
|
reject(getRequest.error);
|
|
};
|
|
|
|
transaction.onerror = (event) => {
|
|
reject(event.target.error);
|
|
};
|
|
});
|
|
}
|
|
|
|
async saveSetting(key, value) {
|
|
await this.performTransaction('settings', 'readwrite', (store) => store.put(value, key));
|
|
}
|
|
|
|
async getSetting(key) {
|
|
return await this.performTransaction('settings', 'readonly', (store) => store.get(key));
|
|
}
|
|
}
|
|
|
|
export const db = new MusicDatabase();
|