@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.
@@ -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, '&amp;')
629
+ .replace(/</g, '&lt;')
630
+ .replace(/>/g, '&gt;')
631
+ .replace(/"/g, '&quot;')
632
+ .replace(/'/g, '&apos;');
633
+ }