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.
- package/css/retold-remote.css +3 -0
- package/html/index.html +1 -1
- package/package.json +1 -1
- package/source/Pict-Application-RetoldRemote.js +21 -2
- package/source/cli/RetoldRemote-Server-Setup.js +129 -0
- package/source/providers/Pict-Provider-AISortManager.js +456 -0
- package/source/providers/Pict-Provider-CollectionManager.js +266 -0
- package/source/server/RetoldRemote-AISortService.js +879 -0
- package/source/server/RetoldRemote-CollectionService.js +161 -2
- package/source/server/RetoldRemote-FileOperationService.js +560 -0
- package/source/server/RetoldRemote-MediaService.js +12 -0
- package/source/server/RetoldRemote-MetadataCache.js +411 -0
- package/source/views/PictView-Remote-CollectionsPanel.js +435 -36
- package/source/views/PictView-Remote-Layout.js +2 -0
- package/source/views/PictView-Remote-SettingsPanel.js +156 -0
- package/source/views/PictView-Remote-TopBar.js +86 -0
- package/web-application/css/retold-remote.css +3 -0
- package/web-application/index.html +1 -1
- package/web-application/retold-remote.js +402 -34
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +32 -15
- package/web-application/retold-remote.min.js.map +1 -1
|
@@ -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;
|