node-red-contrib-hik-media-buffer 1.1.87 → 1.1.88
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 +54 -135
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
2
|
const AxiosDigestAuth = require('@mhoc/axios-digest-auth').default;
|
|
3
|
-
const http = require('http'); // Modulo nativo di Node.js per sconfiggere il 400
|
|
4
3
|
const fs = require('fs');
|
|
5
4
|
const path = require('path');
|
|
6
5
|
const os = require('os');
|
|
@@ -30,113 +29,15 @@ module.exports = function(RED) {
|
|
|
30
29
|
|
|
31
30
|
node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
// Funzione data ISO standard con la Z che Chrome usa nell'NVR
|
|
33
|
+
function toHikDate(d) {
|
|
34
|
+
return d.toISOString().split('.')[0] + "Z";
|
|
35
|
+
}
|
|
34
36
|
|
|
35
|
-
//
|
|
37
|
+
// Axios Digest usato solo per le GET leggere (Stream eventi e nomi canali)
|
|
36
38
|
const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
37
39
|
|
|
38
|
-
// ---
|
|
39
|
-
// --- FUNZIONE DEFINITIVA PER LA POST DIGEST CON CALCOLO INTEGRITÀ DEL BODY ---
|
|
40
|
-
function nativeDigestPost(urlPath, xmlBody) {
|
|
41
|
-
return new Promise((resolve, reject) => {
|
|
42
|
-
const options = {
|
|
43
|
-
hostname: node.host,
|
|
44
|
-
port: node.port,
|
|
45
|
-
path: urlPath,
|
|
46
|
-
method: 'POST',
|
|
47
|
-
headers: {
|
|
48
|
-
'Content-Type': 'application/xml; charset=UTF-8',
|
|
49
|
-
'Content-Length': Buffer.byteLength(xmlBody, 'utf8')
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
// Primo invio a vuoto per scatenare il 401 dell'NVR
|
|
54
|
-
const req = http.request(options, (res) => {
|
|
55
|
-
let chunks = [];
|
|
56
|
-
res.on('data', (chunk) => chunks.push(chunk));
|
|
57
|
-
res.on('end', () => {
|
|
58
|
-
const body = Buffer.concat(chunks).toString();
|
|
59
|
-
|
|
60
|
-
if (res.statusCode === 401) {
|
|
61
|
-
const authHeader = res.headers['www-authenticate'];
|
|
62
|
-
if (!authHeader) return reject(new Error("Manca header WWW-Authenticate"));
|
|
63
|
-
|
|
64
|
-
// Estrazione parametri di sicurezza
|
|
65
|
-
const realm = authHeader.match(/realm="([^"]+)"/)[1];
|
|
66
|
-
const nonce = authHeader.match(/nonce="([^"]+)"/)[1];
|
|
67
|
-
const qopMatch = authHeader.match(/qop="([^"]+)"/);
|
|
68
|
-
const qop = qopMatch ? qopMatch[1] : null;
|
|
69
|
-
|
|
70
|
-
const crypto = require('crypto');
|
|
71
|
-
const md5 = (str) => crypto.createHash('md5').update(str, 'utf8').digest('hex');
|
|
72
|
-
|
|
73
|
-
// 1. Calcolo HA1 base
|
|
74
|
-
const ha1 = md5(`${node.user}:${realm}:${node.pass}`);
|
|
75
|
-
|
|
76
|
-
// 2. Calcolo HA2: SE L'NVR CHIEDE AUTH-INT, INSERIAMO L'HASH DEL BODY XML!
|
|
77
|
-
let ha2;
|
|
78
|
-
if (qop && qop.includes('auth-int')) {
|
|
79
|
-
const bodyHash = md5(xmlBody);
|
|
80
|
-
ha2 = md5(`POST:${urlPath}:${bodyHash}`);
|
|
81
|
-
} else {
|
|
82
|
-
ha2 = md5(`POST:${urlPath}`);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// 3. Generazione parametri finali dell'handshake
|
|
86
|
-
const cnonce = crypto.randomBytes(8).toString('hex');
|
|
87
|
-
const nc = "00000001";
|
|
88
|
-
let response;
|
|
89
|
-
let authString;
|
|
90
|
-
|
|
91
|
-
if (qop) {
|
|
92
|
-
// Se c'è il qop (auth o auth-int), usiamo la formula estesa
|
|
93
|
-
const actualQop = qop.includes('auth-int') ? 'auth-int' : 'auth';
|
|
94
|
-
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${actualQop}:${ha2}`);
|
|
95
|
-
authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", qop="${actualQop}", nc=${nc}, cnonce="${cnonce}", response="${response}"`;
|
|
96
|
-
} else {
|
|
97
|
-
// Altrimenti andiamo di Digest classico
|
|
98
|
-
response = md5(`${ha1}:${nonce}:${ha2}`);
|
|
99
|
-
authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", response="${response}"`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Secondo invio definitivo con l'header Authorization corretto al millesimo
|
|
103
|
-
const finalOptions = {
|
|
104
|
-
hostname: node.host,
|
|
105
|
-
port: node.port,
|
|
106
|
-
path: urlPath,
|
|
107
|
-
method: 'POST',
|
|
108
|
-
headers: {
|
|
109
|
-
'Content-Type': 'application/xml; charset=UTF-8',
|
|
110
|
-
'Authorization': authString,
|
|
111
|
-
'Content-Length': Buffer.byteLength(xmlBody, 'utf8')
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const finalReq = http.request(finalOptions, (finalRes) => {
|
|
116
|
-
let finalChunks = [];
|
|
117
|
-
finalRes.on('data', (c) => finalChunks.push(c));
|
|
118
|
-
finalRes.on('end', () => {
|
|
119
|
-
resolve({
|
|
120
|
-
statusCode: finalRes.statusCode,
|
|
121
|
-
data: Buffer.concat(finalChunks)
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
finalReq.on('error', (err) => reject(err));
|
|
126
|
-
finalReq.write(xmlBody, 'utf8');
|
|
127
|
-
finalReq.end();
|
|
128
|
-
} else {
|
|
129
|
-
resolve({ statusCode: res.statusCode, data: body });
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
req.on('error', (err) => reject(err));
|
|
134
|
-
req.write(xmlBody, 'utf8');
|
|
135
|
-
req.end();
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// --- PRENDE IL NOME DELLA TELECAMERA DALL'NVR ---
|
|
40
|
+
// --- RECUPERA IL NOME DELLA TELECAMERA DALL'NVR ---
|
|
140
41
|
async function getCameraName(channelID) {
|
|
141
42
|
try {
|
|
142
43
|
const res = await nvrAuthAxios.request({
|
|
@@ -152,7 +53,7 @@ module.exports = function(RED) {
|
|
|
152
53
|
}
|
|
153
54
|
}
|
|
154
55
|
|
|
155
|
-
// --- CONTROLLO STATUS
|
|
56
|
+
// --- CONTROLLO STATUS CANERE DIRETTAMENTE DALL'NVR ---
|
|
156
57
|
async function checkCameras() {
|
|
157
58
|
if (isClosing || !nvrOnline) return;
|
|
158
59
|
try {
|
|
@@ -203,7 +104,7 @@ module.exports = function(RED) {
|
|
|
203
104
|
|
|
204
105
|
const heartbeatInterval = setInterval(checkCameras, 30000);
|
|
205
106
|
|
|
206
|
-
// --- DOWNLOAD MEDIA
|
|
107
|
+
// --- DOWNLOAD MEDIA SBLOCCATO VIA cURL ---
|
|
207
108
|
async function downloadMedia(evento, channelID) {
|
|
208
109
|
const chStr = channelID.toString();
|
|
209
110
|
const nowTime = Date.now();
|
|
@@ -214,10 +115,12 @@ module.exports = function(RED) {
|
|
|
214
115
|
const nomeCamera = await getCameraName(channelID);
|
|
215
116
|
const referenceTime = new Date();
|
|
216
117
|
const timestamp = Math.floor(referenceTime.getTime() / 1000);
|
|
217
|
-
const startTime = toHikDate(new Date(referenceTime.getTime() - (
|
|
218
|
-
const endTime = toHikDate(new Date(referenceTime.getTime() + (
|
|
118
|
+
const startTime = toHikDate(new Date(referenceTime.getTime() - (10 * 1000)));
|
|
119
|
+
const endTime = toHikDate(new Date(referenceTime.getTime() + (10 * 1000)));
|
|
219
120
|
|
|
220
121
|
node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
|
|
122
|
+
|
|
123
|
+
// Aspettiamo che l'NVR scriva il file fisicamente sul disco
|
|
221
124
|
await new Promise(resolve => setTimeout(resolve, 6000));
|
|
222
125
|
|
|
223
126
|
let output = {
|
|
@@ -229,49 +132,63 @@ module.exports = function(RED) {
|
|
|
229
132
|
let fileDaCancellare = [];
|
|
230
133
|
|
|
231
134
|
try {
|
|
232
|
-
const trackV = (channelID * 100 + 1).toString();
|
|
233
|
-
const trackI = (channelID * 100 + 3).toString();
|
|
135
|
+
const trackV = (channelID * 100 + 1).toString(); // Es: Canale 2 -> 201
|
|
136
|
+
const trackI = (channelID * 100 + 3).toString(); // Es: Canale 2 -> 203
|
|
234
137
|
const tracks = [{ id: trackV }, { id: trackI }];
|
|
235
138
|
|
|
236
139
|
for (let t of tracks) {
|
|
237
140
|
let searchXml = ``;
|
|
238
141
|
if (t.id === trackV) {
|
|
239
|
-
searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>
|
|
142
|
+
searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>23D1C3CB-8C68-41C0-8A89-2BCF5765B5D2</searchID><trackList><trackID>${t.id}</trackID></trackList><timeSpanList><timeSpan><startTime>${startTime}</startTime><endTime>${endTime}</endTime></timeSpan></timeSpanList><maxResults>100</maxResults><searchResultPostion>0</searchResultPostion><metadataList><metadataDescriptor>//recordType.meta.std-cgi.com</metadataDescriptor></metadataList></CMSearchDescription>`.trim();
|
|
240
143
|
} else {
|
|
241
|
-
searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>
|
|
144
|
+
searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>23D1C3CB-8C68-41C0-8A89-2BCF5765B5D2</searchID><trackIDList><trackID>${t.id}</trackID></trackIDList><timeSpanList><timeSpan><startTime>${startTime}</startTime><endTime>${endTime}</endTime></timeSpan></timeSpanList><contentTypeList><contentType>metadata</contentType></contentTypeList><maxResults>30</maxResults><searchResultPostion>0</searchResultPostion><metadataList><metadataDescriptor>recordType.meta.hikvision.com/${evento}</metadataDescriptor></metadataList></CMSearchDescription>`.trim();
|
|
242
145
|
}
|
|
243
146
|
|
|
244
|
-
//
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
147
|
+
// Chiamata cURL nativa del sistema operativo: se lo fa Chrome, lo fa anche cURL senza fallire sul Digest
|
|
148
|
+
const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`;
|
|
149
|
+
const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/xml; charset=UTF-8" -d '${searchXml}' "${targetUrl}"`;
|
|
150
|
+
|
|
151
|
+
let xmlResponse = await new Promise((resolve) => {
|
|
152
|
+
exec(curlCommand, (err, stdout, stderr) => {
|
|
153
|
+
if (err) resolve(null);
|
|
154
|
+
else resolve(stdout);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!xmlResponse || xmlResponse.includes("400 Bad Request") || xmlResponse.includes("401 Unauthorized")) {
|
|
249
159
|
continue;
|
|
250
160
|
}
|
|
251
161
|
|
|
252
|
-
let xml =
|
|
162
|
+
let xml = xmlResponse.replace(/<(\/?)\w+:/g, "<$1");
|
|
253
163
|
const uriMatch = xml.match(/<playbackURI>([^<]+)</);
|
|
254
164
|
|
|
255
165
|
if (uriMatch) {
|
|
256
166
|
const rawUri = uriMatch[1].replace(/&/g, '&');
|
|
257
|
-
const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&')}</playbackURI></downloadRequest
|
|
167
|
+
const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&')}</playbackURI></downloadRequest>`.trim();
|
|
258
168
|
|
|
259
|
-
|
|
260
|
-
const
|
|
169
|
+
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
170
|
+
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
171
|
+
const targetFilePath = (t.id === trackI) ? fullImgPath : rawPath;
|
|
172
|
+
|
|
173
|
+
// cURL gestisce anche il download binario immenso salvando direttamente su file (-o)
|
|
174
|
+
const downUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/download`;
|
|
175
|
+
const curlDownCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/xml; charset=UTF-8" -d '${downXml}' "${downUrl}" -o "${targetFilePath}"`;
|
|
176
|
+
|
|
177
|
+
await new Promise((resolve) => {
|
|
178
|
+
exec(curlDownCommand, () => resolve());
|
|
179
|
+
});
|
|
261
180
|
|
|
262
|
-
if (
|
|
263
|
-
let buffer = resDown.data;
|
|
181
|
+
if (fs.existsSync(targetFilePath) && fs.statSync(targetFilePath).size > 0) {
|
|
264
182
|
if (t.id === trackI) {
|
|
265
|
-
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
266
|
-
fs.writeFileSync(fullImgPath, buffer);
|
|
267
183
|
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
268
184
|
fileDaCancellare.push(fullImgPath);
|
|
269
185
|
} else {
|
|
270
|
-
if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
|
|
271
|
-
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
272
186
|
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
273
|
-
fs.
|
|
274
|
-
|
|
187
|
+
let videoBuffer = fs.readFileSync(rawPath);
|
|
188
|
+
if (videoBuffer.slice(0, 4).toString() === 'IMKH') {
|
|
189
|
+
try { fs.writeFileSync(rawPath, videoBuffer.slice(40)); } catch(e){}
|
|
190
|
+
}
|
|
191
|
+
|
|
275
192
|
await new Promise((resolve) => {
|
|
276
193
|
exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
277
194
|
if (!err && fs.existsSync(fixedPath)) {
|
|
@@ -304,7 +221,7 @@ module.exports = function(RED) {
|
|
|
304
221
|
updateNodeStatus();
|
|
305
222
|
}
|
|
306
223
|
|
|
307
|
-
// --- ALERT STREAM
|
|
224
|
+
// --- ALERT STREAM DI VEDETTA (Super Stabile con Axios) ---
|
|
308
225
|
function startAlertStream() {
|
|
309
226
|
if (isClosing) return;
|
|
310
227
|
const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
|
|
@@ -319,12 +236,14 @@ module.exports = function(RED) {
|
|
|
319
236
|
updateNodeStatus();
|
|
320
237
|
|
|
321
238
|
response.data.on('data', (chunk) => {
|
|
322
|
-
const data = chunk.toString()
|
|
323
|
-
if (data.includes("active")) {
|
|
324
|
-
const chMatch = data.match(/<
|
|
239
|
+
const data = chunk.toString();
|
|
240
|
+
if (data.toLowerCase().includes("active")) {
|
|
241
|
+
const chMatch = data.match(/<channelID>(\d+)<\/channelID>/i);
|
|
325
242
|
if (chMatch) {
|
|
326
243
|
let ev = "Unknown";
|
|
327
|
-
for (let e of EventList) {
|
|
244
|
+
for (let e of EventList) {
|
|
245
|
+
if (data.toLowerCase().includes(e.toLowerCase())) { ev = e; break; }
|
|
246
|
+
}
|
|
328
247
|
downloadMedia(ev, chMatch[1]);
|
|
329
248
|
}
|
|
330
249
|
}
|