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
package/dist/tags/mp4.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { decodeText, looksLikeText } from "../text/encoding.js";
|
|
2
|
+
import { ID3V1_GENRES } from "./genres.js";
|
|
3
|
+
import { betterPicture, FRONT_COVER, noTags, weakestEncoding } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Read what an MP4 file says about itself.
|
|
6
|
+
*
|
|
7
|
+
* `.m4a` is a third of this collection and was, until this reader existed, the
|
|
8
|
+
* one format that gave neither a name nor a length — every stage after this one
|
|
9
|
+
* saw an empty file. It is MP4 underneath: a tree of length-prefixed boxes, and
|
|
10
|
+
* the two things wanted sit in well-known ones.
|
|
11
|
+
*
|
|
12
|
+
* Both are cheap, and both are worth reading here rather than asking ffprobe,
|
|
13
|
+
* which can do it:
|
|
14
|
+
*
|
|
15
|
+
* - the length is `moov.mvhd`, a `timescale` and a `duration` — the same two
|
|
16
|
+
* numbers `STREAMINFO` gives for FLAC, and dividing one by the other is the
|
|
17
|
+
* whole of it;
|
|
18
|
+
* - the names are `moov.udta.meta.ilst`, and the reason to read them rather
|
|
19
|
+
* than ask is that ffprobe's tag output is a dictionary. Two `©ART` entries
|
|
20
|
+
* are a collaboration to this project — `file_tag` carries a position so
|
|
21
|
+
* they survive — and a dictionary has nowhere to put the second one.
|
|
22
|
+
*
|
|
23
|
+
* Asking would also mean a process per file for a format that is a third of the
|
|
24
|
+
* collection, where ffprobe is spawned elsewhere only for the one file in 267
|
|
25
|
+
* this reader cannot measure.
|
|
26
|
+
*
|
|
27
|
+
* Everything here is stepped over by the length the box declares, never by
|
|
28
|
+
* guessing where the next one starts. That is what lets artwork — most of a
|
|
29
|
+
* tag's bytes and none of its meaning — pass through without being decoded, and
|
|
30
|
+
* what makes it not matter whether `moov` sits before or after the audio.
|
|
31
|
+
*/
|
|
32
|
+
/** Atom name -> the name it is stored under, matching the ID3v2 and Vorbis maps. */
|
|
33
|
+
const TEXT_ATOMS = {
|
|
34
|
+
'©nam': 'title',
|
|
35
|
+
'©ART': 'artist',
|
|
36
|
+
aART: 'albumartist',
|
|
37
|
+
'©alb': 'album',
|
|
38
|
+
'©gen': 'genre',
|
|
39
|
+
'©day': 'date',
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Atoms carrying numbers rather than text.
|
|
43
|
+
*
|
|
44
|
+
* `trkn` and `disk` are a number and its total as two 16-bit values; read as
|
|
45
|
+
* text they are a NUL and a control character, the kind of value that looks
|
|
46
|
+
* like an empty tag and quietly loses the track number.
|
|
47
|
+
*
|
|
48
|
+
* `gnre` is one 16-bit value — the ID3v1 genre list by **one-based** index, so
|
|
49
|
+
* 53 is Electronic and 1 is Blues. It is how iTunes writes a genre it took from
|
|
50
|
+
* that list, and it writes no `©gen` beside it: **480 of the 1424 m4a files in
|
|
51
|
+
* the collection carry `gnre` and no words at all**, so before this a third of
|
|
52
|
+
* them had no genre while ffprobe named one for each. The rule that the index
|
|
53
|
+
* is one-based was read off those files — the name every one of the 480 numbers
|
|
54
|
+
* yields at `value - 1` is the name ffprobe reports, 480 times out of 480 —
|
|
55
|
+
* rather than taken on faith, because being wrong by one here is silent: every
|
|
56
|
+
* genre would be the next one along the list, and each would still be a genre.
|
|
57
|
+
*/
|
|
58
|
+
const NUMBER_ATOMS = {
|
|
59
|
+
trkn: 'tracknumber',
|
|
60
|
+
disk: 'discnumber',
|
|
61
|
+
gnre: 'genre',
|
|
62
|
+
};
|
|
63
|
+
/** The one of those that is a single value rather than a pair. */
|
|
64
|
+
const ID3_GENRE_ATOM = 'gnre';
|
|
65
|
+
/**
|
|
66
|
+
* Atoms carrying one number, read as one.
|
|
67
|
+
*
|
|
68
|
+
* `rtng` is iTunes' content rating — one byte, and the value is the rating
|
|
69
|
+
* itself rather than an index into anything, so it belongs in neither table
|
|
70
|
+
* above: `NUMBER_ATOMS`' entries are all a number *and its total*, and read
|
|
71
|
+
* through that branch a one-byte atom fails the length check and is dropped
|
|
72
|
+
* without a word. Which is what happened to every `rtng` in this collection:
|
|
73
|
+
* nothing read it, and `explicitStatus` had nothing to answer from.
|
|
74
|
+
*/
|
|
75
|
+
const INTEGER_ATOMS = {
|
|
76
|
+
rtng: 'rtng',
|
|
77
|
+
};
|
|
78
|
+
function uint16be(bytes, at) {
|
|
79
|
+
return (((bytes[at] ?? 0) << 8) | (bytes[at + 1] ?? 0)) >>> 0;
|
|
80
|
+
}
|
|
81
|
+
function uint32be(bytes, at) {
|
|
82
|
+
return ((((bytes[at] ?? 0) << 24) |
|
|
83
|
+
((bytes[at + 1] ?? 0) << 16) |
|
|
84
|
+
((bytes[at + 2] ?? 0) << 8) |
|
|
85
|
+
(bytes[at + 3] ?? 0)) >>>
|
|
86
|
+
0);
|
|
87
|
+
}
|
|
88
|
+
/** Only for a 64-bit `largesize`; every file this meets is far under 2^53. */
|
|
89
|
+
function uint64be(bytes, at) {
|
|
90
|
+
return uint32be(bytes, at) * 0x1_0000_0000 + uint32be(bytes, at + 4);
|
|
91
|
+
}
|
|
92
|
+
/** Four characters of atom name. Not validated: an unknown name is stepped over. */
|
|
93
|
+
function nameAt(bytes, at) {
|
|
94
|
+
let out = '';
|
|
95
|
+
for (let i = 0; i < 4; i += 1)
|
|
96
|
+
out += String.fromCharCode(bytes[at + i] ?? 0);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The box starting at `at`, or null when there is not a whole one there.
|
|
101
|
+
*
|
|
102
|
+
* Three of the size field's values are not sizes. One means the real length is
|
|
103
|
+
* in the eight bytes after the name; zero means the box runs to the end of
|
|
104
|
+
* whatever holds it. Reading either as a plain number walks off into nonsense.
|
|
105
|
+
*/
|
|
106
|
+
function boxAt(bytes, at, limit) {
|
|
107
|
+
if (at + 8 > limit)
|
|
108
|
+
return null;
|
|
109
|
+
let size = uint32be(bytes, at);
|
|
110
|
+
let bodyAt = at + 8;
|
|
111
|
+
if (size === 1) {
|
|
112
|
+
if (at + 16 > limit)
|
|
113
|
+
return null;
|
|
114
|
+
size = uint64be(bytes, at + 8);
|
|
115
|
+
bodyAt = at + 16;
|
|
116
|
+
}
|
|
117
|
+
else if (size === 0) {
|
|
118
|
+
size = limit - at;
|
|
119
|
+
}
|
|
120
|
+
// A box shorter than its own header, or one that does not fit, is the end of
|
|
121
|
+
// the road rather than a place to read from.
|
|
122
|
+
if (size < bodyAt - at)
|
|
123
|
+
return null;
|
|
124
|
+
return { type: nameAt(bytes, at + 4), bodyAt, bodyEnd: Math.min(at + size, limit) };
|
|
125
|
+
}
|
|
126
|
+
/** The first box of this type among the children, stepping over the rest. */
|
|
127
|
+
function findBox(bytes, from, to, wanted) {
|
|
128
|
+
let at = from;
|
|
129
|
+
while (at + 8 <= to) {
|
|
130
|
+
const box = boxAt(bytes, at, to);
|
|
131
|
+
if (box === null)
|
|
132
|
+
return null;
|
|
133
|
+
if (box.type === wanted)
|
|
134
|
+
return box;
|
|
135
|
+
if (box.bodyEnd <= at)
|
|
136
|
+
return null;
|
|
137
|
+
at = box.bodyEnd;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Playback length in milliseconds from `mvhd`, or null when it states none.
|
|
143
|
+
*
|
|
144
|
+
* A fragmented file carries its timing in the fragments and writes zero here,
|
|
145
|
+
* and zero is not a length. Nor is it an error: the caller is told the length
|
|
146
|
+
* was refused and asks something that can measure it.
|
|
147
|
+
*/
|
|
148
|
+
function lengthFromMvhd(bytes, mvhd) {
|
|
149
|
+
const version = bytes[mvhd.bodyAt] ?? 0;
|
|
150
|
+
// Version 1 widens the two timestamps before `timescale` to 64 bits, which
|
|
151
|
+
// moves everything after them by eight bytes.
|
|
152
|
+
const timescaleAt = version === 1 ? mvhd.bodyAt + 20 : mvhd.bodyAt + 12;
|
|
153
|
+
const durationAt = timescaleAt + 4;
|
|
154
|
+
if (durationAt + 4 > mvhd.bodyEnd)
|
|
155
|
+
return null;
|
|
156
|
+
const timescale = uint32be(bytes, timescaleAt);
|
|
157
|
+
const duration = version === 1 ? uint64be(bytes, durationAt) : uint32be(bytes, durationAt);
|
|
158
|
+
if (timescale === 0 || duration === 0)
|
|
159
|
+
return null;
|
|
160
|
+
return Math.round((duration * 1000) / timescale);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The value of one `data` box, as the list of values it holds and the call made
|
|
164
|
+
* reading its text.
|
|
165
|
+
*
|
|
166
|
+
* A list because one entry may carry several: iTunes writes a collaboration
|
|
167
|
+
* into a single `©ART` separated by NULs, the same way an ID3v2 frame does, and
|
|
168
|
+
* splitting it is what keeps two artists from becoming one string.
|
|
169
|
+
*
|
|
170
|
+
* The verdict travels out with the values because this reader is the third one
|
|
171
|
+
* in the project and the only one that used to skip asking. It is kept even
|
|
172
|
+
* when no value survives the read: a call made on bytes that turned out not to
|
|
173
|
+
* be text is still a finding about the file.
|
|
174
|
+
*/
|
|
175
|
+
/**
|
|
176
|
+
* The integer at `from`, big-endian, in however many bytes the payload holds.
|
|
177
|
+
*
|
|
178
|
+
* Four at most: no atom this reads states more, and a longer run of bytes read
|
|
179
|
+
* as a number would be a number that means nothing.
|
|
180
|
+
*/
|
|
181
|
+
function readInteger(bytes, from, end) {
|
|
182
|
+
const width = Math.min(end - from, 4);
|
|
183
|
+
if (width <= 0)
|
|
184
|
+
return null;
|
|
185
|
+
let value = 0;
|
|
186
|
+
for (let i = 0; i < width; i += 1)
|
|
187
|
+
value = value * 256 + (bytes[from + i] ?? 0);
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
function valueOf(bytes, data, atomType) {
|
|
191
|
+
if (data.bodyAt + 8 > data.bodyEnd)
|
|
192
|
+
return null;
|
|
193
|
+
// The type indicator says what the payload is; the four bytes after it are a
|
|
194
|
+
// locale, which nothing here reads.
|
|
195
|
+
const kind = uint32be(bytes, data.bodyAt);
|
|
196
|
+
const from = data.bodyAt + 8;
|
|
197
|
+
/** A value that was never text: nothing was decoded, so there is no call. */
|
|
198
|
+
const plain = (values) => ({
|
|
199
|
+
values,
|
|
200
|
+
encoding: null,
|
|
201
|
+
});
|
|
202
|
+
if (atomType === ID3_GENRE_ATOM) {
|
|
203
|
+
// The genre list by number, one-based — see `NUMBER_ATOMS`.
|
|
204
|
+
if (from + 2 > data.bodyEnd)
|
|
205
|
+
return null;
|
|
206
|
+
const name = ID3V1_GENRES[uint16be(bytes, from) - 1];
|
|
207
|
+
return name === undefined ? null : plain([name]);
|
|
208
|
+
}
|
|
209
|
+
if (INTEGER_ATOMS[atomType] !== undefined) {
|
|
210
|
+
// Asked of the name and not of `kind`: these atoms are numbers whatever the
|
|
211
|
+
// payload declares, and the pair branch below would drop a one-byte one.
|
|
212
|
+
const value = readInteger(bytes, from, data.bodyEnd);
|
|
213
|
+
return value === null ? null : plain([String(value)]);
|
|
214
|
+
}
|
|
215
|
+
if (NUMBER_ATOMS[atomType] !== undefined) {
|
|
216
|
+
// Two zero bytes, then the number, then the total.
|
|
217
|
+
if (from + 6 > data.bodyEnd)
|
|
218
|
+
return null;
|
|
219
|
+
const first = uint16be(bytes, from + 2);
|
|
220
|
+
const total = uint16be(bytes, from + 4);
|
|
221
|
+
return plain([total > 0 ? `${first}/${total}` : String(first)]);
|
|
222
|
+
}
|
|
223
|
+
// 21 is a signed integer, which some taggers write a year as.
|
|
224
|
+
if (kind === 21) {
|
|
225
|
+
const value = readInteger(bytes, from, data.bodyEnd);
|
|
226
|
+
return value === null ? null : plain([String(value)]);
|
|
227
|
+
}
|
|
228
|
+
// Everything else — 13 and 14 are JPEG and PNG — is not text and is not read.
|
|
229
|
+
if (kind !== 1)
|
|
230
|
+
return null;
|
|
231
|
+
// The atom declares its payload UTF-8, and a declaration is a hint to verify
|
|
232
|
+
// rather than an answer. It is the same claim, and the same lie, as an ID3v2
|
|
233
|
+
// frame's encoding byte: the rippers who wrote CP1251 under a declaration of
|
|
234
|
+
// UTF-8 wrote it here too. A bare `TextDecoder` turned those bytes into
|
|
235
|
+
// mojibake and stored it as fact, with nothing recording that any call had
|
|
236
|
+
// been made — this reader was the one of the three that never asked.
|
|
237
|
+
const decoded = decodeText(bytes.subarray(from, data.bodyEnd));
|
|
238
|
+
const text = decoded.text;
|
|
239
|
+
const parts = text.split('\u0000').filter((part) => part !== '');
|
|
240
|
+
return { values: parts, encoding: decoded };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The handler type of every track in the movie — `soun`, `vide`, or something
|
|
244
|
+
* else again.
|
|
245
|
+
*
|
|
246
|
+
* This is the only thing in an MP4 that distinguishes a song from a video. The
|
|
247
|
+
* extension does not: an `.m4a` and a phone clip are the same container with
|
|
248
|
+
* the same `ftyp`, and a live clip carries both tracks at once. Rejecting video
|
|
249
|
+
* by extension is what this project's kind list does, and it is exactly the
|
|
250
|
+
* kind of name-based judgement the byte dispatch exists to avoid — so the
|
|
251
|
+
* judgement is made here instead, where the bytes are.
|
|
252
|
+
*/
|
|
253
|
+
function trackHandlers(bytes, moov) {
|
|
254
|
+
const handlers = [];
|
|
255
|
+
let at = moov.bodyAt;
|
|
256
|
+
while (at + 8 <= moov.bodyEnd) {
|
|
257
|
+
const box = boxAt(bytes, at, moov.bodyEnd);
|
|
258
|
+
if (box === null)
|
|
259
|
+
break;
|
|
260
|
+
if (box.type === 'trak') {
|
|
261
|
+
const mdia = findBox(bytes, box.bodyAt, box.bodyEnd, 'mdia');
|
|
262
|
+
const hdlr = mdia === null ? null : findBox(bytes, mdia.bodyAt, mdia.bodyEnd, 'hdlr');
|
|
263
|
+
// Past the version and flags, and the four-byte predefined field.
|
|
264
|
+
if (hdlr !== null)
|
|
265
|
+
handlers.push(nameAt(bytes, hdlr.bodyAt + 8));
|
|
266
|
+
}
|
|
267
|
+
if (box.bodyEnd <= at)
|
|
268
|
+
break;
|
|
269
|
+
at = box.bodyEnd;
|
|
270
|
+
}
|
|
271
|
+
return handlers;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Past a descriptor's tag and length, to the first byte of its payload.
|
|
275
|
+
*
|
|
276
|
+
* An MPEG-4 descriptor length is one to four bytes, and every byte but the last
|
|
277
|
+
* has its high bit set — the same continuation form the box sizes do not use.
|
|
278
|
+
*/
|
|
279
|
+
function pastDescriptor(bytes, at, limit) {
|
|
280
|
+
let cursor = at + 1;
|
|
281
|
+
while (cursor < limit && ((bytes[cursor] ?? 0) & 0x80) !== 0)
|
|
282
|
+
cursor += 1;
|
|
283
|
+
return cursor + 1;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The object type an `esds` declares, which for `mp4a` is what says AAC.
|
|
287
|
+
*
|
|
288
|
+
* Only the two descriptors on the path to it are walked — `ES_Descriptor`
|
|
289
|
+
* (tag 3) holding a `DecoderConfigDescriptor` (tag 4), whose first payload byte
|
|
290
|
+
* is the type. The tags are what identify them rather than the offsets,
|
|
291
|
+
* because an `ES_Descriptor` may carry a URL, an OCR code or a stream
|
|
292
|
+
* dependence between the two, and each of those shifts everything after it.
|
|
293
|
+
*/
|
|
294
|
+
function objectTypeOf(bytes, esds) {
|
|
295
|
+
const limit = esds.bodyEnd;
|
|
296
|
+
// A full box: four bytes of version and flags before the first descriptor.
|
|
297
|
+
let at = esds.bodyAt + 4;
|
|
298
|
+
if (at >= limit || bytes[at] !== 0x03)
|
|
299
|
+
return null;
|
|
300
|
+
at = pastDescriptor(bytes, at, limit);
|
|
301
|
+
at += 2; // the ES_ID, which is not an identifier this project has any use for
|
|
302
|
+
const flags = bytes[at] ?? 0;
|
|
303
|
+
at += 1;
|
|
304
|
+
if ((flags & 0x80) !== 0)
|
|
305
|
+
at += 2; // stream dependence
|
|
306
|
+
if ((flags & 0x40) !== 0)
|
|
307
|
+
at += 1; // URL
|
|
308
|
+
if ((flags & 0x20) !== 0)
|
|
309
|
+
at += 2; // OCR
|
|
310
|
+
if (at >= limit || bytes[at] !== 0x04)
|
|
311
|
+
return null;
|
|
312
|
+
at = pastDescriptor(bytes, at, limit);
|
|
313
|
+
return at < limit ? (bytes[at] ?? null) : null;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* What a sound track's sample description states, said the way ffprobe says it.
|
|
317
|
+
*
|
|
318
|
+
* This is the one place in an MP4 that answers the question a `.m4a` raises.
|
|
319
|
+
* The container is the same whether it holds AAC or Apple's ALAC, so the box
|
|
320
|
+
* name answers nothing — and beside the codec sit the channel count and the
|
|
321
|
+
* sample rate, which the decision path needs for the same reason. Not reading
|
|
322
|
+
* any of the three was not a gap in the meta layer but work on the path that
|
|
323
|
+
* answers a client: `codecOf` spawned ffprobe for every `.m4a` of a collection
|
|
324
|
+
* that has 1425 of them, and a client naming `maxAudioChannels` could not be
|
|
325
|
+
* answered from a row with no channel count at all, so those files were
|
|
326
|
+
* transcoded whole (task:2910).
|
|
327
|
+
*
|
|
328
|
+
* **Measured against ffprobe over that whole collection, not reasoned about.**
|
|
329
|
+
* All three fields, over all 1425 `.m4a`, no disagreement:
|
|
330
|
+
*
|
|
331
|
+
* - 1088 carry `mp4a` with an object type of 0x40, and ffprobe names every one
|
|
332
|
+
* of them `aac`; 337 carry `alac`, and ffprobe names every one of them
|
|
333
|
+
* `alac`. That is why the codec map below is written as narrowly as it is.
|
|
334
|
+
* - the channel count and the sample rate agree with ffprobe on 1425 of 1425,
|
|
335
|
+
* which is what makes them worth storing rather than asking about.
|
|
336
|
+
*
|
|
337
|
+
* A code this does not recognise is `null` rather than a guess, and null is not
|
|
338
|
+
* a failure: it is what sends the question on to a process that can answer it.
|
|
339
|
+
* Dolby on an `.m4a` is an ordinary thing to meet, and calling it AAC would be
|
|
340
|
+
* a number from nowhere.
|
|
341
|
+
*/
|
|
342
|
+
function formatOfSampleEntry(bytes, moov) {
|
|
343
|
+
const nothing = { codec: null, sampleRate: null, channels: null };
|
|
344
|
+
let at = moov.bodyAt;
|
|
345
|
+
while (at + 8 <= moov.bodyEnd) {
|
|
346
|
+
const trak = boxAt(bytes, at, moov.bodyEnd);
|
|
347
|
+
if (trak === null)
|
|
348
|
+
break;
|
|
349
|
+
if (trak.type === 'trak') {
|
|
350
|
+
const mdia = findBox(bytes, trak.bodyAt, trak.bodyEnd, 'mdia');
|
|
351
|
+
const hdlr = mdia === null ? null : findBox(bytes, mdia.bodyAt, mdia.bodyEnd, 'hdlr');
|
|
352
|
+
// Only a sound track describes audio; a picture track's samples are not
|
|
353
|
+
// what any of this is about, and a live clip carries both at once.
|
|
354
|
+
if (mdia !== null && hdlr !== null && nameAt(bytes, hdlr.bodyAt + 8) === 'soun') {
|
|
355
|
+
const minf = findBox(bytes, mdia.bodyAt, mdia.bodyEnd, 'minf');
|
|
356
|
+
const stbl = minf === null ? null : findBox(bytes, minf.bodyAt, minf.bodyEnd, 'stbl');
|
|
357
|
+
const stsd = stbl === null ? null : findBox(bytes, stbl.bodyAt, stbl.bodyEnd, 'stsd');
|
|
358
|
+
// Full box: version and flags, then the entry count, then the entries.
|
|
359
|
+
const entry = stsd === null ? null : boxAt(bytes, stsd.bodyAt + 8, stsd.bodyEnd);
|
|
360
|
+
if (entry === null)
|
|
361
|
+
return nothing;
|
|
362
|
+
// An *audio* sample entry states both, at fixed offsets: six reserved
|
|
363
|
+
// bytes and a data reference index, then version, revision and vendor,
|
|
364
|
+
// then the channel count, the sample size, two predefined fields, and
|
|
365
|
+
// the sample rate as a 16.16 fixed number — whose integer half is the
|
|
366
|
+
// rate. A zero in either is the format's way of saying it is not there,
|
|
367
|
+
// and null is this project's.
|
|
368
|
+
const channels = uint16be(bytes, entry.bodyAt + 16);
|
|
369
|
+
const rate = uint16be(bytes, entry.bodyAt + 24);
|
|
370
|
+
const measured = {
|
|
371
|
+
sampleRate: rate === 0 ? null : rate,
|
|
372
|
+
channels: channels === 0 ? null : channels,
|
|
373
|
+
};
|
|
374
|
+
if (entry.type === 'alac')
|
|
375
|
+
return { codec: 'alac', ...measured };
|
|
376
|
+
if (entry.type !== 'mp4a')
|
|
377
|
+
return { ...nothing, ...measured };
|
|
378
|
+
// `mp4a` keeps its children 28 bytes in, past the fields above.
|
|
379
|
+
const esds = findBox(bytes, entry.bodyAt + 28, entry.bodyEnd, 'esds');
|
|
380
|
+
const objectType = esds === null ? null : objectTypeOf(bytes, esds);
|
|
381
|
+
// 0x40 is MPEG-4 audio, and 0x66/0x67/0x68 are the AAC profiles that
|
|
382
|
+
// are still AAC. 0x69 and 0x6b are MPEG-1 and MPEG-2 audio wrapped in
|
|
383
|
+
// an MP4, which is a real thing an old encoder produced.
|
|
384
|
+
const named = objectType === 0x40 || objectType === 0x66 || objectType === 0x67 || objectType === 0x68
|
|
385
|
+
? 'aac'
|
|
386
|
+
: objectType === 0x69 || objectType === 0x6b
|
|
387
|
+
? 'mp3'
|
|
388
|
+
: null;
|
|
389
|
+
return { codec: named, ...measured };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (trak.bodyEnd <= at)
|
|
393
|
+
break;
|
|
394
|
+
at = trak.bodyEnd;
|
|
395
|
+
}
|
|
396
|
+
return nothing;
|
|
397
|
+
}
|
|
398
|
+
/** Every value the `ilst` holds, in the order the file states them. */
|
|
399
|
+
function readIlst(bytes, ilst, into) {
|
|
400
|
+
let at = ilst.bodyAt;
|
|
401
|
+
while (at + 8 <= ilst.bodyEnd) {
|
|
402
|
+
const entry = boxAt(bytes, at, ilst.bodyEnd);
|
|
403
|
+
if (entry === null)
|
|
404
|
+
return;
|
|
405
|
+
// The cover, as an entry of its own. Its `data` box states what the image
|
|
406
|
+
// is — 13 is JPEG and 14 is PNG, the two the format defines — and the bytes
|
|
407
|
+
// follow the type indicator and a locale. `covr` has no picture *type*
|
|
408
|
+
// because it has only one meaning, and it is recorded as a front cover,
|
|
409
|
+
// which is the type that wins when a file also carries something else.
|
|
410
|
+
if (entry.type === 'covr') {
|
|
411
|
+
let payloadAt = entry.bodyAt;
|
|
412
|
+
while (payloadAt + 8 <= entry.bodyEnd) {
|
|
413
|
+
const data = boxAt(bytes, payloadAt, entry.bodyEnd);
|
|
414
|
+
if (data === null)
|
|
415
|
+
break;
|
|
416
|
+
if (data.type === 'data' && data.bodyAt + 8 <= data.bodyEnd) {
|
|
417
|
+
const kind = uint32be(bytes, data.bodyAt);
|
|
418
|
+
const mime = kind === 13 ? 'image/jpeg' : kind === 14 ? 'image/png' : null;
|
|
419
|
+
const from = data.bodyAt + 8;
|
|
420
|
+
if (mime !== null && data.bodyEnd > from) {
|
|
421
|
+
into.picture = betterPicture(into.picture, {
|
|
422
|
+
mime,
|
|
423
|
+
kind: FRONT_COVER,
|
|
424
|
+
offset: from,
|
|
425
|
+
length: data.bodyEnd - from,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (data.bodyEnd <= payloadAt)
|
|
430
|
+
break;
|
|
431
|
+
payloadAt = data.bodyEnd;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const name = TEXT_ATOMS[entry.type] ?? NUMBER_ATOMS[entry.type] ?? INTEGER_ATOMS[entry.type];
|
|
435
|
+
if (name !== undefined) {
|
|
436
|
+
// An entry is a box of `data` boxes, and may hold more than one.
|
|
437
|
+
let payloadAt = entry.bodyAt;
|
|
438
|
+
while (payloadAt + 8 <= entry.bodyEnd) {
|
|
439
|
+
const data = boxAt(bytes, payloadAt, entry.bodyEnd);
|
|
440
|
+
if (data === null)
|
|
441
|
+
break;
|
|
442
|
+
if (data.type === 'data') {
|
|
443
|
+
const read = valueOf(bytes, data, entry.type);
|
|
444
|
+
if (read !== null) {
|
|
445
|
+
// The weakest call across the file, the way FLAC and ID3 report it:
|
|
446
|
+
// a title that decoded cleanly says nothing about the artist beside
|
|
447
|
+
// it that did not.
|
|
448
|
+
if (read.encoding !== null) {
|
|
449
|
+
into.encoding = weakestEncoding(into.encoding, read.encoding);
|
|
450
|
+
}
|
|
451
|
+
// ...and a value that is not text is not stored. Same judgement the
|
|
452
|
+
// other two readers make, and for the same reason: splitting such a
|
|
453
|
+
// payload on its NULs makes a dozen one-character tags that then
|
|
454
|
+
// outrank a folder name that was readable all along.
|
|
455
|
+
for (const value of read.values) {
|
|
456
|
+
if (looksLikeText(value))
|
|
457
|
+
into.tags.push({ name, value });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (data.bodyEnd <= payloadAt)
|
|
462
|
+
break;
|
|
463
|
+
payloadAt = data.bodyEnd;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (entry.bodyEnd <= at)
|
|
467
|
+
return;
|
|
468
|
+
at = entry.bodyEnd;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Read an MP4's length and names.
|
|
473
|
+
*
|
|
474
|
+
* Never throws: a truncated file, a box that claims a size it does not have and
|
|
475
|
+
* a file with no index at all each end the walk where it stands, because the
|
|
476
|
+
* caller is a scan over a whole collection.
|
|
477
|
+
*/
|
|
478
|
+
export function readMp4(bytes) {
|
|
479
|
+
// No codec yet: the sample description is read further down, and only after
|
|
480
|
+
// the track types have said this is a song — see `formatOfSampleEntry`.
|
|
481
|
+
const into = noTags('mp4');
|
|
482
|
+
const moov = findBox(bytes, 0, bytes.length, 'moov');
|
|
483
|
+
if (moov === null) {
|
|
484
|
+
// No index: a download that stopped early, or a fragmented file whose
|
|
485
|
+
// `moov` is still being written. Not a length — but a question worth
|
|
486
|
+
// asking, which is what the refusal says.
|
|
487
|
+
//
|
|
488
|
+
// A video truncated the same way is indistinguishable here, and would be
|
|
489
|
+
// asked about too. That is left alone deliberately: it takes a video that
|
|
490
|
+
// is also classified as audio, which the kind list prevents, and the
|
|
491
|
+
// alternative — declining anything whose index is missing — would throw
|
|
492
|
+
// away the length of every half-downloaded song instead.
|
|
493
|
+
return { ...into, durationRefused: true };
|
|
494
|
+
}
|
|
495
|
+
// A picture anywhere in the movie makes it a video, sound track or not: a
|
|
496
|
+
// live clip has both, and the collection keeps those as clips. A sound track
|
|
497
|
+
// is required, a picture track is disqualifying.
|
|
498
|
+
//
|
|
499
|
+
// `noTags('mp4')` rather than `noTags()`: the file has been understood, and
|
|
500
|
+
// saying otherwise made the stage report `tag-format-unknown` — "a format the
|
|
501
|
+
// reader does not recognise, no tags, no duration" — about an mp4 whose boxes
|
|
502
|
+
// it had just walked. The dump was being told the wrong thing.
|
|
503
|
+
//
|
|
504
|
+
// A video misclassified as audio is a finding, and it is not that one, so it
|
|
505
|
+
// travels out as itself: `video` says the picture track was seen, and the
|
|
506
|
+
// stage reports it under its own name. Reading it here is the whole of what
|
|
507
|
+
// this reader can do about it — the kind came from the extension, and the
|
|
508
|
+
// reader is the only thing that has looked inside the boxes.
|
|
509
|
+
const handlers = trackHandlers(bytes, moov);
|
|
510
|
+
if (handlers.includes('vide') || !handlers.includes('soun')) {
|
|
511
|
+
return handlers.includes('vide') ? { ...noTags('mp4'), video: true } : noTags('mp4');
|
|
512
|
+
}
|
|
513
|
+
// What is inside the container, which the container's own name does not say.
|
|
514
|
+
// Read here rather than beside the boxes above so that a video never gets
|
|
515
|
+
// any of it: the picture track's sample description describes pictures.
|
|
516
|
+
const format = formatOfSampleEntry(bytes, moov);
|
|
517
|
+
into.codec = format.codec;
|
|
518
|
+
into.sampleRate = format.sampleRate;
|
|
519
|
+
into.channels = format.channels;
|
|
520
|
+
const mvhd = findBox(bytes, moov.bodyAt, moov.bodyEnd, 'mvhd');
|
|
521
|
+
if (mvhd !== null)
|
|
522
|
+
into.durationMs = lengthFromMvhd(bytes, mvhd);
|
|
523
|
+
if (into.durationMs === null)
|
|
524
|
+
into.durationRefused = true;
|
|
525
|
+
const udta = findBox(bytes, moov.bodyAt, moov.bodyEnd, 'udta');
|
|
526
|
+
const meta = udta === null ? null : findBox(bytes, udta.bodyAt, udta.bodyEnd, 'meta');
|
|
527
|
+
// `meta` is a full box: four bytes of version and flags sit before its
|
|
528
|
+
// children, and a walk that starts at the payload reads those bytes as a
|
|
529
|
+
// box header and finds nothing after them.
|
|
530
|
+
const ilst = meta === null ? null : findBox(bytes, meta.bodyAt + 4, meta.bodyEnd, 'ilst');
|
|
531
|
+
if (ilst !== null)
|
|
532
|
+
readIlst(bytes, ilst, into);
|
|
533
|
+
return into;
|
|
534
|
+
}
|