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.
- package/LICENSE +21 -0
- package/css/retold-remote.css +83 -0
- package/html/codejar.js +511 -0
- package/html/index.html +23 -0
- package/package.json +68 -0
- package/server.js +43 -0
- package/source/Pict-Application-RetoldRemote-Configuration.json +7 -0
- package/source/Pict-Application-RetoldRemote.js +622 -0
- package/source/Pict-RetoldRemote-Bundle.js +14 -0
- package/source/cli/RetoldRemote-CLI-Program.js +15 -0
- package/source/cli/RetoldRemote-CLI-Run.js +3 -0
- package/source/cli/RetoldRemote-Server-Setup.js +257 -0
- package/source/cli/commands/RetoldRemote-Command-Serve.js +87 -0
- package/source/providers/Pict-Provider-GalleryFilterSort.js +597 -0
- package/source/providers/Pict-Provider-GalleryNavigation.js +819 -0
- package/source/providers/Pict-Provider-RetoldRemote.js +273 -0
- package/source/providers/Pict-Provider-RetoldRemoteIcons.js +640 -0
- package/source/providers/Pict-Provider-RetoldRemoteTheme.js +879 -0
- package/source/server/RetoldRemote-MediaService.js +536 -0
- package/source/server/RetoldRemote-PathRegistry.js +121 -0
- package/source/server/RetoldRemote-ThumbnailCache.js +89 -0
- package/source/server/RetoldRemote-ToolDetector.js +78 -0
- package/source/views/PictView-Remote-Gallery.js +1437 -0
- package/source/views/PictView-Remote-ImageViewer.js +363 -0
- package/source/views/PictView-Remote-Layout.js +420 -0
- package/source/views/PictView-Remote-MediaViewer.js +530 -0
- package/source/views/PictView-Remote-SettingsPanel.js +318 -0
- package/source/views/PictView-Remote-TopBar.js +206 -0
- package/web-application/codejar.js +511 -0
- package/web-application/css/retold-remote.css +83 -0
- package/web-application/index.html +23 -0
- package/web-application/js/pict.min.js +12 -0
- package/web-application/js/pict.min.js.map +1 -0
- package/web-application/retold-remote.compatible.js +5764 -0
- package/web-application/retold-remote.compatible.js.map +1 -0
- package/web-application/retold-remote.compatible.min.js +120 -0
- package/web-application/retold-remote.compatible.min.js.map +1 -0
- package/web-application/retold-remote.js +5763 -0
- package/web-application/retold-remote.js.map +1 -0
- package/web-application/retold-remote.min.js +120 -0
- 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;
|