New Account System
This commit is contained in:
parent
42d1b74df8
commit
fa716f002a
14 changed files with 9439 additions and 9535 deletions
|
|
@ -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.
|
||||
73
index.html
73
index.html
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
480
js/accounts/pocketbase.js
Normal 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 };
|
||||
|
|
@ -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
249
js/db.js
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
8
js/ui.js
8
js/ui.js
|
|
@ -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
17555
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -41,5 +41,8 @@
|
|||
"overrides": {
|
||||
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14",
|
||||
"source-map": "^0.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"pocketbase": "^0.26.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue