@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
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Moondog</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center">Source candidate 0.2.0-beta.
|
|
7
|
+
<p align="center">Source candidate 0.2.0-beta.4</p>
|
|
8
8
|
|
|
9
9
|
<p align="center"><strong>A listening room that shows its work.</strong></p>
|
|
10
10
|
|
|
@@ -131,7 +131,7 @@ Playing a song a lot does not, on its own, establish how much it matters to you.
|
|
|
131
131
|
| --- | --- |
|
|
132
132
|
| Find a song or artist | Type to filter, then open it. |
|
|
133
133
|
| See why it is there | Read what you played, what that cannot prove, and what you told Moondog, each on its own. |
|
|
134
|
-
| Make a choice | Choose **I like this** (Like) or **Keep it out** (Avoid), and the profile updates in place. |
|
|
134
|
+
| Make a choice | Choose **I like this** (Like) or **Keep it out** (Avoid), and the profile updates in place. You can also say it to the agent, for example “never play this again”. |
|
|
135
135
|
| Change your mind | Choose **Undo my choice**, and the original listening history never changes. |
|
|
136
136
|
| Find more like this | Start an editable request for three songs, from the track you picked. |
|
|
137
137
|
|
|
@@ -155,6 +155,8 @@ It also sees what you already play: the song and playlist in the Music app on a
|
|
|
155
155
|
macOS asks once before Moondog may read the Music app.
|
|
156
156
|
For example, “Pon doce de salsa clásica a la cola” asks it to add up to twelve verified songs directly; your history guides the choice without limiting it to your library.
|
|
157
157
|
What it reads about the wider music world stays separate from what it knows about you.
|
|
158
|
+
Under a queue or a set of songs, one line names the mix: how many you know well, how many come back after a while, and how many are new to you.
|
|
159
|
+
Songs Moondog chose for you do not count as your own taste.
|
|
158
160
|
|
|
159
161
|
| Optional connection | What it adds |
|
|
160
162
|
| --- | --- |
|
|
@@ -199,6 +201,7 @@ Your profile, and anything Moondog remembers, carry over.
|
|
|
199
201
|
- [Private Tasteprint](docs/PRIVATE_TASTEPRINT.md): what the evidence can support, and what a report reveals about you.
|
|
200
202
|
- [Product charter](docs/PRODUCT_CHARTER.md) and [public roadmap](docs/ROADMAP.md): where Moondog is headed, and what it will not do.
|
|
201
203
|
- [Voice](docs/VOICE.md): how Moondog talks, and where Pink Floyd shows up.
|
|
204
|
+
- [The listening loop](docs/LISTENING_LOOP.md): how the agent keeps your choices, your recent listening, and its own picks apart.
|
|
202
205
|
- [Runtime architecture](docs/ADR_0001_AGENT_RUNTIME_AND_CLI.md) and [data contracts](contracts/README.md): how the pieces fit together.
|
|
203
206
|
- [Contribution guide](CONTRIBUTING.md): local setup, checks, and how private data is kept out.
|
|
204
207
|
- [Release status](docs/PUBLIC_RELEASE_READINESS.md): what is published, and what the checks cover.
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
|
|
3
|
-
import { createListenerProfileInput } from "../profile/listener-profile-build.mjs";
|
|
3
|
+
import { createListenerProfileInput, savedListenerChoices } from "../profile/listener-profile-build.mjs";
|
|
4
4
|
import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
|
|
5
|
+
import { cachedAvoidGate, createAvoidGate, listeningVersion } from "./listener-avoids.mjs";
|
|
6
|
+
import { recordListenerChoice } from "../profile/listener-corrections.mjs";
|
|
7
|
+
import { normalizedTrackTitle } from "./track-title.mjs";
|
|
5
8
|
import { collectionCoverage } from "../profile/music-providers.mjs";
|
|
6
9
|
import { lstat } from "node:fs/promises";
|
|
7
10
|
import path from "node:path";
|
|
@@ -812,9 +815,10 @@ function normalizedArtistNames(value) {
|
|
|
812
815
|
.filter(Boolean);
|
|
813
816
|
}
|
|
814
817
|
|
|
818
|
+
// A remaster or live take of a song counts as the song (src/core/track-title.mjs).
|
|
815
819
|
export function exactTitleArtistMatch(left, right) {
|
|
816
|
-
const leftTitle =
|
|
817
|
-
const rightTitle =
|
|
820
|
+
const leftTitle = normalizedTrackTitle(left.title);
|
|
821
|
+
const rightTitle = normalizedTrackTitle(right.title);
|
|
818
822
|
const leftArtists = normalizedArtistNames(left.artist_credit);
|
|
819
823
|
const rightArtists = new Set(normalizedArtistNames(right.artist_credit));
|
|
820
824
|
return (
|
|
@@ -883,13 +887,14 @@ export function safeExternalSource(raw) {
|
|
|
883
887
|
fail("external_candidate_invalid", "External catalog source metadata is invalid.");
|
|
884
888
|
}
|
|
885
889
|
if (raw.provider === "apple_music") {
|
|
886
|
-
|
|
890
|
+
// The storefront follows the listener's account or region, as a two-letter country code.
|
|
891
|
+
if (raw.catalog !== "itunes_search_api" || typeof raw.storefront !== "string" || !/^[A-Z]{2}$/u.test(raw.storefront)) {
|
|
887
892
|
fail("external_candidate_invalid", "External catalog source metadata is invalid.");
|
|
888
893
|
}
|
|
889
894
|
return {
|
|
890
895
|
provider: "apple_music",
|
|
891
896
|
catalog: "itunes_search_api",
|
|
892
|
-
storefront:
|
|
897
|
+
storefront: raw.storefront,
|
|
893
898
|
retrieved_at: raw.retrieved_at,
|
|
894
899
|
coverage: cleanText(raw.coverage, 500, "external_candidate_invalid"),
|
|
895
900
|
};
|
|
@@ -985,6 +990,8 @@ function preferenceSignalLabel(observation = {}) {
|
|
|
985
990
|
return labels.join(", ") || "explicit provider preference signal";
|
|
986
991
|
}
|
|
987
992
|
|
|
993
|
+
const avoidGates = new WeakMap();
|
|
994
|
+
|
|
988
995
|
export function listenerAssertionAvoids(
|
|
989
996
|
listening,
|
|
990
997
|
entityType,
|
|
@@ -992,19 +999,11 @@ export function listenerAssertionAvoids(
|
|
|
992
999
|
artistCredit = "",
|
|
993
1000
|
) {
|
|
994
1001
|
const avoids = listening?.listener_assertions?.avoids ?? [];
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
return
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
return entityType === "artist"
|
|
1001
|
-
? avoidedArtist === normalizedLabel
|
|
1002
|
-
: avoidedArtist === normalizedArtist;
|
|
1003
|
-
}
|
|
1004
|
-
return entityType === "track" &&
|
|
1005
|
-
normalizeText(avoid.label ?? "") === normalizedLabel &&
|
|
1006
|
-
normalizeText(avoid.artist_credit ?? "") === normalizedArtist;
|
|
1007
|
-
});
|
|
1002
|
+
let gate = avoidGates.get(avoids);
|
|
1003
|
+
if (!gate) avoidGates.set(avoids, gate = createAvoidGate(avoids));
|
|
1004
|
+
return entityType === "artist"
|
|
1005
|
+
? gate.avoidsArtist(label)
|
|
1006
|
+
: gate.avoidsTrack({ title: label, artist_credit: artistCredit });
|
|
1008
1007
|
}
|
|
1009
1008
|
|
|
1010
1009
|
function normalizeAppleCorrectionLabels(listening, projection) {
|
|
@@ -1211,6 +1210,8 @@ export class AppleProjectionDomainServices {
|
|
|
1211
1210
|
#subjectId;
|
|
1212
1211
|
#candidateSets = new Map();
|
|
1213
1212
|
#profileBuildInput;
|
|
1213
|
+
#avoids = {};
|
|
1214
|
+
#status = {};
|
|
1214
1215
|
#ownsStores = true;
|
|
1215
1216
|
|
|
1216
1217
|
constructor({ projection, listeningHistoryStore = null, subjectId = null }) {
|
|
@@ -1246,7 +1247,17 @@ export class AppleProjectionDomainServices {
|
|
|
1246
1247
|
};
|
|
1247
1248
|
}
|
|
1248
1249
|
|
|
1250
|
+
// Every model request reads the status, so it is kept until the library or the listening data changes.
|
|
1249
1251
|
profileStatus() {
|
|
1252
|
+
const version = typeof this.#projection.logicalDigest === "function"
|
|
1253
|
+
? `${this.#projection.logicalDigest()}:${listeningVersion(this.#listeningHistoryStore)}` : undefined;
|
|
1254
|
+
if (version === undefined || this.#status.version !== version) {
|
|
1255
|
+
this.#status = { version, value: this.#readProfileStatus() };
|
|
1256
|
+
}
|
|
1257
|
+
return structuredClone(this.#status.value);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
#readProfileStatus() {
|
|
1250
1261
|
const summary = this.#projection.getProfileSummary({ maxItems: 1 });
|
|
1251
1262
|
const listening = this.#listeningHistoryStore?.profileSummary({
|
|
1252
1263
|
subjectId: this.#subjectId,
|
|
@@ -1284,6 +1295,43 @@ export class AppleProjectionDomainServices {
|
|
|
1284
1295
|
return this.#listeningHistoryStore?.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
|
|
1285
1296
|
}
|
|
1286
1297
|
|
|
1298
|
+
// Moondog's suggestions ledger and the declared mix (src/profile/moondog-suggestions.mjs).
|
|
1299
|
+
recordMoondogSuggestions(entries) {
|
|
1300
|
+
return this.#listeningHistoryStore?.recordMoondogSuggestions?.({ entries }) ?? null;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
moondogSuggestionHistory(tracks) {
|
|
1304
|
+
return this.#listeningHistoryStore?.moondogSuggestionHistory?.({ subjectId: this.#subjectId, tracks }) ?? null;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// A song the dated plays do not place but the Apple library has played counts as known: three
|
|
1308
|
+
// or more library plays as familiar, fewer as heard. Library counts carry no dates.
|
|
1309
|
+
listeningMix(tracks) {
|
|
1310
|
+
const slots = this.#listeningHistoryStore?.listeningMix?.({ subjectId: this.#subjectId, tracks }) ??
|
|
1311
|
+
tracks.map(() => "unknown");
|
|
1312
|
+
return slots.map((slot, index) => {
|
|
1313
|
+
if (!["new", "known_artist", "unknown"].includes(slot)) return slot;
|
|
1314
|
+
const plays = this.#libraryPlayCount(tracks[index]);
|
|
1315
|
+
return plays >= 3 ? "familiar" : plays > 0 ? "heard" : slot;
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
#libraryPlayCount(track) {
|
|
1320
|
+
const wanted = { title: track?.title, artist_credit: track?.artist_credit ?? (Array.isArray(track?.artists) ? track.artists.join(", ") : "") };
|
|
1321
|
+
if (typeof wanted.title !== "string" || !wanted.title.trim()) return 0;
|
|
1322
|
+
try {
|
|
1323
|
+
const results = this.#projection.searchLibrary({
|
|
1324
|
+
// Without its version suffix, so a remaster finds the library's copy.
|
|
1325
|
+
query: Array.from(normalizedTrackTitle(wanted.title)).slice(0, 256).join(""), limit: 25, offset: 0,
|
|
1326
|
+
filters: { artists: [], genres: [], familiarity: [], preferenceSignals: [] },
|
|
1327
|
+
});
|
|
1328
|
+
return Math.max(0, ...results.tracks.map(safeTrack).filter((candidate) => exactTitleArtistMatch(wanted, candidate))
|
|
1329
|
+
.map((candidate) => candidate.observation_summary.familiarity.play_count ?? 0));
|
|
1330
|
+
} catch {
|
|
1331
|
+
return 0; // Without the library, the dated plays alone place the song.
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1287
1335
|
rediscoveryReady() {
|
|
1288
1336
|
return (
|
|
1289
1337
|
this.#listeningHistoryStore !== null &&
|
|
@@ -1764,16 +1812,36 @@ export class AppleProjectionDomainServices {
|
|
|
1764
1812
|
};
|
|
1765
1813
|
}
|
|
1766
1814
|
|
|
1815
|
+
// Every play path asks for each candidate, so the gate is kept until the data changes.
|
|
1767
1816
|
filterDiscoveryTracks(tracks) {
|
|
1768
1817
|
const store = this.#listeningHistoryStore;
|
|
1769
|
-
const
|
|
1770
|
-
|
|
1771
|
-
const
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
)
|
|
1818
|
+
const version = store?.profileDataVersion
|
|
1819
|
+
? `${this.#projection.logicalDigest?.() ?? ""}:${listeningVersion(store)}` : undefined;
|
|
1820
|
+
const gate = cachedAvoidGate(this.#avoids, version, () => {
|
|
1821
|
+
const avoids = store?.activeAvoidances?.({ subjectId: this.#subjectId }) ??
|
|
1822
|
+
store?.profileSummary?.({ subjectId: this.#subjectId })?.listener_assertions?.avoids ?? [];
|
|
1823
|
+
return normalizeAppleCorrectionLabels({ listener_assertions: { avoids } }, this.#projection)
|
|
1824
|
+
.listener_assertions.avoids;
|
|
1825
|
+
});
|
|
1826
|
+
return tracks.filter((track) => !gate.avoidsTrack(track));
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// Choices live with the listening history, so a library alone cannot save them.
|
|
1830
|
+
listenerChoicesReady() {
|
|
1831
|
+
return this.#listeningHistoryStore !== null;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
recordListenerChoice(choice) {
|
|
1835
|
+
if (!this.#listeningHistoryStore) throw new Error("Choices can be saved once listening history is imported.");
|
|
1836
|
+
return recordListenerChoice(this.#listeningHistoryStore, { ...choice, subjectId: this.#subjectId });
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// The choices in force, cheap enough to read when there is no saved reading.
|
|
1840
|
+
listenerChoices() {
|
|
1841
|
+
const store = this.#listeningHistoryStore;
|
|
1842
|
+
if (typeof store?.countActiveListenerCorrections !== "function") return null;
|
|
1843
|
+
return savedListenerChoices(store.listListenerCorrections({ subjectId: this.#subjectId, limit: 30 }),
|
|
1844
|
+
store.countActiveListenerCorrections({ subjectId: this.#subjectId }));
|
|
1777
1845
|
}
|
|
1778
1846
|
|
|
1779
1847
|
isKnownDiscoveryTrack(track) {
|
|
@@ -1985,7 +2053,7 @@ export class AppleProjectionDomainServices {
|
|
|
1985
2053
|
|
|
1986
2054
|
getProfileBuildContext() {
|
|
1987
2055
|
const appleDigest = this.#projection.logicalDigest();
|
|
1988
|
-
const version = `${appleDigest}:${this.#listeningHistoryStore
|
|
2056
|
+
const version = `${appleDigest}:${listeningVersion(this.#listeningHistoryStore)}`;
|
|
1989
2057
|
if (this.#profileBuildInput?.version !== version) {
|
|
1990
2058
|
const listening = this.#listeningHistoryStore.profileCatalog({ subjectId: this.#subjectId });
|
|
1991
2059
|
const normalized = normalizeAppleCorrectionLabels({ listener_assertions: {
|
|
@@ -341,6 +341,12 @@ const catalog = [
|
|
|
341
341
|
label: N_("Forget something"),
|
|
342
342
|
},
|
|
343
343
|
},
|
|
344
|
+
{
|
|
345
|
+
id: "taste.choice", version: "1", state: "enabled", effect: "write_local",
|
|
346
|
+
description: "Save a lasting Like or Avoid for an artist or song, or undo one, when the listener states it in their own words.",
|
|
347
|
+
requires_taste_choices: true,
|
|
348
|
+
agent_tool: { name: "moondog_taste_choice", label: N_("Save your choice") },
|
|
349
|
+
},
|
|
344
350
|
{
|
|
345
351
|
id: "spotify.history.import",
|
|
346
352
|
version: "1",
|
|
@@ -629,7 +635,11 @@ function withRuntimeAvailability(
|
|
|
629
635
|
profileExplorationReady,
|
|
630
636
|
profileBuildReady,
|
|
631
637
|
musicPreviewReady,
|
|
638
|
+
tasteChoicesReady,
|
|
632
639
|
) {
|
|
640
|
+
if (capability.requires_taste_choices && !tasteChoicesReady) {
|
|
641
|
+
return { ...capability, state: "blocked", blocked_by: "taste_choices_not_ready" };
|
|
642
|
+
}
|
|
633
643
|
if (capability.requires_profile_build && !profileBuildReady) {
|
|
634
644
|
return { ...capability, state: "blocked", blocked_by: "profile_build_not_ready" };
|
|
635
645
|
}
|
|
@@ -758,6 +768,7 @@ export function listCapabilities({
|
|
|
758
768
|
profileExplorationReady = false,
|
|
759
769
|
profileBuildReady = false,
|
|
760
770
|
musicPreviewReady = false,
|
|
771
|
+
tasteChoicesReady = false,
|
|
761
772
|
} = {}) {
|
|
762
773
|
return structuredClone(
|
|
763
774
|
catalog.map((capability) =>
|
|
@@ -779,6 +790,7 @@ export function listCapabilities({
|
|
|
779
790
|
profileExplorationReady,
|
|
780
791
|
profileBuildReady,
|
|
781
792
|
musicPreviewReady,
|
|
793
|
+
tasteChoicesReady,
|
|
782
794
|
),
|
|
783
795
|
),
|
|
784
796
|
);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// One Avoid check for every play path. Names fold case, accents and spacing, so "Drake " and
|
|
2
|
+
// "Drake" are one artist, and an artist Avoid matches a whole credit or any artist in it.
|
|
3
|
+
|
|
4
|
+
// Collaboration separators in artist credits. Full-width forms fold to these before splitting,
|
|
5
|
+
// but callers that do not fold text first can still split on them. A word separator needs a
|
|
6
|
+
// space before it, which a separator before it may already have taken.
|
|
7
|
+
export const ARTIST_CREDIT_SEPARATORS =
|
|
8
|
+
/\s*(?:,|,|;|;|&|&|\/|、|和|与)\s*|(?<=\s)(?:featuring|feat\.?|ft\.?|with)\s+/iu;
|
|
9
|
+
|
|
10
|
+
// "x" also joins artists ("Future x Drake"), but it ends some names ("Lil Nas X"). Only this
|
|
11
|
+
// check splits on it, because it also reads each run of artists as one name.
|
|
12
|
+
const separated = new RegExp(`(${ARTIST_CREDIT_SEPARATORS.source}|(?<=\\s)(?:x|×)\\s+)`, "iu");
|
|
13
|
+
|
|
14
|
+
// Features named in a title: "Song (feat. Drake)", "Song [with Drake]" or "Song feat. Drake".
|
|
15
|
+
const titleFeature =
|
|
16
|
+
/\s*[((【[]\s*(?:featuring|feat\.?|ft\.?|with)\s+([^))】\]]+)[))】\]]|\s+(?:-\s+)?(?:featuring|feat\.|ft\.)\s+(.+)$/giu;
|
|
17
|
+
|
|
18
|
+
export function avoidKey(value) {
|
|
19
|
+
if (typeof value !== "string") return "";
|
|
20
|
+
// Only Latin, Greek and Cyrillic accents fold: kana voicing marks change the word.
|
|
21
|
+
return value.normalize("NFKD").replace(/[̀-ͯ]/gu, "")
|
|
22
|
+
.toLocaleLowerCase("und").replace(/\s+/gu, " ").trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A title without its features, and the artists those features name.
|
|
26
|
+
function titleParts(title) {
|
|
27
|
+
const featured = [];
|
|
28
|
+
const core = typeof title === "string"
|
|
29
|
+
? title.replace(titleFeature, (_whole, bracketed, trailing) => { featured.push(bracketed ?? trailing); return ""; })
|
|
30
|
+
: title;
|
|
31
|
+
return { core: avoidKey(core), featured };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Every way to read free-text credits as one artist: the whole credit, each credited artist, and
|
|
35
|
+
// each run of them, so a band named "Simon & Garfunkel" stays one name inside a longer credit.
|
|
36
|
+
// A source's own list of artists names each one whole, so its names are not split.
|
|
37
|
+
function creditKeys(credit, artists = [], featured = []) {
|
|
38
|
+
const listed = (Array.isArray(artists) ? artists : []).map(avoidKey).filter(Boolean);
|
|
39
|
+
const keys = new Set(listed);
|
|
40
|
+
if (listed.length) keys.add(avoidKey(credit));
|
|
41
|
+
for (const name of [...(listed.length ? [] : [credit]), ...featured]) {
|
|
42
|
+
const pieces = avoidKey(name).split(separated);
|
|
43
|
+
for (let start = 0; start < pieces.length; start += 2) {
|
|
44
|
+
for (let end = start; end < pieces.length; end += 2) {
|
|
45
|
+
const key = pieces.slice(start, end + 1).join("").trim();
|
|
46
|
+
if (key) keys.add(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
keys.delete("");
|
|
51
|
+
return keys;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const overlaps = (left, right) => [...left].some((key) => right.has(key));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds the check from Avoid records ({ entity_type, label, artist_credit }). A track Avoid
|
|
58
|
+
* matches its title, without any features, with any of its credited artists.
|
|
59
|
+
*/
|
|
60
|
+
export function createAvoidGate(avoids = []) {
|
|
61
|
+
const artists = new Set();
|
|
62
|
+
const tracks = new Map();
|
|
63
|
+
for (const avoid of Array.isArray(avoids) ? avoids : []) {
|
|
64
|
+
const label = avoidKey(avoid?.label);
|
|
65
|
+
if (!label) continue;
|
|
66
|
+
if (avoid.entity_type === "artist") artists.add(label);
|
|
67
|
+
else if (avoid.entity_type === "track") {
|
|
68
|
+
const { core, featured } = titleParts(avoid.label);
|
|
69
|
+
const title = core || label;
|
|
70
|
+
tracks.set(title, [...(tracks.get(title) ?? []), creditKeys(avoid.artist_credit, [], featured)]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const avoidsArtist = (keys) => overlaps(keys, artists);
|
|
74
|
+
return {
|
|
75
|
+
size: artists.size + tracks.size,
|
|
76
|
+
avoidsArtist: (name, names = []) => avoidsArtist(creditKeys(name, names)),
|
|
77
|
+
// A track as { title, artist_credit, artists }, where artists is a source's own list.
|
|
78
|
+
avoidsTrack({ title, artist_credit: credit, artists: names } = {}) {
|
|
79
|
+
const { core, featured } = titleParts(title);
|
|
80
|
+
const keys = creditKeys(credit, names, featured);
|
|
81
|
+
if (avoidsArtist(keys)) return true;
|
|
82
|
+
return (tracks.get(core || avoidKey(title)) ?? []).some((avoided) =>
|
|
83
|
+
avoided.size ? overlaps(avoided, keys) : keys.size === 0);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The gate for the store's current data, rebuilt only when that data changes.
|
|
89
|
+
export function cachedAvoidGate(cache, version, readAvoids) {
|
|
90
|
+
if (version === undefined || cache.version !== version) {
|
|
91
|
+
cache.version = version;
|
|
92
|
+
cache.gate = createAvoidGate(readAvoids());
|
|
93
|
+
}
|
|
94
|
+
return cache.gate;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The store's data version without its own ledger writes, which change no projection until
|
|
98
|
+
// their plays arrive (listeningDataVersion in listening-history-store.mjs).
|
|
99
|
+
export function listeningVersion(store) {
|
|
100
|
+
return store?.listeningDataVersion?.() ?? store?.profileDataVersion?.();
|
|
101
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
|
|
3
|
-
import { createListenerProfileInput } from "../profile/listener-profile-build.mjs";
|
|
3
|
+
import { createListenerProfileInput, savedListenerChoices } from "../profile/listener-profile-build.mjs";
|
|
4
4
|
import { lyricSeedsFromProfile } from "./lyric-profile.mjs";
|
|
5
|
+
import { cachedAvoidGate, listeningVersion } from "./listener-avoids.mjs";
|
|
6
|
+
import { recordListenerChoice } from "../profile/listener-corrections.mjs";
|
|
5
7
|
|
|
6
8
|
import { isUuid } from "./uuid-v5.mjs";
|
|
7
9
|
import { createListeningHistoryProfileProjection } from "../profile/spotify-archive-taste.mjs";
|
|
8
10
|
import {
|
|
9
11
|
assertExternalPlanDiversity,
|
|
10
12
|
exactTitleArtistMatch,
|
|
11
|
-
listenerAssertionAvoids,
|
|
12
13
|
safeExternalSource,
|
|
13
14
|
safeExternalTrack,
|
|
14
15
|
} from "./apple-projection-domain-services.mjs";
|
|
@@ -423,6 +424,8 @@ export class ListeningProfileDomainServices {
|
|
|
423
424
|
#subjectId;
|
|
424
425
|
#candidateSets = new Map();
|
|
425
426
|
#profileBuildInput;
|
|
427
|
+
#avoids = {};
|
|
428
|
+
#status = {};
|
|
426
429
|
#ownsStore = true;
|
|
427
430
|
|
|
428
431
|
constructor({ listeningHistoryStore, subjectId } = {}) {
|
|
@@ -462,7 +465,16 @@ export class ListeningProfileDomainServices {
|
|
|
462
465
|
};
|
|
463
466
|
}
|
|
464
467
|
|
|
468
|
+
// Every model request reads the status, so it is kept until the listening data changes.
|
|
465
469
|
profileStatus() {
|
|
470
|
+
const version = listeningVersion(this.#store);
|
|
471
|
+
if (version === undefined || this.#status.version !== version) {
|
|
472
|
+
this.#status = { version, value: this.#readProfileStatus() };
|
|
473
|
+
}
|
|
474
|
+
return structuredClone(this.#status.value);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#readProfileStatus() {
|
|
466
478
|
const source = this.#store.subjectDataStatus({
|
|
467
479
|
subjectId: this.#subjectId,
|
|
468
480
|
});
|
|
@@ -493,6 +505,19 @@ export class ListeningProfileDomainServices {
|
|
|
493
505
|
return this.#store.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
|
|
494
506
|
}
|
|
495
507
|
|
|
508
|
+
// Moondog's suggestions ledger and the declared mix (src/profile/moondog-suggestions.mjs).
|
|
509
|
+
recordMoondogSuggestions(entries) {
|
|
510
|
+
return this.#store.recordMoondogSuggestions?.({ entries }) ?? null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
moondogSuggestionHistory(tracks) {
|
|
514
|
+
return this.#store.moondogSuggestionHistory?.({ subjectId: this.#subjectId, tracks }) ?? null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
listeningMix(tracks) {
|
|
518
|
+
return this.#store.listeningMix?.({ subjectId: this.#subjectId, tracks }) ?? null;
|
|
519
|
+
}
|
|
520
|
+
|
|
496
521
|
rediscoveryReady() {
|
|
497
522
|
return typeof this.#store.rediscoveryCandidates === "function";
|
|
498
523
|
}
|
|
@@ -553,12 +578,23 @@ export class ListeningProfileDomainServices {
|
|
|
553
578
|
return { candidateSetId, tracks: prepared };
|
|
554
579
|
}
|
|
555
580
|
|
|
581
|
+
// Every play path asks for each candidate, so the gate is kept until the data changes.
|
|
556
582
|
filterDiscoveryTracks(tracks) {
|
|
557
|
-
const
|
|
558
|
-
this.#store.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
583
|
+
const gate = cachedAvoidGate(this.#avoids, listeningVersion(this.#store), () =>
|
|
584
|
+
this.#store.activeAvoidances?.({ subjectId: this.#subjectId }) ??
|
|
585
|
+
this.#store.profileSummary({ subjectId: this.#subjectId })?.listener_assertions?.avoids ?? []);
|
|
586
|
+
return tracks.filter((track) => !gate.avoidsTrack(track));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
recordListenerChoice(choice) {
|
|
590
|
+
return recordListenerChoice(this.#store, { ...choice, subjectId: this.#subjectId });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// The choices in force, cheap enough to read when there is no saved reading.
|
|
594
|
+
listenerChoices() {
|
|
595
|
+
if (typeof this.#store.countActiveListenerCorrections !== "function") return null;
|
|
596
|
+
return savedListenerChoices(this.#store.listListenerCorrections({ subjectId: this.#subjectId, limit: 30 }),
|
|
597
|
+
this.#store.countActiveListenerCorrections({ subjectId: this.#subjectId }));
|
|
562
598
|
}
|
|
563
599
|
|
|
564
600
|
isKnownDiscoveryTrack(track) {
|
|
@@ -652,7 +688,7 @@ export class ListeningProfileDomainServices {
|
|
|
652
688
|
}
|
|
653
689
|
|
|
654
690
|
getProfileBuildContext() {
|
|
655
|
-
const version = this.#store
|
|
691
|
+
const version = listeningVersion(this.#store);
|
|
656
692
|
if (this.#profileBuildInput?.version !== version) {
|
|
657
693
|
this.#profileBuildInput = { version, input: createListenerProfileInput({
|
|
658
694
|
subjectId: this.#subjectId,
|