@xiboplayer/stats 0.1.0
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/README.md +375 -0
- package/package.json +37 -0
- package/src/index.js +13 -0
- package/src/log-reporter.js +541 -0
- package/src/log-reporter.test.js +484 -0
- package/src/stats-collector.js +633 -0
- package/src/stats-collector.test.js +461 -0
- package/vitest.config.js +9 -0
- package/vitest.setup.js +2 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatsCollector - Proof of play tracking for Xibo CMS
|
|
3
|
+
*
|
|
4
|
+
* Tracks layout and widget playback for reporting to CMS via XMDS.
|
|
5
|
+
* Uses IndexedDB for persistent storage across sessions.
|
|
6
|
+
*
|
|
7
|
+
* @module @xiboplayer/stats/collector
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
11
|
+
|
|
12
|
+
const log = createLogger('@xiboplayer/stats');
|
|
13
|
+
|
|
14
|
+
// IndexedDB configuration
|
|
15
|
+
const DB_NAME = 'xibo-player-stats';
|
|
16
|
+
const DB_VERSION = 1;
|
|
17
|
+
const STATS_STORE = 'stats';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Stats collector for proof of play tracking
|
|
21
|
+
*
|
|
22
|
+
* Stores layout and widget playback statistics in IndexedDB.
|
|
23
|
+
* Stats are submitted to CMS via XMDS SubmitStats API.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const collector = new StatsCollector();
|
|
27
|
+
* await collector.init();
|
|
28
|
+
*
|
|
29
|
+
* // Track layout
|
|
30
|
+
* await collector.startLayout(123, 456);
|
|
31
|
+
* // ... layout plays ...
|
|
32
|
+
* await collector.endLayout(123, 456);
|
|
33
|
+
*
|
|
34
|
+
* // Get stats for submission
|
|
35
|
+
* const stats = await collector.getStatsForSubmission(50);
|
|
36
|
+
* const xml = formatStats(stats);
|
|
37
|
+
* // ... submit to CMS ...
|
|
38
|
+
* await collector.clearSubmittedStats(stats);
|
|
39
|
+
*/
|
|
40
|
+
export class StatsCollector {
|
|
41
|
+
constructor() {
|
|
42
|
+
this.db = null;
|
|
43
|
+
this.inProgressStats = new Map(); // Track in-progress stats by key
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Initialize IndexedDB
|
|
48
|
+
*
|
|
49
|
+
* Creates stats store with index on 'submitted' field for fast queries.
|
|
50
|
+
* Safe to call multiple times (idempotent).
|
|
51
|
+
*
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
* @throws {Error} If IndexedDB is not available or initialization fails
|
|
54
|
+
*/
|
|
55
|
+
async init() {
|
|
56
|
+
if (this.db) {
|
|
57
|
+
log.debug('Stats collector already initialized');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
// Check if IndexedDB is available
|
|
63
|
+
if (typeof indexedDB === 'undefined') {
|
|
64
|
+
const error = new Error('IndexedDB not available');
|
|
65
|
+
log.error('IndexedDB not available - stats will not be persisted');
|
|
66
|
+
reject(error);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
71
|
+
|
|
72
|
+
request.onerror = () => {
|
|
73
|
+
const error = new Error(`Failed to open IndexedDB: ${request.error}`);
|
|
74
|
+
log.error('Failed to open stats database:', request.error);
|
|
75
|
+
reject(error);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
request.onsuccess = () => {
|
|
79
|
+
this.db = request.result;
|
|
80
|
+
log.info('Stats database initialized');
|
|
81
|
+
resolve();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
request.onupgradeneeded = (event) => {
|
|
85
|
+
const db = event.target.result;
|
|
86
|
+
|
|
87
|
+
// Create stats store if it doesn't exist
|
|
88
|
+
if (!db.objectStoreNames.contains(STATS_STORE)) {
|
|
89
|
+
const store = db.createObjectStore(STATS_STORE, {
|
|
90
|
+
keyPath: 'id',
|
|
91
|
+
autoIncrement: true
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Index on 'submitted' for fast queries
|
|
95
|
+
store.createIndex('submitted', 'submitted', { unique: false });
|
|
96
|
+
|
|
97
|
+
log.info('Stats store created');
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Start tracking a layout
|
|
105
|
+
*
|
|
106
|
+
* Creates a new layout stat entry and tracks it as in-progress.
|
|
107
|
+
* If a layout with the same ID is already in progress (replay),
|
|
108
|
+
* silently ends the previous cycle and starts a new one.
|
|
109
|
+
*
|
|
110
|
+
* @param {number} layoutId - Layout ID from CMS
|
|
111
|
+
* @param {number} scheduleId - Schedule ID that triggered this layout
|
|
112
|
+
* @returns {Promise<void>}
|
|
113
|
+
*/
|
|
114
|
+
async startLayout(layoutId, scheduleId) {
|
|
115
|
+
if (!this.db) {
|
|
116
|
+
log.warn('Stats database not initialized');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Key excludes scheduleId: only one layout instance can be in-progress at a time,
|
|
121
|
+
// and scheduleId may change mid-play when a collection cycle completes.
|
|
122
|
+
const key = `layout-${layoutId}`;
|
|
123
|
+
|
|
124
|
+
// Layout replay: end previous cycle silently before starting new one
|
|
125
|
+
if (this.inProgressStats.has(key)) {
|
|
126
|
+
const prev = this.inProgressStats.get(key);
|
|
127
|
+
prev.end = new Date();
|
|
128
|
+
prev.duration = Math.floor((prev.end - prev.start) / 1000);
|
|
129
|
+
await this._saveStat(prev);
|
|
130
|
+
this.inProgressStats.delete(key);
|
|
131
|
+
log.debug(`Layout ${layoutId} replay - ended previous cycle (${prev.duration}s)`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const stat = {
|
|
135
|
+
type: 'layout',
|
|
136
|
+
layoutId,
|
|
137
|
+
scheduleId,
|
|
138
|
+
start: new Date(),
|
|
139
|
+
end: null,
|
|
140
|
+
duration: 0,
|
|
141
|
+
count: 1,
|
|
142
|
+
submitted: 0 // Use 0/1 instead of boolean for IndexedDB compatibility
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
this.inProgressStats.set(key, stat);
|
|
146
|
+
log.debug(`Started tracking layout ${layoutId} (schedule ${scheduleId})`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* End tracking a layout
|
|
151
|
+
*
|
|
152
|
+
* Finalizes the layout stat entry and saves it to IndexedDB.
|
|
153
|
+
* Calculates duration in seconds.
|
|
154
|
+
*
|
|
155
|
+
* @param {number} layoutId - Layout ID from CMS
|
|
156
|
+
* @param {number} scheduleId - Schedule ID that triggered this layout
|
|
157
|
+
* @returns {Promise<void>}
|
|
158
|
+
*/
|
|
159
|
+
async endLayout(layoutId, scheduleId) {
|
|
160
|
+
if (!this.db) {
|
|
161
|
+
log.warn('Stats database not initialized');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const key = `layout-${layoutId}`;
|
|
166
|
+
const stat = this.inProgressStats.get(key);
|
|
167
|
+
|
|
168
|
+
if (!stat) {
|
|
169
|
+
log.debug(`Layout ${layoutId} not found in progress (may have been ended by replay)`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Calculate duration in seconds
|
|
174
|
+
stat.end = new Date();
|
|
175
|
+
stat.duration = Math.floor((stat.end - stat.start) / 1000);
|
|
176
|
+
|
|
177
|
+
// Save to database
|
|
178
|
+
try {
|
|
179
|
+
await this._saveStat(stat);
|
|
180
|
+
this.inProgressStats.delete(key);
|
|
181
|
+
log.debug(`Ended tracking layout ${layoutId} (${stat.duration}s)`);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
log.error(`Failed to save layout stat ${layoutId}:`, error);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Start tracking a widget/media
|
|
190
|
+
*
|
|
191
|
+
* Creates a new media stat entry and tracks it as in-progress.
|
|
192
|
+
* If a widget with the same key is already in progress (replay),
|
|
193
|
+
* silently ends the previous cycle and starts a new one.
|
|
194
|
+
*
|
|
195
|
+
* @param {number} mediaId - Media ID from CMS
|
|
196
|
+
* @param {number} layoutId - Parent layout ID
|
|
197
|
+
* @param {number} scheduleId - Schedule ID
|
|
198
|
+
* @returns {Promise<void>}
|
|
199
|
+
*/
|
|
200
|
+
async startWidget(mediaId, layoutId, scheduleId) {
|
|
201
|
+
if (!this.db) {
|
|
202
|
+
log.warn('Stats database not initialized');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Key excludes scheduleId: it may change mid-play during collection cycles.
|
|
207
|
+
const key = `media-${mediaId}-${layoutId}`;
|
|
208
|
+
|
|
209
|
+
// Widget replay: end previous cycle silently before starting new one
|
|
210
|
+
if (this.inProgressStats.has(key)) {
|
|
211
|
+
const prev = this.inProgressStats.get(key);
|
|
212
|
+
prev.end = new Date();
|
|
213
|
+
prev.duration = Math.floor((prev.end - prev.start) / 1000);
|
|
214
|
+
await this._saveStat(prev);
|
|
215
|
+
this.inProgressStats.delete(key);
|
|
216
|
+
log.debug(`Widget ${mediaId} replay - ended previous cycle (${prev.duration}s)`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const stat = {
|
|
220
|
+
type: 'media',
|
|
221
|
+
mediaId,
|
|
222
|
+
layoutId,
|
|
223
|
+
scheduleId,
|
|
224
|
+
start: new Date(),
|
|
225
|
+
end: null,
|
|
226
|
+
duration: 0,
|
|
227
|
+
count: 1,
|
|
228
|
+
submitted: 0 // Use 0/1 instead of boolean for IndexedDB compatibility
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
this.inProgressStats.set(key, stat);
|
|
232
|
+
log.debug(`Started tracking widget ${mediaId} in layout ${layoutId}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* End tracking a widget/media
|
|
237
|
+
*
|
|
238
|
+
* Finalizes the media stat entry and saves it to IndexedDB.
|
|
239
|
+
* Calculates duration in seconds.
|
|
240
|
+
*
|
|
241
|
+
* @param {number} mediaId - Media ID from CMS
|
|
242
|
+
* @param {number} layoutId - Parent layout ID
|
|
243
|
+
* @param {number} scheduleId - Schedule ID
|
|
244
|
+
* @returns {Promise<void>}
|
|
245
|
+
*/
|
|
246
|
+
async endWidget(mediaId, layoutId, scheduleId) {
|
|
247
|
+
if (!this.db) {
|
|
248
|
+
log.warn('Stats database not initialized');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const key = `media-${mediaId}-${layoutId}`;
|
|
253
|
+
const stat = this.inProgressStats.get(key);
|
|
254
|
+
|
|
255
|
+
if (!stat) {
|
|
256
|
+
log.debug(`Widget ${mediaId} not found in progress (expected during layout transitions)`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Calculate duration in seconds
|
|
261
|
+
stat.end = new Date();
|
|
262
|
+
stat.duration = Math.floor((stat.end - stat.start) / 1000);
|
|
263
|
+
|
|
264
|
+
// Save to database
|
|
265
|
+
try {
|
|
266
|
+
await this._saveStat(stat);
|
|
267
|
+
this.inProgressStats.delete(key);
|
|
268
|
+
log.debug(`Ended tracking widget ${mediaId} (${stat.duration}s)`);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
log.error(`Failed to save widget stat ${mediaId}:`, error);
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get stats ready for submission to CMS
|
|
277
|
+
*
|
|
278
|
+
* Returns unsubmitted stats up to the specified limit.
|
|
279
|
+
* Stats are ordered by ID (oldest first).
|
|
280
|
+
*
|
|
281
|
+
* @param {number} limit - Maximum number of stats to return (default: 50)
|
|
282
|
+
* @returns {Promise<Array>} Array of stat objects
|
|
283
|
+
*/
|
|
284
|
+
async getStatsForSubmission(limit = 50) {
|
|
285
|
+
if (!this.db) {
|
|
286
|
+
log.warn('Stats database not initialized');
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
const transaction = this.db.transaction([STATS_STORE], 'readonly');
|
|
292
|
+
const store = transaction.objectStore(STATS_STORE);
|
|
293
|
+
const index = store.index('submitted');
|
|
294
|
+
|
|
295
|
+
// Query for unsubmitted stats (0 = false)
|
|
296
|
+
const request = index.openCursor(IDBKeyRange.only(0));
|
|
297
|
+
const stats = [];
|
|
298
|
+
|
|
299
|
+
request.onsuccess = (event) => {
|
|
300
|
+
const cursor = event.target.result;
|
|
301
|
+
|
|
302
|
+
if (cursor && stats.length < limit) {
|
|
303
|
+
stats.push(cursor.value);
|
|
304
|
+
cursor.continue();
|
|
305
|
+
} else {
|
|
306
|
+
log.debug(`Retrieved ${stats.length} unsubmitted stats`);
|
|
307
|
+
resolve(stats);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
request.onerror = () => {
|
|
312
|
+
log.error('Failed to retrieve stats:', request.error);
|
|
313
|
+
reject(new Error(`Failed to retrieve stats: ${request.error}`));
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Clear submitted stats from database
|
|
320
|
+
*
|
|
321
|
+
* Deletes stats that were successfully submitted to CMS.
|
|
322
|
+
*
|
|
323
|
+
* @param {Array} stats - Array of stat objects to delete
|
|
324
|
+
* @returns {Promise<void>}
|
|
325
|
+
*/
|
|
326
|
+
async clearSubmittedStats(stats) {
|
|
327
|
+
if (!this.db) {
|
|
328
|
+
log.warn('Stats database not initialized');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!stats || stats.length === 0) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
const transaction = this.db.transaction([STATS_STORE], 'readwrite');
|
|
338
|
+
const store = transaction.objectStore(STATS_STORE);
|
|
339
|
+
|
|
340
|
+
let deletedCount = 0;
|
|
341
|
+
|
|
342
|
+
stats.forEach((stat) => {
|
|
343
|
+
if (stat.id) {
|
|
344
|
+
const request = store.delete(stat.id);
|
|
345
|
+
request.onsuccess = () => {
|
|
346
|
+
deletedCount++;
|
|
347
|
+
};
|
|
348
|
+
request.onerror = () => {
|
|
349
|
+
log.error(`Failed to delete stat ${stat.id}:`, request.error);
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
transaction.oncomplete = () => {
|
|
355
|
+
log.debug(`Deleted ${deletedCount} submitted stats`);
|
|
356
|
+
resolve();
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
transaction.onerror = () => {
|
|
360
|
+
log.error('Failed to delete submitted stats:', transaction.error);
|
|
361
|
+
reject(new Error(`Failed to delete stats: ${transaction.error}`));
|
|
362
|
+
};
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Get aggregated stats for submission
|
|
368
|
+
*
|
|
369
|
+
* Groups stats by (type, layoutId, mediaId, scheduleId, hour) and sums
|
|
370
|
+
* durations/counts. Used when CMS aggregationLevel is 'Aggregate'.
|
|
371
|
+
*
|
|
372
|
+
* @param {number} limit - Maximum number of raw stats to read (default: 50)
|
|
373
|
+
* @returns {Promise<Array>} Aggregated stat objects
|
|
374
|
+
*/
|
|
375
|
+
async getAggregatedStatsForSubmission(limit = 50) {
|
|
376
|
+
const rawStats = await this.getStatsForSubmission(limit);
|
|
377
|
+
if (rawStats.length === 0) return [];
|
|
378
|
+
|
|
379
|
+
// Group by (type, layoutId, mediaId, scheduleId, hour)
|
|
380
|
+
const groups = new Map();
|
|
381
|
+
for (const stat of rawStats) {
|
|
382
|
+
const hour = stat.start instanceof Date
|
|
383
|
+
? stat.start.toISOString().slice(0, 13)
|
|
384
|
+
: new Date(stat.start).toISOString().slice(0, 13);
|
|
385
|
+
const key = `${stat.type}|${stat.layoutId}|${stat.mediaId || ''}|${stat.scheduleId}|${hour}`;
|
|
386
|
+
|
|
387
|
+
if (groups.has(key)) {
|
|
388
|
+
const group = groups.get(key);
|
|
389
|
+
group.count += stat.count || 1;
|
|
390
|
+
group.duration += stat.duration || 0;
|
|
391
|
+
// Keep earliest start and latest end
|
|
392
|
+
const statStart = stat.start instanceof Date ? stat.start : new Date(stat.start);
|
|
393
|
+
const statEnd = stat.end instanceof Date ? stat.end : new Date(stat.end || stat.start);
|
|
394
|
+
if (statStart < group.start) group.start = statStart;
|
|
395
|
+
if (statEnd > group.end) group.end = statEnd;
|
|
396
|
+
group._rawIds.push(stat.id);
|
|
397
|
+
} else {
|
|
398
|
+
groups.set(key, {
|
|
399
|
+
...stat,
|
|
400
|
+
start: stat.start instanceof Date ? stat.start : new Date(stat.start),
|
|
401
|
+
end: stat.end instanceof Date ? stat.end : new Date(stat.end || stat.start),
|
|
402
|
+
count: stat.count || 1,
|
|
403
|
+
_rawIds: [stat.id]
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return Array.from(groups.values());
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Get all stats (for debugging)
|
|
413
|
+
*
|
|
414
|
+
* @returns {Promise<Array>} All stats in database
|
|
415
|
+
*/
|
|
416
|
+
async getAllStats() {
|
|
417
|
+
if (!this.db) {
|
|
418
|
+
log.warn('Stats database not initialized');
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return new Promise((resolve, reject) => {
|
|
423
|
+
const transaction = this.db.transaction([STATS_STORE], 'readonly');
|
|
424
|
+
const store = transaction.objectStore(STATS_STORE);
|
|
425
|
+
const request = store.getAll();
|
|
426
|
+
|
|
427
|
+
request.onsuccess = () => {
|
|
428
|
+
resolve(request.result);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
request.onerror = () => {
|
|
432
|
+
log.error('Failed to get all stats:', request.error);
|
|
433
|
+
reject(new Error(`Failed to get all stats: ${request.error}`));
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Clear all stats (for testing)
|
|
440
|
+
*
|
|
441
|
+
* @returns {Promise<void>}
|
|
442
|
+
*/
|
|
443
|
+
async clearAllStats() {
|
|
444
|
+
if (!this.db) {
|
|
445
|
+
log.warn('Stats database not initialized');
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return new Promise((resolve, reject) => {
|
|
450
|
+
const transaction = this.db.transaction([STATS_STORE], 'readwrite');
|
|
451
|
+
const store = transaction.objectStore(STATS_STORE);
|
|
452
|
+
const request = store.clear();
|
|
453
|
+
|
|
454
|
+
request.onsuccess = () => {
|
|
455
|
+
log.debug('Cleared all stats');
|
|
456
|
+
this.inProgressStats.clear();
|
|
457
|
+
resolve();
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
request.onerror = () => {
|
|
461
|
+
log.error('Failed to clear all stats:', request.error);
|
|
462
|
+
reject(new Error(`Failed to clear stats: ${request.error}`));
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Save a stat to IndexedDB
|
|
469
|
+
* @private
|
|
470
|
+
*/
|
|
471
|
+
async _saveStat(stat) {
|
|
472
|
+
return new Promise((resolve, reject) => {
|
|
473
|
+
const transaction = this.db.transaction([STATS_STORE], 'readwrite');
|
|
474
|
+
const store = transaction.objectStore(STATS_STORE);
|
|
475
|
+
const request = store.add(stat);
|
|
476
|
+
|
|
477
|
+
request.onsuccess = () => {
|
|
478
|
+
resolve(request.result);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
request.onerror = () => {
|
|
482
|
+
// Check for quota exceeded error
|
|
483
|
+
if (request.error.name === 'QuotaExceededError') {
|
|
484
|
+
log.error('IndexedDB quota exceeded - cleaning old stats');
|
|
485
|
+
this._cleanOldStats().then(() => {
|
|
486
|
+
// Retry once after cleanup
|
|
487
|
+
const retryRequest = store.add(stat);
|
|
488
|
+
retryRequest.onsuccess = () => resolve(retryRequest.result);
|
|
489
|
+
retryRequest.onerror = () => reject(retryRequest.error);
|
|
490
|
+
}).catch(reject);
|
|
491
|
+
} else {
|
|
492
|
+
reject(request.error);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Clean old stats when quota is exceeded
|
|
500
|
+
* Deletes oldest 100 submitted stats
|
|
501
|
+
* @private
|
|
502
|
+
*/
|
|
503
|
+
async _cleanOldStats() {
|
|
504
|
+
if (!this.db) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
const transaction = this.db.transaction([STATS_STORE], 'readwrite');
|
|
510
|
+
const store = transaction.objectStore(STATS_STORE);
|
|
511
|
+
const index = store.index('submitted');
|
|
512
|
+
|
|
513
|
+
// Get oldest 100 submitted stats (use 1 for boolean true in IndexedDB)
|
|
514
|
+
const request = index.openCursor(1);
|
|
515
|
+
const toDelete = [];
|
|
516
|
+
|
|
517
|
+
request.onsuccess = (event) => {
|
|
518
|
+
const cursor = event.target.result;
|
|
519
|
+
|
|
520
|
+
if (cursor && toDelete.length < 100) {
|
|
521
|
+
toDelete.push(cursor.value.id);
|
|
522
|
+
cursor.continue();
|
|
523
|
+
} else {
|
|
524
|
+
// Delete collected IDs
|
|
525
|
+
toDelete.forEach((id) => {
|
|
526
|
+
store.delete(id);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
log.info(`Cleaned ${toDelete.length} old stats due to quota`);
|
|
530
|
+
resolve();
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
request.onerror = () => {
|
|
535
|
+
log.error('Failed to clean old stats:', request.error);
|
|
536
|
+
reject(request.error);
|
|
537
|
+
};
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Format stats as XML for XMDS submission
|
|
544
|
+
*
|
|
545
|
+
* Converts array of stat objects to XML format expected by CMS.
|
|
546
|
+
*
|
|
547
|
+
* XML format:
|
|
548
|
+
* ```xml
|
|
549
|
+
* <stats>
|
|
550
|
+
* <stat type="layout" fromdt="2026-02-10 12:00:00" todt="2026-02-10 12:05:00"
|
|
551
|
+
* scheduleid="123" layoutid="456" count="1" duration="300" />
|
|
552
|
+
* <stat type="media" fromdt="2026-02-10 12:00:00" todt="2026-02-10 12:01:00"
|
|
553
|
+
* scheduleid="123" layoutid="456" mediaid="789" count="1" duration="60" />
|
|
554
|
+
* </stats>
|
|
555
|
+
* ```
|
|
556
|
+
*
|
|
557
|
+
* @param {Array} stats - Array of stat objects from getStatsForSubmission()
|
|
558
|
+
* @returns {string} XML string for XMDS SubmitStats
|
|
559
|
+
*
|
|
560
|
+
* @example
|
|
561
|
+
* const stats = await collector.getStatsForSubmission(50);
|
|
562
|
+
* const xml = formatStats(stats);
|
|
563
|
+
* await xmds.submitStats(xml);
|
|
564
|
+
*/
|
|
565
|
+
export function formatStats(stats) {
|
|
566
|
+
if (!stats || stats.length === 0) {
|
|
567
|
+
return '<stats></stats>';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const statElements = stats.map((stat) => {
|
|
571
|
+
// Format dates as "YYYY-MM-DD HH:MM:SS"
|
|
572
|
+
const fromdt = formatDateTime(stat.start);
|
|
573
|
+
const todt = formatDateTime(stat.end || stat.start);
|
|
574
|
+
|
|
575
|
+
// Build attributes
|
|
576
|
+
const attrs = [
|
|
577
|
+
`type="${escapeXml(stat.type)}"`,
|
|
578
|
+
`fromdt="${escapeXml(fromdt)}"`,
|
|
579
|
+
`todt="${escapeXml(todt)}"`,
|
|
580
|
+
`scheduleid="${stat.scheduleId}"`,
|
|
581
|
+
`layoutid="${stat.layoutId}"`,
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
// Add mediaId for media stats
|
|
585
|
+
if (stat.type === 'media' && stat.mediaId) {
|
|
586
|
+
attrs.push(`mediaid="${stat.mediaId}"`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Add count and duration
|
|
590
|
+
attrs.push(`count="${stat.count}"`);
|
|
591
|
+
attrs.push(`duration="${stat.duration}"`);
|
|
592
|
+
|
|
593
|
+
return ` <stat ${attrs.join(' ')} />`;
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
return `<stats>\n${statElements.join('\n')}\n</stats>`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Format Date object as "YYYY-MM-DD HH:MM:SS"
|
|
601
|
+
* @private
|
|
602
|
+
*/
|
|
603
|
+
function formatDateTime(date) {
|
|
604
|
+
if (!(date instanceof Date)) {
|
|
605
|
+
date = new Date(date);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const year = date.getFullYear();
|
|
609
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
610
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
611
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
612
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
613
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
614
|
+
|
|
615
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Escape XML special characters
|
|
620
|
+
* @private
|
|
621
|
+
*/
|
|
622
|
+
function escapeXml(str) {
|
|
623
|
+
if (typeof str !== 'string') {
|
|
624
|
+
return str;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return str
|
|
628
|
+
.replace(/&/g, '&')
|
|
629
|
+
.replace(/</g, '<')
|
|
630
|
+
.replace(/>/g, '>')
|
|
631
|
+
.replace(/"/g, '"')
|
|
632
|
+
.replace(/'/g, ''');
|
|
633
|
+
}
|