node-red-contrib-hik-media-buffer 1.1.106 → 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 +125 -54
  2. package/package.json +1 -1
@@ -97,26 +97,17 @@ module.exports = function(RED) {
97
97
  const referenceTime = new Date();
98
98
  const timestamp = Math.floor(referenceTime.getTime() / 1000);
99
99
 
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
100
+ // Costruiamo le date giornaliere stile Chrome
103
101
  const pad = (num) => String(num).padStart(2, '0');
104
- const timeGoBack = new Date(referenceTime.getTime() - (10 * 1000)); // iniziamo 10 secondi prima dello scatto
102
+ const year = referenceTime.getFullYear();
103
+ const month = pad(referenceTime.getMonth() + 1);
104
+ const day = pad(referenceTime.getDate());
105
105
 
106
- // Generiamo il timestamp con T e Z MAIUSCOLE e SENZA due punti, esattamente come piace a Hikvision
107
- const year = timeGoBack.getUTCFullYear();
108
- const month = pad(timeGoBack.getUTCMonth() + 1);
109
- const day = pad(timeGoBack.getUTCDate());
110
- const hours = pad(timeGoBack.getUTCHours());
111
- const minutes = pad(timeGoBack.getUTCMinutes());
112
- const seconds = pad(timeGoBack.getUTCSeconds());
113
-
114
- // Output pulito: 20260624T085731Z
115
- const rtspTimestamp = `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
116
-
117
- node.status({fill:"yellow", shape:"dot", text:`Estrazione media Cam ${channelID}...`});
118
- await new Promise(resolve => setTimeout(resolve, 5000));
106
+ const startTime = `${year}-${month}-${day}T00:00:00 01:00`;
107
+ const endTime = `${year}-${month}-${day}T23:59:59 01:00`;
119
108
 
109
+ node.status({fill:"yellow", shape:"dot", text:`Generazione Sessione...`});
110
+
120
111
  let output = {
121
112
  tipo_messaggio: "evento", nome_cliente: node.name, nome_telecamera: nomeCamera,
122
113
  ip_telecamera: null, tipo_evento: evento, timestamp_epoch: timestamp,
@@ -126,50 +117,130 @@ module.exports = function(RED) {
126
117
  let fileDaCancellare = [];
127
118
 
128
119
  try {
129
- const trackId = parseInt(channelID) * 100 + 1;
130
- const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
131
- const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
132
-
133
- // Cambiamo leggermente l'endpoint usando /channels/ che è più tollerante sul TCP d'archivio
134
- const archiveRtspUrl = `rtsp://${node.user}:${node.pass}@${node.host}:554/Streaming/channels/${channelID}01/?starttime=${rtspTimestamp}`;
135
- node.warn(`[DEBUG RTSP] Tentativo TCP blindato su: ${archiveRtspUrl}`);
136
-
137
- // -rtsp_transport tcp forza il tunnel stabile senza pacchetti persi
138
- // -stimeout imposta il timeout interno della connessione a 20 secondi (in microsecondi)
139
- // -fflags +genpts forza FFmpeg a ricostruire i timestamp mancanti ignorando i drop iniziali dell'NVR
140
- const ffmpegRtspCmd = `ffmpeg -y -rtsp_transport tcp -stimeout 20000000 -i "${archiveRtspUrl}" -t 15 -fflags +genpts -c:v copy -c:a aac -movflags +faststart "${fixedPath}"`;
141
-
142
- await new Promise((resolve) => {
143
- exec(ffmpegRtspCmd, { timeout: 25000 }, (err) => {
144
- if (err) node.error(`[DEBUG FFMPEG VIDEO ERRORE]: ${err.message}`);
145
- resolve();
146
- });
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
147
126
  });
148
127
 
149
- if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
150
- output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
151
- fileDaCancellare.push(fixedPath);
152
- node.warn(`[DEBUG RTSP] Spezzone video d'archivio estratto con successo (${fs.statSync(fixedPath).size} byte).`);
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
+ });
156
+
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
+ };
169
+
170
+ const tempJsonPath = path.join(baseStorage, `search_${channelID}_${timestamp}.json`);
171
+ fs.writeFileSync(tempJsonPath, JSON.stringify(searchPayload), 'utf8');
172
+
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)); });
188
+
189
+ try { if (fs.existsSync(tempJsonPath)) fs.unlinkSync(tempJsonPath); } catch(e){}
190
+
191
+ if (!jsonResponseRaw) {
192
+ node.error("[DEBUG JSON] Nessuna risposta dall'NVR.");
193
+ updateNodeStatus();
194
+ return;
195
+ }
196
+
197
+ const searchResult = JSON.parse(jsonResponseRaw);
198
+ const matches = searchResult?.EventSearchResult?.matchList || [];
199
+
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
+ }
205
+
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
+ }
220
+
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
+ }
153
230
 
154
- // --- 2. GENERAZIONE FOTO DAL VIDEO APPENA SCARICATO ---
155
- // Estraiamo il primo fotogramma utile del video reale registrato su disco dell'evento. Infallibile!
156
- node.warn(`[DEBUG RTSP] Estraggo il fotogramma dell'evento dal video registrato...`);
157
- const ffmpegImgCmd = `ffmpeg -y -i "${fixedPath}" -vframes 1 -q:v 2 "${fullImgPath}"`;
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
- await new Promise((resolve) => {
160
- exec(ffmpegImgCmd, () => resolve());
161
- });
236
+ await new Promise((resolve) => { exec(ffmpegRtspCmd, { timeout: 25000 }, () => resolve()); });
162
237
 
163
- if (fs.existsSync(fullImgPath) && fs.statSync(fullImgPath).size > 100) {
164
- output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
165
- fileDaCancellare.push(fullImgPath);
166
- node.warn(`[DEBUG RTSP] Foto estratta con successo dal video dell'evento.`);
238
+ if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
239
+ output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
240
+ fileDaCancellare.push(fixedPath);
167
241
  }
168
- } else {
169
- node.warn(`[DEBUG RTSP] Impossibile recuperare lo stream video dall'archivio.`);
170
242
  }
171
243
 
172
- // --- SPEDIZIONE PAYLOAD ---
173
244
  if (output.foto_base64 || output.video_base64) {
174
245
  node.send({ payload: output });
175
246
  setTimeout(() => {
@@ -180,7 +251,7 @@ module.exports = function(RED) {
180
251
  }
181
252
 
182
253
  } catch (e) {
183
- node.error(`Errore Critico nel recupero RTSP diretto: ${e.message}`);
254
+ node.error(`Errore session-cloner: ${e.message}`);
184
255
  }
185
256
  updateNodeStatus();
186
257
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-hik-media-buffer",
3
- "version": "1.1.106",
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",