@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.11
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 +30 -4
- package/package.json +1 -1
- package/scripts/moondog.mjs +148 -14
- package/src/core/apple-projection-domain-services.mjs +4 -0
- package/src/core/capability-catalog.mjs +49 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +80 -6
- package/src/core/listening-profile-domain-services.mjs +4 -0
- package/src/core/moondog-application.mjs +578 -32
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/messages/de.mjs +1 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +1 -0
- package/src/i18n/messages/fr.mjs +1 -0
- package/src/i18n/messages/it.mjs +1 -0
- package/src/i18n/messages/ja.mjs +1 -0
- package/src/i18n/messages/ko.mjs +1 -0
- package/src/i18n/messages/pt.mjs +1 -0
- package/src/i18n/messages/zh-Hant.mjs +1 -0
- package/src/i18n/messages/zh.mjs +1 -0
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +94 -0
- package/src/i18n/screen/ja.mjs +94 -0
- package/src/i18n/screen/pt.mjs +94 -0
- package/src/i18n/screen/zh.mjs +94 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/playlist-links.mjs +102 -0
- package/src/integrations/public-playlists.mjs +39 -8
- package/src/integrations/spotify/service.mjs +22 -1
- package/src/integrations/stable-audio/cover.mjs +324 -0
- package/src/integrations/stable-audio/local-generator.mjs +259 -87
- package/src/integrations/stable-audio/setup.mjs +653 -0
- package/src/integrations/stable-audio/sources.mjs +106 -0
- package/src/integrations/web/deepseek-web.mjs +1 -1
- package/src/integrations/web/web-research.mjs +84 -17
- package/src/integrations/web/web-result.mjs +60 -12
- package/src/profile/listening-history-store.mjs +55 -1
- package/src/profile/listening-profile-projection.mjs +1 -1
- package/src/profile/music-import-bundle.mjs +3 -1
- package/src/profile/music-providers.mjs +3 -0
- package/src/profile/taste-compiler.mjs +14 -5
- package/src/runtime/pi/agent-runtime.mjs +359 -37
- package/src/runtime/pi/configured-runtime.mjs +3 -0
- package/src/runtime/pi/image-input.mjs +148 -0
- package/src/runtime/pi/model-roles.mjs +121 -0
- package/src/runtime/pi/model-settings.mjs +63 -2
- package/src/runtime/pi/model-switch.mjs +26 -0
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/record-name.mjs +80 -0
- package/src/runtime/pi/reply-language.mjs +45 -0
- package/src/runtime/pi/runtime-settings.mjs +4 -2
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/command-completions.mjs +15 -1
- package/src/surfaces/cli/model-roles-command.mjs +60 -0
- package/src/surfaces/cli/tui.mjs +419 -19
|
@@ -4,8 +4,8 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
4
4
|
import { compactListenerProfile, readListenerProfile } from "../profile/listener-profile-build.mjs";
|
|
5
5
|
import { mixCounts, songKeys } from "../profile/moondog-suggestions.mjs";
|
|
6
6
|
import { ARTIST_CREDIT_SEPARATORS } from "./listener-avoids.mjs";
|
|
7
|
-
import { textNamesSong } from "./track-title.mjs";
|
|
8
|
-
import { compileTaste, tasteSources } from "../profile/taste-compiler.mjs";
|
|
7
|
+
import { foldedWords, normalizedTrackTitle, textNamesSong } from "./track-title.mjs";
|
|
8
|
+
import { acceptableRecordTitle, compileTaste, tasteSources } from "../profile/taste-compiler.mjs";
|
|
9
9
|
import { stableAudioPrompt } from "../integrations/stable-audio/local-generator.mjs";
|
|
10
10
|
|
|
11
11
|
import {
|
|
@@ -19,9 +19,41 @@ import {
|
|
|
19
19
|
import { recoverArtistReleasesWithCrossCatalogIdentity } from "../integrations/cross-catalog-artist-identity.mjs";
|
|
20
20
|
import { webProviderName } from "../integrations/web/web-result.mjs";
|
|
21
21
|
import { musicAppSource, readListeningNow } from "./listening-now.mjs";
|
|
22
|
+
import { SAVED_PLAYLIST_ID } from "./library-store.mjs";
|
|
23
|
+
import { parsePlaylistLink, readApplePlaylist, readPublicPlaylistLink } from "../integrations/playlist-links.mjs";
|
|
22
24
|
import { normalizeConversationFolder, normalizeConversationLabel, normalizeMemoryContent, shortConversationTitle } from "../memory/local-memory-store.mjs";
|
|
23
25
|
import { queueListeningIntent, queueCancellationRequested, unheardRequested } from "./spotify-listening-intent.mjs";
|
|
24
|
-
import { DEFAULT_LOCALE, normalizeLocale } from "../i18n/index.mjs";
|
|
26
|
+
import { DEFAULT_LOCALE, LOCALES, messageLocale, normalizeLocale } from "../i18n/index.mjs";
|
|
27
|
+
|
|
28
|
+
// Runs work over a list with at most limit calls at once, keeping the list's order.
|
|
29
|
+
async function mapLimited(list, limit, work) {
|
|
30
|
+
const results = new Array(list.length);
|
|
31
|
+
let next = 0;
|
|
32
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, async () => {
|
|
33
|
+
while (next < list.length) {
|
|
34
|
+
const at = next++;
|
|
35
|
+
results[at] = await work(list[at], at);
|
|
36
|
+
}
|
|
37
|
+
}));
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// How long a record's title in the wrong language waits to be named again before the agent's stays.
|
|
42
|
+
const TITLE_LANGUAGE_TIMEOUT_MS = 10_000;
|
|
43
|
+
|
|
44
|
+
// Whether a record's name is written in a language. A short name gives the word-based guess little to
|
|
45
|
+
// go on, and the guess then falls back to the setting, so the script decides first: Chinese, Japanese
|
|
46
|
+
// and Korean names are in their own script, and the other languages Moondog speaks in none of those.
|
|
47
|
+
const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
48
|
+
const SCRIPTS = { zh: /\p{Script=Han}/u, "zh-Hant": /\p{Script=Han}/u, ja: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u, ko: /\p{Script=Hangul}/u };
|
|
49
|
+
export function nameFitsLanguage(name, locale) {
|
|
50
|
+
const text = String(name ?? "");
|
|
51
|
+
if (SCRIPTS[locale] ? !SCRIPTS[locale].test(text) : CJK.test(text)) return false;
|
|
52
|
+
const guess = messageLocale(text, locale);
|
|
53
|
+
// Kanji with no kana, such as 月夜 or 雨音, is Japanese as much as Chinese, so the Japanese setting decides.
|
|
54
|
+
if (locale === "ja" && (guess === "zh" || guess === "zh-Hant") && !/\p{Script=Hangul}/u.test(text)) return true;
|
|
55
|
+
return guess === locale;
|
|
56
|
+
}
|
|
25
57
|
import { createPlaybackSelection, DEFAULT_PLAYBACK_SERVICE, normalizePlaybackService } from "./playback-preferences.mjs";
|
|
26
58
|
|
|
27
59
|
const minimumNodeVersion = [22, 19, 0];
|
|
@@ -222,10 +254,16 @@ export class MoondogApplication {
|
|
|
222
254
|
musicCatalog = null,
|
|
223
255
|
musicSimilarity = null,
|
|
224
256
|
musicPreviewPlayer = null,
|
|
257
|
+
languageSelection = null,
|
|
258
|
+
modelSwitch = null,
|
|
225
259
|
neteasePlayer = null,
|
|
226
260
|
localGenerator = null,
|
|
227
261
|
atmosphereStream = null,
|
|
228
262
|
musicApp = null,
|
|
263
|
+
// The listener's library (src/core/library-store.mjs), shared by every conversation and both surfaces.
|
|
264
|
+
libraryStore = null,
|
|
265
|
+
// Adds an imported playlist's songs to the listener's taste evidence: async ({ provider, id, url, name, tracks }) => receipt.
|
|
266
|
+
playlistTaste = null,
|
|
229
267
|
agentHostNotes = [],
|
|
230
268
|
artistIdentityResolver = null,
|
|
231
269
|
webResearch = null,
|
|
@@ -249,6 +287,7 @@ export class MoondogApplication {
|
|
|
249
287
|
// The language of the conversation so far. A reply such as "2" or "ok" carries none of
|
|
250
288
|
// its own, so it keeps this one rather than switching to the setting mid-conversation.
|
|
251
289
|
this.conversationLocale = null;
|
|
290
|
+
this.replyLanguagePin = null;
|
|
252
291
|
this.domainServices = domainServices;
|
|
253
292
|
this.domainServicesError =
|
|
254
293
|
typeof domainServicesError === "string" &&
|
|
@@ -268,6 +307,10 @@ export class MoondogApplication {
|
|
|
268
307
|
// Hosts that can play audio pass { play(tracks, { album }) }: the website, and the
|
|
269
308
|
// terminal when the system has a player.
|
|
270
309
|
this.musicPreviewPlayer = musicPreviewPlayer;
|
|
310
|
+
// Shared with every surface and conversation of this core, so a language chosen in words is saved and shown everywhere.
|
|
311
|
+
this.languageSelection = languageSelection;
|
|
312
|
+
// The listener's model menu, and the way a model asked for in words reaches the surface that runs this agent.
|
|
313
|
+
this.modelSwitch = modelSwitch;
|
|
271
314
|
// NetEase Cloud Music through ncm-cli (src/integrations/netease), shared by every conversation.
|
|
272
315
|
this.neteasePlayer = neteasePlayer;
|
|
273
316
|
// Stable Audio 3 on this Mac (src/integrations/stable-audio), shared by every conversation, so one track is made at a time.
|
|
@@ -277,6 +320,10 @@ export class MoondogApplication {
|
|
|
277
320
|
// The Music app on this Mac (src/integrations/apple-music/music-app.mjs), read for what the
|
|
278
321
|
// listener plays there themselves.
|
|
279
322
|
this.musicApp = musicApp;
|
|
323
|
+
this.libraryStore = libraryStore;
|
|
324
|
+
this.playlistTaste = playlistTaste;
|
|
325
|
+
// Playlists read from links and waiting for the listener's yes, by token, newest last.
|
|
326
|
+
this.pendingPlaylistImports = new Map();
|
|
280
327
|
// Hosts can tell the agent where it runs; the terminal passes none.
|
|
281
328
|
this.hostNotes = Array.isArray(agentHostNotes) ? agentHostNotes.filter((note) => typeof note === "string") : [];
|
|
282
329
|
this.artistIdentityResolver = artistIdentityResolver;
|
|
@@ -563,7 +610,7 @@ export class MoondogApplication {
|
|
|
563
610
|
const file = await this.localGenerator.trackFile(id);
|
|
564
611
|
if (!file) throw Object.assign(new Error("That track was part of a stream, which keeps only its newest tracks' audio; its receipt is still here."), { code: "made_track_audio_not_kept" });
|
|
565
612
|
// Playing one of Moondog's tracks again ends its stream, on every surface.
|
|
566
|
-
await this.#endStream("replaced");
|
|
613
|
+
const endedStream = await this.#endStream("replaced");
|
|
567
614
|
const title = String(receipt.title ?? "");
|
|
568
615
|
// Named means quoted, or the title with Moondog, as on every play path: an open request shares
|
|
569
616
|
// words with the plain titles Moondog gives its records ("something for a rainy day").
|
|
@@ -571,7 +618,9 @@ export class MoondogApplication {
|
|
|
571
618
|
await this.quietOtherPlayers("apple_music", { signal });
|
|
572
619
|
// A reply stopped before the track starts plays nothing, and no replay is recorded.
|
|
573
620
|
if (signal?.aborted) throw Object.assign(new Error("Nothing was played, because the reply was stopped."), { code: "generation_stopped" });
|
|
574
|
-
// Nothing waits between this check and the play; the replay is written once it plays.
|
|
621
|
+
// Nothing waits between this check and the play; the replay is written once it plays. The ended
|
|
622
|
+
// stream's track it cuts off, or its queued one that started meanwhile, says nothing about the listener.
|
|
623
|
+
if (endedStream) this.localGenerator.discard?.();
|
|
575
624
|
this.localGenerator.restart?.(id);
|
|
576
625
|
this.musicPreviewPlayer.playGenerated({ id, title, seconds: receipt.audio?.duration_seconds, file });
|
|
577
626
|
if (!named) this.#noteGeneratedPlay({ id, title });
|
|
@@ -579,25 +628,299 @@ export class MoondogApplication {
|
|
|
579
628
|
return { state: "playing", track: { id, title, seconds: receipt.audio?.duration_seconds ?? null }, chosen_by: named ? "listener" : "moondog" };
|
|
580
629
|
}
|
|
581
630
|
|
|
631
|
+
// The listener's library: their playlists and the songs and made tracks they kept, side by side.
|
|
632
|
+
libraryReady() {
|
|
633
|
+
return Boolean(this.libraryStore);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
requireLibrary() {
|
|
637
|
+
if (!this.libraryStore) throw Object.assign(new Error("The library is not available here."), { code: "library_not_ready" });
|
|
638
|
+
return this.libraryStore;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
libraryView() {
|
|
642
|
+
return this.libraryReady() ? this.libraryStore.view() : null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// One playlist with its entries; a made track says whether its audio is still on this Mac.
|
|
646
|
+
async libraryPlaylist(id) {
|
|
647
|
+
const playlist = this.requireLibrary().playlist(id);
|
|
648
|
+
if (!playlist) return null;
|
|
649
|
+
for (const entry of playlist.entries) {
|
|
650
|
+
if (entry.kind === "made") entry.audio_kept = Boolean(await this.localGenerator?.trackFile?.(entry.made_id));
|
|
651
|
+
}
|
|
652
|
+
return playlist;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// The song or made track one listening source plays now, as a library entry.
|
|
656
|
+
#entryOfSong(song) {
|
|
657
|
+
if (!song?.title) return null;
|
|
658
|
+
if (song.made_track_id) return { kind: "made", id: song.made_track_id, title: song.title };
|
|
659
|
+
const artist = Array.isArray(song.artists) ? song.artists.join(", ") : "";
|
|
660
|
+
if (!artist) return null;
|
|
661
|
+
const album = typeof song.album === "string" ? song.album : song.album?.name;
|
|
662
|
+
return { kind: "song", title: song.title, artist_credit: artist, ...(album ? { album } : {}) };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// What to save: the song playing now, what plays from now on (its playlist or queue), songs the agent names,
|
|
666
|
+
// or a made track by id.
|
|
667
|
+
async #entriesToSave({ from, songs, madeTrackId }, { signal } = {}) {
|
|
668
|
+
if (madeTrackId) {
|
|
669
|
+
const receipt = await this.localGenerator?.receipt?.(madeTrackId);
|
|
670
|
+
if (!receipt) throw Object.assign(new Error("There is no track Moondog made with that id on this Mac."), { code: "made_track_not_found" });
|
|
671
|
+
if (!(await this.localGenerator.trackFile(madeTrackId))) {
|
|
672
|
+
throw Object.assign(new Error("Only the receipt of that track is left, so it can't be kept."), { code: "made_track_audio_not_kept" });
|
|
673
|
+
}
|
|
674
|
+
return [{ kind: "made", id: madeTrackId, title: String(receipt.title ?? "Moondog") }];
|
|
675
|
+
}
|
|
676
|
+
if (Array.isArray(songs) && songs.length) {
|
|
677
|
+
return songs.map((song) => ({ kind: "song", title: song.title, artist_credit: song.artist ?? song.artist_credit, ...(song.album ? { album: song.album } : {}) }));
|
|
678
|
+
}
|
|
679
|
+
const now = await readListeningNow(this, { signal });
|
|
680
|
+
const active = now.sources.find((source) => source.service === now.active);
|
|
681
|
+
if (!active?.song) throw Object.assign(new Error("Nothing is playing, so there is nothing to save."), { code: "library_nothing_playing" });
|
|
682
|
+
if (from === "queue") {
|
|
683
|
+
// What plays from now on: Moondog's whole remaining queue (its status shows only the next ten); the Music
|
|
684
|
+
// app's playlist from the song playing, which it places; otherwise the song and the service's own queue.
|
|
685
|
+
const remaining = active.service === "moondog" ? this.musicPreviewPlayer?.remaining?.() : null;
|
|
686
|
+
let playing = active.from;
|
|
687
|
+
// The Music app's status reads a window around the playing song; a longer playlist is read on to its end.
|
|
688
|
+
if (active.service === "apple_music" && playing?.tracks?.length && Number.isInteger(playing.tracks_total) &&
|
|
689
|
+
(playing.first_position ?? 1) - 1 + playing.tracks.length < playing.tracks_total && typeof this.musicApp?.remainingPlaylist === "function") {
|
|
690
|
+
const rest = await this.musicApp.remainingPlaylist().then((now) => musicAppSource(now).from, () => null);
|
|
691
|
+
if (rest?.tracks?.length && Number.isInteger(rest.position) && Number.isInteger(rest.first_position)) playing = rest;
|
|
692
|
+
}
|
|
693
|
+
const tracks = playing?.tracks;
|
|
694
|
+
const placed = tracks?.length && Number.isInteger(playing.position) && Number.isInteger(playing.first_position);
|
|
695
|
+
const list = remaining?.length ? remaining.map((track) => ({ title: track.title, artists: track.generated ? ["Moondog"] : [track.artist_credit].filter(Boolean),
|
|
696
|
+
...(track.generated && track.id ? { made_track_id: track.id } : {}) }))
|
|
697
|
+
: placed ? tracks.slice(Math.max(0, playing.position - playing.first_position))
|
|
698
|
+
: [active.song, ...(active.next ?? [])];
|
|
699
|
+
return list.map((song) => this.#entryOfSong(song)).filter(Boolean);
|
|
700
|
+
}
|
|
701
|
+
const one = this.#entryOfSong(active.song);
|
|
702
|
+
if (!one) throw Object.assign(new Error("The song playing now has no artist to keep it by."), { code: "library_nothing_playing" });
|
|
703
|
+
return [one];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Keeps songs or made tracks in the library: in Saved when no playlist is named, in a playlist by id, or
|
|
708
|
+
* in a new playlist by name. by says who chose to keep them: the listener, or Moondog at their request.
|
|
709
|
+
*/
|
|
710
|
+
async saveToLibrary({ from = "now_playing", songs, madeTrackId, playlistId, playlistName, folderId, artwork } = {}, { by = "listener", signal } = {}) {
|
|
711
|
+
const library = this.requireLibrary();
|
|
712
|
+
const entries = await this.#entriesToSave({ from, songs, madeTrackId }, { signal });
|
|
713
|
+
if (!entries.length) throw Object.assign(new Error("There is nothing to save."), { code: "library_nothing_playing" });
|
|
714
|
+
// The window passes the cover it shows for the song playing now.
|
|
715
|
+
if (artwork && from === "now_playing" && !songs?.length && !madeTrackId && entries[0].kind === "song") entries[0].refs = { artwork_url: artwork };
|
|
716
|
+
if (!playlistId && playlistName) {
|
|
717
|
+
const made = library.savePlaylist({ name: playlistName, folderId: folderId ?? null, origin: by === "moondog" ? "moondog" : "listener", entries, addedBy: by });
|
|
718
|
+
return { playlist: { id: made.id, name: library.playlist(made.id).name, created: true }, added: made.added, already: made.already,
|
|
719
|
+
titles: entries.map((entry) => entry.title) };
|
|
720
|
+
}
|
|
721
|
+
const target = playlistId ?? SAVED_PLAYLIST_ID;
|
|
722
|
+
const added = library.addEntries(target, entries, { addedBy: by });
|
|
723
|
+
const playlist = library.playlist(target);
|
|
724
|
+
return { playlist: { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name, ...(playlist.saved ? { saved: true } : {}) },
|
|
725
|
+
added: added.added, already: added.already, titles: entries.map((entry) => entry.title) };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Reads a playlist link (Spotify, Apple Music, NetEase Music, QQ Music) and holds what it found for the
|
|
730
|
+
* listener to import: { token, provider, name, read, total, owned, sample }. owned is true for a playlist the
|
|
731
|
+
* listener made, false for one a service or someone else made, and null when the service doesn't say.
|
|
732
|
+
*/
|
|
733
|
+
async previewPlaylistLink({ link } = {}, { signal } = {}) {
|
|
734
|
+
this.requireLibrary();
|
|
735
|
+
const parsed = parsePlaylistLink(link);
|
|
736
|
+
let found;
|
|
737
|
+
if (parsed.provider === "spotify") {
|
|
738
|
+
if (!this.spotifyReady()) throw Object.assign(new Error("Connect Spotify first, so Moondog can read its playlists."), { code: "spotify_not_ready" });
|
|
739
|
+
const playlist = await this.requireSpotifyService().readPlaylist({ playlistId: parsed.id }, { signal });
|
|
740
|
+
found = { provider: "spotify", id: parsed.id, url: parsed.url, name: playlist.name || "Spotify playlist", total: playlist.tracks_total, owned: playlist.owned,
|
|
741
|
+
tracks: playlist.tracks.map((track) => { const credit = track.artists.join(", "); return { id: track.uri, title: track.name, artist: credit,
|
|
742
|
+
...(track.album ? { album: track.album } : {}), ...(track.isrc ? { isrc: track.isrc } : {}),
|
|
743
|
+
refs: { spotify_uri: track.uri, spotify_title: track.name, spotify_credit: credit, ...(track.image ? { artwork_url: track.image } : {}) } }; }) };
|
|
744
|
+
} else if (parsed.provider === "apple_music") {
|
|
745
|
+
const playlist = await readApplePlaylist(parsed, { signal });
|
|
746
|
+
// A playlist a listener shared has a pl.u- id; the others are Apple's own.
|
|
747
|
+
found = { ...playlist, owned: parsed.id.startsWith("pl.u-") ? null : false,
|
|
748
|
+
tracks: playlist.tracks.map((track) => ({ id: track.id, title: track.title, artist: track.artist, refs: { apple_music_id: track.id, ...(track.artwork ? { artwork_url: track.artwork } : {}) } })) };
|
|
749
|
+
} else {
|
|
750
|
+
const playlist = await readPublicPlaylistLink(parsed);
|
|
751
|
+
found = { ...playlist, owned: null, tracks: playlist.tracks.map(({ artwork, ...track }) => ({ ...track,
|
|
752
|
+
refs: { ...(playlist.provider === "netease" ? { netease_id: String(track.id) } : {}), ...(artwork ? { artwork_url: artwork } : {}) } })) };
|
|
753
|
+
}
|
|
754
|
+
signal?.throwIfAborted();
|
|
755
|
+
// A library playlist holds up to 2,000 songs; a longer one comes in from its start, and total says how long it is.
|
|
756
|
+
found.tracks = found.tracks.filter((track) => track.title && track.artist).slice(0, 2000);
|
|
757
|
+
if (found.total === null || found.total === undefined) found.total = null;
|
|
758
|
+
if (!found.tracks.length) throw Object.assign(new Error("No songs could be read from this playlist."), { code: "playlist_empty" });
|
|
759
|
+
const token = randomUUID();
|
|
760
|
+
this.pendingPlaylistImports.set(token, found);
|
|
761
|
+
while (this.pendingPlaylistImports.size > 5) this.pendingPlaylistImports.delete(this.pendingPlaylistImports.keys().next().value);
|
|
762
|
+
return { token, provider: found.provider, name: found.name, read: found.tracks.length, total: found.total ?? null, owned: found.owned,
|
|
763
|
+
sample: found.tracks.slice(0, 3).map((track) => `${track.title} - ${track.artist}`) };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Imports a previewed playlist: a copy in the library that remembers its link, and, when taste is true, its songs
|
|
768
|
+
* as evidence of what the listener keeps. A playlist the listener didn't make shapes their taste only when they say so.
|
|
769
|
+
*/
|
|
770
|
+
async importPlaylistLink({ token, taste, name, folderId } = {}) {
|
|
771
|
+
const library = this.requireLibrary();
|
|
772
|
+
const found = this.pendingPlaylistImports.get(String(token ?? ""));
|
|
773
|
+
if (!found) throw Object.assign(new Error("That preview is gone. Paste the link again."), { code: "playlist_preview_gone" });
|
|
774
|
+
const shapes = typeof taste === "boolean" ? taste : found.owned === true;
|
|
775
|
+
const made = library.savePlaylist({ name: typeof name === "string" && name.trim() ? name : found.name, folderId: folderId ?? null,
|
|
776
|
+
origin: found.provider, source: { provider: found.provider, id: found.id, url: found.url },
|
|
777
|
+
entries: found.tracks.map((track) => ({ kind: "song", title: track.title, artist_credit: track.artist,
|
|
778
|
+
...(track.album ? { album: track.album } : {}), ...(track.isrc ? { isrc: track.isrc } : {}), refs: track.refs ?? {} })) });
|
|
779
|
+
this.pendingPlaylistImports.delete(String(token));
|
|
780
|
+
let tasteState = "not_added";
|
|
781
|
+
if (shapes) {
|
|
782
|
+
if (typeof this.playlistTaste !== "function") tasteState = "unavailable";
|
|
783
|
+
else tasteState = await this.playlistTaste(found).then(() => "added", () => "failed");
|
|
784
|
+
}
|
|
785
|
+
return { playlist: { id: made.id, name: library.playlist(made.id).name }, added: made.added, read: found.tracks.length,
|
|
786
|
+
total: found.total ?? null, owned: found.owned, taste: tasteState };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Reads and imports a playlist link in one step, as the agent and the terminal do. */
|
|
790
|
+
async importPlaylistFromLink({ link, taste, name, folderId } = {}, { signal } = {}) {
|
|
791
|
+
const preview = await this.previewPlaylistLink({ link }, { signal });
|
|
792
|
+
return { ...(await this.importPlaylistLink({ token: preview.token, taste, name, folderId })), provider: preview.provider };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Playlists by name for the agent, or the newest ones when no words are given. */
|
|
796
|
+
findLibraryPlaylists(query) {
|
|
797
|
+
return this.requireLibrary().findPlaylists(query).map(({ id, name, count, made, origin, saved }) => ({
|
|
798
|
+
id, name: saved ? "Saved" : name, count, made, origin, ...(saved ? { saved: true } : {}) }));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Plays a playlist from the library, from its first entry or from one entry. It plays on the listener's
|
|
803
|
+
* chosen service: Moondog's own player holds Apple Music songs and made tracks in one queue; Spotify and
|
|
804
|
+
* NetEase play its songs, and its made tracks wait for Moondog's own player (left_out_made says how many).
|
|
805
|
+
* The listener's own playlist is their pick, so nothing of it goes in the suggestions ledger.
|
|
806
|
+
*/
|
|
807
|
+
async playLibrary({ playlistId, entryId, shuffle = false } = {}, { signal } = {}) {
|
|
808
|
+
const playlist = await this.libraryPlaylist(playlistId);
|
|
809
|
+
if (!playlist) throw Object.assign(new Error("That playlist is not in the library."), { code: "library_playlist_not_found" });
|
|
810
|
+
const named = { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name };
|
|
811
|
+
let entries = playlist.entries;
|
|
812
|
+
const start = entryId ? entries.findIndex((entry) => entry.id === entryId) : 0;
|
|
813
|
+
entries = entries.slice(Math.max(0, start));
|
|
814
|
+
if (shuffle) entries = entries.map((entry) => [Math.random(), entry]).sort((a, b) => a[0] - b[0]).map(([, entry]) => entry);
|
|
815
|
+
const avoided = entries.filter((entry) => entry.kind === "song" && !this.avoidAllows({ title: entry.title, artist_credit: entry.artist_credit }));
|
|
816
|
+
entries = entries.filter((entry) => !avoided.includes(entry) && (entry.kind === "song" || entry.audio_kept));
|
|
817
|
+
const extra = avoided.length ? { skipped_avoided_count: avoided.length } : {};
|
|
818
|
+
if (!entries.length) return { state: "empty", playlist: named, ...extra };
|
|
819
|
+
const songs = entries.filter((entry) => entry.kind === "song");
|
|
820
|
+
const service = this.playbackService;
|
|
821
|
+
// Only made tracks, or Apple Music chosen: Moondog's own player, with both in one queue.
|
|
822
|
+
if ((!songs.length || service === "apple_music") && typeof this.musicPreviewPlayer?.playList === "function") {
|
|
823
|
+
const refs = [];
|
|
824
|
+
const avoidedFound = [];
|
|
825
|
+
const found = await mapLimited(entries.slice(0, 50), 6, async (entry) => {
|
|
826
|
+
if (entry.kind === "made") return { generated: true, id: entry.made_id, title: entry.title, file: await this.localGenerator.trackFile(entry.made_id) };
|
|
827
|
+
if (!this.musicCatalog) return null;
|
|
828
|
+
const result = await this.musicCatalog.findPreview({ title: entry.title, artist: entry.artist_credit }).catch(() => null);
|
|
829
|
+
if (result?.state !== "resolved") return null;
|
|
830
|
+
// The catalog's recording can credit more than the entry named (a feature, a longer title); an Avoid still holds.
|
|
831
|
+
if (!this.avoidAllows(result.track)) { avoidedFound.push(entry.id); return null; }
|
|
832
|
+
if (result.track.catalog_id) refs.push({ entryId: entry.id, refs: { apple_music_id: String(result.track.catalog_id),
|
|
833
|
+
...(entry.refs?.artwork_url || !result.track.artwork_url ? {} : { artwork_url: result.track.artwork_url }) } });
|
|
834
|
+
return result.track;
|
|
835
|
+
});
|
|
836
|
+
signal?.throwIfAborted();
|
|
837
|
+
const tracks = found.filter(Boolean);
|
|
838
|
+
const notFound = found.length - tracks.length - avoidedFound.length;
|
|
839
|
+
const skipped = avoided.length + avoidedFound.length;
|
|
840
|
+
Object.assign(extra, skipped ? { skipped_avoided_count: skipped } : {});
|
|
841
|
+
if (!tracks.length) return { state: "not_found", playlist: named, not_found: notFound, ...extra };
|
|
842
|
+
this.libraryStore.rememberRefs(refs);
|
|
843
|
+
// A playlist replaces Moondog's stream and quiets the other players, as any music Moondog starts does.
|
|
844
|
+
await this.#endStream("replaced");
|
|
845
|
+
await this.quietOtherPlayers("apple_music", { signal });
|
|
846
|
+
signal?.throwIfAborted();
|
|
847
|
+
this.musicPreviewPlayer.playList(tracks);
|
|
848
|
+
return { state: "playing", player: "moondog", playlist: named, playing: tracks.length, not_found: notFound,
|
|
849
|
+
whole_songs: this.musicPreviewPlayer.wholeSongs?.() === true, ...extra };
|
|
850
|
+
}
|
|
851
|
+
const leftOut = entries.length - songs.length;
|
|
852
|
+
if (service === "spotify") {
|
|
853
|
+
if (!this.spotifyPlaybackReady()) throw Object.assign(new Error("Spotify is not ready to play here."), { code: "spotify_not_ready" });
|
|
854
|
+
const list = songs.slice(0, 100);
|
|
855
|
+
const refs = [];
|
|
856
|
+
const avoidedFound = [];
|
|
857
|
+
// A recording's own title and credit can name more than the entry does (a feature); an Avoid holds against it.
|
|
858
|
+
const allowed = (title, credit) => this.avoidAllows({ title, artist_credit: credit });
|
|
859
|
+
const uris = await mapLimited(list, 4, async (entry) => {
|
|
860
|
+
// A cached recording plays without a search, once its own credit passes today's Avoids.
|
|
861
|
+
if (entry.refs?.spotify_uri && entry.refs.spotify_credit) {
|
|
862
|
+
if (allowed(entry.refs.spotify_title ?? entry.title, entry.refs.spotify_credit)) return entry.refs.spotify_uri;
|
|
863
|
+
avoidedFound.push(entry.id);
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
const { matched } = await this.spotifyResolveSongs([{ title: entry.title, artist: entry.artist_credit }], { signal }).catch(() => ({ matched: [] }));
|
|
867
|
+
// A match reaches the model without its URI; the host keeps it under the match's reference.
|
|
868
|
+
const ref = matched[0]?.item_ref_id;
|
|
869
|
+
const known = ref ? this.spotifyHostReadItems().find((item) => item.item_ref_id === ref) : null;
|
|
870
|
+
const uri = typeof known?.uri === "string" ? known.uri : typeof matched[0]?.uri === "string" ? matched[0].uri : null;
|
|
871
|
+
if (!uri) return null;
|
|
872
|
+
const title = known?.name ?? matched[0]?.name ?? entry.title;
|
|
873
|
+
const credit = (known?.artists ?? matched[0]?.artists ?? []).join(", ") || entry.artist_credit;
|
|
874
|
+
refs.push({ entryId: entry.id, refs: { spotify_uri: uri, spotify_title: title, spotify_credit: credit } });
|
|
875
|
+
if (!allowed(title, credit)) { avoidedFound.push(entry.id); return null; }
|
|
876
|
+
return uri;
|
|
877
|
+
});
|
|
878
|
+
signal?.throwIfAborted();
|
|
879
|
+
this.libraryStore.rememberRefs(refs);
|
|
880
|
+
const playable = uris.filter(Boolean);
|
|
881
|
+
const skipped = avoided.length + avoidedFound.length;
|
|
882
|
+
Object.assign(extra, skipped ? { skipped_avoided_count: skipped } : {});
|
|
883
|
+
const unfound = list.length - playable.length - avoidedFound.length;
|
|
884
|
+
if (!playable.length) return { state: "not_found", playlist: named, not_found: unfound, left_out_made: leftOut, ...extra };
|
|
885
|
+
await this.quietOtherPlayers("spotify", { signal });
|
|
886
|
+
const receipt = await this.#resumeSpotifySelection({ uris: playable }, { signal, played: [] });
|
|
887
|
+
return { state: receipt.state === "accepted" ? "playing" : receipt.state, player: "spotify", playlist: named, playing: playable.length,
|
|
888
|
+
not_found: unfound, left_out_made: leftOut, ...extra };
|
|
889
|
+
}
|
|
890
|
+
if (service === "netease" && this.neteaseReady()) {
|
|
891
|
+
const player = this.requireNetease();
|
|
892
|
+
await this.quietOtherPlayers("netease", { signal });
|
|
893
|
+
const receipt = await player.play({ items: songs.slice(0, 20).map((entry) => ({ title: entry.title, artist: entry.artist_credit })), mode: "play",
|
|
894
|
+
intent: "播放听者自己的歌单", allow: (song) => this.avoidAllows(Array.isArray(song.artists)
|
|
895
|
+
? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
|
|
896
|
+
return { state: receipt?.mode === "playing" ? "playing" : "not_found", player: "netease", playlist: named,
|
|
897
|
+
playing: receipt?.songs?.length ?? 0, left_out_made: leftOut, ...extra };
|
|
898
|
+
}
|
|
899
|
+
throw Object.assign(new Error("No player here can play this playlist."), { code: "library_no_player" });
|
|
900
|
+
}
|
|
901
|
+
|
|
582
902
|
// Makes one instrumental track from the listener's taste and plays it in Moondog's own player.
|
|
583
903
|
// The agent proposes sound descriptors, each with its source; the taste compiler keeps only the
|
|
584
904
|
// ones it can trace and none that names an artist or a song. The track and its receipt stay on
|
|
585
905
|
// this Mac, and its play goes in the ledger as Moondog's own, never as the listener's attention.
|
|
586
906
|
// moment and request come from the agent's host, not from the model.
|
|
587
|
-
async makeTrack({ descriptors, bpm, bpmSource, seconds, title } = {}, { moment = null, request = null, transient = false, signal, onProgress } = {}) {
|
|
907
|
+
async makeTrack({ descriptors, bpm, bpmSource, seconds, title } = {}, { moment = null, request = null, transient = false, signal, onProgress, nameRecord } = {}) {
|
|
588
908
|
const { compiled, receipt } = await this.#compileForGeneration({ descriptors, bpm, bpmSource, title }, { moment, request, transient });
|
|
589
909
|
if (!compiled.usable) {
|
|
590
910
|
return { state: "not_made", reason: compiled.descriptors.length ? "needs_a_sound" : "no_traceable_descriptors", dropped: compiled.dropped };
|
|
591
911
|
}
|
|
592
912
|
const prompt = stableAudioPrompt(compiled);
|
|
593
|
-
await this.#endStream("replaced");
|
|
594
|
-
const
|
|
913
|
+
const endedStream = await this.#endStream("replaced");
|
|
914
|
+
const named = await this.#titleInSettingLanguage(compiled, nameRecord, signal);
|
|
915
|
+
const made = await this.localGenerator.generate({ prompt, seconds, title: named, receipt }, { signal, onProgress });
|
|
595
916
|
// A reply stopped as the track finished keeps the track, but plays nothing over what is on now.
|
|
596
917
|
await this.quietOtherPlayers("apple_music", { signal });
|
|
597
918
|
// The terminal plays the file itself; the window asks for it by id and never sees the path.
|
|
598
919
|
const file = await this.localGenerator.trackFile(made.id);
|
|
599
920
|
// A reply stopped while the track was made, or while the other players paused, keeps the track but plays nothing.
|
|
600
921
|
if (signal?.aborted) throw Object.assign(new Error("The track was made but not played, because the reply was stopped."), { code: "generation_stopped" });
|
|
922
|
+
// The replaced stream's track it cuts off was stopped by Moondog, not left by the listener.
|
|
923
|
+
if (endedStream) this.localGenerator.discard?.();
|
|
601
924
|
this.musicPreviewPlayer.playGenerated({ id: made.id, title: made.title, seconds: made.duration_seconds, file });
|
|
602
925
|
this.#noteGeneratedPlay(made);
|
|
603
926
|
return { state: "playing", track: { id: made.id, title: made.title, seconds: made.duration_seconds, seed: made.seed },
|
|
@@ -632,26 +955,71 @@ export class MoondogApplication {
|
|
|
632
955
|
|
|
633
956
|
// Starts Moondog's atmosphere stream from the listener's taste: its first track plays now, and
|
|
634
957
|
// each next one is made while the last plays. Every track goes in the ledger as Moondog's pick.
|
|
635
|
-
async startStream({ descriptors, bpm, bpmSource, seconds, minutes, title } = {}, { moment = null, request = null, transient = false, signal, onProgress } = {}) {
|
|
958
|
+
async startStream({ descriptors, bpm, bpmSource, seconds, minutes, title } = {}, { moment = null, request = null, transient = false, signal, onProgress, nameRecord } = {}) {
|
|
636
959
|
if (!this.streamReady()) throw Object.assign(new Error("A stream can't run here. Music making must be on, in the terminal room or the window."), { code: "stream_not_ready" });
|
|
637
960
|
const { compiled, receipt } = await this.#compileForGeneration({ descriptors, bpm, bpmSource, title }, { moment, request, transient });
|
|
638
961
|
if (!compiled.usable) {
|
|
639
962
|
return { state: "not_made", reason: compiled.descriptors.length ? "needs_a_sound" : "no_traceable_descriptors", dropped: compiled.dropped };
|
|
640
963
|
}
|
|
641
964
|
await this.#endStream("replaced");
|
|
965
|
+
// The stream's name, which its first track carries, in the language Moondog is set to.
|
|
966
|
+
const named = await this.#titleInSettingLanguage(compiled, nameRecord, signal);
|
|
642
967
|
// Other music pauses only once the first track exists, as for a single track.
|
|
643
|
-
|
|
644
|
-
|
|
968
|
+
let started;
|
|
969
|
+
try {
|
|
970
|
+
started = await this.atmosphereStream.start({ compiled, title: named, seconds, minutes, receipt },
|
|
971
|
+
{ signal, onProgress, onTrack: (track) => this.#noteGeneratedPlay(track), beforePlay: () => this.quietOtherPlayers("apple_music", { signal }),
|
|
972
|
+
nameTrack: typeof nameRecord === "function" ? this.#streamNamer(nameRecord, compiled) : undefined });
|
|
973
|
+
} catch (error) {
|
|
974
|
+
// The listener turned it off while its first track was made: their choice, not a failure to retry.
|
|
975
|
+
if (error?.code === "generation_stopped" && error.ended === "asked" && !signal?.aborted) return { state: "not_made", reason: "stopped_by_listener" };
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
const { made, prompt } = started;
|
|
645
979
|
return { state: "streaming", minutes, track: { id: made.id, title: made.title, seconds: made.duration_seconds, seed: made.seed },
|
|
646
980
|
prompt, shaped_by: compiled.descriptors, sources: tasteSources(compiled.descriptors), left_out: compiled.dropped,
|
|
647
981
|
...(compiled.bpm ? { bpm: compiled.bpm } : {}), elapsed_ms: made.elapsed_ms };
|
|
648
982
|
}
|
|
649
983
|
|
|
984
|
+
// The agent writes a record's title itself, but can follow the conversation's language instead of
|
|
985
|
+
// the one Moondog is set to. A title in another language is named again in the setting's language,
|
|
986
|
+
// keeping its picture; if that cannot be done in time, the agent's title stays.
|
|
987
|
+
async #titleInSettingLanguage(compiled, nameRecord, signal) {
|
|
988
|
+
if (typeof nameRecord !== "function" || nameFitsLanguage(compiled.title, this.locale)) return compiled.title;
|
|
989
|
+
const terms = compiled.descriptors.map((descriptor) => descriptor.term);
|
|
990
|
+
const timeout = AbortSignal.timeout(TITLE_LANGUAGE_TIMEOUT_MS);
|
|
991
|
+
try {
|
|
992
|
+
const name = await Promise.race([
|
|
993
|
+
this.#streamNamer(nameRecord, compiled)({ draft: compiled.title, featured: terms.slice(0, 3), sound: terms.slice(3),
|
|
994
|
+
...(compiled.bpm ? { bpm: compiled.bpm } : {}), earlier: [] }, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout }),
|
|
995
|
+
new Promise((resolve) => { timeout.addEventListener("abort", () => resolve(null), { once: true }); }),
|
|
996
|
+
]);
|
|
997
|
+
return name ?? compiled.title;
|
|
998
|
+
} catch {
|
|
999
|
+
return compiled.title;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Names a stream's later tracks: the agent's namer asks the model, with the names of Moondog's recent
|
|
1004
|
+
// tracks so it does not repeat them, and a name passes the same check as the agent's own titles and
|
|
1005
|
+
// must be in the language Moondog is set to.
|
|
1006
|
+
#streamNamer(nameRecord, compiled) {
|
|
1007
|
+
return async (context, options) => {
|
|
1008
|
+
const recent = (this.localGenerator?.recentTracks?.(8) ?? []).map((track) => track.title).filter(Boolean);
|
|
1009
|
+
const name = acceptableRecordTitle(await nameRecord({ ...context, recent }, options), compiled.names);
|
|
1010
|
+
// A name in another language than Moondog's setting is not kept.
|
|
1011
|
+
return name && nameFitsLanguage(name, this.locale) ? name : null;
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
650
1015
|
// A single track or a new stream takes over from a running stream, once its next track stops being made.
|
|
1016
|
+
// The stream's track plays on until the new one starts, so nothing goes silent while that is made.
|
|
1017
|
+
// Says whether a stream ended.
|
|
651
1018
|
async #endStream(reason) {
|
|
652
|
-
if (!this.atmosphereStream?.active?.()) return;
|
|
1019
|
+
if (!this.atmosphereStream?.active?.()) return false;
|
|
653
1020
|
this.atmosphereStream.stop(reason);
|
|
654
1021
|
for (let waited = 0; this.localGenerator?.busy?.() && waited < 5_000; waited += 100) await delay(100);
|
|
1022
|
+
return true;
|
|
655
1023
|
}
|
|
656
1024
|
|
|
657
1025
|
stopStream() {
|
|
@@ -659,6 +1027,21 @@ export class MoondogApplication {
|
|
|
659
1027
|
return stopped ? { state: "stopped", tracks_made: stopped.tracks_made } : { state: "not_running" };
|
|
660
1028
|
}
|
|
661
1029
|
|
|
1030
|
+
// What the window and the terminal show of the stream: { phase, started_at, ends_at, tracks_made }, or null.
|
|
1031
|
+
streamView() {
|
|
1032
|
+
return this.atmosphereStream?.view?.() ?? null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Each change of the stream, as { view, ended }; a host without a stream hears nothing.
|
|
1036
|
+
subscribeStream(listener) {
|
|
1037
|
+
return this.atmosphereStream?.subscribe?.(listener) ?? (() => {});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Whether a Spotify device is this Mac, where its music and Moondog's would play over each other.
|
|
1041
|
+
spotifyOnThisMac(device) {
|
|
1042
|
+
return spotifyOnThisMac(device);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
662
1045
|
// What the compiler may trace a descriptor to. Each cited finding is read from the saved reading.
|
|
663
1046
|
async #tasteContext({ descriptors, moment, request }) {
|
|
664
1047
|
const choices = this.domainServices?.listenerChoices?.()?.items ?? [];
|
|
@@ -708,10 +1091,13 @@ export class MoondogApplication {
|
|
|
708
1091
|
async playNetease({ items, mode, intent } = {}) {
|
|
709
1092
|
const player = this.requireNetease();
|
|
710
1093
|
this.requireListenerAvoids();
|
|
711
|
-
|
|
1094
|
+
const quiet = () => this.quietOtherPlayers("netease");
|
|
1095
|
+
if (mode !== "queue") await quiet();
|
|
712
1096
|
// A found song lists its artists; a song asked for by name has only the name it was asked by.
|
|
713
|
-
|
|
714
|
-
|
|
1097
|
+
// A queue with nothing loaded starts its first song, and quiets the others just before.
|
|
1098
|
+
const receipt = await player.play({ items, mode, intent, ...(mode === "queue" ? { beforePlay: quiet } : {}),
|
|
1099
|
+
allow: (song) => this.avoidAllows(Array.isArray(song.artists)
|
|
1100
|
+
? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
|
|
715
1101
|
if (["playing", "queued"].includes(receipt?.mode)) {
|
|
716
1102
|
const songs = receipt.songs ?? [];
|
|
717
1103
|
const slots = this.#mixSlots(songs.map((song) => ({ title: song.title, artists: song.artists })));
|
|
@@ -726,8 +1112,13 @@ export class MoondogApplication {
|
|
|
726
1112
|
|
|
727
1113
|
async controlNetease({ action, positionSeconds } = {}) {
|
|
728
1114
|
const player = this.requireNetease();
|
|
1115
|
+
const quiet = () => this.quietOtherPlayers("netease");
|
|
1116
|
+
if (action === "resume") await quiet();
|
|
729
1117
|
if (action === "seek") await player.seek({ positionMs: Math.max(0, Number(positionSeconds) || 0) * 1000 });
|
|
730
|
-
|
|
1118
|
+
// Next and previous start a song, so the other players go quiet just before; a refusal or a
|
|
1119
|
+
// previous that only rewinds starts none.
|
|
1120
|
+
else if (action === "next" || action === "previous") await player[action]({ beforePlay: quiet });
|
|
1121
|
+
else if (["pause", "resume", "stop"].includes(action)) await player[action]();
|
|
731
1122
|
else throw Object.assign(new Error(`Unknown NetEase action: ${action}`), { code: "netease_failed" });
|
|
732
1123
|
return await player.nowPlaying();
|
|
733
1124
|
}
|
|
@@ -736,9 +1127,10 @@ export class MoondogApplication {
|
|
|
736
1127
|
return this.requireNetease().nowPlaying();
|
|
737
1128
|
}
|
|
738
1129
|
|
|
739
|
-
// Whether any player the listener uses can be read: the Music app, Spotify or NetEase.
|
|
1130
|
+
// Whether any player the listener uses can be read: Moondog's own, the Music app, Spotify or NetEase.
|
|
740
1131
|
listeningReadable() {
|
|
741
|
-
return this.
|
|
1132
|
+
return typeof this.musicPreviewPlayer?.nowPlaying === "function" || this.musicApp?.available === true ||
|
|
1133
|
+
this.spotifyReady() || this.neteaseReady();
|
|
742
1134
|
}
|
|
743
1135
|
|
|
744
1136
|
// What the listener plays right now on any of them, and what it plays from (src/core/listening-now.mjs).
|
|
@@ -829,12 +1221,13 @@ export class MoondogApplication {
|
|
|
829
1221
|
]);
|
|
830
1222
|
}
|
|
831
1223
|
|
|
832
|
-
// The service just left stops playing, so two services never play at once.
|
|
1224
|
+
// The service just left stops playing, so two services never play at once. Moondog's stream is its
|
|
1225
|
+
// own channel, not a service: its made tracks play on, and it ends only when a service song plays.
|
|
833
1226
|
async #stopPlaybackService(service) {
|
|
834
1227
|
try {
|
|
835
1228
|
if (service === "apple_music") {
|
|
836
|
-
|
|
837
|
-
this.musicPreviewPlayer?.stop?.();
|
|
1229
|
+
// Apple Music's songs in Moondog's player stop; a player that can't say what it holds stops, as before.
|
|
1230
|
+
if (this.musicPreviewPlayer?.holdsCatalogSongs?.() !== false) this.musicPreviewPlayer?.stop?.();
|
|
838
1231
|
// The listener's own Music app too, when it plays.
|
|
839
1232
|
if ((await this.musicApp?.nowPlaying?.())?.state === "playing") await this.musicApp.pause();
|
|
840
1233
|
} else if (service === "netease" && this.neteasePlayer?.ready?.() === true) await this.neteasePlayer.stop();
|
|
@@ -846,6 +1239,52 @@ export class MoondogApplication {
|
|
|
846
1239
|
return this.playbackSelection.subscribe(listener);
|
|
847
1240
|
}
|
|
848
1241
|
|
|
1242
|
+
// What the agent can tell the listener about its own setup, and what it can change when asked.
|
|
1243
|
+
async settingsStatus(runtime = {}) {
|
|
1244
|
+
const playback = this.playbackStatus();
|
|
1245
|
+
const choices = await this.modelSwitch?.choices().catch(() => []) ?? [];
|
|
1246
|
+
return {
|
|
1247
|
+
model: { provider: runtime.provider ?? null, model: runtime.model ?? null, ...(runtime.effort ? { effort: runtime.effort } : {}),
|
|
1248
|
+
...(this.modelSwitch ? { change: "moondog_settings model", choices } : { change: "not_by_agent" }),
|
|
1249
|
+
where: "Settings › Models in the window, or /model in the terminal" },
|
|
1250
|
+
music_service: { selected: playback.selected_service ?? null, effective: playback.effective_service ?? null,
|
|
1251
|
+
available: (playback.services ?? []).filter((entry) => entry.available).map((entry) => entry.service),
|
|
1252
|
+
change: "moondog_playback_service" },
|
|
1253
|
+
reply_language: { current: this.locale, choices: Object.keys(LOCALES), change: "moondog_settings reply_language" },
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// The reply language the listener asked for in words: used from the next reply on, saved like
|
|
1258
|
+
// the language picker's choice, and shown by each surface.
|
|
1259
|
+
async setReplyLanguage(code, runtime = {}) {
|
|
1260
|
+
const locale = normalizeLocale(code);
|
|
1261
|
+
if (!locale) throw Object.assign(new Error("Unsupported reply language."), { code: "unsupported_reply_language" });
|
|
1262
|
+
const previous = this.locale;
|
|
1263
|
+
this.locale = locale;
|
|
1264
|
+
this.conversationLocale = locale;
|
|
1265
|
+
// Asked for in words, the language holds in this conversation whatever language the listener writes in.
|
|
1266
|
+
this.replyLanguagePin = locale;
|
|
1267
|
+
await this.languageSelection?.change(locale);
|
|
1268
|
+
return { ...await this.settingsStatus(runtime), changed: { setting: "reply_language", from: previous, to: locale } };
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// A model the listener asked for in words, from their model menu: by provider/model, model ID or
|
|
1272
|
+
// name. It takes over when this reply ends; a model not set up, or not signed in, is refused.
|
|
1273
|
+
async requestModel(wanted, runtime = {}) {
|
|
1274
|
+
if (!this.modelSwitch) throw Object.assign(new Error("This host cannot change the model from a conversation."), { code: "model_change_unavailable" });
|
|
1275
|
+
const choices = await this.modelSwitch.choices();
|
|
1276
|
+
const key = String(wanted ?? "").trim().toLowerCase();
|
|
1277
|
+
const folded = (value) => String(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
1278
|
+
const match = choices.find((choice) => choice.model.toLowerCase() === key) ??
|
|
1279
|
+
choices.find((choice) => choice.model.split("/").slice(1).join("/").toLowerCase() === key) ??
|
|
1280
|
+
choices.find((choice) => folded(choice.name) === folded(key));
|
|
1281
|
+
if (!match) throw Object.assign(new Error(`That model is not in the listener's model menu. Choose one of: ${choices.map((choice) => choice.model).join(", ") || "none"}. Others are added in Settings › Models or with /model.`), { code: "model_not_listed" });
|
|
1282
|
+
if (!match.ready) throw Object.assign(new Error(`${match.model} is in the menu but not signed in. The listener signs in from Settings › Models or with /auth.`), { code: "model_not_signed_in" });
|
|
1283
|
+
const [provider, ...rest] = match.model.split("/");
|
|
1284
|
+
this.modelSwitch.request(this, { provider, model: rest.join("/") });
|
|
1285
|
+
return { ...await this.settingsStatus(runtime), changed: { setting: "model", from: `${runtime.provider}/${runtime.model}`, to: match.model, takes_effect: "after this reply" } };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
849
1288
|
// A host-only in-memory change, kept for hosts with no saved settings.
|
|
850
1289
|
setPlaybackService(service) {
|
|
851
1290
|
return this.playbackSelection.set(service);
|
|
@@ -966,6 +1405,13 @@ export class MoondogApplication {
|
|
|
966
1405
|
return this.domainServices?.listeningCoverage?.() ?? null;
|
|
967
1406
|
}
|
|
968
1407
|
|
|
1408
|
+
// The listener's own plays a day at a time, with the most played artists' and Moondog's own kept
|
|
1409
|
+
// apart, for the window's taste record. Host-only; never a model tool.
|
|
1410
|
+
listeningDays() {
|
|
1411
|
+
if (!this.profileServicesReady()) return null;
|
|
1412
|
+
return this.domainServices?.listeningDays?.() ?? null;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
969
1415
|
// What the listener has played lately, apart from the saved reading: stored plays from
|
|
970
1416
|
// the last four weeks, and Spotify's own four-week top artists when the room has read them.
|
|
971
1417
|
recentAttention() {
|
|
@@ -1981,6 +2427,43 @@ export class MoondogApplication {
|
|
|
1981
2427
|
truncated: result.truncated === true || raw.length > 10 };
|
|
1982
2428
|
}
|
|
1983
2429
|
|
|
2430
|
+
// Songs the listener named, by title and artist: the host searches Spotify for each one and
|
|
2431
|
+
// keeps the result whose artist and title match, so the model passes no references and makes
|
|
2432
|
+
// no second call. A song without a clear match is never guessed; its closest results come back
|
|
2433
|
+
// with item_ref_id values, to choose from and play by reference.
|
|
2434
|
+
async spotifyResolveSongs(songs, { signal, otherSources = [] } = {}) {
|
|
2435
|
+
// Songs and a reference together are two choices of what to play; neither wins silently.
|
|
2436
|
+
if (otherSources.length) throw spotifyResolutionError("spotify_playback_source_conflict", "Choose one playback source for resume: songs, or one reference. Nothing was played.");
|
|
2437
|
+
if (!Array.isArray(songs) || songs.length < 1 || songs.length > 12 ||
|
|
2438
|
+
songs.some((song) => typeof song?.title !== "string" || !song.title.trim() || typeof song?.artist !== "string" || !song.artist.trim())) {
|
|
2439
|
+
throw spotifyResolutionError("invalid_spotify_songs", "Give from 1 to 12 songs, each with a title and an artist.");
|
|
2440
|
+
}
|
|
2441
|
+
const fold = (value) => foldedWords(normalizedTrackTitle(value));
|
|
2442
|
+
const matched = [];
|
|
2443
|
+
const unmatched = [];
|
|
2444
|
+
for (const song of songs) {
|
|
2445
|
+
const result = await this.spotifySearchTracks({ query: `${song.title} ${song.artist}`, type: "track", limit: 10 }, { signal });
|
|
2446
|
+
const tracks = result.items.filter((item) => item.type === "track" && item.track_ref_id);
|
|
2447
|
+
const wantedTitle = foldedWords(song.title);
|
|
2448
|
+
const wantedCore = fold(song.title);
|
|
2449
|
+
// The same artist: equal names, or one credited artist of a joint credit ("伍佰" in
|
|
2450
|
+
// "伍佰 & China Blue"). A name inside another act's name ("Adele Tribute Band") is another act.
|
|
2451
|
+
const credited = (name) => [name, ...String(name).split(ARTIST_CREDIT_SEPARATORS)].map(foldedWords).filter(Boolean);
|
|
2452
|
+
const wantedArtists = new Set(credited(song.artist));
|
|
2453
|
+
const byArtist = tracks.filter((item) => item.artists.some((artist) => credited(artist).some((name) => wantedArtists.has(name))));
|
|
2454
|
+
// The exact title first. A version the listener named (a live take, a duet) must match as
|
|
2455
|
+
// named; otherwise the same song in another version (a remaster) is the song asked for.
|
|
2456
|
+
// Nothing looser is guessed: a shared prefix ("One", "One Tree Hill") is another song.
|
|
2457
|
+
const versionNamed = wantedTitle !== wantedCore;
|
|
2458
|
+
const pick = byArtist.find((item) => foldedWords(item.name) === wantedTitle) ??
|
|
2459
|
+
(versionNamed ? undefined : byArtist.find((item) => fold(item.name) === wantedCore));
|
|
2460
|
+
if (pick) matched.push(pick);
|
|
2461
|
+
else unmatched.push({ title: song.title, artist: song.artist,
|
|
2462
|
+
candidates: tracks.slice(0, 5).map(({ item_ref_id, name, artists, album }) => ({ item_ref_id, name, artists, ...(album ? { album } : {}) })) });
|
|
2463
|
+
}
|
|
2464
|
+
return { matched, unmatched };
|
|
2465
|
+
}
|
|
2466
|
+
|
|
1984
2467
|
spotifyDiscoveryAllowed(item) {
|
|
1985
2468
|
return this.avoidAllows({ title: item.name, artist_credit: item.artists.join(", "), artists: item.artists });
|
|
1986
2469
|
}
|
|
@@ -2093,8 +2576,6 @@ export class MoondogApplication {
|
|
|
2093
2576
|
this.requireSpotifyNonRemovalAction();
|
|
2094
2577
|
const transaction = this.pendingPlaylistPromptTransaction;
|
|
2095
2578
|
if (!transaction) throw spotifyResolutionError("spotify_queue_request_required", "Queueing runs inside a listener request.");
|
|
2096
|
-
// An idle device starts the first song.
|
|
2097
|
-
await this.quietOtherPlayers("spotify", { signal });
|
|
2098
2579
|
// The model acts on the listener's words, as for a single queue entry. A parsed
|
|
2099
2580
|
// count only sizes the batch; failing to parse a phrasing never blocks it.
|
|
2100
2581
|
const intent = transaction.queueIntent ?? null;
|
|
@@ -2106,7 +2587,18 @@ export class MoondogApplication {
|
|
|
2106
2587
|
// named in their language; otherwise a sensible batch from the candidates.
|
|
2107
2588
|
const requested = intent?.requested ?? count ?? Math.min(itemRefs.length, 12);
|
|
2108
2589
|
if (!Number.isInteger(requested) || requested < 1 || requested > 12) throw spotifyResolutionError("spotify_queue_count_limit", "A queue request supports 1 to 12 songs. No write was sent.");
|
|
2109
|
-
|
|
2590
|
+
// A reference the host never issued (a model's slip) is left out and counted; only a batch
|
|
2591
|
+
// with no usable reference at all is refused.
|
|
2592
|
+
const chosen = itemRefs.map((ref) => { try { return this.requireSpotifyReadItem(ref, ["track"]); } catch { return null; } }).filter(Boolean);
|
|
2593
|
+
const skippedUnavailable = itemRefs.length - chosen.length;
|
|
2594
|
+
if (!chosen.length) this.requireSpotifyReadItem(itemRefs[0], ["track"]);
|
|
2595
|
+
// The rest of this request's verified discovery results by the artists the model picked stand
|
|
2596
|
+
// behind its picks, in the order found, so a pick skipped as a duplicate or an Avoid still
|
|
2597
|
+
// leaves the count asked for. A song by another artist may have been left out on purpose.
|
|
2598
|
+
const pickedArtists = new Set(chosen.flatMap(item => item.artists));
|
|
2599
|
+
const backups = transaction.discoveryPoolStarted ? (this.spotifyReadSelections.get("discovery") ?? [])
|
|
2600
|
+
.filter(item => item.type === "track" && item.uri && !itemRefs.includes(item.item_ref_id) && item.artists.some(artist => pickedArtists.has(artist))) : [];
|
|
2601
|
+
const items = [...chosen, ...backups].slice(0, 36);
|
|
2110
2602
|
this.requireSpotifyWriteScopes(["user-modify-playback-state"]);
|
|
2111
2603
|
this.requireSpotifyScopes(["user-read-playback-state"]);
|
|
2112
2604
|
transaction.queueBatchAttempted = true;
|
|
@@ -2131,9 +2623,12 @@ export class MoondogApplication {
|
|
|
2131
2623
|
knownTrack: intent?.excludeKnown ? track => this.domainServices?.isKnownDiscoveryTrack?.(track) === true : null });
|
|
2132
2624
|
if (!receipt.queued.length && !receipt.stopped) transaction.queueBatchAttempted = false;
|
|
2133
2625
|
const result = { ...receipt, action: "queue.batch", requested, queued_count: receipt.queued.length,
|
|
2134
|
-
shortfall: requested - receipt.queued.length, queue_observation_truncated: observed.truncated === true
|
|
2626
|
+
shortfall: requested - receipt.queued.length, queue_observation_truncated: observed.truncated === true,
|
|
2627
|
+
...(skippedUnavailable ? { skipped_unavailable_count: skippedUnavailable } : {}) };
|
|
2135
2628
|
this.spotifyDisplayedChoices = null;
|
|
2136
2629
|
if (this.spotifyQueueRequest && (receipt.queued.length || receipt.stopped)) Object.assign(this.spotifyQueueRequest, { attempted: true, receipt: structuredClone(result) });
|
|
2630
|
+
// Kept on the request itself, so a fill finds the batch even when no queue intent was parsed.
|
|
2631
|
+
if (receipt.queued.length) transaction.batchReceipt = structuredClone(result);
|
|
2137
2632
|
return result;
|
|
2138
2633
|
}
|
|
2139
2634
|
|
|
@@ -2146,6 +2641,10 @@ export class MoondogApplication {
|
|
|
2146
2641
|
const rest = new Set();
|
|
2147
2642
|
while (album && queue.at(-1)?.album_uri === album && !rest.has(queue.at(-1).uri)) rest.add(queue.pop().uri);
|
|
2148
2643
|
const seen = new Set([observed.currently_playing?.uri, ...queue.map(track => track.uri)].filter(Boolean));
|
|
2644
|
+
// With nothing playing and nothing queued, Spotify has dropped what Moondog queued (its device
|
|
2645
|
+
// went idle), so those songs were not heard: a new request may queue them again.
|
|
2646
|
+
// A player that could not be read says nothing, so the songs stay held back.
|
|
2647
|
+
if (!seen.size && player && player.is_playing !== true && !player.item) this.recentSimilarQueueUris.clear();
|
|
2149
2648
|
for (const [uri, acceptedAt] of this.recentSimilarQueueUris) {
|
|
2150
2649
|
if (Date.now() - acceptedAt > 15 * 60_000) this.recentSimilarQueueUris.delete(uri);
|
|
2151
2650
|
else seen.add(uri);
|
|
@@ -2253,7 +2752,24 @@ export class MoondogApplication {
|
|
|
2253
2752
|
|
|
2254
2753
|
async spotifyAddToQueue(input, { signal } = {}) {
|
|
2255
2754
|
signal?.throwIfAborted();
|
|
2256
|
-
|
|
2755
|
+
// A batch that came up short may be filled by single adds of songs it did not queue, one per
|
|
2756
|
+
// missing song; anything else after a batch is refused as before.
|
|
2757
|
+
const transaction = this.pendingPlaylistPromptTransaction;
|
|
2758
|
+
const batch = transaction?.queueBatchAttempted && transaction.batchReceipt?.action === "queue.batch" ? transaction.batchReceipt : null;
|
|
2759
|
+
// The retained receipt takes in each accepted fill, so its queued songs are the count so far.
|
|
2760
|
+
const missing = batch ? (batch.requested ?? 0) - (batch.queued?.length ?? 0) : 0;
|
|
2761
|
+
// Either supported reference names the song: a read's item_ref_id, or a retained or resolved track_ref_id.
|
|
2762
|
+
const fillRef = input?.itemRefId ?? input?.trackRefId;
|
|
2763
|
+
const resolved = batch && input?.trackRefId !== undefined ? this.spotifyResolutions.get(input.trackRefId) : null;
|
|
2764
|
+
const candidate = !batch || fillRef === undefined ? null
|
|
2765
|
+
: this.spotifyHostReadItems().find((entry) => entry.item_ref_id === fillRef || entry.track_ref_id === fillRef) ??
|
|
2766
|
+
(resolved?.uri ? { type: "track", uri: resolved.uri, name: resolved.matched?.title ?? "", artists: resolved.matched?.artists ?? [] } : null);
|
|
2767
|
+
// The same song is the same title by the same primary artist, as the batch itself decides.
|
|
2768
|
+
const candidateSongs = candidate ? songKeys({ title: candidate.name, artist_credit: candidate.artists?.join(", ") ?? "", artists: candidate.artists ?? [] }) : [];
|
|
2769
|
+
const fillsBatch = missing > 0 && candidate?.type === "track" &&
|
|
2770
|
+
!batch.queued.some((queued) => songKeys(queued).some((key) => candidateSongs.includes(key))) &&
|
|
2771
|
+
!(transaction.batchFillUris ?? []).includes(candidate.uri);
|
|
2772
|
+
this.requireSpotifyNonRemovalAction({ batchFill: fillsBatch });
|
|
2257
2773
|
let uri;
|
|
2258
2774
|
if (input?.itemRefId !== undefined) {
|
|
2259
2775
|
if (input.trackRefId !== undefined || input.uri !== undefined) throw spotifyResolutionError("spotify_queue_selection_conflict", "Choose one queue item.");
|
|
@@ -2266,11 +2782,37 @@ export class MoondogApplication {
|
|
|
2266
2782
|
const item = this.spotifyHostReadItems().find(item => item.uri === uri);
|
|
2267
2783
|
if (item && !this.#spotifyItemAllowed(item)) throw listenerAvoidError();
|
|
2268
2784
|
const track = { track_ref_id: "single", title: item?.name ?? "the selected track", artist_credit: item?.artists?.join(", ") ?? "", artists: item?.artists ?? [], ...(item?.isrc ? { isrc: item.isrc } : {}) };
|
|
2785
|
+
// A fill keeps the batch's own rules: nothing already in Spotify's queue or playing, and only
|
|
2786
|
+
// unheard songs when the listener asked for those.
|
|
2787
|
+
let fillRules = {};
|
|
2788
|
+
if (fillsBatch) {
|
|
2789
|
+
const service = this.requireSpotifyService();
|
|
2790
|
+
const [observed, player] = await Promise.all([service.queue({ signal }), service.currentPlayer({ signal }).catch(() => null)]);
|
|
2791
|
+
signal?.throwIfAborted();
|
|
2792
|
+
const seenUris = this.#observedQueueUris(observed, player);
|
|
2793
|
+
if (player?.item?.uri) seenUris.add(player.item.uri);
|
|
2794
|
+
fillRules = { seenUris, ...(transaction.queueIntent?.excludeKnown ? { knownTrack: (entry) => this.domainServices?.isKnownDiscoveryTrack?.(entry) === true } : {}) };
|
|
2795
|
+
}
|
|
2269
2796
|
// One entry takes the same path as a batch: one device decision, start
|
|
2270
2797
|
// playback when nothing is playing, and a check of Spotify's real queue.
|
|
2271
|
-
const receipt = await this.#queueSpotifyTracks([track], new Map([["single", uri]]), { signal, deviceId: input.deviceId, rethrow: true });
|
|
2798
|
+
const receipt = await this.#queueSpotifyTracks([track], new Map([["single", uri]]), { signal, deviceId: input.deviceId, rethrow: true, ...fillRules });
|
|
2799
|
+
if (fillsBatch && !receipt.queued.length) {
|
|
2800
|
+
throw spotifyResolutionError("spotify_queue_fill_skipped", receipt.skipped_known_count
|
|
2801
|
+
? "That song is one the listener already knows, and they asked for unheard songs only. Nothing was queued."
|
|
2802
|
+
: "That song is already in Spotify's queue or playing. Nothing was queued; choose another song for the missing place.");
|
|
2803
|
+
}
|
|
2272
2804
|
this.recentSimilarQueueUris.set(uri, Date.now());
|
|
2273
2805
|
while (this.recentSimilarQueueUris.size > 100) this.recentSimilarQueueUris.delete(this.recentSimilarQueueUris.keys().next().value);
|
|
2806
|
+
if (fillsBatch) {
|
|
2807
|
+
transaction.batchFillUris = [...(transaction.batchFillUris ?? []), uri];
|
|
2808
|
+
// The receipt a later "queue, not playlist" reply shows says what is queued now.
|
|
2809
|
+
for (const kept of [batch, this.spotifyQueueRequest?.receipt?.action === "queue.batch" ? this.spotifyQueueRequest.receipt : null]) {
|
|
2810
|
+
if (!kept) continue;
|
|
2811
|
+
kept.queued = [...kept.queued, ...receipt.queued];
|
|
2812
|
+
kept.queued_count = kept.queued.length;
|
|
2813
|
+
kept.shortfall = Math.max(0, (kept.requested ?? 0) - kept.queued.length);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2274
2816
|
return { provider: "spotify", ok: true, effect: "write_external", action: "playback.queue.add", state: "accepted",
|
|
2275
2817
|
...(item?.name ? { targets: [{ title: item.name, artist_credit: item.artists.join(", ") }] } : {}),
|
|
2276
2818
|
...(receipt.device ? { device: receipt.device } : {}), ...(receipt.started_playback ? { started_playback: true } : {}),
|
|
@@ -2406,12 +2948,12 @@ export class MoondogApplication {
|
|
|
2406
2948
|
}
|
|
2407
2949
|
}
|
|
2408
2950
|
|
|
2409
|
-
requireSpotifyNonRemovalAction({ quickEdit = false } = {}) {
|
|
2951
|
+
requireSpotifyNonRemovalAction({ quickEdit = false, batchFill = false } = {}) {
|
|
2410
2952
|
const requested = this.pendingPlaylistPromptTransaction?.queueIntent?.requested;
|
|
2411
2953
|
if (requested != null && (!Number.isInteger(requested) || requested < 1 || requested > 12)) {
|
|
2412
2954
|
throw spotifyResolutionError("spotify_queue_count_limit", "Specify one exact queue count from 1 to 12 songs. No write was sent.");
|
|
2413
2955
|
}
|
|
2414
|
-
if (this.pendingPlaylistPromptTransaction?.queueBatchAttempted) throw spotifyResolutionError("spotify_queue_batch_already_attempted", "The requested queue batch was already attempted. Keep its receipt; do not replay or add another action.");
|
|
2956
|
+
if (this.pendingPlaylistPromptTransaction?.queueBatchAttempted && !batchFill) throw spotifyResolutionError("spotify_queue_batch_already_attempted", "The requested queue batch was already attempted. Keep its receipt; do not replay or add another action.");
|
|
2415
2957
|
if (this.pendingPlaylistPromptTransaction?.quickEditIntent && !quickEdit) {
|
|
2416
2958
|
throw spotifyResolutionError("spotify_quick_edit_action_conflict", "This request authorizes only its exact quick playlist edit. An earlier preview or another Spotify action needs its own explicit request.");
|
|
2417
2959
|
}
|
|
@@ -3289,8 +3831,11 @@ export class MoondogApplication {
|
|
|
3289
3831
|
const service = this.requireSpotifyService();
|
|
3290
3832
|
target ??= await service.queueTarget({ ...(deviceId ? { deviceId } : {}) }, { signal });
|
|
3291
3833
|
const device = target.deviceId ? { deviceId: target.deviceId } : {};
|
|
3292
|
-
// Nothing is playing, so a queue entry has nowhere to go: start this song instead.
|
|
3834
|
+
// Nothing is playing, so a queue entry has nowhere to go: start this song instead. That starts
|
|
3835
|
+
// music, so the other players go quiet first; a queue behind a song already there starts none.
|
|
3293
3836
|
const start = async () => {
|
|
3837
|
+
await this.quietOtherPlayers("spotify", { signal });
|
|
3838
|
+
signal?.throwIfAborted();
|
|
3294
3839
|
dispatching = true;
|
|
3295
3840
|
const receipt = await service.resume({ uris: [uri], ...device }, { signal });
|
|
3296
3841
|
started = label(track);
|
|
@@ -3520,7 +4065,8 @@ export class MoondogApplication {
|
|
|
3520
4065
|
musicPreviewReady: this.musicPreviewReady(),
|
|
3521
4066
|
tasteChoicesReady: this.tasteChoicesReady(),
|
|
3522
4067
|
localGenerationReady: this.localGenerationReady(),
|
|
3523
|
-
}).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id)
|
|
4068
|
+
}).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id) &&
|
|
4069
|
+
(this.libraryReady() || !descriptor.capability_id.startsWith("collection.")));
|
|
3524
4070
|
}
|
|
3525
4071
|
|
|
3526
4072
|
async findArtistReleases(input) {
|
|
@@ -3911,7 +4457,7 @@ export class MoondogApplication {
|
|
|
3911
4457
|
this.pendingPlaylistPromptTransaction = null;
|
|
3912
4458
|
} finally {
|
|
3913
4459
|
const owned = this.ownsServices
|
|
3914
|
-
? [this.memoryStore, this.domainServices, this.musicCatalog, this.musicSimilarity, this.musicPreviewPlayer, this.webResearch]
|
|
4460
|
+
? [this.memoryStore, this.libraryStore, this.domainServices, this.musicCatalog, this.musicSimilarity, this.musicPreviewPlayer, this.webResearch]
|
|
3915
4461
|
: [this.domainServices];
|
|
3916
4462
|
for (const service of owned) service?.close?.();
|
|
3917
4463
|
}
|