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.
- package/hik-media-buffer.js +125 -54
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -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
|
|
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
|
|
102
|
+
const year = referenceTime.getFullYear();
|
|
103
|
+
const month = pad(referenceTime.getMonth() + 1);
|
|
104
|
+
const day = pad(referenceTime.getDate());
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
const
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
// ---
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
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(
|
|
164
|
-
output.
|
|
165
|
-
fileDaCancellare.push(
|
|
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
|
|
254
|
+
node.error(`Errore session-cloner: ${e.message}`);
|
|
184
255
|
}
|
|
185
256
|
updateNodeStatus();
|
|
186
257
|
}
|