@audiofool/moondog 0.1.0 → 0.2.0-beta.3
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 +156 -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 +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -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 +166 -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 +328 -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 +9 -6
- 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 +410 -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 +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -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 +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -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 +2536 -692
- 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 +161 -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 +48 -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
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { exploreProfileCatalog, profileExplorationInput } from "../profile/profile-exploration.mjs";
|
|
3
|
+
import { createListenerProfileInput, savedListenerChoices } from "../profile/listener-profile-build.mjs";
|
|
2
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";
|
|
3
8
|
import { collectionCoverage } from "../profile/music-providers.mjs";
|
|
4
9
|
import { lstat } from "node:fs/promises";
|
|
5
10
|
import path from "node:path";
|
|
@@ -408,6 +413,11 @@ function safeHistoryCount(value) {
|
|
|
408
413
|
return value;
|
|
409
414
|
}
|
|
410
415
|
|
|
416
|
+
// A pick leaves out minutes or skips that its plays never recorded.
|
|
417
|
+
function optionalHistoryCount(context, key) {
|
|
418
|
+
return context[key] === undefined ? {} : { [key]: safeHistoryCount(context[key]) };
|
|
419
|
+
}
|
|
420
|
+
|
|
411
421
|
function safeHistoryExternalRefs(value) {
|
|
412
422
|
if (value === undefined) return [];
|
|
413
423
|
if (!Array.isArray(value)) {
|
|
@@ -487,10 +497,13 @@ function safeHistoryTrack(raw) {
|
|
|
487
497
|
if (hasRediscovery) {
|
|
488
498
|
const rediscovery = raw.rediscovery;
|
|
489
499
|
const engagedPlayCount = safeHistoryCount(rediscovery.engaged_play_count);
|
|
490
|
-
const explicitSkips =
|
|
491
|
-
const listeningMinutes =
|
|
500
|
+
const explicitSkips = optionalHistoryCount(rediscovery, "explicit_skips");
|
|
501
|
+
const listeningMinutes = optionalHistoryCount(rediscovery, "listening_minutes");
|
|
492
502
|
const quietDays = safeHistoryCount(rediscovery.quiet_days);
|
|
493
|
-
if (
|
|
503
|
+
if (
|
|
504
|
+
engagedPlayCount > playCount ||
|
|
505
|
+
(explicitSkips.explicit_skips ?? 0) > playCount
|
|
506
|
+
) {
|
|
494
507
|
fail("history_candidate_invalid", "A listening-history candidate is invalid.");
|
|
495
508
|
}
|
|
496
509
|
const rediscoverySignal = cleanText(
|
|
@@ -512,7 +525,9 @@ function safeHistoryTrack(raw) {
|
|
|
512
525
|
const peakFields = [
|
|
513
526
|
rediscovery.peak_year,
|
|
514
527
|
rediscovery.peak_year_play_count,
|
|
515
|
-
rediscovery.peak_year_listening_minutes
|
|
528
|
+
...(rediscovery.peak_year_listening_minutes === undefined
|
|
529
|
+
? []
|
|
530
|
+
: [rediscovery.peak_year_listening_minutes]),
|
|
516
531
|
];
|
|
517
532
|
if (peakFields.some((value) => value !== undefined)) {
|
|
518
533
|
if (
|
|
@@ -524,9 +539,9 @@ function safeHistoryTrack(raw) {
|
|
|
524
539
|
}
|
|
525
540
|
}
|
|
526
541
|
track.rediscovery = {
|
|
527
|
-
|
|
542
|
+
...listeningMinutes,
|
|
528
543
|
engaged_play_count: engagedPlayCount,
|
|
529
|
-
|
|
544
|
+
...explicitSkips,
|
|
530
545
|
first_played_at: firstPlayedAt,
|
|
531
546
|
last_played_at: lastPlayedAt,
|
|
532
547
|
quiet_days: quietDays,
|
|
@@ -535,8 +550,7 @@ function safeHistoryTrack(raw) {
|
|
|
535
550
|
? {
|
|
536
551
|
peak_year: rediscovery.peak_year,
|
|
537
552
|
peak_year_play_count: rediscovery.peak_year_play_count,
|
|
538
|
-
peak_year_listening_minutes
|
|
539
|
-
rediscovery.peak_year_listening_minutes,
|
|
553
|
+
...optionalHistoryCount(rediscovery, "peak_year_listening_minutes"),
|
|
540
554
|
}
|
|
541
555
|
: {}),
|
|
542
556
|
evidence_id: rediscovery.evidence_id.toLowerCase(),
|
|
@@ -546,9 +560,10 @@ function safeHistoryTrack(raw) {
|
|
|
546
560
|
const engagedPlayCount = safeHistoryCount(
|
|
547
561
|
historicalReturn.engaged_play_count,
|
|
548
562
|
);
|
|
549
|
-
const explicitSkips =
|
|
550
|
-
const listeningMinutes =
|
|
551
|
-
historicalReturn
|
|
563
|
+
const explicitSkips = optionalHistoryCount(historicalReturn, "explicit_skips");
|
|
564
|
+
const listeningMinutes = optionalHistoryCount(
|
|
565
|
+
historicalReturn,
|
|
566
|
+
"listening_minutes",
|
|
552
567
|
);
|
|
553
568
|
const returnCount = safeHistoryCount(historicalReturn.return_count);
|
|
554
569
|
const longestGapDays = safeHistoryCount(
|
|
@@ -577,7 +592,7 @@ function safeHistoryTrack(raw) {
|
|
|
577
592
|
latestReturnGapDays < 1 ||
|
|
578
593
|
latestReturnGapDays > longestGapDays ||
|
|
579
594
|
engagedPlayCount > playCount ||
|
|
580
|
-
explicitSkips > playCount ||
|
|
595
|
+
(explicitSkips.explicit_skips ?? 0) > playCount ||
|
|
581
596
|
Date.parse(firstPlayedAt) > Date.parse(latestReturnAt) ||
|
|
582
597
|
Date.parse(latestReturnAt) > Date.parse(lastPlayedAt) ||
|
|
583
598
|
!rediscoverySignals.has(historicalReturnSignal) ||
|
|
@@ -586,9 +601,9 @@ function safeHistoryTrack(raw) {
|
|
|
586
601
|
fail("history_candidate_invalid", "A listening-history candidate is invalid.");
|
|
587
602
|
}
|
|
588
603
|
track.historical_return = {
|
|
589
|
-
|
|
604
|
+
...listeningMinutes,
|
|
590
605
|
engaged_play_count: engagedPlayCount,
|
|
591
|
-
|
|
606
|
+
...explicitSkips,
|
|
592
607
|
first_played_at: firstPlayedAt,
|
|
593
608
|
last_played_at: lastPlayedAt,
|
|
594
609
|
return_count: returnCount,
|
|
@@ -605,12 +620,14 @@ function safeHistoryTrack(raw) {
|
|
|
605
620
|
const yearEngagedPlayCount = safeHistoryCount(
|
|
606
621
|
capsule.year_engaged_play_count,
|
|
607
622
|
);
|
|
608
|
-
const yearListeningMinutes =
|
|
609
|
-
capsule
|
|
623
|
+
const yearListeningMinutes = optionalHistoryCount(
|
|
624
|
+
capsule,
|
|
625
|
+
"year_listening_minutes",
|
|
610
626
|
);
|
|
611
|
-
const yearExplicitSkips =
|
|
612
|
-
const lifetimeListeningMinutes =
|
|
613
|
-
capsule
|
|
627
|
+
const yearExplicitSkips = optionalHistoryCount(capsule, "year_explicit_skips");
|
|
628
|
+
const lifetimeListeningMinutes = optionalHistoryCount(
|
|
629
|
+
capsule,
|
|
630
|
+
"lifetime_listening_minutes",
|
|
614
631
|
);
|
|
615
632
|
const representativeSignal = cleanText(
|
|
616
633
|
capsule.representative_signal,
|
|
@@ -622,8 +639,9 @@ function safeHistoryTrack(raw) {
|
|
|
622
639
|
year > 9999 ||
|
|
623
640
|
yearPlayCount > playCount ||
|
|
624
641
|
yearEngagedPlayCount > yearPlayCount ||
|
|
625
|
-
yearExplicitSkips > yearPlayCount ||
|
|
626
|
-
yearListeningMinutes
|
|
642
|
+
(yearExplicitSkips.year_explicit_skips ?? 0) > yearPlayCount ||
|
|
643
|
+
(yearListeningMinutes.year_listening_minutes ?? 0) >
|
|
644
|
+
(lifetimeListeningMinutes.lifetime_listening_minutes ?? Infinity) ||
|
|
627
645
|
!rediscoverySignals.has(representativeSignal) ||
|
|
628
646
|
!isUuid(capsule.evidence_id)
|
|
629
647
|
) {
|
|
@@ -633,16 +651,16 @@ function safeHistoryTrack(raw) {
|
|
|
633
651
|
year,
|
|
634
652
|
year_play_count: yearPlayCount,
|
|
635
653
|
year_engaged_play_count: yearEngagedPlayCount,
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
654
|
+
...yearListeningMinutes,
|
|
655
|
+
...yearExplicitSkips,
|
|
656
|
+
...lifetimeListeningMinutes,
|
|
639
657
|
representative_signal: representativeSignal,
|
|
640
658
|
evidence_id: capsule.evidence_id.toLowerCase(),
|
|
641
659
|
};
|
|
642
660
|
} else {
|
|
643
661
|
const context = raw.back_to_back;
|
|
644
662
|
const engagedPlayCount = safeHistoryCount(context.engaged_play_count);
|
|
645
|
-
const explicitSkips =
|
|
663
|
+
const explicitSkips = optionalHistoryCount(context, "explicit_skips");
|
|
646
664
|
const burstCount = safeHistoryCount(context.burst_count);
|
|
647
665
|
const maximumConsecutivePlays = safeHistoryCount(
|
|
648
666
|
context.maximum_consecutive_plays,
|
|
@@ -664,7 +682,7 @@ function safeHistoryTrack(raw) {
|
|
|
664
682
|
playsInBursts < burstCount * 2 ||
|
|
665
683
|
playsInBursts > playCount ||
|
|
666
684
|
engagedPlayCount > playCount ||
|
|
667
|
-
explicitSkips > playCount ||
|
|
685
|
+
(explicitSkips.explicit_skips ?? 0) > playCount ||
|
|
668
686
|
!backToBackSignals.has(sequenceSignal) ||
|
|
669
687
|
!isUuid(context.evidence_id)
|
|
670
688
|
) {
|
|
@@ -672,7 +690,7 @@ function safeHistoryTrack(raw) {
|
|
|
672
690
|
}
|
|
673
691
|
track.back_to_back = {
|
|
674
692
|
engaged_play_count: engagedPlayCount,
|
|
675
|
-
|
|
693
|
+
...explicitSkips,
|
|
676
694
|
burst_count: burstCount,
|
|
677
695
|
maximum_consecutive_plays: maximumConsecutivePlays,
|
|
678
696
|
plays_in_bursts: playsInBursts,
|
|
@@ -797,9 +815,10 @@ function normalizedArtistNames(value) {
|
|
|
797
815
|
.filter(Boolean);
|
|
798
816
|
}
|
|
799
817
|
|
|
818
|
+
// A remaster or live take of a song counts as the song (src/core/track-title.mjs).
|
|
800
819
|
export function exactTitleArtistMatch(left, right) {
|
|
801
|
-
const leftTitle =
|
|
802
|
-
const rightTitle =
|
|
820
|
+
const leftTitle = normalizedTrackTitle(left.title);
|
|
821
|
+
const rightTitle = normalizedTrackTitle(right.title);
|
|
803
822
|
const leftArtists = normalizedArtistNames(left.artist_credit);
|
|
804
823
|
const rightArtists = new Set(normalizedArtistNames(right.artist_credit));
|
|
805
824
|
return (
|
|
@@ -868,13 +887,14 @@ export function safeExternalSource(raw) {
|
|
|
868
887
|
fail("external_candidate_invalid", "External catalog source metadata is invalid.");
|
|
869
888
|
}
|
|
870
889
|
if (raw.provider === "apple_music") {
|
|
871
|
-
|
|
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)) {
|
|
872
892
|
fail("external_candidate_invalid", "External catalog source metadata is invalid.");
|
|
873
893
|
}
|
|
874
894
|
return {
|
|
875
895
|
provider: "apple_music",
|
|
876
896
|
catalog: "itunes_search_api",
|
|
877
|
-
storefront:
|
|
897
|
+
storefront: raw.storefront,
|
|
878
898
|
retrieved_at: raw.retrieved_at,
|
|
879
899
|
coverage: cleanText(raw.coverage, 500, "external_candidate_invalid"),
|
|
880
900
|
};
|
|
@@ -970,26 +990,20 @@ function preferenceSignalLabel(observation = {}) {
|
|
|
970
990
|
return labels.join(", ") || "explicit provider preference signal";
|
|
971
991
|
}
|
|
972
992
|
|
|
973
|
-
|
|
993
|
+
const avoidGates = new WeakMap();
|
|
994
|
+
|
|
995
|
+
export function listenerAssertionAvoids(
|
|
974
996
|
listening,
|
|
975
997
|
entityType,
|
|
976
998
|
label,
|
|
977
999
|
artistCredit = "",
|
|
978
1000
|
) {
|
|
979
1001
|
const avoids = listening?.listener_assertions?.avoids ?? [];
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
return
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
return entityType === "artist"
|
|
986
|
-
? avoidedArtist === normalizedLabel
|
|
987
|
-
: avoidedArtist === normalizedArtist;
|
|
988
|
-
}
|
|
989
|
-
return entityType === "track" &&
|
|
990
|
-
normalizeText(avoid.label ?? "") === normalizedLabel &&
|
|
991
|
-
normalizeText(avoid.artist_credit ?? "") === normalizedArtist;
|
|
992
|
-
});
|
|
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 });
|
|
993
1007
|
}
|
|
994
1008
|
|
|
995
1009
|
function normalizeAppleCorrectionLabels(listening, projection) {
|
|
@@ -1195,6 +1209,10 @@ export class AppleProjectionDomainServices {
|
|
|
1195
1209
|
#listeningHistoryStore;
|
|
1196
1210
|
#subjectId;
|
|
1197
1211
|
#candidateSets = new Map();
|
|
1212
|
+
#profileBuildInput;
|
|
1213
|
+
#avoids = {};
|
|
1214
|
+
#status = {};
|
|
1215
|
+
#ownsStores = true;
|
|
1198
1216
|
|
|
1199
1217
|
constructor({ projection, listeningHistoryStore = null, subjectId = null }) {
|
|
1200
1218
|
if (!projection) throw new TypeError("A trusted Apple projection is required");
|
|
@@ -1206,6 +1224,19 @@ export class AppleProjectionDomainServices {
|
|
|
1206
1224
|
this.#subjectId = subjectId?.toLowerCase() ?? null;
|
|
1207
1225
|
}
|
|
1208
1226
|
|
|
1227
|
+
// Each conversation that runs beside another gets a fork: the same projection and history
|
|
1228
|
+
// with its own candidate sets, so one conversation's prompt never clears another's. Only
|
|
1229
|
+
// the original closes them.
|
|
1230
|
+
fork() {
|
|
1231
|
+
const fork = new AppleProjectionDomainServices({
|
|
1232
|
+
projection: this.#projection,
|
|
1233
|
+
listeningHistoryStore: this.#listeningHistoryStore,
|
|
1234
|
+
subjectId: this.#subjectId,
|
|
1235
|
+
});
|
|
1236
|
+
fork.#ownsStores = false;
|
|
1237
|
+
return fork;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1209
1240
|
status() {
|
|
1210
1241
|
return {
|
|
1211
1242
|
state: "ready",
|
|
@@ -1216,7 +1247,17 @@ export class AppleProjectionDomainServices {
|
|
|
1216
1247
|
};
|
|
1217
1248
|
}
|
|
1218
1249
|
|
|
1250
|
+
// Every model request reads the status, so it is kept until the library or the listening data changes.
|
|
1219
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() {
|
|
1220
1261
|
const summary = this.#projection.getProfileSummary({ maxItems: 1 });
|
|
1221
1262
|
const listening = this.#listeningHistoryStore?.profileSummary({
|
|
1222
1263
|
subjectId: this.#subjectId,
|
|
@@ -1246,6 +1287,51 @@ export class AppleProjectionDomainServices {
|
|
|
1246
1287
|
};
|
|
1247
1288
|
}
|
|
1248
1289
|
|
|
1290
|
+
listeningCoverage() {
|
|
1291
|
+
return this.#listeningHistoryStore?.listeningCoverage?.({ subjectId: this.#subjectId }) ?? null;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
recentAttention() {
|
|
1295
|
+
return this.#listeningHistoryStore?.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
|
|
1296
|
+
}
|
|
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
|
+
|
|
1249
1335
|
rediscoveryReady() {
|
|
1250
1336
|
return (
|
|
1251
1337
|
this.#listeningHistoryStore !== null &&
|
|
@@ -1726,7 +1812,43 @@ export class AppleProjectionDomainServices {
|
|
|
1726
1812
|
};
|
|
1727
1813
|
}
|
|
1728
1814
|
|
|
1729
|
-
|
|
1815
|
+
// Every play path asks for each candidate, so the gate is kept until the data changes.
|
|
1816
|
+
filterDiscoveryTracks(tracks) {
|
|
1817
|
+
const store = this.#listeningHistoryStore;
|
|
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 }));
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
isKnownDiscoveryTrack(track) {
|
|
1848
|
+
return (this.#listeningHistoryStore?.listenedTracks?.({ subjectId: this.#subjectId }) ?? []).some(heard => exactTitleArtistMatch(track, heard));
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
registerExternalCandidateSet({ tracks, source, excludeKnown = true } = {}) {
|
|
1730
1852
|
if (
|
|
1731
1853
|
!Array.isArray(tracks) ||
|
|
1732
1854
|
tracks.length < 1 ||
|
|
@@ -1747,7 +1869,8 @@ export class AppleProjectionDomainServices {
|
|
|
1747
1869
|
}
|
|
1748
1870
|
const accepted = [];
|
|
1749
1871
|
let excludedLibraryMatches = 0;
|
|
1750
|
-
|
|
1872
|
+
const allowed = this.filterDiscoveryTracks(prepared);
|
|
1873
|
+
for (const track of allowed) {
|
|
1751
1874
|
const query = Array.from(track.title).slice(0, 256).join("");
|
|
1752
1875
|
const libraryResults = this.#projection.searchLibrary({
|
|
1753
1876
|
query,
|
|
@@ -1763,14 +1886,14 @@ export class AppleProjectionDomainServices {
|
|
|
1763
1886
|
const libraryMatch = libraryResults.tracks
|
|
1764
1887
|
.map(safeTrack)
|
|
1765
1888
|
.some((candidate) => exactTitleArtistMatch(track, candidate));
|
|
1766
|
-
if (libraryMatch) {
|
|
1889
|
+
if (excludeKnown && libraryMatch) {
|
|
1767
1890
|
excludedLibraryMatches += 1;
|
|
1768
1891
|
continue;
|
|
1769
1892
|
}
|
|
1770
1893
|
accepted.push({
|
|
1771
1894
|
...track,
|
|
1772
1895
|
knownness: {
|
|
1773
|
-
imported_library: "not_found_by_exact_title_artist",
|
|
1896
|
+
imported_library: libraryMatch ? "found_by_exact_title_artist" : "not_found_by_exact_title_artist",
|
|
1774
1897
|
listening_history: "not_checked",
|
|
1775
1898
|
},
|
|
1776
1899
|
});
|
|
@@ -1780,6 +1903,7 @@ export class AppleProjectionDomainServices {
|
|
|
1780
1903
|
candidate_set_id: null,
|
|
1781
1904
|
candidate_scope: "external_catalog",
|
|
1782
1905
|
result_count: 0,
|
|
1906
|
+
excluded_avoided_matches: prepared.length - allowed.length,
|
|
1783
1907
|
excluded_library_matches: excludedLibraryMatches,
|
|
1784
1908
|
expires_on: "prompt_end",
|
|
1785
1909
|
source: trustedSource,
|
|
@@ -1797,6 +1921,7 @@ export class AppleProjectionDomainServices {
|
|
|
1797
1921
|
candidate_set_id: candidateSetId,
|
|
1798
1922
|
candidate_scope: "external_catalog",
|
|
1799
1923
|
result_count: accepted.length,
|
|
1924
|
+
excluded_avoided_matches: prepared.length - allowed.length,
|
|
1800
1925
|
excluded_library_matches: excludedLibraryMatches,
|
|
1801
1926
|
expires_on: "prompt_end",
|
|
1802
1927
|
source: trustedSource,
|
|
@@ -1893,6 +2018,59 @@ export class AppleProjectionDomainServices {
|
|
|
1893
2018
|
};
|
|
1894
2019
|
}
|
|
1895
2020
|
|
|
2021
|
+
#appleProfileCatalog(knownAvoids) {
|
|
2022
|
+
if (typeof this.#projection.getProfileCatalog !== "function") return null;
|
|
2023
|
+
const { listener_assertions: { avoids } } = normalizeAppleCorrectionLabels({
|
|
2024
|
+
listener_assertions: {
|
|
2025
|
+
avoids: knownAvoids ?? this.#listeningHistoryStore?.activeAvoidances?.({ subjectId: this.#subjectId }) ?? [],
|
|
2026
|
+
},
|
|
2027
|
+
}, this.#projection);
|
|
2028
|
+
return this.#projection.getProfileCatalog({ excludePreference: (track) =>
|
|
2029
|
+
listenerAssertionAvoids({ listener_assertions: { avoids } }, "track", track.label, track.artist_credit) });
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
profileExplorationReady() {
|
|
2033
|
+
return typeof this.#projection.getProfileCatalog === "function";
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
async exploreProfile(argumentsValue) {
|
|
2037
|
+
const input = profileExplorationInput(argumentsValue);
|
|
2038
|
+
const listening = this.#listeningHistoryStore?.profileCatalog({ subjectId: this.#subjectId });
|
|
2039
|
+
return exploreProfileCatalog({
|
|
2040
|
+
listening,
|
|
2041
|
+
apple: this.#appleProfileCatalog(listening?.sections.listener_avoids), input,
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
profileBuildReady() {
|
|
2046
|
+
return typeof this.#listeningHistoryStore?.listenerProfileStorage === "function" &&
|
|
2047
|
+
typeof this.#projection.logicalDigest === "function";
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
getListenerProfileStore() {
|
|
2051
|
+
return this.#listeningHistoryStore.listenerProfileStorage({ subjectId: this.#subjectId });
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
getProfileBuildContext() {
|
|
2055
|
+
const appleDigest = this.#projection.logicalDigest();
|
|
2056
|
+
const version = `${appleDigest}:${listeningVersion(this.#listeningHistoryStore)}`;
|
|
2057
|
+
if (this.#profileBuildInput?.version !== version) {
|
|
2058
|
+
const listening = this.#listeningHistoryStore.profileCatalog({ subjectId: this.#subjectId });
|
|
2059
|
+
const normalized = normalizeAppleCorrectionLabels({ listener_assertions: {
|
|
2060
|
+
preferences: listening.sections.listener_preferences, avoids: listening.sections.listener_avoids,
|
|
2061
|
+
} }, this.#projection).listener_assertions;
|
|
2062
|
+
listening.sections.listener_preferences = normalized.preferences;
|
|
2063
|
+
listening.sections.listener_avoids = normalized.avoids;
|
|
2064
|
+
this.#profileBuildInput = { version, input: createListenerProfileInput({
|
|
2065
|
+
subjectId: this.#subjectId,
|
|
2066
|
+
evidenceRevision: [appleDigest,
|
|
2067
|
+
this.#listeningHistoryStore.profileEvidenceRevision({ subjectId: this.#subjectId })],
|
|
2068
|
+
listening, apple: this.#appleProfileCatalog(listening.sections.listener_avoids),
|
|
2069
|
+
}) };
|
|
2070
|
+
}
|
|
2071
|
+
return { store: this.getListenerProfileStore(), input: this.#profileBuildInput.input };
|
|
2072
|
+
}
|
|
2073
|
+
|
|
1896
2074
|
async getProfileSummary(argumentsValue) {
|
|
1897
2075
|
const input = modelArguments(argumentsValue, new Set(["maxItems"]));
|
|
1898
2076
|
const bounded = boundedLimit(
|
|
@@ -1990,13 +2168,16 @@ export class AppleProjectionDomainServices {
|
|
|
1990
2168
|
play_count: item.play_count,
|
|
1991
2169
|
evidence_id: item.evidence_id,
|
|
1992
2170
|
})) ?? [];
|
|
1993
|
-
const
|
|
2171
|
+
const appleCatalog = this.#appleProfileCatalog();
|
|
2172
|
+
const appleArtistFacets = (appleCatalog
|
|
2173
|
+
? appleCatalog.sections.apple_artists.filter((item) => item.preferred_tracks > 0)
|
|
2174
|
+
: facetItems(
|
|
1994
2175
|
preferenceItems,
|
|
1995
2176
|
(item) => item.artist_credit,
|
|
1996
2177
|
bounded,
|
|
1997
|
-
).filter(
|
|
2178
|
+
)).filter(
|
|
1998
2179
|
(item) => !listenerAssertionAvoids(listening, "artist", item.name),
|
|
1999
|
-
);
|
|
2180
|
+
).slice(0, bounded);
|
|
2000
2181
|
const spotifyArtistFacets = [
|
|
2001
2182
|
...(listening?.curated_preferences.followed_artists ?? []),
|
|
2002
2183
|
...(listening?.listening_behavior.enduring_artists ?? []),
|
|
@@ -2009,7 +2190,9 @@ export class AppleProjectionDomainServices {
|
|
|
2009
2190
|
name: item.name,
|
|
2010
2191
|
evidence_id: item.evidence_id,
|
|
2011
2192
|
}));
|
|
2012
|
-
const appleGenreFacets =
|
|
2193
|
+
const appleGenreFacets = appleCatalog
|
|
2194
|
+
? appleCatalog.sections.apple_genres.filter((item) => item.preferred_tracks > 0).slice(0, bounded)
|
|
2195
|
+
: facetItems(
|
|
2013
2196
|
preferenceItems,
|
|
2014
2197
|
(item) => item.labels?.provider_genre,
|
|
2015
2198
|
bounded,
|
|
@@ -2109,6 +2292,17 @@ export class AppleProjectionDomainServices {
|
|
|
2109
2292
|
if (!isUuid(cleanedId)) {
|
|
2110
2293
|
fail("invalid_evidence_id", "Profile evidence ID is invalid.");
|
|
2111
2294
|
}
|
|
2295
|
+
const catalog = this.#appleProfileCatalog();
|
|
2296
|
+
const facet = [...(catalog?.sections.apple_artists ?? []), ...(catalog?.sections.apple_genres ?? [])]
|
|
2297
|
+
.find((item) => item.evidence_id === cleanedId);
|
|
2298
|
+
if (facet) return {
|
|
2299
|
+
evidence_id: facet.evidence_id,
|
|
2300
|
+
claim: { dimension: "taste.library_facet", value: facet.name, direction: "supports" },
|
|
2301
|
+
basis_summary: `${facet.preferred_tracks} tracks with positive provider preferences among ${facet.library_tracks} library tracks, after active Avoids. ${facet.play_count} aggregate plays across ${facet.tracks_with_play_count} tracks with known counts.`,
|
|
2302
|
+
derivation: { kind: "rule", name: "whole-apple-library-facet", version: "1" },
|
|
2303
|
+
confidence: 0.9,
|
|
2304
|
+
interpretation_limit: "A full-library metadata aggregation. Play counts show familiarity; provider preferences are snapshot states, and genre names do not describe measured audio.",
|
|
2305
|
+
};
|
|
2112
2306
|
const raw = this.#projection.explainProfileEvidence({
|
|
2113
2307
|
evidenceId: cleanedId,
|
|
2114
2308
|
});
|
|
@@ -2258,6 +2452,11 @@ export class AppleProjectionDomainServices {
|
|
|
2258
2452
|
"The playlist track count does not match the explicit user intent.",
|
|
2259
2453
|
);
|
|
2260
2454
|
}
|
|
2455
|
+
const external = selections.map((selection) => trustedTracks.get(selection.trackRefId))
|
|
2456
|
+
.filter((track) => track.candidate_scope === "external_catalog");
|
|
2457
|
+
if (this.filterDiscoveryTracks(external).length !== external.length) {
|
|
2458
|
+
fail("discovery_track_avoided", "An external discovery is currently marked Avoid. Choose another track.");
|
|
2459
|
+
}
|
|
2261
2460
|
const tracks = selections.map((selection, index) => {
|
|
2262
2461
|
const track = trustedTracks.get(selection.trackRefId);
|
|
2263
2462
|
const result = {
|
|
@@ -2315,6 +2514,7 @@ export class AppleProjectionDomainServices {
|
|
|
2315
2514
|
|
|
2316
2515
|
close() {
|
|
2317
2516
|
this.resetCandidateSets();
|
|
2517
|
+
if (!this.#ownsStores) return;
|
|
2318
2518
|
try {
|
|
2319
2519
|
this.#projection.close();
|
|
2320
2520
|
} finally {
|