iobroker.assistant 1.0.3 → 1.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.
@@ -0,0 +1,625 @@
1
+ "use strict";
2
+ /**
3
+ * YouTube on the TV: "spiele den Kanal Sailing OceanAir ab" → find the channel, switch the TV on, play it.
4
+ *
5
+ * Playing goes through the `youtube-remote` adapter (YouTube Lounge API, "Link with TV"): its `youtube` state
6
+ * takes a video id, a playlist id (`PL…`), a channel id (`UC…`, plays the uploads) or a URL. What it cannot
7
+ * do is turn a spoken name into one of those — handles and names need a search. That search lives here and
8
+ * uses InnerTube, the JSON API the YouTube web page itself talks to: no API key and no quota, unlike the Data
9
+ * API (100 units per search, 10 000 a day). The price is that Google does not document it; the parser
10
+ * therefore walks the whole response for the renderer objects it knows instead of following one fixed path,
11
+ * so a reshuffled page layout still yields results.
12
+ *
13
+ * Which player is meant comes from a settings table (name, player, power state, room). A request without a
14
+ * name goes to the player in the room it came from, then to the first row — "the TV" in a one-TV household.
15
+ * A row normally names a youtube-remote **instance**, not its device: the device id comes from the TV's
16
+ * screen id and changes when the TV is paired again, so the device is looked up at runtime
17
+ * ({@link pickDevice}).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.DEFAULT_POWER_DELAY_SEC = void 0;
21
+ exports.fold = fold;
22
+ exports.isInstanceId = isInstanceId;
23
+ exports.parsePlayerRows = parsePlayerRows;
24
+ exports.pickDevice = pickDevice;
25
+ exports.findPlayer = findPlayer;
26
+ exports.isPowerOn = isPowerOn;
27
+ exports.directYouTubeTarget = directYouTubeTarget;
28
+ exports.parseSearchResults = parseSearchResults;
29
+ exports.similarName = similarName;
30
+ exports.isNamedChannel = isNamedChannel;
31
+ exports.pickBest = pickBest;
32
+ exports.spokenTitle = spokenTitle;
33
+ exports.searchYouTube = searchYouTube;
34
+ exports.parseYouTubeCommand = parseYouTubeCommand;
35
+ exports.youtubeReply = youtubeReply;
36
+ /** Default boot time of a TV between "power reports on" and "YouTube app answers". */
37
+ exports.DEFAULT_POWER_DELAY_SEC = 8;
38
+ /** Fold a word or name for comparing: lower case, umlauts spelled out, punctuation gone, single spaces. */
39
+ function fold(text) {
40
+ if (typeof text !== 'string') {
41
+ return '';
42
+ }
43
+ return text
44
+ .toLowerCase()
45
+ .replace(/ä/g, 'ae')
46
+ .replace(/ö/g, 'oe')
47
+ .replace(/ü/g, 'ue')
48
+ .replace(/ß/g, 'ss')
49
+ .replace(/ё/g, 'е')
50
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
51
+ .trim()
52
+ .replace(/\s+/g, ' ');
53
+ }
54
+ /** Same word up to an inflected ending ("Wohnzimmer"/"Wohnzimmers", "телевизор"/"телевизоре"). */
55
+ function sameWord(a, b) {
56
+ if (a === b) {
57
+ return true;
58
+ }
59
+ const short = a.length < b.length ? a : b;
60
+ const long = a.length < b.length ? b : a;
61
+ return short.length >= 4 && long.startsWith(short) && long.length - short.length <= 2;
62
+ }
63
+ /** `youtube-remote.0` — an instance, as opposed to one of its devices. */
64
+ function isInstanceId(id) {
65
+ return /^[\w-]+\.\d+$/.test(id);
66
+ }
67
+ /**
68
+ * Turn settings-table rows into players; rows without a player are skipped, the first name wins. A row may
69
+ * name the instance (the usual case) or, for an instance with several TVs, one device or its `youtube` state.
70
+ */
71
+ function parsePlayerRows(rows) {
72
+ const out = [];
73
+ const seen = new Set();
74
+ for (const row of rows || []) {
75
+ const id = String(row?.id ?? '')
76
+ .trim()
77
+ .replace(/\.youtube$/, '');
78
+ if (!id) {
79
+ continue;
80
+ }
81
+ const instance = isInstanceId(id) ? id : '';
82
+ const room = String(row?.room ?? '').trim();
83
+ // An instance row without name and room is named after its device once that is found.
84
+ const name = String(row?.name ?? '').trim() || room || (instance ? '' : id.split('.').pop() || id);
85
+ const key = fold(name);
86
+ if (key && seen.has(key)) {
87
+ continue;
88
+ }
89
+ seen.add(key);
90
+ const delay = Number(row?.delay);
91
+ out.push({
92
+ name,
93
+ instance,
94
+ base: instance ? '' : id,
95
+ target: instance ? '' : `${id}.youtube`,
96
+ power: String(row?.power ?? '').trim(),
97
+ room,
98
+ delaySec: row?.delay === undefined || row.delay === '' || !Number.isFinite(delay)
99
+ ? exports.DEFAULT_POWER_DELAY_SEC
100
+ : Math.min(120, Math.max(0, delay)),
101
+ });
102
+ }
103
+ return out;
104
+ }
105
+ /**
106
+ * The device an instance row means: the one named like the row (name or room), else the first — with one
107
+ * TV per instance, which is the normal case, simply that one. `null` while nothing is paired.
108
+ */
109
+ function pickDevice(devices, player) {
110
+ if (!devices.length) {
111
+ return null;
112
+ }
113
+ const sorted = [...devices].sort((a, b) => a.id.localeCompare(b.id));
114
+ return (
115
+ // Every word of the row must be in the device name: a row "Schlafzimmer TV" is not the device "TV".
116
+ sorted.find(d => !!player.name && nameMatches(player.name, d.name)) ||
117
+ sorted.find(d => !!player.room && nameMatches(player.room, d.name)) ||
118
+ sorted[0]);
119
+ }
120
+ /** Does a configured name (all its words) match what was said? */
121
+ function nameMatches(name, wanted) {
122
+ const a = fold(name);
123
+ const b = fold(wanted);
124
+ if (!a || !b) {
125
+ return false;
126
+ }
127
+ if (a === b) {
128
+ return true;
129
+ }
130
+ const nameWords = a.split(' ');
131
+ const saidWords = b.split(' ');
132
+ return nameWords.every(n => saidWords.some(s => sameWord(n, s)));
133
+ }
134
+ /**
135
+ * Which player is meant: the named one (name, then room), else the one in the room the request came from,
136
+ * else the first row. A name that matches nothing is `null` — playing on the wrong TV is worse than asking.
137
+ */
138
+ function findPlayer(players, wanted, room) {
139
+ if (!players.length) {
140
+ return null;
141
+ }
142
+ const name = (wanted || '').trim();
143
+ if (name) {
144
+ return (players.find(p => fold(p.name) === fold(name)) ||
145
+ players.find(p => p.room && fold(p.room) === fold(name)) ||
146
+ players.find(p => nameMatches(p.name, name) || (!!p.room && nameMatches(p.room, name))) ||
147
+ null);
148
+ }
149
+ if (room) {
150
+ const here = players.find(p => (p.room && fold(p.room) === fold(room)) || fold(p.name) === fold(room));
151
+ if (here) {
152
+ return here;
153
+ }
154
+ }
155
+ return players[0];
156
+ }
157
+ /** Does a value of a power state mean "on"? */
158
+ function isPowerOn(value) {
159
+ if (typeof value === 'boolean') {
160
+ return value;
161
+ }
162
+ if (typeof value === 'number') {
163
+ return value > 0;
164
+ }
165
+ return typeof value === 'string' && /^(on|true|1|an|ein|power_?on|вкл)$/i.test(value.trim());
166
+ }
167
+ /**
168
+ * The query as something the player takes without a search: a youtube.com/youtu.be URL, a channel id, a
169
+ * playlist id, or — when a video is asked for — an 11-character video id. `null` = needs a search.
170
+ */
171
+ function directYouTubeTarget(query, kind) {
172
+ const q = query.trim();
173
+ if (/^(https?:\/\/)?((www|m|music)\.)?(youtube\.com|youtu\.be)\//i.test(q)) {
174
+ return q;
175
+ }
176
+ if (/^UC[\w-]{22}$/.test(q) || /^(PL|UU|OL|FL)[\w-]{10,}$/.test(q)) {
177
+ return q;
178
+ }
179
+ // A plain word of 11 letters ("Tagesschaus") is no id: a real one nearly always mixes in digits,
180
+ // capitals or -/_.
181
+ if (kind === 'video' && /^[\w-]{11}$/.test(q) && /[\d_A-Z-]/.test(q.slice(1))) {
182
+ return q;
183
+ }
184
+ return null;
185
+ }
186
+ /** InnerTube search filter (`sp` parameter of the results page): only channels / videos / playlists. */
187
+ const SEARCH_FILTER = {
188
+ video: 'EgIQAQ==',
189
+ channel: 'EgIQAg==',
190
+ playlist: 'EgIQAw==',
191
+ };
192
+ /** Text of an InnerTube text object (`{simpleText}` or `{runs:[{text}]}`). */
193
+ function textOf(node) {
194
+ if (!node || typeof node !== 'object') {
195
+ return '';
196
+ }
197
+ const n = node;
198
+ if (typeof n.simpleText === 'string') {
199
+ return n.simpleText;
200
+ }
201
+ if (typeof n.content === 'string') {
202
+ return n.content;
203
+ }
204
+ if (Array.isArray(n.runs)) {
205
+ return n.runs.map(r => (typeof r?.text === 'string' ? r.text : '')).join('');
206
+ }
207
+ return '';
208
+ }
209
+ /**
210
+ * Pull the results of one kind out of an InnerTube search response. Walks the whole tree: the renderer
211
+ * objects stay stable for years, the containers around them do not (playlists already moved from
212
+ * `playlistRenderer` to `lockupViewModel`).
213
+ */
214
+ function parseSearchResults(response, kind) {
215
+ const hits = [];
216
+ const seen = new Set();
217
+ const add = (hit) => {
218
+ if (hit.id && !seen.has(hit.id)) {
219
+ seen.add(hit.id);
220
+ hits.push(hit);
221
+ }
222
+ };
223
+ const walk = (node, depth) => {
224
+ if (!node || typeof node !== 'object' || depth > 40) {
225
+ return;
226
+ }
227
+ if (Array.isArray(node)) {
228
+ for (const item of node) {
229
+ walk(item, depth + 1);
230
+ }
231
+ return;
232
+ }
233
+ for (const [key, value] of Object.entries(node)) {
234
+ const v = value;
235
+ if (key === 'channelRenderer' && kind === 'channel' && typeof v?.channelId === 'string') {
236
+ // YouTube shows the handle in the field that used to hold the subscriber count.
237
+ const handle = [textOf(v.subscriberCountText), textOf(v.videoCountText)].find(t => t.startsWith('@'));
238
+ add({ kind, id: v.channelId, title: textOf(v.title), handle });
239
+ }
240
+ else if (key === 'videoRenderer' && kind === 'video' && typeof v?.videoId === 'string') {
241
+ add({ kind, id: v.videoId, title: textOf(v.title), channel: textOf(v.ownerText) || undefined });
242
+ }
243
+ else if (key === 'playlistRenderer' && kind === 'playlist' && typeof v?.playlistId === 'string') {
244
+ add({
245
+ kind,
246
+ id: v.playlistId,
247
+ title: textOf(v.title),
248
+ channel: textOf(v.shortBylineText) || undefined,
249
+ });
250
+ }
251
+ else if (key === 'lockupViewModel' && typeof v?.contentId === 'string') {
252
+ const type = String(v.contentType || '');
253
+ const meta = v.metadata?.lockupMetadataViewModel;
254
+ if ((kind === 'playlist' && type === 'LOCKUP_CONTENT_TYPE_PLAYLIST') ||
255
+ (kind === 'video' && type === 'LOCKUP_CONTENT_TYPE_VIDEO')) {
256
+ add({ kind, id: v.contentId, title: textOf(meta?.title) });
257
+ }
258
+ }
259
+ walk(value, depth + 1);
260
+ }
261
+ };
262
+ walk(response, 0);
263
+ return hits;
264
+ }
265
+ /** Edit distance of two short strings (names, not texts — the matrix is small). */
266
+ function editDistance(a, b) {
267
+ let prev = Array.from({ length: b.length + 1 }, (_, k) => k);
268
+ for (let i = 1; i <= a.length; i++) {
269
+ const cur = [i];
270
+ for (let k = 1; k <= b.length; k++) {
271
+ cur[k] = Math.min(prev[k] + 1, cur[k - 1] + 1, prev[k - 1] + (a[i - 1] === b[k - 1] ? 0 : 1));
272
+ }
273
+ prev = cur;
274
+ }
275
+ return prev[b.length];
276
+ }
277
+ /**
278
+ * Is a spoken name the same name? Spaces do not count and a typo or two per eight letters is allowed,
279
+ * because the name went through speech recognition: "Sailing Ocean Air" is the channel "Sailing Oceanaire".
280
+ */
281
+ function similarName(said, name) {
282
+ const a = fold(said).replace(/ /g, '');
283
+ const b = fold(name).replace(/ /g, '');
284
+ if (!a || !b) {
285
+ return false;
286
+ }
287
+ return a === b || editDistance(a, b) <= Math.max(1, Math.floor(Math.min(a.length, b.length) / 8));
288
+ }
289
+ /** Is this channel the one that was named — by its title (without the " | tagline") or its handle? */
290
+ function isNamedChannel(hit, query) {
291
+ return similarName(query, spokenTitle(hit)) || (!!hit.handle && similarName(query, hit.handle.replace(/^@/, '')));
292
+ }
293
+ /**
294
+ * The hit that was most likely meant: a channel whose name is the query (see {@link isNamedChannel}), a
295
+ * video or playlist titled like it, otherwise YouTube's own first result. The name match matters when a
296
+ * bigger channel outranks the one that was named.
297
+ */
298
+ function pickBest(hits, query) {
299
+ if (!hits.length) {
300
+ return null;
301
+ }
302
+ const q = fold(query);
303
+ return hits.find(h => (h.kind === 'channel' ? isNamedChannel(h, query) : fold(h.title) === q)) || hits[0];
304
+ }
305
+ /** The title as it should be spoken: without the " | tagline" channels like to append. */
306
+ function spokenTitle(hit) {
307
+ return hit.title.split(' | ')[0].trim() || hit.title;
308
+ }
309
+ /** Search YouTube for channels, videos or playlists; throws on a network or HTTP error. */
310
+ async function searchYouTube(query, kind, opts = {}) {
311
+ const fetchFn = opts.fetchFn || fetch;
312
+ const res = await fetchFn('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
313
+ method: 'POST',
314
+ headers: { 'content-type': 'application/json' },
315
+ body: JSON.stringify({
316
+ context: { client: { clientName: 'WEB', clientVersion: '2.20250101.00.00', hl: opts.language || 'en' } },
317
+ query,
318
+ params: SEARCH_FILTER[kind],
319
+ }),
320
+ signal: AbortSignal.timeout(opts.timeoutMs || 10_000),
321
+ });
322
+ if (res.status !== 200) {
323
+ throw new Error(`YouTube search answered HTTP ${res.status}`);
324
+ }
325
+ return parseSearchResults(await res.json(), kind);
326
+ }
327
+ // ── Rule-based "play … on YouTube" ──────────────────────────────────────────
328
+ const VERBS = new Set([
329
+ 'spiel',
330
+ 'spiele',
331
+ 'spielt',
332
+ 'abspielen',
333
+ 'zeig',
334
+ 'zeige',
335
+ 'starte',
336
+ 'start',
337
+ 'play',
338
+ 'show',
339
+ 'put',
340
+ 'включи',
341
+ 'поставь',
342
+ 'запусти',
343
+ 'покажи',
344
+ 'воспроизведи',
345
+ ]);
346
+ const POLITE = new Set(['bitte', 'mal', 'please', 'пожалуйста']);
347
+ const FILLERS = new Set([
348
+ 'mir',
349
+ 'uns',
350
+ 'me',
351
+ 'us',
352
+ 'мне',
353
+ 'нам',
354
+ 'den',
355
+ 'das',
356
+ 'die',
357
+ 'einen',
358
+ 'ein',
359
+ 'eine',
360
+ 'the',
361
+ 'a',
362
+ 'an',
363
+ 'some',
364
+ 'mal',
365
+ 'bitte',
366
+ ]);
367
+ const KIND_WORDS = {
368
+ kanal: 'channel',
369
+ channel: 'channel',
370
+ канал: 'channel',
371
+ video: 'video',
372
+ clip: 'video',
373
+ видео: 'video',
374
+ ролик: 'video',
375
+ playlist: 'playlist',
376
+ wiedergabeliste: 'playlist',
377
+ плейлист: 'playlist',
378
+ };
379
+ const OF_WORDS = new Set(['von', 'vom', 'of', 'from', 'by', 'от']);
380
+ const YOUTUBE_WORDS = new Set(['youtube', 'ютуб', 'ютубе', 'ютюб', 'ютюбе']);
381
+ const PREPOSITIONS = new Set(['auf', 'im', 'in', 'am', 'an', 'bei', 'dem', 'der', 'on', 'at', 'the', 'на', 'в', 'во']);
382
+ /** Prepositions that only ever say "where" — what follows them right after the verb is a place, not a title. */
383
+ const LOCATIVE = new Set(['im', 'в', 'во']);
384
+ const ARTICLES = new Set(['der', 'dem', 'den', 'the']);
385
+ const TRAILING = new Set(['ab', 'please', 'bitte', 'пожалуйста', 'jetzt', 'now']);
386
+ /** A "query" that is really a request to repeat — not something to search for. */
387
+ const NOT_A_QUERY = new Set(['nochmal', 'noch mal', 'weiter', 'again', 'more', 'ещё', 'еще', 'ещё раз', 'еще раз']);
388
+ /**
389
+ * Understand "spiele den Kanal Sailing OceanAir (auf dem Fernseher) ab", "play the channel X on the bedroom
390
+ * TV", "включи канал X", "spiele im Wohnzimmer Sailing Ocean Air auf YouTube". Needs a kind word
391
+ * (Kanal/channel/канал, Video, Playlist) or the word YouTube — "spiele Musik im Wohnzimmer" stays with the
392
+ * rest of the pipeline. Without a kind word the kind is `auto`. A player name or room right after the verb
393
+ * or at the end is cut off the query, as is a place right after the verb ("im Bad") — that one is then
394
+ * looked up as a player and answered as unknown, which beats searching YouTube for "im Bad X". Everything
395
+ * else stays in the query with its spelling, because it is what gets searched for.
396
+ */
397
+ function parseYouTubeCommand(text, players) {
398
+ const words = text
399
+ .trim()
400
+ .split(/\s+/)
401
+ .map(w => w.replace(/^[„"“”'«»]+|[„"“”'«».,!?;:]+$/g, ''))
402
+ .filter(Boolean);
403
+ const low = words.map(w => fold(w));
404
+ let i = 0;
405
+ while (i < low.length && POLITE.has(low[i])) {
406
+ i++;
407
+ }
408
+ if (i >= low.length || !VERBS.has(low[i])) {
409
+ return null;
410
+ }
411
+ i++;
412
+ const sawYouTube = low.some(w => YOUTUBE_WORDS.has(w));
413
+ // Player names and rooms, longest first so "Schlafzimmer TV" wins over "TV".
414
+ const candidates = players
415
+ .flatMap(p => [
416
+ { player: p.name, words: fold(p.name).split(' ') },
417
+ ...(p.room ? [{ player: p.name || p.room, words: fold(p.room).split(' ') }] : []),
418
+ ])
419
+ .filter(c => c.words[0])
420
+ .sort((a, b) => b.words.length - a.words.length);
421
+ const nameAt = (at) => {
422
+ for (const c of candidates) {
423
+ if (c.words.every((w, k) => at + k < low.length && sameWord(w, low[at + k]))) {
424
+ return { player: c.player, end: at + c.words.length };
425
+ }
426
+ }
427
+ return null;
428
+ };
429
+ /** "im Wohnzimmer", "auf dem Fernseher", "в спальне" starting at `at`; `null` if it is no place. */
430
+ const placeAt = (at) => {
431
+ let j = at;
432
+ while (j < low.length && PREPOSITIONS.has(low[j])) {
433
+ j++;
434
+ }
435
+ if (j === at || j >= low.length) {
436
+ return null;
437
+ }
438
+ const named = nameAt(j);
439
+ if (named) {
440
+ return named;
441
+ }
442
+ const locative = LOCATIVE.has(low[at]) || (low[at] === 'in' && ARTICLES.has(low[at + 1]));
443
+ return locative ? { player: words[j], end: j + 1 } : null;
444
+ };
445
+ let kind = null;
446
+ let player;
447
+ // Fillers, "auf YouTube", the kind word and the place may come in any order before the name.
448
+ for (;;) {
449
+ if (i < low.length && FILLERS.has(low[i])) {
450
+ i++;
451
+ }
452
+ else if (i + 1 < low.length && PREPOSITIONS.has(low[i]) && YOUTUBE_WORDS.has(low[i + 1])) {
453
+ i += 2;
454
+ }
455
+ else if (i < low.length && YOUTUBE_WORDS.has(low[i])) {
456
+ i++;
457
+ }
458
+ else if (!kind && i < low.length && KIND_WORDS[low[i]]) {
459
+ kind = KIND_WORDS[low[i]];
460
+ i++;
461
+ if (i < low.length && OF_WORDS.has(low[i])) {
462
+ i++;
463
+ }
464
+ }
465
+ else if (!player && i < low.length) {
466
+ const place = placeAt(i);
467
+ // Only if something is left to search for.
468
+ if (!place || place.end >= low.length) {
469
+ break;
470
+ }
471
+ player = place.player;
472
+ i = place.end;
473
+ }
474
+ else {
475
+ break;
476
+ }
477
+ }
478
+ if (!kind) {
479
+ if (!sawYouTube) {
480
+ return null;
481
+ }
482
+ kind = 'auto';
483
+ }
484
+ let end = words.length;
485
+ const trimTail = () => {
486
+ for (;;) {
487
+ if (end > i && TRAILING.has(low[end - 1])) {
488
+ end--;
489
+ }
490
+ else if (end > i && YOUTUBE_WORDS.has(low[end - 1])) {
491
+ end--;
492
+ while (end > i && PREPOSITIONS.has(low[end - 1])) {
493
+ end--;
494
+ }
495
+ }
496
+ else {
497
+ return;
498
+ }
499
+ }
500
+ };
501
+ trimTail();
502
+ // A trailing player: its name or room, preceded by a preposition so a channel called like a room is
503
+ // not eaten ("Kanal Küche" without "in der" stays a channel).
504
+ for (const c of player ? [] : candidates) {
505
+ const n = c.words.length;
506
+ if (end - n <= i) {
507
+ continue;
508
+ }
509
+ const tail = low.slice(end - n, end);
510
+ if (!c.words.every((w, k) => sameWord(w, tail[k]))) {
511
+ continue;
512
+ }
513
+ let start = end - n;
514
+ if (!PREPOSITIONS.has(low[start - 1])) {
515
+ continue;
516
+ }
517
+ while (start > i && PREPOSITIONS.has(low[start - 1])) {
518
+ start--;
519
+ }
520
+ if (start <= i) {
521
+ continue; // nothing would be left to search for
522
+ }
523
+ player = c.player;
524
+ end = start;
525
+ trimTail();
526
+ break;
527
+ }
528
+ const query = words.slice(i, end).join(' ').trim();
529
+ if (!query || NOT_A_QUERY.has(fold(query))) {
530
+ return null;
531
+ }
532
+ return { kind, query, ...(player ? { player } : {}) };
533
+ }
534
+ /** "the channel „X“" in the reply language. */
535
+ function describeWhat(lang, kind, title) {
536
+ if (lang === 'de') {
537
+ return `${kind === 'channel' ? 'den Kanal' : kind === 'playlist' ? 'die Playlist' : 'das Video'} „${title}“`;
538
+ }
539
+ if (lang === 'ru') {
540
+ return `${kind === 'channel' ? 'канал' : kind === 'playlist' ? 'плейлист' : 'видео'} «${title}»`;
541
+ }
542
+ return `the ${kind} "${title}"`;
543
+ }
544
+ /** What nothing was found of: "keinen Kanal", "no channel", "канал". */
545
+ function describeNone(lang, kind) {
546
+ if (lang === 'de') {
547
+ return kind === 'channel' ? 'keinen Kanal' : kind === 'playlist' ? 'keine Playlist' : 'kein Video';
548
+ }
549
+ if (lang === 'ru') {
550
+ return kind === 'channel' ? 'канал' : kind === 'playlist' ? 'плейлист' : 'видео';
551
+ }
552
+ return `no ${kind}`;
553
+ }
554
+ /** A spoken reply of the YouTube feature. */
555
+ function youtubeReply(lang, key, v = {}) {
556
+ const kind = v.kind || 'video';
557
+ const what = describeWhat(lang, kind, v.title || v.query || '');
558
+ const player = v.player || '';
559
+ if (lang === 'de') {
560
+ switch (key) {
561
+ case 'playing':
562
+ return `Ich spiele ${what} auf ${player}.`;
563
+ case 'poweringOn':
564
+ return `Ich schalte ${player} ein und spiele dann ${what}.`;
565
+ case 'notFound':
566
+ return `Ich habe auf YouTube ${describeNone(lang, kind)} „${v.query}“ gefunden.`;
567
+ case 'searchFailed':
568
+ return 'Die YouTube-Suche ist gerade nicht erreichbar.';
569
+ case 'noConfirm':
570
+ return `${player} hat nicht reagiert. Ist der Fernseher an und die YouTube-App verbunden?`;
571
+ case 'adapterDown':
572
+ return `Der YouTube-Adapter ${v.instance} läuft nicht.`;
573
+ case 'unknownPlayer':
574
+ return `Einen Player „${v.query}“ kenne ich nicht. Ich kenne: ${v.names}.`;
575
+ case 'noControl':
576
+ return 'Die Gerätesteuerung ist in den Einstellungen ausgeschaltet.';
577
+ case 'noDevice':
578
+ return `Mit ${v.instance} ist noch kein Fernseher gekoppelt.`;
579
+ }
580
+ }
581
+ if (lang === 'ru') {
582
+ switch (key) {
583
+ case 'playing':
584
+ return `Включаю ${what} — ${player}.`;
585
+ case 'poweringOn':
586
+ return `Включаю ${player}, потом запущу ${what}.`;
587
+ case 'notFound':
588
+ return `На YouTube не нашла ${describeNone(lang, kind)} «${v.query}».`;
589
+ case 'searchFailed':
590
+ return 'Поиск YouTube сейчас недоступен.';
591
+ case 'noConfirm':
592
+ return `${player} не ответил. Телевизор включён и приложение YouTube подключено?`;
593
+ case 'adapterDown':
594
+ return `Адаптер YouTube ${v.instance} не запущен.`;
595
+ case 'unknownPlayer':
596
+ return `Плеер «${v.query}» мне не известен. Я знаю: ${v.names}.`;
597
+ case 'noControl':
598
+ return 'Управление устройствами отключено в настройках.';
599
+ case 'noDevice':
600
+ return `С ${v.instance} ещё не связан ни один телевизор.`;
601
+ }
602
+ }
603
+ switch (key) {
604
+ case 'playing':
605
+ return `Playing ${what} on ${player}.`;
606
+ case 'poweringOn':
607
+ return `Switching ${player} on, then playing ${what}.`;
608
+ case 'notFound':
609
+ return `I found ${describeNone(lang, kind)} "${v.query}" on YouTube.`;
610
+ case 'searchFailed':
611
+ return 'The YouTube search cannot be reached right now.';
612
+ case 'noConfirm':
613
+ return `${player} did not respond. Is the TV on and the YouTube app linked?`;
614
+ case 'adapterDown':
615
+ return `The YouTube adapter ${v.instance} is not running.`;
616
+ case 'unknownPlayer':
617
+ return `I don't know a player "${v.query}". I know: ${v.names}.`;
618
+ case 'noControl':
619
+ return 'Device control is switched off in the settings.';
620
+ case 'noDevice':
621
+ return `No TV is paired with ${v.instance} yet.`;
622
+ }
623
+ return '';
624
+ }
625
+ //# sourceMappingURL=youtube.js.map