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.
Files changed (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. 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
+ }