@xiboplayer/xmds 0.5.20 → 0.6.1

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.
@@ -1,13 +1,18 @@
1
1
  /**
2
- * REST transport client for Xibo CMS.
2
+ * REST transport client for Xibo CMS Player API.
3
3
  *
4
- * Uses the /pwa REST API endpoints with JSON payloads and ETag caching.
5
- * Lighter than SOAP ~30% smaller payloads, standard HTTP semantics.
4
+ * Uses the Player API REST endpoints with JWT auth, resource-oriented URLs,
5
+ * and native JSON responses (no XML parsing required).
6
6
  *
7
- * Protocol: https://github.com/linuxnow/xibo_players_docs
7
+ * - JWT bearer token auth (single POST /auth → token for all requests)
8
+ * - Resource-oriented URLs (/displays/{id}/schedule vs /schedule)
9
+ * - Native JSON schedule (no client-side XML parsing)
10
+ * - Categorized required files (media/layouts/widgets)
11
+ * - CDN/reverse proxy compatible (GET with cache headers)
12
+ *
13
+ * Same public API as XmdsClient — drop-in replacement.
8
14
  */
9
- import { createLogger, fetchWithRetry } from '@xiboplayer/utils';
10
- import { parseScheduleResponse } from './schedule-parser.js';
15
+ import { createLogger, fetchWithRetry, PLAYER_API } from '@xiboplayer/utils';
11
16
 
12
17
  const log = createLogger('REST');
13
18
 
@@ -17,9 +22,14 @@ export class RestClient {
17
22
  this.schemaVersion = 7;
18
23
  this.retryOptions = config.retryOptions || { maxRetries: 2, baseDelayMs: 2000 };
19
24
 
25
+ // JWT auth state
26
+ this._token = null;
27
+ this._tokenExpiresAt = 0;
28
+ this._displayId = null;
29
+
20
30
  // ETag-based HTTP caching
21
- this._etags = new Map(); // endpoint → ETag string
22
- this._responseCache = new Map(); // endpoint → cached parsed response
31
+ this._etags = new Map();
32
+ this._responseCache = new Map();
23
33
 
24
34
  log.info('Using REST transport');
25
35
  }
@@ -28,10 +38,15 @@ export class RestClient {
28
38
 
29
39
  /**
30
40
  * Get the REST API base URL.
31
- * Falls back to /pwa path relative to the CMS address.
41
+ * In proxy mode (Electron/Chromium), returns the local relative path so
42
+ * requests go through the Express proxy's mirror routes.
43
+ * In direct mode (standalone PWA), returns the full CMS URL.
32
44
  */
33
45
  getRestBaseUrl() {
34
- const base = this.config.restApiUrl || `${this.config.cmsUrl}/pwa`;
46
+ if (this._isProxyMode()) {
47
+ return `${window.location.origin}${PLAYER_API}`;
48
+ }
49
+ const base = this.config.restApiUrl || `${this.config.cmsUrl}${PLAYER_API}`;
35
50
  return base.replace(/\/+$/, '');
36
51
  }
37
52
 
@@ -44,38 +59,62 @@ export class RestClient {
44
59
  window.location.hostname === 'localhost');
45
60
  }
46
61
 
62
+ // ─── JWT auth ─────────────────────────────────────────────────
63
+
47
64
  /**
48
- * Rewrite an absolute REST URL to go through /rest-proxy.
49
- * Preserves all query params from the original URL.
65
+ * Authenticate with the CMS and obtain a JWT token.
66
+ * Called automatically before the first authenticated request.
50
67
  */
51
- _rewriteForProxy(urlString) {
52
- if (!this._isProxyMode() || !urlString.startsWith('http')) return urlString;
53
- const parsed = new URL(urlString);
54
- const proxyUrl = new URL('/rest-proxy', window.location.origin);
55
- proxyUrl.searchParams.set('cms', parsed.origin);
56
- proxyUrl.searchParams.set('path', parsed.pathname);
57
- // Forward all original query params
58
- for (const [key, value] of parsed.searchParams) {
59
- proxyUrl.searchParams.set(key, value);
68
+ async _authenticate() {
69
+ const url = `${this.getRestBaseUrl()}/auth`;
70
+
71
+ log.debug('Authenticating...');
72
+
73
+ const response = await fetchWithRetry(url, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({
77
+ serverKey: this.config.cmsKey,
78
+ hardwareKey: this.config.hardwareKey,
79
+ }),
80
+ }, this.retryOptions);
81
+
82
+ if (!response.ok) {
83
+ const errorBody = await response.text().catch(() => '');
84
+ throw new Error(`Auth failed: ${response.status} ${response.statusText} ${errorBody}`);
60
85
  }
61
- return proxyUrl.toString();
86
+
87
+ const data = await response.json();
88
+ this._token = data.token;
89
+ this._displayId = data.displayId;
90
+ // Refresh 60s before expiry to avoid edge-case rejections
91
+ this._tokenExpiresAt = Date.now() + (data.expiresIn - 60) * 1000;
92
+
93
+ log.info(`Authenticated as display ${this._displayId}`);
62
94
  }
63
95
 
64
96
  /**
65
- * Make a REST GET request with optional ETag caching.
66
- * Returns the parsed JSON body, or cached data on 304.
97
+ * Get a valid JWT token, refreshing if expired or missing.
98
+ */
99
+ async _getToken() {
100
+ if (!this._token || Date.now() >= this._tokenExpiresAt) {
101
+ await this._authenticate();
102
+ }
103
+ return this._token;
104
+ }
105
+
106
+ /**
107
+ * Make an authenticated GET request with ETag caching.
67
108
  */
68
109
  async restGet(path, queryParams = {}) {
110
+ const token = await this._getToken();
69
111
  const url = new URL(`${this.getRestBaseUrl()}${path}`);
70
- url.searchParams.set('serverKey', this.config.cmsKey);
71
- url.searchParams.set('hardwareKey', this.config.hardwareKey);
72
- url.searchParams.set('v', String(this.schemaVersion));
73
112
  for (const [key, value] of Object.entries(queryParams)) {
74
113
  url.searchParams.set(key, String(value));
75
114
  }
76
115
 
77
116
  const cacheKey = path;
78
- const headers = {};
117
+ const headers = { 'Authorization': `Bearer ${token}` };
79
118
  const cachedEtag = this._etags.get(cacheKey);
80
119
  if (cachedEtag) {
81
120
  headers['If-None-Match'] = cachedEtag;
@@ -83,19 +122,23 @@ export class RestClient {
83
122
 
84
123
  log.debug(`GET ${path}`, queryParams);
85
124
 
86
- const response = await fetchWithRetry(this._rewriteForProxy(url.toString()), {
125
+ const response = await fetchWithRetry(url.toString(), {
87
126
  method: 'GET',
88
127
  headers,
89
128
  }, this.retryOptions);
90
129
 
91
- // 304 Not Modifiedreturn cached response
130
+ // Token expired mid-flightre-auth and retry once
131
+ if (response.status === 401) {
132
+ this._token = null;
133
+ return this.restGet(path, queryParams);
134
+ }
135
+
92
136
  if (response.status === 304) {
93
137
  const cached = this._responseCache.get(cacheKey);
94
138
  if (cached) {
95
139
  log.debug(`${path} → 304 (using cache)`);
96
140
  return cached;
97
141
  }
98
- // Cache miss despite 304 — fall through to fetch fresh
99
142
  }
100
143
 
101
144
  if (!response.ok) {
@@ -103,7 +146,6 @@ export class RestClient {
103
146
  throw new Error(`REST GET ${path} failed: ${response.status} ${response.statusText} ${errorBody}`);
104
147
  }
105
148
 
106
- // Store ETag for future requests
107
149
  const etag = response.headers.get('ETag');
108
150
  if (etag) {
109
151
  this._etags.set(cacheKey, etag);
@@ -114,35 +156,37 @@ export class RestClient {
114
156
  if (contentType.includes('application/json')) {
115
157
  data = await response.json();
116
158
  } else {
117
- // XML or HTML — return raw text
118
159
  data = await response.text();
119
160
  }
120
161
 
121
- // Cache parsed response for 304 reuse
122
162
  this._responseCache.set(cacheKey, data);
123
163
  return data;
124
164
  }
125
165
 
126
166
  /**
127
- * Make a REST POST/PUT request with JSON body.
128
- * Returns the parsed JSON response.
167
+ * Make an authenticated POST/PUT request with JSON body.
129
168
  */
130
169
  async restSend(method, path, body = {}) {
170
+ const token = await this._getToken();
131
171
  const url = new URL(`${this.getRestBaseUrl()}${path}`);
132
- url.searchParams.set('v', String(this.schemaVersion));
133
172
 
134
173
  log.debug(`${method} ${path}`);
135
174
 
136
- const response = await fetchWithRetry(this._rewriteForProxy(url.toString()), {
175
+ const response = await fetchWithRetry(url.toString(), {
137
176
  method,
138
- headers: { 'Content-Type': 'application/json' },
139
- body: JSON.stringify({
140
- serverKey: this.config.cmsKey,
141
- hardwareKey: this.config.hardwareKey,
142
- ...body,
143
- }),
177
+ headers: {
178
+ 'Content-Type': 'application/json',
179
+ 'Authorization': `Bearer ${token}`,
180
+ },
181
+ body: JSON.stringify(body),
144
182
  }, this.retryOptions);
145
183
 
184
+ // Token expired mid-flight — re-auth and retry once
185
+ if (response.status === 401) {
186
+ this._token = null;
187
+ return this.restSend(method, path, body);
188
+ }
189
+
146
190
  if (!response.ok) {
147
191
  const errorBody = await response.text().catch(() => '');
148
192
  throw new Error(`REST ${method} ${path} failed: ${response.status} ${response.statusText} ${errorBody}`);
@@ -158,34 +202,36 @@ export class RestClient {
158
202
  // ─── Public API ─────────────────────────────────────────────────
159
203
 
160
204
  /**
161
- * RegisterDisplay - authenticate and get settings
162
- * POST /register → JSON with display settings
205
+ * RegisterDisplay - authenticate and get settings.
206
+ * POST /displays → JSON with display settings
163
207
  */
164
208
  async registerDisplay() {
209
+ // Auth first to get displayId
210
+ await this._getToken();
211
+
165
212
  const os = typeof navigator !== 'undefined'
166
213
  ? `${navigator.platform} ${navigator.userAgent}`
167
214
  : 'unknown';
168
215
 
169
- const json = await this.restSend('POST', '/register', {
216
+ const json = await this.restSend('POST', '/displays', {
170
217
  displayName: this.config.displayName,
171
- clientType: this.config.clientType || 'chromeOS',
218
+ clientType: this.config.clientType || 'linux',
172
219
  clientVersion: this.config.clientVersion || '0.1.0',
173
220
  clientCode: this.config.clientCode || 1,
174
221
  operatingSystem: os,
175
222
  macAddress: this.config.macAddress || 'n/a',
176
223
  xmrChannel: this.config.xmrChannel,
177
224
  xmrPubKey: this.config.xmrPubKey || '',
178
- licenceResult: 'licensed',
179
225
  });
180
226
 
181
227
  return this._parseRegisterDisplayJson(json);
182
228
  }
183
229
 
184
230
  /**
185
- * Parse REST JSON RegisterDisplay response into the same format as SOAP.
231
+ * Parse register display JSON response.
232
+ * Same output format as XmdsClient.
186
233
  */
187
234
  _parseRegisterDisplayJson(json) {
188
- // Handle both direct object and wrapped {display: ...} forms
189
235
  const display = json.display || json;
190
236
  const attrs = display['@attributes'] || {};
191
237
  const code = attrs.code || display.code;
@@ -201,7 +247,6 @@ export class RestClient {
201
247
  for (const [key, value] of Object.entries(display)) {
202
248
  if (key === '@attributes' || key === 'file') continue;
203
249
  if (key === 'commands') {
204
- // Parse commands: array of {code/commandCode, commandString} objects
205
250
  if (Array.isArray(value)) {
206
251
  commands = value.map(c => ({
207
252
  commandCode: c.code || c.commandCode || '',
@@ -211,16 +256,10 @@ export class RestClient {
211
256
  continue;
212
257
  }
213
258
  if (key === 'tags') {
214
- // Parse tags from CMS JSON (SimpleXMLElement serialization varies):
215
- // Array of strings: ["geoApiKey|AIzaSy..."]
216
- // Array of objects: [{tag: "geoApiKey|AIzaSy..."}]
217
- // Single-tag object: {tag: "geoApiKey|AIzaSy..."} (SimpleXMLElement collapses single-element arrays)
218
- // String: "geoApiKey|AIzaSy..."
219
259
  const extractTag = (t) => typeof t === 'object' ? (t.tag || t.value || '') : String(t);
220
260
  if (Array.isArray(value)) {
221
261
  tags = value.map(extractTag).filter(Boolean);
222
262
  } else if (value && typeof value === 'object') {
223
- // Single tag: {tag: "value"} — wrap in array
224
263
  const t = extractTag(value);
225
264
  if (t) tags = [t];
226
265
  } else if (typeof value === 'string' && value) {
@@ -234,7 +273,6 @@ export class RestClient {
234
273
  const checkRf = attrs.checkRf || '';
235
274
  const checkSchedule = attrs.checkSchedule || '';
236
275
 
237
- // Extract display-level attributes from CMS (server time, status, version info)
238
276
  const displayAttrs = {
239
277
  date: attrs.date || display.date || null,
240
278
  timezone: attrs.timezone || display.timezone || null,
@@ -243,8 +281,6 @@ export class RestClient {
243
281
  version_instructions: attrs.version_instructions || display.version_instructions || null,
244
282
  };
245
283
 
246
- // Extract sync group config if present (multi-display sync coordination)
247
- // syncGroup: "lead" if this display is leader, or leader's LAN IP if follower
248
284
  const syncConfig = display.syncGroup ? {
249
285
  syncGroup: String(display.syncGroup),
250
286
  syncPublisherPort: parseInt(display.syncPublisherPort || '9590', 10),
@@ -257,88 +293,124 @@ export class RestClient {
257
293
  }
258
294
 
259
295
  /**
260
- * RequiredFiles - get list of files to download
261
- * GET /requiredFiles → JSON file manifest (with ETag caching)
296
+ * RequiredFiles - get list of files to download.
297
+ * GET /displays/{id}/mediacategorized JSON (no XML parsing)
262
298
  */
263
299
  async requiredFiles() {
264
- const json = await this.restGet('/requiredFiles');
265
- return this._parseRequiredFilesJson(json);
300
+ const json = await this.restGet(`/displays/${this._displayId}/media`);
301
+ return this._parseRequiredFilesV2(json);
266
302
  }
267
303
 
268
304
  /**
269
- * Parse REST JSON RequiredFiles into the same array format as SOAP.
305
+ * Parse v2 categorized required files into the same flat format
306
+ * that the download pipeline expects.
307
+ *
308
+ * v2 server returns: { media: [...], layouts: [...], widgets: [...] }
309
+ * We flatten back to: { files: [...], purge: [] }
270
310
  */
271
- _parseRequiredFilesJson(json) {
311
+ _parseRequiredFilesV2(json) {
272
312
  const files = [];
273
- let fileList = json.file || [];
274
313
 
275
- // Normalize single item to array
276
- if (!Array.isArray(fileList)) {
277
- fileList = [fileList];
314
+ // Media files (images, videos)
315
+ for (const m of json.media || []) {
316
+ files.push({
317
+ type: m.type || 'media',
318
+ id: m.id != null ? String(m.id) : null,
319
+ size: m.fileSize || 0,
320
+ md5: m.md5 || null,
321
+ download: 'http',
322
+ path: m.url || null,
323
+ saveAs: m.saveAs || null,
324
+ fileType: null,
325
+ code: null,
326
+ layoutid: null,
327
+ regionid: null,
328
+ mediaid: null,
329
+ });
330
+ }
331
+
332
+ // Layout files
333
+ for (const l of json.layouts || []) {
334
+ files.push({
335
+ type: 'layout',
336
+ id: l.id != null ? String(l.id) : null,
337
+ size: l.fileSize || 0,
338
+ md5: l.md5 || null,
339
+ download: 'http',
340
+ path: l.url || null,
341
+ saveAs: null,
342
+ fileType: null,
343
+ code: null,
344
+ layoutid: null,
345
+ regionid: null,
346
+ mediaid: null,
347
+ });
278
348
  }
279
349
 
280
- for (const f of fileList) {
281
- const attrs = f['@attributes'] || f;
282
- const path = attrs.path || null;
350
+ // Widget data files (datasets dynamic API, not static media)
351
+ for (const w of json.widgets || []) {
283
352
  files.push({
284
- type: attrs.type || null,
285
- id: attrs.id || null,
286
- size: parseInt(attrs.size || '0'),
287
- md5: attrs.md5 || null,
288
- download: attrs.download || null,
289
- path,
290
- saveAs: attrs.saveAs || null,
291
- fileType: attrs.fileType || null,
292
- code: attrs.code || null,
293
- layoutid: attrs.layoutid || null,
294
- regionid: attrs.regionid || null,
295
- mediaid: attrs.mediaid || null,
353
+ type: 'dataset',
354
+ id: w.id != null ? String(w.id) : null,
355
+ size: 0,
356
+ md5: w.md5 || null,
357
+ download: 'http',
358
+ path: w.url || null,
359
+ saveAs: null,
360
+ fileType: null,
361
+ code: null,
362
+ layoutid: null,
363
+ regionid: null,
364
+ mediaid: null,
365
+ updateInterval: w.updateInterval || 0,
296
366
  });
297
367
  }
298
368
 
299
- // Parse purge items files CMS wants the player to delete
300
- const purgeItems = [];
301
- let purgeList = json.purge?.item || [];
302
- if (!Array.isArray(purgeList)) purgeList = [purgeList];
303
- for (const p of purgeList) {
304
- const pAttrs = p['@attributes'] || p;
305
- purgeItems.push({
306
- id: pAttrs.id || null,
307
- storedAs: pAttrs.storedAs || null,
369
+ // Dependencies (fonts, CSS, JS bundles) pre-classified as 'static'
370
+ for (const d of json.dependencies || []) {
371
+ files.push({
372
+ type: 'static',
373
+ id: d.id != null ? String(d.id) : null,
374
+ size: d.fileSize || 0,
375
+ md5: d.md5 || null,
376
+ download: 'http',
377
+ path: d.url || null,
378
+ saveAs: null,
379
+ fileType: d.type || null,
380
+ code: null,
381
+ layoutid: null,
382
+ regionid: null,
383
+ mediaid: null,
308
384
  });
309
385
  }
310
386
 
311
- return { files, purge: purgeItems };
387
+ return { files, purge: [] };
312
388
  }
313
389
 
314
390
  /**
315
- * Schedule - get layout schedule
316
- * GET /schedule → XML (preserved for layout parser compatibility, with ETag caching)
391
+ * Schedule - get layout schedule.
392
+ * GET /displays/{id}/schedule → native JSON (no XML parsing needed!)
393
+ *
394
+ * The v2 server returns the same structure as parseScheduleResponse(),
395
+ * so we return it directly.
317
396
  */
318
397
  async schedule() {
319
- const xml = await this.restGet('/schedule');
320
- return parseScheduleResponse(xml);
398
+ return this.restGet(`/displays/${this._displayId}/schedule`);
321
399
  }
322
400
 
323
401
  /**
324
- * GetResource - get rendered widget HTML
325
- * GET /getResource → HTML string
402
+ * GetResource - get rendered widget HTML.
403
+ * GET /widgets/{layoutId}/{regionId}/{mediaId} → HTML string
326
404
  */
327
405
  async getResource(layoutId, regionId, mediaId) {
328
- return this.restGet('/getResource', {
329
- layoutId: String(layoutId),
330
- regionId: String(regionId),
331
- mediaId: String(mediaId),
332
- });
406
+ return this.restGet(`/widgets/${layoutId}/${regionId}/${mediaId}`);
333
407
  }
334
408
 
335
409
  /**
336
- * NotifyStatus - report current status
337
- * PUT /status → JSON acknowledgement
338
- * @param {Object} status - Status object with currentLayoutId, deviceName, etc.
410
+ * NotifyStatus - report current status.
411
+ * PUT /displays/{id}/status → JSON acknowledgement
339
412
  */
340
413
  async notifyStatus(status) {
341
- // Enrich with storage estimate if available
342
414
  if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
343
415
  try {
344
416
  const estimate = await navigator.storage.estimate();
@@ -347,115 +419,119 @@ export class RestClient {
347
419
  } catch (_) { /* storage estimate not supported */ }
348
420
  }
349
421
 
350
- // Add timezone if not already provided
351
422
  if (!status.timeZone && typeof Intl !== 'undefined') {
352
423
  status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
353
424
  }
354
425
 
355
- // Add statusDialog (summary for CMS display status page) if not provided
356
426
  if (!status.statusDialog) {
357
427
  status.statusDialog = `Current Layout: ${status.currentLayoutId || 'None'}`;
358
428
  }
359
429
 
360
- return this.restSend('PUT', '/status', {
430
+ return this.restSend('PUT', `/displays/${this._displayId}/status`, {
361
431
  statusData: status,
362
432
  });
363
433
  }
364
434
 
365
435
  /**
366
- * MediaInventory - report downloaded files
367
- * POST /mediaInventory → JSON acknowledgement
436
+ * MediaInventory - report downloaded files.
437
+ * PUT /displays/{id}/inventory → JSON acknowledgement
368
438
  */
369
439
  async mediaInventory(inventoryXml) {
370
- // Accept array (JSON-native) or string (XML) — send under the right key
371
440
  const body = Array.isArray(inventoryXml)
372
441
  ? { inventoryItems: inventoryXml }
373
442
  : { inventory: inventoryXml };
374
- return this.restSend('POST', '/mediaInventory', body);
443
+ return this.restSend('PUT', `/displays/${this._displayId}/inventory`, body);
375
444
  }
376
445
 
377
446
  /**
378
- * BlackList - report broken media to CMS
379
- * POST /blacklist JSON acknowledgement
380
- * @param {string|number} mediaId - The media ID
381
- * @param {string} type - File type ('media' or 'layout')
382
- * @param {string} reason - Reason for blacklisting
383
- * @returns {Promise<boolean>}
447
+ * BlackList - report broken media to CMS.
448
+ * Not in v2 API — falls back to v1 behavior (no-op with warning).
384
449
  */
385
450
  async blackList(mediaId, type, reason) {
386
- try {
387
- const result = await this.restSend('POST', '/blacklist', {
388
- mediaId: String(mediaId),
389
- type: type || 'media',
390
- reason: reason || 'Failed to render',
391
- });
392
- log.info(`BlackListed ${type}/${mediaId}: ${reason}`);
393
- return result?.success === true;
394
- } catch (error) {
395
- log.warn('BlackList failed:', error);
396
- return false;
397
- }
451
+ log.warn(`BlackList not available in v2 API (${type}/${mediaId}: ${reason})`);
452
+ return false;
398
453
  }
399
454
 
400
455
  /**
401
- * SubmitLog - submit player logs to CMS
402
- * POST /log → JSON acknowledgement
456
+ * SubmitLog - submit player logs to CMS.
457
+ * POST /displays/{id}/logs → JSON acknowledgement
403
458
  */
404
459
  async submitLog(logXml, hardwareKeyOverride = null) {
405
- // Accept array (JSON-native) or string (XML) — send under the right key
406
460
  const body = Array.isArray(logXml) ? { logs: logXml } : { logXml };
407
- if (hardwareKeyOverride) body.hardwareKey = hardwareKeyOverride;
408
- const result = await this.restSend('POST', '/log', body);
461
+ const result = await this.restSend('POST', `/displays/${this._displayId}/logs`, body);
409
462
  return result?.success === true;
410
463
  }
411
464
 
412
465
  /**
413
- * SubmitScreenShot - submit screenshot to CMS
414
- * POST /screenshot → JSON acknowledgement
466
+ * SubmitScreenShot - submit screenshot to CMS.
467
+ * POST /displays/{id}/screenshot → JSON acknowledgement
415
468
  */
416
469
  async submitScreenShot(base64Image) {
417
- const result = await this.restSend('POST', '/screenshot', {
470
+ const result = await this.restSend('POST', `/displays/${this._displayId}/screenshot`, {
418
471
  screenshot: base64Image,
419
472
  });
420
473
  return result?.success === true;
421
474
  }
422
475
 
423
476
  /**
424
- * SubmitStats - submit proof of play statistics
425
- * POST /stats → JSON acknowledgement
477
+ * SubmitStats - submit proof of play statistics.
478
+ * POST /displays/{id}/stats → JSON acknowledgement
426
479
  */
480
+ async submitStats(statsXml, hardwareKeyOverride = null) {
481
+ try {
482
+ const body = Array.isArray(statsXml) ? { stats: statsXml } : { statXml: statsXml };
483
+ const result = await this.restSend('POST', `/displays/${this._displayId}/stats`, body);
484
+ const success = result?.success === true;
485
+ log.info(`SubmitStats result: ${success}`);
486
+ return success;
487
+ } catch (error) {
488
+ log.error('SubmitStats failed:', error);
489
+ throw error;
490
+ }
491
+ }
492
+
427
493
  /**
428
- * ReportFaults - submit fault data to CMS for dashboard alerts
429
- * POST /fault → JSON acknowledgement
430
- * @param {string} faultJson - JSON-encoded fault data
431
- * @returns {Promise<boolean>}
494
+ * ReportFaults - submit fault data to CMS for dashboard alerts.
495
+ * POST /displays/{id}/faults → JSON acknowledgement
432
496
  */
433
497
  async reportFaults(faultJson) {
434
- const result = await this.restSend('POST', '/fault', { fault: faultJson });
498
+ const result = await this.restSend('POST', `/displays/${this._displayId}/faults`, {
499
+ fault: faultJson,
500
+ });
435
501
  return result?.success === true;
436
502
  }
437
503
 
438
504
  /**
439
- * GetWeather - get current weather data for schedule criteria
440
- * GET /weather → JSON weather data
441
- * @returns {Promise<Object>} Weather data from CMS
505
+ * GetWeather - get current weather data for schedule criteria.
506
+ * GET /displays/{id}/weather → JSON weather data
442
507
  */
443
508
  async getWeather() {
444
- return this.restGet('/weather');
509
+ return this.restGet(`/displays/${this._displayId}/weather`);
445
510
  }
446
511
 
447
- async submitStats(statsXml, hardwareKeyOverride = null) {
512
+ // ─── Static helpers ───────────────────────────────────────────
513
+
514
+ /**
515
+ * Probe whether the CMS supports API v2.
516
+ * GET /api/v2/player/health → { version: 2, status: "ok" }
517
+ *
518
+ * @param {string} cmsUrl - CMS base URL
519
+ * @param {Object} [retryOptions] - Retry options for fetch
520
+ * @returns {Promise<boolean>} true if v2 is available
521
+ */
522
+ static async isAvailable(cmsUrl, retryOptions) {
448
523
  try {
449
- // Accept array (JSON-native) or string (XML) send under the right key
450
- const body = Array.isArray(statsXml) ? { stats: statsXml } : { statXml: statsXml };
451
- if (hardwareKeyOverride) body.hardwareKey = hardwareKeyOverride;
452
- const result = await this.restSend('POST', '/stats', body);
453
- const success = result?.success === true;
454
- log.info(`SubmitStats result: ${success}`);
455
- return success;
456
- } catch (error) {
457
- log.error('SubmitStats failed:', error);
458
- throw error;
524
+ // In proxy mode, probe the local proxy's forward route instead of the CMS directly (avoids CORS)
525
+ const isProxy = typeof window !== 'undefined' &&
526
+ (window.electronAPI?.isElectron || window.location.hostname === 'localhost');
527
+ const base = isProxy ? '' : cmsUrl.replace(/\/+$/, '');
528
+ const url = `${base}${PLAYER_API}/health`;
529
+ const response = await fetchWithRetry(url, { method: 'GET' }, retryOptions || { maxRetries: 0 });
530
+ if (!response.ok) return false;
531
+ const data = await response.json();
532
+ return data.version === 2 && data.status === 'ok';
533
+ } catch {
534
+ return false;
459
535
  }
460
536
  }
461
537
  }
@@ -150,7 +150,7 @@ export class XmdsClient {
150
150
  serverKey: this.config.cmsKey,
151
151
  hardwareKey: this.config.hardwareKey,
152
152
  displayName: this.config.displayName,
153
- clientType: this.config.clientType || 'chromeOS',
153
+ clientType: this.config.clientType || 'linux',
154
154
  clientVersion: this.config.clientVersion || '0.1.0',
155
155
  clientCode: this.config.clientCode || '1',
156
156
  operatingSystem: os,