feat(metadata): replace taglib-wasm with @dantheman827/taglib-ts
- feat(taglib): updated audio buffer handling in metadata.js to use Uint8Array. - feat(taglib): refactored addMetadataToAudio to support return type as Blob or Uint8Array - feat(taglib): add timeout functionality to metadata functions - Introduced `withTimeout` utility function to handle operation timeouts. - Updated `addMetadataWithTagLib` to use `withTimeout` for promise resolution. - Updated `getMetadataWithTagLib` to use `withTimeout` for promise resolution. - Added default timeout parameter to both metadata functions. - feat(taglib): improve metadata handling with ChunkedByteVectorStream - Enhanced metadata handling in taglib.ts and taglib.worker.ts to utilize ChunkedByteVectorStream. - fix(taglib): handle metadata addition failure gracefully - Updated `addMetadataWithTagLib` to catch errors and return original audio data if metadata addition fails. fix(downloads): return original blob if metadata addition fails - Wrap addMetadataToAudio call in try-catch to handle errors. feat(taglib): add direct calling of taglib methods - Introduced `direct` parameter to `addMetadataWithTagLib` and `getMetadataWithTagLib` functions for direct processing in the current thread. - Exported taglib worker functions.
This commit is contained in:
parent
1ba27308d1
commit
895d5dd20f
11 changed files with 513 additions and 386 deletions
0
.gitmodules
vendored
Normal file
0
.gitmodules
vendored
Normal file
8
bun.lock
8
bun.lock
|
|
@ -5,6 +5,7 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "monochrome",
|
"name": "monochrome",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
|
||||||
"@ffmpeg/core": "^0.12.10",
|
"@ffmpeg/core": "^0.12.10",
|
||||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
"@ffmpeg/util": "^0.12.2",
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
|
@ -23,7 +24,6 @@
|
||||||
"mime": "^4.1.0",
|
"mime": "^4.1.0",
|
||||||
"npm": "^11.11.1",
|
"npm": "^11.11.1",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
"taglib-wasm": "^1.0.5",
|
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -260,6 +260,8 @@
|
||||||
|
|
||||||
"@csstools/selector-specificity": ["@csstools/selector-specificity@5.0.0", "", { "peerDependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw=="],
|
"@csstools/selector-specificity": ["@csstools/selector-specificity@5.0.0", "", { "peerDependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw=="],
|
||||||
|
|
||||||
|
"@dantheman827/taglib-ts": ["@dantheman827/taglib-ts@https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz", {}, "sha512-rvQOn9GDEj2sH4yV6oUTMMG9+rJbFG7tQkiP6/bhGJARg1Vmdy283j4YFCl+ubkqsMQ+UfAhEWSw5d5lfPVfwQ=="],
|
||||||
|
|
||||||
"@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="],
|
"@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="],
|
||||||
|
|
||||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||||
|
|
@ -426,8 +428,6 @@
|
||||||
|
|
||||||
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
|
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
|
||||||
|
|
||||||
"@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="],
|
|
||||||
|
|
||||||
"@neutralinojs/lib": ["@neutralinojs/lib@6.5.0", "", { "optionalDependencies": { "@rollup/rollup-darwin-x64": "*", "@rollup/rollup-linux-x64-gnu": "*" } }, "sha512-ECgYh+CXAfMR1JVTvDw/kHhjL6LzNNcjk8Va1DZUSBkUwROqFTQ7zseFeuFtwGvutqvlWiwpGmU3s11rg/bdvA=="],
|
"@neutralinojs/lib": ["@neutralinojs/lib@6.5.0", "", { "optionalDependencies": { "@rollup/rollup-darwin-x64": "*", "@rollup/rollup-linux-x64-gnu": "*" } }, "sha512-ECgYh+CXAfMR1JVTvDw/kHhjL6LzNNcjk8Va1DZUSBkUwROqFTQ7zseFeuFtwGvutqvlWiwpGmU3s11rg/bdvA=="],
|
||||||
|
|
||||||
"@neutralinojs/neu": ["@neutralinojs/neu@11.7.0", "", { "dependencies": { "@electron/asar": "^3.0.3", "chalk": "^4.1.0", "chokidar": "^4.0.3", "commander": "^7.2.0", "configstore": "^5.0.1", "edit-json-file": "^1.6.2", "follow-redirects": "^1.13.1", "fs-extra": "^9.0.1", "pe-library": "^1.0.1", "png2icons": "^2.0.1", "postject": "1.0.0-alpha.6", "recursive-readdir": "^2.2.2", "resedit": "^2.0.2", "spawn-command": "^1.0.0", "tcp-port-used": "^1.0.2", "uuid": "^8.3.2", "websocket": "^1.0.35", "zip-lib": "^1.0.4" }, "bin": { "neu": "bin/neu.js" } }, "sha512-fUqvR70a+BpKI9mrD92ldZkVC24Rs8XL/9m7zmOCLgCRys3yuWy7vEsxpHzKMzqTiQJkTYIsLmcR8VMzNIjuSw=="],
|
"@neutralinojs/neu": ["@neutralinojs/neu@11.7.0", "", { "dependencies": { "@electron/asar": "^3.0.3", "chalk": "^4.1.0", "chokidar": "^4.0.3", "commander": "^7.2.0", "configstore": "^5.0.1", "edit-json-file": "^1.6.2", "follow-redirects": "^1.13.1", "fs-extra": "^9.0.1", "pe-library": "^1.0.1", "png2icons": "^2.0.1", "postject": "1.0.0-alpha.6", "recursive-readdir": "^2.2.2", "resedit": "^2.0.2", "spawn-command": "^1.0.0", "tcp-port-used": "^1.0.2", "uuid": "^8.3.2", "websocket": "^1.0.35", "zip-lib": "^1.0.4" }, "bin": { "neu": "bin/neu.js" } }, "sha512-fUqvR70a+BpKI9mrD92ldZkVC24Rs8XL/9m7zmOCLgCRys3yuWy7vEsxpHzKMzqTiQJkTYIsLmcR8VMzNIjuSw=="],
|
||||||
|
|
@ -1312,8 +1312,6 @@
|
||||||
|
|
||||||
"table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="],
|
"table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="],
|
||||||
|
|
||||||
"taglib-wasm": ["taglib-wasm@1.0.5", "", { "dependencies": { "@msgpack/msgpack": "^3.1.3" }, "peerDependencies": { "typescript": ">=4.5.0" }, "optionalPeers": ["typescript"] }, "sha512-kuDHX78FbjLOqldWxBBkEgjyyDagYRGcYqr4g6ObkmEMO203Sp1R0KxkELoH9VkZxsOVR8yoSWaSaG9f5oTqyQ=="],
|
|
||||||
|
|
||||||
"tcp-port-used": ["tcp-port-used@1.0.2", "", { "dependencies": { "debug": "4.3.1", "is2": "^2.0.6" } }, "sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA=="],
|
"tcp-port-used": ["tcp-port-used@1.0.2", "", { "dependencies": { "debug": "4.3.1", "is2": "^2.0.6" } }, "sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA=="],
|
||||||
|
|
||||||
"temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="],
|
"temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="],
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { trackDateSettings } from './storage.js';
|
import { trackDateSettings } from './storage.js';
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
|
||||||
import { DashDownloader } from './dash-downloader.ts';
|
import { DashDownloader } from './dash-downloader.ts';
|
||||||
import { HlsDownloader } from './hls-downloader.js';
|
import { HlsDownloader } from './hls-downloader.js';
|
||||||
import { MP3EncodingError } from './mp3-encoder.js';
|
import { MP3EncodingError } from './mp3-encoder.js';
|
||||||
|
|
@ -1320,6 +1319,8 @@ export class LosslessAPI {
|
||||||
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
|
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
|
||||||
// Load ffmpeg in the background.
|
// Load ffmpeg in the background.
|
||||||
loadFfmpeg().catch(console.error);
|
loadFfmpeg().catch(console.error);
|
||||||
|
const metadataModule = await import('./metadata.js');
|
||||||
|
const { prefetchMetadataObjects, addMetadataToAudio } = metadataModule;
|
||||||
|
|
||||||
const { onProgress, track, calculateDashBytes = true } = options;
|
const { onProgress, track, calculateDashBytes = true } = options;
|
||||||
const prefetchPromises = prefetchMetadataObjects(track, this);
|
const prefetchPromises = prefetchMetadataObjects(track, this);
|
||||||
|
|
@ -1504,7 +1505,11 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress?.(new DownloadProgress('Adding metadata'));
|
onProgress?.(new DownloadProgress('Adding metadata'));
|
||||||
|
try {
|
||||||
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
import { AbortError } from './errorTypes.ts';
|
import { AbortError } from './errorTypes.ts';
|
||||||
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
|
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
|
||||||
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
||||||
import { triggerDownload } from './download-utils.ts';
|
|
||||||
import {
|
import {
|
||||||
ZipStreamWriter,
|
ZipStreamWriter,
|
||||||
ZipBlobWriter,
|
ZipBlobWriter,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
import {
|
import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js';
|
||||||
getCoverBlob,
|
import { addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
|
||||||
getTrackTitle,
|
|
||||||
getFullArtistString,
|
|
||||||
getMimeType,
|
|
||||||
getTrackCoverId,
|
|
||||||
getTrackDiscNumber,
|
|
||||||
getExtensionFromBlob,
|
|
||||||
} from './utils.js';
|
|
||||||
import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
|
|
||||||
import { doTimed, doTimedAsync } from './doTimed.ts';
|
import { doTimed, doTimedAsync } from './doTimed.ts';
|
||||||
import { managers } from './app.js';
|
import { managers } from './app.js';
|
||||||
|
|
||||||
|
|
@ -19,7 +11,6 @@ export const METADATA_STRINGS = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function prefetchMetadataObjects(track, api, coverBlob = null) {
|
export function prefetchMetadataObjects(track, api, coverBlob = null) {
|
||||||
const _tagLib = fetchTagLib().catch(console.error);
|
|
||||||
const coverId = getTrackCoverId(track);
|
const coverId = getTrackCoverId(track);
|
||||||
const coverFetch = coverBlob
|
const coverFetch = coverBlob
|
||||||
? Promise.resolve(coverBlob)
|
? Promise.resolve(coverBlob)
|
||||||
|
|
@ -28,7 +19,7 @@ export function prefetchMetadataObjects(track, api, coverBlob = null) {
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
const lyricsFetch = managers?.lyricsManager?.fetchLyrics?.(track.id, track)?.catch(console.error);
|
const lyricsFetch = managers?.lyricsManager?.fetchLyrics?.(track.id, track)?.catch(console.error);
|
||||||
|
|
||||||
return { _tagLib, coverFetch, lyricsFetch };
|
return { coverFetch, lyricsFetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -47,8 +38,6 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
|
||||||
*/
|
*/
|
||||||
const data = {};
|
const data = {};
|
||||||
|
|
||||||
const audioBuffer = await doTimedAsync('Get audio array buffer', () => audioBlob.arrayBuffer());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data.title = getTrackTitle(track);
|
data.title = getTrackTitle(track);
|
||||||
data.artist = getFullArtistString(track);
|
data.artist = getFullArtistString(track);
|
||||||
|
|
@ -117,16 +106,14 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
|
||||||
console.warn('Error setting lyrics metadata', track, e);
|
console.warn('Error setting lyrics metadata', track, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAudioBuffer = await addMetadataWithTagLib(audioBuffer, {
|
return await addMetadataWithTagLib(
|
||||||
|
audioBlob,
|
||||||
|
{
|
||||||
...data,
|
...data,
|
||||||
});
|
},
|
||||||
|
undefined,
|
||||||
return doTimed(
|
true,
|
||||||
'Create new audio blob',
|
true
|
||||||
() =>
|
|
||||||
new Blob([newAudioBuffer], {
|
|
||||||
type: audioBlob.type,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
@ -137,12 +124,12 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads metadata from a file
|
* Reads metadata from a file
|
||||||
* @param {File} file
|
* @param {Uint8Array | Blob | File | FileSystemFileHandle | FileSystemFileEntry} file
|
||||||
* @returns {Promise<Object>} Track metadata
|
* @returns {Promise<Object>} Track metadata
|
||||||
*/
|
*/
|
||||||
export async function readTrackMetadata(file, siblings = []) {
|
export async function readTrackMetadata(file, { filename = file?.name || 'Unknown Title', siblings } = {}) {
|
||||||
const metadata = {
|
const metadata = {
|
||||||
title: file.name.replace(/\.[^/.]+$/, ''),
|
title: filename?.replace(/\.[^/.]+$/, ''),
|
||||||
artists: [],
|
artists: [],
|
||||||
artist: { name: 'Unknown Artist' }, // For fallback/compatibility
|
artist: { name: 'Unknown Artist' }, // For fallback/compatibility
|
||||||
album: { title: 'Unknown Album', cover: 'assets/appicon.png', releaseDate: null },
|
album: { title: 'Unknown Album', cover: 'assets/appicon.png', releaseDate: null },
|
||||||
|
|
@ -152,16 +139,16 @@ export async function readTrackMetadata(file, siblings = []) {
|
||||||
explicit: false,
|
explicit: false,
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
file: file,
|
file: file,
|
||||||
id: `local-${file.name}-${file.lastModified}`,
|
id: `local-${filename}-${file.lastModified}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getMetadataWithTagLib(await file.arrayBuffer());
|
const data = await getMetadataWithTagLib(file, filename, true);
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
metadata.title = data.title || metadata.title;
|
metadata.title = data.title || metadata.title;
|
||||||
const artistNames = (data.artist || "")
|
const artistNames = (data.artist || '')
|
||||||
.split(";")
|
.split(';')
|
||||||
.map((a) => a.trim())
|
.map((a) => a.trim())
|
||||||
.filter((a) => a);
|
.filter((a) => a);
|
||||||
|
|
||||||
|
|
@ -175,7 +162,7 @@ export async function readTrackMetadata(file, siblings = []) {
|
||||||
|
|
||||||
if (data.albumArtist) {
|
if (data.albumArtist) {
|
||||||
metadata.album.artist = { name: data.albumArtist };
|
metadata.album.artist = { name: data.albumArtist };
|
||||||
} else if (metadata.artist.name !== "Unknown Artist") {
|
} else if (metadata.artist.name !== 'Unknown Artist') {
|
||||||
metadata.album.artist = { name: metadata.artist.name };
|
metadata.album.artist = { name: metadata.artist.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,11 +177,11 @@ export async function readTrackMetadata(file, siblings = []) {
|
||||||
metadata.explicit = !!data.explicit;
|
metadata.explicit = !!data.explicit;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Error reading metadata for', file.name, e);
|
console.warn('Error reading metadata for', filename, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadata.album.cover === 'assets/appicon.png' && siblings.length > 0) {
|
if (metadata.album.cover === 'assets/appicon.png' && siblings.length > 0) {
|
||||||
const baseName = file.name.substring(0, file.name.lastIndexOf('.'));
|
const baseName = filename.substring(0, filename.lastIndexOf('.'));
|
||||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||||
const coverFile = siblings.find((f) => {
|
const coverFile = siblings.find((f) => {
|
||||||
const fName = f.name;
|
const fName = f.name;
|
||||||
|
|
|
||||||
165
js/taglib.ts
165
js/taglib.ts
|
|
@ -1,38 +1,113 @@
|
||||||
import { TagLib } from 'taglib-wasm';
|
import { doTimed, doTimedAsync } from './doTimed';
|
||||||
import { fetchBlobURL } from './utils';
|
|
||||||
import _TagLibWasm from '!/taglib-wasm/dist/taglib-web.wasm?blob-url';
|
|
||||||
import type {
|
import type {
|
||||||
AddMetadataMessage,
|
AddMetadataMessage,
|
||||||
TagLibFileResponse,
|
TagLibFileResponse,
|
||||||
TagLibMetadataResponse,
|
TagLibMetadataResponse,
|
||||||
TagLibReadMetadata,
|
TagLibReadMetadata,
|
||||||
|
TagLibReadTypes,
|
||||||
|
TagLibWriteTypes,
|
||||||
} from './taglib.types';
|
} from './taglib.types';
|
||||||
import TagLibWorker from './taglib.worker?worker';
|
import TagLibWorker from './taglib.worker?worker';
|
||||||
|
|
||||||
let tagLib: Promise<TagLib> | null = null;
|
export async function withTimeout<T>(callback: () => Promise<T>, timeout: number): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error(`Operation timed out after ${timeout} ms`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
async function fetchTagLib(): Promise<string> {
|
callback()
|
||||||
return fetchTagLib.blobUrl || (fetchTagLib.blobUrl = await _TagLibWasm());
|
.then((result) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace fetchTagLib {
|
function toUint8Array(audioData: ArrayBufferLike | Uint8Array) {
|
||||||
export let blobUrl = '';
|
if (audioData instanceof Uint8Array) {
|
||||||
|
return audioData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { fetchTagLib };
|
return doTimed(
|
||||||
|
`Converting audio data (${(audioData as any)?.constructor?.name}) to Uint8Array`,
|
||||||
|
() => new Uint8Array(audioData)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertInputToTaglib<R = TagLibReadTypes>(
|
||||||
|
audioData: TagLibReadTypes | TagLibWriteTypes,
|
||||||
|
direct: boolean = false
|
||||||
|
): Promise<R> {
|
||||||
|
if ('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) {
|
||||||
|
audioData = await doTimedAsync('Getting File from FileSystemFileEntry', async () => {
|
||||||
|
const file = await new Promise<File>((resolve) =>
|
||||||
|
(audioData as FileSystemFileEntry).file((f) => resolve(f))
|
||||||
|
);
|
||||||
|
return toUint8Array(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((audioData instanceof Blob || audioData instanceof File) && !direct) {
|
||||||
|
return (await doTimedAsync(
|
||||||
|
`Reading ${audioData instanceof File ? 'File' : 'Blob'} as Uint8Array`,
|
||||||
|
async () => new Uint8Array(await audioData.arrayBuffer())
|
||||||
|
)) as R;
|
||||||
|
} else if ('FileSystemFileHandle' in globalThis && audioData instanceof FileSystemFileHandle && !direct) {
|
||||||
|
return (await doTimedAsync('Reading File from FileSystemHandle as Uint8Array', async () => {
|
||||||
|
const file = await audioData.getFile();
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
return await toUint8Array(arrayBuffer);
|
||||||
|
})) as R;
|
||||||
|
} else if (
|
||||||
|
!(audioData instanceof Uint8Array) &&
|
||||||
|
!(audioData instanceof Blob) &&
|
||||||
|
!(audioData instanceof File) &&
|
||||||
|
!('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) &&
|
||||||
|
!('FileSystemFileHandle' in globalThis && audioData instanceof FileSystemFileHandle)
|
||||||
|
) {
|
||||||
|
return toUint8Array(audioData as any) as R;
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioData as R;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerModule = import('./taglib.worker.js');
|
||||||
|
|
||||||
export async function addMetadataWithTagLib(
|
export async function addMetadataWithTagLib(
|
||||||
audioData: Uint8Array,
|
audioData: TagLibWriteTypes,
|
||||||
data: Omit<AddMetadataMessage, 'type' | 'wasmUrl' | 'audioData'>
|
data: Omit<AddMetadataMessage, 'type' | 'audioData'>,
|
||||||
|
filename?: string,
|
||||||
|
direct: boolean = false,
|
||||||
|
returnBlob: boolean = false,
|
||||||
|
timeout: number = 10000
|
||||||
) {
|
) {
|
||||||
if (!(audioData instanceof Uint8Array)) {
|
audioData = await convertInputToTaglib(audioData, direct);
|
||||||
audioData = new Uint8Array(audioData);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (direct) {
|
||||||
|
const { addMetadataToAudio } = await workerModule;
|
||||||
|
|
||||||
|
return await doTimedAsync('Adding metadata with taglib-ts (direct)', () =>
|
||||||
|
addMetadataToAudio({
|
||||||
|
...data,
|
||||||
|
filename,
|
||||||
|
audioData,
|
||||||
|
returnType: returnBlob && direct ? 'blob' : 'uint8array',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
const worker = new TagLibWorker();
|
const worker = new TagLibWorker();
|
||||||
const wasmUrl = await fetchTagLib();
|
|
||||||
|
|
||||||
return new Promise<Uint8Array>((resolve, reject) => {
|
try {
|
||||||
|
return await doTimedAsync(
|
||||||
|
'Adding metadata with taglib-ts (worker)',
|
||||||
|
async () =>
|
||||||
|
await withTimeout(
|
||||||
|
() =>
|
||||||
|
new Promise<Uint8Array>((resolve, reject) => {
|
||||||
worker.onmessage = (e: MessageEvent<TagLibFileResponse>) => {
|
worker.onmessage = (e: MessageEvent<TagLibFileResponse>) => {
|
||||||
const { data, error } = e.data;
|
const { data, error } = e.data;
|
||||||
|
|
||||||
|
|
@ -45,24 +120,48 @@ export async function addMetadataWithTagLib(
|
||||||
worker.onerror = reject;
|
worker.onerror = reject;
|
||||||
worker.onmessageerror = reject;
|
worker.onmessageerror = reject;
|
||||||
|
|
||||||
const transferables: Transferable[] = [audioData.buffer];
|
const transferables: Transferable[] = [];
|
||||||
|
if ((audioData as any)?.buffer instanceof ArrayBuffer) {
|
||||||
|
transferables.push((audioData as any).buffer);
|
||||||
|
}
|
||||||
|
|
||||||
if ((data as any).cover?.data?.buffer instanceof ArrayBuffer) {
|
if ((data as any).cover?.data?.buffer instanceof ArrayBuffer) {
|
||||||
transferables.push((data as any).cover.data.buffer);
|
transferables.push((data as any).cover.data.buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData }, transferables);
|
worker.postMessage({ ...data, type: 'Add', audioData, filename }, transferables);
|
||||||
});
|
}),
|
||||||
|
timeout
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
worker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMetadataWithTagLib(audioData: Uint8Array) {
|
export async function getMetadataWithTagLib(
|
||||||
if (!(audioData instanceof Uint8Array)) {
|
audioData: TagLibReadTypes,
|
||||||
audioData = new Uint8Array(audioData);
|
filename?: string,
|
||||||
}
|
direct: boolean = false,
|
||||||
|
timeout: number = 10000
|
||||||
|
) {
|
||||||
|
audioData = await convertInputToTaglib<TagLibReadTypes>(audioData, direct);
|
||||||
|
|
||||||
|
if (direct) {
|
||||||
|
const { getMetadataFromAudio } = await workerModule;
|
||||||
|
|
||||||
|
return await doTimedAsync('Getting metadata with taglib-ts (direct)', () =>
|
||||||
|
getMetadataFromAudio({ filename, audioData })
|
||||||
|
);
|
||||||
|
} else {
|
||||||
const worker = new TagLibWorker();
|
const worker = new TagLibWorker();
|
||||||
const wasmUrl = await fetchTagLib();
|
|
||||||
|
|
||||||
return new Promise<TagLibReadMetadata>((resolve, reject) => {
|
try {
|
||||||
|
return await doTimedAsync('Getting metadata with taglib-ts (worker)', () =>
|
||||||
|
withTimeout(
|
||||||
|
() =>
|
||||||
|
new Promise<TagLibReadMetadata>((resolve, reject) => {
|
||||||
worker.onmessage = (e: MessageEvent<TagLibMetadataResponse>) => {
|
worker.onmessage = (e: MessageEvent<TagLibMetadataResponse>) => {
|
||||||
const { data, error } = e.data;
|
const { data, error } = e.data;
|
||||||
|
|
||||||
|
|
@ -74,6 +173,18 @@ export async function getMetadataWithTagLib(audioData: Uint8Array) {
|
||||||
};
|
};
|
||||||
worker.onerror = reject;
|
worker.onerror = reject;
|
||||||
worker.onmessageerror = reject;
|
worker.onmessageerror = reject;
|
||||||
worker.postMessage({ type: 'Get', wasmUrl, audioData }, [audioData.buffer]);
|
|
||||||
});
|
const transferables: Transferable[] = [];
|
||||||
|
if ((audioData as any)?.buffer instanceof ArrayBuffer) {
|
||||||
|
transferables.push((audioData as any).buffer);
|
||||||
|
}
|
||||||
|
worker.postMessage({ type: 'Get', audioData, filename }, transferables);
|
||||||
|
}),
|
||||||
|
timeout
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
worker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
import type { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef';
|
||||||
|
|
||||||
export type TagLibWorkerMessageType = 'Add' | 'Get';
|
export type TagLibWorkerMessageType = 'Add' | 'Get';
|
||||||
|
|
||||||
export interface TagLibWorkerMessage {
|
export interface TagLibWorkerMessage<T = Uint8Array> {
|
||||||
type: TagLibWorkerMessageType;
|
type: TagLibWorkerMessageType;
|
||||||
wasmUrl: string;
|
audioData: T;
|
||||||
audioData: Uint8Array;
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagLibWorkerResponse<T> {
|
export interface TagLibWorkerResponse<T> {
|
||||||
|
|
@ -50,6 +52,19 @@ export type AddMetadataMessage = TagLibWorkerMessage & {
|
||||||
type: 'Add';
|
type: 'Add';
|
||||||
} & TagLibMetadata;
|
} & TagLibMetadata;
|
||||||
|
|
||||||
export type GetMetadataMessage = TagLibWorkerMessage & {
|
export type GetMetadataMessage = TagLibWorkerMessage<TagLibReadTypes> & {
|
||||||
type: 'Get';
|
type: 'Get';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TagLibReadTypes = Uint8Array | Blob | File | FileSystemFileHandle | FileSystemFileEntry;
|
||||||
|
export type TagLibWriteTypes = Uint8Array;
|
||||||
|
|
||||||
|
export type _AddMetadataMessage = Omit<AddMetadataMessage, 'audioData' | 'type'> & {
|
||||||
|
audioRef?: FileRef | null;
|
||||||
|
audioData?: Uint8Array;
|
||||||
|
returnType?: 'blob' | 'uint8array';
|
||||||
|
};
|
||||||
|
export type _GetMetadataMessage = Omit<GetMetadataMessage, 'audioData' | 'type'> & {
|
||||||
|
audioRef?: FileRef | null;
|
||||||
|
audioData?: TagLibReadTypes;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
// filepath: /workspaces/monochrome/js/taglib.worker.ts
|
// filepath: /workspaces/monochrome/js/taglib.worker.ts
|
||||||
declare var self: DedicatedWorkerGlobalScope;
|
declare var self: DedicatedWorkerGlobalScope;
|
||||||
|
|
||||||
import { TagLib, type PictureType } from 'taglib-wasm';
|
import { ByteVector } from '!/@dantheman827/taglib-ts/src/byteVector.js';
|
||||||
|
import { Mp4Tag, Mp4Item } from '!/@dantheman827/taglib-ts/src/mp4/mp4Tag.js';
|
||||||
|
import { Variant } from '!/@dantheman827/taglib-ts/src/toolkit/variant.js';
|
||||||
import { doTimed, doTimedAsync } from './doTimed';
|
import { doTimed, doTimedAsync } from './doTimed';
|
||||||
import type {
|
import type {
|
||||||
|
_AddMetadataMessage,
|
||||||
|
_GetMetadataMessage,
|
||||||
AddMetadataMessage,
|
AddMetadataMessage,
|
||||||
GetMetadataMessage,
|
GetMetadataMessage,
|
||||||
TagLibFileResponse,
|
TagLibFileResponse,
|
||||||
|
|
@ -13,15 +17,27 @@ import type {
|
||||||
TagLibWorkerMessage,
|
TagLibWorkerMessage,
|
||||||
TagLibWorkerResponse,
|
TagLibWorkerResponse,
|
||||||
} from './taglib.types';
|
} from './taglib.types';
|
||||||
|
import { File as TagLibFile } from '!/@dantheman827/taglib-ts/src/file.js';
|
||||||
|
import { FileRef } from '!/@dantheman827/taglib-ts/src/fileRef.js';
|
||||||
|
import { ChunkedByteVectorStream } from '!/@dantheman827/taglib-ts/src/toolkit/chunkedByteVectorStream.js';
|
||||||
|
import { ReadStyle } from '!/@dantheman827/taglib-ts/src/toolkit/types';
|
||||||
|
import { BlobStream } from '!/@dantheman827/taglib-ts/src/toolkit/blobStream.js';
|
||||||
|
import { FileSystemFileHandleStream } from '!/@dantheman827/taglib-ts/src/toolkit/fileSystemFileHandleStream.js';
|
||||||
|
|
||||||
const PICTURE_TYPE_VALUES = {
|
// Imported to ensure support is bundled in this chunk, even if not directly used
|
||||||
FrontCover: 3,
|
import { FlacFile } from '!/@dantheman827/taglib-ts/src/flac/flacFile.js';
|
||||||
};
|
import { MpegFile } from '!/@dantheman827/taglib-ts/src/mpeg/mpegFile.js';
|
||||||
|
import { Mp4File } from '!/@dantheman827/taglib-ts/src/mp4/mp4File.js';
|
||||||
|
import { OggFile } from '!/@dantheman827/taglib-ts/src/ogg/oggFile.js';
|
||||||
|
import { OggVorbisFile } from '!/@dantheman827/taglib-ts/src/ogg/vorbis/vorbisFile.js';
|
||||||
|
|
||||||
async function addMetadataToAudio(message: AddMetadataMessage): Promise<Uint8Array> {
|
export const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
|
||||||
|
|
||||||
|
export async function addMetadataToAudio(message: _AddMetadataMessage): Promise<Uint8Array | Blob> {
|
||||||
const {
|
const {
|
||||||
wasmUrl,
|
|
||||||
audioData,
|
audioData,
|
||||||
|
audioRef,
|
||||||
|
filename,
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
albumTitle,
|
albumTitle,
|
||||||
|
|
@ -38,226 +54,233 @@ async function addMetadataToAudio(message: AddMetadataMessage): Promise<Uint8Arr
|
||||||
isrc,
|
isrc,
|
||||||
explicit,
|
explicit,
|
||||||
lyrics,
|
lyrics,
|
||||||
|
returnType = 'uint8array',
|
||||||
} = message;
|
} = message;
|
||||||
|
|
||||||
const file = await doTimedAsync('Open file with taglib', async () => {
|
const ref =
|
||||||
const tagLib = await TagLib.initialize({
|
audioRef ??
|
||||||
wasmUrl: wasmUrl,
|
(await doTimedAsync(
|
||||||
});
|
`Opening file (${audioData.constructor.name})`,
|
||||||
return await tagLib.open(audioData);
|
async () => await getFileRefFromAudioData(audioData)
|
||||||
});
|
));
|
||||||
|
|
||||||
|
if (!ref || !ref.isValid) {
|
||||||
|
console.warn('taglib-ts: failed to open file');
|
||||||
|
return audioData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const underlying = ref.file();
|
||||||
|
const isMp4 = underlying instanceof Mp4File;
|
||||||
|
const isMpeg = underlying instanceof MpegFile;
|
||||||
|
const needsCombinedTrackDisc = isMp4 || isMpeg;
|
||||||
|
|
||||||
try {
|
|
||||||
doTimed('Tagging file', () => {
|
doTimed('Tagging file', () => {
|
||||||
const isMp4 = file.isMP4();
|
const props = ref.properties();
|
||||||
const media = file.audioProperties();
|
|
||||||
const needsCombinedTrackDisc = isMp4 || media.containerFormat.toLowerCase() === 'mp3';
|
|
||||||
|
|
||||||
if (title) {
|
if (title) props.replace('TITLE', [title]);
|
||||||
file.setProperty('TITLE', title);
|
if (artist) props.replace('ARTIST', [artist]);
|
||||||
}
|
if (albumTitle) props.replace('ALBUM', [albumTitle]);
|
||||||
|
if (albumArtist || artist) props.replace('ALBUMARTIST', [albumArtist || artist!]);
|
||||||
if (artist) {
|
|
||||||
file.setProperty('ARTIST', artist);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (albumTitle) {
|
|
||||||
file.setProperty('ALBUM', albumTitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
const _albumArtist = albumArtist || artist;
|
|
||||||
if (_albumArtist) {
|
|
||||||
file.setProperty('ALBUMARTIST', _albumArtist);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trackNumber) {
|
if (trackNumber) {
|
||||||
let trackString = String(trackNumber);
|
const trackStr =
|
||||||
|
needsCombinedTrackDisc && totalTracks ? `${trackNumber}/${totalTracks}` : String(trackNumber);
|
||||||
if (needsCombinedTrackDisc && trackNumber && totalTracks) {
|
props.replace('TRACKNUMBER', [trackStr]);
|
||||||
trackString = `${trackNumber}/${totalTracks}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsCombinedTrackDisc) {
|
|
||||||
file.setProperty('TRACKNUMBER', trackString);
|
|
||||||
} else {
|
|
||||||
file.setProperty('TRACKNUMBER', String(trackNumber));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsCombinedTrackDisc && totalTracks) {
|
if (!needsCombinedTrackDisc && totalTracks) {
|
||||||
file.setProperty('TRACKTOTAL', String(totalTracks));
|
props.replace('TRACKTOTAL', [String(totalTracks)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discNumber) {
|
if (discNumber) {
|
||||||
let discString = String(discNumber);
|
const discStr = needsCombinedTrackDisc && totalDiscs ? `${discNumber}/${totalDiscs}` : String(discNumber);
|
||||||
|
props.replace('DISCNUMBER', [discStr]);
|
||||||
if (needsCombinedTrackDisc && discNumber && totalDiscs) {
|
|
||||||
discString = `${discNumber}/${totalDiscs}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsCombinedTrackDisc) {
|
|
||||||
file.setProperty('DISCNUMBER', discString);
|
|
||||||
} else {
|
|
||||||
file.setProperty('DISCNUMBER', String(discNumber));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!needsCombinedTrackDisc && totalDiscs) {
|
if (!needsCombinedTrackDisc && totalDiscs) {
|
||||||
file.setProperty('DISCTOTAL', String(totalDiscs));
|
props.replace('DISCTOTAL', [String(totalDiscs)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bpm != null && Number.isFinite(bpm)) {
|
if (bpm != null && Number.isFinite(bpm)) {
|
||||||
file.setProperty('BPM', String(Math.round(bpm)));
|
props.replace('BPM', [String(Math.round(bpm))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replayGain) {
|
if (replayGain) {
|
||||||
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = replayGain;
|
const { albumReplayGain, albumPeakAmplitude, trackReplayGain, trackPeakAmplitude } = replayGain;
|
||||||
if (albumReplayGain) file.setProperty('REPLAYGAIN_ALBUM_GAIN', String(albumReplayGain));
|
if (albumReplayGain != null) props.replace('REPLAYGAIN_ALBUM_GAIN', [String(albumReplayGain)]);
|
||||||
if (albumPeakAmplitude) file.setProperty('REPLAYGAIN_ALBUM_PEAK', String(albumPeakAmplitude));
|
if (albumPeakAmplitude != null) props.replace('REPLAYGAIN_ALBUM_PEAK', [String(albumPeakAmplitude)]);
|
||||||
if (trackReplayGain) file.setProperty('REPLAYGAIN_TRACK_GAIN', String(trackReplayGain));
|
if (trackReplayGain != null) props.replace('REPLAYGAIN_TRACK_GAIN', [String(trackReplayGain)]);
|
||||||
if (trackPeakAmplitude) file.setProperty('REPLAYGAIN_TRACK_PEAK', String(trackPeakAmplitude));
|
if (trackPeakAmplitude != null) props.replace('REPLAYGAIN_TRACK_PEAK', [String(trackPeakAmplitude)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (releaseDate) {
|
if (releaseDate) {
|
||||||
try {
|
try {
|
||||||
const year = Number(releaseDate.split('-')[0]);
|
const year = Number(releaseDate.split('-')[0]);
|
||||||
if (!isNaN(year)) {
|
if (!isNaN(year)) props.replace('DATE', [String(year)]);
|
||||||
file.setProperty('DATE', String(year));
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid date, skip
|
// Invalid date, skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyright) {
|
if (copyright) props.replace('COPYRIGHT', [copyright]);
|
||||||
file.setProperty('COPYRIGHT', copyright);
|
if (isrc) props.replace('ISRC', [isrc]);
|
||||||
}
|
if (lyrics) props.replace('LYRICS', [lyrics.replace(/\r/g, '').replace(/\n/g, '\r\n')]);
|
||||||
|
|
||||||
if (isrc) {
|
|
||||||
file.setProperty('ISRC', isrc);
|
|
||||||
|
|
||||||
|
if (explicit !== undefined) {
|
||||||
if (isMp4) {
|
if (isMp4) {
|
||||||
file.setMP4Item('xid ', `:isrc:${isrc}`);
|
// rtng is a byte item — must be set directly on the Mp4Tag
|
||||||
}
|
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
|
||||||
}
|
mp4Tag.setItem('rtng', Mp4Item.fromByte(explicit ? 1 : 0));
|
||||||
|
|
||||||
if (explicit) {
|
|
||||||
if (isMp4) {
|
|
||||||
file.setMP4Item('rtng', '1');
|
|
||||||
} else {
|
} else {
|
||||||
file.setProperty('ITUNESADVISORY', '1');
|
props.replace('ITUNESADVISORY', [explicit ? '1' : '0']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lyrics) {
|
ref.setProperties(props);
|
||||||
file.setProperty('LYRICS', lyrics.replace(/\r/g, '').replace(/\n/g, '\r\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cover) {
|
if (cover) {
|
||||||
file.setPictures([
|
const pictureMap = new Map<string, Variant>();
|
||||||
{
|
pictureMap.set('data', Variant.fromByteVector(ByteVector.fromByteArray(cover.data)));
|
||||||
mimeType: cover.type,
|
pictureMap.set('mimeType', Variant.fromString(cover.type));
|
||||||
data: cover.data,
|
pictureMap.set('pictureType', Variant.fromInt(3)); // FrontCover
|
||||||
type: 'FrontCover',
|
ref.setComplexProperties('PICTURE', [pictureMap]);
|
||||||
description: 'Cover Art',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await doTimedAsync('Saving in-memory buffer', () => file.save());
|
await doTimedAsync('Saving in-memory buffer', async () => {
|
||||||
|
await ref.save();
|
||||||
|
});
|
||||||
|
|
||||||
return file.getFileBuffer();
|
const file = ref.file() as TagLibFile;
|
||||||
} catch (err) {
|
if (!file) return audioData;
|
||||||
console.error(err);
|
const stream = file.stream();
|
||||||
} finally {
|
|
||||||
file.dispose();
|
if (stream instanceof ChunkedByteVectorStream) {
|
||||||
|
const data = doTimed(
|
||||||
|
'Converting saved file to ' + (returnType == 'blob' ? 'Blob' : 'Uint8Array'),
|
||||||
|
() => stream.data().data
|
||||||
|
);
|
||||||
|
if (returnType === 'blob') {
|
||||||
|
const blob = new Blob([data as BlobPart], { type: 'application/octet-stream' });
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} else if (stream instanceof BlobStream) {
|
||||||
|
const blob = doTimed('Converting saved file to ' + (returnType == 'blob' ? 'Blob' : 'Uint8Array'), () =>
|
||||||
|
stream.toBlob()
|
||||||
|
);
|
||||||
|
if (returnType === 'blob') {
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
const arrayBuffer = await doTimed('Reading Blob as ArrayBuffer', async () => await blob.arrayBuffer());
|
||||||
|
return new Uint8Array(arrayBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.warn('taglib-ts: unexpected stream type after saving file', stream);
|
||||||
return audioData;
|
return audioData;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMetadataFromAudio(message: GetMetadataMessage): Promise<TagLibReadMetadata> {
|
export async function getMetadataFromAudio(message: _GetMetadataMessage): Promise<TagLibReadMetadata> {
|
||||||
const { wasmUrl, audioData } = message;
|
const { audioData, audioRef, filename } = message;
|
||||||
const data: TagLibReadMetadata = {
|
const data: TagLibReadMetadata = { duration: 0 };
|
||||||
duration: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const file = await doTimedAsync('Open file with taglib', async () => {
|
const ref =
|
||||||
const tagLib = await TagLib.initialize({
|
audioRef ??
|
||||||
wasmUrl: wasmUrl,
|
(await doTimedAsync(
|
||||||
});
|
`Opening file (${audioData.constructor.name})`,
|
||||||
return await tagLib.open(audioData);
|
async () => await getFileRefFromAudioData(audioData)
|
||||||
});
|
));
|
||||||
|
|
||||||
try {
|
if (!ref || !ref.isValid) return data;
|
||||||
const pictures = file.getPictures();
|
|
||||||
const isMp4 = file.isMP4();
|
|
||||||
const media = file.audioProperties();
|
|
||||||
|
|
||||||
data.duration = media.duration;
|
const underlying = ref.file();
|
||||||
|
const isMp4 = underlying instanceof Mp4File;
|
||||||
|
const ap = ref.audioProperties();
|
||||||
|
|
||||||
data.title = file.getProperty('TITLE') || undefined;
|
if (ap) data.duration = ap.lengthInSeconds;
|
||||||
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
|
const props = ref.properties();
|
||||||
.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.title = props.get('TITLE')?.[0] || undefined;
|
||||||
data.copyright = file.getProperty('COPYRIGHT') || undefined;
|
data.artist = props.get('ARTIST')?.[0] || undefined;
|
||||||
data.lyrics = file.getProperty('LYRICS') || undefined;
|
data.albumTitle = props.get('ALBUM')?.[0] || undefined;
|
||||||
data.releaseDate = file.getProperty('DATE') || undefined;
|
data.albumArtist = props.get('ALBUMARTIST')?.[0] || undefined;
|
||||||
|
|
||||||
const [replayGainAlbumGain, replayGainAlbumPeak, replayGainTrackGain, replayGainTrackPeak] = [
|
const trackStr = props.get('TRACKNUMBER')?.[0] ?? '';
|
||||||
file.getProperty('REPLAYGAIN_ALBUM_GAIN'),
|
const [trackNum, trackTotal] = trackStr.split('/').map((t) => Number(t.trim() || 0) || undefined);
|
||||||
file.getProperty('REPLAYGAIN_ALBUM_PEAK'),
|
data.trackNumber = trackNum || undefined;
|
||||||
file.getProperty('REPLAYGAIN_TRACK_GAIN'),
|
data.totalTracks = trackTotal ?? (Number(props.get('TRACKTOTAL')?.[0] || 0) || undefined);
|
||||||
file.getProperty('REPLAYGAIN_TRACK_PEAK'),
|
|
||||||
];
|
const discStr = props.get('DISCNUMBER')?.[0] ?? '';
|
||||||
|
const [discNum, discTotal] = discStr.split('/').map((t) => Number(t.trim() || 0) || undefined);
|
||||||
|
data.discNumber = discNum || undefined;
|
||||||
|
if (!data.totalDiscs) {
|
||||||
|
data.totalDiscs = discTotal ?? (Number(props.get('DISCTOTAL')?.[0] || 0) || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.bpm = Number(props.get('BPM')?.[0] || 0) || undefined;
|
||||||
|
data.copyright = props.get('COPYRIGHT')?.[0] || undefined;
|
||||||
|
data.lyrics = props.get('LYRICS')?.[0] || undefined;
|
||||||
|
data.releaseDate = props.get('DATE')?.[0] || undefined;
|
||||||
|
|
||||||
const replayGain: TagLibMetadata['replayGain'] = {};
|
const replayGain: TagLibMetadata['replayGain'] = {};
|
||||||
if (replayGainAlbumGain) replayGain.albumReplayGain = replayGainAlbumGain;
|
const albumGain = props.get('REPLAYGAIN_ALBUM_GAIN')?.[0];
|
||||||
if (replayGainAlbumPeak) replayGain.albumPeakAmplitude = Number(replayGainAlbumPeak);
|
const albumPeak = props.get('REPLAYGAIN_ALBUM_PEAK')?.[0];
|
||||||
if (replayGainTrackGain) replayGain.trackReplayGain = replayGainTrackGain;
|
const trackGain = props.get('REPLAYGAIN_TRACK_GAIN')?.[0];
|
||||||
if (replayGainTrackPeak) replayGain.trackPeakAmplitude = Number(replayGainTrackPeak);
|
const trackPeak = props.get('REPLAYGAIN_TRACK_PEAK')?.[0];
|
||||||
if (Object.keys(replayGain).length > 0) {
|
if (albumGain) replayGain.albumReplayGain = albumGain;
|
||||||
data.replayGain = replayGain;
|
if (albumPeak) replayGain.albumPeakAmplitude = Number(albumPeak);
|
||||||
|
if (trackGain) replayGain.trackReplayGain = trackGain;
|
||||||
|
if (trackPeak) replayGain.trackPeakAmplitude = Number(trackPeak);
|
||||||
|
if (Object.keys(replayGain).length > 0) data.replayGain = replayGain;
|
||||||
|
|
||||||
|
data.isrc = props.get('ISRC')?.[0] || undefined;
|
||||||
|
|
||||||
|
if (isMp4) {
|
||||||
|
const mp4Tag = (underlying as Mp4File).tag() as Mp4Tag;
|
||||||
|
data.explicit = mp4Tag.item('rtng')?.toByte() === 1;
|
||||||
|
} else {
|
||||||
|
data.explicit = props.get('ITUNESADVISORY')?.[0] === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
data.isrc = (isMp4 && file.getMP4Item('xid ')?.split(':').at(-1)) || file.getProperty('ISRC') || undefined;
|
const pictures = ref.complexProperties('PICTURE');
|
||||||
data.explicit = (isMp4 && file.getMP4Item('rtng') === '1') || file.getProperty('ITUNESADVISORY') === '1';
|
|
||||||
|
|
||||||
if (pictures.length > 0) {
|
if (pictures.length > 0) {
|
||||||
const picture = pictures.filter((p) => p.type === 'FrontCover')[0];
|
const pic = pictures[0];
|
||||||
if (picture) {
|
const picData = pic.get('data')?.toByteVector();
|
||||||
data.cover = {
|
const mimeType = pic.get('mimeType')?.toString() ?? '';
|
||||||
data: picture.data,
|
if (picData && picData.length > 0) {
|
||||||
type: picture.mimeType,
|
data.cover = { data: picData.data, type: mimeType };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
file.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getFileRefFromAudioData(
|
||||||
|
audioData: Uint8Array | Blob | File | FileSystemFileHandle | FileSystemFileEntry
|
||||||
|
): Promise<FileRef | null> {
|
||||||
|
if (audioData instanceof Blob || audioData instanceof File) {
|
||||||
|
const stream = new BlobStream(audioData);
|
||||||
|
return await FileRef.open(stream, true, ReadStyle.Average);
|
||||||
|
} else if (audioData instanceof FileSystemFileHandle) {
|
||||||
|
const stream = await FileSystemFileHandleStream.open(audioData, true);
|
||||||
|
return await FileRef.open(stream, true, ReadStyle.Average);
|
||||||
|
} else if ('FileSystemFileEntry' in globalThis && audioData instanceof FileSystemFileEntry) {
|
||||||
|
const file = await new Promise<File>((resolve) => audioData.file((f) => resolve(f)));
|
||||||
|
const stream = new BlobStream(file);
|
||||||
|
return await FileRef.open(stream, true, ReadStyle.Average);
|
||||||
|
} else if (audioData instanceof Uint8Array) {
|
||||||
|
const stream = new ChunkedByteVectorStream(audioData);
|
||||||
|
return await FileRef.open(stream, true, ReadStyle.Average);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported audio data type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWorker) {
|
||||||
self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
||||||
const transfer: Transferable[] = [event.data.audioData.buffer];
|
const transfer: Transferable[] = [];
|
||||||
|
if (event.data.audioData?.buffer instanceof ArrayBuffer) {
|
||||||
|
transfer.push(event.data.audioData.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.data.type) {
|
switch (event.data.type) {
|
||||||
case 'Add':
|
case 'Add':
|
||||||
|
|
@ -266,8 +289,14 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
|
const result = (await addMetadataToAudio({
|
||||||
|
...event.data,
|
||||||
|
returnType: 'uint8array',
|
||||||
|
} as _AddMetadataMessage)) as Uint8Array;
|
||||||
|
|
||||||
|
if (result.buffer !== event.data.audioData.buffer) {
|
||||||
transfer.push(result.buffer);
|
transfer.push(result.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
self.postMessage(
|
self.postMessage(
|
||||||
{
|
{
|
||||||
|
|
@ -289,7 +318,9 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
||||||
|
|
||||||
case 'Get':
|
case 'Get':
|
||||||
try {
|
try {
|
||||||
const result = await getMetadataFromAudio(event.data as GetMetadataMessage);
|
const result = await getMetadataFromAudio({
|
||||||
|
...event.data,
|
||||||
|
} as _GetMetadataMessage);
|
||||||
self.postMessage(
|
self.postMessage(
|
||||||
{
|
{
|
||||||
type: event.data.type,
|
type: event.data.type,
|
||||||
|
|
@ -309,3 +340,4 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
35
package-lock.json
generated
35
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
|
||||||
"@ffmpeg/core": "^0.12.10",
|
"@ffmpeg/core": "^0.12.10",
|
||||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
"@ffmpeg/util": "^0.12.2",
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
|
@ -27,7 +28,6 @@
|
||||||
"mime": "^4.1.0",
|
"mime": "^4.1.0",
|
||||||
"npm": "^11.11.1",
|
"npm": "^11.11.1",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
"taglib-wasm": "^1.0.5",
|
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1676,6 +1676,12 @@
|
||||||
"postcss-selector-parser": "^7.0.0"
|
"postcss-selector-parser": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dantheman827/taglib-ts": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
|
||||||
|
"integrity": "sha512-rvQOn9GDEj2sH4yV6oUTMMG9+rJbFG7tQkiP6/bhGJARg1Vmdy283j4YFCl+ubkqsMQ+UfAhEWSw5d5lfPVfwQ==",
|
||||||
|
"license": "LGPL-2.1-or-later"
|
||||||
|
},
|
||||||
"node_modules/@dual-bundle/import-meta-resolve": {
|
"node_modules/@dual-bundle/import-meta-resolve": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|
@ -3012,13 +3018,6 @@
|
||||||
"@lit-labs/ssr-dom-shim": "^1.5.0"
|
"@lit-labs/ssr-dom-shim": "^1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@msgpack/msgpack": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@neutralinojs/lib": {
|
"node_modules/@neutralinojs/lib": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -10307,24 +10306,6 @@
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/taglib-wasm": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@msgpack/msgpack": "^3.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.6.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": ">=4.5.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"typescript": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tcp-port-used": {
|
"node_modules/tcp-port-used": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|
@ -10550,7 +10531,7 @@
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
"serialize-javascript": "^7.0.3"
|
"serialize-javascript": "^7.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dantheman827/taglib-ts": "https://github.com/DanTheMan827/taglib-ts/archive/b4238b2627aceb97f58813258046f1259f68cab7.tar.gz",
|
||||||
"@ffmpeg/core": "^0.12.10",
|
"@ffmpeg/core": "^0.12.10",
|
||||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
"@ffmpeg/util": "^0.12.2",
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
|
@ -69,7 +70,6 @@
|
||||||
"mime": "^4.1.0",
|
"mime": "^4.1.0",
|
||||||
"npm": "^11.11.1",
|
"npm": "^11.11.1",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
"taglib-wasm": "^1.0.5",
|
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ export default defineConfig(({ mode }) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['pocketbase', '@ffmpeg/ffmpeg', '@ffmpeg/util', 'taglib-wasm'],
|
exclude: ['pocketbase', '@ffmpeg/ffmpeg', '@ffmpeg/util'],
|
||||||
external: ['taglib-wasm'],
|
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
fs: {
|
fs: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue