@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/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
+ }