@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
|
@@ -673,7 +673,7 @@ async function checkpointSqlite(filePath) {
|
|
|
673
673
|
} catch (error) {
|
|
674
674
|
fail(
|
|
675
675
|
"local_music_data_busy",
|
|
676
|
-
"Local music data is busy. Close Moondog
|
|
676
|
+
"Local music data is busy. Close other Moondog windows and processes before reset.",
|
|
677
677
|
{ cause: error },
|
|
678
678
|
);
|
|
679
679
|
} finally {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { normalizedArtistName, trackMatchKey } from "../core/track-title.mjs";
|
|
2
|
+
|
|
3
|
+
const HOUR_MS = 3_600_000;
|
|
4
|
+
const DAY_MS = 86_400_000;
|
|
5
|
+
|
|
6
|
+
// A stored play is one Moondog started when it is the same recording as a suggestion (the same
|
|
7
|
+
// Spotify track or ISRC) or the same song by title and primary artist, and it lands from 60
|
|
8
|
+
// seconds before the request to six hours after it: long enough for a queued batch to play out,
|
|
9
|
+
// short enough that a later return is the listener's own. A play's time is when it ended, so one
|
|
10
|
+
// that ended before the request was the listener's; the 60 seconds only absorb clock skew. A
|
|
11
|
+
// matching play after the window is the listener's choice and marks the suggestion adopted. The
|
|
12
|
+
// match runs whenever the profile is read, so a later history import is matched the same way.
|
|
13
|
+
export const SUGGESTION_WINDOW = Object.freeze({ beforeMs: 60_000, afterMs: 6 * HOUR_MS });
|
|
14
|
+
|
|
15
|
+
export const SUGGESTION_SERVICES = Object.freeze(["spotify", "netease", "apple_music"]);
|
|
16
|
+
|
|
17
|
+
// One artist's name can hold a separator ("AC/DC", "Simon & Garfunkel", "Tyler, The Creator"),
|
|
18
|
+
// so a bare credit is tried whole, then by its comma parts, then by every part.
|
|
19
|
+
function creditSplits(credit) {
|
|
20
|
+
const whole = String(credit ?? "").trim();
|
|
21
|
+
const split = (pattern) => whole.split(pattern).map((name) => name.trim()).filter(Boolean);
|
|
22
|
+
return [[whole].filter(Boolean), split(/[,;]/u), split(/\s*(?:,|&|;|\/|\bfeat\.?|\bfeaturing\b)\s*/iu)];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const creditNames = (credit) => [...new Set(creditSplits(credit).flat())];
|
|
26
|
+
|
|
27
|
+
// The names a track may be stored under: its first artist, or each reading of a bare credit's first one.
|
|
28
|
+
function primaryArtists(track) {
|
|
29
|
+
if (Array.isArray(track?.artists) && typeof track.artists[0] === "string") return [track.artists[0]];
|
|
30
|
+
if (Array.isArray(track?.artist_credits)) return track.artist_credits[0]?.name ? [track.artist_credits[0].name] : [];
|
|
31
|
+
return [...new Set(creditSplits(track?.artist_credit).map((names) => names[0]).filter(Boolean))];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One song by its primary artist, the same for every version of it; a bare credit can give more than one. */
|
|
35
|
+
export function songKeys(track) {
|
|
36
|
+
return [...new Set(primaryArtists(track).map((artist) => trackMatchKey(track?.title, artist)).filter(Boolean))];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const releaseKey = (track) => trackMatchKey(typeof track?.release === "string" ? track.release : track?.release?.title,
|
|
40
|
+
primaryArtists(track)[0]);
|
|
41
|
+
|
|
42
|
+
/** The ways a stored track can match a suggestion: its Spotify track, its ISRC, its song, its album. */
|
|
43
|
+
export function playedTrackIdentities(track) {
|
|
44
|
+
const identities = [];
|
|
45
|
+
for (const ref of Array.isArray(track?.external_refs) ? track.external_refs : []) {
|
|
46
|
+
if (ref?.system === "spotify" && ref.entity_type === "spotify.track" && typeof ref.external_id === "string") {
|
|
47
|
+
identities.push(`spotify:track:${ref.external_id}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (typeof track?.isrc === "string") identities.push(`isrc:${track.isrc.toUpperCase()}`);
|
|
51
|
+
for (const key of songKeys(track)) identities.push(`key:${key}`);
|
|
52
|
+
const release = releaseKey(track);
|
|
53
|
+
if (release) identities.push(`release:${release}`);
|
|
54
|
+
return identities;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function suggestionIdentities(suggestion) {
|
|
58
|
+
return [...new Set([
|
|
59
|
+
suggestion.provider_track_id,
|
|
60
|
+
suggestion.isrc ? `isrc:${suggestion.isrc}` : null,
|
|
61
|
+
suggestion.track_key ? `key:${suggestion.track_key}` : null,
|
|
62
|
+
...(suggestion.track_key ? songKeys({ title: suggestion.title, artist_credit: suggestion.artist_credit }).map((key) => `key:${key}`) : []),
|
|
63
|
+
suggestion.release_key ? `release:${suggestion.release_key}` : null,
|
|
64
|
+
// An album row keeps its release title, so its key is rebuilt too, as a song's is.
|
|
65
|
+
suggestion.release_key && releaseKey({ release: suggestion.title, artist_credit: suggestion.artist_credit })
|
|
66
|
+
? `release:${releaseKey({ release: suggestion.title, artist_credit: suggestion.artist_credit })}` : null,
|
|
67
|
+
].filter(Boolean))];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The ledger entry for one accepted host write, or null when it names no song. An album Moondog
|
|
72
|
+
* started names its release instead, so every song of it that plays matches.
|
|
73
|
+
*/
|
|
74
|
+
export function suggestionEntry({ service, action, providerTrackId = null, isrc = null, title, release = null, artists, artistCredit, mixSlot = null, requestedAt }) {
|
|
75
|
+
const credit = typeof artistCredit === "string" && artistCredit.trim() ? artistCredit.trim()
|
|
76
|
+
: Array.isArray(artists) ? artists.filter((name) => typeof name === "string").join(", ") : "";
|
|
77
|
+
const named = { title, release, artists, artist_credit: credit };
|
|
78
|
+
const key = title ? songKeys(named)[0] ?? null : null;
|
|
79
|
+
const album = !title && typeof release === "string" ? releaseKey(named) : null;
|
|
80
|
+
if (!SUGGESTION_SERVICES.includes(service) || !(key || album)) return null;
|
|
81
|
+
return {
|
|
82
|
+
service, action,
|
|
83
|
+
provider_track_id: typeof providerTrackId === "string" && providerTrackId ? providerTrackId : null,
|
|
84
|
+
isrc: typeof isrc === "string" && /^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$/iu.test(isrc) ? isrc.toUpperCase() : null,
|
|
85
|
+
track_key: key,
|
|
86
|
+
release_key: album,
|
|
87
|
+
title: Array.from(String(title || release).trim()).slice(0, 512).join(""),
|
|
88
|
+
artist_credit: Array.from(credit).slice(0, 512).join(""),
|
|
89
|
+
requested_at: requestedAt,
|
|
90
|
+
mix_slot: key && MIX_SLOTS.includes(mixSlot) ? mixSlot : null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Marks each stored play Moondog started with its suggestion, and returns the suggestions the
|
|
96
|
+
* listener later played again on their own. Events are never rewritten.
|
|
97
|
+
*/
|
|
98
|
+
export function markMoondogStartedPlays(eventRows, suggestions) {
|
|
99
|
+
const adopted = new Set();
|
|
100
|
+
if (!suggestions.length) return { rows: eventRows, adopted };
|
|
101
|
+
const byIdentity = new Map();
|
|
102
|
+
for (const suggestion of suggestions) {
|
|
103
|
+
const entry = { ...suggestion, requestedMs: Date.parse(suggestion.requested_at) };
|
|
104
|
+
for (const identity of suggestionIdentities(suggestion)) {
|
|
105
|
+
byIdentity.set(identity, [...(byIdentity.get(identity) ?? []), entry]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Plays repeat a much smaller set of tracks, so a stored track (one revision of a track_refs
|
|
109
|
+
// row) is matched once, and only plays of a matched track read their time.
|
|
110
|
+
const byTrack = new Map();
|
|
111
|
+
const trackMatches = (track) => {
|
|
112
|
+
const key = typeof track?.track_ref_id === "string" && Number.isInteger(track.revision)
|
|
113
|
+
? `${track.track_ref_id}\0${track.revision}` : null;
|
|
114
|
+
let matches = key === null ? undefined : byTrack.get(key);
|
|
115
|
+
if (!matches) {
|
|
116
|
+
matches = [...new Set(playedTrackIdentities(track).flatMap((identity) => byIdentity.get(identity) ?? []))];
|
|
117
|
+
if (key !== null) byTrack.set(key, matches);
|
|
118
|
+
}
|
|
119
|
+
return matches;
|
|
120
|
+
};
|
|
121
|
+
const rows = eventRows.map((row) => {
|
|
122
|
+
if (row.event?.extensions?.["spotify.incognito_mode"] === true) return row;
|
|
123
|
+
const matches = trackMatches(row.track);
|
|
124
|
+
if (!matches.length) return row;
|
|
125
|
+
const playedMs = Date.parse(row.event?.occurred_at);
|
|
126
|
+
if (!Number.isFinite(playedMs)) return row;
|
|
127
|
+
const started = matches.find((entry) =>
|
|
128
|
+
playedMs >= entry.requestedMs - SUGGESTION_WINDOW.beforeMs && playedMs <= entry.requestedMs + SUGGESTION_WINDOW.afterMs);
|
|
129
|
+
if (started) return { ...row, moondog_suggestion_id: started.suggestion_id };
|
|
130
|
+
for (const entry of matches) if (playedMs > entry.requestedMs + SUGGESTION_WINDOW.afterMs) adopted.add(entry.suggestion_id);
|
|
131
|
+
return row;
|
|
132
|
+
});
|
|
133
|
+
return { rows, adopted };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The declared mix: where each song sits in the listener's own plays, Moondog's plays left out.
|
|
137
|
+
// At least 3 plays is a song they know: familiar when one falls within 365 days of their latest
|
|
138
|
+
// play, otherwise returning (then none falls in the last 180 days either). One or two plays, or
|
|
139
|
+
// only plays Moondog started, is a song they have heard. With none, it is new from an artist
|
|
140
|
+
// they play, or new to them.
|
|
141
|
+
export const MIX_SLOTS = Object.freeze(["familiar", "returning", "heard", "known_artist", "new"]);
|
|
142
|
+
|
|
143
|
+
/** What the listener's own plays say about songs and artists, or null with no plays at all. */
|
|
144
|
+
export function listeningFamiliarity(catalog, referenceDate) {
|
|
145
|
+
const referenceMs = Date.parse(referenceDate ?? "");
|
|
146
|
+
if (!Number.isFinite(referenceMs) || !catalog?.history_tracks?.length) return null;
|
|
147
|
+
const tracks = new Map();
|
|
148
|
+
for (const row of catalog.history_tracks) {
|
|
149
|
+
const key = trackMatchKey(row.label, row.artist_credit);
|
|
150
|
+
if (!key) continue;
|
|
151
|
+
const known = tracks.get(key) ?? { plays: 0, lastMs: 0 };
|
|
152
|
+
known.plays += row.play_count ?? 0;
|
|
153
|
+
known.lastMs = Math.max(known.lastMs, Date.parse(row.last_played_at) || 0);
|
|
154
|
+
tracks.set(key, known);
|
|
155
|
+
}
|
|
156
|
+
const artists = new Set((catalog.history_artists ?? []).map((row) => normalizedArtistName(row.name)).filter(Boolean));
|
|
157
|
+
const moondogHeard = new Set((catalog.moondog_suggestions ?? []).map((row) => trackMatchKey(row.label, row.artist_credit)).filter(Boolean));
|
|
158
|
+
return { referenceMs, tracks, artists, moondogHeard };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function mixSlot(track, familiarity) {
|
|
162
|
+
if (!familiarity) return "unknown";
|
|
163
|
+
const names = Array.isArray(track?.artists) && track.artists.length ? track.artists : creditNames(track?.artist_credit);
|
|
164
|
+
let plays = 0;
|
|
165
|
+
let lastMs = 0;
|
|
166
|
+
let moondogHeard = false;
|
|
167
|
+
for (const name of names) {
|
|
168
|
+
const key = trackMatchKey(track?.title, name);
|
|
169
|
+
moondogHeard ||= familiarity.moondogHeard?.has(key) === true;
|
|
170
|
+
const known = familiarity.tracks.get(key);
|
|
171
|
+
if (!known) continue;
|
|
172
|
+
plays += known.plays;
|
|
173
|
+
lastMs = Math.max(lastMs, known.lastMs);
|
|
174
|
+
}
|
|
175
|
+
if (plays >= 3) return familiarity.referenceMs - lastMs <= 365 * DAY_MS ? "familiar" : "returning";
|
|
176
|
+
if (plays > 0 || moondogHeard) return "heard";
|
|
177
|
+
return names.some((name) => familiarity.artists.has(normalizedArtistName(name))) ? "known_artist" : "new";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Counts per slot for a mix line, or null when no song could be placed. */
|
|
181
|
+
export function mixCounts(slots) {
|
|
182
|
+
if (!slots.some((slot) => MIX_SLOTS.includes(slot))) return null;
|
|
183
|
+
return Object.fromEntries(MIX_SLOTS.map((slot) => [slot, slots.filter((value) => value === slot).length]));
|
|
184
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export const MUSIC_PROVIDER_LABELS = Object.freeze({
|
|
2
2
|
spotify_account_data: "Spotify",
|
|
3
|
+
spotify_web_api: "Spotify",
|
|
3
4
|
youtube_music: "YouTube Music",
|
|
5
|
+
lastfm: "Last.fm",
|
|
4
6
|
qq_music: "QQ Music",
|
|
5
|
-
netease: "NetEase
|
|
7
|
+
netease: "NetEase Music",
|
|
6
8
|
});
|
|
7
9
|
|
|
8
10
|
export function musicProviderLabel(system) {
|
|
@@ -10,16 +12,18 @@ export function musicProviderLabel(system) {
|
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function collectionCoverage(coverage) {
|
|
13
|
-
|
|
15
|
+
// An export and a live library read are both Spotify; evidence is already deduplicated across them.
|
|
16
|
+
const spotify = (coverage.collection_sources ?? []).filter((source) => ["spotify_account_data", "spotify_web_api"].includes(source.system));
|
|
17
|
+
const total = (key) => spotify.reduce((sum, source) => sum + (source[key] ?? 0), 0);
|
|
14
18
|
return {
|
|
15
19
|
collection_tracks: coverage.collection_tracks ?? 0,
|
|
16
20
|
collection_sources: coverage.collection_sources ?? [],
|
|
17
21
|
saved_tracks: coverage.saved_tracks,
|
|
18
22
|
playlist_memberships: coverage.playlist_memberships,
|
|
19
|
-
spotify_profile_evidence:
|
|
20
|
-
spotify_saved_tracks:
|
|
21
|
-
spotify_saved_albums:
|
|
22
|
-
spotify_followed_artists:
|
|
23
|
-
spotify_playlist_memberships:
|
|
23
|
+
spotify_profile_evidence: total("evidence_records"),
|
|
24
|
+
spotify_saved_tracks: total("saved_tracks"),
|
|
25
|
+
spotify_saved_albums: total("saved_albums"),
|
|
26
|
+
spotify_followed_artists: total("followed_artists"),
|
|
27
|
+
spotify_playlist_memberships: total("playlist_memberships"),
|
|
24
28
|
};
|
|
25
29
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The limits below apply to delivery, never to the corpus being analyzed.
|
|
2
|
+
export const PROFILE_SECTIONS = Object.freeze([
|
|
3
|
+
"overview", "history_artists", "history_tracks", "recent_artists", "recent_tracks",
|
|
4
|
+
"history_years", "history_releases", "saved_tracks", "playlist_tracks", "saved_albums",
|
|
5
|
+
"followed_artists", "provider_genres", "listener_preferences", "listener_avoids",
|
|
6
|
+
"provider_evidence", "apple_tracks", "apple_artists", "apple_genres",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export const TRAJECTORIES = Object.freeze(["rising", "steady", "fading", "returning", "burst", "unknown"]);
|
|
10
|
+
|
|
11
|
+
const normalize = (value) => String(value ?? "").normalize("NFKC").toLocaleLowerCase("und");
|
|
12
|
+
|
|
13
|
+
export function profileExplorationInput(value = {}) {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
15
|
+
Object.keys(value).some((key) => !["section", "query", "offset", "limit", "sort", "trajectory"].includes(key))) {
|
|
16
|
+
throw new TypeError("Profile exploration arguments are invalid");
|
|
17
|
+
}
|
|
18
|
+
const { section = "overview", query = "", offset = 0, limit = 12, sort = "ranked", trajectory } = value;
|
|
19
|
+
if (!PROFILE_SECTIONS.includes(section) || typeof query !== "string" || query.length > 256 ||
|
|
20
|
+
!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20 ||
|
|
21
|
+
!["ranked", "least_played", "recent"].includes(sort) ||
|
|
22
|
+
(trajectory !== undefined && !TRAJECTORIES.includes(trajectory))) {
|
|
23
|
+
throw new TypeError("Profile exploration selection is invalid");
|
|
24
|
+
}
|
|
25
|
+
if (sort !== "ranked" && !["history_tracks", "history_artists", "recent_tracks", "recent_artists", "apple_tracks"].includes(section)) {
|
|
26
|
+
throw new TypeError("This profile section supports ranked order only");
|
|
27
|
+
}
|
|
28
|
+
if (sort === "recent" && section === "apple_tracks") {
|
|
29
|
+
throw new TypeError("Apple library play counts have no event chronology");
|
|
30
|
+
}
|
|
31
|
+
if (trajectory !== undefined && section !== "history_artists") {
|
|
32
|
+
throw new TypeError("Only history artists have a trajectory");
|
|
33
|
+
}
|
|
34
|
+
return { section, query: query.trim(), offset, limit, sort, ...(trajectory ? { trajectory } : {}) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function exploreProfileCatalog({ listening, apple, input }) {
|
|
38
|
+
const { section, query, offset, limit, sort, trajectory } = profileExplorationInput(input);
|
|
39
|
+
const catalog = { ...listening?.sections, ...apple?.sections };
|
|
40
|
+
const all = catalog[section] ?? [];
|
|
41
|
+
const needle = normalize(query);
|
|
42
|
+
let matches = all.filter((row) => (!trajectory || row.trajectory === trajectory) && (!needle || [
|
|
43
|
+
row.name, row.label, row.artist_credit, row.release, row.genre, row.year, row.evidence_kind,
|
|
44
|
+
row.source_label, row.period, row.text, row.playlist_name, ...(row.playlist_names ?? []),
|
|
45
|
+
]
|
|
46
|
+
.some((text) => normalize(text).includes(needle))));
|
|
47
|
+
if (sort !== "ranked") {
|
|
48
|
+
matches = matches.map((row, index) => ({ row, index })).sort((left, right) => {
|
|
49
|
+
const order = sort === "least_played"
|
|
50
|
+
? (left.row.play_count ?? Number.MAX_SAFE_INTEGER) - (right.row.play_count ?? Number.MAX_SAFE_INTEGER)
|
|
51
|
+
: String(right.row.last_played_at ?? "").localeCompare(String(left.row.last_played_at ?? ""));
|
|
52
|
+
return order || left.index - right.index;
|
|
53
|
+
}).map(({ row }) => row);
|
|
54
|
+
}
|
|
55
|
+
const items = matches.slice(offset, offset + limit).map((row) => {
|
|
56
|
+
if (!needle || !row.playlist_names) return row;
|
|
57
|
+
// Search every membership, and keep matching names in the bounded display.
|
|
58
|
+
return { ...row, playlist_names: [...row.playlist_names].sort((left, right) =>
|
|
59
|
+
Number(normalize(right).includes(needle)) - Number(normalize(left).includes(needle))) };
|
|
60
|
+
});
|
|
61
|
+
return structuredClone({
|
|
62
|
+
schema_version: "profile-exploration/1",
|
|
63
|
+
analysis_scope: "all_retained_supported_data",
|
|
64
|
+
section,
|
|
65
|
+
coverage: { ...listening?.coverage, ...apple?.coverage },
|
|
66
|
+
sources: listening?.sources ?? [],
|
|
67
|
+
context: listening?.context ?? {},
|
|
68
|
+
section_note: trajectory
|
|
69
|
+
? "Artists with this trajectory label, computed from their share of yearly plays across the retained history. It describes attention over time, not liking."
|
|
70
|
+
: section.startsWith("recent_")
|
|
71
|
+
? "Recent rankings use the retained-history reference date and recent_window_days, not today's date."
|
|
72
|
+
: section === "history_releases"
|
|
73
|
+
? "Release depth includes releases meeting release_minimum_distinct_tracks. It does not prove album completion."
|
|
74
|
+
: section === "provider_genres"
|
|
75
|
+
? "Named provider genre labels only. Unresolved labels remain in provider_evidence."
|
|
76
|
+
: "Complete selected evidence section, before search and pagination.",
|
|
77
|
+
sections: Object.entries(catalog).map(([name, rows]) => ({ name, total: rows.length })),
|
|
78
|
+
total: all.length,
|
|
79
|
+
matched: matches.length,
|
|
80
|
+
offset,
|
|
81
|
+
next_offset: offset + items.length < matches.length ? offset + items.length : null,
|
|
82
|
+
items,
|
|
83
|
+
limitations: [
|
|
84
|
+
"All retained supported records contribute before search, sorting, or pagination. A page is a selection, not the complete profile.",
|
|
85
|
+
"History describes attention; library membership, saved tracks, ratings, provider labels, and explicit listener choices are separate signals.",
|
|
86
|
+
"Spotify private-session plays are excluded from behavioral analysis. Superseded export overlaps are not counted again.",
|
|
87
|
+
"Apple Music is a library snapshot. Its aggregate play counts are not dated plays and must not be added to Spotify event totals.",
|
|
88
|
+
"Apple preferred-track counts exclude active listener Avoids. Original library membership, ratings, and love states remain visible.",
|
|
89
|
+
"Provider genre labels describe catalog metadata, not measured sound. Absence from an export is not a dislike.",
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Sources record different measures for each play. Spotify's extended history
|
|
2
|
+
// records how long a play lasted and, for most plays, whether it was skipped.
|
|
3
|
+
// Spotify account data records only the length. Last.fm and most ListenBrainz
|
|
4
|
+
// listens record neither, and an Apple library counts plays for some tracks
|
|
5
|
+
// only. A measure that no play recorded is unknown: it never becomes a zero
|
|
6
|
+
// that reads as no listening time, never skipped, or unplayed.
|
|
7
|
+
|
|
8
|
+
/** Whether all, some or none of `total` items recorded a measure. */
|
|
9
|
+
export function recordedShare(total, recorded, atLeast = 1) {
|
|
10
|
+
if (recorded >= total * atLeast) return "all";
|
|
11
|
+
return recorded === 0 ? "none" : "some";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a set of plays recorded: its listening time and its skips, each "all",
|
|
16
|
+
* "some" or "none". `timedPlayCount` counts plays with a recorded length, and
|
|
17
|
+
* `skipKnownCount` counts plays that say whether they were skipped.
|
|
18
|
+
*/
|
|
19
|
+
export function recordedMeasures({ playCount, timedPlayCount, skipKnownCount }) {
|
|
20
|
+
return {
|
|
21
|
+
time: recordedShare(playCount, timedPlayCount),
|
|
22
|
+
skips: recordedShare(playCount, skipKnownCount),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -235,10 +235,6 @@ export function createSpotifyListeningProfileProjection(listening, options) {
|
|
|
235
235
|
return createListeningProfileProjection(listening, options);
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
export function createSpotifyArchiveProfileProjection(listening, options) {
|
|
239
|
-
return createSpotifyListeningProfileProjection(listening, options);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
238
|
function validatedArchivePaths(value) {
|
|
243
239
|
if (
|
|
244
240
|
!Array.isArray(value) ||
|