@audiofool/moondog 0.2.0-beta.2 → 0.2.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/core/apple-projection-domain-services.mjs +95 -27
- package/src/core/capability-catalog.mjs +12 -0
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-profile-domain-services.mjs +44 -8
- package/src/core/moondog-application.mjs +238 -34
- package/src/core/spotify-listening-intent.mjs +5 -2
- package/src/core/track-title.mjs +107 -0
- package/src/i18n/messages/de.mjs +7 -0
- package/src/i18n/messages/en.mjs +7 -0
- package/src/i18n/messages/es.mjs +7 -0
- package/src/i18n/messages/fr.mjs +7 -0
- package/src/i18n/messages/it.mjs +7 -0
- package/src/i18n/messages/ja.mjs +7 -0
- package/src/i18n/messages/ko.mjs +7 -0
- package/src/i18n/messages/pt.mjs +7 -0
- package/src/i18n/messages/zh-Hant.mjs +7 -0
- package/src/i18n/messages/zh.mjs +7 -0
- package/src/i18n/screen/es.mjs +1 -0
- package/src/i18n/screen/ja.mjs +1 -0
- package/src/i18n/screen/pt.mjs +1 -0
- package/src/i18n/screen/zh.mjs +1 -0
- package/src/integrations/apple-music/catalog.mjs +52 -9
- package/src/integrations/netease/ncm-cli-player.mjs +10 -3
- package/src/integrations/spotify/catalog-resolver.mjs +1 -2
- package/src/integrations/spotify/web-api-client.mjs +3 -0
- package/src/memory/local-memory-store.mjs +73 -16
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +92 -9
- package/src/profile/listener-profile-worker.mjs +7 -0
- package/src/profile/listening-history-store.mjs +231 -24
- package/src/profile/listening-profile-projection.mjs +212 -10
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/profile-exploration.mjs +16 -8
- package/src/runtime/pi/agent-runtime.mjs +363 -118
- package/src/runtime/pi/profile-build-runtime.mjs +2 -1
- package/src/runtime/pi/skills/listener-profile/SKILL.md +2 -0
- package/src/surfaces/cli/terminal-art.mjs +38 -16
- package/src/surfaces/cli/web-command.mjs +1 -1
|
@@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
4
|
import { compactListenerProfile, readListenerProfile } from "../profile/listener-profile-build.mjs";
|
|
5
|
+
import { mixCounts, songKeys } from "../profile/moondog-suggestions.mjs";
|
|
6
|
+
import { ARTIST_CREDIT_SEPARATORS } from "./listener-avoids.mjs";
|
|
7
|
+
import { textNamesSong } from "./track-title.mjs";
|
|
5
8
|
|
|
6
9
|
import {
|
|
7
10
|
readAppleMusicSourceStatus,
|
|
@@ -35,7 +38,7 @@ const spotifyPlaybackCapabilities = new Set([
|
|
|
35
38
|
const neteaseCapabilities = new Set(["netease.search", "netease.play", "netease.control", "netease.now_playing"]);
|
|
36
39
|
|
|
37
40
|
// What the model sees of a retained Spotify item: no provider identifiers.
|
|
38
|
-
function modelSpotifyItem({ uri: _uri, album_uri: _albumUri, ...item }) {
|
|
41
|
+
function modelSpotifyItem({ uri: _uri, album_uri: _albumUri, isrc: _isrc, ...item }) {
|
|
39
42
|
return structuredClone(item);
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -53,6 +56,23 @@ function spotifyResolutionError(code, message) {
|
|
|
53
56
|
return error;
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
// A ledger entry's artists: its own list, or its credit whole and split into its artists.
|
|
60
|
+
const creditedArtists = (track) => Array.isArray(track.artists) && track.artists.length ? track.artists
|
|
61
|
+
: typeof track.artistCredit === "string" ? [track.artistCredit, ...track.artistCredit.split(ARTIST_CREDIT_SEPARATORS)] : [];
|
|
62
|
+
|
|
63
|
+
// The trusted songs of a pending plan that are still in it.
|
|
64
|
+
const keptTrusted = (draft, kept) => {
|
|
65
|
+
const refs = new Set(kept.map((track) => track.track_ref_id));
|
|
66
|
+
return draft.trustedTracks.filter((track) => refs.has(track.track_ref_id));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// The plan songs an Avoid left out of a play, queue or save, by name.
|
|
70
|
+
const skippedAvoided = (avoided) => avoided.length ? { skipped_avoided: avoided, skipped_avoided_count: avoided.length } : {};
|
|
71
|
+
|
|
72
|
+
// A play the listener's Avoid blocks: nothing is sent, and the model can say why.
|
|
73
|
+
const listenerAvoidError = () => spotifyResolutionError("listener_avoid_blocked",
|
|
74
|
+
"Nothing was sent. The listener asked to keep this artist or song out of what Moondog plays. Tell them so; they can lift it by asking.");
|
|
75
|
+
|
|
56
76
|
// Only the host's current user message can grant a quick edit. Model tool
|
|
57
77
|
// arguments and Spotify metadata never supply authorization. Exact ordinary
|
|
58
78
|
// commands and quoted names work; ambiguity uses the existing preview path.
|
|
@@ -319,10 +339,15 @@ export class MoondogApplication {
|
|
|
319
339
|
}
|
|
320
340
|
|
|
321
341
|
async getListenerProfile(input) {
|
|
322
|
-
|
|
342
|
+
const getChoices = () => this.domainServices?.listenerChoices?.() ?? null;
|
|
343
|
+
if (!this.profileBuildReady()) {
|
|
344
|
+
const choices = getChoices();
|
|
345
|
+
return { state: "missing", total_claims: 0, claims: [], next_offset: null, ...(choices ? { listener_choices: choices } : {}) };
|
|
346
|
+
}
|
|
323
347
|
return readListenerProfile({
|
|
324
348
|
store: this.domainServices.getListenerProfileStore(),
|
|
325
349
|
getInput: () => this.getProfileBuildContext().input,
|
|
350
|
+
getChoices,
|
|
326
351
|
}, input);
|
|
327
352
|
}
|
|
328
353
|
|
|
@@ -498,14 +523,29 @@ export class MoondogApplication {
|
|
|
498
523
|
return this.neteasePlayer;
|
|
499
524
|
}
|
|
500
525
|
|
|
501
|
-
searchNetease({ query, limit, intent } = {}) {
|
|
502
|
-
|
|
526
|
+
async searchNetease({ query, limit, intent } = {}) {
|
|
527
|
+
const songs = await this.requireNetease().search({ query, limit, intent });
|
|
528
|
+
return Array.isArray(songs) ? this.#withSuggestedBefore(songs, song => ({ service: "netease",
|
|
529
|
+
providerTrackId: song.id ? `netease:${song.id}` : null, title: song.title, artists: song.artists })) : songs;
|
|
503
530
|
}
|
|
504
531
|
|
|
505
532
|
async playNetease({ items, mode, intent } = {}) {
|
|
506
533
|
const player = this.requireNetease();
|
|
534
|
+
this.requireListenerAvoids();
|
|
507
535
|
if (mode !== "queue") await this.quietOtherPlayers("netease");
|
|
508
|
-
|
|
536
|
+
// A found song lists its artists; a song asked for by name has only the name it was asked by.
|
|
537
|
+
const receipt = await player.play({ items, mode, intent, allow: (song) => this.avoidAllows(Array.isArray(song.artists)
|
|
538
|
+
? { title: song.title, artist_credit: song.artists.join(", "), artists: song.artists } : song) });
|
|
539
|
+
if (["playing", "queued"].includes(receipt?.mode)) {
|
|
540
|
+
const songs = receipt.songs ?? [];
|
|
541
|
+
const slots = this.#mixSlots(songs.map((song) => ({ title: song.title, artists: song.artists })));
|
|
542
|
+
this.#noteSuggestions("netease", songs.map((song, index) => ({ action: receipt.mode === "queued" ? "netease.queue" : "netease.play",
|
|
543
|
+
providerTrackId: song.id ? `netease:${song.id}` : null, title: song.title, artists: song.artists, mixSlot: slots[index] })));
|
|
544
|
+
// One song needs no mix line.
|
|
545
|
+
const mix = songs.length > 1 ? mixCounts(slots) : null;
|
|
546
|
+
if (mix) return { ...receipt, mix };
|
|
547
|
+
}
|
|
548
|
+
return receipt;
|
|
509
549
|
}
|
|
510
550
|
|
|
511
551
|
async controlNetease({ action, positionSeconds } = {}) {
|
|
@@ -642,29 +682,45 @@ export class MoondogApplication {
|
|
|
642
682
|
throw new Error("Give either up to six songs or one album.");
|
|
643
683
|
}
|
|
644
684
|
let found;
|
|
685
|
+
let avoided = 0;
|
|
645
686
|
if (album) {
|
|
646
687
|
const result = await this.musicCatalog.findAlbumPreviews({ title: album.title, artist: album.artist });
|
|
647
|
-
|
|
688
|
+
const songs = result.tracks ?? [];
|
|
689
|
+
const played = songs.filter((track) => this.avoidAllows(track));
|
|
690
|
+
avoided = songs.length - played.length;
|
|
691
|
+
found = { kind: "album", album: result.album, played, not_found: result.state === "resolved" ? [] : [album] };
|
|
648
692
|
} else {
|
|
649
693
|
const played = [];
|
|
650
694
|
const notFound = [];
|
|
651
695
|
const leftOut = [];
|
|
652
696
|
for (const wanted of tracks.slice(0, 6)) {
|
|
653
697
|
if (byExcluded(wanted.artist)) { leftOut.push(wanted); continue; }
|
|
698
|
+
if (!this.avoidAllows({ title: wanted.title, artist_credit: wanted.artist })) { avoided += 1; continue; }
|
|
654
699
|
const result = await this.musicCatalog.findPreview({ title: wanted.title, artist: wanted.artist });
|
|
655
700
|
if (result.state !== "resolved") notFound.push(wanted);
|
|
656
701
|
else if (byExcluded(result.track.artist_credit)) leftOut.push(wanted);
|
|
702
|
+
else if (!this.avoidAllows(result.track)) avoided += 1;
|
|
657
703
|
else played.push(result.track);
|
|
658
704
|
}
|
|
659
705
|
found = { kind: "tracks", played, not_found: notFound, left_out: leftOut };
|
|
660
706
|
}
|
|
707
|
+
if (avoided) found.skipped_avoided_count = avoided;
|
|
661
708
|
if (found.played.length) {
|
|
662
709
|
await this.quietOtherPlayers("apple_music");
|
|
663
710
|
await this.musicPreviewPlayer.play(structuredClone(found.played), found.album ? { album: structuredClone(found.album) } : {});
|
|
664
711
|
}
|
|
665
712
|
// The desktop window plays whole songs where the listener's Apple Music allows it; a terminal plays previews.
|
|
666
713
|
const whole = this.musicPreviewPlayer.wholeSongs?.() === true;
|
|
667
|
-
|
|
714
|
+
const slots = this.#mixSlots(found.played.map((track) => ({ title: track.title, artist_credit: track.artist_credit })));
|
|
715
|
+
// An album the listener named is theirs, every song of it.
|
|
716
|
+
const albumArtists = [found.album?.artist_credit, album?.artist].filter((name) => typeof name === "string");
|
|
717
|
+
if (!(found.album && (this.#listenerNamed(found.album.title, albumArtists) || this.#listenerNamed(album.title, albumArtists)))) {
|
|
718
|
+
this.#noteSuggestions("apple_music", found.played.map((track, index) => ({ action: whole ? "apple_music.play" : "apple_music.preview",
|
|
719
|
+
providerTrackId: track.catalog_id ? `apple_music:${track.catalog_id}` : null, isrc: track.isrc, title: track.title,
|
|
720
|
+
artistCredit: track.artist_credit, mixSlot: slots[index] })));
|
|
721
|
+
}
|
|
722
|
+
const mix = found.played.length > 1 ? mixCounts(slots) : null;
|
|
723
|
+
return { state: found.played.length ? "resolved" : "not_found", ...found, ...(mix ? { mix } : {}), whole_songs: whole };
|
|
668
724
|
}
|
|
669
725
|
|
|
670
726
|
musicDiscoveryReady() {
|
|
@@ -737,7 +793,7 @@ export class MoondogApplication {
|
|
|
737
793
|
...(stored ? { stored_plays: stored } : {}),
|
|
738
794
|
...(spotify ? { spotify_top_artists_4_weeks: {
|
|
739
795
|
read_at: spotify.read_at, artists: spotify.artists,
|
|
740
|
-
basis: "Spotify's affinity ranking over about four weeks, on every device; not play counts.",
|
|
796
|
+
basis: "Spotify's affinity ranking over about four weeks, on every device; not play counts. It also counts songs Moondog played or queued, which stored_plays.moondog_started_plays counts apart.",
|
|
741
797
|
} } : {}),
|
|
742
798
|
};
|
|
743
799
|
}
|
|
@@ -1500,6 +1556,7 @@ export class MoondogApplication {
|
|
|
1500
1556
|
typeof item.album_uri === "string" && /^spotify:album:[A-Za-z0-9]{1,128}$/u.test(item.album_uri) ? item.album_uri :
|
|
1501
1557
|
this.spotifyKnownItems.get(result.item_ref_id)?.album_uri;
|
|
1502
1558
|
if (albumUri) result.album_uri = albumUri;
|
|
1559
|
+
if (typeof item.isrc === "string" && /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/iu.test(item.isrc)) result.isrc = item.isrc.toUpperCase();
|
|
1503
1560
|
this.spotifyRefsByUri.set(item.uri, result.item_ref_id);
|
|
1504
1561
|
// The newest read of an item wins; the oldest items leave first past the bound.
|
|
1505
1562
|
this.spotifyKnownItems.delete(result.item_ref_id);
|
|
@@ -1569,7 +1626,7 @@ export class MoondogApplication {
|
|
|
1569
1626
|
return albumUri ? { contextUri: albumUri, offsetUri: uri } : { uris: [uri] };
|
|
1570
1627
|
}
|
|
1571
1628
|
|
|
1572
|
-
#resumeSpotifySelection(parameters, { signal } = {}) {
|
|
1629
|
+
#resumeSpotifySelection(parameters, { signal, played = null } = {}) {
|
|
1573
1630
|
const transaction = this.pendingPlaylistPromptTransaction;
|
|
1574
1631
|
// An album started at one song plays that song: it counts as the same selection as
|
|
1575
1632
|
// the song alone, and its target names the song, not the album.
|
|
@@ -1587,10 +1644,20 @@ export class MoondogApplication {
|
|
|
1587
1644
|
return Promise.resolve({ ...receipt, already: true });
|
|
1588
1645
|
}
|
|
1589
1646
|
const items = (songs ?? (context ? [context] : [])).map(uri => this.spotifyHostReadItems().find(item => item.uri === uri)).filter(Boolean).map(item => structuredClone(item));
|
|
1647
|
+
if (!items.every(item => this.#spotifyItemAllowed(item))) throw listenerAvoidError();
|
|
1590
1648
|
const attempt = parameters.uris || parameters.contextUri ? { parameters: structuredClone(parameters), items,
|
|
1591
1649
|
observedAt: this.spotifyContextClock(), turn: this.spotifyContextTurn, epoch: this.spotifyContextEpoch, outcome: "unconfirmed" } : null;
|
|
1592
1650
|
if (attempt) this.spotifyPlaybackAttempt = attempt;
|
|
1593
1651
|
const targets = items.map(item => ({ title: item.name ?? "Spotify item", artist_credit: item.artists.join(", ") }));
|
|
1652
|
+
// The songs this selection starts. An album started whole or at one song goes on playing,
|
|
1653
|
+
// so it goes in the ledger by its release. A playlist or an artist names no song until it plays.
|
|
1654
|
+
const album = parameters.contextUri?.startsWith("spotify:album:")
|
|
1655
|
+
? parameters.offsetUri ? items.find(item => item.uri === parameters.offsetUri && item.type === "track" && item.album)
|
|
1656
|
+
: items.find(item => item.uri === parameters.contextUri && item.type === "album")
|
|
1657
|
+
: null;
|
|
1658
|
+
played ??= [...(songs ?? []).flatMap(uri => items.filter(item => item.uri === uri && item.type === "track").slice(0, 1)
|
|
1659
|
+
.map(item => ({ providerTrackId: uri, isrc: item.isrc, title: item.name, artists: item.artists }))),
|
|
1660
|
+
...(album ? [{ action: "play.album", release: album.type === "album" ? album.name : album.album, artists: album.artists }] : [])];
|
|
1594
1661
|
const explicitDevice = parameters.deviceId !== undefined;
|
|
1595
1662
|
return this.requireSpotifyService().resume(parameters, { signal,
|
|
1596
1663
|
preferredDeviceId: this.spotifyPreferredPlaybackDevice,
|
|
@@ -1604,6 +1671,7 @@ export class MoondogApplication {
|
|
|
1604
1671
|
},
|
|
1605
1672
|
}).then(receipt => {
|
|
1606
1673
|
const result = { ...receipt, ...(targets.length ? { targets } : {}) };
|
|
1674
|
+
if (receipt.state === "accepted") this.#noteSuggestions("spotify", played.map(track => ({ action: "play", ...track })));
|
|
1607
1675
|
if (key && transaction && receipt.state === "accepted") {
|
|
1608
1676
|
transaction.lastAcceptedPlaybackSelection = key;
|
|
1609
1677
|
transaction.lastAcceptedPlaybackReceipt = { ...structuredClone(result), deviceId: attempt?.parameters.deviceId };
|
|
@@ -1710,9 +1778,56 @@ export class MoondogApplication {
|
|
|
1710
1778
|
}
|
|
1711
1779
|
|
|
1712
1780
|
spotifyDiscoveryAllowed(item) {
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1781
|
+
return this.avoidAllows({ title: item.name, artist_credit: item.artists.join(", "), artists: item.artists });
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// A song by its title and artists, an album or an artist by its artists. A playlist, a show or an
|
|
1785
|
+
// episode names no artist the listener avoids.
|
|
1786
|
+
#spotifyItemAllowed(item) {
|
|
1787
|
+
const artists = Array.isArray(item?.artists) ? item.artists : [];
|
|
1788
|
+
if (item?.type === "track") return this.spotifyDiscoveryAllowed({ name: item.name, artists });
|
|
1789
|
+
if (item?.type === "album") return this.avoidAllows({ artist_credit: artists.join(", "), artists });
|
|
1790
|
+
if (item?.type === "artist") return this.avoidAllows({ artist_credit: item.name, artists: [item.name] });
|
|
1791
|
+
return true;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// A pending plan's songs, checked again when it plays, queues or is saved, and read as the
|
|
1795
|
+
// candidate gate read them: a history candidate names its first credited artist whole, so
|
|
1796
|
+
// "Earth, Wind & Fire" stays one artist. A song avoided since the plan is left out and named
|
|
1797
|
+
// in the receipt; only a plan with nothing left is refused.
|
|
1798
|
+
#withoutAvoidedPlanTracks(tracks) {
|
|
1799
|
+
const avoided = tracks.filter((track) => {
|
|
1800
|
+
if (!track) return false;
|
|
1801
|
+
const artists = Array.isArray(track.artists) ? track.artists
|
|
1802
|
+
: track.candidate_scope === "private_history" ? [track.artist_credit] : null;
|
|
1803
|
+
return !this.avoidAllows({ title: track.title, artist_credit: track.artist_credit, ...(artists ? { artists } : {}) });
|
|
1804
|
+
});
|
|
1805
|
+
if (avoided.length && avoided.length === tracks.length) throw listenerAvoidError();
|
|
1806
|
+
return { kept: tracks.filter((track) => !avoided.includes(track)),
|
|
1807
|
+
avoided: avoided.map((track) => ({ title: track.title, artist_credit: track.artist_credit })) };
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// Avoids that exist but could not be loaded stop every play path, rather than letting them through.
|
|
1811
|
+
requireListenerAvoids() {
|
|
1812
|
+
if (!this.domainServices?.filterDiscoveryTracks && this.domainServicesError && !["projection_not_built", "projection_subject_unavailable"].includes(this.domainServicesError)) throw spotifyResolutionError("listener_avoids_unavailable", "The listener's Avoids could not be loaded, so nothing was played. Restore the profile first.");
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// The listener's Avoids, checked the same way on every play path: { title, artist_credit, artists }.
|
|
1816
|
+
avoidAllows(track) {
|
|
1817
|
+
this.requireListenerAvoids();
|
|
1818
|
+
return this.domainServices?.filterDiscoveryTracks?.([track]).length !== 0;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// A Like, an Avoid or its retraction that the listener stated, saved as the profile command
|
|
1822
|
+
// saves it, so the profile, the Avoid check and the saved reading all see it at once.
|
|
1823
|
+
recordTasteChoice({ entityType, label, artistCredit, stance } = {}) {
|
|
1824
|
+
if (!this.tasteChoicesReady()) throw new Error("Choices can be saved once listening history is imported.");
|
|
1825
|
+
return this.domainServices.recordListenerChoice({ entityType, label, artistCredit, stance });
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
tasteChoicesReady() {
|
|
1829
|
+
return typeof this.domainServices?.recordListenerChoice === "function" &&
|
|
1830
|
+
this.domainServices.listenerChoicesReady?.() !== false;
|
|
1716
1831
|
}
|
|
1717
1832
|
|
|
1718
1833
|
consumeSpotifyDiscoveryQueries(count) {
|
|
@@ -1761,7 +1876,8 @@ export class MoondogApplication {
|
|
|
1761
1876
|
truncated ||= result.truncated === true || result.has_more === true;
|
|
1762
1877
|
}
|
|
1763
1878
|
const items = this.spotifyReadSelections.get("discovery") ?? [];
|
|
1764
|
-
return { provider: "spotify", items: items.
|
|
1879
|
+
return { provider: "spotify", items: this.#withSuggestedBefore(items, item => ({ providerTrackId: item.uri, isrc: item.isrc, title: item.name, artists: item.artists }))
|
|
1880
|
+
.map(modelSpotifyItem),
|
|
1765
1881
|
queries_used: used + queries.length, queries_remaining: 6 - used - queries.length,
|
|
1766
1882
|
state: items.length ? "verified_candidates" : failures.length ? "unavailable" : "no_matches",
|
|
1767
1883
|
failures, skipped_avoided_count: skippedAvoids, skipped_known_count: skippedKnown, truncated,
|
|
@@ -1803,11 +1919,11 @@ export class MoondogApplication {
|
|
|
1803
1919
|
throw error;
|
|
1804
1920
|
}
|
|
1805
1921
|
const seenUris = this.#observedQueueUris(observed, player);
|
|
1806
|
-
const tracks = items.map(item => ({ track_ref_id: item.item_ref_id, title: item.name, artist_credit: item.artists.join(", "), artists: item.artists }));
|
|
1922
|
+
const tracks = items.map(item => ({ track_ref_id: item.item_ref_id, title: item.name, artist_credit: item.artists.join(", "), artists: item.artists, ...(item.isrc ? { isrc: item.isrc } : {}) }));
|
|
1807
1923
|
const resolved = new Map(items.map(item => [item.item_ref_id, item.uri]));
|
|
1808
1924
|
this.transientSpotifyContext = true;
|
|
1809
1925
|
const filters = { filterDiscoveryTracks: tracks => tracks.filter(track => this.spotifyDiscoveryAllowed({ name: track.title, artists: track.artists })) };
|
|
1810
|
-
const receipt = await this.#queueSpotifyTracks(tracks, resolved, { signal, deviceId, seenUris, limit: requested, domainServices: filters,
|
|
1926
|
+
const receipt = await this.#queueSpotifyTracks(tracks, resolved, { signal, deviceId, seenUris, limit: requested, domainServices: filters, ledgerAction: "queue.batch",
|
|
1811
1927
|
knownTrack: intent?.excludeKnown ? track => this.domainServices?.isKnownDiscoveryTrack?.(track) === true : null });
|
|
1812
1928
|
if (!receipt.queued.length && !receipt.stopped) transaction.queueBatchAttempted = false;
|
|
1813
1929
|
const result = { ...receipt, action: "queue.batch", requested, queued_count: receipt.queued.length,
|
|
@@ -1833,6 +1949,57 @@ export class MoondogApplication {
|
|
|
1833
1949
|
return seen;
|
|
1834
1950
|
}
|
|
1835
1951
|
|
|
1952
|
+
// What Moondog itself started goes in the listening history's ledger, written from accepted
|
|
1953
|
+
// host receipts only, so its picks never come back as the listener's own plays. A ledger
|
|
1954
|
+
// that cannot be written never undoes the playback. On every path, song by song, a song or
|
|
1955
|
+
// album the listener named in this turn or the two before it is their own choice and stays
|
|
1956
|
+
// out of the ledger, and the songs Moondog added around it stay Moondog's.
|
|
1957
|
+
#noteSuggestions(service, tracks) {
|
|
1958
|
+
tracks = this.#withoutListenerNamed(tracks);
|
|
1959
|
+
if (!tracks.length || typeof this.domainServices?.recordMoondogSuggestions !== "function") return;
|
|
1960
|
+
const requestedAt = new Date(this.spotifyContextClock()).toISOString();
|
|
1961
|
+
const entries = tracks.slice(0, 50);
|
|
1962
|
+
const slots = entries.some((track) => track.mixSlot === undefined)
|
|
1963
|
+
? this.#mixSlots(entries.map((track) => ({ title: track.title, artists: track.artists, artist_credit: track.artistCredit })))
|
|
1964
|
+
: [];
|
|
1965
|
+
try {
|
|
1966
|
+
this.domainServices.recordMoondogSuggestions(entries.map((track, index) => ({ service, requestedAt, mixSlot: slots[index], ...track })));
|
|
1967
|
+
} catch { /* The music still plays; only its origin goes unrecorded. */ }
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// An album row goes with the song it started at: named song or named album, it is the listener's.
|
|
1971
|
+
#withoutListenerNamed(tracks) {
|
|
1972
|
+
const named = (track) => this.#listenerNamed(track.title ?? track.release, creditedArtists(track));
|
|
1973
|
+
const songs = tracks.filter((track) => track.title);
|
|
1974
|
+
if (songs.some(named)) return songs.filter((track) => !named(track));
|
|
1975
|
+
return tracks.filter((track) => track.title || !named(track));
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// Named means one message names the title with one of its artists, or quotes the title.
|
|
1979
|
+
#listenerNamed(title, artists) {
|
|
1980
|
+
return typeof title === "string" &&
|
|
1981
|
+
(this.pendingPlaylistPromptTransaction?.listenerTexts ?? []).some((text) => textNamesSong(text, title, artists));
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// Which candidates Moondog suggested in earlier sessions, so the model can vary its picks.
|
|
1985
|
+
#withSuggestedBefore(items, track) {
|
|
1986
|
+
let history = null;
|
|
1987
|
+
try {
|
|
1988
|
+
history = items.length ? this.domainServices?.moondogSuggestionHistory?.(items.map(track)) : null;
|
|
1989
|
+
} catch { /* Without the ledger, candidates carry no history. */ }
|
|
1990
|
+
return items.map((item, index) => history?.[index] ? { ...item, suggested_before: history[index] } : item);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// Where each song sits in the listener's own plays; unknown without a history.
|
|
1994
|
+
#mixSlots(tracks) {
|
|
1995
|
+
try {
|
|
1996
|
+
const slots = tracks.length ? this.domainServices?.listeningMix?.(tracks) : null;
|
|
1997
|
+
return Array.isArray(slots) && slots.length === tracks.length ? slots : tracks.map(() => "unknown");
|
|
1998
|
+
} catch {
|
|
1999
|
+
return tracks.map(() => "unknown");
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1836
2003
|
// Spotify's own queue is the truth. An accepted write can take a moment to
|
|
1837
2004
|
// appear, so look twice before calling an entry unconfirmed.
|
|
1838
2005
|
async #verifyQueued(uris, { signal } = {}) {
|
|
@@ -1893,7 +2060,8 @@ export class MoondogApplication {
|
|
|
1893
2060
|
if (queueCancellationRequested(this.pendingPlaylistPromptTransaction?.userText)) throw spotifyResolutionError("spotify_queue_cancelled", "The listener asked to stop queueing. No write was sent.");
|
|
1894
2061
|
if (this.pendingPlaylistPromptTransaction?.queueIntent && this.spotifyQueueRequest) this.spotifyQueueRequest.attempted = true;
|
|
1895
2062
|
const item = this.spotifyHostReadItems().find(item => item.uri === uri);
|
|
1896
|
-
|
|
2063
|
+
if (item && !this.#spotifyItemAllowed(item)) throw listenerAvoidError();
|
|
2064
|
+
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 } : {}) };
|
|
1897
2065
|
// One entry takes the same path as a batch: one device decision, start
|
|
1898
2066
|
// playback when nothing is playing, and a check of Spotify's real queue.
|
|
1899
2067
|
const receipt = await this.#queueSpotifyTracks([track], new Map([["single", uri]]), { signal, deviceId: input.deviceId, rethrow: true });
|
|
@@ -1949,7 +2117,7 @@ export class MoondogApplication {
|
|
|
1949
2117
|
const resolvedByRef = new Map((resolution.resolutions ?? [])
|
|
1950
2118
|
.filter((entry) => entry.status === "resolved" && /^spotify:track:[A-Za-z0-9]{1,128}$/u.test(entry.spotify?.uri ?? ""))
|
|
1951
2119
|
.map((entry) => [entry.track_ref_id, entry.spotify.uri]));
|
|
1952
|
-
const receipt = await this.#queueSpotifyTracks(tracks, resolvedByRef, { signal, seenUris, limit: count, domainServices });
|
|
2120
|
+
const receipt = await this.#queueSpotifyTracks(tracks, resolvedByRef, { signal, seenUris, limit: count, domainServices, ledgerAction: "queue.similar" });
|
|
1953
2121
|
this.recordPromptDiscoverySource(registered.source);
|
|
1954
2122
|
const result = { ...receipt, action: "queue.similar", requested: count, queued_count: receipt.queued.length,
|
|
1955
2123
|
shortfall: count - receipt.queued.length, seed_artist: seedArtist, queue_observation_truncated: observed.truncated === true };
|
|
@@ -2731,7 +2899,8 @@ export class MoondogApplication {
|
|
|
2731
2899
|
const details = spotifyPlaylistDetails({ name, description });
|
|
2732
2900
|
this.requireSpotifyWriteScopes(["playlist-modify-private"]);
|
|
2733
2901
|
const uris = this.requireSpotifyTrackUris(trackRefs);
|
|
2734
|
-
|
|
2902
|
+
const planned = new Map((this.pendingSpotifyPlaylist?.plan?.tracks ?? []).map((track) => [track.track_ref_id, track]));
|
|
2903
|
+
return this.writeSpotifyPlaylist(details, uris, { signal, tracks: trackRefs.map((ref) => planned.get(ref) ?? null) });
|
|
2735
2904
|
}
|
|
2736
2905
|
|
|
2737
2906
|
pendingSpotifyPlaylistStatus() {
|
|
@@ -2786,8 +2955,9 @@ export class MoondogApplication {
|
|
|
2786
2955
|
this.requireSpotifyScopes(["user-read-private"]);
|
|
2787
2956
|
this.requireSpotifyWriteScopes(["playlist-modify-private"]);
|
|
2788
2957
|
const draft = this.pendingSpotifyPlaylist;
|
|
2958
|
+
const { kept, avoided } = this.#withoutAvoidedPlanTracks(draft.plan.tracks);
|
|
2789
2959
|
const result = await this.requireSpotifyResolver().resolve(
|
|
2790
|
-
draft
|
|
2960
|
+
keptTrusted(draft, kept),
|
|
2791
2961
|
{ signal },
|
|
2792
2962
|
);
|
|
2793
2963
|
signal?.throwIfAborted();
|
|
@@ -2800,7 +2970,7 @@ export class MoondogApplication {
|
|
|
2800
2970
|
);
|
|
2801
2971
|
}
|
|
2802
2972
|
}
|
|
2803
|
-
const uris =
|
|
2973
|
+
const uris = kept.map((track) =>
|
|
2804
2974
|
resolvedByRef.get(track.track_ref_id),
|
|
2805
2975
|
);
|
|
2806
2976
|
if (uris.some((uri) => typeof uri !== "string")) {
|
|
@@ -2809,7 +2979,7 @@ export class MoondogApplication {
|
|
|
2809
2979
|
"The pending playlist could not be fully resolved on Spotify, so no playlist was created.",
|
|
2810
2980
|
);
|
|
2811
2981
|
}
|
|
2812
|
-
return this.writeSpotifyPlaylist(details, uris, { signal });
|
|
2982
|
+
return this.writeSpotifyPlaylist(details, uris, { signal, tracks: kept, avoided });
|
|
2813
2983
|
}
|
|
2814
2984
|
|
|
2815
2985
|
async spotifyPlayPendingPlan({ deviceId } = {}, { signal } = {}) {
|
|
@@ -2824,14 +2994,15 @@ export class MoondogApplication {
|
|
|
2824
2994
|
}
|
|
2825
2995
|
this.requireSpotifyScopes(["user-read-private"]);
|
|
2826
2996
|
const draft = this.pendingSpotifyPlaylist;
|
|
2827
|
-
const
|
|
2997
|
+
const { kept, avoided } = this.#withoutAvoidedPlanTracks(draft.plan.tracks);
|
|
2998
|
+
const result = await this.requireSpotifyResolver().resolve(keptTrusted(draft, kept), { signal });
|
|
2828
2999
|
signal?.throwIfAborted();
|
|
2829
3000
|
const resolvedByRef = new Map(
|
|
2830
3001
|
(result.resolutions ?? [])
|
|
2831
3002
|
.filter((resolution) => resolution.status === "resolved" && resolution.spotify?.uri)
|
|
2832
3003
|
.map((resolution) => [resolution.track_ref_id, resolution.spotify.uri]),
|
|
2833
3004
|
);
|
|
2834
|
-
const uris =
|
|
3005
|
+
const uris = kept.map((track) => resolvedByRef.get(track.track_ref_id));
|
|
2835
3006
|
if (uris.some((uri) => typeof uri !== "string")) {
|
|
2836
3007
|
throw spotifyResolutionError(
|
|
2837
3008
|
"spotify_pending_playback_unresolved",
|
|
@@ -2841,8 +3012,10 @@ export class MoondogApplication {
|
|
|
2841
3012
|
const receipt = await this.#resumeSpotifySelection({
|
|
2842
3013
|
uris,
|
|
2843
3014
|
...(deviceId ? { deviceId } : {}),
|
|
2844
|
-
}, { signal
|
|
2845
|
-
|
|
3015
|
+
}, { signal, played: kept.map((track, index) => ({
|
|
3016
|
+
providerTrackId: uris[index], title: track.title, artistCredit: track.artist_credit,
|
|
3017
|
+
})) });
|
|
3018
|
+
return { ...receipt, track_count: uris.length, ...skippedAvoided(avoided) };
|
|
2846
3019
|
}
|
|
2847
3020
|
|
|
2848
3021
|
async spotifyQueuePendingPlan({ deviceId } = {}, { signal } = {}) {
|
|
@@ -2856,8 +3029,9 @@ export class MoondogApplication {
|
|
|
2856
3029
|
}
|
|
2857
3030
|
this.requireSpotifyScopes(["user-read-private"]);
|
|
2858
3031
|
const draft = this.pendingSpotifyPlaylist;
|
|
3032
|
+
const { kept, avoided } = this.#withoutAvoidedPlanTracks(draft.plan.tracks);
|
|
2859
3033
|
const result = await this.requireSpotifyResolver().resolve(
|
|
2860
|
-
draft
|
|
3034
|
+
keptTrusted(draft, kept),
|
|
2861
3035
|
{ signal },
|
|
2862
3036
|
);
|
|
2863
3037
|
signal?.throwIfAborted();
|
|
@@ -2867,15 +3041,19 @@ export class MoondogApplication {
|
|
|
2867
3041
|
resolvedByRef.set(resolution.track_ref_id, resolution.spotify.uri);
|
|
2868
3042
|
}
|
|
2869
3043
|
}
|
|
2870
|
-
const receipt = await this.#queueSpotifyTracks(
|
|
3044
|
+
const receipt = await this.#queueSpotifyTracks(kept, resolvedByRef, { signal, deviceId, ledgerAction: "queue.plan" });
|
|
2871
3045
|
if (receipt.state === "no_candidates") {
|
|
2872
3046
|
throw spotifyResolutionError("spotify_pending_queue_unmatched", "None of the pending tracks matched a Spotify track, so nothing was queued.");
|
|
2873
3047
|
}
|
|
2874
|
-
return receipt;
|
|
3048
|
+
return avoided.length ? { ...receipt, skipped_avoided: avoided, skipped_avoided_count: receipt.skipped_avoided_count + avoided.length } : receipt;
|
|
2875
3049
|
}
|
|
2876
3050
|
|
|
2877
|
-
async #queueSpotifyTracks(tracks, resolvedByRef, { signal, deviceId, seenUris = null, limit = tracks.length, domainServices = null, knownTrack = null, rethrow = false } = {}) {
|
|
3051
|
+
async #queueSpotifyTracks(tracks, resolvedByRef, { signal, deviceId, seenUris = null, limit = tracks.length, domainServices = null, knownTrack = null, rethrow = false, ledgerAction = "queue.add" } = {}) {
|
|
2878
3052
|
const label = (track) => ({ title: track.title, artist_credit: track.artist_credit });
|
|
3053
|
+
// A remaster or a second copy of the same song is a duplicate too, not only the same URI.
|
|
3054
|
+
const seenSongs = new Set();
|
|
3055
|
+
const sameSong = (track) => [...songKeys(track), track.isrc ? `isrc:${track.isrc}` : null].filter(Boolean);
|
|
3056
|
+
const accepted = [];
|
|
2879
3057
|
const queued = [];
|
|
2880
3058
|
const unmatched = [];
|
|
2881
3059
|
let skippedDuplicates = 0;
|
|
@@ -2891,13 +3069,14 @@ export class MoondogApplication {
|
|
|
2891
3069
|
let outcomeUnknown = false;
|
|
2892
3070
|
let target = null;
|
|
2893
3071
|
let started = null;
|
|
3072
|
+
let startedUri = null;
|
|
2894
3073
|
const acceptedUris = [];
|
|
2895
3074
|
for (const [index, track] of tracks.entries()) {
|
|
2896
3075
|
if (queued.length >= limit) break;
|
|
2897
3076
|
if (signal?.aborted) { stopped = label(track); stopIndex = index; break; }
|
|
2898
3077
|
const uri = resolvedByRef.get(track.track_ref_id);
|
|
2899
3078
|
if (typeof uri !== "string") { unmatched.push(label(track)); continue; }
|
|
2900
|
-
if (seenUris?.has(uri)) { skippedDuplicates++; continue; }
|
|
3079
|
+
if (seenUris?.has(uri) || sameSong(track).some(key => seenSongs.has(key))) { skippedDuplicates++; continue; }
|
|
2901
3080
|
if (this.recentUncertainQueueUris.has(uri)) { skippedUncertain++; continue; }
|
|
2902
3081
|
let dispatching = false;
|
|
2903
3082
|
try {
|
|
@@ -2911,6 +3090,7 @@ export class MoondogApplication {
|
|
|
2911
3090
|
dispatching = true;
|
|
2912
3091
|
const receipt = await service.resume({ uris: [uri], ...device }, { signal });
|
|
2913
3092
|
started = label(track);
|
|
3093
|
+
startedUri = uri;
|
|
2914
3094
|
target = { ...target, active: true, ...(receipt?.device ? { device: receipt.device } : {}) };
|
|
2915
3095
|
};
|
|
2916
3096
|
if (target.active === false) await start();
|
|
@@ -2930,6 +3110,8 @@ export class MoondogApplication {
|
|
|
2930
3110
|
}
|
|
2931
3111
|
acceptedUris.push(uri);
|
|
2932
3112
|
queued.push(label(track));
|
|
3113
|
+
accepted.push({ track, uri });
|
|
3114
|
+
for (const key of sameSong(track)) seenSongs.add(key);
|
|
2933
3115
|
if (this.pendingPlaylistPromptTransaction) this.pendingPlaylistPromptTransaction.queueWriteCount = (this.pendingPlaylistPromptTransaction.queueWriteCount ?? 0) + 1;
|
|
2934
3116
|
seenUris?.add(uri);
|
|
2935
3117
|
if (seenUris) {
|
|
@@ -2958,9 +3140,16 @@ export class MoondogApplication {
|
|
|
2958
3140
|
if (queued.length === 0 && !outcomeUnknown) signal?.throwIfAborted();
|
|
2959
3141
|
const state = stopped ? (queued.length ? "partial" : outcomeUnknown ? "unknown" : "failed")
|
|
2960
3142
|
: queued.length ? "accepted" : "no_candidates";
|
|
3143
|
+
// Each song is placed in the listener's own history before it lands in the ledger.
|
|
3144
|
+
const slots = this.#mixSlots(accepted.map(({ track }) => track));
|
|
3145
|
+
this.#noteSuggestions("spotify", accepted.map(({ track, uri }, index) => ({
|
|
3146
|
+
action: uri === startedUri ? "play" : ledgerAction, providerTrackId: uri, isrc: track.isrc,
|
|
3147
|
+
title: track.title, artists: track.artists, artistCredit: track.artist_credit, mixSlot: slots[index],
|
|
3148
|
+
})));
|
|
3149
|
+
const mix = mixCounts(slots);
|
|
2961
3150
|
const verification = acceptedUris.length && !signal?.aborted ? await this.#verifyQueued(acceptedUris, { signal }) : null;
|
|
2962
3151
|
return { provider: "spotify", ok: !["unknown", "failed"].includes(state), effect: "write_external", action: "playback.queue.add",
|
|
2963
|
-
state, queued, unmatched, not_added: stopIndex >= 0 ? tracks.slice(stopIndex + 1, stopIndex + Math.max(1, limit - queued.length)).map(label) : [],
|
|
3152
|
+
state, queued, ...(mix ? { mix } : {}), unmatched, not_added: stopIndex >= 0 ? tracks.slice(stopIndex + 1, stopIndex + Math.max(1, limit - queued.length)).map(label) : [],
|
|
2964
3153
|
...(target?.device ? { device: target.device } : {}), ...(started ? { started_playback: started } : {}),
|
|
2965
3154
|
...(verification ? { verification } : {}),
|
|
2966
3155
|
...(stopIndex >= 0 ? { not_added_count: Math.min(tracks.length - stopIndex - 1, Math.max(0, limit - queued.length - 1)) } : {}),
|
|
@@ -2972,9 +3161,14 @@ export class MoondogApplication {
|
|
|
2972
3161
|
...(signal?.aborted ? { cancelled: true } : {}) };
|
|
2973
3162
|
}
|
|
2974
3163
|
|
|
2975
|
-
async writeSpotifyPlaylist(details, uris, { signal } = {}) {
|
|
3164
|
+
async writeSpotifyPlaylist(details, uris, { signal, tracks = [], avoided: leftOut = [] } = {}) {
|
|
2976
3165
|
signal?.throwIfAborted();
|
|
2977
3166
|
this.requireSpotifyPlaylistCreationFlow();
|
|
3167
|
+
const { kept, avoided } = this.#withoutAvoidedPlanTracks(tracks);
|
|
3168
|
+
if (avoided.length) {
|
|
3169
|
+
uris = uris.filter((_uri, index) => kept.includes(tracks[index]));
|
|
3170
|
+
tracks = kept;
|
|
3171
|
+
}
|
|
2978
3172
|
if (this.pendingPlaylistPromptTransaction) this.pendingPlaylistPromptTransaction.playlistWriteAttempted = true;
|
|
2979
3173
|
this.invalidateSpotifyQuickEditContext();
|
|
2980
3174
|
try {
|
|
@@ -2982,12 +3176,14 @@ export class MoondogApplication {
|
|
|
2982
3176
|
...details,
|
|
2983
3177
|
uris,
|
|
2984
3178
|
}, { signal });
|
|
3179
|
+
this.#noteSuggestions("spotify", tracks.flatMap((track, index) => track ? [{ action: "playlist.write",
|
|
3180
|
+
providerTrackId: uris[index], title: track.title, artistCredit: track.artist_credit }] : []));
|
|
2985
3181
|
if (this.pendingPlaylistPromptTransaction) {
|
|
2986
3182
|
this.pendingPlaylistPromptTransaction.externalized = true;
|
|
2987
3183
|
}
|
|
2988
3184
|
this.pendingSpotifyPlaylist = null;
|
|
2989
3185
|
this.pendingPlaylistRevisionCandidateSet = null;
|
|
2990
|
-
return receipt;
|
|
3186
|
+
return { ...receipt, ...skippedAvoided([...leftOut, ...avoided]) };
|
|
2991
3187
|
} catch (error) {
|
|
2992
3188
|
if (error?.outcomeUnknown === true || ["playlist_created_without_tracks", "playlist_created_tracks_unknown"].includes(error?.code)) {
|
|
2993
3189
|
if (this.pendingPlaylistPromptTransaction) {
|
|
@@ -3080,6 +3276,7 @@ export class MoondogApplication {
|
|
|
3080
3276
|
musicSimilarityReady: this.musicSimilarityReady(),
|
|
3081
3277
|
...this.webLookupOptions(),
|
|
3082
3278
|
musicPreviewReady: this.musicPreviewReady(),
|
|
3279
|
+
tasteChoicesReady: this.tasteChoicesReady(),
|
|
3083
3280
|
}).map(capability => capability.state === "enabled" && this.#playbackServiceExcludes(capability.id)
|
|
3084
3281
|
? { ...capability, state: "blocked", blocked_by: capability.id === "listening.now" ? "no_readable_player" : capability.id === "music_app.control" ? "no_music_app" : capability.id === "playback.service" ? "no_playback_service"
|
|
3085
3282
|
: neteaseCapabilities.has(capability.id) ? "netease_not_ready" : "playback_service_not_spotify" } : capability),
|
|
@@ -3114,6 +3311,7 @@ export class MoondogApplication {
|
|
|
3114
3311
|
musicSimilarityReady: this.musicSimilarityReady(),
|
|
3115
3312
|
...this.webLookupOptions(),
|
|
3116
3313
|
musicPreviewReady: this.musicPreviewReady(),
|
|
3314
|
+
tasteChoicesReady: this.tasteChoicesReady(),
|
|
3117
3315
|
}).filter(descriptor => !this.#playbackServiceExcludes(descriptor.capability_id));
|
|
3118
3316
|
}
|
|
3119
3317
|
|
|
@@ -3324,6 +3522,9 @@ export class MoondogApplication {
|
|
|
3324
3522
|
if (transaction) transaction.playlistPlanAttempted = true;
|
|
3325
3523
|
this.invalidateSpotifyQuickEditContext();
|
|
3326
3524
|
const plan = await this.requirePlaylistServices().buildPlaylistPlan(input);
|
|
3525
|
+
// The host declares the blend from the listener's own plays; the model never writes it.
|
|
3526
|
+
const mix = Array.isArray(plan?.tracks) ? mixCounts(this.#mixSlots(plan.tracks)) : null;
|
|
3527
|
+
if (mix) plan.mix = mix;
|
|
3327
3528
|
const validatedPlaylistTrackRefs = Array.isArray(plan?.tracks)
|
|
3328
3529
|
? plan.tracks.map((track) => track.track_ref_id)
|
|
3329
3530
|
: null;
|
|
@@ -3401,7 +3602,8 @@ export class MoondogApplication {
|
|
|
3401
3602
|
};
|
|
3402
3603
|
}
|
|
3403
3604
|
|
|
3404
|
-
|
|
3605
|
+
// recentTexts are the listener's messages of the turns before this one, newest last.
|
|
3606
|
+
beginPrompt({ text, recentTexts = [] } = {}) {
|
|
3405
3607
|
if (this.pendingPlaylistPromptTransaction) {
|
|
3406
3608
|
throw new Error("A Moondog prompt state transaction is already active.");
|
|
3407
3609
|
}
|
|
@@ -3413,6 +3615,8 @@ export class MoondogApplication {
|
|
|
3413
3615
|
if (queueIntent) this.spotifyQueueRequest = { ...(queueIntent.clarification_only ? previousQueue : {}), ...queueIntent, observedAt: this.spotifyContextClock(), turn: this.spotifyContextTurn, epoch: this.spotifyContextEpoch };
|
|
3414
3616
|
this.pendingPlaylistPromptTransaction = {
|
|
3415
3617
|
userText: text,
|
|
3618
|
+
// What the listener said in this turn and the two before it: a song named there is theirs.
|
|
3619
|
+
listenerTexts: [...(Array.isArray(recentTexts) ? recentTexts : []).slice(-2), text].filter((value) => typeof value === "string"),
|
|
3416
3620
|
queueIntent,
|
|
3417
3621
|
removalConfirmationRequested: /^Confirm removal of (?:playlist|track|album|episode|show) .+ from my library[.!。!]?$/u.test(text?.trim() ?? ""),
|
|
3418
3622
|
pendingSpotifyRemoval: structuredClone(this.pendingSpotifyRemoval),
|
|
@@ -69,10 +69,13 @@ export function queueCancellationRequested(text) {
|
|
|
69
69
|
/^(?:キャンセル|やめて|止めて|中止)(?:して)?(?:ください)?[。!!]?$|(?:キュー|再生待ち)[^。!?]{0,20}(?:入れないで|追加しないで|やめて)|追加しないで/u.test(value);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* The listener asked for music they have not heard, in any supported language. "Not heard" counts
|
|
74
|
+
* only when it ends there or says yet, before or ever: "haven't heard in a while" asks to come back.
|
|
75
|
+
*/
|
|
73
76
|
export function unheardRequested(text) {
|
|
74
77
|
return typeof text === "string" &&
|
|
75
|
-
/没听过|从未听|不要听过|\bunheard\b|\bnever heard\b|outside.*library|曲库之外|nunca he (?:escuchado|o[ií]do)|que no (?:he|haya) (?:escuchado|o[ií]do)|sin escuchar|nuevas para m[ií]|nunca ouvi|que eu (?:ainda )?n[aã]o (?:ouvi|conhe[cç]o)|novas pra mim|novas para mim|聴いたことのない|聞いたことのない|知らない曲|未聴/iu.test(text);
|
|
78
|
+
/没听过|从未听|不要听过|\bunheard\b|\bnever (?:heard|listened to)\b|\b(?:haven't|have not|'ve not|not)\s+(?:yet\s+|ever\s+)?(?:heard|listened to)(?:\s+(?:yet|before|of|ever|please)\b|\s*(?:$|[,.;!?,。;!?]))|\bnew (?:to|for) me\b|\bunfamiliar\b|outside.*library|曲库之外|nunca he (?:escuchado|o[ií]do)|que no (?:he|haya) (?:escuchado|o[ií]do)|sin escuchar|nuevas para m[ií]|nunca ouvi|que eu (?:ainda )?n[aã]o (?:ouvi|conhe[cç]o)|novas pra mim|novas para mim|聴いたことのない|聞いたことのない|知らない曲|未聴/iu.test(text.replace(/[\u2018\u2019]/gu, "'"));
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
function queueRefinement(value, previous) {
|