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,209 @@
|
|
|
1
|
+
import { decodeText } from "../text/encoding.js";
|
|
2
|
+
import { ID3V1_GENRES } from "./genres.js";
|
|
3
|
+
import {} from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Read the ID3v1 block: 128 bytes at the very end of an MPEG file.
|
|
6
|
+
*
|
|
7
|
+
* **There is no ID3v1 specification to cite, and this reader says so rather than
|
|
8
|
+
* inventing one.** The format is not formally standardized — the words are the
|
|
9
|
+
* conformance suite's own README — so the authority used here is the layout as
|
|
10
|
+
* *the author of the ID3 specifications* writes it in the suite's `generate.pike`
|
|
11
|
+
* (the `ID3_1` and `ID3_11` classes, which are the format stated as data), and
|
|
12
|
+
* the suite itself as the oracle for what a reader must do with each field. That
|
|
13
|
+
* is a weaker footing than `flac.ts` has with RFC 9639 or `ogg.ts` with
|
|
14
|
+
* RFC 3533, and pretending otherwise would be the one thing this directory is
|
|
15
|
+
* not allowed to do.
|
|
16
|
+
*
|
|
17
|
+
* The layout, from those two classes:
|
|
18
|
+
*
|
|
19
|
+
* head 3 "TAG", and the case matters — a lowercase `tag` is not a tag
|
|
20
|
+
* title 30 artist 30 album 30 year 4
|
|
21
|
+
* comment 30 (v1.0) | 28, a zero byte, and the track number (v1.1)
|
|
22
|
+
* genre 1 an index into the list in `genres.ts`
|
|
23
|
+
*
|
|
24
|
+
* The suite splits its cases by what a reader owes them, and the name of each
|
|
25
|
+
* file states which: nothing (`_W` aside) is ordinary, `W` "might generate a
|
|
26
|
+
* decoding warning", and `F` "should generate a decoding failure". What is
|
|
27
|
+
* refused here is what the suite calls a failure — a year that is not four
|
|
28
|
+
* digits, and a genre byte the list does not name — and each refusal is reported
|
|
29
|
+
* rather than swallowed. The warnings are not reported, and that is a decision:
|
|
30
|
+
* the one warning class is junk after a string terminator, which changes nothing
|
|
31
|
+
* about what is read, and a finding on every tag some tagger left junk in is
|
|
32
|
+
* noise rather than information.
|
|
33
|
+
*
|
|
34
|
+
* Three cases part company with the suite, each for the same reason and each
|
|
35
|
+
* reported by `test/tools/id3v1-suite.ts` rather than hidden:
|
|
36
|
+
*
|
|
37
|
+
* - a magic in the wrong case reads as *no tag* rather than as a failure — a
|
|
38
|
+
* scan cannot tell a lowercase `tag` from an audio frame that landed there,
|
|
39
|
+
* and the alternative is a finding against every mp3 without a tag;
|
|
40
|
+
* - a year field of NULs reads as nothing stated — there is no value in it to
|
|
41
|
+
* refuse, and untagged years are ordinary;
|
|
42
|
+
* - genre 255 reads as nothing stated — see `NO_GENRE`, measured on this very
|
|
43
|
+
* collection.
|
|
44
|
+
*
|
|
45
|
+
* All three are the same judgement, and it is the one the suite cannot make: it
|
|
46
|
+
* reports on a single file, while this reader runs over a library and a finding
|
|
47
|
+
* that fires on a hundred ordinary files is not information.
|
|
48
|
+
*/
|
|
49
|
+
/** RFC-less and fixed: the block is 128 bytes and always the last 128. */
|
|
50
|
+
const TAG_BYTES = 128;
|
|
51
|
+
/** The last field's index within the block, and the genre byte's. */
|
|
52
|
+
const GENRE_AT = 127;
|
|
53
|
+
/**
|
|
54
|
+
* The genre byte that says there is no genre.
|
|
55
|
+
*
|
|
56
|
+
* Not a genre the list is missing — 255 is how ID3v1 states *none*, and it is
|
|
57
|
+
* the only value past the end of the table that this collection's **music**
|
|
58
|
+
* uses. Measured over the scanned root, which since 2026-09-13 also holds the
|
|
59
|
+
* conformance suite: 489 of the collection's 700 mp3 carry a v1 block, 184 of
|
|
60
|
+
* those state 255, and not one states anything between 148 and 254. The 107
|
|
61
|
+
* files that do are the suite's own failure cases, which exist in order to state
|
|
62
|
+
* exactly that — so the corpus is where the refusals belong, and it is where
|
|
63
|
+
* all 109 of them land.
|
|
64
|
+
*
|
|
65
|
+
* So it is read as nothing stated rather than as a value that could not be read.
|
|
66
|
+
* Reporting it would put 184 findings in the dump about the absence of a fact,
|
|
67
|
+
* which is the aggregate mistake `tag-format-unknown` exists to avoid.
|
|
68
|
+
*/
|
|
69
|
+
const NO_GENRE = 255;
|
|
70
|
+
/** How much room each text field has. */
|
|
71
|
+
const FIELD_BYTES = 30;
|
|
72
|
+
function magicAt(bytes, at, magic) {
|
|
73
|
+
if (at + magic.length > bytes.length)
|
|
74
|
+
return false;
|
|
75
|
+
for (let i = 0; i < magic.length; i += 1) {
|
|
76
|
+
if (bytes[at + i] !== magic.charCodeAt(i))
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* One text field, decoded, or null when it holds nothing.
|
|
83
|
+
*
|
|
84
|
+
* Two rules, and they do different jobs. A field ends at its **first NUL** —
|
|
85
|
+
* everything after the terminator is junk and "should not show up for the user"
|
|
86
|
+
* in the suite's own words, which is what the two warning cases exist to test.
|
|
87
|
+
* And a field padded with **spaces** rather than NULs has that padding taken
|
|
88
|
+
* off, which is what taggers other than this suite's generator write.
|
|
89
|
+
*
|
|
90
|
+
* Neither rule is what refuses a bad year; the shape check is. `" 3"` is
|
|
91
|
+
* leading-padded, so the trailing strip does nothing to it, and `"112\0"` reads
|
|
92
|
+
* as `"112"` whichever order the two rules are applied in — both are refused by
|
|
93
|
+
* `/^\d{4}$/` and by nothing else. Saying otherwise would credit the padding
|
|
94
|
+
* rules with a job they do not do, and the next person to touch this would keep
|
|
95
|
+
* them for the wrong reason.
|
|
96
|
+
*
|
|
97
|
+
* The encoding is not declared anywhere, so it is detected: `decodeText` is the
|
|
98
|
+
* entry point for exactly that, and it settles a byte-order mark, then strict
|
|
99
|
+
* UTF-8, then the single-byte verdict. ID3v1 predates any convention here, which
|
|
100
|
+
* is why the suite has a whole category of cases for it — some taggers wrote
|
|
101
|
+
* Latin-1 as the format implies, others wrote whatever the local code page was.
|
|
102
|
+
*/
|
|
103
|
+
function fieldText(bytes, at, length) {
|
|
104
|
+
let end = at + length;
|
|
105
|
+
for (let i = at; i < end; i += 1) {
|
|
106
|
+
if (bytes[i] === 0) {
|
|
107
|
+
end = i;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
while (end > at && bytes[end - 1] === 0x20)
|
|
112
|
+
end -= 1;
|
|
113
|
+
if (end <= at)
|
|
114
|
+
return null;
|
|
115
|
+
const decoded = decodeText(bytes.subarray(at, end));
|
|
116
|
+
return {
|
|
117
|
+
text: decoded.text,
|
|
118
|
+
verdict: { encoding: decoded.encoding, confidence: decoded.confidence, basis: decoded.basis },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** The weaker of two verdicts, by the rule `weakestEncoding` uses. */
|
|
122
|
+
function weaker(current, candidate) {
|
|
123
|
+
return current === null || candidate.confidence < current.confidence ? candidate : current;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The ID3v1 block a file carries, or null when it carries none.
|
|
127
|
+
*
|
|
128
|
+
* Null means the last 128 bytes do not begin `TAG`, and nothing else — a file
|
|
129
|
+
* too short to hold a block carries none, and so does one whose tagger wrote the
|
|
130
|
+
* magic in the wrong case. The suite calls that last one a decoding failure, and
|
|
131
|
+
* for a decoder reporting on a single file it is; here it is the difference
|
|
132
|
+
* between "this file has no ID3v1 tag" and "this file has a broken one", and a
|
|
133
|
+
* scan over a collection has no way to tell a lowercase `tag` from the bytes of
|
|
134
|
+
* an audio frame that happened to land there. Reporting every mp3 without a tag
|
|
135
|
+
* as a failure is the alternative, and it is absurd.
|
|
136
|
+
*/
|
|
137
|
+
export function readId3v1(bytes) {
|
|
138
|
+
if (bytes.length < TAG_BYTES)
|
|
139
|
+
return null;
|
|
140
|
+
const base = bytes.length - TAG_BYTES;
|
|
141
|
+
if (!magicAt(bytes, base, 'TAG'))
|
|
142
|
+
return null;
|
|
143
|
+
const tags = [];
|
|
144
|
+
const refusals = [];
|
|
145
|
+
const verdicts = new Map();
|
|
146
|
+
let encoding = null;
|
|
147
|
+
const push = (name, field) => {
|
|
148
|
+
if (field === null)
|
|
149
|
+
return;
|
|
150
|
+
encoding = weaker(encoding, field.verdict);
|
|
151
|
+
verdicts.set(name, field.verdict);
|
|
152
|
+
tags.push({ name, value: field.text });
|
|
153
|
+
};
|
|
154
|
+
push('title', fieldText(bytes, base + 3, FIELD_BYTES));
|
|
155
|
+
push('artist', fieldText(bytes, base + 3 + FIELD_BYTES, FIELD_BYTES));
|
|
156
|
+
push('album', fieldText(bytes, base + 3 + 2 * FIELD_BYTES, FIELD_BYTES));
|
|
157
|
+
// The suite's year cases, and the whole of the rule they express: a year is
|
|
158
|
+
// four digits. `0000` and `9999` are years a reader must accept — the bounds
|
|
159
|
+
// are tested precisely because a parser that used a range check instead of a
|
|
160
|
+
// shape check would reject them — while `" 3"`, `"112\0"` and a field of
|
|
161
|
+
// NULs are the three shapes that must be refused. `date` is the name the
|
|
162
|
+
// ID3v2 reader stores TYER under (see the frame table in `id3v2.ts`), so a
|
|
163
|
+
// file carrying both versions states one fact under one name.
|
|
164
|
+
const year = fieldText(bytes, base + 3 + 3 * FIELD_BYTES, 4);
|
|
165
|
+
if (year === null) {
|
|
166
|
+
// An empty year states nothing, which is not a refusal — nothing was
|
|
167
|
+
// claimed. The suite's NULL case is listed as a failure, and it is the one
|
|
168
|
+
// case where this reader deliberately differs, for the same reason as the
|
|
169
|
+
// lowercase magic above: there is no field to refuse.
|
|
170
|
+
}
|
|
171
|
+
else if (/^\d{4}$/.test(year.text)) {
|
|
172
|
+
push('date', year);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
encoding = weaker(encoding, year.verdict);
|
|
176
|
+
refusals.push(`id3v1 year ${JSON.stringify(year.text)} is not four digits`);
|
|
177
|
+
}
|
|
178
|
+
// ID3v1.1 is told from v1.0 by the comment's tail: 28 bytes of comment, then a
|
|
179
|
+
// zero byte, then the track number. The generator's own log prints the version
|
|
180
|
+
// as `track ? 1.1 : 1.0`, which is the same ambiguity from the other side — a
|
|
181
|
+
// track of zero is not distinguishable from an untracked v1.0 tag, and no
|
|
182
|
+
// reader can do better.
|
|
183
|
+
const tracked = bytes[base + 125] === 0 && bytes[base + 126] !== 0;
|
|
184
|
+
push('comment', fieldText(bytes, base + 97, tracked ? 28 : FIELD_BYTES));
|
|
185
|
+
if (tracked) {
|
|
186
|
+
const track = bytes[base + 126];
|
|
187
|
+
// The track number is a byte, so it is 1..255 — and the suite tests 255 as
|
|
188
|
+
// an ordinary value rather than a failure, which is why there is no upper
|
|
189
|
+
// bound to check here. It is formatted the way `tracknumber` is elsewhere
|
|
190
|
+
// in this project: two digits, which is what every other reader's tag
|
|
191
|
+
// values look like and what the ordering downstream expects.
|
|
192
|
+
tags.push({ name: 'tracknumber', value: String(track).padStart(2, '0') });
|
|
193
|
+
}
|
|
194
|
+
// The genre is an index, and the list is the one already in this directory —
|
|
195
|
+
// taken from three sources that agree, and stopping at 147 because no source
|
|
196
|
+
// names anything beyond. A byte the list cannot answer is refused rather than
|
|
197
|
+
// invented, which is the suite's own boundary: 0..79 are safe, 80..147 are a
|
|
198
|
+
// warning, and 148 and up are a failure — except for the one value that means
|
|
199
|
+
// the absence of a genre rather than an unknown one.
|
|
200
|
+
const genre = bytes[base + GENRE_AT] ?? 0;
|
|
201
|
+
const name = ID3V1_GENRES[genre];
|
|
202
|
+
if (name !== undefined && name !== '') {
|
|
203
|
+
tags.push({ name: 'genre', value: name });
|
|
204
|
+
}
|
|
205
|
+
else if (genre !== NO_GENRE) {
|
|
206
|
+
refusals.push(`id3v1 genre ${genre} is not in the list, which names 0..${ID3V1_GENRES.length - 1}`);
|
|
207
|
+
}
|
|
208
|
+
return { tags, refusals, encoding, verdicts };
|
|
209
|
+
}
|