Merge pull request #274 from DanTheMan827/taglib-wasm
Use taglib-wasm for writing and improve ffmpeg caching
This commit is contained in:
commit
f20935d2d2
23 changed files with 3398 additions and 2176 deletions
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
195
js/BaseCodec.ts
Normal file
195
js/BaseCodec.ts
Normal 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;
|
||||
13
js/api.js
13
js/api.js
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
26
js/doTimed.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
47
js/ffmpeg.js
47
js/ffmpeg.js
|
|
@ -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]
|
||||
);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
4
js/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*?url' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
169
js/id3-writer.js
169
js/id3-writer.js
|
|
@ -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
619
js/metadata.flac.js
Normal 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;
|
||||
}
|
||||
}
|
||||
1851
js/metadata.js
1851
js/metadata.js
File diff suppressed because it is too large
Load diff
347
js/metadata.mp3.js
Normal file
347
js/metadata.mp3.js
Normal 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
846
js/metadata.mp4.js
Normal 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
76
js/taglib.ts
Normal 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
55
js/taglib.types.ts
Normal 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
306
js/taglib.worker.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
76
js/utils.js
76
js/utils.js
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -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
21
tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue