Merge pull request #274 from DanTheMan827/taglib-wasm

Use taglib-wasm for writing and improve ffmpeg caching
This commit is contained in:
Samidy 2026-03-11 01:23:40 +03:00 committed by GitHub
commit f20935d2d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3398 additions and 2176 deletions

View file

@ -1,22 +1,14 @@
{
"name": "debian-npm-fish-devcontainer",
"name": "Monochrome Dev Container",
"build": {
"dockerfile": "Dockerfile"
"context": "..",
"dockerfile": "./Dockerfile"
},
"remoteUser": "devuser",
"features": {},
"postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && npm install && bun install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
},
"postCreateCommand": "npm install",
"remoteEnv": {
"SHELL": "/usr/bin/fish"
}
"mounts": ["source=${env:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,consistency=cached"]
}

766
bun.lock

File diff suppressed because it is too large Load diff

195
js/BaseCodec.ts Normal file
View file

@ -0,0 +1,195 @@
class BaseCodec {
private readonly dictionary: string[];
private readonly base: number;
private readonly dictionarySet: Set<string>;
constructor(dictionary: string) {
if (new Set(dictionary).size !== dictionary.length) {
throw new Error('Dictionary must not contain duplicate characters.');
}
if (dictionary.length < 2) {
throw new Error('Dictionary must contain at least 2 symbols.');
}
this.dictionary = [...dictionary];
this.dictionarySet = new Set(dictionary);
this.base = dictionary.length;
}
/**
* Encode overloads:
* - number sync encoding (base-N)
* - string | Uint8Array | Blob byte-level encoding
*/
encode(input: number): string;
encode(input: string | Uint8Array): string;
encode(input: number | string | Uint8Array): string {
if (typeof input === 'number') {
return this.encodeNumber(input);
}
return this.encodeBytes(input);
}
/**
* Converts a number to a base-N string using the provided dictionary.
* Rounds the number first; prefixes '-' if negative.
*/
encodeNumber(num: number): string {
if (!Number.isFinite(num)) {
throw new Error('Input must be a finite number.');
}
const negative = num < 0;
num = Math.round(Math.abs(num));
if (num === 0) {
return this.dictionary[0];
}
let encoded = '';
while (num > 0) {
encoded = this.dictionary[num % this.base] + encoded;
num = Math.floor(num / this.base);
}
return negative ? '-' + encoded : encoded;
}
/**
* Asynchronously encodes binary input (string, bytes, or Blob) using base-N byte-level logic.
*/
encodeBytes(input: string | Uint8Array | ArrayBuffer): string {
let bytes: Uint8Array;
if (typeof input === 'string') {
bytes = new TextEncoder().encode(input);
} else if (input instanceof Uint8Array) {
bytes = input;
} else if (input instanceof ArrayBuffer) {
bytes = new Uint8Array(input);
} else if (Array.isArray(input)) {
bytes = new Uint8Array(input);
} else {
throw new Error('Unsupported input type for encode');
}
// Count leading zeros
let zeroCount = 0;
while (zeroCount < bytes.length && bytes[zeroCount] === 0) zeroCount++;
const digits: string[] = [];
let inputArray = Array.from(bytes);
while (inputArray.length > 0 && !(inputArray.length === 1 && inputArray[0] === 0)) {
const newInput: number[] = [];
let remainder = 0;
for (const byte of inputArray) {
const acc = (remainder << 8) + byte;
const digit = Math.floor(acc / this.base);
remainder = acc % this.base;
if (newInput.length > 0 || digit !== 0) newInput.push(digit);
}
digits.push(this.dictionary[remainder]);
inputArray = newInput;
}
for (let i = 0; i < zeroCount; i++) digits.push(this.dictionary[0]);
return digits.reverse().join('');
}
/**
* Decodes a base-N string back to a number. Handles optional '-' prefix.
*/
decodeNumber(str: string): number {
if (typeof str !== 'string' || str.length === 0) {
throw new Error('Input must be a non-empty string.');
}
const negative = str[0] === '-';
if (negative) str = str.slice(1);
let num = 0;
if (new Set(str).isSubsetOf(this.dictionarySet) === false) {
throw new Error('Input contains invalid characters.');
}
for (let i = 0; i < str.length; i++) {
const val = this.dictionary.indexOf(str[i]);
num = num * this.base + val;
}
return negative ? -num : num;
}
/**
* Decodes a string or binary representation back to a Uint8Array.
*/
decodeBytes(input: string): Uint8Array {
if (input.length === 0) return new Uint8Array();
let zeroCount = 0;
while (zeroCount < input.length && input[zeroCount] === this.dictionary[0]) zeroCount++;
const charToValue: Record<string, number> = Object.fromEntries(this.dictionary.map((c, i) => [c, i]));
const bytes: number[] = [];
let inputArray = Array.from(input, (c) => {
const v = charToValue[c];
if (v === undefined) throw new Error(`Invalid character: ${c}`);
return v;
});
while (inputArray.length > 0 && !(inputArray.length === 1 && inputArray[0] === 0)) {
const newInput: number[] = [];
let remainder = 0;
for (const digit of inputArray) {
const acc = remainder * this.base + digit;
const byte = Math.floor(acc / 256);
remainder = acc % 256;
if (newInput.length > 0 || byte !== 0) newInput.push(byte);
}
bytes.push(remainder);
inputArray = newInput;
}
for (let i = 0; i < zeroCount; i++) bytes.push(0);
return new Uint8Array(bytes.reverse());
}
}
const dictionaries: Record<string, BaseCodec> = {};
export const Base64Dictionary = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
export const InvisibleDictionary = '\u200B\u200C\u200D\uFEFF';
export function baseCodecFrom(dictionary: string): BaseCodec {
return dictionaries[dictionary] || (dictionaries[dictionary] = new BaseCodec(dictionary));
}
namespace BaseCodec {
/**
* Converts a number to a Base64 string.
* Rounds the number first; prefixes '-' if negative.
* @param {number} num - The number to convert.
* @returns {string} The Base64-encoded representation.
*/
export const encode = (num: number) => baseCodecFrom(Base64Dictionary).encodeNumber(num);
/**
* Decodes a Base64 string back to a number.
* Handles optional '-' prefix.
* @param {string} str - The Base64-encoded string.
* @returns {number} The decoded number.
*/
export const decode = (str: string) => baseCodecFrom(Base64Dictionary).decodeNumber(str);
}
export default BaseCodec;

View file

@ -8,11 +8,12 @@ import {
} from './utils.js';
import { trackDateSettings, losslessContainerSettings } from './storage.js';
import { APICache } from './cache.js';
import { addMetadataToAudio } from './metadata.js';
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
import { HlsDownloader } from './hls-downloader.js';
import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
import { ffmpeg } from './ffmpeg.js';
import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@ -1282,7 +1283,11 @@ export class LosslessAPI {
}
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
// Load ffmpeg in the background.
loadFfmpeg().catch(console.error);
const { onProgress, track } = options;
const prefetchPromises = prefetchMetadataObjects(track, this);
const isVideo = track?.type === 'video';
try {
@ -1436,6 +1441,8 @@ export class LosslessAPI {
onProgress,
options.signal
);
} else {
blob = await rebuildFlacWithoutMetadata(blob);
}
break;
case 'alac':
@ -1479,7 +1486,7 @@ export class LosslessAPI {
};
}
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, options.coverBlob);
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
}
}

View file

@ -98,6 +98,8 @@ let settingsModule = null;
let downloadsModule = null;
let metadataModule = null;
export const managers = {};
async function loadSettingsModule() {
if (!settingsModule) {
settingsModule = await import('./settings.js');
@ -498,6 +500,7 @@ document.addEventListener('DOMContentLoaded', async () => {
window.monochromeScrobbler = scrobbler;
const lyricsManager = new LyricsManager(api);
ui.lyricsManager = lyricsManager;
managers.lyricsManager = lyricsManager;
// Check browser support for local files
const selectLocalBtn = document.getElementById('select-local-folder-btn');

26
js/doTimed.ts Normal file
View file

@ -0,0 +1,26 @@
import { InvisibleDictionary, baseCodecFrom } from './BaseCodec';
import { v7 } from 'uuid';
export const InvisibleCodec = baseCodecFrom(InvisibleDictionary);
export function doTimed<T>(message: string, callback: () => T): T {
const hiddenId = InvisibleCodec.encode(v7());
console.time(message + hiddenId);
try {
const output = callback();
return output;
} finally {
console.timeEnd(message + hiddenId);
}
}
export async function doTimedAsync<T>(message: string, callback: () => T): Promise<Awaited<T>> {
const hiddenId = InvisibleCodec.encode(v7());
console.time(message + hiddenId);
try {
const output = await callback();
return output;
} finally {
console.timeEnd(message + hiddenId);
}
}

View file

@ -12,11 +12,12 @@ import {
escapeHtml,
} from './utils.js';
import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js';
import { addMetadataToAudio } from './metadata.js';
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 { encodeToMp3 } from './mp3-encoder.js';
import { ffmpeg } from './ffmpeg.js';
import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
const downloadTasks = new Map();
const bulkDownloadTasks = new Map();
@ -287,6 +288,11 @@ async function downloadTrackBlob(
onProgress = null,
coverBlob = null
) {
// Load ffmpeg in the background.
loadFfmpeg().catch(console.error);
const prefetchPromises = prefetchMetadataObjects(track, api, coverBlob);
let enrichedTrack = {
...track,
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
@ -391,6 +397,8 @@ async function downloadTrackBlob(
onProgress,
signal
);
} else {
blob = await rebuildFlacWithoutMetadata(blob);
}
break;
case 'alac':
@ -419,7 +427,7 @@ async function downloadTrackBlob(
const extension = await getExtensionFromBlob(blob);
// Add metadata to the blob
blob = await addMetadataToAudio(blob, enrichedTrack, api, quality);
blob = await addMetadataToAudio(blob, enrichedTrack, api, quality, prefetchPromises);
return { blob, extension };
}

View file

@ -1,3 +1,9 @@
import { fetchBlobURL } from './utils';
import FfmpegWorker from './ffmpeg.worker.js?worker';
const ffmpegBase = 'https://unpkg.com/@ffmpeg/core/dist/esm';
const coreJs = `${ffmpegBase}/ffmpeg-core.js`;
const coreWasm = `${ffmpegBase}/ffmpeg-core.wasm`;
class FfmpegError extends Error {
constructor(message) {
super(message);
@ -6,6 +12,20 @@ class FfmpegError extends Error {
}
}
export function loadFfmpeg() {
return (
loadFfmpeg.promise ||
(loadFfmpeg.promise = (async () => {
const data = {
coreURL: await fetchBlobURL(coreJs),
wasmURL: await fetchBlobURL(coreWasm),
};
return data;
})())
);
}
async function ffmpegWorker(
audioBlob,
args = {},
@ -15,9 +35,10 @@ async function ffmpegWorker(
signal = null
) {
const audioData = await audioBlob.arrayBuffer();
const assets = loadFfmpeg();
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./ffmpeg.worker.js', import.meta.url), { type: 'module' });
const worker = new FfmpegWorker();
// Handle abort signal
const abortHandler = () => {
@ -57,18 +78,20 @@ async function ffmpegWorker(
reject(new FfmpegError('Worker failed: ' + error.message));
};
// Transfer audio data to worker
worker.postMessage(
{
audioData,
...args,
output: {
name: outputName,
mime: outputMime,
(async () => {
worker.postMessage(
{
audioData,
...args,
output: {
name: outputName,
mime: outputMime,
},
loadOptions: await assets,
},
},
[audioData]
);
[audioData]
);
})();
});
}

View file

@ -1,10 +1,39 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
let ffmpeg = null;
let loadingPromise = null;
async function loadFFmpeg() {
// For granular progress
let totalDurationSeconds = null;
let lastProgress = 0;
function parseTimestamp(str) {
// Expects format: 00:03:19.26
const match = str.match(/(\d+):(\d+):(\d+\.?\d*)/);
if (!match) return null;
const [, h, m, s] = match;
return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
}
function extractDurationFromLog(log) {
// Looks for 'Duration: 00:03:19.26'
const match = log.match(/Duration: (\d+:\d+:\d+\.?\d*)/);
if (match) {
return parseTimestamp(match[1]);
}
return null;
}
function extractTimeFromLog(log) {
// Looks for 'time=00:01:05.53'
const match = log.match(/time=(\d+:\d+:\d+\.?\d*)/);
if (match) {
return parseTimestamp(match[1]);
}
return null;
}
async function loadFFmpeg(loadOptions = {}) {
if (loadingPromise) return loadingPromise;
loadingPromise = (async () => {
@ -12,24 +41,55 @@ async function loadFFmpeg() {
ffmpeg.on('log', ({ message }) => {
self.postMessage({ type: 'log', message });
// Try to extract total duration from input log
if (totalDurationSeconds === null) {
const dur = extractDurationFromLog(message);
if (dur) {
totalDurationSeconds = dur;
self.postMessage({ type: 'progress', stage: 'parsing', message: `Detected duration: ${dur}s` });
}
}
// Try to extract current time from progress log
if (totalDurationSeconds) {
const cur = extractTimeFromLog(message);
if (cur !== null) {
let progress = Math.min(100, (cur / totalDurationSeconds) * 100);
// Only send if progress increased by at least 0.1%
if (progress - lastProgress >= 0.1 || progress === 100) {
lastProgress = progress;
self.postMessage({
type: 'progress',
stage: 'encoding',
progress,
time: cur,
message: `Encoding: ${progress.toFixed(1)}% (${cur.toFixed(2)}s / ${totalDurationSeconds.toFixed(2)}s)`,
});
}
}
}
});
// Optionally keep the original progress event for fallback
ffmpeg.on('progress', ({ progress, time }) => {
self.postMessage({
type: 'progress',
stage: 'encoding',
progress: progress * 100,
time,
});
// Only send if we don't have granular progress
if (!totalDurationSeconds) {
self.postMessage({
type: 'progress',
stage: 'encoding',
progress: progress * 100,
time,
});
}
});
self.postMessage({ type: 'progress', stage: 'loading', message: 'Loading FFmpeg...' });
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
await ffmpeg.load(loadOptions);
// Reset progress state for each run
totalDurationSeconds = null;
lastProgress = 0;
})();
return loadingPromise;
@ -45,12 +105,14 @@ self.onmessage = async (e) => {
},
encodeStartMessage = 'Encoding...',
encodeEndMessage = 'Finalizing...',
loadOptions = {},
} = e.data;
try {
await loadFFmpeg();
console.log(loadOptions);
await loadFFmpeg(loadOptions);
self.postMessage({ type: 'progress', stage: 'encoding', message: encodeStartMessage });
self.postMessage({ type: 'progress', stage: 'encoding', message: encodeStartMessage, progress: 0.0 });
try {
// Write input file to FFmpeg virtual filesystem
@ -64,7 +126,7 @@ self.onmessage = async (e) => {
// Run FFMPEG with the provided arguments.
await ffmpeg.exec(ffmpegArgs);
self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage });
self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage, progress: 100.0 });
// Read output file - use Uint8Array directly to avoid extra bytes from ArrayBuffer
const data = await ffmpeg.readFile(output.name);

4
js/global.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*?url' {
const content: string;
export default content;
}

View file

@ -1,169 +0,0 @@
import { getCoverBlob, getTrackTitle } from './utils.js';
async function writeID3v2Tag(mp3Blob, metadata, coverBlob = null) {
const frames = [];
if (metadata.title) {
frames.push(createTextFrame('TIT2', getTrackTitle(metadata)));
}
const artistName = metadata.artist?.name || metadata.artists?.[0]?.name;
if (artistName) {
frames.push(createTextFrame('TPE1', artistName));
}
if (metadata.album?.title) {
frames.push(createTextFrame('TALB', metadata.album.title));
}
const albumArtistName = metadata.album?.artist?.name || metadata.artist?.name || metadata.artists?.[0]?.name;
if (albumArtistName) {
frames.push(createTextFrame('TPE2', albumArtistName));
}
if (metadata.trackNumber) {
frames.push(createTextFrame('TRCK', metadata.trackNumber.toString()));
}
if (metadata.album?.releaseDate) {
const year = new Date(metadata.album.releaseDate).getFullYear();
if (!Number.isNaN(year) && Number.isFinite(year)) {
frames.push(createTextFrame('TYER', year.toString()));
}
}
if (metadata.isrc) {
frames.push(createTextFrame('TSRC', metadata.isrc));
}
if (metadata.copyright) {
frames.push(createTextFrame('TCOP', metadata.copyright));
}
frames.push(createTextFrame('TENC', 'Monochrome'));
if (coverBlob) {
frames.push(await createAPICFrame(coverBlob));
}
return buildID3v2Tag(mp3Blob, frames);
}
function createTextFrame(frameId, text) {
// ID3v2.3 UTF-16 encoding with BOM
const bom = new Uint8Array([0xff, 0xfe]); // UTF-16LE BOM
const utf16Bytes = new Uint8Array(text.length * 2);
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
utf16Bytes[i * 2] = charCode & 0xff;
utf16Bytes[i * 2 + 1] = (charCode >> 8) & 0xff;
}
const frameSize = 1 + bom.length + utf16Bytes.length;
const frame = new Uint8Array(10 + frameSize);
const view = new DataView(frame.buffer);
for (let i = 0; i < 4; i++) {
frame[i] = frameId.charCodeAt(i);
}
view.setUint32(4, frameSize, false);
frame[10] = 0x01; // UTF-16 with BOM
frame.set(bom, 11);
frame.set(utf16Bytes, 11 + bom.length);
return frame;
}
async function createAPICFrame(coverBlob) {
const imageBytes = new Uint8Array(await coverBlob.arrayBuffer());
const mimeType = coverBlob.type || 'image/jpeg';
const mimeBytes = new TextEncoder().encode(mimeType);
const frameSize = 1 + mimeBytes.length + 1 + 1 + 1 + imageBytes.length;
const frame = new Uint8Array(10 + frameSize);
const view = new DataView(frame.buffer);
for (let i = 0; i < 4; i++) {
frame[i] = 'APIC'.charCodeAt(i);
}
view.setUint32(4, frameSize, false);
let offset = 10;
frame[offset++] = 0x00;
frame.set(mimeBytes, offset);
offset += mimeBytes.length;
frame[offset++] = 0x00;
frame[offset++] = 0x03;
frame[offset++] = 0x00;
frame.set(imageBytes, offset);
return frame;
}
function buildID3v2Tag(mp3Blob, frames) {
const framesData = new Uint8Array(frames.reduce((acc, f) => acc + f.length, 0));
let offset = 0;
for (const frame of frames) {
framesData.set(frame, offset);
offset += frame.length;
}
const tagSize = framesData.length;
const header = new Uint8Array(10);
header[0] = 0x49;
header[1] = 0x44;
header[2] = 0x33;
header[3] = 0x03;
header[4] = 0x00;
header[5] = 0x00;
header[6] = (tagSize >> 21) & 0x7f;
header[7] = (tagSize >> 14) & 0x7f;
header[8] = (tagSize >> 7) & 0x7f;
header[9] = tagSize & 0x7f;
return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' });
}
function getTrackCoverId(track) {
return (
track.album?.cover ||
track.cover ||
track.image ||
track.album?.coverId ||
track.coverId ||
track.album?.image ||
null
);
}
export async function addMp3Metadata(mp3Blob, track, api, coverBlob = null) {
try {
if (!coverBlob) {
const coverId = getTrackCoverId(track);
if (coverId) {
try {
coverBlob = await getCoverBlob(api, coverId);
} catch (error) {
console.warn('Failed to fetch album art for MP3:', error);
}
}
}
return await writeID3v2Tag(mp3Blob, track, coverBlob);
} catch (error) {
console.error('Failed to add MP3 metadata:', error);
return mp3Blob;
}
}

619
js/metadata.flac.js Normal file
View file

@ -0,0 +1,619 @@
import { getCoverBlob, getTrackTitle } from './utils.js';
import { getFullArtistString } from './utils.js';
import { METADATA_STRINGS } from './metadata.js';
export const FLAC_MIME_TYPE = 'audio/flac';
const FLAC_BLOCK_TYPES = {
/** This block has information about the whole stream, like sample rate, number of channels, total number of samples, etc. It must be present as the first metadata block in the stream. Other metadata blocks may follow, and ones that the decoder doesn't understand, it will skip. */
StreamInfo: 0,
/** This block allows for an arbitrary amount of padding. The contents of a PADDING block have no meaning. This block is useful when it is known that metadata will be edited after encoding; the user can instruct the encoder to reserve a PADDING block of sufficient size so that when metadata is added, it will simply overwrite the padding (which is relatively quick) instead of having to insert it into the right place in the existing file (which would normally require rewriting the entire file). */
Padding: 1,
/** This block is for use by third-party applications. The only mandatory field is a 32-bit identifier. This ID is granted upon request to an application by the FLAC maintainers. The remainder is of the block is defined by the registered application. Visit the registration page if you would like to register an ID for your application with FLAC. */
Application: 2,
/** This is an optional block for storing seek points. It is possible to seek to any given sample in a FLAC stream without a seek table, but the delay can be unpredictable since the bitrate may vary widely within a stream. By adding seek points to a stream, this delay can be significantly reduced. Each seek point takes 18 bytes, so 1% resolution within a stream adds less than 2k. There can be only one SEEKTABLE in a stream, but the table can have any number of seek points. There is also a special 'placeholder' seekpoint which will be ignored by decoders but which can be used to reserve space for future seek point insertion. */
SeekTable: 3,
/** This block is for storing a list of human-readable name/value pairs. Values are encoded using UTF-8. It is an implementation of the Vorbis comment specification (without the framing bit). This is the only officially supported tagging mechanism in FLAC. There may be only one VORBIS_COMMENT block in a stream. In some external documentation, Vorbis comments are called FLAC tags to lessen confusion. */
VorbisComment: 4,
/** This block is for storing various information that can be used in a cue sheet. It supports track and index points, compatible with Red Book CD digital audio discs, as well as other CD-DA metadata such as media catalog number and track ISRCs. The CUESHEET block is especially useful for backing up CD-DA discs, but it can be used as a general purpose cueing mechanism for playback. */
CueSheet: 5,
/** This block is for storing pictures associated with the file, most commonly cover art from CDs. There may be more than one PICTURE block in a file. The picture format is similar to the APIC frame in ID3v2. The PICTURE block has a type, MIME type, and UTF-8 description like ID3v2, and supports external linking via URL (though this is discouraged). The differences are that there is no uniqueness constraint on the description field, and the MIME type is mandatory. The FLAC PICTURE block also includes the resolution, color depth, and palette size so that the client can search for a suitable picture without having to scan them all. */
Picture: 6,
};
export async function readFlacMetadata(file, metadata) {
const arrayBuffer = await file.arrayBuffer();
const dataView = new DataView(arrayBuffer);
if (!isFlacFile(dataView)) return;
const blocks = parseFlacBlocks(dataView);
const vorbisBlock = blocks.find((b) => b.type === FLAC_BLOCK_TYPES.VorbisComment);
const pictureBlock = blocks.find((b) => b.type === FLAC_BLOCK_TYPES.Picture);
const streamInfo = blocks.find((b) => b.type === FLAC_BLOCK_TYPES.StreamInfo);
const artists = [];
if (vorbisBlock) {
const offset = vorbisBlock.offset;
const vendorLen = dataView.getUint32(offset, true);
let pos = offset + 4 + vendorLen;
const commentListLen = dataView.getUint32(pos, true);
pos += 4;
for (let i = 0; i < commentListLen; i++) {
const len = dataView.getUint32(pos, true);
pos += 4;
const comment = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, len));
pos += len;
const eqIdx = comment.indexOf('=');
if (eqIdx > -1) {
const key = comment.substring(0, eqIdx);
const value = comment.substring(eqIdx + 1);
const upperKey = key.toUpperCase();
if (upperKey === 'TITLE') metadata.title = value;
if (upperKey === 'ARTIST' || upperKey === 'ALBUMARTIST') {
artists.push(value);
}
if (upperKey === 'ALBUM') metadata.album.title = value;
if (upperKey === 'ISRC') metadata.isrc = value;
if (upperKey === 'COPYRIGHT') metadata.copyright = value;
if (upperKey === 'ITUNESADVISORY') metadata.explicit = value === '1';
}
}
}
if (streamInfo) {
const offset = streamInfo.offset;
// Sample Rate is 20 bits spanning bytes 10, 11, and the first 4 bits of 12
const byte10 = dataView.getUint8(offset + 10);
const byte11 = dataView.getUint8(offset + 11);
const byte12 = dataView.getUint8(offset + 12);
// since data for some reason spans across multiple bytes, we need to combine them into one int
const sampleRate = (byte10 << 12) | (byte11 << 4) | (byte12 >> 4);
const byte13 = dataView.getUint8(offset + 13);
const tsHigh = byte13 & 0x0f;
const tsLow = dataView.getUint32(offset + 14, false);
// same thing for total samples
const totalSamples = tsHigh * 0x100000000 + tsLow;
if (sampleRate > 0) {
// beatiful
metadata.duration = totalSamples / sampleRate;
}
}
if (artists.length > 0) {
metadata.artists = artists.flatMap((a) => a.split(/; |\/|\\/)).map((name) => ({ name: name.trim() }));
}
if (pictureBlock) {
try {
let pos = pictureBlock.offset;
pos += 4;
const mimeLen = dataView.getUint32(pos, false);
pos += 4;
const mime = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, mimeLen));
pos += mimeLen;
const descLen = dataView.getUint32(pos, false);
pos += 4;
pos += descLen;
pos += 16;
const dataLen = dataView.getUint32(pos, false);
pos += 4;
const pictureData = new Uint8Array(arrayBuffer, pos, dataLen);
const blob = new Blob([pictureData], { type: mime });
metadata.album.cover = URL.createObjectURL(blob);
} catch (e) {
console.warn('Error parsing FLAC picture:', e);
}
}
}
/**
* Adds Vorbis comment metadata to FLAC files
*/
export async function addFlacMetadata(flacBlob, track, api) {
try {
const arrayBuffer = await flacBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer);
// Verify FLAC signature
if (!isFlacFile(dataView)) {
console.warn('Not a valid FLAC file, returning original');
return flacBlob;
}
// Parse FLAC structure
const blocks = parseFlacBlocks(dataView);
// If parsing failed or no audio data found, return original
if (!blocks || blocks.length === 0 || blocks.audioDataOffset === undefined) {
console.warn('Failed to parse FLAC blocks, returning original');
return flacBlob;
}
// Check for STREAMINFO block (must be first, type 0)
if (blocks[0].type !== 0) {
console.warn('FLAC file missing STREAMINFO block, returning original');
return flacBlob;
}
// Create or update Vorbis comment block
const vorbisCommentBlock = createVorbisCommentBlock(track);
// Fetch album artwork if available
let pictureBlock = null;
if (track.album?.cover) {
try {
pictureBlock = await createFlacPictureBlock(track.album.cover, api);
} catch (error) {
console.warn('Failed to embed album art:', error);
}
}
// Rebuild FLAC file with new metadata
let newFlacData;
try {
newFlacData = rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock);
} catch (rebuildError) {
console.error('Failed to rebuild FLAC structure:', rebuildError);
return flacBlob;
}
// Validate the rebuilt file
const validationView = new DataView(newFlacData.buffer);
if (!isFlacFile(validationView)) {
console.error('Rebuilt FLAC has invalid signature, returning original');
return flacBlob;
}
// Validate new file has proper block structure
const newBlocks = parseFlacBlocks(validationView);
if (!newBlocks || newBlocks.length === 0 || newBlocks.audioDataOffset === undefined) {
console.error('Rebuilt FLAC has invalid block structure, returning original');
return flacBlob;
}
return new Blob([newFlacData], { type: 'audio/flac' });
} catch (error) {
console.error('Failed to add FLAC metadata:', error);
return flacBlob;
}
}
export function isFlacFile(dataView) {
// Check for "fLaC" signature at the beginning
return (
dataView.byteLength >= 4 &&
dataView.getUint8(0) === 0x66 && // 'f'
dataView.getUint8(1) === 0x4c && // 'L'
dataView.getUint8(2) === 0x61 && // 'a'
dataView.getUint8(3) === 0x43
); // 'C'
}
export function parseFlacBlocks(dataView) {
const blocks = [];
let offset = 4; // Skip "fLaC" signature
while (offset + 4 <= dataView.byteLength) {
const header = dataView.getUint8(offset);
const isLast = (header & 0x80) !== 0;
const blockType = header & 0x7f;
// Block type 127 is invalid, types > 6 are reserved (except 127)
// Valid types: 0=STREAMINFO, 1=PADDING, 2=APPLICATION, 3=SEEKTABLE, 4=VORBIS_COMMENT, 5=CUESHEET, 6=PICTURE
if (blockType === 127) {
console.warn('Encountered invalid block type 127, stopping parse');
break;
}
const blockSize =
(dataView.getUint8(offset + 1) << 16) |
(dataView.getUint8(offset + 2) << 8) |
dataView.getUint8(offset + 3);
// Validate block size
if (blockSize < 0 || offset + 4 + blockSize > dataView.byteLength) {
console.warn(`Invalid block size ${blockSize} at offset ${offset}, stopping parse`);
break;
}
blocks.push({
type: blockType,
isLast: isLast,
size: blockSize,
offset: offset + 4,
headerOffset: offset,
});
offset += 4 + blockSize;
if (isLast) {
// Save the audio data offset
blocks.audioDataOffset = offset;
break;
}
}
// If we didn't find the last block marker, estimate audio offset
if (blocks.audioDataOffset === undefined && blocks.length > 0) {
const lastBlock = blocks[blocks.length - 1];
blocks.audioDataOffset = lastBlock.headerOffset + 4 + lastBlock.size;
console.warn('No last-block marker found, estimated audio offset:', blocks.audioDataOffset);
}
return blocks;
}
export function createVorbisComments(track) {
// Vorbis comment structure
const comments = [];
const discNumber = track.volumeNumber ?? track.discNumber;
// Add standard tags
if (track.title) {
comments.push(['TITLE', getTrackTitle(track)]);
}
const artistStr = getFullArtistString(track);
if (artistStr) {
comments.push(['ARTIST', artistStr]);
}
if (track.album?.title) {
comments.push(['ALBUM', track.album.title]);
}
const albumArtist = track.album?.artist?.name || track.artist?.name;
if (albumArtist) {
comments.push(['ALBUMARTIST', albumArtist]);
}
if (track.trackNumber) {
comments.push(['TRACKNUMBER', String(track.trackNumber)]);
}
if (discNumber) {
comments.push(['DISCNUMBER', String(discNumber)]);
}
if (track.album?.numberOfTracks) {
comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]);
}
if (track.bpm != null) {
const bpm = Number(track.bpm);
if (Number.isFinite(bpm)) {
comments.push(['TEMPO', String(Math.round(bpm))]);
}
}
if (track.replayGain) {
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain;
if (albumReplayGain) comments.push(['REPLAYGAIN_ALBUM_GAIN', String(albumReplayGain)]);
if (albumPeakAmplitude) comments.push(['REPLAYGAIN_ALBUM_PEAK', String(albumPeakAmplitude)]);
if (trackReplayGain) comments.push(['REPLAYGAIN_TRACK_GAIN', String(trackReplayGain)]);
if (trackPeakAmplitude) comments.push(['REPLAYGAIN_TRACK_PEAK', String(trackPeakAmplitude)]);
}
const releaseDateStr =
track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
if (releaseDateStr) {
try {
const year = new Date(releaseDateStr).getFullYear();
if (!isNaN(year)) {
comments.push(['DATE', String(year)]);
}
} catch {
// Invalid date, skip
}
}
if (track.copyright) {
comments.push(['COPYRIGHT', track.copyright]);
}
if (track.isrc) {
comments.push(['ISRC', track.isrc]);
}
if (track.explicit) {
comments.push(['ITUNESADVISORY', '1']);
}
return comments;
}
export function createVorbisCommentBlock(comments = []) {
// Calculate total size
const vendor = METADATA_STRINGS.VENDOR_STRING;
const vendorBytes = new TextEncoder().encode(vendor);
let totalSize = 4 + vendorBytes.length + 4; // vendor length + vendor + comment count
const encodedComments = comments.map(([key, value]) => {
const text = `${key}=${value}`;
const bytes = new TextEncoder().encode(text);
totalSize += 4 + bytes.length;
return bytes;
});
// Create buffer
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const uint8Array = new Uint8Array(buffer);
let offset = 0;
// Vendor length (little-endian)
view.setUint32(offset, vendorBytes.length, true);
offset += 4;
// Vendor string
uint8Array.set(vendorBytes, offset);
offset += vendorBytes.length;
// Comment count (little-endian)
view.setUint32(offset, comments.length, true);
offset += 4;
// Comments
for (const commentBytes of encodedComments) {
view.setUint32(offset, commentBytes.length, true);
offset += 4;
uint8Array.set(commentBytes, offset);
offset += commentBytes.length;
}
// Pad to at least 1024 bytes for future metadata edits without needing to rewrite the whole file
if (uint8Array.length < 1024) {
const newArray = new Uint8Array(1024);
newArray.set(uint8Array);
return newArray;
}
return uint8Array;
}
export async function createFlacPictureBlock(coverId, api) {
try {
// Fetch album art
const imageBlob = await getCoverBlob(api, coverId);
if (!imageBlob) {
throw new Error('Failed to fetch album art');
}
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
// Detect MIME type from blob or use default
const mimeType = imageBlob.type || 'image/jpeg';
const mimeBytes = new TextEncoder().encode(mimeType);
const description = '';
const descBytes = new TextEncoder().encode(description);
// Calculate total size
const totalSize =
4 + // picture type
4 +
mimeBytes.length + // mime length + mime
4 +
descBytes.length + // desc length + desc
4 + // width
4 + // height
4 + // color depth
4 + // indexed colors
4 +
imageBytes.length; // image length + image
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const uint8Array = new Uint8Array(buffer);
let offset = 0;
// Picture type (3 = front cover)
view.setUint32(offset, 3, false);
offset += 4;
// MIME type length
view.setUint32(offset, mimeBytes.length, false);
offset += 4;
// MIME type
uint8Array.set(mimeBytes, offset);
offset += mimeBytes.length;
// Description length
view.setUint32(offset, descBytes.length, false);
offset += 4;
// Description (empty)
if (descBytes.length > 0) {
uint8Array.set(descBytes, offset);
offset += descBytes.length;
}
// Width (0 = unknown)
view.setUint32(offset, 0, false);
offset += 4;
// Height (0 = unknown)
view.setUint32(offset, 0, false);
offset += 4;
// Color depth (0 = unknown)
view.setUint32(offset, 0, false);
offset += 4;
// Indexed colors (0 = not indexed)
view.setUint32(offset, 0, false);
offset += 4;
// Image data length
view.setUint32(offset, imageBytes.length, false);
offset += 4;
// Image data
uint8Array.set(imageBytes, offset);
return uint8Array;
} catch (error) {
console.error('Failed to create FLAC picture block:', error);
return null;
}
}
export function rebuildFlacWithMetadata(
dataView,
blocks,
vorbisCommentBlock = createVorbisCommentBlock(),
pictureBlock
) {
const originalArray = new Uint8Array(dataView.buffer);
// Remove seek table, old Vorbis comment, and picture blocks
const filteredBlocks = blocks.filter(
(b) => ![FLAC_BLOCK_TYPES.SeekTable, FLAC_BLOCK_TYPES.VorbisComment, FLAC_BLOCK_TYPES.Picture].includes(b.type)
);
// Calculate new file size
let newSize = 4; // "fLaC" signature
// Add STREAMINFO and other essential blocks
for (const block of filteredBlocks) {
newSize += 4 + block.size; // header + data
}
if (vorbisCommentBlock) {
// Add new Vorbis comment block
newSize += 4 + vorbisCommentBlock.length;
}
// Add picture block if available
if (pictureBlock) {
newSize += 4 + pictureBlock.length;
}
// Add audio data
const audioDataOffset = blocks.audioDataOffset;
if (audioDataOffset === undefined) {
throw new Error('Invalid FLAC file structure: unable to locate audio data stream');
}
const audioDataSize = dataView.byteLength - audioDataOffset;
newSize += audioDataSize;
// Build new file
const newFile = new Uint8Array(newSize);
let offset = 0;
// Write "fLaC" signature
newFile[offset++] = 0x66; // 'f'
newFile[offset++] = 0x4c; // 'L'
newFile[offset++] = 0x61; // 'a'
newFile[offset++] = 0x43; // 'C'
// Write existing blocks (except Vorbis and Picture)
for (let i = 0; i < filteredBlocks.length; i++) {
const block = filteredBlocks[i];
const isLast = false; // We'll add more blocks
// Write block header
const header = (isLast ? 0x80 : 0x00) | block.type;
newFile[offset++] = header;
newFile[offset++] = (block.size >> 16) & 0xff;
newFile[offset++] = (block.size >> 8) & 0xff;
newFile[offset++] = block.size & 0xff;
// Write block data
newFile.set(originalArray.subarray(block.offset, block.offset + block.size), offset);
offset += block.size;
}
let lastBlockHeaderOffset = offset;
if (vorbisCommentBlock) {
// Write new Vorbis comment block
const vorbisHeaderOffset = offset;
const vorbisHeader = FLAC_BLOCK_TYPES.VorbisComment; // Vorbis comment type
newFile[offset++] = vorbisHeader;
newFile[offset++] = (vorbisCommentBlock.length >> 16) & 0xff;
newFile[offset++] = (vorbisCommentBlock.length >> 8) & 0xff;
newFile[offset++] = vorbisCommentBlock.length & 0xff;
newFile.set(vorbisCommentBlock, offset);
offset += vorbisCommentBlock.length;
lastBlockHeaderOffset = vorbisHeaderOffset;
}
// Write picture block if available
if (pictureBlock) {
const pictureHeaderOffset = offset;
const pictureHeader = FLAC_BLOCK_TYPES.Picture; // Picture type
newFile[offset++] = pictureHeader;
newFile[offset++] = (pictureBlock.length >> 16) & 0xff;
newFile[offset++] = (pictureBlock.length >> 8) & 0xff;
newFile[offset++] = pictureBlock.length & 0xff;
newFile.set(pictureBlock, offset);
offset += pictureBlock.length;
lastBlockHeaderOffset = pictureHeaderOffset;
}
// Mark the last metadata block with the 0x80 flag
newFile[lastBlockHeaderOffset] |= 0x80;
// Write audio data
if (audioDataSize > 0) {
newFile.set(originalArray.subarray(audioDataOffset, audioDataOffset + audioDataSize), offset);
}
return newFile;
}
export function getFlacBlocks(dataView) {
// Verify FLAC signature
if (!isFlacFile(dataView)) {
throw new Error('Not a valid FLAC file');
}
// Parse FLAC structure
const blocks = parseFlacBlocks(dataView);
// If parsing failed or no audio data found, return original
if (!blocks || blocks.length === 0 || blocks.audioDataOffset === undefined) {
throw new Error('Failed to parse FLAC blocks');
}
// Check for STREAMINFO block (must be first, type 0)
if (blocks[0].type !== 0) {
throw new Error('FLAC file missing STREAMINFO block');
}
return blocks;
}
/**
* Removes all metadata from a FLAC file blob and returns the rebuilt FLAC data.
*
* @async
* @param {Blob} flacBlob - The FLAC audio file as a Blob object
* @returns {Promise<Blob>} A Promise that resolves to a new Blob containing the FLAC file without metadata,
* or the original flacBlob if an error occurs during processing
* @throws {Error} Logs errors to console but catches and returns original blob instead of throwing
*
* @example
* const flacFile = new Blob([arrayBuffer], { type: 'audio/flac' });
* const cleanFlac = await rebuildFlacWithoutMetadata(flacFile);
*/
export async function rebuildFlacWithoutMetadata(flacBlob) {
try {
const arrayBuffer = await flacBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer);
const blocks = getFlacBlocks(dataView);
return new Blob([rebuildFlacWithMetadata(dataView, blocks, createVorbisCommentBlock(), null)], {
type: FLAC_MIME_TYPE,
});
} catch (err) {
console.error('Error rebuilding FLAC file:', err);
return flacBlob;
}
}

File diff suppressed because it is too large Load diff

347
js/metadata.mp3.js Normal file
View file

@ -0,0 +1,347 @@
import { getCoverBlob, getTrackTitle, getTrackCoverId } from './utils.js';
export async function writeID3v2Tag(mp3Blob, metadata, coverBlob = null) {
const frames = [];
if (metadata.title) {
frames.push(createTextFrame('TIT2', getTrackTitle(metadata)));
}
const artistName = metadata.artist?.name || metadata.artists?.[0]?.name;
if (artistName) {
frames.push(createTextFrame('TPE1', artistName));
}
if (metadata.album?.title) {
frames.push(createTextFrame('TALB', metadata.album.title));
}
const albumArtistName = metadata.album?.artist?.name || metadata.artist?.name || metadata.artists?.[0]?.name;
if (albumArtistName) {
frames.push(createTextFrame('TPE2', albumArtistName));
}
if (metadata.trackNumber) {
frames.push(createTextFrame('TRCK', metadata.trackNumber.toString()));
}
if (metadata.album?.releaseDate) {
const year = new Date(metadata.album.releaseDate).getFullYear();
if (!Number.isNaN(year) && Number.isFinite(year)) {
frames.push(createTextFrame('TYER', year.toString()));
}
}
if (metadata.isrc) {
frames.push(createTextFrame('TSRC', metadata.isrc));
}
if (metadata.copyright) {
frames.push(createTextFrame('TCOP', metadata.copyright));
}
frames.push(createTextFrame('TENC', 'Monochrome'));
if (coverBlob) {
frames.push(await createAPICFrame(coverBlob));
}
return buildID3v2Tag(mp3Blob, frames);
}
export function createTextFrame(frameId, text) {
// ID3v2.3 UTF-16 encoding with BOM
const bom = new Uint8Array([0xff, 0xfe]); // UTF-16LE BOM
const utf16Bytes = new Uint8Array(text.length * 2);
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
utf16Bytes[i * 2] = charCode & 0xff;
utf16Bytes[i * 2 + 1] = (charCode >> 8) & 0xff;
}
const frameSize = 1 + bom.length + utf16Bytes.length;
const frame = new Uint8Array(10 + frameSize);
const view = new DataView(frame.buffer);
for (let i = 0; i < 4; i++) {
frame[i] = frameId.charCodeAt(i);
}
view.setUint32(4, frameSize, false);
frame[10] = 0x01; // UTF-16 with BOM
frame.set(bom, 11);
frame.set(utf16Bytes, 11 + bom.length);
return frame;
}
export async function createAPICFrame(coverBlob) {
const imageBytes = new Uint8Array(await coverBlob.arrayBuffer());
const mimeType = coverBlob.type || 'image/jpeg';
const mimeBytes = new TextEncoder().encode(mimeType);
const frameSize = 1 + mimeBytes.length + 1 + 1 + 1 + imageBytes.length;
const frame = new Uint8Array(10 + frameSize);
const view = new DataView(frame.buffer);
for (let i = 0; i < 4; i++) {
frame[i] = 'APIC'.charCodeAt(i);
}
view.setUint32(4, frameSize, false);
let offset = 10;
frame[offset++] = 0x00;
frame.set(mimeBytes, offset);
offset += mimeBytes.length;
frame[offset++] = 0x00;
frame[offset++] = 0x03;
frame[offset++] = 0x00;
frame.set(imageBytes, offset);
return frame;
}
export function buildID3v2Tag(mp3Blob, frames) {
const framesData = new Uint8Array(frames.reduce((acc, f) => acc + f.length, 0));
let offset = 0;
for (const frame of frames) {
framesData.set(frame, offset);
offset += frame.length;
}
const tagSize = framesData.length;
const header = new Uint8Array(10);
header[0] = 0x49;
header[1] = 0x44;
header[2] = 0x33;
header[3] = 0x03;
header[4] = 0x00;
header[5] = 0x00;
header[6] = (tagSize >> 21) & 0x7f;
header[7] = (tagSize >> 14) & 0x7f;
header[8] = (tagSize >> 7) & 0x7f;
header[9] = tagSize & 0x7f;
return new Blob([header, framesData, mp3Blob], { type: 'audio/mpeg' });
}
export async function addMp3Metadata(mp3Blob, track, api, coverBlob = null) {
try {
if (!coverBlob) {
const coverId = getTrackCoverId(track);
if (coverId) {
try {
coverBlob = await getCoverBlob(api, coverId);
} catch (error) {
console.warn('Failed to fetch album art for MP3:', error);
}
}
}
return await writeID3v2Tag(mp3Blob, track, coverBlob);
} catch (error) {
console.error('Failed to add MP3 metadata:', error);
return mp3Blob;
}
}
export async function readMp3Metadata(file, metadata) {
let buffer = await file.slice(0, 10).arrayBuffer();
let view = new DataView(buffer);
if (view.getUint8(0) === 0x49 && view.getUint8(1) === 0x44 && view.getUint8(2) === 0x33) {
const majorVer = view.getUint8(3);
const size = readSynchsafeInteger32(view, 6);
const tagSize = size + 10;
buffer = await file.slice(0, tagSize).arrayBuffer();
view = new DataView(buffer);
let offset = 10;
if ((view.getUint8(5) & 0x40) !== 0) {
const extSize = readSynchsafeInteger32(view, offset);
offset += extSize;
}
let tpe1 = null;
let tpe2 = null;
while (offset < view.byteLength) {
let frameId, frameSize;
if (majorVer === 3) {
frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4));
frameSize = view.getUint32(offset + 4, false);
offset += 10;
} else if (majorVer === 4) {
frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4));
frameSize = readSynchsafeInteger32(view, offset + 4);
offset += 10;
} else {
break;
}
if (frameId.charCodeAt(0) === 0) break;
if (offset + frameSize > view.byteLength) break;
const frameData = new DataView(buffer, offset, frameSize);
if (frameId === 'TIT2') metadata.title = readID3Text(frameData);
if (frameId === 'TPE1') tpe1 = readID3Text(frameData);
if (frameId === 'TPE2') tpe2 = readID3Text(frameData);
if (frameId === 'TALB') metadata.album.title = readID3Text(frameData);
if (frameId === 'TSRC') metadata.isrc = readID3Text(frameData);
if (frameId === 'TCOP') metadata.copyright = readID3Text(frameData);
if (frameId === 'TLEN') metadata.duration = parseInt(readID3Text(frameData)) / 1000; // usually not present
if (frameId === 'TYER' || frameId === 'TDRC') {
const year = readID3Text(frameData);
if (year) metadata.album.releaseDate = year;
}
if (frameId === 'APIC') {
try {
const encoding = frameData.getUint8(0);
let mimeType = '';
let pos = 1;
while (pos < frameData.byteLength && frameData.getUint8(pos) !== 0) {
mimeType += String.fromCharCode(frameData.getUint8(pos));
pos++;
}
pos++;
pos++;
let terminator = encoding === 1 || encoding === 2 ? 2 : 1;
while (pos < frameData.byteLength) {
if (frameData.getUint8(pos) === 0) {
if (terminator === 1) {
pos++;
break;
} else if (pos + 1 < frameData.byteLength && frameData.getUint8(pos + 1) === 0) {
pos += 2;
break;
}
}
pos++;
}
const pictureData = new Uint8Array(buffer, offset + pos, frameSize - pos);
const blob = new Blob([pictureData], { type: mimeType || 'image/jpeg' });
metadata.album.cover = URL.createObjectURL(blob);
} catch (e) {
console.warn('Error parsing APIC:', e);
}
}
offset += frameSize;
}
const artistStr = tpe1 || tpe2;
if (artistStr) {
metadata.artists = artistStr.split('/').map((name) => ({ name: name.trim() }));
}
if (!metadata.duration || metadata.duration === 0) {
metadata.duration = await calculateMp3Duration(file, tagSize);
}
}
if (file.size > 128) {
const tailBuffer = await file.slice(file.size - 128).arrayBuffer();
const tag = new TextDecoder().decode(new Uint8Array(tailBuffer, 0, 3));
if (tag === 'TAG') {
const title = new TextDecoder()
.decode(new Uint8Array(tailBuffer, 3, 30))
.replace(/\0/g, '')
.trim();
const artist = new TextDecoder()
.decode(new Uint8Array(tailBuffer, 33, 30))
.replace(/\0/g, '')
.trim();
const album = new TextDecoder()
.decode(new Uint8Array(tailBuffer, 63, 30))
.replace(/\0/g, '')
.trim();
if (title) metadata.title = title;
if (artist && metadata.artists.length === 0) {
metadata.artists = [{ name: artist }];
}
if (album) metadata.album.title = album;
}
}
}
// since mp3 file don't have metadata about duration, estimating it
// uses evil bitwise magic
export async function calculateMp3Duration(file, startOffset) {
const buffer = await file.slice(startOffset, startOffset + 32768).arrayBuffer();
const view = new DataView(buffer);
const uint8 = new Uint8Array(buffer);
let offset = 0;
// finding sync word
while (offset < view.byteLength - 4 && !(uint8[offset] === 0xff && (uint8[offset + 1] & 0xe0) === 0xe0)) {
offset++;
}
if (offset >= view.byteLength - 4) return 0;
const header = view.getUint32(offset, false);
// header info
const mpegVer = (header >> 19) & 3;
const brIdx = (header >> 12) & 15;
const srIdx = (header >> 10) & 3;
// Reject invalid headers
if (mpegVer === 1 || brIdx === 0 || brIdx === 15 || srIdx === 3) return 0;
const sampleRates = [[11025, 12000, 8000], null, [22050, 24000, 16000], [44100, 48000, 32000]];
const brMpeg1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
const brMpeg2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
const sampleRate = sampleRates[mpegVer][srIdx];
const bitrate = mpegVer === 3 ? brMpeg1[brIdx] : brMpeg2[brIdx];
// this xing header is present in many mp3 files and contains total frame count, which allows for accurate duration calculation
const channelMode = (header >> 6) & 3; // mono or stereo
const xingOffset = offset + 4 + (mpegVer === 3 ? (channelMode === 3 ? 17 : 32) : channelMode === 3 ? 9 : 17); // the position of xing header
if (xingOffset + 8 <= view.byteLength) {
const sig = view.getUint32(xingOffset, false);
if ((sig === 0x58696e67 || sig === 0x496e666f) && view.getUint32(xingOffset + 4, false) & 1) {
const frames = view.getUint32(xingOffset + 8, false);
// basically, duration = frames * samples per frame / sample rate
return (frames * (mpegVer === 3 ? 1152 : 576)) / sampleRate;
}
}
// if no Xing header, estimate duration from file size and bitrate
return ((file.size - startOffset) * 8) / (bitrate * 1000);
}
export function readSynchsafeInteger32(view, offset) {
return (
((view.getUint8(offset) & 0x7f) << 21) |
((view.getUint8(offset + 1) & 0x7f) << 14) |
((view.getUint8(offset + 2) & 0x7f) << 7) |
(view.getUint8(offset + 3) & 0x7f)
);
}
export function readID3Text(view) {
const encoding = view.getUint8(0);
const buffer = view.buffer.slice(view.byteOffset + 1, view.byteOffset + view.byteLength);
let decoder;
if (encoding === 0) decoder = new TextDecoder('iso-8859-1');
else if (encoding === 1) decoder = new TextDecoder('utf-16');
else if (encoding === 2) decoder = new TextDecoder('utf-16be');
else decoder = new TextDecoder('utf-8');
return decoder.decode(buffer).replace(/\0/g, '');
}

846
js/metadata.mp4.js Normal file
View file

@ -0,0 +1,846 @@
import { getCoverBlob, getTrackTitle, getMimeType, getFullArtistString } from './utils.js';
import { METADATA_STRINGS } from './metadata.js';
const { DEFAULT_TITLE, DEFAULT_ARTIST, DEFAULT_ALBUM } = METADATA_STRINGS;
export async function readM4aMetadata(file, metadata) {
try {
const chunkSize = Math.min(file.size, 5 * 1024 * 1024);
const buffer = await file.slice(0, chunkSize).arrayBuffer();
const view = new DataView(buffer);
const atoms = parseMp4Atoms(view);
const moov = atoms.find((a) => a.type === 'moov');
if (!moov) return;
const moovStart = moov.offset + 8;
const moovLen = moov.size - 8;
const moovData = new DataView(view.buffer, moovStart, moovLen);
const moovAtoms = parseMp4Atoms(moovData);
// mvhd metadata tag
const mvhd = moovAtoms.find((a) => a.type === 'mvhd');
if (mvhd) {
const mvhdStart = moovStart + mvhd.offset + 8;
const version = view.getUint8(mvhdStart);
// resolution and length, basically
let timeScale, duration;
if (version === 0) {
// 32-bit format
timeScale = view.getUint32(mvhdStart + 12, false);
duration = view.getUint32(mvhdStart + 16, false);
} else if (version === 1) {
// 64-bit format
timeScale = view.getUint32(mvhdStart + 20, false);
const durHigh = view.getUint32(mvhdStart + 24, false);
const durLow = view.getUint32(mvhdStart + 28, false);
duration = durHigh * 0x100000000 + durLow;
}
if (timeScale > 0) {
metadata.duration = duration / timeScale;
}
}
const udta = moovAtoms.find((a) => a.type === 'udta');
if (!udta) return;
const udtaStart = moovStart + udta.offset + 8;
const udtaLen = udta.size - 8;
const udtaData = new DataView(view.buffer, udtaStart, udtaLen);
const udtaAtoms = parseMp4Atoms(udtaData);
const meta = udtaAtoms.find((a) => a.type === 'meta');
if (!meta) return;
const metaStart = udtaStart + meta.offset + 12;
const metaLen = meta.size - 12;
const metaData = new DataView(view.buffer, metaStart, metaLen);
const metaAtoms = parseMp4Atoms(metaData);
const ilst = metaAtoms.find((a) => a.type === 'ilst');
if (!ilst) return;
const ilstStart = metaStart + ilst.offset + 8;
const ilstLen = ilst.size - 8;
const ilstData = new DataView(view.buffer, ilstStart, ilstLen);
const items = parseMp4Atoms(ilstData);
let artistStr = null;
for (const item of items) {
const itemStart = ilstStart + item.offset + 8;
const itemLen = item.size - 8;
const itemData = new DataView(view.buffer, itemStart, itemLen);
const dataAtom = parseMp4Atoms(itemData).find((a) => a.type === 'data');
if (dataAtom) {
const contentLen = dataAtom.size - 16;
const contentOffset = itemStart + dataAtom.offset + 16;
if (item.type === '©nam') {
metadata.title = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
} else if (item.type === '©ART') {
artistStr = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
} else if (item.type === '©alb') {
metadata.album.title = new TextDecoder().decode(
new Uint8Array(view.buffer, contentOffset, contentLen)
);
} else if (item.type === 'ISRC') {
metadata.isrc = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen));
} else if (item.type === 'cprt') {
metadata.copyright = new TextDecoder().decode(
new Uint8Array(view.buffer, contentOffset, contentLen)
);
} else if (item.type === 'covr') {
const pictureData = new Uint8Array(view.buffer, contentOffset, contentLen);
const mime = getMimeType(pictureData);
const blob = new Blob([pictureData], { type: mime });
metadata.album.cover = URL.createObjectURL(blob);
} else if (item.type === 'rtng') {
metadata.explicit =
contentLen > 0 && new Uint8Array(view.buffer, contentOffset, contentLen)[0] === 1;
}
}
}
if (artistStr) {
metadata.artists = artistStr.split(/; |\/|\\/).map((name) => ({ name: name.trim() }));
}
} catch (e) {
console.warn('Error parsing M4A:', e);
}
}
/**
* Adds metadata to M4A files using MP4 atoms
*/
export async function addM4aMetadata(m4aBlob, track, api) {
try {
const arrayBuffer = await m4aBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer);
// Parse MP4 atoms
const atoms = parseMp4Atoms(dataView);
// Create metadata atoms
const metadataAtoms = createMp4MetadataAtoms(track);
// Fetch album artwork if available
if (track.album?.cover) {
try {
const imageBlob = await getCoverBlob(api, track.album.cover);
if (imageBlob) {
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
metadataAtoms.cover = {
type: 'covr',
data: imageBytes,
};
}
} catch (error) {
console.warn('Failed to embed album art in M4A:', error);
}
}
// Rebuild MP4 file with metadata
const newMp4Data = rebuildMp4WithMetadata(dataView, atoms, metadataAtoms);
return new Blob([newMp4Data], { type: 'audio/mp4' });
} catch (error) {
console.error('Failed to add M4A metadata:', error);
return m4aBlob;
}
}
export function parseMp4Atoms(dataView) {
const atoms = [];
let offset = 0;
while (offset + 8 <= dataView.byteLength) {
// MP4 atoms use big-endian byte order
let size = dataView.getUint32(offset, false);
// Handle special size values
if (size === 0) {
// Size 0 means the atom extends to the end of the file
size = dataView.byteLength - offset;
} else if (size === 1) {
// Size 1 means 64-bit extended size follows (after the type field)
if (offset + 16 > dataView.byteLength) {
break;
}
// Read 64-bit size from offset+8 (big-endian)
const sizeHigh = dataView.getUint32(offset + 8, false);
const sizeLow = dataView.getUint32(offset + 12, false);
if (sizeHigh !== 0) {
console.warn('64-bit MP4 atoms larger than 4GB are not supported - file may be processed incompletely');
break;
}
size = sizeLow;
}
if (size < 8 || offset + size > dataView.byteLength) {
break;
}
const type = String.fromCharCode(
dataView.getUint8(offset + 4),
dataView.getUint8(offset + 5),
dataView.getUint8(offset + 6),
dataView.getUint8(offset + 7)
);
atoms.push({
type: type,
offset: offset,
size: size,
});
offset += size;
}
return atoms;
}
export function createMp4MetadataAtoms(track) {
// MP4 metadata atoms are more complex than FLAC
// We'll create basic iTunes-style metadata
/**
* Array of arrays: [namespace, name, value]
*/
const userTags = [];
const tags = {
'©nam': getTrackTitle(track) || DEFAULT_TITLE,
'©ART': getFullArtistString(track) || DEFAULT_ARTIST,
'©alb': track.album?.title || DEFAULT_ALBUM,
aART: track.album?.artist?.name || track.artist?.name || DEFAULT_ARTIST,
};
if (track.isrc) {
tags['ISRC'] = track.isrc;
tags['xid '] = ':isrc:' + track.isrc;
}
if (track.copyright) {
tags['cprt'] = track.copyright;
}
if (track.trackNumber) {
tags['trkn'] = {
current: track.trackNumber,
total: track.album?.numberOfTracks,
};
}
if (track.explicit) {
tags['rtng'] = 1; // 1 = Explicit, 2 = Clean, 0 = Unknown
}
const discNumber = track.volumeNumber ?? track.discNumber;
if (discNumber) {
tags['disk'] = {
current: discNumber,
total: 0,
};
}
if (track.bpm) {
tags['tmpo'] = Math.round(track.bpm);
}
const releaseDateStr =
track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
if (releaseDateStr) {
try {
const year = new Date(releaseDateStr).getFullYear();
if (!isNaN(year)) {
tags['©day'] = String(year);
}
} catch {
// Invalid date, skip
}
}
if (track.replayGain) {
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = track.replayGain;
let trackPeakAmplitudeString = String(trackPeakAmplitude);
let albumPeakAmplitudeString = String(albumPeakAmplitude);
if (trackPeakAmplitudeString.indexOf('.') === -1) {
trackPeakAmplitudeString += '.000000';
}
if (albumPeakAmplitudeString.indexOf('.') === -1) {
albumPeakAmplitudeString += '.000000';
}
if (trackPeakAmplitude) userTags.push(['com.apple.iTunes', 'replaygain_track_peak', trackPeakAmplitudeString]);
if (trackReplayGain) userTags.push(['com.apple.iTunes', 'replaygain_track_gain', `${trackReplayGain} dB`]);
if (albumPeakAmplitude) userTags.push(['com.apple.iTunes', 'replaygain_album_peak', albumPeakAmplitudeString]);
if (albumReplayGain) userTags.push(['com.apple.iTunes', 'replaygain_album_gain', `${albumReplayGain} dB`]);
}
return { tags, userTags };
}
export function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
const originalArray = new Uint8Array(dataView.buffer);
// Find moov atom
const moovAtom = atoms.find((a) => a.type === 'moov');
if (!moovAtom) {
console.warn('No moov atom found in M4A file');
return originalArray;
}
// Construct the new metadata block (udta -> meta -> ilst)
const newMetadataBytes = createMetadataBlock(metadataAtoms);
// We need to insert this into the moov atom.
// If udta exists, we merge/replace. For simplicity, we'll append/create.
// Ideally, we should parse moov children to find udta.
// 1. Calculate new sizes
// New file size = Original size + Metadata block size
// Note: If we are replacing existing metadata, this calculation would be different,
// but here we are assuming we are adding fresh or appending.
// A robust implementation would parse moov children, remove existing udta, and add new one.
// Let's try to do it right: parse moov children
const moovChildren = parseMp4Atoms(new DataView(originalArray.buffer, moovAtom.offset + 8, moovAtom.size - 8));
// Filter out existing udta to replace it
const filteredMoovChildren = moovChildren.filter((a) => a.type !== 'udta');
// Calculate new moov size
// Header (8) + Sum of other children sizes + New Metadata Block Size
let newMoovSize = 8;
for (const child of filteredMoovChildren) {
newMoovSize += child.size;
}
newMoovSize += newMetadataBytes.length;
const sizeDiff = newMoovSize - moovAtom.size;
const newFileSize = originalArray.length + sizeDiff;
const newFile = new Uint8Array(newFileSize);
let offset = 0;
let originalOffset = 0;
// Copy atoms before moov
const atomsBeforeMoov = atoms.filter((a) => a.offset < moovAtom.offset);
for (const atom of atomsBeforeMoov) {
newFile.set(originalArray.subarray(atom.offset, atom.offset + atom.size), offset);
offset += atom.size;
originalOffset += atom.size;
}
// Write new moov atom
// Size
newFile[offset++] = (newMoovSize >> 24) & 0xff;
newFile[offset++] = (newMoovSize >> 16) & 0xff;
newFile[offset++] = (newMoovSize >> 8) & 0xff;
newFile[offset++] = newMoovSize & 0xff;
// Type 'moov'
newFile[offset++] = 0x6d;
newFile[offset++] = 0x6f;
newFile[offset++] = 0x6f;
newFile[offset++] = 0x76;
// Write preserved children of moov
for (const child of filteredMoovChildren) {
const absoluteChildStart = moovAtom.offset + 8 + child.offset;
newFile.set(originalArray.subarray(absoluteChildStart, absoluteChildStart + child.size), offset);
offset += child.size;
}
// Write new metadata block (udta)
newFile.set(newMetadataBytes, offset);
offset += newMetadataBytes.length;
// Update originalOffset to skip old moov
originalOffset = moovAtom.offset + moovAtom.size;
// Copy atoms after moov
// Adjust offsets in stco/co64 atoms if necessary?
// Changing the size of moov (or atoms before mdat) shifts the mdat offsets.
// If moov comes before mdat, we MUST update the Chunk Offset Atom (stco or co64).
// This is complex.
// Safe strategy: If moov is AFTER mdat, we don't need to update offsets.
// If moov is BEFORE mdat, we need to shift offsets.
// Most streaming optimized files have moov before mdat.
const mdatAtom = atoms.find((a) => a.type === 'mdat');
const moovBeforeMdat = mdatAtom && moovAtom.offset < mdatAtom.offset;
if (moovBeforeMdat) {
// We need to update stco/co64 atoms inside the copied moov children content in newFile.
// This is getting very complicated for a simple "add metadata" feature without a proper library.
// However, we can try to find 'stco' or 'co64' in the new buffer we just wrote and offset values.
// Let's assume we need to shift by sizeDiff.
updateChunkOffsets(newFile, offset - newMoovSize, newMoovSize, sizeDiff);
}
// Copy remaining data (mdat etc.)
if (originalOffset < originalArray.length) {
newFile.set(originalArray.subarray(originalOffset), offset);
}
return newFile;
}
export function createMetadataBlock(metadataAtoms) {
const { tags, userTags, cover } = metadataAtoms;
const ilstChildren = [];
// Text tags
for (const [key, value] of Object.entries(tags)) {
if (key === 'trkn' || key === 'disk') {
ilstChildren.push(createIntAtom(key, value));
} else if (key === 'rtng') {
ilstChildren.push(createUintAtom(key, value, 1));
} else if (key === 'tmpo') {
ilstChildren.push(createUintAtom(key, value, 2));
} else {
ilstChildren.push(createStringAtom(key, value));
}
}
// User tags
for (const [namespace, name, value] of userTags) {
ilstChildren.push(createUserAtom(namespace, name, value));
}
// Cover art
if (cover) {
ilstChildren.push(createCoverAtom(cover.data));
}
// Construct ilst atom
const ilstSize = 8 + ilstChildren.reduce((acc, buf) => acc + buf.length, 0);
const ilst = new Uint8Array(ilstSize);
let offset = 0;
writeAtomHeader(ilst, offset, ilstSize, 'ilst');
offset += 8;
for (const child of ilstChildren) {
ilst.set(child, offset);
offset += child.length;
}
// Construct meta atom (FullAtom, version+flags = 4 bytes)
const metaSize = 12 + ilstSize;
const meta = new Uint8Array(metaSize);
offset = 0;
writeAtomHeader(meta, offset, metaSize, 'meta');
offset += 8;
meta[offset++] = 0; // Version
meta[offset++] = 0; // Flags
meta[offset++] = 0;
meta[offset++] = 0;
meta.set(ilst, offset);
// Construct hdlr atom (required for meta)
// "mdir" subtype, "appl" manufacturer, 0 flags/masks, empty name
// hdlr size: 4 (size) + 4 (type) + 4 (ver/flags) + 4 (pre_defined) + 4 (handler_type) + 12 (reserved) + name (string)
// Minimal valid hdlr for iTunes metadata:
const hdlrContent = new Uint8Array([
0,
0,
0,
0, // Version/Flags
0,
0,
0,
0, // Pre-defined
0x6d,
0x64,
0x69,
0x72, // 'mdir'
0x61,
0x70,
0x70,
0x6c, // 'appl'
0,
0,
0,
0, // Reserved
0,
0,
0,
0,
0,
0, // Name (empty null-term) check spec? usually simple 0 is enough
]);
const hdlrSize = 8 + hdlrContent.length;
const hdlr = new Uint8Array(hdlrSize);
writeAtomHeader(hdlr, 0, hdlrSize, 'hdlr');
hdlr.set(hdlrContent, 8);
// Construct udta atom
// udta contains meta. meta usually should contain hdlr before ilst?
// Actually, QuickTime spec says meta contains hdlr then ilst.
const finalMetaSize = 12 + hdlrSize + ilstSize;
const finalMeta = new Uint8Array(finalMetaSize);
offset = 0;
writeAtomHeader(finalMeta, offset, finalMetaSize, 'meta');
offset += 8;
finalMeta[offset++] = 0; // Version
finalMeta[offset++] = 0; // Flags
finalMeta[offset++] = 0;
finalMeta[offset++] = 0;
finalMeta.set(hdlr, offset);
offset += hdlrSize;
finalMeta.set(ilst, offset);
const udtaSize = 8 + finalMetaSize;
const udta = new Uint8Array(udtaSize);
writeAtomHeader(udta, 0, udtaSize, 'udta');
udta.set(finalMeta, 8);
return udta;
}
export function createStringAtom(type, value, truncateType = true) {
const typeLength = truncateType ? 4 : type.length;
const textBytes = new TextEncoder().encode(value);
const dataSize = 16 + textBytes.length; // 8 (data atom header) + 8 (flags/null) + text
const atomSize = 4 + typeLength + dataSize;
const buf = new Uint8Array(atomSize);
let offset = 0;
// Wrapper atom (e.g., ©nam)
writeAtomHeader(buf, offset, atomSize, type, truncateType);
offset += 4 + typeLength;
// Data atom
writeAtomHeader(buf, offset, dataSize, 'data');
offset += 8;
// Data Type (1 = UTF-8 text) + Locale (0)
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 1; // Type 1
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf.set(textBytes, offset);
return buf;
}
export function createUserAtom(namespace, name, value) {
const encoder = new TextEncoder();
const dashBytes = encoder.encode('----'); // User-defined atom type
const namespaceBytes = encoder.encode(namespace);
const meanBytes = encoder.encode('mean'); // Standard 'mean' atom for namespace
const nameBytes = encoder.encode(name);
const valueBytes = encoder.encode('\x00\x00\x00\x01\x00\x00\x00\x00' + value);
/**
* Atom structure:
* [----] (atom header)
* [mean] (namespace)
* [name] (name)
* [data] (value)
*/
const atomSize = 8 + 12 + namespaceBytes.length + 12 + nameBytes.length + 8 + valueBytes.length;
const buf = new Uint8Array(atomSize);
let offset = 0;
writeAtomHeader(buf, offset, atomSize, '----');
offset += 8; // Skip header
writeAtomHeader(buf, offset, namespaceBytes.length + 12, 'mean');
offset += 12;
buf.set(namespaceBytes, offset);
offset += namespaceBytes.length;
writeAtomHeader(buf, offset, nameBytes.length + 12, 'name');
offset += 12;
buf.set(nameBytes, offset);
offset += nameBytes.length;
writeAtomHeader(buf, offset, valueBytes.length + 8, 'data');
offset += 8;
buf.set(valueBytes, offset);
return buf;
}
/**
* Converts a number or BigInt value to a big-endian byte array.
* @param {number|BigInt|null} value - The value to convert to bytes. If null, returns null.
* @param {number|null} [byteLength=null] - Optional fixed byte length. If provided, the result will be padded or truncated to this length. If not provided, returns the minimal byte representation.
* @returns {Uint8Array} A Uint8Array representing the value in big-endian format, or null if value is null.
* @throws {Error} If the value is a negative number.
* @example
* // Variable length (minimal bytes)
* toBigEndianBytes(256); // Uint8Array [ 1, 0 ]
* toBigEndianBytes(0); // Uint8Array [ 0 ]
*
* // Fixed length with padding
* toBigEndianBytes(1, 4); // Uint8Array [ 0, 0, 0, 1 ]
*
* // With BigInt
* toBigEndianBytes(0xDEADBEEFn, 4); // Uint8Array [ 222, 173, 190, 239 ]
*/
export function toBigEndianBytes(value, byteLength = null) {
if (value == null) return new Uint8Array(0);
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error('Value must be a non-negative safe integer.');
}
// Fixed-length mode
if (byteLength != null) {
const bytes = new Uint8Array(byteLength);
for (let i = byteLength - 1; i >= 0; i--) {
bytes[i] = value & 0xff;
value = Math.floor(value / 256);
}
return bytes;
}
// Variable (minimal) mode
if (value === 0) return new Uint8Array([0]);
const result = [];
while (value > 0) {
result.push(value & 0xff);
value = Math.floor(value / 256);
}
result.reverse();
return new Uint8Array(result);
}
export function createUintAtom(key, value, intByteLength = 1) {
const numberBytes = toBigEndianBytes(value, intByteLength);
const dataSize = 16 + intByteLength; // Atom header (8) + number bytes
const atomSize = 8 + dataSize;
const buf = new Uint8Array(atomSize);
let offset = 0;
// Wrapper atom (e.g., ©nam)
writeAtomHeader(buf, offset, atomSize, key);
offset += 8;
// Data atom
writeAtomHeader(buf, offset, dataSize, 'data');
offset += 8;
// Data Type ((Big Endian Unsigned Integer) + Locale (0))
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 21; // Type 21
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf.set(numberBytes, offset++);
return buf;
}
export function createIntAtom(type, value) {
// trkn/disk are special: data is 8 bytes.
// reserved(2) + track(2) + total(2) + reserved(2)
const dataSize = 16 + 8;
const atomSize = 8 + dataSize;
const buf = new Uint8Array(atomSize);
let offset = 0;
writeAtomHeader(buf, offset, atomSize, type);
offset += 8;
writeAtomHeader(buf, offset, dataSize, 'data');
offset += 8;
// Data Type (0 = implicit/int) + Locale
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0; // Type 0
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
const current = typeof value === 'object' ? value.current : value;
const total = typeof value === 'object' ? value.total : 0;
// Numbering payload (track/disc number + total)
buf[offset++] = 0;
buf[offset++] = 0;
const numberValue = parseInt(current, 10) || 0;
buf[offset++] = (numberValue >> 8) & 0xff;
buf[offset++] = numberValue & 0xff;
const totalValue = parseInt(total, 10) || 0;
buf[offset++] = (totalValue >> 8) & 0xff;
buf[offset++] = totalValue & 0xff;
buf[offset++] = 0;
buf[offset++] = 0;
return buf;
}
export function createCoverAtom(imageBytes) {
const dataSize = 16 + imageBytes.length;
const atomSize = 8 + dataSize;
const buf = new Uint8Array(atomSize);
let offset = 0;
writeAtomHeader(buf, offset, atomSize, 'covr');
offset += 8;
writeAtomHeader(buf, offset, dataSize, 'data');
offset += 8;
// Data Type (13 = JPEG, 14 = PNG)
// We try to detect or default to JPEG (13)
let type = 13;
if (imageBytes[0] === 0x89 && imageBytes[1] === 0x50) {
// PNG signature
type = 14;
}
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = type;
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf[offset++] = 0;
buf.set(imageBytes, offset);
return buf;
}
/**
* Creates an atom header for MP4 metadata.
* @param {number} size - The size of the atom in bytes.
* @param {string} type - The 4-character atom type identifier.
* @param {boolean} [truncate=false] - Whether to truncate the type to 4 characters or use full length.
* @returns {Uint8Array} A byte array containing the atom header with size and type information.
*/
export function getAtomHeader(size, type, truncate = false) {
const buf = new Uint8Array(4 + (truncate ? 4 : type.length));
buf[0] = (size >> 24) & 0xff;
buf[1] = (size >> 16) & 0xff;
buf[2] = (size >> 8) & 0xff;
buf[3] = size & 0xff;
for (let i = 0; i < (truncate ? 4 : type.length); i++) {
buf[4 + i] = type.charCodeAt(i);
}
return buf;
}
/**
* Writes an atom header to a buffer at the specified offset.
* @param {Uint8Array} buf - The buffer to write the atom header to.
* @param {number} offset - The offset in the buffer where the atom header should be written.
* @param {number} size - The size of the atom.
* @param {string} type - The type of the atom (typically a 4-character code).
* @param {boolean} [truncate=true] - Whether to truncate the atom header. Defaults to true.
* @returns {void}
*/
export function writeAtomHeader(buf, offset, size, type, truncate = true) {
buf.set(getAtomHeader(size, type, truncate), offset);
}
export function updateChunkOffsets(buffer, moovOffset, moovSize, shift) {
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
// Scan moov for stco/co64
// This is a naive recursive search restricted to the known moov range
// We parse atoms starting from moov content
let offset = moovOffset + 8; // Skip moov header
const end = moovOffset + moovSize;
findAndShiftOffsets(view, offset, end, shift);
}
export function findAndShiftOffsets(view, start, end, shift) {
let offset = start;
while (offset + 8 <= end) {
const size = view.getUint32(offset, false);
const type = String.fromCharCode(
view.getUint8(offset + 4),
view.getUint8(offset + 5),
view.getUint8(offset + 6),
view.getUint8(offset + 7)
);
if (size < 8) break;
if (type === 'trak' || type === 'mdia' || type === 'minf' || type === 'stbl') {
// Container atoms, recurse
findAndShiftOffsets(view, offset + 8, offset + size, shift);
} else if (type === 'stco') {
// Chunk Offset Atom (32-bit)
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 4)
const count = view.getUint32(offset + 12, false);
for (let i = 0; i < count; i++) {
const entryOffset = offset + 16 + i * 4;
const oldVal = view.getUint32(entryOffset, false);
view.setUint32(entryOffset, oldVal + shift, false);
}
} else if (type === 'co64') {
// Chunk Offset Atom (64-bit)
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 8)
const count = view.getUint32(offset + 12, false);
for (let i = 0; i < count; i++) {
const entryOffset = offset + 16 + i * 8;
// Read 64-bit int
const oldHigh = view.getUint32(entryOffset, false);
const oldLow = view.getUint32(entryOffset + 4, false);
// Add shift (assuming shift is small enough not to overflow low 32 in a way that affects high simply?)
// Shift is Javascript number (double), up to 9007199254740991.
// 32-bit uint max is 4294967295.
// Proper 64-bit addition
// Construct BigInt
// Note: BigInt might not be available in all older environments, but modern browsers support it.
// Fallback: simpler logic
let newLow = oldLow + shift;
let carry = 0;
if (newLow > 0xffffffff) {
carry = Math.floor(newLow / 0x100000000);
newLow = newLow >>> 0;
}
const newHigh = oldHigh + carry;
view.setUint32(entryOffset, newHigh, false);
view.setUint32(entryOffset + 4, newLow, false);
}
}
offset += size;
}
}

76
js/taglib.ts Normal file
View file

@ -0,0 +1,76 @@
import { TagLib } from 'taglib-wasm';
import { fetchBlobURL } from './utils';
import _TagLibWasm from '!/taglib-wasm/dist/taglib-web.wasm?url';
import type {
TagLibWorkerMessageType,
AddMetadataMessage,
GetMetadataMessage,
TagLibFileResponse,
TagLibMetadataResponse,
TagLibMetadata,
TagLibReadMetadata,
} from './taglib.types';
import TagLibWorker from './taglib.worker?worker';
let tagLib: Promise<TagLib> | null = null;
async function fetchTagLib(): Promise<string> {
return fetchTagLib.blobUrl || (fetchTagLib.blobUrl = await fetchBlobURL(_TagLibWasm));
}
namespace fetchTagLib {
export let blobUrl = '';
}
export { fetchTagLib };
export async function addMetadataWithTagLib(
audioData: Uint8Array,
data: Omit<AddMetadataMessage, 'type' | 'wasmUrl' | 'audioData'>
) {
if (!(audioData instanceof Uint8Array)) {
audioData = new Uint8Array(audioData);
}
const worker = new TagLibWorker();
const wasmUrl = await fetchTagLib();
return new Promise<Uint8Array>((resolve, reject) => {
worker.onmessage = (e: MessageEvent<TagLibFileResponse>) => {
const { data, error } = e.data;
if (error) {
reject(new Error(error));
} else {
resolve(data!);
}
};
worker.onerror = reject;
worker.onmessageerror = reject;
worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData }, [audioData.buffer]);
});
}
export async function getMetadataWithTagLib(audioData: Uint8Array) {
if (!(audioData instanceof Uint8Array)) {
audioData = new Uint8Array(audioData);
}
const worker = new Worker(new URL(TagLibWorker, import.meta.url), { type: 'module' });
const wasmUrl = await fetchTagLib();
return new Promise<TagLibReadMetadata>((resolve, reject) => {
worker.onmessage = (e: MessageEvent<TagLibMetadataResponse>) => {
const { data, error } = e.data;
if (error) {
reject(new Error(error));
} else {
resolve(data!);
}
};
worker.onerror = reject;
worker.onmessageerror = reject;
worker.postMessage({ type: 'Get', wasmUrl, audioData }, [audioData.buffer]);
});
}

55
js/taglib.types.ts Normal file
View file

@ -0,0 +1,55 @@
export type TagLibWorkerMessageType = 'Add' | 'Get';
export interface TagLibWorkerMessage {
type: TagLibWorkerMessageType;
wasmUrl: string;
audioData: Uint8Array;
}
export interface TagLibWorkerResponse<T> {
type: TagLibWorkerMessageType;
data?: T;
error?: string;
}
export interface TagLibMetadata {
title?: string;
artist?: string;
albumTitle?: string;
albumArtist?: string;
trackNumber?: number;
totalTracks?: number;
discNumber?: number;
totalDiscs?: number;
bpm?: number;
replayGain?: {
albumReplayGain?: string;
albumPeakAmplitude?: number;
trackReplayGain?: string;
trackPeakAmplitude?: number;
};
cover?: {
data: Uint8Array;
type: string;
};
releaseDate?: string;
copyright?: string;
isrc?: string;
explicit?: boolean;
lyrics?: string;
}
export interface TagLibReadMetadata extends TagLibMetadata {
duration: number;
}
export type TagLibFileResponse = TagLibWorkerResponse<Uint8Array>;
export type TagLibMetadataResponse = TagLibWorkerResponse<TagLibReadMetadata>;
export type AddMetadataMessage = TagLibWorkerMessage & {
type: 'Add';
} & TagLibMetadata;
export type GetMetadataMessage = TagLibWorkerMessage & {
type: 'Get';
};

306
js/taglib.worker.ts Normal file
View file

@ -0,0 +1,306 @@
// filepath: /workspaces/monochrome/js/taglib.worker.ts
declare var self: DedicatedWorkerGlobalScope;
import { TagLib, type PictureType } from 'taglib-wasm';
import { doTimed, doTimedAsync } from './doTimed';
import type {
AddMetadataMessage,
GetMetadataMessage,
TagLibFileResponse,
TagLibMetadata,
TagLibMetadataResponse,
TagLibReadMetadata,
TagLibWorkerMessage,
TagLibWorkerResponse,
} from './taglib.types';
const PICTURE_TYPE_VALUES = {
FrontCover: 3,
};
async function addMetadataToAudio(message: AddMetadataMessage): Promise<Uint8Array> {
const {
wasmUrl,
audioData,
title,
artist,
albumTitle,
albumArtist,
trackNumber,
totalTracks,
discNumber,
totalDiscs,
bpm,
replayGain,
cover,
releaseDate,
copyright,
isrc,
explicit,
lyrics,
} = message;
const file = await doTimedAsync('Open file with taglib', async () => {
const tagLib = await TagLib.initialize({
wasmUrl: wasmUrl,
});
return await tagLib.open(audioData);
});
try {
doTimed('Tagging file', () => {
const isMp4 = file.isMP4();
const media = file.audioProperties();
const needsCombinedTrackDisc = isMp4 || media.containerFormat.toLowerCase() === 'mp3';
if (title) {
file.setProperty('TITLE', title);
}
if (artist) {
file.setProperty('ARTIST', artist);
}
if (albumTitle) {
file.setProperty('ALBUM', albumTitle);
}
const _albumArtist = albumArtist || artist;
if (_albumArtist) {
file.setProperty('ALBUMARTIST', _albumArtist);
}
if (trackNumber) {
let trackString = String(trackNumber);
if (needsCombinedTrackDisc && trackNumber && totalTracks) {
trackString = `${trackNumber}/${totalTracks}`;
}
if (needsCombinedTrackDisc) {
file.setProperty('TRACKNUMBER', trackString);
} else {
file.setProperty('TRACKNUMBER', String(trackNumber));
}
}
if (!needsCombinedTrackDisc && totalTracks) {
file.setProperty('TRACKTOTAL', String(totalTracks));
}
if (discNumber) {
let discString = String(discNumber);
if (needsCombinedTrackDisc && discNumber && totalDiscs) {
discString = `${discNumber}/${totalDiscs}`;
}
if (needsCombinedTrackDisc) {
file.setProperty('DISCNUMBER', discString);
} else {
file.setProperty('DISCNUMBER', String(discNumber));
}
}
if (!needsCombinedTrackDisc && totalDiscs) {
file.setProperty('DISCTOTAL', String(totalDiscs));
}
if (bpm != null && Number.isFinite(bpm)) {
file.setProperty('BPM', String(Math.round(bpm)));
}
if (replayGain) {
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = replayGain;
if (albumReplayGain) file.setProperty('REPLAYGAIN_ALBUM_GAIN', String(albumReplayGain));
if (albumPeakAmplitude) file.setProperty('REPLAYGAIN_ALBUM_PEAK', String(albumPeakAmplitude));
if (trackReplayGain) file.setProperty('REPLAYGAIN_TRACK_GAIN', String(trackReplayGain));
if (trackPeakAmplitude) file.setProperty('REPLAYGAIN_TRACK_PEAK', String(trackPeakAmplitude));
}
if (releaseDate) {
try {
const year = Number(releaseDate.split('-')[0]);
if (!isNaN(year)) {
file.setProperty('DATE', String(year));
}
} catch {
// Invalid date, skip
}
}
if (copyright) {
file.setProperty('COPYRIGHT', copyright);
}
if (isrc) {
file.setProperty('ISRC', isrc);
if (isMp4) {
file.setMP4Item('xid ', `:isrc:${isrc}`);
}
}
if (explicit) {
if (isMp4) {
file.setMP4Item('rtng', '1');
} else {
file.setProperty('ITUNESADVISORY', '1');
}
}
if (lyrics) {
file.setProperty('LYRICS', lyrics.replace(/\r/g, '').replace(/\n/g, '\r\n'));
}
if (cover) {
file.setPictures([
{
mimeType: cover.type,
data: cover.data,
type: 'FrontCover',
description: 'Cover Art',
},
]);
}
});
await doTimedAsync('Saving in-memory buffer', () => file.save());
return file.getFileBuffer();
} catch (err) {
console.error(err);
} finally {
file.dispose();
}
return audioData;
}
async function getMetadataFromAudio(message: GetMetadataMessage): Promise<TagLibReadMetadata> {
const { wasmUrl, audioData } = message;
const data: TagLibReadMetadata = {
duration: 0,
};
const file = await doTimedAsync('Open file with taglib', async () => {
const tagLib = await TagLib.initialize({
wasmUrl: wasmUrl,
});
return await tagLib.open(audioData);
});
try {
const pictures = file.getPictures();
const isMp4 = file.isMP4();
const media = file.audioProperties();
data.duration = media.duration;
data.title = file.getProperty('TITLE') || undefined;
data.artist = file.getProperty('ARTIST') || undefined;
data.albumTitle = file.getProperty('ALBUM') || undefined;
data.albumArtist = file.getProperty('ALBUMARTIST') || undefined;
const [trackNumber, trackTotal] = file
.getProperty('TRACKNUMBER')
?.split('/')
.map((t) => Number(t.trim() || 0) || undefined);
data.trackNumber = trackNumber || undefined;
data.totalTracks = trackTotal ? trackTotal : Number(file.getProperty('TRACKTOTAL') || 0) || undefined;
const [discNumber, discTotal] = file
.getProperty('DISCNUMBER')
?.split('/')
.map((t) => Number(t.trim() || 0) || undefined);
data.discNumber = Number(file.getProperty('DISCNUMBER') || 0) || undefined;
data.bpm = Number(file.getProperty('BPM') || 0) || undefined;
data.copyright = file.getProperty('COPYRIGHT') || undefined;
data.lyrics = file.getProperty('LYRICS') || undefined;
data.releaseDate = file.getProperty('DATE') || undefined;
const [replayGainAlbumGain, replayGainAlbumPeak, replayGainTrackGain, replayGainTrackPeak] = [
file.getProperty('REPLAYGAIN_ALBUM_GAIN'),
file.getProperty('REPLAYGAIN_ALBUM_PEAK'),
file.getProperty('REPLAYGAIN_TRACK_GAIN'),
file.getProperty('REPLAYGAIN_TRACK_PEAK'),
];
const replayGain: TagLibMetadata['replayGain'] = {};
if (replayGainAlbumGain) replayGain.albumReplayGain = replayGainAlbumGain;
if (replayGainAlbumPeak) replayGain.albumPeakAmplitude = Number(replayGainAlbumPeak);
if (replayGainTrackGain) replayGain.trackReplayGain = replayGainTrackGain;
if (replayGainTrackPeak) replayGain.trackPeakAmplitude = Number(replayGainTrackPeak);
if (Object.keys(replayGain).length > 0) {
data.replayGain = replayGain;
}
data.isrc = (isMp4 && file.getMP4Item('xid ')?.split(':').at(-1)) || file.getProperty('ISRC') || undefined;
data.explicit = (isMp4 && file.getMP4Item('rtng') === '1') || file.getProperty('ITUNESADVISORY') === '1';
if (pictures.length > 0) {
const picture = pictures.filter((p) => p.type === 'FrontCover')[0];
if (picture) {
data.cover = {
data: picture.data,
type: picture.mimeType,
};
}
}
} catch (err) {
console.error(err);
} finally {
file.dispose();
}
return data;
}
self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
const transfer: Transferable[] = [event.data.audioData.buffer];
switch (event.data.type) {
case 'Add':
try {
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
transfer.push(result.buffer);
self.postMessage(
{
type: event.data.type,
data: result,
} satisfies TagLibFileResponse,
transfer
);
} catch (error) {
self.postMessage(
{
type: event.data.type,
error: error instanceof Error ? error.message : String(error),
} satisfies TagLibWorkerResponse<undefined>,
transfer
);
}
break;
case 'Get':
try {
const result = await getMetadataFromAudio(event.data as GetMetadataMessage);
self.postMessage(
{
type: event.data.type,
data: result,
} satisfies TagLibMetadataResponse,
transfer
);
} catch (error) {
self.postMessage(
{
type: event.data.type,
error: error instanceof Error ? error.message : String(error),
} satisfies TagLibWorkerResponse<undefined>,
transfer
);
}
break;
}
};

View file

@ -421,6 +421,9 @@ function resizeImageBlob(blob, size) {
/**
* Fetches and caches cover art as a Blob
* @param {Object} api - API instance with getCoverUrl method
* @param {string} coverId - ID of the cover art to fetch
* @returns {Promise<Blob|null>} - Cover art blob or null if not available
*/
export async function getCoverBlob(api, coverId) {
if (!coverId) return null;
@ -553,3 +556,76 @@ export const getShareUrl = (path) => {
const safePath = path.startsWith('/') ? path : `/${path}`;
return `${baseUrl}${safePath}`;
};
/**
* Builds a full artist string by combining the track's listed artists
* with any featured artists parsed from the title (feat./with).
*/
export function getFullArtistString(track) {
const knownArtists =
Array.isArray(track.artists) && track.artists.length > 0
? track.artists.map((a) => (typeof a === 'string' ? a : a.name) || '').filter(Boolean)
: track.artist?.name
? [track.artist.name]
: [];
// Parse featured artists from title, e.g. "Song (feat. A, B & C)" or "(with X & Y)"
// Note: splitting on '&' may incorrectly fragment compound artist names like "Simon & Garfunkel".
const featPattern = /\(\s*(?:feat\.?|ft\.?|with)\s+(.+?)\s*\)/gi;
const allFeatArtists = [...(track.title?.matchAll(featPattern) ?? [])].flatMap((m) =>
m[1]
.split(/\s*[,&]\s*/)
.map((s) => s.trim())
.filter(Boolean)
);
if (allFeatArtists.length > 0) {
const knownLower = new Set(knownArtists.map((n) => n.toLowerCase()));
for (const feat of allFeatArtists) {
if (!knownLower.has(feat.toLowerCase())) {
knownArtists.push(feat);
knownLower.add(feat.toLowerCase());
}
}
}
return knownArtists.join('; ') || null;
}
export function fetchBlob(url) {
return fetch(url).then((d) => d.blob());
}
export async function fetchBlobURL(url) {
return await URL.createObjectURL(await fetchBlob(url));
}
export function getMimeType(data) {
if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8) return 'image/jpeg';
if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
return 'image/png';
return 'image/jpeg';
}
/**
* Retrieves the cover ID or image URL for a track
* @param {Object} track - The track object
* @param {Object} [track.album] - The album object associated with the track
* @param {string} [track.album.cover] - The album cover ID or URL
* @param {string} [track.album.coverId] - The album cover ID
* @param {string} [track.album.image] - The album image URL
* @param {string} [track.cover] - The track cover ID or URL
* @param {string} [track.coverId] - The track cover ID
* @param {string} [track.image] - The track image URL
* @returns {string|null} The cover ID or image URL, or null if none is available
*/
export function getTrackCoverId(track) {
return (
track.album?.cover ||
track.cover ||
track.image ||
track.album?.coverId ||
track.coverId ||
track.album?.image ||
null
);
}

10
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@kawarp/core": "^1.1.1",
@ -2579,6 +2580,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@ffmpeg/core": {
"version": "0.12.10",
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
"license": "GPL-2.0-or-later",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/ffmpeg": {
"version": "0.12.15",
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",

View file

@ -30,16 +30,18 @@
"homepage": "https://github.com/SamidyFR/monochrome#readme",
"devDependencies": {
"@neutralinojs/neu": "^11.7.0",
"@types/node": "^25.3.5",
"eslint": "^9.39.3",
"eslint-config-prettier": "^10.1.8",
"formidable": "^3.5.4",
"globals": "^17.4.0",
"htmlhint": "^1.9.1",
"htmlhint": "^1.9.2",
"miniflare": "^4.20260301.1",
"prettier": "^3.8.1",
"stylelint": "^16.26.1",
"stylelint-config-standard": "^39.0.1",
"stylelint-config-standard-scss": "^16.0.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-neutralino": "^1.0.3",
"vite-plugin-pwa": "^1.2.0"
@ -50,6 +52,7 @@
"serialize-javascript": "^7.0.3"
},
"dependencies": {
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@kawarp/core": "^1.1.1",
@ -60,8 +63,11 @@
"cookie-session": "^2.1.1",
"dashjs": "^5.1.1",
"fuse.js": "^7.1.0",
"jose": "^6.2.0",
"npm": "^11.11.0",
"taglib-wasm": "^1.0.5",
"uuid": "^13.0.0",
"hls.js": "^1.6.15",
"jose": "^6.1.3",
"pocketbase": "^0.26.8"
}
}

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable", "webworker"],
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"!/*": ["node_modules/*"]
},
"allowJs": true,
"checkJs": false,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["js/**/*.ts", "js/**/*.d.ts"]
}

View file

@ -2,6 +2,7 @@ import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import neutralino from 'vite-plugin-neutralino';
import authGatePlugin from './vite-plugin-auth-gate.js';
import path from 'path';
import uploadPlugin from './vite-plugin-upload.js';
export default defineConfig(({ mode }) => {
@ -9,13 +10,18 @@ export default defineConfig(({ mode }) => {
return {
base: './',
worker: {
format: 'es',
},
resolve: {
alias: {
'!': '/node_modules',
pocketbase: '/node_modules/pocketbase/dist/pocketbase.es.js',
},
},
optimizeDeps: {
exclude: ['pocketbase', '@ffmpeg/ffmpeg', '@ffmpeg/util'],
exclude: ['pocketbase', '@ffmpeg/ffmpeg', '@ffmpeg/util', 'taglib-wasm'],
external: ['taglib-wasm'],
},
server: {
fs: {