New Account System

This commit is contained in:
Samidy 2026-01-16 18:47:28 +03:00
parent 42d1b74df8
commit fa716f002a
14 changed files with 9439 additions and 9535 deletions

View file

@ -1,93 +0,0 @@
# Setting Up Firebase Sync for Monochrome
Follow these steps to enable cross-device synchronization for your library, history, and settings using your own Firebase project.
## 1. Create a Firebase Project
1. Go to the [Firebase Console](https://console.firebase.google.com/).
2. Click **Add project** and give it a name (e.g., "Monochrome Sync").
3. (Optional) Disable Gemini and Google Analytics if you want to keep it simple.
4. Click **Create project**.
## 2. Enable Authentication
1. In the left sidebar, click **Build** > **Authentication**.
2. Click **Get Started**.
3. Go to the **Sign-in method** tab.
4. Select **Google** and enable it.
5. Set your project support email and click **Save**.
### 2.1 Authorized Domains (CRITICAL)
Firebase will block login attempts from unknown domains.
1. In the **Authentication** section, go to the **Settings** tab.
2. Click **Authorized domains** in the left sub-menu.
3. Click **Add domain**.
4. Add your hosting domain (e.g., `julienmaille.github.io`).
- _Note: `localhost` and `127.0.0.1` are usually added by default for local testing._
## 3. Enable Realtime Database
1. In the left sidebar, click **Build** > **Realtime Database**.
2. Click **Create Database**.
3. Choose a location near you and click **Next**.
4. Select **Start in test mode** (we will change the rules in the next step) and click **Enable**.
## 4. Set Security Rules
1. In the Realtime Database section, go to the **Rules** tab.
2. Replace the existing rules with the following to ensure users can only see their own data:
```json
{
"rules": {
"users": {
"$uid": {
".read": "$uid === auth.uid",
".write": "$uid === auth.uid"
}
},
"public_playlists": {
".read": true,
".indexOn": ["uid", "name"],
"$playlistId": {
".write": "auth != null && (!data.exists() || data.child('uid').val() === auth.uid)"
}
}
}
}
```
- **Note:** The `public_playlists` rule allows anyone to read the playlists. The write rule ensures that only authenticated users can publish, and only the owner (creator) of a playlist can modify or delete it.
3. Click **Publish**.
## 5. Get Your Configuration
1. Click the gear icon (⚙️) next to "Project Overview" and select **Project settings**.
2. In the **General** tab, scroll down to "Your apps" and click the **Web icon (`</>`)**.
3. Register the app (e.g., "Monochrome App").
4. You will see a `firebaseConfig` object. It looks like this:
```javascript
const firebaseConfig = {
apiKey: 'AIzaSy...',
authDomain: 'your-project.firebaseapp.com',
databaseURL: 'https://your-project.firebaseio.com',
projectId: 'your-project',
storageBucket: 'your-project.appspot.com',
messagingSenderId: '...',
appId: '...',
};
```
5. **Copy only the part with the curly braces `{ ... }`**.
## 6. Configure Monochrome
1. Open the Monochrome app and go to **Settings**.
2. Find the **Firebase Configuration** section.
3. Paste the JSON object you copied into the textarea.
4. Click **Save & Reload**.
5. Under **Sync & Backup**, click **Connect with Google** and sign in.
**Your library is now synced to the cloud!** Log in on any other device with the same configuration to see your music everywhere.

View file

@ -152,7 +152,7 @@
>
<div class="info">
<span class="label">Public Playlist</span>
<span class="description">Visible to anyone with the link</span>
<span class="description">Visible to anyone with the link.</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="playlist-public-toggle" />
@ -1099,55 +1099,6 @@
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<a class="label" id="firebase-config" href="#toggle-firebase-config-btn"
>ADVANCED: Firebase Configuration</a
>
<span class="description"
>Select which database you would like to connect to. <br />
Monochrome-owned database is default.</span
>
</div>
<div>
<button id="toggle-firebase-config-btn" class="btn-secondary">
Custom Configuration
</button>
<div class="firebase-settings-wrapper">
<div id="custom-firebase-config-container" class="custom-firebase-config">
<p class="config-help-text">
Default shared instance is active.
<a
href="https://github.com/SamidyFR/monochrome/blob/main/firebase-setup.md"
target="_blank"
class="text-link"
>Override below only if needed.</a
>
</p>
<textarea
id="firebase-config-input"
class="template-input"
rows="5"
placeholder='{ "apiKey": "...", "authDomain": "...", ... }'
></textarea>
<div class="firebase-controls-container">
<button id="save-firebase-config-btn" class="btn-secondary">
Save & Reload
</button>
<button id="share-firebase-config-btn" class="btn-secondary">
Share
</button>
<button id="clear-firebase-config-btn" class="btn-secondary danger">
Clear Config
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
@ -1460,7 +1411,7 @@
</a>
</div>
<div class="about-footer">
<p class="version">Version 1.5.0</p>
<p class="version">Version 1.6.0</p>
<p class="disclaimer">
This is an independent client and is not affiliated with or endorsed by TIDAL or any
music streaming service.
@ -1542,27 +1493,10 @@
}
</script>
<p style="padding-top: 50px; text-align: center; color: #8b8b93">
We only store music data and a randomized ID to find out which Google account is which.
We only store music data and a randomized ID to find out which Google/Email account is which.
<br />
All data is anonymous. We do not store anything like emails, usernames, or anything
sensitive. <br />
<br />
However, if you want complete control over your data, we allow you to use your own firebase
configuration.
</p>
<div
style="
display: flex;
gap: 50px;
align-items: center;
justify-content: center;
padding-top: 25px;
"
>
<a id="advanced-config-link" class="btn-secondary" href="#settings"
>Advanced: Custom Configuration</a
>
</div>
</div>
</div>
<div id="page-donate" class="page">
@ -1857,6 +1791,7 @@
</footer>
</div>
<script src="https://unpkg.com/@studio-freight/lenis"></script>
<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.21.3/dist/pocketbase.umd.js"></script>
<script type="module" src="js/app.js"></script>
</body>
</html>

View file

@ -7,12 +7,12 @@ import {
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
} from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js';
import { syncManager } from './sync.js';
export class AuthManager {
constructor() {
this.user = null;
this.unsubscribe = null;
this.authListeners = [];
this.init();
}
@ -23,16 +23,18 @@ export class AuthManager {
this.user = user;
this.updateUI(user);
if (user) {
console.log('User logged in:', user.uid);
syncManager.initialize(user);
} else {
console.log('User logged out');
syncManager.disconnect();
}
this.authListeners.forEach((listener) => listener(user));
});
}
onAuthStateChanged(callback) {
this.authListeners.push(callback);
// If we already have a user state, trigger immediately
if (this.user !== null) {
callback(this.user);
}
}
async signInWithGoogle() {
if (!auth) {
alert('Firebase is not configured. Please check console.');

480
js/accounts/pocketbase.js Normal file
View file

@ -0,0 +1,480 @@
import { db } from '../db.js';
import { authManager } from './auth.js';
const PUBLIC_COLLECTION = 'public_playlists';
const POCKETBASE_URL = 'https://monodb.samidy.com';
const pb = new PocketBase(POCKETBASE_URL);
pb.autoCancellation(false);
const syncManager = {
pb: pb,
_userRecordCache: null,
_isSyncing: false,
async _getUserRecord(uid) {
if (!uid) {
console.warn('_getUserRecord called with no UID.');
return null;
}
if (this._userRecordCache && this._userRecordCache.firebase_id === uid) {
return this._userRecordCache;
}
try {
const record = await this.pb.collection('DB_users').getFirstListItem(
`firebase_id="${uid}"`,
{ f_id: uid }
);
this._userRecordCache = record;
return record;
} catch (error) {
if (error.status === 404) {
try {
const newRecord = await this.pb.collection('DB_users').create({
firebase_id: uid,
library: {},
history: [],
}, { f_id: uid });
this._userRecordCache = newRecord;
return newRecord;
} catch (createError) {
console.error('Failed to create user record in PocketBase:', createError);
return null;
}
}
console.error('Failed to get user record from PocketBase:', error);
return null;
}
},
async getUserData() {
const user = authManager.user;
if (!user) return null;
const record = await this._getUserRecord(user.uid);
if (!record) return null;
let library = record.library || {};
if (typeof library === 'string') {
try {
library = JSON.parse(library);
} catch (e) {
console.error('Failed to parse library JSON:', e);
library = {};
}
}
let history = record.history || [];
if (typeof history === 'string') {
try {
history = JSON.parse(history);
} catch (e) {
console.error('Failed to parse history JSON:', e);
history = [];
}
}
let userPlaylists = record.user_playlists || {};
if (typeof userPlaylists === 'string') {
try {
userPlaylists = JSON.parse(userPlaylists);
} catch (e) {
console.error('Failed to parse user_playlists JSON:', e);
userPlaylists = {};
}
}
return { library, history, userPlaylists };
},
async _updateUserJSON(uid, field, data) {
const record = await this._getUserRecord(uid);
if (!record) {
console.error('Cannot update: no user record found');
return;
}
try {
const updated = await this.pb.collection('DB_users').update(
record.id,
{ [field]: data },
{ f_id: uid }
);
this._userRecordCache = updated;
} catch (error) {
console.error(`Failed to sync ${field} to PocketBase:`, error);
}
},
async syncLibraryItem(type, item, added) {
const user = authManager.user;
if (!user) return;
const record = await this._getUserRecord(user.uid);
if (!record) return;
let library = record.library || {};
if (typeof library === 'string') {
try {
library = JSON.parse(library);
} catch (e) {
console.error('Library field is not valid JSON', e);
library = {};
}
}
const pluralType = type === 'mix' ? 'mixes' : `${type}s`;
const key = type === 'playlist' ? item.uuid : item.id;
if (!library[pluralType]) {
library[pluralType] = {};
}
if (added) {
library[pluralType][key] = this._minifyItem(type, item);
} else {
delete library[pluralType][key];
}
await this._updateUserJSON(user.uid, 'library', library);
},
_minifyItem(type, item) {
if (!item) return item;
const base = {
id: item.id,
addedAt: item.addedAt || Date.now(),
};
if (type === 'track') {
return {
...base,
title: item.title || null,
duration: item.duration || null,
explicit: item.explicit || false,
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
? {
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,
}
: null,
copyright: item.copyright || null,
isrc: item.isrc || null,
trackNumber: item.trackNumber || null,
streamStartDate: item.streamStartDate || null,
version: item.version || null,
mixes: item.mixes || null,
};
}
if (type === 'album') {
return {
...base,
title: item.title || null,
cover: item.cover || null,
releaseDate: item.releaseDate || null,
explicit: item.explicit || false,
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,
type: item.type || null,
numberOfTracks: item.numberOfTracks || null,
};
}
if (type === 'artist') {
return {
...base,
name: item.name || null,
picture: item.picture || item.image || null,
};
}
if (type === 'playlist') {
return {
uuid: item.uuid || item.id,
addedAt: item.addedAt || Date.now(),
title: item.title || item.name || null,
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 || Date.now(),
title: item.title,
subTitle: item.subTitle,
mixType: item.mixType,
cover: item.cover,
};
}
return item;
},
async syncHistoryItem(historyEntry) {
const user = authManager.user;
if (!user) return;
const record = await this._getUserRecord(user.uid);
if (!record) return;
let history = record.history || [];
if (typeof history === 'string') {
try {
history = JSON.parse(history);
} catch (e) {
console.error('History field is not valid JSON', e);
history = [];
}
}
const newHistory = [historyEntry, ...history].slice(0, 100);
await this._updateUserJSON(user.uid, 'history', newHistory);
},
async syncUserPlaylist(playlist, action) {
const user = authManager.user;
if (!user) return;
const record = await this._getUserRecord(user.uid);
if (!record) return;
let userPlaylists = record.user_playlists || {};
if (typeof userPlaylists === 'string') {
try {
userPlaylists = JSON.parse(userPlaylists);
} catch (e) {
console.error('user_playlists field is not valid JSON', e);
userPlaylists = {};
}
}
if (action === 'delete') {
delete userPlaylists[playlist.id];
} else {
userPlaylists[playlist.id] = {
id: playlist.id,
name: playlist.name,
cover: playlist.cover || null,
tracks: playlist.tracks ? playlist.tracks.map(t => this._minifyItem('track', t)) : [],
createdAt: playlist.createdAt || Date.now(),
updatedAt: playlist.updatedAt || Date.now(),
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
images: playlist.images || [],
isPublic: playlist.isPublic || false,
};
}
await this._updateUserJSON(user.uid, 'user_playlists', userPlaylists);
},
async getPublicPlaylist(uuid) {
try {
const record = await this.pb.collection(PUBLIC_COLLECTION).getFirstListItem(
`uuid="${uuid}"`,
{ p_id: uuid }
);
let rawCover = record.image || record.cover || record.playlist_cover || '';
let extraData = record.data;
if (typeof extraData === 'string') {
try { extraData = JSON.parse(extraData); } catch(e) {}
}
if (!rawCover && extraData && typeof extraData === 'object') {
rawCover = extraData.cover || extraData.image || '';
}
let finalCover = rawCover;
if (rawCover && !rawCover.startsWith('http') && !rawCover.startsWith('data:')) {
finalCover = this.pb.files.getUrl(record, rawCover);
}
let images = [];
let tracks = record.tracks || [];
if (typeof tracks === 'string') {
try {
tracks = JSON.parse(tracks);
} catch (e) {
console.error('Failed to parse tracks JSON:', e);
tracks = [];
}
}
if (!finalCover && tracks && tracks.length > 0) {
const uniqueCovers = [];
const seenCovers = new Set();
for (const track of tracks) {
const c = track.album?.cover;
if (c && !seenCovers.has(c)) {
seenCovers.add(c);
uniqueCovers.push(c);
if (uniqueCovers.length >= 4) break;
}
}
images = uniqueCovers;
}
let finalTitle = record.title || record.name || record.playlist_name;
if (!finalTitle && extraData && typeof extraData === 'object') {
finalTitle = extraData.title || extraData.name;
}
if (!finalTitle) finalTitle = 'Untitled Playlist';
return {
...record,
id: record.uuid,
name: finalTitle,
title: finalTitle,
cover: finalCover,
image: finalCover,
tracks: tracks,
images: images,
numberOfTracks: tracks.length,
type: 'user-playlist',
isPublic: true,
user: { name: 'Community Playlist' }
};
} catch (error) {
if (error.status === 404) return null;
console.error('Failed to fetch public playlist:', error);
throw error;
}
},
async publishPlaylist(playlist) {
if (!playlist || !playlist.id) return;
const uid = authManager.user?.uid;
if (!uid) return;
const data = {
uuid: playlist.id,
uid: uid,
title: playlist.name,
name: playlist.name,
playlist_name: playlist.name,
image: playlist.cover,
cover: playlist.cover,
playlist_cover: playlist.cover,
tracks: playlist.tracks,
isPublic: true,
data: {
title: playlist.name,
cover: playlist.cover
}
};
try {
const existing = await this.pb.collection(PUBLIC_COLLECTION).getList(1, 1, {
filter: `uuid="${playlist.id}"`,
p_id: playlist.id
});
if (existing.items.length > 0) {
await this.pb.collection(PUBLIC_COLLECTION).update(existing.items[0].id, data);
} else {
await this.pb.collection(PUBLIC_COLLECTION).create(data);
}
} catch (error) {
console.error('Failed to publish playlist:', error);
}
},
async unpublishPlaylist(uuid) {
const uid = authManager.user?.uid;
if (!uid) return;
try {
const existing = await this.pb.collection('public_playlists').getList(1, 1, {
filter: `uuid="${uuid}"`,
p_id: uuid
});
if (existing.items && existing.items.length > 0) {
await this.pb.collection('public_playlists').delete(existing.items[0].id, { p_id: uuid });
}
} catch (error) {
console.error('Failed to unpublish playlist:', error);
}
},
async clearCloudData() {
const user = authManager.user;
if (!user) return;
try {
const record = await this._getUserRecord(user.uid);
if (record) {
await this.pb.collection('DB_users').delete(record.id, { f_id: user.uid });
this._userRecordCache = null;
alert('Cloud data cleared successfully.');
}
} catch (error) {
console.error('Failed to clear cloud data!', error);
alert('Failed to clear cloud data! :( Check console for details.');
}
},
async onAuthStateChanged(user) {
if (user) {
if (this._isSyncing) return;
this._isSyncing = true;
try {
const data = await this.getUserData();
if (data) {
const convertedData = {
favorites_tracks: data.library.tracks ? Object.values(data.library.tracks).filter(t => t && typeof t === 'object') : [],
favorites_albums: data.library.albums ? Object.values(data.library.albums).filter(a => a && typeof a === 'object') : [],
favorites_artists: data.library.artists ? Object.values(data.library.artists).filter(a => a && typeof a === 'object') : [],
favorites_playlists: data.library.playlists ? Object.values(data.library.playlists).filter(p => p && typeof p === 'object') : [],
favorites_mixes: data.library.mixes ? Object.values(data.library.mixes).filter(m => m && typeof m === 'object') : [],
history_tracks: data.history || [],
user_playlists: data.userPlaylists ? Object.values(data.userPlaylists).filter(p => p && typeof p === 'object') : [],
};
await db.importData(convertedData);
await new Promise(resolve => setTimeout(resolve, 300));
window.dispatchEvent(new CustomEvent('library-changed'));
window.dispatchEvent(new CustomEvent('history-changed'));
window.dispatchEvent(new HashChangeEvent('hashchange'));
}
} catch (error) {
console.error('Error during PocketBase sync!', error);
} finally {
this._isSyncing = false;
}
} else {
this._userRecordCache = null;
this._isSyncing = false;
}
},
};
if (pb) {
authManager.onAuthStateChanged(syncManager.onAuthStateChanged.bind(syncManager));
}
export { pb, syncManager };

View file

@ -25,7 +25,7 @@ import { downloadAlbumAsZip, downloadDiscography, downloadPlaylistAsZip } from '
import { debounce, SVG_PLAY } from './utils.js';
import { sidePanelManager } from './side-panel.js';
import { db } from './db.js';
import { syncManager } from './firebase/sync.js';
import { syncManager } from './accounts/pocketbase.js';
import { registerSW } from 'virtual:pwa-register';
import './smooth-scrolling.js';
import { readTrackMetadata } from './metadata.js';
@ -584,7 +584,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const publicToggle = document.getElementById('playlist-public-toggle');
const shareBtn = document.getElementById('playlist-share-btn');
// Check if actually public in Firebase to be sure (async) or trust local flag
// Check if actually public in Pocketbase to be sure (async) or trust local flag
// We trust local flag for UI speed, but could verify.
if (publicToggle) publicToggle.checked = !!playlist.isPublic;
@ -684,7 +684,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (userPlaylist) {
tracks = userPlaylist.tracks;
} else {
// Try API, if fail, try Public Firebase
// Try API, if fail, try Public Pocketbase
try {
const { tracks: apiTracks } = await api.getPlaylist(playlistId);
tracks = apiTracks;
@ -990,7 +990,7 @@ document.addEventListener('DOMContentLoaded', async () => {
}, 3000);
}
// Listener for Firebase Sync updates
// Listener for Pocketbase Sync updates
window.addEventListener('library-changed', () => {
const hash = window.location.hash;
if (hash === '#library') {

249
js/db.js
View file

@ -141,23 +141,28 @@ export class MusicDatabase {
}
}
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);
});
}
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;
@ -276,92 +281,105 @@ export class MusicDatabase {
return data;
}
async importData(data, clear = false) {
const db = await this.open();
async importData(data, clear = false) {
const db = await this.open();
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);
let hasChanges = false;
// force clear on first sync
console.log(`Clearing ${storeName} to Make Sure Everythings Good`);
store.clear();
hasChanges = true;
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);
}
});
}
console.log(`${storeName}: Adding item with ID ${item.id || item.uuid || item.timestamp}`);
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,
});
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),
]);
console.log('Import results:', results);
return results.some((r) => r);
}
const importStore = async (storeName, items) => {
if (items === undefined) return false;
let itemsArray = Array.isArray(items) ? items : Object.values(items || {});
if (itemsArray.length === 0) {
if (clear) {
return new Promise((resolve) => {
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const countReq = store.count();
countReq.onsuccess = () => {
if (countReq.result > 0) {
store.clear();
resolve(true);
} else {
resolve(false);
}
};
countReq.onerror = () => resolve(false);
});
}
return false;
}
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
let hasChanges = false;
if (clear) {
store.clear();
hasChanges = true;
}
let pending = itemsArray.length;
itemsArray.forEach((item) => {
if (clear) {
store.put(item);
pending--;
if (pending === 0) resolve(true);
return;
}
let key;
if (storeName === 'favorites_playlists') key = item.uuid;
else if (storeName === 'history_tracks') key = item.timestamp;
else key = item.id;
const getReq = store.get(key);
getReq.onsuccess = () => {
const existing = getReq.result;
if (!existing || JSON.stringify(existing) !== JSON.stringify(item)) {
store.put(item);
hasChanges = true;
}
pending--;
if (pending === 0) resolve(hasChanges);
};
getReq.onerror = () => {
store.put(item);
hasChanges = true;
pending--;
if (pending === 0) resolve(hasChanges);
};
});
transaction.onerror = () => reject(transaction.error);
});
};
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),
]);
return results.some((r) => r);
}
_updatePlaylistMetadata(playlist) {
playlist.numberOfTracks = playlist.tracks ? playlist.tracks.length : 0;
@ -383,6 +401,12 @@ export class MusicDatabase {
return playlist;
}
_dispatchPlaylistSync(action, playlist) {
window.dispatchEvent(new CustomEvent('sync-playlist-change', {
detail: { action, playlist }
}));
}
// User Playlists API
async createPlaylist(name, tracks = [], cover = '') {
const id = crypto.randomUUID();
@ -393,9 +417,15 @@ export class MusicDatabase {
cover: cover,
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);
return playlist;
}
@ -409,6 +439,9 @@ export class MusicDatabase {
playlist.updatedAt = Date.now();
this._updatePlaylistMetadata(playlist);
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
this._dispatchPlaylistSync('update', playlist);
return playlist;
}
@ -420,17 +453,33 @@ export class MusicDatabase {
playlist.updatedAt = Date.now();
this._updatePlaylistMetadata(playlist);
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
this._dispatchPlaylistSync('update', playlist);
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 });
}
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 getPlaylists(includeTracks = false) {
const db = await this.open();
return new Promise((resolve, reject) => {

View file

@ -16,7 +16,7 @@ import { showNotification, downloadTrackWithMetadata } from './downloads.js';
import { lyricsSettings, downloadQualitySettings } from './storage.js';
import { updateTabTitle } from './router.js';
import { db } from './db.js';
import { syncManager } from './firebase/sync.js';
import { syncManager } from './accounts/pocketbase.js';
import { waveformGenerator } from './waveform.js';
let currentWaveformPeaks = null;

View file

@ -1,465 +0,0 @@
// 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();

View file

@ -13,14 +13,12 @@ import {
downloadQualitySettings,
} from './storage.js';
import { db } from './db.js';
import { authManager } from './firebase/auth.js';
import { syncManager } from './firebase/sync.js';
import { initializeFirebaseSettingsUI } from './firebase/config.js';
import { authManager } from './accounts/auth.js';
import { syncManager } from './accounts/pocketbase.js';
export function initializeSettings(scrobbler, player, api, ui) {
// Initialize Firebase UI & Settings
// Initialize account system UI & Settings
authManager.updateUI(authManager.user);
initializeFirebaseSettingsUI();
// Email Auth UI Logic
const toggleEmailBtn = document.getElementById('toggle-email-auth-btn');

View file

@ -81,8 +81,8 @@ export function initializeUIInteractions(player, api) {
const likeBtn = container.querySelector('#like-queue-btn');
if (likeBtn) {
likeBtn.addEventListener('click', async () => {
const { db } = await import('./db.js');
const { syncManager } = await import('./firebase/sync.js');
const { db } = await import('./db.js'); // Already imported
const { syncManager } = await import('./accounts/pocketbase.js');
const { showNotification } = await import('./downloads.js');
let addedCount = 0;
@ -107,8 +107,8 @@ export function initializeUIInteractions(player, api) {
const addToPlaylistBtn = container.querySelector('#add-queue-to-playlist-btn');
if (addToPlaylistBtn) {
addToPlaylistBtn.addEventListener('click', async () => {
const { db } = await import('./db.js');
const { syncManager } = await import('./firebase/sync.js');
const { db } = await import('./db.js'); // Already imported
const { syncManager } = await import('./accounts/pocketbase.js');
const { showNotification } = await import('./downloads.js');
const playlists = await db.getPlaylists();
@ -259,8 +259,8 @@ export function initializeUIInteractions(player, api) {
e.stopPropagation();
const track = player.getCurrentQueue()[index];
if (track) {
const { db } = await import('./db.js');
const { syncManager } = await import('./firebase/sync.js');
const { db } = await import('./db.js'); // Already imported
const { syncManager } = await import('./accounts/pocketbase.js');
const { showNotification } = await import('./downloads.js');
const added = await db.toggleFavorite('track', track);

View file

@ -18,7 +18,7 @@ import { openLyricsPanel } from './lyrics.js';
import { recentActivityManager, backgroundSettings, trackListSettings, cardSettings } from './storage.js';
import { db } from './db.js';
import { getVibrantColorFromImage } from './vibrant-color.js';
import { syncManager } from './firebase/sync.js';
import { syncManager } from './accounts/pocketbase.js';
export class UIRenderer {
constructor(api, player) {
@ -1366,12 +1366,12 @@ export class UIRenderer {
ownedPlaylist = await db.getPlaylist(playlistId);
playlistData = ownedPlaylist;
// If not in local DB, check if it's a public Firebase playlist
// If not in local DB, check if it's a public Pocketbase playlist
if (!playlistData) {
try {
playlistData = await syncManager.getPublicPlaylist(playlistId);
} catch (e) {
console.warn('Failed to check public Firebase playlists:', e);
console.warn('Failed to check public pocketbase playlists:', e);
}
}
}
@ -1379,7 +1379,7 @@ export class UIRenderer {
if (playlistData) {
// ... (rest of the logic)
// Render user or public firebase playlist
// Render user or public Pocketbase playlist
imageEl.src = playlistData.cover || 'assets/appicon.png';
imageEl.style.backgroundColor = '';

17555
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -41,5 +41,8 @@
"overrides": {
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14",
"source-map": "^0.7.4"
},
"dependencies": {
"pocketbase": "^0.26.5"
}
}