@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.
Files changed (57) hide show
  1. package/README.md +30 -4
  2. package/package.json +1 -1
  3. package/scripts/moondog.mjs +148 -14
  4. package/src/core/apple-projection-domain-services.mjs +4 -0
  5. package/src/core/capability-catalog.mjs +49 -0
  6. package/src/core/library-store.mjs +500 -0
  7. package/src/core/listening-now.mjs +80 -6
  8. package/src/core/listening-profile-domain-services.mjs +4 -0
  9. package/src/core/moondog-application.mjs +578 -32
  10. package/src/i18n/index.mjs +6 -3
  11. package/src/i18n/messages/de.mjs +1 -0
  12. package/src/i18n/messages/en.mjs +2 -0
  13. package/src/i18n/messages/es.mjs +1 -0
  14. package/src/i18n/messages/fr.mjs +1 -0
  15. package/src/i18n/messages/it.mjs +1 -0
  16. package/src/i18n/messages/ja.mjs +1 -0
  17. package/src/i18n/messages/ko.mjs +1 -0
  18. package/src/i18n/messages/pt.mjs +1 -0
  19. package/src/i18n/messages/zh-Hant.mjs +1 -0
  20. package/src/i18n/messages/zh.mjs +1 -0
  21. package/src/i18n/preferences.mjs +24 -0
  22. package/src/i18n/screen/es.mjs +94 -0
  23. package/src/i18n/screen/ja.mjs +94 -0
  24. package/src/i18n/screen/pt.mjs +94 -0
  25. package/src/i18n/screen/zh.mjs +94 -0
  26. package/src/integrations/apple-music/music-app.mjs +9 -3
  27. package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
  28. package/src/integrations/netease/ncm-cli-player.mjs +12 -6
  29. package/src/integrations/playlist-links.mjs +102 -0
  30. package/src/integrations/public-playlists.mjs +39 -8
  31. package/src/integrations/spotify/service.mjs +22 -1
  32. package/src/integrations/stable-audio/cover.mjs +324 -0
  33. package/src/integrations/stable-audio/local-generator.mjs +259 -87
  34. package/src/integrations/stable-audio/setup.mjs +653 -0
  35. package/src/integrations/stable-audio/sources.mjs +106 -0
  36. package/src/integrations/web/deepseek-web.mjs +1 -1
  37. package/src/integrations/web/web-research.mjs +84 -17
  38. package/src/integrations/web/web-result.mjs +60 -12
  39. package/src/profile/listening-history-store.mjs +55 -1
  40. package/src/profile/listening-profile-projection.mjs +1 -1
  41. package/src/profile/music-import-bundle.mjs +3 -1
  42. package/src/profile/music-providers.mjs +3 -0
  43. package/src/profile/taste-compiler.mjs +14 -5
  44. package/src/runtime/pi/agent-runtime.mjs +359 -37
  45. package/src/runtime/pi/configured-runtime.mjs +3 -0
  46. package/src/runtime/pi/image-input.mjs +148 -0
  47. package/src/runtime/pi/model-roles.mjs +121 -0
  48. package/src/runtime/pi/model-settings.mjs +63 -2
  49. package/src/runtime/pi/model-switch.mjs +26 -0
  50. package/src/runtime/pi/model-transport.mjs +34 -4
  51. package/src/runtime/pi/record-name.mjs +80 -0
  52. package/src/runtime/pi/reply-language.mjs +45 -0
  53. package/src/runtime/pi/runtime-settings.mjs +4 -2
  54. package/src/runtime/pi/tool-arguments.mjs +89 -0
  55. package/src/surfaces/cli/command-completions.mjs +15 -1
  56. package/src/surfaces/cli/model-roles-command.mjs +60 -0
  57. 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 made = await this.localGenerator.generate({ prompt, seconds, title: compiled.title, receipt }, { signal, onProgress });
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
- 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 }) });
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
- if (mode !== "queue") await this.quietOtherPlayers("netease");
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
- 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) });
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
- else if (["pause", "resume", "next", "previous", "stop"].includes(action)) await player[action]();
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.musicApp?.available === true || this.spotifyReady() || this.neteaseReady();
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
- this.atmosphereStream?.stop?.("service_change");
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
- const items = itemRefs.map(ref => this.requireSpotifyReadItem(ref, ["track"]));
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
- this.requireSpotifyNonRemovalAction();
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
  }