@audiofool/moondog 0.1.0 → 0.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +153 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +168 -36
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +278 -55
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +153 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +1908 -104
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +121 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +128 -0
- package/src/i18n/messages/en.mjs +129 -0
- package/src/i18n/messages/es.mjs +128 -0
- package/src/i18n/messages/fr.mjs +128 -0
- package/src/i18n/messages/it.mjs +128 -0
- package/src/i18n/messages/ja.mjs +128 -0
- package/src/i18n/messages/ko.mjs +128 -0
- package/src/i18n/messages/pt.mjs +128 -0
- package/src/i18n/messages/zh-Hant.mjs +128 -0
- package/src/i18n/messages/zh.mjs +128 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +966 -0
- package/src/i18n/screen/ja.mjs +966 -0
- package/src/i18n/screen/pt.mjs +966 -0
- package/src/i18n/screen/zh.mjs +966 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +123 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +321 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +8 -4
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +407 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +469 -41
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-profile-build.mjs +378 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +172 -0
- package/src/profile/listening-history-store.mjs +424 -8
- package/src/profile/listening-profile-projection.mjs +242 -76
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +84 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2204 -605
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +160 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -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,9 @@ import {
|
|
|
9
9
|
createListenerCorrectionRetraction,
|
|
10
10
|
} from "./listener-corrections.mjs";
|
|
11
11
|
import { projectListeningProfile } from "./listening-profile-projection.mjs";
|
|
12
|
+
import { ListenerProfileStore, listenerProfileSchema } from "./listener-profile-store.mjs";
|
|
12
13
|
|
|
13
|
-
export const LISTENING_HISTORY_SCHEMA_VERSION =
|
|
14
|
+
export const LISTENING_HISTORY_SCHEMA_VERSION = 7;
|
|
14
15
|
|
|
15
16
|
export function resolveListeningHistoryPath(environment = process.env) {
|
|
16
17
|
return path.join(resolveMoondogStateDirectory(environment), "listening-history.sqlite");
|
|
@@ -48,6 +49,8 @@ const schema = `
|
|
|
48
49
|
|
|
49
50
|
CREATE INDEX IF NOT EXISTS listening_events_subject_time
|
|
50
51
|
ON listening_events(subject_id, occurred_at DESC);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS listening_events_subject_track
|
|
53
|
+
ON listening_events(subject_id, track_ref_id, track_ref_revision);
|
|
51
54
|
|
|
52
55
|
CREATE TABLE IF NOT EXISTS source_cursors (
|
|
53
56
|
source_key TEXT PRIMARY KEY NOT NULL,
|
|
@@ -55,6 +58,18 @@ const schema = `
|
|
|
55
58
|
updated_at TEXT NOT NULL
|
|
56
59
|
) STRICT;
|
|
57
60
|
|
|
61
|
+
-- Spotify keeps only a listener's last 50 plays. When a sync finds the window full,
|
|
62
|
+
-- plays between the last stored one and the oldest returned one may be gone for good.
|
|
63
|
+
CREATE TABLE IF NOT EXISTS listening_coverage_gaps (
|
|
64
|
+
subject_id TEXT NOT NULL,
|
|
65
|
+
source_key TEXT NOT NULL,
|
|
66
|
+
gap_start TEXT NOT NULL,
|
|
67
|
+
gap_end TEXT NOT NULL,
|
|
68
|
+
detected_at TEXT NOT NULL,
|
|
69
|
+
PRIMARY KEY (subject_id, gap_start, gap_end),
|
|
70
|
+
CHECK (gap_start < gap_end)
|
|
71
|
+
) STRICT;
|
|
72
|
+
|
|
58
73
|
CREATE TABLE IF NOT EXISTS import_batches (
|
|
59
74
|
import_batch_id TEXT PRIMARY KEY NOT NULL,
|
|
60
75
|
subject_id TEXT NOT NULL,
|
|
@@ -98,6 +113,18 @@ const schema = `
|
|
|
98
113
|
REFERENCES import_batches(import_batch_id)
|
|
99
114
|
) STRICT;
|
|
100
115
|
|
|
116
|
+
-- A Last.fm scrobble that repeats a play another service recorded stays stored,
|
|
117
|
+
-- linked to that play, and is left out of every count.
|
|
118
|
+
CREATE TABLE IF NOT EXISTS listening_event_mirrors (
|
|
119
|
+
mirror_event_id TEXT PRIMARY KEY NOT NULL,
|
|
120
|
+
original_event_id TEXT NOT NULL UNIQUE,
|
|
121
|
+
created_at TEXT NOT NULL,
|
|
122
|
+
FOREIGN KEY (mirror_event_id)
|
|
123
|
+
REFERENCES listening_events(listening_event_id),
|
|
124
|
+
FOREIGN KEY (original_event_id)
|
|
125
|
+
REFERENCES listening_events(listening_event_id)
|
|
126
|
+
) STRICT;
|
|
127
|
+
|
|
101
128
|
CREATE TABLE IF NOT EXISTS profile_import_batches (
|
|
102
129
|
profile_import_id TEXT PRIMARY KEY NOT NULL,
|
|
103
130
|
subject_id TEXT NOT NULL,
|
|
@@ -158,6 +185,8 @@ const schema = `
|
|
|
158
185
|
CREATE UNIQUE INDEX IF NOT EXISTS taste_events_one_retraction
|
|
159
186
|
ON taste_events(retracts_taste_event_id)
|
|
160
187
|
WHERE retracts_taste_event_id IS NOT NULL;
|
|
188
|
+
|
|
189
|
+
${listenerProfileSchema}
|
|
161
190
|
`;
|
|
162
191
|
|
|
163
192
|
function listenerCorrectionError(code, message) {
|
|
@@ -207,6 +236,7 @@ function normalizedBundle(value, maximumRecords = 50) {
|
|
|
207
236
|
capturedAt: new Date(value.captured_at).toISOString(),
|
|
208
237
|
trackRefs: value.track_refs,
|
|
209
238
|
listeningEvents: value.listening_events,
|
|
239
|
+
windowFull: value.window_full === true,
|
|
210
240
|
};
|
|
211
241
|
}
|
|
212
242
|
|
|
@@ -321,7 +351,8 @@ function normalizedProfileInput(value, batch) {
|
|
|
321
351
|
!isPlainObject(record.provenance) ||
|
|
322
352
|
record.provenance.source_kind !== "import" ||
|
|
323
353
|
!(record.provenance.source_system === "spotify_account_data" ||
|
|
324
|
-
(
|
|
354
|
+
(record.provenance.source_system === "spotify_web_api" && profileImport.source_key === "spotify.web_api_library") ||
|
|
355
|
+
(["youtube_music", "qq_music", "netease", "lastfm"].includes(record.provenance.source_system) &&
|
|
325
356
|
profileImport.source_key === `${record.provenance.source_system}.music_import`)) ||
|
|
326
357
|
!profileImport.member_names.includes(record.provenance.source_member) ||
|
|
327
358
|
record.provenance.profile_import_id?.toLowerCase() !== profileImportId ||
|
|
@@ -535,9 +566,45 @@ function ingestionStatements(database) {
|
|
|
535
566
|
cursor_after_ms = MAX(source_cursors.cursor_after_ms, excluded.cursor_after_ms),
|
|
536
567
|
updated_at = excluded.updated_at`,
|
|
537
568
|
),
|
|
569
|
+
hasEvent: database.prepare(
|
|
570
|
+
"SELECT 1 FROM listening_events WHERE deduplication_key = ?",
|
|
571
|
+
),
|
|
572
|
+
touchCursor: database.prepare(
|
|
573
|
+
"UPDATE source_cursors SET updated_at = MAX(updated_at, ?) WHERE source_key = ?",
|
|
574
|
+
),
|
|
538
575
|
};
|
|
539
576
|
}
|
|
540
577
|
|
|
578
|
+
// Records the span between the newest stored play and `before` as possibly missed.
|
|
579
|
+
function recordCoverageGap(database, { subjectId, sourceKey, before, detectedAt }) {
|
|
580
|
+
const after = database.prepare(
|
|
581
|
+
"SELECT MAX(occurred_at) AS occurred_at FROM listening_events WHERE subject_id = ? AND occurred_at < ?",
|
|
582
|
+
).get(subjectId, before)?.occurred_at;
|
|
583
|
+
// A history that starts with this read has nothing to have missed.
|
|
584
|
+
if (!after) return;
|
|
585
|
+
database.prepare(
|
|
586
|
+
`INSERT OR IGNORE INTO listening_coverage_gaps (subject_id, source_key, gap_start, gap_end, detected_at)
|
|
587
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
588
|
+
).run(subjectId, sourceKey, after, before, detectedAt);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Recent syncs from before gaps were recorded: a run that stored a full window of 50
|
|
592
|
+
// new plays at once may have missed plays before its oldest one.
|
|
593
|
+
function recordEarlierRecentSyncGaps(database) {
|
|
594
|
+
const runs = database.prepare(
|
|
595
|
+
`SELECT subject_id, json_extract(record_json, '$.ingested_at') AS ingested_at,
|
|
596
|
+
MIN(occurred_at) AS earliest, COUNT(*) AS plays
|
|
597
|
+
FROM listening_events
|
|
598
|
+
WHERE json_extract(record_json, '$.provenance.external_ref.entity_type') = 'spotify.play_history'
|
|
599
|
+
GROUP BY subject_id, ingested_at HAVING plays >= 50`,
|
|
600
|
+
).all();
|
|
601
|
+
for (const run of runs) {
|
|
602
|
+
recordCoverageGap(database, {
|
|
603
|
+
subjectId: run.subject_id, sourceKey: "spotify.recently_played", before: run.earliest, detectedAt: run.ingested_at,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
541
608
|
function insertBundleRecords(bundle, statements) {
|
|
542
609
|
let insertedTracks = 0;
|
|
543
610
|
let insertedEvents = 0;
|
|
@@ -569,6 +636,165 @@ function insertBundleRecords(bundle, statements) {
|
|
|
569
636
|
return { insertedTracks, insertedEvents };
|
|
570
637
|
}
|
|
571
638
|
|
|
639
|
+
// Where stored plays came from, in words the agent can repeat. Others keep their kind.
|
|
640
|
+
const COVERAGE_SOURCE_LABELS = {
|
|
641
|
+
"spotify.play_history": "Spotify recent plays, read while Moondog runs",
|
|
642
|
+
"spotify.extended_history.play": "Spotify Extended Streaming History file",
|
|
643
|
+
"spotify.account_data.play_history": "Spotify Account Data file",
|
|
644
|
+
"listenbrainz.listen": "ListenBrainz export file",
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const SCROBBLE_SOURCE_KEY = "lastfm.music_import";
|
|
648
|
+
// Scrobbles record when a song started. Spotify records when a play ended, so its start is
|
|
649
|
+
// the end minus the listening time; Account Data times are rounded to the minute.
|
|
650
|
+
const MIRROR_TOLERANCE_MS = 150_000;
|
|
651
|
+
const DAY_MS = 86_400_000;
|
|
652
|
+
const foldLabel = (value) => String(value ?? "").normalize("NFKC").toLocaleLowerCase("und")
|
|
653
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
654
|
+
const lexicalCompare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Many Last.fm scrobbles repeat plays that Spotify or ListenBrainz recorded too. Each scrobble
|
|
658
|
+
* that matches another service's play (same artist and title, starting within a few minutes)
|
|
659
|
+
* is linked to it once, whichever was imported first, so the play counts once.
|
|
660
|
+
*/
|
|
661
|
+
function reconcileScrobbleMirrors(database, subjectId, earliest, latest, createdAt) {
|
|
662
|
+
if (!subjectId || !earliest || !latest) return 0;
|
|
663
|
+
const scrobbled = database.prepare(
|
|
664
|
+
"SELECT 1 FROM import_batches WHERE subject_id = ? AND source_key = ? LIMIT 1",
|
|
665
|
+
).get(subjectId, SCROBBLE_SOURCE_KEY);
|
|
666
|
+
if (!scrobbled) return 0;
|
|
667
|
+
const rows = database.prepare(
|
|
668
|
+
`SELECT e.listening_event_id AS id, e.occurred_at AS at,
|
|
669
|
+
e.record_json AS event_json, t.record_json AS track_json
|
|
670
|
+
FROM listening_events e
|
|
671
|
+
JOIN track_refs t
|
|
672
|
+
ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
|
|
673
|
+
WHERE e.subject_id = ? AND e.occurred_at BETWEEN ? AND ?
|
|
674
|
+
AND NOT EXISTS (
|
|
675
|
+
SELECT 1 FROM listening_event_supersessions s
|
|
676
|
+
WHERE s.superseded_event_id = e.listening_event_id
|
|
677
|
+
)
|
|
678
|
+
AND NOT EXISTS (
|
|
679
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
680
|
+
WHERE m.mirror_event_id = e.listening_event_id
|
|
681
|
+
OR m.original_event_id = e.listening_event_id
|
|
682
|
+
)`,
|
|
683
|
+
).all(
|
|
684
|
+
subjectId,
|
|
685
|
+
new Date(Date.parse(earliest) - DAY_MS).toISOString(),
|
|
686
|
+
new Date(Date.parse(latest) + DAY_MS).toISOString(),
|
|
687
|
+
);
|
|
688
|
+
const scrobbles = new Map();
|
|
689
|
+
const plays = [];
|
|
690
|
+
for (const row of rows) {
|
|
691
|
+
const event = JSON.parse(row.event_json);
|
|
692
|
+
const track = JSON.parse(row.track_json);
|
|
693
|
+
const artist = foldLabel(track.artist_credits?.[0]?.name);
|
|
694
|
+
const title = foldLabel(track.title);
|
|
695
|
+
if (!artist || !title) continue;
|
|
696
|
+
const key = `${artist}\u0000${title}`;
|
|
697
|
+
const at = Date.parse(row.at);
|
|
698
|
+
const system = String(event.provenance?.external_ref?.system ?? "");
|
|
699
|
+
if (system === "lastfm") {
|
|
700
|
+
if (!scrobbles.has(key)) scrobbles.set(key, []);
|
|
701
|
+
scrobbles.get(key).push({ id: row.id, start: at });
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const length = Number.isSafeInteger(event.played_ms) ? event.played_ms
|
|
705
|
+
: Number.isSafeInteger(track.duration_ms) ? track.duration_ms : null;
|
|
706
|
+
// A Spotify play of unknown length can't be placed precisely enough to match.
|
|
707
|
+
if (system.startsWith("spotify") && length === null) continue;
|
|
708
|
+
plays.push({ id: row.id, key, start: system.startsWith("spotify") ? at - length : at });
|
|
709
|
+
}
|
|
710
|
+
const pairs = [];
|
|
711
|
+
for (const list of scrobbles.values()) list.sort((left, right) => left.start - right.start);
|
|
712
|
+
for (const play of plays) {
|
|
713
|
+
const list = scrobbles.get(play.key);
|
|
714
|
+
if (!list) continue;
|
|
715
|
+
let low = 0;
|
|
716
|
+
let high = list.length;
|
|
717
|
+
while (low < high) {
|
|
718
|
+
const middle = (low + high) >> 1;
|
|
719
|
+
if (list[middle].start < play.start - MIRROR_TOLERANCE_MS) low = middle + 1;
|
|
720
|
+
else high = middle;
|
|
721
|
+
}
|
|
722
|
+
for (let index = low; index < list.length && list[index].start <= play.start + MIRROR_TOLERANCE_MS; index += 1) {
|
|
723
|
+
pairs.push({ gap: Math.abs(list[index].start - play.start), play: play.id, scrobble: list[index].id });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// Closest pairs first, so each play and each scrobble is used once.
|
|
727
|
+
pairs.sort((left, right) => left.gap - right.gap ||
|
|
728
|
+
lexicalCompare(left.scrobble, right.scrobble) || lexicalCompare(left.play, right.play));
|
|
729
|
+
const insert = database.prepare(
|
|
730
|
+
`INSERT INTO listening_event_mirrors (mirror_event_id, original_event_id, created_at)
|
|
731
|
+
VALUES (?, ?, ?)`,
|
|
732
|
+
);
|
|
733
|
+
const usedPlays = new Set();
|
|
734
|
+
const usedScrobbles = new Set();
|
|
735
|
+
let linked = 0;
|
|
736
|
+
for (const pair of pairs) {
|
|
737
|
+
if (usedPlays.has(pair.play) || usedScrobbles.has(pair.scrobble)) continue;
|
|
738
|
+
usedPlays.add(pair.play);
|
|
739
|
+
usedScrobbles.add(pair.scrobble);
|
|
740
|
+
linked += insert.run(pair.scrobble, pair.play, createdAt).changes;
|
|
741
|
+
}
|
|
742
|
+
return linked;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function preserveExtendedHistoryMatches(bundle, retainedMatch) {
|
|
746
|
+
const candidates = bundle.reconciliationCandidates;
|
|
747
|
+
if (bundle.importBatch.sourceFormat !== "spotify_extended_streaming_history_music_v1") return candidates;
|
|
748
|
+
const retained = new Map(candidates.map((candidate) => [
|
|
749
|
+
candidate.supersedingEventId, retainedMatch(candidate.supersedingEventId),
|
|
750
|
+
]));
|
|
751
|
+
if (!candidates.some((candidate) => retained.get(candidate.supersedingEventId) &&
|
|
752
|
+
retained.get(candidate.supersedingEventId) !== candidate.supersededEventId)) return candidates;
|
|
753
|
+
|
|
754
|
+
// Account Data cannot distinguish equal-duration plays of the same title and
|
|
755
|
+
// artist within one minute. Added video plays can reorder those ordinals.
|
|
756
|
+
// A narrower export may omit an established counterpart's ordinal entirely.
|
|
757
|
+
// Keep those pairs too; only new plays use this archive's remaining candidates.
|
|
758
|
+
// The store's conflict checks still reject a new play claiming an absent play's
|
|
759
|
+
// retained counterpart, so an incomplete export cannot invent another match.
|
|
760
|
+
const events = new Map(bundle.listeningEvents.map((event) => [event.listening_event_id, event]));
|
|
761
|
+
const tracks = new Map(bundle.trackRefs.map((track) => [track.track_ref_id, track]));
|
|
762
|
+
const groups = new Map();
|
|
763
|
+
for (const candidate of candidates) {
|
|
764
|
+
const event = events.get(candidate.supersedingEventId);
|
|
765
|
+
const track = tracks.get(event.track_ref_id);
|
|
766
|
+
const key = JSON.stringify([
|
|
767
|
+
event.occurred_at.slice(0, 16), event.played_ms,
|
|
768
|
+
track.title.normalize("NFKC").toLowerCase(),
|
|
769
|
+
track.artist_credits[0].name.normalize("NFKC").toLowerCase(),
|
|
770
|
+
]);
|
|
771
|
+
const group = groups.get(key) ?? [];
|
|
772
|
+
group.push(candidate);
|
|
773
|
+
groups.set(key, group);
|
|
774
|
+
}
|
|
775
|
+
const matches = new Map();
|
|
776
|
+
for (const group of groups.values()) {
|
|
777
|
+
const available = new Set(group.map((candidate) => candidate.supersededEventId));
|
|
778
|
+
for (const candidate of group) {
|
|
779
|
+
const previous = retained.get(candidate.supersedingEventId);
|
|
780
|
+
if (!previous) continue;
|
|
781
|
+
available.delete(previous);
|
|
782
|
+
matches.set(candidate.supersedingEventId, previous);
|
|
783
|
+
}
|
|
784
|
+
const remaining = available.values();
|
|
785
|
+
for (const candidate of group) {
|
|
786
|
+
if (matches.has(candidate.supersedingEventId)) continue;
|
|
787
|
+
const selected = available.has(candidate.supersededEventId)
|
|
788
|
+
? candidate.supersededEventId : remaining.next().value;
|
|
789
|
+
matches.set(candidate.supersedingEventId, selected);
|
|
790
|
+
available.delete(selected);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return candidates.map((candidate) => ({
|
|
794
|
+
...candidate, supersededEventId: matches.get(candidate.supersedingEventId),
|
|
795
|
+
}));
|
|
796
|
+
}
|
|
797
|
+
|
|
572
798
|
export class ListeningHistoryStore {
|
|
573
799
|
#database;
|
|
574
800
|
#closed = false;
|
|
@@ -670,6 +896,10 @@ export class ListeningHistoryStore {
|
|
|
670
896
|
AND NOT EXISTS (
|
|
671
897
|
SELECT 1 FROM listening_event_supersessions s
|
|
672
898
|
WHERE s.superseded_event_id = e.listening_event_id
|
|
899
|
+
)
|
|
900
|
+
AND NOT EXISTS (
|
|
901
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
902
|
+
WHERE m.mirror_event_id = e.listening_event_id
|
|
673
903
|
)`,
|
|
674
904
|
)
|
|
675
905
|
.get(normalized).count;
|
|
@@ -1024,14 +1254,24 @@ export class ListeningHistoryStore {
|
|
|
1024
1254
|
this.#database.exec("BEGIN IMMEDIATE");
|
|
1025
1255
|
let insertedTracks;
|
|
1026
1256
|
let insertedEvents;
|
|
1257
|
+
let mirroredEvents = 0;
|
|
1027
1258
|
try {
|
|
1028
1259
|
if (subjectId !== undefined) {
|
|
1029
1260
|
this.localSubjectId({ preferredSubjectId: subjectId });
|
|
1030
1261
|
}
|
|
1262
|
+
const times = bundle.listeningEvents.map((record) => record.occurred_at).sort();
|
|
1263
|
+
// A read that overlaps a stored play joins up with it.
|
|
1264
|
+
const overlaps = bundle.listeningEvents.some((record) => statements.hasEvent.get(record.deduplication_key));
|
|
1265
|
+
if (bundle.windowFull && subjectId !== undefined && !overlaps) {
|
|
1266
|
+
recordCoverageGap(this.#database, { subjectId, sourceKey: bundle.sourceKey, before: times[0], detectedAt: bundle.capturedAt });
|
|
1267
|
+
}
|
|
1031
1268
|
({ insertedTracks, insertedEvents } = insertBundleRecords(
|
|
1032
1269
|
bundle,
|
|
1033
1270
|
statements,
|
|
1034
1271
|
));
|
|
1272
|
+
// A read that finds nothing new still moves the source's checked time.
|
|
1273
|
+
statements.touchCursor.run(bundle.capturedAt, bundle.sourceKey);
|
|
1274
|
+
mirroredEvents = reconcileScrobbleMirrors(this.#database, subjectId, times[0], times.at(-1), bundle.capturedAt);
|
|
1035
1275
|
this.#database.exec("COMMIT");
|
|
1036
1276
|
} catch (error) {
|
|
1037
1277
|
this.#database.exec("ROLLBACK");
|
|
@@ -1045,6 +1285,7 @@ export class ListeningHistoryStore {
|
|
|
1045
1285
|
inserted_events: insertedEvents,
|
|
1046
1286
|
duplicate_events: bundle.listeningEvents.length - insertedEvents,
|
|
1047
1287
|
inserted_track_refs: insertedTracks,
|
|
1288
|
+
...(mirroredEvents ? { mirrored_events: mirroredEvents } : {}),
|
|
1048
1289
|
cursor_after_ms: this.sourceCursor(bundle.sourceKey),
|
|
1049
1290
|
profile_effects: insertedEvents > 0 ? "updated" : "unchanged",
|
|
1050
1291
|
};
|
|
@@ -1055,7 +1296,7 @@ export class ListeningHistoryStore {
|
|
|
1055
1296
|
const bundle = normalizedImportBundle(value);
|
|
1056
1297
|
const batch = bundle.importBatch;
|
|
1057
1298
|
const existingBatch = this.#database.prepare(
|
|
1058
|
-
`SELECT
|
|
1299
|
+
`SELECT * FROM import_batches
|
|
1059
1300
|
WHERE import_batch_id = ?`,
|
|
1060
1301
|
);
|
|
1061
1302
|
const insertBatch = this.#database.prepare(
|
|
@@ -1141,12 +1382,14 @@ export class ListeningHistoryStore {
|
|
|
1141
1382
|
let insertedTracks = 0;
|
|
1142
1383
|
let insertedEvents = 0;
|
|
1143
1384
|
let supersededEvents = 0;
|
|
1385
|
+
let mirroredEvents = 0;
|
|
1144
1386
|
let insertedProfileEvidence = 0;
|
|
1145
1387
|
let alreadyImported = false;
|
|
1146
1388
|
let profileAlreadyImported = bundle.profileImport === undefined;
|
|
1147
1389
|
try {
|
|
1148
1390
|
this.localSubjectId({ preferredSubjectId: batch.subjectId });
|
|
1149
|
-
|
|
1391
|
+
const previousBatch = existingBatch.get(batch.importBatchId);
|
|
1392
|
+
alreadyImported = Boolean(previousBatch);
|
|
1150
1393
|
if (!alreadyImported) {
|
|
1151
1394
|
({ insertedTracks, insertedEvents } = insertBundleRecords(
|
|
1152
1395
|
bundle,
|
|
@@ -1169,8 +1412,38 @@ export class ListeningHistoryStore {
|
|
|
1169
1412
|
batch.inputRecords - insertedEvents,
|
|
1170
1413
|
insertedTracks,
|
|
1171
1414
|
);
|
|
1415
|
+
} else {
|
|
1416
|
+
// Earlier readers skipped video members, even when they contained music.
|
|
1417
|
+
// Extend that same archive's receipt without replacing its existing rows.
|
|
1418
|
+
const previousMembers = JSON.parse(previousBatch.member_names_json);
|
|
1419
|
+
const addedMembers = batch.memberNames.filter((name) => !previousMembers.includes(name));
|
|
1420
|
+
const expandsExtendedArchive = batch.sourceFormat === "spotify_extended_streaming_history_music_v1" &&
|
|
1421
|
+
previousBatch.subject_id === batch.subjectId && previousBatch.source_key === bundle.sourceKey &&
|
|
1422
|
+
previousBatch.source_format === batch.sourceFormat && previousBatch.data_scope === batch.dataScope &&
|
|
1423
|
+
previousBatch.archive_sha256 === batch.archiveSha256 && previousBatch.archive_size_bytes === batch.archiveSizeBytes &&
|
|
1424
|
+
previousMembers.every((name) => batch.memberNames.includes(name)) && addedMembers.length > 0 &&
|
|
1425
|
+
addedMembers.every((name) => /^Streaming_History_Video_\d{4}(?:_\d+)?\.json$/u.test(name));
|
|
1426
|
+
if (expandsExtendedArchive) {
|
|
1427
|
+
const incomingIds = new Set(bundle.listeningEvents.map((event) => event.listening_event_id));
|
|
1428
|
+
const previousEvents = this.#database.prepare(`SELECT listening_event_id FROM listening_events
|
|
1429
|
+
WHERE subject_id = ? AND json_extract(record_json, '$.provenance.import_batch_id') = ?`)
|
|
1430
|
+
.all(batch.subjectId, batch.importBatchId);
|
|
1431
|
+
if (batch.inputRecords < previousBatch.input_records || previousEvents.some((event) => !incomingIds.has(event.listening_event_id))) {
|
|
1432
|
+
throw new Error("Extended archive expansion must preserve every previously imported event");
|
|
1433
|
+
}
|
|
1434
|
+
({ insertedTracks, insertedEvents } = insertBundleRecords(bundle, statements));
|
|
1435
|
+
this.#database.prepare(`UPDATE import_batches SET member_names_json = ?, input_records = ?,
|
|
1436
|
+
earliest_occurred_at = ?, latest_occurred_at = ?,
|
|
1437
|
+
inserted_events = inserted_events + ?, duplicate_events = ? - inserted_events - ?,
|
|
1438
|
+
inserted_track_refs = inserted_track_refs + ? WHERE import_batch_id = ?`)
|
|
1439
|
+
.run(JSON.stringify(batch.memberNames), batch.inputRecords, batch.earliestOccurredAt, batch.latestOccurredAt,
|
|
1440
|
+
insertedEvents, batch.inputRecords, insertedEvents, insertedTracks, batch.importBatchId);
|
|
1441
|
+
}
|
|
1172
1442
|
}
|
|
1173
|
-
|
|
1443
|
+
const reconciliationCandidates = preserveExtendedHistoryMatches(bundle, (eventId) =>
|
|
1444
|
+
selectCandidateBySuperseding.get(eventId)?.superseded_event_id ??
|
|
1445
|
+
selectBySuperseding.get(eventId)?.superseded_event_id);
|
|
1446
|
+
for (const candidate of reconciliationCandidates) {
|
|
1174
1447
|
const existingBySuperseded = selectCandidateBySuperseded.get(
|
|
1175
1448
|
candidate.supersededEventId,
|
|
1176
1449
|
)?.superseding_event_id;
|
|
@@ -1292,6 +1565,9 @@ export class ListeningHistoryStore {
|
|
|
1292
1565
|
);
|
|
1293
1566
|
}
|
|
1294
1567
|
}
|
|
1568
|
+
mirroredEvents = reconcileScrobbleMirrors(
|
|
1569
|
+
this.#database, batch.subjectId, batch.earliestOccurredAt, batch.latestOccurredAt, batch.importedAt,
|
|
1570
|
+
);
|
|
1295
1571
|
this.#database.exec("COMMIT");
|
|
1296
1572
|
} catch (error) {
|
|
1297
1573
|
this.#database.exec("ROLLBACK");
|
|
@@ -1310,7 +1586,8 @@ export class ListeningHistoryStore {
|
|
|
1310
1586
|
duplicate_events: batch.inputRecords - insertedEvents,
|
|
1311
1587
|
inserted_track_refs: insertedTracks,
|
|
1312
1588
|
superseded_events: supersededEvents,
|
|
1313
|
-
|
|
1589
|
+
...(mirroredEvents ? { mirrored_events: mirroredEvents } : {}),
|
|
1590
|
+
effective_event_delta: insertedEvents - supersededEvents - mirroredEvents,
|
|
1314
1591
|
cursor_after_ms: this.sourceCursor(bundle.sourceKey),
|
|
1315
1592
|
earliest_occurred_at: batch.earliestOccurredAt,
|
|
1316
1593
|
latest_occurred_at: batch.latestOccurredAt,
|
|
@@ -1333,7 +1610,7 @@ export class ListeningHistoryStore {
|
|
|
1333
1610
|
}
|
|
1334
1611
|
: {}),
|
|
1335
1612
|
profile_effects:
|
|
1336
|
-
insertedEvents
|
|
1613
|
+
insertedEvents > 0 || supersededEvents > 0 || insertedProfileEvidence > 0
|
|
1337
1614
|
? "updated"
|
|
1338
1615
|
: "unchanged",
|
|
1339
1616
|
};
|
|
@@ -1355,6 +1632,10 @@ export class ListeningHistoryStore {
|
|
|
1355
1632
|
SELECT 1 FROM listening_event_supersessions
|
|
1356
1633
|
WHERE superseded_event_id = listening_events.listening_event_id
|
|
1357
1634
|
)
|
|
1635
|
+
AND NOT EXISTS (
|
|
1636
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
1637
|
+
WHERE m.mirror_event_id = listening_events.listening_event_id
|
|
1638
|
+
)
|
|
1358
1639
|
ORDER BY occurred_at DESC, listening_event_id DESC
|
|
1359
1640
|
LIMIT ?`,
|
|
1360
1641
|
)
|
|
@@ -1454,6 +1735,10 @@ export class ListeningHistoryStore {
|
|
|
1454
1735
|
SELECT 1 FROM listening_event_supersessions s
|
|
1455
1736
|
WHERE s.superseded_event_id = e.listening_event_id
|
|
1456
1737
|
)
|
|
1738
|
+
AND NOT EXISTS (
|
|
1739
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
1740
|
+
WHERE m.mirror_event_id = e.listening_event_id
|
|
1741
|
+
)
|
|
1457
1742
|
ORDER BY e.occurred_at, e.listening_event_id`,
|
|
1458
1743
|
)
|
|
1459
1744
|
.all(normalizedSubjectId)
|
|
@@ -1517,6 +1802,10 @@ export class ListeningHistoryStore {
|
|
|
1517
1802
|
SELECT 1 FROM listening_event_supersessions
|
|
1518
1803
|
WHERE superseded_event_id = listening_events.listening_event_id
|
|
1519
1804
|
)
|
|
1805
|
+
AND NOT EXISTS (
|
|
1806
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
1807
|
+
WHERE m.mirror_event_id = listening_events.listening_event_id
|
|
1808
|
+
)
|
|
1520
1809
|
GROUP BY track_ref_id
|
|
1521
1810
|
ORDER BY play_count DESC, track_ref_id`,
|
|
1522
1811
|
)
|
|
@@ -1545,12 +1834,131 @@ export class ListeningHistoryStore {
|
|
|
1545
1834
|
return structuredClone({ ...value.summary, lyric_exclusions: value.lyricExclusions });
|
|
1546
1835
|
}
|
|
1547
1836
|
|
|
1837
|
+
activeAvoidances({ subjectId } = {}) {
|
|
1838
|
+
// Host-side filtering must include corrections outside the bounded display.
|
|
1839
|
+
return structuredClone(
|
|
1840
|
+
this.#profileProjection({ subjectId, maxItems: 1 }).lyricExclusions,
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1548
1844
|
profileSummary({ subjectId, maxItems = 10 } = {}) {
|
|
1549
1845
|
return structuredClone(
|
|
1550
1846
|
this.#profileProjection({ subjectId, maxItems }).summary,
|
|
1551
1847
|
);
|
|
1552
1848
|
}
|
|
1553
1849
|
|
|
1850
|
+
profileCatalog({ subjectId } = {}) {
|
|
1851
|
+
const projected = this.#profileProjection({ subjectId, maxItems: 1 });
|
|
1852
|
+
const normalizedSubjectId = subjectId.toLowerCase();
|
|
1853
|
+
const imports = this.#database.prepare(`SELECT source_format AS format, data_scope AS scope,
|
|
1854
|
+
SUM(input_records) AS input_records, MIN(earliest_occurred_at) AS earliest,
|
|
1855
|
+
MAX(latest_occurred_at) AS latest
|
|
1856
|
+
FROM import_batches WHERE subject_id = ? GROUP BY source_format, data_scope ORDER BY source_format`)
|
|
1857
|
+
.all(normalizedSubjectId);
|
|
1858
|
+
const stored = this.#database.prepare("SELECT COUNT(*) AS count FROM listening_events WHERE subject_id = ?")
|
|
1859
|
+
.get(normalizedSubjectId).count;
|
|
1860
|
+
return structuredClone({
|
|
1861
|
+
coverage: { ...projected.summary.coverage,
|
|
1862
|
+
stored_listening_events: stored,
|
|
1863
|
+
superseded_listening_events: stored - projected.summary.coverage.effective_listening_events },
|
|
1864
|
+
sources: imports,
|
|
1865
|
+
context: {
|
|
1866
|
+
reference_date: projected.summary.listening_behavior.context.reference_date,
|
|
1867
|
+
recent_window_days: projected.summary.listening_behavior.context.recent_window_days,
|
|
1868
|
+
incognito_events_excluded: projected.summary.listening_behavior.context.incognito_events_excluded,
|
|
1869
|
+
release_minimum_distinct_tracks: projected.summary.listening_behavior.context.release_minimum_distinct_tracks,
|
|
1870
|
+
},
|
|
1871
|
+
sections: projected.catalog,
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// What the listener played lately: stored plays in the last `days` up to `now`, by artist.
|
|
1876
|
+
// It reads the clock, not the newest play, so a history that stopped shows as quiet.
|
|
1877
|
+
recentAttention({ subjectId, days = 28, now = Date.now(), limit = 10 } = {}) {
|
|
1878
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1879
|
+
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
1880
|
+
const from = new Date(now - days * DAY_MS).toISOString();
|
|
1881
|
+
const effective = `e.subject_id = ? AND e.occurred_at >= ?
|
|
1882
|
+
AND NOT EXISTS (SELECT 1 FROM listening_event_supersessions s WHERE s.superseded_event_id = e.listening_event_id)
|
|
1883
|
+
AND NOT EXISTS (SELECT 1 FROM listening_event_mirrors m WHERE m.mirror_event_id = e.listening_event_id)`;
|
|
1884
|
+
const subject = subjectId.toLowerCase();
|
|
1885
|
+
const plays = this.#database.prepare(`SELECT COUNT(*) AS plays FROM listening_events e WHERE ${effective}`)
|
|
1886
|
+
.get(subject, from).plays;
|
|
1887
|
+
const artists = this.#database.prepare(`SELECT json_extract(a.value, '$.name') AS name, COUNT(*) AS plays,
|
|
1888
|
+
MAX(e.occurred_at) AS last_played_at
|
|
1889
|
+
FROM listening_events e
|
|
1890
|
+
JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
|
|
1891
|
+
JOIN json_each(t.record_json, '$.artist_credits') a
|
|
1892
|
+
WHERE ${effective} AND name IS NOT NULL
|
|
1893
|
+
GROUP BY name ORDER BY plays DESC, last_played_at DESC LIMIT ?`).all(subject, from, limit);
|
|
1894
|
+
return structuredClone({ from, to: new Date(now).toISOString(), plays, top_artists: artists });
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// Which spans of time the stored plays cover, and where plays may be missing.
|
|
1898
|
+
listeningCoverage({ subjectId } = {}) {
|
|
1899
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1900
|
+
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
1901
|
+
const subject = subjectId.toLowerCase();
|
|
1902
|
+
const sources = this.#database.prepare(`SELECT json_extract(record_json, '$.provenance.external_ref.entity_type') AS kind,
|
|
1903
|
+
COUNT(*) AS plays, MIN(occurred_at) AS first_played_at, MAX(occurred_at) AS last_played_at
|
|
1904
|
+
FROM listening_events WHERE subject_id = ? GROUP BY kind ORDER BY first_played_at`).all(subject);
|
|
1905
|
+
// A history file imported later can fill a span that a sync missed.
|
|
1906
|
+
const filled = this.#database.prepare(`SELECT earliest_occurred_at AS earliest, latest_occurred_at AS latest
|
|
1907
|
+
FROM import_batches WHERE subject_id = ? AND earliest_occurred_at IS NOT NULL AND latest_occurred_at IS NOT NULL`).all(subject);
|
|
1908
|
+
const gaps = this.#database.prepare(`SELECT gap_start AS "from", gap_end AS "to"
|
|
1909
|
+
FROM listening_coverage_gaps WHERE subject_id = ? ORDER BY gap_start`).all(subject)
|
|
1910
|
+
.flatMap((gap) => filled.reduce((spans, { earliest, latest }) => spans.flatMap((span) =>
|
|
1911
|
+
latest <= span.from || earliest >= span.to ? [span] : [
|
|
1912
|
+
...(earliest > span.from ? [{ from: span.from, to: earliest }] : []),
|
|
1913
|
+
...(latest < span.to ? [{ from: latest, to: span.to }] : []),
|
|
1914
|
+
]), [{ ...gap }]));
|
|
1915
|
+
const checked = this.#database.prepare("SELECT updated_at FROM source_cursors WHERE source_key = 'spotify.recently_played'").get();
|
|
1916
|
+
return structuredClone({
|
|
1917
|
+
sources: sources.map(({ kind, ...source }) => ({ source: COVERAGE_SOURCE_LABELS[kind] ?? kind, ...source })),
|
|
1918
|
+
gaps: gaps.slice(-10),
|
|
1919
|
+
...(checked ? { recent_plays_checked_at: checked.updated_at } : {}),
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
listenerProfileStorage({ subjectId } = {}) {
|
|
1924
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1925
|
+
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
1926
|
+
return new ListenerProfileStore(this.#database, subjectId.toLowerCase());
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
profileDataVersion() {
|
|
1930
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1931
|
+
// data_version observes other connections; total_changes observes our own.
|
|
1932
|
+
// This is a connection-local cache key, never a persisted evidence digest.
|
|
1933
|
+
return `${this.#database.prepare("PRAGMA data_version").get().data_version}:${
|
|
1934
|
+
this.#database.prepare("SELECT total_changes() AS changes").get().changes}`;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
profileEvidenceRevision({ subjectId } = {}) {
|
|
1938
|
+
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1939
|
+
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
1940
|
+
const digest = createHash("sha256");
|
|
1941
|
+
// Build artifacts never feed back into source evidence. Hash locally; raw
|
|
1942
|
+
// events, source identifiers and private import metadata do not go to Pi.
|
|
1943
|
+
const queries = [
|
|
1944
|
+
"SELECT record_json FROM listening_events WHERE subject_id = ? ORDER BY listening_event_id",
|
|
1945
|
+
"SELECT record_json FROM spotify_profile_evidence WHERE subject_id = ? ORDER BY profile_evidence_id",
|
|
1946
|
+
"SELECT record_json FROM taste_events WHERE subject_id = ? ORDER BY taste_event_id",
|
|
1947
|
+
"SELECT * FROM import_batches WHERE subject_id = ? ORDER BY import_batch_id",
|
|
1948
|
+
"SELECT * FROM profile_import_batches WHERE subject_id = ? ORDER BY profile_import_id",
|
|
1949
|
+
`SELECT t.record_json FROM track_refs t WHERE EXISTS (SELECT 1 FROM listening_events e
|
|
1950
|
+
WHERE e.subject_id = ? AND e.track_ref_id = t.track_ref_id AND e.track_ref_revision = t.revision)
|
|
1951
|
+
ORDER BY t.track_ref_id, t.revision`,
|
|
1952
|
+
];
|
|
1953
|
+
for (const query of queries) {
|
|
1954
|
+
digest.update(query).update("\0");
|
|
1955
|
+
for (const row of this.#database.prepare(query).iterate(subjectId.toLowerCase())) {
|
|
1956
|
+
digest.update(JSON.stringify(row)).update("\0");
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
return digest.digest("hex");
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1554
1962
|
rediscoveryCandidates({ subjectId, limit = 6 } = {}) {
|
|
1555
1963
|
if (!Number.isInteger(limit) || limit < 1 || limit > 12) {
|
|
1556
1964
|
throw new TypeError("Listening rediscovery limit is invalid");
|
|
@@ -1930,6 +2338,10 @@ export class ListeningHistoryStore {
|
|
|
1930
2338
|
WHERE NOT EXISTS (
|
|
1931
2339
|
SELECT 1 FROM listening_event_supersessions
|
|
1932
2340
|
WHERE superseded_event_id = listening_events.listening_event_id
|
|
2341
|
+
)
|
|
2342
|
+
AND NOT EXISTS (
|
|
2343
|
+
SELECT 1 FROM listening_event_mirrors m
|
|
2344
|
+
WHERE m.mirror_event_id = listening_events.listening_event_id
|
|
1933
2345
|
)`,
|
|
1934
2346
|
)
|
|
1935
2347
|
.get().count,
|
|
@@ -2003,7 +2415,11 @@ async function initializeListeningHistoryDatabase(
|
|
|
2003
2415
|
) {
|
|
2004
2416
|
throw new Error("Listening history database schema is incompatible");
|
|
2005
2417
|
}
|
|
2418
|
+
const recordsGaps = database.prepare(
|
|
2419
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'listening_coverage_gaps'",
|
|
2420
|
+
).get();
|
|
2006
2421
|
database.exec(schema);
|
|
2422
|
+
if (!recordsGaps) recordEarlierRecentSyncGaps(database);
|
|
2007
2423
|
if (currentVersion < LISTENING_HISTORY_SCHEMA_VERSION) {
|
|
2008
2424
|
database.exec(
|
|
2009
2425
|
`PRAGMA user_version = ${LISTENING_HISTORY_SCHEMA_VERSION}`,
|