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,181 @@
1
+ /**
2
+ * The two locks on the admin port that are not the token: an address list, and a
3
+ * limit on how many wrong tokens one address may offer.
4
+ *
5
+ * Both are pure enough to test without a socket, which is the point of the file
6
+ * existing next to the listener rather than inside it — an address comparison
7
+ * that is subtly wrong is not something a request log will ever show you.
8
+ *
9
+ * **Neither of these is the gate.** The token is: it is what proves who is
10
+ * calling. The list keeps a port from being knocked at by a machine with no
11
+ * business knocking, and the limit keeps a good token from being *found*; a
12
+ * deployment with both unset is still guarded by its token, and this file says
13
+ * so rather than pretending otherwise.
14
+ */
15
+ /**
16
+ * A lockout after this many wrong tokens from one address.
17
+ *
18
+ * Ten, because a person mistyping a token twice is ordinary and a person
19
+ * mistyping it ten times in a row is not — and because the cost of being wrong
20
+ * is bounded on both sides: an attacker gets ten guesses per quarter hour,
21
+ * which against a 32-byte token is not a rate worth measuring, while an operator
22
+ * who locked themselves out can restart the process (the counter lives in
23
+ * memory, deliberately) or wait.
24
+ */
25
+ export const MAX_FAILURES = 10;
26
+ /** How long an address stays locked out after `MAX_FAILURES`. */
27
+ export const LOCKOUT_MS = 15 * 60_000;
28
+ /**
29
+ * A guard over the rules an operator wrote.
30
+ *
31
+ * `rules` is the `allow` setting: comma-separated addresses and CIDR blocks, or
32
+ * empty for every address. Whitespace is forgiven and an empty entry is not a
33
+ * rule — a trailing comma in a settings file should not lock the operator out of
34
+ * their own server.
35
+ *
36
+ * `now` is a parameter because a lockout is a thing about time, and a test that
37
+ * had to wait fifteen minutes to check one would not be written.
38
+ */
39
+ export function guard(rules, now = Date.now) {
40
+ const allowed = rules
41
+ .split(',')
42
+ .map((rule) => rule.trim())
43
+ .filter((rule) => rule !== '')
44
+ .map(block);
45
+ const failures = new Map();
46
+ return {
47
+ allowed: (address) => allowed.length === 0 || allowed.some((block) => block.holds(address)),
48
+ locked: (address) => (failures.get(address)?.until ?? 0) > now(),
49
+ waitFor: (address) => Math.ceil(((failures.get(address)?.until ?? 0) - now()) / 1000),
50
+ failuresLeft: (address) => {
51
+ const count = failures.get(address)?.count ?? 0;
52
+ return count === 0 ? undefined : Math.max(0, MAX_FAILURES - count);
53
+ },
54
+ recordFailure: (address) => {
55
+ const seen = failures.get(address);
56
+ const count = (seen?.count ?? 0) + 1;
57
+ failures.set(address, {
58
+ count,
59
+ until: count >= MAX_FAILURES ? now() + LOCKOUT_MS : (seen?.until ?? 0),
60
+ });
61
+ // Bounded memory on a port that is being attacked: entries that have
62
+ // neither a lockout running nor a recent failure are dropped as new ones
63
+ // arrive. Without this the map is a place a stranger can write one entry
64
+ // per request for as long as they like.
65
+ if (failures.size > 1_000) {
66
+ for (const [key, value] of failures) {
67
+ if (value.until <= now() && value.count < MAX_FAILURES)
68
+ failures.delete(key);
69
+ }
70
+ }
71
+ },
72
+ // A correct token clears the count: it proves the caller knows it, and a
73
+ // person who mistyped it five times before getting it right must not be one
74
+ // stranger's attack away from being locked out of their own server.
75
+ recordSuccess: (address) => {
76
+ failures.delete(address);
77
+ },
78
+ };
79
+ }
80
+ function block(rule) {
81
+ const slash = rule.lastIndexOf('/');
82
+ const base = bits(slash === -1 ? rule : rule.slice(0, slash));
83
+ // **The address itself is checked, not only the length.** A rule nobody can
84
+ // read is the worst of both answers: it silently allows nothing, closing a
85
+ // port the operator believes is open — or, if it were read as a wildcard,
86
+ // silently allows everything. So it is refused while the guard is being built,
87
+ // where the sentence reaches somebody at startup with time to fix it.
88
+ if (base.v4 === null && base.v6 === null) {
89
+ throw new Error(`"${rule}" is not an address or a CIDR block`);
90
+ }
91
+ if (slash === -1)
92
+ return { holds: (address) => same(bits(address), base) };
93
+ const length = Number(rule.slice(slash + 1));
94
+ if (!Number.isInteger(length) || length < 0 || length > base.width) {
95
+ throw new Error(`"${rule}" is not an address or a CIDR block`);
96
+ }
97
+ return { holds: (address) => matches(bits(address), base, length) };
98
+ }
99
+ function bits(address) {
100
+ // A socket reached over IPv4 on a machine with IPv6 shows it mapped:
101
+ // `::ffff:10.0.0.5` is 10.0.0.5, and a rule that did not know that would
102
+ // refuse the very address it names.
103
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
104
+ const text = mapped?.[1] ?? address;
105
+ const v4 = ipv4(text);
106
+ if (v4 !== null)
107
+ return { width: 32, v4, v6: null };
108
+ const v6 = ipv6(text);
109
+ if (v6 !== null)
110
+ return { width: 128, v4: null, v6 };
111
+ // An address that cannot be read matches nothing, including itself.
112
+ return { width: 128, v4: null, v6: null };
113
+ }
114
+ function same(one, other) {
115
+ if (one.width !== other.width)
116
+ return false;
117
+ return one.width === 32 ? one.v4 !== null && one.v4 === other.v4 : one.v6 !== null && one.v6 === other.v6;
118
+ }
119
+ function matches(address, base, length) {
120
+ if (address.width !== base.width)
121
+ return false;
122
+ if (address.width === 32) {
123
+ if (address.v4 === null || base.v4 === null)
124
+ return false;
125
+ const mask = length === 0 ? 0 : (0xffff_ffff << (32 - length)) >>> 0;
126
+ return ((address.v4 ^ base.v4) & mask) >>> 0 === 0;
127
+ }
128
+ if (address.v6 === null || base.v6 === null)
129
+ return false;
130
+ const shift = BigInt(128 - length);
131
+ const mask = length === 0 ? 0n : ((1n << BigInt(length)) - 1n) << shift;
132
+ return ((address.v6 ^ base.v6) & mask) === 0n;
133
+ }
134
+ /** `a.b.c.d` as a 32-bit number, or null when that is not what it is. */
135
+ function ipv4(text) {
136
+ const parts = text.split('.');
137
+ if (parts.length !== 4)
138
+ return null;
139
+ let value = 0;
140
+ for (const part of parts) {
141
+ if (!/^\d{1,3}$/.test(part))
142
+ return null;
143
+ const octet = Number(part);
144
+ if (octet > 255)
145
+ return null;
146
+ value = value * 256 + octet;
147
+ }
148
+ return value;
149
+ }
150
+ /** An IPv6 address as 128 bits, or null. `::` stands for as many zero groups as it takes. */
151
+ function ipv6(text) {
152
+ // A zone — `fe80::1%eth0` — names an interface locally and means nothing here.
153
+ const zone = text.indexOf('%');
154
+ const address = zone === -1 ? text : text.slice(0, zone);
155
+ if (!address.includes(':'))
156
+ return null;
157
+ const halves = address.split('::');
158
+ if (halves.length > 2)
159
+ return null;
160
+ const groups = (part) => {
161
+ if (part === '')
162
+ return [];
163
+ const pieces = part.split(':');
164
+ return pieces.every((piece) => /^[0-9a-f]{1,4}$/i.test(piece)) ? pieces : null;
165
+ };
166
+ const left = groups(halves[0] ?? '');
167
+ const right = halves.length === 2 ? groups(halves[1] ?? '') : [];
168
+ if (left === null || right === null)
169
+ return null;
170
+ // Without a `::`, an address is exactly eight groups; with one, it is at most.
171
+ if (halves.length === 1 && left.length !== 8)
172
+ return null;
173
+ if (halves.length === 2 && left.length + right.length > 7)
174
+ return null;
175
+ const width = 8 - left.length - right.length;
176
+ const all = [...left, ...Array(halves.length === 2 ? width : 0).fill('0'), ...right];
177
+ let value = 0n;
178
+ for (const group of all)
179
+ value = (value << 16n) | BigInt(Number.parseInt(group, 16));
180
+ return value;
181
+ }
@@ -0,0 +1,113 @@
1
+ import { closeSync, fstatSync, openSync, readSync, statSync } from 'node:fs';
2
+ import { count } from "./meta.js";
3
+ export function stats(db, dbPath) {
4
+ const counted = (table, extra = '') => count(db, `SELECT COUNT(*) AS n FROM ${table} WHERE 1 = 1 ${extra}`);
5
+ return {
6
+ roots: counted('root'),
7
+ folders: counted('folder'),
8
+ files: counted('file'),
9
+ songs: counted('file', "AND kind = 'audio'"),
10
+ albums: counted('album'),
11
+ artists: counted('artist'),
12
+ playlists: counted('playlist'),
13
+ hidden: counted('album', 'AND junk_reason IS NOT NULL'),
14
+ issues: counted('issue'),
15
+ // A meta layer that is missing cannot be measured, and a stat route that
16
+ // threw over it would be answering "how big is the library" with a stack
17
+ // trace. Zero is what a missing file comes back as — which is a number a
18
+ // reader could act on, and is why this sentence is here rather than a claim
19
+ // that nothing is reported at all.
20
+ databaseBytes: sizeOf(dbPath),
21
+ };
22
+ }
23
+ function sizeOf(path) {
24
+ try {
25
+ return statSync(path).size;
26
+ }
27
+ catch {
28
+ return 0;
29
+ }
30
+ }
31
+ /**
32
+ * What the scanner could not understand, newest first.
33
+ *
34
+ * The table the project has always kept — "every guess, skip and unmatched cue
35
+ * lands here so a scan can never lose information silently" — and the one thing
36
+ * an operator cannot get anywhere else: the API answers about the collection it
37
+ * managed to build, and this answers about the parts it did not.
38
+ */
39
+ export function issues(db, limit, severity) {
40
+ const filter = severity === undefined ? '' : 'WHERE i.severity = ?';
41
+ const args = severity === undefined ? [limit] : [severity, limit];
42
+ const rows = db
43
+ .prepare(`SELECT i.id AS id, i.kind AS kind, i.severity AS severity, i.detail AS detail,
44
+ r.path AS rootPath, i.rel_path AS relPath, i.scan_run_id AS scanRunId
45
+ FROM issue i LEFT JOIN root r ON r.id = i.root_id
46
+ ${filter}
47
+ ORDER BY i.id DESC LIMIT ?`)
48
+ .all(...args);
49
+ const counted = db
50
+ .prepare('SELECT kind, COUNT(*) AS n FROM issue GROUP BY kind ORDER BY n DESC')
51
+ .all();
52
+ return { counts: Object.fromEntries(counted.map((row) => [row.kind, row.n])), issues: rows };
53
+ }
54
+ /**
55
+ * How much of the end of a log is read.
56
+ *
57
+ * The deployment this was built for has a **24 MB** log, and reading all of it
58
+ * to answer "what did it just say" is 24 MB into the one thread that answers
59
+ * every client — for five lines. A quarter of a megabyte is tens of thousands of
60
+ * lines, which is more than anyone reads back, and it is the same size whatever
61
+ * the file has grown to.
62
+ */
63
+ const LOG_TAIL_BYTES = 256 * 1024;
64
+ export function logs(logFile, dbPath, wanted) {
65
+ const file = logFile === '' ? `${dbPath}.log` : logFile;
66
+ let all;
67
+ let whole;
68
+ try {
69
+ const read = tail(file, LOG_TAIL_BYTES);
70
+ whole = read.whole;
71
+ all = read.text.split(/\r?\n/);
72
+ // **The first line of a tail is usually half a line.** It is whatever the
73
+ // read happened to start in the middle of, and a reader shown a fragment
74
+ // presented as a log line has been shown something nobody wrote. Dropped —
75
+ // and `truncated` is what says there are more above.
76
+ if (!whole)
77
+ all = all.slice(1);
78
+ }
79
+ catch {
80
+ // No log is not an error: a supervisor that collects the output itself is
81
+ // the ordinary deployment, and the answer says which file it looked in so
82
+ // that "nothing there" is checkable rather than mysterious.
83
+ return { file, lines: [], truncated: false };
84
+ }
85
+ // A trailing newline is how a line ends, not an empty line.
86
+ if (all.at(-1) === '')
87
+ all.pop();
88
+ return {
89
+ file,
90
+ lines: all.slice(-wanted),
91
+ truncated: !whole || all.length > wanted,
92
+ };
93
+ }
94
+ /**
95
+ * The last `cap` bytes of a file, and whether that was the whole of it.
96
+ *
97
+ * Positioned reads rather than a stream: this answers one question about one
98
+ * offset, and a stream would pump the entire file through the event loop to
99
+ * throw all but the end of it away.
100
+ */
101
+ function tail(file, cap) {
102
+ const fd = openSync(file, 'r');
103
+ try {
104
+ const size = fstatSync(fd).size;
105
+ const from = Math.max(0, size - cap);
106
+ const buffer = Buffer.alloc(size - from);
107
+ readSync(fd, buffer, 0, buffer.length, from);
108
+ return { text: buffer.toString('utf8'), whole: from === 0 };
109
+ }
110
+ finally {
111
+ closeSync(fd);
112
+ }
113
+ }