@audiofool/moondog 0.2.0-beta.2 → 0.2.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/core/apple-projection-domain-services.mjs +95 -27
- package/src/core/capability-catalog.mjs +12 -0
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-profile-domain-services.mjs +44 -8
- package/src/core/moondog-application.mjs +238 -34
- package/src/core/spotify-listening-intent.mjs +5 -2
- package/src/core/track-title.mjs +107 -0
- package/src/i18n/messages/de.mjs +7 -0
- package/src/i18n/messages/en.mjs +7 -0
- package/src/i18n/messages/es.mjs +7 -0
- package/src/i18n/messages/fr.mjs +7 -0
- package/src/i18n/messages/it.mjs +7 -0
- package/src/i18n/messages/ja.mjs +7 -0
- package/src/i18n/messages/ko.mjs +7 -0
- package/src/i18n/messages/pt.mjs +7 -0
- package/src/i18n/messages/zh-Hant.mjs +7 -0
- package/src/i18n/messages/zh.mjs +7 -0
- package/src/i18n/screen/es.mjs +1 -0
- package/src/i18n/screen/ja.mjs +1 -0
- package/src/i18n/screen/pt.mjs +1 -0
- package/src/i18n/screen/zh.mjs +1 -0
- package/src/integrations/apple-music/catalog.mjs +52 -9
- package/src/integrations/netease/ncm-cli-player.mjs +10 -3
- package/src/integrations/spotify/catalog-resolver.mjs +1 -2
- package/src/integrations/spotify/web-api-client.mjs +3 -0
- package/src/memory/local-memory-store.mjs +73 -16
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +92 -9
- package/src/profile/listener-profile-worker.mjs +7 -0
- package/src/profile/listening-history-store.mjs +231 -24
- package/src/profile/listening-profile-projection.mjs +212 -10
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/profile-exploration.mjs +16 -8
- package/src/runtime/pi/agent-runtime.mjs +363 -118
- package/src/runtime/pi/profile-build-runtime.mjs +2 -1
- package/src/runtime/pi/skills/listener-profile/SKILL.md +2 -0
- package/src/surfaces/cli/terminal-art.mjs +38 -16
- package/src/surfaces/cli/web-command.mjs +1 -1
|
@@ -9,10 +9,24 @@ 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";
|
|
12
15
|
import { ListenerProfileStore, listenerProfileSchema } from "./listener-profile-store.mjs";
|
|
13
16
|
|
|
14
17
|
export const LISTENING_HISTORY_SCHEMA_VERSION = 7;
|
|
15
18
|
|
|
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"]);
|
|
29
|
+
|
|
16
30
|
export function resolveListeningHistoryPath(environment = process.env) {
|
|
17
31
|
return path.join(resolveMoondogStateDirectory(environment), "listening-history.sqlite");
|
|
18
32
|
}
|
|
@@ -186,6 +200,26 @@ const schema = `
|
|
|
186
200
|
ON taste_events(retracts_taste_event_id)
|
|
187
201
|
WHERE retracts_taste_event_id IS NOT NULL;
|
|
188
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
|
+
|
|
189
223
|
${listenerProfileSchema}
|
|
190
224
|
`;
|
|
191
225
|
|
|
@@ -649,6 +683,13 @@ const SCROBBLE_SOURCE_KEY = "lastfm.music_import";
|
|
|
649
683
|
// the end minus the listening time; Account Data times are rounded to the minute.
|
|
650
684
|
const MIRROR_TOLERANCE_MS = 150_000;
|
|
651
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
|
+
|
|
652
693
|
const foldLabel = (value) => String(value ?? "").normalize("NFKC").toLocaleLowerCase("und")
|
|
653
694
|
.replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
654
695
|
const lexicalCompare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
@@ -798,6 +839,11 @@ function preserveExtendedHistoryMatches(bundle, retainedMatch) {
|
|
|
798
839
|
export class ListeningHistoryStore {
|
|
799
840
|
#database;
|
|
800
841
|
#closed = false;
|
|
842
|
+
#snapshot = null;
|
|
843
|
+
#projection = null;
|
|
844
|
+
#coverageSources = null;
|
|
845
|
+
#evidence = null;
|
|
846
|
+
#ledgerChanges = 0;
|
|
801
847
|
|
|
802
848
|
constructor(database) {
|
|
803
849
|
this.#database = database;
|
|
@@ -1239,6 +1285,22 @@ export class ListeningHistoryStore {
|
|
|
1239
1285
|
});
|
|
1240
1286
|
}
|
|
1241
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
|
+
|
|
1242
1304
|
ingest(value) {
|
|
1243
1305
|
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1244
1306
|
const bundle = normalizedBundle(value);
|
|
@@ -1772,15 +1834,103 @@ export class ListeningHistoryStore {
|
|
|
1772
1834
|
)
|
|
1773
1835
|
.all(normalizedSubjectId)
|
|
1774
1836
|
.map((row) => JSON.parse(row.record_json));
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
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)) };
|
|
1781
1904
|
});
|
|
1782
1905
|
}
|
|
1783
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
|
+
|
|
1784
1934
|
/** Every track the subject has played, with its play count, most played first. */
|
|
1785
1935
|
listenedTracks({ subjectId } = {}) {
|
|
1786
1936
|
if (this.#closed) throw new Error("Listening history store is closed");
|
|
@@ -1836,19 +1986,16 @@ export class ListeningHistoryStore {
|
|
|
1836
1986
|
|
|
1837
1987
|
activeAvoidances({ subjectId } = {}) {
|
|
1838
1988
|
// Host-side filtering must include corrections outside the bounded display.
|
|
1839
|
-
return structuredClone(
|
|
1840
|
-
this.#profileProjection({ subjectId, maxItems: 1 }).lyricExclusions,
|
|
1841
|
-
);
|
|
1989
|
+
return structuredClone(this.#sharedProjection(subjectId).lyricExclusions);
|
|
1842
1990
|
}
|
|
1843
1991
|
|
|
1844
1992
|
profileSummary({ subjectId, maxItems = 10 } = {}) {
|
|
1845
|
-
return structuredClone(
|
|
1846
|
-
this.#profileProjection({ subjectId, maxItems }).summary
|
|
1847
|
-
);
|
|
1993
|
+
return structuredClone((maxItems === 1 ? this.#sharedProjection(subjectId)
|
|
1994
|
+
: this.#profileProjection({ subjectId, maxItems })).summary);
|
|
1848
1995
|
}
|
|
1849
1996
|
|
|
1850
1997
|
profileCatalog({ subjectId } = {}) {
|
|
1851
|
-
const projected = this.#
|
|
1998
|
+
const projected = this.#sharedProjection(subjectId);
|
|
1852
1999
|
const normalizedSubjectId = subjectId.toLowerCase();
|
|
1853
2000
|
const imports = this.#database.prepare(`SELECT source_format AS format, data_scope AS scope,
|
|
1854
2001
|
SUM(input_records) AS input_records, MIN(earliest_occurred_at) AS earliest,
|
|
@@ -1866,32 +2013,49 @@ export class ListeningHistoryStore {
|
|
|
1866
2013
|
reference_date: projected.summary.listening_behavior.context.reference_date,
|
|
1867
2014
|
recent_window_days: projected.summary.listening_behavior.context.recent_window_days,
|
|
1868
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 } : {}),
|
|
1869
2018
|
release_minimum_distinct_tracks: projected.summary.listening_behavior.context.release_minimum_distinct_tracks,
|
|
1870
2019
|
},
|
|
1871
2020
|
sections: projected.catalog,
|
|
1872
2021
|
});
|
|
1873
2022
|
}
|
|
1874
2023
|
|
|
1875
|
-
// What the listener played lately: stored plays
|
|
1876
|
-
// It reads the clock, not the newest play, so a history that stopped shows as
|
|
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.
|
|
1877
2027
|
recentAttention({ subjectId, days = 28, now = Date.now(), limit = 10 } = {}) {
|
|
1878
2028
|
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1879
2029
|
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
1880
|
-
const
|
|
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();
|
|
1881
2035
|
const effective = `e.subject_id = ? AND e.occurred_at >= ?
|
|
1882
2036
|
AND NOT EXISTS (SELECT 1 FROM listening_event_supersessions s WHERE s.superseded_event_id = e.listening_event_id)
|
|
1883
2037
|
AND NOT EXISTS (SELECT 1 FROM listening_event_mirrors m WHERE m.mirror_event_id = e.listening_event_id)`;
|
|
1884
2038
|
const subject = subjectId.toLowerCase();
|
|
1885
|
-
|
|
1886
|
-
|
|
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;
|
|
1887
2050
|
const artists = this.#database.prepare(`SELECT json_extract(a.value, '$.name') AS name, COUNT(*) AS plays,
|
|
1888
2051
|
MAX(e.occurred_at) AS last_played_at
|
|
1889
2052
|
FROM listening_events e
|
|
1890
2053
|
JOIN track_refs t ON t.track_ref_id = e.track_ref_id AND t.revision = e.track_ref_revision
|
|
1891
2054
|
JOIN json_each(t.record_json, '$.artist_credits') a
|
|
1892
|
-
WHERE ${
|
|
1893
|
-
GROUP BY name ORDER BY plays DESC, last_played_at DESC LIMIT ?`).all(subject, from, limit);
|
|
1894
|
-
return structuredClone({
|
|
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 });
|
|
1895
2059
|
}
|
|
1896
2060
|
|
|
1897
2061
|
// Which spans of time the stored plays cover, and where plays may be missing.
|
|
@@ -1899,9 +2063,15 @@ export class ListeningHistoryStore {
|
|
|
1899
2063
|
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1900
2064
|
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
1901
2065
|
const subject = subjectId.toLowerCase();
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
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;
|
|
1905
2075
|
// A history file imported later can fill a span that a sync missed.
|
|
1906
2076
|
const filled = this.#database.prepare(`SELECT earliest_occurred_at AS earliest, latest_occurred_at AS latest
|
|
1907
2077
|
FROM import_batches WHERE subject_id = ? AND earliest_occurred_at IS NOT NULL AND latest_occurred_at IS NOT NULL`).all(subject);
|
|
@@ -1934,6 +2104,33 @@ export class ListeningHistoryStore {
|
|
|
1934
2104
|
this.#database.prepare("SELECT total_changes() AS changes").get().changes}`;
|
|
1935
2105
|
}
|
|
1936
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
|
+
|
|
1937
2134
|
profileEvidenceRevision({ subjectId } = {}) {
|
|
1938
2135
|
if (this.#closed) throw new Error("Listening history store is closed");
|
|
1939
2136
|
if (!isUuid(subjectId)) throw new TypeError("A valid profile subject is required");
|
|
@@ -1956,6 +2153,16 @@ export class ListeningHistoryStore {
|
|
|
1956
2153
|
digest.update(JSON.stringify(row)).update("\0");
|
|
1957
2154
|
}
|
|
1958
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));
|
|
1959
2166
|
return digest.digest("hex");
|
|
1960
2167
|
}
|
|
1961
2168
|
|