Merge branch 'main' into copilot/add-custom-download-formats
This commit is contained in:
commit
5b4bff97e0
13 changed files with 329 additions and 125 deletions
|
|
@ -9,12 +9,11 @@ MONOCHROME_DEV_PORT=5173
|
|||
# Set AUTH_ENABLED=true to enable the auth gate entirely (login required)
|
||||
AUTH_ENABLED=false
|
||||
AUTH_SECRET=change-me-to-a-random-string
|
||||
FIREBASE_PROJECT_ID=monochrome-database
|
||||
APPWRITE_ENDPOINT=https://auth.yourdomain.com/v1
|
||||
APPWRITE_PROJECT_ID=auth-for-monochrome
|
||||
# Optional: toggle login providers (defaults to true when unset)
|
||||
# AUTH_GOOGLE_ENABLED=true
|
||||
# AUTH_EMAIL_ENABLED=true
|
||||
# Optional: override the Firebase config for the login page (JSON string)
|
||||
# FIREBASE_CONFIG={"apiKey":"...","authDomain":"...","projectId":"...","storageBucket":"...","messagingSenderId":"...","appId":"..."}
|
||||
# Optional: set PocketBase URL (hides the field in settings when set)
|
||||
# POCKETBASE_URL=https://monodb.samidy.com
|
||||
# SESSION_MAX_AGE=604800000 # 7 days in ms (default)
|
||||
|
|
|
|||
28
DOCKER.md
28
DOCKER.md
|
|
@ -92,6 +92,34 @@ Override files can extend existing services (add labels, env vars, networks) and
|
|||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
The application is configured via environment variables. Copy `.env.example` to `.env` and edit it to match your setup.
|
||||
|
||||
### Authentication (Appwrite)
|
||||
|
||||
Monochrome uses Appwrite for user authentication. While it defaults to official instances, you can use your own self-hosted Appwrite instance:
|
||||
|
||||
1. Create a project in Appwrite.
|
||||
2. Enable the **Google** or **Email/Password** providers in the Appwrite Console.
|
||||
3. Set these variables in your `.env`:
|
||||
- `APPWRITE_ENDPOINT`: Your Appwrite API endpoint (e.g., `https://auth.yourdomain.com/v1`).
|
||||
- `APPWRITE_PROJECT_ID`: Your Appwrite project ID (e.g., `auth-for-monochrome`).
|
||||
|
||||
### Database (PocketBase)
|
||||
|
||||
Monochrome uses PocketBase to store user data (playlists, favorites, profiles, etc.). You can run it alongside Monochrome using the `pocketbase` profile:
|
||||
|
||||
```bash
|
||||
docker compose --profile pocketbase up -d
|
||||
```
|
||||
|
||||
#### PocketBase Schema Note
|
||||
|
||||
If you are setting up a new PocketBase collection for user data, ensure it has a field named `firebase_id` (this is a legacy name we use when we first started the accounts system, we used firebase. and im too lazy to change it so yea fuck you).
|
||||
|
||||
---
|
||||
|
||||
## Portainer Deployment
|
||||
|
||||
Portainer can deploy directly from your GitHub fork with auto-updates on push.
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ services:
|
|||
environment:
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||
AUTH_SECRET: ${AUTH_SECRET:-}
|
||||
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-monochrome-database}
|
||||
FIREBASE_CONFIG: ${FIREBASE_CONFIG:-}
|
||||
APPWRITE_ENDPOINT: ${APPWRITE_ENDPOINT:-https://auth.yourdomain.com/v1}
|
||||
APPWRITE_PROJECT_ID: ${APPWRITE_PROJECT_ID:-auth-for-monochrome}
|
||||
POCKETBASE_URL: ${POCKETBASE_URL:-}
|
||||
SESSION_MAX_AGE: ${SESSION_MAX_AGE:-604800000}
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
39
index.html
39
index.html
|
|
@ -1185,6 +1185,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="missing-tracks-actions">
|
||||
<button class="btn-secondary" id="copy-missing-tracks-btn">Copy to Clipboard</button>
|
||||
<button class="btn-secondary" id="export-missing-tracks-csv-btn">Export as CSV</button>
|
||||
<button class="btn-secondary" id="close-missing-tracks-btn">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1276,11 +1278,11 @@
|
|||
</button>
|
||||
</div>
|
||||
<p style="font-size: 0.9rem; color: var(--muted-foreground); margin-bottom: 1rem">
|
||||
Configure custom PocketBase and Firebase instances. Leave empty to use defaults.
|
||||
Configure custom PocketBase and Appwrite instances. Leave empty to use defaults.
|
||||
<br />
|
||||
A Guide To Set This Up Can Be Found
|
||||
<a
|
||||
href="https://github.com/monochrome-music/monochrome/blob/main/self-hosted-database.md"
|
||||
href="https://github.com/monochrome-music/monochrome/blob/main/DOCKER.md"
|
||||
style="text-decoration: underline"
|
||||
>Here</a
|
||||
>.
|
||||
|
|
@ -1295,15 +1297,22 @@
|
|||
/>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem">
|
||||
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem"
|
||||
>Firebase Configuration (JSON)</label
|
||||
>
|
||||
<textarea
|
||||
id="custom-firebase-config"
|
||||
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem">Appwrite Endpoint</label>
|
||||
<input
|
||||
type="url"
|
||||
id="custom-appwrite-endpoint"
|
||||
class="template-input"
|
||||
style="height: 150px; font-family: monospace; font-size: 0.8rem; resize: vertical"
|
||||
placeholder="{'apiKey': '...', ...}"
|
||||
></textarea>
|
||||
placeholder="https://auth.samidy.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem">
|
||||
<label style="display: block; margin-bottom: 0.5rem; font-size: 0.9rem">Appwrite Project ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="custom-appwrite-project"
|
||||
class="template-input"
|
||||
placeholder="auth-for-monochrome"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="custom-db-cancel" class="btn-secondary">Cancel</button>
|
||||
|
|
@ -5257,7 +5266,7 @@
|
|||
<div class="info">
|
||||
<span class="label">ADVANCED: Custom Database/Auth</span>
|
||||
<span class="description"
|
||||
>Configure custom PocketBase and Firebase instances</span
|
||||
>Configure custom PocketBase and Appwrite instances</span
|
||||
>
|
||||
</div>
|
||||
<button id="custom-db-btn" class="btn-secondary">Configure</button>
|
||||
|
|
@ -5352,7 +5361,7 @@
|
|||
>Delete all your data from the cloud (cannot be undone)</span
|
||||
>
|
||||
</div>
|
||||
<button id="firebase-clear-cloud-btn" class="btn-secondary danger">
|
||||
<button id="auth-clear-cloud-btn" class="btn-secondary danger">
|
||||
Clear Cloud Data
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -5525,19 +5534,19 @@
|
|||
flex-wrap: wrap;
|
||||
"
|
||||
>
|
||||
<button id="firebase-connect-btn" class="btn-secondary">Connect with Google</button>
|
||||
<button id="auth-connect-btn" class="btn-secondary">Connect with Google</button>
|
||||
<button id="toggle-email-auth-btn" class="btn-secondary">Connect with Email</button>
|
||||
<button id="view-my-profile-btn" class="btn-secondary" style="display: none">
|
||||
View My Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="firebase-status" style="text-align: center; padding-top: 15px; color: #8b8b93">
|
||||
<p id="auth-status" style="text-align: center; padding-top: 15px; color: #8b8b93">
|
||||
Sync your library across devices
|
||||
</p>
|
||||
<script>
|
||||
if (window.authManager && window.authManager.user) {
|
||||
const statusText = document.getElementById('firebase-status');
|
||||
const statusText = document.getElementById('auth-status');
|
||||
if (statusText)
|
||||
statusText.textContent = `Signed in as ${window.authManager.user.email}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,9 +117,9 @@ export class AuthManager {
|
|||
}
|
||||
|
||||
updateUI(user) {
|
||||
const connectBtn = document.getElementById('firebase-connect-btn');
|
||||
const clearDataBtn = document.getElementById('firebase-clear-cloud-btn');
|
||||
const statusText = document.getElementById('firebase-status');
|
||||
const connectBtn = document.getElementById('auth-connect-btn');
|
||||
const clearDataBtn = document.getElementById('auth-clear-cloud-btn');
|
||||
const statusText = document.getElementById('auth-status');
|
||||
const emailContainer = document.getElementById('email-auth-container');
|
||||
const emailToggleBtn = document.getElementById('toggle-email-auth-btn');
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ export class AuthManager {
|
|||
const title = accountPage.querySelector('.section-title');
|
||||
if (title) title.textContent = 'Account';
|
||||
accountPage.querySelectorAll('.account-content > p, .account-content > div').forEach((el) => {
|
||||
if (el.id !== 'firebase-status' && el.id !== 'auth-buttons-container') {
|
||||
if (el.id !== 'auth-status' && el.id !== 'auth-buttons-container') {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { Client, Account } from 'appwrite';
|
||||
|
||||
const getEndpoint = () => {
|
||||
const local = localStorage.getItem('monochrome-appwrite-endpoint');
|
||||
if (local) return local;
|
||||
|
||||
if (window.__APPWRITE_ENDPOINT__) return window.__APPWRITE_ENDPOINT__;
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
if (hostname.endsWith('monochrome.tf') || hostname === 'monochrome.tf') {
|
||||
return 'https://auth.monochrome.tf/v1';
|
||||
|
|
@ -8,13 +13,16 @@ const getEndpoint = () => {
|
|||
return 'https://auth.samidy.com/v1';
|
||||
};
|
||||
|
||||
const client = new Client().setEndpoint(getEndpoint()).setProject('auth-for-monochrome');
|
||||
const getProject = () => {
|
||||
const local = localStorage.getItem('monochrome-appwrite-project');
|
||||
if (local) return local;
|
||||
|
||||
if (window.__APPWRITE_PROJECT_ID__) return window.__APPWRITE_PROJECT_ID__;
|
||||
|
||||
return 'auth-for-monochrome';
|
||||
};
|
||||
|
||||
const client = new Client().setEndpoint(getEndpoint()).setProject(getProject());
|
||||
|
||||
const account = new Account(client);
|
||||
export { client, account as auth };
|
||||
export const saveFirebaseConfig = () => {
|
||||
console.log('ill fix this tomorrow');
|
||||
};
|
||||
export const clearFirebaseConfig = () => {
|
||||
console.log('ill fix this tomorrow');
|
||||
};
|
||||
|
|
|
|||
49
js/api.js
49
js/api.js
|
|
@ -1484,6 +1484,55 @@ export class LosslessAPI {
|
|||
};
|
||||
}
|
||||
|
||||
if (
|
||||
track.album?.id &&
|
||||
(track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)
|
||||
) {
|
||||
try {
|
||||
// Broad disc-field resolver — mirrors getExplicitTrackDiscNumber in downloads.js
|
||||
const resolveDiscNumber = (t) => {
|
||||
const candidates = [
|
||||
t.volumeNumber,
|
||||
t.discNumber,
|
||||
t.mediaNumber,
|
||||
t.media_number,
|
||||
t.volume,
|
||||
t.disc,
|
||||
t.disc_no,
|
||||
t.discNo,
|
||||
t.disc_number,
|
||||
t.mediaMetadata?.discNumber,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
const parsed = parseInt(c, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
|
||||
const albumData = await this.getAlbum(track.album.id);
|
||||
if (albumData.tracks?.length > 0) {
|
||||
const discTrackCounts = new Map();
|
||||
let maxDiscNumber = 0;
|
||||
for (const t of albumData.tracks) {
|
||||
const dn = resolveDiscNumber(t);
|
||||
discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1);
|
||||
if (dn > maxDiscNumber) maxDiscNumber = dn;
|
||||
}
|
||||
const totalDiscs = maxDiscNumber || 1;
|
||||
const discNumber = resolveDiscNumber(track);
|
||||
enrichedTrack.album = {
|
||||
...(enrichedTrack.album || {}),
|
||||
totalDiscs: track.album?.totalDiscs ?? totalDiscs,
|
||||
numberOfTracksOnDisc:
|
||||
track.album?.numberOfTracksOnDisc ?? discTrackCounts.get(discNumber),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch album for disc info:', e);
|
||||
}
|
||||
}
|
||||
|
||||
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
js/app.js
64
js/app.js
|
|
@ -1421,7 +1421,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||
}, 500);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -1506,7 +1506,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1610,7 +1610,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1669,7 +1669,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1728,7 +1728,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1787,7 +1787,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
showMissingTracksNotification(missingTracks, name || 'Untitled');
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -2839,10 +2839,11 @@ function escapeHtml(text) {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showMissingTracksNotification(missingTracks) {
|
||||
function showMissingTracksNotification(missingTracks, playlistName) {
|
||||
const modal = document.getElementById('missing-tracks-modal');
|
||||
const listUl = document.getElementById('missing-tracks-list-ul');
|
||||
const copyBtn = document.getElementById('copy-missing-tracks-btn');
|
||||
const exportCSVBtn = document.getElementById('export-missing-tracks-csv-btn');
|
||||
|
||||
listUl.innerHTML = missingTracks
|
||||
.map((track) => {
|
||||
|
|
@ -2857,13 +2858,16 @@ function showMissingTracksNotification(missingTracks) {
|
|||
copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn);
|
||||
|
||||
newCopyBtn.addEventListener('click', () => {
|
||||
const textToCopy = missingTracks
|
||||
.map((track) => {
|
||||
return typeof track === 'string'
|
||||
? track
|
||||
: `${track.artist ? track.artist + ' - ' : ''}${track.title}`;
|
||||
})
|
||||
.join('\n');
|
||||
const header = `Missing songs from ${playlistName} import:\n\n`;
|
||||
const textToCopy =
|
||||
header +
|
||||
missingTracks
|
||||
.map((track) => {
|
||||
return typeof track === 'string'
|
||||
? track
|
||||
: `${track.artist ? track.artist + ' - ' : ''}${track.title}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
const originalText = newCopyBtn.textContent;
|
||||
|
|
@ -2873,6 +2877,38 @@ function showMissingTracksNotification(missingTracks) {
|
|||
});
|
||||
}
|
||||
|
||||
if (exportCSVBtn) {
|
||||
const newExportBtn = exportCSVBtn.cloneNode(true);
|
||||
exportCSVBtn.parentNode.replaceChild(newExportBtn, exportCSVBtn);
|
||||
|
||||
newExportBtn.addEventListener('click', () => {
|
||||
const headers = ['Artist', 'Title', 'Album'];
|
||||
let csvContent = headers.join(',') + '\n';
|
||||
|
||||
missingTracks.forEach((track) => {
|
||||
if (typeof track === 'string') {
|
||||
csvContent += `"${track.replace(/"/g, '""')}","",""\n`;
|
||||
} else {
|
||||
const artist = (track.artist || '').replace(/"/g, '""');
|
||||
const title = (track.title || '').replace(/"/g, '""');
|
||||
const album = (track.album || '').replace(/"/g, '""');
|
||||
csvContent += `"${artist}","${title}","${album}"\n`;
|
||||
}
|
||||
});
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
const fileName = `${playlistName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_missing_tracks.csv`;
|
||||
link.setAttribute('download', fileName);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
const closeModal = () => modal.classList.remove('active');
|
||||
|
||||
// Remove old listeners if any (though usually these functions are called once per instance,
|
||||
|
|
|
|||
101
js/downloads.js
101
js/downloads.js
|
|
@ -109,6 +109,63 @@ async function createDiscLayoutContext(tracks, api) {
|
|||
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
||||
}
|
||||
|
||||
async function computeDiscInfo(tracks, api = null) {
|
||||
// First pass: collect explicit disc numbers from the raw track objects.
|
||||
const explicitDiscNumbers = tracks.map((track) => getExplicitTrackDiscNumber(track));
|
||||
const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean));
|
||||
|
||||
let resolvedDiscNumbers = explicitDiscNumbers;
|
||||
|
||||
// Some providers omit disc fields in the album payload. When we can't
|
||||
// distinguish discs from the raw data and an API instance is provided,
|
||||
// hydrate missing disc numbers via full-track metadata (mirrors the logic
|
||||
// in createDiscLayoutContext).
|
||||
if (explicitDistinct.size <= 1 && api) {
|
||||
const hydratedDiscNumbers = await Promise.all(
|
||||
tracks.map(async (track, index) => {
|
||||
if (explicitDiscNumbers[index]) return explicitDiscNumbers[index];
|
||||
try {
|
||||
const fullTrack = await api.getTrackMetadata(track.id);
|
||||
return getExplicitTrackDiscNumber(fullTrack);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
const hydratedDistinct = new Set(hydratedDiscNumbers.filter(Boolean));
|
||||
if (hydratedDistinct.size > 1) {
|
||||
resolvedDiscNumbers = hydratedDiscNumbers;
|
||||
}
|
||||
}
|
||||
|
||||
const tracksPerDisc = new Map();
|
||||
let maxDiscNumber = 0;
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const discNumber = resolvedDiscNumbers[i] || 1;
|
||||
tracksPerDisc.set(discNumber, (tracksPerDisc.get(discNumber) || 0) + 1);
|
||||
if (discNumber > maxDiscNumber) {
|
||||
maxDiscNumber = discNumber;
|
||||
}
|
||||
}
|
||||
|
||||
return { totalDiscs: maxDiscNumber || 1, tracksPerDisc, resolvedDiscNumbers };
|
||||
}
|
||||
|
||||
async function annotateTracksWithDiscInfo(tracks, api = null) {
|
||||
const { totalDiscs, tracksPerDisc, resolvedDiscNumbers } = await computeDiscInfo(tracks, api);
|
||||
return tracks.map((track, index) => {
|
||||
const discNumber = resolvedDiscNumbers[index] || 1;
|
||||
return {
|
||||
...track,
|
||||
album: {
|
||||
...(track.album || {}),
|
||||
totalDiscs,
|
||||
numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getDiscFolderName(discNumber) {
|
||||
return `Disc ${discNumber}`;
|
||||
}
|
||||
|
|
@ -327,15 +384,24 @@ async function downloadTrackBlob(
|
|||
// Non-fatal: continue with best available track payload
|
||||
}
|
||||
|
||||
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
|
||||
if (enrichedTrack.album?.id) {
|
||||
try {
|
||||
const albumData = await api.getAlbum(enrichedTrack.album.id);
|
||||
if (albumData.album) {
|
||||
if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) {
|
||||
enrichedTrack.album = {
|
||||
...enrichedTrack.album,
|
||||
...albumData.album,
|
||||
};
|
||||
}
|
||||
if (albumData.tracks?.length > 0) {
|
||||
const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api);
|
||||
const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1;
|
||||
enrichedTrack.album = {
|
||||
...enrichedTrack.album,
|
||||
totalDiscs,
|
||||
numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch album data for metadata:', error);
|
||||
}
|
||||
|
|
@ -1079,7 +1145,17 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
|
|||
});
|
||||
|
||||
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
|
||||
await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'album', album.title, coverBlob, album);
|
||||
await startBulkDownload(
|
||||
await annotateTracksWithDiscInfo(tracks, api),
|
||||
folderName,
|
||||
api,
|
||||
quality,
|
||||
lyricsManager,
|
||||
'album',
|
||||
album.title,
|
||||
coverBlob,
|
||||
album
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
|
||||
|
|
@ -1121,7 +1197,8 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
|
||||
|
||||
try {
|
||||
const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
|
||||
const { album: fullAlbum, tracks: rawTracks } = await api.getAlbum(album.id);
|
||||
const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
|
||||
const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover);
|
||||
const releaseDateStr =
|
||||
fullAlbum.releaseDate ||
|
||||
|
|
@ -1292,7 +1369,8 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
|||
if (signal.aborted) break;
|
||||
const album = selectedReleases[albumIndex];
|
||||
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
|
||||
const { tracks } = await api.getAlbum(album.id);
|
||||
const { tracks: rawTracks } = await api.getAlbum(album.id);
|
||||
const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
|
||||
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
||||
}
|
||||
completeBulkDownload(notification, true);
|
||||
|
|
@ -1436,15 +1514,24 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
|||
// Continue with available track payload
|
||||
}
|
||||
|
||||
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
|
||||
if (enrichedTrack.album?.id) {
|
||||
try {
|
||||
const albumData = await api.getAlbum(enrichedTrack.album.id);
|
||||
if (albumData.album) {
|
||||
if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) {
|
||||
enrichedTrack.album = {
|
||||
...enrichedTrack.album,
|
||||
...albumData.album,
|
||||
};
|
||||
}
|
||||
if (albumData.tracks?.length > 0) {
|
||||
const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api);
|
||||
const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1;
|
||||
enrichedTrack.album = {
|
||||
...enrichedTrack.album,
|
||||
totalDiscs,
|
||||
numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch album data for metadata:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
|
|||
data.albumArtist = track.album?.artist?.name || track.artist?.name;
|
||||
data.trackNumber = track.trackNumber;
|
||||
data.discNumber = track.volumeNumber ?? track.discNumber;
|
||||
data.totalTracks = track.album.numberOfTracks;
|
||||
data.totalTracks = track.album.numberOfTracksOnDisc ?? track.album.numberOfTracks;
|
||||
data.totalDiscs = track.album.totalDiscs;
|
||||
data.copyright = track.copyright;
|
||||
data.isrc = track.isrc;
|
||||
data.explicit = Boolean(track.explicit);
|
||||
|
|
|
|||
|
|
@ -2814,7 +2814,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
}
|
||||
});
|
||||
|
||||
document.getElementById('firebase-clear-cloud-btn')?.addEventListener('click', async () => {
|
||||
document.getElementById('auth-clear-cloud-btn')?.addEventListener('click', async () => {
|
||||
if (confirm('Are you sure you want to delete ALL your data from the cloud? This cannot be undone.')) {
|
||||
try {
|
||||
await syncManager.clearCloudData();
|
||||
|
|
@ -2921,41 +2921,38 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
const customDbBtn = document.getElementById('custom-db-btn');
|
||||
const customDbModal = document.getElementById('custom-db-modal');
|
||||
const customPbUrlInput = document.getElementById('custom-pb-url');
|
||||
const customFirebaseConfigInput = document.getElementById('custom-firebase-config');
|
||||
const customAppwriteEndpointInput = document.getElementById('custom-appwrite-endpoint');
|
||||
const customAppwriteProjectInput = document.getElementById('custom-appwrite-project');
|
||||
const customDbSaveBtn = document.getElementById('custom-db-save');
|
||||
const customDbResetBtn = document.getElementById('custom-db-reset');
|
||||
const customDbCancelBtn = document.getElementById('custom-db-cancel');
|
||||
|
||||
if (customDbBtn && customDbModal) {
|
||||
const fbFromEnv = !!window.__FIREBASE_CONFIG__;
|
||||
const appwriteFromEnv = !!(window.__APPWRITE_ENDPOINT__ || window.__APPWRITE_PROJECT_ID__);
|
||||
const pbFromEnv = !!window.__POCKETBASE_URL__;
|
||||
|
||||
// Hide entire setting if both are server-configured
|
||||
if (fbFromEnv && pbFromEnv) {
|
||||
if (appwriteFromEnv && pbFromEnv) {
|
||||
const settingItem = customDbBtn.closest('.setting-item');
|
||||
if (settingItem) settingItem.style.display = 'none';
|
||||
}
|
||||
|
||||
// Hide individual fields in the modal
|
||||
if (pbFromEnv && customPbUrlInput) customPbUrlInput.closest('div[style]').style.display = 'none';
|
||||
if (fbFromEnv && customFirebaseConfigInput)
|
||||
customFirebaseConfigInput.closest('div[style]').style.display = 'none';
|
||||
if (appwriteFromEnv) {
|
||||
if (customAppwriteEndpointInput) customAppwriteEndpointInput.closest('div[style]').style.display = 'none';
|
||||
if (customAppwriteProjectInput) customAppwriteProjectInput.closest('div[style]').style.display = 'none';
|
||||
}
|
||||
|
||||
customDbBtn.addEventListener('click', () => {
|
||||
const pbUrl = localStorage.getItem('monochrome-pocketbase-url') || '';
|
||||
const fbConfig = localStorage.getItem('monochrome-firebase-config');
|
||||
const appwriteEndpoint = localStorage.getItem('monochrome-appwrite-endpoint') || '';
|
||||
const appwriteProject = localStorage.getItem('monochrome-appwrite-project') || '';
|
||||
|
||||
if (!pbFromEnv) customPbUrlInput.value = pbUrl;
|
||||
if (!fbFromEnv) {
|
||||
if (fbConfig) {
|
||||
try {
|
||||
customFirebaseConfigInput.value = JSON.stringify(JSON.parse(fbConfig), null, 2);
|
||||
} catch {
|
||||
customFirebaseConfigInput.value = fbConfig;
|
||||
}
|
||||
} else {
|
||||
customFirebaseConfigInput.value = '';
|
||||
}
|
||||
if (!pbFromEnv && customPbUrlInput) customPbUrlInput.value = pbUrl;
|
||||
if (!appwriteFromEnv) {
|
||||
if (customAppwriteEndpointInput) customAppwriteEndpointInput.value = appwriteEndpoint;
|
||||
if (customAppwriteProjectInput) customAppwriteProjectInput.value = appwriteProject;
|
||||
}
|
||||
|
||||
customDbModal.classList.add('active');
|
||||
|
|
@ -2969,25 +2966,30 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
customDbModal.querySelector('.modal-overlay').addEventListener('click', closeCustomDbModal);
|
||||
|
||||
customDbSaveBtn.addEventListener('click', () => {
|
||||
const pbUrl = customPbUrlInput.value.trim();
|
||||
const fbConfigStr = customFirebaseConfigInput.value.trim();
|
||||
|
||||
if (pbUrl) {
|
||||
localStorage.setItem('monochrome-pocketbase-url', pbUrl);
|
||||
} else {
|
||||
localStorage.removeItem('monochrome-pocketbase-url');
|
||||
if (!pbFromEnv && customPbUrlInput) {
|
||||
const pbUrl = customPbUrlInput.value.trim();
|
||||
if (pbUrl) {
|
||||
localStorage.setItem('monochrome-pocketbase-url', pbUrl);
|
||||
} else {
|
||||
localStorage.removeItem('monochrome-pocketbase-url');
|
||||
}
|
||||
}
|
||||
|
||||
if (fbConfigStr) {
|
||||
try {
|
||||
const fbConfig = JSON.parse(fbConfigStr);
|
||||
saveFirebaseConfig(fbConfig);
|
||||
} catch {
|
||||
alert('Invalid JSON for Firebase Config');
|
||||
return;
|
||||
if (!appwriteFromEnv) {
|
||||
const endpoint = customAppwriteEndpointInput?.value.trim();
|
||||
const project = customAppwriteProjectInput?.value.trim();
|
||||
|
||||
if (endpoint) {
|
||||
localStorage.setItem('monochrome-appwrite-endpoint', endpoint);
|
||||
} else {
|
||||
localStorage.removeItem('monochrome-appwrite-endpoint');
|
||||
}
|
||||
|
||||
if (project) {
|
||||
localStorage.setItem('monochrome-appwrite-project', project);
|
||||
} else {
|
||||
localStorage.removeItem('monochrome-appwrite-project');
|
||||
}
|
||||
} else {
|
||||
clearFirebaseConfig();
|
||||
}
|
||||
|
||||
alert('Settings saved. Reloading...');
|
||||
|
|
@ -2997,7 +2999,8 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
customDbResetBtn.addEventListener('click', () => {
|
||||
if (confirm('Reset custom database settings to default?')) {
|
||||
localStorage.removeItem('monochrome-pocketbase-url');
|
||||
clearFirebaseConfig();
|
||||
localStorage.removeItem('monochrome-appwrite-endpoint');
|
||||
localStorage.removeItem('monochrome-appwrite-project');
|
||||
alert('Settings reset. Reloading...');
|
||||
window.location.reload();
|
||||
}
|
||||
|
|
|
|||
15
styles.css
15
styles.css
|
|
@ -5349,8 +5349,7 @@ img[src=''] {
|
|||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Firebase Settings Styling */
|
||||
.firebase-settings-wrapper {
|
||||
.appwrite-settings-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
|
@ -5358,7 +5357,8 @@ img[src=''] {
|
|||
max-width: 400px;
|
||||
}
|
||||
|
||||
.custom-firebase-config {
|
||||
.custom-appwrite-endpoint,
|
||||
.custom-appwrite-project {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
|
@ -5366,7 +5366,8 @@ img[src=''] {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-firebase-config.visible {
|
||||
.custom-appwrite-endpoint.visible,
|
||||
.custom-appwrite-project.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
|
@ -5376,13 +5377,13 @@ img[src=''] {
|
|||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.firebase-controls-container {
|
||||
.appwrite-controls-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#toggle-firebase-config-btn {
|
||||
#toggle-auth-config-btn {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
|
|
@ -5875,6 +5876,8 @@ img[src=''] {
|
|||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.missing-tracks-actions .btn-secondary {
|
||||
|
|
|
|||
|
|
@ -31,24 +31,12 @@ export default function authGatePlugin() {
|
|||
|
||||
configurePreviewServer(server) {
|
||||
const AUTH_ENABLED = (env.AUTH_ENABLED ?? 'false') !== 'false';
|
||||
const FIREBASE_CONFIG = env.FIREBASE_CONFIG;
|
||||
const APPWRITE_ENDPOINT = env.APPWRITE_ENDPOINT;
|
||||
const APPWRITE_PROJECT_ID = env.APPWRITE_PROJECT_ID;
|
||||
const POCKETBASE_URL = env.POCKETBASE_URL;
|
||||
const AUTH_GOOGLE_ENABLED = env.AUTH_GOOGLE_ENABLED;
|
||||
const AUTH_EMAIL_ENABLED = env.AUTH_EMAIL_ENABLED;
|
||||
|
||||
// Parse Firebase config once (used for injection + auth verification)
|
||||
let parsedFirebaseConfig = null;
|
||||
let PROJECT_ID = env.FIREBASE_PROJECT_ID || 'monochrome-database';
|
||||
if (FIREBASE_CONFIG) {
|
||||
try {
|
||||
parsedFirebaseConfig = JSON.parse(FIREBASE_CONFIG);
|
||||
if (parsedFirebaseConfig.projectId) PROJECT_ID = parsedFirebaseConfig.projectId;
|
||||
} catch (e) {
|
||||
console.error('Invalid FIREBASE_CONFIG JSON:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build injection script (always, for both auth gate and env config) ---
|
||||
|
||||
const flags = [];
|
||||
|
|
@ -58,13 +46,14 @@ export default function authGatePlugin() {
|
|||
authProviderOverrides.google = AUTH_GOOGLE_ENABLED !== 'false';
|
||||
}
|
||||
if (AUTH_EMAIL_ENABLED !== undefined) {
|
||||
// Firebase calls it "password" provider; env uses "EMAIL" for clarity
|
||||
authProviderOverrides.password = AUTH_EMAIL_ENABLED !== 'false';
|
||||
}
|
||||
if (Object.keys(authProviderOverrides).length > 0) {
|
||||
flags.push(`window.__AUTH_PROVIDERS__=${JSON.stringify(authProviderOverrides)}`);
|
||||
}
|
||||
if (parsedFirebaseConfig) flags.push(`window.__FIREBASE_CONFIG__=${JSON.stringify(parsedFirebaseConfig)}`);
|
||||
if (APPWRITE_ENDPOINT) flags.push(`window.__APPWRITE_ENDPOINT__=${JSON.stringify(APPWRITE_ENDPOINT)}`);
|
||||
if (APPWRITE_PROJECT_ID)
|
||||
flags.push(`window.__APPWRITE_PROJECT_ID__=${JSON.stringify(APPWRITE_PROJECT_ID)}`);
|
||||
if (POCKETBASE_URL) flags.push(`window.__POCKETBASE_URL__=${JSON.stringify(POCKETBASE_URL)}`);
|
||||
const configScript = flags.length > 0 ? `<script>${flags.join(';')};</script>` : null;
|
||||
|
||||
|
|
@ -109,11 +98,7 @@ export default function authGatePlugin() {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Auth gate enabled (Firebase project: ${PROJECT_ID})`);
|
||||
|
||||
const JWKS = createRemoteJWKSet(
|
||||
new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com')
|
||||
);
|
||||
console.log(`Auth gate enabled (Project: ${APPWRITE_PROJECT_ID})`);
|
||||
|
||||
server.middlewares.use(
|
||||
cookieSession({
|
||||
|
|
@ -148,26 +133,22 @@ export default function authGatePlugin() {
|
|||
if (url === '/api/auth/login' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseBody(req);
|
||||
if (!body.token) {
|
||||
if (!body.userId) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Missing token' }));
|
||||
res.end(JSON.stringify({ error: 'Missing userId' }));
|
||||
return;
|
||||
}
|
||||
const { payload } = await jwtVerify(body.token, JWKS, {
|
||||
issuer: `https://securetoken.google.com/${PROJECT_ID}`,
|
||||
audience: PROJECT_ID,
|
||||
});
|
||||
req.session.uid = payload.sub;
|
||||
req.session.email = payload.email;
|
||||
req.session.uid = body.userId;
|
||||
req.session.email = body.email;
|
||||
req.session.iat = Date.now();
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} catch (err) {
|
||||
console.error('Token verification failed:', err.message);
|
||||
console.error('Login session creation failed:', err.message);
|
||||
res.statusCode = 401;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Invalid token' }));
|
||||
res.end(JSON.stringify({ error: 'Login failed' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue