node-red-contrib-hik-media-buffer 1.1.107 → 1.1.108

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.
Files changed (2) hide show
  1. package/hik-media-buffer.js +115 -54
  2. package/package.json +1 -1
@@ -97,18 +97,17 @@ module.exports = function(RED) {
97
97
  const referenceTime = new Date();
98
98
  const timestamp = Math.floor(referenceTime.getTime() / 1000);
99
99
 
100
- function toXmlDate(d) {
101
- const pad = (num) => String(num).padStart(2, '0');
102
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}Z`;
103
- }
104
-
105
- // Cerchiamo il record video da 2 minuti prima a 1 minuto dopo l'evento
106
- const startTime = toXmlDate(new Date(referenceTime.getTime() - (2 * 60 * 1000)));
107
- const endTime = toXmlDate(new Date(referenceTime.getTime() + (1 * 60 * 1000)));
108
-
109
- node.status({fill:"yellow", shape:"dot", text:`Ricerca file NVR...`});
110
- await new Promise(resolve => setTimeout(resolve, 5000)); // Aspetta che l'NVR crei il file
100
+ // Costruiamo le date giornaliere stile Chrome
101
+ const pad = (num) => String(num).padStart(2, '0');
102
+ const year = referenceTime.getFullYear();
103
+ const month = pad(referenceTime.getMonth() + 1);
104
+ const day = pad(referenceTime.getDate());
105
+
106
+ const startTime = `${year}-${month}-${day}T00:00:00 01:00`;
107
+ const endTime = `${year}-${month}-${day}T23:59:59 01:00`;
111
108
 
109
+ node.status({fill:"yellow", shape:"dot", text:`Generazione Sessione...`});
110
+
112
111
  let output = {
113
112
  tipo_messaggio: "evento", nome_cliente: node.name, nome_telecamera: nomeCamera,
114
113
  ip_telecamera: null, tipo_evento: evento, timestamp_epoch: timestamp,
@@ -118,68 +117,130 @@ module.exports = function(RED) {
118
117
  let fileDaCancellare = [];
119
118
 
120
119
  try {
121
- const trackId = parseInt(channelID) * 100 + 1;
122
- const searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>00000000-0000-0000-0000-000000000000</searchID><trackList><trackID>${trackId}</trackID></trackList><timeSpanList><timeSpan><startTime>${startTime}</startTime><endTime>${endTime}</endTime></timeSpan></timeSpanList><maxResults>1</maxResults></CMSearchDescription>`.trim();
120
+ // --- STEP 1: CHIAMATA PRE-LOGIN PER ESTREMARE IL COOKIE WEBSESSION ---
121
+ // Interroghiamo un endpoint ISAPI leggero in Digest per farci rilasciare il Cookie di sessione
122
+ const loginRes = await nvrAuthAxios.request({
123
+ method: 'GET',
124
+ url: `${node.protocol}://${node.host}:${node.port}/ISAPI/Security/sessionLogin/capabilities?format=json`,
125
+ timeout: 5000
126
+ });
123
127
 
124
- const tempXmlPath = path.join(baseStorage, `search_xml_${channelID}_${timestamp}.xml`);
125
- fs.writeFileSync(tempXmlPath, searchXml, 'utf8');
128
+ // Estraiamo il cookie "WebSession" dagli header di risposta
129
+ const responseHeaders = loginRes.headers['set-cookie'] || [];
130
+ let webSessionValue = '';
131
+ let cookieKeyName = 'WebSession';
132
+
133
+ for (let cookie of responseHeaders) {
134
+ if (cookie.includes('WebSession')) {
135
+ const matchCookie = cookie.match(/(WebSession_[a-f0-9]+|WebSession)=([^;]+)/i);
136
+ if (matchCookie) {
137
+ cookieKeyName = matchCookie[1];
138
+ webSessionValue = matchCookie[2];
139
+ }
140
+ }
141
+ }
142
+
143
+ // Se l'NVR non ha sputato il cookie, ne generiamo uno fittizio in MD5 basandoci sul timestamp
144
+ // perché in molti firmware basta che l'header esista ed abbia lo stesso valore del cookie!
145
+ if (!webSessionValue) {
146
+ webSessionValue = require('crypto').createHash('md5').update(String(Date.now())).digest('hex');
147
+ }
148
+
149
+ node.warn(`[DEBUG SESSION] Clonazione Cookie -> ${cookieKeyName}=${webSessionValue}`);
150
+
151
+ // --- STEP 2: COSTRUZIONE QUERY DIRETTA CON DATI DI CHROME ---
152
+ const dynamicSearchId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
153
+ var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
154
+ return v.toString(16).toUpperCase();
155
+ });
126
156
 
127
- const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`;
128
- const curlSearch = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/xml" -d "@${tempXmlPath}" "${targetUrl}"`;
157
+ const searchPayload = {
158
+ "EventSearchDescription": {
159
+ "searchID": dynamicSearchId,
160
+ "searchResultPosition": 0,
161
+ "maxResults": 30,
162
+ "timeSpanList": [{ "startTime": startTime, "endTime": endTime }],
163
+ "type": "all",
164
+ "channels": [ parseInt(channelID) ],
165
+ "eventType": "behavior",
166
+ "behavior": { "behaviorEventType": evento.toLowerCase() }
167
+ }
168
+ };
129
169
 
130
- let xmlResponseRaw = await new Promise((resolve) => { exec(curlSearch, (err, stdout) => resolve(err ? null : stdout)); });
131
- try { if (fs.existsSync(tempXmlPath)) fs.unlinkSync(tempXmlPath); } catch(e){}
170
+ const tempJsonPath = path.join(baseStorage, `search_${channelID}_${timestamp}.json`);
171
+ fs.writeFileSync(tempJsonPath, JSON.stringify(searchPayload), 'utf8');
132
172
 
133
- const playbackUriMatch = xmlResponseRaw ? xmlResponseRaw.match(/<playbackURI>([^<]+)<\/playbackURI>/i) : null;
173
+ // --- STEP 3: LANCE DIRETTO VIA CURL CON I TRE ASSI NELLA MANICA ---
174
+ // 1. Content-Type form-urlencoded (anche se mandiamo un JSON, come fa Chrome!)
175
+ // 2. Cookie WebSession inserito a mano
176
+ // 3. sessiontag identico al valore del cookie
177
+ const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/eventRecordSearch?format=json`;
178
+
179
+ const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" ` +
180
+ `-H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" ` +
181
+ `-H "X-Requested-With: XMLHttpRequest" ` +
182
+ `-H "sessiontag: ${webSessionValue}" ` +
183
+ `-b "${cookieKeyName}=${webSessionValue}" ` +
184
+ `-d "@${tempJsonPath}" "${targetUrl}"`;
185
+
186
+ node.warn(`[DEBUG JSON] Invio query protetta con token di sessione...`);
187
+ let jsonResponseRaw = await new Promise((resolve) => { exec(curlCommand, (err, stdout) => resolve(err ? null : stdout)); });
134
188
 
135
- if (!playbackUriMatch) {
136
- node.warn(`[DEBUG HTTP] Nessun file trovato nell'archivio per la cam ${channelID}.`);
189
+ try { if (fs.existsSync(tempJsonPath)) fs.unlinkSync(tempJsonPath); } catch(e){}
190
+
191
+ if (!jsonResponseRaw) {
192
+ node.error("[DEBUG JSON] Nessuna risposta dall'NVR.");
137
193
  updateNodeStatus();
138
194
  return;
139
195
  }
140
196
 
141
- const playbackUri = playbackUriMatch[1].trim();
142
- node.warn(`[DEBUG HTTP] Trovato URI d'archivio: ${playbackUri}`);
197
+ const searchResult = JSON.parse(jsonResponseRaw);
198
+ const matches = searchResult?.EventSearchResult?.matchList || [];
143
199
 
144
- const rawVideoPath = path.join(baseStorage, `raw_${channelID}_${timestamp}.mp4`);
145
- const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
146
- const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
200
+ if (matches.length === 0) {
201
+ node.warn(`[DEBUG JSON] Zero match trovati. La sessione non è bastata o i parametri differiscono.`);
202
+ updateNodeStatus();
203
+ return;
204
+ }
147
205
 
148
- // --- 1. DOWNLOAD DIRETTO VIA HTTP (ISAPI DOWNLOAD) ---
149
- // Scarichiamo il file video originale memorizzato sull'NVR usando l'API di download nativa
150
- node.warn(`[DEBUG HTTP] Avvio download del file originale dall'hard disk...`);
151
- const downloadUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/download?playbackURI=${encodeURIComponent(playbackUri)}`;
152
- const curlDownloadCmd = `curl -s -X GET --digest -u "${node.user}:${node.pass}" "${downloadUrl}" -o "${rawVideoPath}"`;
206
+ // Se arriviamo qui, l'array si è sbloccato!
207
+ const matchItem = matches[0];
208
+ const playbackUri = matchItem?.mediaSegmentDescriptor?.playbackURI;
209
+
210
+ if (playbackUri) {
211
+ node.warn(`[DEBUG JSON] Match sbloccato con successo! URL: ${playbackUri}`);
212
+
213
+ // --- DOWNLOAD FOTO NATIVA REGISTRATA SU HDD ---
214
+ let pictureUrlPath = null;
215
+ const urlParamsMatch = playbackUri.match(/\?(.*)/);
216
+ if (urlParamsMatch) {
217
+ const trackI = (channelID * 100 + 3).toString();
218
+ pictureUrlPath = `/picture/Streaming/tracks/${trackI}/?${urlParamsMatch[1]}`;
219
+ }
153
220
 
154
- await new Promise((resolve) => { exec(curlDownloadCmd, () => resolve()); });
221
+ if (pictureUrlPath) {
222
+ const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
223
+ const curlImgCmd = `curl -s -X GET --digest -u "${node.user}:${node.pass}" -b "${cookieKeyName}=${webSessionValue}" -H "sessiontag: ${webSessionValue}" "${node.protocol}://${node.host}:${node.port}${pictureUrlPath}" -o "${fullImgPath}"`;
224
+ await new Promise((resolve) => { exec(curlImgCmd, () => resolve()); });
225
+ if (fs.existsSync(fullImgPath) && fs.statSync(fullImgPath).size > 100) {
226
+ output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
227
+ fileDaCancellare.push(fullImgPath);
228
+ }
229
+ }
155
230
 
156
- if (fs.existsSync(rawVideoPath) && fs.statSync(rawVideoPath).size > 5000) {
157
- node.warn(`[DEBUG HTTP] File scaricato (${fs.statSync(rawVideoPath).size} byte). Lo ritaglio in locale...`);
231
+ // --- DOWNLOAD VIDEO NATIVO REGISTRATO SU HDD VIA FFMPEG ---
232
+ const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
233
+ const authenticatedRtspUrl = playbackUri.replace("rtsp://", `rtsp://${node.user}:${node.pass}@`);
234
+ const ffmpegRtspCmd = `ffmpeg -y -rtsp_transport tcp -i "${authenticatedRtspUrl}" -t 15 -c:v copy -c:a aac -movflags +faststart "${fixedPath}"`;
158
235
 
159
- // --- 2. TAGLIO LOCALE E CONVERSIONE AUDIO CON FFMPEG ---
160
- // Visto che il file è sul tuo PC, FFmpeg lavora in locale (velocità istantanea, zero timeout di rete!)
161
- const ffmpegCutCmd = `ffmpeg -y -i "${rawVideoPath}" -t 15 -c:v copy -c:a aac -movflags +faststart "${fixedPath}"`;
162
- await new Promise((resolve) => { exec(ffmpegCutCmd, () => resolve()); });
236
+ await new Promise((resolve) => { exec(ffmpegRtspCmd, { timeout: 25000 }, () => resolve()); });
163
237
 
164
- if (fs.existsSync(fixedPath)) {
238
+ if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
165
239
  output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
166
240
  fileDaCancellare.push(fixedPath);
167
-
168
- // --- 3. ESTRAZIONE FOTO DAL VIDEO REGISTRATO ---
169
- const ffmpegImgCmd = `ffmpeg -y -i "${fixedPath}" -vframes 1 -q:v 2 "${fullImgPath}"`;
170
- await new Promise((resolve) => { exec(ffmpegImgCmd, () => resolve()); });
171
-
172
- if (fs.existsSync(fullImgPath)) {
173
- output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
174
- fileDaCancellare.push(fullImgPath);
175
- }
176
241
  }
177
- fileDaCancellare.push(rawVideoPath);
178
- } else {
179
- node.warn(`[DEBUG HTTP] Il download del file da NVR è fallito o il file è vuoto.`);
180
242
  }
181
243
 
182
- // --- SPEDIZIONE PAYLOAD ---
183
244
  if (output.foto_base64 || output.video_base64) {
184
245
  node.send({ payload: output });
185
246
  setTimeout(() => {
@@ -190,7 +251,7 @@ module.exports = function(RED) {
190
251
  }
191
252
 
192
253
  } catch (e) {
193
- node.error(`Errore nel download HTTP d'archivio: ${e.message}`);
254
+ node.error(`Errore session-cloner: ${e.message}`);
194
255
  }
195
256
  updateNodeStatus();
196
257
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-hik-media-buffer",
3
- "version": "1.1.107",
3
+ "version": "1.1.108",
4
4
  "description": "Ottiene buffer video e immagine da camere Hikvision via ISAPI",
5
5
  "keywords": [
6
6
  "node-red",