@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,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { chmod, mkdir } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
@@ -9,8 +9,23 @@ import {
|
|
|
9
9
|
createListenerCorrectionRetraction,
|
|
10
10
|
} from "./listener-corrections.mjs";
|
|
11
11
|
import { projectListeningProfile } from "./listening-profile-projection.mjs";
|
|
12
|
+
import {
|
|
13
|
+
listeningFamiliarity, markMoondogStartedPlays, mixSlot, suggestionEntry, suggestionIdentities, SUGGESTION_WINDOW,
|
|
14
|
+
} from "./moondog-suggestions.mjs";
|
|
15
|
+
import { ListenerProfileStore, listenerProfileSchema } from "./listener-profile-store.mjs";
|
|
16
|
+
|
|
17
|
+
export const LISTENING_HISTORY_SCHEMA_VERSION = 7;
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
// The tables a cached projection, build input, Avoid gate or status reads, apart from the
|
|
20
|
+
// suggestions ledger (listeningDataVersion). Rows are only added to the first ones: the one
|
|
21
|
+
// update to a stored play, which records the play that supersedes it, is made with its row in
|
|
22
|
+
// listening_event_supersessions. Imports update their batch rows, so those are read in full.
|
|
23
|
+
// A new table those caches read, or a new update or delete, belongs here.
|
|
24
|
+
const APPENDED_EVIDENCE_TABLES = Object.freeze([
|
|
25
|
+
"track_refs", "listening_events", "listening_event_supersessions", "listening_event_mirrors",
|
|
26
|
+
"spotify_profile_evidence", "taste_events",
|
|
27
|
+
]);
|
|
28
|
+
const UPDATED_EVIDENCE_TABLES = Object.freeze(["import_batches", "profile_import_batches"]);
|
|
14
29
|
|
|
15
30
|
export function resolveListeningHistoryPath(environment = process.env) {
|
|
16
31
|
return path.join(resolveMoondogStateDirectory(environment), "listening-history.sqlite");
|
|
@@ -48,6 +63,8 @@ const schema = `
|
|
|
48
63
|
|
|
49
64
|
CREATE INDEX IF NOT EXISTS listening_events_subject_time
|
|
50
65
|
ON listening_events(subject_id, occurred_at DESC);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS listening_events_subject_track
|
|
67
|
+
ON listening_events(subject_id, track_ref_id, track_ref_revision);
|
|
51
68
|
|
|
52
69
|
CREATE TABLE IF NOT EXISTS source_cursors (
|
|
53
70
|
source_key TEXT PRIMARY KEY NOT NULL,
|
|
@@ -55,6 +72,18 @@ const schema = `
|
|
|
55
72
|
updated_at TEXT NOT NULL
|
|
56
73
|
) STRICT;
|
|
57
74
|
|
|
75
|
+
-- Spotify keeps only a listener's last 50 plays. When a sync finds the window full,
|
|
76
|
+
-- plays between the last stored one and the oldest returned one may be gone for good.
|
|
77
|
+
CREATE TABLE IF NOT EXISTS listening_coverage_gaps (
|
|
78
|
+
subject_id TEXT NOT NULL,
|
|
79
|
+
source_key TEXT NOT NULL,
|
|
80
|
+
gap_start TEXT NOT NULL,
|
|
81
|
+
gap_end TEXT NOT NULL,
|
|
82
|
+
detected_at TEXT NOT NULL,
|
|
83
|
+
PRIMARY KEY (subject_id, gap_start, gap_end),
|
|
84
|
+
CHECK (gap_start < gap_end)
|
|
85
|
+
) STRICT;
|
|
86
|
+
|
|
58
87
|
CREATE TABLE IF NOT EXISTS import_batches (
|
|
59
88
|
import_batch_id TEXT PRIMARY KEY NOT NULL,
|
|
60
89
|
subject_id TEXT NOT NULL,
|
|
@@ -98,6 +127,18 @@ const schema = `
|
|
|
98
127
|
REFERENCES import_batches(import_batch_id)
|
|
99
128
|
) STRICT;
|
|
100
129
|
|
|
130
|
+
-- A Last.fm scrobble that repeats a play another service recorded stays stored,
|
|
131
|
+
-- linked to that play, and is left out of every count.
|
|
132
|
+
CREATE TABLE IF NOT EXISTS listening_event_mirrors (
|
|
133
|
+
mirror_event_id TEXT PRIMARY KEY NOT NULL,
|
|
134
|
+
original_event_id TEXT NOT NULL UNIQUE,
|
|
135
|
+
created_at TEXT NOT NULL,
|
|
136
|
+
FOREIGN KEY (mirror_event_id)
|
|
137
|
+
REFERENCES listening_events(listening_event_id),
|
|
138
|
+
FOREIGN KEY (original_event_id)
|
|
139
|
+
REFERENCES listening_events(listening_event_id)
|
|
140
|
+
) STRICT;
|
|
141
|
+
|
|
101
142
|
CREATE TABLE IF NOT EXISTS profile_import_batches (
|
|
102
143
|
profile_import_id TEXT PRIMARY KEY NOT NULL,
|
|
103
144
|
subject_id TEXT NOT NULL,
|
|
@@ -158,6 +199,28 @@ const schema = `
|
|
|
158
199
|
CREATE UNIQUE INDEX IF NOT EXISTS taste_events_one_retraction
|
|
159
200
|
ON taste_events(retracts_taste_event_id)
|
|
160
201
|
WHERE retracts_taste_event_id IS NOT NULL;
|
|
202
|
+
|
|
203
|
+
-- What Moondog itself queued or played, written from the host's accepted receipts. Plays
|
|
204
|
+
-- that match one are left out of the listener's own attention when the profile is read
|
|
205
|
+
-- (src/profile/moondog-suggestions.mjs). Provider track identities never leave the host.
|
|
206
|
+
CREATE TABLE IF NOT EXISTS moondog_suggestions (
|
|
207
|
+
suggestion_id TEXT PRIMARY KEY NOT NULL,
|
|
208
|
+
service TEXT NOT NULL,
|
|
209
|
+
action TEXT NOT NULL,
|
|
210
|
+
provider_track_id TEXT,
|
|
211
|
+
isrc TEXT,
|
|
212
|
+
track_key TEXT,
|
|
213
|
+
release_key TEXT,
|
|
214
|
+
title TEXT NOT NULL,
|
|
215
|
+
artist_credit TEXT NOT NULL,
|
|
216
|
+
requested_at TEXT NOT NULL,
|
|
217
|
+
mix_slot TEXT
|
|
218
|
+
) STRICT;
|
|
219
|
+
|
|
220
|
+
CREATE INDEX IF NOT EXISTS moondog_suggestions_time
|
|
221
|
+
ON moondog_suggestions(requested_at);
|
|
222
|
+
|
|
223
|
+
${listenerProfileSchema}
|
|
161
224
|
`;
|
|
162
225
|
|
|
163
226
|
function listenerCorrectionError(code, message) {
|
|
@@ -207,6 +270,7 @@ function normalizedBundle(value, maximumRecords = 50) {
|
|
|
207
270
|
capturedAt: new Date(value.captured_at).toISOString(),
|
|
208
271
|
trackRefs: value.track_refs,
|
|
209
272
|
listeningEvents: value.listening_events,
|
|
273
|
+
windowFull: value.window_full === true,
|
|
210
274
|
};
|
|
211
275
|
}
|
|
212
276
|
|
|
@@ -321,7 +385,8 @@ function normalizedProfileInput(value, batch) {
|
|
|
321
385
|
!isPlainObject(record.provenance) ||
|
|
322
386
|
record.provenance.source_kind !== "import" ||
|
|
323
387
|
!(record.provenance.source_system === "spotify_account_data" ||
|
|
324
|
-
(
|
|
388
|
+
(record.provenance.source_system === "spotify_web_api" && profileImport.source_key === "spotify.web_api_library") ||
|
|
389
|
+
(["youtube_music", "qq_music", "netease", "lastfm"].includes(record.provenance.source_system) &&
|
|
325
390
|
profileImport.source_key === `${record.provenance.source_system}.music_import`)) ||
|
|
326
391
|
!profileImport.member_names.includes(record.provenance.source_member) ||
|
|
327
392
|
record.provenance.profile_import_id?.toLowerCase() !== profileImportId ||
|
|
@@ -535,9 +600,45 @@ function ingestionStatements(database) {
|
|
|
535
600
|
cursor_after_ms = MAX(source_cursors.cursor_after_ms, excluded.cursor_after_ms),
|
|
536
601
|
updated_at = excluded.updated_at`,
|
|
537
602
|
),
|
|
603
|
+
hasEvent: database.prepare(
|
|
604
|
+
"SELECT 1 FROM listening_events WHERE deduplication_key = ?",
|
|
605
|
+
),
|
|
606
|
+
touchCursor: database.prepare(
|
|
607
|
+
"UPDATE source_cursors SET updated_at = MAX(updated_at, ?) WHERE source_key = ?",
|
|
608
|
+
),
|
|
538
609
|
};
|
|
539
610
|
}
|
|
540
611
|
|
|
612
|
+
// Records the span between the newest stored play and `before` as possibly missed.
|
|
613
|
+
function recordCoverageGap(database, { subjectId, sourceKey, before, detectedAt }) {
|
|
614
|
+
const after = database.prepare(
|
|
615
|
+
"SELECT MAX(occurred_at) AS occurred_at FROM listening_events WHERE subject_id = ? AND occurred_at < ?",
|
|
616
|
+
).get(subjectId, before)?.occurred_at;
|
|
617
|
+
// A history that starts with this read has nothing to have missed.
|
|
618
|
+
if (!after) return;
|
|
619
|
+
database.prepare(
|
|
620
|
+
`INSERT OR IGNORE INTO listening_coverage_gaps (subject_id, source_key, gap_start, gap_end, detected_at)
|
|
621
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
622
|
+
).run(subjectId, sourceKey, after, before, detectedAt);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Recent syncs from before gaps were recorded: a run that stored a full window of 50
|
|
626
|
+
// new plays at once may have missed plays before its oldest one.
|
|
627
|
+
function recordEarlierRecentSyncGaps(database) {
|
|
628
|
+
const runs = database.prepare(
|
|
629
|
+
`SELECT subject_id, json_extract(record_json, '$.ingested_at') AS ingested_at,
|
|
630
|
+
MIN(occurred_at) AS earliest, COUNT(*) AS plays
|
|
631
|
+
FROM listening_events
|
|
632
|
+
WHERE json_extract(record_json, '$.provenance.external_ref.entity_type') = 'spotify.play_history'
|
|
633
|
+
GROUP BY subject_id, ingested_at HAVING plays >= 50`,
|
|
634
|
+
).all();
|
|
635
|
+
for (const run of runs) {
|
|
636
|
+
recordCoverageGap(database, {
|
|
637
|
+
subjectId: run.subject_id, sourceKey: "spotify.recently_played", before: run.earliest, detectedAt: run.ingested_at,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
541
642
|
function insertBundleRecords(bundle, statements) {
|
|
542
643
|
let insertedTracks = 0;
|
|
543
644
|
let insertedEvents = 0;
|
|
@@ -569,9 +670,180 @@ function insertBundleRecords(bundle, statements) {
|
|
|
569
670
|
return { insertedTracks, insertedEvents };
|
|
570
671
|
}
|
|
571
672
|
|
|
673
|
+
// Where stored plays came from, in words the agent can repeat. Others keep their kind.
|
|
674
|
+
const COVERAGE_SOURCE_LABELS = {
|
|
675
|
+
"spotify.play_history": "Spotify recent plays, read while Moondog runs",
|
|
676
|
+
"spotify.extended_history.play": "Spotify Extended Streaming History file",
|
|
677
|
+
"spotify.account_data.play_history": "Spotify Account Data file",
|
|
678
|
+
"listenbrainz.listen": "ListenBrainz export file",
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const SCROBBLE_SOURCE_KEY = "lastfm.music_import";
|
|
682
|
+
// Scrobbles record when a song started. Spotify records when a play ended, so its start is
|
|
683
|
+
// the end minus the listening time; Account Data times are rounded to the minute.
|
|
684
|
+
const MIRROR_TOLERANCE_MS = 150_000;
|
|
685
|
+
const DAY_MS = 86_400_000;
|
|
686
|
+
|
|
687
|
+
// A local calendar date, such as 2026-10-25.
|
|
688
|
+
function localDate(date) {
|
|
689
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
690
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const foldLabel = (value) => String(value ?? "").normalize("NFKC").toLocaleLowerCase("und")
|
|
694
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
695
|
+
const lexicalCompare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Many Last.fm scrobbles repeat plays that Spotify or ListenBrainz recorded too. Each scrobble
|
|
699
|
+
* that matches another service's play (same artist and title, starting within a few minutes)
|
|
700
|
+
* is linked to it once, whichever was imported first, so the play counts once.
|
|
701
|
+
*/
|
|
702
|
+
function reconcileScrobbleMirrors(database, subjectId, earliest, latest, createdAt) {
|
|
703
|
+
if (!subjectId || !earliest || !latest) return 0;
|
|
704
|
+
const scrobbled = database.prepare(
|
|
705
|
+
"SELECT 1 FROM import_batches WHERE subject_id = ? AND source_key = ? LIMIT 1",
|
|
706
|
+
).get(subjectId, SCROBBLE_SOURCE_KEY);
|
|
707
|
+
if (!scrobbled) return 0;
|
|
708
|
+
const rows = database.prepare(
|
|
709
|
+
`SELECT e.listening_event_id AS id, e.occurred_at AS at,
|
|
710
|
+
e.record_json AS event_json, t.record_json AS track_json
|
|
711
|
+
FROM listening_events e
|
|
712
|
+
JOIN track_refs t
|
|
713
|
+
ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
|
|
714
|
+
WHERE e.subject_id = ? AND e.occurred_at BETWEEN ? AND ?
|
|
715
|
+
AND NOT EXISTS (
|
|
716
|
+
SELECT 1 FROM listening_event_supersessions s
|
|
717
|
+
WHERE s.superseded_event_id = e.listening_event_id
|
|
718
|
+
)
|
|
719
|
+
AND NOT EXISTS (
|
|
720
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
721
|
+
WHERE m.mirror_event_id = e.listening_event_id
|
|
722
|
+
OR m.original_event_id = e.listening_event_id
|
|
723
|
+
)`,
|
|
724
|
+
).all(
|
|
725
|
+
subjectId,
|
|
726
|
+
new Date(Date.parse(earliest) - DAY_MS).toISOString(),
|
|
727
|
+
new Date(Date.parse(latest) + DAY_MS).toISOString(),
|
|
728
|
+
);
|
|
729
|
+
const scrobbles = new Map();
|
|
730
|
+
const plays = [];
|
|
731
|
+
for (const row of rows) {
|
|
732
|
+
const event = JSON.parse(row.event_json);
|
|
733
|
+
const track = JSON.parse(row.track_json);
|
|
734
|
+
const artist = foldLabel(track.artist_credits?.[0]?.name);
|
|
735
|
+
const title = foldLabel(track.title);
|
|
736
|
+
if (!artist || !title) continue;
|
|
737
|
+
const key = `${artist}\u0000${title}`;
|
|
738
|
+
const at = Date.parse(row.at);
|
|
739
|
+
const system = String(event.provenance?.external_ref?.system ?? "");
|
|
740
|
+
if (system === "lastfm") {
|
|
741
|
+
if (!scrobbles.has(key)) scrobbles.set(key, []);
|
|
742
|
+
scrobbles.get(key).push({ id: row.id, start: at });
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const length = Number.isSafeInteger(event.played_ms) ? event.played_ms
|
|
746
|
+
: Number.isSafeInteger(track.duration_ms) ? track.duration_ms : null;
|
|
747
|
+
// A Spotify play of unknown length can't be placed precisely enough to match.
|
|
748
|
+
if (system.startsWith("spotify") && length === null) continue;
|
|
749
|
+
plays.push({ id: row.id, key, start: system.startsWith("spotify") ? at - length : at });
|
|
750
|
+
}
|
|
751
|
+
const pairs = [];
|
|
752
|
+
for (const list of scrobbles.values()) list.sort((left, right) => left.start - right.start);
|
|
753
|
+
for (const play of plays) {
|
|
754
|
+
const list = scrobbles.get(play.key);
|
|
755
|
+
if (!list) continue;
|
|
756
|
+
let low = 0;
|
|
757
|
+
let high = list.length;
|
|
758
|
+
while (low < high) {
|
|
759
|
+
const middle = (low + high) >> 1;
|
|
760
|
+
if (list[middle].start < play.start - MIRROR_TOLERANCE_MS) low = middle + 1;
|
|
761
|
+
else high = middle;
|
|
762
|
+
}
|
|
763
|
+
for (let index = low; index < list.length && list[index].start <= play.start + MIRROR_TOLERANCE_MS; index += 1) {
|
|
764
|
+
pairs.push({ gap: Math.abs(list[index].start - play.start), play: play.id, scrobble: list[index].id });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// Closest pairs first, so each play and each scrobble is used once.
|
|
768
|
+
pairs.sort((left, right) => left.gap - right.gap ||
|
|
769
|
+
lexicalCompare(left.scrobble, right.scrobble) || lexicalCompare(left.play, right.play));
|
|
770
|
+
const insert = database.prepare(
|
|
771
|
+
`INSERT INTO listening_event_mirrors (mirror_event_id, original_event_id, created_at)
|
|
772
|
+
VALUES (?, ?, ?)`,
|
|
773
|
+
);
|
|
774
|
+
const usedPlays = new Set();
|
|
775
|
+
const usedScrobbles = new Set();
|
|
776
|
+
let linked = 0;
|
|
777
|
+
for (const pair of pairs) {
|
|
778
|
+
if (usedPlays.has(pair.play) || usedScrobbles.has(pair.scrobble)) continue;
|
|
779
|
+
usedPlays.add(pair.play);
|
|
780
|
+
usedScrobbles.add(pair.scrobble);
|
|
781
|
+
linked += insert.run(pair.scrobble, pair.play, createdAt).changes;
|
|
782
|
+
}
|
|
783
|
+
return linked;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function preserveExtendedHistoryMatches(bundle, retainedMatch) {
|
|
787
|
+
const candidates = bundle.reconciliationCandidates;
|
|
788
|
+
if (bundle.importBatch.sourceFormat !== "spotify_extended_streaming_history_music_v1") return candidates;
|
|
789
|
+
const retained = new Map(candidates.map((candidate) => [
|
|
790
|
+
candidate.supersedingEventId, retainedMatch(candidate.supersedingEventId),
|
|
791
|
+
]));
|
|
792
|
+
if (!candidates.some((candidate) => retained.get(candidate.supersedingEventId) &&
|
|
793
|
+
retained.get(candidate.supersedingEventId) !== candidate.supersededEventId)) return candidates;
|
|
794
|
+
|
|
795
|
+
// Account Data cannot distinguish equal-duration plays of the same title and
|
|
796
|
+
// artist within one minute. Added video plays can reorder those ordinals.
|
|
797
|
+
// A narrower export may omit an established counterpart's ordinal entirely.
|
|
798
|
+
// Keep those pairs too; only new plays use this archive's remaining candidates.
|
|
799
|
+
// The store's conflict checks still reject a new play claiming an absent play's
|
|
800
|
+
// retained counterpart, so an incomplete export cannot invent another match.
|
|
801
|
+
const events = new Map(bundle.listeningEvents.map((event) => [event.listening_event_id, event]));
|
|
802
|
+
const tracks = new Map(bundle.trackRefs.map((track) => [track.track_ref_id, track]));
|
|
803
|
+
const groups = new Map();
|
|
804
|
+
for (const candidate of candidates) {
|
|
805
|
+
const event = events.get(candidate.supersedingEventId);
|
|
806
|
+
const track = tracks.get(event.track_ref_id);
|
|
807
|
+
const key = JSON.stringify([
|
|
808
|
+
event.occurred_at.slice(0, 16), event.played_ms,
|
|
809
|
+
track.title.normalize("NFKC").toLowerCase(),
|
|
810
|
+
track.artist_credits[0].name.normalize("NFKC").toLowerCase(),
|
|
811
|
+
]);
|
|
812
|
+
const group = groups.get(key) ?? [];
|
|
813
|
+
group.push(candidate);
|
|
814
|
+
groups.set(key, group);
|
|
815
|
+
}
|
|
816
|
+
const matches = new Map();
|
|
817
|
+
for (const group of groups.values()) {
|
|
818
|
+
const available = new Set(group.map((candidate) => candidate.supersededEventId));
|
|
819
|
+
for (const candidate of group) {
|
|
820
|
+
const previous = retained.get(candidate.supersedingEventId);
|
|
821
|
+
if (!previous) continue;
|
|
822
|
+
available.delete(previous);
|
|
823
|
+
matches.set(candidate.supersedingEventId, previous);
|
|
824
|
+
}
|
|
825
|
+
const remaining = available.values();
|
|
826
|
+
for (const candidate of group) {
|
|
827
|
+
if (matches.has(candidate.supersedingEventId)) continue;
|
|
828
|
+
const selected = available.has(candidate.supersededEventId)
|
|
829
|
+
? candidate.supersededEventId : remaining.next().value;
|
|
830
|
+
matches.set(candidate.supersedingEventId, selected);
|
|
831
|
+
available.delete(selected);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return candidates.map((candidate) => ({
|
|
835
|
+
...candidate, supersededEventId: matches.get(candidate.supersedingEventId),
|
|
836
|
+
}));
|
|
837
|
+
}
|
|
838
|
+
|
|
572
839
|
export class ListeningHistoryStore {
|
|
573
840
|
#database;
|
|
574
841
|
#closed = false;
|
|
842
|
+
#snapshot = null;
|
|
843
|
+
#projection = null;
|
|
844
|
+
#coverageSources = null;
|
|
845
|
+
#evidence = null;
|
|
846
|
+
#ledgerChanges = 0;
|
|
575
847
|
|
|
576
848
|
constructor(database) {
|
|
577
849
|
this.#database = database;
|
|
@@ -670,6 +942,10 @@ export class ListeningHistoryStore {
|
|
|
670
942
|
AND NOT EXISTS (
|
|
671
943
|
SELECT 1 FROM listening_event_supersessions s
|
|
672
944
|
WHERE s.superseded_event_id = e.listening_event_id
|
|
945
|
+
)
|
|
946
|
+
AND NOT EXISTS (
|
|
947
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
948
|
+
WHERE m.mirror_event_id = e.listening_event_id
|
|
673
949
|
)`,
|
|
674
950
|
)
|
|
675
951
|
.get(normalized).count;
|
|
@@ -1009,6 +1285,22 @@ export class ListeningHistoryStore {
|
|
|
1009
1285
|
});
|
|
1010
1286
|
}
|
|
1011
1287
|
|
|
1288
|
+
// How many choices are in force, beyond the ones a list shows.
|
|
1289
|
+
countActiveListenerCorrections({ subjectId } = {}) {
|
|
1290
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1291
|
+
if (!isUuid(subjectId)) {
|
|
1292
|
+
throw new TypeError("A valid listener correction subject is required");
|
|
1293
|
+
}
|
|
1294
|
+
return this.#database
|
|
1295
|
+
.prepare(
|
|
1296
|
+
`SELECT COUNT(*) AS count FROM taste_events a
|
|
1297
|
+
WHERE a.subject_id = ? AND a.operation = 'assert'
|
|
1298
|
+
AND NOT EXISTS (SELECT 1 FROM taste_events r WHERE r.retracts_taste_event_id = a.taste_event_id)
|
|
1299
|
+
AND NOT EXISTS (SELECT 1 FROM taste_events s WHERE s.supersedes_taste_event_id = a.taste_event_id)`,
|
|
1300
|
+
)
|
|
1301
|
+
.get(subjectId.toLowerCase()).count;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1012
1304
|
ingest(value) {
|
|
1013
1305
|
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1014
1306
|
const bundle = normalizedBundle(value);
|
|
@@ -1024,14 +1316,24 @@ export class ListeningHistoryStore {
|
|
|
1024
1316
|
this.#database.exec("BEGIN IMMEDIATE");
|
|
1025
1317
|
let insertedTracks;
|
|
1026
1318
|
let insertedEvents;
|
|
1319
|
+
let mirroredEvents = 0;
|
|
1027
1320
|
try {
|
|
1028
1321
|
if (subjectId !== undefined) {
|
|
1029
1322
|
this.localSubjectId({ preferredSubjectId: subjectId });
|
|
1030
1323
|
}
|
|
1324
|
+
const times = bundle.listeningEvents.map((record) => record.occurred_at).sort();
|
|
1325
|
+
// A read that overlaps a stored play joins up with it.
|
|
1326
|
+
const overlaps = bundle.listeningEvents.some((record) => statements.hasEvent.get(record.deduplication_key));
|
|
1327
|
+
if (bundle.windowFull && subjectId !== undefined && !overlaps) {
|
|
1328
|
+
recordCoverageGap(this.#database, { subjectId, sourceKey: bundle.sourceKey, before: times[0], detectedAt: bundle.capturedAt });
|
|
1329
|
+
}
|
|
1031
1330
|
({ insertedTracks, insertedEvents } = insertBundleRecords(
|
|
1032
1331
|
bundle,
|
|
1033
1332
|
statements,
|
|
1034
1333
|
));
|
|
1334
|
+
// A read that finds nothing new still moves the source's checked time.
|
|
1335
|
+
statements.touchCursor.run(bundle.capturedAt, bundle.sourceKey);
|
|
1336
|
+
mirroredEvents = reconcileScrobbleMirrors(this.#database, subjectId, times[0], times.at(-1), bundle.capturedAt);
|
|
1035
1337
|
this.#database.exec("COMMIT");
|
|
1036
1338
|
} catch (error) {
|
|
1037
1339
|
this.#database.exec("ROLLBACK");
|
|
@@ -1045,6 +1347,7 @@ export class ListeningHistoryStore {
|
|
|
1045
1347
|
inserted_events: insertedEvents,
|
|
1046
1348
|
duplicate_events: bundle.listeningEvents.length - insertedEvents,
|
|
1047
1349
|
inserted_track_refs: insertedTracks,
|
|
1350
|
+
...(mirroredEvents ? { mirrored_events: mirroredEvents } : {}),
|
|
1048
1351
|
cursor_after_ms: this.sourceCursor(bundle.sourceKey),
|
|
1049
1352
|
profile_effects: insertedEvents > 0 ? "updated" : "unchanged",
|
|
1050
1353
|
};
|
|
@@ -1055,7 +1358,7 @@ export class ListeningHistoryStore {
|
|
|
1055
1358
|
const bundle = normalizedImportBundle(value);
|
|
1056
1359
|
const batch = bundle.importBatch;
|
|
1057
1360
|
const existingBatch = this.#database.prepare(
|
|
1058
|
-
`SELECT
|
|
1361
|
+
`SELECT * FROM import_batches
|
|
1059
1362
|
WHERE import_batch_id = ?`,
|
|
1060
1363
|
);
|
|
1061
1364
|
const insertBatch = this.#database.prepare(
|
|
@@ -1141,12 +1444,14 @@ export class ListeningHistoryStore {
|
|
|
1141
1444
|
let insertedTracks = 0;
|
|
1142
1445
|
let insertedEvents = 0;
|
|
1143
1446
|
let supersededEvents = 0;
|
|
1447
|
+
let mirroredEvents = 0;
|
|
1144
1448
|
let insertedProfileEvidence = 0;
|
|
1145
1449
|
let alreadyImported = false;
|
|
1146
1450
|
let profileAlreadyImported = bundle.profileImport === undefined;
|
|
1147
1451
|
try {
|
|
1148
1452
|
this.localSubjectId({ preferredSubjectId: batch.subjectId });
|
|
1149
|
-
|
|
1453
|
+
const previousBatch = existingBatch.get(batch.importBatchId);
|
|
1454
|
+
alreadyImported = Boolean(previousBatch);
|
|
1150
1455
|
if (!alreadyImported) {
|
|
1151
1456
|
({ insertedTracks, insertedEvents } = insertBundleRecords(
|
|
1152
1457
|
bundle,
|
|
@@ -1169,8 +1474,38 @@ export class ListeningHistoryStore {
|
|
|
1169
1474
|
batch.inputRecords - insertedEvents,
|
|
1170
1475
|
insertedTracks,
|
|
1171
1476
|
);
|
|
1477
|
+
} else {
|
|
1478
|
+
// Earlier readers skipped video members, even when they contained music.
|
|
1479
|
+
// Extend that same archive's receipt without replacing its existing rows.
|
|
1480
|
+
const previousMembers = JSON.parse(previousBatch.member_names_json);
|
|
1481
|
+
const addedMembers = batch.memberNames.filter((name) => !previousMembers.includes(name));
|
|
1482
|
+
const expandsExtendedArchive = batch.sourceFormat === "spotify_extended_streaming_history_music_v1" &&
|
|
1483
|
+
previousBatch.subject_id === batch.subjectId && previousBatch.source_key === bundle.sourceKey &&
|
|
1484
|
+
previousBatch.source_format === batch.sourceFormat && previousBatch.data_scope === batch.dataScope &&
|
|
1485
|
+
previousBatch.archive_sha256 === batch.archiveSha256 && previousBatch.archive_size_bytes === batch.archiveSizeBytes &&
|
|
1486
|
+
previousMembers.every((name) => batch.memberNames.includes(name)) && addedMembers.length > 0 &&
|
|
1487
|
+
addedMembers.every((name) => /^Streaming_History_Video_\d{4}(?:_\d+)?\.json$/u.test(name));
|
|
1488
|
+
if (expandsExtendedArchive) {
|
|
1489
|
+
const incomingIds = new Set(bundle.listeningEvents.map((event) => event.listening_event_id));
|
|
1490
|
+
const previousEvents = this.#database.prepare(`SELECT listening_event_id FROM listening_events
|
|
1491
|
+
WHERE subject_id = ? AND json_extract(record_json, '$.provenance.import_batch_id') = ?`)
|
|
1492
|
+
.all(batch.subjectId, batch.importBatchId);
|
|
1493
|
+
if (batch.inputRecords < previousBatch.input_records || previousEvents.some((event) => !incomingIds.has(event.listening_event_id))) {
|
|
1494
|
+
throw new Error("Extended archive expansion must preserve every previously imported event");
|
|
1495
|
+
}
|
|
1496
|
+
({ insertedTracks, insertedEvents } = insertBundleRecords(bundle, statements));
|
|
1497
|
+
this.#database.prepare(`UPDATE import_batches SET member_names_json = ?, input_records = ?,
|
|
1498
|
+
earliest_occurred_at = ?, latest_occurred_at = ?,
|
|
1499
|
+
inserted_events = inserted_events + ?, duplicate_events = ? - inserted_events - ?,
|
|
1500
|
+
inserted_track_refs = inserted_track_refs + ? WHERE import_batch_id = ?`)
|
|
1501
|
+
.run(JSON.stringify(batch.memberNames), batch.inputRecords, batch.earliestOccurredAt, batch.latestOccurredAt,
|
|
1502
|
+
insertedEvents, batch.inputRecords, insertedEvents, insertedTracks, batch.importBatchId);
|
|
1503
|
+
}
|
|
1172
1504
|
}
|
|
1173
|
-
|
|
1505
|
+
const reconciliationCandidates = preserveExtendedHistoryMatches(bundle, (eventId) =>
|
|
1506
|
+
selectCandidateBySuperseding.get(eventId)?.superseded_event_id ??
|
|
1507
|
+
selectBySuperseding.get(eventId)?.superseded_event_id);
|
|
1508
|
+
for (const candidate of reconciliationCandidates) {
|
|
1174
1509
|
const existingBySuperseded = selectCandidateBySuperseded.get(
|
|
1175
1510
|
candidate.supersededEventId,
|
|
1176
1511
|
)?.superseding_event_id;
|
|
@@ -1292,6 +1627,9 @@ export class ListeningHistoryStore {
|
|
|
1292
1627
|
);
|
|
1293
1628
|
}
|
|
1294
1629
|
}
|
|
1630
|
+
mirroredEvents = reconcileScrobbleMirrors(
|
|
1631
|
+
this.#database, batch.subjectId, batch.earliestOccurredAt, batch.latestOccurredAt, batch.importedAt,
|
|
1632
|
+
);
|
|
1295
1633
|
this.#database.exec("COMMIT");
|
|
1296
1634
|
} catch (error) {
|
|
1297
1635
|
this.#database.exec("ROLLBACK");
|
|
@@ -1310,7 +1648,8 @@ export class ListeningHistoryStore {
|
|
|
1310
1648
|
duplicate_events: batch.inputRecords - insertedEvents,
|
|
1311
1649
|
inserted_track_refs: insertedTracks,
|
|
1312
1650
|
superseded_events: supersededEvents,
|
|
1313
|
-
|
|
1651
|
+
...(mirroredEvents ? { mirrored_events: mirroredEvents } : {}),
|
|
1652
|
+
effective_event_delta: insertedEvents - supersededEvents - mirroredEvents,
|
|
1314
1653
|
cursor_after_ms: this.sourceCursor(bundle.sourceKey),
|
|
1315
1654
|
earliest_occurred_at: batch.earliestOccurredAt,
|
|
1316
1655
|
latest_occurred_at: batch.latestOccurredAt,
|
|
@@ -1333,7 +1672,7 @@ export class ListeningHistoryStore {
|
|
|
1333
1672
|
}
|
|
1334
1673
|
: {}),
|
|
1335
1674
|
profile_effects:
|
|
1336
|
-
insertedEvents
|
|
1675
|
+
insertedEvents > 0 || supersededEvents > 0 || insertedProfileEvidence > 0
|
|
1337
1676
|
? "updated"
|
|
1338
1677
|
: "unchanged",
|
|
1339
1678
|
};
|
|
@@ -1355,6 +1694,10 @@ export class ListeningHistoryStore {
|
|
|
1355
1694
|
SELECT 1 FROM listening_event_supersessions
|
|
1356
1695
|
WHERE superseded_event_id = listening_events.listening_event_id
|
|
1357
1696
|
)
|
|
1697
|
+
AND NOT EXISTS (
|
|
1698
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
1699
|
+
WHERE m.mirror_event_id = listening_events.listening_event_id
|
|
1700
|
+
)
|
|
1358
1701
|
ORDER BY occurred_at DESC, listening_event_id DESC
|
|
1359
1702
|
LIMIT ?`,
|
|
1360
1703
|
)
|
|
@@ -1454,6 +1797,10 @@ export class ListeningHistoryStore {
|
|
|
1454
1797
|
SELECT 1 FROM listening_event_supersessions s
|
|
1455
1798
|
WHERE s.superseded_event_id = e.listening_event_id
|
|
1456
1799
|
)
|
|
1800
|
+
AND NOT EXISTS (
|
|
1801
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
1802
|
+
WHERE m.mirror_event_id = e.listening_event_id
|
|
1803
|
+
)
|
|
1457
1804
|
ORDER BY e.occurred_at, e.listening_event_id`,
|
|
1458
1805
|
)
|
|
1459
1806
|
.all(normalizedSubjectId)
|
|
@@ -1487,15 +1834,103 @@ export class ListeningHistoryStore {
|
|
|
1487
1834
|
)
|
|
1488
1835
|
.all(normalizedSubjectId)
|
|
1489
1836
|
.map((row) => JSON.parse(row.record_json));
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1837
|
+
const { rows, adopted } = markMoondogStartedPlays(eventRows, this.#suggestionsForMatching());
|
|
1838
|
+
return {
|
|
1839
|
+
...projectListeningProfile({
|
|
1840
|
+
subjectId: normalizedSubjectId,
|
|
1841
|
+
eventRows: rows,
|
|
1842
|
+
profileEvidence,
|
|
1843
|
+
tasteEvents,
|
|
1844
|
+
maxItems,
|
|
1845
|
+
adoptedSuggestionIds: adopted,
|
|
1846
|
+
}),
|
|
1847
|
+
adoptedSuggestionIds: adopted,
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
#suggestionsForMatching() {
|
|
1852
|
+
return this.#database.prepare(`SELECT suggestion_id, provider_track_id, isrc, track_key, release_key, title, artist_credit,
|
|
1853
|
+
requested_at FROM moondog_suggestions ORDER BY requested_at, suggestion_id`).all();
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/** Records what Moondog itself queued or played. Only accepted host writes belong here. */
|
|
1857
|
+
recordMoondogSuggestions({ entries } = {}) {
|
|
1858
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1859
|
+
if (!Array.isArray(entries) || entries.length > 50) throw new TypeError("Moondog suggestions are invalid");
|
|
1860
|
+
const insert = this.#database.prepare(`INSERT INTO moondog_suggestions
|
|
1861
|
+
(suggestion_id, service, action, provider_track_id, isrc, track_key, release_key, title, artist_credit, requested_at, mix_slot)
|
|
1862
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
1863
|
+
let recorded = 0;
|
|
1864
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1865
|
+
try {
|
|
1866
|
+
for (const value of entries) {
|
|
1867
|
+
const entry = suggestionEntry(value);
|
|
1868
|
+
if (!entry || !/^[a-z][a-z0-9_.]{1,63}$/u.test(entry.action ?? "") || !Number.isFinite(Date.parse(entry.requested_at))) continue;
|
|
1869
|
+
insert.run(randomUUID(), entry.service, entry.action, entry.provider_track_id, entry.isrc, entry.track_key, entry.release_key,
|
|
1870
|
+
entry.title, entry.artist_credit, new Date(entry.requested_at).toISOString(), entry.mix_slot);
|
|
1871
|
+
recorded += 1;
|
|
1872
|
+
}
|
|
1873
|
+
this.#database.exec("COMMIT");
|
|
1874
|
+
} catch (error) {
|
|
1875
|
+
this.#database.exec("ROLLBACK");
|
|
1876
|
+
throw error;
|
|
1877
|
+
}
|
|
1878
|
+
this.#ledgerChanges += recorded;
|
|
1879
|
+
return { recorded };
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/**
|
|
1883
|
+
* Moondog's earlier suggestions of each track, matched as stored plays are: the same provider
|
|
1884
|
+
* track, ISRC or song. Adopted means the listener later played it again on their own.
|
|
1885
|
+
*/
|
|
1886
|
+
moondogSuggestionHistory({ subjectId, tracks } = {}) {
|
|
1887
|
+
if (!Array.isArray(tracks)) throw new TypeError("Suggestion history tracks are invalid");
|
|
1888
|
+
const suggestions = this.#suggestionsForMatching();
|
|
1889
|
+
if (!suggestions.length) return tracks.map(() => null);
|
|
1890
|
+
const { adopted } = this.#listeningSnapshot(subjectId);
|
|
1891
|
+
const byIdentity = new Map();
|
|
1892
|
+
for (const suggestion of suggestions) {
|
|
1893
|
+
for (const identity of suggestionIdentities(suggestion)) {
|
|
1894
|
+
byIdentity.set(identity, [...(byIdentity.get(identity) ?? []), suggestion]);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
return tracks.map((track) => {
|
|
1898
|
+
const entry = suggestionEntry({ service: "spotify", action: "lookup", ...track });
|
|
1899
|
+
const matches = new Set(entry ? suggestionIdentities(entry).flatMap((identity) => byIdentity.get(identity) ?? []) : []);
|
|
1900
|
+
if (!matches.size) return null;
|
|
1901
|
+
const last = [...matches].reduce((latest, item) => item.requested_at > latest ? item.requested_at : latest, "");
|
|
1902
|
+
return { times: matches.size, last_suggested_at: last.slice(0, 10),
|
|
1903
|
+
adopted: [...matches].some((item) => adopted.has(item.suggestion_id)) };
|
|
1496
1904
|
});
|
|
1497
1905
|
}
|
|
1498
1906
|
|
|
1907
|
+
/** Where each track sits in the listener's own plays (MIX_SLOTS in moondog-suggestions.mjs). */
|
|
1908
|
+
listeningMix({ subjectId, tracks } = {}) {
|
|
1909
|
+
if (!Array.isArray(tracks)) throw new TypeError("Listening mix tracks are invalid");
|
|
1910
|
+
const { familiarity } = this.#listeningSnapshot(subjectId);
|
|
1911
|
+
return tracks.map((track) => mixSlot(track, familiarity));
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// One projection with a single item per list serves the status, the Avoids, the catalog, the
|
|
1915
|
+
// mix and the suggestion history until the listening data changes. Callers copy what they return.
|
|
1916
|
+
#sharedProjection(subjectId) {
|
|
1917
|
+
const version = `${subjectId}:${this.listeningDataVersion()}`;
|
|
1918
|
+
if (this.#projection?.version !== version) {
|
|
1919
|
+
this.#projection = { version, value: this.#profileProjection({ subjectId, maxItems: 1 }) };
|
|
1920
|
+
}
|
|
1921
|
+
return this.#projection.value;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
#listeningSnapshot(subjectId) {
|
|
1925
|
+
const version = `${subjectId}:${this.listeningDataVersion()}`;
|
|
1926
|
+
if (this.#snapshot?.version !== version) {
|
|
1927
|
+
const projected = this.#sharedProjection(subjectId);
|
|
1928
|
+
this.#snapshot = { version, adopted: projected.adoptedSuggestionIds,
|
|
1929
|
+
familiarity: listeningFamiliarity(projected.catalog, projected.summary.listening_behavior.context.reference_date) };
|
|
1930
|
+
}
|
|
1931
|
+
return this.#snapshot;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1499
1934
|
/** Every track the subject has played, with its play count, most played first. */
|
|
1500
1935
|
listenedTracks({ subjectId } = {}) {
|
|
1501
1936
|
if (this.#closed) throw new Error("Listening history store is closed");
|
|
@@ -1517,6 +1952,10 @@ export class ListeningHistoryStore {
|
|
|
1517
1952
|
SELECT 1 FROM listening_event_supersessions
|
|
1518
1953
|
WHERE superseded_event_id = listening_events.listening_event_id
|
|
1519
1954
|
)
|
|
1955
|
+
AND NOT EXISTS (
|
|
1956
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
1957
|
+
WHERE m.mirror_event_id = listening_events.listening_event_id
|
|
1958
|
+
)
|
|
1520
1959
|
GROUP BY track_ref_id
|
|
1521
1960
|
ORDER BY play_count DESC, track_ref_id`,
|
|
1522
1961
|
)
|
|
@@ -1545,10 +1984,186 @@ export class ListeningHistoryStore {
|
|
|
1545
1984
|
return structuredClone({ ...value.summary, lyric_exclusions: value.lyricExclusions });
|
|
1546
1985
|
}
|
|
1547
1986
|
|
|
1987
|
+
activeAvoidances({ subjectId } = {}) {
|
|
1988
|
+
// Host-side filtering must include corrections outside the bounded display.
|
|
1989
|
+
return structuredClone(this.#sharedProjection(subjectId).lyricExclusions);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1548
1992
|
profileSummary({ subjectId, maxItems = 10 } = {}) {
|
|
1549
|
-
return structuredClone(
|
|
1550
|
-
this.#profileProjection({ subjectId, maxItems }).summary
|
|
1551
|
-
|
|
1993
|
+
return structuredClone((maxItems === 1 ? this.#sharedProjection(subjectId)
|
|
1994
|
+
: this.#profileProjection({ subjectId, maxItems })).summary);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
profileCatalog({ subjectId } = {}) {
|
|
1998
|
+
const projected = this.#sharedProjection(subjectId);
|
|
1999
|
+
const normalizedSubjectId = subjectId.toLowerCase();
|
|
2000
|
+
const imports = this.#database.prepare(`SELECT source_format AS format, data_scope AS scope,
|
|
2001
|
+
SUM(input_records) AS input_records, MIN(earliest_occurred_at) AS earliest,
|
|
2002
|
+
MAX(latest_occurred_at) AS latest
|
|
2003
|
+
FROM import_batches WHERE subject_id = ? GROUP BY source_format, data_scope ORDER BY source_format`)
|
|
2004
|
+
.all(normalizedSubjectId);
|
|
2005
|
+
const stored = this.#database.prepare("SELECT COUNT(*) AS count FROM listening_events WHERE subject_id = ?")
|
|
2006
|
+
.get(normalizedSubjectId).count;
|
|
2007
|
+
return structuredClone({
|
|
2008
|
+
coverage: { ...projected.summary.coverage,
|
|
2009
|
+
stored_listening_events: stored,
|
|
2010
|
+
superseded_listening_events: stored - projected.summary.coverage.effective_listening_events },
|
|
2011
|
+
sources: imports,
|
|
2012
|
+
context: {
|
|
2013
|
+
reference_date: projected.summary.listening_behavior.context.reference_date,
|
|
2014
|
+
recent_window_days: projected.summary.listening_behavior.context.recent_window_days,
|
|
2015
|
+
incognito_events_excluded: projected.summary.listening_behavior.context.incognito_events_excluded,
|
|
2016
|
+
...(projected.summary.listening_behavior.context.moondog_started_events_excluded
|
|
2017
|
+
? { moondog_started_events_excluded: projected.summary.listening_behavior.context.moondog_started_events_excluded } : {}),
|
|
2018
|
+
release_minimum_distinct_tracks: projected.summary.listening_behavior.context.release_minimum_distinct_tracks,
|
|
2019
|
+
},
|
|
2020
|
+
sections: projected.catalog,
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// What the listener played lately: stored plays on today and the `days` - 1 local days before
|
|
2025
|
+
// it, by artist. It reads the clock, not the newest play, so a history that stopped shows as
|
|
2026
|
+
// quiet. Whole local days keep the window, and so the model's context, the same all day.
|
|
2027
|
+
recentAttention({ subjectId, days = 28, now = Date.now(), limit = 10 } = {}) {
|
|
2028
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
2029
|
+
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
2030
|
+
const today = new Date(now);
|
|
2031
|
+
const start = new Date(today);
|
|
2032
|
+
start.setHours(0, 0, 0, 0);
|
|
2033
|
+
start.setDate(start.getDate() - (days - 1));
|
|
2034
|
+
const from = start.toISOString();
|
|
2035
|
+
const effective = `e.subject_id = ? AND e.occurred_at >= ?
|
|
2036
|
+
AND NOT EXISTS (SELECT 1 FROM listening_event_supersessions s WHERE s.superseded_event_id = e.listening_event_id)
|
|
2037
|
+
AND NOT EXISTS (SELECT 1 FROM listening_event_mirrors m WHERE m.mirror_event_id = e.listening_event_id)`;
|
|
2038
|
+
const subject = subjectId.toLowerCase();
|
|
2039
|
+
// Plays Moondog started are not the listener's own attention.
|
|
2040
|
+
const suggestions = this.#suggestionsForMatching()
|
|
2041
|
+
.filter((suggestion) => Date.parse(suggestion.requested_at) + SUGGESTION_WINDOW.afterMs >= Date.parse(from));
|
|
2042
|
+
const started = suggestions.length === 0 ? [] : markMoondogStartedPlays(this.#database.prepare(`SELECT
|
|
2043
|
+
e.listening_event_id, e.record_json AS event_json, t.record_json AS track_json FROM listening_events e
|
|
2044
|
+
JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision WHERE ${effective}`)
|
|
2045
|
+
.all(subject, from).map((row) => ({ id: row.listening_event_id, event: JSON.parse(row.event_json), track: JSON.parse(row.track_json) })),
|
|
2046
|
+
suggestions).rows.filter((row) => row.moondog_suggestion_id).map((row) => row.id);
|
|
2047
|
+
const own = `${effective} AND e.listening_event_id NOT IN (SELECT value FROM json_each(?))`;
|
|
2048
|
+
const plays = this.#database.prepare(`SELECT COUNT(*) AS plays FROM listening_events e WHERE ${own}`)
|
|
2049
|
+
.get(subject, from, JSON.stringify(started)).plays;
|
|
2050
|
+
const artists = this.#database.prepare(`SELECT json_extract(a.value, '$.name') AS name, COUNT(*) AS plays,
|
|
2051
|
+
MAX(e.occurred_at) AS last_played_at
|
|
2052
|
+
FROM listening_events e
|
|
2053
|
+
JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
|
|
2054
|
+
JOIN json_each(t.record_json, '$.artist_credits') a
|
|
2055
|
+
WHERE ${own} AND name IS NOT NULL
|
|
2056
|
+
GROUP BY name ORDER BY plays DESC, last_played_at DESC LIMIT ?`).all(subject, from, JSON.stringify(started), limit);
|
|
2057
|
+
return structuredClone({ from_date: localDate(start), through_date: localDate(today), plays,
|
|
2058
|
+
...(started.length ? { moondog_started_plays: started.length } : {}), top_artists: artists });
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// Which spans of time the stored plays cover, and where plays may be missing.
|
|
2062
|
+
listeningCoverage({ subjectId } = {}) {
|
|
2063
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
2064
|
+
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
2065
|
+
const subject = subjectId.toLowerCase();
|
|
2066
|
+
// Reading every play is the slow part, so it is kept until the listening data changes.
|
|
2067
|
+
const version = `${subject}:${this.listeningDataVersion()}`;
|
|
2068
|
+
if (this.#coverageSources?.version !== version) {
|
|
2069
|
+
this.#coverageSources = { version, sources: this.#database.prepare(`SELECT
|
|
2070
|
+
json_extract(record_json, '$.provenance.external_ref.entity_type') AS kind,
|
|
2071
|
+
COUNT(*) AS plays, MIN(occurred_at) AS first_played_at, MAX(occurred_at) AS last_played_at
|
|
2072
|
+
FROM listening_events WHERE subject_id = ? GROUP BY kind ORDER BY first_played_at`).all(subject) };
|
|
2073
|
+
}
|
|
2074
|
+
const { sources } = this.#coverageSources;
|
|
2075
|
+
// A history file imported later can fill a span that a sync missed.
|
|
2076
|
+
const filled = this.#database.prepare(`SELECT earliest_occurred_at AS earliest, latest_occurred_at AS latest
|
|
2077
|
+
FROM import_batches WHERE subject_id = ? AND earliest_occurred_at IS NOT NULL AND latest_occurred_at IS NOT NULL`).all(subject);
|
|
2078
|
+
const gaps = this.#database.prepare(`SELECT gap_start AS "from", gap_end AS "to"
|
|
2079
|
+
FROM listening_coverage_gaps WHERE subject_id = ? ORDER BY gap_start`).all(subject)
|
|
2080
|
+
.flatMap((gap) => filled.reduce((spans, { earliest, latest }) => spans.flatMap((span) =>
|
|
2081
|
+
latest <= span.from || earliest >= span.to ? [span] : [
|
|
2082
|
+
...(earliest > span.from ? [{ from: span.from, to: earliest }] : []),
|
|
2083
|
+
...(latest < span.to ? [{ from: latest, to: span.to }] : []),
|
|
2084
|
+
]), [{ ...gap }]));
|
|
2085
|
+
const checked = this.#database.prepare("SELECT updated_at FROM source_cursors WHERE source_key = 'spotify.recently_played'").get();
|
|
2086
|
+
return structuredClone({
|
|
2087
|
+
sources: sources.map(({ kind, ...source }) => ({ source: COVERAGE_SOURCE_LABELS[kind] ?? kind, ...source })),
|
|
2088
|
+
gaps: gaps.slice(-10),
|
|
2089
|
+
...(checked ? { recent_plays_checked_at: checked.updated_at } : {}),
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
listenerProfileStorage({ subjectId } = {}) {
|
|
2094
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
2095
|
+
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
2096
|
+
return new ListenerProfileStore(this.#database, subjectId.toLowerCase());
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
profileDataVersion() {
|
|
2100
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
2101
|
+
// data_version observes other connections; total_changes observes our own.
|
|
2102
|
+
// This is a connection-local cache key, never a persisted evidence digest.
|
|
2103
|
+
return `${this.#database.prepare("PRAGMA data_version").get().data_version}:${
|
|
2104
|
+
this.#database.prepare("SELECT total_changes() AS changes").get().changes}`;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// A version of the listening evidence that no suggestions ledger write changes, from this
|
|
2108
|
+
// connection or another, such as the other surface's. A new suggestion changes no projection
|
|
2109
|
+
// until its plays arrive, so caches of the projection, the Avoids, the status and the build
|
|
2110
|
+
// input key on this and are not rebuilt after every queue. This connection's own writes count
|
|
2111
|
+
// by total_changes. Another connection's commit moves data_version whatever it wrote, so then
|
|
2112
|
+
// the evidence tables themselves say whether the evidence changed.
|
|
2113
|
+
listeningDataVersion() {
|
|
2114
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
2115
|
+
const dataVersion = this.#database.prepare("PRAGMA data_version").get().data_version;
|
|
2116
|
+
const ownChanges = this.#database.prepare("SELECT total_changes() AS changes").get().changes - this.#ledgerChanges;
|
|
2117
|
+
if (this.#evidence?.dataVersion !== dataVersion || this.#evidence.ownChanges !== ownChanges) {
|
|
2118
|
+
this.#evidence = { dataVersion, ownChanges, fingerprint: this.#evidenceFingerprint() };
|
|
2119
|
+
}
|
|
2120
|
+
return this.#evidence.fingerprint;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
#evidenceFingerprint() {
|
|
2124
|
+
const digest = createHash("sha256");
|
|
2125
|
+
for (const table of APPENDED_EVIDENCE_TABLES) {
|
|
2126
|
+
digest.update(JSON.stringify(this.#database.prepare(`SELECT COUNT(*) AS rows, MAX(rowid) AS last FROM ${table}`).get()));
|
|
2127
|
+
}
|
|
2128
|
+
for (const table of UPDATED_EVIDENCE_TABLES) {
|
|
2129
|
+
for (const row of this.#database.prepare(`SELECT * FROM ${table} ORDER BY rowid`).iterate()) digest.update(JSON.stringify(row));
|
|
2130
|
+
}
|
|
2131
|
+
return digest.digest("hex");
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
profileEvidenceRevision({ subjectId } = {}) {
|
|
2135
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
2136
|
+
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
2137
|
+
const digest = createHash("sha256");
|
|
2138
|
+
// Build artifacts never feed back into source evidence. Hash locally; raw
|
|
2139
|
+
// events, source identifiers and private import metadata do not go to Pi.
|
|
2140
|
+
const queries = [
|
|
2141
|
+
"SELECT record_json FROM listening_events WHERE subject_id = ? ORDER BY listening_event_id",
|
|
2142
|
+
"SELECT record_json FROM spotify_profile_evidence WHERE subject_id = ? ORDER BY profile_evidence_id",
|
|
2143
|
+
"SELECT record_json FROM taste_events WHERE subject_id = ? ORDER BY taste_event_id",
|
|
2144
|
+
"SELECT * FROM import_batches WHERE subject_id = ? ORDER BY import_batch_id",
|
|
2145
|
+
"SELECT * FROM profile_import_batches WHERE subject_id = ? ORDER BY profile_import_id",
|
|
2146
|
+
`SELECT t.record_json FROM track_refs t WHERE EXISTS (SELECT 1 FROM listening_events e
|
|
2147
|
+
WHERE e.subject_id = ? AND e.track_ref_id = t.track_ref_id AND e.track_ref_revision = t.revision)
|
|
2148
|
+
ORDER BY t.track_ref_id, t.revision`,
|
|
2149
|
+
];
|
|
2150
|
+
for (const query of queries) {
|
|
2151
|
+
digest.update(query).update("\0");
|
|
2152
|
+
for (const row of this.#database.prepare(query).iterate(subjectId.toLowerCase())) {
|
|
2153
|
+
digest.update(JSON.stringify(row)).update("\0");
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
// Moondog's suggestions decide which plays count as the listener's own. A play can match a
|
|
2157
|
+
// suggestion made up to SUGGESTION_WINDOW.beforeMs after it, for clock skew; one made later
|
|
2158
|
+
// than that after the latest stored play matches nothing yet, so it leaves the reading current.
|
|
2159
|
+
const latest = this.#database.prepare("SELECT MAX(occurred_at) AS latest FROM listening_events WHERE subject_id = ?")
|
|
2160
|
+
.get(subjectId.toLowerCase()).latest;
|
|
2161
|
+
const latestMs = Date.parse(latest ?? "");
|
|
2162
|
+
const suggestions = Number.isFinite(latestMs) ? this.#database.prepare(`SELECT suggestion_id, provider_track_id, isrc, track_key,
|
|
2163
|
+
release_key, title, artist_credit, requested_at FROM moondog_suggestions WHERE requested_at <= ? ORDER BY suggestion_id`)
|
|
2164
|
+
.all(new Date(latestMs + SUGGESTION_WINDOW.beforeMs).toISOString()) : [];
|
|
2165
|
+
if (suggestions.length) digest.update("moondog_suggestions\0").update(JSON.stringify(suggestions));
|
|
2166
|
+
return digest.digest("hex");
|
|
1552
2167
|
}
|
|
1553
2168
|
|
|
1554
2169
|
rediscoveryCandidates({ subjectId, limit = 6 } = {}) {
|
|
@@ -1930,6 +2545,10 @@ export class ListeningHistoryStore {
|
|
|
1930
2545
|
WHERE NOT EXISTS (
|
|
1931
2546
|
SELECT 1 FROM listening_event_supersessions
|
|
1932
2547
|
WHERE superseded_event_id = listening_events.listening_event_id
|
|
2548
|
+
)
|
|
2549
|
+
AND NOT EXISTS (
|
|
2550
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
2551
|
+
WHERE m.mirror_event_id = listening_events.listening_event_id
|
|
1933
2552
|
)`,
|
|
1934
2553
|
)
|
|
1935
2554
|
.get().count,
|
|
@@ -2003,7 +2622,11 @@ async function initializeListeningHistoryDatabase(
|
|
|
2003
2622
|
) {
|
|
2004
2623
|
throw new Error("Listening history database schema is incompatible");
|
|
2005
2624
|
}
|
|
2625
|
+
const recordsGaps = database.prepare(
|
|
2626
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'listening_coverage_gaps'",
|
|
2627
|
+
).get();
|
|
2006
2628
|
database.exec(schema);
|
|
2629
|
+
if (!recordsGaps) recordEarlierRecentSyncGaps(database);
|
|
2007
2630
|
if (currentVersion < LISTENING_HISTORY_SCHEMA_VERSION) {
|
|
2008
2631
|
database.exec(
|
|
2009
2632
|
`PRAGMA user_version = ${LISTENING_HISTORY_SCHEMA_VERSION}`,
|