node-red-contrib-hik-media-buffer 1.1.100 → 1.1.102
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/hik-media-buffer.js +48 -123
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -28,27 +28,6 @@ module.exports = function(RED) {
|
|
|
28
28
|
|
|
29
29
|
node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
|
|
30
30
|
|
|
31
|
-
// CLONE PERFETTO DEL FORMATO DATA DI CHROME (Es: 2026-06-23T17:40:00 02:00)
|
|
32
|
-
function toJsonDate(d) {
|
|
33
|
-
const pad = (num) => String(num).padStart(2, '0');
|
|
34
|
-
const year = d.getFullYear();
|
|
35
|
-
const month = pad(d.getMonth() + 1);
|
|
36
|
-
const day = pad(d.getDate());
|
|
37
|
-
const hours = pad(d.getHours());
|
|
38
|
-
const minutes = pad(d.getMinutes());
|
|
39
|
-
const seconds = pad(d.getSeconds());
|
|
40
|
-
|
|
41
|
-
const offsetMinutes = d.getTimezoneOffset();
|
|
42
|
-
// Chiariamo l'offset senza inserire il '+' che fa incazzare l'NVR
|
|
43
|
-
const offsetSign = offsetMinutes <= 0 ? '' : '-';
|
|
44
|
-
const absOffsetMinutes = Math.abs(offsetMinutes);
|
|
45
|
-
const offsetHours = pad(Math.floor(absOffsetMinutes / 60));
|
|
46
|
-
const offsetMins = pad(absOffsetMinutes % 60);
|
|
47
|
-
|
|
48
|
-
// NOTA: C'è uno spazio intenzionale prima di offsetSign per replicare il log dell'NVR
|
|
49
|
-
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds} ${offsetSign}${offsetHours}:${offsetMins}`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
31
|
const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
53
32
|
|
|
54
33
|
async function getCameraName(channelID) {
|
|
@@ -106,7 +85,7 @@ module.exports = function(RED) {
|
|
|
106
85
|
|
|
107
86
|
const heartbeatInterval = setInterval(checkCameras, 30000);
|
|
108
87
|
|
|
109
|
-
// ---
|
|
88
|
+
// --- NUOVA STRATEGIA INDISTRUTTIBILE: DIRETTA SU STREAMING RTSP STORICO ---
|
|
110
89
|
async function downloadMedia(evento, channelID) {
|
|
111
90
|
const chStr = channelID.toString();
|
|
112
91
|
const nowTime = Date.now();
|
|
@@ -117,21 +96,27 @@ module.exports = function(RED) {
|
|
|
117
96
|
const nomeCamera = await getCameraName(channelID);
|
|
118
97
|
const referenceTime = new Date();
|
|
119
98
|
const timestamp = Math.floor(referenceTime.getTime() / 1000);
|
|
120
|
-
|
|
121
|
-
// Cerchiamo nell'ora corrente (da inizio ora a fine ora) per non fare tutto il giorno
|
|
122
|
-
const dStart = new Date(referenceTime);
|
|
123
|
-
dStart.setMinutes(0, 0, 0);
|
|
124
|
-
const dEnd = new Date(referenceTime);
|
|
125
|
-
dEnd.setMinutes(59, 59, 999);
|
|
126
99
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
100
|
+
// Costruiamo la stringa temporale RTSP Hikvision calcolando l'orario di inizio (10 secondi prima di ADESSO)
|
|
101
|
+
// Il formato richiesto dall'RTSP Hikvision è: AAAAMMGGtHHMMSSz (tutto minuscolo)
|
|
102
|
+
// Usiamo l'ora UTC per compatibilità nativa con i file system dei flussi video
|
|
103
|
+
const pad = (num) => String(num).padStart(2, '0');
|
|
104
|
+
const timeGoBack = new Date(referenceTime.getTime() - (10 * 1000)); // iniziamo 10 secondi prima dello scatto
|
|
131
105
|
|
|
132
|
-
|
|
133
|
-
|
|
106
|
+
const year = timeGoBack.getUTCFullYear();
|
|
107
|
+
const month = pad(timeGoBack.getUTCMonth() + 1);
|
|
108
|
+
const day = pad(timeGoBack.getUTCDate());
|
|
109
|
+
const hours = pad(timeGoBack.getUTCHours());
|
|
110
|
+
const minutes = pad(timeGoBack.getUTCMinutes());
|
|
111
|
+
const seconds = pad(timeGoBack.getUTCSeconds());
|
|
134
112
|
|
|
113
|
+
const rtspTimestamp = `${year}${month}${day}t${hours}${minutes}${seconds}z`;
|
|
114
|
+
|
|
115
|
+
node.status({fill:"yellow", shape:"dot", text:`Estrazione media Cam ${channelID}...`});
|
|
116
|
+
|
|
117
|
+
// Aspettiamo 5 secondi per dare all'NVR il tempo di scrivere i frame correnti sull'hard disk
|
|
118
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
119
|
+
|
|
135
120
|
let output = {
|
|
136
121
|
tipo_messaggio: "evento", nome_cliente: node.name, nome_telecamera: nomeCamera,
|
|
137
122
|
ip_telecamera: null, tipo_evento: evento, timestamp_epoch: timestamp,
|
|
@@ -141,105 +126,45 @@ module.exports = function(RED) {
|
|
|
141
126
|
let fileDaCancellare = [];
|
|
142
127
|
|
|
143
128
|
try {
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
return v.toString(16).toUpperCase();
|
|
148
|
-
});
|
|
129
|
+
const trackId = parseInt(channelID) * 100 + 1; // Es: Canale 2 -> traccia 201
|
|
130
|
+
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
131
|
+
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
149
132
|
|
|
150
|
-
//
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
"searchID": dynamicSearchId,
|
|
154
|
-
"searchResultPosition": 0,
|
|
155
|
-
"maxResults": 30,
|
|
156
|
-
"timeSpanList": [{ "startTime": startTime, "endTime": endTime }],
|
|
157
|
-
"type": "all",
|
|
158
|
-
"channels": [ parseInt(channelID) ],
|
|
159
|
-
"eventType": "behavior",
|
|
160
|
-
"behavior": { "behaviorEventType": evento.toLowerCase() }
|
|
161
|
-
}
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const tempJsonPath = path.join(baseStorage, `search_${channelID}_${timestamp}.json`);
|
|
165
|
-
fs.writeFileSync(tempJsonPath, JSON.stringify(searchPayload), 'utf8');
|
|
133
|
+
// Costruiamo l'URL RTSP di Playback diretto inserendo le credenziali e il timestamp di inizio
|
|
134
|
+
const archiveRtspUrl = `rtsp://${node.user}:${node.pass}@${node.host}:554/Streaming/tracks/${trackId}/?starttime=${rtspTimestamp}`;
|
|
135
|
+
node.warn(`[DEBUG RTSP] Mi collego all'archivio su traccia ${trackId} dall'orario: ${rtspTimestamp}`);
|
|
166
136
|
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/json" -d "@${tempJsonPath}" "${targetUrl}"`;
|
|
137
|
+
// --- 1. CATTURA VIDEO DIRECT (Registriamo 15 secondi di archivio) ---
|
|
138
|
+
const ffmpegRtspCmd = `ffmpeg -y -rtsp_transport tcp -i "${archiveRtspUrl}" -t 15 -c copy -movflags +faststart "${fixedPath}"`;
|
|
170
139
|
|
|
171
|
-
|
|
172
|
-
exec(
|
|
140
|
+
await new Promise((resolve) => {
|
|
141
|
+
exec(ffmpegRtspCmd, { timeout: 25000 }, (err) => {
|
|
142
|
+
if (err) node.error(`[DEBUG FFMPEG VIDEO ERRORE]: ${err.message}`);
|
|
143
|
+
resolve();
|
|
144
|
+
});
|
|
173
145
|
});
|
|
174
146
|
|
|
175
|
-
|
|
147
|
+
if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
|
|
148
|
+
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
149
|
+
fileDaCancellare.push(fixedPath);
|
|
150
|
+
node.warn(`[DEBUG RTSP] Spezzone video d'archivio estratto con successo (${fs.statSync(fixedPath).size} byte).`);
|
|
176
151
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
152
|
+
// --- 2. GENERAZIONE FOTO DAL VIDEO APPENA SCARICATO ---
|
|
153
|
+
// Estraiamo il primo fotogramma utile del video reale registrato su disco dell'evento. Infallibile!
|
|
154
|
+
node.warn(`[DEBUG RTSP] Estraggo il fotogramma dell'evento dal video registrato...`);
|
|
155
|
+
const ffmpegImgCmd = `ffmpeg -y -i "${fixedPath}" -vframes 1 -q:v 2 "${fullImgPath}"`;
|
|
182
156
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (matches.length === 0) {
|
|
187
|
-
node.warn(`[DEBUG JSON] Zero match trovati nell'intervallo orario con sintassi Chrome.`);
|
|
188
|
-
updateNodeStatus();
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Prendiamo il record più fresco (il primo restituito)
|
|
193
|
-
const matchItem = matches[0];
|
|
194
|
-
const playbackUri = matchItem?.mediaSegmentDescriptor?.playbackURI;
|
|
195
|
-
|
|
196
|
-
// --- 1. DOWNLOAD FOTO ORIGINALE DAL PATH NATIVO GET ---
|
|
197
|
-
let pictureUrlPath = null;
|
|
198
|
-
if (playbackUri) {
|
|
199
|
-
const urlParamsMatch = playbackUri.match(/\?(.*)/);
|
|
200
|
-
if (urlParamsMatch) {
|
|
201
|
-
const trackI = (channelID * 100 + 3).toString(); // Traccia 203 per le foto
|
|
202
|
-
pictureUrlPath = `/picture/Streaming/tracks/${trackI}/?${urlParamsMatch[1]}`;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
157
|
+
await new Promise((resolve) => {
|
|
158
|
+
exec(ffmpegImgCmd, () => resolve());
|
|
159
|
+
});
|
|
205
160
|
|
|
206
|
-
if (pictureUrlPath) {
|
|
207
|
-
node.warn(`[DEBUG FOTO] Scarico la foto originale dell'evento via HTTP GET...`);
|
|
208
|
-
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
209
|
-
const curlImgCmd = `curl -s -X GET --digest -u "${node.user}:${node.pass}" "${node.protocol}://${node.host}:${node.port}${pictureUrlPath}" -o "${fullImgPath}"`;
|
|
210
|
-
|
|
211
|
-
await new Promise((resolve) => { exec(curlImgCmd, () => resolve()); });
|
|
212
|
-
|
|
213
161
|
if (fs.existsSync(fullImgPath) && fs.statSync(fullImgPath).size > 100) {
|
|
214
162
|
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
215
163
|
fileDaCancellare.push(fullImgPath);
|
|
216
|
-
node.warn(`[DEBUG
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// --- 2. DOWNLOAD VIDEO CHIRURGICO VIA RTSP (Taglio forzato a 15 secondi) ---
|
|
221
|
-
if (playbackUri) {
|
|
222
|
-
node.warn(`[DEBUG VIDEO] Aggancio il flusso RTSP temporizzato dell'evento...`);
|
|
223
|
-
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
224
|
-
|
|
225
|
-
// Integriamo utente e password nell'URL RTSP
|
|
226
|
-
const authenticatedRtspUrl = playbackUri.replace("rtsp://", `rtsp://${node.user}:${node.pass}@`);
|
|
227
|
-
|
|
228
|
-
// -t 15 ferma Ffmpeg dopo 15 secondi esatti dall'inizio del record, evitando i file da 1GB
|
|
229
|
-
const ffmpegRtspCmd = `ffmpeg -y -rtsp_transport tcp -i "${authenticatedRtspUrl}" -t 15 -c copy -movflags +faststart "${fixedPath}"`;
|
|
230
|
-
|
|
231
|
-
await new Promise((resolve) => {
|
|
232
|
-
exec(ffmpegRtspCmd, { timeout: 25000 }, (err) => {
|
|
233
|
-
if (err) node.error(`[DEBUG FFMPEG ERRORE]: ${err.message}`);
|
|
234
|
-
resolve();
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
|
|
239
|
-
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
240
|
-
fileDaCancellare.push(fixedPath);
|
|
241
|
-
node.warn(`[DEBUG VIDEO] Video catturato con successo (${fs.statSync(fixedPath).size} byte).`);
|
|
164
|
+
node.warn(`[DEBUG RTSP] Foto estratta con successo dal video dell'evento.`);
|
|
242
165
|
}
|
|
166
|
+
} else {
|
|
167
|
+
node.warn(`[DEBUG RTSP] Impossibile recuperare lo stream video dall'archivio.`);
|
|
243
168
|
}
|
|
244
169
|
|
|
245
170
|
// --- SPEDIZIONE PAYLOAD ---
|
|
@@ -253,12 +178,12 @@ module.exports = function(RED) {
|
|
|
253
178
|
}
|
|
254
179
|
|
|
255
180
|
} catch (e) {
|
|
256
|
-
node.error(`Errore Critico nel
|
|
181
|
+
node.error(`Errore Critico nel recupero RTSP diretto: ${e.message}`);
|
|
257
182
|
}
|
|
258
183
|
updateNodeStatus();
|
|
259
184
|
}
|
|
260
185
|
|
|
261
|
-
// ---
|
|
186
|
+
// --- ASCOLTO LIVE STREAM DEGLI ALLARMI ---
|
|
262
187
|
function startAlertStream() {
|
|
263
188
|
if (isClosing) return;
|
|
264
189
|
const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
|