diff --git a/js/api.js b/js/api.js
index 8d9d719..25d6532 100644
--- a/js/api.js
+++ b/js/api.js
@@ -12,9 +12,15 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
import { HlsDownloader } from './hls-downloader.js';
import { MP3EncodingError } from './mp3-encoder.js';
-import { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js';
+import { loadFfmpeg, FfmpegError } from './ffmpeg.js';
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
-import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts';
+import {
+ isCustomFormat,
+ getCustomFormat,
+ transcodeWithCustomFormat,
+ getContainerFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats.ts';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@@ -1437,33 +1443,18 @@ export class LosslessAPI {
if (quality.endsWith('LOSSLESS')) {
try {
- switch (losslessContainerSettings.getContainer()) {
- case 'flac':
- if ((await getExtensionFromBlob(blob)) != 'flac') {
- blob = await ffmpeg(
- blob,
- { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
- 'output.flac',
- 'audio/flac',
- onProgress,
- options.signal
- );
- } else {
- blob = await rebuildFlacWithoutMetadata(blob);
- }
- break;
- case 'alac':
- blob = await ffmpeg(
+ const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
+ if (containerFmt) {
+ if (await containerFmt.needsTranscode(blob)) {
+ blob = await transcodeWithContainerFormat(
blob,
- { args: ['-c:a', 'alac'] },
- 'output.m4a',
- 'audio/mp4',
+ containerFmt,
onProgress,
options.signal
);
- break;
- default:
- break;
+ } else if ((await getExtensionFromBlob(blob)) == 'flac') {
+ blob = await rebuildFlacWithoutMetadata(blob);
+ }
}
} catch (error) {
if (error?.name === 'AbortError') {
diff --git a/js/customFormats.ts b/js/customFormats.ts
index 5d9bb7c..f8d5c2e 100644
--- a/js/customFormats.ts
+++ b/js/customFormats.ts
@@ -1,148 +1,13 @@
-import { ffmpeg } from './ffmpeg';
-
-export interface ProgressEvent {
- stage?: string;
- message?: string;
- progress?: number;
- receivedBytes?: number;
- totalBytes?: number;
-}
-
-export interface CustomFormat {
- /** Human-readable label shown in the UI */
- displayName: string;
- /** Internal identifier, must start with `FFMPEG_` */
- internalName: string;
- /** Arguments passed to ffmpeg (excluding input/output file args) */
- ffmpegArgs: string[];
- /** Output filename used when calling ffmpeg */
- outputFilename: string;
- /** MIME type of the encoded output */
- outputMime: string;
- /** File extension of the encoded output */
- extension: string;
- /** Category label used for grouping in the UI (e.g. 'MP3', 'OGG', 'AAC') */
- category: string;
-}
-
-export const customFormats: CustomFormat[] = [
- {
- displayName: 'MP3 320kbps',
- internalName: 'FFMPEG_MP3_320',
- ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'],
- outputFilename: 'output.mp3',
- outputMime: 'audio/mpeg',
- extension: 'mp3',
- category: 'MP3',
- },
- {
- displayName: 'MP3 256kbps',
- internalName: 'FFMPEG_MP3_256',
- ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'],
- outputFilename: 'output.mp3',
- outputMime: 'audio/mpeg',
- extension: 'mp3',
- category: 'MP3',
- },
- {
- displayName: 'MP3 128kbps',
- internalName: 'FFMPEG_MP3_128',
- ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'],
- outputFilename: 'output.mp3',
- outputMime: 'audio/mpeg',
- extension: 'mp3',
- category: 'MP3',
- },
- {
- displayName: 'OGG 320kbps',
- internalName: 'FFMPEG_OGG_320',
- ffmpegArgs: [
- '-map_metadata',
- '-1',
- '-c:a',
- 'libvorbis',
- '-b:a',
- '320k',
- '-minrate',
- '320k',
- '-maxrate',
- '320k',
- ],
- outputFilename: 'output.ogg',
- outputMime: 'audio/ogg',
- extension: 'ogg',
- category: 'OGG',
- },
- {
- displayName: 'OGG 256kbps',
- internalName: 'FFMPEG_OGG_256',
- ffmpegArgs: [
- '-map_metadata',
- '-1',
- '-c:a',
- 'libvorbis',
- '-b:a',
- '256k',
- '-minrate',
- '256k',
- '-maxrate',
- '256k',
- ],
- outputFilename: 'output.ogg',
- outputMime: 'audio/ogg',
- extension: 'ogg',
- category: 'OGG',
- },
- {
- displayName: 'OGG 128kbps',
- internalName: 'FFMPEG_OGG_128',
- ffmpegArgs: [
- '-map_metadata',
- '-1',
- '-c:a',
- 'libvorbis',
- '-b:a',
- '128k',
- '-minrate',
- '128k',
- '-maxrate',
- '128k',
- ],
- outputFilename: 'output.ogg',
- outputMime: 'audio/ogg',
- extension: 'ogg',
- category: 'OGG',
- },
- {
- displayName: 'AAC 256kbps',
- internalName: 'FFMPEG_AAC_256',
- ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'],
- outputFilename: 'output.m4a',
- outputMime: 'audio/mp4',
- extension: 'm4a',
- category: 'AAC',
- },
-];
-
-/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */
-export function isCustomFormat(quality: string): boolean {
- return getCustomFormat(quality) !== undefined;
-}
-
-/** Looks up a custom format by its internal name, or returns undefined */
-export function getCustomFormat(internalName: string): CustomFormat | undefined {
- return customFormats.find((f) => f.internalName === internalName);
-}
-
-/**
- * Transcodes an audio blob using the specified custom format via ffmpeg.
- * Throws if ffmpeg fails during transcoding.
- */
-export async function transcodeWithCustomFormat(
- audioBlob: Blob,
- format: CustomFormat,
- onProgress: ((progress: ProgressEvent) => void) | null = null,
- signal: AbortSignal | null = null
-): Promise {
- return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal);
-}
+// Re-exports for backwards compatibility – canonical source is ffmpegFormats.ts
+export {
+ type ProgressEvent,
+ type CustomFormat,
+ type ContainerFormat,
+ customFormats,
+ containerFormats,
+ isCustomFormat,
+ getCustomFormat,
+ getContainerFormat,
+ transcodeWithCustomFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats';
diff --git a/js/downloads.js b/js/downloads.js
index 6494fda..dd4fce9 100644
--- a/js/downloads.js
+++ b/js/downloads.js
@@ -16,8 +16,14 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
import { DashDownloader } from './dash-downloader.js';
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
-import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
-import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts';
+import { loadFfmpeg } from './ffmpeg.js';
+import {
+ isCustomFormat,
+ getCustomFormat,
+ transcodeWithCustomFormat,
+ getContainerFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats.ts';
const downloadTasks = new Map();
const bulkDownloadTasks = new Map();
@@ -389,33 +395,13 @@ async function downloadTrackBlob(
if (quality.endsWith('LOSSLESS')) {
try {
- switch (losslessContainerSettings.getContainer()) {
- case 'flac':
- if ((await getExtensionFromBlob(blob)) != 'flac') {
- blob = await ffmpeg(
- blob,
- { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
- 'output.flac',
- 'audio/flac',
- onProgress,
- signal
- );
- } else {
- blob = await rebuildFlacWithoutMetadata(blob);
- }
- break;
- case 'alac':
- blob = await ffmpeg(
- blob,
- { args: ['-c:a', 'alac'] },
- 'output.m4a',
- 'audio/mp4',
- onProgress,
- signal
- );
- break;
- default:
- break;
+ const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
+ if (containerFmt) {
+ if (await containerFmt.needsTranscode(blob)) {
+ blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
+ } else if ((await getExtensionFromBlob(blob)) == 'flac') {
+ blob = await rebuildFlacWithoutMetadata(blob);
+ }
}
} catch (error) {
if (error?.name === 'AbortError') {
diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts
new file mode 100644
index 0000000..5e5a9cb
--- /dev/null
+++ b/js/ffmpegFormats.ts
@@ -0,0 +1,229 @@
+import { ffmpeg } from './ffmpeg';
+import { getExtensionFromBlob } from './utils';
+
+export interface ProgressEvent {
+ stage?: string;
+ message?: string;
+ progress?: number;
+ receivedBytes?: number;
+ totalBytes?: number;
+}
+
+export interface CustomFormat {
+ /** Human-readable label shown in the UI */
+ displayName: string;
+ /** Internal identifier, must start with `FFMPEG_` */
+ internalName: string;
+ /** Arguments passed to ffmpeg (excluding input/output file args) */
+ ffmpegArgs: string[];
+ /** Output filename used when calling ffmpeg */
+ outputFilename: string;
+ /** MIME type of the encoded output */
+ outputMime: string;
+ /** File extension of the encoded output */
+ extension: string;
+ /** Category label used for grouping in the UI (e.g. 'MP3', 'OGG', 'AAC') */
+ category: string;
+}
+
+/**
+ * A container format definition for lossless re-muxing/re-encoding.
+ * Extends CustomFormat with a callback that decides whether ffmpeg needs to run
+ * at all (e.g. FLAC can skip if the source is already FLAC).
+ */
+export interface ContainerFormat extends Omit {
+ /**
+ * Returns true when the source blob must be passed through ffmpeg to produce
+ * the desired container. Return false to skip the ffmpeg step (the caller
+ * may still apply a lightweight metadata-strip pass instead).
+ */
+ needsTranscode: (blob: Blob) => Promise;
+}
+
+export const customFormats: CustomFormat[] = [
+ {
+ displayName: 'MP3 320kbps',
+ internalName: 'FFMPEG_MP3_320',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'],
+ outputFilename: 'output.mp3',
+ outputMime: 'audio/mpeg',
+ extension: 'mp3',
+ category: 'MP3',
+ },
+ {
+ displayName: 'MP3 256kbps',
+ internalName: 'FFMPEG_MP3_256',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'],
+ outputFilename: 'output.mp3',
+ outputMime: 'audio/mpeg',
+ extension: 'mp3',
+ category: 'MP3',
+ },
+ {
+ displayName: 'MP3 128kbps',
+ internalName: 'FFMPEG_MP3_128',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'],
+ outputFilename: 'output.mp3',
+ outputMime: 'audio/mpeg',
+ extension: 'mp3',
+ category: 'MP3',
+ },
+ {
+ displayName: 'OGG 320kbps',
+ internalName: 'FFMPEG_OGG_320',
+ ffmpegArgs: [
+ '-map_metadata',
+ '-1',
+ '-c:a',
+ 'libvorbis',
+ '-b:a',
+ '320k',
+ '-minrate',
+ '320k',
+ '-maxrate',
+ '320k',
+ ],
+ outputFilename: 'output.ogg',
+ outputMime: 'audio/ogg',
+ extension: 'ogg',
+ category: 'OGG',
+ },
+ {
+ displayName: 'OGG 256kbps',
+ internalName: 'FFMPEG_OGG_256',
+ ffmpegArgs: [
+ '-map_metadata',
+ '-1',
+ '-c:a',
+ 'libvorbis',
+ '-b:a',
+ '256k',
+ '-minrate',
+ '256k',
+ '-maxrate',
+ '256k',
+ ],
+ outputFilename: 'output.ogg',
+ outputMime: 'audio/ogg',
+ extension: 'ogg',
+ category: 'OGG',
+ },
+ {
+ displayName: 'OGG 128kbps',
+ internalName: 'FFMPEG_OGG_128',
+ ffmpegArgs: [
+ '-map_metadata',
+ '-1',
+ '-c:a',
+ 'libvorbis',
+ '-b:a',
+ '128k',
+ '-minrate',
+ '128k',
+ '-maxrate',
+ '128k',
+ ],
+ outputFilename: 'output.ogg',
+ outputMime: 'audio/ogg',
+ extension: 'ogg',
+ category: 'OGG',
+ },
+ {
+ displayName: 'AAC 256kbps',
+ internalName: 'FFMPEG_AAC_256',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'],
+ outputFilename: 'output.m4a',
+ outputMime: 'audio/mp4',
+ extension: 'm4a',
+ category: 'AAC',
+ },
+];
+
+/**
+ * Container format definitions for lossless re-muxing. Each entry describes
+ * the ffmpeg arguments needed to produce that container and provides a
+ * `needsTranscode` predicate so callers can skip the ffmpeg step when the
+ * source is already in the correct container.
+ */
+export const containerFormats: ContainerFormat[] = [
+ {
+ displayName: 'FLAC',
+ internalName: 'flac',
+ ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'],
+ outputFilename: 'output.flac',
+ outputMime: 'audio/flac',
+ extension: 'flac',
+ // Only transcode when the source is NOT already a FLAC file.
+ needsTranscode: async (blob) => (await getExtensionFromBlob(blob)) !== 'flac',
+ },
+ {
+ displayName: 'FLAC - Max Compression',
+ internalName: 'flac_max',
+ // `-compression_level 12` is the highest FLAC compression level; audio
+ // data is bit-identical to the source — only the compressed size changes.
+ ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'],
+ outputFilename: 'output.flac',
+ outputMime: 'audio/flac',
+ extension: 'flac',
+ needsTranscode: async () => true,
+ },
+ {
+ displayName: 'Apple Lossless',
+ internalName: 'alac',
+ ffmpegArgs: ['-c:a', 'alac'],
+ outputFilename: 'output.m4a',
+ outputMime: 'audio/mp4',
+ extension: 'm4a',
+ needsTranscode: async () => true,
+ },
+ {
+ displayName: "Don't change",
+ internalName: 'nochange',
+ ffmpegArgs: [],
+ outputFilename: '',
+ outputMime: '',
+ extension: '',
+ needsTranscode: async () => false,
+ },
+];
+
+/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */
+export function isCustomFormat(quality: string): boolean {
+ return getCustomFormat(quality) !== undefined;
+}
+
+/** Looks up a custom format by its internal name, or returns undefined */
+export function getCustomFormat(internalName: string): CustomFormat | undefined {
+ return customFormats.find((f) => f.internalName === internalName);
+}
+
+/** Looks up a container format by its internal name, or returns undefined */
+export function getContainerFormat(internalName: string): ContainerFormat | undefined {
+ return containerFormats.find((f) => f.internalName === internalName);
+}
+
+/**
+ * Transcodes an audio blob using the specified custom format via ffmpeg.
+ * Throws if ffmpeg fails during transcoding.
+ */
+export async function transcodeWithCustomFormat(
+ audioBlob: Blob,
+ format: CustomFormat,
+ onProgress: ((progress: ProgressEvent) => void) | null = null,
+ signal: AbortSignal | null = null
+): Promise {
+ return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal);
+}
+
+/**
+ * Re-muxes / re-encodes an audio blob into the specified container format via ffmpeg.
+ * Throws if ffmpeg fails during transcoding.
+ */
+export async function transcodeWithContainerFormat(
+ audioBlob: Blob,
+ format: ContainerFormat,
+ onProgress: ((progress: ProgressEvent) => void) | null = null,
+ signal: AbortSignal | null = null
+): Promise {
+ return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal);
+}
diff --git a/js/settings.js b/js/settings.js
index d18d5b9..da9cd81 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -42,7 +42,7 @@ import { db } from './db.js';
import { authManager } from './accounts/auth.js';
import { syncManager } from './accounts/pocketbase.js';
import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js';
-import { customFormats } from './customFormats.ts';
+import { containerFormats, customFormats } from './ffmpegFormats.ts';
export function initializeSettings(scrobbler, player, api, ui) {
// Restore last active settings tab
@@ -867,6 +867,13 @@ export function initializeSettings(scrobbler, player, api, ui) {
const losslessContainerSetting = document.getElementById('lossless-container-setting');
if (losslessContainerSetting) {
+ for (const { internalName, displayName } of containerFormats) {
+ const option = document.createElement('option');
+ option.value = internalName;
+ option.textContent = displayName;
+ losslessContainerSetting.appendChild(option);
+ }
+
losslessContainerSetting.value = losslessContainerSettings.getContainer();
losslessContainerSetting.addEventListener('change', (e) => {
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 3/6] 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 4/6] 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 @@