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
@@ -12,16 +12,21 @@
12
12
  * specific endpoints (save, upload) that aren't needed here.
13
13
  *
14
14
  * @param {object} pOptions
15
- * @param {string} pOptions.ContentPath - Absolute path to the media folder to browse
16
- * @param {string} pOptions.DistPath - Absolute path to the built web-application folder
17
- * @param {number} pOptions.Port - HTTP port
18
- * @param {string} [pOptions.ThumbnailCachePath] - Override thumbnail cache location
19
- * @param {boolean} [pOptions.HashedFilenames] - Enable hashed filenames mode
20
- * @param {Function} fCallback - Callback(pError, { Fable, Orator, Port })
15
+ * @param {string} pOptions.ContentPath - Absolute path to the media folder to browse
16
+ * @param {string} pOptions.DistPath - Absolute path to the built web-application folder
17
+ * @param {number} pOptions.Port - HTTP port
18
+ * @param {boolean} [pOptions.HashedFilenames] - Enable hashed filenames mode
19
+ * @param {string} [pOptions.CacheRoot] - Root cache directory (default: ./dist/retold-cache/)
20
+ * @param {string} [pOptions.CacheThumbnails] - Override thumbnails cache directory
21
+ * @param {string} [pOptions.CacheArchives] - Override archives cache directory
22
+ * @param {string} [pOptions.CacheVideoFrames] - Override video-frames cache directory
23
+ * @param {string} [pOptions.CacheAudioWaveforms] - Override audio-waveforms cache directory
24
+ * @param {Function} fCallback - Callback(pError, { Fable, Orator, Port })
21
25
  */
22
26
 
23
27
  const libFs = require('fs');
24
28
  const libPath = require('path');
29
+ const libChildProcess = require('child_process');
25
30
 
26
31
  const libFable = require('fable');
27
32
  const libOrator = require('orator');
@@ -30,6 +35,11 @@ const libFileBrowserService = require('pict-section-filebrowser').FileBrowserSer
30
35
 
31
36
  const libRetoldRemoteMediaService = require('../server/RetoldRemote-MediaService.js');
32
37
  const libRetoldRemotePathRegistry = require('../server/RetoldRemote-PathRegistry.js');
38
+ const libRetoldRemoteArchiveService = require('../server/RetoldRemote-ArchiveService.js');
39
+ const libRetoldRemoteVideoFrameService = require('../server/RetoldRemote-VideoFrameService.js');
40
+ const libRetoldRemoteAudioWaveformService = require('../server/RetoldRemote-AudioWaveformService.js');
41
+ const libRetoldRemoteEbookService = require('../server/RetoldRemote-EbookService.js');
42
+ const libUrl = require('url');
33
43
 
34
44
  function setupRetoldRemoteServer(pOptions, fCallback)
35
45
  {
@@ -79,11 +89,63 @@ function setupRetoldRemoteServer(pOptions, fCallback)
79
89
  tmpFable.log.info('Hashed filenames mode: ENABLED');
80
90
  }
81
91
 
92
+ // --- Resolve cache paths ---
93
+ // Default root: ./dist/retold-cache/ relative to process cwd.
94
+ // Individual overrides take precedence over root.
95
+ let tmpCacheRoot = pOptions.CacheRoot
96
+ || libPath.resolve(process.cwd(), 'dist', 'retold-cache');
97
+
98
+ let tmpCacheThumbnails = pOptions.CacheThumbnails
99
+ || libPath.join(tmpCacheRoot, 'thumbnails');
100
+ let tmpCacheArchives = pOptions.CacheArchives
101
+ || libPath.join(tmpCacheRoot, 'archives');
102
+ let tmpCacheVideoFrames = pOptions.CacheVideoFrames
103
+ || libPath.join(tmpCacheRoot, 'video-frames');
104
+ let tmpCacheAudioWaveforms = pOptions.CacheAudioWaveforms
105
+ || libPath.join(tmpCacheRoot, 'audio-waveforms');
106
+ let tmpCacheEbooks = pOptions.CacheEbooks
107
+ || libPath.join(tmpCacheRoot, 'ebook-conversions');
108
+
109
+ tmpFable.log.info(`Cache root: ${tmpCacheRoot}`);
110
+ tmpFable.log.info(` Thumbnails: ${tmpCacheThumbnails}`);
111
+ tmpFable.log.info(` Archives: ${tmpCacheArchives}`);
112
+ tmpFable.log.info(` Video frames: ${tmpCacheVideoFrames}`);
113
+ tmpFable.log.info(` Audio waveforms: ${tmpCacheAudioWaveforms}`);
114
+ tmpFable.log.info(` Ebook conversions: ${tmpCacheEbooks}`);
115
+
116
+ // Set up the archive service
117
+ let tmpArchiveService = new libRetoldRemoteArchiveService(tmpFable,
118
+ {
119
+ ContentPath: tmpContentPath,
120
+ CachePath: tmpCacheArchives
121
+ });
122
+
123
+ // Set up the video frame service
124
+ let tmpVideoFrameService = new libRetoldRemoteVideoFrameService(tmpFable,
125
+ {
126
+ ContentPath: tmpContentPath,
127
+ CachePath: tmpCacheVideoFrames
128
+ });
129
+
130
+ // Set up the audio waveform service
131
+ let tmpAudioWaveformService = new libRetoldRemoteAudioWaveformService(tmpFable,
132
+ {
133
+ ContentPath: tmpContentPath,
134
+ CachePath: tmpCacheAudioWaveforms
135
+ });
136
+
137
+ // Set up the ebook conversion service
138
+ let tmpEbookService = new libRetoldRemoteEbookService(tmpFable,
139
+ {
140
+ ContentPath: tmpContentPath,
141
+ CachePath: tmpCacheEbooks
142
+ });
143
+
82
144
  // Set up the media service
83
145
  let tmpMediaService = new libRetoldRemoteMediaService(tmpFable,
84
146
  {
85
147
  ContentPath: tmpContentPath,
86
- ThumbnailCachePath: pOptions.ThumbnailCachePath || null,
148
+ ThumbnailCachePath: tmpCacheThumbnails,
87
149
  APIRoutePrefix: '/api/media',
88
150
  PathRegistry: tmpPathRegistry
89
151
  });
@@ -133,7 +195,22 @@ function setupRetoldRemoteServer(pOptions, fCallback)
133
195
  pResponse.send(
134
196
  {
135
197
  Success: true,
136
- HashedFilenames: tmpHashedFilenames
198
+ HashedFilenames: tmpHashedFilenames,
199
+ CachePaths:
200
+ {
201
+ Root: tmpCacheRoot,
202
+ Thumbnails: tmpCacheThumbnails,
203
+ Archives: tmpCacheArchives,
204
+ VideoFrames: tmpCacheVideoFrames,
205
+ AudioWaveforms: tmpCacheAudioWaveforms
206
+ },
207
+ ArchiveSupport:
208
+ {
209
+ Enabled: true,
210
+ Has7z: tmpArchiveService.has7z,
211
+ NativeZipOnly: !tmpArchiveService.has7z,
212
+ SupportedExtensions: tmpArchiveService.getSupportedExtensions()
213
+ }
137
214
  });
138
215
  return fNext();
139
216
  });
@@ -178,6 +255,49 @@ function setupRetoldRemoteServer(pOptions, fCallback)
178
255
  });
179
256
  }
180
257
 
258
+ // Archive annotation middleware: wrap /api/filebrowser/list responses to
259
+ // change Type: 'file' to Type: 'archive' for entries with archive extensions.
260
+ // This makes archive files appear as navigable containers to the client.
261
+ tmpServiceServer.server.use(
262
+ (pRequest, pResponse, fNext) =>
263
+ {
264
+ if (!pRequest.url.startsWith('/api/filebrowser/list'))
265
+ {
266
+ return fNext();
267
+ }
268
+
269
+ let tmpOriginalSendArchive = pResponse.send.bind(pResponse);
270
+ let tmpSendWrapped = pResponse.send;
271
+
272
+ // Wrap send — there may already be a wrapper from hashed filenames.
273
+ // We chain on top of whatever send is currently set.
274
+ let tmpPreviousSend = pResponse.send;
275
+ pResponse.send = function (pStatusOrData, pData)
276
+ {
277
+ let tmpFileList = (typeof (pStatusOrData) === 'number') ? pData : pStatusOrData;
278
+
279
+ if (Array.isArray(tmpFileList))
280
+ {
281
+ for (let i = 0; i < tmpFileList.length; i++)
282
+ {
283
+ let tmpEntry = tmpFileList[i];
284
+ if (tmpEntry.Type === 'file' && tmpEntry.Extension)
285
+ {
286
+ if (tmpArchiveService.isArchiveFile(tmpEntry.Extension)
287
+ && tmpArchiveService.canHandle(tmpEntry.Extension))
288
+ {
289
+ tmpEntry.Type = 'archive';
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ return tmpPreviousSend.call(pResponse, pStatusOrData, pData);
296
+ };
297
+
298
+ return fNext();
299
+ });
300
+
181
301
  // Connect file browser API routes
182
302
  tmpFileBrowser.connectRoutes();
183
303
 
@@ -203,6 +323,473 @@ function setupRetoldRemoteServer(pOptions, fCallback)
203
323
  // Connect media service API routes
204
324
  tmpMediaService.connectRoutes(tmpServiceServer);
205
325
 
326
+ // --- GET /api/media/video-frames ---
327
+ // Extract evenly-spaced frames from a video for the Video Explorer.
328
+ tmpServiceServer.get('/api/media/video-frames',
329
+ (pRequest, pResponse, fNext) =>
330
+ {
331
+ try
332
+ {
333
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
334
+ let tmpQuery = tmpParsedUrl.query;
335
+ let tmpRelPath = tmpQuery.path;
336
+
337
+ if (!tmpRelPath || typeof (tmpRelPath) !== 'string')
338
+ {
339
+ pResponse.send(400, { Success: false, Error: 'Missing path parameter.' });
340
+ return fNext();
341
+ }
342
+
343
+ // Sanitize
344
+ tmpRelPath = decodeURIComponent(tmpRelPath).replace(/^\/+/, '');
345
+ if (tmpRelPath.includes('..') || libPath.isAbsolute(tmpRelPath))
346
+ {
347
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
348
+ return fNext();
349
+ }
350
+
351
+ let tmpAbsPath = libPath.join(tmpContentPath, tmpRelPath);
352
+ if (!libFs.existsSync(tmpAbsPath))
353
+ {
354
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
355
+ return fNext();
356
+ }
357
+
358
+ tmpVideoFrameService.extractFrames(tmpAbsPath, tmpRelPath,
359
+ {
360
+ count: tmpQuery.count,
361
+ width: tmpQuery.width,
362
+ height: tmpQuery.height,
363
+ format: tmpQuery.format
364
+ },
365
+ (pError, pResult) =>
366
+ {
367
+ if (pError)
368
+ {
369
+ pResponse.send(400, { Success: false, Error: pError.message });
370
+ return fNext();
371
+ }
372
+
373
+ pResponse.send(pResult);
374
+ return fNext();
375
+ });
376
+ }
377
+ catch (pError)
378
+ {
379
+ pResponse.send(500, { Success: false, Error: pError.message });
380
+ return fNext();
381
+ }
382
+ });
383
+
384
+ // --- GET /api/media/video-frame/:cacheKey/:filename ---
385
+ // Serve a single cached video frame image.
386
+ tmpServiceServer.get('/api/media/video-frame/:cacheKey/:filename',
387
+ (pRequest, pResponse, fNext) =>
388
+ {
389
+ try
390
+ {
391
+ let tmpCacheKey = pRequest.params.cacheKey;
392
+ let tmpFilename = pRequest.params.filename;
393
+
394
+ let tmpFramePath = tmpVideoFrameService.getFramePath(tmpCacheKey, tmpFilename);
395
+
396
+ if (!tmpFramePath)
397
+ {
398
+ pResponse.send(404, { Success: false, Error: 'Frame not found.' });
399
+ return fNext();
400
+ }
401
+
402
+ let tmpStat = libFs.statSync(tmpFramePath);
403
+ let tmpExt = libPath.extname(tmpFilename).toLowerCase();
404
+ let tmpMimeTypes = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' };
405
+ let tmpMime = tmpMimeTypes[tmpExt] || 'image/jpeg';
406
+
407
+ pResponse.writeHead(200,
408
+ {
409
+ 'Content-Type': tmpMime,
410
+ 'Content-Length': tmpStat.size,
411
+ 'Cache-Control': 'public, max-age=86400'
412
+ });
413
+
414
+ let tmpStream = libFs.createReadStream(tmpFramePath);
415
+ tmpStream.pipe(pResponse);
416
+ tmpStream.on('end', () => { return fNext(false); });
417
+ tmpStream.on('error', () =>
418
+ {
419
+ pResponse.send(500, { Error: 'Failed to serve frame.' });
420
+ return fNext(false);
421
+ });
422
+ }
423
+ catch (pError)
424
+ {
425
+ pResponse.send(500, { Success: false, Error: pError.message });
426
+ return fNext();
427
+ }
428
+ });
429
+
430
+ // --- GET /api/media/video-frame-at ---
431
+ // Extract a single frame at an arbitrary timestamp (for timeline click).
432
+ tmpServiceServer.get('/api/media/video-frame-at',
433
+ (pRequest, pResponse, fNext) =>
434
+ {
435
+ try
436
+ {
437
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
438
+ let tmpQuery = tmpParsedUrl.query;
439
+ let tmpRelPath = tmpQuery.path;
440
+ let tmpCacheKey = tmpQuery.cacheKey;
441
+ let tmpTimestamp = parseFloat(tmpQuery.timestamp);
442
+
443
+ if (!tmpRelPath || typeof (tmpRelPath) !== 'string')
444
+ {
445
+ pResponse.send(400, { Success: false, Error: 'Missing path parameter.' });
446
+ return fNext();
447
+ }
448
+ if (!tmpCacheKey || typeof (tmpCacheKey) !== 'string')
449
+ {
450
+ pResponse.send(400, { Success: false, Error: 'Missing cacheKey parameter.' });
451
+ return fNext();
452
+ }
453
+ if (isNaN(tmpTimestamp) || tmpTimestamp < 0)
454
+ {
455
+ pResponse.send(400, { Success: false, Error: 'Invalid timestamp.' });
456
+ return fNext();
457
+ }
458
+
459
+ // Sanitize
460
+ tmpRelPath = decodeURIComponent(tmpRelPath).replace(/^\/+/, '');
461
+ if (tmpRelPath.includes('..') || libPath.isAbsolute(tmpRelPath))
462
+ {
463
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
464
+ return fNext();
465
+ }
466
+
467
+ let tmpAbsPath = libPath.join(tmpContentPath, tmpRelPath);
468
+ if (!libFs.existsSync(tmpAbsPath))
469
+ {
470
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
471
+ return fNext();
472
+ }
473
+
474
+ tmpVideoFrameService.extractSingleFrame(tmpAbsPath, tmpCacheKey, tmpTimestamp,
475
+ {
476
+ width: tmpQuery.width,
477
+ height: tmpQuery.height,
478
+ format: tmpQuery.format
479
+ },
480
+ (pError, pResult) =>
481
+ {
482
+ if (pError)
483
+ {
484
+ pResponse.send(400, { Success: false, Error: pError.message });
485
+ return fNext();
486
+ }
487
+
488
+ pResponse.send(pResult);
489
+ return fNext();
490
+ });
491
+ }
492
+ catch (pError)
493
+ {
494
+ pResponse.send(500, { Success: false, Error: pError.message });
495
+ return fNext();
496
+ }
497
+ });
498
+
499
+ // --- GET /api/media/audio-waveform ---
500
+ // Extract waveform peak data from an audio file for the Audio Explorer.
501
+ tmpServiceServer.get('/api/media/audio-waveform',
502
+ (pRequest, pResponse, fNext) =>
503
+ {
504
+ try
505
+ {
506
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
507
+ let tmpQuery = tmpParsedUrl.query;
508
+ let tmpRelPath = tmpQuery.path;
509
+
510
+ if (!tmpRelPath || typeof (tmpRelPath) !== 'string')
511
+ {
512
+ pResponse.send(400, { Success: false, Error: 'Missing path parameter.' });
513
+ return fNext();
514
+ }
515
+
516
+ // Sanitize
517
+ tmpRelPath = decodeURIComponent(tmpRelPath).replace(/^\/+/, '');
518
+ if (tmpRelPath.includes('..') || libPath.isAbsolute(tmpRelPath))
519
+ {
520
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
521
+ return fNext();
522
+ }
523
+
524
+ let tmpAbsPath = libPath.join(tmpContentPath, tmpRelPath);
525
+ if (!libFs.existsSync(tmpAbsPath))
526
+ {
527
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
528
+ return fNext();
529
+ }
530
+
531
+ tmpAudioWaveformService.extractWaveform(tmpAbsPath, tmpRelPath,
532
+ {
533
+ peaks: tmpQuery.peaks
534
+ },
535
+ (pError, pResult) =>
536
+ {
537
+ if (pError)
538
+ {
539
+ pResponse.send(400, { Success: false, Error: pError.message });
540
+ return fNext();
541
+ }
542
+
543
+ pResponse.send(pResult);
544
+ return fNext();
545
+ });
546
+ }
547
+ catch (pError)
548
+ {
549
+ pResponse.send(500, { Success: false, Error: pError.message });
550
+ return fNext();
551
+ }
552
+ });
553
+
554
+ // --- GET /api/media/audio-segment ---
555
+ // Extract an audio sub-clip for remote playback.
556
+ tmpServiceServer.get('/api/media/audio-segment',
557
+ (pRequest, pResponse, fNext) =>
558
+ {
559
+ try
560
+ {
561
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
562
+ let tmpQuery = tmpParsedUrl.query;
563
+ let tmpRelPath = tmpQuery.path;
564
+
565
+ if (!tmpRelPath || typeof (tmpRelPath) !== 'string')
566
+ {
567
+ pResponse.send(400, { Success: false, Error: 'Missing path parameter.' });
568
+ return fNext();
569
+ }
570
+
571
+ // Sanitize
572
+ tmpRelPath = decodeURIComponent(tmpRelPath).replace(/^\/+/, '');
573
+ if (tmpRelPath.includes('..') || libPath.isAbsolute(tmpRelPath))
574
+ {
575
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
576
+ return fNext();
577
+ }
578
+
579
+ let tmpAbsPath = libPath.join(tmpContentPath, tmpRelPath);
580
+ if (!libFs.existsSync(tmpAbsPath))
581
+ {
582
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
583
+ return fNext();
584
+ }
585
+
586
+ tmpAudioWaveformService.extractSegment(tmpAbsPath, tmpRelPath,
587
+ {
588
+ start: tmpQuery.start,
589
+ end: tmpQuery.end,
590
+ format: tmpQuery.format
591
+ },
592
+ (pError, pResult) =>
593
+ {
594
+ if (pError)
595
+ {
596
+ pResponse.send(400, { Success: false, Error: pError.message });
597
+ return fNext();
598
+ }
599
+
600
+ // Stream the segment file back
601
+ try
602
+ {
603
+ let tmpSegPath = pResult.SegmentPath;
604
+ let tmpSegStat = libFs.statSync(tmpSegPath);
605
+ let tmpMimeMap = { 'mp3': 'audio/mpeg', 'aac': 'audio/aac', 'ogg': 'audio/ogg', 'wav': 'audio/wav', 'flac': 'audio/flac' };
606
+ let tmpMime = tmpMimeMap[pResult.Format] || 'audio/mpeg';
607
+
608
+ pResponse.writeHead(200,
609
+ {
610
+ 'Content-Type': tmpMime,
611
+ 'Content-Length': tmpSegStat.size,
612
+ 'Cache-Control': 'public, max-age=86400',
613
+ 'Content-Disposition': `inline; filename="segment_${pResult.Start.toFixed(0)}-${pResult.End.toFixed(0)}.${pResult.Format}"`
614
+ });
615
+
616
+ let tmpStream = libFs.createReadStream(tmpSegPath);
617
+ tmpStream.pipe(pResponse);
618
+ tmpStream.on('end', () => { return fNext(false); });
619
+ tmpStream.on('error', () =>
620
+ {
621
+ pResponse.send(500, { Error: 'Failed to serve segment.' });
622
+ return fNext(false);
623
+ });
624
+ }
625
+ catch (pStreamError)
626
+ {
627
+ pResponse.send(500, { Success: false, Error: pStreamError.message });
628
+ return fNext();
629
+ }
630
+ });
631
+ }
632
+ catch (pError)
633
+ {
634
+ pResponse.send(500, { Success: false, Error: pError.message });
635
+ return fNext();
636
+ }
637
+ });
638
+
639
+ // --- GET /api/media/ebook-convert ---
640
+ // Convert an ebook (MOBI, AZW, etc.) to EPUB for in-browser reading.
641
+ tmpServiceServer.get('/api/media/ebook-convert',
642
+ (pRequest, pResponse, fNext) =>
643
+ {
644
+ try
645
+ {
646
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
647
+ let tmpQuery = tmpParsedUrl.query;
648
+ let tmpRelPath = tmpQuery.path;
649
+
650
+ if (!tmpRelPath || typeof (tmpRelPath) !== 'string')
651
+ {
652
+ pResponse.send(400, { Success: false, Error: 'Missing path parameter.' });
653
+ return fNext();
654
+ }
655
+
656
+ // Sanitize
657
+ tmpRelPath = decodeURIComponent(tmpRelPath).replace(/^\/+/, '');
658
+ if (tmpRelPath.includes('..') || libPath.isAbsolute(tmpRelPath))
659
+ {
660
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
661
+ return fNext();
662
+ }
663
+
664
+ let tmpAbsPath = libPath.join(tmpContentPath, tmpRelPath);
665
+ if (!libFs.existsSync(tmpAbsPath))
666
+ {
667
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
668
+ return fNext();
669
+ }
670
+
671
+ tmpEbookService.convertToEpub(tmpAbsPath, tmpRelPath,
672
+ (pError, pResult) =>
673
+ {
674
+ if (pError)
675
+ {
676
+ pResponse.send(400, { Success: false, Error: pError.message });
677
+ return fNext();
678
+ }
679
+
680
+ pResponse.send(pResult);
681
+ return fNext();
682
+ });
683
+ }
684
+ catch (pError)
685
+ {
686
+ pResponse.send(500, { Success: false, Error: pError.message });
687
+ return fNext();
688
+ }
689
+ });
690
+
691
+ // --- GET /api/media/ebook/:cacheKey/:filename ---
692
+ // Serve a cached converted ebook file.
693
+ tmpServiceServer.get('/api/media/ebook/:cacheKey/:filename',
694
+ (pRequest, pResponse, fNext) =>
695
+ {
696
+ try
697
+ {
698
+ let tmpCacheKey = pRequest.params.cacheKey;
699
+ let tmpFilename = pRequest.params.filename;
700
+
701
+ let tmpEbookPath = tmpEbookService.getConvertedPath(tmpCacheKey, tmpFilename);
702
+
703
+ if (!tmpEbookPath)
704
+ {
705
+ pResponse.send(404, { Success: false, Error: 'Ebook not found.' });
706
+ return fNext();
707
+ }
708
+
709
+ let tmpStat = libFs.statSync(tmpEbookPath);
710
+
711
+ pResponse.writeHead(200,
712
+ {
713
+ 'Content-Type': 'application/epub+zip',
714
+ 'Content-Length': tmpStat.size,
715
+ 'Cache-Control': 'public, max-age=86400'
716
+ });
717
+
718
+ let tmpStream = libFs.createReadStream(tmpEbookPath);
719
+ tmpStream.pipe(pResponse);
720
+ tmpStream.on('end', () => { return fNext(false); });
721
+ tmpStream.on('error', () =>
722
+ {
723
+ pResponse.send(500, { Error: 'Failed to serve ebook.' });
724
+ return fNext(false);
725
+ });
726
+ }
727
+ catch (pError)
728
+ {
729
+ pResponse.send(500, { Success: false, Error: pError.message });
730
+ return fNext();
731
+ }
732
+ });
733
+
734
+ // --- POST /api/media/open ---
735
+ // Open a media file with an external application (e.g. VLC).
736
+ tmpServiceServer.post('/api/media/open',
737
+ (pRequest, pResponse, fNext) =>
738
+ {
739
+ try
740
+ {
741
+ let tmpBody = pRequest.body || {};
742
+ let tmpRelPath = tmpBody.path;
743
+
744
+ if (!tmpRelPath || typeof (tmpRelPath) !== 'string')
745
+ {
746
+ pResponse.send(400, { Success: false, Error: 'Missing or invalid path.' });
747
+ return fNext();
748
+ }
749
+
750
+ // Sanitize: no absolute paths, no directory traversal
751
+ if (tmpRelPath.indexOf('..') !== -1 || libPath.isAbsolute(tmpRelPath))
752
+ {
753
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
754
+ return fNext();
755
+ }
756
+
757
+ let tmpAbsPath = libPath.join(tmpContentPath, tmpRelPath);
758
+
759
+ // Verify file exists
760
+ if (!libFs.existsSync(tmpAbsPath))
761
+ {
762
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
763
+ return fNext();
764
+ }
765
+
766
+ // Determine the open command based on platform
767
+ let tmpSpawnArgs;
768
+ if (process.platform === 'darwin')
769
+ {
770
+ tmpSpawnArgs = ['open', ['-a', 'VLC', tmpAbsPath]];
771
+ }
772
+ else
773
+ {
774
+ tmpSpawnArgs = ['vlc', [tmpAbsPath]];
775
+ }
776
+
777
+ let tmpChild = libChildProcess.spawn(tmpSpawnArgs[0], tmpSpawnArgs[1],
778
+ {
779
+ detached: true,
780
+ stdio: 'ignore'
781
+ });
782
+ tmpChild.unref();
783
+
784
+ pResponse.send({ Success: true });
785
+ }
786
+ catch (pError)
787
+ {
788
+ pResponse.send(500, { Success: false, Error: pError.message });
789
+ }
790
+ return fNext();
791
+ });
792
+
206
793
  // Content-hashed URL rewrite: resolve /content-hashed/<hash>
207
794
  // to /content/<resolved-path> so the static route serves the file.
208
795
  // Uses server.pre() to rewrite BEFORE route matching.
@@ -232,6 +819,198 @@ function setupRetoldRemoteServer(pOptions, fCallback)
232
819
  });
233
820
  }
234
821
 
822
+ // Archive file listing: intercept /api/filebrowser/list requests
823
+ // where the path crosses into an archive. Uses server.pre() to
824
+ // respond before the normal file browser route handler.
825
+ tmpServiceServer.server.pre(
826
+ (pRequest, pResponse, fNext) =>
827
+ {
828
+ if (!pRequest.url.startsWith('/api/filebrowser/list'))
829
+ {
830
+ return fNext();
831
+ }
832
+
833
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
834
+ let tmpPathParam = (tmpParsedUrl.query && tmpParsedUrl.query.path) || '';
835
+
836
+ // Resolve hash to path if hashed filenames is enabled
837
+ // (server.pre runs before server.use middleware, so hash resolution hasn't happened yet)
838
+ if (tmpHashedFilenames && /^[a-f0-9]{10}$/.test(tmpPathParam))
839
+ {
840
+ let tmpResolved = tmpPathRegistry.resolve(tmpPathParam);
841
+ if (tmpResolved !== null)
842
+ {
843
+ tmpPathParam = tmpResolved;
844
+ }
845
+ }
846
+
847
+ let tmpArchiveInfo = tmpArchiveService.parseArchivePath(tmpPathParam);
848
+
849
+ if (!tmpArchiveInfo)
850
+ {
851
+ return fNext();
852
+ }
853
+
854
+ let tmpArchiveAbsPath = libPath.join(tmpContentPath, tmpArchiveInfo.archivePath);
855
+
856
+ if (!libFs.existsSync(tmpArchiveAbsPath))
857
+ {
858
+ pResponse.send(404, { Error: 'Archive not found.' });
859
+ return fNext(false);
860
+ }
861
+
862
+ tmpArchiveService.listContents(
863
+ tmpArchiveAbsPath, tmpArchiveInfo.innerPath, tmpArchiveInfo.archivePath,
864
+ (pError, pFileList) =>
865
+ {
866
+ if (pError)
867
+ {
868
+ pResponse.send(400, { Error: pError.message });
869
+ return fNext(false);
870
+ }
871
+
872
+ // Annotate with hashes if enabled
873
+ if (tmpHashedFilenames)
874
+ {
875
+ tmpPathRegistry.annotateFileList(pFileList);
876
+
877
+ // Register the archive inner path as a folder
878
+ let tmpFolderPath = tmpArchiveInfo.innerPath
879
+ ? (tmpArchiveInfo.archivePath + '/' + tmpArchiveInfo.innerPath)
880
+ : tmpArchiveInfo.archivePath;
881
+ let tmpFolderHash = tmpPathRegistry.register(tmpFolderPath);
882
+ pResponse.header('X-Retold-Folder-Hash', tmpFolderHash);
883
+ }
884
+
885
+ pResponse.send(pFileList);
886
+ return fNext(false);
887
+ });
888
+ });
889
+
890
+ // Archive content serving: intercept /content/* requests that cross
891
+ // into an archive. Extracts the file to cache and streams it back.
892
+ tmpServiceServer.server.pre(
893
+ (pRequest, pResponse, fNext) =>
894
+ {
895
+ if (!pRequest.url.startsWith('/content/'))
896
+ {
897
+ return fNext();
898
+ }
899
+
900
+ let tmpRelPath = decodeURIComponent(pRequest.url.replace(/^\/content\//, ''));
901
+ let tmpArchiveInfo = tmpArchiveService.parseArchivePath(tmpRelPath);
902
+
903
+ if (!tmpArchiveInfo || !tmpArchiveInfo.innerPath)
904
+ {
905
+ return fNext();
906
+ }
907
+
908
+ let tmpArchiveAbsPath = libPath.join(tmpContentPath, tmpArchiveInfo.archivePath);
909
+
910
+ tmpArchiveService.extractFile(
911
+ tmpArchiveAbsPath, tmpArchiveInfo.innerPath,
912
+ (pError, pCachedPath) =>
913
+ {
914
+ if (pError || !pCachedPath)
915
+ {
916
+ pResponse.send(404, { Error: 'Could not extract file from archive.' });
917
+ return fNext(false);
918
+ }
919
+
920
+ // Stream the cached file
921
+ try
922
+ {
923
+ let tmpStat = libFs.statSync(pCachedPath);
924
+ let tmpExt = libPath.extname(pCachedPath).toLowerCase();
925
+ let tmpMime = tmpArchiveService.getMimeType(tmpExt);
926
+
927
+ pResponse.writeHead(200,
928
+ {
929
+ 'Content-Type': tmpMime,
930
+ 'Content-Length': tmpStat.size,
931
+ 'Cache-Control': 'public, max-age=3600'
932
+ });
933
+
934
+ let tmpStream = libFs.createReadStream(pCachedPath);
935
+ tmpStream.pipe(pResponse);
936
+ tmpStream.on('end', () => { return fNext(false); });
937
+ tmpStream.on('error', () =>
938
+ {
939
+ pResponse.send(500, { Error: 'Failed to serve extracted file.' });
940
+ return fNext(false);
941
+ });
942
+ }
943
+ catch (pStreamError)
944
+ {
945
+ pResponse.send(500, { Error: pStreamError.message });
946
+ return fNext(false);
947
+ }
948
+ });
949
+ });
950
+
951
+ // Archive-aware thumbnail/probe: intercept /api/media/thumbnail and
952
+ // /api/media/probe requests for files inside archives. Extracts the
953
+ // file to cache first, then rewrites the URL to point at the cached
954
+ // copy so the normal MediaService handler processes it.
955
+ tmpServiceServer.server.pre(
956
+ (pRequest, pResponse, fNext) =>
957
+ {
958
+ if (!pRequest.url.startsWith('/api/media/thumbnail')
959
+ && !pRequest.url.startsWith('/api/media/probe'))
960
+ {
961
+ return fNext();
962
+ }
963
+
964
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
965
+ let tmpPathParam = (tmpParsedUrl.query && tmpParsedUrl.query.path) || '';
966
+
967
+ // Resolve hash to path if hashed filenames is enabled
968
+ if (tmpHashedFilenames && /^[a-f0-9]{10}$/.test(tmpPathParam))
969
+ {
970
+ let tmpResolved = tmpPathRegistry.resolve(tmpPathParam);
971
+ if (tmpResolved !== null)
972
+ {
973
+ tmpPathParam = tmpResolved;
974
+ }
975
+ }
976
+
977
+ let tmpArchiveInfo = tmpArchiveService.parseArchivePath(tmpPathParam);
978
+
979
+ if (!tmpArchiveInfo || !tmpArchiveInfo.innerPath)
980
+ {
981
+ return fNext();
982
+ }
983
+
984
+ let tmpArchiveAbsPath = libPath.join(tmpContentPath, tmpArchiveInfo.archivePath);
985
+
986
+ tmpArchiveService.extractFile(
987
+ tmpArchiveAbsPath, tmpArchiveInfo.innerPath,
988
+ (pError, pCachedPath) =>
989
+ {
990
+ if (pError || !pCachedPath)
991
+ {
992
+ pResponse.send(404, { Error: 'Could not extract file from archive.' });
993
+ return fNext(false);
994
+ }
995
+
996
+ // Rewrite the path query param to point at the cached file
997
+ // relative to the content root. The MediaService resolves
998
+ // paths relative to ContentPath, so we need to compute the
999
+ // relative path from contentPath to the cached file.
1000
+ let tmpRelCached = libPath.relative(tmpContentPath, pCachedPath);
1001
+
1002
+ tmpParsedUrl.query.path = tmpRelCached;
1003
+ delete tmpParsedUrl.search;
1004
+ pRequest.url = libUrl.format(tmpParsedUrl);
1005
+ if (pRequest.query)
1006
+ {
1007
+ pRequest.query.path = tmpRelCached;
1008
+ }
1009
+
1010
+ return fNext();
1011
+ });
1012
+ });
1013
+
235
1014
  // Serve content files at /content/ (for direct media access)
236
1015
  tmpOrator.addStaticRoute(`${tmpContentPath}/`, 'index.html', '/content/*', '/content/');
237
1016
 
@@ -247,6 +1026,9 @@ function setupRetoldRemoteServer(pOptions, fCallback)
247
1026
  Fable: tmpFable,
248
1027
  Orator: tmpOrator,
249
1028
  MediaService: tmpMediaService,
1029
+ ArchiveService: tmpArchiveService,
1030
+ VideoFrameService: tmpVideoFrameService,
1031
+ AudioWaveformService: tmpAudioWaveformService,
250
1032
  PathRegistry: tmpPathRegistry,
251
1033
  Port: tmpPort
252
1034
  });