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,192 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { entryPoint } from "./entry.js";
4
+ function pidPath(dbPath) {
5
+ return `${dbPath}.pid`;
6
+ }
7
+ function logPath(dbPath) {
8
+ return `${dbPath}.log`;
9
+ }
10
+ /**
11
+ * Whether a process with this pid exists right now. Signal 0 asks and sends nothing.
12
+ *
13
+ * `EPERM` is the answer that matters and the one an obvious implementation gets
14
+ * wrong: it means the process is *there* and this one may not signal it. Reading
15
+ * it as "gone" would report a running server as stopped and — worse — let the
16
+ * next start overwrite a claim that was true. The rule is borrowed from
17
+ * `agensyn`'s runner, where the same check guards the same decision.
18
+ */
19
+ export function alive(pid) {
20
+ if (!Number.isInteger(pid) || pid <= 0)
21
+ return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch (err) {
27
+ return err.code === 'EPERM';
28
+ }
29
+ }
30
+ /**
31
+ * What the pid file says, if it says anything and the process it names is there.
32
+ *
33
+ * Null for a file that is missing, unreadable, or not laid out the way this
34
+ * wrote it — a file this cannot parse describes nothing it can act on, and
35
+ * guessing at its contents is how a stop command signals the wrong process.
36
+ */
37
+ export function running(dbPath) {
38
+ let record;
39
+ try {
40
+ record = JSON.parse(readFileSync(pidPath(dbPath), 'utf8'));
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ if (typeof record?.pid !== 'number' || !alive(record.pid))
46
+ return null;
47
+ return record;
48
+ }
49
+ /**
50
+ * Write the claim, once the socket is actually listening.
51
+ *
52
+ * Best-effort, and deliberately so: this runs inside the server's `listen`
53
+ * callback, where a throw would take down a server that is up, listening and
54
+ * answering. What is lost when the write fails is `stop` and `status` finding
55
+ * it — which is bad, and is not as bad as the server itself — so the failure is
56
+ * said on stderr rather than swallowed, and the server keeps serving.
57
+ */
58
+ export function claim(dbPath, record) {
59
+ try {
60
+ writeFileSync(pidPath(dbPath), `${JSON.stringify(record, null, 2)}\n`);
61
+ }
62
+ catch (err) {
63
+ process.stderr.write(`funoteka: serving, but could not write ${pidPath(dbPath)}: ${err.message}\n`);
64
+ }
65
+ }
66
+ export function release(dbPath) {
67
+ try {
68
+ rmSync(pidPath(dbPath), { force: true });
69
+ }
70
+ catch {
71
+ // A file that cannot be removed leaves a claim the next reader will check
72
+ // against the process table and find false, which is the answer anyway.
73
+ }
74
+ }
75
+ /**
76
+ * Start a server that does not belong to this shell.
77
+ *
78
+ * The child is the same program with the same arguments minus `--daemon`, so
79
+ * there is exactly one implementation of serving and no second path that could
80
+ * drift from it. It is detached and its output goes to the log rather than to a
81
+ * terminal that will be gone in a moment — a daemon whose output goes nowhere
82
+ * cannot say why it died.
83
+ *
84
+ * Readiness is the child's own report, not a timer: it writes the pid file when
85
+ * the socket is listening, so this waits for that file rather than for a
86
+ * number of milliseconds that would be wrong on both a slow and a fast machine.
87
+ * A child that exits instead is a failure, and its complaint is in the log.
88
+ */
89
+ export async function start(dbPath, argv, timeoutMs = 15_000) {
90
+ const existing = running(dbPath);
91
+ if (existing !== null) {
92
+ return { ok: false, message: `already running: pid ${existing.pid} on port ${existing.port}` };
93
+ }
94
+ // A pid file naming a process that is gone describes a server that is not
95
+ // there; it is cleared so that this start's readiness check cannot pass on it.
96
+ release(dbPath);
97
+ // Where this attempt's output starts. Everything before it belongs to an
98
+ // earlier run, and a failure reported with a previous run's complaint in it
99
+ // would send whoever reads it after the wrong problem.
100
+ const mark = logSize(dbPath);
101
+ const log = openSync(logPath(dbPath), 'a');
102
+ const child = spawn(process.execPath,
103
+ // The entry point as *this* build spells it — `.ts` beside the sources, `.js`
104
+ // in the compiled build the npm package ships (see `cli/entry.ts`).
105
+ [entryPoint('../cli'), 'serve', ...argv], {
106
+ detached: true,
107
+ // Nothing on stdin: a daemon that could read a terminal would be waiting
108
+ // on a prompt nobody is there to answer.
109
+ stdio: ['ignore', log, log],
110
+ windowsHide: true,
111
+ env: process.env,
112
+ });
113
+ closeSync(log);
114
+ let exited = null;
115
+ child.on('exit', (code) => {
116
+ exited = code ?? -1;
117
+ });
118
+ child.unref();
119
+ const deadline = Date.now() + timeoutMs;
120
+ while (Date.now() < deadline) {
121
+ if (exited !== null)
122
+ break;
123
+ const record = running(dbPath);
124
+ if (record !== null)
125
+ return { ok: true, message: 'started', record };
126
+ await new Promise((resolve) => setTimeout(resolve, 100));
127
+ }
128
+ // What it said on the way out, quoted into the message. A start that failed
129
+ // with "see the log file" makes the operator open the log to learn a sentence
130
+ // the daemon already knows, and the sentence is usually the whole answer —
131
+ // "no credentials", "address already in use".
132
+ const complaint = lastLine(dbPath, mark);
133
+ return {
134
+ ok: false,
135
+ message: exited === null
136
+ ? `did not start within ${Math.round(timeoutMs / 1000)}s${complaint} — see ${logPath(dbPath)}`
137
+ : `exited with ${exited}${complaint} — see ${logPath(dbPath)}`,
138
+ };
139
+ }
140
+ /** The last thing this attempt wrote to the log, or nothing if it wrote nothing. */
141
+ function lastLine(dbPath, from) {
142
+ try {
143
+ const written = readFileSync(logPath(dbPath), 'utf8').slice(from);
144
+ const lines = written.split(/\r?\n/).filter((line) => line.trim() !== '');
145
+ const last = lines.at(-1);
146
+ return last === undefined ? '' : `: ${last.trim()}`;
147
+ }
148
+ catch {
149
+ return '';
150
+ }
151
+ }
152
+ /**
153
+ * Stop it.
154
+ *
155
+ * The process is killed rather than asked to stop, and on Windows there is no
156
+ * choice: a signal has no handler to reach, so `SIGTERM` terminates the process
157
+ * on the spot. That is safe here, and not by luck — the meta layer is SQLite in
158
+ * WAL mode, which is built to survive a process that stops mid-write, and the
159
+ * server holds no state that is not already on disk.
160
+ */
161
+ export async function stop(dbPath, timeoutMs = 10_000) {
162
+ const record = running(dbPath);
163
+ if (record === null) {
164
+ release(dbPath);
165
+ return { ok: false, message: 'not running' };
166
+ }
167
+ try {
168
+ process.kill(record.pid);
169
+ }
170
+ catch (err) {
171
+ return { ok: false, message: `could not stop pid ${record.pid}: ${err.message}` };
172
+ }
173
+ const deadline = Date.now() + timeoutMs;
174
+ while (Date.now() < deadline && alive(record.pid)) {
175
+ await new Promise((resolve) => setTimeout(resolve, 100));
176
+ }
177
+ if (alive(record.pid)) {
178
+ return { ok: false, message: `pid ${record.pid} is still there after ${timeoutMs / 1000}s` };
179
+ }
180
+ release(dbPath);
181
+ return { ok: true, message: `stopped pid ${record.pid}` };
182
+ }
183
+ /** How big the log has grown, for `status` to say where the server has been talking. */
184
+ export function logSize(dbPath) {
185
+ try {
186
+ return statSync(logPath(dbPath)).size;
187
+ }
188
+ catch {
189
+ return 0;
190
+ }
191
+ }
192
+ export { logPath, pidPath };
@@ -0,0 +1,25 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ /**
4
+ * A sibling entry point, named as *this* build spells it.
5
+ *
6
+ * Two places spawn this program as a child: the scanner the admin surface starts
7
+ * (`POST /scan` and the interval timer), and the daemon's own start. Both used to
8
+ * name `cli.ts`, which is right in the repository — Node strips the types itself —
9
+ * and wrong in the compiled build the npm package ships, where the file is
10
+ * `cli.js` and no `cli.ts` exists. The package would install, answer `--help`, and
11
+ * then fail to scan or to daemonise, which is the kind of breakage that looks
12
+ * like a missing feature rather than a missing file.
13
+ *
14
+ * Asking the filesystem instead of inferring the answer from the caller's own
15
+ * extension makes it checkable rather than assumed: the emitted file is preferred
16
+ * because a tree that has one is a built tree, the source is the fallback, and
17
+ * neither existing names the source — so the failure reads as "the build did not
18
+ * run" rather than as a `.js` that was never going to be there.
19
+ */
20
+ export function entryPoint(relative, from = import.meta.url) {
21
+ const emitted = fileURLToPath(new URL(`${relative}.js`, from));
22
+ if (existsSync(emitted))
23
+ return emitted;
24
+ return fileURLToPath(new URL(`${relative}.ts`, from));
25
+ }
@@ -0,0 +1,52 @@
1
+ import { addKey, listKeys, revokeKey } from "../api/keys.js";
2
+ /**
3
+ * The `keys` command, as text a person reads.
4
+ *
5
+ * Kept out of the entry point for the same reason the other reports are: what
6
+ * the command *says* is testable without spawning a process, and the wording is
7
+ * the part of a key registry that decides whether anyone dares use it.
8
+ *
9
+ * **The environment's key is listed, and it is listed first.** A person asking
10
+ * "which keys does this server accept" is owed the answer in full — a listing
11
+ * that showed only the registered ones would say "one key" to a server that
12
+ * accepts two, and the one it left out is the one every existing client is
13
+ * probably using. What it says about that key is where it lives, because that is
14
+ * where it is revoked.
15
+ */
16
+ export function reportKeys(db, environmentKey) {
17
+ const lines = [];
18
+ if (environmentKey !== '') {
19
+ lines.push('from the environment (FUNOTEKA_APIKEY) — always valid, revoked by editing start.cmd:', ` ${environmentKey}`, '');
20
+ }
21
+ else {
22
+ // **Said even though there is nothing to show.** The daemon is started by
23
+ // `start.cmd`, which sets this variable in its own process; a person running
24
+ // this command in an ordinary shell has it unset while the server they are
25
+ // asking about has it set. Silence here reads as "none" — which is how you
26
+ // spend an afternoon looking for a key that is working exactly as intended.
27
+ lines.push('from the environment (FUNOTEKA_APIKEY): not set in this shell.', ' The daemon is started by start.cmd, which sets it in its own process — this', ' command does not read that file. If a client is using a key that is not listed', ' below, that is where it is, and editing that file is how it is revoked.', '');
28
+ }
29
+ const keys = listKeys(db);
30
+ const active = keys.filter((key) => key.revokedAt === null);
31
+ lines.push(active.length === 0
32
+ ? 'registered keys: none'
33
+ : `registered keys (${active.length} active):`, ...active.map((key) => ` #${key.id} ${key.label} added ${key.createdAt}\n ${key.secret}`));
34
+ const revoked = keys.filter((key) => key.revokedAt !== null);
35
+ if (revoked.length > 0) {
36
+ lines.push('', `revoked (${revoked.length}) — kept so that one taken back cannot be added again:`, ...revoked.map((key) => ` #${key.id} ${key.label} revoked ${key.revokedAt}`));
37
+ }
38
+ return `${lines.join('\n')}\n`;
39
+ }
40
+ /** What `keys add` says: the secret, once, and the id to revoke it by. */
41
+ export function addedKey(key) {
42
+ return (`#${key.id} ${key.label}\n` +
43
+ ` ${key.secret}\n` +
44
+ ' A client presents this as `apiKey`, on its own — no `u` beside it.\n' +
45
+ ` Take it back with: funoteka keys revoke ${key.id}\n`);
46
+ }
47
+ /** What `keys revoke` says: the key that stopped being accepted. */
48
+ export function revokedKey(key) {
49
+ return (`#${key.id} ${key.label} is revoked as of ${key.revokedAt}.\n` +
50
+ ' Any client still using it is refused from now on, with no restart.\n');
51
+ }
52
+ export { addKey, revokeKey };