retold-remote 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/css/retold-remote.css +3 -0
- package/html/index.html +1 -1
- package/package.json +1 -1
- package/source/Pict-Application-RetoldRemote.js +21 -2
- package/source/cli/RetoldRemote-Server-Setup.js +129 -0
- package/source/providers/Pict-Provider-AISortManager.js +456 -0
- package/source/providers/Pict-Provider-CollectionManager.js +266 -0
- package/source/server/RetoldRemote-AISortService.js +879 -0
- package/source/server/RetoldRemote-CollectionService.js +161 -2
- package/source/server/RetoldRemote-FileOperationService.js +560 -0
- package/source/server/RetoldRemote-MediaService.js +12 -0
- package/source/server/RetoldRemote-MetadataCache.js +411 -0
- package/source/views/PictView-Remote-CollectionsPanel.js +435 -36
- package/source/views/PictView-Remote-Layout.js +2 -0
- package/source/views/PictView-Remote-SettingsPanel.js +156 -0
- package/source/views/PictView-Remote-TopBar.js +86 -0
- package/web-application/css/retold-remote.css +3 -0
- package/web-application/index.html +1 -1
- package/web-application/retold-remote.js +402 -34
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +32 -15
- package/web-application/retold-remote.min.js.map +1 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retold Remote -- Metadata Cache
|
|
3
|
+
*
|
|
4
|
+
* Wraps ffprobe calls with a Parime BinaryStorage cache layer so that
|
|
5
|
+
* file metadata (duration, codec, dimensions, ID3/format tags, etc.)
|
|
6
|
+
* is extracted once and served from cache on subsequent requests.
|
|
7
|
+
*
|
|
8
|
+
* Cache key: SHA-256 of "metadata:{relativePath}:{mtimeMs}" truncated
|
|
9
|
+
* to 16 hex chars. Invalidation is mtime-based -- if the source file
|
|
10
|
+
* is modified, the stale entry is automatically bypassed and replaced.
|
|
11
|
+
*
|
|
12
|
+
* Storage category: "metadata" in ParimeBinaryStorage.
|
|
13
|
+
*
|
|
14
|
+
* API:
|
|
15
|
+
* getMetadata(pRelPath, fCallback)
|
|
16
|
+
* -> { Path, FileSize, Modified, Category, Extension, Duration, ... Tags, Video, Audio }
|
|
17
|
+
*
|
|
18
|
+
* getMetadataBatch(pRelPaths, fCallback)
|
|
19
|
+
* -> [ metadata, metadata, ... ]
|
|
20
|
+
*
|
|
21
|
+
* invalidate(pRelPath, fCallback)
|
|
22
|
+
* -> removes cached entry
|
|
23
|
+
*
|
|
24
|
+
* @license MIT
|
|
25
|
+
*/
|
|
26
|
+
const libFableServiceProviderBase = require('fable-serviceproviderbase');
|
|
27
|
+
const libFs = require('fs');
|
|
28
|
+
const libPath = require('path');
|
|
29
|
+
const libCrypto = require('crypto');
|
|
30
|
+
const libChildProcess = require('child_process');
|
|
31
|
+
|
|
32
|
+
const libExtensionMaps = require('../RetoldRemote-ExtensionMaps.js');
|
|
33
|
+
|
|
34
|
+
const CACHE_CATEGORY = 'metadata';
|
|
35
|
+
|
|
36
|
+
const _DefaultServiceConfiguration =
|
|
37
|
+
{
|
|
38
|
+
"ContentPath": "."
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
class RetoldRemoteMetadataCache extends libFableServiceProviderBase
|
|
42
|
+
{
|
|
43
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
44
|
+
{
|
|
45
|
+
super(pFable, pOptions, pServiceHash);
|
|
46
|
+
|
|
47
|
+
this.serviceType = 'RetoldRemoteMetadataCache';
|
|
48
|
+
|
|
49
|
+
// Merge with defaults
|
|
50
|
+
for (let tmpKey in _DefaultServiceConfiguration)
|
|
51
|
+
{
|
|
52
|
+
if (!(tmpKey in this.options))
|
|
53
|
+
{
|
|
54
|
+
this.options[tmpKey] = _DefaultServiceConfiguration[tmpKey];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.contentPath = libPath.resolve(this.options.ContentPath);
|
|
59
|
+
|
|
60
|
+
// Detect ffprobe availability
|
|
61
|
+
this.hasFfprobe = this._detectCommand('ffprobe -version');
|
|
62
|
+
|
|
63
|
+
this.fable.log.info(`Metadata Cache: using ParimeBinaryStorage (category: ${CACHE_CATEGORY})`);
|
|
64
|
+
this.fable.log.info(` ffprobe: ${this.hasFfprobe ? 'available' : 'not found'}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if a command-line tool is available.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} pCommand
|
|
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
|
+
* Build a cache key from a relative path and modification time.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} pRelPath - Relative file path
|
|
90
|
+
* @param {number} pMtimeMs - Modification time in milliseconds
|
|
91
|
+
* @returns {string} 16-char hex hash
|
|
92
|
+
*/
|
|
93
|
+
_buildCacheKey(pRelPath, pMtimeMs)
|
|
94
|
+
{
|
|
95
|
+
let tmpInput = `metadata:${pRelPath}:${pMtimeMs}`;
|
|
96
|
+
return libCrypto.createHash('sha256').update(tmpInput).digest('hex').substring(0, 16);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get metadata for a file. Returns cached data if available and
|
|
101
|
+
* the file has not been modified; otherwise runs ffprobe and caches
|
|
102
|
+
* the result.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} pRelPath - Path relative to the content root
|
|
105
|
+
* @param {function} fCallback - Callback(pError, pMetadata)
|
|
106
|
+
*/
|
|
107
|
+
getMetadata(pRelPath, fCallback)
|
|
108
|
+
{
|
|
109
|
+
let tmpSelf = this;
|
|
110
|
+
|
|
111
|
+
try
|
|
112
|
+
{
|
|
113
|
+
let tmpAbsPath = libPath.join(this.contentPath, pRelPath);
|
|
114
|
+
|
|
115
|
+
if (!libFs.existsSync(tmpAbsPath))
|
|
116
|
+
{
|
|
117
|
+
return fCallback(new Error('File not found: ' + pRelPath));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let tmpStat = libFs.statSync(tmpAbsPath);
|
|
121
|
+
let tmpCacheKey = this._buildCacheKey(pRelPath, tmpStat.mtimeMs);
|
|
122
|
+
|
|
123
|
+
// Try cache first
|
|
124
|
+
this.fable.ParimeBinaryStorage.read(CACHE_CATEGORY, tmpCacheKey,
|
|
125
|
+
(pReadError, pBuffer) =>
|
|
126
|
+
{
|
|
127
|
+
if (!pReadError && pBuffer && pBuffer.length > 0)
|
|
128
|
+
{
|
|
129
|
+
try
|
|
130
|
+
{
|
|
131
|
+
let tmpCached = JSON.parse(pBuffer.toString());
|
|
132
|
+
return fCallback(null, tmpCached);
|
|
133
|
+
}
|
|
134
|
+
catch (pParseError)
|
|
135
|
+
{
|
|
136
|
+
// Corrupted cache entry; fall through to re-probe
|
|
137
|
+
tmpSelf.fable.log.warn(`Metadata cache parse error for ${pRelPath}: ${pParseError.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Cache miss -- probe and cache
|
|
142
|
+
tmpSelf._probeAndCache(pRelPath, tmpAbsPath, tmpStat, tmpCacheKey, fCallback);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (pError)
|
|
146
|
+
{
|
|
147
|
+
return fCallback(pError);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get metadata for multiple files. Processes sequentially to avoid
|
|
153
|
+
* overwhelming ffprobe on large folders.
|
|
154
|
+
*
|
|
155
|
+
* @param {Array<string>} pRelPaths - Array of relative paths
|
|
156
|
+
* @param {function} fCallback - Callback(pError, pMetadataArray)
|
|
157
|
+
*/
|
|
158
|
+
getMetadataBatch(pRelPaths, fCallback)
|
|
159
|
+
{
|
|
160
|
+
let tmpSelf = this;
|
|
161
|
+
let tmpResults = [];
|
|
162
|
+
let tmpIndex = 0;
|
|
163
|
+
|
|
164
|
+
function _next()
|
|
165
|
+
{
|
|
166
|
+
if (tmpIndex >= pRelPaths.length)
|
|
167
|
+
{
|
|
168
|
+
return fCallback(null, tmpResults);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let tmpRelPath = pRelPaths[tmpIndex];
|
|
172
|
+
tmpIndex++;
|
|
173
|
+
|
|
174
|
+
tmpSelf.getMetadata(tmpRelPath,
|
|
175
|
+
(pError, pMetadata) =>
|
|
176
|
+
{
|
|
177
|
+
if (pError)
|
|
178
|
+
{
|
|
179
|
+
// Include error but continue processing
|
|
180
|
+
tmpResults.push(
|
|
181
|
+
{
|
|
182
|
+
Path: tmpRelPath,
|
|
183
|
+
Success: false,
|
|
184
|
+
Error: pError.message
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
else
|
|
188
|
+
{
|
|
189
|
+
tmpResults.push(pMetadata);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_next();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_next();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Remove a cached metadata entry.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} pRelPath - Path relative to the content root
|
|
203
|
+
* @param {function} fCallback - Callback(pError)
|
|
204
|
+
*/
|
|
205
|
+
invalidate(pRelPath, fCallback)
|
|
206
|
+
{
|
|
207
|
+
try
|
|
208
|
+
{
|
|
209
|
+
let tmpAbsPath = libPath.join(this.contentPath, pRelPath);
|
|
210
|
+
let tmpStat = libFs.statSync(tmpAbsPath);
|
|
211
|
+
let tmpCacheKey = this._buildCacheKey(pRelPath, tmpStat.mtimeMs);
|
|
212
|
+
|
|
213
|
+
this.fable.ParimeBinaryStorage.delete(CACHE_CATEGORY, tmpCacheKey, fCallback);
|
|
214
|
+
}
|
|
215
|
+
catch (pError)
|
|
216
|
+
{
|
|
217
|
+
return fCallback(pError);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Run ffprobe on a file, build the metadata record, cache it,
|
|
223
|
+
* and return it via callback.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} pRelPath - Relative path
|
|
226
|
+
* @param {string} pAbsPath - Absolute path
|
|
227
|
+
* @param {object} pStat - fs.Stats object
|
|
228
|
+
* @param {string} pCacheKey - Pre-computed cache key
|
|
229
|
+
* @param {function} fCallback - Callback(pError, pMetadata)
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
_probeAndCache(pRelPath, pAbsPath, pStat, pCacheKey, fCallback)
|
|
233
|
+
{
|
|
234
|
+
let tmpSelf = this;
|
|
235
|
+
let tmpExtension = libPath.extname(pRelPath).replace('.', '').toLowerCase();
|
|
236
|
+
let tmpCategory = libExtensionMaps.getCategory(tmpExtension);
|
|
237
|
+
|
|
238
|
+
// Build base metadata from stat
|
|
239
|
+
let tmpMetadata =
|
|
240
|
+
{
|
|
241
|
+
Success: true,
|
|
242
|
+
Path: pRelPath,
|
|
243
|
+
FileSize: pStat.size,
|
|
244
|
+
Modified: pStat.mtime.toISOString(),
|
|
245
|
+
ModifiedMs: pStat.mtimeMs,
|
|
246
|
+
Category: tmpCategory,
|
|
247
|
+
Extension: tmpExtension,
|
|
248
|
+
|
|
249
|
+
// Format-level (populated by ffprobe)
|
|
250
|
+
FormatName: null,
|
|
251
|
+
Duration: null,
|
|
252
|
+
Bitrate: null,
|
|
253
|
+
|
|
254
|
+
// Tags (populated by ffprobe)
|
|
255
|
+
Tags: {},
|
|
256
|
+
|
|
257
|
+
// Video stream (null if absent)
|
|
258
|
+
Video: null,
|
|
259
|
+
|
|
260
|
+
// Audio stream (null if absent)
|
|
261
|
+
Audio: null,
|
|
262
|
+
|
|
263
|
+
// Timestamp
|
|
264
|
+
CachedAt: new Date().toISOString()
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Only probe video/audio files with ffprobe
|
|
268
|
+
if ((tmpCategory === 'video' || tmpCategory === 'audio') && this.hasFfprobe)
|
|
269
|
+
{
|
|
270
|
+
this._probe(pAbsPath,
|
|
271
|
+
(pProbeError, pProbeData) =>
|
|
272
|
+
{
|
|
273
|
+
if (!pProbeError && pProbeData)
|
|
274
|
+
{
|
|
275
|
+
tmpMetadata.FormatName = pProbeData.formatName;
|
|
276
|
+
tmpMetadata.Duration = pProbeData.duration;
|
|
277
|
+
tmpMetadata.Bitrate = pProbeData.bitrate;
|
|
278
|
+
tmpMetadata.Tags = pProbeData.tags || {};
|
|
279
|
+
tmpMetadata.Video = pProbeData.video;
|
|
280
|
+
tmpMetadata.Audio = pProbeData.audio;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Cache even if probe failed (the stat-only data is still useful)
|
|
284
|
+
tmpSelf._writeCache(pCacheKey, tmpMetadata, fCallback);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
else
|
|
288
|
+
{
|
|
289
|
+
// Non-probeable file; cache basic stat data
|
|
290
|
+
this._writeCache(pCacheKey, tmpMetadata, fCallback);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Write a metadata record to the cache.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} pCacheKey
|
|
298
|
+
* @param {object} pMetadata
|
|
299
|
+
* @param {function} fCallback - Callback(pError, pMetadata)
|
|
300
|
+
* @private
|
|
301
|
+
*/
|
|
302
|
+
_writeCache(pCacheKey, pMetadata, fCallback)
|
|
303
|
+
{
|
|
304
|
+
let tmpBuffer = Buffer.from(JSON.stringify(pMetadata));
|
|
305
|
+
|
|
306
|
+
this.fable.ParimeBinaryStorage.write(CACHE_CATEGORY, pCacheKey, tmpBuffer,
|
|
307
|
+
(pWriteError) =>
|
|
308
|
+
{
|
|
309
|
+
if (pWriteError)
|
|
310
|
+
{
|
|
311
|
+
this.fable.log.warn(`Metadata cache write error: ${pWriteError.message}`);
|
|
312
|
+
}
|
|
313
|
+
// Return metadata regardless of cache write success
|
|
314
|
+
return fCallback(null, pMetadata);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Run ffprobe and parse the full output including format tags and
|
|
320
|
+
* all stream details.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} pAbsPath - Absolute path to the file
|
|
323
|
+
* @param {function} fCallback - Callback(pError, pResult)
|
|
324
|
+
* @private
|
|
325
|
+
*/
|
|
326
|
+
_probe(pAbsPath, fCallback)
|
|
327
|
+
{
|
|
328
|
+
try
|
|
329
|
+
{
|
|
330
|
+
let tmpCmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${pAbsPath}"`;
|
|
331
|
+
let tmpOutput = libChildProcess.execSync(tmpCmd, { maxBuffer: 1024 * 1024, timeout: 15000 });
|
|
332
|
+
let tmpData = JSON.parse(tmpOutput.toString());
|
|
333
|
+
|
|
334
|
+
let tmpResult =
|
|
335
|
+
{
|
|
336
|
+
formatName: null,
|
|
337
|
+
duration: null,
|
|
338
|
+
bitrate: null,
|
|
339
|
+
tags: {},
|
|
340
|
+
video: null,
|
|
341
|
+
audio: null
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Parse format section
|
|
345
|
+
if (tmpData.format)
|
|
346
|
+
{
|
|
347
|
+
tmpResult.formatName = tmpData.format.format_name || null;
|
|
348
|
+
tmpResult.duration = parseFloat(tmpData.format.duration) || null;
|
|
349
|
+
tmpResult.bitrate = parseInt(tmpData.format.bit_rate, 10) || null;
|
|
350
|
+
|
|
351
|
+
// Extract format-level tags (ID3, Vorbis comments, etc.)
|
|
352
|
+
if (tmpData.format.tags)
|
|
353
|
+
{
|
|
354
|
+
let tmpTagKeys = Object.keys(tmpData.format.tags);
|
|
355
|
+
for (let t = 0; t < tmpTagKeys.length; t++)
|
|
356
|
+
{
|
|
357
|
+
tmpResult.tags[tmpTagKeys[t].toLowerCase()] = tmpData.format.tags[tmpTagKeys[t]];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Parse streams
|
|
363
|
+
if (tmpData.streams)
|
|
364
|
+
{
|
|
365
|
+
for (let i = 0; i < tmpData.streams.length; i++)
|
|
366
|
+
{
|
|
367
|
+
let tmpStream = tmpData.streams[i];
|
|
368
|
+
|
|
369
|
+
if (tmpStream.codec_type === 'video' && !tmpResult.video)
|
|
370
|
+
{
|
|
371
|
+
// Skip attached pictures (album art in MP3s, etc.)
|
|
372
|
+
if (tmpStream.disposition && tmpStream.disposition.attached_pic)
|
|
373
|
+
{
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
tmpResult.video =
|
|
378
|
+
{
|
|
379
|
+
Codec: tmpStream.codec_name || null,
|
|
380
|
+
Width: tmpStream.width || null,
|
|
381
|
+
Height: tmpStream.height || null,
|
|
382
|
+
FrameRate: tmpStream.r_frame_rate || tmpStream.avg_frame_rate || null,
|
|
383
|
+
PixelFormat: tmpStream.pix_fmt || null,
|
|
384
|
+
Bitrate: parseInt(tmpStream.bit_rate, 10) || null,
|
|
385
|
+
Level: tmpStream.level || null
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
else if (tmpStream.codec_type === 'audio' && !tmpResult.audio)
|
|
389
|
+
{
|
|
390
|
+
tmpResult.audio =
|
|
391
|
+
{
|
|
392
|
+
Codec: tmpStream.codec_name || null,
|
|
393
|
+
SampleRate: parseInt(tmpStream.sample_rate, 10) || null,
|
|
394
|
+
Channels: tmpStream.channels || null,
|
|
395
|
+
ChannelLayout: tmpStream.channel_layout || null,
|
|
396
|
+
Bitrate: parseInt(tmpStream.bit_rate, 10) || null
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return fCallback(null, tmpResult);
|
|
403
|
+
}
|
|
404
|
+
catch (pError)
|
|
405
|
+
{
|
|
406
|
+
return fCallback(pError);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
module.exports = RetoldRemoteMetadataCache;
|