@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.10

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.
Files changed (46) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/scripts/moondog.mjs +138 -10
  4. package/src/core/capability-catalog.mjs +49 -0
  5. package/src/core/library-store.mjs +500 -0
  6. package/src/core/listening-now.mjs +80 -6
  7. package/src/core/moondog-application.mjs +570 -32
  8. package/src/i18n/index.mjs +6 -3
  9. package/src/i18n/messages/de.mjs +1 -0
  10. package/src/i18n/messages/en.mjs +2 -0
  11. package/src/i18n/messages/es.mjs +1 -0
  12. package/src/i18n/messages/fr.mjs +1 -0
  13. package/src/i18n/messages/it.mjs +1 -0
  14. package/src/i18n/messages/ja.mjs +1 -0
  15. package/src/i18n/messages/ko.mjs +1 -0
  16. package/src/i18n/messages/pt.mjs +1 -0
  17. package/src/i18n/messages/zh-Hant.mjs +1 -0
  18. package/src/i18n/messages/zh.mjs +1 -0
  19. package/src/i18n/preferences.mjs +24 -0
  20. package/src/i18n/screen/es.mjs +92 -0
  21. package/src/i18n/screen/ja.mjs +92 -0
  22. package/src/i18n/screen/pt.mjs +92 -0
  23. package/src/i18n/screen/zh.mjs +92 -0
  24. package/src/integrations/apple-music/music-app.mjs +9 -3
  25. package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
  26. package/src/integrations/netease/ncm-cli-player.mjs +12 -6
  27. package/src/integrations/playlist-links.mjs +96 -0
  28. package/src/integrations/public-playlists.mjs +10 -4
  29. package/src/integrations/spotify/service.mjs +22 -1
  30. package/src/integrations/stable-audio/cover.mjs +324 -0
  31. package/src/integrations/stable-audio/local-generator.mjs +259 -87
  32. package/src/integrations/stable-audio/setup.mjs +653 -0
  33. package/src/integrations/stable-audio/sources.mjs +106 -0
  34. package/src/profile/listening-history-store.mjs +1 -1
  35. package/src/profile/listening-profile-projection.mjs +1 -1
  36. package/src/profile/music-import-bundle.mjs +3 -1
  37. package/src/profile/music-providers.mjs +3 -0
  38. package/src/profile/taste-compiler.mjs +14 -5
  39. package/src/runtime/pi/agent-runtime.mjs +324 -31
  40. package/src/runtime/pi/model-switch.mjs +26 -0
  41. package/src/runtime/pi/model-transport.mjs +34 -4
  42. package/src/runtime/pi/record-name.mjs +80 -0
  43. package/src/runtime/pi/reply-language.mjs +45 -0
  44. package/src/runtime/pi/tool-arguments.mjs +89 -0
  45. package/src/surfaces/cli/command-completions.mjs +15 -1
  46. package/src/surfaces/cli/tui.mjs +404 -18
@@ -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,298 @@ 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 } }; }) };
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((track) => ({ ...track, ...(playlist.provider === "netease" ? { refs: { netease_id: String(track.id) } } : {}) })) };
752
+ }
753
+ signal?.throwIfAborted();
754
+ // A library playlist holds up to 2,000 songs; a longer one comes in from its start, and total says how long it is.
755
+ found.tracks = found.tracks.filter((track) => track.title && track.artist).slice(0, 2000);
756
+ if (found.total === null || found.total === undefined) found.total = null;
757
+ if (!found.tracks.length) throw Object.assign(new Error("No songs could be read from this playlist."), { code: "playlist_empty" });
758
+ const token = randomUUID();
759
+ this.pendingPlaylistImports.set(token, found);
760
+ while (this.pendingPlaylistImports.size > 5) this.pendingPlaylistImports.delete(this.pendingPlaylistImports.keys().next().value);
761
+ return { token, provider: found.provider, name: found.name, read: found.tracks.length, total: found.total ?? null, owned: found.owned,
762
+ sample: found.tracks.slice(0, 3).map((track) => `${track.title} - ${track.artist}`) };
763
+ }
764
+
765
+ /**
766
+ * Imports a previewed playlist: a copy in the library that remembers its link, and, when taste is true, its songs
767
+ * as evidence of what the listener keeps. A playlist the listener didn't make shapes their taste only when they say so.
768
+ */
769
+ async importPlaylistLink({ token, taste, name, folderId } = {}) {
770
+ const library = this.requireLibrary();
771
+ const found = this.pendingPlaylistImports.get(String(token ?? ""));
772
+ if (!found) throw Object.assign(new Error("That preview is gone. Paste the link again."), { code: "playlist_preview_gone" });
773
+ const shapes = typeof taste === "boolean" ? taste : found.owned === true;
774
+ const made = library.savePlaylist({ name: typeof name === "string" && name.trim() ? name : found.name, folderId: folderId ?? null,
775
+ origin: found.provider, source: { provider: found.provider, id: found.id, url: found.url },
776
+ entries: found.tracks.map((track) => ({ kind: "song", title: track.title, artist_credit: track.artist,
777
+ ...(track.album ? { album: track.album } : {}), ...(track.isrc ? { isrc: track.isrc } : {}), refs: track.refs ?? {} })) });
778
+ this.pendingPlaylistImports.delete(String(token));
779
+ let tasteState = "not_added";
780
+ if (shapes) {
781
+ if (typeof this.playlistTaste !== "function") tasteState = "unavailable";
782
+ else tasteState = await this.playlistTaste(found).then(() => "added", () => "failed");
783
+ }
784
+ return { playlist: { id: made.id, name: library.playlist(made.id).name }, added: made.added, read: found.tracks.length,
785
+ total: found.total ?? null, owned: found.owned, taste: tasteState };
786
+ }
787
+
788
+ /** Reads and imports a playlist link in one step, as the agent and the terminal do. */
789
+ async importPlaylistFromLink({ link, taste, name, folderId } = {}, { signal } = {}) {
790
+ const preview = await this.previewPlaylistLink({ link }, { signal });
791
+ return { ...(await this.importPlaylistLink({ token: preview.token, taste, name, folderId })), provider: preview.provider };
792
+ }
793
+
794
+ /** Playlists by name for the agent, or the newest ones when no words are given. */
795
+ findLibraryPlaylists(query) {
796
+ return this.requireLibrary().findPlaylists(query).map(({ id, name, count, made, origin, saved }) => ({
797
+ id, name: saved ? "Saved" : name, count, made, origin, ...(saved ? { saved: true } : {}) }));
798
+ }
799
+
800
+ /**
801
+ * Plays a playlist from the library, from its first entry or from one entry. It plays on the listener's
802
+ * chosen service: Moondog's own player holds Apple Music songs and made tracks in one queue; Spotify and
803
+ * NetEase play its songs, and its made tracks wait for Moondog's own player (left_out_made says how many).
804
+ * The listener's own playlist is their pick, so nothing of it goes in the suggestions ledger.
805
+ */
806
+ async playLibrary({ playlistId, entryId, shuffle = false } = {}, { signal } = {}) {
807
+ const playlist = await this.libraryPlaylist(playlistId);
808
+ if (!playlist) throw Object.assign(new Error("That playlist is not in the library."), { code: "library_playlist_not_found" });
809
+ const named = { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name };
810
+ let entries = playlist.entries;
811
+ const start = entryId ? entries.findIndex((entry) => entry.id === entryId) : 0;
812
+ entries = entries.slice(Math.max(0, start));
813
+ if (shuffle) entries = entries.map((entry) => [Math.random(), entry]).sort((a, b) => a[0] - b[0]).map(([, entry]) => entry);
814
+ const avoided = entries.filter((entry) => entry.kind === "song" && !this.avoidAllows({ title: entry.title, artist_credit: entry.artist_credit }));
815
+ entries = entries.filter((entry) => !avoided.includes(entry) && (entry.kind === "song" || entry.audio_kept));
816
+ const extra = avoided.length ? { skipped_avoided_count: avoided.length } : {};
817
+ if (!entries.length) return { state: "empty", playlist: named, ...extra };
818
+ const songs = entries.filter((entry) => entry.kind === "song");
819
+ const service = this.playbackService;
820
+ // Only made tracks, or Apple Music chosen: Moondog's own player, with both in one queue.
821
+ if ((!songs.length || service === "apple_music") && typeof this.musicPreviewPlayer?.playList === "function") {
822
+ const refs = [];
823
+ const avoidedFound = [];
824
+ const found = await mapLimited(entries.slice(0, 50), 6, async (entry) => {
825
+ if (entry.kind === "made") return { generated: true, id: entry.made_id, title: entry.title, file: await this.localGenerator.trackFile(entry.made_id) };
826
+ if (!this.musicCatalog) return null;
827
+ const result = await this.musicCatalog.findPreview({ title: entry.title, artist: entry.artist_credit }).catch(() => null);
828
+ if (result?.state !== "resolved") return null;
829
+ // The catalog's recording can credit more than the entry named (a feature, a longer title); an Avoid still holds.
830
+ if (!this.avoidAllows(result.track)) { avoidedFound.push(entry.id); return null; }
831
+ if (result.track.catalog_id) refs.push({ entryId: entry.id, refs: { apple_music_id: String(result.track.catalog_id),
832
+ ...(entry.refs?.artwork_url || !result.track.artwork_url ? {} : { artwork_url: result.track.artwork_url }) } });
833
+ return result.track;
834
+ });
835
+ signal?.throwIfAborted();
836
+ const tracks = found.filter(Boolean);
837
+ const notFound = found.length - tracks.length - avoidedFound.length;
838
+ const skipped = avoided.length + avoidedFound.length;
839
+ Object.assign(extra, skipped ? { skipped_avoided_count: skipped } : {});
840
+ if (!tracks.length) return { state: "not_found", playlist: named, not_found: notFound, ...extra };
841
+ this.libraryStore.rememberRefs(refs);
842
+ // A playlist replaces Moondog's stream and quiets the other players, as any music Moondog starts does.
843
+ await this.#endStream("replaced");
844
+ await this.quietOtherPlayers("apple_music", { signal });
845
+ signal?.throwIfAborted();
846
+ this.musicPreviewPlayer.playList(tracks);
847
+ return { state: "playing", player: "moondog", playlist: named, playing: tracks.length, not_found: notFound,
848
+ whole_songs: this.musicPreviewPlayer.wholeSongs?.() === true, ...extra };
849
+ }
850
+ const leftOut = entries.length - songs.length;
851
+ if (service === "spotify") {
852
+ if (!this.spotifyPlaybackReady()) throw Object.assign(new Error("Spotify is not ready to play here."), { code: "spotify_not_ready" });
853
+ const list = songs.slice(0, 100);
854
+ const refs = [];
855
+ const avoidedFound = [];
856
+ // A recording's own title and credit can name more than the entry does (a feature); an Avoid holds against it.
857
+ const allowed = (title, credit) => this.avoidAllows({ title, artist_credit: credit });
858
+ const uris = await mapLimited(list, 4, async (entry) => {
859
+ // A cached recording plays without a search, once its own credit passes today's Avoids.
860
+ if (entry.refs?.spotify_uri && entry.refs.spotify_credit) {
861
+ if (allowed(entry.refs.spotify_title ?? entry.title, entry.refs.spotify_credit)) return entry.refs.spotify_uri;
862
+ avoidedFound.push(entry.id);
863
+ return null;
864
+ }
865
+ const { matched } = await this.spotifyResolveSongs([{ title: entry.title, artist: entry.artist_credit }], { signal }).catch(() => ({ matched: [] }));
866
+ // A match reaches the model without its URI; the host keeps it under the match's reference.
867
+ const ref = matched[0]?.item_ref_id;
868
+ const known = ref ? this.spotifyHostReadItems().find((item) => item.item_ref_id === ref) : null;
869
+ const uri = typeof known?.uri === "string" ? known.uri : typeof matched[0]?.uri === "string" ? matched[0].uri : null;
870
+ if (!uri) return null;
871
+ const title = known?.name ?? matched[0]?.name ?? entry.title;
872
+ const credit = (known?.artists ?? matched[0]?.artists ?? []).join(", ") || entry.artist_credit;
873
+ refs.push({ entryId: entry.id, refs: { spotify_uri: uri, spotify_title: title, spotify_credit: credit } });
874
+ if (!allowed(title, credit)) { avoidedFound.push(entry.id); return null; }
875
+ return uri;
876
+ });
877
+ signal?.throwIfAborted();
878
+ this.libraryStore.rememberRefs(refs);
879
+ const playable = uris.filter(Boolean);
880
+ const skipped = avoided.length + avoidedFound.length;
881
+ Object.assign(extra, skipped ? { skipped_avoided_count: skipped } : {});
882
+ const unfound = list.length - playable.length - avoidedFound.length;
883
+ if (!playable.length) return { state: "not_found", playlist: named, not_found: unfound, left_out_made: leftOut, ...extra };
884
+ await this.quietOtherPlayers("spotify", { signal });
885
+ const receipt = await this.#resumeSpotifySelection({ uris: playable }, { signal, played: [] });
886
+ return { state: receipt.state === "accepted" ? "playing" : receipt.state, player: "spotify", playlist: named, playing: playable.length,
887
+ not_found: unfound, left_out_made: leftOut, ...extra };
888
+ }
889
+ if (service === "netease" && this.neteaseReady()) {
890
+ const player = this.requireNetease();
891
+ await this.quietOtherPlayers("netease", { signal });
892
+ const receipt = await player.play({ items: songs.slice(0, 20).map((entry) => ({ title: entry.title, artist: entry.artist_credit })), mode: "play",
893
+ intent: "播放听者自己的歌单", allow: (song) => this.avoidAllows(Array.isArray(song.artists)
894
+ ? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
895
+ return { state: receipt?.mode === "playing" ? "playing" : "not_found", player: "netease", playlist: named,
896
+ playing: receipt?.songs?.length ?? 0, left_out_made: leftOut, ...extra };
897
+ }
898
+ throw Object.assign(new Error("No player here can play this playlist."), { code: "library_no_player" });
899
+ }
900
+
582
901
  // Makes one instrumental track from the listener's taste and plays it in Moondog's own player.
583
902
  // The agent proposes sound descriptors, each with its source; the taste compiler keeps only the
584
903
  // ones it can trace and none that names an artist or a song. The track and its receipt stay on
585
904
  // this Mac, and its play goes in the ledger as Moondog's own, never as the listener's attention.
586
905
  // 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 } = {}) {
906
+ async makeTrack({ descriptors, bpm, bpmSource, seconds, title } = {}, { moment = null, request = null, transient = false, signal, onProgress, nameRecord } = {}) {
588
907
  const { compiled, receipt } = await this.#compileForGeneration({ descriptors, bpm, bpmSource, title }, { moment, request, transient });
589
908
  if (!compiled.usable) {
590
909
  return { state: "not_made", reason: compiled.descriptors.length ? "needs_a_sound" : "no_traceable_descriptors", dropped: compiled.dropped };
591
910
  }
592
911
  const prompt = stableAudioPrompt(compiled);
593
- await this.#endStream("replaced");
594
- const made = await this.localGenerator.generate({ prompt, seconds, title: compiled.title, receipt }, { signal, onProgress });
912
+ const endedStream = await this.#endStream("replaced");
913
+ const named = await this.#titleInSettingLanguage(compiled, nameRecord, signal);
914
+ const made = await this.localGenerator.generate({ prompt, seconds, title: named, receipt }, { signal, onProgress });
595
915
  // A reply stopped as the track finished keeps the track, but plays nothing over what is on now.
596
916
  await this.quietOtherPlayers("apple_music", { signal });
597
917
  // The terminal plays the file itself; the window asks for it by id and never sees the path.
598
918
  const file = await this.localGenerator.trackFile(made.id);
599
919
  // A reply stopped while the track was made, or while the other players paused, keeps the track but plays nothing.
600
920
  if (signal?.aborted) throw Object.assign(new Error("The track was made but not played, because the reply was stopped."), { code: "generation_stopped" });
921
+ // The replaced stream's track it cuts off was stopped by Moondog, not left by the listener.
922
+ if (endedStream) this.localGenerator.discard?.();
601
923
  this.musicPreviewPlayer.playGenerated({ id: made.id, title: made.title, seconds: made.duration_seconds, file });
602
924
  this.#noteGeneratedPlay(made);
603
925
  return { state: "playing", track: { id: made.id, title: made.title, seconds: made.duration_seconds, seed: made.seed },
@@ -632,26 +954,71 @@ export class MoondogApplication {
632
954
 
633
955
  // Starts Moondog's atmosphere stream from the listener's taste: its first track plays now, and
634
956
  // 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 } = {}) {
957
+ async startStream({ descriptors, bpm, bpmSource, seconds, minutes, title } = {}, { moment = null, request = null, transient = false, signal, onProgress, nameRecord } = {}) {
636
958
  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
959
  const { compiled, receipt } = await this.#compileForGeneration({ descriptors, bpm, bpmSource, title }, { moment, request, transient });
638
960
  if (!compiled.usable) {
639
961
  return { state: "not_made", reason: compiled.descriptors.length ? "needs_a_sound" : "no_traceable_descriptors", dropped: compiled.dropped };
640
962
  }
641
963
  await this.#endStream("replaced");
964
+ // The stream's name, which its first track carries, in the language Moondog is set to.
965
+ const named = await this.#titleInSettingLanguage(compiled, nameRecord, signal);
642
966
  // Other music pauses only once the first track exists, as for a single track.
643
- const { made, prompt } = await this.atmosphereStream.start({ compiled, title: compiled.title, seconds, minutes, receipt },
644
- { signal, onProgress, onTrack: (track) => this.#noteGeneratedPlay(track), beforePlay: () => this.quietOtherPlayers("apple_music", { signal }) });
967
+ let started;
968
+ try {
969
+ started = await this.atmosphereStream.start({ compiled, title: named, seconds, minutes, receipt },
970
+ { signal, onProgress, onTrack: (track) => this.#noteGeneratedPlay(track), beforePlay: () => this.quietOtherPlayers("apple_music", { signal }),
971
+ nameTrack: typeof nameRecord === "function" ? this.#streamNamer(nameRecord, compiled) : undefined });
972
+ } catch (error) {
973
+ // The listener turned it off while its first track was made: their choice, not a failure to retry.
974
+ if (error?.code === "generation_stopped" && error.ended === "asked" && !signal?.aborted) return { state: "not_made", reason: "stopped_by_listener" };
975
+ throw error;
976
+ }
977
+ const { made, prompt } = started;
645
978
  return { state: "streaming", minutes, track: { id: made.id, title: made.title, seconds: made.duration_seconds, seed: made.seed },
646
979
  prompt, shaped_by: compiled.descriptors, sources: tasteSources(compiled.descriptors), left_out: compiled.dropped,
647
980
  ...(compiled.bpm ? { bpm: compiled.bpm } : {}), elapsed_ms: made.elapsed_ms };
648
981
  }
649
982
 
983
+ // The agent writes a record's title itself, but can follow the conversation's language instead of
984
+ // the one Moondog is set to. A title in another language is named again in the setting's language,
985
+ // keeping its picture; if that cannot be done in time, the agent's title stays.
986
+ async #titleInSettingLanguage(compiled, nameRecord, signal) {
987
+ if (typeof nameRecord !== "function" || nameFitsLanguage(compiled.title, this.locale)) return compiled.title;
988
+ const terms = compiled.descriptors.map((descriptor) => descriptor.term);
989
+ const timeout = AbortSignal.timeout(TITLE_LANGUAGE_TIMEOUT_MS);
990
+ try {
991
+ const name = await Promise.race([
992
+ this.#streamNamer(nameRecord, compiled)({ draft: compiled.title, featured: terms.slice(0, 3), sound: terms.slice(3),
993
+ ...(compiled.bpm ? { bpm: compiled.bpm } : {}), earlier: [] }, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout }),
994
+ new Promise((resolve) => { timeout.addEventListener("abort", () => resolve(null), { once: true }); }),
995
+ ]);
996
+ return name ?? compiled.title;
997
+ } catch {
998
+ return compiled.title;
999
+ }
1000
+ }
1001
+
1002
+ // Names a stream's later tracks: the agent's namer asks the model, with the names of Moondog's recent
1003
+ // tracks so it does not repeat them, and a name passes the same check as the agent's own titles and
1004
+ // must be in the language Moondog is set to.
1005
+ #streamNamer(nameRecord, compiled) {
1006
+ return async (context, options) => {
1007
+ const recent = (this.localGenerator?.recentTracks?.(8) ?? []).map((track) => track.title).filter(Boolean);
1008
+ const name = acceptableRecordTitle(await nameRecord({ ...context, recent }, options), compiled.names);
1009
+ // A name in another language than Moondog's setting is not kept.
1010
+ return name && nameFitsLanguage(name, this.locale) ? name : null;
1011
+ };
1012
+ }
1013
+
650
1014
  // A single track or a new stream takes over from a running stream, once its next track stops being made.
1015
+ // The stream's track plays on until the new one starts, so nothing goes silent while that is made.
1016
+ // Says whether a stream ended.
651
1017
  async #endStream(reason) {
652
- if (!this.atmosphereStream?.active?.()) return;
1018
+ if (!this.atmosphereStream?.active?.()) return false;
653
1019
  this.atmosphereStream.stop(reason);
654
1020
  for (let waited = 0; this.localGenerator?.busy?.() && waited < 5_000; waited += 100) await delay(100);
1021
+ return true;
655
1022
  }
656
1023
 
657
1024
  stopStream() {
@@ -659,6 +1026,21 @@ export class MoondogApplication {
659
1026
  return stopped ? { state: "stopped", tracks_made: stopped.tracks_made } : { state: "not_running" };
660
1027
  }
661
1028
 
1029
+ // What the window and the terminal show of the stream: { phase, started_at, ends_at, tracks_made }, or null.
1030
+ streamView() {
1031
+ return this.atmosphereStream?.view?.() ?? null;
1032
+ }
1033
+
1034
+ // Each change of the stream, as { view, ended }; a host without a stream hears nothing.
1035
+ subscribeStream(listener) {
1036
+ return this.atmosphereStream?.subscribe?.(listener) ?? (() => {});
1037
+ }
1038
+
1039
+ // Whether a Spotify device is this Mac, where its music and Moondog's would play over each other.
1040
+ spotifyOnThisMac(device) {
1041
+ return spotifyOnThisMac(device);
1042
+ }
1043
+
662
1044
  // What the compiler may trace a descriptor to. Each cited finding is read from the saved reading.
663
1045
  async #tasteContext({ descriptors, moment, request }) {
664
1046
  const choices = this.domainServices?.listenerChoices?.()?.items ?? [];
@@ -708,10 +1090,13 @@ export class MoondogApplication {
708
1090
  async playNetease({ items, mode, intent } = {}) {
709
1091
  const player = this.requireNetease();
710
1092
  this.requireListenerAvoids();
711
- if (mode !== "queue") await this.quietOtherPlayers("netease");
1093
+ const quiet = () => this.quietOtherPlayers("netease");
1094
+ if (mode !== "queue") await quiet();
712
1095
  // A found song lists its artists; a song asked for by name has only the name it was asked by.
713
- const receipt = await player.play({ items, mode, intent, allow: (song) => this.avoidAllows(Array.isArray(song.artists)
714
- ? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
1096
+ // A queue with nothing loaded starts its first song, and quiets the others just before.
1097
+ const receipt = await player.play({ items, mode, intent, ...(mode === "queue" ? { beforePlay: quiet } : {}),
1098
+ allow: (song) => this.avoidAllows(Array.isArray(song.artists)
1099
+ ? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
715
1100
  if (["playing", "queued"].includes(receipt?.mode)) {
716
1101
  const songs = receipt.songs ?? [];
717
1102
  const slots = this.#mixSlots(songs.map((song) => ({ title: song.title, artists: song.artists })));
@@ -726,8 +1111,13 @@ export class MoondogApplication {
726
1111
 
727
1112
  async controlNetease({ action, positionSeconds } = {}) {
728
1113
  const player = this.requireNetease();
1114
+ const quiet = () => this.quietOtherPlayers("netease");
1115
+ if (action === "resume") await quiet();
729
1116
  if (action === "seek") await player.seek({ positionMs: Math.max(0, Number(positionSeconds) || 0) * 1000 });
730
- else if (["pause", "resume", "next", "previous", "stop"].includes(action)) await player[action]();
1117
+ // Next and previous start a song, so the other players go quiet just before; a refusal or a
1118
+ // previous that only rewinds starts none.
1119
+ else if (action === "next" || action === "previous") await player[action]({ beforePlay: quiet });
1120
+ else if (["pause", "resume", "stop"].includes(action)) await player[action]();
731
1121
  else throw Object.assign(new Error(`Unknown NetEase action: ${action}`), { code: "netease_failed" });
732
1122
  return await player.nowPlaying();
733
1123
  }
@@ -736,9 +1126,10 @@ export class MoondogApplication {
736
1126
  return this.requireNetease().nowPlaying();
737
1127
  }
738
1128
 
739
- // Whether any player the listener uses can be read: the Music app, Spotify or NetEase.
1129
+ // Whether any player the listener uses can be read: Moondog's own, the Music app, Spotify or NetEase.
740
1130
  listeningReadable() {
741
- return this.musicApp?.available === true || this.spotifyReady() || this.neteaseReady();
1131
+ return typeof this.musicPreviewPlayer?.nowPlaying === "function" || this.musicApp?.available === true ||
1132
+ this.spotifyReady() || this.neteaseReady();
742
1133
  }
743
1134
 
744
1135
  // What the listener plays right now on any of them, and what it plays from (src/core/listening-now.mjs).
@@ -829,12 +1220,13 @@ export class MoondogApplication {
829
1220
  ]);
830
1221
  }
831
1222
 
832
- // The service just left stops playing, so two services never play at once.
1223
+ // The service just left stops playing, so two services never play at once. Moondog's stream is its
1224
+ // own channel, not a service: its made tracks play on, and it ends only when a service song plays.
833
1225
  async #stopPlaybackService(service) {
834
1226
  try {
835
1227
  if (service === "apple_music") {
836
- this.atmosphereStream?.stop?.("service_change");
837
- this.musicPreviewPlayer?.stop?.();
1228
+ // Apple Music's songs in Moondog's player stop; a player that can't say what it holds stops, as before.
1229
+ if (this.musicPreviewPlayer?.holdsCatalogSongs?.() !== false) this.musicPreviewPlayer?.stop?.();
838
1230
  // The listener's own Music app too, when it plays.
839
1231
  if ((await this.musicApp?.nowPlaying?.())?.state === "playing") await this.musicApp.pause();
840
1232
  } else if (service === "netease" && this.neteasePlayer?.ready?.() === true) await this.neteasePlayer.stop();
@@ -846,6 +1238,52 @@ export class MoondogApplication {
846
1238
  return this.playbackSelection.subscribe(listener);
847
1239
  }
848
1240
 
1241
+ // What the agent can tell the listener about its own setup, and what it can change when asked.
1242
+ async settingsStatus(runtime = {}) {
1243
+ const playback = this.playbackStatus();
1244
+ const choices = await this.modelSwitch?.choices().catch(() => []) ?? [];
1245
+ return {
1246
+ model: { provider: runtime.provider ?? null, model: runtime.model ?? null, ...(runtime.effort ? { effort: runtime.effort } : {}),
1247
+ ...(this.modelSwitch ? { change: "moondog_settings model", choices } : { change: "not_by_agent" }),
1248
+ where: "Settings › Models in the window, or /model in the terminal" },
1249
+ music_service: { selected: playback.selected_service ?? null, effective: playback.effective_service ?? null,
1250
+ available: (playback.services ?? []).filter((entry) => entry.available).map((entry) => entry.service),
1251
+ change: "moondog_playback_service" },
1252
+ reply_language: { current: this.locale, choices: Object.keys(LOCALES), change: "moondog_settings reply_language" },
1253
+ };
1254
+ }
1255
+
1256
+ // The reply language the listener asked for in words: used from the next reply on, saved like
1257
+ // the language picker's choice, and shown by each surface.
1258
+ async setReplyLanguage(code, runtime = {}) {
1259
+ const locale = normalizeLocale(code);
1260
+ if (!locale) throw Object.assign(new Error("Unsupported reply language."), { code: "unsupported_reply_language" });
1261
+ const previous = this.locale;
1262
+ this.locale = locale;
1263
+ this.conversationLocale = locale;
1264
+ // Asked for in words, the language holds in this conversation whatever language the listener writes in.
1265
+ this.replyLanguagePin = locale;
1266
+ await this.languageSelection?.change(locale);
1267
+ return { ...await this.settingsStatus(runtime), changed: { setting: "reply_language", from: previous, to: locale } };
1268
+ }
1269
+
1270
+ // A model the listener asked for in words, from their model menu: by provider/model, model ID or
1271
+ // name. It takes over when this reply ends; a model not set up, or not signed in, is refused.
1272
+ async requestModel(wanted, runtime = {}) {
1273
+ if (!this.modelSwitch) throw Object.assign(new Error("This host cannot change the model from a conversation."), { code: "model_change_unavailable" });
1274
+ const choices = await this.modelSwitch.choices();
1275
+ const key = String(wanted ?? "").trim().toLowerCase();
1276
+ const folded = (value) => String(value).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
1277
+ const match = choices.find((choice) => choice.model.toLowerCase() === key) ??
1278
+ choices.find((choice) => choice.model.split("/").slice(1).join("/").toLowerCase() === key) ??
1279
+ choices.find((choice) => folded(choice.name) === folded(key));
1280
+ 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" });
1281
+ 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" });
1282
+ const [provider, ...rest] = match.model.split("/");
1283
+ this.modelSwitch.request(this, { provider, model: rest.join("/") });
1284
+ return { ...await this.settingsStatus(runtime), changed: { setting: "model", from: `${runtime.provider}/${runtime.model}`, to: match.model, takes_effect: "after this reply" } };
1285
+ }
1286
+
849
1287
  // A host-only in-memory change, kept for hosts with no saved settings.
850
1288
  setPlaybackService(service) {
851
1289
  return this.playbackSelection.set(service);
@@ -1981,6 +2419,43 @@ export class MoondogApplication {
1981
2419
  truncated: result.truncated === true || raw.length > 10 };
1982
2420
  }
1983
2421
 
2422
+ // Songs the listener named, by title and artist: the host searches Spotify for each one and
2423
+ // keeps the result whose artist and title match, so the model passes no references and makes
2424
+ // no second call. A song without a clear match is never guessed; its closest results come back
2425
+ // with item_ref_id values, to choose from and play by reference.
2426
+ async spotifyResolveSongs(songs, { signal, otherSources = [] } = {}) {
2427
+ // Songs and a reference together are two choices of what to play; neither wins silently.
2428
+ if (otherSources.length) throw spotifyResolutionError("spotify_playback_source_conflict", "Choose one playback source for resume: songs, or one reference. Nothing was played.");
2429
+ if (!Array.isArray(songs) || songs.length < 1 || songs.length > 12 ||
2430
+ songs.some((song) => typeof song?.title !== "string" || !song.title.trim() || typeof song?.artist !== "string" || !song.artist.trim())) {
2431
+ throw spotifyResolutionError("invalid_spotify_songs", "Give from 1 to 12 songs, each with a title and an artist.");
2432
+ }
2433
+ const fold = (value) => foldedWords(normalizedTrackTitle(value));
2434
+ const matched = [];
2435
+ const unmatched = [];
2436
+ for (const song of songs) {
2437
+ const result = await this.spotifySearchTracks({ query: `${song.title} ${song.artist}`, type: "track", limit: 10 }, { signal });
2438
+ const tracks = result.items.filter((item) => item.type === "track" && item.track_ref_id);
2439
+ const wantedTitle = foldedWords(song.title);
2440
+ const wantedCore = fold(song.title);
2441
+ // The same artist: equal names, or one credited artist of a joint credit ("伍佰" in
2442
+ // "伍佰 & China Blue"). A name inside another act's name ("Adele Tribute Band") is another act.
2443
+ const credited = (name) => [name, ...String(name).split(ARTIST_CREDIT_SEPARATORS)].map(foldedWords).filter(Boolean);
2444
+ const wantedArtists = new Set(credited(song.artist));
2445
+ const byArtist = tracks.filter((item) => item.artists.some((artist) => credited(artist).some((name) => wantedArtists.has(name))));
2446
+ // The exact title first. A version the listener named (a live take, a duet) must match as
2447
+ // named; otherwise the same song in another version (a remaster) is the song asked for.
2448
+ // Nothing looser is guessed: a shared prefix ("One", "One Tree Hill") is another song.
2449
+ const versionNamed = wantedTitle !== wantedCore;
2450
+ const pick = byArtist.find((item) => foldedWords(item.name) === wantedTitle) ??
2451
+ (versionNamed ? undefined : byArtist.find((item) => fold(item.name) === wantedCore));
2452
+ if (pick) matched.push(pick);
2453
+ else unmatched.push({ title: song.title, artist: song.artist,
2454
+ candidates: tracks.slice(0, 5).map(({ item_ref_id, name, artists, album }) => ({ item_ref_id, name, artists, ...(album ? { album } : {}) })) });
2455
+ }
2456
+ return { matched, unmatched };
2457
+ }
2458
+
1984
2459
  spotifyDiscoveryAllowed(item) {
1985
2460
  return this.avoidAllows({ title: item.name, artist_credit: item.artists.join(", "), artists: item.artists });
1986
2461
  }
@@ -2093,8 +2568,6 @@ export class MoondogApplication {
2093
2568
  this.requireSpotifyNonRemovalAction();
2094
2569
  const transaction = this.pendingPlaylistPromptTransaction;
2095
2570
  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
2571
  // The model acts on the listener's words, as for a single queue entry. A parsed
2099
2572
  // count only sizes the batch; failing to parse a phrasing never blocks it.
2100
2573
  const intent = transaction.queueIntent ?? null;
@@ -2106,7 +2579,18 @@ export class MoondogApplication {
2106
2579
  // named in their language; otherwise a sensible batch from the candidates.
2107
2580
  const requested = intent?.requested ?? count ?? Math.min(itemRefs.length, 12);
2108
2581
  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
- const items = itemRefs.map(ref => this.requireSpotifyReadItem(ref, ["track"]));
2582
+ // A reference the host never issued (a model's slip) is left out and counted; only a batch
2583
+ // with no usable reference at all is refused.
2584
+ const chosen = itemRefs.map((ref) => { try { return this.requireSpotifyReadItem(ref, ["track"]); } catch { return null; } }).filter(Boolean);
2585
+ const skippedUnavailable = itemRefs.length - chosen.length;
2586
+ if (!chosen.length) this.requireSpotifyReadItem(itemRefs[0], ["track"]);
2587
+ // The rest of this request's verified discovery results by the artists the model picked stand
2588
+ // behind its picks, in the order found, so a pick skipped as a duplicate or an Avoid still
2589
+ // leaves the count asked for. A song by another artist may have been left out on purpose.
2590
+ const pickedArtists = new Set(chosen.flatMap(item => item.artists));
2591
+ const backups = transaction.discoveryPoolStarted ? (this.spotifyReadSelections.get("discovery") ?? [])
2592
+ .filter(item => item.type === "track" && item.uri && !itemRefs.includes(item.item_ref_id) && item.artists.some(artist => pickedArtists.has(artist))) : [];
2593
+ const items = [...chosen, ...backups].slice(0, 36);
2110
2594
  this.requireSpotifyWriteScopes(["user-modify-playback-state"]);
2111
2595
  this.requireSpotifyScopes(["user-read-playback-state"]);
2112
2596
  transaction.queueBatchAttempted = true;
@@ -2131,9 +2615,12 @@ export class MoondogApplication {
2131
2615
  knownTrack: intent?.excludeKnown ? track => this.domainServices?.isKnownDiscoveryTrack?.(track) === true : null });
2132
2616
  if (!receipt.queued.length && !receipt.stopped) transaction.queueBatchAttempted = false;
2133
2617
  const result = { ...receipt, action: "queue.batch", requested, queued_count: receipt.queued.length,
2134
- shortfall: requested - receipt.queued.length, queue_observation_truncated: observed.truncated === true };
2618
+ shortfall: requested - receipt.queued.length, queue_observation_truncated: observed.truncated === true,
2619
+ ...(skippedUnavailable ? { skipped_unavailable_count: skippedUnavailable } : {}) };
2135
2620
  this.spotifyDisplayedChoices = null;
2136
2621
  if (this.spotifyQueueRequest && (receipt.queued.length || receipt.stopped)) Object.assign(this.spotifyQueueRequest, { attempted: true, receipt: structuredClone(result) });
2622
+ // Kept on the request itself, so a fill finds the batch even when no queue intent was parsed.
2623
+ if (receipt.queued.length) transaction.batchReceipt = structuredClone(result);
2137
2624
  return result;
2138
2625
  }
2139
2626
 
@@ -2146,6 +2633,10 @@ export class MoondogApplication {
2146
2633
  const rest = new Set();
2147
2634
  while (album && queue.at(-1)?.album_uri === album && !rest.has(queue.at(-1).uri)) rest.add(queue.pop().uri);
2148
2635
  const seen = new Set([observed.currently_playing?.uri, ...queue.map(track => track.uri)].filter(Boolean));
2636
+ // With nothing playing and nothing queued, Spotify has dropped what Moondog queued (its device
2637
+ // went idle), so those songs were not heard: a new request may queue them again.
2638
+ // A player that could not be read says nothing, so the songs stay held back.
2639
+ if (!seen.size && player && player.is_playing !== true && !player.item) this.recentSimilarQueueUris.clear();
2149
2640
  for (const [uri, acceptedAt] of this.recentSimilarQueueUris) {
2150
2641
  if (Date.now() - acceptedAt > 15 * 60_000) this.recentSimilarQueueUris.delete(uri);
2151
2642
  else seen.add(uri);
@@ -2253,7 +2744,24 @@ export class MoondogApplication {
2253
2744
 
2254
2745
  async spotifyAddToQueue(input, { signal } = {}) {
2255
2746
  signal?.throwIfAborted();
2256
- this.requireSpotifyNonRemovalAction();
2747
+ // A batch that came up short may be filled by single adds of songs it did not queue, one per
2748
+ // missing song; anything else after a batch is refused as before.
2749
+ const transaction = this.pendingPlaylistPromptTransaction;
2750
+ const batch = transaction?.queueBatchAttempted && transaction.batchReceipt?.action === "queue.batch" ? transaction.batchReceipt : null;
2751
+ // The retained receipt takes in each accepted fill, so its queued songs are the count so far.
2752
+ const missing = batch ? (batch.requested ?? 0) - (batch.queued?.length ?? 0) : 0;
2753
+ // Either supported reference names the song: a read's item_ref_id, or a retained or resolved track_ref_id.
2754
+ const fillRef = input?.itemRefId ?? input?.trackRefId;
2755
+ const resolved = batch && input?.trackRefId !== undefined ? this.spotifyResolutions.get(input.trackRefId) : null;
2756
+ const candidate = !batch || fillRef === undefined ? null
2757
+ : this.spotifyHostReadItems().find((entry) => entry.item_ref_id === fillRef || entry.track_ref_id === fillRef) ??
2758
+ (resolved?.uri ? { type: "track", uri: resolved.uri, name: resolved.matched?.title ?? "", artists: resolved.matched?.artists ?? [] } : null);
2759
+ // The same song is the same title by the same primary artist, as the batch itself decides.
2760
+ const candidateSongs = candidate ? songKeys({ title: candidate.name, artist_credit: candidate.artists?.join(", ") ?? "", artists: candidate.artists ?? [] }) : [];
2761
+ const fillsBatch = missing > 0 && candidate?.type === "track" &&
2762
+ !batch.queued.some((queued) => songKeys(queued).some((key) => candidateSongs.includes(key))) &&
2763
+ !(transaction.batchFillUris ?? []).includes(candidate.uri);
2764
+ this.requireSpotifyNonRemovalAction({ batchFill: fillsBatch });
2257
2765
  let uri;
2258
2766
  if (input?.itemRefId !== undefined) {
2259
2767
  if (input.trackRefId !== undefined || input.uri !== undefined) throw spotifyResolutionError("spotify_queue_selection_conflict", "Choose one queue item.");
@@ -2266,11 +2774,37 @@ export class MoondogApplication {
2266
2774
  const item = this.spotifyHostReadItems().find(item => item.uri === uri);
2267
2775
  if (item && !this.#spotifyItemAllowed(item)) throw listenerAvoidError();
2268
2776
  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 } : {}) };
2777
+ // A fill keeps the batch's own rules: nothing already in Spotify's queue or playing, and only
2778
+ // unheard songs when the listener asked for those.
2779
+ let fillRules = {};
2780
+ if (fillsBatch) {
2781
+ const service = this.requireSpotifyService();
2782
+ const [observed, player] = await Promise.all([service.queue({ signal }), service.currentPlayer({ signal }).catch(() => null)]);
2783
+ signal?.throwIfAborted();
2784
+ const seenUris = this.#observedQueueUris(observed, player);
2785
+ if (player?.item?.uri) seenUris.add(player.item.uri);
2786
+ fillRules = { seenUris, ...(transaction.queueIntent?.excludeKnown ? { knownTrack: (entry) => this.domainServices?.isKnownDiscoveryTrack?.(entry) === true } : {}) };
2787
+ }
2269
2788
  // One entry takes the same path as a batch: one device decision, start
2270
2789
  // 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 });
2790
+ const receipt = await this.#queueSpotifyTracks([track], new Map([["single", uri]]), { signal, deviceId: input.deviceId, rethrow: true, ...fillRules });
2791
+ if (fillsBatch && !receipt.queued.length) {
2792
+ throw spotifyResolutionError("spotify_queue_fill_skipped", receipt.skipped_known_count
2793
+ ? "That song is one the listener already knows, and they asked for unheard songs only. Nothing was queued."
2794
+ : "That song is already in Spotify's queue or playing. Nothing was queued; choose another song for the missing place.");
2795
+ }
2272
2796
  this.recentSimilarQueueUris.set(uri, Date.now());
2273
2797
  while (this.recentSimilarQueueUris.size > 100) this.recentSimilarQueueUris.delete(this.recentSimilarQueueUris.keys().next().value);
2798
+ if (fillsBatch) {
2799
+ transaction.batchFillUris = [...(transaction.batchFillUris ?? []), uri];
2800
+ // The receipt a later "queue, not playlist" reply shows says what is queued now.
2801
+ for (const kept of [batch, this.spotifyQueueRequest?.receipt?.action === "queue.batch" ? this.spotifyQueueRequest.receipt : null]) {
2802
+ if (!kept) continue;
2803
+ kept.queued = [...kept.queued, ...receipt.queued];
2804
+ kept.queued_count = kept.queued.length;
2805
+ kept.shortfall = Math.max(0, (kept.requested ?? 0) - kept.queued.length);
2806
+ }
2807
+ }
2274
2808
  return { provider: "spotify", ok: true, effect: "write_external", action: "playback.queue.add", state: "accepted",
2275
2809
  ...(item?.name ? { targets: [{ title: item.name, artist_credit: item.artists.join(", ") }] } : {}),
2276
2810
  ...(receipt.device ? { device: receipt.device } : {}), ...(receipt.started_playback ? { started_playback: true } : {}),
@@ -2406,12 +2940,12 @@ export class MoondogApplication {
2406
2940
  }
2407
2941
  }
2408
2942
 
2409
- requireSpotifyNonRemovalAction({ quickEdit = false } = {}) {
2943
+ requireSpotifyNonRemovalAction({ quickEdit = false, batchFill = false } = {}) {
2410
2944
  const requested = this.pendingPlaylistPromptTransaction?.queueIntent?.requested;
2411
2945
  if (requested != null && (!Number.isInteger(requested) || requested < 1 || requested > 12)) {
2412
2946
  throw spotifyResolutionError("spotify_queue_count_limit", "Specify one exact queue count from 1 to 12 songs. No write was sent.");
2413
2947
  }
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.");
2948
+ 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
2949
  if (this.pendingPlaylistPromptTransaction?.quickEditIntent && !quickEdit) {
2416
2950
  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
2951
  }
@@ -3289,8 +3823,11 @@ export class MoondogApplication {
3289
3823
  const service = this.requireSpotifyService();
3290
3824
  target ??= await service.queueTarget({ ...(deviceId ? { deviceId } : {}) }, { signal });
3291
3825
  const device = target.deviceId ? { deviceId: target.deviceId } : {};
3292
- // Nothing is playing, so a queue entry has nowhere to go: start this song instead.
3826
+ // Nothing is playing, so a queue entry has nowhere to go: start this song instead. That starts
3827
+ // music, so the other players go quiet first; a queue behind a song already there starts none.
3293
3828
  const start = async () => {
3829
+ await this.quietOtherPlayers("spotify", { signal });
3830
+ signal?.throwIfAborted();
3294
3831
  dispatching = true;
3295
3832
  const receipt = await service.resume({ uris: [uri], ...device }, { signal });
3296
3833
  started = label(track);
@@ -3520,7 +4057,8 @@ export class MoondogApplication {
3520
4057
  musicPreviewReady: this.musicPreviewReady(),
3521
4058
  tasteChoicesReady: this.tasteChoicesReady(),
3522
4059
  localGenerationReady: this.localGenerationReady(),
3523
- }).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id));
4060
+ }).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id) &&
4061
+ (this.libraryReady() || !descriptor.capability_id.startsWith("collection.")));
3524
4062
  }
3525
4063
 
3526
4064
  async findArtistReleases(input) {
@@ -3911,7 +4449,7 @@ export class MoondogApplication {
3911
4449
  this.pendingPlaylistPromptTransaction = null;
3912
4450
  } finally {
3913
4451
  const owned = this.ownsServices
3914
- ? [this.memoryStore, this.domainServices, this.musicCatalog, this.musicSimilarity, this.musicPreviewPlayer, this.webResearch]
4452
+ ? [this.memoryStore, this.libraryStore, this.domainServices, this.musicCatalog, this.musicSimilarity, this.musicPreviewPlayer, this.webResearch]
3915
4453
  : [this.domainServices];
3916
4454
  for (const service of owned) service?.close?.();
3917
4455
  }