@xiboplayer/xmds 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/docs/README.md +60 -0
- package/package.json +36 -0
- package/src/index.js +4 -0
- package/src/rest-client.js +342 -0
- package/src/schedule-parser.js +172 -0
- package/src/xmds-client.js +373 -0
- package/src/xmds.overlays.test.js +170 -0
- package/src/xmds.rest.integration.test.js +659 -0
- package/src/xmds.rest.test.js +676 -0
- package/src/xmds.test.js +831 -0
- package/vitest.config.js +8 -0
package/docs/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @xiboplayer/xmds Documentation
|
|
2
|
+
|
|
3
|
+
**XMDS (XML-based Media Distribution Service) SOAP client.**
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
SOAP client for Xibo CMS communication:
|
|
8
|
+
|
|
9
|
+
- Display registration
|
|
10
|
+
- Content synchronization
|
|
11
|
+
- File downloads
|
|
12
|
+
- Proof of play submission
|
|
13
|
+
- Log reporting
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @xiboplayer/xmds
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
import { XMDSClient } from '@xiboplayer/xmds';
|
|
25
|
+
|
|
26
|
+
const client = new XMDSClient({
|
|
27
|
+
cmsUrl: 'https://cms.example.com',
|
|
28
|
+
serverKey: 'abc123',
|
|
29
|
+
hardwareKey: 'def456'
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Register display
|
|
33
|
+
await client.registerDisplay();
|
|
34
|
+
|
|
35
|
+
// Get required files
|
|
36
|
+
const files = await client.requiredFiles();
|
|
37
|
+
|
|
38
|
+
// Download file
|
|
39
|
+
const blob = await client.getFile(fileId, fileType);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## SOAP Methods
|
|
43
|
+
|
|
44
|
+
- `RegisterDisplay` - Register/verify display
|
|
45
|
+
- `RequiredFiles` - Get content to download
|
|
46
|
+
- `GetFile` - Download media file
|
|
47
|
+
- `SubmitStats` - Send proof of play
|
|
48
|
+
- `SubmitLog` - Report errors
|
|
49
|
+
|
|
50
|
+
## Dependencies
|
|
51
|
+
|
|
52
|
+
- `@xiboplayer/utils` - Logger
|
|
53
|
+
|
|
54
|
+
## Related Packages
|
|
55
|
+
|
|
56
|
+
- [@xiboplayer/core](../../core/docs/) - Player core
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
**Package Version**: 1.0.0
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xiboplayer/xmds",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "XMDS SOAP client for Xibo CMS communication",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./xmds": "./src/xmds.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@xiboplayer/utils": "0.1.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"vitest": "^2.0.0"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"xibo",
|
|
19
|
+
"digital-signage",
|
|
20
|
+
"xmds",
|
|
21
|
+
"soap",
|
|
22
|
+
"cms"
|
|
23
|
+
],
|
|
24
|
+
"author": "Pau Aliagas <linuxnow@gmail.com>",
|
|
25
|
+
"license": "AGPL-3.0-or-later",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/xibo-players/xiboplayer.git",
|
|
29
|
+
"directory": "packages/xmds"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"test:coverage": "vitest run --coverage"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST transport client for Xibo CMS.
|
|
3
|
+
*
|
|
4
|
+
* Uses the /pwa REST API endpoints with JSON payloads and ETag caching.
|
|
5
|
+
* Lighter than SOAP — ~30% smaller payloads, standard HTTP semantics.
|
|
6
|
+
*
|
|
7
|
+
* Protocol: https://github.com/linuxnow/xibo_players_docs
|
|
8
|
+
*/
|
|
9
|
+
import { createLogger, fetchWithRetry } from '@xiboplayer/utils';
|
|
10
|
+
import { parseScheduleResponse } from './schedule-parser.js';
|
|
11
|
+
|
|
12
|
+
const log = createLogger('REST');
|
|
13
|
+
|
|
14
|
+
export class RestClient {
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.schemaVersion = 7;
|
|
18
|
+
this.retryOptions = config.retryOptions || { maxRetries: 2, baseDelayMs: 2000 };
|
|
19
|
+
|
|
20
|
+
// ETag-based HTTP caching
|
|
21
|
+
this._etags = new Map(); // endpoint → ETag string
|
|
22
|
+
this._responseCache = new Map(); // endpoint → cached parsed response
|
|
23
|
+
|
|
24
|
+
log.info('Using REST transport');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Transport helpers ──────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the REST API base URL.
|
|
31
|
+
* Falls back to /pwa path relative to the CMS address.
|
|
32
|
+
*/
|
|
33
|
+
getRestBaseUrl() {
|
|
34
|
+
const base = this.config.restApiUrl || `${this.config.cmsAddress}/pwa`;
|
|
35
|
+
return base.replace(/\/+$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Make a REST GET request with optional ETag caching.
|
|
40
|
+
* Returns the parsed JSON body, or cached data on 304.
|
|
41
|
+
*/
|
|
42
|
+
async restGet(path, queryParams = {}) {
|
|
43
|
+
const url = new URL(`${this.getRestBaseUrl()}${path}`);
|
|
44
|
+
url.searchParams.set('serverKey', this.config.cmsKey);
|
|
45
|
+
url.searchParams.set('hardwareKey', this.config.hardwareKey);
|
|
46
|
+
url.searchParams.set('v', String(this.schemaVersion));
|
|
47
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
48
|
+
url.searchParams.set(key, String(value));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cacheKey = path;
|
|
52
|
+
const headers = {};
|
|
53
|
+
const cachedEtag = this._etags.get(cacheKey);
|
|
54
|
+
if (cachedEtag) {
|
|
55
|
+
headers['If-None-Match'] = cachedEtag;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
log.debug(`GET ${path}`, queryParams);
|
|
59
|
+
|
|
60
|
+
const response = await fetchWithRetry(url.toString(), {
|
|
61
|
+
method: 'GET',
|
|
62
|
+
headers,
|
|
63
|
+
}, this.retryOptions);
|
|
64
|
+
|
|
65
|
+
// 304 Not Modified — return cached response
|
|
66
|
+
if (response.status === 304) {
|
|
67
|
+
const cached = this._responseCache.get(cacheKey);
|
|
68
|
+
if (cached) {
|
|
69
|
+
log.debug(`${path} → 304 (using cache)`);
|
|
70
|
+
return cached;
|
|
71
|
+
}
|
|
72
|
+
// Cache miss despite 304 — fall through to fetch fresh
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
const errorBody = await response.text().catch(() => '');
|
|
77
|
+
throw new Error(`REST GET ${path} failed: ${response.status} ${response.statusText} ${errorBody}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Store ETag for future requests
|
|
81
|
+
const etag = response.headers.get('ETag');
|
|
82
|
+
if (etag) {
|
|
83
|
+
this._etags.set(cacheKey, etag);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
87
|
+
let data;
|
|
88
|
+
if (contentType.includes('application/json')) {
|
|
89
|
+
data = await response.json();
|
|
90
|
+
} else {
|
|
91
|
+
// XML or HTML — return raw text
|
|
92
|
+
data = await response.text();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Cache parsed response for 304 reuse
|
|
96
|
+
this._responseCache.set(cacheKey, data);
|
|
97
|
+
return data;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Make a REST POST/PUT request with JSON body.
|
|
102
|
+
* Returns the parsed JSON response.
|
|
103
|
+
*/
|
|
104
|
+
async restSend(method, path, body = {}) {
|
|
105
|
+
const url = new URL(`${this.getRestBaseUrl()}${path}`);
|
|
106
|
+
url.searchParams.set('v', String(this.schemaVersion));
|
|
107
|
+
|
|
108
|
+
log.debug(`${method} ${path}`);
|
|
109
|
+
|
|
110
|
+
const response = await fetchWithRetry(url.toString(), {
|
|
111
|
+
method,
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
serverKey: this.config.cmsKey,
|
|
115
|
+
hardwareKey: this.config.hardwareKey,
|
|
116
|
+
...body,
|
|
117
|
+
}),
|
|
118
|
+
}, this.retryOptions);
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const errorBody = await response.text().catch(() => '');
|
|
122
|
+
throw new Error(`REST ${method} ${path} failed: ${response.status} ${response.statusText} ${errorBody}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
126
|
+
if (contentType.includes('application/json')) {
|
|
127
|
+
return await response.json();
|
|
128
|
+
}
|
|
129
|
+
return await response.text();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Public API ─────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* RegisterDisplay - authenticate and get settings
|
|
136
|
+
* POST /register → JSON with display settings
|
|
137
|
+
*/
|
|
138
|
+
async registerDisplay() {
|
|
139
|
+
const os = typeof navigator !== 'undefined'
|
|
140
|
+
? `${navigator.platform} ${navigator.userAgent}`
|
|
141
|
+
: 'unknown';
|
|
142
|
+
|
|
143
|
+
const json = await this.restSend('POST', '/register', {
|
|
144
|
+
displayName: this.config.displayName,
|
|
145
|
+
clientType: 'chromeOS',
|
|
146
|
+
clientVersion: '0.1.0',
|
|
147
|
+
clientCode: 1,
|
|
148
|
+
operatingSystem: os,
|
|
149
|
+
macAddress: this.config.macAddress || 'n/a',
|
|
150
|
+
xmrChannel: this.config.xmrChannel,
|
|
151
|
+
xmrPubKey: '',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return this._parseRegisterDisplayJson(json);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parse REST JSON RegisterDisplay response into the same format as SOAP.
|
|
159
|
+
*/
|
|
160
|
+
_parseRegisterDisplayJson(json) {
|
|
161
|
+
// Handle both direct object and wrapped {display: ...} forms
|
|
162
|
+
const display = json.display || json;
|
|
163
|
+
const attrs = display['@attributes'] || {};
|
|
164
|
+
const code = attrs.code || display.code;
|
|
165
|
+
const message = attrs.message || display.message || '';
|
|
166
|
+
|
|
167
|
+
if (code !== 'READY') {
|
|
168
|
+
return { code, message, settings: null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const settings = {};
|
|
172
|
+
for (const [key, value] of Object.entries(display)) {
|
|
173
|
+
if (key === '@attributes' || key === 'commands' || key === 'file') continue;
|
|
174
|
+
settings[key] = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const checkRf = attrs.checkRf || '';
|
|
178
|
+
const checkSchedule = attrs.checkSchedule || '';
|
|
179
|
+
|
|
180
|
+
// Extract sync group config if present (multi-display sync coordination)
|
|
181
|
+
// syncGroup: "lead" if this display is leader, or leader's LAN IP if follower
|
|
182
|
+
const syncConfig = display.syncGroup ? {
|
|
183
|
+
syncGroup: String(display.syncGroup),
|
|
184
|
+
syncPublisherPort: parseInt(display.syncPublisherPort || '9590', 10),
|
|
185
|
+
syncSwitchDelay: parseInt(display.syncSwitchDelay || '750', 10),
|
|
186
|
+
syncVideoPauseDelay: parseInt(display.syncVideoPauseDelay || '100', 10),
|
|
187
|
+
isLead: String(display.syncGroup) === 'lead',
|
|
188
|
+
} : null;
|
|
189
|
+
|
|
190
|
+
return { code, message, settings, checkRf, checkSchedule, syncConfig };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* RequiredFiles - get list of files to download
|
|
195
|
+
* GET /requiredFiles → JSON file manifest (with ETag caching)
|
|
196
|
+
*/
|
|
197
|
+
async requiredFiles() {
|
|
198
|
+
const json = await this.restGet('/requiredFiles');
|
|
199
|
+
return this._parseRequiredFilesJson(json);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Parse REST JSON RequiredFiles into the same array format as SOAP.
|
|
204
|
+
*/
|
|
205
|
+
_parseRequiredFilesJson(json) {
|
|
206
|
+
const files = [];
|
|
207
|
+
let fileList = json.file || [];
|
|
208
|
+
|
|
209
|
+
// Normalize single item to array
|
|
210
|
+
if (!Array.isArray(fileList)) {
|
|
211
|
+
fileList = [fileList];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const f of fileList) {
|
|
215
|
+
const attrs = f['@attributes'] || f;
|
|
216
|
+
const path = attrs.path || null;
|
|
217
|
+
files.push({
|
|
218
|
+
type: attrs.type || null,
|
|
219
|
+
id: attrs.id || null,
|
|
220
|
+
size: parseInt(attrs.size || '0'),
|
|
221
|
+
md5: attrs.md5 || null,
|
|
222
|
+
download: attrs.download || null,
|
|
223
|
+
path,
|
|
224
|
+
code: attrs.code || null,
|
|
225
|
+
layoutid: attrs.layoutid || null,
|
|
226
|
+
regionid: attrs.regionid || null,
|
|
227
|
+
mediaid: attrs.mediaid || null,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return files;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Schedule - get layout schedule
|
|
236
|
+
* GET /schedule → XML (preserved for layout parser compatibility, with ETag caching)
|
|
237
|
+
*/
|
|
238
|
+
async schedule() {
|
|
239
|
+
const xml = await this.restGet('/schedule');
|
|
240
|
+
return parseScheduleResponse(xml);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* GetResource - get rendered widget HTML
|
|
245
|
+
* GET /getResource → HTML string
|
|
246
|
+
*/
|
|
247
|
+
async getResource(layoutId, regionId, mediaId) {
|
|
248
|
+
return this.restGet('/getResource', {
|
|
249
|
+
layoutId: String(layoutId),
|
|
250
|
+
regionId: String(regionId),
|
|
251
|
+
mediaId: String(mediaId),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* NotifyStatus - report current status
|
|
257
|
+
* PUT /status → JSON acknowledgement
|
|
258
|
+
* @param {Object} status - Status object with currentLayoutId, deviceName, etc.
|
|
259
|
+
*/
|
|
260
|
+
async notifyStatus(status) {
|
|
261
|
+
// Enrich with storage estimate if available
|
|
262
|
+
if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
|
|
263
|
+
try {
|
|
264
|
+
const estimate = await navigator.storage.estimate();
|
|
265
|
+
status.availableSpace = estimate.quota - estimate.usage;
|
|
266
|
+
status.totalSpace = estimate.quota;
|
|
267
|
+
} catch (_) { /* storage estimate not supported */ }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Add timezone if not already provided
|
|
271
|
+
if (!status.timeZone && typeof Intl !== 'undefined') {
|
|
272
|
+
status.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return this.restSend('PUT', '/status', {
|
|
276
|
+
statusData: status,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* MediaInventory - report downloaded files
|
|
282
|
+
* POST /mediaInventory → JSON acknowledgement
|
|
283
|
+
*/
|
|
284
|
+
async mediaInventory(inventoryXml) {
|
|
285
|
+
// Accept array (JSON-native) or string (XML) — send under the right key
|
|
286
|
+
const body = Array.isArray(inventoryXml)
|
|
287
|
+
? { inventoryItems: inventoryXml }
|
|
288
|
+
: { inventory: inventoryXml };
|
|
289
|
+
return this.restSend('POST', '/mediaInventory', body);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* BlackList - report broken media to CMS
|
|
294
|
+
*
|
|
295
|
+
* BlackList has no REST equivalent endpoint.
|
|
296
|
+
* Log a warning and return false.
|
|
297
|
+
*/
|
|
298
|
+
async blackList(mediaId, type, reason) {
|
|
299
|
+
log.warn(`BlackList not available via REST (${type}/${mediaId}: ${reason})`);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* SubmitLog - submit player logs to CMS
|
|
305
|
+
* POST /log → JSON acknowledgement
|
|
306
|
+
*/
|
|
307
|
+
async submitLog(logXml) {
|
|
308
|
+
// Accept array (JSON-native) or string (XML) — send under the right key
|
|
309
|
+
const body = Array.isArray(logXml) ? { logs: logXml } : { logXml };
|
|
310
|
+
const result = await this.restSend('POST', '/log', body);
|
|
311
|
+
return result?.success === true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* SubmitScreenShot - submit screenshot to CMS
|
|
316
|
+
* POST /screenshot → JSON acknowledgement
|
|
317
|
+
*/
|
|
318
|
+
async submitScreenShot(base64Image) {
|
|
319
|
+
const result = await this.restSend('POST', '/screenshot', {
|
|
320
|
+
screenshot: base64Image,
|
|
321
|
+
});
|
|
322
|
+
return result?.success === true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* SubmitStats - submit proof of play statistics
|
|
327
|
+
* POST /stats → JSON acknowledgement
|
|
328
|
+
*/
|
|
329
|
+
async submitStats(statsXml) {
|
|
330
|
+
try {
|
|
331
|
+
// Accept array (JSON-native) or string (XML) — send under the right key
|
|
332
|
+
const body = Array.isArray(statsXml) ? { stats: statsXml } : { statXml: statsXml };
|
|
333
|
+
const result = await this.restSend('POST', '/stats', body);
|
|
334
|
+
const success = result?.success === true;
|
|
335
|
+
log.info(`SubmitStats result: ${success}`);
|
|
336
|
+
return success;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
log.error('SubmitStats failed:', error);
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared schedule XML parser used by both RestClient and XmdsClient.
|
|
3
|
+
*
|
|
4
|
+
* Both transports return the same XML structure for the Schedule endpoint,
|
|
5
|
+
* so the parsing logic lives here to avoid duplication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse criteria child elements from a layout/overlay element.
|
|
10
|
+
* Criteria are conditions that must be met for the item to display.
|
|
11
|
+
*
|
|
12
|
+
* XML format: <criteria metric="dayOfWeek" condition="equals" type="string">Monday</criteria>
|
|
13
|
+
*
|
|
14
|
+
* @param {Element} parentEl - Parent XML element containing <criteria> children
|
|
15
|
+
* @returns {Array<{metric: string, condition: string, type: string, value: string}>}
|
|
16
|
+
*/
|
|
17
|
+
function parseCriteria(parentEl) {
|
|
18
|
+
const criteria = [];
|
|
19
|
+
for (const criteriaEl of parentEl.querySelectorAll(':scope > criteria')) {
|
|
20
|
+
criteria.push({
|
|
21
|
+
metric: criteriaEl.getAttribute('metric') || '',
|
|
22
|
+
condition: criteriaEl.getAttribute('condition') || '',
|
|
23
|
+
type: criteriaEl.getAttribute('type') || 'string',
|
|
24
|
+
value: criteriaEl.textContent || ''
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return criteria;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse Schedule XML response into a normalized schedule object.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} xml - Raw XML string from CMS schedule endpoint
|
|
34
|
+
* @returns {Object} Parsed schedule with default, layouts, campaigns, overlays, actions, commands, dataConnectors
|
|
35
|
+
*/
|
|
36
|
+
export function parseScheduleResponse(xml) {
|
|
37
|
+
const parser = new DOMParser();
|
|
38
|
+
const doc = parser.parseFromString(xml, 'text/xml');
|
|
39
|
+
|
|
40
|
+
const schedule = {
|
|
41
|
+
default: null,
|
|
42
|
+
layouts: [],
|
|
43
|
+
campaigns: [],
|
|
44
|
+
overlays: [],
|
|
45
|
+
actions: [],
|
|
46
|
+
commands: [],
|
|
47
|
+
dataConnectors: []
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const defaultEl = doc.querySelector('default');
|
|
51
|
+
if (defaultEl) {
|
|
52
|
+
schedule.default = defaultEl.getAttribute('file');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Parse campaigns (groups of layouts with shared priority)
|
|
56
|
+
for (const campaignEl of doc.querySelectorAll('campaign')) {
|
|
57
|
+
const campaign = {
|
|
58
|
+
id: campaignEl.getAttribute('id'),
|
|
59
|
+
priority: parseInt(campaignEl.getAttribute('priority') || '0'),
|
|
60
|
+
fromdt: campaignEl.getAttribute('fromdt'),
|
|
61
|
+
todt: campaignEl.getAttribute('todt'),
|
|
62
|
+
scheduleid: campaignEl.getAttribute('scheduleid'),
|
|
63
|
+
layouts: []
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Parse layouts within this campaign
|
|
67
|
+
for (const layoutEl of campaignEl.querySelectorAll('layout')) {
|
|
68
|
+
const fileId = layoutEl.getAttribute('file');
|
|
69
|
+
campaign.layouts.push({
|
|
70
|
+
id: String(fileId), // Normalized string ID for consistent type usage
|
|
71
|
+
file: fileId,
|
|
72
|
+
// Layouts in campaigns inherit timing from campaign level
|
|
73
|
+
fromdt: layoutEl.getAttribute('fromdt') || campaign.fromdt,
|
|
74
|
+
todt: layoutEl.getAttribute('todt') || campaign.todt,
|
|
75
|
+
scheduleid: campaign.scheduleid,
|
|
76
|
+
priority: campaign.priority, // Priority at campaign level
|
|
77
|
+
campaignId: campaign.id,
|
|
78
|
+
maxPlaysPerHour: parseInt(layoutEl.getAttribute('maxPlaysPerHour') || '0'),
|
|
79
|
+
isGeoAware: layoutEl.getAttribute('isGeoAware') === '1',
|
|
80
|
+
geoLocation: layoutEl.getAttribute('geoLocation') || '',
|
|
81
|
+
syncEvent: layoutEl.getAttribute('syncEvent') === '1',
|
|
82
|
+
shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
|
|
83
|
+
criteria: parseCriteria(layoutEl)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
schedule.campaigns.push(campaign);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Parse standalone layouts (not in campaigns)
|
|
91
|
+
for (const layoutEl of doc.querySelectorAll('schedule > layout')) {
|
|
92
|
+
const fileId = layoutEl.getAttribute('file');
|
|
93
|
+
schedule.layouts.push({
|
|
94
|
+
id: String(fileId), // Normalized string ID for consistent type usage
|
|
95
|
+
file: fileId,
|
|
96
|
+
fromdt: layoutEl.getAttribute('fromdt'),
|
|
97
|
+
todt: layoutEl.getAttribute('todt'),
|
|
98
|
+
scheduleid: layoutEl.getAttribute('scheduleid'),
|
|
99
|
+
priority: parseInt(layoutEl.getAttribute('priority') || '0'),
|
|
100
|
+
campaignId: null, // Standalone layout
|
|
101
|
+
maxPlaysPerHour: parseInt(layoutEl.getAttribute('maxPlaysPerHour') || '0'),
|
|
102
|
+
isGeoAware: layoutEl.getAttribute('isGeoAware') === '1',
|
|
103
|
+
geoLocation: layoutEl.getAttribute('geoLocation') || '',
|
|
104
|
+
syncEvent: layoutEl.getAttribute('syncEvent') === '1',
|
|
105
|
+
shareOfVoice: parseInt(layoutEl.getAttribute('shareOfVoice') || '0'),
|
|
106
|
+
criteria: parseCriteria(layoutEl)
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Parse overlay layouts (appear on top of main layouts)
|
|
111
|
+
const overlaysContainer = doc.querySelector('overlays');
|
|
112
|
+
if (overlaysContainer) {
|
|
113
|
+
for (const overlayEl of overlaysContainer.querySelectorAll('overlay')) {
|
|
114
|
+
const fileId = overlayEl.getAttribute('file');
|
|
115
|
+
schedule.overlays.push({
|
|
116
|
+
id: String(fileId), // Normalized string ID for consistent type usage
|
|
117
|
+
duration: parseInt(overlayEl.getAttribute('duration') || '60'),
|
|
118
|
+
file: fileId,
|
|
119
|
+
fromDt: overlayEl.getAttribute('fromdt'),
|
|
120
|
+
toDt: overlayEl.getAttribute('todt'),
|
|
121
|
+
priority: parseInt(overlayEl.getAttribute('priority') || '0'),
|
|
122
|
+
scheduleId: overlayEl.getAttribute('scheduleid'),
|
|
123
|
+
isGeoAware: overlayEl.getAttribute('isGeoAware') === '1',
|
|
124
|
+
geoLocation: overlayEl.getAttribute('geoLocation') || '',
|
|
125
|
+
syncEvent: overlayEl.getAttribute('syncEvent') === '1',
|
|
126
|
+
maxPlaysPerHour: parseInt(overlayEl.getAttribute('maxPlaysPerHour') || '0'),
|
|
127
|
+
criteria: parseCriteria(overlayEl)
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Parse action events (scheduled triggers)
|
|
133
|
+
const actionsContainer = doc.querySelector('actions');
|
|
134
|
+
if (actionsContainer) {
|
|
135
|
+
for (const actionEl of actionsContainer.querySelectorAll('action')) {
|
|
136
|
+
schedule.actions.push({
|
|
137
|
+
actionType: actionEl.getAttribute('actionType') || '',
|
|
138
|
+
triggerCode: actionEl.getAttribute('triggerCode') || '',
|
|
139
|
+
layoutCode: actionEl.getAttribute('layoutCode') || '',
|
|
140
|
+
commandCode: actionEl.getAttribute('commandCode') || '',
|
|
141
|
+
duration: parseInt(actionEl.getAttribute('duration') || '0'),
|
|
142
|
+
fromDt: actionEl.getAttribute('fromdt'),
|
|
143
|
+
toDt: actionEl.getAttribute('todt'),
|
|
144
|
+
priority: parseInt(actionEl.getAttribute('priority') || '0'),
|
|
145
|
+
scheduleId: actionEl.getAttribute('scheduleid'),
|
|
146
|
+
isGeoAware: actionEl.getAttribute('isGeoAware') === '1',
|
|
147
|
+
geoLocation: actionEl.getAttribute('geoLocation') || ''
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Parse server commands (remote control)
|
|
153
|
+
for (const cmdEl of doc.querySelectorAll('schedule > command')) {
|
|
154
|
+
schedule.commands.push({
|
|
155
|
+
code: cmdEl.getAttribute('command') || '',
|
|
156
|
+
date: cmdEl.getAttribute('date') || ''
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Parse data connectors (real-time data sources for widgets)
|
|
161
|
+
for (const dcEl of doc.querySelectorAll('dataconnector')) {
|
|
162
|
+
schedule.dataConnectors.push({
|
|
163
|
+
id: dcEl.getAttribute('id') || '',
|
|
164
|
+
dataConnectorId: dcEl.getAttribute('dataConnectorId') || '',
|
|
165
|
+
dataKey: dcEl.getAttribute('dataKey') || '',
|
|
166
|
+
url: dcEl.getAttribute('url') || '',
|
|
167
|
+
updateInterval: parseInt(dcEl.getAttribute('updateInterval') || '300', 10)
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return schedule;
|
|
172
|
+
}
|