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.
@@ -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;