@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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
2
|
+
import { N_, screenTranslator } from "../../i18n/index.mjs";
|
|
3
|
+
|
|
4
|
+
export const savedProfileActions = new Set(["build", "saved", "explain"]);
|
|
5
|
+
|
|
6
|
+
const kindLabels = { observation: N_("measured"), hypothesis: N_("interpretation"), listener_assertion: N_("your choice") };
|
|
7
|
+
|
|
8
|
+
function builtLine(value, tr) {
|
|
9
|
+
const coverage = value.coverage?.coverage ?? {};
|
|
10
|
+
const concerns = value.verification?.state === "flagged" ? value.verification.checks.at(-1).issues.length : 0;
|
|
11
|
+
const check = value.verification?.state === "revised" ? tr("checked and revised once")
|
|
12
|
+
: concerns ? tr.n(concerns, "checked; one concern remains", "checked; {count} concerns remain") : tr("checked");
|
|
13
|
+
const built = [
|
|
14
|
+
tr("Built {date}", { date: value.built_at.slice(0, 10) }),
|
|
15
|
+
...(Number.isInteger(coverage.effective_listening_events) ? [tr.n(coverage.effective_listening_events, "from {count} play", "from {count} plays")] : []),
|
|
16
|
+
].join(" ");
|
|
17
|
+
const span = coverage.earliest_played_at && coverage.latest_played_at
|
|
18
|
+
? `, ${tr("{first} to {last}", { first: coverage.earliest_played_at.slice(0, 10), last: coverage.latest_played_at.slice(0, 10) })}` : "";
|
|
19
|
+
return `${built}${span} · ${check}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatSaved(value, prefix, allFindings = false, tr) {
|
|
23
|
+
if (value.state === "missing") return tr("No saved reading yet. Use {prefix} build with a connected model.", { prefix });
|
|
24
|
+
const lines = [
|
|
25
|
+
`# ${tr("Your saved listening profile · v{version}", { version: value.sequence })}`,
|
|
26
|
+
value.state === "stale" ? tr("Evidence, choices or analysis changed. Use {prefix} build to update this reading.", { prefix }) : builtLine(value, tr),
|
|
27
|
+
"", value.summary, "",
|
|
28
|
+
];
|
|
29
|
+
// The TUI renders this text as Markdown, which renumbers an ordered list from its
|
|
30
|
+
// first item. Ascending finding numbers keep the shown numbers valid for explain.
|
|
31
|
+
const displayed = allFindings ? value.claims : [...new Map([
|
|
32
|
+
...(value.highlights ?? []), ...(value.listener_assertions ?? []),
|
|
33
|
+
].map(claim => [claim.claim_id, claim])).values()].sort((a, b) => a.finding_number - b.finding_number);
|
|
34
|
+
for (const [index, claim] of displayed.entries()) {
|
|
35
|
+
lines.push(`${claim.finding_number ?? value.offset + index + 1}. ${claim.statement}`,
|
|
36
|
+
` ${kindLabels[claim.kind] ? tr.marked(kindLabels[claim.kind]) : claim.kind.replaceAll("_", " ")} · ${claim.scope} · ${claim.uncertainty}`);
|
|
37
|
+
}
|
|
38
|
+
if (value.state === "current" && value.verification?.state === "flagged") {
|
|
39
|
+
lines.push("", tr("The final check still flagged:"), ...value.verification.checks.at(-1).issues.map(issue => `- ${issue.problem}`));
|
|
40
|
+
}
|
|
41
|
+
lines.push("", tr("Use {prefix} explain <number> for a finding's evidence.", { prefix }));
|
|
42
|
+
if (!allFindings && value.total_claims > displayed.length) lines.push(tr("All {count} findings: {prefix} saved 0", { count: value.total_claims, prefix }));
|
|
43
|
+
else if (allFindings && value.next_offset !== null) lines.push(tr("More findings: {prefix} saved {offset}", { prefix, offset: value.next_offset }));
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatEvidence(entry) {
|
|
48
|
+
const { data } = entry;
|
|
49
|
+
const label = data.label ?? data.name ?? data.title ?? data.year ?? data.evidence_kind ?? entry.section;
|
|
50
|
+
const fields = Object.entries(data).filter(([key]) => !["label", "name", "title"].includes(key))
|
|
51
|
+
.map(([key, value]) => `${key.replaceAll("_", " ")}: ${typeof value === "object" ? JSON.stringify(value) : value}`);
|
|
52
|
+
return `- ${label} (${entry.section.replaceAll("_", " ")})\n ${fields.join(" · ")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runListenerProfileCommand({
|
|
56
|
+
application, args, json = false, output = process.stdout, commandPrefix = "moondog profile",
|
|
57
|
+
signal, onProgress, runtimeFactory, tr = screenTranslator("en"),
|
|
58
|
+
} = {}) {
|
|
59
|
+
const [action, ...values] = args ?? [];
|
|
60
|
+
let result;
|
|
61
|
+
let rendered;
|
|
62
|
+
if (action === "build") {
|
|
63
|
+
if (values.length > 1 || (values.length === 1 && values[0] !== "--force")) {
|
|
64
|
+
throw new Error(tr("Usage: {prefix} build [--force].", { prefix: commandPrefix }));
|
|
65
|
+
}
|
|
66
|
+
result = await application.buildListenerProfile({ force: values[0] === "--force", signal, onProgress, runtimeFactory });
|
|
67
|
+
rendered = result.state === "no_evidence" ? tr("Import your music first with /import.")
|
|
68
|
+
: `${result.state === "unchanged" ? tr("Your saved reading already matches the current evidence.") : tr("Saved your new listening profile.")}\n\n${formatSaved(result.revision, commandPrefix, false, tr)}`;
|
|
69
|
+
} else if (action === "saved") {
|
|
70
|
+
if (values.length > 1 || (values.length === 1 && !/^\d+$/u.test(values[0]))) {
|
|
71
|
+
throw new Error(tr("Usage: {prefix} saved [offset].", { prefix: commandPrefix }));
|
|
72
|
+
}
|
|
73
|
+
result = await application.getListenerProfile({ offset: Number(values[0] ?? 0) });
|
|
74
|
+
rendered = formatSaved(result, commandPrefix, values.length > 0, tr);
|
|
75
|
+
} else if (action === "explain") {
|
|
76
|
+
if (values.length !== 1 || !/^[1-9]\d*$/u.test(values[0]) || !Number.isSafeInteger(Number(values[0]))) {
|
|
77
|
+
throw new Error(tr("Usage: {prefix} explain <finding-number>.", { prefix: commandPrefix }));
|
|
78
|
+
}
|
|
79
|
+
const page = await application.getListenerProfile({ offset: Number(values[0]) - 1, limit: 1 });
|
|
80
|
+
if (!page.claims.length) throw new Error(tr("That finding is not in your saved profile. Open /profile saved first."));
|
|
81
|
+
result = await application.getListenerProfile({ claimId: page.claims[0].claim_id, revisionId: page.revision_id });
|
|
82
|
+
rendered = [
|
|
83
|
+
`# ${result.state === "stale" ? tr("Finding {number} · profile v{version} · needs update", { number: values[0], version: result.sequence })
|
|
84
|
+
: tr("Finding {number} · profile v{version}", { number: values[0], version: result.sequence })}`,
|
|
85
|
+
result.claim.statement, "", tr("Scope: {scope}", { scope: result.claim.scope }), tr("Uncertainty: {uncertainty}", { uncertainty: result.claim.uncertainty }),
|
|
86
|
+
"", tr("Supporting evidence:"), ...result.supporting_evidence.map(formatEvidence),
|
|
87
|
+
...(result.contradicting_evidence.length ? ["", tr("Conflicting evidence:"), ...result.contradicting_evidence.map(formatEvidence)] : []),
|
|
88
|
+
].join("\n");
|
|
89
|
+
} else throw new Error("Unknown saved profile command");
|
|
90
|
+
output.write(`${sanitizeTerminalText(json ? JSON.stringify(result, null, 2) : rendered)}\n`);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
truncateToWidth,
|
|
6
6
|
visibleWidth,
|
|
7
7
|
} from "@earendil-works/pi-tui";
|
|
8
|
+
import { screenTranslator } from "../../i18n/index.mjs";
|
|
8
9
|
|
|
9
10
|
function paintLine(value, width, theme) {
|
|
10
11
|
const clipped = truncateToWidth(value, width, "");
|
|
@@ -16,7 +17,8 @@ function paintLine(value, width, theme) {
|
|
|
16
17
|
|
|
17
18
|
/** A searchable Pi menu that keeps its own contents inside the viewport. */
|
|
18
19
|
export class ListeningMenu {
|
|
19
|
-
constructor({ items, title, getTheme, getRows, currentValue }) {
|
|
20
|
+
constructor({ items, title, getTheme, getRows, currentValue, getText = () => screenTranslator("en") }) {
|
|
21
|
+
this.getText = getText;
|
|
20
22
|
this.items = items;
|
|
21
23
|
this.title = title;
|
|
22
24
|
this.getTheme = getTheme;
|
|
@@ -125,9 +127,10 @@ export class ListeningMenu {
|
|
|
125
127
|
const title = theme.bold(truncateToWidth(this.title, titleWidth, ""));
|
|
126
128
|
const heading = title + " ".repeat(Math.max(1, inner - visibleWidth(title) - count.length)) + theme.muted(count);
|
|
127
129
|
const search = this.input.render(inner)[0];
|
|
130
|
+
const tr = this.getText();
|
|
128
131
|
const hint = theme.muted(inner >= 55
|
|
129
|
-
? "type to filter ↑↓ choose enter select esc back"
|
|
130
|
-
: inner >= 29 ? "↑↓ choose enter ↵ esc back" : "↑↓ ↵ esc");
|
|
132
|
+
? tr("type to filter ↑↓ choose enter select esc back")
|
|
133
|
+
: inner >= 29 ? tr("↑↓ choose enter ↵ esc back") : "↑↓ ↵ esc");
|
|
131
134
|
let lines;
|
|
132
135
|
if (budget < 4) {
|
|
133
136
|
// An exceptionally short terminal has no room for separate chrome rows.
|
|
@@ -144,7 +147,7 @@ export class ListeningMenu {
|
|
|
144
147
|
// The title owns the item count; omit SelectList's additional scroll-info row.
|
|
145
148
|
const candidates = this.filteredItems.length
|
|
146
149
|
? this.select.render(inner).slice(0, this.capacity)
|
|
147
|
-
: [theme.muted("Nothing matches. Backspace to widen the search.")];
|
|
150
|
+
: [theme.muted(tr("Nothing matches. Backspace to widen the search."))];
|
|
148
151
|
let labels = [heading, search];
|
|
149
152
|
if (compactBox) {
|
|
150
153
|
const labelWidth = Math.max(1, Math.floor(inner / 2));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { screenTranslator } from "../../i18n/index.mjs";
|
|
1
2
|
import { access } from "node:fs/promises";
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -11,6 +12,10 @@ export function profileCorrectionHelpText(commandPrefix = "moondog profile") {
|
|
|
11
12
|
|
|
12
13
|
Commands:
|
|
13
14
|
|
|
15
|
+
- \`${commandPrefix} build [--force]\` - build or resume a saved reading with your connected model
|
|
16
|
+
- \`${commandPrefix} saved [offset]\` - read saved findings, including after a restart
|
|
17
|
+
- \`${commandPrefix} explain <number>\` - inspect a saved finding's evidence
|
|
18
|
+
|
|
14
19
|
- \`${commandPrefix} corrections [--all] [--json]\` - everything you've told me; add --all to include earlier choices
|
|
15
20
|
- \`${commandPrefix} correct --artist <name> (--like|--avoid) [--note <text>] [--json]\` - like an artist, or keep them out
|
|
16
21
|
- \`${commandPrefix} correct --track <title> --by <artist> (--like|--avoid) [--note <text>] [--json]\` - like a track, or keep it out
|
|
@@ -107,14 +112,14 @@ function writeLine(output, value = "") {
|
|
|
107
112
|
output.write(`${sanitizeTerminalText(value)}\n`);
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
function formatCorrections(value) {
|
|
115
|
+
function formatCorrections(value, tr) {
|
|
111
116
|
const lines = [
|
|
112
|
-
"
|
|
117
|
+
`# ${tr("What you've told me")}`,
|
|
113
118
|
"",
|
|
114
|
-
|
|
119
|
+
tr.n(value.active, "{count} choice in effect.", "{count} choices in effect."),
|
|
115
120
|
];
|
|
116
121
|
if (value.corrections.length === 0) {
|
|
117
|
-
lines.push("", "Nothing yet. Open a song or artist in /taste to like it or keep it out.");
|
|
122
|
+
lines.push("", tr("Nothing yet. Open a song or artist in /taste to like it or keep it out."));
|
|
118
123
|
return lines.join("\n");
|
|
119
124
|
}
|
|
120
125
|
lines.push("");
|
|
@@ -122,8 +127,11 @@ function formatCorrections(value) {
|
|
|
122
127
|
const target = item.artist_credit
|
|
123
128
|
? `${item.label} - ${item.artist_credit}`
|
|
124
129
|
: item.label;
|
|
130
|
+
const choice = item.entity_type === "track"
|
|
131
|
+
? item.stance === "avoid" ? tr("Keep out track") : tr("Like track")
|
|
132
|
+
: item.stance === "avoid" ? tr("Keep out artist") : tr("Like artist");
|
|
125
133
|
lines.push(
|
|
126
|
-
`- ${
|
|
134
|
+
`- ${choice}: ${target}${item.state === "active" ? "" : ` (${item.state})`}`,
|
|
127
135
|
` ${String(item.occurred_at).slice(0, 10)} · \`${item.correction_id}\``,
|
|
128
136
|
);
|
|
129
137
|
if (item.note) lines.push(` "${item.note}"`);
|
|
@@ -131,22 +139,22 @@ function formatCorrections(value) {
|
|
|
131
139
|
return lines.join("\n");
|
|
132
140
|
}
|
|
133
141
|
|
|
134
|
-
function formatCorrection(value, commandPrefix) {
|
|
142
|
+
function formatCorrection(value, commandPrefix, tr) {
|
|
135
143
|
const target = value.artist_credit
|
|
136
144
|
? `${value.label} - ${value.artist_credit}`
|
|
137
145
|
: value.label;
|
|
138
146
|
return [
|
|
139
|
-
value.stance === "avoid" ?
|
|
140
|
-
...(value.superseded_correction_id ? ["This replaces what you told me before about it."] : []),
|
|
141
|
-
"Your profile is updated. Your listening history stays as it was.",
|
|
142
|
-
|
|
147
|
+
value.stance === "avoid" ? tr("Noted. I'll keep {name} out.", { name: target }) : tr("Noted. You like {name}.", { name: target }),
|
|
148
|
+
...(value.superseded_correction_id ? [tr("This replaces what you told me before about it.")] : []),
|
|
149
|
+
tr("Your profile is updated. Your listening history stays as it was."),
|
|
150
|
+
tr("To undo it: {command}", { command: `${commandPrefix} retract ${value.correction_id}` }),
|
|
143
151
|
].join("\n");
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
function formatRetraction(value) {
|
|
154
|
+
function formatRetraction(value, tr) {
|
|
147
155
|
return [
|
|
148
|
-
|
|
149
|
-
"Your profile is updated. Your listening history stays as it was.",
|
|
156
|
+
tr("Undone. I'll read {name} from your listening again.", { name: value.label }),
|
|
157
|
+
tr("Your profile is updated. Your listening history stays as it was."),
|
|
150
158
|
].join("\n");
|
|
151
159
|
}
|
|
152
160
|
|
|
@@ -169,6 +177,7 @@ export async function runProfileCommand({
|
|
|
169
177
|
openStore = openListeningHistoryStore,
|
|
170
178
|
now = () => new Date(),
|
|
171
179
|
commandPrefix = "moondog profile",
|
|
180
|
+
tr = screenTranslator("en"),
|
|
172
181
|
} = {}) {
|
|
173
182
|
const options = parseProfileArguments(args ?? []);
|
|
174
183
|
if (!options.action || options.action === "help") {
|
|
@@ -193,11 +202,11 @@ export async function runProfileCommand({
|
|
|
193
202
|
corrections: [],
|
|
194
203
|
writes: "none",
|
|
195
204
|
};
|
|
196
|
-
writeLine(output, json ? JSON.stringify(empty, null, 2) : formatCorrections(empty));
|
|
205
|
+
writeLine(output, json ? JSON.stringify(empty, null, 2) : formatCorrections(empty, tr));
|
|
197
206
|
return empty;
|
|
198
207
|
}
|
|
199
208
|
if (options.action === "retract" && !databasePresent) {
|
|
200
|
-
throw new Error("There's nothing to undo yet.");
|
|
209
|
+
throw new Error(tr("There's nothing to undo yet."));
|
|
201
210
|
}
|
|
202
211
|
if (
|
|
203
212
|
options.action === "correct" &&
|
|
@@ -206,7 +215,7 @@ export async function runProfileCommand({
|
|
|
206
215
|
preferredSubjectId = await resolvePreferredSubjectId();
|
|
207
216
|
if (!preferredSubjectId) {
|
|
208
217
|
throw new Error(
|
|
209
|
-
"There's no profile to change yet. Bring in your music with /import first.",
|
|
218
|
+
tr("There's no profile to change yet. Bring in your music with /import first."),
|
|
210
219
|
);
|
|
211
220
|
}
|
|
212
221
|
}
|
|
@@ -224,7 +233,7 @@ export async function runProfileCommand({
|
|
|
224
233
|
};
|
|
225
234
|
writeLine(
|
|
226
235
|
output,
|
|
227
|
-
json ? JSON.stringify(empty, null, 2) : formatCorrections(empty),
|
|
236
|
+
json ? JSON.stringify(empty, null, 2) : formatCorrections(empty, tr),
|
|
228
237
|
);
|
|
229
238
|
return empty;
|
|
230
239
|
}
|
|
@@ -236,7 +245,7 @@ export async function runProfileCommand({
|
|
|
236
245
|
}
|
|
237
246
|
if (!subjectId) {
|
|
238
247
|
throw new Error(
|
|
239
|
-
"There's no profile to change yet. Bring in your music with /import first.",
|
|
248
|
+
tr("There's no profile to change yet. Bring in your music with /import first."),
|
|
240
249
|
);
|
|
241
250
|
}
|
|
242
251
|
let value;
|
|
@@ -278,10 +287,10 @@ export async function runProfileCommand({
|
|
|
278
287
|
json
|
|
279
288
|
? JSON.stringify(value, null, 2)
|
|
280
289
|
: options.action === "corrections"
|
|
281
|
-
? formatCorrections(value)
|
|
290
|
+
? formatCorrections(value, tr)
|
|
282
291
|
: options.action === "correct"
|
|
283
|
-
? formatCorrection(value, commandPrefix)
|
|
284
|
-
: formatRetraction(value),
|
|
292
|
+
? formatCorrection(value, commandPrefix, tr)
|
|
293
|
+
: formatRetraction(value, tr),
|
|
285
294
|
);
|
|
286
295
|
return value;
|
|
287
296
|
} finally {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
import { formatListeningTime, sanitizeTerminalText } from "./format-output.mjs";
|
|
4
|
+
|
|
5
|
+
// The last twelve quarters as bars: listening time per quarter, or plays when the history
|
|
6
|
+
// didn't record how long every play lasted, with its new songs and leading artist beside
|
|
7
|
+
// each bar. Every terminal draws the bars with block characters.
|
|
8
|
+
// In Moondog's own window (MOONDOG_HOST=app), each bar row also carries an invisible
|
|
9
|
+
// OSC 7717 mark: block id, row, bar width, and the chart's numbers, so any row that is
|
|
10
|
+
// in view is enough. The window paints graphics over exactly those cells; the labels
|
|
11
|
+
// and details stay terminal text. A marked region keeps a blank cell on each side,
|
|
12
|
+
// which the graphics may bleed into.
|
|
13
|
+
export const BLOCK_MARK = 7717;
|
|
14
|
+
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
|
15
|
+
let charts = 0;
|
|
16
|
+
|
|
17
|
+
function textBar(fraction, width) {
|
|
18
|
+
const eighths = Math.round(Math.max(0, Math.min(1, fraction)) * width * 8);
|
|
19
|
+
return "█".repeat(Math.floor(eighths / 8)) + EIGHTHS[eighths % 8];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function inline(value) {
|
|
23
|
+
return sanitizeTerminalText(String(value ?? "")).replace(/\s+/gu, " ").trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class SeasonsChart {
|
|
27
|
+
constructor(seasons, { getTheme, tr, environment = process.env }) {
|
|
28
|
+
this.seasons = seasons.filter((season) => /^\d{4}-Q[1-4]$/u.test(season?.key ?? "")).slice(-12);
|
|
29
|
+
this.getTheme = getTheme;
|
|
30
|
+
this.tr = tr;
|
|
31
|
+
this.marked = environment.MOONDOG_HOST === "app";
|
|
32
|
+
this.id = `seasons-${(charts += 1)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
invalidate() {}
|
|
36
|
+
|
|
37
|
+
render(width) {
|
|
38
|
+
const theme = this.getTheme();
|
|
39
|
+
const tr = this.tr;
|
|
40
|
+
// Minutes compare quarters only when every quarter with plays has them. Otherwise every
|
|
41
|
+
// bar counts plays: Last.fm and ListenBrainz record no play lengths, and unknown isn't zero.
|
|
42
|
+
const timed = this.seasons.every((season) => !(season.event_count > 0) || Number.isFinite(season.listening_minutes));
|
|
43
|
+
const amounts = this.seasons.map((season) => {
|
|
44
|
+
const amount = timed ? season.listening_minutes : season.event_count;
|
|
45
|
+
return Number.isFinite(amount) ? amount : 0;
|
|
46
|
+
});
|
|
47
|
+
const most = Math.max(1, ...amounts);
|
|
48
|
+
const details = this.seasons.map((season) => {
|
|
49
|
+
if (!Number.isInteger(season.event_count) || season.event_count === 0) return tr("nothing in your history");
|
|
50
|
+
const artist = inline(season.leading_artist?.name);
|
|
51
|
+
return [
|
|
52
|
+
timed ? formatListeningTime(season.listening_minutes, tr) : tr.n(season.event_count, "{count} play", "{count} plays"),
|
|
53
|
+
season.first_observed_tracks > 0 ? tr("{count} new", { count: tr.number(season.first_observed_tracks) }) : null,
|
|
54
|
+
artist || null,
|
|
55
|
+
].filter(Boolean).join(" · ");
|
|
56
|
+
});
|
|
57
|
+
const detailWidth = Math.min(28, Math.max(...details.map((detail) => visibleWidth(detail))));
|
|
58
|
+
// " 2024 Q3 bars details": the bars take what the label and a short detail leave.
|
|
59
|
+
const barWidth = Math.max(8, Math.min(30, width - 1 - 7 - 2 - 2 - detailWidth));
|
|
60
|
+
const payload = btoa(JSON.stringify({
|
|
61
|
+
kind: "seasons",
|
|
62
|
+
measure: timed ? "minutes" : "plays",
|
|
63
|
+
rows: this.seasons.map((season, row) => ({
|
|
64
|
+
quarter: Number(season.key.slice(-1)),
|
|
65
|
+
amount: amounts[row],
|
|
66
|
+
fraction: amounts[row] / most,
|
|
67
|
+
newSongs: Number.isInteger(season.first_observed_tracks) ? season.first_observed_tracks : 0,
|
|
68
|
+
empty: !(season.event_count > 0),
|
|
69
|
+
})),
|
|
70
|
+
colors: {
|
|
71
|
+
ink: theme.palette.foreground,
|
|
72
|
+
muted: theme.palette.muted,
|
|
73
|
+
faint: theme.palette.faint,
|
|
74
|
+
paper: theme.palette.background,
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
return this.seasons.map((season, row) => {
|
|
78
|
+
const [year, quarter] = season.key.split("-");
|
|
79
|
+
// The year shows where it changes, so the column reads as a calendar.
|
|
80
|
+
const label = row === 0 || quarter === "Q1" ? `${year} ${quarter}` : ` ${quarter}`;
|
|
81
|
+
const bar = amounts[row] > 0 ? textBar(amounts[row] / most, barWidth) : "·";
|
|
82
|
+
const mark = this.marked ? `\x1b]${BLOCK_MARK};${this.id};${row};${barWidth};${payload}\x07` : "";
|
|
83
|
+
const line = ` ${theme.muted(label)} ${mark}${theme.text(bar)}${" ".repeat(Math.max(0, barWidth - visibleWidth(bar)))} ${theme.muted(details[row])}`;
|
|
84
|
+
return truncateToWidth(line, width, "");
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -16,9 +16,10 @@ import {
|
|
|
16
16
|
projectSpotifyRecentActivity,
|
|
17
17
|
} from "../../integrations/spotify/recent-activity.mjs";
|
|
18
18
|
import { readSpotifyHistoryArchive } from "../../integrations/spotify/history-archive.mjs";
|
|
19
|
+
import { projectSpotifyLibrarySnapshot } from "../../integrations/spotify/library-snapshot.mjs";
|
|
19
20
|
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
20
21
|
|
|
21
|
-
const readCommands = new Set(["account", "now", "devices", "queue", "recent"]);
|
|
22
|
+
const readCommands = new Set(["account", "now", "devices", "queue", "recent", "library"]);
|
|
22
23
|
const writeCommands = new Set([
|
|
23
24
|
"play",
|
|
24
25
|
"pause",
|
|
@@ -50,6 +51,8 @@ Commands:
|
|
|
50
51
|
- \`moondog spotify account|now|devices|queue [--json]\`
|
|
51
52
|
- \`moondog spotify recent [--limit <1-50>] [--after <ms>|--before <ms>] [--json]\`
|
|
52
53
|
- \`moondog spotify sync-recent [--limit <1-50>] [--json]\`
|
|
54
|
+
- \`moondog spotify library [--json]\`
|
|
55
|
+
- \`moondog spotify sync-library [--json]\`
|
|
53
56
|
- \`moondog spotify import-history <spotify-history.zip> [--json]\`
|
|
54
57
|
- \`moondog spotify play [spotify-uri] [--json]\`
|
|
55
58
|
- \`moondog spotify pause|next|previous [--json]\`
|
|
@@ -260,7 +263,7 @@ function formatPlain(action, value) {
|
|
|
260
263
|
`Redirect URI: ${SPOTIFY_DEFAULT_REDIRECT_URI}`,
|
|
261
264
|
];
|
|
262
265
|
if (value.state === "stored") {
|
|
263
|
-
const purpose = value.scope_purpose === "recent_listening" ? "reading recent plays" : "playback and
|
|
266
|
+
const purpose = value.scope_purpose === "recent_listening" ? "reading recent plays" : "playback, playlists and top artists/tracks";
|
|
264
267
|
lines.push(
|
|
265
268
|
value.scopes_sufficient
|
|
266
269
|
? `Permissions for ${purpose}: all set`
|
|
@@ -351,6 +354,26 @@ function formatPlain(action, value) {
|
|
|
351
354
|
`- ${value.inserted_track_refs ?? 0} songs new to your profile`,
|
|
352
355
|
].join("\n");
|
|
353
356
|
}
|
|
357
|
+
if (action === "library") {
|
|
358
|
+
return [
|
|
359
|
+
"# Your Spotify library",
|
|
360
|
+
`- ${value.saved_tracks?.length ?? 0} saved songs, ${value.saved_albums?.length ?? 0} saved albums`,
|
|
361
|
+
`- ${value.followed_artists?.length ?? 0} followed artists`,
|
|
362
|
+
`- ${value.playlists?.length ?? 0} of your own playlists`,
|
|
363
|
+
`- Top artists and tracks for the last four weeks, six months and year`,
|
|
364
|
+
].join("\n");
|
|
365
|
+
}
|
|
366
|
+
if (action === "sync-library") {
|
|
367
|
+
if (value.already_imported) return "Your Spotify library hasn't changed since the last sync.";
|
|
368
|
+
return [
|
|
369
|
+
"# Your Spotify library is in",
|
|
370
|
+
`- ${value.preview.savedTracks} saved songs, ${value.preview.savedAlbums} saved albums, ${value.preview.followedArtists} followed artists`,
|
|
371
|
+
`- ${value.preview.playlistTracks} songs across ${value.preview.playlists} of your own playlists`,
|
|
372
|
+
`- ${value.preview.topArtists} top artists and ${value.preview.topTracks} top tracks from Spotify`,
|
|
373
|
+
...(value.preview.truncated.length ? [`- Very large library: stopped reading ${value.preview.truncated.join(", ").replaceAll("_", " ")} at Moondog's limit`] : []),
|
|
374
|
+
...(value.preview.skippedPlaylists ? [`- ${value.preview.skippedPlaylists} playlists couldn't be read and were skipped`] : []),
|
|
375
|
+
].join("\n");
|
|
376
|
+
}
|
|
354
377
|
if (action === "import-history") {
|
|
355
378
|
const extended =
|
|
356
379
|
value.data_scope === "lifetime_extended_streaming_history";
|
|
@@ -478,7 +501,8 @@ function createRuntimeResolver({
|
|
|
478
501
|
const auth = await this.authentication();
|
|
479
502
|
client = createSpotifyWebApiClient({
|
|
480
503
|
fetchImpl,
|
|
481
|
-
tokenProvider: () => auth.getAccessToken(),
|
|
504
|
+
tokenProvider: (options) => auth.getAccessToken(options),
|
|
505
|
+
refreshAccessToken: (options) => auth.refreshAccessToken(options),
|
|
482
506
|
});
|
|
483
507
|
return client;
|
|
484
508
|
},
|
|
@@ -507,11 +531,12 @@ function createRuntimeResolver({
|
|
|
507
531
|
};
|
|
508
532
|
}
|
|
509
533
|
|
|
510
|
-
async function runReadCommand(action, service, input) {
|
|
534
|
+
async function runReadCommand(action, service, input, { signal, onProgress } = {}) {
|
|
511
535
|
if (action === "account") return service.account();
|
|
512
536
|
if (action === "now") return service.currentPlayer();
|
|
513
537
|
if (action === "devices") return service.devices();
|
|
514
538
|
if (action === "recent") return service.recentActivity(input);
|
|
539
|
+
if (action === "library") return service.librarySnapshot({ signal, onProgress });
|
|
515
540
|
return service.queue();
|
|
516
541
|
}
|
|
517
542
|
|
|
@@ -601,6 +626,7 @@ export async function runSpotifyCommand({
|
|
|
601
626
|
spotify,
|
|
602
627
|
signalTarget = process,
|
|
603
628
|
signal,
|
|
629
|
+
onProgress,
|
|
604
630
|
} = {}) {
|
|
605
631
|
const options = parseArguments(args, json);
|
|
606
632
|
const action = options.action;
|
|
@@ -617,6 +643,7 @@ export async function runSpotifyCommand({
|
|
|
617
643
|
"logout",
|
|
618
644
|
"resolve",
|
|
619
645
|
"sync-recent",
|
|
646
|
+
"sync-library",
|
|
620
647
|
"import-history",
|
|
621
648
|
...readCommands,
|
|
622
649
|
...writeCommands,
|
|
@@ -793,7 +820,7 @@ export async function runSpotifyCommand({
|
|
|
793
820
|
: (exactArguments(action, options.rest, 0), undefined);
|
|
794
821
|
const service = await runtime.service();
|
|
795
822
|
const result = await externalCall(
|
|
796
|
-
() => runReadCommand(action, service, input),
|
|
823
|
+
() => runReadCommand(action, service, input, { signal, onProgress }),
|
|
797
824
|
`Spotify ${action} could not be read.`,
|
|
798
825
|
);
|
|
799
826
|
emit(stdout, action, result, options.json);
|
|
@@ -814,10 +841,12 @@ export async function runSpotifyCommand({
|
|
|
814
841
|
const service = await runtime.service();
|
|
815
842
|
const page = await externalCall(
|
|
816
843
|
() =>
|
|
844
|
+
// Read Spotify's whole window: the cursor moves past whatever a shorter read leaves out.
|
|
817
845
|
service.recentActivity({
|
|
846
|
+
limit: 50,
|
|
818
847
|
...input,
|
|
819
848
|
...(cursor !== null ? { after: cursor } : {}),
|
|
820
|
-
}),
|
|
849
|
+
}, { signal }),
|
|
821
850
|
"Spotify recent activity could not be read.",
|
|
822
851
|
);
|
|
823
852
|
const currentTime = now();
|
|
@@ -839,6 +868,25 @@ export async function runSpotifyCommand({
|
|
|
839
868
|
return result;
|
|
840
869
|
}
|
|
841
870
|
|
|
871
|
+
if (action === "sync-library") {
|
|
872
|
+
exactArguments(action, options.rest, 0);
|
|
873
|
+
if (!recentActivityStore || typeof recentActivityStore.ingestImport !== "function") {
|
|
874
|
+
throw commandError("Spotify library storage is unavailable.");
|
|
875
|
+
}
|
|
876
|
+
if (typeof subjectId !== "string" || !subjectId.trim()) {
|
|
877
|
+
throw commandError("A trusted local music subject is required for a library sync.");
|
|
878
|
+
}
|
|
879
|
+
const service = await runtime.service();
|
|
880
|
+
const snapshot = await externalCall(() => service.librarySnapshot({ signal, onProgress }), "Your Spotify library could not be read.");
|
|
881
|
+
const currentTime = now();
|
|
882
|
+
if (!Number.isFinite(currentTime)) throw commandError("Spotify library capture time is unavailable.");
|
|
883
|
+
const projected = projectSpotifyLibrarySnapshot({ subjectId, snapshot, capturedAt: new Date(currentTime).toISOString() });
|
|
884
|
+
const receipt = await externalCall(() => recentActivityStore.ingestImport(projected.bundle), "Your Spotify library could not be stored.");
|
|
885
|
+
const result = { ...receipt, preview: projected.preview };
|
|
886
|
+
emit(stdout, action, result, options.json);
|
|
887
|
+
return result;
|
|
888
|
+
}
|
|
889
|
+
|
|
842
890
|
const service = await runtime.service();
|
|
843
891
|
const result = await externalCall(
|
|
844
892
|
() => runWriteCommand(action, options.rest, service),
|