iobroker.emby 1.2.0 → 2.0.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.
Files changed (47) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -78
  3. package/admin/i18n/de.json +17 -0
  4. package/admin/i18n/en.json +17 -0
  5. package/admin/i18n/es.json +17 -0
  6. package/admin/i18n/fr.json +17 -0
  7. package/admin/i18n/it.json +17 -0
  8. package/admin/i18n/nl.json +17 -0
  9. package/admin/i18n/pl.json +17 -0
  10. package/admin/i18n/pt.json +17 -0
  11. package/admin/i18n/ru.json +17 -0
  12. package/admin/i18n/uk.json +17 -0
  13. package/admin/i18n/zh-cn.json +17 -0
  14. package/admin/jsonConfig.json +130 -0
  15. package/build/lib/types.js +5 -0
  16. package/build/lib/types.js.map +1 -0
  17. package/build/main.js +979 -0
  18. package/build/main.js.map +1 -0
  19. package/io-package.json +68 -34
  20. package/package.json +38 -51
  21. package/.eslintignore +0 -2
  22. package/.eslintrc.json +0 -42
  23. package/.gitattributes +0 -2
  24. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
  25. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  26. package/.github/auto-merge.yml +0 -17
  27. package/.github/dependabot.yml +0 -18
  28. package/.github/workflows/dependabot-auto-merge.yml +0 -27
  29. package/.github/workflows/test-and-release.yml +0 -92
  30. package/.mocharc.json +0 -5
  31. package/.prettierignore +0 -2
  32. package/.prettierrc.js +0 -9
  33. package/.releaseconfig.json +0 -3
  34. package/admin/i18n/de/translations.json +0 -9
  35. package/admin/i18n/en/translations.json +0 -9
  36. package/admin/i18n/es/translations.json +0 -9
  37. package/admin/i18n/fr/translations.json +0 -9
  38. package/admin/i18n/it/translations.json +0 -9
  39. package/admin/i18n/nl/translations.json +0 -9
  40. package/admin/i18n/pl/translations.json +0 -9
  41. package/admin/i18n/pt/translations.json +0 -9
  42. package/admin/i18n/ru/translations.json +0 -9
  43. package/admin/i18n/uk/translations.json +0 -9
  44. package/admin/i18n/zh-cn/translations.json +0 -9
  45. package/admin/index_m.html +0 -119
  46. package/admin/words.js +0 -33
  47. package/main.js +0 -971
package/build/main.js ADDED
@@ -0,0 +1,979 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const utils = __importStar(require("@iobroker/adapter-core"));
37
+ /** Delay before a new connection attempt after the socket closed. */
38
+ const RECONNECT_DELAY_MS = 60_000;
39
+ /** Interval for client KeepAlive messages if the server does not request one. */
40
+ const DEFAULT_KEEPALIVE_MS = 30_000;
41
+ /** Lower limit for the poll interval. */
42
+ const MIN_POLL_SECONDS = 5;
43
+ /** One day in ms. */
44
+ const DAY_MS = 24 * 60 * 60 * 1000;
45
+ /** Timeout for REST calls to the server. */
46
+ const HTTP_TIMEOUT_MS = 10_000;
47
+ /**
48
+ * Simple remote commands: state id below `<session>.` → REST path below `/Sessions/<session>/`.
49
+ */
50
+ const SIMPLE_COMMANDS = {
51
+ 'command.toggleplay': 'Playing/PlayPause',
52
+ 'command.play': 'Playing/Unpause',
53
+ 'command.pause': 'Playing/Pause',
54
+ 'command.mute': 'Command/Mute',
55
+ 'command.unmute': 'Command/Unmute',
56
+ 'command.togglemute': 'Command/ToggleMute',
57
+ 'command.goHome': 'Command/GoHome',
58
+ 'command.goToSearch': 'Command/GoToSearch',
59
+ 'command.back': 'Command/Back',
60
+ };
61
+ /**
62
+ * Builds a read-only state definition.
63
+ *
64
+ * @param name name
65
+ * @param type common.type
66
+ * @param role role
67
+ */
68
+ function roState(name, type, role = 'state') {
69
+ return { type: 'state', common: { name, role, type, read: true, write: false }, native: {} };
70
+ }
71
+ /**
72
+ * Builds a button definition.
73
+ *
74
+ * @param name name
75
+ */
76
+ function button(name) {
77
+ return { type: 'state', common: { name, role: 'button', type: 'boolean', read: false, write: true }, native: {} };
78
+ }
79
+ /**
80
+ * Object name for log messages. The adapter writes plain strings, but common.name may be translated.
81
+ *
82
+ * @param name common.name
83
+ */
84
+ function nameOf(name) {
85
+ return typeof name === 'string' ? name : JSON.stringify(name);
86
+ }
87
+ /**
88
+ * Formats the expected end time as HH:MM.
89
+ *
90
+ * @param msFromNow remaining playback time in ms
91
+ */
92
+ function formatEndTime(msFromNow) {
93
+ const end = new Date(Date.now() + msFromNow);
94
+ return `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`;
95
+ }
96
+ class EmbyAdapter extends utils.Adapter {
97
+ connection = null;
98
+ stopping = false;
99
+ retryTimer = null;
100
+ keepAliveTimer = null;
101
+ pollTimer = null;
102
+ /** Serialises processing of Sessions messages so slow object creation cannot overlap. */
103
+ sessionQueue = Promise.resolve();
104
+ /** Session id → signature (name, remote control, supported commands) of the objects created during this run. */
105
+ createdSessions = new Map();
106
+ /** Session ids reported by the server in the last Sessions message. */
107
+ activeSessions = new Set();
108
+ initialSessionsLoaded = false;
109
+ /** API key, resolved once at start (see migratePlainApiKey). */
110
+ apiKey = '';
111
+ laststates = {};
112
+ timeoutplays = {};
113
+ timeoutstates = {};
114
+ timeoutstarted = {};
115
+ constructor(options = {}) {
116
+ super({ ...options, name: 'emby' });
117
+ this.on('ready', () => {
118
+ this.onReady().catch(e => this.log.error(`Startup failed: ${e.message}`));
119
+ });
120
+ this.on('stateChange', (id, state) => this.onStateChange(id, state));
121
+ this.on('objectChange', (id, obj) => this.onObjectChange(id, obj));
122
+ this.on('unload', callback => this.onUnload(callback));
123
+ }
124
+ /** Adapter start. */
125
+ async onReady() {
126
+ await this.setStateAsync('info.connection', false, true);
127
+ // the key is copied once, this.config.apikey is not read again (see migratePlainApiKey)
128
+ this.apiKey = this.config.apikey || '';
129
+ await this.migratePlainApiKey();
130
+ if (!this.config.ip || this.config.ip === '0.0.0.0:8096') {
131
+ this.log.error('No server address configured (IP:Port).');
132
+ return;
133
+ }
134
+ if (!this.apiKey) {
135
+ this.log.warn('There is no ApiKey. You can receive information but cannot control clients.');
136
+ }
137
+ this.subscribeStates('*');
138
+ this.subscribeObjects('*');
139
+ this.tryConnect();
140
+ this.startPolling();
141
+ }
142
+ /**
143
+ * Called when the adapter shuts down. The callback has to be called under any circumstances.
144
+ *
145
+ * @param callback done callback
146
+ */
147
+ onUnload(callback) {
148
+ this.stopping = true;
149
+ try {
150
+ this.stopTimers();
151
+ Object.keys(this.timeoutplays).forEach(id => this.clearTimeout(this.timeoutplays[id]));
152
+ if (this.connection) {
153
+ if (this.connection.readyState === WebSocket.OPEN) {
154
+ this.connection.send(JSON.stringify({ MessageType: 'SessionsStop', Data: '' }));
155
+ }
156
+ this.connection.close();
157
+ this.connection = null;
158
+ }
159
+ void this.setState('info.connection', false, true);
160
+ }
161
+ catch {
162
+ // ignore, we are shutting down anyway
163
+ }
164
+ finally {
165
+ callback();
166
+ }
167
+ }
168
+ /** Stops reconnect, keepalive and poll timers. */
169
+ stopTimers() {
170
+ if (this.retryTimer) {
171
+ this.clearTimeout(this.retryTimer);
172
+ this.retryTimer = null;
173
+ }
174
+ if (this.keepAliveTimer) {
175
+ this.clearInterval(this.keepAliveTimer);
176
+ this.keepAliveTimer = null;
177
+ }
178
+ if (this.pollTimer) {
179
+ this.clearInterval(this.pollTimer);
180
+ this.pollTimer = null;
181
+ }
182
+ }
183
+ /** Base URL of the server for REST calls (honours the SSL option), e.g. http://192.168.0.100:8096 */
184
+ baseUrl() {
185
+ return `${this.config.isSSL ? 'https' : 'http'}://${this.config.ip}`;
186
+ }
187
+ /**
188
+ * Sends a request to the server. The API key is sent as header, never in the URL,
189
+ * so it cannot end up in logs.
190
+ *
191
+ * @param method HTTP method
192
+ * @param path path below the server root, e.g. `Sessions/<id>/Playing/Pause`
193
+ * @param body JSON body
194
+ * @returns response (status 2xx)
195
+ */
196
+ async apiRequest(method, path, body) {
197
+ const res = await fetch(`${this.baseUrl()}/${path}`, {
198
+ method,
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ 'X-Emby-Token': this.apiKey,
202
+ // same DeviceId as the WebSocket, otherwise the server registers every REST call as an extra client
203
+ 'X-Emby-Authorization': `MediaBrowser Client="ioBroker", Device="ioBroker", DeviceId="${this.ownDeviceId()}", Version="${this.version}"`,
204
+ },
205
+ body: body !== undefined ? JSON.stringify(body) : undefined,
206
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
207
+ });
208
+ if (!res.ok) {
209
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
210
+ }
211
+ return res;
212
+ }
213
+ /**
214
+ * Sends a POST request to the server.
215
+ *
216
+ * @param path path below the server root
217
+ * @param body JSON body
218
+ */
219
+ async apiPost(path, body) {
220
+ await this.apiRequest('POST', path, body);
221
+ }
222
+ /**
223
+ * Queues a list of sessions for processing (WebSocket push or REST poll).
224
+ *
225
+ * @param data session list
226
+ */
227
+ queueSessions(data) {
228
+ const list = Array.isArray(data) ? data : [];
229
+ this.sessionQueue = this.sessionQueue
230
+ .then(() => this.handleSessions(list))
231
+ .catch(err => this.log.warn(`Error processing sessions: ${err.message}`));
232
+ }
233
+ /**
234
+ * Reads the current sessions via REST and runs the device cleanup afterwards. Needs an API key.
235
+ *
236
+ * @param quiet log failures only on debug level (periodic poll)
237
+ */
238
+ async refreshSessions(quiet = false) {
239
+ if (!this.apiKey) {
240
+ return;
241
+ }
242
+ try {
243
+ const res = await this.apiRequest('GET', 'Sessions');
244
+ this.queueSessions(await res.json());
245
+ this.sessionQueue = this.sessionQueue
246
+ .then(() => this.cleanupDevices())
247
+ .catch(err => this.log.warn(`Error during device cleanup: ${err.message}`));
248
+ }
249
+ catch (e) {
250
+ this.log[quiet ? 'debug' : 'warn'](`Could not read sessions: ${e.message}`);
251
+ }
252
+ }
253
+ /**
254
+ * Reads the device ids registered on the server (Dashboard > Devices).
255
+ *
256
+ * @returns device ids, null if unknown (request failed or empty list)
257
+ */
258
+ async getRegisteredDeviceIds() {
259
+ try {
260
+ const res = await this.apiRequest('GET', 'Devices');
261
+ const data = (await res.json());
262
+ const items = Array.isArray(data) ? data : data && Array.isArray(data.Items) ? data.Items : [];
263
+ const ids = new Set();
264
+ for (const item of items) {
265
+ // Emby: ReportedDeviceId = DeviceId of the session, Id = internal number; Jellyfin: Id
266
+ for (const id of [item.ReportedDeviceId, item.Id]) {
267
+ if (id !== undefined && id !== null && id !== '') {
268
+ ids.add(String(id));
269
+ }
270
+ }
271
+ }
272
+ // an empty list is treated as unknown so a server problem can never wipe all devices
273
+ return ids.size > 0 ? ids : null;
274
+ }
275
+ catch (e) {
276
+ this.log.debug(`Could not read devices: ${e.message}`);
277
+ return null;
278
+ }
279
+ }
280
+ /** Device objects of all sessions below the adapter namespace. */
281
+ async getSessionDevices() {
282
+ const view = await this.getObjectViewAsync('system', 'device', {
283
+ startkey: `${this.namespace}.`,
284
+ endkey: `${this.namespace}.香`,
285
+ });
286
+ return view.rows.map(row => ({ sid: row.id.substring(this.namespace.length + 1), obj: row.value }));
287
+ }
288
+ /**
289
+ * Removes the objects of inactive clients according to the cleanup settings:
290
+ * clients deleted in the Emby dashboard and/or clients without activity for the configured days.
291
+ * Sessions the server currently reports are never touched.
292
+ */
293
+ async cleanupDevices() {
294
+ const removeDeleted = this.config.removeDeletedDevices !== false;
295
+ const days = Number(this.config.removeInactiveDays) || 0;
296
+ if (!removeDeleted && days <= 0) {
297
+ return;
298
+ }
299
+ const registered = removeDeleted ? await this.getRegisteredDeviceIds() : null;
300
+ for (const { sid, obj } of await this.getSessionDevices()) {
301
+ if (this.activeSessions.has(sid)) {
302
+ continue;
303
+ }
304
+ const deviceId = obj?.native?.deviceId || '';
305
+ const name = obj?.common ? obj.common.name : sid;
306
+ let reason = '';
307
+ if (registered && deviceId && !registered.has(deviceId)) {
308
+ reason = 'it was removed in Emby';
309
+ }
310
+ else if (days > 0) {
311
+ const last = await this.getLastActivity(sid);
312
+ if (last > 0 && Date.now() - last > days * DAY_MS) {
313
+ reason = `it was inactive for more than ${days} days`;
314
+ }
315
+ }
316
+ if (reason) {
317
+ this.log.info(`Removing objects of client "${nameOf(name)}" (${sid}) because ${reason}`);
318
+ await this.deleteSessionObjects(sid);
319
+ }
320
+ }
321
+ }
322
+ /**
323
+ * Time of the last activity of a session from its states.
324
+ *
325
+ * @param sid session id
326
+ * @returns timestamp in ms, 0 if unknown
327
+ */
328
+ async getLastActivity(sid) {
329
+ const last = await this.getStateAsync(`${sid}.info.lastActivity`);
330
+ if (last && typeof last.val === 'number' && last.val > 0) {
331
+ return last.val;
332
+ }
333
+ // objects of older versions: time the session went offline
334
+ const online = await this.getStateAsync(`${sid}.info.online`);
335
+ return online && online.val === false ? online.lc || online.ts || 0 : 0;
336
+ }
337
+ /**
338
+ * Forgets sessions whose objects were deleted (e.g. by the user in admin), so they are
339
+ * recreated completely on the next update instead of writing states without objects.
340
+ *
341
+ * @param id full object id
342
+ * @param obj new object, null if deleted
343
+ */
344
+ onObjectChange(id, obj) {
345
+ if (obj || !id.startsWith(`${this.namespace}.`)) {
346
+ return;
347
+ }
348
+ const sid = id.substring(this.namespace.length + 1).split('.')[0];
349
+ if (sid === 'info') {
350
+ return;
351
+ }
352
+ if (this.createdSessions.delete(sid)) {
353
+ this.log.debug(`Objects of session ${sid} were deleted`);
354
+ }
355
+ // a deleted session that is no longer active must not be recreated by markSessionOffline
356
+ this.activeSessions.delete(sid);
357
+ delete this.laststates[sid];
358
+ }
359
+ /**
360
+ * Handles commands written by the user.
361
+ *
362
+ * @param id full state id
363
+ * @param state new state
364
+ */
365
+ onStateChange(id, state) {
366
+ if (!id || !state || state.ack) {
367
+ return;
368
+ }
369
+ this.handleCommand(id.substring(this.namespace.length + 1), state).catch(e => this.log.warn(`Command ${id} failed: ${e.message}`));
370
+ }
371
+ /**
372
+ * Executes a command for a session.
373
+ *
374
+ * @param id state id without namespace
375
+ * @param state new state
376
+ */
377
+ async handleCommand(id, state) {
378
+ if (id.startsWith('info.') || id.includes('.info.') || id.includes('.media.') || id.includes('.posters.')) {
379
+ // read-only data written by the user: confirm the value as before
380
+ await this.setStateAsync(id, state.val, true);
381
+ return;
382
+ }
383
+ const sessionId = id.substring(0, id.indexOf('.'));
384
+ const cmd = id.substring(sessionId.length + 1);
385
+ if (!sessionId || !cmd.startsWith('command.')) {
386
+ this.log.info(`Not supported command: ${id}`);
387
+ return;
388
+ }
389
+ if (!this.apiKey) {
390
+ this.log.warn('No API key configured, commands cannot be sent.');
391
+ return;
392
+ }
393
+ const base = `Sessions/${encodeURIComponent(sessionId)}`;
394
+ if (SIMPLE_COMMANDS[cmd]) {
395
+ await this.apiPost(`${base}/${SIMPLE_COMMANDS[cmd]}`);
396
+ await this.setStateAsync(id, state.val, true);
397
+ return;
398
+ }
399
+ const text = state.val === null || state.val === undefined ? '' : String(state.val);
400
+ switch (cmd) {
401
+ case 'command.message':
402
+ await this.apiPost(`${base}/Message`, { Header: 'ioBroker', Text: text, TimeoutMs: 5000 });
403
+ await this.setStateAsync(id, '', true);
404
+ break;
405
+ case 'command.dialog': {
406
+ const sep = text.indexOf('|');
407
+ const body = sep !== -1
408
+ ? { Header: text.substring(0, sep), Text: text.substring(sep + 1) }
409
+ : { Header: 'ioBroker', Text: text };
410
+ await this.apiPost(`${base}/Message`, body);
411
+ await this.setStateAsync(id, '', true);
412
+ break;
413
+ }
414
+ case 'command.volume': {
415
+ const volume = Math.round(Number(state.val));
416
+ if (!Number.isFinite(volume) || volume < 0 || volume > 100) {
417
+ this.log.warn(`Invalid volume "${String(state.val)}", expected 0-100`);
418
+ return;
419
+ }
420
+ await this.apiPost(`${base}/Command`, { Name: 'SetVolume', Arguments: { Volume: String(volume) } });
421
+ await this.setStateAsync(id, volume, true);
422
+ break;
423
+ }
424
+ default:
425
+ this.log.info(`Not supported command: ${cmd} | ${id}`);
426
+ break;
427
+ }
428
+ }
429
+ /**
430
+ * Converts a plain-text API key stored by older versions into an encrypted value.
431
+ *
432
+ * @returns true if the instance object was rewritten
433
+ */
434
+ async migratePlainApiKey() {
435
+ // @KI_HINWEIS: Since `apikey` is listed in encryptedNative, js-controller decrypts it on start.
436
+ // Values without the `$/aes-192-cbc:` prefix are treated as legacy (XOR) encrypted, so a key that
437
+ // versions <= 1.3.0 stored in plain text arrives as garbage in this.config. Emby/Jellyfin API keys
438
+ // are 32 hex characters; a raw value of exactly that form is plain text and gets encrypted once.
439
+ // Anything else (aes or real legacy encryption) is left alone and this.config is trusted.
440
+ const obj = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`);
441
+ const raw = obj?.native ? obj.native.apikey : '';
442
+ if (typeof raw !== 'string' || !/^[0-9a-f]{32}$/i.test(raw)) {
443
+ return false;
444
+ }
445
+ this.log.info('Encrypting API key stored in plain text by an older version.');
446
+ this.apiKey = raw;
447
+ // updateConfig() encrypts all encryptedNative attributes itself
448
+ await this.updateConfig({ apikey: raw });
449
+ return true;
450
+ }
451
+ /** Starts the periodic REST poll of the sessions and the device cleanup. */
452
+ startPolling() {
453
+ // undefined for instances created by older versions: use the defaults from io-package
454
+ const seconds = this.config.pollInterval === undefined ? 60 : Number(this.config.pollInterval) || 0;
455
+ if (seconds <= 0 || !this.apiKey) {
456
+ return;
457
+ }
458
+ // @KI_HINWEIS: Emby pushes "Sessions" only on changes (start, stop, pause). The poll keeps
459
+ // info.lastActivity current, catches missed pushes and drives the optional device cleanup.
460
+ this.pollTimer = this.setInterval(() => {
461
+ if (this.connection && this.connection.readyState === WebSocket.OPEN) {
462
+ void this.refreshSessions(true);
463
+ }
464
+ }, Math.max(MIN_POLL_SECONDS, seconds) * 1000);
465
+ }
466
+ /**
467
+ * Device id the adapter uses towards the server. Stable per instance so it does not
468
+ * register as a new device on every start and does not collide with other installations.
469
+ */
470
+ ownDeviceId() {
471
+ return `ioBroker-${this.namespace}`;
472
+ }
473
+ /** Opens the WebSocket connection. Reconnects are scheduled from the close handler only. */
474
+ tryConnect() {
475
+ this.retryTimer = null;
476
+ if (this.stopping) {
477
+ return;
478
+ }
479
+ const prefix = this.config.isSSL ? 'wss://' : 'ws://';
480
+ const url = `${prefix}${this.config.ip}?api_key=${encodeURIComponent(this.apiKey)}&deviceId=${encodeURIComponent(this.ownDeviceId())}`;
481
+ this.log.debug(`try to connect to: ${this.config.ip}`);
482
+ let connection;
483
+ try {
484
+ connection = new WebSocket(url);
485
+ }
486
+ catch (e) {
487
+ this.log.warn(`Connection could not be established: ${e.message}`);
488
+ this.scheduleReconnect();
489
+ return;
490
+ }
491
+ this.connection = connection;
492
+ connection.onopen = () => this.onSocketOpen();
493
+ connection.onmessage = event => this.onSocketMessage(event);
494
+ // WHATWG WebSocket: an error is always followed by a close event, reconnect is handled there
495
+ connection.onerror = event => {
496
+ const message = event.message;
497
+ this.log.debug(`WebSocket error: ${message || 'unknown'}`);
498
+ };
499
+ connection.onclose = event => this.onSocketClose(event);
500
+ }
501
+ /** Schedules exactly one reconnect attempt. */
502
+ scheduleReconnect() {
503
+ if (this.stopping || this.retryTimer) {
504
+ return;
505
+ }
506
+ this.retryTimer = this.setTimeout(() => this.tryConnect(), RECONNECT_DELAY_MS);
507
+ }
508
+ /** Connection established: subscribe to session updates and start keepalive. */
509
+ onSocketOpen() {
510
+ this.log.info(`connected with emby (${this.config.ip})`);
511
+ void this.setState('info.connection', true, true);
512
+ // @KI_HINWEIS: Emby 4.10 pushes "Sessions" only when something changes (the interval in the
513
+ // data is ignored) and does not answer KeepAlive. A "no data" watchdog would therefore reconnect
514
+ // on every quiet server. The periodic KeepAlive keeps traffic on the socket, so a dead TCP
515
+ // connection ends in a close event and the normal reconnect.
516
+ this.connection?.send(JSON.stringify({ MessageType: 'SessionsStart', Data: '0,10000' }));
517
+ this.startKeepAlive(DEFAULT_KEEPALIVE_MS);
518
+ // the server sends the first "Sessions" message only after the next change, so read the
519
+ // current state right away (also brings states up to date after a reconnect)
520
+ void this.refreshSessions();
521
+ }
522
+ /**
523
+ * Socket closed (server restart, network loss, idle timeout).
524
+ *
525
+ * @param event close event
526
+ */
527
+ onSocketClose(event) {
528
+ if (this.keepAliveTimer) {
529
+ this.clearInterval(this.keepAliveTimer);
530
+ this.keepAliveTimer = null;
531
+ }
532
+ this.connection = null;
533
+ if (this.stopping) {
534
+ return;
535
+ }
536
+ void this.setState('info.connection', false, true);
537
+ this.log.warn(`Connection to emby closed (code ${event?.code}${event?.reason ? `, ${event.reason}` : ''}). Reconnect in ${RECONNECT_DELAY_MS / 1000}s`);
538
+ this.scheduleReconnect();
539
+ }
540
+ /**
541
+ * Sends KeepAlive messages in the given interval.
542
+ *
543
+ * @param intervalMs interval in milliseconds
544
+ */
545
+ startKeepAlive(intervalMs) {
546
+ if (this.keepAliveTimer) {
547
+ this.clearInterval(this.keepAliveTimer);
548
+ }
549
+ this.keepAliveTimer = this.setInterval(() => {
550
+ if (this.connection && this.connection.readyState === WebSocket.OPEN) {
551
+ this.connection.send(JSON.stringify({ MessageType: 'KeepAlive' }));
552
+ }
553
+ }, intervalMs);
554
+ }
555
+ /**
556
+ * Dispatches incoming WebSocket messages by MessageType.
557
+ *
558
+ * @param e message event
559
+ */
560
+ onSocketMessage(e) {
561
+ let msg;
562
+ try {
563
+ msg = JSON.parse(typeof e.data === 'string' ? e.data : String(e.data));
564
+ }
565
+ catch {
566
+ this.log.debug(`Ignoring non-JSON message: ${String(e.data).substring(0, 200)}`);
567
+ return;
568
+ }
569
+ this.log.silly(JSON.stringify(msg));
570
+ switch (msg?.MessageType) {
571
+ case 'Sessions':
572
+ this.queueSessions(msg.Data);
573
+ break;
574
+ case 'ForceKeepAlive': {
575
+ // Data = timeout in seconds after which the server drops the connection; answer at half of it
576
+ const seconds = Number(msg.Data);
577
+ if (Number.isFinite(seconds) && seconds > 0) {
578
+ this.startKeepAlive(Math.max(5_000, (seconds * 1000) / 2));
579
+ }
580
+ if (this.connection && this.connection.readyState === WebSocket.OPEN) {
581
+ this.connection.send(JSON.stringify({ MessageType: 'KeepAlive' }));
582
+ }
583
+ break;
584
+ }
585
+ case 'KeepAlive':
586
+ break;
587
+ case 'ServerRestarting':
588
+ case 'ServerShuttingDown':
589
+ this.log.info(`Server message: ${msg.MessageType}`);
590
+ break;
591
+ default:
592
+ this.log.debug(`Unhandled message type: ${msg?.MessageType}`);
593
+ break;
594
+ }
595
+ }
596
+ /**
597
+ * Returns true if a session should be skipped according to the ignore list (comma separated
598
+ * session ids or device ids, exact match).
599
+ *
600
+ * @param d session from the server
601
+ */
602
+ isIgnored(d) {
603
+ const list = String(this.config.deviceIds || '')
604
+ .split(/[\s,;]+/)
605
+ .map(s => s.trim())
606
+ .filter(Boolean);
607
+ return list.includes(d.Id) || (!!d.DeviceId && list.includes(d.DeviceId));
608
+ }
609
+ /**
610
+ * Writes a state only if its value changed.
611
+ *
612
+ * @param id state id below the namespace
613
+ * @param val value
614
+ */
615
+ async setValue(id, val) {
616
+ await this.setStateChangedAsync(id, { val, ack: true });
617
+ }
618
+ /**
619
+ * Processes a Sessions message: updates all reported sessions and marks sessions that are no
620
+ * longer reported as offline.
621
+ *
622
+ * @param list sessions
623
+ */
624
+ async handleSessions(list) {
625
+ if (!this.initialSessionsLoaded) {
626
+ // sessions left over from before an adapter restart are marked offline on the first message
627
+ for (const { sid } of await this.getSessionDevices()) {
628
+ this.activeSessions.add(sid);
629
+ }
630
+ this.initialSessionsLoaded = true;
631
+ }
632
+ const seen = new Set();
633
+ for (const d of list) {
634
+ if (!d || typeof d.Id !== 'string' || d.Id === '') {
635
+ continue;
636
+ }
637
+ if (d.DeviceId === this.ownDeviceId() || d.DeviceId === '00001') {
638
+ // the adapter's own connection (00001 = fixed id used by versions <= 1.3.0)
639
+ continue;
640
+ }
641
+ if (this.isIgnored(d)) {
642
+ this.log.debug(`Device skipped: ${d.DeviceName}`);
643
+ continue;
644
+ }
645
+ seen.add(d.Id);
646
+ // @KI_HINWEIS: a web client reports SupportsRemoteControl=false while its page is in the
647
+ // background or reconnecting, so the objects are rebuilt whenever this signature changes.
648
+ const signature = JSON.stringify([d.DeviceName, !!d.SupportsRemoteControl, d.SupportedCommands || []]);
649
+ if (this.createdSessions.get(d.Id) !== signature) {
650
+ await this.createSessionObjects(d);
651
+ this.createdSessions.set(d.Id, signature);
652
+ }
653
+ await this.updateSession(d);
654
+ }
655
+ for (const id of this.activeSessions) {
656
+ if (!seen.has(id)) {
657
+ await this.markSessionOffline(id);
658
+ }
659
+ }
660
+ this.activeSessions = seen;
661
+ }
662
+ /**
663
+ * Updates the states of one session.
664
+ *
665
+ * @param d session from the server
666
+ */
667
+ async updateSession(d) {
668
+ const sid = d.Id;
669
+ await this.setValue(`${sid}.info.online`, true);
670
+ await this.setValue(`${sid}.info.deviceName`, d.DeviceName || '');
671
+ await this.setValue(`${sid}.info.userName`, d.UserName || '');
672
+ if (Array.isArray(d.SupportedCommands)) {
673
+ await this.setValue(`${sid}.info.supportedCommands`, JSON.stringify(d.SupportedCommands));
674
+ }
675
+ const lastActivity = Date.parse(d.LastActivityDate);
676
+ if (Number.isFinite(lastActivity)) {
677
+ await this.setValue(`${sid}.info.lastActivity`, lastActivity);
678
+ }
679
+ const npi = d.NowPlayingItem;
680
+ const ps = d.PlayState || {};
681
+ if (!npi) {
682
+ await this.clearMedia(sid);
683
+ this.changeState(sid, 'idle');
684
+ return;
685
+ }
686
+ const str = (v) => (v === undefined || v === null ? '' : String(v));
687
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
688
+ const list = (v) => (Array.isArray(v) ? v.join(',') : '');
689
+ await this.setValue(`${sid}.media.endtime`, typeof npi.RunTimeTicks === 'number' && typeof ps.PositionTicks === 'number'
690
+ ? formatEndTime((npi.RunTimeTicks - ps.PositionTicks) / 10000)
691
+ : '');
692
+ await this.setValue(`${sid}.media.title`, str(npi.Name));
693
+ await this.setValue(`${sid}.media.description`, str(npi.Overview));
694
+ await this.setValue(`${sid}.media.type`, str(npi.Type));
695
+ await this.setValue(`${sid}.media.rating`, num(npi.CommunityRating));
696
+ await this.setValue(`${sid}.media.year`, num(npi.ProductionYear));
697
+ await this.setValue(`${sid}.media.tags`, list(npi.Taglines));
698
+ await this.setValue(`${sid}.media.genres`, list(npi.Genres));
699
+ await this.setValue(`${sid}.media.backdropimage`, list(npi.BackdropImageTags));
700
+ await this.setValue(`${sid}.media.ratio`, num(npi.PrimaryImageAspectRatio));
701
+ await this.setValue(`${sid}.media.agerating`, str(npi.OfficialRating));
702
+ await this.setValue(`${sid}.media.originaltitle`, str(npi.OriginalTitle));
703
+ await this.setValue(`${sid}.media.seasonName`, str(npi.SeasonName));
704
+ await this.setValue(`${sid}.media.seriesName`, npi.SeasonName !== undefined ? str(npi.SeriesName) : '');
705
+ await this.setValue(`${sid}.media.isMuted`, ps.IsMuted === true);
706
+ if (typeof ps.VolumeLevel === 'number' &&
707
+ d.SupportsRemoteControl &&
708
+ Array.isArray(d.SupportedCommands) &&
709
+ d.SupportedCommands.includes('SetVolume')) {
710
+ await this.setValue(`${sid}.command.volume`, ps.VolumeLevel);
711
+ }
712
+ const basePoster = `${this.baseUrl()}/Items/`;
713
+ let posters = ['', '', ''];
714
+ if (npi.Type === 'Episode') {
715
+ posters = [
716
+ `${basePoster}${npi.SeriesId}/Images/Primary`,
717
+ `${basePoster}${npi.SeasonId}/Images/Primary`,
718
+ `${basePoster}${npi.Id}/Images/Primary`,
719
+ ];
720
+ }
721
+ else if (npi.Type === 'Movie') {
722
+ posters = [`${basePoster}${npi.Id}/Images/Primary`, '', ''];
723
+ }
724
+ await this.setValue(`${sid}.posters.main`, posters[0]);
725
+ await this.setValue(`${sid}.posters.season`, posters[1]);
726
+ await this.setValue(`${sid}.posters.episode`, posters[2]);
727
+ if (ps.MediaSourceId !== undefined && !ps.IsPaused) {
728
+ this.changeState(sid, 'playing');
729
+ }
730
+ else {
731
+ this.changeState(sid, 'paused');
732
+ }
733
+ }
734
+ /**
735
+ * Resets all media states of a session.
736
+ *
737
+ * @param sid session id
738
+ */
739
+ async clearMedia(sid) {
740
+ for (const s of [
741
+ 'title',
742
+ 'description',
743
+ 'seasonName',
744
+ 'seriesName',
745
+ 'endtime',
746
+ 'tags',
747
+ 'genres',
748
+ 'backdropimage',
749
+ 'agerating',
750
+ 'originaltitle',
751
+ ]) {
752
+ await this.setValue(`${sid}.media.${s}`, '');
753
+ }
754
+ for (const s of ['rating', 'year', 'ratio']) {
755
+ await this.setValue(`${sid}.media.${s}`, null);
756
+ }
757
+ await this.setValue(`${sid}.media.type`, 'None');
758
+ await this.setValue(`${sid}.media.isMuted`, false);
759
+ for (const s of ['main', 'season', 'episode']) {
760
+ await this.setValue(`${sid}.posters.${s}`, '');
761
+ }
762
+ }
763
+ /**
764
+ * Deletes the objects of older sessions of the same client.
765
+ *
766
+ * Emby assigns a new session id whenever a client reconnects, so without this every app restart
767
+ * would leave another device below the adapter namespace. The old objects cannot receive data any
768
+ * more, scripts pointing at them were already broken by the id change.
769
+ *
770
+ * @param sid id of the current session
771
+ * @param deviceId Emby device id of the client
772
+ */
773
+ async removeSupersededSessions(sid, deviceId) {
774
+ for (const { sid: oldSid, obj } of await this.getSessionDevices()) {
775
+ if (oldSid === sid || !obj?.native || obj.native.deviceId !== deviceId) {
776
+ continue;
777
+ }
778
+ this.log.info(`Removing objects of old session ${oldSid}, client "${nameOf(obj.common.name)}" now uses ${sid}`);
779
+ await this.deleteSessionObjects(oldSid);
780
+ }
781
+ }
782
+ /**
783
+ * Deletes all objects of a session and forgets its runtime state.
784
+ *
785
+ * @param sid session id
786
+ */
787
+ async deleteSessionObjects(sid) {
788
+ this.activeSessions.delete(sid);
789
+ this.createdSessions.delete(sid);
790
+ if (this.timeoutplays[sid]) {
791
+ this.clearTimeout(this.timeoutplays[sid]);
792
+ }
793
+ delete this.timeoutplays[sid];
794
+ delete this.timeoutstates[sid];
795
+ delete this.timeoutstarted[sid];
796
+ delete this.laststates[sid];
797
+ await this.delObjectAsync(sid, { recursive: true });
798
+ }
799
+ /**
800
+ * Marks a session the server no longer reports (client closed, TV switched off).
801
+ *
802
+ * @param sid session id
803
+ */
804
+ async markSessionOffline(sid) {
805
+ if (!(await this.getObjectAsync(sid))) {
806
+ // objects were deleted in the meantime, nothing to update
807
+ return;
808
+ }
809
+ this.log.debug(`Session ${sid} is no longer active`);
810
+ // objects created by versions <= 1.3.0 have no info.online yet
811
+ await this.extendObjectAsync(`${sid}.info.online`, roState('Session is active on the server', 'boolean', 'indicator.reachable'));
812
+ await this.setValue(`${sid}.info.online`, false);
813
+ await this.clearMedia(sid);
814
+ this.changeState(sid, 'idle');
815
+ }
816
+ /**
817
+ * Sets media.state; changes away from "playing" are delayed by the configured timeout
818
+ * to suppress short flickering.
819
+ *
820
+ * @param id session id
821
+ * @param state idle | paused | playing
822
+ */
823
+ changeState(id, state) {
824
+ if (this.laststates[id] === state) {
825
+ return;
826
+ }
827
+ if (state === 'playing') {
828
+ if (this.timeoutplays[id]) {
829
+ this.clearTimeout(this.timeoutplays[id]);
830
+ this.timeoutplays[id] = null;
831
+ }
832
+ this.timeoutstarted[id] = false;
833
+ void this.setState(`${id}.media.state`, state, true);
834
+ }
835
+ else {
836
+ this.timeoutstates[id] = state;
837
+ if (!this.timeoutstarted[id]) {
838
+ this.timeoutstarted[id] = true;
839
+ this.timeoutplays[id] = this.setTimeout(() => {
840
+ this.timeoutplays[id] = null;
841
+ this.timeoutstarted[id] = false;
842
+ void this.setState(`${id}.media.state`, this.timeoutstates[id], true);
843
+ }, Number(this.config.timeout) || 0);
844
+ }
845
+ }
846
+ this.laststates[id] = state;
847
+ }
848
+ /**
849
+ * Creates or updates the objects of a session. Uses extendObject so objects created by older
850
+ * versions with invalid types (e.g. common.type "button") are corrected.
851
+ *
852
+ * @param device session from the server
853
+ */
854
+ async createSessionObjects(device) {
855
+ const sid = device.Id;
856
+ await this.setObjectNotExistsAsync(sid, {
857
+ type: 'device',
858
+ common: { name: device.DeviceName || sid },
859
+ native: {},
860
+ });
861
+ // the display name can be changed in the Emby dashboard (Devices), keep it in sync
862
+ await this.extendObjectAsync(sid, {
863
+ common: { name: device.DeviceName || sid },
864
+ native: { deviceId: device.DeviceId || '' },
865
+ });
866
+ if (device.DeviceId) {
867
+ await this.removeSupersededSessions(sid, device.DeviceId);
868
+ }
869
+ const objs = {
870
+ info: { type: 'channel', common: { name: 'Info' }, native: {} },
871
+ media: { type: 'channel', common: { name: 'Media Info' }, native: {} },
872
+ posters: { type: 'channel', common: { name: 'Media Posters' }, native: {} },
873
+ 'info.online': roState('Session is active on the server', 'boolean', 'indicator.reachable'),
874
+ 'info.deviceName': roState('Name of the Device now playing', 'string', 'info.name'),
875
+ 'info.userName': roState('Logged in User', 'string', 'info.name'),
876
+ 'info.supportedCommands': roState('List of supported Commands', 'string', 'json'),
877
+ 'info.lastActivity': roState('Last activity of the client', 'number', 'date'),
878
+ 'media.state': {
879
+ type: 'state',
880
+ common: {
881
+ name: 'State of the Client',
882
+ role: 'media.state',
883
+ type: 'string',
884
+ read: true,
885
+ write: false,
886
+ states: { idle: 'Idle', paused: 'Paused', playing: 'Playing' },
887
+ },
888
+ native: {},
889
+ },
890
+ 'media.isMuted': roState('If Player is muted', 'boolean', 'media.mute'),
891
+ 'media.title': roState('Title of file playing', 'string', 'media.title'),
892
+ 'media.seriesName': roState('Name of serie now playing', 'string', 'media.title'),
893
+ 'media.seasonName': roState('Name of season now playing', 'string', 'media.season'),
894
+ 'media.type': roState('Type of file playing', 'string'),
895
+ 'media.description': roState('Description of file playing', 'string'),
896
+ 'media.endtime': roState('Endtime of playing video/audio', 'string'),
897
+ 'media.rating': roState('Community Rating', 'number'),
898
+ 'media.year': roState('Production Year', 'number'),
899
+ 'media.tags': roState('Tags', 'string'),
900
+ 'media.genres': roState('Genres', 'string'),
901
+ 'media.backdropimage': roState('BackdropImageTags', 'string'),
902
+ 'media.ratio': roState('Ratio', 'number'),
903
+ 'media.agerating': roState('Age Rating', 'string'),
904
+ 'media.originaltitle': roState('Original Title', 'string'),
905
+ 'posters.main': roState('Main Poster', 'string', 'url'),
906
+ 'posters.episode': roState('Episode Poster', 'string', 'url'),
907
+ 'posters.season': roState('Season Poster', 'string', 'url'),
908
+ };
909
+ if (device.SupportsRemoteControl) {
910
+ objs.command = { type: 'channel', common: { name: 'Control Client' }, native: {} };
911
+ objs['command.pause'] = button('Pause');
912
+ objs['command.play'] = button('Play');
913
+ objs['command.toggleplay'] = button('Toggle Play');
914
+ const supported = Array.isArray(device.SupportedCommands) ? device.SupportedCommands : [];
915
+ for (const c of supported) {
916
+ switch (c) {
917
+ case 'DisplayMessage':
918
+ objs['command.message'] = {
919
+ type: 'state',
920
+ common: { name: 'Message', role: 'text', type: 'string', read: false, write: true },
921
+ native: {},
922
+ };
923
+ objs['command.dialog'] = {
924
+ type: 'state',
925
+ common: { name: 'Message Dialog', role: 'text', type: 'string', read: false, write: true },
926
+ native: {},
927
+ };
928
+ break;
929
+ case 'GoHome':
930
+ objs['command.goHome'] = button('GoHome');
931
+ break;
932
+ case 'SetVolume':
933
+ objs['command.volume'] = {
934
+ type: 'state',
935
+ common: {
936
+ name: 'Set Volume (causes crash on some devices)',
937
+ role: 'level.volume',
938
+ type: 'number',
939
+ min: 0,
940
+ max: 100,
941
+ read: true,
942
+ write: true,
943
+ },
944
+ native: {},
945
+ };
946
+ break;
947
+ case 'GoToSearch':
948
+ objs['command.goToSearch'] = button('Go to search');
949
+ break;
950
+ case 'Back':
951
+ objs['command.back'] = button('Back');
952
+ break;
953
+ case 'Mute':
954
+ objs['command.mute'] = button('Mute');
955
+ break;
956
+ case 'Unmute':
957
+ case 'UnMute':
958
+ objs['command.unmute'] = button('Unmute');
959
+ break;
960
+ case 'ToggleMute':
961
+ objs['command.togglemute'] = button('Toggle Mute');
962
+ break;
963
+ }
964
+ }
965
+ }
966
+ for (const [id, obj] of Object.entries(objs)) {
967
+ await this.extendObjectAsync(`${sid}.${id}`, obj);
968
+ }
969
+ }
970
+ }
971
+ if (require.main !== module) {
972
+ // Export the constructor in compact mode
973
+ module.exports = (options) => new EmbyAdapter(options);
974
+ }
975
+ else {
976
+ // otherwise start the instance directly
977
+ (() => new EmbyAdapter())();
978
+ }
979
+ //# sourceMappingURL=main.js.map