yoto-nodejs-client 0.0.7 → 0.0.9

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/README.md CHANGED
@@ -958,6 +958,7 @@ Create a stateful device client that manages device state primarily from MQTT wi
958
958
  - `await deviceClient.refreshConfig()` - Refresh config from HTTP API
959
959
  - `await deviceClient.updateConfig(configUpdate)` - Update device configuration
960
960
  - `await deviceClient.sendCommand(command)` - Send device command via HTTP
961
+ - `await deviceClient.startCard({ cardId, [chapterKey], [trackKey] })` - Start playing a card
961
962
 
962
963
  **Events:**
963
964
  - `started(metadata)` - Device client started, passes metadata object with device, config, shortcuts, status, playback, initialized, running
package/lib/pkg.d.cts CHANGED
@@ -7,13 +7,15 @@ export const pkg: {
7
7
  url: string;
8
8
  };
9
9
  dependencies: {
10
+ fastq: string;
10
11
  "jwt-decode": string;
11
12
  mqtt: string;
13
+ "quick-lru": string;
12
14
  undici: string;
13
15
  };
14
16
  devDependencies: {
15
- "@unblessed/node": string;
16
17
  "@types/node": string;
18
+ "@unblessed/node": string;
17
19
  "@voxpelli/tsconfig": string;
18
20
  argsclopts: string;
19
21
  "auto-changelog": string;
@@ -116,6 +116,34 @@ export const YotoDeviceModelConfigType: {};
116
116
  * @property {string} updatedAt - ISO 8601 timestamp of last update
117
117
  */
118
118
  export const YotoPlaybackStateType: {};
119
+ /**
120
+ * Cached chapter info for playback enrichment.
121
+ * @typedef {Object} YotoCardCacheChapterInfo
122
+ * @property {string} key
123
+ * @property {string} title
124
+ */
125
+ /**
126
+ * Cached track info for playback enrichment.
127
+ * @typedef {Object} YotoCardCacheTrackInfo
128
+ * @property {string} key
129
+ * @property {string} title
130
+ * @property {number} duration
131
+ * @property {string} chapterKey
132
+ * @property {string} chapterTitle
133
+ */
134
+ /**
135
+ * Cached card metadata for playback enrichment.
136
+ * @typedef {Object} YotoCardCacheEntry
137
+ * @property {string} cardId
138
+ * @property {string} title
139
+ * @property {Map<string, YotoCardCacheChapterInfo>} chaptersByKey
140
+ * @property {Map<string, YotoCardCacheTrackInfo>} tracksByKey
141
+ */
142
+ /**
143
+ * Cached card lookup task.
144
+ * @typedef {Object} YotoCardCacheTask
145
+ * @property {string} cardId
146
+ */
119
147
  /**
120
148
  * Complete device client state
121
149
  * @typedef {Object} YotoDeviceClientState
@@ -389,23 +417,10 @@ export class YotoDeviceModel extends EventEmitter<YotoDeviceModelEventMap> {
389
417
  reboot(): Promise<void>;
390
418
  /**
391
419
  * Start card playback over MQTT
392
- * @param {Object} options - Card start options
393
- * @param {string} options.uri - Card URI (e.g., "https://yoto.io/<cardID>")
394
- * @param {string} [options.chapterKey] - Chapter to start from
395
- * @param {string} [options.trackKey] - Track to start from
396
- * @param {number} [options.secondsIn] - Playback start offset in seconds
397
- * @param {number} [options.cutOff] - Playback stop offset in seconds
398
- * @param {boolean} [options.anyButtonStop] - Whether button press stops playback
420
+ * @param {YotoCardStartOptions} options - Card start options
399
421
  * @returns {Promise<void>}
400
422
  */
401
- startCard(options: {
402
- uri: string;
403
- chapterKey?: string | undefined;
404
- trackKey?: string | undefined;
405
- secondsIn?: number | undefined;
406
- cutOff?: number | undefined;
407
- anyButtonStop?: boolean | undefined;
408
- }): Promise<void>;
423
+ startCard(options: YotoCardStartOptions): Promise<void>;
409
424
  /**
410
425
  * Stop card playback over MQTT
411
426
  * @returns {Promise<void>}
@@ -522,6 +537,35 @@ export type DayMode = "unknown" | "night" | "day";
522
537
  * Power source state
523
538
  */
524
539
  export type PowerSource = "battery" | "dock" | "usb-c" | "wireless";
540
+ /**
541
+ * Card start options for Yoto playback.
542
+ */
543
+ export type YotoCardStartOptions = {
544
+ /**
545
+ * - Card ID (used when uri is not provided)
546
+ */
547
+ cardId: string;
548
+ /**
549
+ * - Chapter to start from
550
+ */
551
+ chapterKey?: string;
552
+ /**
553
+ * - Track to start from
554
+ */
555
+ trackKey?: string;
556
+ /**
557
+ * - Playback start offset in seconds
558
+ */
559
+ secondsIn?: number;
560
+ /**
561
+ * - Playback stop offset in seconds
562
+ */
563
+ cutOff?: number;
564
+ /**
565
+ * - Whether button press stops playback
566
+ */
567
+ anyButtonStop?: boolean;
568
+ };
525
569
  /**
526
570
  * Canonical device status - normalized format for both HTTP and MQTT sources
527
571
  *
@@ -829,6 +873,38 @@ export type YotoPlaybackState = {
829
873
  */
830
874
  updatedAt: string;
831
875
  };
876
+ /**
877
+ * Cached chapter info for playback enrichment.
878
+ */
879
+ export type YotoCardCacheChapterInfo = {
880
+ key: string;
881
+ title: string;
882
+ };
883
+ /**
884
+ * Cached track info for playback enrichment.
885
+ */
886
+ export type YotoCardCacheTrackInfo = {
887
+ key: string;
888
+ title: string;
889
+ duration: number;
890
+ chapterKey: string;
891
+ chapterTitle: string;
892
+ };
893
+ /**
894
+ * Cached card metadata for playback enrichment.
895
+ */
896
+ export type YotoCardCacheEntry = {
897
+ cardId: string;
898
+ title: string;
899
+ chaptersByKey: Map<string, YotoCardCacheChapterInfo>;
900
+ tracksByKey: Map<string, YotoCardCacheTrackInfo>;
901
+ };
902
+ /**
903
+ * Cached card lookup task.
904
+ */
905
+ export type YotoCardCacheTask = {
906
+ cardId: string;
907
+ };
832
908
  /**
833
909
  * Complete device client state
834
910
  */
@@ -1 +1 @@
1
- {"version":3,"file":"yoto-device.d.ts","sourceRoot":"","sources":["yoto-device.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"yoto-device.d.ts","sourceRoot":"","sources":["yoto-device.js"],"names":[],"mappings":"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"}
@@ -8,17 +8,26 @@
8
8
 
9
9
  /**
10
10
  * @import { IConnackPacket } from 'mqtt'
11
+ * @import { queueAsPromised } from 'fastq'
11
12
  * @import { YotoClient } from './api-client.js'
13
+ * @import { YotoCard } from './api-endpoints/content.js'
12
14
  * @import { YotoDevice, YotoDeviceConfig, YotoDeviceShortcuts, YotoDeviceFullStatus, YotoDeviceStatusResponse, YotoDeviceCommand, YotoDeviceCommandResponse } from './api-endpoints/devices.js'
13
15
  * @import { YotoMqttClient, YotoMqttStatus, YotoEventsMessage, YotoLegacyStatus, YotoStatusMessage, YotoStatusLegacyMessage, YotoResponseMessage, YotoMqttClientDisconnectMetadata, YotoMqttClientCloseMetadata, PlaybackStatus } from './mqtt/client.js'
14
16
  * @import { YotoMqttOptions } from './mqtt/factory.js'
17
+ * @import { YotoCardStartCommand } from './mqtt/commands.js'
15
18
  */
16
19
 
17
20
  import { EventEmitter } from 'events'
21
+ import fastq from 'fastq'
22
+ import QuickLRU from 'quick-lru'
18
23
  import { parseTemperature } from './helpers/temperature.js'
19
24
  import { detectPowerState } from './helpers/power-state.js'
20
25
  import { typedKeys } from './helpers/typed-keys.js'
21
26
 
27
+ const CARD_CACHE_MAX_SIZE = 2000
28
+ const CARD_CACHE_QUEUE_CONCURRENCY = 1
29
+ const CARD_ID_NONE = 'none'
30
+
22
31
  // ============================================================================
23
32
  // Type Definitions
24
33
  // ============================================================================
@@ -81,6 +90,17 @@ function convertPowerSource (numericSource) {
81
90
  * @typedef {'battery' | 'dock' | 'usb-c' | 'wireless'} PowerSource
82
91
  */
83
92
 
93
+ /**
94
+ * Card start options for Yoto playback.
95
+ * @typedef {Object} YotoCardStartOptions
96
+ * @property {string} cardId - Card ID (used when uri is not provided)
97
+ * @property {string} [chapterKey] - Chapter to start from
98
+ * @property {string} [trackKey] - Track to start from
99
+ * @property {number} [secondsIn] - Playback start offset in seconds
100
+ * @property {number} [cutOff] - Playback stop offset in seconds
101
+ * @property {boolean} [anyButtonStop] - Whether button press stops playback
102
+ */
103
+
84
104
  /**
85
105
  * Normalize "0"/"1" booleans from config to true/false.
86
106
  * @param {string | boolean} value
@@ -398,6 +418,38 @@ export const YotoPlaybackStateType = {}
398
418
  // Internal Types
399
419
  // ============================================================================
400
420
 
421
+ /**
422
+ * Cached chapter info for playback enrichment.
423
+ * @typedef {Object} YotoCardCacheChapterInfo
424
+ * @property {string} key
425
+ * @property {string} title
426
+ */
427
+
428
+ /**
429
+ * Cached track info for playback enrichment.
430
+ * @typedef {Object} YotoCardCacheTrackInfo
431
+ * @property {string} key
432
+ * @property {string} title
433
+ * @property {number} duration
434
+ * @property {string} chapterKey
435
+ * @property {string} chapterTitle
436
+ */
437
+
438
+ /**
439
+ * Cached card metadata for playback enrichment.
440
+ * @typedef {Object} YotoCardCacheEntry
441
+ * @property {string} cardId
442
+ * @property {string} title
443
+ * @property {Map<string, YotoCardCacheChapterInfo>} chaptersByKey
444
+ * @property {Map<string, YotoCardCacheTrackInfo>} tracksByKey
445
+ */
446
+
447
+ /**
448
+ * Cached card lookup task.
449
+ * @typedef {Object} YotoCardCacheTask
450
+ * @property {string} cardId
451
+ */
452
+
401
453
  /**
402
454
  * Complete device client state
403
455
  * @typedef {Object} YotoDeviceClientState
@@ -555,6 +607,9 @@ export class YotoDeviceModel extends EventEmitter {
555
607
  /** @type {NodeJS.Timeout | null} */ #eventsRequestTimer = null
556
608
  /** @type {NodeJS.Timeout | null} */ #backgroundPollTimer = null
557
609
  /** @type {number | null} */ #shutdownDetectedAt = null
610
+ /** @type {QuickLRU<string, YotoCardCacheEntry>} */ #cardCache = new QuickLRU({ maxSize: CARD_CACHE_MAX_SIZE })
611
+ /** @type {queueAsPromised<YotoCardCacheTask, YotoCardCacheEntry | null>} */ #cardCacheQueue = fastq.promise(this, this.#fetchCardCacheTask, CARD_CACHE_QUEUE_CONCURRENCY)
612
+ /** @type {Set<string>} */ #cardCacheQueueTasks = new Set()
558
613
 
559
614
  /**
560
615
  * Create a Yoto device client
@@ -941,17 +996,26 @@ export class YotoDeviceModel extends EventEmitter {
941
996
 
942
997
  /**
943
998
  * Start card playback over MQTT
944
- * @param {Object} options - Card start options
945
- * @param {string} options.uri - Card URI (e.g., "https://yoto.io/<cardID>")
946
- * @param {string} [options.chapterKey] - Chapter to start from
947
- * @param {string} [options.trackKey] - Track to start from
948
- * @param {number} [options.secondsIn] - Playback start offset in seconds
949
- * @param {number} [options.cutOff] - Playback stop offset in seconds
950
- * @param {boolean} [options.anyButtonStop] - Whether button press stops playback
999
+ * @param {YotoCardStartOptions} options - Card start options
951
1000
  * @returns {Promise<void>}
952
1001
  */
953
1002
  async startCard (options) {
954
- return await this.#mqttClient?.startCard(options)
1003
+ const uri = `https://yoto.io/${options.cardId}`
1004
+
1005
+ if (!uri) {
1006
+ throw new Error('Card URI or cardId is required')
1007
+ }
1008
+
1009
+ /** @type {YotoCardStartCommand} */
1010
+ const payload = { uri }
1011
+
1012
+ if (typeof options.chapterKey === 'string') payload.chapterKey = options.chapterKey
1013
+ if (typeof options.trackKey === 'string') payload.trackKey = options.trackKey
1014
+ if (typeof options.secondsIn === 'number') payload.secondsIn = options.secondsIn
1015
+ if (typeof options.cutOff === 'number') payload.cutOff = options.cutOff
1016
+ if (typeof options.anyButtonStop === 'boolean') payload.anyButtonStop = options.anyButtonStop
1017
+
1018
+ return await this.#mqttClient?.startCard(payload)
955
1019
  }
956
1020
 
957
1021
  /**
@@ -3081,6 +3145,69 @@ export class YotoDeviceModel extends EventEmitter {
3081
3145
  }
3082
3146
  }
3083
3147
 
3148
+ /**
3149
+ * Apply cached card metadata to playback state (if available).
3150
+ * @param {string | null} cardId
3151
+ * @param {YotoPlaybackState} playback
3152
+ * @param {Set<keyof YotoPlaybackState>} playbackChangedFields
3153
+ * @returns {boolean}
3154
+ */
3155
+ #applyCardCacheToPlayback (cardId, playback, playbackChangedFields) {
3156
+ if (!isCacheableCardId(cardId)) {
3157
+ return false
3158
+ }
3159
+
3160
+ const cachedEntry = this.#cardCache.get(cardId)
3161
+ if (cachedEntry) {
3162
+ return applyCardCacheToPlayback(cachedEntry, playback, playbackChangedFields)
3163
+ }
3164
+
3165
+ this.#queueCardCacheFetch(cardId)
3166
+ return false
3167
+ }
3168
+
3169
+ /**
3170
+ * @param {YotoCardCacheTask} task
3171
+ * @returns {Promise<YotoCardCacheEntry | null>}
3172
+ */
3173
+ async #fetchCardCacheTask (task) {
3174
+ try {
3175
+ const response = await this.#client.getContent({ cardId: task.cardId })
3176
+ const entry = buildCardCacheEntry(response.card)
3177
+ this.#cardCache.set(task.cardId, entry)
3178
+
3179
+ if (this.#state.playback.cardId === task.cardId) {
3180
+ /** @type {Set<keyof YotoPlaybackState>} */
3181
+ const playbackChangedFields = new Set()
3182
+ if (applyCardCacheToPlayback(entry, this.#state.playback, playbackChangedFields)) {
3183
+ this.#state.playback.updatedAt = new Date().toISOString()
3184
+ this.#state.lastUpdate.playback = Date.now()
3185
+ this.emit('playbackUpdate', this.playback, playbackChangedFields)
3186
+ }
3187
+ }
3188
+
3189
+ return entry
3190
+ } catch {
3191
+ return null
3192
+ } finally {
3193
+ this.#cardCacheQueueTasks.delete(task.cardId)
3194
+ }
3195
+ }
3196
+
3197
+ /**
3198
+ * Fetch card content for playback enrichment and cache the result.
3199
+ * @param {string} cardId
3200
+ * @returns {void}
3201
+ */
3202
+ #queueCardCacheFetch (cardId) {
3203
+ if (this.#cardCache.has(cardId) || this.#cardCacheQueueTasks.has(cardId)) {
3204
+ return
3205
+ }
3206
+
3207
+ this.#cardCacheQueueTasks.add(cardId)
3208
+ this.#cardCacheQueue.push({ cardId })
3209
+ }
3210
+
3084
3211
  /**
3085
3212
  * Handle MQTT event message - updates status, config, and playback
3086
3213
  * Events are partial updates - only changed fields are included
@@ -3102,6 +3229,7 @@ export class YotoDeviceModel extends EventEmitter {
3102
3229
  let playbackChanged = false
3103
3230
 
3104
3231
  const { status, config, playback } = this.#state
3232
+ const previousCardId = playback.cardId
3105
3233
  /** @type {Set<keyof YotoDeviceStatus>} */
3106
3234
  const statusChangedFields = new Set()
3107
3235
  /** @type {Set<keyof YotoDeviceModelConfig>} */
@@ -3183,10 +3311,13 @@ export class YotoDeviceModel extends EventEmitter {
3183
3311
  break
3184
3312
  }
3185
3313
  case 'cardId': {
3186
- if (eventsMessage.cardId !== undefined && playback.cardId !== eventsMessage.cardId) {
3187
- playback.cardId = eventsMessage.cardId
3188
- playbackChangedFields.add('cardId')
3189
- playbackChanged = true
3314
+ if (eventsMessage.cardId !== undefined) {
3315
+ const normalizedCardId = eventsMessage.cardId === CARD_ID_NONE ? null : eventsMessage.cardId
3316
+ if (playback.cardId !== normalizedCardId) {
3317
+ playback.cardId = normalizedCardId
3318
+ playbackChangedFields.add('cardId')
3319
+ playbackChanged = true
3320
+ }
3190
3321
  }
3191
3322
  break
3192
3323
  }
@@ -3265,6 +3396,12 @@ export class YotoDeviceModel extends EventEmitter {
3265
3396
  handleField(key, eventsMessage)
3266
3397
  }
3267
3398
 
3399
+ if (previousCardId !== playback.cardId) {
3400
+ if (this.#applyCardCacheToPlayback(playback.cardId, playback, playbackChangedFields)) {
3401
+ playbackChanged = true
3402
+ }
3403
+ }
3404
+
3268
3405
  // Update timestamps and emit events for changed categories
3269
3406
  if (statusChanged) {
3270
3407
  this.#state.lastUpdate.status = Date.now()
@@ -3311,6 +3448,98 @@ function createEmptyPlaybackState () {
3311
3448
  }
3312
3449
  }
3313
3450
 
3451
+ /**
3452
+ * @param {string | null} cardId
3453
+ * @returns {cardId is string}
3454
+ */
3455
+ function isCacheableCardId (cardId) {
3456
+ return typeof cardId === 'string' && cardId.length > 0 && cardId !== CARD_ID_NONE
3457
+ }
3458
+
3459
+ /**
3460
+ * @param {YotoCard} card
3461
+ * @returns {YotoCardCacheEntry}
3462
+ */
3463
+ function buildCardCacheEntry (card) {
3464
+ const chaptersByKey = new Map()
3465
+ const tracksByKey = new Map()
3466
+
3467
+ for (const chapter of card.content.chapters) {
3468
+ chaptersByKey.set(chapter.key, {
3469
+ key: chapter.key,
3470
+ title: chapter.title
3471
+ })
3472
+
3473
+ for (const track of chapter.tracks) {
3474
+ tracksByKey.set(track.key, {
3475
+ key: track.key,
3476
+ title: track.title,
3477
+ duration: track.duration,
3478
+ chapterKey: chapter.key,
3479
+ chapterTitle: chapter.title
3480
+ })
3481
+ }
3482
+ }
3483
+
3484
+ return {
3485
+ cardId: card.cardId,
3486
+ title: card.title,
3487
+ chaptersByKey,
3488
+ tracksByKey
3489
+ }
3490
+ }
3491
+
3492
+ /**
3493
+ * @param {YotoCardCacheEntry} entry
3494
+ * @param {YotoPlaybackState} playback
3495
+ * @param {Set<keyof YotoPlaybackState>} changedFields
3496
+ * @returns {boolean}
3497
+ */
3498
+ function applyCardCacheToPlayback (entry, playback, changedFields) {
3499
+ let changed = false
3500
+ const { trackKey } = playback
3501
+
3502
+ if (trackKey) {
3503
+ const trackInfo = entry.tracksByKey.get(trackKey)
3504
+ if (trackInfo) {
3505
+ if (playback.trackTitle !== trackInfo.title) {
3506
+ playback.trackTitle = trackInfo.title
3507
+ changedFields.add('trackTitle')
3508
+ changed = true
3509
+ }
3510
+
3511
+ if (playback.trackLength !== trackInfo.duration) {
3512
+ playback.trackLength = trackInfo.duration
3513
+ changedFields.add('trackLength')
3514
+ changed = true
3515
+ }
3516
+
3517
+ if (playback.chapterKey !== trackInfo.chapterKey) {
3518
+ playback.chapterKey = trackInfo.chapterKey
3519
+ changedFields.add('chapterKey')
3520
+ changed = true
3521
+ }
3522
+
3523
+ if (playback.chapterKey === trackInfo.chapterKey && playback.chapterTitle !== trackInfo.chapterTitle) {
3524
+ playback.chapterTitle = trackInfo.chapterTitle
3525
+ changedFields.add('chapterTitle')
3526
+ changed = true
3527
+ }
3528
+ }
3529
+ }
3530
+
3531
+ if (playback.chapterKey) {
3532
+ const chapterInfo = entry.chaptersByKey.get(playback.chapterKey)
3533
+ if (chapterInfo && playback.chapterTitle !== chapterInfo.title) {
3534
+ playback.chapterTitle = chapterInfo.title
3535
+ changedFields.add('chapterTitle')
3536
+ changed = true
3537
+ }
3538
+ }
3539
+
3540
+ return changed
3541
+ }
3542
+
3314
3543
  /**
3315
3544
  * Create an empty device config object
3316
3545
  * @returns {YotoDeviceModelConfig}
@@ -1,4 +1,5 @@
1
1
  /** @import { YotoDevice } from './api-endpoints/devices.js' */
2
+ /** @import { YotoPlaybackState } from './yoto-device.js' */
2
3
 
3
4
  import test from 'node:test'
4
5
  import assert from 'node:assert/strict'
@@ -15,6 +16,122 @@ import {
15
16
  } from './test-helpers/device-model-test-helpers.js'
16
17
 
17
18
  const envPath = join(import.meta.dirname, '..', '.env')
19
+ const PLAYBACK_SAMPLE_SCAN_LIMIT = 5
20
+
21
+ /**
22
+ * @typedef {Object} PlaybackSample
23
+ * @property {string} cardId
24
+ * @property {Map<string, { title: string, duration: number, chapterKey: string, chapterTitle: string }>} tracksByKey
25
+ * @property {string} chapterKey
26
+ * @property {string} chapterTitle
27
+ * @property {string} trackKey
28
+ * @property {string} trackTitle
29
+ * @property {number} trackLength
30
+ */
31
+
32
+ /**
33
+ * @param {YotoDeviceModel} model
34
+ * @param {(playback: YotoPlaybackState, changedFields: Set<keyof YotoPlaybackState>) => boolean} predicate
35
+ * @param {number} timeoutMs
36
+ * @param {string} label
37
+ * @returns {Promise<[YotoPlaybackState, Set<keyof YotoPlaybackState>]>}
38
+ */
39
+ function waitForPlaybackUpdateMatching (model, predicate, timeoutMs, label) {
40
+ return new Promise((resolve, reject) => {
41
+ const timeout = setTimeout(() => {
42
+ model.off('playbackUpdate', handler)
43
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`))
44
+ }, timeoutMs)
45
+
46
+ /**
47
+ * @param {YotoPlaybackState} playback
48
+ * @param {Set<keyof YotoPlaybackState>} changedFields
49
+ */
50
+ const handler = (playback, changedFields) => {
51
+ let matches = false
52
+
53
+ try {
54
+ matches = predicate(playback, changedFields)
55
+ } catch (err) {
56
+ clearTimeout(timeout)
57
+ model.off('playbackUpdate', handler)
58
+ reject(err)
59
+ return
60
+ }
61
+
62
+ if (!matches) {
63
+ return
64
+ }
65
+
66
+ clearTimeout(timeout)
67
+ model.off('playbackUpdate', handler)
68
+ resolve([playback, changedFields])
69
+ }
70
+
71
+ model.on('playbackUpdate', handler)
72
+ })
73
+ }
74
+
75
+ /**
76
+ * @param {YotoClient} client
77
+ * @returns {Promise<PlaybackSample | null>}
78
+ */
79
+ async function findPlaybackSample (client) {
80
+ const response = await client.getUserMyoContent({ showDeleted: false })
81
+
82
+ for (const card of response.cards.slice(0, PLAYBACK_SAMPLE_SCAN_LIMIT)) {
83
+ if (!card.cardId || card.deleted) {
84
+ continue
85
+ }
86
+
87
+ try {
88
+ const contentResponse = await client.getContent({ cardId: card.cardId })
89
+ const chapters = contentResponse.card.content.chapters
90
+ const firstChapter = chapters.find(candidate => candidate.tracks.length > 0)
91
+
92
+ if (!firstChapter) {
93
+ continue
94
+ }
95
+
96
+ const firstTrack = firstChapter.tracks[0]
97
+
98
+ if (!firstTrack || typeof firstTrack.title !== 'string' || !Number.isFinite(firstTrack.duration)) {
99
+ continue
100
+ }
101
+
102
+ const tracksByKey = new Map()
103
+
104
+ for (const chapter of chapters) {
105
+ for (const track of chapter.tracks) {
106
+ if (!track || typeof track.title !== 'string' || !Number.isFinite(track.duration)) {
107
+ continue
108
+ }
109
+
110
+ tracksByKey.set(track.key, {
111
+ title: track.title,
112
+ duration: track.duration,
113
+ chapterKey: chapter.key,
114
+ chapterTitle: chapter.title
115
+ })
116
+ }
117
+ }
118
+
119
+ return {
120
+ cardId: contentResponse.card.cardId,
121
+ tracksByKey,
122
+ chapterKey: firstChapter.key,
123
+ chapterTitle: firstChapter.title,
124
+ trackKey: firstTrack.key,
125
+ trackTitle: firstTrack.title,
126
+ trackLength: firstTrack.duration
127
+ }
128
+ } catch {
129
+ continue
130
+ }
131
+ }
132
+
133
+ return null
134
+ }
18
135
 
19
136
  /**
20
137
  * @returns {YotoClient}
@@ -86,3 +203,104 @@ test('YotoDeviceModel - online devices', async (t) => {
86
203
  await assertDeviceModel(client, onlineV3)
87
204
  })
88
205
  })
206
+
207
+ test('YotoDeviceModel - playback normalization', async (t) => {
208
+ loadTestTokens()
209
+ const client = createTestClient()
210
+ const response = await client.getDevices()
211
+
212
+ const onlineDevice = response.devices.find(device => device.online)
213
+ if (!onlineDevice) {
214
+ t.skip('No online device found')
215
+ return
216
+ }
217
+
218
+ const sample = await findPlaybackSample(client)
219
+ if (!sample) {
220
+ t.skip('No MYO content with tracks available')
221
+ return
222
+ }
223
+
224
+ const model = new YotoDeviceModel(client, onlineDevice)
225
+
226
+ try {
227
+ await waitForModelReady(model)
228
+ assertPlaybackShape(model.playback)
229
+
230
+ const mqttClient = model.mqttClient
231
+ assert.ok(mqttClient, 'MQTT client should be initialized')
232
+
233
+ await t.test('card cache enrichment', async () => {
234
+ if (model.playback.cardId === sample.cardId) {
235
+ const resetPromise = waitForPlaybackUpdateMatching(
236
+ model,
237
+ (playback, changedFields) => playback.cardId === null && changedFields.has('cardId'),
238
+ 5000,
239
+ 'playback card reset'
240
+ )
241
+
242
+ mqttClient.emit('events', 'test', { cardId: 'none' })
243
+ await resetPromise
244
+ }
245
+
246
+ const cardUpdatePromise = waitForPlaybackUpdateMatching(
247
+ model,
248
+ (playback, changedFields) => playback.cardId === sample.cardId && changedFields.has('cardId'),
249
+ 5000,
250
+ 'playback card update'
251
+ )
252
+
253
+ const cacheUpdatePromise = waitForPlaybackUpdateMatching(
254
+ model,
255
+ (playback, changedFields) => {
256
+ if (playback.cardId !== sample.cardId) {
257
+ return false
258
+ }
259
+
260
+ return (
261
+ changedFields.has('trackTitle') ||
262
+ changedFields.has('trackLength') ||
263
+ changedFields.has('chapterKey') ||
264
+ changedFields.has('chapterTitle')
265
+ )
266
+ },
267
+ 15000,
268
+ 'playback cache update'
269
+ )
270
+
271
+ mqttClient.emit('events', 'test', {
272
+ cardId: sample.cardId,
273
+ trackKey: sample.trackKey
274
+ })
275
+
276
+ const [cardPlayback] = await cardUpdatePromise
277
+ assert.equal(cardPlayback.cardId, sample.cardId)
278
+
279
+ const [cachePlayback] = await cacheUpdatePromise
280
+ assert.ok(cachePlayback.trackKey, 'trackKey should be set after cache update')
281
+ const trackInfo = sample.tracksByKey.get(cachePlayback.trackKey)
282
+ assert.ok(trackInfo, 'trackKey should exist in card content')
283
+ assert.equal(cachePlayback.trackTitle, trackInfo.title)
284
+ assert.equal(cachePlayback.trackLength, trackInfo.duration)
285
+ assert.equal(cachePlayback.chapterKey, trackInfo.chapterKey)
286
+ assert.equal(cachePlayback.chapterTitle, trackInfo.chapterTitle)
287
+ })
288
+
289
+ await t.test('cardId none normalization', async () => {
290
+ const noneUpdatePromise = waitForPlaybackUpdateMatching(
291
+ model,
292
+ (playback, changedFields) => playback.cardId === null && changedFields.has('cardId'),
293
+ 5000,
294
+ 'playback cardId none normalization'
295
+ )
296
+
297
+ mqttClient.emit('events', 'test', { cardId: 'none' })
298
+
299
+ const [nonePlayback, changedFields] = await noneUpdatePromise
300
+ assert.equal(nonePlayback.cardId, null)
301
+ assert.ok(changedFields.has('cardId'), 'cardId should be in changed fields')
302
+ })
303
+ } finally {
304
+ await model.stop()
305
+ }
306
+ })
package/package.json CHANGED
@@ -1,19 +1,21 @@
1
1
  {
2
2
  "name": "yoto-nodejs-client",
3
3
  "description": "(Unofficial) Node.js client for the Yoto API with automatic token refresh, MQTT device communication, and TypeScript support",
4
- "version": "0.0.7",
4
+ "version": "0.0.9",
5
5
  "author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io)",
6
6
  "bugs": {
7
7
  "url": "https://github.com/bcomnes/yoto-nodejs-client/issues"
8
8
  },
9
9
  "dependencies": {
10
+ "fastq": "^1.20.1",
10
11
  "jwt-decode": "^4.0.0",
11
12
  "mqtt": "^5.14.1",
13
+ "quick-lru": "^7.3.0",
12
14
  "undici": "^7.16.0"
13
15
  },
14
16
  "devDependencies": {
15
- "@unblessed/node": "1.0.0-alpha.23",
16
17
  "@types/node": "^25.0.0",
18
+ "@unblessed/node": "1.0.0-alpha.23",
17
19
  "@voxpelli/tsconfig": "^16.1.0",
18
20
  "argsclopts": "^1.0.5",
19
21
  "auto-changelog": "^2.0.0",