@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.
- package/package.json +2 -2
- package/src/index.d.ts +11 -7
- package/src/rest-client.js +242 -166
- package/src/xmds-client.js +1 -1
- package/src/xmds.rest.integration.test.js +172 -508
- package/src/xmds.test.js +2 -2
- package/src/xmds.rest.test.js +0 -742
package/src/rest-client.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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();
|
|
22
|
-
this._responseCache = new Map();
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
49
|
-
*
|
|
65
|
+
* Authenticate with the CMS and obtain a JWT token.
|
|
66
|
+
* Called automatically before the first authenticated request.
|
|
50
67
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
66
|
-
|
|
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(
|
|
125
|
+
const response = await fetchWithRetry(url.toString(), {
|
|
87
126
|
method: 'GET',
|
|
88
127
|
headers,
|
|
89
128
|
}, this.retryOptions);
|
|
90
129
|
|
|
91
|
-
//
|
|
130
|
+
// Token expired mid-flight — re-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
|
|
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(
|
|
175
|
+
const response = await fetchWithRetry(url.toString(), {
|
|
137
176
|
method,
|
|
138
|
-
headers: {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 /
|
|
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', '/
|
|
216
|
+
const json = await this.restSend('POST', '/displays', {
|
|
170
217
|
displayName: this.config.displayName,
|
|
171
|
-
clientType: this.config.clientType || '
|
|
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
|
|
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 /
|
|
296
|
+
* RequiredFiles - get list of files to download.
|
|
297
|
+
* GET /displays/{id}/media → categorized JSON (no XML parsing)
|
|
262
298
|
*/
|
|
263
299
|
async requiredFiles() {
|
|
264
|
-
const json = await this.restGet(
|
|
265
|
-
return this.
|
|
300
|
+
const json = await this.restGet(`/displays/${this._displayId}/media`);
|
|
301
|
+
return this._parseRequiredFilesV2(json);
|
|
266
302
|
}
|
|
267
303
|
|
|
268
304
|
/**
|
|
269
|
-
* Parse
|
|
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
|
-
|
|
311
|
+
_parseRequiredFilesV2(json) {
|
|
272
312
|
const files = [];
|
|
273
|
-
let fileList = json.file || [];
|
|
274
313
|
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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:
|
|
285
|
-
id:
|
|
286
|
-
size:
|
|
287
|
-
md5:
|
|
288
|
-
download:
|
|
289
|
-
path,
|
|
290
|
-
saveAs:
|
|
291
|
-
fileType:
|
|
292
|
-
code:
|
|
293
|
-
layoutid:
|
|
294
|
-
regionid:
|
|
295
|
-
mediaid:
|
|
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
|
-
//
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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:
|
|
387
|
+
return { files, purge: [] };
|
|
312
388
|
}
|
|
313
389
|
|
|
314
390
|
/**
|
|
315
|
-
* Schedule - get layout schedule
|
|
316
|
-
* GET /schedule →
|
|
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
|
-
|
|
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 /
|
|
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(
|
|
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',
|
|
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
|
-
*
|
|
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('
|
|
443
|
+
return this.restSend('PUT', `/displays/${this._displayId}/inventory`, body);
|
|
375
444
|
}
|
|
376
445
|
|
|
377
446
|
/**
|
|
378
|
-
* BlackList - report broken media to CMS
|
|
379
|
-
*
|
|
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
|
-
|
|
387
|
-
|
|
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 /
|
|
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
|
-
|
|
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',
|
|
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 /
|
|
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',
|
|
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(
|
|
509
|
+
return this.restGet(`/displays/${this._displayId}/weather`);
|
|
445
510
|
}
|
|
446
511
|
|
|
447
|
-
|
|
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
|
-
//
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
return
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
}
|
package/src/xmds-client.js
CHANGED
|
@@ -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 || '
|
|
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,
|