@audiofool/moondog 0.1.0 → 0.2.0-beta.2
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/LICENSE +202 -0
- package/README.md +153 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +123 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +321 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +8 -4
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +407 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2204 -605
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +160 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// What the listener plays right now, wherever it plays: the Music app on this Mac, Spotify on its
|
|
2
|
+
// active device, or NetEase through ncm-cli, with the playlist, album or queue it plays from.
|
|
3
|
+
// Two players rarely play at once. When they do, one that plays wins over a paused one, then the
|
|
4
|
+
// listener's chosen service, then this order.
|
|
5
|
+
export const LISTENING_ORDER = Object.freeze(["apple_music", "spotify", "netease"]);
|
|
6
|
+
|
|
7
|
+
export function activeListening(sources, chosen) {
|
|
8
|
+
const order = [...new Set([chosen, ...LISTENING_ORDER])];
|
|
9
|
+
const heard = sources.filter((source) => source.song && (source.state === "playing" || source.state === "paused"));
|
|
10
|
+
return heard.sort((a, b) => (a.state === "playing" ? 0 : 1) - (b.state === "playing" ? 0 : 1) ||
|
|
11
|
+
order.indexOf(a.service) - order.indexOf(b.service))[0] ?? null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const songOf = (item) => ({
|
|
15
|
+
title: item.name ?? item.title ?? "",
|
|
16
|
+
artists: Array.isArray(item.artists) ? item.artists : [],
|
|
17
|
+
album: item.album ?? null,
|
|
18
|
+
duration_ms: Number.isInteger(item.duration_ms) ? item.duration_ms : null,
|
|
19
|
+
...(item.track_ref_id ? { track_ref_id: item.track_ref_id } : {}),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// What the Music app plays, as one source. Its playlist's songs come only from a read that asked for them.
|
|
23
|
+
export function musicAppSource(now) {
|
|
24
|
+
if (!now.song) return { state: now.state, ...(now.reason ? { reason: now.reason } : {}) };
|
|
25
|
+
const playlist = now.playlist;
|
|
26
|
+
return {
|
|
27
|
+
state: now.state,
|
|
28
|
+
player: "Music app on this Mac",
|
|
29
|
+
song: songOf(now.song),
|
|
30
|
+
position_ms: now.position_ms,
|
|
31
|
+
shuffle: now.shuffle,
|
|
32
|
+
repeat: now.repeat,
|
|
33
|
+
from: playlist ? {
|
|
34
|
+
kind: playlist.library ? "library" : "playlist",
|
|
35
|
+
name: playlist.name,
|
|
36
|
+
tracks_total: playlist.tracks_total ?? null,
|
|
37
|
+
position: playlist.position ?? null,
|
|
38
|
+
...(playlist.tracks ? { first_position: playlist.first_position, tracks: playlist.tracks.map(songOf) } : {}),
|
|
39
|
+
} : null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const musicApp = async (application) => musicAppSource(await application.musicApp.nowPlaying({ tracks: true }));
|
|
44
|
+
|
|
45
|
+
// Spotify reports only the device that plays; reading it costs three or four requests of the quota.
|
|
46
|
+
async function spotify(application, { signal }) {
|
|
47
|
+
const player = await application.spotifyNowPlaying({ signal });
|
|
48
|
+
if (player.state !== "available" || !player.item) return { state: "stopped" };
|
|
49
|
+
const context = player.context ?? null;
|
|
50
|
+
const [queue, playlist] = await Promise.all([
|
|
51
|
+
application.spotifyQueueStatus({ signal }).catch((error) => (signal?.aborted ? Promise.reject(error) : null)),
|
|
52
|
+
context?.type === "playlist"
|
|
53
|
+
? application.requireSpotifyService().playingPlaylist({ uri: context.uri }, { signal })
|
|
54
|
+
.catch((error) => (signal?.aborted ? Promise.reject(error) : { unavailable: String(error?.message ?? error).slice(0, 200) }))
|
|
55
|
+
: null,
|
|
56
|
+
]);
|
|
57
|
+
const from = context?.type === "playlist"
|
|
58
|
+
? { kind: "playlist", name: playlist.name ?? null, tracks_total: playlist.tracks_total ?? null,
|
|
59
|
+
...(playlist.tracks ? { first_position: 1, tracks: playlist.tracks.map(songOf) } : {}),
|
|
60
|
+
...(playlist.unavailable ? { unavailable: playlist.unavailable } : {}) }
|
|
61
|
+
: context?.type === "album" ? { kind: "album", name: player.item.album ?? null }
|
|
62
|
+
: context?.type === "artist" ? { kind: "artist", name: player.item.artists?.[0] ?? null }
|
|
63
|
+
: context?.type === "collection" ? { kind: "liked_songs", name: "Liked Songs" }
|
|
64
|
+
: null;
|
|
65
|
+
return {
|
|
66
|
+
state: player.is_playing === true ? "playing" : "paused",
|
|
67
|
+
player: player.device?.name ? `Spotify on ${player.device.name}` : "Spotify",
|
|
68
|
+
...(player.device ? { device: { name: player.device.name ?? null, type: player.device.type ?? null } } : {}),
|
|
69
|
+
song: songOf(player.item),
|
|
70
|
+
position_ms: player.progress_ms ?? 0,
|
|
71
|
+
shuffle: player.shuffle_state === true,
|
|
72
|
+
repeat: player.repeat_state ?? "off",
|
|
73
|
+
from,
|
|
74
|
+
next: (queue?.queue ?? []).map(songOf),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function netease(application) {
|
|
79
|
+
const now = await application.neteaseNowPlaying();
|
|
80
|
+
if (now.state === "stopped" || !now.song) return { state: "stopped" };
|
|
81
|
+
return {
|
|
82
|
+
state: now.state,
|
|
83
|
+
player: "NetEase Music through ncm-cli on this Mac",
|
|
84
|
+
song: songOf(now.song),
|
|
85
|
+
position_ms: now.position_ms ?? 0,
|
|
86
|
+
from: { kind: "moondog_queue", name: null },
|
|
87
|
+
next: (now.next ?? []).map(songOf),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every player Moondog can read, the one the listener hears first: { active, sources }. */
|
|
92
|
+
export async function readListeningNow(application, { signal } = {}) {
|
|
93
|
+
const readers = [
|
|
94
|
+
application.musicApp?.available ? ["apple_music", () => musicApp(application)] : null,
|
|
95
|
+
application.spotifyReady() ? ["spotify", () => spotify(application, { signal })] : null,
|
|
96
|
+
application.neteaseReady() ? ["netease", () => netease(application)] : null,
|
|
97
|
+
].filter(Boolean);
|
|
98
|
+
const sources = await Promise.all(readers.map(async ([service, read]) => {
|
|
99
|
+
try {
|
|
100
|
+
return { service, ...(await read()) };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (signal?.aborted) throw error;
|
|
103
|
+
return { service, state: "unreadable", reason: String(error?.message ?? error).slice(0, 200) };
|
|
104
|
+
}
|
|
105
|
+
}));
|
|
106
|
+
const active = activeListening(sources, application.playbackService);
|
|
107
|
+
return { active: active?.service ?? null, sources: active ? [active, ...sources.filter((source) => source !== active)] : sources };
|
|
108
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
|
|
3
|
+
import { createListenerProfileInput } from "../profile/listener-profile-build.mjs";
|
|
2
4
|
import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
|
|
3
5
|
|
|
4
6
|
import { isUuid } from "./uuid-v5.mjs";
|
|
@@ -6,6 +8,7 @@ import { createListeningHistoryProfileProjection } from "../profile/spotify-arch
|
|
|
6
8
|
import {
|
|
7
9
|
assertExternalPlanDiversity,
|
|
8
10
|
exactTitleArtistMatch,
|
|
11
|
+
listenerAssertionAvoids,
|
|
9
12
|
safeExternalSource,
|
|
10
13
|
safeExternalTrack,
|
|
11
14
|
} from "./apple-projection-domain-services.mjs";
|
|
@@ -75,6 +78,11 @@ function safeCount(value, label) {
|
|
|
75
78
|
return value;
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
// A pick leaves out minutes or skips that its plays never recorded.
|
|
82
|
+
function optionalCount(context, key, label) {
|
|
83
|
+
return context[key] === undefined ? {} : { [key]: safeCount(context[key], label) };
|
|
84
|
+
}
|
|
85
|
+
|
|
78
86
|
function safeTimestamp(value, label) {
|
|
79
87
|
const cleaned = cleanText(value, 64, label);
|
|
80
88
|
const parsed = Date.parse(cleaned);
|
|
@@ -86,15 +94,19 @@ function safeTimestamp(value, label) {
|
|
|
86
94
|
|
|
87
95
|
function safeExternalRefs(value) {
|
|
88
96
|
if (value === undefined) return [];
|
|
89
|
-
if (!Array.isArray(value)
|
|
97
|
+
if (!Array.isArray(value)) {
|
|
98
|
+
throw new TypeError("Listening-history provider identity is invalid");
|
|
99
|
+
}
|
|
100
|
+
// Only a Spotify track ID is kept for playback. Last.fm, ListenBrainz and
|
|
101
|
+
// Spotify account-data identities stay in the store.
|
|
102
|
+
const references = value.filter((reference) => reference?.system === "spotify");
|
|
103
|
+
if (references.length > 1) {
|
|
90
104
|
throw new TypeError("Listening-history provider identity is invalid");
|
|
91
105
|
}
|
|
92
|
-
return
|
|
106
|
+
return references.map((reference) => {
|
|
93
107
|
if (
|
|
94
|
-
!reference ||
|
|
95
108
|
typeof reference !== "object" ||
|
|
96
109
|
Array.isArray(reference) ||
|
|
97
|
-
reference.system !== "spotify" ||
|
|
98
110
|
reference.entity_type !== "spotify.track" ||
|
|
99
111
|
typeof reference.external_id !== "string" ||
|
|
100
112
|
!SPOTIFY_TRACK_ID.test(reference.external_id)
|
|
@@ -188,18 +200,16 @@ function safeHistoryTrack(value) {
|
|
|
188
200
|
throw new TypeError("Rediscovery signal is invalid");
|
|
189
201
|
}
|
|
190
202
|
track.rediscovery = {
|
|
191
|
-
|
|
192
|
-
context
|
|
203
|
+
...optionalCount(
|
|
204
|
+
context,
|
|
205
|
+
"listening_minutes",
|
|
193
206
|
"Rediscovery listening minutes",
|
|
194
207
|
),
|
|
195
208
|
engaged_play_count: safeCount(
|
|
196
209
|
context.engaged_play_count,
|
|
197
210
|
"Rediscovery engaged plays",
|
|
198
211
|
),
|
|
199
|
-
explicit_skips
|
|
200
|
-
context.explicit_skips,
|
|
201
|
-
"Rediscovery skips",
|
|
202
|
-
),
|
|
212
|
+
...optionalCount(context, "explicit_skips", "Rediscovery skips"),
|
|
203
213
|
first_played_at: safeTimestamp(
|
|
204
214
|
context.first_played_at,
|
|
205
215
|
"Rediscovery first play",
|
|
@@ -217,8 +227,9 @@ function safeHistoryTrack(value) {
|
|
|
217
227
|
context.peak_year_play_count,
|
|
218
228
|
"Rediscovery peak-year plays",
|
|
219
229
|
),
|
|
220
|
-
|
|
221
|
-
context
|
|
230
|
+
...optionalCount(
|
|
231
|
+
context,
|
|
232
|
+
"peak_year_listening_minutes",
|
|
222
233
|
"Rediscovery peak-year minutes",
|
|
223
234
|
),
|
|
224
235
|
}
|
|
@@ -260,8 +271,9 @@ function safeHistoryTrack(value) {
|
|
|
260
271
|
context.engaged_play_count,
|
|
261
272
|
"Historical-return engaged plays",
|
|
262
273
|
);
|
|
263
|
-
const explicitSkips =
|
|
264
|
-
context
|
|
274
|
+
const explicitSkips = optionalCount(
|
|
275
|
+
context,
|
|
276
|
+
"explicit_skips",
|
|
265
277
|
"Historical-return skips",
|
|
266
278
|
);
|
|
267
279
|
if (
|
|
@@ -270,7 +282,7 @@ function safeHistoryTrack(value) {
|
|
|
270
282
|
latestReturnGapDays < 1 ||
|
|
271
283
|
latestReturnGapDays > longestGapDays ||
|
|
272
284
|
engagedPlayCount > playCount ||
|
|
273
|
-
explicitSkips > playCount ||
|
|
285
|
+
(explicitSkips.explicit_skips ?? 0) > playCount ||
|
|
274
286
|
Date.parse(firstPlayedAt) > Date.parse(latestReturnAt) ||
|
|
275
287
|
Date.parse(latestReturnAt) > Date.parse(lastPlayedAt) ||
|
|
276
288
|
!historySupportSignals.has(historicalReturnSignal) ||
|
|
@@ -279,12 +291,13 @@ function safeHistoryTrack(value) {
|
|
|
279
291
|
throw new TypeError("Historical-return candidate is invalid");
|
|
280
292
|
}
|
|
281
293
|
track.historical_return = {
|
|
282
|
-
|
|
283
|
-
context
|
|
294
|
+
...optionalCount(
|
|
295
|
+
context,
|
|
296
|
+
"listening_minutes",
|
|
284
297
|
"Historical-return listening minutes",
|
|
285
298
|
),
|
|
286
299
|
engaged_play_count: engagedPlayCount,
|
|
287
|
-
|
|
300
|
+
...explicitSkips,
|
|
288
301
|
first_played_at: firstPlayedAt,
|
|
289
302
|
last_played_at: lastPlayedAt,
|
|
290
303
|
return_count: returnCount,
|
|
@@ -310,16 +323,15 @@ function safeHistoryTrack(value) {
|
|
|
310
323
|
context.year_engaged_play_count,
|
|
311
324
|
"Time Machine engaged plays",
|
|
312
325
|
),
|
|
313
|
-
|
|
314
|
-
context
|
|
326
|
+
...optionalCount(
|
|
327
|
+
context,
|
|
328
|
+
"year_listening_minutes",
|
|
315
329
|
"Time Machine year minutes",
|
|
316
330
|
),
|
|
317
|
-
year_explicit_skips
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
lifetime_listening_minutes: safeCount(
|
|
322
|
-
context.lifetime_listening_minutes,
|
|
331
|
+
...optionalCount(context, "year_explicit_skips", "Time Machine year skips"),
|
|
332
|
+
...optionalCount(
|
|
333
|
+
context,
|
|
334
|
+
"lifetime_listening_minutes",
|
|
323
335
|
"Time Machine lifetime minutes",
|
|
324
336
|
),
|
|
325
337
|
representative_signal: cleanText(
|
|
@@ -335,8 +347,9 @@ function safeHistoryTrack(value) {
|
|
|
335
347
|
context.engaged_play_count,
|
|
336
348
|
"Back-to-back engaged plays",
|
|
337
349
|
);
|
|
338
|
-
const explicitSkips =
|
|
339
|
-
context
|
|
350
|
+
const explicitSkips = optionalCount(
|
|
351
|
+
context,
|
|
352
|
+
"explicit_skips",
|
|
340
353
|
"Back-to-back skips",
|
|
341
354
|
);
|
|
342
355
|
const burstCount = safeCount(
|
|
@@ -363,7 +376,7 @@ function safeHistoryTrack(value) {
|
|
|
363
376
|
playsInBursts < burstCount * 2 ||
|
|
364
377
|
playsInBursts > playCount ||
|
|
365
378
|
engagedPlayCount > playCount ||
|
|
366
|
-
explicitSkips > playCount ||
|
|
379
|
+
(explicitSkips.explicit_skips ?? 0) > playCount ||
|
|
367
380
|
!backToBackSignals.has(sequenceSignal) ||
|
|
368
381
|
!isUuid(context.evidence_id)
|
|
369
382
|
) {
|
|
@@ -371,7 +384,7 @@ function safeHistoryTrack(value) {
|
|
|
371
384
|
}
|
|
372
385
|
track.back_to_back = {
|
|
373
386
|
engaged_play_count: engagedPlayCount,
|
|
374
|
-
|
|
387
|
+
...explicitSkips,
|
|
375
388
|
burst_count: burstCount,
|
|
376
389
|
maximum_consecutive_plays: maximumConsecutivePlays,
|
|
377
390
|
plays_in_bursts: playsInBursts,
|
|
@@ -409,6 +422,8 @@ export class ListeningProfileDomainServices {
|
|
|
409
422
|
#store;
|
|
410
423
|
#subjectId;
|
|
411
424
|
#candidateSets = new Map();
|
|
425
|
+
#profileBuildInput;
|
|
426
|
+
#ownsStore = true;
|
|
412
427
|
|
|
413
428
|
constructor({ listeningHistoryStore, subjectId } = {}) {
|
|
414
429
|
if (
|
|
@@ -426,6 +441,18 @@ export class ListeningProfileDomainServices {
|
|
|
426
441
|
this.#subjectId = subjectId.toLowerCase();
|
|
427
442
|
}
|
|
428
443
|
|
|
444
|
+
// Each conversation that runs beside another gets a fork: the same store with its own
|
|
445
|
+
// candidate sets, so one conversation's prompt never clears another's. Only the
|
|
446
|
+
// original closes the store.
|
|
447
|
+
fork() {
|
|
448
|
+
const fork = new ListeningProfileDomainServices({
|
|
449
|
+
listeningHistoryStore: this.#store,
|
|
450
|
+
subjectId: this.#subjectId,
|
|
451
|
+
});
|
|
452
|
+
fork.#ownsStore = false;
|
|
453
|
+
return fork;
|
|
454
|
+
}
|
|
455
|
+
|
|
429
456
|
status() {
|
|
430
457
|
return {
|
|
431
458
|
state: "ready",
|
|
@@ -458,6 +485,14 @@ export class ListeningProfileDomainServices {
|
|
|
458
485
|
};
|
|
459
486
|
}
|
|
460
487
|
|
|
488
|
+
listeningCoverage() {
|
|
489
|
+
return this.#store.listeningCoverage?.({ subjectId: this.#subjectId }) ?? null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
recentAttention() {
|
|
493
|
+
return this.#store.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
|
|
494
|
+
}
|
|
495
|
+
|
|
461
496
|
rediscoveryReady() {
|
|
462
497
|
return typeof this.#store.rediscoveryCandidates === "function";
|
|
463
498
|
}
|
|
@@ -518,7 +553,19 @@ export class ListeningProfileDomainServices {
|
|
|
518
553
|
return { candidateSetId, tracks: prepared };
|
|
519
554
|
}
|
|
520
555
|
|
|
521
|
-
|
|
556
|
+
filterDiscoveryTracks(tracks) {
|
|
557
|
+
const avoids = this.#store.activeAvoidances?.({ subjectId: this.#subjectId }) ??
|
|
558
|
+
this.#store.profileSummary({ subjectId: this.#subjectId })?.listener_assertions?.avoids ?? [];
|
|
559
|
+
return tracks.filter((track) => !listenerAssertionAvoids(
|
|
560
|
+
{ listener_assertions: { avoids } }, "track", track.title, track.artist_credit,
|
|
561
|
+
));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
isKnownDiscoveryTrack(track) {
|
|
565
|
+
return (this.#store.listenedTracks?.({ subjectId: this.#subjectId }) ?? []).some(heard => exactTitleArtistMatch(track, heard));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
registerExternalCandidateSet({ tracks, source, excludeKnown = true } = {}) {
|
|
522
569
|
if (!Array.isArray(tracks) || tracks.length < 1 || tracks.length > HISTORY_MAX_ITEMS) {
|
|
523
570
|
throw new TypeError("External catalog candidates are invalid");
|
|
524
571
|
}
|
|
@@ -530,14 +577,16 @@ export class ListeningProfileDomainServices {
|
|
|
530
577
|
if (new Set(prepared.map((track) => track.track_ref_id)).size !== prepared.length) {
|
|
531
578
|
throw new TypeError("External catalog candidates are duplicated");
|
|
532
579
|
}
|
|
533
|
-
//
|
|
580
|
+
// Familiarity is useful evidence, not an automatic discovery exclusion.
|
|
534
581
|
const listened = typeof this.#store.listenedTracks === "function"
|
|
535
582
|
? this.#store.listenedTracks({ subjectId: this.#subjectId })
|
|
536
583
|
: [];
|
|
537
584
|
const accepted = [];
|
|
538
585
|
let excludedHistoryMatches = 0;
|
|
539
|
-
|
|
540
|
-
|
|
586
|
+
const allowed = this.filterDiscoveryTracks(prepared);
|
|
587
|
+
for (const track of allowed) {
|
|
588
|
+
const heard = listened.some((heard) => exactTitleArtistMatch(track, heard));
|
|
589
|
+
if (excludeKnown && heard) {
|
|
541
590
|
excludedHistoryMatches += 1;
|
|
542
591
|
continue;
|
|
543
592
|
}
|
|
@@ -545,7 +594,7 @@ export class ListeningProfileDomainServices {
|
|
|
545
594
|
...track,
|
|
546
595
|
knownness: {
|
|
547
596
|
imported_library: "not_imported",
|
|
548
|
-
listening_history: "not_found_by_exact_title_artist",
|
|
597
|
+
listening_history: heard ? "found_by_exact_title_artist" : "not_found_by_exact_title_artist",
|
|
549
598
|
},
|
|
550
599
|
});
|
|
551
600
|
}
|
|
@@ -560,6 +609,7 @@ export class ListeningProfileDomainServices {
|
|
|
560
609
|
candidate_set_id: registered,
|
|
561
610
|
candidate_scope: "external_catalog",
|
|
562
611
|
result_count: accepted.length,
|
|
612
|
+
excluded_avoided_matches: prepared.length - allowed.length,
|
|
563
613
|
excluded_library_matches: excludedHistoryMatches,
|
|
564
614
|
expires_on: "prompt_end",
|
|
565
615
|
source: trustedSource,
|
|
@@ -584,6 +634,35 @@ export class ListeningProfileDomainServices {
|
|
|
584
634
|
return createListeningHistoryProfileProjection(listening, { maxItems });
|
|
585
635
|
}
|
|
586
636
|
|
|
637
|
+
profileExplorationReady() {
|
|
638
|
+
return typeof this.#store.profileCatalog === "function";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async exploreProfile(argumentsValue) {
|
|
642
|
+
const input = profileExplorationInput(argumentsValue);
|
|
643
|
+
return exploreProfileCatalog({ listening: this.#store.profileCatalog({ subjectId: this.#subjectId }), input });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
profileBuildReady() {
|
|
647
|
+
return typeof this.#store.listenerProfileStorage === "function";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
getListenerProfileStore() {
|
|
651
|
+
return this.#store.listenerProfileStorage({ subjectId: this.#subjectId });
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
getProfileBuildContext() {
|
|
655
|
+
const version = this.#store.profileDataVersion();
|
|
656
|
+
if (this.#profileBuildInput?.version !== version) {
|
|
657
|
+
this.#profileBuildInput = { version, input: createListenerProfileInput({
|
|
658
|
+
subjectId: this.#subjectId,
|
|
659
|
+
evidenceRevision: this.#store.profileEvidenceRevision({ subjectId: this.#subjectId }),
|
|
660
|
+
listening: this.#store.profileCatalog({ subjectId: this.#subjectId }),
|
|
661
|
+
}) };
|
|
662
|
+
}
|
|
663
|
+
return { store: this.getListenerProfileStore(), input: this.#profileBuildInput.input };
|
|
664
|
+
}
|
|
665
|
+
|
|
587
666
|
async getRediscoveryCandidates(argumentsValue) {
|
|
588
667
|
const input = modelArguments(argumentsValue, new Set(["limit"]));
|
|
589
668
|
const limit = boundedHistoryItems(input.limit);
|
|
@@ -896,6 +975,11 @@ export class ListeningProfileDomainServices {
|
|
|
896
975
|
) {
|
|
897
976
|
throw new TypeError("A playlist plan cannot contain duplicate tracks");
|
|
898
977
|
}
|
|
978
|
+
const external = selected.map(({ track }) => track)
|
|
979
|
+
.filter((track) => track.candidate_scope === "external_catalog");
|
|
980
|
+
if (this.filterDiscoveryTracks(external).length !== external.length) {
|
|
981
|
+
throw new Error("An external discovery is currently marked Avoid. Choose another track.");
|
|
982
|
+
}
|
|
899
983
|
const tracks = selected.map(({ track, selectionReason }, index) => ({
|
|
900
984
|
position: index + 1,
|
|
901
985
|
track_ref_id: track.track_ref_id,
|
|
@@ -972,13 +1056,41 @@ export class ListeningProfileDomainServices {
|
|
|
972
1056
|
) {
|
|
973
1057
|
throw new TypeError("Retained playlist candidates are invalid");
|
|
974
1058
|
}
|
|
975
|
-
|
|
1059
|
+
if (this.#candidateSets.size >= CANDIDATE_SETS_MAX) {
|
|
1060
|
+
throw new Error("The prompt has reached its candidate set limit");
|
|
1061
|
+
}
|
|
1062
|
+
const prepared = tracks.map((track) =>
|
|
1063
|
+
track?.candidate_scope === "external_catalog"
|
|
1064
|
+
? {
|
|
1065
|
+
...safeExternalTrack(track),
|
|
1066
|
+
knownness: {
|
|
1067
|
+
imported_library: "not_imported",
|
|
1068
|
+
listening_history: "not_found_by_exact_title_artist",
|
|
1069
|
+
},
|
|
1070
|
+
}
|
|
1071
|
+
: safeHistoryTrack(track),
|
|
1072
|
+
);
|
|
1073
|
+
if (
|
|
1074
|
+
new Set(prepared.map((track) => track.track_ref_id)).size !==
|
|
1075
|
+
prepared.length
|
|
1076
|
+
) {
|
|
1077
|
+
throw new TypeError("Retained playlist candidates are duplicated");
|
|
1078
|
+
}
|
|
1079
|
+
const candidateSetId = randomUUID();
|
|
1080
|
+
this.#candidateSets.set(
|
|
1081
|
+
candidateSetId,
|
|
1082
|
+
new Map(
|
|
1083
|
+
prepared.map((track) => [track.track_ref_id, structuredClone(track)]),
|
|
1084
|
+
),
|
|
1085
|
+
);
|
|
1086
|
+
const scopes = new Set(prepared.map((track) => track.candidate_scope));
|
|
976
1087
|
return {
|
|
977
|
-
candidate_set_id:
|
|
978
|
-
candidate_scope: "
|
|
979
|
-
result_count:
|
|
1088
|
+
candidate_set_id: candidateSetId,
|
|
1089
|
+
candidate_scope: scopes.size === 1 ? [...scopes][0] : "mixed",
|
|
1090
|
+
result_count: prepared.length,
|
|
980
1091
|
expires_on: "prompt_end",
|
|
981
|
-
|
|
1092
|
+
source: "retained_validated_playlist",
|
|
1093
|
+
tracks: prepared.map(publicHistoryTrack),
|
|
982
1094
|
};
|
|
983
1095
|
}
|
|
984
1096
|
|
|
@@ -1001,7 +1113,7 @@ export class ListeningProfileDomainServices {
|
|
|
1001
1113
|
|
|
1002
1114
|
close() {
|
|
1003
1115
|
this.resetCandidateSets();
|
|
1004
|
-
this.#store.close();
|
|
1116
|
+
if (this.#ownsStore) this.#store.close();
|
|
1005
1117
|
}
|
|
1006
1118
|
}
|
|
1007
1119
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Spotify keeps only a listener's last 50 plays, about three hours of listening. The
|
|
2
|
+
// room runs its listening upkeep when it opens and every half hour while it runs: it
|
|
3
|
+
// reads recent plays before they fall out, then rebuilds the saved reading when due.
|
|
4
|
+
export const LISTENING_UPKEEP_INTERVAL_MS = 30 * 60_000;
|
|
5
|
+
|
|
6
|
+
export function startListeningUpkeep({ run, intervalMs = LISTENING_UPKEEP_INTERVAL_MS }) {
|
|
7
|
+
let running = null;
|
|
8
|
+
let stopped = false;
|
|
9
|
+
const tick = async () => {
|
|
10
|
+
// A rebuild can outlast the half hour; the next tick waits for the one after.
|
|
11
|
+
if (stopped || running) return;
|
|
12
|
+
running = new AbortController();
|
|
13
|
+
try {
|
|
14
|
+
await run({ signal: running.signal });
|
|
15
|
+
} catch {
|
|
16
|
+
// Signed out, offline or rate limited: the next tick tries again, and nothing waits on this one.
|
|
17
|
+
} finally {
|
|
18
|
+
running = null;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const timer = setInterval(tick, intervalMs);
|
|
22
|
+
timer.unref?.();
|
|
23
|
+
tick();
|
|
24
|
+
return {
|
|
25
|
+
stop() {
|
|
26
|
+
stopped = true;
|
|
27
|
+
clearInterval(timer);
|
|
28
|
+
running?.abort();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|