node-red-contrib-hik-media-buffer 1.1.101 → 1.1.102
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 +49 -128
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -28,25 +28,6 @@ module.exports = function(RED) {
|
|
|
28
28
|
|
|
29
29
|
node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
|
|
30
30
|
|
|
31
|
-
// Formatta la data simulando il fuso orario senza il segno '+'
|
|
32
|
-
function toJsonDate(d) {
|
|
33
|
-
const pad = (num) => String(num).padStart(2, '0');
|
|
34
|
-
const year = d.getFullYear();
|
|
35
|
-
const month = pad(d.getMonth() + 1);
|
|
36
|
-
const day = pad(d.getDate());
|
|
37
|
-
const hours = pad(d.getHours());
|
|
38
|
-
const minutes = pad(d.getMinutes());
|
|
39
|
-
const seconds = pad(d.getSeconds());
|
|
40
|
-
|
|
41
|
-
const offsetMinutes = d.getTimezoneOffset();
|
|
42
|
-
const offsetSign = offsetMinutes <= 0 ? '' : '-';
|
|
43
|
-
const offsetHours = pad(Math.floor(Math.abs(offsetMinutes) / 60));
|
|
44
|
-
const offsetMins = pad(Math.abs(offsetMinutes) % 60);
|
|
45
|
-
const fusoOrario = `${offsetSign}${offsetHours}:${offsetMins}`;
|
|
46
|
-
|
|
47
|
-
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds} ${fusoOrario}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
31
|
const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
51
32
|
|
|
52
33
|
async function getCameraName(channelID) {
|
|
@@ -104,7 +85,7 @@ module.exports = function(RED) {
|
|
|
104
85
|
|
|
105
86
|
const heartbeatInterval = setInterval(checkCameras, 30000);
|
|
106
87
|
|
|
107
|
-
// ---
|
|
88
|
+
// --- NUOVA STRATEGIA INDISTRUTTIBILE: DIRETTA SU STREAMING RTSP STORICO ---
|
|
108
89
|
async function downloadMedia(evento, channelID) {
|
|
109
90
|
const chStr = channelID.toString();
|
|
110
91
|
const nowTime = Date.now();
|
|
@@ -116,26 +97,26 @@ module.exports = function(RED) {
|
|
|
116
97
|
const referenceTime = new Date();
|
|
117
98
|
const timestamp = Math.floor(referenceTime.getTime() / 1000);
|
|
118
99
|
|
|
119
|
-
//
|
|
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
|
|
120
103
|
const pad = (num) => String(num).padStart(2, '0');
|
|
121
|
-
const
|
|
122
|
-
const month = pad(referenceTime.getMonth() + 1);
|
|
123
|
-
const day = pad(referenceTime.getDate());
|
|
124
|
-
|
|
125
|
-
const offsetMinutes = referenceTime.getTimezoneOffset();
|
|
126
|
-
const offsetSign = offsetMinutes <= 0 ? '' : '-';
|
|
127
|
-
const offsetHours = pad(Math.floor(Math.abs(offsetMinutes) / 60));
|
|
128
|
-
const offsetMins = pad(Math.abs(offsetMinutes) % 60);
|
|
129
|
-
const fusoOrario = `${offsetSign}${offsetHours}:${offsetMins}`;
|
|
130
|
-
|
|
131
|
-
const startTime = `${year}-${month}-${day}T00:00:00 ${fusoOrario}`;
|
|
132
|
-
const endTime = `${year}-${month}-${day}T23:59:59 ${fusoOrario}`;
|
|
133
|
-
|
|
134
|
-
node.status({fill:"yellow", shape:"dot", text:`Ricerca archivio Cam ${channelID}...`});
|
|
104
|
+
const timeGoBack = new Date(referenceTime.getTime() - (10 * 1000)); // iniziamo 10 secondi prima dello scatto
|
|
135
105
|
|
|
136
|
-
|
|
137
|
-
|
|
106
|
+
const year = timeGoBack.getUTCFullYear();
|
|
107
|
+
const month = pad(timeGoBack.getUTCMonth() + 1);
|
|
108
|
+
const day = pad(timeGoBack.getUTCDate());
|
|
109
|
+
const hours = pad(timeGoBack.getUTCHours());
|
|
110
|
+
const minutes = pad(timeGoBack.getUTCMinutes());
|
|
111
|
+
const seconds = pad(timeGoBack.getUTCSeconds());
|
|
138
112
|
|
|
113
|
+
const rtspTimestamp = `${year}${month}${day}t${hours}${minutes}${seconds}z`;
|
|
114
|
+
|
|
115
|
+
node.status({fill:"yellow", shape:"dot", text:`Estrazione media Cam ${channelID}...`});
|
|
116
|
+
|
|
117
|
+
// Aspettiamo 5 secondi per dare all'NVR il tempo di scrivere i frame correnti sull'hard disk
|
|
118
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
119
|
+
|
|
139
120
|
let output = {
|
|
140
121
|
tipo_messaggio: "evento", nome_cliente: node.name, nome_telecamera: nomeCamera,
|
|
141
122
|
ip_telecamera: null, tipo_evento: evento, timestamp_epoch: timestamp,
|
|
@@ -145,108 +126,48 @@ module.exports = function(RED) {
|
|
|
145
126
|
let fileDaCancellare = [];
|
|
146
127
|
|
|
147
128
|
try {
|
|
148
|
-
//
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
return v.toString(16).toUpperCase();
|
|
152
|
-
});
|
|
129
|
+
const trackId = parseInt(channelID) * 100 + 1; // Es: Canale 2 -> traccia 201
|
|
130
|
+
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
131
|
+
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
153
132
|
|
|
154
|
-
//
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
"searchID": dynamicSearchId,
|
|
158
|
-
"searchResultPosition": 0,
|
|
159
|
-
"maxResults": 30,
|
|
160
|
-
"timeSpanList": [{ "startTime": startTime, "endTime": endTime }],
|
|
161
|
-
"type": "all",
|
|
162
|
-
"channels": [ parseInt(channelID) ],
|
|
163
|
-
"eventType": "behavior",
|
|
164
|
-
"behavior": { "behaviorEventType": evento.toLowerCase() }
|
|
165
|
-
}
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const tempJsonPath = path.join(baseStorage, `search_${channelID}_${timestamp}.json`);
|
|
169
|
-
fs.writeFileSync(tempJsonPath, JSON.stringify(searchPayload), 'utf8');
|
|
133
|
+
// Costruiamo l'URL RTSP di Playback diretto inserendo le credenziali e il timestamp di inizio
|
|
134
|
+
const archiveRtspUrl = `rtsp://${node.user}:${node.pass}@${node.host}:554/Streaming/tracks/${trackId}/?starttime=${rtspTimestamp}`;
|
|
135
|
+
node.warn(`[DEBUG RTSP] Mi collego all'archivio su traccia ${trackId} dall'orario: ${rtspTimestamp}`);
|
|
170
136
|
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
// Forziamo gli header completi ricavati dall'analisi del client web
|
|
175
|
-
const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/json; charset=UTF-8" -H "X-Requested-With: XMLHttpRequest" -d "@${tempJsonPath}" "${targetUrl}"`;
|
|
137
|
+
// --- 1. CATTURA VIDEO DIRECT (Registriamo 15 secondi di archivio) ---
|
|
138
|
+
const ffmpegRtspCmd = `ffmpeg -y -rtsp_transport tcp -i "${archiveRtspUrl}" -t 15 -c copy -movflags +faststart "${fixedPath}"`;
|
|
176
139
|
|
|
177
|
-
|
|
178
|
-
exec(
|
|
140
|
+
await new Promise((resolve) => {
|
|
141
|
+
exec(ffmpegRtspCmd, { timeout: 25000 }, (err) => {
|
|
142
|
+
if (err) node.error(`[DEBUG FFMPEG VIDEO ERRORE]: ${err.message}`);
|
|
143
|
+
resolve();
|
|
144
|
+
});
|
|
179
145
|
});
|
|
180
146
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
node.
|
|
185
|
-
updateNodeStatus();
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const searchResult = JSON.parse(jsonResponseRaw);
|
|
190
|
-
const matches = searchResult?.EventSearchResult?.matchList || [];
|
|
191
|
-
|
|
192
|
-
if (matches.length === 0) {
|
|
193
|
-
node.warn(`[DEBUG JSON] Zero match trovati nell'archivio giornaliero.`);
|
|
194
|
-
updateNodeStatus();
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Prendiamo il record d'archivio più fresco (l'ultimo registrato)
|
|
199
|
-
const matchItem = matches[0];
|
|
200
|
-
const playbackUri = matchItem?.mediaSegmentDescriptor?.playbackURI;
|
|
201
|
-
|
|
202
|
-
if (playbackUri) {
|
|
203
|
-
node.warn(`[DEBUG JSON] Trovato PlaybackURI: ${playbackUri}`);
|
|
204
|
-
|
|
205
|
-
// --- 1. DOWNLOAD FOTO HISTORIC (GET diretta sulla traccia foto 203) ---
|
|
206
|
-
let pictureUrlPath = null;
|
|
207
|
-
const urlParamsMatch = playbackUri.match(/\?(.*)/);
|
|
208
|
-
if (urlParamsMatch) {
|
|
209
|
-
const trackI = (channelID * 100 + 3).toString();
|
|
210
|
-
pictureUrlPath = `/picture/Streaming/tracks/${trackI}/?${urlParamsMatch[1]}`;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (pictureUrlPath) {
|
|
214
|
-
node.warn(`[DEBUG FOTO] Scarico lo snapshot nativo d'archivio via GET...`);
|
|
215
|
-
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
216
|
-
const curlImgCmd = `curl -s -X GET --digest -u "${node.user}:${node.pass}" "${node.protocol}://${node.host}:${node.port}${pictureUrlPath}" -o "${fullImgPath}"`;
|
|
217
|
-
|
|
218
|
-
await new Promise((resolve) => { exec(curlImgCmd, () => resolve()); });
|
|
219
|
-
|
|
220
|
-
if (fs.existsSync(fullImgPath) && fs.statSync(fullImgPath).size > 100) {
|
|
221
|
-
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
222
|
-
fileDaCancellare.push(fullImgPath);
|
|
223
|
-
node.warn(`[DEBUG FOTO] Foto scaricata con successo.`);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// --- 2. DOWNLOAD VIDEO HISTORIC VIA RTSP (Taglio a 15 secondi) ---
|
|
228
|
-
node.warn(`[DEBUG VIDEO] Recupero lo spezzone video registrato via RTSP...`);
|
|
229
|
-
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
230
|
-
const authenticatedRtspUrl = playbackUri.replace("rtsp://", `rtsp://${node.user}:${node.pass}@`);
|
|
147
|
+
if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
|
|
148
|
+
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
149
|
+
fileDaCancellare.push(fixedPath);
|
|
150
|
+
node.warn(`[DEBUG RTSP] Spezzone video d'archivio estratto con successo (${fs.statSync(fixedPath).size} byte).`);
|
|
231
151
|
|
|
232
|
-
//
|
|
233
|
-
|
|
152
|
+
// --- 2. GENERAZIONE FOTO DAL VIDEO APPENA SCARICATO ---
|
|
153
|
+
// Estraiamo il primo fotogramma utile del video reale registrato su disco dell'evento. Infallibile!
|
|
154
|
+
node.warn(`[DEBUG RTSP] Estraggo il fotogramma dell'evento dal video registrato...`);
|
|
155
|
+
const ffmpegImgCmd = `ffmpeg -y -i "${fixedPath}" -vframes 1 -q:v 2 "${fullImgPath}"`;
|
|
234
156
|
|
|
235
157
|
await new Promise((resolve) => {
|
|
236
|
-
exec(
|
|
237
|
-
if (err) node.error(`[DEBUG FFMPEG ERRORE]: ${err.message}`);
|
|
238
|
-
resolve();
|
|
239
|
-
});
|
|
158
|
+
exec(ffmpegImgCmd, () => resolve());
|
|
240
159
|
});
|
|
241
160
|
|
|
242
|
-
if (fs.existsSync(
|
|
243
|
-
output.
|
|
244
|
-
fileDaCancellare.push(
|
|
245
|
-
node.warn(`[DEBUG
|
|
161
|
+
if (fs.existsSync(fullImgPath) && fs.statSync(fullImgPath).size > 100) {
|
|
162
|
+
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
163
|
+
fileDaCancellare.push(fullImgPath);
|
|
164
|
+
node.warn(`[DEBUG RTSP] Foto estratta con successo dal video dell'evento.`);
|
|
246
165
|
}
|
|
166
|
+
} else {
|
|
167
|
+
node.warn(`[DEBUG RTSP] Impossibile recuperare lo stream video dall'archivio.`);
|
|
247
168
|
}
|
|
248
169
|
|
|
249
|
-
// --- SPEDIZIONE
|
|
170
|
+
// --- SPEDIZIONE PAYLOAD ---
|
|
250
171
|
if (output.foto_base64 || output.video_base64) {
|
|
251
172
|
node.send({ payload: output });
|
|
252
173
|
setTimeout(() => {
|
|
@@ -257,12 +178,12 @@ module.exports = function(RED) {
|
|
|
257
178
|
}
|
|
258
179
|
|
|
259
180
|
} catch (e) {
|
|
260
|
-
node.error(`Errore Critico nel
|
|
181
|
+
node.error(`Errore Critico nel recupero RTSP diretto: ${e.message}`);
|
|
261
182
|
}
|
|
262
183
|
updateNodeStatus();
|
|
263
184
|
}
|
|
264
185
|
|
|
265
|
-
// --- ASCOLTO
|
|
186
|
+
// --- ASCOLTO LIVE STREAM DEGLI ALLARMI ---
|
|
266
187
|
function startAlertStream() {
|
|
267
188
|
if (isClosing) return;
|
|
268
189
|
const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
|