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/ogg.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { betterPicture, noTags } from "./types.js";
|
|
2
|
+
import { pictureInComment, readCommentList } from "./vorbis-comment.js";
|
|
3
|
+
/**
|
|
4
|
+
* Read an Ogg stream's tags, codec and length.
|
|
5
|
+
*
|
|
6
|
+
* Written against **RFC 3533** — the format's own encapsulation specification —
|
|
7
|
+
* for the page layer, **Vorbis I** for the Vorbis half, and **RFC 7845** for the
|
|
8
|
+
* Opus half. Every rule below is a citation rather than a recollection, which is
|
|
9
|
+
* the standard this directory was swept to after a reader written from memory
|
|
10
|
+
* turned out to have been misreading real files since the day it was written
|
|
11
|
+
* (see `flac.ts`).
|
|
12
|
+
*
|
|
13
|
+
* Ogg is a container of containers, and that shapes the whole reader. A file is
|
|
14
|
+
* a sequence of *pages*; each page carries pieces of *packets*; a packet belongs
|
|
15
|
+
* to one of possibly several *logical bitstreams* multiplexed into the same
|
|
16
|
+
* physical one. So the walk below is three nested loops that answer three
|
|
17
|
+
* different questions — where the next page starts, whether a packet has
|
|
18
|
+
* finished, and whether this page is even ours — and getting any one of them
|
|
19
|
+
* wrong produces a reader that silently reads the wrong bytes.
|
|
20
|
+
*
|
|
21
|
+
* Two facts about the format make this cheap. The name and value pairs live in
|
|
22
|
+
* the second packet, which is a few hundred bytes into the file, so the walk
|
|
23
|
+
* stops collecting as soon as they are read. And the length is a number the last
|
|
24
|
+
* page *states* — no decoding, no bitrate arithmetic.
|
|
25
|
+
*
|
|
26
|
+
* The walk is defensive by design, like every reader here: a truncated download,
|
|
27
|
+
* a page claiming more bytes than the file holds, a comment list full of
|
|
28
|
+
* nonsense — each ends the walk with whatever was already gathered, because the
|
|
29
|
+
* caller is a scan over a whole collection and one damaged file must not be able
|
|
30
|
+
* to stop it.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* RFC 3533 §6, field 1: every page opens with these four bytes.
|
|
34
|
+
*
|
|
35
|
+
* They are what `read.ts` dispatches on, and they are also the only thing that
|
|
36
|
+
* says where a page begins — so a file whose stream is damaged in the middle is
|
|
37
|
+
* picked up again here rather than abandoned.
|
|
38
|
+
*/
|
|
39
|
+
const CAPTURE = 'OggS';
|
|
40
|
+
/**
|
|
41
|
+
* RFC 3533 §6: the header is `number_page_segments + 27` bytes.
|
|
42
|
+
*
|
|
43
|
+
* The count is *inside* the fixed part and the segment table it describes is
|
|
44
|
+
* not, which is the arithmetic every wrong implementation gets wrong by one.
|
|
45
|
+
*/
|
|
46
|
+
const PAGE_HEADER_BYTES = 27;
|
|
47
|
+
/**
|
|
48
|
+
* The granule position of a page no packet finishes on.
|
|
49
|
+
*
|
|
50
|
+
* RFC 3533 §6, field 4: "a special value of -1 (in two's complement) indicates
|
|
51
|
+
* that no packets finish on this page". It is not a position, and reading it as
|
|
52
|
+
* one gives 18446744073709551615 samples — about thirteen million years.
|
|
53
|
+
*/
|
|
54
|
+
const NO_GRANULE = -1n;
|
|
55
|
+
/**
|
|
56
|
+
* The rate an Opus granule counts in, whatever the encoder was fed.
|
|
57
|
+
*
|
|
58
|
+
* RFC 7845 §4: "The granule position of an audio data page is in units of PCM
|
|
59
|
+
* audio samples at a fixed rate of 48 kHz (per channel)". §5.1 is emphatic that
|
|
60
|
+
* the `Input Sample Rate` field in the identification header is *not* this — it
|
|
61
|
+
* records what the encoder was handed before resampling, and no decoder plays at
|
|
62
|
+
* it. Both facts are needed to get a length out of an Opus file, and taking the
|
|
63
|
+
* input rate as the playback rate is the mistake this constant exists to stop.
|
|
64
|
+
*/
|
|
65
|
+
const OPUS_RATE = 48_000;
|
|
66
|
+
/**
|
|
67
|
+
* The last Opus version this reader will look at.
|
|
68
|
+
*
|
|
69
|
+
* RFC 7845 §5.1, item 2: the version's upper four bits name the major version,
|
|
70
|
+
* and "an implementation of this specification SHOULD accept any stream with a
|
|
71
|
+
* version number of '15' or less, and SHOULD assume any stream with a version
|
|
72
|
+
* number '16' or greater is incompatible". So a later major version is declined
|
|
73
|
+
* rather than guessed at — the layout could be anything.
|
|
74
|
+
*/
|
|
75
|
+
const OPUS_MAX_VERSION = 15;
|
|
76
|
+
/**
|
|
77
|
+
* The largest packet this reader will assemble, in bytes.
|
|
78
|
+
*
|
|
79
|
+
* A header packet is a few hundred bytes, and a comment packet carrying a cover
|
|
80
|
+
* is a few megabytes at the very outside. The bound is here because a file can
|
|
81
|
+
* be built whose second packet never finishes — page after page of 255-value
|
|
82
|
+
* lacing, to the end of the file — and the walk below would then hold a view of
|
|
83
|
+
* every segment and hand the lot to `concat`, which copies the whole file a
|
|
84
|
+
* second time. Measured on a 60 MB file of exactly that shape: 246,725 pieces
|
|
85
|
+
* held, a second 60 MB allocated, for a file with no tags in it at all.
|
|
86
|
+
*
|
|
87
|
+
* Past the bound the walk stops collecting and drops what it held. It does not
|
|
88
|
+
* stop walking, because the pages still have to be read for the granule — so a
|
|
89
|
+
* file this happens to still gets its length.
|
|
90
|
+
*/
|
|
91
|
+
const MAX_HEADER_PACKET_BYTES = 16 * 1024 * 1024;
|
|
92
|
+
function readUInt32LE(bytes, at) {
|
|
93
|
+
return (((bytes[at] ?? 0) |
|
|
94
|
+
((bytes[at + 1] ?? 0) << 8) |
|
|
95
|
+
((bytes[at + 2] ?? 0) << 16) |
|
|
96
|
+
((bytes[at + 3] ?? 0) << 24)) >>>
|
|
97
|
+
0);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* RFC 3533 §6, field 4: eight bytes, and — like every multi-byte field in this
|
|
101
|
+
* format — least significant byte first.
|
|
102
|
+
*
|
|
103
|
+
* Read as a *signed* number because the format uses -1 as a sentinel, and built
|
|
104
|
+
* as a BigInt because the field is 64 bits wide: a long recording's sample count
|
|
105
|
+
* passes 2^32 within a day of audio, and a reader that kept it in a double would
|
|
106
|
+
* start losing the low bits of a number that is exact everywhere upstream.
|
|
107
|
+
*/
|
|
108
|
+
function readGranule(bytes, at) {
|
|
109
|
+
let value = 0n;
|
|
110
|
+
for (let i = 7; i >= 0; i -= 1)
|
|
111
|
+
value = (value << 8n) | BigInt(bytes[at + i] ?? 0);
|
|
112
|
+
// Two's complement: §6 says -1 is the sentinel, which only means anything if
|
|
113
|
+
// the value is signed.
|
|
114
|
+
return value >= 0x8000000000000000n ? value - 0x10000000000000000n : value;
|
|
115
|
+
}
|
|
116
|
+
function magicAt(bytes, at, magic) {
|
|
117
|
+
if (at + magic.length > bytes.length)
|
|
118
|
+
return false;
|
|
119
|
+
for (let i = 0; i < magic.length; i += 1) {
|
|
120
|
+
if (bytes[at + i] !== magic.charCodeAt(i))
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The page at `at`, or null when there is not one there.
|
|
127
|
+
*
|
|
128
|
+
* Null is the answer to every way a page can fail to be a page, and they are
|
|
129
|
+
* deliberately not distinguished: the caller has nothing different to do about a
|
|
130
|
+
* page that is missing, one that is cut off by the end of the file, one whose
|
|
131
|
+
* segment table promises more body than the file holds, and one written by a
|
|
132
|
+
* version of this format that is not the one specified here. Each of them ends
|
|
133
|
+
* the walk, and each of them ends it with what was already read.
|
|
134
|
+
*
|
|
135
|
+
* The checksum is not verified, and that is a decision rather than an omission.
|
|
136
|
+
* RFC 3533 §6 defines one and a decoder "verifies page sync and integrity by
|
|
137
|
+
* computing and comparing" it — but the only thing this reader could do about a
|
|
138
|
+
* page that failed is discard it, and discarding a page of a file that is
|
|
139
|
+
* otherwise readable costs that file its tags over one flipped bit. The pages
|
|
140
|
+
* this reader *needs* are the first two, and a file whose first two pages are
|
|
141
|
+
* corrupt has nothing to lose by being read anyway.
|
|
142
|
+
*/
|
|
143
|
+
function pageAt(bytes, at) {
|
|
144
|
+
if (at + PAGE_HEADER_BYTES > bytes.length)
|
|
145
|
+
return null;
|
|
146
|
+
if (!magicAt(bytes, at, CAPTURE))
|
|
147
|
+
return null;
|
|
148
|
+
// §6, field 2: "this document specifies version 0". A different value is a
|
|
149
|
+
// different layout under the same four magic bytes, and guessing at it is
|
|
150
|
+
// worse than declining.
|
|
151
|
+
if (bytes[at + 4] !== 0)
|
|
152
|
+
return null;
|
|
153
|
+
const segmentCount = bytes[at + 26] ?? 0;
|
|
154
|
+
const segmentsAt = at + PAGE_HEADER_BYTES;
|
|
155
|
+
const bodyAt = segmentsAt + segmentCount;
|
|
156
|
+
if (bodyAt > bytes.length)
|
|
157
|
+
return null;
|
|
158
|
+
// §6: the page's own size is the sum of its lacing values. A table that adds
|
|
159
|
+
// up past the end of the file is a truncated download, and the last page of
|
|
160
|
+
// one is exactly what reading this is supposed to survive.
|
|
161
|
+
let body = 0;
|
|
162
|
+
for (let i = 0; i < segmentCount; i += 1)
|
|
163
|
+
body += bytes[segmentsAt + i] ?? 0;
|
|
164
|
+
const end = bodyAt + body;
|
|
165
|
+
if (end > bytes.length)
|
|
166
|
+
return null;
|
|
167
|
+
return {
|
|
168
|
+
headerType: bytes[at + 5] ?? 0,
|
|
169
|
+
granule: readGranule(bytes, at + 6),
|
|
170
|
+
serial: readUInt32LE(bytes, at + 14),
|
|
171
|
+
segmentCount,
|
|
172
|
+
segmentsAt,
|
|
173
|
+
bodyAt,
|
|
174
|
+
end,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Which header packet this is, or null when it is neither.
|
|
179
|
+
*
|
|
180
|
+
* Both codecs identify their headers in the packet's first bytes, and both put
|
|
181
|
+
* the identification header first and the comments second (Vorbis I §4.2.1,
|
|
182
|
+
* RFC 7845 §3). The identification header is checked for three of the conditions
|
|
183
|
+
* its specification makes decodability depend on — Vorbis I §4.2.2 requires
|
|
184
|
+
* `vorbis_version` to read 0, and requires a non-zero channel count and sample
|
|
185
|
+
* rate; RFC 7845 §5.1 requires a non-zero channel count too — because a file
|
|
186
|
+
* that fails them is one whose other fields mean something this reader has no
|
|
187
|
+
* way to know. It is declined here rather than reported as a stream with a
|
|
188
|
+
* length.
|
|
189
|
+
*
|
|
190
|
+
* §4.2.2 asks for two more — a valid block size pair and a non-zero framing bit
|
|
191
|
+
* — and neither is enforced. Both are the same kind of rule as the comment
|
|
192
|
+
* name's character range in `vorbis-comment.ts`: keeping it would mean dropping
|
|
193
|
+
* a file that a decoder might well play, and a stream whose channel count and
|
|
194
|
+
* rate are readable is one worth reading the comments of.
|
|
195
|
+
*
|
|
196
|
+
* The Vorbis *setup* header is not recognised and does not need to be: nothing
|
|
197
|
+
* in this reader goes past the comments, and a packet it cannot name ends the
|
|
198
|
+
* header sequence where it stands.
|
|
199
|
+
*/
|
|
200
|
+
function headerOf(packet) {
|
|
201
|
+
// Vorbis I §4.2.1: header packets open with a type octet — 1 identification,
|
|
202
|
+
// 3 comment, 5 setup — followed by the **six** octets `vorbis` (§4.2.1 counts
|
|
203
|
+
// them, and a comment that says five is the kind of small wrongness this file
|
|
204
|
+
// exists to not have).
|
|
205
|
+
if (magicAt(packet, 1, 'vorbis')) {
|
|
206
|
+
const type = packet[0];
|
|
207
|
+
if (type === 0x01 && packet.length >= 30) {
|
|
208
|
+
const version = readUInt32LE(packet, 7);
|
|
209
|
+
const channels = packet[11] ?? 0;
|
|
210
|
+
const sampleRate = readUInt32LE(packet, 12);
|
|
211
|
+
if (version !== 0 || channels === 0 || sampleRate === 0)
|
|
212
|
+
return null;
|
|
213
|
+
return { role: 'identification', codec: 'vorbis', channels, sampleRate };
|
|
214
|
+
}
|
|
215
|
+
if (type === 0x03)
|
|
216
|
+
return { role: 'comments', codec: 'vorbis', at: 7 };
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
// RFC 7845 §5: the two Opus headers carry an eight-byte signature instead.
|
|
220
|
+
if (magicAt(packet, 0, 'OpusHead') && packet.length >= 19) {
|
|
221
|
+
const version = packet[8] ?? 0;
|
|
222
|
+
const channels = packet[9] ?? 0;
|
|
223
|
+
if (version > OPUS_MAX_VERSION || channels === 0)
|
|
224
|
+
return null;
|
|
225
|
+
// §5.1, item 4: pre-skip is 16 bits, little endian.
|
|
226
|
+
const preSkip = (packet[10] ?? 0) | ((packet[11] ?? 0) << 8);
|
|
227
|
+
return { role: 'identification', codec: 'opus', channels, preSkip };
|
|
228
|
+
}
|
|
229
|
+
if (magicAt(packet, 0, 'OpusTags'))
|
|
230
|
+
return { role: 'comments', codec: 'opus', at: 8 };
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Walk the pages of one logical stream, reporting what it finishes.
|
|
235
|
+
*
|
|
236
|
+
* The format's three nested loops in one place: pages in file order, their
|
|
237
|
+
* segments in table order, and the packets those segments add up to. Two callers
|
|
238
|
+
* want different things out of the same walk — the reader wants the two header
|
|
239
|
+
* packets and the last page's granule, `oggPicture` wants the picture the
|
|
240
|
+
* comment packet holds — and a second copy of the lacing rule is a second place
|
|
241
|
+
* for it to be wrong.
|
|
242
|
+
*
|
|
243
|
+
* `onPacket` sees each packet the stream *finishes*, in order, and returning
|
|
244
|
+
* false stops it seeing any more. `onPage` sees every page of the stream whether
|
|
245
|
+
* a packet finished on it or not, which is where the granule comes from.
|
|
246
|
+
*/
|
|
247
|
+
function walkStream(bytes, serial, onPacket, onPage) {
|
|
248
|
+
let pending = [];
|
|
249
|
+
let held = 0;
|
|
250
|
+
let index = 0;
|
|
251
|
+
let collecting = true;
|
|
252
|
+
let at = 0;
|
|
253
|
+
while (at < bytes.length) {
|
|
254
|
+
const page = pageAt(bytes, at);
|
|
255
|
+
if (page === null)
|
|
256
|
+
break;
|
|
257
|
+
at = page.end;
|
|
258
|
+
if (page.serial !== serial)
|
|
259
|
+
continue;
|
|
260
|
+
onPage?.(page);
|
|
261
|
+
// Once there is nothing left to collect the pages are still walked, and that
|
|
262
|
+
// is not waste: the granule of the last one is the length of the file, and
|
|
263
|
+
// it is stated nowhere else.
|
|
264
|
+
if (!collecting)
|
|
265
|
+
continue;
|
|
266
|
+
let cursor = page.bodyAt;
|
|
267
|
+
for (let i = 0; i < page.segmentCount; i += 1) {
|
|
268
|
+
const lace = bytes[page.segmentsAt + i] ?? 0;
|
|
269
|
+
pending.push(bytes.subarray(cursor, cursor + lace));
|
|
270
|
+
cursor += lace;
|
|
271
|
+
held += lace;
|
|
272
|
+
// A packet that has not finished and has grown past any size a header can
|
|
273
|
+
// be is not a header becoming readable — it is a file built to make this
|
|
274
|
+
// hold everything it is given. Dropping what was collected costs such a
|
|
275
|
+
// file its tags, which it does not have.
|
|
276
|
+
if (held > MAX_HEADER_PACKET_BYTES) {
|
|
277
|
+
collecting = false;
|
|
278
|
+
pending = [];
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
// RFC 3533 §5: a lacing value of 255 means "this packet is not finished",
|
|
282
|
+
// and any value below it ends the packet. That single rule is the whole of
|
|
283
|
+
// the framing — and it is why a packet may be spread across a page
|
|
284
|
+
// boundary, and why the pieces have to be collected rather than read where
|
|
285
|
+
// they lie.
|
|
286
|
+
if (lace === 255)
|
|
287
|
+
continue;
|
|
288
|
+
const packet = concat(pending);
|
|
289
|
+
pending = [];
|
|
290
|
+
held = 0;
|
|
291
|
+
index += 1;
|
|
292
|
+
if (!onPacket(packet, index, page)) {
|
|
293
|
+
collecting = false;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Read an Ogg file's tags, codec, length and cover.
|
|
301
|
+
*
|
|
302
|
+
* Never throws: a file that opens with `OggS` and then turns into something else
|
|
303
|
+
* ends the walk where it stands, exactly as a damaged FLAC ends its block walk.
|
|
304
|
+
*/
|
|
305
|
+
export function readOgg(bytes) {
|
|
306
|
+
const opening = pageAt(bytes, 0);
|
|
307
|
+
// `OggS` and then no page: the magic without the format. Answered as "nothing
|
|
308
|
+
// recognised" rather than as an Ogg with no tags, so that the scan still
|
|
309
|
+
// reports it as a format this project cannot read.
|
|
310
|
+
if (opening === null)
|
|
311
|
+
return noTags();
|
|
312
|
+
const into = noTags('ogg');
|
|
313
|
+
// RFC 3533 §6, field 5: the serial number is "the unique serial number by
|
|
314
|
+
// which the logical bitstream is identified". A physical stream may carry
|
|
315
|
+
// several — a video track beside the audio — interleaved page by page, and a
|
|
316
|
+
// reader that followed all of them would read another stream's packets as
|
|
317
|
+
// this one's. The first page is the one that says which stream this file is
|
|
318
|
+
// about, and every later page of a different serial is stepped over.
|
|
319
|
+
const serial = opening.serial;
|
|
320
|
+
let codec = null;
|
|
321
|
+
let sampleRate = null;
|
|
322
|
+
let preSkip = 0;
|
|
323
|
+
let lastGranule = null;
|
|
324
|
+
walkStream(bytes, serial, (packet, index, page) => {
|
|
325
|
+
const header = headerOf(packet);
|
|
326
|
+
// Something that is neither codec's header, or one of theirs this reader
|
|
327
|
+
// does not read. Either way the file's headers are over, and collecting
|
|
328
|
+
// any more would be collecting audio.
|
|
329
|
+
if (header === null)
|
|
330
|
+
return false;
|
|
331
|
+
if (header.role === 'identification' && index === 1) {
|
|
332
|
+
codec = header.codec;
|
|
333
|
+
if (header.codec === 'vorbis') {
|
|
334
|
+
sampleRate = header.sampleRate;
|
|
335
|
+
into.channels = header.channels;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
// §4: an Opus granule counts at 48 kHz whatever §5.1's input sample
|
|
339
|
+
// rate says, so that is the rate this stream is measured in.
|
|
340
|
+
sampleRate = OPUS_RATE;
|
|
341
|
+
preSkip = header.preSkip;
|
|
342
|
+
into.channels = header.channels;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
// The comment header is the second packet of either codec, and the only
|
|
347
|
+
// one whose contents this reader keeps.
|
|
348
|
+
if (header.role === 'comments' && index === 2 && header.codec === codec) {
|
|
349
|
+
const picture = readCommentList(packet, header.at, packet.length, into);
|
|
350
|
+
if (picture !== null) {
|
|
351
|
+
// Where to find it again. Not the picture's bytes, because there is no
|
|
352
|
+
// range of this file that holds them — the block is base64 inside a
|
|
353
|
+
// packet the lacing rule has scattered across pages. So what is
|
|
354
|
+
// recorded is the head of the file through the page the comment ends
|
|
355
|
+
// on, which is a range worth reading and holds everything needed to
|
|
356
|
+
// derive them again. See `src/cover/picture.ts`.
|
|
357
|
+
into.picture = betterPicture(into.picture, {
|
|
358
|
+
mime: picture.mime,
|
|
359
|
+
kind: picture.kind,
|
|
360
|
+
indirect: true,
|
|
361
|
+
offset: 0,
|
|
362
|
+
length: page.end,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}, (page) => {
|
|
368
|
+
// §6, field 4: the granule is the running total up to the last packet the
|
|
369
|
+
// page *finishes*. Kept from every page of ours, so what stands at the end
|
|
370
|
+
// is the last one the stream states — and the -1 sentinel is skipped
|
|
371
|
+
// rather than kept, because a page that finished nothing has stated
|
|
372
|
+
// nothing.
|
|
373
|
+
if (page.granule !== NO_GRANULE)
|
|
374
|
+
lastGranule = page.granule;
|
|
375
|
+
});
|
|
376
|
+
into.codec = codec;
|
|
377
|
+
into.sampleRate = sampleRate;
|
|
378
|
+
// The length, stated by the stream rather than arrived at. Vorbis I §A.2
|
|
379
|
+
// counts a Vorbis granule in PCM samples; RFC 7845 §4.3 gives the Opus one as
|
|
380
|
+
// the granule *less the pre-skip*, because the samples the pre-skip names are
|
|
381
|
+
// decoder padding that no player ever emits. A granule that is zero or
|
|
382
|
+
// negative states no audio, which is no length rather than a zero-length one.
|
|
383
|
+
if (lastGranule !== null && lastGranule > 0n) {
|
|
384
|
+
if (codec === 'vorbis' && sampleRate !== null) {
|
|
385
|
+
into.durationMs = Math.round((Number(lastGranule) / sampleRate) * 1000);
|
|
386
|
+
}
|
|
387
|
+
else if (codec === 'opus') {
|
|
388
|
+
const samples = lastGranule - BigInt(preSkip);
|
|
389
|
+
if (samples > 0n)
|
|
390
|
+
into.durationMs = Math.round((Number(samples) / OPUS_RATE) * 1000);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// The bytes were understood and their length was not established: a stream cut
|
|
394
|
+
// off before any page states one. That is a question worth handing on, and a
|
|
395
|
+
// different answer from a file whose format nothing here recognised — see
|
|
396
|
+
// `TagRead.durationRefused`.
|
|
397
|
+
//
|
|
398
|
+
// `codec === null` reaches here as well, and it belongs here. An Ogg holding a
|
|
399
|
+
// codec this project does not read — Theora, Speex, FLAC-in-Ogg — has a length
|
|
400
|
+
// in it that this reader cannot interpret, so the container was understood and
|
|
401
|
+
// the length was not. Left out, such a file is the one thing the contract
|
|
402
|
+
// forbids: read, found unreadable, and reported to nobody — not as an unknown
|
|
403
|
+
// format, since the container *is* known, and not to ffprobe either.
|
|
404
|
+
if (into.durationMs === null)
|
|
405
|
+
into.durationRefused = true;
|
|
406
|
+
return into;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The collected pieces of a packet as one array.
|
|
410
|
+
*
|
|
411
|
+
* `Buffer.concat` where it is available, because this runs once per packet and
|
|
412
|
+
* a scan opens fifty thousand files; the fallback keeps the reader usable
|
|
413
|
+
* anywhere the bytes are a plain `Uint8Array`, which is what its signature
|
|
414
|
+
* promises.
|
|
415
|
+
*/
|
|
416
|
+
function concat(pieces) {
|
|
417
|
+
if (pieces.length === 1)
|
|
418
|
+
return pieces[0];
|
|
419
|
+
let total = 0;
|
|
420
|
+
for (const piece of pieces)
|
|
421
|
+
total += piece.length;
|
|
422
|
+
const out = new Uint8Array(total);
|
|
423
|
+
let at = 0;
|
|
424
|
+
for (const piece of pieces) {
|
|
425
|
+
out.set(piece, at);
|
|
426
|
+
at += piece.length;
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The picture an Ogg file carries, derived again from its bytes.
|
|
432
|
+
*
|
|
433
|
+
* The serving side of this reader, and the reason `TagPicture.indirect` exists.
|
|
434
|
+
* A cover is asked for long after the scan that noticed it, from a file nothing
|
|
435
|
+
* is holding in memory — and because the picture is base64 inside a packet
|
|
436
|
+
* scattered across pages, there is no range of the file that could have been
|
|
437
|
+
* written down instead. So the bytes are derived on demand, from the region the
|
|
438
|
+
* scan recorded, by the same walk and the same parser that found them the first
|
|
439
|
+
* time.
|
|
440
|
+
*
|
|
441
|
+
* `bytes` is that region: the head of the file through the page the comment
|
|
442
|
+
* ends on, which for a real file is a couple of hundred kilobytes — the whole
|
|
443
|
+
* point of recording an end rather than reading the file.
|
|
444
|
+
*/
|
|
445
|
+
export function oggPicture(bytes) {
|
|
446
|
+
const opening = pageAt(bytes, 0);
|
|
447
|
+
if (opening === null)
|
|
448
|
+
return null;
|
|
449
|
+
let found = null;
|
|
450
|
+
walkStream(bytes, opening.serial, (packet) => {
|
|
451
|
+
const header = headerOf(packet);
|
|
452
|
+
// The headers are over, so this packet is audio and there is nothing further
|
|
453
|
+
// to look at. The identification header is stepped past; the comment is the
|
|
454
|
+
// one that is read.
|
|
455
|
+
if (header === null)
|
|
456
|
+
return false;
|
|
457
|
+
if (header.role !== 'comments')
|
|
458
|
+
return true;
|
|
459
|
+
found = pictureInComment(packet, header.at, packet.length);
|
|
460
|
+
return false;
|
|
461
|
+
});
|
|
462
|
+
return found;
|
|
463
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The picture block, as RFC 9639 section 8.8 defines it.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `flac.ts` and shared, because the same block turns up in two
|
|
5
|
+
* places that have nothing else to do with each other. A FLAC file carries it as
|
|
6
|
+
* metadata block type 6; a Vorbis comment carries it base64-encoded under the
|
|
7
|
+
* name `METADATA_BLOCK_PICTURE`, which is how an Ogg file holds a cover at all —
|
|
8
|
+
* Ogg has no metadata block of its own to put one in. The bytes of the block are
|
|
9
|
+
* identical in both, down to the four 32-bit dimensions in the middle, and one
|
|
10
|
+
* parser for them is one place for the layout to be wrong.
|
|
11
|
+
*
|
|
12
|
+
* In order: a 32-bit picture type, a 32-bit length and the MIME string, another
|
|
13
|
+
* length and the description, then four 32-bit numbers (width, height, colour
|
|
14
|
+
* depth, number of colours), then the length of the image and the image. The
|
|
15
|
+
* four dimensions are stepped over rather than read — a cover's size is the
|
|
16
|
+
* client's business, and none of them is a number this project wants.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is null when the block is not laid out the way the section
|
|
19
|
+
* says. A description in the middle is what makes that likely enough to check:
|
|
20
|
+
* it is arbitrary text of arbitrary length, and a reader that trusted the two
|
|
21
|
+
* lengths it read first would point at the middle of a caption and serve it as
|
|
22
|
+
* a picture.
|
|
23
|
+
*/
|
|
24
|
+
function readUInt32BE(bytes, at) {
|
|
25
|
+
return ((((bytes[at] ?? 0) << 24) |
|
|
26
|
+
((bytes[at + 1] ?? 0) << 16) |
|
|
27
|
+
((bytes[at + 2] ?? 0) << 8) |
|
|
28
|
+
(bytes[at + 3] ?? 0)) >>>
|
|
29
|
+
0);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the picture block occupying `[at, end)`, or null when there is not one.
|
|
33
|
+
*
|
|
34
|
+
* `dataAt` is an offset into the array this was handed, whatever that array is —
|
|
35
|
+
* the file, for a FLAC metadata block, or a decoded comment value, for one that
|
|
36
|
+
* came base64-encoded out of a Vorbis comment. The caller is the only one that
|
|
37
|
+
* knows which, and the two mean different things by it, so it is stated rather
|
|
38
|
+
* than assumed: see `TagPicture.pages` on why an Ogg picture is not a range of
|
|
39
|
+
* its file at all.
|
|
40
|
+
*/
|
|
41
|
+
export function pictureBlockAt(bytes, at, end) {
|
|
42
|
+
if (end > bytes.length)
|
|
43
|
+
return null;
|
|
44
|
+
const take = (cursor) => {
|
|
45
|
+
if (cursor + 4 > end)
|
|
46
|
+
return null;
|
|
47
|
+
return { value: readUInt32BE(bytes, cursor), next: cursor + 4 };
|
|
48
|
+
};
|
|
49
|
+
const type = take(at);
|
|
50
|
+
if (type === null)
|
|
51
|
+
return null;
|
|
52
|
+
const mimeLength = take(type.next);
|
|
53
|
+
if (mimeLength === null)
|
|
54
|
+
return null;
|
|
55
|
+
const mimeStart = mimeLength.next;
|
|
56
|
+
if (mimeStart + mimeLength.value > end)
|
|
57
|
+
return null;
|
|
58
|
+
const mime = Buffer.from(bytes.subarray(mimeStart, mimeStart + mimeLength.value)).toString('latin1');
|
|
59
|
+
const descriptionLength = take(mimeStart + mimeLength.value);
|
|
60
|
+
if (descriptionLength === null)
|
|
61
|
+
return null;
|
|
62
|
+
// The description, then the four dimensions, then the length of the image.
|
|
63
|
+
const imageLengthAt = descriptionLength.next + descriptionLength.value + 16;
|
|
64
|
+
const imageLength = take(imageLengthAt);
|
|
65
|
+
if (imageLength === null)
|
|
66
|
+
return null;
|
|
67
|
+
const dataAt = imageLength.next;
|
|
68
|
+
if (imageLength.value <= 0 || dataAt + imageLength.value > end)
|
|
69
|
+
return null;
|
|
70
|
+
return { mime, kind: type.value, dataAt, dataLength: imageLength.value };
|
|
71
|
+
}
|