diff --git a/src/utils/metadata.d.ts b/src/utils/metadata.d.ts index 2702ff4..1e146da 100644 --- a/src/utils/metadata.d.ts +++ b/src/utils/metadata.d.ts @@ -6,6 +6,7 @@ export interface FileMetadataResult { detectedMarkers: string[]; provenanceRisk: 'High' | 'Low'; raw: unknown; + parseError?: string | null; } export function readFileMetadata(file: File): Promise; diff --git a/src/utils/metadata.js b/src/utils/metadata.js index a921079..36cb772 100644 --- a/src/utils/metadata.js +++ b/src/utils/metadata.js @@ -1,9 +1,37 @@ -import { parseBlob } from 'music-metadata-browser'; import ID3Writer from 'browser-id3-writer'; const AI_MARKERS = ['ai','generated','suno','udio','boomy','aiva','soundraw','mubert','stable audio','provenance','c2pa','content credentials','watermark','synthetic','elevenlabs']; +const MARKER_REGEX_CACHE = new Map(); -function collectStrings(metadata) { +let parseBlobLoader = null; + +async function getParseBlob() { + if (parseBlobLoader) return parseBlobLoader; + parseBlobLoader = import('music-metadata-browser').then((mod) => { + const fn = mod?.parseBlob || mod?.default?.parseBlob; + if (typeof fn !== 'function') { + throw new Error('music-metadata-browser parseBlob export not found'); + } + return fn; + }); + return parseBlobLoader; +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function markerToRegex(marker) { + if (MARKER_REGEX_CACHE.has(marker)) return MARKER_REGEX_CACHE.get(marker); + const escaped = escapeRegex(marker); + const regex = marker.length <= 2 + ? new RegExp(`\\b${escaped}\\b`, 'i') + : new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i'); + MARKER_REGEX_CACHE.set(marker, regex); + return regex; +} + +function collectStrings(metadata, fileName = '') { const common = metadata?.common || {}; const native = metadata?.native || {}; const values = [common.title,common.artist,common.album,...(common.genre || []),...(common.comment || []),common.encodedby,common.publisher] @@ -16,21 +44,32 @@ function collectStrings(metadata) { if (frame?.value && typeof frame.value === 'object') values.push(JSON.stringify(frame.value)); }); }); + if (fileName) values.push(String(fileName)); return values.join(' | ').toLowerCase(); } export async function readFileMetadata(file) { - const parsed = await parseBlob(file); - const searchable = collectStrings(parsed); - const detectedMarkers = AI_MARKERS.filter(marker => searchable.includes(marker)); + let parsed = null; + let parseError = null; + + try { + const parseBlob = await getParseBlob(); + parsed = await parseBlob(file); + } catch (error) { + parseError = error; + } + + const searchable = collectStrings(parsed, file?.name || ''); + const detectedMarkers = AI_MARKERS.filter((marker) => markerToRegex(marker).test(searchable)); return { - format: parsed.format?.container || file.type || 'unknown', - title: parsed.common?.title || file.name.replace(/\.[^.]+$/, ''), - artist: parsed.common?.artist || '', - genre: parsed.common?.genre?.[0] || '', + format: parsed?.format?.container || file.type || 'unknown', + title: parsed?.common?.title || file.name.replace(/\.[^.]+$/, ''), + artist: parsed?.common?.artist || '', + genre: parsed?.common?.genre?.[0] || '', detectedMarkers, provenanceRisk: detectedMarkers.length > 0 ? 'High' : 'Low', raw: parsed, + parseError: parseError ? String(parseError?.message || parseError) : null, }; } @@ -38,10 +77,20 @@ export async function writeMP3Metadata(file, metadata) { const buffer = await file.arrayBuffer(); const writer = new ID3Writer(buffer); writer.removeTag(); - if (metadata.title) writer.setFrame('TIT2', metadata.title); - if (metadata.artist) writer.setFrame('TPE1', [metadata.artist]); - if (metadata.genre) writer.setFrame('TCON', [metadata.genre]); - writer.setFrame('TENC', 'SpectraCleanseAI Browser Quick Cleanse'); + + const safeText = (value) => { + if (typeof value !== 'string') return ''; + return value.replace(/\u0000/g, '').trim().slice(0, 500); + }; + + const title = safeText(metadata?.title); + const artist = safeText(metadata?.artist); + const genre = safeText(metadata?.genre); + + if (title) writer.setFrame('TIT2', title); + if (artist) writer.setFrame('TPE1', [artist]); + if (genre) writer.setFrame('TCON', [genre]); + if (title || artist || genre) writer.setFrame('TENC', 'SpectraCleanseAI Browser Quick Cleanse'); writer.addTag(); return new Blob([writer.getBlob()], { type: 'audio/mpeg' }); }