node-red-contrib-hik-media-buffer 1.1.53 → 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 +124 -126
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -112,19 +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
|
-
|
|
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)));
|
|
128
134
|
|
|
129
135
|
node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
|
|
130
136
|
const nomeCamera = await getCameraName(channelID);
|
|
@@ -134,13 +140,10 @@ module.exports = function(RED) {
|
|
|
134
140
|
|
|
135
141
|
const baseUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt`;
|
|
136
142
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
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";
|
|
144
147
|
|
|
145
148
|
let output = {
|
|
146
149
|
tipo_messaggio: "evento",
|
|
@@ -156,132 +159,127 @@ module.exports = function(RED) {
|
|
|
156
159
|
};
|
|
157
160
|
|
|
158
161
|
const parsedChannel = parseInt(channelID, 10);
|
|
159
|
-
const
|
|
160
|
-
const trackFoto = (parsedChannel * 100) + 3;
|
|
161
|
-
|
|
162
|
-
const tracks = [
|
|
163
|
-
{ id: trackVideo.toString(), isFoto: false, label: "VIDEO" },
|
|
164
|
-
{ id: trackFoto.toString(), isFoto: true, label: "FOTO" }
|
|
165
|
-
];
|
|
162
|
+
const trackFoto = (parsedChannel * 100) + 3; // Es. 203
|
|
166
163
|
let fileDaCancellare = [];
|
|
167
164
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
` </timeSpanList>\n`;
|
|
185
|
-
|
|
186
|
-
// Inseriamo il contentTypeList SOLO per la foto, per la ricerca video standard dell'NVR va rimosso!
|
|
187
|
-
if (t.isFoto) {
|
|
188
|
-
searchXml += ` <contentTypeList>\n` +
|
|
189
|
-
` <contentType>metadata</contentType>\n` +
|
|
190
|
-
` </contentTypeList>\n`;
|
|
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
|
|
191
181
|
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
192
184
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
node.warn(`[STEP 4.1] Invio POST /search per traccia ${t.id}. XML inviato:\n${searchXml}`);
|
|
201
|
-
|
|
202
|
-
try {
|
|
203
|
-
const resSearch = await camAuth.request({
|
|
204
|
-
method: 'POST',
|
|
205
|
-
url: `${baseUrl}/search`,
|
|
206
|
-
data: searchXml,
|
|
207
|
-
headers: { "Content-Type": "application/xml" }
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
node.warn(`[STEP 4.2] Risposta ricevuta da /search per traccia ${t.id}: Status ${resSearch.status}`);
|
|
211
|
-
|
|
212
|
-
let xml = resSearch.data.replace(/<(\/?)\w+:/g, "<$1");
|
|
213
|
-
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
|
+
});
|
|
214
192
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
});
|
|
226
207
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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);
|
|
236
220
|
} else {
|
|
237
|
-
|
|
238
|
-
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
239
|
-
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
240
|
-
fs.writeFileSync(rawPath, buffer);
|
|
241
|
-
|
|
242
|
-
node.warn(`[STEP 5.3] Video grezzo salvato. Avvio conversione FFMPEG...`);
|
|
243
|
-
await new Promise((resolve) => {
|
|
244
|
-
exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
245
|
-
if (!err && fs.existsSync(fixedPath)) {
|
|
246
|
-
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
247
|
-
fileDaCancellare.push(fixedPath);
|
|
248
|
-
node.warn(`[STEP 5.4] Conversione FFMPEG completata con successo.`);
|
|
249
|
-
} else {
|
|
250
|
-
node.warn(`[STEP 5.4] Errore FFMPEG (uso il video grezzo di backup): ${err ? err.message : 'File mancante'}`);
|
|
251
|
-
output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
|
|
252
|
-
}
|
|
253
|
-
fileDaCancellare.push(rawPath);
|
|
254
|
-
resolve();
|
|
255
|
-
});
|
|
256
|
-
});
|
|
221
|
+
output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
|
|
257
222
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
errorDetails += ` | Status: ${searchError.response.status} | Dati Risposta: ${JSON.stringify(searchError.response.data)}`;
|
|
265
|
-
}
|
|
266
|
-
node.error(`[CATCH TRACCIA ${t.id}] Errore durante la POST di ricerca: ${errorDetails}`);
|
|
267
|
-
}
|
|
223
|
+
fileDaCancellare.push(rawPath);
|
|
224
|
+
resolve();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
node.warn(`[STEP 4.2 - VIDEO] Nessun URI video trovato nella risposta JSON.`);
|
|
268
229
|
}
|
|
230
|
+
} catch (videoErr) {
|
|
231
|
+
node.error(`[VIDEO ERR] Errore durante la ricerca video JSON: ${videoErr.message}`);
|
|
232
|
+
}
|
|
269
233
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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}`);
|
|
273
252
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
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);
|
|
279
264
|
} else {
|
|
280
|
-
node.warn(`[STEP
|
|
265
|
+
node.warn(`[STEP 5.2 - FOTO] Nessun playbackURI foto trovato nell'XML.`);
|
|
281
266
|
}
|
|
267
|
+
} catch (fotoErr) {
|
|
268
|
+
node.error(`[FOTO ERR] Errore durante la ricerca foto XML: ${fotoErr.message}`);
|
|
269
|
+
}
|
|
282
270
|
|
|
283
|
-
|
|
284
|
-
|
|
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.`);
|
|
285
283
|
}
|
|
286
284
|
node.status({fill:"green", shape:"ring", text:"In ascolto"});
|
|
287
285
|
}
|