Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:

strategy:
matrix:
node-version: [16.x, 18.x, 20.x]
node-version: [16.x, 18.x, 20.x, 22.x]

steps:
- uses: actions/checkout@v2
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,23 @@ on:
release:
types: [published]

permissions:
id-token: write
contents: read

jobs:
build:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: '22.x'
registry-url: 'https://registry.npmjs.org'
- run: npm install -g npm@latest
- run: npm ci
- run: npm run build
env:
NODE_ENV: production
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm publish --access public
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The website is also open-sourced and can be viewed at the
* Read and write ID3v2 tags in MP4/M4A/M4V/MOV containers (via ID32 box)
* Read and write ID3v2 tags in AIFF/AIFC files (via ID3 chunk)
* Read and write ID3v2 tags in AAC/ADTS streams (prepended ID3v2)
* Partial reading from Blob/File without loading entire file into memory
* Supports unsynchronisation
* Standards compliant. See ~~[id3.org](http://id3.org)~~
[mutagen-specs.readthedocs.io](https://mutagen-specs.readthedocs.io/en/latest/id3/index.html)
Expand Down Expand Up @@ -130,6 +131,50 @@ async function main () {
main()
```

### Partial reading with `readBlob`

For read-only scenarios, you can read the metadata without loading the entire
file into memory. `MP3Tag.readBlob()` accepts a
[Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) (or `File`) and
uses `blob.slice()` to read only the bytes it needs.

In the browser:
```html
<input type="file" id="input-file" accept="audio/mpeg,audio/mp4,audio/x-m4a,audio/aiff,audio/aac">
<script>
const inputFile = document.getElementById('input-file')
inputFile.addEventListener('change', async function () {
for (const file of this.files) {
// Only reads headers + tag data, not the entire file
const tags = await MP3Tag.readBlob(file)
console.log(tags.title, tags.artist)
}
})
</script>
```

In Node.js (requires Node 19.8+):
```javascript
import { openAsBlob } from 'node:fs'
import MP3Tag from 'mp3tag.js'

const blob = await openAsBlob('/path/to/large-file.mp3')
const tags = await MP3Tag.readBlob(blob)
console.log(tags.title, tags.artist)
```

`readBlob` accepts the same options as `read`:
```javascript
const tags = await MP3Tag.readBlob(blob, {
id3v1: true,
id3v2: true,
mp4: true,
aiff: true,
aac: true,
unsupported: false
})
```

### MP4/M4A Support

mp3tag.js supports reading and writing ID3v2 tags embedded in MP4/M4A containers
Expand Down
53 changes: 26 additions & 27 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mp3tag.js",
"version": "3.14.1",
"version": "3.16.0",
"description": "MP3 tagging library written in pure JavaScript",
"keywords": [
"node",
Expand Down
24 changes: 20 additions & 4 deletions src/id3v2/parse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -325,12 +325,28 @@ export function etcoFrame (buffer, version) {
const view = new BufferView(buffer)
const format = view.getUint8(0)
const raw = view.getUint8(1, view.byteLength - 1)
const rview = new BufferView(Array.isArray(raw) ? raw : [raw])
const bytes = Array.isArray(raw) ? raw : [raw]
const rview = new BufferView(bytes)
const data = []
for (let i = 0; i < raw.length; i += 5) {
const event = rview.getUint8(i)
const time = rview.getUint32(i + 1)
let i = 0
while (i + 5 <= bytes.length) {
// Per ID3v2.4 §4.5: $FF means "one more byte of events follows"
// and "all the following bytes with the value $FF have the same
// function". So an event type may span multiple bytes: any number
// of leading $FF bytes followed by a single terminating byte
// (0x00–0xFE), then a 4-byte timestamp.
let typeEnd = i
while (typeEnd < bytes.length && bytes[typeEnd] === 0xff) typeEnd++
if (typeEnd >= bytes.length || typeEnd + 4 >= bytes.length) break
let event
if (typeEnd === i) {
event = rview.getUint8(i)
} else {
event = bytes.slice(i, typeEnd + 1)
}
const time = rview.getUint32(typeEnd + 1)
data.push({ event, time })
i = typeEnd + 5
}
return {
format,
Expand Down
39 changes: 35 additions & 4 deletions src/id3v2/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -542,10 +542,41 @@ export function etcoFrame (value, version, strict) {
throw new Error('Invalid timestamp format (should be 1 or 2)')
}
for (const { event, time } of value.data) {
if (typeof event !== 'number') {
throw new Error('Event is not a number')
} else if (event > 255 || event < 0) {
throw new Error('Event should be in range of 0 - 255')
// Per ID3v2.4 §4.5: events are either a single byte (0x00–0xFE)
// or, when 0xFF is used as escape prefix, a sequence of one or
// more 0xFF bytes followed by a single terminating byte. Accept
// either a number or an Array<number> of bytes.
// Structural checks (always on, including non-strict mode) — these
// rules are what make the byte stream parseable per §4.5. Emitting a
// bare 0xFF as a single-byte event, or an extended-event array whose
// prefix bytes aren't 0xFF, produces a malformed frame that no
// compliant reader can decode.
if (typeof event === 'number') {
if (event > 255 || event < 0) {
throw new Error('Event should be in range of 0 - 255')
}
if (event === 0xff) {
throw new Error('Event 0xFF is the escape byte (§4.5); use an array of bytes for extended events')
}
} else if (Array.isArray(event)) {
if (event.length === 0) {
throw new Error('Event array must not be empty')
}
for (const byte of event) {
if (typeof byte !== 'number' || byte > 255 || byte < 0) {
throw new Error('Event byte should be a number in range of 0 - 255')
}
}
for (let i = 0; i < event.length - 1; i++) {
if (event[i] !== 0xff) {
throw new Error('Extended event prefix bytes must all be 0xFF (§4.5)')
}
}
if (event[event.length - 1] === 0xff) {
throw new Error('Extended event terminator byte must not be 0xFF (§4.5)')
}
} else {
throw new Error('Event is not a number or an array of bytes')
}

if (typeof time !== 'number') {
Expand Down
8 changes: 7 additions & 1 deletion src/id3v2/write.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,13 @@ export function sytcFrame (value, options) {
export function etcoFrame (value, options) {
const { id, version, unsynch } = options

const array = value.data.flatMap(({ event, time }) => [event, ...timeBytes(time)])
// Per ID3v2.4 §4.5, event types may be multi-byte: any number of
// leading $FF bytes followed by a single terminating byte. Accept
// either a number (single byte) or an array of bytes.
const array = value.data.flatMap(({ event, time }) => {
const bytes = Array.isArray(event) ? event : [event]
return [...bytes, ...timeBytes(time)]
})
let data = mergeBytes(value.format, array)
if (unsynch) data = unsynchData(data, version)

Expand Down
Loading
Loading