node-red-contrib-hik-media-buffer 1.1.54 → 1.1.55
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 +125 -104
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -112,20 +112,25 @@ module.exports = function(RED) {
|
|
|
112
112
|
|
|
113
113
|
const nowTime = Date.now();
|
|
114
114
|
if (!lastTriggerTime[channelID]) lastTriggerTime[channelID] = 0;
|
|
115
|
-
if (nowTime - lastTriggerTime[channelID] < 5000)
|
|
116
|
-
node.warn(`[STEP 1] Richiesta bloccata dall'antirimbalzo (meno di 5s dall'ultimo trigger)`);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
115
|
+
if (nowTime - lastTriggerTime[channelID] < 5000) return;
|
|
119
116
|
lastTriggerTime[channelID] = nowTime;
|
|
120
117
|
|
|
121
118
|
const camAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
122
119
|
const referenceTime = new Date();
|
|
123
120
|
const timestamp = Math.floor(referenceTime.getTime() / 1000);
|
|
124
121
|
|
|
125
|
-
//
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
122
|
+
// Finestre temporali per i due endpoint
|
|
123
|
+
const startTimeZ = toHikDate(new Date(referenceTime.getTime() - (20 * 1000))); // Per XML (es. 2026-06-08T15:00:00Z)
|
|
124
|
+
const endTimeZ = toHikDate(new Date(referenceTime.getTime() + (20 * 1000)));
|
|
125
|
+
|
|
126
|
+
// Per il JSON creiamo il formato con l'offset del fuso orario italiano "+02:00" (o "+01:00" a seconda di come risponde il sistema)
|
|
127
|
+
// Sfruttiamo una formattazione pulita per l'orario del JSON dell'NVR
|
|
128
|
+
const formatJsonDate = (d) => {
|
|
129
|
+
const pad = (num) => String(num).padStart(2, '0');
|
|
130
|
+
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} 02:00`;
|
|
131
|
+
};
|
|
132
|
+
const startTimeJson = formatJsonDate(new Date(referenceTime.getTime() - (20 * 1000)));
|
|
133
|
+
const endTimeJson = formatJsonDate(new Date(referenceTime.getTime() + (20 * 1000)));
|
|
129
134
|
|
|
130
135
|
node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
|
|
131
136
|
const nomeCamera = await getCameraName(channelID);
|
|
@@ -135,13 +140,10 @@ module.exports = function(RED) {
|
|
|
135
140
|
|
|
136
141
|
const baseUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt`;
|
|
137
142
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
143
|
+
// Stringhe per i due formati
|
|
144
|
+
const eventoMinuscolo = evento.toLowerCase(); // "linedetection" o "fielddetection" per il JSON
|
|
145
|
+
let eventoNVR = "FieldDetection"; // "FieldDetection" o "LineDetection" per l'XML
|
|
146
|
+
if (eventoMinuscolo.includes("linedetection")) eventoNVR = "LineDetection";
|
|
145
147
|
|
|
146
148
|
let output = {
|
|
147
149
|
tipo_messaggio: "evento",
|
|
@@ -157,108 +159,127 @@ module.exports = function(RED) {
|
|
|
157
159
|
};
|
|
158
160
|
|
|
159
161
|
const parsedChannel = parseInt(channelID, 10);
|
|
160
|
-
const
|
|
161
|
-
const trackFoto = (parsedChannel * 100) + 3;
|
|
162
|
-
|
|
163
|
-
const tracks = [
|
|
164
|
-
{ id: trackVideo.toString(), isFoto: false, label: "VIDEO" },
|
|
165
|
-
{ id: trackFoto.toString(), isFoto: true, label: "FOTO" }
|
|
166
|
-
];
|
|
162
|
+
const trackFoto = (parsedChannel * 100) + 3; // Es. 203
|
|
167
163
|
let fileDaCancellare = [];
|
|
168
164
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
165
|
+
// --- PARTE 1: SCARICHIAMO IL VIDEO USANDO IL JSON SUL NUOVO ENDPOINT ---
|
|
166
|
+
node.warn(`[STEP 4 - VIDEO] Invio POST a eventRecordSearch con JSON dell'ispeziona...`);
|
|
167
|
+
const searchJsonPayload = {
|
|
168
|
+
"EventSearchDescription": {
|
|
169
|
+
"searchID": "BA80CAF2-A1E5-42E3-8624-529C3D6665D7",
|
|
170
|
+
"searchResultPosition": 0,
|
|
171
|
+
"maxResults": 30,
|
|
172
|
+
"timeSpanList": [{
|
|
173
|
+
"startTime": startTimeJson,
|
|
174
|
+
"endTime": endTimeJson
|
|
175
|
+
}],
|
|
176
|
+
"type": "all",
|
|
177
|
+
"channels": [parsedChannel],
|
|
178
|
+
"eventType": "behavior",
|
|
179
|
+
"behavior": {
|
|
180
|
+
"behaviorEventType": eventoMinuscolo
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
178
184
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
node.warn(`[STEP 4.2] Risposta ricevuta da /search per traccia ${t.id}: Status ${resSearch.status}`);
|
|
188
|
-
|
|
189
|
-
let xml = resSearch.data.replace(/<(\/?)\w+:/g, "<$1");
|
|
190
|
-
const uriMatch = xml.match(/<playbackURI>([^<]+)</);
|
|
185
|
+
try {
|
|
186
|
+
const resSearchVideo = await camAuth.request({
|
|
187
|
+
method: 'POST',
|
|
188
|
+
url: `${baseUrl}/eventRecordSearch?format=json`,
|
|
189
|
+
data: searchJsonPayload,
|
|
190
|
+
headers: { "Content-Type": "application/json" }
|
|
191
|
+
});
|
|
191
192
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
193
|
+
// Il payload JSON restituisce una struttura differente, estraiamo l'url di download (playbackURI)
|
|
194
|
+
const resDataStr = JSON.stringify(resSearchVideo.data);
|
|
195
|
+
const videoUriMatch = resDataStr.match(/rtsp:\/\/[^"]+/i) || resDataStr.match(/\/ISAPI\/ContentMgmt\/download[^"]+/i);
|
|
196
|
+
|
|
197
|
+
if (videoUriMatch) {
|
|
198
|
+
let rawVideoUri = videoUriMatch[0].replace(/\\/g, '');
|
|
199
|
+
node.warn(`[STEP 4.1 - VIDEO] Trovato URI video nel JSON: ${rawVideoUri}`);
|
|
200
|
+
|
|
201
|
+
const resDownVideo = await camAuth.request({
|
|
202
|
+
method: 'GET',
|
|
203
|
+
url: `${baseUrl}/download`,
|
|
204
|
+
data: `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawVideoUri.replace(/&/g, '&')}</playbackURI></downloadRequest>`,
|
|
205
|
+
responseType: 'arraybuffer'
|
|
206
|
+
});
|
|
203
207
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
208
|
+
let videoBuffer = Buffer.from(resDownVideo.data);
|
|
209
|
+
if (videoBuffer.slice(0, 4).toString() === 'IMKH') videoBuffer = videoBuffer.slice(40);
|
|
210
|
+
|
|
211
|
+
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
212
|
+
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
213
|
+
fs.writeFileSync(rawPath, videoBuffer);
|
|
214
|
+
|
|
215
|
+
await new Promise((resolve) => {
|
|
216
|
+
exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
217
|
+
if (!err && fs.existsSync(fixedPath)) {
|
|
218
|
+
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
219
|
+
fileDaCancellare.push(fixedPath);
|
|
213
220
|
} else {
|
|
214
|
-
|
|
215
|
-
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
216
|
-
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
217
|
-
fs.writeFileSync(rawPath, buffer);
|
|
218
|
-
|
|
219
|
-
node.warn(`[STEP 5.3] Video grezzo salvato. Avvio conversione FFMPEG...`);
|
|
220
|
-
await new Promise((resolve) => {
|
|
221
|
-
exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
222
|
-
if (!err && fs.existsSync(fixedPath)) {
|
|
223
|
-
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
224
|
-
fileDaCancellare.push(fixedPath);
|
|
225
|
-
node.warn(`[STEP 5.4] Conversione FFMPEG completata con successo.`);
|
|
226
|
-
} else {
|
|
227
|
-
node.warn(`[STEP 5.4] Errore FFMPEG (uso il video grezzo di backup): ${err ? err.message : 'File mancante'}`);
|
|
228
|
-
output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
|
|
229
|
-
}
|
|
230
|
-
fileDaCancellare.push(rawPath);
|
|
231
|
-
resolve();
|
|
232
|
-
});
|
|
233
|
-
});
|
|
221
|
+
output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
|
|
234
222
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
errorDetails += ` | Status: ${searchError.response.status} | Dati Risposta: ${JSON.stringify(searchError.response.data)}`;
|
|
242
|
-
}
|
|
243
|
-
node.error(`[CATCH TRACCIA ${t.id}] Errore durante la POST di ricerca: ${errorDetails}`);
|
|
244
|
-
}
|
|
223
|
+
fileDaCancellare.push(rawPath);
|
|
224
|
+
resolve();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
node.warn(`[STEP 4.2 - VIDEO] Nessun URI video trovato nella risposta JSON.`);
|
|
245
229
|
}
|
|
230
|
+
} catch (videoErr) {
|
|
231
|
+
node.error(`[VIDEO ERR] Errore durante la ricerca video JSON: ${videoErr.message}`);
|
|
232
|
+
}
|
|
246
233
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
234
|
+
// --- PARTE 2: SCARICHIAMO LA FOTO USANDO L'XML SUL VECCHIO ENDPOINT ---
|
|
235
|
+
node.warn(`[STEP 5 - FOTO] Invio POST /search in XML per la traccia foto ${trackFoto}...`);
|
|
236
|
+
const searchXmlFoto = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>LAST_EVENT</searchID><trackIDList><trackID>${trackFoto}</trackID></trackIDList><timeSpanList><timeSpan><startTime>${startTimeZ}</startTime><endTime>${endTimeZ}</endTime></timeSpan></timeSpanList><contentTypeList><contentType>metadata</contentType></contentTypeList><maxResults>30</maxResults><searchResultPostion>0</searchResultPostion><metadataList><metadataDescriptor>recordType.meta.hikvision.com/${eventoNVR}</metadataDescriptor></metadataList></CMSearchDescription>`;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const resSearchFoto = await camAuth.request({
|
|
240
|
+
method: 'POST',
|
|
241
|
+
url: `${baseUrl}/search`,
|
|
242
|
+
data: searchXmlFoto,
|
|
243
|
+
headers: { "Content-Type": "application/xml" }
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
let xml = resSearchFoto.data.replace(/<(\/?)\w+:/g, "<$1");
|
|
247
|
+
const uriMatch = xml.match(/<playbackURI>([^<]+)</);
|
|
248
|
+
|
|
249
|
+
if (uriMatch) {
|
|
250
|
+
const rawUriFoto = uriMatch[1].replace(/&/g, '&');
|
|
251
|
+
node.warn(`[STEP 5.1 - FOTO] Trovato playbackURI foto: ${rawUriFoto}`);
|
|
250
252
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
253
|
+
const resDownFoto = await camAuth.request({
|
|
254
|
+
method: 'GET',
|
|
255
|
+
url: `${baseUrl}/download`,
|
|
256
|
+
data: `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUriFoto.replace(/&/g, '&')}</playbackURI></downloadRequest>`,
|
|
257
|
+
responseType: 'arraybuffer'
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
261
|
+
fs.writeFileSync(fullImgPath, Buffer.from(resDownFoto.data));
|
|
262
|
+
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
263
|
+
fileDaCancellare.push(fullImgPath);
|
|
256
264
|
} else {
|
|
257
|
-
node.warn(`[STEP
|
|
265
|
+
node.warn(`[STEP 5.2 - FOTO] Nessun playbackURI foto trovato nell'XML.`);
|
|
258
266
|
}
|
|
267
|
+
} catch (fotoErr) {
|
|
268
|
+
node.error(`[FOTO ERR] Errore durante la ricerca foto XML: ${fotoErr.message}`);
|
|
269
|
+
}
|
|
259
270
|
|
|
260
|
-
|
|
261
|
-
|
|
271
|
+
// --- FINE: INVIO DATI A NODE-RED ---
|
|
272
|
+
if (output.foto_base64 || output.video_base64) {
|
|
273
|
+
node.warn(`[STEP 6] Payload multimediale spedito correttamente!`);
|
|
274
|
+
node.send({ payload: output });
|
|
275
|
+
|
|
276
|
+
setTimeout(() => {
|
|
277
|
+
for (let file of fileDaCancellare) {
|
|
278
|
+
try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
|
|
279
|
+
}
|
|
280
|
+
}, 120000);
|
|
281
|
+
} else {
|
|
282
|
+
node.warn(`[STEP 6] Nessun file base64 generato.`);
|
|
262
283
|
}
|
|
263
284
|
node.status({fill:"green", shape:"ring", text:"In ascolto"});
|
|
264
285
|
}
|