funoteka 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import { inflateSync } from 'node:zlib';
|
|
2
|
+
import { decodeId3Text, looksLikeText } from "../text/encoding.js";
|
|
3
|
+
import { resolveGenre } from "./genres.js";
|
|
4
|
+
import { betterPicture, noTags, weakestEncoding } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Read an ID3v2 tag.
|
|
7
|
+
*
|
|
8
|
+
* Written against the specification: `id3v2.3.0` and `id3v2-00` for the two
|
|
9
|
+
* earlier versions, `id3v2.4.0-structure` and `id3v2.4.0-frames` for v2.4 —
|
|
10
|
+
* which renumbered its sections and grouped its frames differently, so a
|
|
11
|
+
* citation below names its version where the numbers could be confused. Every
|
|
12
|
+
* rule here has a section beside it, because this reader was first written from
|
|
13
|
+
* memory of the format, and the v2.2 ids record what that cost.
|
|
14
|
+
*
|
|
15
|
+
* What is kept is what the standard calls a text frame — §4.2: "All text frame
|
|
16
|
+
* identifiers begin with 'T'. Only text frame identifiers begin with 'T', with
|
|
17
|
+
* the exception of the TXXX frame" — together with the comment and the frames a
|
|
18
|
+
* date is split across. Everything else (pictures, lyrics, replay gain) is
|
|
19
|
+
* stepped over by its own size, which is why a tag full of artwork still yields
|
|
20
|
+
* its titles. The set is the standard's rather than this collection's on
|
|
21
|
+
* purpose: the table used to hold nine frames and a FLAC beside it kept every
|
|
22
|
+
* field its comment block named, so the container decided which tags existed
|
|
23
|
+
* (task:2737).
|
|
24
|
+
*
|
|
25
|
+
* Three things make this parser more than a loop. Frame sizes changed meaning
|
|
26
|
+
* between v2.3 and v2.4 — plain big-endian in one, seven bits per byte in the
|
|
27
|
+
* other (§3.3 against §4) — so a reader that assumes either one misreads the
|
|
28
|
+
* other's long frames. The text decoders are handed the frame's declared
|
|
29
|
+
* encoding byte as a hint, never as the answer, because the declaration is
|
|
30
|
+
* routinely a lie. And v2.4 frames may be unsynchronised, which is not a detail
|
|
31
|
+
* the text decoders can absorb: the escaping has to come off before anything
|
|
32
|
+
* reads the bytes.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Frame id -> the name its text is stored under.
|
|
36
|
+
*
|
|
37
|
+
* Both spellings of an id are here, because v2.2 names a frame with **three**
|
|
38
|
+
* letters — `TP1` for the artist, `TAL` for the album — where v2.3 and v2.4 use
|
|
39
|
+
* four. Read as a four-letter id, `TP1` followed by the first byte of its size
|
|
40
|
+
* is not a frame id at all, so the walk stopped at the first frame and a file
|
|
41
|
+
* with a complete tag was read as having none. Two thirds of the operator's
|
|
42
|
+
* Kroogi rips are written this way by iTunes 10, whose tags ffprobe reads
|
|
43
|
+
* without complaint.
|
|
44
|
+
*
|
|
45
|
+
* The nine names this table used to hold were the whole of what the reader kept,
|
|
46
|
+
* and the collection says what that cost: 116 files carry a copyright, 62 a
|
|
47
|
+
* comment, 16 a language, and a FLAC in the same library keeps *every* field its
|
|
48
|
+
* comment block holds — so the container decided which tags existed, and the
|
|
49
|
+
* meta layer could not be read across the two (task:2737).
|
|
50
|
+
*
|
|
51
|
+
* What replaces the list is the standard's own rule, §4.2: "All text frame
|
|
52
|
+
* identifiers begin with 'T'. Only text frame identifiers begin with 'T', with
|
|
53
|
+
* the exception of the TXXX frame." The set is therefore the standard's rather
|
|
54
|
+
* than this collection's, and a text frame the table has no word for is kept
|
|
55
|
+
* under its own identifier, lowercased, rather than dropped — see `nameOf`.
|
|
56
|
+
*/
|
|
57
|
+
const TEXT_FRAME_NAMES = {
|
|
58
|
+
// The names the meta layer already answers by and a client already reads.
|
|
59
|
+
// They are product names rather than the standard's words — §4.2.1 calls TPE1
|
|
60
|
+
// "Lead performer(s)/Soloist(s)" and TCON "Content type" — and they stay as
|
|
61
|
+
// they are, because queries are written against them.
|
|
62
|
+
TIT2: 'title', TT2: 'title',
|
|
63
|
+
TPE1: 'artist', TP1: 'artist',
|
|
64
|
+
TPE2: 'albumartist', TP2: 'albumartist',
|
|
65
|
+
TALB: 'album', TAL: 'album',
|
|
66
|
+
TRCK: 'tracknumber', TRK: 'tracknumber',
|
|
67
|
+
TPOS: 'discnumber', TPA: 'discnumber',
|
|
68
|
+
TCON: 'genre', TCO: 'genre',
|
|
69
|
+
// Everything else §4.2.1 names, under its own word, with v2.2's spelling of
|
|
70
|
+
// the same frame beside it. The three-letter ids are the set §4.2.1 of v2.2
|
|
71
|
+
// defines and no wider: v2.2 has no frame for a file's owner or for an
|
|
72
|
+
// internet radio station at all, so there is no v2.2 spelling for `TOWN`,
|
|
73
|
+
// `TRSN` or `TRSO` below.
|
|
74
|
+
TBPM: 'bpm', TBP: 'bpm',
|
|
75
|
+
TCOM: 'composer', TCM: 'composer',
|
|
76
|
+
TCOP: 'copyright', TCR: 'copyright',
|
|
77
|
+
TDLY: 'playlistdelay', TDY: 'playlistdelay',
|
|
78
|
+
TENC: 'encodedby', TEN: 'encodedby',
|
|
79
|
+
TEXT: 'lyricist', TXT: 'lyricist',
|
|
80
|
+
TFLT: 'filetype', TFT: 'filetype',
|
|
81
|
+
TIT1: 'contentgroup', TT1: 'contentgroup',
|
|
82
|
+
TIT3: 'subtitle', TT3: 'subtitle',
|
|
83
|
+
TKEY: 'initialkey', TKE: 'initialkey',
|
|
84
|
+
TLAN: 'language', TLA: 'language',
|
|
85
|
+
TLEN: 'length', TLE: 'length',
|
|
86
|
+
TMED: 'mediatype', TMT: 'mediatype',
|
|
87
|
+
TOAL: 'originalalbum', TOT: 'originalalbum',
|
|
88
|
+
TOFN: 'originalfilename', TOF: 'originalfilename',
|
|
89
|
+
TOLY: 'originallyricist', TOL: 'originallyricist',
|
|
90
|
+
TOPE: 'originalartist', TOA: 'originalartist',
|
|
91
|
+
TORY: 'originalreleaseyear', TOR: 'originalreleaseyear',
|
|
92
|
+
TOWN: 'fileowner',
|
|
93
|
+
TPE3: 'conductor', TP3: 'conductor',
|
|
94
|
+
TPE4: 'remixedby', TP4: 'remixedby',
|
|
95
|
+
TPUB: 'publisher', TPB: 'publisher',
|
|
96
|
+
TRDA: 'recordingdates', TRD: 'recordingdates',
|
|
97
|
+
TRSN: 'radiostation',
|
|
98
|
+
TRSO: 'radiostationowner',
|
|
99
|
+
TSIZ: 'size', TSI: 'size',
|
|
100
|
+
TSRC: 'isrc', TRC: 'isrc',
|
|
101
|
+
TSSE: 'encodersettings', TSS: 'encodersettings',
|
|
102
|
+
// `TDRC` is v2.4's replacement for `TYER`, and it carries a whole timestamp
|
|
103
|
+
// rather than a year. The older date frames are deliberately not here: §4.2.1
|
|
104
|
+
// splits the date across `TYER` and `TDAT` (v2.2's `TYE`, `TDA` and `TIM`),
|
|
105
|
+
// and one name cannot hold both halves without making which is which a coin
|
|
106
|
+
// toss. They are put together into one `date` after the walk — see `dated`.
|
|
107
|
+
TDRC: 'date',
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* What this reader calls a frame, or undefined when the frame is not its to keep.
|
|
111
|
+
*
|
|
112
|
+
* `TXXX` is excluded because its own description names it — and so is `TXX`,
|
|
113
|
+
* which is the same frame in v2.2's three-letter spelling (§4.2.2 of both
|
|
114
|
+
* documents). Missing that one cost exactly what this table exists to prevent: a
|
|
115
|
+
* v2.2 tag holding `ALBUMARTIST` read back as two rows named `txx`, one of them
|
|
116
|
+
* the *description* standing where a value belongs, and the artist the frame
|
|
117
|
+
* named never reaching the stage that looks for it (task:2851, both axes).
|
|
118
|
+
*
|
|
119
|
+
* The frames that carry no text at all — `PRIV`, `GEOB`, `POPM`, `UFID` — are
|
|
120
|
+
* not here either. None of them was ever a name, and a value read out of an
|
|
121
|
+
* opaque body would be an invention in a table of names, which is the failure
|
|
122
|
+
* this reader exists to avoid. The picture frames are `pictureIn`'s business.
|
|
123
|
+
*/
|
|
124
|
+
function nameOf(id) {
|
|
125
|
+
if (id === 'TXXX' || id === 'TXX')
|
|
126
|
+
return undefined;
|
|
127
|
+
return TEXT_FRAME_NAMES[id] ?? (id.startsWith('T') ? id.toLowerCase() : undefined);
|
|
128
|
+
}
|
|
129
|
+
function readUInt24BE(bytes, at) {
|
|
130
|
+
return (((bytes[at] ?? 0) << 16) | ((bytes[at + 1] ?? 0) << 8) | (bytes[at + 2] ?? 0)) >>> 0;
|
|
131
|
+
}
|
|
132
|
+
function readUInt32BE(bytes, at) {
|
|
133
|
+
return ((((bytes[at] ?? 0) << 24) |
|
|
134
|
+
((bytes[at + 1] ?? 0) << 16) |
|
|
135
|
+
((bytes[at + 2] ?? 0) << 8) |
|
|
136
|
+
(bytes[at + 3] ?? 0)) >>>
|
|
137
|
+
0);
|
|
138
|
+
}
|
|
139
|
+
/** Seven bits per byte, which is how every size inside an ID3v2 tag is written. */
|
|
140
|
+
function synchsafeAt(bytes, at) {
|
|
141
|
+
return ((((bytes[at] ?? 0) & 0x7f) << 21) |
|
|
142
|
+
(((bytes[at + 1] ?? 0) & 0x7f) << 14) |
|
|
143
|
+
(((bytes[at + 2] ?? 0) & 0x7f) << 7) |
|
|
144
|
+
((bytes[at + 3] ?? 0) & 0x7f));
|
|
145
|
+
}
|
|
146
|
+
/** A frame id is upper-case letters or digits — three of them in v2.2, four
|
|
147
|
+
* after it. Zeros mean padding, and anything else is not a frame. */
|
|
148
|
+
function asciiFrameId(bytes, at, width) {
|
|
149
|
+
let out = '';
|
|
150
|
+
for (let i = 0; i < width; i += 1) {
|
|
151
|
+
const byte = bytes[at + i] ?? 0;
|
|
152
|
+
if (!((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39)))
|
|
153
|
+
return '';
|
|
154
|
+
out += String.fromCharCode(byte);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Undo the unsynchronisation a writer applied.
|
|
160
|
+
*
|
|
161
|
+
* A writer escapes any `FF` that a reader could take for the start of a frame
|
|
162
|
+
* sync, so what is stored is never the original: `FF 00` was written `FF 00 00`
|
|
163
|
+
* and `FF Ex` was written `FF 00 Ex`. Three bytes therefore settle every case —
|
|
164
|
+
* `FF 00 00` is a real `FF 00`, `FF 00 x` is a real `FF x` — and that middle
|
|
165
|
+
* rule is what keeps a UTF-16 letter like `ÿ` (whose bytes *are* `FF 00`) from
|
|
166
|
+
* being eaten down to a lone `FF`.
|
|
167
|
+
*
|
|
168
|
+
* The same three bytes undo every version, but not in the same place, and that
|
|
169
|
+
* is the whole of what a caller has to get right. v2.4 escapes frame by frame
|
|
170
|
+
* and its frame sizes count what is on disk, so the decoding happens per frame,
|
|
171
|
+
* in `frameData` — and decoding the whole tag instead is what
|
|
172
|
+
* `id3v2.4.0-changes` §3 warns against: "Resynchronisation of the complete tag
|
|
173
|
+
* when the unsynchronisation flag in the tag header is set might result in a
|
|
174
|
+
* corrupt tag". v2.2 and v2.3 escape the tag as it stands (§5 applies the scheme
|
|
175
|
+
* after compression; §3.2 of v2.3 has the CRC computed on the frames *before*
|
|
176
|
+
* it), so their frame sizes count the clean bytes and the whole body is decoded
|
|
177
|
+
* once, in `readId3v2`, before the walk begins.
|
|
178
|
+
*/
|
|
179
|
+
function deunsynchronise(bytes) {
|
|
180
|
+
// The common case is a frame with nothing to escape, and it is worth not
|
|
181
|
+
// copying for: the frame is handed back as it is.
|
|
182
|
+
let escaped = false;
|
|
183
|
+
for (let i = 0; i + 1 < bytes.length; i += 1) {
|
|
184
|
+
if (bytes[i] === 0xff && bytes[i + 1] === 0x00) {
|
|
185
|
+
escaped = true;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!escaped)
|
|
190
|
+
return bytes;
|
|
191
|
+
const out = new Uint8Array(bytes.length);
|
|
192
|
+
let at = 0;
|
|
193
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
194
|
+
const byte = bytes[i] ?? 0;
|
|
195
|
+
out[at] = byte;
|
|
196
|
+
at += 1;
|
|
197
|
+
if (byte !== 0xff || bytes[i + 1] !== 0x00)
|
|
198
|
+
continue;
|
|
199
|
+
if (bytes[i + 2] === 0x00) {
|
|
200
|
+
out[at] = 0x00; // a real `FF 00`, which the writer had to double
|
|
201
|
+
at += 1;
|
|
202
|
+
i += 2;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
i += 1; // a real `FF`, which the writer had to fence off
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out.subarray(0, at);
|
|
209
|
+
}
|
|
210
|
+
/** Where a description ends and its value begins, per the encoding's terminator width. */
|
|
211
|
+
function splitTerminated(body, encoding) {
|
|
212
|
+
const width = encoding === 1 || encoding === 2 ? 2 : 1;
|
|
213
|
+
for (let i = 0; i + width <= body.length; i += width) {
|
|
214
|
+
if (body[i] !== 0)
|
|
215
|
+
continue;
|
|
216
|
+
if (width === 1 || body[i + 1] === 0) {
|
|
217
|
+
return [body.subarray(0, i), body.subarray(i + width)];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return [body, body.subarray(body.length)];
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Push a decoded value, split on any embedded NUL.
|
|
224
|
+
*
|
|
225
|
+
* §4.2 of v2.4: "All text information frames supports multiple strings, stored
|
|
226
|
+
* as a null separated list, where null is represented by the termination code
|
|
227
|
+
* for the character encoding" — which after decoding is a plain NUL at every
|
|
228
|
+
* width. So a reader that kept the frame whole would hand back a single string
|
|
229
|
+
* holding control characters, and two artists as one name.
|
|
230
|
+
*
|
|
231
|
+
* Worth knowing before trusting this: the split never runs on a value that is
|
|
232
|
+
* mostly control characters, because `looksLikeText` refuses those first, and a
|
|
233
|
+
* short multi-value frame — v2.4's own example is "21" $00 "Eurodisco" — is
|
|
234
|
+
* mostly separator by that measure. Not one of the 700 mp3 files in the
|
|
235
|
+
* collection carries such a frame once its unsynchronisation is removed, so the
|
|
236
|
+
* two rules have yet to meet outside a fixture.
|
|
237
|
+
*
|
|
238
|
+
* `resolve` is applied to each value after the split, because the values of a
|
|
239
|
+
* frame are separate statements: a genre frame holding a reference and a
|
|
240
|
+
* refinement holds two of them, and resolving the pair together would answer
|
|
241
|
+
* for one with the other.
|
|
242
|
+
*/
|
|
243
|
+
function pushValues(into, name, decoded, resolve = (value) => value) {
|
|
244
|
+
// The verdict is kept even when the value is not kept. A malformed frame is
|
|
245
|
+
// a finding, and a reader that swallowed it whole would leave "why is this
|
|
246
|
+
// album unnamed?" with no answer anywhere in the meta layer.
|
|
247
|
+
into.encoding = weakestEncoding(into.encoding, decoded);
|
|
248
|
+
// ...but a value that is not text is not stored. Splitting such a body on its
|
|
249
|
+
// NULs yields one "tag" per character, and those fragments then outrank a
|
|
250
|
+
// folder name that was readable all along.
|
|
251
|
+
// The judgement is made of the parts, and not of the body they were split
|
|
252
|
+
// from — which is the same judgement, because the harm named above *is* the
|
|
253
|
+
// fragments. Section 4.2 says a text frame's NULs are separators, so a body
|
|
254
|
+
// judged whole counts a legitimate separator against itself: `21` and
|
|
255
|
+
// `Eurodisco` with a NUL between them is one control in thirteen bytes, and
|
|
256
|
+
// the guard threw the value away before ever splitting it (task:2738). What
|
|
257
|
+
// it is really looking for is what a misread UTF-16 body produces — every
|
|
258
|
+
// part one character long — and that is asked of the parts directly.
|
|
259
|
+
const parts = decoded.text
|
|
260
|
+
.split('\u0000')
|
|
261
|
+
.map((part) => part.trim())
|
|
262
|
+
.filter((part) => part !== '');
|
|
263
|
+
const readable = parts.filter((part) => looksLikeText(part));
|
|
264
|
+
if (readable.length === 0)
|
|
265
|
+
return;
|
|
266
|
+
// Every part a single character is not a list of values: it is a body read in
|
|
267
|
+
// the wrong width. `A`, `B`, `C` with NULs between them is `ABC` in UTF-16LE,
|
|
268
|
+
// and one tag per letter would outrank the folder name as the note above says.
|
|
269
|
+
// A majority rather than every one, because a misread body does not end cleanly:
|
|
270
|
+
// its last fragment is whatever is left over. The guard above is a ratio for the
|
|
271
|
+
// same reason.
|
|
272
|
+
const oneChar = readable.filter((part) => [...part].length === 1).length;
|
|
273
|
+
if (readable.length > 1 && oneChar * 2 > readable.length)
|
|
274
|
+
return;
|
|
275
|
+
for (const part of readable)
|
|
276
|
+
into.tags.push({ name, value: resolve(part) });
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* What a frame's value means, beyond the bytes it is written in.
|
|
280
|
+
*
|
|
281
|
+
* Only the genre has one: section 4.2.1 lets it be a reference to the ID3v1
|
|
282
|
+
* list rather than a word, which is a second encoding of the same value beside
|
|
283
|
+
* the text encoding every frame already carries. Nothing else here reads as
|
|
284
|
+
* anything but itself, and inventing an interpretation for the rest would be
|
|
285
|
+
* deciding what a title is in a stage that only reads.
|
|
286
|
+
*/
|
|
287
|
+
const RESOLVERS = {
|
|
288
|
+
genre: resolveGenre,
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* The picture in an `APIC` frame, or in the `PIC` frame v2.2 wrote instead.
|
|
292
|
+
*
|
|
293
|
+
* §4.15 of v2.3 and §4.14 of v2.4 lay it out the same way: the text encoding,
|
|
294
|
+
* the MIME type as a null-terminated Latin-1 string, the picture type, a
|
|
295
|
+
* description in the frame's encoding and terminated the way that encoding
|
|
296
|
+
* terminates, and then the image. v2.2 named the frame `PIC`, used three
|
|
297
|
+
* characters — `JPG`, `PNG` — instead of a MIME type, and is otherwise the same.
|
|
298
|
+
*
|
|
299
|
+
* Returns where the image starts, or null when the frame is not laid out that
|
|
300
|
+
* way — and a frame that cannot be walked yields no picture rather than a guess
|
|
301
|
+
* at one, which is the same rule the rest of this reader follows.
|
|
302
|
+
*/
|
|
303
|
+
function pictureIn(body, major) {
|
|
304
|
+
if (body.length < 4)
|
|
305
|
+
return null;
|
|
306
|
+
let at = 1; // past the encoding byte, which the description below needs
|
|
307
|
+
const encoding = body[0] ?? 0;
|
|
308
|
+
let mime;
|
|
309
|
+
if (major === 2) {
|
|
310
|
+
// Three characters, which are not a MIME type and are not treated as one:
|
|
311
|
+
// the two that were ever defined are read, and anything else is a format
|
|
312
|
+
// this cannot name, which is a reason to say nothing.
|
|
313
|
+
const format = Buffer.from(body.subarray(at, at + 3)).toString('latin1').toUpperCase();
|
|
314
|
+
at += 3;
|
|
315
|
+
if (format === 'JPG')
|
|
316
|
+
mime = 'image/jpeg';
|
|
317
|
+
else if (format === 'PNG')
|
|
318
|
+
mime = 'image/png';
|
|
319
|
+
else
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
// The MIME type runs to a null. A frame that never ends it has no picture
|
|
324
|
+
// behind it to find, so the end of the body is the end of the search.
|
|
325
|
+
let end = at;
|
|
326
|
+
while (end < body.length && body[end] !== 0)
|
|
327
|
+
end += 1;
|
|
328
|
+
if (end >= body.length)
|
|
329
|
+
return null;
|
|
330
|
+
mime = Buffer.from(body.subarray(at, end)).toString('latin1');
|
|
331
|
+
at = end + 1;
|
|
332
|
+
}
|
|
333
|
+
if (at >= body.length)
|
|
334
|
+
return null;
|
|
335
|
+
const kind = body[at] ?? 0;
|
|
336
|
+
at += 1;
|
|
337
|
+
// The description, then the image. Its terminator is one byte or two
|
|
338
|
+
// depending on the encoding, which is the same rule a TXXX frame's
|
|
339
|
+
// description follows.
|
|
340
|
+
const [, rest] = splitTerminated(body.subarray(at), encoding);
|
|
341
|
+
const start = body.length - rest.length;
|
|
342
|
+
return start >= body.length ? null : { mime, kind, start };
|
|
343
|
+
}
|
|
344
|
+
function readFrame(into, id, body,
|
|
345
|
+
/** Collects the frames that are half of a date rather than a value. */
|
|
346
|
+
dates,
|
|
347
|
+
/** Where `body` starts in the file, when it is the file's own bytes. */
|
|
348
|
+
at) {
|
|
349
|
+
if (body.length === 0)
|
|
350
|
+
return;
|
|
351
|
+
const encoding = body[0] ?? 0;
|
|
352
|
+
if (id === 'APIC' || id === 'PIC') {
|
|
353
|
+
const picture = pictureIn(body, id === 'PIC' ? 2 : 3);
|
|
354
|
+
// A frame the reader had to rebuild — de-unsynchronised, decompressed — is
|
|
355
|
+
// a copy, and a copy has no offset in the file to be served from. The
|
|
356
|
+
// picture is then simply not recorded: an absent cover is a smaller lie
|
|
357
|
+
// than one served from the middle of something else.
|
|
358
|
+
if (picture !== null && at !== null) {
|
|
359
|
+
into.picture = betterPicture(into.picture, {
|
|
360
|
+
mime: picture.mime,
|
|
361
|
+
kind: picture.kind,
|
|
362
|
+
offset: at + picture.start,
|
|
363
|
+
length: body.length - picture.start,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// `TXX` is the same frame in v2.2's spelling (§4.2.2 of `id3v2-00`).
|
|
369
|
+
if (id === 'TXXX' || id === 'TXX') {
|
|
370
|
+
// A user-defined pair: its description names it (§4.2.2 of v2.3, §4.2.6 of
|
|
371
|
+
// v2.4, which moved it). This is where a ripper puts ALBUMARTIST when it
|
|
372
|
+
// does not use TPE2, and where the collection keeps ENSEMBLE and the
|
|
373
|
+
// ripping tool's own notes.
|
|
374
|
+
//
|
|
375
|
+
// No resolver, and that is the rule rather than an omission: the genre
|
|
376
|
+
// resolver answers for the frame the standard defines as the content type,
|
|
377
|
+
// and it is asked for by *frame*, not by name. A file free to name its own
|
|
378
|
+
// field `genre` is free to mean anything by it, and reading `17` in it as
|
|
379
|
+
// Rock is deciding what the file meant — measured on a built tag, the whole
|
|
380
|
+
// of that difference (task:2851).
|
|
381
|
+
const [description, value] = splitTerminated(body.subarray(1), encoding);
|
|
382
|
+
// The description is text and is read as text, so how well it decoded counts
|
|
383
|
+
// towards the file's verdict exactly as the value's does. The Vorbis reader
|
|
384
|
+
// folds its field *name* the same way — one figure, and it was being made
|
|
385
|
+
// two ways until this matched it (task:2756).
|
|
386
|
+
const described = decodeId3Text(encoding, description);
|
|
387
|
+
into.encoding = weakestEncoding(into.encoding, described);
|
|
388
|
+
const name = described.text.trim().toLowerCase();
|
|
389
|
+
if (name === '')
|
|
390
|
+
return;
|
|
391
|
+
pushValues(into, name, decodeId3Text(encoding, value));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// A comment is a text frame under an id that does not begin with `T`: §4.11 of
|
|
395
|
+
// v2.3 and §4.10 of v2.4 give it the encoding, then a three-byte language,
|
|
396
|
+
// then the short description that `TXXX` also carries, then the text.
|
|
397
|
+
//
|
|
398
|
+
// The description does *not* name it, though `TXXX`'s does. A TXXX description
|
|
399
|
+
// is the field's name — that is what "user defined" means — while a comment's
|
|
400
|
+
// is a content descriptor, and §4.11 says so: it exists to tell several
|
|
401
|
+
// comments apart, not to introduce a name. Taking it as one let a file name a
|
|
402
|
+
// comment `title` and have it read as a second title ahead of the real one,
|
|
403
|
+
// and the genre resolver then answered for the name it invented (task:2851).
|
|
404
|
+
if (id === 'COMM' || id === 'COM') {
|
|
405
|
+
// Three bytes of language, exactly, whatever they are: they are ISO-639-2
|
|
406
|
+
// codes and not text in the frame's encoding, so the terminator rule does
|
|
407
|
+
// not apply to them.
|
|
408
|
+
const [, text] = splitTerminated(body.subarray(4), encoding);
|
|
409
|
+
pushValues(into, 'comment', decodeId3Text(encoding, text), RESOLVERS.comment);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// The frames a date is split across, collected rather than stored — see
|
|
413
|
+
// `DateParts`. The encoding verdict is still folded in: a frame whose value is
|
|
414
|
+
// put to another use has still been read, and how well is a fact about the
|
|
415
|
+
// file.
|
|
416
|
+
if (id === 'TYER' || id === 'TYE' || id === 'TDAT' || id === 'TDA' || id === 'TIME' || id === 'TIM') {
|
|
417
|
+
const decoded = decodeId3Text(encoding, body.subarray(1));
|
|
418
|
+
into.encoding = weakestEncoding(into.encoding, decoded);
|
|
419
|
+
const value = decoded.text.trim();
|
|
420
|
+
if (id === 'TDAT' || id === 'TDA')
|
|
421
|
+
dates.dayMonth = value;
|
|
422
|
+
else if (id === 'TIME' || id === 'TIM')
|
|
423
|
+
dates.hourMinute = value;
|
|
424
|
+
else
|
|
425
|
+
dates.year = value;
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const known = nameOf(id);
|
|
429
|
+
if (known === undefined)
|
|
430
|
+
return;
|
|
431
|
+
pushValues(into, known, decodeId3Text(encoding, body.subarray(1)), RESOLVERS[known]);
|
|
432
|
+
}
|
|
433
|
+
export function readId3v2(bytes) {
|
|
434
|
+
// The tag block says nothing about the audio behind it: the codec is the
|
|
435
|
+
// frame walk's to name, in `read.ts`, and the bytes here are ID3 and nothing more.
|
|
436
|
+
const result = noTags('id3v2');
|
|
437
|
+
if (bytes.length < 10)
|
|
438
|
+
return result;
|
|
439
|
+
// What this reading declined to use, one sentence each, carried out through
|
|
440
|
+
// `TagRead.refusals` so the stage can write it down — see that field on why a
|
|
441
|
+
// value read and discarded is the silence the contract forbids.
|
|
442
|
+
const refusals = [];
|
|
443
|
+
const major = bytes[3] ?? 0;
|
|
444
|
+
const flags = bytes[5] ?? 0;
|
|
445
|
+
const stored = Math.min(10 + synchsafeAt(bytes, 6), bytes.length);
|
|
446
|
+
let at = 10;
|
|
447
|
+
let end = stored;
|
|
448
|
+
// v2.2's bit 6 is *compression*, not an extended header, and the spec is
|
|
449
|
+
// explicit about what to do with it: "the ID3 decoder (for now) should just
|
|
450
|
+
// ignore the entire tag if the compression bit is set" (id3v2-00 §3.1). No
|
|
451
|
+
// scheme was ever defined, so there is nothing to decompress — parsing on
|
|
452
|
+
// would read compressed bytes as frames and invent names out of them.
|
|
453
|
+
//
|
|
454
|
+
// Ignoring the tag is what the spec asks for; ignoring it *silently* is not,
|
|
455
|
+
// and was: the file then reads exactly like a rip that never had tags, which is
|
|
456
|
+
// a different thing from one whose tags this reader could not use. The sentence
|
|
457
|
+
// goes up through `refusals` and the stage writes it down (task:2754).
|
|
458
|
+
if (major === 2 && (flags & 0x40) !== 0) {
|
|
459
|
+
result.refusals = [
|
|
460
|
+
'the tag is marked compressed, which v2.2 defines no scheme for — nothing in it was read',
|
|
461
|
+
];
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
// v2.2 and v2.3 escape the tag as a whole and leave no per-frame place to say
|
|
465
|
+
// so — §3.3.1 of v2.3's frame flags are compression, encryption and grouping —
|
|
466
|
+
// so the escaping comes off here, once, and everything below reads clean bytes.
|
|
467
|
+
// The extended header is included: §3.2 of v2.3 calls it "subject to
|
|
468
|
+
// unsynchronisation" as well, so decoding from the first frame on would leave
|
|
469
|
+
// it escaped and its size field would then be read out of escaped bytes.
|
|
470
|
+
//
|
|
471
|
+
// What the frame sizes describe follows from where the escaping sat, and v2.3
|
|
472
|
+
// says where: §3.2 has the extended header's CRC "calculated before
|
|
473
|
+
// unsynchronisation on the data between the extended header and the padding,
|
|
474
|
+
// i.e. the frames and only the frames", and §5 applies the scheme after
|
|
475
|
+
// compression — to the tag as it stands. So the escaping was applied to
|
|
476
|
+
// assembled frames, whose size fields were written before it, and those sizes
|
|
477
|
+
// therefore count the clean bytes. v2.2 §3.1 and §5 say the same in the same
|
|
478
|
+
// words. Walking such a tag without decoding it first reads the second frame
|
|
479
|
+
// from the wrong offset and the first frame's escaped bytes as text
|
|
480
|
+
// (task:2753); v2.4 is the other arrangement and is `frameData`'s business.
|
|
481
|
+
if (major < 4 && (flags & 0x80) !== 0) {
|
|
482
|
+
const body = deunsynchronise(bytes.subarray(10, stored));
|
|
483
|
+
// The header is copied rather than decoded — §3.1's size is synchsafe, the
|
|
484
|
+
// version bytes are never $FF, and the flags byte names this very flag — so
|
|
485
|
+
// the bytes below keep meaning what the ones above did.
|
|
486
|
+
const joined = new Uint8Array(10 + body.length);
|
|
487
|
+
joined.set(bytes.subarray(0, 10), 0);
|
|
488
|
+
joined.set(body, 10);
|
|
489
|
+
bytes = joined;
|
|
490
|
+
end = joined.length;
|
|
491
|
+
}
|
|
492
|
+
// v2.2 writes a three-letter id and a plain three-byte size, and has no
|
|
493
|
+
// per-frame flags: its frame header is six bytes where every later version
|
|
494
|
+
// uses ten. Everything below is written against that difference.
|
|
495
|
+
const idWidth = major === 2 ? 3 : 4;
|
|
496
|
+
const frameHeader = major === 2 ? 6 : 10;
|
|
497
|
+
const dates = { year: null, dayMonth: null, hourMinute: null, full: false };
|
|
498
|
+
// An extended header sits between the tag header and the first frame, and its
|
|
499
|
+
// size field means different things in v2.3 and v2.4. §3.2 of v2.3: "the
|
|
500
|
+
// 'Extended header size', currently 6 or 10 bytes, excludes itself", so the
|
|
501
|
+
// header is four bytes longer than the number. §3.2 of v2.4: "the 'Extended
|
|
502
|
+
// header size' is the size of the whole extended header, stored as a 32 bit
|
|
503
|
+
// synchsafe integer", so it is the number itself. Getting this backwards
|
|
504
|
+
// shifts every frame by a few bytes without any error anywhere. v2.2 has no
|
|
505
|
+
// extended header at all: bit 6 of its flag byte means the tag is compressed.
|
|
506
|
+
if (major >= 3 && (flags & 0x40) !== 0 && at + 4 <= end) {
|
|
507
|
+
if (major >= 4)
|
|
508
|
+
at += synchsafeAt(bytes, at);
|
|
509
|
+
else
|
|
510
|
+
at += 4 + readUInt32BE(bytes, at);
|
|
511
|
+
}
|
|
512
|
+
// A header flag saying every frame was escaped. §6.1 of v2.4: "If all frames
|
|
513
|
+
// in the tag are unsynchronised the unsynchronisation flag in the tag header
|
|
514
|
+
// SHOULD be set", with the per-frame flag (§4.1.2, bit n) saying which of
|
|
515
|
+
// them were. The header flag is therefore a summary — but a writer that sets
|
|
516
|
+
// it has escaped every `FF 00` in the tag, so taking it as a blanket
|
|
517
|
+
// instruction costs nothing and missing it would leave escaped bytes in
|
|
518
|
+
// place. v2.2 and v2.3 carry the same flag in their own §3.1 and have no
|
|
519
|
+
// per-frame place to say it instead. A tag of either older version has been
|
|
520
|
+
// decoded above already, so what is left for this flag to mean below is v2.4's
|
|
521
|
+
// summary — and the two places that read it are `frameData` and `sits`.
|
|
522
|
+
const tagEscaped = (flags & 0x80) !== 0;
|
|
523
|
+
// §3.3 of v2.3 and §4 of v2.4 give the frame as an identifier, a size, and two
|
|
524
|
+
// flag bytes; §4.2.1 of v2.2 — three letters where the rest use four, a plain
|
|
525
|
+
// three-byte size, and no flags at all, which is the six-byte header `idWidth`
|
|
526
|
+
// and `frameHeader` below are built around.
|
|
527
|
+
while (at + frameHeader <= end) {
|
|
528
|
+
const id = asciiFrameId(bytes, at, idWidth);
|
|
529
|
+
if (id === '')
|
|
530
|
+
break; // padding, or something that is not a frame
|
|
531
|
+
const size = major >= 4
|
|
532
|
+
? synchsafeAt(bytes, at + 4)
|
|
533
|
+
: major === 2
|
|
534
|
+
? readUInt24BE(bytes, at + 3)
|
|
535
|
+
: readUInt32BE(bytes, at + 4);
|
|
536
|
+
const body = at + frameHeader;
|
|
537
|
+
if (size <= 0 || body + size > end)
|
|
538
|
+
break;
|
|
539
|
+
// A v2.4 frame's size counts what is on disk — escaping and indicator
|
|
540
|
+
// included — so the walk stays on the raw bytes and only what is handed to
|
|
541
|
+
// the frame reader is adjusted. Doing it the other way round there —
|
|
542
|
+
// de-escaping the frames and then walking them — reads every boundary from
|
|
543
|
+
// the wrong offset. v2.2 and v2.3 are the opposite arrangement and are
|
|
544
|
+
// already decoded above, sizes and all, which is why only a v2.4 frame ever
|
|
545
|
+
// reaches here escaped.
|
|
546
|
+
const format = major >= 3 ? (bytes[at + 9] ?? 0) : 0;
|
|
547
|
+
const raw = bytes.subarray(body, body + size);
|
|
548
|
+
// A frame this reader would have read had it been readable. One it ignores
|
|
549
|
+
// anyway — a `PRIV`, a `GEOB` — is not a loss, and a line about it would be
|
|
550
|
+
// noise on every file that carries one.
|
|
551
|
+
const wanted = nameOf(id) !== undefined ||
|
|
552
|
+
id === 'TXXX' ||
|
|
553
|
+
id === 'TXX' ||
|
|
554
|
+
id === 'COMM' ||
|
|
555
|
+
id === 'COM' ||
|
|
556
|
+
id === 'APIC' ||
|
|
557
|
+
id === 'PIC';
|
|
558
|
+
const data = frameData(raw, format, major, tagEscaped, (why) => {
|
|
559
|
+
if (wanted)
|
|
560
|
+
refusals.push(`the frame ${id} ${why}`);
|
|
561
|
+
});
|
|
562
|
+
// Where this frame's data sits in the file, or null when it is not certainly
|
|
563
|
+
// the file's own bytes.
|
|
564
|
+
//
|
|
565
|
+
// Two ways that can fail, and the second is the one worth spelling out.
|
|
566
|
+
// `frameData` rebuilds a frame whose format flags say so — de-escaped,
|
|
567
|
+
// decompressed — and a rebuilt frame is a copy with no place in the file;
|
|
568
|
+
// the buffer identity catches that. A tag of v2.2 or v2.3 that carries the
|
|
569
|
+
// tag flag is the same answer for the other reason: its frames describe
|
|
570
|
+
// bytes that are not the file's — escaped, in v2.2's case, and a decoded
|
|
571
|
+
// copy in v2.3's — so an offset into them would serve the escaping, or the
|
|
572
|
+
// wrong bytes entirely, as part of the picture. The guard is deliberately
|
|
573
|
+
// wider than the rebuild: it needs no measurement of which of the two
|
|
574
|
+
// happened, because neither answer is a file offset.
|
|
575
|
+
const escaped = major < 4 && tagEscaped;
|
|
576
|
+
const sits = !escaped && data.buffer === bytes.buffer ? data.byteOffset - bytes.byteOffset : null;
|
|
577
|
+
if (id === 'TDRC')
|
|
578
|
+
dates.full = true;
|
|
579
|
+
readFrame(result, id, data, dates, sits);
|
|
580
|
+
at = body + size;
|
|
581
|
+
}
|
|
582
|
+
dated(result, dates);
|
|
583
|
+
if (refusals.length > 0)
|
|
584
|
+
result.refusals = refusals;
|
|
585
|
+
return result;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Write the date the tag states, out of the frames it states it in.
|
|
589
|
+
*
|
|
590
|
+
* The composite is not the specification's — §4.2.1 defines the three frames and
|
|
591
|
+
* says nothing about putting them together — so it is a choice. It follows
|
|
592
|
+
* ffmpeg, which is the second reader this project measures itself against, on
|
|
593
|
+
* the well-formed case: `2005-09-12` where a year alone loses the day, and it
|
|
594
|
+
* agrees with ffmpeg there on every file of the collection that states a `TDAT`
|
|
595
|
+
* (task:2737).
|
|
596
|
+
*
|
|
597
|
+
* Where they part is the malformed case, and the parting is deliberate: ffmpeg
|
|
598
|
+
* composes `2005-00-00` from a `TDAT` of `0000`, and this reader will not — a
|
|
599
|
+
* date with a date's shape and no date in it is the invention the rest of this
|
|
600
|
+
* file exists to avoid. ffmpeg also drops a `TYER` that holds a whole
|
|
601
|
+
* `2025-03-28`, where this reader keeps what the file wrote; that is the seven
|
|
602
|
+
* files of the collection that carry one (task:2851, measured). Neither
|
|
603
|
+
* difference is a disagreement about the format, which is silent on both.
|
|
604
|
+
*
|
|
605
|
+
* `TDRC`, which v2.4 writes instead, is already a whole timestamp and is left
|
|
606
|
+
* exactly as the file wrote it.
|
|
607
|
+
*
|
|
608
|
+
* A part that is not a date is not one: `TDAT` of `0000` is what a writer puts
|
|
609
|
+
* there when it has no date, and composing `2005-00-00` from it would be an
|
|
610
|
+
* invention with a date's shape. Such a tag keeps the year it does state. A tag
|
|
611
|
+
* that states no year at all yields no `date` rather than a bare `DDMM` — four
|
|
612
|
+
* digits under a name that other code reads a year out of is worse than silence.
|
|
613
|
+
*/
|
|
614
|
+
function dated(into, dates) {
|
|
615
|
+
if (dates.year === null)
|
|
616
|
+
return;
|
|
617
|
+
// `TDRC` had the last word, and it is asked for by name *and* by having been
|
|
618
|
+
// seen. The weaker test — is any tag called `date` already here — let a
|
|
619
|
+
// user-defined `TXXX` named `date` suppress the year, the day and the month
|
|
620
|
+
// the tag states in the frames that exist for them (task:2851).
|
|
621
|
+
if (dates.full)
|
|
622
|
+
return;
|
|
623
|
+
// A year is judged by the same rule the stage that reads one out uses — four
|
|
624
|
+
// digits beginning `1` or `2` — and not by being exactly four characters:
|
|
625
|
+
// writers do put a whole `1997-05-06` in `TYER`, and a file that already states
|
|
626
|
+
// a date is not improved by this function recomposing one. `not a year` is
|
|
627
|
+
// what that rule is for, and it stays unanswered.
|
|
628
|
+
const year = /^[12][0-9]{3}$/.test(dates.year.slice(0, 4)) ? dates.year.slice(0, 4) : null;
|
|
629
|
+
if (year === null)
|
|
630
|
+
return;
|
|
631
|
+
if (dates.year.length > 4) {
|
|
632
|
+
into.tags.push({ name: 'date', value: dates.year });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const dayMonth = /^[0-9]{4}$/.test(dates.dayMonth ?? '') ? dates.dayMonth : null;
|
|
636
|
+
const month = dayMonth === null ? 0 : Number(dayMonth.slice(2, 4));
|
|
637
|
+
const day = dayMonth === null ? 0 : Number(dayMonth.slice(0, 2));
|
|
638
|
+
const usable = month >= 1 && month <= 12 && day >= 1 && day <= 31;
|
|
639
|
+
if (!usable) {
|
|
640
|
+
into.tags.push({ name: 'date', value: year });
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const hourMinute = /^[0-9]{4}$/.test(dates.hourMinute ?? '') ? dates.hourMinute : null;
|
|
644
|
+
const hour = hourMinute === null ? 0 : Number(hourMinute.slice(0, 2));
|
|
645
|
+
const minute = hourMinute === null ? 0 : Number(hourMinute.slice(2, 4));
|
|
646
|
+
const clock = hourMinute !== null && hour <= 23 && minute <= 59
|
|
647
|
+
? `T${hourMinute.slice(0, 2)}:${hourMinute.slice(2, 4)}`
|
|
648
|
+
: '';
|
|
649
|
+
const dd = String(day).padStart(2, '0');
|
|
650
|
+
const mm = String(month).padStart(2, '0');
|
|
651
|
+
into.tags.push({ name: 'date', value: `${year}-${mm}-${dd}${clock}` });
|
|
652
|
+
}
|
|
653
|
+
/** Drop up to `count` bytes from the front, or all of them if there are fewer. */
|
|
654
|
+
function skip(bytes, count) {
|
|
655
|
+
return bytes.subarray(Math.min(count, bytes.length));
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Undo whatever the frame's format flags say was done to its data.
|
|
659
|
+
*
|
|
660
|
+
* Both versions put extra fields ahead of the data and say the order is the
|
|
661
|
+
* order of the flags, which is the whole of the difficulty: the layout differs
|
|
662
|
+
* by version even where the flags look alike.
|
|
663
|
+
*
|
|
664
|
+
* v2.3, §3.3.1, flags %ijk00000 — i compression, j encryption, k grouping —
|
|
665
|
+
* puts four bytes of decompressed size first, then the encryption method
|
|
666
|
+
* byte, then the group byte.
|
|
667
|
+
*
|
|
668
|
+
* v2.4, §4.1.2, flags %0h00kmnp — h grouping, k compression, m encryption,
|
|
669
|
+
* n unsynchronisation, p data length indicator — puts the group byte first,
|
|
670
|
+
* then the method byte, then the four-byte indicator.
|
|
671
|
+
*
|
|
672
|
+
* The data length indicator is the length the frame would have had with its
|
|
673
|
+
* format flags zeroed — that is, the length *before* compression. It is not
|
|
674
|
+
* needed to read the frame, whose size is right there, so it is stepped over
|
|
675
|
+
* rather than trusted.
|
|
676
|
+
*
|
|
677
|
+
* What §4.1.2 asks for differs by flag, and the two sentences are worth keeping
|
|
678
|
+
* apart, because they say opposite things: for a **compressed** frame the
|
|
679
|
+
* indicator is required — "If set, this requires the 'Data Length Indicator' bit
|
|
680
|
+
* to be set as well", and "A 'Data Length Indicator' byte MUST be included in
|
|
681
|
+
* the frame" — while for an **unsynchronised** one it is only wanted, "Although
|
|
682
|
+
* desirable, the presence of a 'Data Length Indicator' is not made mandatory by
|
|
683
|
+
* unsynchronisation". This reader takes the indicator when the flag says it is
|
|
684
|
+
* there and does not require it in either case, which is not laxity: the frame's
|
|
685
|
+
* own size settles where it ends, so a writer that omitted a required indicator
|
|
686
|
+
* costs nothing here.
|
|
687
|
+
*
|
|
688
|
+
* An encrypted frame yields nothing. The method byte names a scheme this reader
|
|
689
|
+
* does not implement, and there is no plaintext behind it to read — decoding
|
|
690
|
+
* ciphertext as text is how a title gets invented.
|
|
691
|
+
*/
|
|
692
|
+
function frameData(bytes, format, major, tagEscaped, refuse) {
|
|
693
|
+
// v2.2 has no frame flags at all: its header is six bytes and that is the
|
|
694
|
+
// whole of it. Compression there is a tag-level flag, handled by the caller.
|
|
695
|
+
if (major === 2)
|
|
696
|
+
return bytes;
|
|
697
|
+
const grouped = (format & (major >= 4 ? 0x40 : 0x20)) !== 0;
|
|
698
|
+
const encrypted = (format & (major >= 4 ? 0x04 : 0x40)) !== 0;
|
|
699
|
+
const indicator = major >= 4 && (format & 0x01) !== 0;
|
|
700
|
+
const compressed = (format & (major >= 4 ? 0x08 : 0x80)) !== 0;
|
|
701
|
+
if (major === 3 && compressed)
|
|
702
|
+
bytes = skip(bytes, 4);
|
|
703
|
+
// Encryption is answered before the group byte is stepped over, and that is
|
|
704
|
+
// the order the header states: the decompressed size, the method byte, then
|
|
705
|
+
// the group byte. Nothing here reads the method byte — the frame is refused —
|
|
706
|
+
// so the only thing the order decides is which of the two a reader would be
|
|
707
|
+
// wrong about, and this way the comment above and the code agree.
|
|
708
|
+
if (encrypted) {
|
|
709
|
+
refuse('is encrypted, and no scheme here reads it');
|
|
710
|
+
return new Uint8Array(0);
|
|
711
|
+
}
|
|
712
|
+
if (grouped)
|
|
713
|
+
bytes = skip(bytes, 1);
|
|
714
|
+
if (indicator)
|
|
715
|
+
bytes = skip(bytes, 4);
|
|
716
|
+
// De-escaping comes before decompressing, and it has to: a writer compresses
|
|
717
|
+
// first and unsynchronises the result, so the reader takes the escaping off
|
|
718
|
+
// the compressed bytes. v2.2 and v2.3 say the same of their tag-level flag,
|
|
719
|
+
// and their case never reaches here: `readId3v2` decoded the whole body before
|
|
720
|
+
// it began to walk, so a compressed v2.3 frame is handed over already clean.
|
|
721
|
+
if (major >= 4 && (tagEscaped || (format & 0x02) !== 0))
|
|
722
|
+
bytes = deunsynchronise(bytes);
|
|
723
|
+
if (!compressed)
|
|
724
|
+
return bytes;
|
|
725
|
+
const inflated = inflateFrame(bytes);
|
|
726
|
+
// Empty is the answer for a body that came back with nothing, and it is the
|
|
727
|
+
// same answer a frame that failed to inflate gets — which is why the failure
|
|
728
|
+
// has to be said here or nowhere: downstream sees an empty body either way.
|
|
729
|
+
if (inflated.length === 0)
|
|
730
|
+
refuse('says it is compressed and did not decompress');
|
|
731
|
+
return inflated;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* A zlib frame body, or nothing at all.
|
|
735
|
+
*
|
|
736
|
+
* §3.3.1 compresses with zlib and §4.1.2 says "zlib deflate method", so Node's
|
|
737
|
+
* own inflate is the same scheme. ffmpeg reads these frames — that was checked
|
|
738
|
+
* by building one to the specification and asking it — while v2.3's variant it
|
|
739
|
+
* does not implement, so that half of this path has the specification behind it
|
|
740
|
+
* and no second instrument.
|
|
741
|
+
*
|
|
742
|
+
* A frame that says it is compressed and does not decompress is left with no
|
|
743
|
+
* value rather than with its compressed bytes as text: the second would put a
|
|
744
|
+
* plausible-looking title made of zlib header bytes into a column that a title
|
|
745
|
+
* belongs in.
|
|
746
|
+
*/
|
|
747
|
+
function inflateFrame(bytes) {
|
|
748
|
+
try {
|
|
749
|
+
return inflateSync(bytes);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return new Uint8Array(0);
|
|
753
|
+
}
|
|
754
|
+
}
|