dictawhisper 0.0.13 → 0.0.14

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 (60) hide show
  1. package/README.md +10 -2
  2. package/bin/dictawhisper.mjs +14 -0
  3. package/config.example.json +41 -0
  4. package/dist/ui/_app/immutable/assets/0.C6M3gHo0.css +1 -0
  5. package/dist/ui/_app/immutable/assets/2.C96vgj_4.css +1 -0
  6. package/dist/ui/_app/immutable/chunks/BSs9MmST.js +3 -0
  7. package/dist/ui/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  8. package/dist/ui/_app/immutable/chunks/BslYtab-.js +1 -0
  9. package/dist/ui/_app/immutable/chunks/xihTtKlq.js +1 -0
  10. package/dist/ui/_app/immutable/entry/app.BJlRju4c.js +2 -0
  11. package/dist/ui/_app/immutable/entry/start.C5XBbXTT.js +1 -0
  12. package/dist/ui/_app/immutable/nodes/0.D23pkrHr.js +1 -0
  13. package/dist/ui/_app/immutable/nodes/1.C5T-U4uV.js +1 -0
  14. package/dist/ui/_app/immutable/nodes/2.59uAq_ko.js +5 -0
  15. package/dist/ui/_app/version.json +1 -0
  16. package/dist/ui/favicon.png +0 -0
  17. package/dist/ui/favicon.svg +3 -0
  18. package/dist/ui/index.html +38 -0
  19. package/dist/ui/logo.png +0 -0
  20. package/package.json +12 -3
  21. package/scripts/transcribe_faster_whisper.py +428 -0
  22. package/src/apiRoutes.ts +464 -0
  23. package/src/classes/Queue.ts +77 -0
  24. package/src/classes/Watcher.ts +117 -0
  25. package/src/cli.ts +132 -0
  26. package/src/config.ts +238 -0
  27. package/src/dev.ts +63 -0
  28. package/src/doctor.ts +12 -0
  29. package/src/lib/alignLib.ts +154 -0
  30. package/src/lib/audioLib.ts +202 -0
  31. package/src/lib/audioProbeLib.ts +112 -0
  32. package/src/lib/cleanupProvenanceLib.ts +80 -0
  33. package/src/lib/downloadLib.ts +8 -0
  34. package/src/lib/fileSettleLib.ts +213 -0
  35. package/src/lib/fsLib.ts +37 -0
  36. package/src/lib/healthLib.ts +377 -0
  37. package/src/lib/inboxUrl.ts +123 -0
  38. package/src/lib/journalEmbedLib.ts +127 -0
  39. package/src/lib/journalIndexLib.ts +923 -0
  40. package/src/lib/journalQueryLib.ts +269 -0
  41. package/src/lib/journalService.ts +287 -0
  42. package/src/lib/jsonLib.ts +71 -0
  43. package/src/lib/localberthGet.ts +14 -0
  44. package/src/lib/ollanetLib.ts +117 -0
  45. package/src/lib/ollanetReadyLib.ts +7 -0
  46. package/src/lib/organizationLib.ts +182 -0
  47. package/src/lib/pathAllowLib.ts +60 -0
  48. package/src/lib/queueLib.ts +30 -0
  49. package/src/lib/speechCleanupLib.ts +14 -0
  50. package/src/lib/tagConsolidateLib.ts +431 -0
  51. package/src/lib/tailscaleLib.ts +78 -0
  52. package/src/lib/transcriptionLib.ts +484 -0
  53. package/src/lib/whisperLib.ts +409 -0
  54. package/src/mcp.ts +151 -0
  55. package/src/prompts/cleanTranscription.ts +62 -0
  56. package/src/prompts/consolidateTags.ts +38 -0
  57. package/src/prompts/whisperInitial.ts +11 -0
  58. package/src/server.ts +220 -0
  59. package/src/socketEvents.ts +72 -0
  60. package/src/types/transcription.ts +56 -0
@@ -0,0 +1,464 @@
1
+ import express from 'express';
2
+ import multer from 'multer';
3
+ import { process, cleanTranscription, countStatus, getTranscriptionFilename, listNoteSummaries, patchTranscription, readTranscription, skipCleanup, emitNotesIndex } from './lib/transcriptionLib.ts';
4
+ import {
5
+ journalStats,
6
+ journalTags,
7
+ journalYears,
8
+ listInboxNotes,
9
+ notesIndexPayload,
10
+ searchJournalIndex,
11
+ } from './lib/journalService.ts';
12
+ import type { SearchMode, SearchSort } from './lib/journalIndexLib.ts';
13
+ import { q } from './lib/queueLib.ts';
14
+ import { config } from './config.ts';
15
+ import { resolveWhisperModel } from './lib/whisperLib.ts';
16
+ import { getHealthReport } from './lib/healthLib.ts';
17
+ import { resolveAllowedPath } from './lib/pathAllowLib.ts';
18
+ import { audioContentType, findAudioForSidecar, saveUploadedAudio } from './lib/audioLib.ts';
19
+ import { contentDisposition } from './lib/downloadLib.ts';
20
+ import { applyConsolidateGroups, buildConsolidatePlan } from './lib/tagConsolidateLib.ts';
21
+ import { resolveHeldFile, type HoldingAction } from './lib/organizationLib.ts';
22
+ import { getProbeJob, startProbeJob } from './lib/audioProbeLib.ts';
23
+
24
+ const upload = multer({
25
+ storage: multer.memoryStorage(),
26
+ limits: { fileSize: 80 * 1024 * 1024 },
27
+ });
28
+
29
+ function queryList(value: unknown): string[] {
30
+ if (Array.isArray(value)) return value.flatMap((item) => queryList(item));
31
+ if (typeof value === 'string' && value.trim()) return [value.trim()];
32
+ return [];
33
+ }
34
+
35
+ function queryFlag(value: unknown): boolean {
36
+ return value === '1' || value === 'true' || value === true;
37
+ }
38
+
39
+ function requireAllowedFile(req: express.Request, res: express.Response): string | null {
40
+ const file = typeof req.body?.file === 'string' ? req.body.file.trim() : '';
41
+ if (!file) {
42
+ res.status(400).json({ error: 'POST { "file": "<path under a watch root>" }' });
43
+ return null;
44
+ }
45
+ const allowed = resolveAllowedPath(file);
46
+ if (!allowed.ok) {
47
+ res.status(403).json({ error: allowed.error });
48
+ return null;
49
+ }
50
+ return allowed.path;
51
+ }
52
+
53
+ export const apiRoutes = [
54
+ {
55
+ path: '/',
56
+ method: 'GET',
57
+ handler: (_req: express.Request, res: express.Response) => {
58
+ res.send('<h1>DictaWhisper</h1><p>Watch, transcribe, clean.</p>');
59
+ },
60
+ },
61
+ {
62
+ path: '/health',
63
+ method: 'GET',
64
+ handler: async (req: express.Request, res: express.Response) => {
65
+ const fresh = req.query.fresh === '1' || req.query.fresh === 'true';
66
+ const report = await getHealthReport(config, { mode: 'live', fresh });
67
+ res.status(report.ok ? 200 : 503).json({
68
+ ok: report.ok,
69
+ degraded: report.degraded,
70
+ host: config.http.host,
71
+ port: config.http.port,
72
+ whisper: resolveWhisperModel(),
73
+ whisperWorker: report.whisper.worker,
74
+ device: report.whisper.device,
75
+ ollanet: {
76
+ ...config.ollanet,
77
+ reachable: report.ollanet.reachable,
78
+ },
79
+ settleMinutes: config.watch.settleMinutes,
80
+ browserSettleMs: config.watch.browserSettleMs,
81
+ checks: report.checks,
82
+ queues: {
83
+ transcription: q['transcription']
84
+ ? { length: q['transcription'].length(), running: q['transcription'].running() }
85
+ : null,
86
+ processing: q['processing']
87
+ ? { length: q['processing'].length(), running: q['processing'].running() }
88
+ : null,
89
+ },
90
+ });
91
+ },
92
+ },
93
+ {
94
+ path: '/audio',
95
+ method: 'GET',
96
+ handler: (req: express.Request, res: express.Response) => {
97
+ const file = typeof req.query.file === 'string' ? req.query.file.trim() : '';
98
+ if (!file) {
99
+ res.status(400).json({ error: 'GET /audio?file=<path under a watch root>' });
100
+ return;
101
+ }
102
+ const allowed = resolveAllowedPath(file);
103
+ if (!allowed.ok) {
104
+ res.status(403).json({ error: allowed.error });
105
+ return;
106
+ }
107
+ let audioPath = allowed.path;
108
+ if (audioPath.toLowerCase().endsWith('.json')) {
109
+ const sibling = findAudioForSidecar(audioPath);
110
+ if (!sibling) {
111
+ res.status(404).json({ error: 'no audio file next to that sidecar' });
112
+ return;
113
+ }
114
+ audioPath = sibling;
115
+ }
116
+ const audioAllowed = resolveAllowedPath(audioPath);
117
+ if (!audioAllowed.ok) {
118
+ res.status(403).json({ error: audioAllowed.error });
119
+ return;
120
+ }
121
+ const asDownload = queryFlag(req.query.download);
122
+ res.setHeader('Content-Type', audioContentType(audioAllowed.path));
123
+ res.setHeader(
124
+ 'Content-Disposition',
125
+ contentDisposition(audioAllowed.path, asDownload ? 'attachment' : 'inline'),
126
+ );
127
+ res.sendFile(audioAllowed.relative, { root: audioAllowed.root });
128
+ },
129
+ },
130
+ {
131
+ path: '/notes/index',
132
+ method: 'GET',
133
+ handler: (req: express.Request, res: express.Response) => {
134
+ try {
135
+ const year = typeof req.query.year === 'string' ? req.query.year.trim() : '';
136
+ const month = typeof req.query.month === 'string' ? req.query.month.trim() : '';
137
+ const unreadable = queryFlag(req.query.unreadable);
138
+ const starred = queryFlag(req.query.starred);
139
+ const all = queryFlag(req.query.all);
140
+ if (year || month || unreadable || starred || all) {
141
+ res.json({
142
+ notes: listInboxNotes({
143
+ year: year || undefined,
144
+ month: month || undefined,
145
+ unreadable: unreadable || undefined,
146
+ starred: starred || undefined,
147
+ all: all || undefined,
148
+ }),
149
+ paged: !all,
150
+ indexing: journalStats().indexing,
151
+ });
152
+ return;
153
+ }
154
+ res.json(notesIndexPayload(listNoteSummaries));
155
+ } catch (error) {
156
+ const detail = error instanceof Error ? error.message : String(error);
157
+ console.error(`[notes-index] failed: ${detail}`);
158
+ res.status(500).json({ error: 'failed to build notes index' });
159
+ }
160
+ },
161
+ },
162
+ {
163
+ path: '/notes/search',
164
+ method: 'GET',
165
+ handler: async (req: express.Request, res: express.Response) => {
166
+ try {
167
+ const query = typeof req.query.q === 'string' ? req.query.q : '';
168
+ const tags = queryList(req.query.tag);
169
+ const since = typeof req.query.since === 'string' ? req.query.since.trim() : '';
170
+ const until = typeof req.query.until === 'string' ? req.query.until.trim() : '';
171
+ const modeRaw = typeof req.query.mode === 'string' ? req.query.mode.trim() : '';
172
+ const mode = modeRaw === 'lex' || modeRaw === 'semantic' || modeRaw === 'hybrid' ? (modeRaw as SearchMode) : undefined;
173
+ const sortRaw = typeof req.query.sort === 'string' ? req.query.sort.trim() : '';
174
+ const sort = sortRaw === 'recent' || sortRaw === 'oldest' || sortRaw === 'relevance' ? (sortRaw as SearchSort) : undefined;
175
+ const year = typeof req.query.year === 'string' ? req.query.year.trim() : '';
176
+ const month = typeof req.query.month === 'string' ? req.query.month.trim() : '';
177
+ const limit = Number(req.query.limit);
178
+ const hits = await searchJournalIndex({
179
+ query,
180
+ tags,
181
+ since: since || undefined,
182
+ until: until || undefined,
183
+ year: year || undefined,
184
+ month: month || undefined,
185
+ mode,
186
+ sort,
187
+ limit: Number.isFinite(limit) ? limit : undefined,
188
+ unreadable: queryFlag(req.query.unreadable),
189
+ starred: queryFlag(req.query.starred),
190
+ });
191
+ res.json({ hits, count: hits.length });
192
+ } catch (error) {
193
+ const detail = error instanceof Error ? error.message : String(error);
194
+ console.error(`[notes-search] failed: ${detail}`);
195
+ res.status(500).json({ error: 'search failed' });
196
+ }
197
+ },
198
+ },
199
+ {
200
+ path: '/notes/years',
201
+ method: 'GET',
202
+ handler: (_req: express.Request, res: express.Response) => {
203
+ try {
204
+ res.json({ years: journalYears() });
205
+ } catch (error) {
206
+ const detail = error instanceof Error ? error.message : String(error);
207
+ res.status(500).json({ error: detail });
208
+ }
209
+ },
210
+ },
211
+ {
212
+ path: '/notes/tags',
213
+ method: 'GET',
214
+ handler: (req: express.Request, res: express.Response) => {
215
+ try {
216
+ res.json({
217
+ tags: journalTags({
218
+ includeSingletons: queryFlag(req.query.includeSingletons),
219
+ limit: Number(req.query.limit) || undefined,
220
+ }),
221
+ });
222
+ } catch (error) {
223
+ const detail = error instanceof Error ? error.message : String(error);
224
+ res.status(500).json({ error: detail });
225
+ }
226
+ },
227
+ },
228
+ {
229
+ path: '/notes/stats',
230
+ method: 'GET',
231
+ handler: (_req: express.Request, res: express.Response) => {
232
+ try {
233
+ res.json(journalStats());
234
+ } catch (error) {
235
+ const detail = error instanceof Error ? error.message : String(error);
236
+ res.status(500).json({ error: detail });
237
+ }
238
+ },
239
+ },
240
+ {
241
+ path: '/note',
242
+ method: 'GET',
243
+ handler: (req: express.Request, res: express.Response) => {
244
+ const file = typeof req.query.file === 'string' ? req.query.file.trim() : '';
245
+ if (!file) {
246
+ res.status(400).json({ error: 'GET /note?file=<sidecar path under a watch root>' });
247
+ return;
248
+ }
249
+ const allowed = resolveAllowedPath(file);
250
+ if (!allowed.ok) {
251
+ res.status(403).json({ error: allowed.error });
252
+ return;
253
+ }
254
+ const sidecar = allowed.path.toLowerCase().endsWith('.json')
255
+ ? allowed.path
256
+ : getTranscriptionFilename(allowed.path);
257
+ const sidecarAllowed = resolveAllowedPath(sidecar);
258
+ if (!sidecarAllowed.ok) {
259
+ res.status(403).json({ error: sidecarAllowed.error });
260
+ return;
261
+ }
262
+ if (queryFlag(req.query.download)) {
263
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
264
+ res.setHeader('Content-Disposition', contentDisposition(sidecarAllowed.path, 'attachment'));
265
+ res.sendFile(sidecarAllowed.relative, { root: sidecarAllowed.root });
266
+ return;
267
+ }
268
+ try {
269
+ const note = readTranscription(sidecarAllowed.path);
270
+ if (!note) {
271
+ res.status(404).json({ error: 'note not found' });
272
+ return;
273
+ }
274
+ res.json(note);
275
+ } catch (error: any) {
276
+ res.status(500).json({ error: error?.message || 'failed to read note' });
277
+ }
278
+ },
279
+ },
280
+ {
281
+ path: '/note',
282
+ method: 'POST',
283
+ handler: (req: express.Request, res: express.Response) => {
284
+ const file = requireAllowedFile(req, res);
285
+ if (!file) return;
286
+ const jsonFile = file.toLowerCase().endsWith('.json') ? file : getTranscriptionFilename(file);
287
+ const allowed = resolveAllowedPath(jsonFile);
288
+ if (!allowed.ok) {
289
+ res.status(403).json({ error: allowed.error });
290
+ return;
291
+ }
292
+ if (req.body?.tags === undefined && typeof req.body?.starred !== 'boolean') {
293
+ res.status(400).json({ error: 'POST { "file", "tags"?: string[], "starred"?: boolean }' });
294
+ return;
295
+ }
296
+ try {
297
+ res.json(
298
+ patchTranscription(allowed.path, {
299
+ tags: req.body?.tags,
300
+ starred: typeof req.body?.starred === 'boolean' ? req.body.starred : undefined,
301
+ }),
302
+ );
303
+ } catch (error: any) {
304
+ const status = String(error?.message || '').includes('not found') ? 404 : 500;
305
+ res.status(status).json({ error: error?.message || 'failed to update note' });
306
+ }
307
+ },
308
+ },
309
+ {
310
+ path: '/tools/probe',
311
+ method: 'GET',
312
+ handler: (_req: express.Request, res: express.Response) => {
313
+ res.json(getProbeJob());
314
+ },
315
+ },
316
+ {
317
+ path: '/tools/probe',
318
+ method: 'POST',
319
+ handler: (req: express.Request, res: express.Response) => {
320
+ const apply = Boolean(req.body?.apply);
321
+ const current = getProbeJob();
322
+ if (current.running) {
323
+ res.status(409).json(current);
324
+ return;
325
+ }
326
+ const started = startProbeJob([...config.watch.roots, config.watch.browserDropFolder], { apply });
327
+ res.status(202).json(started);
328
+ },
329
+ },
330
+ {
331
+ path: '/status',
332
+ method: 'GET',
333
+ handler: (_req: express.Request, res: express.Response) => {
334
+ res.json(countStatus([...config.watch.roots, config.watch.browserDropFolder]));
335
+ },
336
+ },
337
+ {
338
+ path: '/transcribe/force',
339
+ method: 'POST',
340
+ handler: (req: express.Request, res: express.Response) => {
341
+ const file = requireAllowedFile(req, res);
342
+ if (!file) return;
343
+ process(file, { retry: true });
344
+ res.json({ ok: true, file, retried: true });
345
+ },
346
+ },
347
+ {
348
+ path: '/process/force',
349
+ method: 'POST',
350
+ handler: (req: express.Request, res: express.Response) => {
351
+ const file = requireAllowedFile(req, res);
352
+ if (!file) return;
353
+ cleanTranscription(file, (err) => {
354
+ if (err) {
355
+ res.status(500).json({ ok: false, error: err.message });
356
+ return;
357
+ }
358
+ res.json({ ok: true, file, processed: true });
359
+ }, { reclean: true });
360
+ },
361
+ },
362
+ {
363
+ path: '/process/skip',
364
+ method: 'POST',
365
+ handler: (req: express.Request, res: express.Response) => {
366
+ const file = requireAllowedFile(req, res);
367
+ if (!file) return;
368
+ try {
369
+ skipCleanup(file);
370
+ res.json({ ok: true, file, skipped: true });
371
+ } catch (error: any) {
372
+ res.status(500).json({ error: error?.message || 'skip failed' });
373
+ }
374
+ },
375
+ },
376
+ {
377
+ path: '/audio',
378
+ method: 'POST',
379
+ handler: (req: express.Request, res: express.Response) => {
380
+ upload.fields([
381
+ { name: 'file', maxCount: 1 },
382
+ { name: 'audio', maxCount: 1 },
383
+ ])(req, res, (err: unknown) => {
384
+ if (err) {
385
+ res.status(400).json({ error: err instanceof Error ? err.message : 'upload failed' });
386
+ return;
387
+ }
388
+ const files = req.files as Record<string, Express.Multer.File[]> | undefined;
389
+ const uploaded = files?.file?.[0] || files?.audio?.[0];
390
+ if (!uploaded?.buffer) {
391
+ res.status(400).json({ error: 'POST multipart field "file" (or "audio")' });
392
+ return;
393
+ }
394
+ const clipName = typeof req.body?.clipName === 'string' ? req.body.clipName.trim() : '';
395
+ const saved = saveUploadedAudio(uploaded.buffer, uploaded.originalname, clipName || undefined);
396
+ void process(saved, { force: true });
397
+ res.json({ ok: true, file: saved });
398
+ });
399
+ },
400
+ },
401
+ {
402
+ path: '/holding/resolve',
403
+ method: 'POST',
404
+ handler: async (req: express.Request, res: express.Response) => {
405
+ const file = requireAllowedFile(req, res);
406
+ if (!file) return;
407
+ const action = String(req.body?.action || '').trim() as HoldingAction;
408
+ if (action !== 'overwrite' && action !== 'rename' && action !== 'unfile') {
409
+ res.status(400).json({ error: 'POST { "file", "action": "overwrite" | "rename" | "unfile" }' });
410
+ return;
411
+ }
412
+ try {
413
+ const dest = await resolveHeldFile(file, action);
414
+ void process(dest, { force: true });
415
+ emitNotesIndex();
416
+ res.json({ ok: true, file: dest, action });
417
+ } catch (error: any) {
418
+ res.status(500).json({ error: error?.message || 'holding resolve failed' });
419
+ }
420
+ },
421
+ },
422
+ {
423
+ path: '/tags/consolidate/preview',
424
+ method: 'POST',
425
+ handler: async (req: express.Request, res: express.Response) => {
426
+ try {
427
+ const useModel = req.body?.useModel !== false;
428
+ const plan = await buildConsolidatePlan({ useModel });
429
+ res.json(plan);
430
+ } catch (error: any) {
431
+ res.status(500).json({ error: error?.message || 'tag preview failed' });
432
+ }
433
+ },
434
+ },
435
+ {
436
+ path: '/tags/consolidate/apply',
437
+ method: 'POST',
438
+ handler: (req: express.Request, res: express.Response) => {
439
+ const groups = Array.isArray(req.body?.groups) ? req.body.groups : null;
440
+ if (!groups) {
441
+ res.status(400).json({ error: 'POST { "groups": [{ "keep": "tag", "drop": ["alias"] }] }' });
442
+ return;
443
+ }
444
+ const cleaned: { keep: string; drop: string[] }[] = [];
445
+ for (const group of groups) {
446
+ const keep = typeof group?.keep === 'string' ? group.keep.trim() : '';
447
+ const drop = Array.isArray(group?.drop)
448
+ ? group.drop.map((tag: unknown) => String(tag || '').trim()).filter(Boolean)
449
+ : [];
450
+ if (!keep || !drop.length) continue;
451
+ cleaned.push({ keep, drop });
452
+ }
453
+ if (!cleaned.length) {
454
+ res.status(400).json({ error: 'no merge groups to apply' });
455
+ return;
456
+ }
457
+ try {
458
+ res.json(applyConsolidateGroups(cleaned));
459
+ } catch (error: any) {
460
+ res.status(500).json({ error: error?.message || 'tag apply failed' });
461
+ }
462
+ },
463
+ },
464
+ ];
@@ -0,0 +1,77 @@
1
+ import async from 'async';
2
+
3
+ export type QueueCallback = (err: any, result?: any) => void;
4
+
5
+ export type QueueConfig = {
6
+ processor: (task: any, callback: QueueCallback) => void;
7
+ concurrency: number;
8
+ active: boolean;
9
+ onDrain?: () => void;
10
+ onError?: (err: any, task: any) => void;
11
+ };
12
+
13
+ export class Queue {
14
+ private queue: async.QueueObject<any>;
15
+ private config: QueueConfig;
16
+ private name: string;
17
+
18
+ constructor(name: string, config: QueueConfig) {
19
+ this.config = config;
20
+ this.name = name;
21
+ this.queue = this.init();
22
+
23
+ }
24
+
25
+ init(): async.QueueObject<any> {
26
+ const queue = async.queue((task, callback) => {
27
+ let settled = false;
28
+ const done: QueueCallback = (err, result) => {
29
+ if (settled) return;
30
+ settled = true;
31
+ (callback as QueueCallback)(err, result);
32
+ };
33
+ console.log(`[queue-${this.name}] Processing task (${this.queue.length() + 1} remain in queue):`, task);
34
+ try {
35
+ const ret = this.config.processor(task, done);
36
+ Promise.resolve(ret).catch((err) => done(err));
37
+ } catch (err) {
38
+ done(err);
39
+ }
40
+ }, this.config.concurrency);
41
+
42
+ // Default or custom drain handler
43
+ queue.drain(this.config.onDrain || (() => {
44
+ console.log(`[queue] All tasks in ${this.name} queue have been processed.`);
45
+ }));
46
+
47
+ // Default or custom error handler
48
+ queue.error(this.config.onError || ((err: any, task) => {
49
+ if (err?.err !== null) {
50
+ console.error(`[queue-error] Error processing task in ${this.name} queue:`, err, 'Task:', task);
51
+ } else {
52
+ console.log(`[queue-success] Successfully processed task in ${this.name} queue in ${err.result.elapsed}:`, task);
53
+ }
54
+ }));
55
+ return queue;
56
+ }
57
+
58
+ push(task: any, callback?: QueueCallback): void {
59
+ this.queue.push(task, callback as any);
60
+ }
61
+
62
+ unshift(task: any, callback?: QueueCallback): void {
63
+ this.queue.unshift(task, callback as any);
64
+ }
65
+
66
+ length(): number {
67
+ return this.queue.length();
68
+ }
69
+
70
+ running(): number {
71
+ return this.queue.running();
72
+ }
73
+
74
+ idle(): boolean {
75
+ return this.queue.idle();
76
+ }
77
+ }
@@ -0,0 +1,117 @@
1
+ import chokidar, { FSWatcher } from 'chokidar';
2
+ import fs from 'fs';
3
+ import { compareAudioNewestFirst } from '../lib/audioLib.ts';
4
+
5
+ type WatcherConfig = {
6
+ watchFolder: string,
7
+ watchDepth: number,
8
+ logEvents?: boolean,
9
+ ignoreCheck: (path: string, stats?: fs.Stats) => boolean,
10
+ fileMatchRegex: RegExp,
11
+ addHandler: (filePath: string, match: RegExpMatchArray, watchFolder: string) => Promise<void>
12
+ changeHandler?: (filePath: string, match: RegExpMatchArray, watchFolder: string) => void,
13
+ unlinkHandler?: (filePath: string) => void,
14
+ addDirHandler?: (filePath: string) => void,
15
+ unlinkDirHandler?: (filePath: string) => void,
16
+ errorHandler?: (error: Error) => void,
17
+ readyHandler?: () => void
18
+ };
19
+
20
+ const liveWatchers: Watcher[] = [];
21
+
22
+ export async function closeAllWatchers(): Promise<void> {
23
+ const closing = liveWatchers.splice(0, liveWatchers.length);
24
+ await Promise.all(closing.map((watcher) => watcher.close()));
25
+ }
26
+
27
+ export class Watcher {
28
+ private watcher: FSWatcher;
29
+ private holdingInitial = true;
30
+ private pendingAdds: string[] = [];
31
+ logEvents: boolean = false;
32
+
33
+ constructor(config: WatcherConfig = {watchFolder: '', watchDepth: 0, logEvents: false, ignoreCheck: () => false, fileMatchRegex: new RegExp(''), addHandler: async () => {}}) {
34
+ this.watcher = chokidar.watch(config.watchFolder, { persistent: true, ignored: config.ignoreCheck, depth: config.watchDepth });
35
+ liveWatchers.push(this);
36
+
37
+ this.watcher
38
+ .on('add', async (filePath) => {
39
+ const match = filePath.match(config.fileMatchRegex);
40
+ if (!match) return;
41
+ if (this.logEvents) {
42
+ console.log(`[watcher] File ${filePath} has been added`);
43
+ }
44
+ if (this.holdingInitial) {
45
+ this.pendingAdds.push(filePath);
46
+ return;
47
+ }
48
+ try {
49
+ await config.addHandler(filePath, match, config.watchFolder);
50
+ } catch (error) {
51
+ console.error(`[watcher] addHandler failed for ${filePath}:`, error);
52
+ }
53
+ })
54
+ .on('change', (filePath) => {
55
+ const match = filePath.match(config.fileMatchRegex);
56
+ if (match) {
57
+ if (this.logEvents) {
58
+ console.log(`[watcher] File ${filePath} has been changed`);
59
+ }
60
+ try {
61
+ config.changeHandler?.(filePath, match, config.watchFolder);
62
+ } catch (error) {
63
+ console.error(`[watcher] changeHandler failed for ${filePath}:`, error);
64
+ }
65
+ }
66
+ })
67
+ .on('unlink', (filePath) => {
68
+ if (this.logEvents) {
69
+ console.log(`[watcher] File ${filePath} has been removed`);
70
+ }
71
+ config.unlinkHandler?.(filePath);
72
+ })
73
+ .on('addDir', (filePath) => {
74
+ if (this.logEvents) {
75
+ console.log(`[watcher] Directory ${filePath} has been added`);
76
+ }
77
+ config.addDirHandler?.(filePath);
78
+ })
79
+ .on('unlinkDir', (filePath) => {
80
+ if (this.logEvents) {
81
+ console.log(`[watcher] Directory ${filePath} has been removed`);
82
+ }
83
+ config.unlinkDirHandler?.(filePath);
84
+ })
85
+ .on('error', (error: any) => {
86
+ if (this.logEvents) {
87
+ console.log(`[watcher-error] Watcher error: ${error}`);
88
+ }
89
+ config.errorHandler?.(error);
90
+ })
91
+ .on('ready', () => {
92
+ void this.flushInitialAdds(config);
93
+ });
94
+ }
95
+
96
+ private async flushInitialAdds(config: WatcherConfig) {
97
+ this.holdingInitial = false;
98
+ const files = this.pendingAdds.splice(0).sort(compareAudioNewestFirst);
99
+ console.log(
100
+ `[watcher] Initial scan complete of ${config.watchFolder} (${files.length} files, newest first). Ready for changes`,
101
+ );
102
+ for (const filePath of files) {
103
+ const match = filePath.match(config.fileMatchRegex);
104
+ if (!match) continue;
105
+ try {
106
+ await config.addHandler(filePath, match, config.watchFolder);
107
+ } catch (error) {
108
+ console.error(`[watcher] addHandler failed for ${filePath}:`, error);
109
+ }
110
+ }
111
+ config.readyHandler?.();
112
+ }
113
+
114
+ close() {
115
+ return this.watcher.close();
116
+ }
117
+ }