@apocaliss92/scrypted-reolink-native 0.1.32 → 0.1.33
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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +3 -2
- package/src/baichuan-base.ts +35 -7
- package/src/camera-battery.ts +0 -6
- package/src/camera.ts +1 -36
- package/src/common.ts +514 -17
- package/src/debug-options.ts +4 -0
- package/src/main.ts +158 -4
- package/src/multiFocal.ts +1 -29
- package/src/nvr.ts +1 -3
- package/src/stream-utils.ts +24 -7
- package/src/utils.ts +471 -2
- package/logs/composite-stream.txt +0 -16390
- package/logs/lense.txt +0 -44
- package/logs/multifocal.txt +0 -136
- package/logs/multifocal2.txt +0 -3585
package/src/utils.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import type { DeviceCapabilities, ReolinkDeviceInfo } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
-
import sdk, {
|
|
1
|
+
import type { DeviceCapabilities, EnrichedRecordingFile, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
+
import sdk, { DeviceBase, HttpRequest, HttpResponse, MediaObject, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, VideoClip, VideoClips } from "@scrypted/sdk";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import crypto from "crypto";
|
|
7
|
+
import { CommonCameraMixin } from "./common";
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Enumeration of operation types that may require specific channel assignments
|
|
@@ -112,4 +117,468 @@ export const updateDeviceInfo = async (props: {
|
|
|
112
117
|
logger.log(`Device info updated`);
|
|
113
118
|
logger.debug(`${JSON.stringify({ newInfo: device.info, deviceData })}`);
|
|
114
119
|
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Convert a Reolink RecordingFile or EnrichedRecordingFile to a Scrypted VideoClip
|
|
124
|
+
*/
|
|
125
|
+
export async function recordingFileToVideoClip(
|
|
126
|
+
rec: RecordingFile | EnrichedRecordingFile,
|
|
127
|
+
options: {
|
|
128
|
+
/** Fallback start date if recording doesn't have one */
|
|
129
|
+
fallbackStart: Date;
|
|
130
|
+
/** API instance to get playback URLs (optional, can provide videoHref directly) */
|
|
131
|
+
api?: ReolinkBaichuanApi;
|
|
132
|
+
/** Pre-fetched video URL (optional, will fetch if not provided and api is available) */
|
|
133
|
+
videoHref?: string;
|
|
134
|
+
/** Logger for debug messages */
|
|
135
|
+
logger?: Console;
|
|
136
|
+
/** Plugin instance for generating webhook URLs */
|
|
137
|
+
plugin?: ScryptedDeviceBase;
|
|
138
|
+
/** Device ID for webhook URLs */
|
|
139
|
+
deviceId?: string;
|
|
140
|
+
/** Use webhook URLs instead of direct RTMP URLs */
|
|
141
|
+
useWebhook?: boolean;
|
|
142
|
+
}
|
|
143
|
+
): Promise<VideoClip> {
|
|
144
|
+
const { fallbackStart, api, videoHref: providedVideoHref, logger, plugin, deviceId, useWebhook } = options;
|
|
145
|
+
|
|
146
|
+
// Handle both RecordingFile (has startTime/endTime as Date) and EnrichedRecordingFile (has startTimeMs/endTimeMs as number)
|
|
147
|
+
let recStart: Date;
|
|
148
|
+
let recEnd: Date;
|
|
149
|
+
|
|
150
|
+
if ('startTime' in rec && rec.startTime instanceof Date) {
|
|
151
|
+
recStart = rec.startTime;
|
|
152
|
+
} else if ('startTimeMs' in rec && typeof rec.startTimeMs === 'number') {
|
|
153
|
+
recStart = new Date(rec.startTimeMs);
|
|
154
|
+
} else {
|
|
155
|
+
recStart = rec.parsedFileName?.start ?? fallbackStart;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if ('endTime' in rec && rec.endTime instanceof Date) {
|
|
159
|
+
recEnd = rec.endTime;
|
|
160
|
+
} else if ('endTimeMs' in rec && typeof rec.endTimeMs === 'number') {
|
|
161
|
+
recEnd = new Date(rec.endTimeMs);
|
|
162
|
+
} else {
|
|
163
|
+
recEnd = rec.parsedFileName?.end ?? recStart;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const recStartMs = recStart.getTime();
|
|
167
|
+
const recEndMs = Math.max(recEnd.getTime(), recStartMs);
|
|
168
|
+
const duration = recEndMs - recStartMs;
|
|
169
|
+
|
|
170
|
+
const id = rec.id || rec.fileName;
|
|
171
|
+
|
|
172
|
+
// Get video URL if not provided
|
|
173
|
+
let videoHref: string | undefined = providedVideoHref;
|
|
174
|
+
let thumbnailHref: string | undefined;
|
|
175
|
+
|
|
176
|
+
// If webhook is enabled, generate webhook URLs
|
|
177
|
+
if (useWebhook && plugin && deviceId) {
|
|
178
|
+
try {
|
|
179
|
+
const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
|
|
180
|
+
deviceId,
|
|
181
|
+
fileId: id,
|
|
182
|
+
plugin,
|
|
183
|
+
});
|
|
184
|
+
videoHref = videoUrl;
|
|
185
|
+
thumbnailHref = thumbnailUrl;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
logger?.error('recordingFileToVideoClip: failed to generate webhook URLs', e);
|
|
188
|
+
}
|
|
189
|
+
} else if (!videoHref && api) {
|
|
190
|
+
// Fallback to direct RTMP URL if webhook is not used
|
|
191
|
+
try {
|
|
192
|
+
const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
|
|
193
|
+
fileName: rec.fileName,
|
|
194
|
+
});
|
|
195
|
+
videoHref = rtmpVodUrl;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
logger?.debug('recordingFileToVideoClip: failed to build playback URL for recording', rec.fileName, e);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const description = ('name' in rec && typeof rec.name === 'string' && rec.name) ? rec.name : (rec.fileName ?? rec.id ?? '');
|
|
202
|
+
|
|
203
|
+
// Build detectionClasses from flags or recordType
|
|
204
|
+
const detectionClasses: string[] = ['Motion'];
|
|
205
|
+
|
|
206
|
+
// Check for EnrichedRecordingFile flags
|
|
207
|
+
if ('hasPerson' in rec && rec.hasPerson) {
|
|
208
|
+
detectionClasses.push('Person');
|
|
209
|
+
}
|
|
210
|
+
if ('hasVehicle' in rec && rec.hasVehicle) {
|
|
211
|
+
detectionClasses.push('Vehicle');
|
|
212
|
+
}
|
|
213
|
+
if ('hasAnimal' in rec && rec.hasAnimal) {
|
|
214
|
+
detectionClasses.push('Animal');
|
|
215
|
+
}
|
|
216
|
+
if ('hasFace' in rec && rec.hasFace) {
|
|
217
|
+
detectionClasses.push('Face');
|
|
218
|
+
}
|
|
219
|
+
if ('hasDoorbell' in rec && rec.hasDoorbell) {
|
|
220
|
+
detectionClasses.push('Doorbell');
|
|
221
|
+
}
|
|
222
|
+
if ('hasPackage' in rec && rec.hasPackage) {
|
|
223
|
+
detectionClasses.push('Package');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Fallback: parse recordType string if flags are not available
|
|
227
|
+
if (detectionClasses.length === 0 && rec.recordType) {
|
|
228
|
+
const recordTypeLower = rec.recordType.toLowerCase();
|
|
229
|
+
if (recordTypeLower.includes('people') || recordTypeLower.includes('person')) {
|
|
230
|
+
detectionClasses.push('Person');
|
|
231
|
+
}
|
|
232
|
+
if (recordTypeLower.includes('vehicle')) {
|
|
233
|
+
detectionClasses.push('Vehicle');
|
|
234
|
+
}
|
|
235
|
+
if (recordTypeLower.includes('dog_cat') || recordTypeLower.includes('animal')) {
|
|
236
|
+
detectionClasses.push('Animal');
|
|
237
|
+
}
|
|
238
|
+
if (recordTypeLower.includes('face')) {
|
|
239
|
+
detectionClasses.push('Face');
|
|
240
|
+
}
|
|
241
|
+
if (recordTypeLower.includes('md') || recordTypeLower.includes('motion')) {
|
|
242
|
+
detectionClasses.push('Motion');
|
|
243
|
+
}
|
|
244
|
+
if (recordTypeLower.includes('visitor') || recordTypeLower.includes('doorbell')) {
|
|
245
|
+
detectionClasses.push('Doorbell');
|
|
246
|
+
}
|
|
247
|
+
if (recordTypeLower.includes('package')) {
|
|
248
|
+
detectionClasses.push('Package');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
id,
|
|
254
|
+
startTime: recStartMs,
|
|
255
|
+
duration,
|
|
256
|
+
event: rec.recordType,
|
|
257
|
+
description,
|
|
258
|
+
detectionClasses: detectionClasses.length > 0 ? detectionClasses : undefined,
|
|
259
|
+
resources: videoHref || thumbnailHref
|
|
260
|
+
? {
|
|
261
|
+
...(videoHref ? { video: { href: videoHref } } : {}),
|
|
262
|
+
...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
|
|
263
|
+
}
|
|
264
|
+
: undefined,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Generate webhook URLs for video clips
|
|
270
|
+
*/
|
|
271
|
+
export async function getVideoClipWebhookUrls(props: {
|
|
272
|
+
deviceId: string;
|
|
273
|
+
fileId: string;
|
|
274
|
+
plugin: ScryptedDeviceBase;
|
|
275
|
+
}): Promise<{ videoUrl: string; thumbnailUrl: string }> {
|
|
276
|
+
const { deviceId, fileId, plugin } = props;
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
let endpoint: string;
|
|
280
|
+
try {
|
|
281
|
+
endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
|
|
282
|
+
} catch (e) {
|
|
283
|
+
// Fallback to local endpoint if cloud is not available (e.g., not logged in)
|
|
284
|
+
// plugin.console.debug('Cloud endpoint not available, using local endpoint', e);
|
|
285
|
+
endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const encodedDeviceId = encodeURIComponent(deviceId);
|
|
289
|
+
// Remove leading slash from fileId if present, as it causes invalid paths when encoded
|
|
290
|
+
const cleanFileId = fileId.startsWith('/') ? fileId.substring(1) : fileId;
|
|
291
|
+
const encodedFileId = encodeURIComponent(cleanFileId);
|
|
292
|
+
|
|
293
|
+
// Parse endpoint URL to extract query parameters (for authentication)
|
|
294
|
+
const endpointUrl = new URL(endpoint);
|
|
295
|
+
// Preserve query parameters (e.g., user_token for authentication)
|
|
296
|
+
const queryParams = endpointUrl.search;
|
|
297
|
+
// Remove query parameters from the base endpoint URL
|
|
298
|
+
endpointUrl.search = '';
|
|
299
|
+
|
|
300
|
+
// Ensure endpoint has trailing slash
|
|
301
|
+
const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
|
|
302
|
+
|
|
303
|
+
// Build webhook URLs and append query parameters at the end
|
|
304
|
+
const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
305
|
+
const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
306
|
+
|
|
307
|
+
return { videoUrl, thumbnailUrl };
|
|
308
|
+
} catch (e) {
|
|
309
|
+
plugin.console.error('Failed to generate webhook URLs', e);
|
|
310
|
+
throw e;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Extract a thumbnail frame from video using ffmpeg
|
|
316
|
+
*/
|
|
317
|
+
export async function extractThumbnailFromVideo(props: {
|
|
318
|
+
rtmpUrl?: string;
|
|
319
|
+
filePath?: string;
|
|
320
|
+
fileId: string;
|
|
321
|
+
deviceId: string;
|
|
322
|
+
logger: Console;
|
|
323
|
+
}): Promise<MediaObject> {
|
|
324
|
+
const { rtmpUrl, filePath, fileId, deviceId, logger } = props;
|
|
325
|
+
|
|
326
|
+
// Use file path if available, otherwise use RTMP URL
|
|
327
|
+
const inputSource = filePath || rtmpUrl;
|
|
328
|
+
if (!inputSource) {
|
|
329
|
+
throw new Error('Either rtmpUrl or filePath must be provided');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
// Get ffmpeg path
|
|
334
|
+
const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
|
|
335
|
+
|
|
336
|
+
// Build ffmpeg args to extract a frame at 2 seconds
|
|
337
|
+
const ffmpegArgs = [
|
|
338
|
+
'-ss', '2', // Seek to 2 seconds
|
|
339
|
+
'-i', inputSource,
|
|
340
|
+
'-vframes', '1', // Extract only 1 frame
|
|
341
|
+
'-q:v', '2', // High quality JPEG
|
|
342
|
+
'-f', 'image2', // Output format
|
|
343
|
+
'pipe:1', // Output to stdout
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
return new Promise<MediaObject>((resolve, reject) => {
|
|
347
|
+
const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
|
|
348
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const chunks: Buffer[] = [];
|
|
352
|
+
let errorOutput = '';
|
|
353
|
+
|
|
354
|
+
ffmpeg.stdout.on('data', (chunk: Buffer) => {
|
|
355
|
+
chunks.push(chunk);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
ffmpeg.stderr.on('data', (chunk: Buffer) => {
|
|
359
|
+
errorOutput += chunk.toString();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
let resolved = false;
|
|
363
|
+
|
|
364
|
+
ffmpeg.on('close', async (code) => {
|
|
365
|
+
if (resolved) return;
|
|
366
|
+
resolved = true;
|
|
367
|
+
|
|
368
|
+
if (code !== 0) {
|
|
369
|
+
logger.error(`[Thumbnail] Error: fileId=${fileId}`, new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
|
|
370
|
+
reject(new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const imageBuffer = Buffer.concat(chunks);
|
|
376
|
+
if (imageBuffer.length === 0) {
|
|
377
|
+
logger.error(`[Thumbnail] Error: fileId=${fileId}`, new Error('No image data received from ffmpeg'));
|
|
378
|
+
reject(new Error('No image data received from ffmpeg'));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const mo = await sdk.mediaManager.createMediaObject(imageBuffer, 'image/jpeg');
|
|
383
|
+
logger.log(`[Thumbnail] Completed: fileId=${fileId}, size=${imageBuffer.length} bytes`);
|
|
384
|
+
resolve(mo);
|
|
385
|
+
} catch (e) {
|
|
386
|
+
logger.error(`[Thumbnail] Error: fileId=${fileId}`, e);
|
|
387
|
+
reject(e);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
ffmpeg.on('error', (error) => {
|
|
392
|
+
if (resolved) return;
|
|
393
|
+
resolved = true;
|
|
394
|
+
logger.error(`[Thumbnail] Error: fileId=${fileId}`, error);
|
|
395
|
+
reject(error);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Timeout after 30 seconds
|
|
399
|
+
const timeout = setTimeout(() => {
|
|
400
|
+
if (resolved) return;
|
|
401
|
+
resolved = true;
|
|
402
|
+
try {
|
|
403
|
+
ffmpeg.kill('SIGKILL');
|
|
404
|
+
} catch (e) {
|
|
405
|
+
// Ignore
|
|
406
|
+
}
|
|
407
|
+
reject(new Error('Thumbnail extraction timeout'));
|
|
408
|
+
}, 30000);
|
|
409
|
+
|
|
410
|
+
ffmpeg.on('close', () => {
|
|
411
|
+
clearTimeout(timeout);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
} catch (e) {
|
|
415
|
+
logger.error(`[Thumbnail] Error: fileId=${fileId}`, e);
|
|
416
|
+
throw e;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Get cache file path for a video clip
|
|
422
|
+
*/
|
|
423
|
+
function getVideoClipCachePath(deviceId: string, fileId: string): string {
|
|
424
|
+
const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME || '';
|
|
425
|
+
// Create a safe filename from fileId using hash
|
|
426
|
+
const hash = crypto.createHash('md5').update(fileId).digest('hex');
|
|
427
|
+
// Keep original extension if present, otherwise use .mp4
|
|
428
|
+
const ext = fileId.includes('.') ? path.extname(fileId) : '.mp4';
|
|
429
|
+
const cacheDir = path.join(pluginVolume, 'snapshots', deviceId);
|
|
430
|
+
return path.join(cacheDir, `${hash}${ext}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Handle video clip webhook request
|
|
435
|
+
* Checks cache first, then proxies RTMP stream if not cached
|
|
436
|
+
*/
|
|
437
|
+
export async function handleVideoClipRequest(props: {
|
|
438
|
+
device: CommonCameraMixin;
|
|
439
|
+
deviceId: string;
|
|
440
|
+
fileId: string;
|
|
441
|
+
request: HttpRequest;
|
|
442
|
+
response: HttpResponse;
|
|
443
|
+
logger: Console;
|
|
444
|
+
}): Promise<void> {
|
|
445
|
+
const { device, deviceId, fileId, request, response, logger } = props;
|
|
446
|
+
|
|
447
|
+
// Check if file is cached
|
|
448
|
+
const cachePath = getVideoClipCachePath(deviceId, fileId);
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
// Check if cached file exists
|
|
452
|
+
const stat = await fs.promises.stat(cachePath);
|
|
453
|
+
const fileSize = stat.size;
|
|
454
|
+
const range = request.headers.range;
|
|
455
|
+
|
|
456
|
+
logger.log(`Serving cached video clip: fileId=${fileId}, size=${fileSize}, range=${range}`);
|
|
457
|
+
|
|
458
|
+
if (range) {
|
|
459
|
+
// Parse range header
|
|
460
|
+
const parts = range.replace(/bytes=/, "").split("-");
|
|
461
|
+
const start = parseInt(parts[0], 10);
|
|
462
|
+
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
|
463
|
+
|
|
464
|
+
const chunksize = (end - start) + 1;
|
|
465
|
+
const file = fs.createReadStream(cachePath, { start, end });
|
|
466
|
+
|
|
467
|
+
// Send stream with range support
|
|
468
|
+
response.sendStream((async function* () {
|
|
469
|
+
for await (const chunk of file) {
|
|
470
|
+
yield chunk;
|
|
471
|
+
}
|
|
472
|
+
})(), {
|
|
473
|
+
code: 206,
|
|
474
|
+
headers: {
|
|
475
|
+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
476
|
+
'Accept-Ranges': 'bytes',
|
|
477
|
+
'Content-Length': chunksize.toString(),
|
|
478
|
+
'Content-Type': 'video/mp4',
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
return;
|
|
482
|
+
} else {
|
|
483
|
+
// No range header, send full file
|
|
484
|
+
response.sendFile(cachePath, {
|
|
485
|
+
code: 200,
|
|
486
|
+
headers: {
|
|
487
|
+
'Content-Length': fileSize.toString(),
|
|
488
|
+
'Content-Type': 'video/mp4',
|
|
489
|
+
'Accept-Ranges': 'bytes',
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
} catch (e) {
|
|
495
|
+
// File not cached, need to proxy RTMP stream
|
|
496
|
+
logger.log(`Cache miss, proxying RTMP stream: fileId=${fileId}`);
|
|
497
|
+
|
|
498
|
+
// Get RTMP URL directly from API using fileId
|
|
499
|
+
// Cast device to CommonCameraMixin to access API
|
|
500
|
+
let rtmpVodUrl: string | undefined;
|
|
501
|
+
try {
|
|
502
|
+
const api = await device.ensureClient();
|
|
503
|
+
const result = await api.getRecordingPlaybackUrls({
|
|
504
|
+
fileName: fileId,
|
|
505
|
+
});
|
|
506
|
+
rtmpVodUrl = result.rtmpVodUrl;
|
|
507
|
+
} catch (e2) {
|
|
508
|
+
logger.error(`Failed to get RTMP URL from API: fileId=${fileId}`, e2);
|
|
509
|
+
response.send('Failed to get RTMP playback URL', { code: 500 });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!rtmpVodUrl) {
|
|
514
|
+
logger.error(`No RTMP URL found for video: fileId=${fileId}`);
|
|
515
|
+
response.send('No RTMP playback URL found for video', { code: 404 });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// logger.log(`Got RTMP URL for proxy: fileId=${fileId}`);
|
|
520
|
+
|
|
521
|
+
// Use ffmpeg to proxy the RTMP stream
|
|
522
|
+
const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
|
|
523
|
+
const ffmpegArgs: string[] = [
|
|
524
|
+
'-i', rtmpVodUrl,
|
|
525
|
+
'-c', 'copy', // Copy codecs without re-encoding
|
|
526
|
+
'-f', 'mp4',
|
|
527
|
+
'-movflags', 'frag_keyframe+empty_moov', // Enable streaming
|
|
528
|
+
'pipe:1', // Output to stdout
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
|
|
532
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
let ffmpegError = '';
|
|
536
|
+
ffmpeg.stderr.on('data', (chunk: Buffer) => {
|
|
537
|
+
ffmpegError += chunk.toString();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
let streamStarted = false;
|
|
541
|
+
|
|
542
|
+
// Stream the output
|
|
543
|
+
response.sendStream((async function* () {
|
|
544
|
+
try {
|
|
545
|
+
for await (const chunk of ffmpeg.stdout) {
|
|
546
|
+
if (!streamStarted) {
|
|
547
|
+
streamStarted = true;
|
|
548
|
+
}
|
|
549
|
+
yield chunk;
|
|
550
|
+
}
|
|
551
|
+
} catch (e) {
|
|
552
|
+
logger.error(`Error streaming video: fileId=${fileId}`, e);
|
|
553
|
+
throw e;
|
|
554
|
+
} finally {
|
|
555
|
+
// Clean up ffmpeg process
|
|
556
|
+
try {
|
|
557
|
+
ffmpeg.kill('SIGKILL');
|
|
558
|
+
} catch (e) {
|
|
559
|
+
// Ignore
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
})(), {
|
|
563
|
+
code: 200,
|
|
564
|
+
headers: {
|
|
565
|
+
'Content-Type': 'video/mp4',
|
|
566
|
+
'Accept-Ranges': 'bytes',
|
|
567
|
+
'Cache-Control': 'no-cache',
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// Handle ffmpeg errors
|
|
572
|
+
ffmpeg.on('close', (code) => {
|
|
573
|
+
if (code !== 0 && code !== null && !streamStarted) {
|
|
574
|
+
logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${ffmpegError}`);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
ffmpeg.on('error', (error) => {
|
|
579
|
+
logger.error(`FFmpeg spawn error for video proxy: fileId=${fileId}`, error);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
115
584
|
}
|