UNPKG

taglib-wasm

Version:

TagLib for TypeScript platforms: Deno, Node.js, Bun, Electron, browsers, and Cloudflare Workers

343 lines (342 loc) 11.3 kB
import { TagLib } from "./taglib.js"; import { updateTags } from "./simple.js"; import { getGlobalWorkerPool } from "./worker-pool.js"; function join(...paths) { return paths.filter((p) => p).join("/").replace(/\/+/g, "/"); } function extname(path) { const lastDot = path.lastIndexOf("."); if (lastDot === -1 || lastDot === path.length - 1) return ""; return path.slice(lastDot); } const DEFAULT_AUDIO_EXTENSIONS = [ ".mp3", ".m4a", ".mp4", ".flac", ".ogg", ".oga", ".opus", ".wav", ".wv", ".ape", ".mpc", ".tta", ".wma" ]; async function* walkDirectory(path, options = {}) { const { recursive = true, extensions = DEFAULT_AUDIO_EXTENSIONS } = options; if (typeof Deno !== "undefined") { for await (const entry of Deno.readDir(path)) { const fullPath = join(path, entry.name); if (entry.isDirectory && recursive) { yield* walkDirectory(fullPath, options); } else if (entry.isFile) { const ext = extname(entry.name).toLowerCase(); if (extensions.includes(ext)) { yield fullPath; } } } } else if (typeof globalThis.process !== "undefined" && globalThis.process.versions?.node) { const fs = await import("fs/promises"); const entries = await fs.readdir(path, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(path, entry.name); if (entry.isDirectory() && recursive) { yield* walkDirectory(fullPath, options); } else if (entry.isFile()) { const ext = extname(entry.name).toLowerCase(); if (extensions.includes(ext)) { yield fullPath; } } } } else if (typeof globalThis.process !== "undefined" && globalThis.process.versions?.bun) { const fs = await import("fs/promises"); const entries = await fs.readdir(path, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(path, entry.name); if (entry.isDirectory() && recursive) { yield* walkDirectory(fullPath, options); } else if (entry.isFile()) { const ext = extname(entry.name).toLowerCase(); if (extensions.includes(ext)) { yield fullPath; } } } } else { throw new Error("Directory scanning not supported in this runtime"); } } async function processBatch(files, processor, concurrency) { const results = []; for (let i = 0; i < files.length; i += concurrency) { const chunk = files.slice(i, i + concurrency); const chunkResults = await Promise.all( chunk.map((file) => processor(file)) ); results.push(...chunkResults); } return results; } async function scanFolder(folderPath, options = {}) { const startTime = Date.now(); const { maxFiles = Infinity, includeProperties = true, continueOnError = true, useWorkerPool = true, workerPool, onProgress } = options; const files = []; const errors = []; const filePaths = []; let fileCount = 0; for await (const filePath of walkDirectory(folderPath, options)) { filePaths.push(filePath); fileCount++; if (fileCount >= maxFiles) break; } const totalFound = filePaths.length; let processed = 0; const shouldUseWorkerPool = useWorkerPool && (workerPool || typeof Worker !== "undefined"); let pool = null; if (shouldUseWorkerPool) { pool = workerPool || getGlobalWorkerPool(); } const taglib = shouldUseWorkerPool ? null : await TagLib.initialize(); try { if (pool) { const batchSize = Math.min(50, filePaths.length); for (let i = 0; i < filePaths.length; i += batchSize) { const batch = filePaths.slice( i, Math.min(i + batchSize, filePaths.length) ); const batchPromises = batch.map(async (filePath) => { try { const [tags, properties, pictures] = await Promise.all([ pool.readTags(filePath), includeProperties ? pool.readProperties(filePath) : Promise.resolve(null), pool.readPictures(filePath) ]); const hasCoverArt = pictures.length > 0; const dynamics = {}; if (tags.REPLAYGAIN_TRACK_GAIN) { dynamics.replayGainTrackGain = tags.REPLAYGAIN_TRACK_GAIN; } if (tags.REPLAYGAIN_TRACK_PEAK) { dynamics.replayGainTrackPeak = tags.REPLAYGAIN_TRACK_PEAK; } if (tags.REPLAYGAIN_ALBUM_GAIN) { dynamics.replayGainAlbumGain = tags.REPLAYGAIN_ALBUM_GAIN; } if (tags.REPLAYGAIN_ALBUM_PEAK) { dynamics.replayGainAlbumPeak = tags.REPLAYGAIN_ALBUM_PEAK; } if (tags.ITUNNORM) { dynamics.appleSoundCheck = tags.ITUNNORM; } processed++; onProgress?.(processed, totalFound, filePath); return { path: filePath, tags, properties: properties || void 0, hasCoverArt, dynamics: Object.keys(dynamics).length > 0 ? dynamics : void 0 }; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); if (continueOnError) { errors.push({ path: filePath, error: err }); processed++; onProgress?.(processed, totalFound, filePath); return { path: filePath, tags: {}, error: err }; } else { throw err; } } }); const batchResults = await Promise.all(batchPromises); files.push(...batchResults.filter((r) => !r.error)); } } else { const processor = async (filePath) => { try { const audioFile = await taglib.open(filePath); try { const tags = audioFile.tag(); let properties; if (includeProperties) { const props = audioFile.audioProperties(); if (props) { properties = props; } } const pictures = audioFile.getPictures(); const hasCoverArt = pictures.length > 0; const dynamics = {}; const replayGainTrackGain = audioFile.getProperty( "REPLAYGAIN_TRACK_GAIN" ); if (replayGainTrackGain) { dynamics.replayGainTrackGain = replayGainTrackGain; } const replayGainTrackPeak = audioFile.getProperty( "REPLAYGAIN_TRACK_PEAK" ); if (replayGainTrackPeak) { dynamics.replayGainTrackPeak = replayGainTrackPeak; } const replayGainAlbumGain = audioFile.getProperty( "REPLAYGAIN_ALBUM_GAIN" ); if (replayGainAlbumGain) { dynamics.replayGainAlbumGain = replayGainAlbumGain; } const replayGainAlbumPeak = audioFile.getProperty( "REPLAYGAIN_ALBUM_PEAK" ); if (replayGainAlbumPeak) { dynamics.replayGainAlbumPeak = replayGainAlbumPeak; } let appleSoundCheck = audioFile.getProperty("ITUNNORM"); if (!appleSoundCheck && audioFile.isMP4()) { appleSoundCheck = audioFile.getMP4Item( "----:com.apple.iTunes:iTunNORM" ); } if (appleSoundCheck) dynamics.appleSoundCheck = appleSoundCheck; processed++; onProgress?.(processed, totalFound, filePath); return { path: filePath, tags, properties, hasCoverArt, dynamics: Object.keys(dynamics).length > 0 ? dynamics : void 0 }; } finally { audioFile.dispose(); } } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); if (continueOnError) { errors.push({ path: filePath, error: err }); processed++; onProgress?.(processed, totalFound, filePath); return { path: filePath, tags: {}, error: err }; } else { throw err; } } }; const concurrency = 4; const batchSize = concurrency * 10; for (let i = 0; i < filePaths.length; i += batchSize) { const batch = filePaths.slice( i, Math.min(i + batchSize, filePaths.length) ); const batchResults = await processBatch(batch, processor, concurrency); files.push(...batchResults.filter((r) => !r.error)); } } } finally { if (pool && !workerPool) { } } return { files, errors, totalFound, totalProcessed: processed, duration: Date.now() - startTime }; } async function updateFolderTags(updates, options = {}) { const startTime = Date.now(); const { continueOnError = true, concurrency = 4 } = options; let successful = 0; const failed = []; const processor = async (update) => { try { await updateTags(update.path, update.tags); successful++; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); if (continueOnError) { failed.push({ path: update.path, error: err }); } else { throw err; } } }; const batchSize = concurrency * 10; for (let i = 0; i < updates.length; i += batchSize) { const batch = updates.slice(i, Math.min(i + batchSize, updates.length)); await processBatch( batch.map((u) => u.path), async (path) => { const update = batch.find((u) => u.path === path); await processor(update); return { path, tags: {} }; }, concurrency ); } return { successful, failed, duration: Date.now() - startTime }; } async function findDuplicates(folderPath, criteria = ["artist", "title"]) { const result = await scanFolder(folderPath); const duplicates = /* @__PURE__ */ new Map(); for (const file of result.files) { const key = criteria.map((field) => file.tags[field] || "").filter((v) => v !== "").join("|"); if (key) { const group = duplicates.get(key) || []; group.push(file); duplicates.set(key, group); } } for (const [key, files] of duplicates.entries()) { if (files.length < 2) { duplicates.delete(key); } } return duplicates; } async function exportFolderMetadata(folderPath, outputPath, options) { const result = await scanFolder(folderPath, options); const data = { folder: folderPath, scanDate: (/* @__PURE__ */ new Date()).toISOString(), summary: { totalFiles: result.totalFound, processedFiles: result.totalProcessed, errors: result.errors.length, duration: result.duration }, files: result.files, errors: result.errors }; if (typeof Deno !== "undefined") { await Deno.writeTextFile(outputPath, JSON.stringify(data, null, 2)); } else if (typeof globalThis.process !== "undefined") { const fs = await import("fs/promises"); await fs.writeFile(outputPath, JSON.stringify(data, null, 2)); } } export { exportFolderMetadata, findDuplicates, scanFolder, updateFolderTags };