@audiofool/moondog 0.2.0-beta.2 → 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.
Files changed (39) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/apple-projection-domain-services.mjs +95 -27
  4. package/src/core/capability-catalog.mjs +12 -0
  5. package/src/core/listener-avoids.mjs +101 -0
  6. package/src/core/listening-profile-domain-services.mjs +44 -8
  7. package/src/core/moondog-application.mjs +238 -34
  8. package/src/core/spotify-listening-intent.mjs +5 -2
  9. package/src/core/track-title.mjs +107 -0
  10. package/src/i18n/messages/de.mjs +7 -0
  11. package/src/i18n/messages/en.mjs +7 -0
  12. package/src/i18n/messages/es.mjs +7 -0
  13. package/src/i18n/messages/fr.mjs +7 -0
  14. package/src/i18n/messages/it.mjs +7 -0
  15. package/src/i18n/messages/ja.mjs +7 -0
  16. package/src/i18n/messages/ko.mjs +7 -0
  17. package/src/i18n/messages/pt.mjs +7 -0
  18. package/src/i18n/messages/zh-Hant.mjs +7 -0
  19. package/src/i18n/messages/zh.mjs +7 -0
  20. package/src/i18n/screen/es.mjs +1 -0
  21. package/src/i18n/screen/ja.mjs +1 -0
  22. package/src/i18n/screen/pt.mjs +1 -0
  23. package/src/i18n/screen/zh.mjs +1 -0
  24. package/src/integrations/apple-music/catalog.mjs +52 -9
  25. package/src/integrations/netease/ncm-cli-player.mjs +10 -3
  26. package/src/integrations/spotify/catalog-resolver.mjs +1 -2
  27. package/src/integrations/spotify/web-api-client.mjs +3 -0
  28. package/src/memory/local-memory-store.mjs +73 -16
  29. package/src/profile/listener-corrections.mjs +22 -0
  30. package/src/profile/listener-profile-build.mjs +92 -9
  31. package/src/profile/listener-profile-worker.mjs +7 -0
  32. package/src/profile/listening-history-store.mjs +231 -24
  33. package/src/profile/listening-profile-projection.mjs +212 -10
  34. package/src/profile/moondog-suggestions.mjs +184 -0
  35. package/src/profile/profile-exploration.mjs +16 -8
  36. package/src/runtime/pi/agent-runtime.mjs +363 -118
  37. package/src/runtime/pi/profile-build-runtime.mjs +2 -1
  38. package/src/runtime/pi/skills/listener-profile/SKILL.md +2 -0
  39. 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
- return projectListeningProfile({
1776
- subjectId: normalizedSubjectId,
1777
- eventRows,
1778
- profileEvidence,
1779
- tasteEvents,
1780
- maxItems,
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.#profileProjection({ subjectId, maxItems: 1 });
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 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.
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 from = new Date(now - days * DAY_MS).toISOString();
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
- const plays = this.#database.prepare(`SELECT COUNT(*) AS plays FROM listening_events e WHERE ${effective}`)
1886
- .get(subject, from).plays;
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 ${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 });
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
- 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);
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