@audiofool/moondog 0.3.0-beta.7 → 0.3.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,10 +19,24 @@ 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";
22
23
  import { normalizeConversationFolder, normalizeConversationLabel, normalizeMemoryContent, shortConversationTitle } from "../memory/local-memory-store.mjs";
23
24
  import { queueListeningIntent, queueCancellationRequested, unheardRequested } from "./spotify-listening-intent.mjs";
24
25
  import { DEFAULT_LOCALE, LOCALES, messageLocale, normalizeLocale } from "../i18n/index.mjs";
25
26
 
27
+ // Runs work over a list with at most limit calls at once, keeping the list's order.
28
+ async function mapLimited(list, limit, work) {
29
+ const results = new Array(list.length);
30
+ let next = 0;
31
+ await Promise.all(Array.from({ length: Math.min(limit, list.length) }, async () => {
32
+ while (next < list.length) {
33
+ const at = next++;
34
+ results[at] = await work(list[at], at);
35
+ }
36
+ }));
37
+ return results;
38
+ }
39
+
26
40
  // How long a record's title in the wrong language waits to be named again before the agent's stays.
27
41
  const TITLE_LANGUAGE_TIMEOUT_MS = 10_000;
28
42
 
@@ -245,6 +259,8 @@ export class MoondogApplication {
245
259
  localGenerator = null,
246
260
  atmosphereStream = null,
247
261
  musicApp = null,
262
+ // The listener's library (src/core/library-store.mjs), shared by every conversation and both surfaces.
263
+ libraryStore = null,
248
264
  agentHostNotes = [],
249
265
  artistIdentityResolver = null,
250
266
  webResearch = null,
@@ -301,6 +317,7 @@ export class MoondogApplication {
301
317
  // The Music app on this Mac (src/integrations/apple-music/music-app.mjs), read for what the
302
318
  // listener plays there themselves.
303
319
  this.musicApp = musicApp;
320
+ this.libraryStore = libraryStore;
304
321
  // Hosts can tell the agent where it runs; the terminal passes none.
305
322
  this.hostNotes = Array.isArray(agentHostNotes) ? agentHostNotes.filter((note) => typeof note === "string") : [];
306
323
  this.artistIdentityResolver = artistIdentityResolver;
@@ -605,6 +622,210 @@ export class MoondogApplication {
605
622
  return { state: "playing", track: { id, title, seconds: receipt.audio?.duration_seconds ?? null }, chosen_by: named ? "listener" : "moondog" };
606
623
  }
607
624
 
625
+ // The listener's library: their playlists and the songs and made tracks they kept, side by side.
626
+ libraryReady() {
627
+ return Boolean(this.libraryStore);
628
+ }
629
+
630
+ requireLibrary() {
631
+ if (!this.libraryStore) throw Object.assign(new Error("The library is not available here."), { code: "library_not_ready" });
632
+ return this.libraryStore;
633
+ }
634
+
635
+ libraryView() {
636
+ return this.libraryReady() ? this.libraryStore.view() : null;
637
+ }
638
+
639
+ // One playlist with its entries; a made track says whether its audio is still on this Mac.
640
+ async libraryPlaylist(id) {
641
+ const playlist = this.requireLibrary().playlist(id);
642
+ if (!playlist) return null;
643
+ for (const entry of playlist.entries) {
644
+ if (entry.kind === "made") entry.audio_kept = Boolean(await this.localGenerator?.trackFile?.(entry.made_id));
645
+ }
646
+ return playlist;
647
+ }
648
+
649
+ // The song or made track one listening source plays now, as a library entry.
650
+ #entryOfSong(song) {
651
+ if (!song?.title) return null;
652
+ if (song.made_track_id) return { kind: "made", id: song.made_track_id, title: song.title };
653
+ const artist = Array.isArray(song.artists) ? song.artists.join(", ") : "";
654
+ if (!artist) return null;
655
+ const album = typeof song.album === "string" ? song.album : song.album?.name;
656
+ return { kind: "song", title: song.title, artist_credit: artist, ...(album ? { album } : {}) };
657
+ }
658
+
659
+ // What to save: the song playing now, what plays from now on (its playlist or queue), songs the agent names,
660
+ // or a made track by id.
661
+ async #entriesToSave({ from, songs, madeTrackId }, { signal } = {}) {
662
+ if (madeTrackId) {
663
+ const receipt = await this.localGenerator?.receipt?.(madeTrackId);
664
+ if (!receipt) throw Object.assign(new Error("There is no track Moondog made with that id on this Mac."), { code: "made_track_not_found" });
665
+ if (!(await this.localGenerator.trackFile(madeTrackId))) {
666
+ 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" });
667
+ }
668
+ return [{ kind: "made", id: madeTrackId, title: String(receipt.title ?? "Moondog") }];
669
+ }
670
+ if (Array.isArray(songs) && songs.length) {
671
+ return songs.map((song) => ({ kind: "song", title: song.title, artist_credit: song.artist ?? song.artist_credit, ...(song.album ? { album: song.album } : {}) }));
672
+ }
673
+ const now = await readListeningNow(this, { signal });
674
+ const active = now.sources.find((source) => source.service === now.active);
675
+ if (!active?.song) throw Object.assign(new Error("Nothing is playing, so there is nothing to save."), { code: "library_nothing_playing" });
676
+ if (from === "queue") {
677
+ // What plays from now on: Moondog's whole remaining queue (its status shows only the next ten); the Music
678
+ // app's playlist from the song playing, which it places; otherwise the song and the service's own queue.
679
+ const remaining = active.service === "moondog" ? this.musicPreviewPlayer?.remaining?.() : null;
680
+ let playing = active.from;
681
+ // The Music app's status reads a window around the playing song; a longer playlist is read on to its end.
682
+ if (active.service === "apple_music" && playing?.tracks?.length && Number.isInteger(playing.tracks_total) &&
683
+ (playing.first_position ?? 1) - 1 + playing.tracks.length < playing.tracks_total && typeof this.musicApp?.remainingPlaylist === "function") {
684
+ const rest = await this.musicApp.remainingPlaylist().then((now) => musicAppSource(now).from, () => null);
685
+ if (rest?.tracks?.length && Number.isInteger(rest.position) && Number.isInteger(rest.first_position)) playing = rest;
686
+ }
687
+ const tracks = playing?.tracks;
688
+ const placed = tracks?.length && Number.isInteger(playing.position) && Number.isInteger(playing.first_position);
689
+ const list = remaining?.length ? remaining.map((track) => ({ title: track.title, artists: track.generated ? ["Moondog"] : [track.artist_credit].filter(Boolean),
690
+ ...(track.generated && track.id ? { made_track_id: track.id } : {}) }))
691
+ : placed ? tracks.slice(Math.max(0, playing.position - playing.first_position))
692
+ : [active.song, ...(active.next ?? [])];
693
+ return list.map((song) => this.#entryOfSong(song)).filter(Boolean);
694
+ }
695
+ const one = this.#entryOfSong(active.song);
696
+ if (!one) throw Object.assign(new Error("The song playing now has no artist to keep it by."), { code: "library_nothing_playing" });
697
+ return [one];
698
+ }
699
+
700
+ /**
701
+ * Keeps songs or made tracks in the library: in Saved when no playlist is named, in a playlist by id, or
702
+ * in a new playlist by name. by says who chose to keep them: the listener, or Moondog at their request.
703
+ */
704
+ async saveToLibrary({ from = "now_playing", songs, madeTrackId, playlistId, playlistName, folderId, artwork } = {}, { by = "listener", signal } = {}) {
705
+ const library = this.requireLibrary();
706
+ const entries = await this.#entriesToSave({ from, songs, madeTrackId }, { signal });
707
+ if (!entries.length) throw Object.assign(new Error("There is nothing to save."), { code: "library_nothing_playing" });
708
+ // The window passes the cover it shows for the song playing now.
709
+ if (artwork && from === "now_playing" && !songs?.length && !madeTrackId && entries[0].kind === "song") entries[0].refs = { artwork_url: artwork };
710
+ if (!playlistId && playlistName) {
711
+ const made = library.savePlaylist({ name: playlistName, folderId: folderId ?? null, origin: by === "moondog" ? "moondog" : "listener", entries, addedBy: by });
712
+ return { playlist: { id: made.id, name: library.playlist(made.id).name, created: true }, added: made.added, already: made.already,
713
+ titles: entries.map((entry) => entry.title) };
714
+ }
715
+ const target = playlistId ?? SAVED_PLAYLIST_ID;
716
+ const added = library.addEntries(target, entries, { addedBy: by });
717
+ const playlist = library.playlist(target);
718
+ return { playlist: { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name, ...(playlist.saved ? { saved: true } : {}) },
719
+ added: added.added, already: added.already, titles: entries.map((entry) => entry.title) };
720
+ }
721
+
722
+ /** Playlists by name for the agent, or the newest ones when no words are given. */
723
+ findLibraryPlaylists(query) {
724
+ return this.requireLibrary().findPlaylists(query).map(({ id, name, count, made, origin, saved }) => ({
725
+ id, name: saved ? "Saved" : name, count, made, origin, ...(saved ? { saved: true } : {}) }));
726
+ }
727
+
728
+ /**
729
+ * Plays a playlist from the library, from its first entry or from one entry. It plays on the listener's
730
+ * chosen service: Moondog's own player holds Apple Music songs and made tracks in one queue; Spotify and
731
+ * NetEase play its songs, and its made tracks wait for Moondog's own player (left_out_made says how many).
732
+ * The listener's own playlist is their pick, so nothing of it goes in the suggestions ledger.
733
+ */
734
+ async playLibrary({ playlistId, entryId, shuffle = false } = {}, { signal } = {}) {
735
+ const playlist = await this.libraryPlaylist(playlistId);
736
+ if (!playlist) throw Object.assign(new Error("That playlist is not in the library."), { code: "library_playlist_not_found" });
737
+ const named = { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name };
738
+ let entries = playlist.entries;
739
+ const start = entryId ? entries.findIndex((entry) => entry.id === entryId) : 0;
740
+ entries = entries.slice(Math.max(0, start));
741
+ if (shuffle) entries = entries.map((entry) => [Math.random(), entry]).sort((a, b) => a[0] - b[0]).map(([, entry]) => entry);
742
+ const avoided = entries.filter((entry) => entry.kind === "song" && !this.avoidAllows({ title: entry.title, artist_credit: entry.artist_credit }));
743
+ entries = entries.filter((entry) => !avoided.includes(entry) && (entry.kind === "song" || entry.audio_kept));
744
+ const extra = avoided.length ? { skipped_avoided_count: avoided.length } : {};
745
+ if (!entries.length) return { state: "empty", playlist: named, ...extra };
746
+ const songs = entries.filter((entry) => entry.kind === "song");
747
+ const service = this.playbackService;
748
+ // Only made tracks, or Apple Music chosen: Moondog's own player, with both in one queue.
749
+ if ((!songs.length || service === "apple_music") && typeof this.musicPreviewPlayer?.playList === "function") {
750
+ const refs = [];
751
+ const avoidedFound = [];
752
+ const found = await mapLimited(entries.slice(0, 50), 6, async (entry) => {
753
+ if (entry.kind === "made") return { generated: true, id: entry.made_id, title: entry.title, file: await this.localGenerator.trackFile(entry.made_id) };
754
+ if (!this.musicCatalog) return null;
755
+ const result = await this.musicCatalog.findPreview({ title: entry.title, artist: entry.artist_credit }).catch(() => null);
756
+ if (result?.state !== "resolved") return null;
757
+ // The catalog's recording can credit more than the entry named (a feature, a longer title); an Avoid still holds.
758
+ if (!this.avoidAllows(result.track)) { avoidedFound.push(entry.id); return null; }
759
+ if (result.track.catalog_id) refs.push({ entryId: entry.id, refs: { apple_music_id: String(result.track.catalog_id),
760
+ ...(entry.refs?.artwork_url || !result.track.artwork_url ? {} : { artwork_url: result.track.artwork_url }) } });
761
+ return result.track;
762
+ });
763
+ signal?.throwIfAborted();
764
+ const tracks = found.filter(Boolean);
765
+ const notFound = found.length - tracks.length - avoidedFound.length;
766
+ const skipped = avoided.length + avoidedFound.length;
767
+ Object.assign(extra, skipped ? { skipped_avoided_count: skipped } : {});
768
+ if (!tracks.length) return { state: "not_found", playlist: named, not_found: notFound, ...extra };
769
+ this.libraryStore.rememberRefs(refs);
770
+ // A playlist replaces Moondog's stream and quiets the other players, as any music Moondog starts does.
771
+ await this.#endStream("replaced");
772
+ await this.quietOtherPlayers("apple_music", { signal });
773
+ signal?.throwIfAborted();
774
+ this.musicPreviewPlayer.playList(tracks);
775
+ return { state: "playing", player: "moondog", playlist: named, playing: tracks.length, not_found: notFound,
776
+ whole_songs: this.musicPreviewPlayer.wholeSongs?.() === true, ...extra };
777
+ }
778
+ const leftOut = entries.length - songs.length;
779
+ if (service === "spotify") {
780
+ if (!this.spotifyPlaybackReady()) throw Object.assign(new Error("Spotify is not ready to play here."), { code: "spotify_not_ready" });
781
+ const list = songs.slice(0, 100);
782
+ const refs = [];
783
+ const avoidedFound = [];
784
+ // A recording's own title and credit can name more than the entry does (a feature); an Avoid holds against it.
785
+ const allowed = (title, credit) => this.avoidAllows({ title, artist_credit: credit });
786
+ const uris = await mapLimited(list, 4, async (entry) => {
787
+ // A cached recording plays without a search, once its own credit passes today's Avoids.
788
+ if (entry.refs?.spotify_uri && entry.refs.spotify_credit) {
789
+ if (allowed(entry.refs.spotify_title ?? entry.title, entry.refs.spotify_credit)) return entry.refs.spotify_uri;
790
+ avoidedFound.push(entry.id);
791
+ return null;
792
+ }
793
+ const { matched } = await this.spotifyResolveSongs([{ title: entry.title, artist: entry.artist_credit }], { signal }).catch(() => ({ matched: [] }));
794
+ // A match reaches the model without its URI; the host keeps it under the match's reference.
795
+ const ref = matched[0]?.item_ref_id;
796
+ const known = ref ? this.spotifyHostReadItems().find((item) => item.item_ref_id === ref) : null;
797
+ const uri = typeof known?.uri === "string" ? known.uri : typeof matched[0]?.uri === "string" ? matched[0].uri : null;
798
+ if (!uri) return null;
799
+ const title = known?.name ?? matched[0]?.name ?? entry.title;
800
+ const credit = (known?.artists ?? matched[0]?.artists ?? []).join(", ") || entry.artist_credit;
801
+ refs.push({ entryId: entry.id, refs: { spotify_uri: uri, spotify_title: title, spotify_credit: credit } });
802
+ if (!allowed(title, credit)) { avoidedFound.push(entry.id); return null; }
803
+ return uri;
804
+ });
805
+ signal?.throwIfAborted();
806
+ this.libraryStore.rememberRefs(refs);
807
+ const playable = uris.filter(Boolean);
808
+ const skipped = avoided.length + avoidedFound.length;
809
+ Object.assign(extra, skipped ? { skipped_avoided_count: skipped } : {});
810
+ const unfound = list.length - playable.length - avoidedFound.length;
811
+ if (!playable.length) return { state: "not_found", playlist: named, not_found: unfound, left_out_made: leftOut, ...extra };
812
+ await this.quietOtherPlayers("spotify", { signal });
813
+ const receipt = await this.#resumeSpotifySelection({ uris: playable }, { signal, played: [] });
814
+ return { state: receipt.state === "accepted" ? "playing" : receipt.state, player: "spotify", playlist: named, playing: playable.length,
815
+ not_found: unfound, left_out_made: leftOut, ...extra };
816
+ }
817
+ if (service === "netease" && this.neteaseReady()) {
818
+ const player = this.requireNetease();
819
+ await this.quietOtherPlayers("netease", { signal });
820
+ const receipt = await player.play({ items: songs.slice(0, 20).map((entry) => ({ title: entry.title, artist: entry.artist_credit })), mode: "play",
821
+ intent: "播放听者自己的歌单", allow: (song) => this.avoidAllows(Array.isArray(song.artists)
822
+ ? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
823
+ return { state: receipt?.mode === "playing" ? "playing" : "not_found", player: "netease", playlist: named,
824
+ playing: receipt?.songs?.length ?? 0, left_out_made: leftOut, ...extra };
825
+ }
826
+ throw Object.assign(new Error("No player here can play this playlist."), { code: "library_no_player" });
827
+ }
828
+
608
829
  // Makes one instrumental track from the listener's taste and plays it in Moondog's own player.
609
830
  // The agent proposes sound descriptors, each with its source; the taste compiler keeps only the
610
831
  // ones it can trace and none that names an artist or a song. The track and its receipt stay on
@@ -2286,7 +2507,11 @@ export class MoondogApplication {
2286
2507
  // named in their language; otherwise a sensible batch from the candidates.
2287
2508
  const requested = intent?.requested ?? count ?? Math.min(itemRefs.length, 12);
2288
2509
  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.");
2289
- const chosen = itemRefs.map(ref => this.requireSpotifyReadItem(ref, ["track"]));
2510
+ // A reference the host never issued (a model's slip) is left out and counted; only a batch
2511
+ // with no usable reference at all is refused.
2512
+ const chosen = itemRefs.map((ref) => { try { return this.requireSpotifyReadItem(ref, ["track"]); } catch { return null; } }).filter(Boolean);
2513
+ const skippedUnavailable = itemRefs.length - chosen.length;
2514
+ if (!chosen.length) this.requireSpotifyReadItem(itemRefs[0], ["track"]);
2290
2515
  // The rest of this request's verified discovery results by the artists the model picked stand
2291
2516
  // behind its picks, in the order found, so a pick skipped as a duplicate or an Avoid still
2292
2517
  // leaves the count asked for. A song by another artist may have been left out on purpose.
@@ -2318,9 +2543,12 @@ export class MoondogApplication {
2318
2543
  knownTrack: intent?.excludeKnown ? track => this.domainServices?.isKnownDiscoveryTrack?.(track) === true : null });
2319
2544
  if (!receipt.queued.length && !receipt.stopped) transaction.queueBatchAttempted = false;
2320
2545
  const result = { ...receipt, action: "queue.batch", requested, queued_count: receipt.queued.length,
2321
- shortfall: requested - receipt.queued.length, queue_observation_truncated: observed.truncated === true };
2546
+ shortfall: requested - receipt.queued.length, queue_observation_truncated: observed.truncated === true,
2547
+ ...(skippedUnavailable ? { skipped_unavailable_count: skippedUnavailable } : {}) };
2322
2548
  this.spotifyDisplayedChoices = null;
2323
2549
  if (this.spotifyQueueRequest && (receipt.queued.length || receipt.stopped)) Object.assign(this.spotifyQueueRequest, { attempted: true, receipt: structuredClone(result) });
2550
+ // Kept on the request itself, so a fill finds the batch even when no queue intent was parsed.
2551
+ if (receipt.queued.length) transaction.batchReceipt = structuredClone(result);
2324
2552
  return result;
2325
2553
  }
2326
2554
 
@@ -2444,7 +2672,24 @@ export class MoondogApplication {
2444
2672
 
2445
2673
  async spotifyAddToQueue(input, { signal } = {}) {
2446
2674
  signal?.throwIfAborted();
2447
- this.requireSpotifyNonRemovalAction();
2675
+ // A batch that came up short may be filled by single adds of songs it did not queue, one per
2676
+ // missing song; anything else after a batch is refused as before.
2677
+ const transaction = this.pendingPlaylistPromptTransaction;
2678
+ const batch = transaction?.queueBatchAttempted && transaction.batchReceipt?.action === "queue.batch" ? transaction.batchReceipt : null;
2679
+ // The retained receipt takes in each accepted fill, so its queued songs are the count so far.
2680
+ const missing = batch ? (batch.requested ?? 0) - (batch.queued?.length ?? 0) : 0;
2681
+ // Either supported reference names the song: a read's item_ref_id, or a retained or resolved track_ref_id.
2682
+ const fillRef = input?.itemRefId ?? input?.trackRefId;
2683
+ const resolved = batch && input?.trackRefId !== undefined ? this.spotifyResolutions.get(input.trackRefId) : null;
2684
+ const candidate = !batch || fillRef === undefined ? null
2685
+ : this.spotifyHostReadItems().find((entry) => entry.item_ref_id === fillRef || entry.track_ref_id === fillRef) ??
2686
+ (resolved?.uri ? { type: "track", uri: resolved.uri, name: resolved.matched?.title ?? "", artists: resolved.matched?.artists ?? [] } : null);
2687
+ // The same song is the same title by the same primary artist, as the batch itself decides.
2688
+ const candidateSongs = candidate ? songKeys({ title: candidate.name, artist_credit: candidate.artists?.join(", ") ?? "", artists: candidate.artists ?? [] }) : [];
2689
+ const fillsBatch = missing > 0 && candidate?.type === "track" &&
2690
+ !batch.queued.some((queued) => songKeys(queued).some((key) => candidateSongs.includes(key))) &&
2691
+ !(transaction.batchFillUris ?? []).includes(candidate.uri);
2692
+ this.requireSpotifyNonRemovalAction({ batchFill: fillsBatch });
2448
2693
  let uri;
2449
2694
  if (input?.itemRefId !== undefined) {
2450
2695
  if (input.trackRefId !== undefined || input.uri !== undefined) throw spotifyResolutionError("spotify_queue_selection_conflict", "Choose one queue item.");
@@ -2457,11 +2702,37 @@ export class MoondogApplication {
2457
2702
  const item = this.spotifyHostReadItems().find(item => item.uri === uri);
2458
2703
  if (item && !this.#spotifyItemAllowed(item)) throw listenerAvoidError();
2459
2704
  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 } : {}) };
2705
+ // A fill keeps the batch's own rules: nothing already in Spotify's queue or playing, and only
2706
+ // unheard songs when the listener asked for those.
2707
+ let fillRules = {};
2708
+ if (fillsBatch) {
2709
+ const service = this.requireSpotifyService();
2710
+ const [observed, player] = await Promise.all([service.queue({ signal }), service.currentPlayer({ signal }).catch(() => null)]);
2711
+ signal?.throwIfAborted();
2712
+ const seenUris = this.#observedQueueUris(observed, player);
2713
+ if (player?.item?.uri) seenUris.add(player.item.uri);
2714
+ fillRules = { seenUris, ...(transaction.queueIntent?.excludeKnown ? { knownTrack: (entry) => this.domainServices?.isKnownDiscoveryTrack?.(entry) === true } : {}) };
2715
+ }
2460
2716
  // One entry takes the same path as a batch: one device decision, start
2461
2717
  // playback when nothing is playing, and a check of Spotify's real queue.
2462
- const receipt = await this.#queueSpotifyTracks([track], new Map([["single", uri]]), { signal, deviceId: input.deviceId, rethrow: true });
2718
+ const receipt = await this.#queueSpotifyTracks([track], new Map([["single", uri]]), { signal, deviceId: input.deviceId, rethrow: true, ...fillRules });
2719
+ if (fillsBatch && !receipt.queued.length) {
2720
+ throw spotifyResolutionError("spotify_queue_fill_skipped", receipt.skipped_known_count
2721
+ ? "That song is one the listener already knows, and they asked for unheard songs only. Nothing was queued."
2722
+ : "That song is already in Spotify's queue or playing. Nothing was queued; choose another song for the missing place.");
2723
+ }
2463
2724
  this.recentSimilarQueueUris.set(uri, Date.now());
2464
2725
  while (this.recentSimilarQueueUris.size > 100) this.recentSimilarQueueUris.delete(this.recentSimilarQueueUris.keys().next().value);
2726
+ if (fillsBatch) {
2727
+ transaction.batchFillUris = [...(transaction.batchFillUris ?? []), uri];
2728
+ // The receipt a later "queue, not playlist" reply shows says what is queued now.
2729
+ for (const kept of [batch, this.spotifyQueueRequest?.receipt?.action === "queue.batch" ? this.spotifyQueueRequest.receipt : null]) {
2730
+ if (!kept) continue;
2731
+ kept.queued = [...kept.queued, ...receipt.queued];
2732
+ kept.queued_count = kept.queued.length;
2733
+ kept.shortfall = Math.max(0, (kept.requested ?? 0) - kept.queued.length);
2734
+ }
2735
+ }
2465
2736
  return { provider: "spotify", ok: true, effect: "write_external", action: "playback.queue.add", state: "accepted",
2466
2737
  ...(item?.name ? { targets: [{ title: item.name, artist_credit: item.artists.join(", ") }] } : {}),
2467
2738
  ...(receipt.device ? { device: receipt.device } : {}), ...(receipt.started_playback ? { started_playback: true } : {}),
@@ -2597,12 +2868,12 @@ export class MoondogApplication {
2597
2868
  }
2598
2869
  }
2599
2870
 
2600
- requireSpotifyNonRemovalAction({ quickEdit = false } = {}) {
2871
+ requireSpotifyNonRemovalAction({ quickEdit = false, batchFill = false } = {}) {
2601
2872
  const requested = this.pendingPlaylistPromptTransaction?.queueIntent?.requested;
2602
2873
  if (requested != null && (!Number.isInteger(requested) || requested < 1 || requested > 12)) {
2603
2874
  throw spotifyResolutionError("spotify_queue_count_limit", "Specify one exact queue count from 1 to 12 songs. No write was sent.");
2604
2875
  }
2605
- 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.");
2876
+ 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.");
2606
2877
  if (this.pendingPlaylistPromptTransaction?.quickEditIntent && !quickEdit) {
2607
2878
  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.");
2608
2879
  }
@@ -3714,7 +3985,8 @@ export class MoondogApplication {
3714
3985
  musicPreviewReady: this.musicPreviewReady(),
3715
3986
  tasteChoicesReady: this.tasteChoicesReady(),
3716
3987
  localGenerationReady: this.localGenerationReady(),
3717
- }).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id));
3988
+ }).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id) &&
3989
+ (this.libraryReady() || !descriptor.capability_id.startsWith("collection.")));
3718
3990
  }
3719
3991
 
3720
3992
  async findArtistReleases(input) {
@@ -4105,7 +4377,7 @@ export class MoondogApplication {
4105
4377
  this.pendingPlaylistPromptTransaction = null;
4106
4378
  } finally {
4107
4379
  const owned = this.ownsServices
4108
- ? [this.memoryStore, this.domainServices, this.musicCatalog, this.musicSimilarity, this.musicPreviewPlayer, this.webResearch]
4380
+ ? [this.memoryStore, this.libraryStore, this.domainServices, this.musicCatalog, this.musicSimilarity, this.musicPreviewPlayer, this.webResearch]
4109
4381
  : [this.domainServices];
4110
4382
  for (const service of owned) service?.close?.();
4111
4383
  }
@@ -1119,4 +1119,21 @@ export default {
1119
1119
  "downloading the model": "descargando el modelo",
1120
1120
  "checking the files": "comprobando los archivos",
1121
1121
  "Stable Audio 3's Python environment couldn't be set up. Check your connection, then use /generation download to try again.": "No se pudo preparar el entorno de Python de Stable Audio 3. Revisa tu conexión y usa /generation download para intentarlo de nuevo.",
1122
+ "The library isn't available here.": "La biblioteca no está disponible aquí.",
1123
+ "Saved": "Guardadas",
1124
+ "Play one with `/library play <name>`, or keep the song playing now with `/library save`.": "Reproduce una con `/library play <nombre>` o guarda lo que suena con `/library save`.",
1125
+ "No playlist is called “{name}”.": "No hay ninguna lista llamada “{name}”.",
1126
+ "{name} has nothing in it yet.": "{name} aún no tiene nada.",
1127
+ "None of its songs could be found.": "No se encontró ninguna de sus canciones.",
1128
+ "Playing {name}.": "Reproduciendo {name}.",
1129
+ "Moondog's tracks were left out. They play with Apple Music chosen.": "Las pistas de Moondog se quedaron fuera. Suenan con Apple Music elegido.",
1130
+ "Some songs couldn't be found.": "No se encontraron algunas canciones.",
1131
+ "Already in {name}.": "Ya está en {name}.",
1132
+ "Saved.": "Guardada.",
1133
+ "Saved to {name}.": "Guardada en {name}.",
1134
+ "Use /library, /library play <name>, or /library save [playlist].": "Usa /library, /library play <nombre> o /library save [lista].",
1135
+ "Your saved songs and playlists": "Tus canciones guardadas y tus listas",
1136
+ "Look in the library": "Mirar en la biblioteca",
1137
+ "Save to the library": "Guardar en la biblioteca",
1138
+ "Play from the library": "Reproducir de la biblioteca",
1122
1139
  };
@@ -1119,4 +1119,21 @@ export default {
1119
1119
  "downloading the model": "モデルをダウンロード中",
1120
1120
  "checking the files": "ファイルを確認中",
1121
1121
  "Stable Audio 3's Python environment couldn't be set up. Check your connection, then use /generation download to try again.": "Stable Audio 3 の Python 環境を準備できませんでした。接続を確かめてから、/generation download でやり直してください。",
1122
+ "The library isn't available here.": "ここではライブラリを使えません。",
1123
+ "Saved": "保存済み",
1124
+ "Play one with `/library play <name>`, or keep the song playing now with `/library save`.": "`/library play <名前>` で再生するか、`/library save` で再生中の曲を保存してください。",
1125
+ "No playlist is called “{name}”.": "「{name}」というプレイリストはありません。",
1126
+ "{name} has nothing in it yet.": "{name} にはまだ何もありません。",
1127
+ "None of its songs could be found.": "曲がひとつも見つかりませんでした。",
1128
+ "Playing {name}.": "{name} を再生しています。",
1129
+ "Moondog's tracks were left out. They play with Apple Music chosen.": "Moondog の曲は再生されませんでした。Apple Music を選ぶと再生されます。",
1130
+ "Some songs couldn't be found.": "見つからない曲がありました。",
1131
+ "Already in {name}.": "すでに「{name}」にあります。",
1132
+ "Saved.": "保存しました。",
1133
+ "Saved to {name}.": "「{name}」に保存しました。",
1134
+ "Use /library, /library play <name>, or /library save [playlist].": "/library、/library play <名前>、または /library save [プレイリスト] を使ってください。",
1135
+ "Your saved songs and playlists": "保存した曲とプレイリスト",
1136
+ "Look in the library": "ライブラリを見る",
1137
+ "Save to the library": "ライブラリに保存",
1138
+ "Play from the library": "ライブラリから再生",
1122
1139
  };
@@ -1119,4 +1119,21 @@ export default {
1119
1119
  "downloading the model": "baixando o modelo",
1120
1120
  "checking the files": "conferindo os arquivos",
1121
1121
  "Stable Audio 3's Python environment couldn't be set up. Check your connection, then use /generation download to try again.": "Não foi possível preparar o ambiente Python do Stable Audio 3. Verifique sua conexão e use /generation download para tentar de novo.",
1122
+ "The library isn't available here.": "A biblioteca não está disponível aqui.",
1123
+ "Saved": "Salvas",
1124
+ "Play one with `/library play <name>`, or keep the song playing now with `/library save`.": "Toque uma com `/library play <nome>` ou salve o que está tocando com `/library save`.",
1125
+ "No playlist is called “{name}”.": "Nenhuma playlist se chama “{name}”.",
1126
+ "{name} has nothing in it yet.": "{name} ainda não tem nada.",
1127
+ "None of its songs could be found.": "Nenhuma das músicas foi encontrada.",
1128
+ "Playing {name}.": "Tocando {name}.",
1129
+ "Moondog's tracks were left out. They play with Apple Music chosen.": "As faixas do Moondog ficaram de fora. Elas tocam com o Apple Music escolhido.",
1130
+ "Some songs couldn't be found.": "Algumas músicas não foram encontradas.",
1131
+ "Already in {name}.": "Já está em {name}.",
1132
+ "Saved.": "Salva.",
1133
+ "Saved to {name}.": "Salva em {name}.",
1134
+ "Use /library, /library play <name>, or /library save [playlist].": "Use /library, /library play <nome> ou /library save [playlist].",
1135
+ "Your saved songs and playlists": "Suas músicas salvas e playlists",
1136
+ "Look in the library": "Olhar na biblioteca",
1137
+ "Save to the library": "Salvar na biblioteca",
1138
+ "Play from the library": "Tocar da biblioteca",
1122
1139
  };
@@ -1119,4 +1119,21 @@ export default {
1119
1119
  "downloading the model": "正在下载模型",
1120
1120
  "checking the files": "正在校验文件",
1121
1121
  "Stable Audio 3's Python environment couldn't be set up. Check your connection, then use /generation download to try again.": "没能准备好 Stable Audio 3 的 Python 环境。检查网络连接,再用 /generation download 重试。",
1122
+ "The library isn't available here.": "这里用不了资料库。",
1123
+ "Saved": "收藏",
1124
+ "Play one with `/library play <name>`, or keep the song playing now with `/library save`.": "用 `/library play <名字>` 播放一个,或者用 `/library save` 收藏正在播放的歌。",
1125
+ "No playlist is called “{name}”.": "没有叫“{name}”的播放列表。",
1126
+ "{name} has nothing in it yet.": "{name} 里还是空的。",
1127
+ "None of its songs could be found.": "里面的歌都没有找到。",
1128
+ "Playing {name}.": "正在播放 {name}。",
1129
+ "Moondog's tracks were left out. They play with Apple Music chosen.": "Moondog 做的曲子没有放。选择 Apple Music 时才会播放它们。",
1130
+ "Some songs couldn't be found.": "有些歌没有找到。",
1131
+ "Already in {name}.": "已经在“{name}”里了。",
1132
+ "Saved.": "已收藏。",
1133
+ "Saved to {name}.": "已收藏到“{name}”。",
1134
+ "Use /library, /library play <name>, or /library save [playlist].": "用 /library、/library play <名字> 或 /library save [播放列表]。",
1135
+ "Your saved songs and playlists": "你收藏的歌和播放列表",
1136
+ "Look in the library": "翻翻资料库",
1137
+ "Save to the library": "收进资料库",
1138
+ "Play from the library": "播放资料库里的歌",
1122
1139
  };
@@ -12,6 +12,8 @@ const READ_TIMEOUT_MS = 15_000;
12
12
  // A playlist longer than this is read as a window around the playing song.
13
13
  const PLAYLIST_WINDOW = 50;
14
14
  const PLAYLIST_BEFORE = 5;
15
+ // Keeping the rest of a playlist reads up to as many songs as a library playlist holds.
16
+ const REST_MAX = 2_000;
15
17
  const COLUMN_READ_MAX = 2_000;
16
18
  // Covers already drawn, by album, or by song for one without an album.
17
19
  const KNOWN_COVERS = 40;
@@ -47,13 +49,15 @@ function run(argv) {
47
49
  const playlist = music.currentPlaylist;
48
50
  answer.playlist = { id: playlist.persistentID(), name: playlist.name() };
49
51
  try { answer.playlist.special = playlist.specialKind(); } catch (error) {}
50
- if (action === "tracks") {
52
+ if (action === "tracks" || action === "rest") {
51
53
  const tracks = playlist.tracks;
52
54
  const ids = tracks.persistentID();
53
55
  answer.playlist.count = ids.length;
54
56
  const at = answer.track ? ids.indexOf(answer.track.id) : -1;
55
- const start = ids.length <= ${PLAYLIST_WINDOW} || at < 0 ? 0 : Math.max(0, Math.min(at - ${PLAYLIST_BEFORE}, ids.length - ${PLAYLIST_WINDOW}));
56
- const end = Math.min(ids.length, start + ${PLAYLIST_WINDOW});
57
+ // "rest" reads from the playing song to the playlist's end, for keeping it in the library.
58
+ const start = action === "rest" ? Math.max(0, at)
59
+ : ids.length <= ${PLAYLIST_WINDOW} || at < 0 ? 0 : Math.max(0, Math.min(at - ${PLAYLIST_BEFORE}, ids.length - ${PLAYLIST_WINDOW}));
60
+ const end = Math.min(ids.length, start + (action === "rest" ? ${REST_MAX} : ${PLAYLIST_WINDOW}));
57
61
  answer.playlist.index = at;
58
62
  answer.playlist.start = start;
59
63
  answer.playlist.tracks = [];
@@ -197,6 +201,8 @@ export function createMusicAppPlayer({ platform = process.platform, run = osascr
197
201
  return {
198
202
  available,
199
203
  nowPlaying: ({ tracks = false } = {}) => read(tracks ? "tracks" : "read"),
204
+ // The playing song and the rest of its playlist, past the window nowPlaying reads, for keeping in the library.
205
+ remainingPlaylist: () => read("rest"),
200
206
  pause: () => read("pause"),
201
207
  resume: () => read("resume"),
202
208
  next: () => read("next"),
@@ -19,6 +19,8 @@ const systemPlayers = {
19
19
  ],
20
20
  };
21
21
  const longestQueue = 30;
22
+ // A library playlist plays as many entries as the application sends it, as the window does.
23
+ const longestPlaylist = 50;
22
24
  const largestPreview = 10 * 1024 * 1024;
23
25
  // Some routes to Apple's audio host take over 20 seconds for one preview.
24
26
  const downloadTimeoutMs = 60_000;
@@ -213,13 +215,29 @@ export async function createTerminalPreviewPlayer({
213
215
  });
214
216
  start(accepted);
215
217
  },
218
+ // A playlist from the listener's library: Apple Music previews and tracks Moondog made, in one queue.
219
+ // A song is { title, artist_credit, catalog_url, preview_url }; a made track { generated: true, id, title, file }.
220
+ playList(tracks) {
221
+ if (closed) return;
222
+ const accepted = (Array.isArray(tracks) ? tracks : []).map((track) => (track?.generated === true
223
+ ? { title: String(track.title ?? "").trim(), artist_credit: "", generated: true, id: String(track.id ?? ""),
224
+ file: typeof track.file === "string" && path.isAbsolute(track.file) ? track.file : null }
225
+ : { title: String(track?.title ?? "").trim(), artist_credit: String(track?.artist_credit ?? "").trim(),
226
+ catalog_url: https(track?.catalog_url, linkHost), preview: https(track?.preview_url, previewHost) }))
227
+ .filter((track) => track.title && (track.generated ? track.file : track.catalog_url && track.preview)).slice(0, longestPlaylist);
228
+ if (!accepted.length) return;
229
+ stop();
230
+ emit({ type: "queue", tracks: accepted.map(({ title, artist_credit, catalog_url, generated, id }) => (generated
231
+ ? { title, artist_credit: "", generated: true, id } : { title, artist_credit, catalog_url })), album: null });
232
+ start(accepted);
233
+ },
216
234
  // A track Moondog made on this Mac: { id, title, file }, played whole and kept after it ends.
217
235
  playGenerated({ id, title, file } = {}) {
218
236
  const name = String(title ?? "").trim();
219
237
  if (closed || !name || typeof file !== "string" || !path.isAbsolute(file)) return;
220
238
  stop();
221
239
  const track = { title: name, artist_credit: "", generated: true, id: String(id ?? "") };
222
- emit({ type: "queue", tracks: [{ title: track.title, artist_credit: "", generated: true }], album: null });
240
+ emit({ type: "queue", tracks: [{ title: track.title, artist_credit: "", generated: true, id: track.id }], album: null });
223
241
  start([{ ...track, file }]);
224
242
  },
225
243
  // A made track queued behind the one playing, as Moondog's stream does; with nothing playing, it plays now.
@@ -228,7 +246,7 @@ export async function createTerminalPreviewPlayer({
228
246
  if (closed || !name || typeof file !== "string" || !path.isAbsolute(file)) return;
229
247
  if (!queue || !queue.tracks.every((track) => track.generated)) return this.playGenerated({ id, title, file });
230
248
  queue.tracks.push({ title: name, artist_credit: "", generated: true, id: String(id ?? ""), file });
231
- now.appended({ title: name, generated: true });
249
+ now.appended({ title: name, generated: true, id: String(id ?? "") });
232
250
  },
233
251
  // Ends the song now playing, or still loading; the next one starts after it.
234
252
  skip() {
@@ -240,6 +258,8 @@ export async function createTerminalPreviewPlayer({
240
258
  stop,
241
259
  // What plays now, as createPlayerNow reads it: null, or { state, song, position, count, next }.
242
260
  nowPlaying: () => (closed ? null : now.read()),
261
+ // The song playing and every one after it, for keeping the whole queue in the library.
262
+ remaining: () => (closed ? [] : now.remaining()),
243
263
  // Leaving Apple Music stops its previews here, never the tracks Moondog made.
244
264
  holdsCatalogSongs: () => Boolean(queue?.tracks.some((track) => !track.generated)),
245
265
  subscribe(listener) {
@@ -229,6 +229,8 @@ export function createLocalGenerator({
229
229
  arch = process.arch,
230
230
  spawnImpl = spawn,
231
231
  now = () => new Date(),
232
+ // Whether the listener's library holds a made track: a stream never drops the audio of one they kept.
233
+ keeps = () => false,
232
234
  } = {}) {
233
235
  let current = { state: "unknown", enabled: false, install: "unknown", path: null, source: null, model: DEFAULT_STABLE_AUDIO_MODEL, managed: false };
234
236
  let running = null;
@@ -437,6 +439,9 @@ export function createLocalGenerator({
437
439
  close(false);
438
440
  playing = { id: track.id, heardMs: 0, since: Date.now() };
439
441
  } else if (playing.since === null) playing.since = Date.now();
442
+ } else if (event?.type === "playing" && playing && track && track.generated !== true) {
443
+ // A song from the catalog after a made track in a library playlist: the made track's play ended there.
444
+ close(false);
440
445
  } else if (event?.type === "paused" && playing && playing.since !== null) {
441
446
  playing.heardMs += Date.now() - playing.since;
442
447
  playing.since = null;
@@ -452,9 +457,11 @@ export function createLocalGenerator({
452
457
  /** Waits for the outcomes still being written, as a room does before it closes. */
453
458
  flush: () => Promise.allSettled([...writing]),
454
459
  // A replay of the track playing now starts it over; that is not a skip.
455
- // A stream keeps only its newest tracks' audio; the receipt and outcomes of the rest stay.
460
+ // A stream keeps only its newest tracks' audio; the receipt and outcomes of the rest stay. A track in the
461
+ // listener's library keeps its audio, and a library that can't be read keeps it too.
456
462
  async removeAudio(id) {
457
463
  if (!isGeneratedTrackId(id)) return;
464
+ try { if (keeps(id)) return; } catch { return; }
458
465
  await rm(path.join(tracksDirectory(), id, "track.wav"), { force: true }).catch(() => {});
459
466
  const entry = made.get(id);
460
467
  if (entry) entry.audio_kept = false;