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.
- package/LICENSE +1 -1
- package/README.md +107 -78
- package/admin/i18n/de.json +17 -0
- package/admin/i18n/en.json +17 -0
- package/admin/i18n/es.json +17 -0
- package/admin/i18n/fr.json +17 -0
- package/admin/i18n/it.json +17 -0
- package/admin/i18n/nl.json +17 -0
- package/admin/i18n/pl.json +17 -0
- package/admin/i18n/pt.json +17 -0
- package/admin/i18n/ru.json +17 -0
- package/admin/i18n/uk.json +17 -0
- package/admin/i18n/zh-cn.json +17 -0
- package/admin/jsonConfig.json +130 -0
- package/build/lib/types.js +5 -0
- package/build/lib/types.js.map +1 -0
- package/build/main.js +979 -0
- package/build/main.js.map +1 -0
- package/io-package.json +68 -34
- package/package.json +38 -51
- package/.eslintignore +0 -2
- package/.eslintrc.json +0 -42
- package/.gitattributes +0 -2
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/.github/auto-merge.yml +0 -17
- package/.github/dependabot.yml +0 -18
- package/.github/workflows/dependabot-auto-merge.yml +0 -27
- package/.github/workflows/test-and-release.yml +0 -92
- package/.mocharc.json +0 -5
- package/.prettierignore +0 -2
- package/.prettierrc.js +0 -9
- package/.releaseconfig.json +0 -3
- package/admin/i18n/de/translations.json +0 -9
- package/admin/i18n/en/translations.json +0 -9
- package/admin/i18n/es/translations.json +0 -9
- package/admin/i18n/fr/translations.json +0 -9
- package/admin/i18n/it/translations.json +0 -9
- package/admin/i18n/nl/translations.json +0 -9
- package/admin/i18n/pl/translations.json +0 -9
- package/admin/i18n/pt/translations.json +0 -9
- package/admin/i18n/ru/translations.json +0 -9
- package/admin/i18n/uk/translations.json +0 -9
- package/admin/i18n/zh-cn/translations.json +0 -9
- package/admin/index_m.html +0 -119
- package/admin/words.js +0 -33
- 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
|