retold-remote 0.0.1 → 0.0.2

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 (33) hide show
  1. package/html/index.html +2 -0
  2. package/package.json +20 -14
  3. package/source/Pict-Application-RetoldRemote.js +46 -5
  4. package/source/cli/RetoldRemote-CLI-Run.js +0 -0
  5. package/source/cli/RetoldRemote-Server-Setup.js +790 -8
  6. package/source/cli/commands/RetoldRemote-Command-Serve.js +34 -1
  7. package/source/providers/Pict-Provider-GalleryFilterSort.js +61 -9
  8. package/source/providers/Pict-Provider-GalleryNavigation.js +517 -18
  9. package/source/providers/Pict-Provider-RetoldRemote.js +11 -2
  10. package/source/providers/Pict-Provider-RetoldRemoteIcons.js +1 -0
  11. package/source/server/RetoldRemote-ArchiveService.js +830 -0
  12. package/source/server/RetoldRemote-AudioWaveformService.js +673 -0
  13. package/source/server/RetoldRemote-EbookService.js +242 -0
  14. package/source/server/RetoldRemote-MediaService.js +1 -1
  15. package/source/server/RetoldRemote-ToolDetector.js +31 -1
  16. package/source/server/RetoldRemote-VideoFrameService.js +486 -0
  17. package/source/views/PictView-Remote-AudioExplorer.js +1213 -0
  18. package/source/views/PictView-Remote-Gallery.js +141 -2
  19. package/source/views/PictView-Remote-Layout.js +18 -27
  20. package/source/views/PictView-Remote-MediaViewer.js +638 -39
  21. package/source/views/PictView-Remote-SettingsPanel.js +23 -0
  22. package/source/views/PictView-Remote-TopBar.js +121 -0
  23. package/source/views/PictView-Remote-VideoExplorer.js +1229 -0
  24. package/web-application/index.html +2 -0
  25. package/web-application/js/epub.min.js +1 -0
  26. package/web-application/retold-remote.js +7030 -1244
  27. package/web-application/retold-remote.js.map +1 -1
  28. package/web-application/retold-remote.min.js +13 -44
  29. package/web-application/retold-remote.min.js.map +1 -1
  30. package/web-application/retold-remote.compatible.js +0 -5764
  31. package/web-application/retold-remote.compatible.js.map +0 -1
  32. package/web-application/retold-remote.compatible.min.js +0 -120
  33. package/web-application/retold-remote.compatible.min.js.map +0 -1
@@ -0,0 +1,673 @@
1
+ /**
2
+ * Retold Remote -- Audio Waveform Service
3
+ *
4
+ * Extracts waveform peak data and audio segments from audio/video files.
5
+ * Uses BBC audiowaveform when available, falls back to ffprobe/ffmpeg.
6
+ * Results are cached so repeated requests are instant.
7
+ *
8
+ * API:
9
+ * extractWaveform(pAbsPath, pRelPath, pOptions, fCallback)
10
+ * -> { Peaks: [{ Min, Max }], Duration, SampleRate, Channels, ... }
11
+ *
12
+ * extractSegment(pAbsPath, pRelPath, pOptions, fCallback)
13
+ * -> { SegmentPath, Duration, Start, End, Format, CacheKey, Filename }
14
+ *
15
+ * @license MIT
16
+ */
17
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
18
+ const libFs = require('fs');
19
+ const libPath = require('path');
20
+ const libCrypto = require('crypto');
21
+ const libChildProcess = require('child_process');
22
+
23
+ const _DefaultServiceConfiguration =
24
+ {
25
+ "ContentPath": ".",
26
+ "CachePath": null,
27
+ "DefaultPeakCount": 2000,
28
+ "DefaultSegmentFormat": "mp3",
29
+ "MaxSegmentDuration": 600
30
+ };
31
+
32
+ class RetoldRemoteAudioWaveformService extends libFableServiceProviderBase
33
+ {
34
+ constructor(pFable, pOptions, pServiceHash)
35
+ {
36
+ super(pFable, pOptions, pServiceHash);
37
+
38
+ this.serviceType = 'RetoldRemoteAudioWaveformService';
39
+
40
+ // Merge with defaults
41
+ for (let tmpKey in _DefaultServiceConfiguration)
42
+ {
43
+ if (!(tmpKey in this.options))
44
+ {
45
+ this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
46
+ }
47
+ }
48
+
49
+ this.contentPath = libPath.resolve(this.options.ContentPath);
50
+
51
+ this.cachePath = this.options.CachePath
52
+ || libPath.join(process.cwd(), 'dist', 'retold-cache', 'audio-waveforms');
53
+
54
+ // Ensure cache directory exists
55
+ if (!libFs.existsSync(this.cachePath))
56
+ {
57
+ libFs.mkdirSync(this.cachePath, { recursive: true });
58
+ }
59
+
60
+ // Detect audiowaveform availability
61
+ this.hasAudiowaveform = this._detectCommand('audiowaveform --version');
62
+
63
+ this.fable.log.info(`Audio Waveform Service: cache at ${this.cachePath}`);
64
+ this.fable.log.info(` audiowaveform tool: ${this.hasAudiowaveform ? 'available' : 'not found (using ffprobe fallback)'}`);
65
+ }
66
+
67
+ /**
68
+ * Check if a command-line tool is available.
69
+ *
70
+ * @param {string} pCommand - The command to test
71
+ * @returns {boolean}
72
+ */
73
+ _detectCommand(pCommand)
74
+ {
75
+ try
76
+ {
77
+ libChildProcess.execSync(pCommand, { stdio: 'ignore', timeout: 5000 });
78
+ return true;
79
+ }
80
+ catch (pError)
81
+ {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Get the cache directory for a specific audio file's waveform.
88
+ *
89
+ * @param {string} pAbsPath - Absolute path to the audio file
90
+ * @param {number} pMtimeMs - Modification time in ms
91
+ * @param {number} pPeakCount - Number of peaks requested
92
+ * @returns {string} Absolute path to the cache directory
93
+ */
94
+ _getWaveformCacheDir(pAbsPath, pMtimeMs, pPeakCount)
95
+ {
96
+ let tmpInput = `waveform:${pAbsPath}:${pMtimeMs}:${pPeakCount}`;
97
+ let tmpHash = libCrypto.createHash('sha256').update(tmpInput).digest('hex').substring(0, 16);
98
+ return libPath.join(this.cachePath, tmpHash);
99
+ }
100
+
101
+ /**
102
+ * Get the cache directory for an extracted audio segment.
103
+ *
104
+ * @param {string} pAbsPath - Absolute path to the audio file
105
+ * @param {number} pMtimeMs - Modification time in ms
106
+ * @param {number} pStart - Start time in seconds
107
+ * @param {number} pEnd - End time in seconds
108
+ * @param {string} pFormat - Output format
109
+ * @returns {string} Absolute path to the cache directory
110
+ */
111
+ _getSegmentCacheDir(pAbsPath, pMtimeMs, pStart, pEnd, pFormat)
112
+ {
113
+ let tmpInput = `segment:${pAbsPath}:${pMtimeMs}:${pStart}:${pEnd}:${pFormat}`;
114
+ let tmpHash = libCrypto.createHash('sha256').update(tmpInput).digest('hex').substring(0, 16);
115
+ return libPath.join(this.cachePath, tmpHash);
116
+ }
117
+
118
+ /**
119
+ * Probe an audio file with ffprobe to get metadata.
120
+ *
121
+ * @param {string} pAbsPath - Absolute path to the audio file
122
+ * @param {Function} fCallback - Callback(pError, { duration, sampleRate, channels, codec, bitrate, size })
123
+ */
124
+ _probeAudio(pAbsPath, fCallback)
125
+ {
126
+ try
127
+ {
128
+ let tmpCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${pAbsPath}"`;
129
+ let tmpOutput = libChildProcess.execSync(tmpCmd, { maxBuffer: 1024 * 1024, timeout: 15000 });
130
+ let tmpData = JSON.parse(tmpOutput.toString());
131
+
132
+ let tmpResult = {};
133
+
134
+ if (tmpData.format)
135
+ {
136
+ tmpResult.duration = parseFloat(tmpData.format.duration) || null;
137
+ tmpResult.bitrate = parseInt(tmpData.format.bit_rate, 10) || null;
138
+ tmpResult.size = parseInt(tmpData.format.size, 10) || null;
139
+ tmpResult.formatName = tmpData.format.format_name || null;
140
+ }
141
+
142
+ if (tmpData.streams)
143
+ {
144
+ for (let i = 0; i < tmpData.streams.length; i++)
145
+ {
146
+ let tmpStream = tmpData.streams[i];
147
+ if (tmpStream.codec_type === 'audio')
148
+ {
149
+ tmpResult.sampleRate = parseInt(tmpStream.sample_rate, 10) || null;
150
+ tmpResult.channels = tmpStream.channels || null;
151
+ tmpResult.codec = tmpStream.codec_name || null;
152
+ tmpResult.channelLayout = tmpStream.channel_layout || null;
153
+ break;
154
+ }
155
+ }
156
+ }
157
+
158
+ return fCallback(null, tmpResult);
159
+ }
160
+ catch (pError)
161
+ {
162
+ return fCallback(pError);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Extract waveform peaks using BBC audiowaveform tool.
168
+ * Generates a JSON file with min/max peak pairs.
169
+ *
170
+ * @param {string} pAbsPath - Absolute path to the audio file
171
+ * @param {number} pPeakCount - Desired number of peak data points
172
+ * @param {string} pOutputPath - Path for the output JSON file
173
+ * @param {number} pDuration - Duration of the audio in seconds
174
+ * @param {Function} fCallback - Callback(pError, pPeaksArray)
175
+ */
176
+ _extractWithAudiowaveform(pAbsPath, pPeakCount, pOutputPath, pDuration, fCallback)
177
+ {
178
+ try
179
+ {
180
+ // audiowaveform uses pixels-per-second, so we calculate from desired peak count
181
+ // and the duration. Each "pixel" gives us a min/max pair.
182
+ let tmpSamplesPerPixel = Math.max(1, Math.round((pDuration * 44100) / pPeakCount));
183
+
184
+ // audiowaveform requires specific input formats; for unsupported formats we
185
+ // pipe through ffmpeg first.
186
+ let tmpExt = libPath.extname(pAbsPath).toLowerCase();
187
+ let tmpNativeFormats = { '.wav': true, '.mp3': true, '.flac': true, '.ogg': true };
188
+
189
+ let tmpCmd;
190
+ if (tmpNativeFormats[tmpExt])
191
+ {
192
+ tmpCmd = `audiowaveform -i "${pAbsPath}" -o "${pOutputPath}" --pixels-per-second ${Math.max(1, Math.round(pPeakCount / pDuration))} -b 8`;
193
+ }
194
+ else
195
+ {
196
+ // Pipe through ffmpeg to convert to wav first
197
+ tmpCmd = `ffmpeg -i "${pAbsPath}" -f wav -ac 1 -ar 44100 pipe:1 2>/dev/null | audiowaveform -i - --input-format wav -o "${pOutputPath}" --pixels-per-second ${Math.max(1, Math.round(pPeakCount / pDuration))} -b 8`;
198
+ }
199
+
200
+ libChildProcess.execSync(tmpCmd, { stdio: ['pipe', 'pipe', 'ignore'], timeout: 120000, maxBuffer: 10 * 1024 * 1024 });
201
+
202
+ if (!libFs.existsSync(pOutputPath))
203
+ {
204
+ return fCallback(new Error('audiowaveform did not produce output.'));
205
+ }
206
+
207
+ // Parse the audiowaveform JSON output
208
+ let tmpRawData = JSON.parse(libFs.readFileSync(pOutputPath, 'utf8'));
209
+ let tmpData = tmpRawData.data || [];
210
+
211
+ // audiowaveform outputs interleaved min/max values
212
+ let tmpPeaks = [];
213
+ for (let i = 0; i < tmpData.length - 1; i += 2)
214
+ {
215
+ tmpPeaks.push(
216
+ {
217
+ Min: tmpData[i] / 128, // Normalize from -128..127 to -1..1
218
+ Max: tmpData[i + 1] / 128
219
+ });
220
+ }
221
+
222
+ return fCallback(null, tmpPeaks);
223
+ }
224
+ catch (pError)
225
+ {
226
+ return fCallback(pError);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Extract waveform peaks using ffprobe with the astats filter.
232
+ * This is the fallback when audiowaveform is not available.
233
+ *
234
+ * @param {string} pAbsPath - Absolute path to the audio file
235
+ * @param {number} pPeakCount - Desired number of peak data points
236
+ * @param {number} pDuration - Duration in seconds
237
+ * @param {Function} fCallback - Callback(pError, pPeaksArray)
238
+ */
239
+ _extractWithFfprobe(pAbsPath, pPeakCount, pDuration, fCallback)
240
+ {
241
+ try
242
+ {
243
+ // Calculate the chunk size (number of samples per peak)
244
+ // Default sample rate assumption: 44100
245
+ let tmpSampleRate = 44100;
246
+ let tmpTotalSamples = Math.round(pDuration * tmpSampleRate);
247
+ let tmpSamplesPerChunk = Math.max(1, Math.round(tmpTotalSamples / pPeakCount));
248
+
249
+ // Use ffmpeg to output raw peak levels as a series of volume measurements
250
+ // The showvolume or volumedetect filter is too coarse; instead we use
251
+ // the astats filter with frame-based analysis via ffprobe.
252
+ let tmpCmd = `ffprobe -f lavfi -i "amovie='${pAbsPath.replace(/'/g, "'\\''")}',asetnsamples=${tmpSamplesPerChunk},astats=metadata=1:reset=1" -show_entries frame_tags=lavfi.astats.Overall.Peak_level,lavfi.astats.Overall.RMS_level -of json -v quiet`;
253
+
254
+ let tmpOutput = libChildProcess.execSync(tmpCmd,
255
+ {
256
+ maxBuffer: 50 * 1024 * 1024,
257
+ timeout: 120000
258
+ });
259
+
260
+ let tmpData = JSON.parse(tmpOutput.toString());
261
+ let tmpFrames = tmpData.frames || [];
262
+
263
+ let tmpPeaks = [];
264
+ for (let i = 0; i < tmpFrames.length; i++)
265
+ {
266
+ let tmpTags = tmpFrames[i].tags || {};
267
+ let tmpPeakLevel = parseFloat(tmpTags['lavfi.astats.Overall.Peak_level']);
268
+
269
+ if (isNaN(tmpPeakLevel) || tmpPeakLevel === -Infinity || tmpPeakLevel < -120)
270
+ {
271
+ tmpPeaks.push({ Min: 0, Max: 0 });
272
+ }
273
+ else
274
+ {
275
+ // Convert dB to linear (0..1 range)
276
+ let tmpLinear = Math.pow(10, tmpPeakLevel / 20);
277
+ tmpLinear = Math.min(1.0, tmpLinear);
278
+ tmpPeaks.push(
279
+ {
280
+ Min: -tmpLinear,
281
+ Max: tmpLinear
282
+ });
283
+ }
284
+ }
285
+
286
+ // If we got no peaks, try a simpler approach
287
+ if (tmpPeaks.length === 0)
288
+ {
289
+ return this._extractWithFfmpegFallback(pAbsPath, pPeakCount, pDuration, fCallback);
290
+ }
291
+
292
+ return fCallback(null, tmpPeaks);
293
+ }
294
+ catch (pError)
295
+ {
296
+ // Try simpler fallback
297
+ return this._extractWithFfmpegFallback(pAbsPath, pPeakCount, pDuration, fCallback);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Simplest fallback: use ffmpeg to generate a WAV representation and
303
+ * read raw PCM values directly. Works when ffprobe lavfi fails.
304
+ *
305
+ * @param {string} pAbsPath - Absolute path to the audio file
306
+ * @param {number} pPeakCount - Desired number of peak data points
307
+ * @param {number} pDuration - Duration in seconds
308
+ * @param {Function} fCallback - Callback(pError, pPeaksArray)
309
+ */
310
+ _extractWithFfmpegFallback(pAbsPath, pPeakCount, pDuration, fCallback)
311
+ {
312
+ try
313
+ {
314
+ // Downsample to a low sample rate to reduce data; mono 8-bit signed
315
+ let tmpTargetRate = Math.max(100, Math.round(pPeakCount / pDuration));
316
+ // Use a maximum rate to avoid oversized buffers
317
+ tmpTargetRate = Math.min(tmpTargetRate, 8000);
318
+
319
+ let tmpCmd = `ffmpeg -i "${pAbsPath}" -ac 1 -ar ${tmpTargetRate} -f s16le -acodec pcm_s16le pipe:1 2>/dev/null`;
320
+
321
+ let tmpBuffer = libChildProcess.execSync(tmpCmd,
322
+ {
323
+ maxBuffer: 50 * 1024 * 1024,
324
+ timeout: 120000
325
+ });
326
+
327
+ // Each sample is 2 bytes (16-bit signed)
328
+ let tmpTotalSamples = tmpBuffer.length / 2;
329
+ let tmpSamplesPerPeak = Math.max(1, Math.round(tmpTotalSamples / pPeakCount));
330
+
331
+ let tmpPeaks = [];
332
+ for (let i = 0; i < tmpTotalSamples; i += tmpSamplesPerPeak)
333
+ {
334
+ let tmpMin = 0;
335
+ let tmpMax = 0;
336
+ let tmpEnd = Math.min(i + tmpSamplesPerPeak, tmpTotalSamples);
337
+
338
+ for (let j = i; j < tmpEnd; j++)
339
+ {
340
+ let tmpSample = tmpBuffer.readInt16LE(j * 2);
341
+ let tmpNorm = tmpSample / 32768;
342
+ if (tmpNorm < tmpMin) tmpMin = tmpNorm;
343
+ if (tmpNorm > tmpMax) tmpMax = tmpNorm;
344
+ }
345
+
346
+ tmpPeaks.push({ Min: tmpMin, Max: tmpMax });
347
+ }
348
+
349
+ if (tmpPeaks.length === 0)
350
+ {
351
+ return fCallback(new Error('Could not extract waveform data.'));
352
+ }
353
+
354
+ return fCallback(null, tmpPeaks);
355
+ }
356
+ catch (pError)
357
+ {
358
+ return fCallback(pError);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Format a timestamp in seconds to a human-readable string.
364
+ *
365
+ * @param {number} pSeconds - Timestamp in seconds
366
+ * @returns {string} Formatted string like "1:23:45" or "12:34"
367
+ */
368
+ _formatTimestamp(pSeconds)
369
+ {
370
+ let tmpHours = Math.floor(pSeconds / 3600);
371
+ let tmpMinutes = Math.floor((pSeconds % 3600) / 60);
372
+ let tmpSecs = Math.floor(pSeconds % 60);
373
+
374
+ if (tmpHours > 0)
375
+ {
376
+ return `${tmpHours}:${String(tmpMinutes).padStart(2, '0')}:${String(tmpSecs).padStart(2, '0')}`;
377
+ }
378
+ return `${tmpMinutes}:${String(tmpSecs).padStart(2, '0')}`;
379
+ }
380
+
381
+ /**
382
+ * Extract waveform peak data from an audio file.
383
+ * Results are cached for fast repeated access.
384
+ *
385
+ * @param {string} pAbsPath - Absolute path to the audio file
386
+ * @param {string} pRelPath - Relative path (for the response)
387
+ * @param {object} pOptions - { peaks }
388
+ * @param {Function} fCallback - Callback(pError, pResult)
389
+ */
390
+ extractWaveform(pAbsPath, pRelPath, pOptions, fCallback)
391
+ {
392
+ let tmpSelf = this;
393
+ let tmpPeakCount = parseInt(pOptions.peaks, 10) || this.options.DefaultPeakCount;
394
+
395
+ // Clamp peak count
396
+ tmpPeakCount = Math.min(Math.max(tmpPeakCount, 100), 10000);
397
+
398
+ // Get file stats for cache key
399
+ let tmpStat;
400
+ try
401
+ {
402
+ tmpStat = libFs.statSync(pAbsPath);
403
+ }
404
+ catch (pError)
405
+ {
406
+ return fCallback(new Error('File not found.'));
407
+ }
408
+
409
+ let tmpCacheDir = this._getWaveformCacheDir(pAbsPath, tmpStat.mtimeMs, tmpPeakCount);
410
+
411
+ // Check for cached manifest
412
+ let tmpManifestPath = libPath.join(tmpCacheDir, 'manifest.json');
413
+ if (libFs.existsSync(tmpManifestPath))
414
+ {
415
+ try
416
+ {
417
+ let tmpManifest = JSON.parse(libFs.readFileSync(tmpManifestPath, 'utf8'));
418
+ this.fable.log.info(`Audio waveform cache hit for ${pRelPath}`);
419
+ return fCallback(null, tmpManifest);
420
+ }
421
+ catch (pError)
422
+ {
423
+ // Corrupted manifest, regenerate
424
+ }
425
+ }
426
+
427
+ // Probe the audio for metadata
428
+ this._probeAudio(pAbsPath,
429
+ (pError, pAudioInfo) =>
430
+ {
431
+ if (pError || !pAudioInfo || !pAudioInfo.duration)
432
+ {
433
+ return fCallback(new Error('Could not probe audio file. ffprobe may not be available.'));
434
+ }
435
+
436
+ let tmpDuration = pAudioInfo.duration;
437
+
438
+ // Ensure cache directory exists
439
+ if (!libFs.existsSync(tmpCacheDir))
440
+ {
441
+ libFs.mkdirSync(tmpCacheDir, { recursive: true });
442
+ }
443
+
444
+ tmpSelf.fable.log.info(`Extracting waveform for ${pRelPath} (${tmpDuration.toFixed(1)}s, ${tmpPeakCount} peaks)`);
445
+
446
+ let tmpExtractCallback = (pExtractError, pPeaks) =>
447
+ {
448
+ if (pExtractError || !pPeaks || pPeaks.length === 0)
449
+ {
450
+ return fCallback(new Error('Failed to extract waveform data: ' + (pExtractError ? pExtractError.message : 'no peaks generated')));
451
+ }
452
+
453
+ let tmpResult =
454
+ {
455
+ Success: true,
456
+ Path: pRelPath,
457
+ Duration: tmpDuration,
458
+ DurationFormatted: tmpSelf._formatTimestamp(tmpDuration),
459
+ SampleRate: pAudioInfo.sampleRate,
460
+ Channels: pAudioInfo.channels,
461
+ ChannelLayout: pAudioInfo.channelLayout,
462
+ Codec: pAudioInfo.codec,
463
+ Bitrate: pAudioInfo.bitrate,
464
+ FileSize: pAudioInfo.size || tmpStat.size,
465
+ FormatName: pAudioInfo.formatName,
466
+ PeakCount: pPeaks.length,
467
+ RequestedPeaks: tmpPeakCount,
468
+ Method: tmpSelf.hasAudiowaveform ? 'audiowaveform' : 'ffmpeg',
469
+ CacheKey: libPath.basename(tmpCacheDir),
470
+ Peaks: pPeaks
471
+ };
472
+
473
+ // Write manifest to cache
474
+ try
475
+ {
476
+ libFs.writeFileSync(tmpManifestPath, JSON.stringify(tmpResult, null, '\t'));
477
+ }
478
+ catch (pWriteError)
479
+ {
480
+ tmpSelf.fable.log.warn(`Could not write waveform manifest: ${pWriteError.message}`);
481
+ }
482
+
483
+ tmpSelf.fable.log.info(`Extracted ${pPeaks.length} peaks for ${pRelPath} (${tmpResult.Method})`);
484
+ return fCallback(null, tmpResult);
485
+ };
486
+
487
+ // Use audiowaveform if available, otherwise fall back to ffprobe/ffmpeg
488
+ if (tmpSelf.hasAudiowaveform)
489
+ {
490
+ let tmpAwfOutputPath = libPath.join(tmpCacheDir, 'waveform.json');
491
+ tmpSelf._extractWithAudiowaveform(pAbsPath, tmpPeakCount, tmpAwfOutputPath, tmpDuration, tmpExtractCallback);
492
+ }
493
+ else
494
+ {
495
+ tmpSelf._extractWithFfprobe(pAbsPath, tmpPeakCount, tmpDuration, tmpExtractCallback);
496
+ }
497
+ });
498
+ }
499
+
500
+ /**
501
+ * Extract an audio segment (sub-clip) from a file.
502
+ * Uses stream copy when possible for lossless & fast extraction.
503
+ * Results are cached.
504
+ *
505
+ * @param {string} pAbsPath - Absolute path to the audio file
506
+ * @param {string} pRelPath - Relative path (for the response)
507
+ * @param {object} pOptions - { start, end, format }
508
+ * @param {Function} fCallback - Callback(pError, pResult)
509
+ */
510
+ extractSegment(pAbsPath, pRelPath, pOptions, fCallback)
511
+ {
512
+ let tmpSelf = this;
513
+ let tmpStart = parseFloat(pOptions.start) || 0;
514
+ let tmpEnd = parseFloat(pOptions.end) || 0;
515
+ let tmpFormat = pOptions.format || this.options.DefaultSegmentFormat;
516
+
517
+ // Validate format
518
+ let tmpValidFormats = { 'mp3': true, 'aac': true, 'ogg': true, 'wav': true, 'flac': true };
519
+ if (!tmpValidFormats[tmpFormat])
520
+ {
521
+ tmpFormat = 'mp3';
522
+ }
523
+
524
+ if (tmpEnd <= tmpStart)
525
+ {
526
+ return fCallback(new Error('End time must be greater than start time.'));
527
+ }
528
+
529
+ let tmpDuration = tmpEnd - tmpStart;
530
+ if (tmpDuration > this.options.MaxSegmentDuration)
531
+ {
532
+ return fCallback(new Error(`Segment too long. Maximum is ${this.options.MaxSegmentDuration} seconds.`));
533
+ }
534
+
535
+ // Get file stats for cache key
536
+ let tmpStat;
537
+ try
538
+ {
539
+ tmpStat = libFs.statSync(pAbsPath);
540
+ }
541
+ catch (pError)
542
+ {
543
+ return fCallback(new Error('File not found.'));
544
+ }
545
+
546
+ let tmpCacheDir = this._getSegmentCacheDir(pAbsPath, tmpStat.mtimeMs, tmpStart, tmpEnd, tmpFormat);
547
+
548
+ // Check for cached segment
549
+ let tmpFilename = `segment.${tmpFormat}`;
550
+ let tmpSegmentPath = libPath.join(tmpCacheDir, tmpFilename);
551
+ if (libFs.existsSync(tmpSegmentPath))
552
+ {
553
+ this.fable.log.info(`Audio segment cache hit for ${pRelPath} [${tmpStart}-${tmpEnd}]`);
554
+ return fCallback(null,
555
+ {
556
+ Success: true,
557
+ SegmentPath: tmpSegmentPath,
558
+ CacheKey: libPath.basename(tmpCacheDir),
559
+ Filename: tmpFilename,
560
+ Start: tmpStart,
561
+ End: tmpEnd,
562
+ Duration: tmpDuration,
563
+ Format: tmpFormat
564
+ });
565
+ }
566
+
567
+ // Ensure cache directory exists
568
+ if (!libFs.existsSync(tmpCacheDir))
569
+ {
570
+ libFs.mkdirSync(tmpCacheDir, { recursive: true });
571
+ }
572
+
573
+ this.fable.log.info(`Extracting audio segment from ${pRelPath} [${tmpStart.toFixed(1)}s - ${tmpEnd.toFixed(1)}s]`);
574
+
575
+ try
576
+ {
577
+ // Format timestamps for ffmpeg
578
+ let tmpStartStr = tmpStart.toFixed(3);
579
+ let tmpDurationStr = tmpDuration.toFixed(3);
580
+
581
+ // Map format to ffmpeg codec options
582
+ let tmpCodecArgs;
583
+ switch (tmpFormat)
584
+ {
585
+ case 'wav':
586
+ tmpCodecArgs = '-c:a pcm_s16le';
587
+ break;
588
+ case 'flac':
589
+ tmpCodecArgs = '-c:a flac';
590
+ break;
591
+ case 'ogg':
592
+ tmpCodecArgs = '-c:a libvorbis -q:a 5';
593
+ break;
594
+ case 'aac':
595
+ tmpCodecArgs = '-c:a aac -b:a 192k';
596
+ break;
597
+ case 'mp3':
598
+ default:
599
+ tmpCodecArgs = '-c:a libmp3lame -q:a 2';
600
+ break;
601
+ }
602
+
603
+ let tmpCmd = `ffmpeg -ss ${tmpStartStr} -t ${tmpDurationStr} -i "${pAbsPath}" -vn ${tmpCodecArgs} -y "${tmpSegmentPath}"`;
604
+ libChildProcess.execSync(tmpCmd, { stdio: 'ignore', timeout: 60000 });
605
+
606
+ if (!libFs.existsSync(tmpSegmentPath))
607
+ {
608
+ return fCallback(new Error('Failed to extract audio segment.'));
609
+ }
610
+
611
+ this.fable.log.info(`Extracted audio segment for ${pRelPath} [${tmpStart.toFixed(1)}s - ${tmpEnd.toFixed(1)}s]`);
612
+
613
+ return fCallback(null,
614
+ {
615
+ Success: true,
616
+ SegmentPath: tmpSegmentPath,
617
+ CacheKey: libPath.basename(tmpCacheDir),
618
+ Filename: tmpFilename,
619
+ Start: tmpStart,
620
+ End: tmpEnd,
621
+ Duration: tmpDuration,
622
+ Format: tmpFormat
623
+ });
624
+ }
625
+ catch (pError)
626
+ {
627
+ this.fable.log.warn(`Audio segment extraction failed: ${pError.message}`);
628
+ return fCallback(new Error('Failed to extract audio segment.'));
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Get the absolute path to a cached segment file.
634
+ *
635
+ * @param {string} pCacheKey - The cache key (directory name)
636
+ * @param {string} pFilename - The segment filename
637
+ * @returns {string|null} Absolute path or null if not found
638
+ */
639
+ getSegmentPath(pCacheKey, pFilename)
640
+ {
641
+ // Sanitize inputs to prevent directory traversal
642
+ if (!pCacheKey || !pFilename)
643
+ {
644
+ return null;
645
+ }
646
+ if (pCacheKey.includes('..') || pCacheKey.includes('/') || pCacheKey.includes('\\'))
647
+ {
648
+ return null;
649
+ }
650
+ if (pFilename.includes('..') || pFilename.includes('/') || pFilename.includes('\\'))
651
+ {
652
+ return null;
653
+ }
654
+
655
+ let tmpPath = libPath.join(this.cachePath, pCacheKey, pFilename);
656
+
657
+ // Double-check it's under our cache dir
658
+ let tmpResolved = libPath.resolve(tmpPath);
659
+ if (!tmpResolved.startsWith(this.cachePath))
660
+ {
661
+ return null;
662
+ }
663
+
664
+ if (libFs.existsSync(tmpPath))
665
+ {
666
+ return tmpPath;
667
+ }
668
+
669
+ return null;
670
+ }
671
+ }
672
+
673
+ module.exports = RetoldRemoteAudioWaveformService;