From 33668ae11816e04cac8470b89316042563995220 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:46:33 +0000 Subject: [PATCH 1/4] fix: correct total tracks per disc and add total discs to metadata for multi-disc albums --- js/api.js | 49 +++++++++++++++++++++++ js/downloads.js | 101 ++++++++++++++++++++++++++++++++++++++++++++---- js/metadata.js | 3 +- 3 files changed, 145 insertions(+), 8 deletions(-) diff --git a/js/api.js b/js/api.js index fd56dc2..30c3d9f 100644 --- a/js/api.js +++ b/js/api.js @@ -1489,6 +1489,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); } } diff --git a/js/downloads.js b/js/downloads.js index ae947b2..bd05f19 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -103,6 +103,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}`; } @@ -321,15 +378,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); } @@ -1090,7 +1156,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) { @@ -1132,7 +1208,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 || @@ -1303,7 +1380,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); @@ -1447,15 +1525,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); } diff --git a/js/metadata.js b/js/metadata.js index 76e24b9..93c2e94 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -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); From 4b7833dc8ebe4bdede7a4532da6c3201979248f6 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 02:03:36 +0300 Subject: [PATCH 2/4] update outdated self-hosting shit --- .env.example | 5 ++- DOCKER.md | 27 ++++++++++++++ docker-compose.yml | 4 +-- index.html | 37 ++++++++++++------- js/accounts/auth.js | 8 ++--- js/accounts/config.js | 22 ++++++++---- js/settings.js | 76 +++++++++++++++++++++------------------- styles.css | 11 +++--- vite-plugin-auth-gate.js | 42 +++++++--------------- 9 files changed, 131 insertions(+), 101 deletions(-) diff --git a/.env.example b/.env.example index d51cf88..da95031 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/DOCKER.md b/DOCKER.md index f6bd93e..9e8426d 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -92,6 +92,33 @@ 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. diff --git a/docker-compose.yml b/docker-compose.yml index f4602cc..02fe95e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/index.html b/index.html index 5a253c3..43e8baf 100644 --- a/index.html +++ b/index.html @@ -1276,11 +1276,11 @@

- Configure custom PocketBase and Firebase instances. Leave empty to use defaults. + Configure custom PocketBase and Appwrite instances. Leave empty to use defaults.
A Guide To Set This Up Can Be Found Here. @@ -1296,14 +1296,25 @@

Appwrite Endpoint - + placeholder="https://auth.samidy.com/v1" + /> +
+
+ +
- @@ -5530,19 +5541,19 @@ flex-wrap: wrap; " > - + -

+

Sync your library across devices

` : null; @@ -109,11 +98,8 @@ export default function authGatePlugin() { process.exit(1); } - console.log(`Auth gate enabled (Firebase project: ${PROJECT_ID})`); + console.log(`Auth gate enabled (Project: ${APPWRITE_PROJECT_ID})`); - const JWKS = createRemoteJWKSet( - new URL('https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com') - ); server.middlewares.use( cookieSession({ @@ -148,26 +134,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; } From 6efd88b31e6801708b06a9a3d98fa0b78095a81d Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:04:04 +0000 Subject: [PATCH 3/4] style: auto-fix linting issues --- DOCKER.md | 5 +++-- index.html | 8 ++------ js/settings.js | 6 ++---- styles.css | 6 ++++-- vite-plugin-auth-gate.js | 1 - 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index 9e8426d..a5b78e9 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -103,8 +103,8 @@ Monochrome uses Appwrite for user authentication. While it defaults to official 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`). + - `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) @@ -115,6 +115,7 @@ 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). --- diff --git a/index.html b/index.html index 43e8baf..2c311fd 100644 --- a/index.html +++ b/index.html @@ -1295,9 +1295,7 @@ />
- +
- + { diff --git a/styles.css b/styles.css index e0f4a4d..611a32b 100644 --- a/styles.css +++ b/styles.css @@ -5357,7 +5357,8 @@ img[src=''] { max-width: 400px; } -.custom-appwrite-endpoint, .custom-appwrite-project { +.custom-appwrite-endpoint, +.custom-appwrite-project { display: none; flex-direction: column; gap: 0.5rem; @@ -5365,7 +5366,8 @@ img[src=''] { width: 100%; } -.custom-appwrite-endpoint.visible, .custom-appwrite-project.visible { +.custom-appwrite-endpoint.visible, +.custom-appwrite-project.visible { display: flex; } diff --git a/vite-plugin-auth-gate.js b/vite-plugin-auth-gate.js index 9306b46..a83b58c 100644 --- a/vite-plugin-auth-gate.js +++ b/vite-plugin-auth-gate.js @@ -100,7 +100,6 @@ export default function authGatePlugin() { console.log(`Auth gate enabled (Project: ${APPWRITE_PROJECT_ID})`); - server.middlewares.use( cookieSession({ name: 'mono_session', From 5e55e141daed755ebdbffc560f0a36bebb3da108 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 02:48:55 +0300 Subject: [PATCH 4/4] feat(missing-songs-import): export missing songs to CSV or copy to clipboard --- index.html | 2 ++ js/app.js | 64 ++++++++++++++++++++++++++++++++++++++++++------------ styles.css | 2 ++ 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/index.html b/index.html index 43e8baf..c48b469 100644 --- a/index.html +++ b/index.html @@ -1185,6 +1185,8 @@
+ +
diff --git a/js/app.js b/js/app.js index 4786f72..480c333 100644 --- a/js/app.js +++ b/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, diff --git a/styles.css b/styles.css index e0f4a4d..c230a8d 100644 --- a/styles.css +++ b/styles.css @@ -5874,6 +5874,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 {