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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tags of a song, as bytes a container can carry.
|
|
3
|
+
*
|
|
4
|
+
* A cue track is not a file — it is a stretch of an image — so when this server
|
|
5
|
+
* serves one it *builds* the file, and the file it built had no tags at all:
|
|
6
|
+
* measured on the live collection, a cut segment arrived with one `STREAMINFO`
|
|
7
|
+
* block and nothing else, and a client that saved it for offline use had a file
|
|
8
|
+
* whose own player could show nothing but the file name (task:2895). What is
|
|
9
|
+
* here is the missing half of that answer.
|
|
10
|
+
*
|
|
11
|
+
* **The tags come from the meta layer, not from the image.** Copying the
|
|
12
|
+
* image's own blocks was the obvious fix and the measurement refused it: of the
|
|
13
|
+
* 156 FLAC images that hold split tracks, 27 carry tags — and those tags are the
|
|
14
|
+
* *disc's*. `Кино - Ночь (MKK861CD1).flac` states `title = Ночь (MKK861CD1)` and
|
|
15
|
+
* `album = lossless-galaxy.ru`; put on a cut track, the first is the name of a
|
|
16
|
+
* disc the track is one song of, and the second is the site it was downloaded
|
|
17
|
+
* from. A track wearing those is worse off than a track wearing nothing, because
|
|
18
|
+
* a player shows them with confidence. The meta layer is the only thing that
|
|
19
|
+
* knows what this track is, so it is the only thing asked.
|
|
20
|
+
*
|
|
21
|
+
* Names are the ones the collection already states — `title`, `artist`, `album`,
|
|
22
|
+
* `albumartist`, `tracknumber`, `discnumber`, `date`, `genre` are the eight most
|
|
23
|
+
* common names in `file_tag` (measured: 1943–3220 rows each, ahead of `comment`
|
|
24
|
+
* at 745) — so what a file this server wrote states is what a file it read
|
|
25
|
+
* states, and the reader needed no new spelling to understand its own output.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* What this writer calls itself, which is what the vendor field is for.
|
|
29
|
+
*
|
|
30
|
+
* Vorbis I §5.2.1 defines the vendor string as identifying the *software*, not
|
|
31
|
+
* the collection, and it is written for the same reason a `TENC` frame is: a
|
|
32
|
+
* file that says who wrote it can be asked why it looks the way it does.
|
|
33
|
+
*/
|
|
34
|
+
const VENDOR = 'funoteka';
|
|
35
|
+
/** The fields to write, in a fixed order, each as `NAME=value` — Vorbis I §5.2.2. */
|
|
36
|
+
function fields(tags) {
|
|
37
|
+
const wanted = [
|
|
38
|
+
['TITLE', tags.title],
|
|
39
|
+
['ARTIST', tags.artist],
|
|
40
|
+
['ALBUMARTIST', tags.albumArtist],
|
|
41
|
+
['ALBUM', tags.album],
|
|
42
|
+
['TRACKNUMBER', tags.trackNumber],
|
|
43
|
+
['DISCNUMBER', tags.discNumber],
|
|
44
|
+
['DATE', tags.date],
|
|
45
|
+
['GENRE', tags.genre],
|
|
46
|
+
];
|
|
47
|
+
return wanted
|
|
48
|
+
.filter((entry) => entry[1] !== null && entry[1] !== '')
|
|
49
|
+
.map(([name, value]) => [name, String(value)]);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The body of a `VORBIS_COMMENT` metadata block (Vorbis I §5.2.1, RFC 9639
|
|
53
|
+
* §8.6, which is the same layout).
|
|
54
|
+
*
|
|
55
|
+
* A 32-bit vendor length and its bytes, a 32-bit field count, then each field as
|
|
56
|
+
* its own length and bytes — every length little-endian, every string UTF-8. The
|
|
57
|
+
* block *header* is not here: that is the container's, and `stream/flac.ts`
|
|
58
|
+
* writes it, because a block header is three bytes of type and size that only a
|
|
59
|
+
* FLAC file has.
|
|
60
|
+
*
|
|
61
|
+
* A field is written even when it is the only one, and the block is written even
|
|
62
|
+
* when there are none — an empty comment list is a valid block that says the
|
|
63
|
+
* file states nothing, which is a different statement from a file with no
|
|
64
|
+
* comment block at all, and the one this server can make.
|
|
65
|
+
*/
|
|
66
|
+
export function vorbisComment(tags) {
|
|
67
|
+
const vendor = Buffer.from(VENDOR, 'utf8');
|
|
68
|
+
const written = fields(tags).map(([name, value]) => Buffer.from(`${name}=${value}`, 'utf8'));
|
|
69
|
+
const at = (length) => {
|
|
70
|
+
const buffer = Buffer.alloc(4);
|
|
71
|
+
buffer.writeUInt32LE(length, 0);
|
|
72
|
+
return buffer;
|
|
73
|
+
};
|
|
74
|
+
return Buffer.concat([
|
|
75
|
+
at(vendor.length),
|
|
76
|
+
vendor,
|
|
77
|
+
at(written.length),
|
|
78
|
+
...written.map((field) => Buffer.concat([at(field.length), field])),
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A whole ID3v2.4 tag, which is what an mp3 segment is served behind.
|
|
83
|
+
*
|
|
84
|
+
* **Version 2.4 and not 2.3, and the reason is the collection.** 2.3 has two
|
|
85
|
+
* encodings and neither is UTF-8 — a title with one Cyrillic letter in it needs
|
|
86
|
+
* UTF-16 with a byte-order mark, and this collection is full of them. 2.4 added
|
|
87
|
+
* UTF-8 as a third encoding, and every reader that matters — this project's own
|
|
88
|
+
* (`tags/id3v2.ts`) and ffmpeg's — reads it. The alternative is writing a
|
|
89
|
+
* byte-order mark into every song whose name is not ASCII and hoping the reader
|
|
90
|
+
* guesses the endianness, which is the guess the reader's own comments spend a
|
|
91
|
+
* page explaining.
|
|
92
|
+
*
|
|
93
|
+
* The frame sizes are **syncsafe** here, which is 2.4's other change: four bytes
|
|
94
|
+
* of seven bits each, so that a size can never contain a byte that looks like a
|
|
95
|
+
* frame sync. That is the trap in writing 2.4 by hand, and the test reads the
|
|
96
|
+
* result back through this project's own reader rather than asserting the bytes.
|
|
97
|
+
*/
|
|
98
|
+
export function id3v2(tags) {
|
|
99
|
+
const frames = [];
|
|
100
|
+
for (const [name, value] of fields(tags)) {
|
|
101
|
+
frames.push(textFrame(FRAMES[name] ?? name, value));
|
|
102
|
+
}
|
|
103
|
+
return Buffer.concat([header(frames), ...frames]);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The frame identifiers a name is written under.
|
|
107
|
+
*
|
|
108
|
+
* 2.4's own set, and the date is the one worth stating: `TYER` is 2.3's frame
|
|
109
|
+
* for a year and 2.4 replaced it with `TDRC`, which carries a whole timestamp —
|
|
110
|
+
* so a year goes in `TDRC` because that is the frame a 2.4 reader looks for, and
|
|
111
|
+
* the value is short rather than wrong. `tags/id3v2.ts` reads both and prefers
|
|
112
|
+
* the one that states more, so a file this server wrote and a file a ripper
|
|
113
|
+
* wrote are read by the same rule.
|
|
114
|
+
*/
|
|
115
|
+
const FRAMES = {
|
|
116
|
+
TITLE: 'TIT2',
|
|
117
|
+
ARTIST: 'TPE1',
|
|
118
|
+
ALBUMARTIST: 'TPE2',
|
|
119
|
+
ALBUM: 'TALB',
|
|
120
|
+
TRACKNUMBER: 'TRCK',
|
|
121
|
+
DISCNUMBER: 'TPOS',
|
|
122
|
+
DATE: 'TDRC',
|
|
123
|
+
GENRE: 'TCON',
|
|
124
|
+
};
|
|
125
|
+
/** Four bytes of id, four of syncsafe size, two of flags — the informal 2.4 §4. */
|
|
126
|
+
function textFrame(id, value) {
|
|
127
|
+
const text = Buffer.concat([Buffer.from([0x03]), Buffer.from(value, 'utf8')]);
|
|
128
|
+
const frame = Buffer.alloc(10);
|
|
129
|
+
frame.write(id, 0, 'latin1');
|
|
130
|
+
syncsafe(text.length).copy(frame, 4);
|
|
131
|
+
// Flags are zero: no compression, no encryption, no grouping, no unsynchronisation.
|
|
132
|
+
return Buffer.concat([frame, text]);
|
|
133
|
+
}
|
|
134
|
+
/** The tag header: `ID3`, version, flags, and the size of what follows (2.4 §3.1). */
|
|
135
|
+
function header(frames) {
|
|
136
|
+
const body = frames.reduce((total, frame) => total + frame.length, 0);
|
|
137
|
+
const head = Buffer.alloc(10);
|
|
138
|
+
head.write('ID3', 0, 'latin1');
|
|
139
|
+
head[3] = 0x04;
|
|
140
|
+
head[4] = 0x00;
|
|
141
|
+
head[5] = 0x00;
|
|
142
|
+
syncsafe(body).copy(head, 6);
|
|
143
|
+
return head;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A length as four seven-bit bytes, most significant first (2.4 §6.2).
|
|
147
|
+
*
|
|
148
|
+
* The top bit of every byte is clear, so no byte of a size can be mistaken for a
|
|
149
|
+
* frame sync or a terminator by a scanner that is looking for one. A length that
|
|
150
|
+
* does not fit in 28 bits is refused rather than truncated: it cannot happen for
|
|
151
|
+
* a tag this writer builds, and the failure of getting it wrong silently is a
|
|
152
|
+
* tag whose frames are read from the wrong offsets.
|
|
153
|
+
*/
|
|
154
|
+
function syncsafe(value) {
|
|
155
|
+
if (!Number.isInteger(value) || value < 0 || value >= 1 << 28) {
|
|
156
|
+
throw new Error(`a tag length must fit in four syncsafe bytes: ${value}`);
|
|
157
|
+
}
|
|
158
|
+
return Buffer.from([
|
|
159
|
+
(value >> 21) & 0x7f,
|
|
160
|
+
(value >> 14) & 0x7f,
|
|
161
|
+
(value >> 7) & 0x7f,
|
|
162
|
+
value & 0x7f,
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tags a song listing asks for, as one row of their own per file.
|
|
3
|
+
*
|
|
4
|
+
* A listing builds a `Child` per song, and eight of that payload's fields come
|
|
5
|
+
* out of `file_tag`: the genre, the track artist, the two advisories that decide
|
|
6
|
+
* `explicitStatus`, and the four ReplayGain numbers.
|
|
7
|
+
*
|
|
8
|
+
* **A page of five hundred songs costs 10.04 ms reading them the obvious way —
|
|
9
|
+
* one correlated subquery per name — and 6.37 ms reading them off this row**,
|
|
10
|
+
* measured 2026-09-16 on a copy of the live collection, the two statements
|
|
11
|
+
* interleaved in one run (task:2925). The saving is 36% of the heaviest listing
|
|
12
|
+
* route there is.
|
|
13
|
+
*
|
|
14
|
+
* **The shape was not the defect, and the measurement is why this is a table and
|
|
15
|
+
* not a query rewrite.** Every alternative is worse: all eight as one
|
|
16
|
+
* `json_group_object` is no faster and answers differently; a `LEFT JOIN` to a
|
|
17
|
+
* derived pivot — the shape `ALBUM_GENRES` uses — cannot be restricted to the
|
|
18
|
+
* page, so it materialises over the whole tag table and multiplies the page's
|
|
19
|
+
* cost by an order of magnitude; a covering index `(file_id, name, position,
|
|
20
|
+
* value)` is **thirteen times** worse when forced, and the planner will not
|
|
21
|
+
* choose it because it gives up the free ordering; `WITHOUT ROWID` five times
|
|
22
|
+
* worse. What is left is that eight seeks a song are eight seeks a song.
|
|
23
|
+
*
|
|
24
|
+
* So the values move out of the tag table and a file's row is joined the way
|
|
25
|
+
* `audio_probe` already is: one primary-key lookup, one row a file. The join
|
|
26
|
+
* itself is not quite free — the page without any tag columns at all is 6.56 ms
|
|
27
|
+
* in a run that put the eight subqueries at 11.29 — but it is an order of
|
|
28
|
+
* magnitude cheaper than asking the tag table, and it is what the 36% above is
|
|
29
|
+
* made of.
|
|
30
|
+
*
|
|
31
|
+
* **The rule is the API's own and is written once**, in `SELECT` below: the
|
|
32
|
+
* *first value by position* of each name, trimmed of the whitespace a tag can
|
|
33
|
+
* hide in, an empty result counted as no value at all so that a second line
|
|
34
|
+
* falls through to a first. `migrations/036_file_tag_first.sql` carries the
|
|
35
|
+
* frozen copy of it for the backfill; this is the one that runs from here on.
|
|
36
|
+
*
|
|
37
|
+
* What is deliberately *not* here is a cheaper-looking `MIN(position)` beside a
|
|
38
|
+
* bare `value`, which reads the value out of whichever row the aggregate
|
|
39
|
+
* happened to keep — SQLite guarantees that only while the subquery holds
|
|
40
|
+
* exactly one aggregate, so a later `COUNT(*)` added beside it would silently
|
|
41
|
+
* turn the genre into an arbitrary row's genre. `albumGenres` in `meta.ts`
|
|
42
|
+
* declines that form at length, and this declines it for the same reason.
|
|
43
|
+
*/
|
|
44
|
+
/** What a `Child` shows, and the tag each column is read from. */
|
|
45
|
+
const FIRST_TAGS = [
|
|
46
|
+
['genre', 'genre'],
|
|
47
|
+
['artist', 'track_artist'],
|
|
48
|
+
['itunesadvisory', 'advisory_itunes'],
|
|
49
|
+
['rtng', 'advisory_mp4'],
|
|
50
|
+
['replaygain_track_gain', 'rg_track_gain'],
|
|
51
|
+
['replaygain_album_gain', 'rg_album_gain'],
|
|
52
|
+
['replaygain_track_peak', 'rg_track_peak'],
|
|
53
|
+
['replaygain_album_peak', 'rg_album_peak'],
|
|
54
|
+
];
|
|
55
|
+
const COLUMNS = FIRST_TAGS.map(([, column]) => column);
|
|
56
|
+
/**
|
|
57
|
+
* The whitespace a tag value is measured through, spelled out.
|
|
58
|
+
*
|
|
59
|
+
* Not SQLite's one-argument `TRIM`, which removes spaces and leaves a tab — so a
|
|
60
|
+
* tag holding `"\t"` would pass the emptiness guard and arrive at a client as a
|
|
61
|
+
* genre nobody can see. `meta.ts` spells the same set beside `trimmed`, and
|
|
62
|
+
* `migrations/036_file_tag_first.sql` spells it a third time for the backfill.
|
|
63
|
+
*
|
|
64
|
+
* **Three spellings, and only two of them are held together by a test.**
|
|
65
|
+
* `test/tags-first.test.ts` compares this module against its own oracle, written
|
|
66
|
+
* in the old subquery form; the migration's copy is compared to neither, and no
|
|
67
|
+
* test can reach it — on a fresh database `file_tag` is empty, so its `INSERT`
|
|
68
|
+
* selects nothing. The three were checked against each other by hand on a copy
|
|
69
|
+
* of the live collection (3280 rows, no drift) and against the eight subqueries
|
|
70
|
+
* on nineteen awkward synthetic shapes (no drift); what is missing is a way to
|
|
71
|
+
* keep them honest without a person.
|
|
72
|
+
*/
|
|
73
|
+
const TRIMMED = `TRIM(ft.value, ' ' || CHAR(9) || CHAR(10) || CHAR(13) || CHAR(160) || CHAR(12288))`;
|
|
74
|
+
const NAMES = FIRST_TAGS.map(([name]) => `'${name}'`).join(', ');
|
|
75
|
+
/**
|
|
76
|
+
* One file's row, from the tags it has now.
|
|
77
|
+
*
|
|
78
|
+
* `rn = 1` leaves one row per (file, name), so the `MAX` below has nothing to
|
|
79
|
+
* choose between and is exact rather than arbitrary — the whole reason for the
|
|
80
|
+
* window function.
|
|
81
|
+
*/
|
|
82
|
+
const SELECT = `
|
|
83
|
+
SELECT ft.file_id,
|
|
84
|
+
${FIRST_TAGS.map(([name, column]) => `MAX(CASE WHEN ft.name = '${name}' THEN ft.value END) AS ${column}`).join(',\n ')}
|
|
85
|
+
FROM (SELECT ft.file_id, ft.name, ${TRIMMED} AS value,
|
|
86
|
+
ROW_NUMBER() OVER (PARTITION BY ft.file_id, ft.name ORDER BY ft.position) AS rn
|
|
87
|
+
FROM file_tag ft
|
|
88
|
+
WHERE ft.file_id = ? AND ft.name IN (${NAMES}) AND ${TRIMMED} <> '') ft
|
|
89
|
+
WHERE ft.rn = 1
|
|
90
|
+
GROUP BY ft.file_id`;
|
|
91
|
+
/**
|
|
92
|
+
* The two statements the tags stage runs for each file it has just read.
|
|
93
|
+
*
|
|
94
|
+
* Prepared once and handed back rather than prepared per file, which is how
|
|
95
|
+
* every other statement in that stage is treated: it is inside one
|
|
96
|
+
* `BEGIN IMMEDIATE`, and the compiler is not what this should be spending the
|
|
97
|
+
* write lock on.
|
|
98
|
+
*
|
|
99
|
+
* **Cleared before it is written, and not upserted.** A file whose genre tag was
|
|
100
|
+
* taken out of it has to lose the genre — and a write that only inserts when
|
|
101
|
+
* there is something to insert would leave the old row standing, because a file
|
|
102
|
+
* with no tags of these eight names produces no row at all. Clearing first makes
|
|
103
|
+
* "no row" and "no tags" the same thing, which is what a file nobody has read
|
|
104
|
+
* has always been.
|
|
105
|
+
*/
|
|
106
|
+
export function firstTagStatements(db) {
|
|
107
|
+
return {
|
|
108
|
+
clear: db.prepare('DELETE FROM file_tag_first WHERE file_id = ?'),
|
|
109
|
+
write: db.prepare(`INSERT INTO file_tag_first (file_id, ${COLUMNS.join(', ')}) ${SELECT}`),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The same pair, prepared and run, for one file.
|
|
114
|
+
*
|
|
115
|
+
* For a caller that has one file in hand and no reason to keep a statement
|
|
116
|
+
* around — a test writing `file_tag` by hand and standing in for the scanner,
|
|
117
|
+
* which is the only other thing that writes it. Both roads go through `SELECT`
|
|
118
|
+
* above, so the rule has one spelling either way, and a test that wrote the tags
|
|
119
|
+
* without writing this row would be describing a collection the scanner cannot
|
|
120
|
+
* produce.
|
|
121
|
+
*
|
|
122
|
+
* It prepares both statements on every call, which is a cost a caller in a loop
|
|
123
|
+
* should not pay: a fixture rebuilding many files wants `firstTagStatements`
|
|
124
|
+
* once and its two statements inside the loop. The stage does exactly that.
|
|
125
|
+
*/
|
|
126
|
+
export function refreshFirstTags(db, fileId) {
|
|
127
|
+
const { clear, write } = firstTagStatements(db);
|
|
128
|
+
clear.run(fileId);
|
|
129
|
+
write.run(fileId);
|
|
130
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { pictureBlockAt } from "./picture.js";
|
|
2
|
+
import { betterPicture, noTags } from "./types.js";
|
|
3
|
+
import { readCommentList } from "./vorbis-comment.js";
|
|
4
|
+
/**
|
|
5
|
+
* Read a FLAC stream's metadata blocks.
|
|
6
|
+
*
|
|
7
|
+
* Written against **RFC 9639** — the format's standard (IETF Standards Track,
|
|
8
|
+
* December 2024) — so that every section number below is a citation and not a
|
|
9
|
+
* recollection. The other readers in this directory were written the second way,
|
|
10
|
+
* and on 2026-09-12 one of them turned out to have been misreading real files
|
|
11
|
+
* since it was written; this is the sweep that followed.
|
|
12
|
+
*
|
|
13
|
+
* The comments inside a FLAC block are described by section 8.6 and by Xiph.Org's
|
|
14
|
+
* `v-comment` page, which is the Vorbis original — where the two differ, section
|
|
15
|
+
* 8.6 is what a FLAC file does.
|
|
16
|
+
*
|
|
17
|
+
* Everything a file has to say about itself lives in this chain: the block walk
|
|
18
|
+
* that finds the Vorbis comments walks past the duration on the way, which is
|
|
19
|
+
* why both come out of one pass and why neither costs a process.
|
|
20
|
+
*
|
|
21
|
+
* The walk is defensive by design. A truncated download, a block claiming more
|
|
22
|
+
* bytes than the file holds, a comment block full of nonsense — each ends the
|
|
23
|
+
* walk with whatever was already gathered, because the caller is a scan over a
|
|
24
|
+
* whole collection and one damaged file must not be able to stop it.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* RFC 9639 (FLAC), section 8.1: a metadata block opens with a four-byte header —
|
|
28
|
+
* a last-block flag, a seven-bit type, and the block's size in bytes as a
|
|
29
|
+
* three-byte big-endian number *excluding* the header.
|
|
30
|
+
*
|
|
31
|
+
* The same section forbids type 127. It is not special-cased here: the walk
|
|
32
|
+
* steps over any unrecognised block by its declared size, which is the only
|
|
33
|
+
* thing a reader can do with a block it does not know, and stopping on it would
|
|
34
|
+
* cost the file its tags.
|
|
35
|
+
*
|
|
36
|
+
* The block types this reader needs (Table 2): 0 is the stream information,
|
|
37
|
+
* 4 the Vorbis comments. A file also carries padding, a seek table, pictures and
|
|
38
|
+
* an application block, none of which say anything about who made the record.
|
|
39
|
+
*/
|
|
40
|
+
const BLOCK_STREAMINFO = 0;
|
|
41
|
+
const BLOCK_VORBIS_COMMENT = 4;
|
|
42
|
+
const BLOCK_PICTURE = 6;
|
|
43
|
+
function readUInt24BE(bytes, at) {
|
|
44
|
+
return ((bytes[at] ?? 0) << 16) | ((bytes[at + 1] ?? 0) << 8) | (bytes[at + 2] ?? 0);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read the block at `at` — the body, past its four-byte header.
|
|
48
|
+
*
|
|
49
|
+
* A zero in any of the four size fields means the encoder did not state it
|
|
50
|
+
* (section 8.2), which is why they are carried out rather than folded into a
|
|
51
|
+
* duration here: a caller rebuilding a stream needs to know what was stated and
|
|
52
|
+
* what was not, and only this function knows the difference.
|
|
53
|
+
*/
|
|
54
|
+
export function flacStreamInfo(bytes, at) {
|
|
55
|
+
const uint = (index, width) => {
|
|
56
|
+
let value = 0;
|
|
57
|
+
for (let i = 0; i < width; i += 1)
|
|
58
|
+
value = (value << 8) | (bytes[at + index + i] ?? 0);
|
|
59
|
+
return value;
|
|
60
|
+
};
|
|
61
|
+
// 16 + 16 + 24 + 24 bits before the packed tail: rate(20) | channels-1(3) |
|
|
62
|
+
// bits-1(5) | total samples(36).
|
|
63
|
+
let packed = 0n;
|
|
64
|
+
for (let i = 0; i < 8; i += 1)
|
|
65
|
+
packed = (packed << 8n) | BigInt(bytes[at + 10 + i] ?? 0);
|
|
66
|
+
return {
|
|
67
|
+
minBlockSize: uint(0, 2),
|
|
68
|
+
maxBlockSize: uint(2, 2),
|
|
69
|
+
minFrameSize: uint(4, 3),
|
|
70
|
+
maxFrameSize: uint(7, 3),
|
|
71
|
+
sampleRate: Number(packed >> 44n),
|
|
72
|
+
channels: Number((packed >> 41n) & 7n) + 1,
|
|
73
|
+
bitsPerSample: Number((packed >> 36n) & 31n) + 1,
|
|
74
|
+
totalSamples: Number(packed & ((1n << 36n) - 1n)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function readStreamInfo(bytes, at, into) {
|
|
78
|
+
const info = flacStreamInfo(bytes, at);
|
|
79
|
+
into.sampleRate = info.sampleRate;
|
|
80
|
+
into.channels = info.channels;
|
|
81
|
+
into.bitsPerSample = info.bitsPerSample;
|
|
82
|
+
if (info.sampleRate > 0 && info.totalSamples > 0) {
|
|
83
|
+
into.durationMs = Math.round((info.totalSamples / info.sampleRate) * 1000);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Where the metadata ends and the audio begins.
|
|
88
|
+
*
|
|
89
|
+
* Section 8: the blocks follow the `fLaC` signature directly and come before any
|
|
90
|
+
* audio frame, and the last of them says so with a flag in its header — so the
|
|
91
|
+
* first frame starts where that block ends. Null when the signature is absent or
|
|
92
|
+
* a block claims more bytes than are here, which is a file whose audio offset is
|
|
93
|
+
* not knowable rather than one that starts at some other place.
|
|
94
|
+
*
|
|
95
|
+
* This walks the same chain `readFlac` walks, which is deliberate and not an
|
|
96
|
+
* oversight: that one is gathering tags and must keep whatever it found when a
|
|
97
|
+
* file turns out to be damaged, and this one wants a single number and would
|
|
98
|
+
* rather have none than a wrong one.
|
|
99
|
+
*/
|
|
100
|
+
export function flacAudioStart(bytes) {
|
|
101
|
+
if (bytes.length < 4)
|
|
102
|
+
return null;
|
|
103
|
+
if (bytes[0] !== 0x66 || bytes[1] !== 0x4c || bytes[2] !== 0x61 || bytes[3] !== 0x43)
|
|
104
|
+
return null;
|
|
105
|
+
let at = 4;
|
|
106
|
+
while (at + 4 <= bytes.length) {
|
|
107
|
+
const header = bytes[at] ?? 0;
|
|
108
|
+
const body = at + 4 + readUInt24BE(bytes, at + 1);
|
|
109
|
+
if (body > bytes.length)
|
|
110
|
+
return null;
|
|
111
|
+
at = body;
|
|
112
|
+
if ((header & 0x80) !== 0)
|
|
113
|
+
return at;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The picture a PICTURE block carries.
|
|
119
|
+
*
|
|
120
|
+
* The block's layout is RFC 9639 section 8.8 and lives in `picture.ts`, because
|
|
121
|
+
* the same block turns up base64-encoded in a Vorbis comment — one parser for
|
|
122
|
+
* it is one place for the layout to be wrong. What is here is the part that is
|
|
123
|
+
* this container's own: the block is a contiguous run of the file, so the image
|
|
124
|
+
* inside it is named by a *file* offset, which is what `TagPicture` means
|
|
125
|
+
* everywhere except in the one case the type marks.
|
|
126
|
+
*/
|
|
127
|
+
function readPicture(bytes, at, length, into) {
|
|
128
|
+
const found = pictureBlockAt(bytes, at, at + length);
|
|
129
|
+
if (found === null)
|
|
130
|
+
return;
|
|
131
|
+
into.picture = betterPicture(into.picture, {
|
|
132
|
+
mime: found.mime,
|
|
133
|
+
kind: found.kind,
|
|
134
|
+
offset: found.dataAt,
|
|
135
|
+
length: found.dataLength,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Walk the chain of metadata blocks.
|
|
140
|
+
*
|
|
141
|
+
* Section 8: the blocks follow the `fLaC` signature directly and come before any
|
|
142
|
+
* audio frame, and the first of them must be the stream information. Nothing
|
|
143
|
+
* here insists on that order — a file that puts its stream information later is
|
|
144
|
+
* broken, section 8.2 leaves a decoder's behaviour on broken stream information
|
|
145
|
+
* unspecified, and the reading that costs the collection least is to take what
|
|
146
|
+
* the blocks say and keep walking. A second stream information block, which the
|
|
147
|
+
* same section forbids, would be read the same way: last one wins.
|
|
148
|
+
*/
|
|
149
|
+
export function readFlac(bytes) {
|
|
150
|
+
const result = noTags('flac', 'flac');
|
|
151
|
+
let at = 4; // past the `fLaC` marker the caller has already checked
|
|
152
|
+
while (at + 4 <= bytes.length) {
|
|
153
|
+
const header = bytes[at] ?? 0;
|
|
154
|
+
const last = (header & 0x80) !== 0;
|
|
155
|
+
const type = header & 0x7f;
|
|
156
|
+
const length = readUInt24BE(bytes, at + 1);
|
|
157
|
+
const body = at + 4;
|
|
158
|
+
// A block promising more bytes than the file holds means the file is
|
|
159
|
+
// damaged; what was read before it stands.
|
|
160
|
+
if (body + length > bytes.length)
|
|
161
|
+
break;
|
|
162
|
+
if (type === BLOCK_STREAMINFO && length >= 34)
|
|
163
|
+
readStreamInfo(bytes, body, result);
|
|
164
|
+
else if (type === BLOCK_VORBIS_COMMENT)
|
|
165
|
+
readCommentBlock(bytes, body, length, result);
|
|
166
|
+
else if (type === BLOCK_PICTURE)
|
|
167
|
+
readPicture(bytes, body, length, result);
|
|
168
|
+
at = body + length;
|
|
169
|
+
if (last)
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The Vorbis comment block (section 8.6), read by the same code an Ogg file's
|
|
176
|
+
* comments are read by — the block and the packet hold the same list, and
|
|
177
|
+
* `vorbis-comment.ts` is where that is stated once.
|
|
178
|
+
*
|
|
179
|
+
* A picture carried here rather than in a PICTURE block is a `METADATA_BLOCK_PICTURE`
|
|
180
|
+
* comment: base64 inside the block, so the image is *not* a contiguous range of
|
|
181
|
+
* the file even though the block is, and the answer says so. No file in this
|
|
182
|
+
* collection uses that form — a FLAC that carries art carries it as a block —
|
|
183
|
+
* but the rule is the shared reader's and applies the moment one does.
|
|
184
|
+
*/
|
|
185
|
+
function readCommentBlock(bytes, at, length, into) {
|
|
186
|
+
const picture = readCommentList(bytes, at, at + length, into);
|
|
187
|
+
if (picture === null)
|
|
188
|
+
return;
|
|
189
|
+
into.picture = betterPicture(into.picture, {
|
|
190
|
+
mime: picture.mime,
|
|
191
|
+
kind: picture.kind,
|
|
192
|
+
indirect: true,
|
|
193
|
+
offset: at,
|
|
194
|
+
length,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ID3v1 genre list, and the references that point into it.
|
|
3
|
+
*
|
|
4
|
+
* An ID3v2 genre is not always a word. Section 4.2.1 lets it be a reference to
|
|
5
|
+
* this list — `"(17)"` means Rock, and v2.4 writes the same reference as a bare
|
|
6
|
+
* number — so a reader that stores the string stores a number where a genre
|
|
7
|
+
* belongs. The collection is full of them: of 700 mp3 files here, 105 carry
|
|
8
|
+
* `"(52)Electronic"` or `"(17)"`, and a FLAC beside them states its genre in
|
|
9
|
+
* plain words, so the reference would be the one shape of genre the meta layer
|
|
10
|
+
* could not read.
|
|
11
|
+
*
|
|
12
|
+
* The table is taken from the sources and not retyped, and three of them are
|
|
13
|
+
* compared against each other: Appendix A of v2.3 (0..125, labelling 80..125
|
|
14
|
+
* "Winamp extensions"), Appendix A of v2.4 (0..79), and the official ID3v1 test
|
|
15
|
+
* suite's generation log (0..255) — Martin Nilsson's generator, by the author of
|
|
16
|
+
* the specification. They agree on 124 of the first 126 entries word for word.
|
|
17
|
+
* The two exceptions are spelling: entry 67, where v2.3 repeats ID3v1's original
|
|
18
|
+
* misspelling and v2.4 corrects it, and entry 123, where the appendix has
|
|
19
|
+
* A cappella and the suite A capella. The appendix's spelling is kept where the
|
|
20
|
+
* two appendices agree, and v2.4's where they do not.
|
|
21
|
+
*
|
|
22
|
+
* 126..147 come from the suite alone, which is the only source here that names
|
|
23
|
+
* them. Past 147 it names nothing, and the one list that goes further is
|
|
24
|
+
* ffmpeg's own — an implementation, not a text — so the table stops where the
|
|
25
|
+
* sources do and a reference it cannot answer stays the string the file wrote.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Index is the reference: `ID3V1_GENRES[17]` is Rock.
|
|
29
|
+
*
|
|
30
|
+
* Exported because the list is not ID3's alone — an MP4 carries the same
|
|
31
|
+
* numbering in its `gnre` atom, one-based, and a reader of it needs this table
|
|
32
|
+
* rather than a second copy of it.
|
|
33
|
+
*/
|
|
34
|
+
export const ID3V1_GENRES = [
|
|
35
|
+
'Blues', 'Classic Rock', 'Country',
|
|
36
|
+
'Dance', 'Disco', 'Funk',
|
|
37
|
+
'Grunge', 'Hip-Hop', 'Jazz',
|
|
38
|
+
'Metal', 'New Age', 'Oldies',
|
|
39
|
+
'Other', 'Pop', 'R&B',
|
|
40
|
+
'Rap', 'Reggae', 'Rock',
|
|
41
|
+
'Techno', 'Industrial', 'Alternative',
|
|
42
|
+
'Ska', 'Death Metal', 'Pranks',
|
|
43
|
+
'Soundtrack', 'Euro-Techno', 'Ambient',
|
|
44
|
+
'Trip-Hop', 'Vocal', 'Jazz+Funk',
|
|
45
|
+
'Fusion', 'Trance', 'Classical',
|
|
46
|
+
'Instrumental', 'Acid', 'House',
|
|
47
|
+
'Game', 'Sound Clip', 'Gospel',
|
|
48
|
+
'Noise', 'AlternRock', 'Bass',
|
|
49
|
+
'Soul', 'Punk', 'Space',
|
|
50
|
+
'Meditative', 'Instrumental Pop', 'Instrumental Rock',
|
|
51
|
+
'Ethnic', 'Gothic', 'Darkwave',
|
|
52
|
+
'Techno-Industrial', 'Electronic', 'Pop-Folk',
|
|
53
|
+
'Eurodance', 'Dream', 'Southern Rock',
|
|
54
|
+
'Comedy', 'Cult', 'Gangsta',
|
|
55
|
+
'Top 40', 'Christian Rap', 'Pop/Funk',
|
|
56
|
+
'Jungle', 'Native American', 'Cabaret',
|
|
57
|
+
'New Wave', 'Psychedelic', 'Rave',
|
|
58
|
+
'Showtunes', 'Trailer', 'Lo-Fi',
|
|
59
|
+
'Tribal', 'Acid Punk', 'Acid Jazz',
|
|
60
|
+
'Polka', 'Retro', 'Musical',
|
|
61
|
+
'Rock & Roll', 'Hard Rock', 'Folk',
|
|
62
|
+
'Folk-Rock', 'National Folk', 'Swing',
|
|
63
|
+
'Fast Fusion', 'Bebob', 'Latin',
|
|
64
|
+
'Revival', 'Celtic', 'Bluegrass',
|
|
65
|
+
'Avantgarde', 'Gothic Rock', 'Progressive Rock',
|
|
66
|
+
'Psychedelic Rock', 'Symphonic Rock', 'Slow Rock',
|
|
67
|
+
'Big Band', 'Chorus', 'Easy Listening',
|
|
68
|
+
'Acoustic', 'Humour', 'Speech',
|
|
69
|
+
'Chanson', 'Opera', 'Chamber Music',
|
|
70
|
+
'Sonata', 'Symphony', 'Booty Bass',
|
|
71
|
+
'Primus', 'Porn Groove', 'Satire',
|
|
72
|
+
'Slow Jam', 'Club', 'Tango',
|
|
73
|
+
'Samba', 'Folklore', 'Ballad',
|
|
74
|
+
'Power Ballad', 'Rhythmic Soul', 'Freestyle',
|
|
75
|
+
'Duet', 'Punk Rock', 'Drum Solo',
|
|
76
|
+
'A cappella', 'Euro-House', 'Dance Hall',
|
|
77
|
+
'Goa', 'Drum & Bass', 'Club-House',
|
|
78
|
+
'Hardcore', 'Terror', 'Indie',
|
|
79
|
+
'BritPop', 'Negerpunk', 'Polsk Punk',
|
|
80
|
+
'Beat', 'Christian', 'Heavy Metal',
|
|
81
|
+
'Black Metal', 'Crossover', 'Contemporary',
|
|
82
|
+
'Christian Rock', 'Merengue', 'Salsa',
|
|
83
|
+
'Thrash Metal', 'Anime', 'JPop',
|
|
84
|
+
'Synthpop',
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* The two content types the list does not number.
|
|
88
|
+
*
|
|
89
|
+
* Section 4.2.1 defines them beside the numeric references and says they work
|
|
90
|
+
* the same way; v2.4 writes them without brackets. ffprobe leaves `(RX)` as
|
|
91
|
+
* typed, which is a difference this reader takes deliberately: the
|
|
92
|
+
* specification gives the string a meaning, and a reader that ignored it would
|
|
93
|
+
* be storing a keyword as though it were a genre.
|
|
94
|
+
*/
|
|
95
|
+
const KEYWORD_GENRES = {
|
|
96
|
+
RX: 'Remix',
|
|
97
|
+
CR: 'Cover',
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* A genre reference resolved to the genre, or the value unchanged.
|
|
101
|
+
*
|
|
102
|
+
* Unchanged is the answer for everything the specification does not describe:
|
|
103
|
+
* a genre already written as a word, a reference the table cannot answer, a
|
|
104
|
+
* bracket that does not close. Returning the value as it arrived is what keeps
|
|
105
|
+
* this a decoding step rather than a guess — a reader with nothing to decode
|
|
106
|
+
* must not be able to alter what it read.
|
|
107
|
+
*
|
|
108
|
+
* Everything after the closing bracket goes, refinement and all. That is
|
|
109
|
+
* ffprobe's style — `"(4)Eurodisco"` reads Disco — and this project compares
|
|
110
|
+
* itself against ffprobe, so the divergence is worth less than the agreement.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveGenre(value) {
|
|
113
|
+
const bracketed = /^\(([^)]*)\)/.exec(value);
|
|
114
|
+
const inner = bracketed?.[1] ?? value;
|
|
115
|
+
const name = /^\d+$/.test(inner)
|
|
116
|
+
? ID3V1_GENRES[Number(inner)]
|
|
117
|
+
: KEYWORD_GENRES[inner];
|
|
118
|
+
// A bracket that opened but resolved to nothing leaves the whole value alone,
|
|
119
|
+
// which is also what happens to a value with no bracket at all.
|
|
120
|
+
if (name === undefined)
|
|
121
|
+
return value;
|
|
122
|
+
return name;
|
|
123
|
+
}
|