retold-remote 0.0.6 → 0.0.7

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.
@@ -0,0 +1,879 @@
1
+ /**
2
+ * Retold Remote -- AI Sort Service
3
+ *
4
+ * Orchestrates AI-powered file sorting:
5
+ * 1. Scans a folder and extracts metadata via MetadataCache
6
+ * 2. Sends metadata to a configurable AI endpoint (Ollama / OpenAI-compatible)
7
+ * 3. Generates an operation-plan collection with proposed file moves
8
+ *
9
+ * Two-pass plan generation:
10
+ * Pass 1 (no AI): Files with complete tags (artist + album + title) are
11
+ * sorted purely by naming template substitution.
12
+ * Pass 2 (AI): Files with missing tags are batched to the AI for inference.
13
+ *
14
+ * Endpoints:
15
+ * POST /api/ai-sort/test-connection -- Test AI endpoint reachability
16
+ * POST /api/ai-sort/scan -- Scan folder, extract metadata
17
+ * POST /api/ai-sort/generate-plan -- Generate sort plan → creates collection
18
+ *
19
+ * @license MIT
20
+ */
21
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
22
+ const libFs = require('fs');
23
+ const libPath = require('path');
24
+
25
+ const libExtensionMaps = require('../RetoldRemote-ExtensionMaps.js');
26
+
27
+ const COLLECTION_SOURCE = 'retold-remote-collections';
28
+
29
+ const _DefaultServiceConfiguration =
30
+ {
31
+ "ContentPath": ".",
32
+ "AIEndpoint": "http://localhost:11434",
33
+ "AIModel": "llama3.1",
34
+ "AIProvider": "ollama",
35
+ "NamingTemplate": "{artist}/{album}/{track} - {title}",
36
+ "MaxFilesPerBatch": 30,
37
+ "AITimeout": 120000
38
+ };
39
+
40
+ class RetoldRemoteAISortService extends libFableServiceProviderBase
41
+ {
42
+ constructor(pFable, pOptions, pServiceHash)
43
+ {
44
+ super(pFable, pOptions, pServiceHash);
45
+
46
+ this.serviceType = 'RetoldRemoteAISortService';
47
+
48
+ // Merge with defaults
49
+ for (let tmpKey in _DefaultServiceConfiguration)
50
+ {
51
+ if (!(tmpKey in this.options))
52
+ {
53
+ this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
54
+ }
55
+ }
56
+
57
+ this.contentPath = libPath.resolve(this.options.ContentPath);
58
+
59
+ // Will be set by server setup
60
+ this._metadataCache = null;
61
+
62
+ this.fable.log.info('AI Sort Service: initialized');
63
+ this.fable.log.info(` AI endpoint: ${this.options.AIEndpoint}`);
64
+ this.fable.log.info(` AI model: ${this.options.AIModel}`);
65
+ this.fable.log.info(` AI provider: ${this.options.AIProvider}`);
66
+ }
67
+
68
+ /**
69
+ * Set the metadata cache instance.
70
+ *
71
+ * @param {RetoldRemoteMetadataCache} pCache
72
+ */
73
+ setMetadataCache(pCache)
74
+ {
75
+ this._metadataCache = pCache;
76
+ }
77
+
78
+ // -- Helpers ----------------------------------------------------------
79
+
80
+ /**
81
+ * Sanitize a path to prevent directory traversal.
82
+ *
83
+ * @param {string} pPath
84
+ * @returns {string|null}
85
+ */
86
+ _sanitizePath(pPath)
87
+ {
88
+ if (!pPath || typeof (pPath) !== 'string')
89
+ {
90
+ return null;
91
+ }
92
+
93
+ let tmpPath = decodeURIComponent(pPath).replace(/^\/+/, '');
94
+
95
+ if (tmpPath.includes('..') || libPath.isAbsolute(tmpPath))
96
+ {
97
+ return null;
98
+ }
99
+
100
+ return tmpPath;
101
+ }
102
+
103
+ /**
104
+ * Scan a directory for audio files.
105
+ *
106
+ * @param {string} pRelPath - Relative directory path
107
+ * @param {boolean} pRecursive - Whether to scan subdirectories
108
+ * @returns {Array<string>} Array of relative file paths
109
+ */
110
+ _scanAudioFiles(pRelPath, pRecursive)
111
+ {
112
+ let tmpAbsDir = libPath.join(this.contentPath, pRelPath);
113
+ let tmpFiles = [];
114
+
115
+ if (!libFs.existsSync(tmpAbsDir) || !libFs.statSync(tmpAbsDir).isDirectory())
116
+ {
117
+ return tmpFiles;
118
+ }
119
+
120
+ let tmpEntries = libFs.readdirSync(tmpAbsDir);
121
+
122
+ for (let i = 0; i < tmpEntries.length; i++)
123
+ {
124
+ let tmpEntry = tmpEntries[i];
125
+ let tmpFullPath = libPath.join(tmpAbsDir, tmpEntry);
126
+ let tmpRelFilePath = pRelPath ? (pRelPath + '/' + tmpEntry) : tmpEntry;
127
+
128
+ try
129
+ {
130
+ let tmpStat = libFs.statSync(tmpFullPath);
131
+
132
+ if (tmpStat.isFile())
133
+ {
134
+ let tmpExt = libPath.extname(tmpEntry).replace('.', '').toLowerCase();
135
+ if (libExtensionMaps.AudioExtensions[tmpExt])
136
+ {
137
+ tmpFiles.push(tmpRelFilePath);
138
+ }
139
+ }
140
+ else if (tmpStat.isDirectory() && pRecursive)
141
+ {
142
+ let tmpSubFiles = this._scanAudioFiles(tmpRelFilePath, true);
143
+ tmpFiles = tmpFiles.concat(tmpSubFiles);
144
+ }
145
+ }
146
+ catch (pError)
147
+ {
148
+ // Skip inaccessible files
149
+ this.fable.log.warn('AI Sort scan skip: ' + pError.message);
150
+ }
151
+ }
152
+
153
+ return tmpFiles;
154
+ }
155
+
156
+ /**
157
+ * Apply a naming template using metadata tags.
158
+ *
159
+ * @param {string} pTemplate - Template string like "{artist}/{album}/{track} - {title}"
160
+ * @param {object} pTags - Tag object with lowercase keys
161
+ * @param {string} pOriginalFilename - Original filename for extension
162
+ * @returns {string|null} Resulting path, or null if insufficient data
163
+ */
164
+ _applyNamingTemplate(pTemplate, pTags, pOriginalFilename)
165
+ {
166
+ let tmpExt = libPath.extname(pOriginalFilename).toLowerCase();
167
+
168
+ let tmpResult = pTemplate;
169
+
170
+ // Substitute known placeholders
171
+ let tmpArtist = pTags.artist || pTags.album_artist || '';
172
+ let tmpAlbum = pTags.album || '';
173
+ let tmpTitle = pTags.title || '';
174
+ let tmpTrack = pTags.track || '';
175
+ let tmpYear = pTags.date || pTags.year || '';
176
+ let tmpGenre = pTags.genre || '';
177
+
178
+ // Zero-pad track number to 2 digits
179
+ if (tmpTrack)
180
+ {
181
+ // Handle "3/12" format
182
+ let tmpTrackNum = parseInt(tmpTrack.toString().split('/')[0], 10);
183
+ if (!isNaN(tmpTrackNum))
184
+ {
185
+ tmpTrack = (tmpTrackNum < 10 ? '0' : '') + tmpTrackNum;
186
+ }
187
+ }
188
+
189
+ tmpResult = tmpResult.replace(/\{artist\}/gi, tmpArtist);
190
+ tmpResult = tmpResult.replace(/\{album\}/gi, tmpAlbum);
191
+ tmpResult = tmpResult.replace(/\{title\}/gi, tmpTitle);
192
+ tmpResult = tmpResult.replace(/\{track\}/gi, tmpTrack);
193
+ tmpResult = tmpResult.replace(/\{year\}/gi, tmpYear);
194
+ tmpResult = tmpResult.replace(/\{genre\}/gi, tmpGenre);
195
+
196
+ // Sanitize for filesystem safety
197
+ tmpResult = this._sanitizeFilename(tmpResult);
198
+
199
+ // Add extension
200
+ tmpResult = tmpResult + tmpExt;
201
+
202
+ return tmpResult;
203
+ }
204
+
205
+ /**
206
+ * Sanitize a string for use in filesystem paths.
207
+ * Preserves forward slashes as directory separators.
208
+ *
209
+ * @param {string} pPath
210
+ * @returns {string}
211
+ */
212
+ _sanitizeFilename(pPath)
213
+ {
214
+ // Remove/replace characters invalid on most filesystems
215
+ let tmpSanitized = pPath
216
+ .replace(/[\\:*?"<>|]/g, '')
217
+ .replace(/\s+/g, ' ')
218
+ .trim();
219
+
220
+ // Clean up each path segment
221
+ let tmpSegments = tmpSanitized.split('/');
222
+ for (let i = 0; i < tmpSegments.length; i++)
223
+ {
224
+ // Remove leading/trailing dots and spaces from each segment
225
+ tmpSegments[i] = tmpSegments[i].replace(/^[\s.]+|[\s.]+$/g, '').trim();
226
+ }
227
+
228
+ // Filter out empty segments
229
+ tmpSegments = tmpSegments.filter((s) => s.length > 0);
230
+
231
+ return tmpSegments.join('/');
232
+ }
233
+
234
+ /**
235
+ * Call the AI endpoint with a prompt and return the parsed response.
236
+ *
237
+ * @param {string} pPrompt - The prompt to send
238
+ * @param {function} fCallback - Callback(pError, pResponseText)
239
+ */
240
+ _callAI(pPrompt, fCallback)
241
+ {
242
+ let tmpSelf = this;
243
+ let tmpEndpoint = this.options.AIEndpoint;
244
+ let tmpModel = this.options.AIModel;
245
+ let tmpProvider = this.options.AIProvider;
246
+
247
+ let tmpBody;
248
+ let tmpPath;
249
+
250
+ if (tmpProvider === 'ollama')
251
+ {
252
+ tmpPath = '/api/generate';
253
+ tmpBody = JSON.stringify(
254
+ {
255
+ model: tmpModel,
256
+ prompt: pPrompt,
257
+ stream: false,
258
+ format: 'json'
259
+ });
260
+ }
261
+ else
262
+ {
263
+ // OpenAI-compatible
264
+ tmpPath = '/v1/chat/completions';
265
+ tmpBody = JSON.stringify(
266
+ {
267
+ model: tmpModel,
268
+ messages: [{ role: 'user', content: pPrompt }],
269
+ response_format: { type: 'json_object' },
270
+ temperature: 0.1
271
+ });
272
+ }
273
+
274
+ let tmpParsed;
275
+ try
276
+ {
277
+ tmpParsed = new URL(tmpEndpoint);
278
+ }
279
+ catch (pError)
280
+ {
281
+ return fCallback(new Error('Invalid AI endpoint URL: ' + tmpEndpoint));
282
+ }
283
+
284
+ let tmpLib = (tmpParsed.protocol === 'https:') ? require('https') : require('http');
285
+
286
+ let tmpReq = tmpLib.request(
287
+ {
288
+ hostname: tmpParsed.hostname,
289
+ port: tmpParsed.port,
290
+ path: tmpPath,
291
+ method: 'POST',
292
+ headers:
293
+ {
294
+ 'Content-Type': 'application/json',
295
+ 'Content-Length': Buffer.byteLength(tmpBody)
296
+ },
297
+ timeout: tmpSelf.options.AITimeout
298
+ },
299
+ (pResponse) =>
300
+ {
301
+ let tmpChunks = [];
302
+ pResponse.on('data', (pChunk) => { tmpChunks.push(pChunk); });
303
+ pResponse.on('end', () =>
304
+ {
305
+ if (pResponse.statusCode >= 400)
306
+ {
307
+ return fCallback(new Error('AI endpoint returned status ' + pResponse.statusCode));
308
+ }
309
+
310
+ try
311
+ {
312
+ let tmpResponseBody = Buffer.concat(tmpChunks).toString();
313
+ let tmpParsedResponse = JSON.parse(tmpResponseBody);
314
+
315
+ let tmpResponseText;
316
+
317
+ if (tmpProvider === 'ollama')
318
+ {
319
+ tmpResponseText = tmpParsedResponse.response || '';
320
+ }
321
+ else
322
+ {
323
+ // OpenAI-compatible
324
+ tmpResponseText = (tmpParsedResponse.choices &&
325
+ tmpParsedResponse.choices[0] &&
326
+ tmpParsedResponse.choices[0].message &&
327
+ tmpParsedResponse.choices[0].message.content) || '';
328
+ }
329
+
330
+ return fCallback(null, tmpResponseText);
331
+ }
332
+ catch (pParseError)
333
+ {
334
+ return fCallback(new Error('Failed to parse AI response: ' + pParseError.message));
335
+ }
336
+ });
337
+ });
338
+
339
+ tmpReq.on('error', (pError) => { return fCallback(pError); });
340
+ tmpReq.on('timeout', () =>
341
+ {
342
+ tmpReq.destroy();
343
+ return fCallback(new Error('AI request timed out after ' + tmpSelf.options.AITimeout + 'ms'));
344
+ });
345
+ tmpReq.write(tmpBody);
346
+ tmpReq.end();
347
+ }
348
+
349
+ /**
350
+ * Build the AI prompt for file sorting.
351
+ *
352
+ * @param {Array} pFiles - Array of { Path, Tags, Filename } objects
353
+ * @param {string} pTemplate - Naming template
354
+ * @returns {string} The prompt
355
+ */
356
+ _buildPrompt(pFiles, pTemplate)
357
+ {
358
+ let tmpFileList = pFiles.map((f) =>
359
+ {
360
+ let tmpEntry = { filename: f.Filename };
361
+ if (f.Tags && Object.keys(f.Tags).length > 0)
362
+ {
363
+ tmpEntry.tags = f.Tags;
364
+ }
365
+ return tmpEntry;
366
+ });
367
+
368
+ return `You are a music file organizer. I will give you a list of audio files with their current filenames and any available metadata (ID3 tags). For each file, determine the correct Artist, Album, Title, Track Number, and Year.
369
+
370
+ Rules:
371
+ 1. Use existing ID3 tags when they are present and seem correct.
372
+ 2. For files missing tags, infer from the filename pattern. Common patterns:
373
+ - "Artist - Title.mp3"
374
+ - "01 - Title.mp3" (track number prefix)
375
+ - "Artist - Album - 01 - Title.mp3"
376
+ - "01. Title.mp3"
377
+ 3. If the album cannot be determined, use "Singles" as the album name.
378
+ 4. If the artist cannot be determined, use "Unknown Artist".
379
+ 5. Clean up common issues: extra spaces, underscores as spaces, case normalization (Title Case for names).
380
+ 6. Track numbers should be zero-padded to 2 digits.
381
+
382
+ The naming template is: ${pTemplate}
383
+
384
+ Respond with a JSON object containing a "files" array. Each entry must have:
385
+ - "source": the original filename (exactly as provided)
386
+ - "artist": the determined artist name
387
+ - "album": the determined album name
388
+ - "title": the determined song title
389
+ - "track": track number as a zero-padded string (e.g. "01"), or "" if unknown
390
+ - "year": year if known, or ""
391
+ - "destination": the full destination path using the naming template above
392
+ - "confidence": "high", "medium", or "low"
393
+
394
+ Files to organize:
395
+ ${JSON.stringify(tmpFileList, null, 2)}`;
396
+ }
397
+
398
+ /**
399
+ * Generate a sort plan. Pass 1 uses tags directly; Pass 2 uses AI.
400
+ *
401
+ * @param {Array} pMetadataArray - Array of metadata objects from MetadataCache
402
+ * @param {string} pNamingTemplate - Naming template
403
+ * @param {string} pBasePath - Base path prefix for source files
404
+ * @param {function} fCallback - Callback(pError, pPlanItems)
405
+ */
406
+ _generateSortPlan(pMetadataArray, pNamingTemplate, pBasePath, fCallback)
407
+ {
408
+ let tmpSelf = this;
409
+ let tmpPlanItems = [];
410
+ let tmpNeedAI = [];
411
+
412
+ // Pass 1: Files with complete tags → template substitution
413
+ for (let i = 0; i < pMetadataArray.length; i++)
414
+ {
415
+ let tmpMeta = pMetadataArray[i];
416
+
417
+ if (!tmpMeta.Success)
418
+ {
419
+ continue;
420
+ }
421
+
422
+ let tmpTags = tmpMeta.Tags || {};
423
+ let tmpFilename = tmpMeta.Path.split('/').pop();
424
+ let tmpArtist = tmpTags.artist || tmpTags.album_artist;
425
+ let tmpTitle = tmpTags.title;
426
+
427
+ if (tmpArtist && tmpTitle)
428
+ {
429
+ // Has enough tags for template substitution
430
+ let tmpDest = this._applyNamingTemplate(pNamingTemplate, tmpTags, tmpFilename);
431
+ if (tmpDest)
432
+ {
433
+ // Prefix with base path if provided
434
+ if (pBasePath)
435
+ {
436
+ tmpDest = pBasePath + '/' + tmpDest;
437
+ }
438
+
439
+ tmpPlanItems.push(
440
+ {
441
+ ID: this.fable.getUUID(),
442
+ Type: 'file',
443
+ Path: tmpMeta.Path,
444
+ Label: tmpFilename,
445
+ Note: '',
446
+ SortOrder: tmpPlanItems.length,
447
+ AddedAt: new Date().toISOString(),
448
+ Operation: 'move',
449
+ DestinationPath: tmpDest,
450
+ OperationStatus: 'pending',
451
+ OperationError: null
452
+ });
453
+ }
454
+ }
455
+ else
456
+ {
457
+ // Needs AI inference
458
+ tmpNeedAI.push(
459
+ {
460
+ Path: tmpMeta.Path,
461
+ Filename: tmpFilename,
462
+ Tags: tmpTags
463
+ });
464
+ }
465
+ }
466
+
467
+ // Pass 2: Files needing AI
468
+ if (tmpNeedAI.length === 0)
469
+ {
470
+ return fCallback(null, tmpPlanItems);
471
+ }
472
+
473
+ // Batch files for AI (in groups of MaxFilesPerBatch)
474
+ let tmpBatchSize = this.options.MaxFilesPerBatch;
475
+ let tmpBatches = [];
476
+ for (let i = 0; i < tmpNeedAI.length; i += tmpBatchSize)
477
+ {
478
+ tmpBatches.push(tmpNeedAI.slice(i, i + tmpBatchSize));
479
+ }
480
+
481
+ let tmpBatchIndex = 0;
482
+
483
+ function _processNextBatch()
484
+ {
485
+ if (tmpBatchIndex >= tmpBatches.length)
486
+ {
487
+ return fCallback(null, tmpPlanItems);
488
+ }
489
+
490
+ let tmpBatch = tmpBatches[tmpBatchIndex];
491
+ tmpBatchIndex++;
492
+
493
+ let tmpPrompt = tmpSelf._buildPrompt(tmpBatch, pNamingTemplate);
494
+
495
+ tmpSelf._callAI(tmpPrompt,
496
+ (pError, pResponseText) =>
497
+ {
498
+ if (pError)
499
+ {
500
+ tmpSelf.fable.log.warn('AI sort batch error: ' + pError.message);
501
+ // Add files with error as failed items
502
+ for (let j = 0; j < tmpBatch.length; j++)
503
+ {
504
+ tmpPlanItems.push(
505
+ {
506
+ ID: tmpSelf.fable.getUUID(),
507
+ Type: 'file',
508
+ Path: tmpBatch[j].Path,
509
+ Label: tmpBatch[j].Filename,
510
+ Note: 'AI inference failed: ' + pError.message,
511
+ SortOrder: tmpPlanItems.length,
512
+ AddedAt: new Date().toISOString(),
513
+ Operation: 'move',
514
+ DestinationPath: null,
515
+ OperationStatus: 'pending',
516
+ OperationError: 'AI inference failed'
517
+ });
518
+ }
519
+ return _processNextBatch();
520
+ }
521
+
522
+ // Parse AI response
523
+ try
524
+ {
525
+ let tmpAIResult = JSON.parse(pResponseText);
526
+ let tmpAIFiles = tmpAIResult.files || [];
527
+
528
+ // Build lookup by source filename
529
+ let tmpResultMap = {};
530
+ for (let j = 0; j < tmpAIFiles.length; j++)
531
+ {
532
+ tmpResultMap[tmpAIFiles[j].source] = tmpAIFiles[j];
533
+ }
534
+
535
+ for (let j = 0; j < tmpBatch.length; j++)
536
+ {
537
+ let tmpFile = tmpBatch[j];
538
+ let tmpAIFile = tmpResultMap[tmpFile.Filename];
539
+
540
+ let tmpDest = null;
541
+ if (tmpAIFile && tmpAIFile.destination)
542
+ {
543
+ tmpDest = tmpSelf._sanitizeFilename(tmpAIFile.destination);
544
+ // Add extension if missing
545
+ let tmpOrigExt = libPath.extname(tmpFile.Filename).toLowerCase();
546
+ if (tmpDest && !tmpDest.toLowerCase().endsWith(tmpOrigExt))
547
+ {
548
+ tmpDest = tmpDest + tmpOrigExt;
549
+ }
550
+ // Prefix with base path
551
+ if (pBasePath && tmpDest)
552
+ {
553
+ tmpDest = pBasePath + '/' + tmpDest;
554
+ }
555
+ }
556
+
557
+ tmpPlanItems.push(
558
+ {
559
+ ID: tmpSelf.fable.getUUID(),
560
+ Type: 'file',
561
+ Path: tmpFile.Path,
562
+ Label: tmpFile.Filename,
563
+ Note: tmpAIFile ? ('AI confidence: ' + (tmpAIFile.confidence || 'unknown')) : '',
564
+ SortOrder: tmpPlanItems.length,
565
+ AddedAt: new Date().toISOString(),
566
+ Operation: 'move',
567
+ DestinationPath: tmpDest,
568
+ OperationStatus: tmpDest ? 'pending' : null,
569
+ OperationError: tmpDest ? null : 'AI could not determine destination'
570
+ });
571
+ }
572
+ }
573
+ catch (pParseError)
574
+ {
575
+ tmpSelf.fable.log.warn('AI response parse error: ' + pParseError.message);
576
+ // Try to extract JSON from response text
577
+ let tmpJsonMatch = pResponseText.match(/\{[\s\S]*\}/);
578
+ if (tmpJsonMatch)
579
+ {
580
+ try
581
+ {
582
+ let tmpRetry = JSON.parse(tmpJsonMatch[0]);
583
+ // Recurse on successful extraction... but keep it simple
584
+ tmpSelf.fable.log.warn('Extracted JSON from AI response on retry');
585
+ }
586
+ catch (e)
587
+ {
588
+ // Truly failed
589
+ }
590
+ }
591
+
592
+ for (let j = 0; j < tmpBatch.length; j++)
593
+ {
594
+ tmpPlanItems.push(
595
+ {
596
+ ID: tmpSelf.fable.getUUID(),
597
+ Type: 'file',
598
+ Path: tmpBatch[j].Path,
599
+ Label: tmpBatch[j].Filename,
600
+ Note: 'AI response could not be parsed',
601
+ SortOrder: tmpPlanItems.length,
602
+ AddedAt: new Date().toISOString(),
603
+ Operation: 'move',
604
+ DestinationPath: null,
605
+ OperationStatus: null,
606
+ OperationError: 'AI response parse error'
607
+ });
608
+ }
609
+ }
610
+
611
+ _processNextBatch();
612
+ });
613
+ }
614
+
615
+ _processNextBatch();
616
+ }
617
+
618
+ // -- Route Wiring -----------------------------------------------------
619
+
620
+ /**
621
+ * Wire REST endpoints.
622
+ *
623
+ * @param {object} pServiceServer - The Orator service server
624
+ */
625
+ connectRoutes(pServiceServer)
626
+ {
627
+ let tmpSelf = this;
628
+ let tmpServer = pServiceServer.server;
629
+
630
+ // --- POST /api/ai-sort/test-connection ---
631
+ tmpServer.post('/api/ai-sort/test-connection',
632
+ (pRequest, pResponse, fNext) =>
633
+ {
634
+ let tmpEndpoint = (pRequest.body && pRequest.body.AIEndpoint) || tmpSelf.options.AIEndpoint;
635
+ let tmpModel = (pRequest.body && pRequest.body.AIModel) || tmpSelf.options.AIModel;
636
+ let tmpProvider = (pRequest.body && pRequest.body.AIProvider) || tmpSelf.options.AIProvider;
637
+
638
+ // Temporarily override settings for this test
639
+ let tmpOrigEndpoint = tmpSelf.options.AIEndpoint;
640
+ let tmpOrigModel = tmpSelf.options.AIModel;
641
+ let tmpOrigProvider = tmpSelf.options.AIProvider;
642
+
643
+ tmpSelf.options.AIEndpoint = tmpEndpoint;
644
+ tmpSelf.options.AIModel = tmpModel;
645
+ tmpSelf.options.AIProvider = tmpProvider;
646
+
647
+ let tmpStartTime = Date.now();
648
+
649
+ tmpSelf._callAI('Respond with JSON: {"status": "ok"}',
650
+ (pError, pResponseText) =>
651
+ {
652
+ let tmpResponseTime = Date.now() - tmpStartTime;
653
+
654
+ // Restore original settings
655
+ tmpSelf.options.AIEndpoint = tmpOrigEndpoint;
656
+ tmpSelf.options.AIModel = tmpOrigModel;
657
+ tmpSelf.options.AIProvider = tmpOrigProvider;
658
+
659
+ if (pError)
660
+ {
661
+ pResponse.send(200,
662
+ {
663
+ Success: false,
664
+ Error: pError.message,
665
+ Endpoint: tmpEndpoint,
666
+ Model: tmpModel,
667
+ Provider: tmpProvider
668
+ });
669
+ return fNext();
670
+ }
671
+
672
+ pResponse.send(200,
673
+ {
674
+ Success: true,
675
+ ResponseTime: tmpResponseTime,
676
+ Endpoint: tmpEndpoint,
677
+ Model: tmpModel,
678
+ Provider: tmpProvider,
679
+ Response: pResponseText
680
+ });
681
+ return fNext();
682
+ });
683
+ });
684
+
685
+ // --- POST /api/ai-sort/scan ---
686
+ tmpServer.post('/api/ai-sort/scan',
687
+ (pRequest, pResponse, fNext) =>
688
+ {
689
+ try
690
+ {
691
+ let tmpPath = tmpSelf._sanitizePath(pRequest.body && pRequest.body.Path);
692
+ let tmpRecursive = (pRequest.body && pRequest.body.Recursive === true);
693
+
694
+ if (!tmpPath && tmpPath !== '')
695
+ {
696
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
697
+ return fNext();
698
+ }
699
+
700
+ // Scan for audio files
701
+ let tmpAudioFiles = tmpSelf._scanAudioFiles(tmpPath, tmpRecursive);
702
+
703
+ if (tmpAudioFiles.length === 0)
704
+ {
705
+ pResponse.send(200,
706
+ {
707
+ Success: true,
708
+ Path: tmpPath,
709
+ FileCount: 0,
710
+ Files: []
711
+ });
712
+ return fNext();
713
+ }
714
+
715
+ // Get metadata for all audio files
716
+ if (!tmpSelf._metadataCache)
717
+ {
718
+ pResponse.send(500, { Success: false, Error: 'Metadata cache not available.' });
719
+ return fNext();
720
+ }
721
+
722
+ tmpSelf._metadataCache.getMetadataBatch(tmpAudioFiles,
723
+ (pError, pMetadata) =>
724
+ {
725
+ if (pError)
726
+ {
727
+ pResponse.send(500, { Success: false, Error: pError.message });
728
+ return fNext();
729
+ }
730
+
731
+ pResponse.send(200,
732
+ {
733
+ Success: true,
734
+ Path: tmpPath,
735
+ FileCount: pMetadata.length,
736
+ Files: pMetadata
737
+ });
738
+ return fNext();
739
+ });
740
+ }
741
+ catch (pError)
742
+ {
743
+ pResponse.send(500, { Success: false, Error: pError.message });
744
+ return fNext();
745
+ }
746
+ });
747
+
748
+ // --- POST /api/ai-sort/generate-plan ---
749
+ tmpServer.post('/api/ai-sort/generate-plan',
750
+ (pRequest, pResponse, fNext) =>
751
+ {
752
+ try
753
+ {
754
+ let tmpPath = tmpSelf._sanitizePath(pRequest.body && pRequest.body.Path);
755
+ let tmpNamingTemplate = (pRequest.body && pRequest.body.NamingTemplate) || tmpSelf.options.NamingTemplate;
756
+ let tmpRecursive = (pRequest.body && pRequest.body.Recursive === true);
757
+
758
+ // Allow overriding AI settings per-request
759
+ if (pRequest.body && pRequest.body.AIEndpoint)
760
+ {
761
+ tmpSelf.options.AIEndpoint = pRequest.body.AIEndpoint;
762
+ }
763
+ if (pRequest.body && pRequest.body.AIModel)
764
+ {
765
+ tmpSelf.options.AIModel = pRequest.body.AIModel;
766
+ }
767
+ if (pRequest.body && pRequest.body.AIProvider)
768
+ {
769
+ tmpSelf.options.AIProvider = pRequest.body.AIProvider;
770
+ }
771
+
772
+ if (!tmpPath && tmpPath !== '')
773
+ {
774
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
775
+ return fNext();
776
+ }
777
+
778
+ // Scan for audio files
779
+ let tmpAudioFiles = tmpSelf._scanAudioFiles(tmpPath, tmpRecursive);
780
+
781
+ if (tmpAudioFiles.length === 0)
782
+ {
783
+ pResponse.send(200, { Success: true, Message: 'No audio files found.', CollectionGUID: null });
784
+ return fNext();
785
+ }
786
+
787
+ if (!tmpSelf._metadataCache)
788
+ {
789
+ pResponse.send(500, { Success: false, Error: 'Metadata cache not available.' });
790
+ return fNext();
791
+ }
792
+
793
+ // Get metadata for all audio files
794
+ tmpSelf._metadataCache.getMetadataBatch(tmpAudioFiles,
795
+ (pMetaError, pMetadata) =>
796
+ {
797
+ if (pMetaError)
798
+ {
799
+ pResponse.send(500, { Success: false, Error: pMetaError.message });
800
+ return fNext();
801
+ }
802
+
803
+ // Determine base path: the parent directory where sorted files will land
804
+ // Default: same directory level as the source folder
805
+ let tmpBasePath = tmpPath ? libPath.dirname(tmpPath) : '';
806
+ if (tmpBasePath === '.') tmpBasePath = '';
807
+
808
+ // Generate the sort plan
809
+ tmpSelf._generateSortPlan(pMetadata, tmpNamingTemplate, tmpBasePath,
810
+ (pPlanError, pPlanItems) =>
811
+ {
812
+ if (pPlanError)
813
+ {
814
+ pResponse.send(500, { Success: false, Error: pPlanError.message });
815
+ return fNext();
816
+ }
817
+
818
+ // Create the operation-plan collection
819
+ let tmpCollectionGUID = tmpSelf.fable.getUUID();
820
+ let tmpFolderName = tmpPath ? tmpPath.split('/').pop() : 'root';
821
+ let tmpNow = new Date().toISOString();
822
+
823
+ let tmpCollection =
824
+ {
825
+ GUID: tmpCollectionGUID,
826
+ Name: 'Sort: ' + tmpFolderName,
827
+ Description: 'AI sort plan for ' + tmpPath + '\nTemplate: ' + tmpNamingTemplate,
828
+ CoverImage: '',
829
+ Icon: 'bookmark',
830
+ CreatedAt: tmpNow,
831
+ ModifiedAt: tmpNow,
832
+ SortMode: 'manual',
833
+ SortDirection: 'asc',
834
+ Tags: ['ai-sort'],
835
+ CollectionType: 'operation-plan',
836
+ OperationBatchGUID: null,
837
+ Items: pPlanItems
838
+ };
839
+
840
+ // Save via Bibliograph
841
+ tmpSelf.fable.Bibliograph.createSource(COLLECTION_SOURCE,
842
+ () =>
843
+ {
844
+ tmpSelf.fable.Bibliograph.write(COLLECTION_SOURCE, tmpCollectionGUID, tmpCollection,
845
+ (pWriteError) =>
846
+ {
847
+ if (pWriteError)
848
+ {
849
+ pResponse.send(500, { Success: false, Error: 'Failed to save sort plan: ' + pWriteError.message });
850
+ return fNext();
851
+ }
852
+
853
+ pResponse.send(200,
854
+ {
855
+ Success: true,
856
+ CollectionGUID: tmpCollectionGUID,
857
+ TotalFiles: pPlanItems.length,
858
+ TaggedFiles: pPlanItems.filter((item) => !item.Note || item.Note.indexOf('AI') < 0).length,
859
+ AIFiles: pPlanItems.filter((item) => item.Note && item.Note.indexOf('AI') >= 0).length,
860
+ Collection: tmpCollection
861
+ });
862
+ return fNext();
863
+ });
864
+ });
865
+ });
866
+ });
867
+ }
868
+ catch (pError)
869
+ {
870
+ pResponse.send(500, { Success: false, Error: pError.message });
871
+ return fNext();
872
+ }
873
+ });
874
+
875
+ this.fable.log.info('AI Sort Service: routes connected.');
876
+ }
877
+ }
878
+
879
+ module.exports = RetoldRemoteAISortService;