@xiboplayer/utils 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 +61 -0
- package/package.json +36 -0
- package/src/cms-api.js +764 -0
- package/src/cms-api.test.js +803 -0
- package/src/config.js +288 -0
- package/src/config.test.js +473 -0
- package/src/event-emitter.js +77 -0
- package/src/event-emitter.test.js +432 -0
- package/src/fetch-retry.js +61 -0
- package/src/fetch-retry.test.js +108 -0
- package/src/index.js +6 -0
- package/src/logger.js +237 -0
- package/src/logger.test.js +477 -0
- package/vitest.config.js +8 -0
package/src/cms-api.js
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS API Client — OAuth2-authenticated REST client for Xibo CMS
|
|
3
|
+
*
|
|
4
|
+
* Full CRUD client for all Xibo CMS REST API entities: displays, layouts,
|
|
5
|
+
* regions, widgets, media, campaigns, schedules, display groups, resolutions.
|
|
6
|
+
* Implements OAuth2 client_credentials flow (machine-to-machine).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const api = new CmsApiClient({ baseUrl: 'https://cms.example.com', clientId, clientSecret });
|
|
10
|
+
* await api.authenticate();
|
|
11
|
+
* const layout = await api.createLayout({ name: 'Test', resolutionId: 9 });
|
|
12
|
+
* const region = await api.addRegion(layout.layoutId, { width: 1920, height: 1080 });
|
|
13
|
+
* await api.addWidget('text', region.playlists[0].playlistId, { text: 'Hello' });
|
|
14
|
+
* await api.publishLayout(layout.layoutId);
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createLogger } from './logger.js';
|
|
18
|
+
|
|
19
|
+
const log = createLogger('CmsApi');
|
|
20
|
+
|
|
21
|
+
export class CmsApiClient {
|
|
22
|
+
/**
|
|
23
|
+
* @param {Object} options
|
|
24
|
+
* @param {string} options.baseUrl - CMS base URL (e.g. https://cms.example.com)
|
|
25
|
+
* @param {string} [options.clientId] - OAuth2 application client ID
|
|
26
|
+
* @param {string} [options.clientSecret] - OAuth2 application client secret
|
|
27
|
+
* @param {string} [options.apiToken] - Pre-configured bearer token (skips OAuth2 flow)
|
|
28
|
+
*/
|
|
29
|
+
constructor({ baseUrl, clientId, clientSecret, apiToken } = {}) {
|
|
30
|
+
this.baseUrl = (baseUrl || '').replace(/\/+$/, '');
|
|
31
|
+
this.clientId = clientId || null;
|
|
32
|
+
this.clientSecret = clientSecret || null;
|
|
33
|
+
this.accessToken = apiToken || null;
|
|
34
|
+
this.tokenExpiry = apiToken ? Infinity : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── OAuth2 Token Management ─────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Authenticate using client_credentials grant
|
|
41
|
+
* @returns {Promise<string>} Access token
|
|
42
|
+
*/
|
|
43
|
+
async authenticate() {
|
|
44
|
+
log.info('Authenticating with CMS API...');
|
|
45
|
+
|
|
46
|
+
const response = await fetch(`${this.baseUrl}/api/authorize/access_token`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
49
|
+
body: new URLSearchParams({
|
|
50
|
+
grant_type: 'client_credentials',
|
|
51
|
+
client_id: this.clientId,
|
|
52
|
+
client_secret: this.clientSecret
|
|
53
|
+
})
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
throw new Error(`OAuth2 authentication failed (${response.status}): ${text}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const data = await response.json();
|
|
62
|
+
this.accessToken = data.access_token;
|
|
63
|
+
this.tokenExpiry = Date.now() + (data.expires_in || 3600) * 1000;
|
|
64
|
+
|
|
65
|
+
log.info('Authenticated successfully, token expires in', data.expires_in, 's');
|
|
66
|
+
return this.accessToken;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Ensure we have a valid token (auto-refresh if expired)
|
|
71
|
+
*/
|
|
72
|
+
async ensureToken() {
|
|
73
|
+
if (this.accessToken && Date.now() < this.tokenExpiry - 60000) return;
|
|
74
|
+
if (!this.clientId || !this.clientSecret) {
|
|
75
|
+
if (this.accessToken) return; // apiToken with no expiry
|
|
76
|
+
throw new CmsApiError('AUTH', '/authorize', 0, 'No valid token and no OAuth2 credentials');
|
|
77
|
+
}
|
|
78
|
+
await this.authenticate();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Make an authenticated API request
|
|
83
|
+
* @param {string} method - HTTP method
|
|
84
|
+
* @param {string} path - API path (e.g. /display)
|
|
85
|
+
* @param {Object} [params] - Query params (GET) or body params (POST/PUT)
|
|
86
|
+
* @returns {Promise<any>} Response data
|
|
87
|
+
*/
|
|
88
|
+
async request(method, path, params = {}) {
|
|
89
|
+
await this.ensureToken();
|
|
90
|
+
|
|
91
|
+
const url = new URL(`${this.baseUrl}/api${path}`);
|
|
92
|
+
const options = {
|
|
93
|
+
method,
|
|
94
|
+
headers: {
|
|
95
|
+
'Authorization': `Bearer ${this.accessToken}`
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (method === 'GET') {
|
|
100
|
+
for (const [key, value] of Object.entries(params)) {
|
|
101
|
+
if (value !== undefined && value !== null) {
|
|
102
|
+
url.searchParams.set(key, String(value));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
107
|
+
options.body = new URLSearchParams(params);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const response = await fetch(url, options);
|
|
111
|
+
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const text = await response.text();
|
|
114
|
+
let errorMsg;
|
|
115
|
+
try {
|
|
116
|
+
const errorData = JSON.parse(text);
|
|
117
|
+
errorMsg = errorData.error?.message || errorData.message || text;
|
|
118
|
+
} catch (_) {
|
|
119
|
+
errorMsg = text;
|
|
120
|
+
}
|
|
121
|
+
throw new CmsApiError(method, path, response.status, errorMsg);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Some endpoints return empty body (204)
|
|
125
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
126
|
+
if (contentType.includes('application/json')) {
|
|
127
|
+
return response.json();
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Convenience methods ────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** GET request (path relative to /api/) */
|
|
135
|
+
get(path, params) { return this.request('GET', path, params); }
|
|
136
|
+
/** POST request (path relative to /api/) */
|
|
137
|
+
post(path, body) { return this.request('POST', path, body); }
|
|
138
|
+
/** PUT request (path relative to /api/) */
|
|
139
|
+
put(path, body) { return this.request('PUT', path, body); }
|
|
140
|
+
/** DELETE request (path relative to /api/) */
|
|
141
|
+
del(path) { return this.request('DELETE', path); }
|
|
142
|
+
|
|
143
|
+
// ── Display Management ──────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Find a display by hardware key
|
|
147
|
+
* @param {string} hardwareKey
|
|
148
|
+
* @returns {Promise<Object|null>} Display object or null if not found
|
|
149
|
+
*/
|
|
150
|
+
async findDisplay(hardwareKey) {
|
|
151
|
+
log.info('Looking up display by hardwareKey:', hardwareKey);
|
|
152
|
+
const data = await this.request('GET', '/display', { hardwareKey });
|
|
153
|
+
|
|
154
|
+
// API returns array of matching displays
|
|
155
|
+
const displays = Array.isArray(data) ? data : [];
|
|
156
|
+
if (displays.length === 0) {
|
|
157
|
+
log.info('No display found for hardwareKey:', hardwareKey);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const display = displays[0];
|
|
162
|
+
log.info(`Found display: ${display.display} (ID: ${display.displayId}, licensed: ${display.licensed})`);
|
|
163
|
+
return display;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Authorize (toggle licence) a display
|
|
168
|
+
* @param {number} displayId
|
|
169
|
+
* @returns {Promise<void>}
|
|
170
|
+
*/
|
|
171
|
+
async authorizeDisplay(displayId) {
|
|
172
|
+
log.info('Authorizing display:', displayId);
|
|
173
|
+
await this.request('PUT', `/display/authorise/${displayId}`);
|
|
174
|
+
log.info('Display authorized successfully');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Edit display properties
|
|
179
|
+
* @param {number} displayId
|
|
180
|
+
* @param {Object} properties - Properties to update (display, description, defaultLayoutId, etc.)
|
|
181
|
+
* @returns {Promise<Object>} Updated display
|
|
182
|
+
*/
|
|
183
|
+
async editDisplay(displayId, properties) {
|
|
184
|
+
log.info('Editing display:', displayId, properties);
|
|
185
|
+
return this.request('PUT', `/display/${displayId}`, properties);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* List all displays (with optional filters)
|
|
190
|
+
* @param {Object} [filters] - Optional filters (displayId, display, macAddress, hardwareKey, clientType)
|
|
191
|
+
* @returns {Promise<Array>} Array of display objects
|
|
192
|
+
*/
|
|
193
|
+
async listDisplays(filters = {}) {
|
|
194
|
+
const data = await this.request('GET', '/display', filters);
|
|
195
|
+
return Array.isArray(data) ? data : [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Request screenshot from a display
|
|
200
|
+
* @param {number} displayId
|
|
201
|
+
* @returns {Promise<void>}
|
|
202
|
+
*/
|
|
203
|
+
async requestScreenshot(displayId) {
|
|
204
|
+
await this.request('PUT', `/display/requestscreenshot/${displayId}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get display status
|
|
209
|
+
* @param {number} displayId
|
|
210
|
+
* @returns {Promise<Object>}
|
|
211
|
+
*/
|
|
212
|
+
async getDisplayStatus(displayId) {
|
|
213
|
+
return this.request('GET', `/display/status/${displayId}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Multipart Requests (File Uploads) ─────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Make an authenticated multipart/form-data request (for file uploads).
|
|
220
|
+
* Do NOT set Content-Type — fetch adds the multipart boundary automatically.
|
|
221
|
+
* @param {string} method - HTTP method (POST/PUT)
|
|
222
|
+
* @param {string} path - API path
|
|
223
|
+
* @param {FormData} formData - Form data with files
|
|
224
|
+
* @returns {Promise<any>} Response data
|
|
225
|
+
*/
|
|
226
|
+
async requestMultipart(method, path, formData) {
|
|
227
|
+
await this.ensureToken();
|
|
228
|
+
|
|
229
|
+
const url = `${this.baseUrl}/api${path}`;
|
|
230
|
+
const response = await fetch(url, {
|
|
231
|
+
method,
|
|
232
|
+
headers: {
|
|
233
|
+
'Authorization': `Bearer ${this.accessToken}`
|
|
234
|
+
// No Content-Type — fetch sets multipart boundary automatically
|
|
235
|
+
},
|
|
236
|
+
body: formData
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
const text = await response.text();
|
|
241
|
+
let errorMsg;
|
|
242
|
+
try {
|
|
243
|
+
const errorData = JSON.parse(text);
|
|
244
|
+
errorMsg = errorData.error?.message || errorData.message || text;
|
|
245
|
+
} catch (_) {
|
|
246
|
+
errorMsg = text;
|
|
247
|
+
}
|
|
248
|
+
throw new Error(`CMS API ${method} ${path} failed (${response.status}): ${errorMsg}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
252
|
+
if (contentType.includes('application/json')) {
|
|
253
|
+
return response.json();
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Layout Management ─────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Create a new layout
|
|
262
|
+
* @param {Object} params
|
|
263
|
+
* @param {string} params.name - Layout name
|
|
264
|
+
* @param {number} params.resolutionId - Resolution ID
|
|
265
|
+
* @param {string} [params.description] - Description
|
|
266
|
+
* @returns {Promise<Object>} Created layout
|
|
267
|
+
*/
|
|
268
|
+
async createLayout({ name, resolutionId, description }) {
|
|
269
|
+
const params = { name, resolutionId };
|
|
270
|
+
if (description) params.description = description;
|
|
271
|
+
return this.request('POST', '/layout', params);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* List layouts with optional filters
|
|
276
|
+
* @param {Object} [filters] - Filters (layoutId, layout, userId, retired, etc.)
|
|
277
|
+
* @returns {Promise<Array>}
|
|
278
|
+
*/
|
|
279
|
+
async listLayouts(filters = {}) {
|
|
280
|
+
const data = await this.request('GET', '/layout', filters);
|
|
281
|
+
return Array.isArray(data) ? data : [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get a single layout by ID
|
|
286
|
+
* @param {number} layoutId
|
|
287
|
+
* @returns {Promise<Object>}
|
|
288
|
+
*/
|
|
289
|
+
async getLayout(layoutId) {
|
|
290
|
+
return this.request('GET', `/layout/${layoutId}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Delete a layout
|
|
295
|
+
* @param {number} layoutId
|
|
296
|
+
* @returns {Promise<void>}
|
|
297
|
+
*/
|
|
298
|
+
async deleteLayout(layoutId) {
|
|
299
|
+
await this.request('DELETE', `/layout/${layoutId}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Publish a draft layout (makes it available for scheduling)
|
|
304
|
+
* @param {number} layoutId
|
|
305
|
+
* @returns {Promise<void>}
|
|
306
|
+
*/
|
|
307
|
+
async publishLayout(layoutId) {
|
|
308
|
+
await this.request('PUT', `/layout/publish/${layoutId}`, { publishNow: 1 });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Checkout a published layout (creates editable draft).
|
|
313
|
+
* NOTE: Not needed for newly created layouts — they already have a draft.
|
|
314
|
+
* Use getDraftLayout() to find the auto-created draft instead.
|
|
315
|
+
* @param {number} layoutId
|
|
316
|
+
* @returns {Promise<Object>} Draft layout
|
|
317
|
+
*/
|
|
318
|
+
async checkoutLayout(layoutId) {
|
|
319
|
+
return this.request('PUT', `/layout/checkout/${layoutId}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Get the draft (editable) layout for a given parent layout.
|
|
324
|
+
* In Xibo v4, POST /layout creates a parent + hidden draft automatically.
|
|
325
|
+
* The draft is the one you edit (add regions, widgets) before publishing.
|
|
326
|
+
* @param {number} parentId - The parent layout ID returned by createLayout()
|
|
327
|
+
* @returns {Promise<Object|null>} Draft layout or null if not found
|
|
328
|
+
*/
|
|
329
|
+
async getDraftLayout(parentId) {
|
|
330
|
+
const drafts = await this.listLayouts({ parentId });
|
|
331
|
+
return drafts.length > 0 ? drafts[0] : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Edit layout background
|
|
336
|
+
* @param {number} layoutId
|
|
337
|
+
* @param {Object} params
|
|
338
|
+
* @param {number} [params.backgroundImageId] - Media ID for background image
|
|
339
|
+
* @param {string} [params.backgroundColor] - Hex color (e.g. '#FF0000')
|
|
340
|
+
* @returns {Promise<Object>}
|
|
341
|
+
*/
|
|
342
|
+
async editLayoutBackground(layoutId, params) {
|
|
343
|
+
return this.request('PUT', `/layout/background/${layoutId}`, params);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Region Management ─────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Add a region to a layout
|
|
350
|
+
* @param {number} layoutId - Must be the DRAFT layout ID (not the parent)
|
|
351
|
+
* @param {Object} params - { width, height, top, left }
|
|
352
|
+
* @returns {Promise<Object>} Created region with regionPlaylist (singular object, not array)
|
|
353
|
+
*/
|
|
354
|
+
async addRegion(layoutId, params) {
|
|
355
|
+
return this.request('POST', `/region/${layoutId}`, params);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Edit a region's properties
|
|
360
|
+
* @param {number} regionId
|
|
361
|
+
* @param {Object} params - { width, height, top, left, zIndex }
|
|
362
|
+
* @returns {Promise<Object>}
|
|
363
|
+
*/
|
|
364
|
+
async editRegion(regionId, params) {
|
|
365
|
+
return this.request('PUT', `/region/${regionId}`, params);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Delete a region
|
|
370
|
+
* @param {number} regionId
|
|
371
|
+
* @returns {Promise<void>}
|
|
372
|
+
*/
|
|
373
|
+
async deleteRegion(regionId) {
|
|
374
|
+
await this.request('DELETE', `/region/${regionId}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Widget/Playlist Management ────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Add a widget to a playlist
|
|
381
|
+
*
|
|
382
|
+
* Xibo CMS v4 uses a two-step process:
|
|
383
|
+
* 1. POST creates the widget shell (only templateId and displayOrder are processed)
|
|
384
|
+
* 2. PUT sets all widget properties (uri, duration, mute, etc.)
|
|
385
|
+
*
|
|
386
|
+
* @param {string} type - Widget type (text, image, video, embedded, clock, etc.)
|
|
387
|
+
* @param {number} playlistId - Target playlist ID (from region.playlists[0].playlistId)
|
|
388
|
+
* @param {Object} [properties] - Widget-specific properties
|
|
389
|
+
* @returns {Promise<Object>} Created widget with properties applied
|
|
390
|
+
*/
|
|
391
|
+
async addWidget(type, playlistId, properties = {}) {
|
|
392
|
+
// Step 1: Create the widget (only templateId/displayOrder handled by CMS addWidget)
|
|
393
|
+
const { templateId, displayOrder, ...editProps } = properties;
|
|
394
|
+
const createParams = {};
|
|
395
|
+
if (templateId !== undefined) createParams.templateId = templateId;
|
|
396
|
+
if (displayOrder !== undefined) createParams.displayOrder = displayOrder;
|
|
397
|
+
|
|
398
|
+
const widget = await this.request('POST', `/playlist/widget/${type}/${playlistId}`, createParams);
|
|
399
|
+
|
|
400
|
+
// Step 2: Set widget properties via editWidget (CMS processes all module properties here)
|
|
401
|
+
if (Object.keys(editProps).length > 0) {
|
|
402
|
+
// useDuration=1 tells CMS to use our custom duration instead of module default
|
|
403
|
+
if (editProps.duration !== undefined && editProps.useDuration === undefined) {
|
|
404
|
+
editProps.useDuration = 1;
|
|
405
|
+
}
|
|
406
|
+
return this.request('PUT', `/playlist/widget/${widget.widgetId}`, editProps);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return widget;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Edit a widget's properties
|
|
414
|
+
* @param {number} widgetId
|
|
415
|
+
* @param {Object} properties - Widget-specific properties to update
|
|
416
|
+
* @returns {Promise<Object>}
|
|
417
|
+
*/
|
|
418
|
+
async editWidget(widgetId, properties) {
|
|
419
|
+
return this.request('PUT', `/playlist/widget/${widgetId}`, properties);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Delete a widget
|
|
424
|
+
* @param {number} widgetId
|
|
425
|
+
* @returns {Promise<void>}
|
|
426
|
+
*/
|
|
427
|
+
async deleteWidget(widgetId) {
|
|
428
|
+
await this.request('DELETE', `/playlist/widget/${widgetId}`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── Media / Library ───────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Upload a media file to the library
|
|
435
|
+
* @param {FormData} formData - Must include 'files' field with the file(s)
|
|
436
|
+
* @returns {Promise<Object>} Upload result with media info
|
|
437
|
+
*/
|
|
438
|
+
async uploadMedia(formData) {
|
|
439
|
+
return this.requestMultipart('POST', '/library', formData);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* List media in the library
|
|
444
|
+
* @param {Object} [filters] - Filters (mediaId, media, type, ownerId, etc.)
|
|
445
|
+
* @returns {Promise<Array>}
|
|
446
|
+
*/
|
|
447
|
+
async listMedia(filters = {}) {
|
|
448
|
+
const data = await this.request('GET', '/library', filters);
|
|
449
|
+
return Array.isArray(data) ? data : [];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Get a single media item by ID
|
|
454
|
+
* @param {number} mediaId
|
|
455
|
+
* @returns {Promise<Object>}
|
|
456
|
+
*/
|
|
457
|
+
async getMedia(mediaId) {
|
|
458
|
+
return this.request('GET', `/library/${mediaId}`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Delete a media item from the library
|
|
463
|
+
* @param {number} mediaId
|
|
464
|
+
* @returns {Promise<void>}
|
|
465
|
+
*/
|
|
466
|
+
async deleteMedia(mediaId) {
|
|
467
|
+
await this.request('DELETE', `/library/${mediaId}`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── Campaign Management ───────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Create a campaign
|
|
474
|
+
* @param {string} name - Campaign name
|
|
475
|
+
* @returns {Promise<Object>} Created campaign
|
|
476
|
+
*/
|
|
477
|
+
async createCampaign(name) {
|
|
478
|
+
return this.request('POST', '/campaign', { name });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* List campaigns
|
|
483
|
+
* @param {Object} [filters] - Filters (campaignId, name, etc.)
|
|
484
|
+
* @returns {Promise<Array>}
|
|
485
|
+
*/
|
|
486
|
+
async listCampaigns(filters = {}) {
|
|
487
|
+
const data = await this.request('GET', '/campaign', filters);
|
|
488
|
+
return Array.isArray(data) ? data : [];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Delete a campaign
|
|
493
|
+
* @param {number} campaignId
|
|
494
|
+
* @returns {Promise<void>}
|
|
495
|
+
*/
|
|
496
|
+
async deleteCampaign(campaignId) {
|
|
497
|
+
await this.request('DELETE', `/campaign/${campaignId}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Assign a layout to a campaign
|
|
502
|
+
* @param {number} campaignId
|
|
503
|
+
* @param {number} layoutId
|
|
504
|
+
* @param {number} [displayOrder] - Position in campaign playlist
|
|
505
|
+
* @returns {Promise<void>}
|
|
506
|
+
*/
|
|
507
|
+
async assignLayoutToCampaign(campaignId, layoutId, displayOrder) {
|
|
508
|
+
const params = { layoutId };
|
|
509
|
+
if (displayOrder !== undefined) params.displayOrder = displayOrder;
|
|
510
|
+
await this.request('POST', `/campaign/layout/assign/${campaignId}`, params);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── Schedule Management ───────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Create a schedule event
|
|
517
|
+
* @param {Object} params
|
|
518
|
+
* @param {number} params.eventTypeId - 1=Campaign, 2=Command, 3=Overlay
|
|
519
|
+
* @param {number} params.campaignId - Campaign to schedule
|
|
520
|
+
* @param {Array<number>} params.displayGroupIds - Target display group IDs
|
|
521
|
+
* @param {string} params.fromDt - Start date (ISO 8601)
|
|
522
|
+
* @param {string} params.toDt - End date (ISO 8601)
|
|
523
|
+
* @param {number} [params.isPriority] - 0 or 1
|
|
524
|
+
* @param {number} [params.displayOrder] - Order within schedule
|
|
525
|
+
* @returns {Promise<Object>} Created schedule event
|
|
526
|
+
*/
|
|
527
|
+
async createSchedule(params) {
|
|
528
|
+
// displayGroupIds needs to be sent as displayGroupIds[] for the API
|
|
529
|
+
const body = { ...params };
|
|
530
|
+
if (Array.isArray(body.displayGroupIds)) {
|
|
531
|
+
// Xibo API expects repeated keys: displayGroupIds[]=1&displayGroupIds[]=2
|
|
532
|
+
// URLSearchParams handles this when we pass entries manually
|
|
533
|
+
delete body.displayGroupIds;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
await this.ensureToken();
|
|
537
|
+
|
|
538
|
+
const url = `${this.baseUrl}/api/schedule`;
|
|
539
|
+
const urlParams = new URLSearchParams();
|
|
540
|
+
|
|
541
|
+
for (const [key, value] of Object.entries(body)) {
|
|
542
|
+
if (value !== undefined && value !== null) {
|
|
543
|
+
urlParams.set(key, String(value));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Append array values as repeated keys
|
|
548
|
+
if (Array.isArray(params.displayGroupIds)) {
|
|
549
|
+
for (const id of params.displayGroupIds) {
|
|
550
|
+
urlParams.append('displayGroupIds[]', String(id));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const response = await fetch(url, {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: {
|
|
557
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
558
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
559
|
+
},
|
|
560
|
+
body: urlParams
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
if (!response.ok) {
|
|
564
|
+
const text = await response.text();
|
|
565
|
+
throw new Error(`CMS API POST /schedule failed (${response.status}): ${text}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
569
|
+
if (contentType.includes('application/json')) {
|
|
570
|
+
return response.json();
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Delete a schedule event
|
|
577
|
+
* @param {number} eventId
|
|
578
|
+
* @returns {Promise<void>}
|
|
579
|
+
*/
|
|
580
|
+
async deleteSchedule(eventId) {
|
|
581
|
+
await this.request('DELETE', `/schedule/${eventId}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* List schedule events
|
|
586
|
+
* @param {Object} [filters] - Filters (displayGroupIds, fromDt, toDt)
|
|
587
|
+
* @returns {Promise<Array>}
|
|
588
|
+
*/
|
|
589
|
+
async listSchedules(filters = {}) {
|
|
590
|
+
const data = await this.request('GET', '/schedule/data/events', filters);
|
|
591
|
+
return Array.isArray(data) ? data : (data?.events || []);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── Display Group Management ──────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* List display groups
|
|
598
|
+
* @param {Object} [filters] - Filters (displayGroupId, displayGroup)
|
|
599
|
+
* @returns {Promise<Array>}
|
|
600
|
+
*/
|
|
601
|
+
async listDisplayGroups(filters = {}) {
|
|
602
|
+
const data = await this.request('GET', '/displaygroup', filters);
|
|
603
|
+
return Array.isArray(data) ? data : [];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Create a display group
|
|
608
|
+
* @param {string} name - Display group name
|
|
609
|
+
* @param {string} [description]
|
|
610
|
+
* @returns {Promise<Object>} Created display group
|
|
611
|
+
*/
|
|
612
|
+
async createDisplayGroup(name, description) {
|
|
613
|
+
const params = { displayGroup: name };
|
|
614
|
+
if (description) params.description = description;
|
|
615
|
+
return this.request('POST', '/displaygroup', params);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Delete a display group
|
|
620
|
+
* @param {number} displayGroupId
|
|
621
|
+
* @returns {Promise<void>}
|
|
622
|
+
*/
|
|
623
|
+
async deleteDisplayGroup(displayGroupId) {
|
|
624
|
+
await this.request('DELETE', `/displaygroup/${displayGroupId}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Assign a display to a display group
|
|
629
|
+
* @param {number} displayGroupId
|
|
630
|
+
* @param {number} displayId
|
|
631
|
+
* @returns {Promise<void>}
|
|
632
|
+
*/
|
|
633
|
+
async assignDisplayToGroup(displayGroupId, displayId) {
|
|
634
|
+
await this.ensureToken();
|
|
635
|
+
|
|
636
|
+
const url = `${this.baseUrl}/api/displaygroup/${displayGroupId}/display/assign`;
|
|
637
|
+
const urlParams = new URLSearchParams();
|
|
638
|
+
urlParams.append('displayId[]', String(displayId));
|
|
639
|
+
|
|
640
|
+
const response = await fetch(url, {
|
|
641
|
+
method: 'POST',
|
|
642
|
+
headers: {
|
|
643
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
644
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
645
|
+
},
|
|
646
|
+
body: urlParams
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
if (!response.ok) {
|
|
650
|
+
const text = await response.text();
|
|
651
|
+
throw new Error(`CMS API assign display to group failed (${response.status}): ${text}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Unassign a display from a display group
|
|
657
|
+
* @param {number} displayGroupId
|
|
658
|
+
* @param {number} displayId
|
|
659
|
+
* @returns {Promise<void>}
|
|
660
|
+
*/
|
|
661
|
+
async unassignDisplayFromGroup(displayGroupId, displayId) {
|
|
662
|
+
await this.ensureToken();
|
|
663
|
+
|
|
664
|
+
const url = `${this.baseUrl}/api/displaygroup/${displayGroupId}/display/unassign`;
|
|
665
|
+
const urlParams = new URLSearchParams();
|
|
666
|
+
urlParams.append('displayId[]', String(displayId));
|
|
667
|
+
|
|
668
|
+
const response = await fetch(url, {
|
|
669
|
+
method: 'POST',
|
|
670
|
+
headers: {
|
|
671
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
672
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
673
|
+
},
|
|
674
|
+
body: urlParams
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (!response.ok) {
|
|
678
|
+
const text = await response.text();
|
|
679
|
+
throw new Error(`CMS API unassign display from group failed (${response.status}): ${text}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ── Resolution Management ─────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* List available resolutions
|
|
687
|
+
* @returns {Promise<Array>}
|
|
688
|
+
*/
|
|
689
|
+
async listResolutions() {
|
|
690
|
+
const data = await this.request('GET', '/resolution');
|
|
691
|
+
return Array.isArray(data) ? data : [];
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── Template Management ──────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* List available layout templates
|
|
698
|
+
* @param {Object} [filters] - Filters (layout, tags, etc.)
|
|
699
|
+
* @returns {Promise<Array>}
|
|
700
|
+
*/
|
|
701
|
+
async listTemplates(filters = {}) {
|
|
702
|
+
const data = await this.request('GET', '/template', filters);
|
|
703
|
+
return Array.isArray(data) ? data : [];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ── Playlist Management ──────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Assign media library items to a playlist (for file-based widgets: audio, PDF, video)
|
|
710
|
+
* @param {number} playlistId
|
|
711
|
+
* @param {number[]} mediaIds - Array of media IDs to assign
|
|
712
|
+
* @returns {Promise<Object>}
|
|
713
|
+
*/
|
|
714
|
+
async assignMediaToPlaylist(playlistId, mediaIds) {
|
|
715
|
+
const ids = Array.isArray(mediaIds) ? mediaIds : [mediaIds];
|
|
716
|
+
// Xibo API expects media[] repeated keys
|
|
717
|
+
await this.ensureToken();
|
|
718
|
+
const url = `${this.baseUrl}/api/playlist/library/assign/${playlistId}`;
|
|
719
|
+
const urlParams = new URLSearchParams();
|
|
720
|
+
for (const id of ids) {
|
|
721
|
+
urlParams.append('media[]', String(id));
|
|
722
|
+
}
|
|
723
|
+
const response = await fetch(url, {
|
|
724
|
+
method: 'POST',
|
|
725
|
+
headers: {
|
|
726
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
727
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
728
|
+
},
|
|
729
|
+
body: urlParams
|
|
730
|
+
});
|
|
731
|
+
if (!response.ok) {
|
|
732
|
+
const text = await response.text();
|
|
733
|
+
throw new CmsApiError('POST', `/playlist/library/assign/${playlistId}`, response.status, text);
|
|
734
|
+
}
|
|
735
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
736
|
+
return contentType.includes('application/json') ? response.json() : null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ── Layout Edit ───────────────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Edit layout properties
|
|
743
|
+
* @param {number} layoutId
|
|
744
|
+
* @param {Object} params - Properties to update
|
|
745
|
+
* @returns {Promise<Object>}
|
|
746
|
+
*/
|
|
747
|
+
async editLayout(layoutId, params) {
|
|
748
|
+
return this.request('PUT', `/layout/${layoutId}`, params);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Structured error for CMS API failures
|
|
754
|
+
*/
|
|
755
|
+
export class CmsApiError extends Error {
|
|
756
|
+
constructor(method, path, status, detail) {
|
|
757
|
+
super(`CMS API ${method} ${path} → ${status}: ${detail}`);
|
|
758
|
+
this.name = 'CmsApiError';
|
|
759
|
+
this.method = method;
|
|
760
|
+
this.path = path;
|
|
761
|
+
this.status = status;
|
|
762
|
+
this.detail = detail;
|
|
763
|
+
}
|
|
764
|
+
}
|