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,311 @@
1
+ import * as store from "../playlist/store.js";
2
+ import { ID, parseId, required, seconds, songChild } from "./browse.js";
3
+ import { ApiError, ERROR } from "./envelope.js";
4
+ import { knownTrackIds, songsOfPlaylist } from "./meta.js";
5
+ /**
6
+ * Playlists, in the protocol's shapes.
7
+ *
8
+ * The one part of this API that is not a reading of the collection. A playlist
9
+ * is what the listener said about the music rather than what the music is, and
10
+ * it is the only thing here a client can create — which makes three of these
11
+ * five routes the only routes in the whole API that write (the writes
12
+ * themselves are `playlist/store.ts`).
13
+ *
14
+ * The protocol's own split of that surface is kept as it is: `getPlaylists` is
15
+ * the list a client draws a sidebar from and carries no songs, `getPlaylist` is
16
+ * one playlist with its entries, and `createPlaylist` doubles as the update
17
+ * when it is handed an id — which is the protocol's design and not a shortcut
18
+ * taken here.
19
+ */
20
+ /**
21
+ * The id a playlist is named by.
22
+ *
23
+ * A `pl:` prefix like every other id this API hands out, and deliberately *not*
24
+ * added to `KINDS` in `browse.ts`: that set is the tree — artists, albums,
25
+ * tracks, folders, roots — and a playlist is not a branch of it. An id from
26
+ * here is therefore refused by `getSong` and `getMusicDirectory` as naming
27
+ * nothing, which is the true answer, while the routes below read it themselves.
28
+ *
29
+ * Digits only, rather than whatever `Number` accepts: `pl:1e3` and `pl:0x10`
30
+ * would otherwise read as ids this server may never have handed out — and the
31
+ * one thing an id from here is allowed to be is one that was handed out. The
32
+ * refusals are the same either way; what the strict reading buys is that they
33
+ * are always true.
34
+ */
35
+ function playlistIdOf(raw) {
36
+ // **Both separators are read, and this is where that promise was broken
37
+ // first.** `parseId` reads a colon and a hyphen; this compared against the
38
+ // prefix *constant*, so when the constant changed the old spelling stopped
39
+ // being recognised — and a client holding a playlist id from before was told
40
+ // there is no such playlist, while the commit that changed the separator said
41
+ // nothing would be orphaned (found by asking both spellings of a real
42
+ // playlist, task:2896).
43
+ const digits = /^pl[-:]/.test(raw) ? raw.slice(3) : '';
44
+ if (!/^\d+$/.test(digits))
45
+ throw new ApiError(ERROR.notFound, `No such playlist: ${raw}`);
46
+ return Number(digits);
47
+ }
48
+ /**
49
+ * The playlist an id names, or a refusal.
50
+ *
51
+ * Every route but the listing has one of these, and all three spell the
52
+ * protocol's not-found code rather than answering about a playlist that is not
53
+ * there. An id that was never handed out by this server is the same answer as
54
+ * one whose playlist was deleted: the client's question has no subject.
55
+ */
56
+ function requiredPlaylist(db, raw) {
57
+ const row = store.playlist(db, playlistIdOf(raw));
58
+ if (row === undefined)
59
+ throw new ApiError(ERROR.notFound, `No such playlist: ${raw}`);
60
+ return row;
61
+ }
62
+ /**
63
+ * A playlist the listener may change — which a file's is not.
64
+ *
65
+ * Refused rather than allowed-and-later-undone, and the refusal is the only
66
+ * honest answer available: the row is re-derived from its `.m3u` on every scan
67
+ * that re-reads the file, so an edit would last until the next scan and a
68
+ * deletion would be undone by the scan after it. A client that lost a rename
69
+ * that way would have no way to tell it from its own bug — and the answer it
70
+ * gets here says which file owns the list, which is also what it takes to
71
+ * change it.
72
+ *
73
+ * The code is the protocol's "not authorized", which is what this is: the
74
+ * caller is authenticated, and this particular list is not theirs to change.
75
+ */
76
+ function requiredEditable(db, raw) {
77
+ const row = requiredPlaylist(db, raw);
78
+ if (row.source_file_id !== null) {
79
+ throw new ApiError(ERROR.notAuthorized, `This playlist is read from a file in the collection, and a scan rebuilds it: ${raw}`);
80
+ }
81
+ return row;
82
+ }
83
+ /**
84
+ * Every value of a repeated parameter, as song ids.
85
+ *
86
+ * `songId` and `songIdToAdd` arrive the protocol's way — the name repeated once
87
+ * per song — so reading only the first would silently build a playlist one song
88
+ * long. An empty value is dropped rather than refused: `songId=` is a client
89
+ * with nothing to add, which is not an error.
90
+ *
91
+ * The refusal and the existence check below it say the same sentence, and that
92
+ * is on purpose — from a client's side "that is not a song id" and "that song
93
+ * is not here" are one answer about one parameter.
94
+ */
95
+ function trackIdsIn(query, name) {
96
+ return query
97
+ .getAll(name)
98
+ .filter((raw) => raw !== '')
99
+ .map((raw) => {
100
+ const parsed = parseId(raw);
101
+ if (parsed === undefined || parsed.kind !== 'tr') {
102
+ throw new ApiError(ERROR.notFound, `No such song: ${raw}`);
103
+ }
104
+ return parsed.n;
105
+ });
106
+ }
107
+ /**
108
+ * Songs that exist, or a refusal naming the first that does not.
109
+ *
110
+ * Answered once for the whole call instead of per id, and the refusal is the
111
+ * point: a playlist quietly shorter than the request is one the client cannot
112
+ * tell from the playlist it asked for, and the song that went missing would
113
+ * only surface when somebody played the list through.
114
+ */
115
+ function requireKnown(db, ids) {
116
+ const known = knownTrackIds(db, ids);
117
+ for (const id of ids) {
118
+ if (!known.has(id))
119
+ throw new ApiError(ERROR.notFound, `No such song: ${ID.track(id)}`);
120
+ }
121
+ }
122
+ /**
123
+ * Positions in a playlist, as the client sent them.
124
+ *
125
+ * A position is what the client is holding — it reads them out of the answer to
126
+ * `getPlaylist` — so a negative one names nothing. It is refused rather than
127
+ * ignored: a client that computed `-1` has a bug, and the alternative is a
128
+ * playlist that silently kept a song it was told to drop.
129
+ *
130
+ * The code is the generic one, which is what `browse.ts` and `genre.ts` answer
131
+ * with for a parameter that is not the number it should be — a client reading
132
+ * the code learns the same thing from either.
133
+ */
134
+ function positionsIn(query, name) {
135
+ return query
136
+ .getAll(name)
137
+ .filter((raw) => raw !== '')
138
+ .map((raw) => {
139
+ const n = Number(raw);
140
+ if (!Number.isInteger(n) || n < 0) {
141
+ throw new ApiError(ERROR.generic, `${name} is not a position: ${raw}`);
142
+ }
143
+ return n;
144
+ });
145
+ }
146
+ /** The protocol's booleans, which arrive as `true` or `false`. */
147
+ function booleanish(raw) {
148
+ const value = raw.toLowerCase();
149
+ return value === 'true' || value === '1' || value === 'yes';
150
+ }
151
+ /**
152
+ * A playlist in the protocol's shape, without its songs.
153
+ *
154
+ * What `getPlaylists` answers with, and the half `getPlaylist` adds `entry` to.
155
+ * The two therefore cannot disagree about a name, a length or a timestamp —
156
+ * they are one object, read once.
157
+ */
158
+ function asPlaylist(row, config) {
159
+ return {
160
+ id: ID.playlist(row.id),
161
+ name: row.name,
162
+ ...(row.comment === null ? {} : { comment: row.comment }),
163
+ // The one account this server has. Kept in the answer because the protocol
164
+ // makes it a field of a playlist and a client draws it beside the name.
165
+ owner: config.user,
166
+ public: row.public === 1,
167
+ // OpenSubsonic's own field, and the protocol's rule for one of its own
168
+ // additions: a server that supports it sends it even when it has nothing to
169
+ // say, so a client can tell "editable" from "this server has never heard of
170
+ // the question".
171
+ //
172
+ // False for the listener's own playlists — the one account here owns those —
173
+ // and true for one read from a `.m3u` in the collection. That is not a
174
+ // restriction invented here: the file is what that list *is*, the stage
175
+ // rewrites the row from it on every scan that re-reads it, and an edit a
176
+ // client was allowed to make would be undone by a scan nobody asked for.
177
+ // A client that is told so can grey the controls out instead of losing work.
178
+ readonly: row.source_file_id !== null,
179
+ songCount: row.song_count,
180
+ duration: seconds(row.duration_ms),
181
+ created: row.created_at,
182
+ changed: row.changed_at,
183
+ // The picture is the first song's, in this answer and in the listing alike
184
+ // — the protocol lets a server name the cover with any id `getCoverArt`
185
+ // answers to, and an id of the playlist's own would need `getCoverArt`
186
+ // taught a second kind of subject to say what this says already. An empty
187
+ // playlist is handed no picture rather than an id for a cover that does not
188
+ // exist.
189
+ ...(row.cover_track_id === null
190
+ ? {}
191
+ : {
192
+ coverArt: row.cover_album_id === null ? ID.track(row.cover_track_id) : ID.album(row.cover_album_id),
193
+ }),
194
+ };
195
+ }
196
+ /** A playlist and its songs, which is what opening one asks for. */
197
+ function withSongs(row, db, config) {
198
+ const id = ID.playlist(row.id);
199
+ return {
200
+ ...asPlaylist(row, config),
201
+ entry: songsOfPlaylist(db, row.id).map((song) => songChild(song, id)),
202
+ };
203
+ }
204
+ /**
205
+ * The playlists a client lists, without their songs.
206
+ *
207
+ * `username` is the protocol's filter, and this server has exactly one account:
208
+ * the configured one owns every playlist. A name that is not that one is
209
+ * answered with an empty list rather than an error — the question is "what does
210
+ * this user have", and the true answer is nothing, since no other user exists
211
+ * to have anything.
212
+ */
213
+ export function getPlaylists(db, config, query) {
214
+ const username = query.get('username');
215
+ const mine = username === null || username === '' || username === config.user;
216
+ return {
217
+ playlists: {
218
+ playlist: (mine ? store.playlists(db) : []).map((row) => asPlaylist(row, config)),
219
+ },
220
+ };
221
+ }
222
+ /** One playlist with its songs, by the id `getPlaylists` handed out. */
223
+ export function getPlaylist(db, config, query) {
224
+ const raw = required(query, 'id');
225
+ return { playlist: withSongs(requiredPlaylist(db, raw), db, config) };
226
+ }
227
+ /**
228
+ * A new playlist — or, handed an id, the replacement of an existing one.
229
+ *
230
+ * The protocol folds the two into one method, and the difference is one
231
+ * parameter — but **what an id does to the songs already there, the
232
+ * documentation does not say.** "Creates (or updates) a playlist" is the whole
233
+ * of it, and no client is told which of the two it will get.
234
+ *
235
+ * This server replaces them, because that is what the call means to the client
236
+ * that makes it: a player saving a playlist sends the list it has on screen,
237
+ * and a server that appended instead would make a song impossible to take out
238
+ * of a playlist by the one route that saves one. A client adding a single song
239
+ * has `updatePlaylist` for that, and says so by asking for it.
240
+ *
241
+ * **A call that names no songs at all is not a call that names none.** The
242
+ * protocol's own criterion for this feature tells renaming apart from emptying,
243
+ * and a client that renames sends an id and a name and nothing else; reading
244
+ * its silence as "the list is now empty" would throw the listener's songs away
245
+ * while answering `ok`, and the client would have no way to tell. So the songs
246
+ * are replaced only when the parameter is there, and a client that means to
247
+ * empty a playlist says so — `songId=` is that statement, and so is
248
+ * `updatePlaylist` with every position removed.
249
+ *
250
+ * `name` is required only when there is no id — a playlist with no name cannot
251
+ * be told from another in the sidebar a client draws, and a call that omits it
252
+ * is refused by name rather than given one. With an id it is optional, and a
253
+ * name the client did not send stays where it was.
254
+ */
255
+ export function createPlaylist(db, config, query) {
256
+ const songs = trackIdsIn(query, 'songId');
257
+ requireKnown(db, songs);
258
+ const rawId = query.get('playlistId');
259
+ if (rawId !== null && rawId !== '') {
260
+ const row = requiredPlaylist(db, rawId);
261
+ const asked = query.get('name');
262
+ const name = asked === null || asked === '' ? row.name : asked;
263
+ // Only when the client said something about the songs — see above.
264
+ const written = store.replace(db, row.id, name, query.has('songId') ? songs : undefined);
265
+ return { playlist: withSongs(written, db, config) };
266
+ }
267
+ const name = required(query, 'name');
268
+ return { playlist: withSongs(store.create(db, name, songs), db, config) };
269
+ }
270
+ /**
271
+ * A partial edit: rename, re-comment, add songs, drop songs.
272
+ *
273
+ * The two song arguments are applied with the removals first, because a
274
+ * position names a place in the list the client is holding — see `store.edit`
275
+ * for what the protocol does and does not say about that.
276
+ *
277
+ * A field the client did not send is left alone, and an empty one is left alone
278
+ * too **except for the comment**: a name of nothing is not a name a client can
279
+ * have meant to give, and neither is a visibility of nothing, while an empty
280
+ * comment is exactly how a client takes a comment off a playlist. That one is
281
+ * stored as no comment rather than as a comment that reads empty — the two look
282
+ * the same in a client's sidebar and only one of them is true.
283
+ */
284
+ export function updatePlaylist(db, config, query) {
285
+ const row = requiredEditable(db, required(query, 'playlistId'));
286
+ const add = trackIdsIn(query, 'songIdToAdd');
287
+ requireKnown(db, add);
288
+ const name = query.get('name');
289
+ const comment = query.get('comment');
290
+ const isPublic = query.get('public');
291
+ store.edit(db, row.id, {
292
+ ...(name === null || name === '' ? {} : { name }),
293
+ ...(comment === null ? {} : { comment: comment === '' ? null : comment }),
294
+ ...(isPublic === null || isPublic === '' ? {} : { public: booleanish(isPublic) }),
295
+ add,
296
+ remove: positionsIn(query, 'songIndexToRemove'),
297
+ });
298
+ return {};
299
+ }
300
+ /**
301
+ * A playlist, gone — the songs it named are untouched.
302
+ *
303
+ * A second call for the same id is refused as naming nothing, for the reason
304
+ * every other route refuses one: the client is told its question had no
305
+ * subject, which is truer than an `ok` about a deletion that did not happen.
306
+ */
307
+ export function deletePlaylist(db, query) {
308
+ const row = requiredEditable(db, required(query, 'id'));
309
+ store.remove(db, row.id);
310
+ return {};
311
+ }
@@ -0,0 +1,196 @@
1
+ import { watch } from 'node:fs';
2
+ import { dueAt, quiet, staleReadings, watchable } from "../scan/schedule.js";
3
+ import { SETTLE_MS } from "../scan/settle.js";
4
+ /**
5
+ * How often to look at the clock, when the interval does not ask for finer.
6
+ *
7
+ * Five minutes costs two counts and a row, which is nothing — but a tick coarser
8
+ * than the interval would make the interval a lie: a deployment that asked for a
9
+ * scan every minute and got one every five would be a deployment whose setting
10
+ * does not mean what it says. So the tick is a quarter of the interval whenever
11
+ * that is finer, and five minutes otherwise.
12
+ */
13
+ const TICK_MS = 5 * 60_000;
14
+ export function tickFor(schedule) {
15
+ if (schedule.intervalMinutes <= 0)
16
+ return TICK_MS;
17
+ return Math.max(1_000, Math.min(TICK_MS, (schedule.intervalMinutes * 60_000) / 4));
18
+ }
19
+ /**
20
+ * How long the disk must be silent before a change is followed by a scan.
21
+ *
22
+ * **This is the watcher's half of the half-write gate, and the number belongs to
23
+ * the other half too.** The interval scan faces the same problem with no events
24
+ * to wait on, so it looks at a recently-written file twice instead — same rule,
25
+ * same window, and the constant lives with that rule in `scan/settle.ts` rather
26
+ * than here. Two numbers for one contract clause would be two rules written down
27
+ * as agreement.
28
+ *
29
+ * Waiting for silence is what the watcher owes and cannot avoid: it is told
30
+ * about an event, not about a file, so the torrent stopping is the only thing it
31
+ * can wait for. Five seconds rather than one, because a copy over the network
32
+ * pauses — and a pause longer than the window is the one case neither path
33
+ * catches, which `settle.ts` states rather than hides.
34
+ */
35
+ export function rescan(deps) {
36
+ const now = deps.now ?? (() => new Date());
37
+ const tick = deps.tickMs ?? tickFor(deps.schedule);
38
+ const settle = deps.settleMs ?? SETTLE_MS;
39
+ let stopped = false;
40
+ /**
41
+ * Whether the operator has already been told there is nothing to read.
42
+ *
43
+ * Said once rather than on every tick: a server with no roots is a server
44
+ * somebody is about to configure, and a line every five minutes for a week is
45
+ * a log that teaches its reader to skip lines. Said again if roots come and
46
+ * go, because then it is news again.
47
+ */
48
+ let saidNoRoots = false;
49
+ let timer = null;
50
+ let settling = null;
51
+ const start = (mode, why) => {
52
+ const started = deps.scanner.start(mode);
53
+ deps.log(started.ok
54
+ ? `funoteka: scanning (${why})`
55
+ : // Not an error: a scan that is already running is a scan, and the
56
+ // reason this one was asked for is worth saying anyway — it is how
57
+ // somebody finds out their interval is shorter than their scan.
58
+ `funoteka: not scanning (${why}): ${started.reason}`);
59
+ };
60
+ /**
61
+ * The roots as they were when the watchers were last reconciled, so that a
62
+ * tick which finds them unchanged does nothing at all.
63
+ */
64
+ let watched = [];
65
+ const check = () => {
66
+ if (stopped)
67
+ return;
68
+ if (deps.watch) {
69
+ const now = deps.roots();
70
+ if (now.join('\u0000') !== watched.join('\u0000')) {
71
+ watched = now;
72
+ reconcileWatchers();
73
+ }
74
+ }
75
+ // A scan already on is the answer to every reason to start one.
76
+ if (deps.scanner.status().running !== null)
77
+ return;
78
+ if (deps.roots().length === 0) {
79
+ if (!saidNoRoots) {
80
+ deps.log('funoteka: nothing to scan — no roots are configured');
81
+ saidNoRoots = true;
82
+ }
83
+ return;
84
+ }
85
+ saidNoRoots = false;
86
+ const stale = staleReadings(deps.db);
87
+ // **The quiet hours hold here too.** They are a rule about when a scan may
88
+ // *start*, and an upgraded deployment whose files are read by an older method
89
+ // is not urgent enough to be the exception: the tick asks again, and when the
90
+ // window closes it scans. The review found this trigger walking past the
91
+ // window that the interval beside it respects.
92
+ if ((stale.tags > 0 || stale.probes > 0) && !quiet(now(), deps.schedule)) {
93
+ start('incremental', `${stale.tags} file(s) and ${stale.probes} probe(s) read by an older method — this build reads them again`);
94
+ return;
95
+ }
96
+ const last = deps.scanner.status().last?.startedAt ?? null;
97
+ const due = dueAt(last, now(), deps.schedule);
98
+ if (due === null)
99
+ return;
100
+ if (due.getTime() > now().getTime())
101
+ return;
102
+ start('incremental', last === null ? 'this library has never been read' : `its interval elapsed at ${due.toISOString()}`);
103
+ };
104
+ /**
105
+ * Something changed under a root; scan once the disk has stopped changing.
106
+ *
107
+ * The timer is reset by every event, which is the whole of the gate: what is
108
+ * waited for is silence, and a copy that is still running keeps pushing it
109
+ * away.
110
+ */
111
+ const touched = () => {
112
+ if (settling !== null)
113
+ clearTimeout(settling);
114
+ settling = setTimeout(() => {
115
+ settling = null;
116
+ if (stopped)
117
+ return;
118
+ // Silent when a scan is already on, and deliberately: the change is either
119
+ // picked up by the scan that is running or by the next one, and a line per
120
+ // event would be a log full of a copy that is still going on.
121
+ if (deps.scanner.status().running !== null)
122
+ return;
123
+ if (deps.roots().length === 0)
124
+ return;
125
+ start('incremental', `something changed under a root and the disk has been quiet for ${settle / 1000}s`);
126
+ }, settle);
127
+ settling.unref?.();
128
+ };
129
+ /**
130
+ * Watch what is configured *now*.
131
+ *
132
+ * **Called whenever the roots change, and that is the point.** The first
133
+ * version built the watcher set once, when the engine was created — so a shelf
134
+ * added afterwards with `POST /roots` was never watched, while the field beside
135
+ * it promised "a shelf added a minute ago is watched too". A watcher nobody
136
+ * notices is missing is the failure this option is careful about everywhere
137
+ * else, and it was the one place it was not.
138
+ */
139
+ const watching = new Map();
140
+ const reconcileWatchers = () => {
141
+ const wanted = new Set(deps.roots());
142
+ for (const [root, watcher] of watching) {
143
+ if (wanted.has(root))
144
+ continue;
145
+ watcher.close();
146
+ watching.delete(root);
147
+ deps.log(`funoteka: stopped watching ${root} — it is no longer a root`);
148
+ }
149
+ for (const root of wanted) {
150
+ if (watching.has(root))
151
+ continue;
152
+ const can = watchable(root);
153
+ if (!can.ok) {
154
+ // Said out loud, and the interval carries on: a watcher that is silently
155
+ // deaf is worse than no watcher, because the deployment believes it has
156
+ // one.
157
+ deps.log(`funoteka: not watching ${root} — ${can.why}; the interval still scans`);
158
+ continue;
159
+ }
160
+ try {
161
+ // Recursive where the platform has it. Linux does not, and this is the
162
+ // honest handling of that: one line, and the interval is what scans.
163
+ const watcher = watch(root, { recursive: true }, touched);
164
+ watcher.on('error', (err) => {
165
+ deps.log(`funoteka: watching ${root} failed (${err.message}) — the interval still scans`);
166
+ });
167
+ watching.set(root, watcher);
168
+ deps.log(`funoteka: watching ${root} for changes`);
169
+ }
170
+ catch (err) {
171
+ deps.log(`funoteka: cannot watch ${root} (${err.message}) — the interval still scans`);
172
+ }
173
+ }
174
+ };
175
+ if (deps.watch) {
176
+ watched = deps.roots();
177
+ reconcileWatchers();
178
+ }
179
+ timer = setInterval(check, tick);
180
+ timer.unref?.();
181
+ if (deps.startup !== false)
182
+ check();
183
+ return {
184
+ check,
185
+ stop: () => {
186
+ stopped = true;
187
+ if (timer !== null)
188
+ clearInterval(timer);
189
+ if (settling !== null)
190
+ clearTimeout(settling);
191
+ for (const watcher of watching.values())
192
+ watcher.close();
193
+ watching.clear();
194
+ },
195
+ };
196
+ }
@@ -0,0 +1,161 @@
1
+ import { statSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { rootKey } from "../scan/scan.js";
4
+ import { parseRoots } from "./scanner.js";
5
+ /**
6
+ * Every root, with what is under it.
7
+ *
8
+ * The counts are per root rather than a total, because the question the listing
9
+ * answers is "where did my library come from" — a root with nothing under it is
10
+ * either a shelf that has not been scanned yet or a path that leads somewhere
11
+ * empty, and those want different answers from whoever is reading.
12
+ */
13
+ export function listRoots(db) {
14
+ const rows = db
15
+ .prepare('SELECT id, path, alias, created_at FROM root ORDER BY path')
16
+ .all();
17
+ const runs = recentRuns(db);
18
+ return rows.map((row) => {
19
+ // Found once: the two fields below are one run read twice, and asking for it
20
+ // twice walked the fifty runs twice for every root.
21
+ const last = lastRunFor(runs, rootKey(row.path));
22
+ return {
23
+ id: row.id,
24
+ path: row.path,
25
+ alias: row.alias,
26
+ createdAt: row.created_at,
27
+ folders: countOf(db, 'folder', row.id),
28
+ files: countOf(db, 'file', row.id, "AND kind = 'audio'"),
29
+ albums: countOf(db, 'album', row.id),
30
+ lastScannedAt: last?.finished_at ?? null,
31
+ lastScanStatus: last?.status ?? null,
32
+ };
33
+ });
34
+ }
35
+ /**
36
+ * Configure a directory as a root.
37
+ *
38
+ * **It has to be there.** A root naming a directory that does not exist is a
39
+ * deployment that will scan nothing and report success — the classic shape of a
40
+ * mistyped path — so the path is checked while the operator is still looking at
41
+ * the answer. The check is a `stat` and not a walk: what is under it is the
42
+ * scan's business.
43
+ *
44
+ * Adding one that is already a root is not an error and not a second row. It is
45
+ * the same directory, whoever says so — and the answer says it was already
46
+ * there, because an operator who thought they were adding a shelf should know
47
+ * they did not.
48
+ */
49
+ export function addRoot(db, given) {
50
+ const path = resolve(given);
51
+ let isDirectory = false;
52
+ try {
53
+ isDirectory = statSync(path).isDirectory();
54
+ }
55
+ catch {
56
+ // The sentence below covers both a path that is not there and one that
57
+ // cannot be read, and the second is rarer than the first by a long way.
58
+ isDirectory = false;
59
+ }
60
+ if (!isDirectory)
61
+ throw new Error(`not a directory on this machine: ${path}`);
62
+ const rows = db.prepare('SELECT id, path, alias, created_at FROM root').all();
63
+ const key = rootKey(path);
64
+ const known = rows.find((row) => rootKey(row.path) === key);
65
+ if (known !== undefined) {
66
+ // The row is kept as it is, and the stored spelling is left alone: it is
67
+ // what the operator typed when they configured it, and a listing that
68
+ // silently rewrote it would be reporting a path nobody chose. A scan
69
+ // canonicalises it, which is the place that has the filesystem's answer.
70
+ return { root: view(db, known.id), already: true };
71
+ }
72
+ const inserted = db
73
+ .prepare('INSERT INTO root (path, created_at) VALUES (?, ?)')
74
+ .run(path, new Date().toISOString());
75
+ return { root: view(db, Number(inserted.lastInsertRowid)), already: false };
76
+ }
77
+ /**
78
+ * Stop serving a directory, and take what came from it with it.
79
+ *
80
+ * **This is the destructive verb, and the cascade is deliberate.** Every folder,
81
+ * file, album and track under this root is derived from it — `scan.ts` says so
82
+ * in the schema, `ON DELETE CASCADE` all the way down — so a root that stayed
83
+ * while its rows did would be a library still serving music from a shelf the
84
+ * operator had removed from the deployment. The files on disk are untouched:
85
+ * this is a statement about what this server reads, not about the disk.
86
+ *
87
+ * The answer carries the count, because "removed" and "removed a third of your
88
+ * library" are the same word and not the same event.
89
+ */
90
+ export function removeRoot(db, given) {
91
+ const key = rootKey(resolve(given));
92
+ const rows = db.prepare('SELECT id, path FROM root').all();
93
+ const doomed = rows.find((row) => rootKey(row.path) === key);
94
+ if (doomed === undefined)
95
+ return null;
96
+ const before = view(db, doomed.id);
97
+ // One transaction, and `IMMEDIATE` like every other write here: a cascade
98
+ // that unwound halfway would leave a root whose rows are some of what it had.
99
+ db.exec('BEGIN IMMEDIATE');
100
+ try {
101
+ db.prepare('DELETE FROM root WHERE id = ?').run(doomed.id);
102
+ db.exec('COMMIT');
103
+ }
104
+ catch (err) {
105
+ try {
106
+ db.exec('ROLLBACK');
107
+ }
108
+ catch {
109
+ // Already unwound; the original error is what matters.
110
+ }
111
+ throw err;
112
+ }
113
+ return { root: before, songs: before.files, albums: before.albums };
114
+ }
115
+ /** One root, counted the way the listing counts them. */
116
+ function view(db, id) {
117
+ const row = db.prepare('SELECT id, path, alias, created_at FROM root WHERE id = ?').get(id);
118
+ const runs = recentRuns(db);
119
+ const last = lastRunFor(runs, rootKey(row.path));
120
+ return {
121
+ id: row.id,
122
+ path: row.path,
123
+ alias: row.alias,
124
+ createdAt: row.created_at,
125
+ folders: countOf(db, 'folder', row.id),
126
+ files: countOf(db, 'file', row.id, "AND kind = 'audio'"),
127
+ albums: countOf(db, 'album', row.id),
128
+ lastScannedAt: last?.finished_at ?? null,
129
+ lastScanStatus: last?.status ?? null,
130
+ };
131
+ }
132
+ function countOf(db, table, rootId, extra = '') {
133
+ const row = db
134
+ .prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE root_id = ? ${extra}`)
135
+ .get(rootId);
136
+ return row.n;
137
+ }
138
+ function recentRuns(db) {
139
+ const rows = db
140
+ .prepare('SELECT status, finished_at, roots_json FROM scan_run ORDER BY id DESC LIMIT 50')
141
+ .all();
142
+ // The same two or three shelves appear in every one of the fifty runs, so the
143
+ // answer for a path is asked for once and kept.
144
+ const keys = new Map();
145
+ const keyOf = (path) => {
146
+ const known = keys.get(path);
147
+ if (known !== undefined)
148
+ return known;
149
+ const key = rootKey(path);
150
+ keys.set(path, key);
151
+ return key;
152
+ };
153
+ return rows.map((row) => ({
154
+ status: row.status,
155
+ finished_at: row.finished_at,
156
+ keys: parseRoots(row.roots_json).map(keyOf),
157
+ }));
158
+ }
159
+ function lastRunFor(runs, key) {
160
+ return runs.find((run) => run.keys.includes(key));
161
+ }