retold-remote 0.0.1

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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/css/retold-remote.css +83 -0
  3. package/html/codejar.js +511 -0
  4. package/html/index.html +23 -0
  5. package/package.json +68 -0
  6. package/server.js +43 -0
  7. package/source/Pict-Application-RetoldRemote-Configuration.json +7 -0
  8. package/source/Pict-Application-RetoldRemote.js +622 -0
  9. package/source/Pict-RetoldRemote-Bundle.js +14 -0
  10. package/source/cli/RetoldRemote-CLI-Program.js +15 -0
  11. package/source/cli/RetoldRemote-CLI-Run.js +3 -0
  12. package/source/cli/RetoldRemote-Server-Setup.js +257 -0
  13. package/source/cli/commands/RetoldRemote-Command-Serve.js +87 -0
  14. package/source/providers/Pict-Provider-GalleryFilterSort.js +597 -0
  15. package/source/providers/Pict-Provider-GalleryNavigation.js +819 -0
  16. package/source/providers/Pict-Provider-RetoldRemote.js +273 -0
  17. package/source/providers/Pict-Provider-RetoldRemoteIcons.js +640 -0
  18. package/source/providers/Pict-Provider-RetoldRemoteTheme.js +879 -0
  19. package/source/server/RetoldRemote-MediaService.js +536 -0
  20. package/source/server/RetoldRemote-PathRegistry.js +121 -0
  21. package/source/server/RetoldRemote-ThumbnailCache.js +89 -0
  22. package/source/server/RetoldRemote-ToolDetector.js +78 -0
  23. package/source/views/PictView-Remote-Gallery.js +1437 -0
  24. package/source/views/PictView-Remote-ImageViewer.js +363 -0
  25. package/source/views/PictView-Remote-Layout.js +420 -0
  26. package/source/views/PictView-Remote-MediaViewer.js +530 -0
  27. package/source/views/PictView-Remote-SettingsPanel.js +318 -0
  28. package/source/views/PictView-Remote-TopBar.js +206 -0
  29. package/web-application/codejar.js +511 -0
  30. package/web-application/css/retold-remote.css +83 -0
  31. package/web-application/index.html +23 -0
  32. package/web-application/js/pict.min.js +12 -0
  33. package/web-application/js/pict.min.js.map +1 -0
  34. package/web-application/retold-remote.compatible.js +5764 -0
  35. package/web-application/retold-remote.compatible.js.map +1 -0
  36. package/web-application/retold-remote.compatible.min.js +120 -0
  37. package/web-application/retold-remote.compatible.min.js.map +1 -0
  38. package/web-application/retold-remote.js +5763 -0
  39. package/web-application/retold-remote.js.map +1 -0
  40. package/web-application/retold-remote.min.js +120 -0
  41. package/web-application/retold-remote.min.js.map +1 -0
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Retold Remote -- Media Service
3
+ *
4
+ * Provides REST API endpoints for media operations:
5
+ * GET /api/media/capabilities -- Detected tool availability
6
+ * GET /api/media/thumbnail -- Generate/serve cached thumbnails
7
+ * GET /api/media/probe -- Media metadata (dimensions, duration)
8
+ * GET /api/media/folder-summary -- Folder media type counts
9
+ *
10
+ * @license MIT
11
+ */
12
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
13
+ const libFs = require('fs');
14
+ const libPath = require('path');
15
+ const libChildProcess = require('child_process');
16
+ const libUrl = require('url');
17
+
18
+ const libToolDetector = require('./RetoldRemote-ToolDetector.js');
19
+ const libThumbnailCache = require('./RetoldRemote-ThumbnailCache.js');
20
+
21
+ const _ImageExtensions = { 'png': true, 'jpg': true, 'jpeg': true, 'gif': true, 'webp': true, 'svg': true, 'bmp': true, 'ico': true, 'avif': true, 'tiff': true, 'tif': true, 'heic': true, 'heif': true };
22
+ const _VideoExtensions = { 'mp4': true, 'webm': true, 'mov': true, 'mkv': true, 'avi': true, 'wmv': true, 'flv': true, 'm4v': true, 'ogv': true };
23
+ const _AudioExtensions = { 'mp3': true, 'wav': true, 'ogg': true, 'flac': true, 'aac': true, 'm4a': true, 'wma': true, 'oga': true };
24
+ const _DocumentExtensions = { 'pdf': true, 'epub': true, 'mobi': true, 'doc': true, 'docx': true };
25
+
26
+ const _DefaultServiceConfiguration =
27
+ {
28
+ "ContentPath": ".",
29
+ "ThumbnailCachePath": null,
30
+ "APIRoutePrefix": "/api/media",
31
+ "DefaultThumbnailWidth": 200,
32
+ "DefaultThumbnailHeight": 200
33
+ };
34
+
35
+ class RetoldRemoteMediaService extends libFableServiceProviderBase
36
+ {
37
+ constructor(pFable, pOptions, pServiceHash)
38
+ {
39
+ super(pFable, pOptions, pServiceHash);
40
+
41
+ this.serviceType = 'RetoldRemoteMediaService';
42
+
43
+ // Merge with defaults
44
+ for (let tmpKey in _DefaultServiceConfiguration)
45
+ {
46
+ if (!(tmpKey in this.options))
47
+ {
48
+ this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
49
+ }
50
+ }
51
+
52
+ this.contentPath = libPath.resolve(this.options.ContentPath);
53
+
54
+ let tmpCachePath = this.options.ThumbnailCachePath
55
+ || libPath.join(this.contentPath, '.retold-remote-cache');
56
+
57
+ this.toolDetector = new libToolDetector();
58
+ this.capabilities = this.toolDetector.detect();
59
+ this.thumbnailCache = new libThumbnailCache(tmpCachePath);
60
+ this.pathRegistry = this.options.PathRegistry || null;
61
+
62
+ this.fable.log.info(`Media Service: capabilities = ${JSON.stringify(this.capabilities)}`);
63
+ }
64
+
65
+ /**
66
+ * Sanitize a file path to prevent directory traversal.
67
+ *
68
+ * @param {string} pPath - Raw path from query
69
+ * @returns {string|null} Safe relative path or null
70
+ */
71
+ _sanitizePath(pPath)
72
+ {
73
+ if (!pPath || typeof (pPath) !== 'string')
74
+ {
75
+ return null;
76
+ }
77
+ let tmpPath = decodeURIComponent(pPath);
78
+ tmpPath = tmpPath.replace(/^\/+/, '');
79
+ if (tmpPath.includes('..'))
80
+ {
81
+ return null;
82
+ }
83
+ if (libPath.isAbsolute(tmpPath))
84
+ {
85
+ return null;
86
+ }
87
+ return tmpPath || null;
88
+ }
89
+
90
+ /**
91
+ * Get the media category for a file extension.
92
+ *
93
+ * @param {string} pExtension - Lowercase extension
94
+ * @returns {string} 'image', 'video', 'audio', 'document', or 'other'
95
+ */
96
+ _getMediaCategory(pExtension)
97
+ {
98
+ if (_ImageExtensions[pExtension]) return 'image';
99
+ if (_VideoExtensions[pExtension]) return 'video';
100
+ if (_AudioExtensions[pExtension]) return 'audio';
101
+ if (_DocumentExtensions[pExtension]) return 'document';
102
+ return 'other';
103
+ }
104
+
105
+ /**
106
+ * Connect all media API routes to the server.
107
+ *
108
+ * @param {object} pServer - The Restify server instance
109
+ */
110
+ connectRoutes(pServer)
111
+ {
112
+ let tmpSelf = this;
113
+ let tmpPrefix = this.options.APIRoutePrefix;
114
+
115
+ // --- GET /api/media/capabilities ---
116
+ pServer.get(`${tmpPrefix}/capabilities`,
117
+ (pRequest, pResponse, fNext) =>
118
+ {
119
+ pResponse.send(
120
+ {
121
+ Success: true,
122
+ Capabilities: tmpSelf.capabilities,
123
+ CachePath: tmpSelf.thumbnailCache.getCachePath()
124
+ });
125
+ return fNext();
126
+ });
127
+
128
+ // --- GET /api/media/thumbnail ---
129
+ pServer.get(`${tmpPrefix}/thumbnail`,
130
+ (pRequest, pResponse, fNext) =>
131
+ {
132
+ try
133
+ {
134
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
135
+ let tmpQuery = tmpParsedUrl.query;
136
+ let tmpRelPath = tmpSelf._sanitizePath(tmpQuery.path);
137
+
138
+ if (!tmpRelPath)
139
+ {
140
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
141
+ return fNext();
142
+ }
143
+
144
+ let tmpFullPath = libPath.join(tmpSelf.contentPath, tmpRelPath);
145
+ if (!libFs.existsSync(tmpFullPath))
146
+ {
147
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
148
+ return fNext();
149
+ }
150
+
151
+ let tmpWidth = parseInt(tmpQuery.width, 10) || tmpSelf.options.DefaultThumbnailWidth;
152
+ let tmpHeight = parseInt(tmpQuery.height, 10) || tmpSelf.options.DefaultThumbnailHeight;
153
+ let tmpFormat = tmpQuery.format || 'webp';
154
+
155
+ // Clamp dimensions
156
+ tmpWidth = Math.min(Math.max(tmpWidth, 32), 1024);
157
+ tmpHeight = Math.min(Math.max(tmpHeight, 32), 1024);
158
+
159
+ let tmpStat = libFs.statSync(tmpFullPath);
160
+ let tmpCacheKey = tmpSelf.thumbnailCache.buildKey(tmpRelPath, tmpStat.mtimeMs, tmpWidth, tmpHeight);
161
+ let tmpCachedPath = tmpSelf.thumbnailCache.get(tmpCacheKey, tmpFormat);
162
+
163
+ if (tmpCachedPath)
164
+ {
165
+ // Serve from cache
166
+ let tmpBuffer = libFs.readFileSync(tmpCachedPath);
167
+ pResponse.writeHead(200,
168
+ {
169
+ 'Content-Type': `image/${tmpFormat}`,
170
+ 'Content-Length': tmpBuffer.length,
171
+ 'Cache-Control': 'public, max-age=86400'
172
+ });
173
+ pResponse.end(tmpBuffer);
174
+ return fNext();
175
+ }
176
+
177
+ // Generate thumbnail
178
+ let tmpExtension = libPath.extname(tmpRelPath).replace('.', '').toLowerCase();
179
+ let tmpCategory = tmpSelf._getMediaCategory(tmpExtension);
180
+
181
+ tmpSelf._generateThumbnail(tmpFullPath, tmpCategory, tmpWidth, tmpHeight, tmpFormat,
182
+ (pError, pBuffer) =>
183
+ {
184
+ if (pError || !pBuffer)
185
+ {
186
+ pResponse.send(404, { Success: false, Fallback: true, Error: pError ? pError.message : 'No thumbnail tools available.' });
187
+ return fNext();
188
+ }
189
+
190
+ // Cache the result
191
+ tmpSelf.thumbnailCache.put(tmpCacheKey, pBuffer, tmpFormat);
192
+
193
+ pResponse.writeHead(200,
194
+ {
195
+ 'Content-Type': `image/${tmpFormat}`,
196
+ 'Content-Length': pBuffer.length,
197
+ 'Cache-Control': 'public, max-age=86400'
198
+ });
199
+ pResponse.end(pBuffer);
200
+ return fNext();
201
+ });
202
+ }
203
+ catch (pError)
204
+ {
205
+ tmpSelf.fable.log.error(`Thumbnail error: ${pError.message}`);
206
+ pResponse.send(500, { Success: false, Error: pError.message });
207
+ return fNext();
208
+ }
209
+ });
210
+
211
+ // --- GET /api/media/probe ---
212
+ pServer.get(`${tmpPrefix}/probe`,
213
+ (pRequest, pResponse, fNext) =>
214
+ {
215
+ try
216
+ {
217
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
218
+ let tmpQuery = tmpParsedUrl.query;
219
+ let tmpRelPath = tmpSelf._sanitizePath(tmpQuery.path);
220
+
221
+ if (!tmpRelPath)
222
+ {
223
+ pResponse.send(400, { Success: false, Error: 'Invalid path.' });
224
+ return fNext();
225
+ }
226
+
227
+ let tmpFullPath = libPath.join(tmpSelf.contentPath, tmpRelPath);
228
+ if (!libFs.existsSync(tmpFullPath))
229
+ {
230
+ pResponse.send(404, { Success: false, Error: 'File not found.' });
231
+ return fNext();
232
+ }
233
+
234
+ let tmpStat = libFs.statSync(tmpFullPath);
235
+ let tmpExtension = libPath.extname(tmpRelPath).replace('.', '').toLowerCase();
236
+ let tmpCategory = tmpSelf._getMediaCategory(tmpExtension);
237
+
238
+ let tmpProbe =
239
+ {
240
+ Success: true,
241
+ Path: tmpRelPath,
242
+ Size: tmpStat.size,
243
+ Modified: tmpStat.mtime,
244
+ Created: tmpStat.birthtime,
245
+ Extension: tmpExtension,
246
+ Category: tmpCategory
247
+ };
248
+
249
+ // Annotate with hash when path registry is available
250
+ if (tmpSelf.pathRegistry && tmpSelf.pathRegistry.isEnabled())
251
+ {
252
+ tmpProbe.Hash = tmpSelf.pathRegistry.register(tmpRelPath);
253
+ }
254
+
255
+ // Try ffprobe for video/audio metadata
256
+ if ((tmpCategory === 'video' || tmpCategory === 'audio') && tmpSelf.capabilities.ffprobe)
257
+ {
258
+ tmpSelf._ffprobe(tmpFullPath,
259
+ (pError, pMetadata) =>
260
+ {
261
+ if (!pError && pMetadata)
262
+ {
263
+ tmpProbe.Duration = pMetadata.duration;
264
+ tmpProbe.Width = pMetadata.width;
265
+ tmpProbe.Height = pMetadata.height;
266
+ tmpProbe.Codec = pMetadata.codec;
267
+ tmpProbe.Bitrate = pMetadata.bitrate;
268
+ }
269
+ pResponse.send(tmpProbe);
270
+ return fNext();
271
+ });
272
+ return;
273
+ }
274
+
275
+ // Try sharp for image metadata
276
+ if (tmpCategory === 'image' && tmpSelf.capabilities.sharp)
277
+ {
278
+ try
279
+ {
280
+ let tmpSharp = require('sharp');
281
+ tmpSharp(tmpFullPath).metadata()
282
+ .then((pMetadata) =>
283
+ {
284
+ tmpProbe.Width = pMetadata.width;
285
+ tmpProbe.Height = pMetadata.height;
286
+ tmpProbe.Format = pMetadata.format;
287
+ tmpProbe.Space = pMetadata.space;
288
+ tmpProbe.HasAlpha = pMetadata.hasAlpha;
289
+ pResponse.send(tmpProbe);
290
+ return fNext();
291
+ })
292
+ .catch(() =>
293
+ {
294
+ pResponse.send(tmpProbe);
295
+ return fNext();
296
+ });
297
+ return;
298
+ }
299
+ catch (pErr)
300
+ {
301
+ // sharp not available after all
302
+ }
303
+ }
304
+
305
+ pResponse.send(tmpProbe);
306
+ return fNext();
307
+ }
308
+ catch (pError)
309
+ {
310
+ pResponse.send(500, { Success: false, Error: pError.message });
311
+ return fNext();
312
+ }
313
+ });
314
+
315
+ // --- GET /api/media/folder-summary ---
316
+ pServer.get(`${tmpPrefix}/folder-summary`,
317
+ (pRequest, pResponse, fNext) =>
318
+ {
319
+ try
320
+ {
321
+ let tmpParsedUrl = libUrl.parse(pRequest.url, true);
322
+ let tmpQuery = tmpParsedUrl.query;
323
+ let tmpRelPath = tmpSelf._sanitizePath(tmpQuery.path || '');
324
+ let tmpDirPath = tmpRelPath
325
+ ? libPath.join(tmpSelf.contentPath, tmpRelPath)
326
+ : tmpSelf.contentPath;
327
+
328
+ if (!libFs.existsSync(tmpDirPath) || !libFs.statSync(tmpDirPath).isDirectory())
329
+ {
330
+ pResponse.send(404, { Success: false, Error: 'Directory not found.' });
331
+ return fNext();
332
+ }
333
+
334
+ let tmpEntries = libFs.readdirSync(tmpDirPath);
335
+ let tmpSummary =
336
+ {
337
+ Success: true,
338
+ Path: tmpRelPath || '',
339
+ TotalFiles: 0,
340
+ Folders: 0,
341
+ MediaFiles: 0,
342
+ Images: 0,
343
+ Videos: 0,
344
+ Audio: 0,
345
+ Documents: 0,
346
+ Other: 0
347
+ };
348
+
349
+ for (let i = 0; i < tmpEntries.length; i++)
350
+ {
351
+ let tmpEntry = tmpEntries[i];
352
+ // Skip hidden files
353
+ if (tmpEntry.startsWith('.'))
354
+ {
355
+ continue;
356
+ }
357
+
358
+ let tmpEntryPath = libPath.join(tmpDirPath, tmpEntry);
359
+ let tmpEntryStat;
360
+ try
361
+ {
362
+ tmpEntryStat = libFs.statSync(tmpEntryPath);
363
+ }
364
+ catch (pErr)
365
+ {
366
+ continue;
367
+ }
368
+
369
+ if (tmpEntryStat.isDirectory())
370
+ {
371
+ tmpSummary.Folders++;
372
+ continue;
373
+ }
374
+
375
+ tmpSummary.TotalFiles++;
376
+ let tmpExt = libPath.extname(tmpEntry).replace('.', '').toLowerCase();
377
+ let tmpCat = tmpSelf._getMediaCategory(tmpExt);
378
+
379
+ if (tmpCat === 'image') { tmpSummary.Images++; tmpSummary.MediaFiles++; }
380
+ else if (tmpCat === 'video') { tmpSummary.Videos++; tmpSummary.MediaFiles++; }
381
+ else if (tmpCat === 'audio') { tmpSummary.Audio++; tmpSummary.MediaFiles++; }
382
+ else if (tmpCat === 'document') { tmpSummary.Documents++; tmpSummary.MediaFiles++; }
383
+ else { tmpSummary.Other++; }
384
+ }
385
+
386
+ tmpSummary.HasThumbnailableContent = (tmpSummary.Images > 0 || tmpSummary.Videos > 0);
387
+
388
+ pResponse.send(tmpSummary);
389
+ return fNext();
390
+ }
391
+ catch (pError)
392
+ {
393
+ pResponse.send(500, { Success: false, Error: pError.message });
394
+ return fNext();
395
+ }
396
+ });
397
+ }
398
+
399
+ /**
400
+ * Generate a thumbnail for the given file.
401
+ *
402
+ * @param {string} pFullPath - Absolute path to source file
403
+ * @param {string} pCategory - 'image', 'video', etc.
404
+ * @param {number} pWidth - Target width
405
+ * @param {number} pHeight - Target height
406
+ * @param {string} pFormat - Output format ('webp', 'jpg', 'png')
407
+ * @param {Function} fCallback - Callback(pError, pBuffer)
408
+ */
409
+ _generateThumbnail(pFullPath, pCategory, pWidth, pHeight, pFormat, fCallback)
410
+ {
411
+ if (pCategory === 'image')
412
+ {
413
+ return this._generateImageThumbnail(pFullPath, pWidth, pHeight, pFormat, fCallback);
414
+ }
415
+
416
+ if (pCategory === 'video' && this.capabilities.ffmpeg)
417
+ {
418
+ return this._generateVideoThumbnail(pFullPath, pWidth, pHeight, pFormat, fCallback);
419
+ }
420
+
421
+ // No thumbnail generation available for this type
422
+ return fCallback(new Error('No thumbnail strategy for this file type.'));
423
+ }
424
+
425
+ /**
426
+ * Generate an image thumbnail using sharp or ImageMagick.
427
+ */
428
+ _generateImageThumbnail(pFullPath, pWidth, pHeight, pFormat, fCallback)
429
+ {
430
+ // Try sharp first
431
+ if (this.capabilities.sharp)
432
+ {
433
+ try
434
+ {
435
+ let tmpSharp = require('sharp');
436
+ tmpSharp(pFullPath)
437
+ .resize(pWidth, pHeight, { fit: 'inside', withoutEnlargement: true })
438
+ .toFormat(pFormat === 'webp' ? 'webp' : 'jpeg', { quality: 80 })
439
+ .toBuffer()
440
+ .then((pBuffer) => fCallback(null, pBuffer))
441
+ .catch((pError) => fCallback(pError));
442
+ return;
443
+ }
444
+ catch (pError)
445
+ {
446
+ // Fall through to ImageMagick
447
+ }
448
+ }
449
+
450
+ // Try ImageMagick
451
+ if (this.capabilities.imagemagick)
452
+ {
453
+ try
454
+ {
455
+ let tmpOutputFormat = pFormat === 'webp' ? 'webp' : 'jpeg';
456
+ let tmpCmd = `convert "${pFullPath}" -thumbnail ${pWidth}x${pHeight} -auto-orient ${tmpOutputFormat}:-`;
457
+ let tmpBuffer = libChildProcess.execSync(tmpCmd, { maxBuffer: 10 * 1024 * 1024, timeout: 15000 });
458
+ return fCallback(null, tmpBuffer);
459
+ }
460
+ catch (pError)
461
+ {
462
+ return fCallback(pError);
463
+ }
464
+ }
465
+
466
+ return fCallback(new Error('No image thumbnail tools available.'));
467
+ }
468
+
469
+ /**
470
+ * Generate a video thumbnail by extracting a frame with ffmpeg.
471
+ */
472
+ _generateVideoThumbnail(pFullPath, pWidth, pHeight, pFormat, fCallback)
473
+ {
474
+ try
475
+ {
476
+ let tmpOutputFormat = pFormat === 'webp' ? 'webp' : 'mjpeg';
477
+ // Extract a frame at 10% into the video
478
+ let tmpCmd = `ffmpeg -ss 00:00:02 -i "${pFullPath}" -vframes 1 -vf "scale=${pWidth}:${pHeight}:force_original_aspect_ratio=decrease" -f image2 -c:v ${tmpOutputFormat} pipe:1`;
479
+ let tmpBuffer = libChildProcess.execSync(tmpCmd, { maxBuffer: 10 * 1024 * 1024, timeout: 30000 });
480
+ return fCallback(null, tmpBuffer);
481
+ }
482
+ catch (pError)
483
+ {
484
+ return fCallback(pError);
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Run ffprobe and parse the output.
490
+ */
491
+ _ffprobe(pFullPath, fCallback)
492
+ {
493
+ try
494
+ {
495
+ let tmpCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${pFullPath}"`;
496
+ let tmpOutput = libChildProcess.execSync(tmpCmd, { maxBuffer: 1024 * 1024, timeout: 10000 });
497
+ let tmpData = JSON.parse(tmpOutput.toString());
498
+
499
+ let tmpResult = {};
500
+
501
+ if (tmpData.format)
502
+ {
503
+ tmpResult.duration = parseFloat(tmpData.format.duration) || null;
504
+ tmpResult.bitrate = parseInt(tmpData.format.bit_rate, 10) || null;
505
+ }
506
+
507
+ // Find video stream for dimensions
508
+ if (tmpData.streams)
509
+ {
510
+ for (let i = 0; i < tmpData.streams.length; i++)
511
+ {
512
+ let tmpStream = tmpData.streams[i];
513
+ if (tmpStream.codec_type === 'video')
514
+ {
515
+ tmpResult.width = tmpStream.width;
516
+ tmpResult.height = tmpStream.height;
517
+ tmpResult.codec = tmpStream.codec_name;
518
+ break;
519
+ }
520
+ if (tmpStream.codec_type === 'audio' && !tmpResult.codec)
521
+ {
522
+ tmpResult.codec = tmpStream.codec_name;
523
+ }
524
+ }
525
+ }
526
+
527
+ return fCallback(null, tmpResult);
528
+ }
529
+ catch (pError)
530
+ {
531
+ return fCallback(pError);
532
+ }
533
+ }
534
+ }
535
+
536
+ module.exports = RetoldRemoteMediaService;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Retold Remote -- Path Registry
3
+ *
4
+ * Maps short deterministic hashes (10-char hex, SHA-256 truncated)
5
+ * to full relative paths. Hashes are deterministic so they survive
6
+ * server restarts without persistence. The registry is populated
7
+ * on-demand as directories are listed.
8
+ *
9
+ * @license MIT
10
+ */
11
+ const libCrypto = require('crypto');
12
+ const libFableServiceProviderBase = require('fable-serviceproviderbase');
13
+
14
+ const _HASH_LENGTH = 10;
15
+
16
+ class RetoldRemotePathRegistry extends libFableServiceProviderBase
17
+ {
18
+ constructor(pFable, pOptions, pServiceHash)
19
+ {
20
+ super(pFable, pOptions, pServiceHash);
21
+
22
+ this.serviceType = 'RetoldRemotePathRegistry';
23
+
24
+ this._enabled = !!(pOptions && pOptions.Enabled);
25
+
26
+ // hash -> relative path
27
+ this._hashToPath = new Map();
28
+ // relative path -> hash
29
+ this._pathToHash = new Map();
30
+ }
31
+
32
+ /**
33
+ * Whether hashed filenames mode is active.
34
+ *
35
+ * @returns {boolean}
36
+ */
37
+ isEnabled()
38
+ {
39
+ return this._enabled;
40
+ }
41
+
42
+ /**
43
+ * Compute a deterministic 10-char hex hash for a relative path.
44
+ *
45
+ * @param {string} pRelativePath
46
+ * @returns {string} 10-character lowercase hex string
47
+ */
48
+ hashPath(pRelativePath)
49
+ {
50
+ let tmpNormalized = (pRelativePath || '').replace(/\\/g, '/').replace(/\/+$/, '');
51
+ return libCrypto
52
+ .createHash('sha256')
53
+ .update(tmpNormalized)
54
+ .digest('hex')
55
+ .substring(0, _HASH_LENGTH);
56
+ }
57
+
58
+ /**
59
+ * Register a relative path and return its hash.
60
+ *
61
+ * @param {string} pRelativePath
62
+ * @returns {string} 10-char hex hash
63
+ */
64
+ register(pRelativePath)
65
+ {
66
+ let tmpHash = this.hashPath(pRelativePath);
67
+ this._hashToPath.set(tmpHash, pRelativePath);
68
+ this._pathToHash.set(pRelativePath, tmpHash);
69
+ return tmpHash;
70
+ }
71
+
72
+ /**
73
+ * Resolve a hash back to its relative path.
74
+ *
75
+ * @param {string} pHash
76
+ * @returns {string|null}
77
+ */
78
+ resolve(pHash)
79
+ {
80
+ return this._hashToPath.get(pHash) || null;
81
+ }
82
+
83
+ /**
84
+ * Get the hash for a previously registered path.
85
+ *
86
+ * @param {string} pRelativePath
87
+ * @returns {string|null}
88
+ */
89
+ getHash(pRelativePath)
90
+ {
91
+ return this._pathToHash.get(pRelativePath) || null;
92
+ }
93
+
94
+ /**
95
+ * Annotate an array of file list entries with Hash fields.
96
+ * Each entry is expected to have a Path property.
97
+ *
98
+ * @param {Array} pFileList
99
+ * @returns {Array} Same array with Hash fields added
100
+ */
101
+ annotateFileList(pFileList)
102
+ {
103
+ if (!Array.isArray(pFileList))
104
+ {
105
+ return pFileList;
106
+ }
107
+
108
+ for (let i = 0; i < pFileList.length; i++)
109
+ {
110
+ let tmpEntry = pFileList[i];
111
+ if (tmpEntry && tmpEntry.Path)
112
+ {
113
+ tmpEntry.Hash = this.register(tmpEntry.Path);
114
+ }
115
+ }
116
+
117
+ return pFileList;
118
+ }
119
+ }
120
+
121
+ module.exports = RetoldRemotePathRegistry;