@khalido/home 0.2.1 → 0.2.2
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/CHANGELOG.md +9 -1
- package/dist/connectors/cast/index.d.ts +2 -1
- package/dist/connectors/cast/index.js +1 -0
- package/dist/connectors/cast/wav.d.ts +31 -0
- package/dist/connectors/cast/wav.js +149 -0
- package/dist/connectors/cast/writes.d.ts +25 -6
- package/dist/connectors/cast/writes.js +155 -62
- package/dist/connectors/spotify/index.d.ts +28 -14
- package/dist/connectors/spotify/index.js +77 -47
- package/docs/cast.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/using.md +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/skills/use-home/SKILL.md +1 -1
- package/src/cli/cast.ts +41 -11
- package/src/cli/context.ts +1 -1
- package/src/cli/main.ts +6 -2
- package/src/cli/spotify.ts +16 -12
- package/src/connectors/cast/README.md +2 -0
- package/src/connectors/cast/index.ts +4 -0
- package/src/connectors/cast/wav.ts +181 -0
- package/src/connectors/cast/writes.ts +211 -71
- package/src/connectors/spotify/README.md +2 -0
- package/src/connectors/spotify/index.ts +221 -105
|
@@ -96,8 +96,23 @@ export type SpotifyConfig = {
|
|
|
96
96
|
/** Soloist's repeat modes. */
|
|
97
97
|
export type Repeat = "off" | "context" | "track";
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
export type
|
|
99
|
+
/** One WebSocket command, as a dry run reports it: Soloist's command name and its fields. */
|
|
100
|
+
export type SpotifyRequest = {
|
|
101
|
+
command: string;
|
|
102
|
+
fields?: Record<string, unknown>;
|
|
103
|
+
};
|
|
104
|
+
/** `dryRun`: run every check a real write runs (connected, signed in, the input), send nothing, and say what would go. */
|
|
105
|
+
export type SpotifyWriteOptions = { dryRun?: boolean };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* What a write left: the state once Soloist reported the change (`confirmed`), or the state as it stood when `CONFIRM_MS`
|
|
109
|
+
* ran out without it. A dry run resolves `confirmed: false` with `wouldSend`, the commands in order; an `activate` already done resolves `confirmed: true` with `wouldSend: []`.
|
|
110
|
+
*/
|
|
111
|
+
export type Applied = {
|
|
112
|
+
state: SpotifyState;
|
|
113
|
+
confirmed: boolean;
|
|
114
|
+
wouldSend?: SpotifyRequest[];
|
|
115
|
+
};
|
|
101
116
|
|
|
102
117
|
/**
|
|
103
118
|
* What happened to the music around `duck` or `interrupt`'s `fn`: `untouched` (it was not playing, not connected, already
|
|
@@ -115,34 +130,41 @@ export type Aside<T> = { value: T; music: Music; why?: string };
|
|
|
115
130
|
*/
|
|
116
131
|
export type SpotifyActions = {
|
|
117
132
|
/** Make this the account's active Spotify device. Soloist drops volume changes until it is. It makes a new audio stream, so the volume can jump to the level last saved. */
|
|
118
|
-
activate(): Promise<Applied>;
|
|
133
|
+
activate(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
119
134
|
/** Play a Spotify link or URI (track, album, playlist, artist, episode, show), or resume what is loaded when none is given. */
|
|
120
|
-
play(linkOrUri?: string): Promise<Applied>;
|
|
121
|
-
pause(): Promise<Applied>;
|
|
122
|
-
resume(): Promise<Applied>;
|
|
123
|
-
next(): Promise<Applied>;
|
|
124
|
-
prev(): Promise<Applied>;
|
|
135
|
+
play(linkOrUri?: string, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
136
|
+
pause(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
137
|
+
resume(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
138
|
+
next(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
139
|
+
prev(options?: SpotifyWriteOptions): Promise<Applied>;
|
|
125
140
|
/** Jump to a position in the current track, in ms. */
|
|
126
|
-
seek(ms: number): Promise<Applied>;
|
|
141
|
+
seek(ms: number, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
127
142
|
/** Set the volume 0–100, held to `maxVolume` and rounded to a whole percent: `state.volume` is what Soloist then reports. */
|
|
128
|
-
setVolume(volume: number): Promise<Applied>;
|
|
143
|
+
setVolume(volume: number, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
129
144
|
/** Add a track to the queue (Soloist queues tracks only); confirmed when it shows in `upNext`. */
|
|
130
|
-
queue(track: string): Promise<Applied>;
|
|
131
|
-
setShuffle(on: boolean): Promise<Applied>;
|
|
145
|
+
queue(track: string, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
146
|
+
setShuffle(on: boolean, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
132
147
|
/** Repeat off, the context (playlist, album) or the track: two commands, in the order Soloist's docs give, as one write. */
|
|
133
|
-
setRepeat(mode: Repeat): Promise<Applied>;
|
|
148
|
+
setRepeat(mode: Repeat, options?: SpotifyWriteOptions): Promise<Applied>;
|
|
134
149
|
/**
|
|
135
150
|
* Pause if playing, run `fn`, then resume, unless someone changed it meanwhile (resumed, stopped or skipped it from a
|
|
136
151
|
* phone): then it is theirs and stays. The resume runs even if `fn` throws. Not playing or not connected: just `fn`.
|
|
137
152
|
* One `interrupt` or `duck` at a time; a second waits for the first, so never call either from inside `fn` (it would wait
|
|
138
153
|
* forever). Two announcements back to back belong in one `fn`, or the music comes back between them.
|
|
139
154
|
*/
|
|
140
|
-
interrupt<T>(
|
|
155
|
+
interrupt<T>(
|
|
156
|
+
fn: () => Promise<T> | T,
|
|
157
|
+
options?: SpotifyWriteOptions,
|
|
158
|
+
): Promise<Aside<T>>;
|
|
141
159
|
/**
|
|
142
160
|
* Lower the volume to `to` while `fn` runs, then put the earlier volume back, unless someone set the volume meanwhile.
|
|
143
161
|
* For a short announcement over the music. Not playing, not connected or already at `to` or below: just `fn`.
|
|
162
|
+
* With `dryRun`, `interrupt` and `duck` run `fn`, touch nothing, and say in `why` what they would have done.
|
|
144
163
|
*/
|
|
145
|
-
duck<T>(
|
|
164
|
+
duck<T>(
|
|
165
|
+
fn: () => Promise<T> | T,
|
|
166
|
+
options: { to: number } & SpotifyWriteOptions,
|
|
167
|
+
): Promise<Aside<T>>;
|
|
146
168
|
};
|
|
147
169
|
|
|
148
170
|
/** How long a command waits for Soloist's answer. Loopback answers in milliseconds; a stated guess. */
|
|
@@ -332,12 +354,34 @@ export function spotify(
|
|
|
332
354
|
* Soloist accepted the command that carried it (a merged write's may be another's: a pause merged into a later play never
|
|
333
355
|
* confirms). A drop ends the wait early, unconfirmed.
|
|
334
356
|
*/
|
|
335
|
-
const change = async (
|
|
357
|
+
const change = async (
|
|
358
|
+
slot: string,
|
|
359
|
+
body: Body,
|
|
360
|
+
options: SpotifyWriteOptions = {},
|
|
361
|
+
): Promise<Applied> => {
|
|
362
|
+
if (options.dryRun) return dry(body);
|
|
336
363
|
const sent = await write(slot, body);
|
|
337
364
|
const took = () => body.took?.(state, sent.before, sent.at) ?? true;
|
|
338
365
|
await waitFor(() => took() || !isOpen(), CONFIRM_MS);
|
|
339
366
|
return { state, confirmed: took() };
|
|
340
367
|
};
|
|
368
|
+
/** A dry run: `command`'s own checks (connected, signed in), then the commands `send` would send, in order. */
|
|
369
|
+
const dry = (body: Body): Applied => {
|
|
370
|
+
if (!socket || !isOpen() || socket.readyState !== OPEN) throw offline();
|
|
371
|
+
if (state.loggedIn === false)
|
|
372
|
+
throw new ConnectorError(WAITING, { code: "waiting" });
|
|
373
|
+
const one = (command: string, fields?: Record<string, unknown>) =>
|
|
374
|
+
fields ? { command, fields } : { command };
|
|
375
|
+
return {
|
|
376
|
+
state,
|
|
377
|
+
confirmed: false,
|
|
378
|
+
wouldSend: [
|
|
379
|
+
...(body.activeFirst && inactive() ? [one("activate")] : []),
|
|
380
|
+
one(body.command, body.fields),
|
|
381
|
+
...(body.then ? [one(body.then.command, body.then.fields)] : []),
|
|
382
|
+
],
|
|
383
|
+
};
|
|
384
|
+
};
|
|
341
385
|
/** The track restarted: a new anchor near 0 when it had been 5 s or more in (a prev past the first seconds, a next on repeat-track). */
|
|
342
386
|
const restarted = (s: SpotifyState, b: SpotifyState, at: number) =>
|
|
343
387
|
s.position !== b.position &&
|
|
@@ -349,12 +393,16 @@ export function spotify(
|
|
|
349
393
|
new ConnectorError(message, { code: "invalid" });
|
|
350
394
|
const near = (volume: number | undefined, v: number) =>
|
|
351
395
|
volume !== undefined && Math.abs(volume - v) <= VOLUME_SLACK;
|
|
352
|
-
const volumeTo = (v: number) =>
|
|
353
|
-
change(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
396
|
+
const volumeTo = (v: number, options?: SpotifyWriteOptions) =>
|
|
397
|
+
change(
|
|
398
|
+
"volume",
|
|
399
|
+
{
|
|
400
|
+
command: "set_volume",
|
|
401
|
+
fields: { volume: v },
|
|
402
|
+
took: (s) => near(s.volume, v),
|
|
403
|
+
},
|
|
404
|
+
options,
|
|
405
|
+
);
|
|
358
406
|
/** Soloist accepts a `set_volume` while it isn't the active device and drops it (three times live, 2026-11-09). */
|
|
359
407
|
const inactive = () =>
|
|
360
408
|
isOpen() && state.loggedIn !== false && state.active === false;
|
|
@@ -637,6 +685,33 @@ export function spotify(
|
|
|
637
685
|
return p;
|
|
638
686
|
};
|
|
639
687
|
type Left = Omit<Aside<never>, "value">;
|
|
688
|
+
/** Why `interrupt` would leave the music alone, if it would. */
|
|
689
|
+
const interruptSkip = () =>
|
|
690
|
+
notPlaying() ??
|
|
691
|
+
// "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
|
|
692
|
+
(inactive()
|
|
693
|
+
? "it is not the active device, so the music playing is on another speaker"
|
|
694
|
+
: undefined);
|
|
695
|
+
/** Why `duck` would leave the volume alone, if it would. */
|
|
696
|
+
const duckSkip = (to: number) =>
|
|
697
|
+
notPlaying() ??
|
|
698
|
+
(inactive()
|
|
699
|
+
? "it is not the active device, so its volume can't be changed"
|
|
700
|
+
: state.volume === undefined
|
|
701
|
+
? "its volume is not known"
|
|
702
|
+
: state.volume <= to
|
|
703
|
+
? `it was already at ${state.volume}`
|
|
704
|
+
: undefined);
|
|
705
|
+
/** A dry `interrupt` or `duck`: `fn` runs, the music is not touched, and `why` says what would have happened to it. */
|
|
706
|
+
const dryAside = async <T>(
|
|
707
|
+
fn: () => Promise<T> | T,
|
|
708
|
+
skip: string | undefined,
|
|
709
|
+
would: string,
|
|
710
|
+
): Promise<Aside<T>> => ({
|
|
711
|
+
value: await fn(),
|
|
712
|
+
music: "untouched",
|
|
713
|
+
why: `dry run: ${skip ? `${skip}, so the music would be left alone` : `would ${would}`}`,
|
|
714
|
+
});
|
|
640
715
|
const putBack = (r: Applied, what: string): Left =>
|
|
641
716
|
r.confirmed
|
|
642
717
|
? { music: "restored" }
|
|
@@ -646,69 +721,96 @@ export function spotify(
|
|
|
646
721
|
};
|
|
647
722
|
|
|
648
723
|
const actions: SpotifyActions = {
|
|
649
|
-
async activate() {
|
|
724
|
+
async activate(options) {
|
|
650
725
|
if (isOpen() && state.loggedIn !== false && state.active === true)
|
|
651
|
-
return {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
726
|
+
return {
|
|
727
|
+
state,
|
|
728
|
+
confirmed: true,
|
|
729
|
+
...(options?.dryRun ? { wouldSend: [] } : {}),
|
|
730
|
+
}; // a second activate might make a new stream
|
|
731
|
+
return change(
|
|
732
|
+
"activate",
|
|
733
|
+
{ command: "activate", took: (s) => s.active === true },
|
|
734
|
+
options,
|
|
735
|
+
);
|
|
656
736
|
},
|
|
657
737
|
// A `play` while inactive didn't take live; going active first is untested for this. `resume` worked inactive, so it doesn't.
|
|
658
|
-
async play(linkOrUri) {
|
|
738
|
+
async play(linkOrUri, options) {
|
|
659
739
|
if (linkOrUri === undefined)
|
|
660
|
-
return change(
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
740
|
+
return change(
|
|
741
|
+
"transport",
|
|
742
|
+
{
|
|
743
|
+
command: "play",
|
|
744
|
+
activeFirst: true,
|
|
745
|
+
took: (s) => s.status === "playing",
|
|
746
|
+
},
|
|
747
|
+
options,
|
|
748
|
+
);
|
|
665
749
|
const uri = toSpotifyUri(linkOrUri);
|
|
666
750
|
if (!uri) throw invalid(`not a Spotify link or URI: ${linkOrUri}`);
|
|
667
|
-
return change(
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
(s
|
|
674
|
-
|
|
751
|
+
return change(
|
|
752
|
+
"transport",
|
|
753
|
+
{
|
|
754
|
+
command: "play",
|
|
755
|
+
fields: { uri },
|
|
756
|
+
activeFirst: true,
|
|
757
|
+
took: (s) =>
|
|
758
|
+
s.status === "playing" &&
|
|
759
|
+
(s.track?.uri === uri || s.context?.uri === uri),
|
|
760
|
+
},
|
|
761
|
+
options,
|
|
762
|
+
);
|
|
675
763
|
},
|
|
676
|
-
pause: () =>
|
|
677
|
-
change(
|
|
678
|
-
|
|
679
|
-
took: (s) => s.status === "paused",
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
764
|
+
pause: (options) =>
|
|
765
|
+
change(
|
|
766
|
+
"transport",
|
|
767
|
+
{ command: "pause", took: (s) => s.status === "paused" },
|
|
768
|
+
options,
|
|
769
|
+
),
|
|
770
|
+
resume: (options) =>
|
|
771
|
+
change(
|
|
772
|
+
"transport",
|
|
773
|
+
{ command: "play", took: (s) => s.status === "playing" },
|
|
774
|
+
options,
|
|
775
|
+
),
|
|
686
776
|
// The end of the queue goes idle; repeat-track restarts it.
|
|
687
|
-
next: () =>
|
|
688
|
-
change(
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
(s
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
777
|
+
next: (options) =>
|
|
778
|
+
change(
|
|
779
|
+
`once-${++once}`,
|
|
780
|
+
{
|
|
781
|
+
command: "skip_next",
|
|
782
|
+
took: (s, b, at) =>
|
|
783
|
+
newTrack(s, b) ||
|
|
784
|
+
(s.status === "idle" && b.status !== "idle") ||
|
|
785
|
+
restarted(s, b, at),
|
|
786
|
+
},
|
|
787
|
+
options,
|
|
788
|
+
),
|
|
789
|
+
prev: (options) =>
|
|
790
|
+
change(
|
|
791
|
+
`once-${++once}`,
|
|
792
|
+
{
|
|
793
|
+
command: "skip_prev",
|
|
794
|
+
took: (s, b, at) => newTrack(s, b) || restarted(s, b, at),
|
|
795
|
+
},
|
|
796
|
+
options,
|
|
797
|
+
),
|
|
798
|
+
async seek(ms, options) {
|
|
701
799
|
if (!Number.isFinite(ms) || ms < 0)
|
|
702
800
|
throw invalid(`a position in ms, 0 or more, not ${ms}`);
|
|
703
|
-
return change(
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
801
|
+
return change(
|
|
802
|
+
"seek",
|
|
803
|
+
{
|
|
804
|
+
command: "seek",
|
|
805
|
+
fields: { position_ms: ms },
|
|
806
|
+
took: (s, b) =>
|
|
807
|
+
s.position !== b.position &&
|
|
808
|
+
Math.abs((s.position?.ms ?? -Infinity) - ms) < 2000,
|
|
809
|
+
},
|
|
810
|
+
options,
|
|
811
|
+
);
|
|
710
812
|
},
|
|
711
|
-
async setVolume(volume) {
|
|
813
|
+
async setVolume(volume, options) {
|
|
712
814
|
if (!Number.isFinite(volume) || volume < 0 || volume > 100)
|
|
713
815
|
throw invalid(`volume is 0–100, not ${volume}`);
|
|
714
816
|
if (inactive())
|
|
@@ -718,48 +820,60 @@ export function spotify(
|
|
|
718
820
|
);
|
|
719
821
|
return volumeTo(
|
|
720
822
|
Math.round(cap === undefined ? volume : Math.min(volume, cap)),
|
|
823
|
+
options,
|
|
721
824
|
);
|
|
722
825
|
},
|
|
723
|
-
async queue(track) {
|
|
826
|
+
async queue(track, options) {
|
|
724
827
|
const uri = toSpotifyUri(track);
|
|
725
828
|
if (!uri?.startsWith("spotify:track:"))
|
|
726
829
|
throw invalid(`only a Spotify track can be queued, not ${track}`);
|
|
727
|
-
return change(
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
830
|
+
return change(
|
|
831
|
+
`once-${++once}`,
|
|
832
|
+
{
|
|
833
|
+
command: "add_to_queue",
|
|
834
|
+
fields: { uri },
|
|
835
|
+
took: (s) => !!s.upNext?.some((t) => t.uri === uri),
|
|
836
|
+
},
|
|
837
|
+
options,
|
|
838
|
+
);
|
|
732
839
|
},
|
|
733
|
-
setShuffle: (on) =>
|
|
734
|
-
change(
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
840
|
+
setShuffle: (on, options) =>
|
|
841
|
+
change(
|
|
842
|
+
"shuffle",
|
|
843
|
+
{
|
|
844
|
+
command: "set_shuffle",
|
|
845
|
+
fields: { enabled: on },
|
|
846
|
+
took: (s) => s.shuffle === on,
|
|
847
|
+
},
|
|
848
|
+
options,
|
|
849
|
+
),
|
|
850
|
+
async setRepeat(mode, options) {
|
|
740
851
|
// developer.spotify.com/documentation/soloist/reference/websocket-api: clear the other mode first, then set this one.
|
|
741
852
|
if (!Object.hasOwn(REPEAT_ORDER, mode))
|
|
742
853
|
throw invalid(`repeat is off, context or track, not ${mode}`);
|
|
743
854
|
const [first, second] = REPEAT_ORDER[mode];
|
|
744
|
-
return change(
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
855
|
+
return change(
|
|
856
|
+
"repeat",
|
|
857
|
+
{
|
|
858
|
+
command: first[0],
|
|
859
|
+
fields: { enabled: first[1] },
|
|
860
|
+
then: { command: second[0], fields: { enabled: second[1] } },
|
|
861
|
+
took: (s) => s.repeat === mode,
|
|
862
|
+
},
|
|
863
|
+
options,
|
|
864
|
+
);
|
|
750
865
|
},
|
|
751
|
-
interrupt(fn) {
|
|
866
|
+
interrupt(fn, options) {
|
|
867
|
+
if (options?.dryRun)
|
|
868
|
+
return dryAside(fn, interruptSkip(), "pause the music, then resume it");
|
|
752
869
|
let track: string | undefined;
|
|
753
870
|
let shown = false; // the pause showed
|
|
754
871
|
return aside(
|
|
755
872
|
"interrupt",
|
|
756
873
|
fn,
|
|
757
874
|
async () => {
|
|
758
|
-
const no =
|
|
875
|
+
const no = interruptSkip();
|
|
759
876
|
if (no) return no;
|
|
760
|
-
// "playing" while inactive is the account playing on another speaker (live, 2026-11-10): a pause would stop that one.
|
|
761
|
-
if (inactive())
|
|
762
|
-
return "it is not the active device, so the music playing is on another speaker";
|
|
763
877
|
track = state.track?.uri;
|
|
764
878
|
try {
|
|
765
879
|
shown = (await actions.pause()).confirmed; // a pause that never shows still runs fn: what interrupts matters more
|
|
@@ -788,22 +902,24 @@ export function spotify(
|
|
|
788
902
|
},
|
|
789
903
|
);
|
|
790
904
|
},
|
|
791
|
-
duck(fn, { to }) {
|
|
905
|
+
duck(fn, { to, dryRun }) {
|
|
792
906
|
if (!Number.isFinite(to) || to < 0 || to > 100)
|
|
793
907
|
return Promise.reject(invalid(`duck's volume is 0–100, not ${to}`));
|
|
908
|
+
if (dryRun)
|
|
909
|
+
return dryAside(
|
|
910
|
+
fn,
|
|
911
|
+
duckSkip(to),
|
|
912
|
+
`lower the volume from ${state.volume} to ${Math.round(to)}, then put it back`,
|
|
913
|
+
);
|
|
794
914
|
let earlier = 0;
|
|
795
915
|
let shown = false; // the lowered volume showed
|
|
796
916
|
return aside(
|
|
797
917
|
"duck",
|
|
798
918
|
fn,
|
|
799
919
|
async () => {
|
|
800
|
-
const no =
|
|
920
|
+
const no = duckSkip(to);
|
|
801
921
|
if (no) return no;
|
|
802
|
-
|
|
803
|
-
return "it is not the active device, so its volume can't be changed";
|
|
804
|
-
if (state.volume === undefined) return "its volume is not known";
|
|
805
|
-
if (state.volume <= to) return `it was already at ${state.volume}`;
|
|
806
|
-
earlier = state.volume;
|
|
922
|
+
earlier = state.volume!;
|
|
807
923
|
try {
|
|
808
924
|
shown = (await volumeTo(Math.round(to))).confirmed;
|
|
809
925
|
} catch (error) {
|