@audiofool/moondog 0.1.0
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 +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
|
|
3
|
+
|
|
4
|
+
import { isUuid } from "./uuid-v5.mjs";
|
|
5
|
+
import { createListeningHistoryProfileProjection } from "../profile/spotify-archive-taste.mjs";
|
|
6
|
+
import {
|
|
7
|
+
assertExternalPlanDiversity,
|
|
8
|
+
exactTitleArtistMatch,
|
|
9
|
+
safeExternalSource,
|
|
10
|
+
safeExternalTrack,
|
|
11
|
+
} from "./apple-projection-domain-services.mjs";
|
|
12
|
+
|
|
13
|
+
const PROFILE_DEFAULT_ITEMS = 6;
|
|
14
|
+
const PROFILE_MAX_ITEMS = 10;
|
|
15
|
+
const HISTORY_DEFAULT_ITEMS = 6;
|
|
16
|
+
const HISTORY_MAX_ITEMS = 12;
|
|
17
|
+
const CANDIDATE_SETS_MAX = 8;
|
|
18
|
+
const SPOTIFY_TRACK_ID = /^[A-Za-z0-9]{22}$/u;
|
|
19
|
+
const historySupportSignals = new Set([
|
|
20
|
+
"explicit listener preference",
|
|
21
|
+
"saved-library state",
|
|
22
|
+
"private playlist curation",
|
|
23
|
+
"historical attention only",
|
|
24
|
+
]);
|
|
25
|
+
const backToBackSignals = new Set(["adjacent retained plays"]);
|
|
26
|
+
|
|
27
|
+
function modelArguments(value, allowedKeys) {
|
|
28
|
+
const input = value ?? {};
|
|
29
|
+
if (
|
|
30
|
+
!input ||
|
|
31
|
+
typeof input !== "object" ||
|
|
32
|
+
Array.isArray(input) ||
|
|
33
|
+
Object.keys(input).some((key) => !allowedKeys.has(key))
|
|
34
|
+
) {
|
|
35
|
+
throw new TypeError("Profile service arguments are invalid");
|
|
36
|
+
}
|
|
37
|
+
return input;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function boundedItems(value) {
|
|
41
|
+
if (value === undefined) return PROFILE_DEFAULT_ITEMS;
|
|
42
|
+
if (!Number.isInteger(value) || value < 1 || value > PROFILE_MAX_ITEMS) {
|
|
43
|
+
throw new TypeError("Profile service limit is invalid");
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function boundedHistoryItems(value, minimum = 1) {
|
|
49
|
+
if (value === undefined) return HISTORY_DEFAULT_ITEMS;
|
|
50
|
+
if (
|
|
51
|
+
!Number.isInteger(value) ||
|
|
52
|
+
value < minimum ||
|
|
53
|
+
value > HISTORY_MAX_ITEMS
|
|
54
|
+
) {
|
|
55
|
+
throw new TypeError("Listening-history candidate limit is invalid");
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cleanText(value, maximum, label) {
|
|
61
|
+
if (
|
|
62
|
+
typeof value !== "string" ||
|
|
63
|
+
!value.trim() ||
|
|
64
|
+
Array.from(value.trim()).length > maximum
|
|
65
|
+
) {
|
|
66
|
+
throw new TypeError(`${label} is invalid`);
|
|
67
|
+
}
|
|
68
|
+
return value.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function safeCount(value, label) {
|
|
72
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
73
|
+
throw new TypeError(`${label} is invalid`);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safeTimestamp(value, label) {
|
|
79
|
+
const cleaned = cleanText(value, 64, label);
|
|
80
|
+
const parsed = Date.parse(cleaned);
|
|
81
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== cleaned) {
|
|
82
|
+
throw new TypeError(`${label} is invalid`);
|
|
83
|
+
}
|
|
84
|
+
return cleaned;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function safeExternalRefs(value) {
|
|
88
|
+
if (value === undefined) return [];
|
|
89
|
+
if (!Array.isArray(value) || value.length > 1) {
|
|
90
|
+
throw new TypeError("Listening-history provider identity is invalid");
|
|
91
|
+
}
|
|
92
|
+
return value.map((reference) => {
|
|
93
|
+
if (
|
|
94
|
+
!reference ||
|
|
95
|
+
typeof reference !== "object" ||
|
|
96
|
+
Array.isArray(reference) ||
|
|
97
|
+
reference.system !== "spotify" ||
|
|
98
|
+
reference.entity_type !== "spotify.track" ||
|
|
99
|
+
typeof reference.external_id !== "string" ||
|
|
100
|
+
!SPOTIFY_TRACK_ID.test(reference.external_id)
|
|
101
|
+
) {
|
|
102
|
+
throw new TypeError("Listening-history provider identity is invalid");
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
system: "spotify",
|
|
106
|
+
entity_type: "spotify.track",
|
|
107
|
+
external_id: reference.external_id,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function safeHistoryTrack(value) {
|
|
113
|
+
if (
|
|
114
|
+
!value ||
|
|
115
|
+
typeof value !== "object" ||
|
|
116
|
+
Array.isArray(value) ||
|
|
117
|
+
value.candidate_scope !== "private_history" ||
|
|
118
|
+
!isUuid(value.track_ref_id)
|
|
119
|
+
) {
|
|
120
|
+
throw new TypeError("Listening-history candidate is invalid");
|
|
121
|
+
}
|
|
122
|
+
const hasRediscovery =
|
|
123
|
+
value.rediscovery &&
|
|
124
|
+
typeof value.rediscovery === "object" &&
|
|
125
|
+
!Array.isArray(value.rediscovery);
|
|
126
|
+
const hasTimeCapsule =
|
|
127
|
+
value.time_capsule &&
|
|
128
|
+
typeof value.time_capsule === "object" &&
|
|
129
|
+
!Array.isArray(value.time_capsule);
|
|
130
|
+
const hasHistoricalReturn =
|
|
131
|
+
value.historical_return &&
|
|
132
|
+
typeof value.historical_return === "object" &&
|
|
133
|
+
!Array.isArray(value.historical_return);
|
|
134
|
+
const hasBackToBack =
|
|
135
|
+
value.back_to_back &&
|
|
136
|
+
typeof value.back_to_back === "object" &&
|
|
137
|
+
!Array.isArray(value.back_to_back);
|
|
138
|
+
if (
|
|
139
|
+
[
|
|
140
|
+
hasRediscovery,
|
|
141
|
+
hasHistoricalReturn,
|
|
142
|
+
hasTimeCapsule,
|
|
143
|
+
hasBackToBack,
|
|
144
|
+
].filter(Boolean).length !== 1
|
|
145
|
+
) {
|
|
146
|
+
throw new TypeError("Listening-history candidate context is invalid");
|
|
147
|
+
}
|
|
148
|
+
if (!new Set(["resolved", "provisional"]).has(value.identity_status)) {
|
|
149
|
+
throw new TypeError("Listening-history candidate identity is invalid");
|
|
150
|
+
}
|
|
151
|
+
const playCount = safeCount(
|
|
152
|
+
value.observation_summary?.familiarity?.play_count,
|
|
153
|
+
"Listening-history play count",
|
|
154
|
+
);
|
|
155
|
+
const track = {
|
|
156
|
+
track_ref_id: value.track_ref_id.toLowerCase(),
|
|
157
|
+
title: cleanText(value.title, 512, "Listening-history title"),
|
|
158
|
+
artist_credit: cleanText(
|
|
159
|
+
value.artist_credit,
|
|
160
|
+
512,
|
|
161
|
+
"Listening-history artist",
|
|
162
|
+
),
|
|
163
|
+
release: cleanText(value.release, 512, "Listening-history release"),
|
|
164
|
+
candidate_scope: "private_history",
|
|
165
|
+
identity_status: value.identity_status,
|
|
166
|
+
labels: { genres: [] },
|
|
167
|
+
observation_summary: {
|
|
168
|
+
preference_signals: [],
|
|
169
|
+
familiarity: {
|
|
170
|
+
level:
|
|
171
|
+
playCount >= 20 ? "high" : playCount >= 5 ? "medium" : "low",
|
|
172
|
+
basis: "effective_listening_history",
|
|
173
|
+
play_count: playCount,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
if (hasRediscovery) {
|
|
178
|
+
const context = value.rediscovery;
|
|
179
|
+
if (!isUuid(context.evidence_id)) {
|
|
180
|
+
throw new TypeError("Listening-history evidence is invalid");
|
|
181
|
+
}
|
|
182
|
+
const rediscoverySignal = cleanText(
|
|
183
|
+
context.rediscovery_signal,
|
|
184
|
+
128,
|
|
185
|
+
"Rediscovery signal",
|
|
186
|
+
);
|
|
187
|
+
if (!historySupportSignals.has(rediscoverySignal)) {
|
|
188
|
+
throw new TypeError("Rediscovery signal is invalid");
|
|
189
|
+
}
|
|
190
|
+
track.rediscovery = {
|
|
191
|
+
listening_minutes: safeCount(
|
|
192
|
+
context.listening_minutes,
|
|
193
|
+
"Rediscovery listening minutes",
|
|
194
|
+
),
|
|
195
|
+
engaged_play_count: safeCount(
|
|
196
|
+
context.engaged_play_count,
|
|
197
|
+
"Rediscovery engaged plays",
|
|
198
|
+
),
|
|
199
|
+
explicit_skips: safeCount(
|
|
200
|
+
context.explicit_skips,
|
|
201
|
+
"Rediscovery skips",
|
|
202
|
+
),
|
|
203
|
+
first_played_at: safeTimestamp(
|
|
204
|
+
context.first_played_at,
|
|
205
|
+
"Rediscovery first play",
|
|
206
|
+
),
|
|
207
|
+
last_played_at: safeTimestamp(
|
|
208
|
+
context.last_played_at,
|
|
209
|
+
"Rediscovery last play",
|
|
210
|
+
),
|
|
211
|
+
quiet_days: safeCount(context.quiet_days, "Rediscovery quiet days"),
|
|
212
|
+
rediscovery_signal: rediscoverySignal,
|
|
213
|
+
...(Number.isSafeInteger(context.peak_year)
|
|
214
|
+
? {
|
|
215
|
+
peak_year: safeCount(context.peak_year, "Rediscovery peak year"),
|
|
216
|
+
peak_year_play_count: safeCount(
|
|
217
|
+
context.peak_year_play_count,
|
|
218
|
+
"Rediscovery peak-year plays",
|
|
219
|
+
),
|
|
220
|
+
peak_year_listening_minutes: safeCount(
|
|
221
|
+
context.peak_year_listening_minutes,
|
|
222
|
+
"Rediscovery peak-year minutes",
|
|
223
|
+
),
|
|
224
|
+
}
|
|
225
|
+
: {}),
|
|
226
|
+
evidence_id: context.evidence_id.toLowerCase(),
|
|
227
|
+
};
|
|
228
|
+
} else if (hasHistoricalReturn) {
|
|
229
|
+
const context = value.historical_return;
|
|
230
|
+
const returnCount = safeCount(
|
|
231
|
+
context.return_count,
|
|
232
|
+
"Historical-return count",
|
|
233
|
+
);
|
|
234
|
+
const longestGapDays = safeCount(
|
|
235
|
+
context.longest_gap_days,
|
|
236
|
+
"Historical-return longest gap",
|
|
237
|
+
);
|
|
238
|
+
const latestReturnGapDays = safeCount(
|
|
239
|
+
context.latest_return_gap_days,
|
|
240
|
+
"Historical-return latest gap",
|
|
241
|
+
);
|
|
242
|
+
const historicalReturnSignal = cleanText(
|
|
243
|
+
context.historical_return_signal,
|
|
244
|
+
128,
|
|
245
|
+
"Historical-return signal",
|
|
246
|
+
);
|
|
247
|
+
const firstPlayedAt = safeTimestamp(
|
|
248
|
+
context.first_played_at,
|
|
249
|
+
"Historical-return first play",
|
|
250
|
+
);
|
|
251
|
+
const lastPlayedAt = safeTimestamp(
|
|
252
|
+
context.last_played_at,
|
|
253
|
+
"Historical-return last play",
|
|
254
|
+
);
|
|
255
|
+
const latestReturnAt = safeTimestamp(
|
|
256
|
+
context.latest_return_at,
|
|
257
|
+
"Historical-return latest return",
|
|
258
|
+
);
|
|
259
|
+
const engagedPlayCount = safeCount(
|
|
260
|
+
context.engaged_play_count,
|
|
261
|
+
"Historical-return engaged plays",
|
|
262
|
+
);
|
|
263
|
+
const explicitSkips = safeCount(
|
|
264
|
+
context.explicit_skips,
|
|
265
|
+
"Historical-return skips",
|
|
266
|
+
);
|
|
267
|
+
if (
|
|
268
|
+
returnCount < 1 ||
|
|
269
|
+
longestGapDays < 1 ||
|
|
270
|
+
latestReturnGapDays < 1 ||
|
|
271
|
+
latestReturnGapDays > longestGapDays ||
|
|
272
|
+
engagedPlayCount > playCount ||
|
|
273
|
+
explicitSkips > playCount ||
|
|
274
|
+
Date.parse(firstPlayedAt) > Date.parse(latestReturnAt) ||
|
|
275
|
+
Date.parse(latestReturnAt) > Date.parse(lastPlayedAt) ||
|
|
276
|
+
!historySupportSignals.has(historicalReturnSignal) ||
|
|
277
|
+
!isUuid(context.evidence_id)
|
|
278
|
+
) {
|
|
279
|
+
throw new TypeError("Historical-return candidate is invalid");
|
|
280
|
+
}
|
|
281
|
+
track.historical_return = {
|
|
282
|
+
listening_minutes: safeCount(
|
|
283
|
+
context.listening_minutes,
|
|
284
|
+
"Historical-return listening minutes",
|
|
285
|
+
),
|
|
286
|
+
engaged_play_count: engagedPlayCount,
|
|
287
|
+
explicit_skips: explicitSkips,
|
|
288
|
+
first_played_at: firstPlayedAt,
|
|
289
|
+
last_played_at: lastPlayedAt,
|
|
290
|
+
return_count: returnCount,
|
|
291
|
+
longest_gap_days: longestGapDays,
|
|
292
|
+
latest_return_at: latestReturnAt,
|
|
293
|
+
latest_return_gap_days: latestReturnGapDays,
|
|
294
|
+
historical_return_signal: historicalReturnSignal,
|
|
295
|
+
evidence_id: context.evidence_id.toLowerCase(),
|
|
296
|
+
};
|
|
297
|
+
} else if (hasTimeCapsule) {
|
|
298
|
+
const context = value.time_capsule;
|
|
299
|
+
const year = safeCount(context.year, "Time Machine year");
|
|
300
|
+
if (year < 1900 || year > 9999 || !isUuid(context.evidence_id)) {
|
|
301
|
+
throw new TypeError("Time Machine candidate is invalid");
|
|
302
|
+
}
|
|
303
|
+
track.time_capsule = {
|
|
304
|
+
year,
|
|
305
|
+
year_play_count: safeCount(
|
|
306
|
+
context.year_play_count,
|
|
307
|
+
"Time Machine year plays",
|
|
308
|
+
),
|
|
309
|
+
year_engaged_play_count: safeCount(
|
|
310
|
+
context.year_engaged_play_count,
|
|
311
|
+
"Time Machine engaged plays",
|
|
312
|
+
),
|
|
313
|
+
year_listening_minutes: safeCount(
|
|
314
|
+
context.year_listening_minutes,
|
|
315
|
+
"Time Machine year minutes",
|
|
316
|
+
),
|
|
317
|
+
year_explicit_skips: safeCount(
|
|
318
|
+
context.year_explicit_skips,
|
|
319
|
+
"Time Machine year skips",
|
|
320
|
+
),
|
|
321
|
+
lifetime_listening_minutes: safeCount(
|
|
322
|
+
context.lifetime_listening_minutes,
|
|
323
|
+
"Time Machine lifetime minutes",
|
|
324
|
+
),
|
|
325
|
+
representative_signal: cleanText(
|
|
326
|
+
context.representative_signal,
|
|
327
|
+
128,
|
|
328
|
+
"Time Machine signal",
|
|
329
|
+
),
|
|
330
|
+
evidence_id: context.evidence_id.toLowerCase(),
|
|
331
|
+
};
|
|
332
|
+
} else {
|
|
333
|
+
const context = value.back_to_back;
|
|
334
|
+
const engagedPlayCount = safeCount(
|
|
335
|
+
context.engaged_play_count,
|
|
336
|
+
"Back-to-back engaged plays",
|
|
337
|
+
);
|
|
338
|
+
const explicitSkips = safeCount(
|
|
339
|
+
context.explicit_skips,
|
|
340
|
+
"Back-to-back skips",
|
|
341
|
+
);
|
|
342
|
+
const burstCount = safeCount(
|
|
343
|
+
context.burst_count,
|
|
344
|
+
"Back-to-back burst count",
|
|
345
|
+
);
|
|
346
|
+
const maximumConsecutivePlays = safeCount(
|
|
347
|
+
context.maximum_consecutive_plays,
|
|
348
|
+
"Back-to-back maximum consecutive plays",
|
|
349
|
+
);
|
|
350
|
+
const playsInBursts = safeCount(
|
|
351
|
+
context.plays_in_bursts,
|
|
352
|
+
"Back-to-back plays in bursts",
|
|
353
|
+
);
|
|
354
|
+
const sequenceSignal = cleanText(
|
|
355
|
+
context.sequence_signal,
|
|
356
|
+
128,
|
|
357
|
+
"Back-to-back sequence signal",
|
|
358
|
+
);
|
|
359
|
+
if (
|
|
360
|
+
burstCount < 1 ||
|
|
361
|
+
maximumConsecutivePlays < 2 ||
|
|
362
|
+
playsInBursts < maximumConsecutivePlays ||
|
|
363
|
+
playsInBursts < burstCount * 2 ||
|
|
364
|
+
playsInBursts > playCount ||
|
|
365
|
+
engagedPlayCount > playCount ||
|
|
366
|
+
explicitSkips > playCount ||
|
|
367
|
+
!backToBackSignals.has(sequenceSignal) ||
|
|
368
|
+
!isUuid(context.evidence_id)
|
|
369
|
+
) {
|
|
370
|
+
throw new TypeError("Back-to-back candidate is invalid");
|
|
371
|
+
}
|
|
372
|
+
track.back_to_back = {
|
|
373
|
+
engaged_play_count: engagedPlayCount,
|
|
374
|
+
explicit_skips: explicitSkips,
|
|
375
|
+
burst_count: burstCount,
|
|
376
|
+
maximum_consecutive_plays: maximumConsecutivePlays,
|
|
377
|
+
plays_in_bursts: playsInBursts,
|
|
378
|
+
listening_minutes_in_bursts: safeCount(
|
|
379
|
+
context.listening_minutes_in_bursts,
|
|
380
|
+
"Back-to-back listening minutes",
|
|
381
|
+
),
|
|
382
|
+
latest_burst_at: safeTimestamp(
|
|
383
|
+
context.latest_burst_at,
|
|
384
|
+
"Back-to-back latest burst",
|
|
385
|
+
),
|
|
386
|
+
sequence_signal: sequenceSignal,
|
|
387
|
+
evidence_id: context.evidence_id.toLowerCase(),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (
|
|
391
|
+
Number.isSafeInteger(value.duration_ms) &&
|
|
392
|
+
value.duration_ms >= 0 &&
|
|
393
|
+
value.duration_ms <= 86_400_000
|
|
394
|
+
) {
|
|
395
|
+
track.duration_ms = value.duration_ms;
|
|
396
|
+
}
|
|
397
|
+
const refs = safeExternalRefs(value.external_refs);
|
|
398
|
+
if (refs.length > 0) track.external_refs = refs;
|
|
399
|
+
return track;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function publicHistoryTrack(track) {
|
|
403
|
+
const result = structuredClone(track);
|
|
404
|
+
delete result.external_refs;
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export class ListeningProfileDomainServices {
|
|
409
|
+
#store;
|
|
410
|
+
#subjectId;
|
|
411
|
+
#candidateSets = new Map();
|
|
412
|
+
|
|
413
|
+
constructor({ listeningHistoryStore, subjectId } = {}) {
|
|
414
|
+
if (
|
|
415
|
+
!listeningHistoryStore ||
|
|
416
|
+
typeof listeningHistoryStore.profileSummary !== "function" ||
|
|
417
|
+
typeof listeningHistoryStore.explainProfileEvidence !== "function" ||
|
|
418
|
+
typeof listeningHistoryStore.subjectDataStatus !== "function"
|
|
419
|
+
) {
|
|
420
|
+
throw new TypeError("A trusted listening-history store is required");
|
|
421
|
+
}
|
|
422
|
+
if (!isUuid(subjectId)) {
|
|
423
|
+
throw new TypeError("A trusted listening profile subject is required");
|
|
424
|
+
}
|
|
425
|
+
this.#store = listeningHistoryStore;
|
|
426
|
+
this.#subjectId = subjectId.toLowerCase();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
status() {
|
|
430
|
+
return {
|
|
431
|
+
state: "ready",
|
|
432
|
+
adapter: "listening_history_profile",
|
|
433
|
+
subject_scope: "trusted_runtime",
|
|
434
|
+
external_effects: "none",
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
profileStatus() {
|
|
439
|
+
const source = this.#store.subjectDataStatus({
|
|
440
|
+
subjectId: this.#subjectId,
|
|
441
|
+
});
|
|
442
|
+
const listening = this.#store.profileSummary({
|
|
443
|
+
subjectId: this.#subjectId,
|
|
444
|
+
maxItems: 1,
|
|
445
|
+
});
|
|
446
|
+
return {
|
|
447
|
+
state: source.state,
|
|
448
|
+
projection_version: "profile-projection/listening-history/1",
|
|
449
|
+
evidence_records:
|
|
450
|
+
source.profile_evidence + source.active_taste_assertions,
|
|
451
|
+
effective_listening_events: source.effective_listening_events,
|
|
452
|
+
listening_hours: listening.coverage.listening_hours,
|
|
453
|
+
spotify_profile_evidence: source.profile_evidence,
|
|
454
|
+
claims: source.active_taste_assertions,
|
|
455
|
+
listening_sources: listening.source.providers ?? [],
|
|
456
|
+
reason:
|
|
457
|
+
"The local profile combines supported private persistent listening-history evidence with current explicit, retractable listener assertions. Apple Music setup is optional.",
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
rediscoveryReady() {
|
|
462
|
+
return typeof this.#store.rediscoveryCandidates === "function";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
historicalReturnReady() {
|
|
466
|
+
return typeof this.#store.historicalReturnCandidates === "function";
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
timeCapsuleReady() {
|
|
470
|
+
return typeof this.#store.timeCapsuleCandidates === "function";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
backToBackReady() {
|
|
474
|
+
return typeof this.#store.backToBackCandidates === "function";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
playlistReady() {
|
|
478
|
+
return (
|
|
479
|
+
this.rediscoveryReady() ||
|
|
480
|
+
this.historicalReturnReady() ||
|
|
481
|
+
this.timeCapsuleReady() ||
|
|
482
|
+
this.backToBackReady()
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
beginPrompt() {
|
|
487
|
+
return this.resetCandidateSets();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
endPrompt() {
|
|
491
|
+
return this.resetCandidateSets();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
resetCandidateSets() {
|
|
495
|
+
const invalidated = this.#candidateSets.size;
|
|
496
|
+
this.#candidateSets.clear();
|
|
497
|
+
return { invalidated_candidate_sets: invalidated };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#registerHistoryTracks(tracks) {
|
|
501
|
+
if (this.#candidateSets.size >= CANDIDATE_SETS_MAX) {
|
|
502
|
+
throw new Error("The prompt has reached its candidate set limit");
|
|
503
|
+
}
|
|
504
|
+
const prepared = tracks.map(safeHistoryTrack);
|
|
505
|
+
if (
|
|
506
|
+
new Set(prepared.map((track) => track.track_ref_id)).size !==
|
|
507
|
+
prepared.length
|
|
508
|
+
) {
|
|
509
|
+
throw new TypeError("Listening-history candidates are duplicated");
|
|
510
|
+
}
|
|
511
|
+
const candidateSetId = randomUUID();
|
|
512
|
+
this.#candidateSets.set(
|
|
513
|
+
candidateSetId,
|
|
514
|
+
new Map(
|
|
515
|
+
prepared.map((track) => [track.track_ref_id, structuredClone(track)]),
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
return { candidateSetId, tracks: prepared };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
registerExternalCandidateSet({ tracks, source } = {}) {
|
|
522
|
+
if (!Array.isArray(tracks) || tracks.length < 1 || tracks.length > HISTORY_MAX_ITEMS) {
|
|
523
|
+
throw new TypeError("External catalog candidates are invalid");
|
|
524
|
+
}
|
|
525
|
+
if (this.#candidateSets.size >= CANDIDATE_SETS_MAX) {
|
|
526
|
+
throw new Error("The prompt has reached its candidate set limit");
|
|
527
|
+
}
|
|
528
|
+
const trustedSource = safeExternalSource(source);
|
|
529
|
+
const prepared = tracks.map(safeExternalTrack);
|
|
530
|
+
if (new Set(prepared.map((track) => track.track_ref_id)).size !== prepared.length) {
|
|
531
|
+
throw new TypeError("External catalog candidates are duplicated");
|
|
532
|
+
}
|
|
533
|
+
// Outside suggestions should be new to the listener, so drop anything already played.
|
|
534
|
+
const listened = typeof this.#store.listenedTracks === "function"
|
|
535
|
+
? this.#store.listenedTracks({ subjectId: this.#subjectId })
|
|
536
|
+
: [];
|
|
537
|
+
const accepted = [];
|
|
538
|
+
let excludedHistoryMatches = 0;
|
|
539
|
+
for (const track of prepared) {
|
|
540
|
+
if (listened.some((heard) => exactTitleArtistMatch(track, heard))) {
|
|
541
|
+
excludedHistoryMatches += 1;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
accepted.push({
|
|
545
|
+
...track,
|
|
546
|
+
knownness: {
|
|
547
|
+
imported_library: "not_imported",
|
|
548
|
+
listening_history: "not_found_by_exact_title_artist",
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const registered = accepted.length > 0 ? randomUUID() : null;
|
|
553
|
+
if (registered) {
|
|
554
|
+
this.#candidateSets.set(
|
|
555
|
+
registered,
|
|
556
|
+
new Map(accepted.map((track) => [track.track_ref_id, structuredClone(track)])),
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
candidate_set_id: registered,
|
|
561
|
+
candidate_scope: "external_catalog",
|
|
562
|
+
result_count: accepted.length,
|
|
563
|
+
excluded_library_matches: excludedHistoryMatches,
|
|
564
|
+
expires_on: "prompt_end",
|
|
565
|
+
source: trustedSource,
|
|
566
|
+
tracks: accepted.map((track) => structuredClone(track)),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async getLyricSeeds() {
|
|
571
|
+
return {
|
|
572
|
+
subjectId: this.#subjectId,
|
|
573
|
+
tracks: lyricSeedsFromProfile(this.#store.lyricProfile({ subjectId: this.#subjectId })),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async getProfileSummary(argumentsValue) {
|
|
578
|
+
const input = modelArguments(argumentsValue, new Set(["maxItems"]));
|
|
579
|
+
const maxItems = boundedItems(input.maxItems);
|
|
580
|
+
const listening = this.#store.profileSummary({
|
|
581
|
+
subjectId: this.#subjectId,
|
|
582
|
+
maxItems,
|
|
583
|
+
});
|
|
584
|
+
return createListeningHistoryProfileProjection(listening, { maxItems });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async getRediscoveryCandidates(argumentsValue) {
|
|
588
|
+
const input = modelArguments(argumentsValue, new Set(["limit"]));
|
|
589
|
+
const limit = boundedHistoryItems(input.limit);
|
|
590
|
+
if (!this.rediscoveryReady()) {
|
|
591
|
+
throw new Error("Listening-history rediscovery is unavailable");
|
|
592
|
+
}
|
|
593
|
+
const raw = this.#store.rediscoveryCandidates({
|
|
594
|
+
subjectId: this.#subjectId,
|
|
595
|
+
limit,
|
|
596
|
+
});
|
|
597
|
+
const tracks = Array.isArray(raw?.tracks) ? raw.tracks : [];
|
|
598
|
+
if (tracks.length === 0) {
|
|
599
|
+
return {
|
|
600
|
+
state: "empty",
|
|
601
|
+
candidate_set_id: null,
|
|
602
|
+
candidate_scope: "private_history",
|
|
603
|
+
result_count: 0,
|
|
604
|
+
limit_applied: limit,
|
|
605
|
+
reference_date: raw?.reference_date ?? null,
|
|
606
|
+
quiet_days: safeCount(raw?.quiet_days, "Rediscovery quiet days"),
|
|
607
|
+
minimum_plays: safeCount(
|
|
608
|
+
raw?.minimum_plays,
|
|
609
|
+
"Rediscovery minimum plays",
|
|
610
|
+
),
|
|
611
|
+
minimum_engaged_plays: safeCount(
|
|
612
|
+
raw?.minimum_engaged_plays,
|
|
613
|
+
"Rediscovery minimum engaged plays",
|
|
614
|
+
),
|
|
615
|
+
minimum_listening_minutes: safeCount(
|
|
616
|
+
raw?.minimum_listening_minutes,
|
|
617
|
+
"Rediscovery minimum minutes",
|
|
618
|
+
),
|
|
619
|
+
expires_on: "prompt_end",
|
|
620
|
+
tracks: [],
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const registered = this.#registerHistoryTracks(tracks.slice(0, limit));
|
|
624
|
+
return {
|
|
625
|
+
state: "ready",
|
|
626
|
+
candidate_set_id: registered.candidateSetId,
|
|
627
|
+
candidate_scope: "private_history",
|
|
628
|
+
result_count: registered.tracks.length,
|
|
629
|
+
limit_applied: limit,
|
|
630
|
+
reference_date: safeTimestamp(raw.reference_date, "Rediscovery reference date"),
|
|
631
|
+
quiet_days: safeCount(raw.quiet_days, "Rediscovery quiet days"),
|
|
632
|
+
minimum_plays: safeCount(raw.minimum_plays, "Rediscovery minimum plays"),
|
|
633
|
+
minimum_engaged_plays: safeCount(
|
|
634
|
+
raw.minimum_engaged_plays,
|
|
635
|
+
"Rediscovery minimum engaged plays",
|
|
636
|
+
),
|
|
637
|
+
minimum_listening_minutes: safeCount(
|
|
638
|
+
raw.minimum_listening_minutes,
|
|
639
|
+
"Rediscovery minimum minutes",
|
|
640
|
+
),
|
|
641
|
+
expires_on: "prompt_end",
|
|
642
|
+
tracks: registered.tracks.map(publicHistoryTrack),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async getHistoricalReturnCandidates(argumentsValue) {
|
|
647
|
+
const input = modelArguments(argumentsValue, new Set(["limit"]));
|
|
648
|
+
const limit = boundedHistoryItems(input.limit);
|
|
649
|
+
if (!this.historicalReturnReady()) {
|
|
650
|
+
throw new Error("Listening-history returns are unavailable");
|
|
651
|
+
}
|
|
652
|
+
const raw = this.#store.historicalReturnCandidates({
|
|
653
|
+
subjectId: this.#subjectId,
|
|
654
|
+
limit,
|
|
655
|
+
});
|
|
656
|
+
const tracks = Array.isArray(raw?.tracks) ? raw.tracks : [];
|
|
657
|
+
const common = {
|
|
658
|
+
reference_date: raw?.reference_date ?? null,
|
|
659
|
+
minimum_gap_days: safeCount(
|
|
660
|
+
raw?.minimum_gap_days,
|
|
661
|
+
"Historical-return minimum gap",
|
|
662
|
+
),
|
|
663
|
+
minimum_plays: safeCount(
|
|
664
|
+
raw?.minimum_plays,
|
|
665
|
+
"Historical-return minimum plays",
|
|
666
|
+
),
|
|
667
|
+
minimum_engaged_plays: safeCount(
|
|
668
|
+
raw?.minimum_engaged_plays,
|
|
669
|
+
"Historical-return minimum engaged plays",
|
|
670
|
+
),
|
|
671
|
+
minimum_listening_minutes: safeCount(
|
|
672
|
+
raw?.minimum_listening_minutes,
|
|
673
|
+
"Historical-return minimum minutes",
|
|
674
|
+
),
|
|
675
|
+
};
|
|
676
|
+
if (tracks.length === 0) {
|
|
677
|
+
return {
|
|
678
|
+
state: "empty",
|
|
679
|
+
candidate_set_id: null,
|
|
680
|
+
candidate_scope: "private_history",
|
|
681
|
+
result_count: 0,
|
|
682
|
+
limit_applied: limit,
|
|
683
|
+
...common,
|
|
684
|
+
expires_on: "prompt_end",
|
|
685
|
+
tracks: [],
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
const registered = this.#registerHistoryTracks(tracks.slice(0, limit));
|
|
689
|
+
return {
|
|
690
|
+
state: "ready",
|
|
691
|
+
candidate_set_id: registered.candidateSetId,
|
|
692
|
+
candidate_scope: "private_history",
|
|
693
|
+
result_count: registered.tracks.length,
|
|
694
|
+
limit_applied: limit,
|
|
695
|
+
...common,
|
|
696
|
+
reference_date: safeTimestamp(
|
|
697
|
+
raw.reference_date,
|
|
698
|
+
"Historical-return reference date",
|
|
699
|
+
),
|
|
700
|
+
expires_on: "prompt_end",
|
|
701
|
+
tracks: registered.tracks.map(publicHistoryTrack),
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async getBackToBackCandidates(argumentsValue) {
|
|
706
|
+
const input = modelArguments(argumentsValue, new Set(["limit"]));
|
|
707
|
+
const limit = boundedHistoryItems(input.limit);
|
|
708
|
+
if (!this.backToBackReady()) {
|
|
709
|
+
throw new Error("Listening-history back-to-back playback is unavailable");
|
|
710
|
+
}
|
|
711
|
+
const raw = this.#store.backToBackCandidates({
|
|
712
|
+
subjectId: this.#subjectId,
|
|
713
|
+
limit,
|
|
714
|
+
});
|
|
715
|
+
const tracks = Array.isArray(raw?.tracks) ? raw.tracks : [];
|
|
716
|
+
const common = {
|
|
717
|
+
reference_date: raw?.reference_date ?? null,
|
|
718
|
+
minimum_consecutive_plays: safeCount(
|
|
719
|
+
raw?.minimum_consecutive_plays,
|
|
720
|
+
"Back-to-back minimum consecutive plays",
|
|
721
|
+
),
|
|
722
|
+
minimum_played_seconds: safeCount(
|
|
723
|
+
raw?.minimum_played_seconds,
|
|
724
|
+
"Back-to-back minimum played seconds",
|
|
725
|
+
),
|
|
726
|
+
maximum_gap_minutes: safeCount(
|
|
727
|
+
raw?.maximum_gap_minutes,
|
|
728
|
+
"Back-to-back maximum gap minutes",
|
|
729
|
+
),
|
|
730
|
+
};
|
|
731
|
+
if (
|
|
732
|
+
common.minimum_consecutive_plays < 2 ||
|
|
733
|
+
common.minimum_played_seconds < 1 ||
|
|
734
|
+
common.maximum_gap_minutes < 1
|
|
735
|
+
) {
|
|
736
|
+
throw new TypeError("Back-to-back thresholds are invalid");
|
|
737
|
+
}
|
|
738
|
+
if (tracks.length === 0) {
|
|
739
|
+
return {
|
|
740
|
+
state: "empty",
|
|
741
|
+
candidate_set_id: null,
|
|
742
|
+
candidate_scope: "private_history",
|
|
743
|
+
result_count: 0,
|
|
744
|
+
limit_applied: limit,
|
|
745
|
+
...common,
|
|
746
|
+
expires_on: "prompt_end",
|
|
747
|
+
tracks: [],
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const registered = this.#registerHistoryTracks(tracks.slice(0, limit));
|
|
751
|
+
return {
|
|
752
|
+
state: "ready",
|
|
753
|
+
candidate_set_id: registered.candidateSetId,
|
|
754
|
+
candidate_scope: "private_history",
|
|
755
|
+
result_count: registered.tracks.length,
|
|
756
|
+
limit_applied: limit,
|
|
757
|
+
...common,
|
|
758
|
+
reference_date: safeTimestamp(
|
|
759
|
+
raw.reference_date,
|
|
760
|
+
"Back-to-back reference date",
|
|
761
|
+
),
|
|
762
|
+
expires_on: "prompt_end",
|
|
763
|
+
tracks: registered.tracks.map(publicHistoryTrack),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async getTimeCapsuleCandidates(argumentsValue) {
|
|
768
|
+
const input = modelArguments(argumentsValue, new Set(["limit"]));
|
|
769
|
+
const limit = boundedHistoryItems(input.limit, 2);
|
|
770
|
+
if (!this.timeCapsuleReady()) {
|
|
771
|
+
throw new Error("Listening-history Time Machine is unavailable");
|
|
772
|
+
}
|
|
773
|
+
const raw = this.#store.timeCapsuleCandidates({
|
|
774
|
+
subjectId: this.#subjectId,
|
|
775
|
+
limit,
|
|
776
|
+
});
|
|
777
|
+
const tracks = Array.isArray(raw?.tracks) ? raw.tracks : [];
|
|
778
|
+
const common = {
|
|
779
|
+
reference_date: raw?.reference_date ?? null,
|
|
780
|
+
history_start_year: raw?.history_start_year ?? null,
|
|
781
|
+
history_end_year: raw?.history_end_year ?? null,
|
|
782
|
+
minimum_years: safeCount(raw?.minimum_years, "Time Machine minimum years"),
|
|
783
|
+
minimum_engaged_plays: safeCount(
|
|
784
|
+
raw?.minimum_engaged_plays,
|
|
785
|
+
"Time Machine minimum engaged plays",
|
|
786
|
+
),
|
|
787
|
+
minimum_listening_minutes: safeCount(
|
|
788
|
+
raw?.minimum_listening_minutes,
|
|
789
|
+
"Time Machine minimum minutes",
|
|
790
|
+
),
|
|
791
|
+
};
|
|
792
|
+
if (tracks.length === 0) {
|
|
793
|
+
return {
|
|
794
|
+
state: "empty",
|
|
795
|
+
candidate_set_id: null,
|
|
796
|
+
candidate_scope: "private_history",
|
|
797
|
+
result_count: 0,
|
|
798
|
+
limit_applied: limit,
|
|
799
|
+
...common,
|
|
800
|
+
represented_years: [],
|
|
801
|
+
expires_on: "prompt_end",
|
|
802
|
+
tracks: [],
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
const registered = this.#registerHistoryTracks(tracks.slice(0, limit));
|
|
806
|
+
const representedYears = registered.tracks.map(
|
|
807
|
+
(track) => track.time_capsule.year,
|
|
808
|
+
);
|
|
809
|
+
if (
|
|
810
|
+
representedYears.length < common.minimum_years ||
|
|
811
|
+
representedYears.some(
|
|
812
|
+
(year, index) => index > 0 && year <= representedYears[index - 1],
|
|
813
|
+
)
|
|
814
|
+
) {
|
|
815
|
+
throw new TypeError("Time Machine candidate years are invalid");
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
state: "ready",
|
|
819
|
+
candidate_set_id: registered.candidateSetId,
|
|
820
|
+
candidate_scope: "private_history",
|
|
821
|
+
result_count: registered.tracks.length,
|
|
822
|
+
limit_applied: limit,
|
|
823
|
+
...common,
|
|
824
|
+
reference_date: safeTimestamp(
|
|
825
|
+
raw.reference_date,
|
|
826
|
+
"Time Machine reference date",
|
|
827
|
+
),
|
|
828
|
+
represented_years: representedYears,
|
|
829
|
+
expires_on: "prompt_end",
|
|
830
|
+
tracks: registered.tracks.map(publicHistoryTrack),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async buildPlaylistPlan(argumentsValue) {
|
|
835
|
+
const input = modelArguments(
|
|
836
|
+
argumentsValue,
|
|
837
|
+
new Set([
|
|
838
|
+
"intent",
|
|
839
|
+
"requestedTrackCount",
|
|
840
|
+
"candidateSetIds",
|
|
841
|
+
"trackRefs",
|
|
842
|
+
"orderingNotes",
|
|
843
|
+
]),
|
|
844
|
+
);
|
|
845
|
+
const intent = cleanText(input.intent, 500, "Playlist intent");
|
|
846
|
+
const orderingNotes = cleanText(
|
|
847
|
+
input.orderingNotes,
|
|
848
|
+
500,
|
|
849
|
+
"Playlist ordering notes",
|
|
850
|
+
);
|
|
851
|
+
if (
|
|
852
|
+
!Number.isInteger(input.requestedTrackCount) ||
|
|
853
|
+
input.requestedTrackCount < 1 ||
|
|
854
|
+
input.requestedTrackCount > HISTORY_MAX_ITEMS ||
|
|
855
|
+
!Array.isArray(input.candidateSetIds) ||
|
|
856
|
+
input.candidateSetIds.length < 1 ||
|
|
857
|
+
input.candidateSetIds.length > CANDIDATE_SETS_MAX ||
|
|
858
|
+
new Set(input.candidateSetIds).size !== input.candidateSetIds.length ||
|
|
859
|
+
!Array.isArray(input.trackRefs) ||
|
|
860
|
+
input.trackRefs.length !== input.requestedTrackCount
|
|
861
|
+
) {
|
|
862
|
+
throw new TypeError("Playlist plan request is invalid");
|
|
863
|
+
}
|
|
864
|
+
const trusted = new Map();
|
|
865
|
+
for (const candidateSetId of input.candidateSetIds) {
|
|
866
|
+
const set = this.#candidateSets.get(candidateSetId);
|
|
867
|
+
if (!set) throw new Error("A playlist candidate set is unavailable or expired");
|
|
868
|
+
for (const [trackRefId, track] of set) trusted.set(trackRefId, track);
|
|
869
|
+
}
|
|
870
|
+
const selected = input.trackRefs.map((selection) => {
|
|
871
|
+
if (
|
|
872
|
+
!selection ||
|
|
873
|
+
typeof selection !== "object" ||
|
|
874
|
+
Array.isArray(selection) ||
|
|
875
|
+
Object.keys(selection).some(
|
|
876
|
+
(key) => !new Set(["trackRefId", "selectionReason"]).has(key),
|
|
877
|
+
) ||
|
|
878
|
+
!isUuid(selection.trackRefId)
|
|
879
|
+
) {
|
|
880
|
+
throw new TypeError("Playlist track selection is invalid");
|
|
881
|
+
}
|
|
882
|
+
const track = trusted.get(selection.trackRefId.toLowerCase());
|
|
883
|
+
if (!track) throw new Error("A playlist track was not returned by a trusted candidate set");
|
|
884
|
+
return {
|
|
885
|
+
track,
|
|
886
|
+
selectionReason: cleanText(
|
|
887
|
+
selection.selectionReason,
|
|
888
|
+
256,
|
|
889
|
+
"Playlist selection reason",
|
|
890
|
+
),
|
|
891
|
+
};
|
|
892
|
+
});
|
|
893
|
+
if (
|
|
894
|
+
new Set(selected.map(({ track }) => track.track_ref_id)).size !==
|
|
895
|
+
selected.length
|
|
896
|
+
) {
|
|
897
|
+
throw new TypeError("A playlist plan cannot contain duplicate tracks");
|
|
898
|
+
}
|
|
899
|
+
const tracks = selected.map(({ track, selectionReason }, index) => ({
|
|
900
|
+
position: index + 1,
|
|
901
|
+
track_ref_id: track.track_ref_id,
|
|
902
|
+
title: track.title,
|
|
903
|
+
artist_credit: track.artist_credit,
|
|
904
|
+
release: track.release,
|
|
905
|
+
candidate_scope: track.candidate_scope ?? "private_history",
|
|
906
|
+
selection_reason: selectionReason,
|
|
907
|
+
...(track.duration_ms !== undefined
|
|
908
|
+
? { duration_ms: track.duration_ms }
|
|
909
|
+
: {}),
|
|
910
|
+
...(track.candidate_scope === "external_catalog"
|
|
911
|
+
? track.catalog_provider === "apple_music" && track.catalog_url
|
|
912
|
+
? { public_catalog_reference: { provider: "apple_music", url: track.catalog_url } }
|
|
913
|
+
: {}
|
|
914
|
+
: {
|
|
915
|
+
history_context: track.time_capsule
|
|
916
|
+
? { kind: "time_capsule", year: track.time_capsule.year }
|
|
917
|
+
: track.historical_return
|
|
918
|
+
? { kind: "historical_return" }
|
|
919
|
+
: track.back_to_back
|
|
920
|
+
? { kind: "back_to_back" }
|
|
921
|
+
: { kind: "rediscovery" },
|
|
922
|
+
}),
|
|
923
|
+
}));
|
|
924
|
+
assertExternalPlanDiversity(tracks, intent);
|
|
925
|
+
const selectedScopes = new Set(tracks.map((track) => track.candidate_scope));
|
|
926
|
+
return {
|
|
927
|
+
plan_version: "playlist_plan/0",
|
|
928
|
+
intent,
|
|
929
|
+
requested_track_count: input.requestedTrackCount,
|
|
930
|
+
track_count: tracks.length,
|
|
931
|
+
tracks,
|
|
932
|
+
candidate_scope: selectedScopes.size === 1 ? [...selectedScopes][0] : "mixed",
|
|
933
|
+
ordering_rationale: orderingNotes,
|
|
934
|
+
candidate_sets_validated: input.candidateSetIds.length,
|
|
935
|
+
persistence: "none",
|
|
936
|
+
external_effects: "none",
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
getTrustedTracks(trackRefs) {
|
|
941
|
+
if (
|
|
942
|
+
!Array.isArray(trackRefs) ||
|
|
943
|
+
trackRefs.length < 1 ||
|
|
944
|
+
trackRefs.length > HISTORY_MAX_ITEMS
|
|
945
|
+
) {
|
|
946
|
+
throw new TypeError("Trusted track references are invalid");
|
|
947
|
+
}
|
|
948
|
+
const tracks = [];
|
|
949
|
+
for (const trackRefId of trackRefs) {
|
|
950
|
+
if (!isUuid(trackRefId)) {
|
|
951
|
+
throw new TypeError("Trusted track references are invalid");
|
|
952
|
+
}
|
|
953
|
+
let found = null;
|
|
954
|
+
for (const set of this.#candidateSets.values()) {
|
|
955
|
+
const candidate = set.get(trackRefId.toLowerCase());
|
|
956
|
+
if (candidate) {
|
|
957
|
+
found = candidate;
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (!found) throw new Error("A trusted track reference is unavailable");
|
|
962
|
+
tracks.push(structuredClone(found));
|
|
963
|
+
}
|
|
964
|
+
return tracks;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
registerRetainedPlaylistCandidateSet({ tracks } = {}) {
|
|
968
|
+
if (
|
|
969
|
+
!Array.isArray(tracks) ||
|
|
970
|
+
tracks.length < 1 ||
|
|
971
|
+
tracks.length > HISTORY_MAX_ITEMS
|
|
972
|
+
) {
|
|
973
|
+
throw new TypeError("Retained playlist candidates are invalid");
|
|
974
|
+
}
|
|
975
|
+
const registered = this.#registerHistoryTracks(tracks);
|
|
976
|
+
return {
|
|
977
|
+
candidate_set_id: registered.candidateSetId,
|
|
978
|
+
candidate_scope: "private_history",
|
|
979
|
+
result_count: registered.tracks.length,
|
|
980
|
+
expires_on: "prompt_end",
|
|
981
|
+
tracks: registered.tracks.map(publicHistoryTrack),
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async explainProfileEvidence(argumentsValue) {
|
|
986
|
+
const input = modelArguments(argumentsValue, new Set(["evidenceId"]));
|
|
987
|
+
if (!isUuid(input.evidenceId)) {
|
|
988
|
+
throw new TypeError("Profile evidence ID is invalid");
|
|
989
|
+
}
|
|
990
|
+
const value = this.#store.explainProfileEvidence({
|
|
991
|
+
subjectId: this.#subjectId,
|
|
992
|
+
evidenceId: input.evidenceId,
|
|
993
|
+
});
|
|
994
|
+
if (!value) {
|
|
995
|
+
throw new Error(
|
|
996
|
+
"Profile evidence is unavailable in the trusted subject scope",
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
return value;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
close() {
|
|
1003
|
+
this.resetCandidateSets();
|
|
1004
|
+
this.#store.close();
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export function createListeningProfileDomainServices(options) {
|
|
1009
|
+
return new ListeningProfileDomainServices(options);
|
|
1010
|
+
}
|