@audiofool/moondog 0.3.0-beta.7 → 0.3.0-beta.8
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 +1 -1
- package/package.json +1 -1
- package/scripts/moondog.mjs +6 -2
- package/src/core/capability-catalog.mjs +33 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +7 -2
- package/src/core/moondog-application.mjs +280 -8
- package/src/i18n/screen/es.mjs +17 -0
- package/src/i18n/screen/ja.mjs +17 -0
- package/src/i18n/screen/pt.mjs +17 -0
- package/src/i18n/screen/zh.mjs +17 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +22 -2
- package/src/integrations/stable-audio/local-generator.mjs +8 -1
- package/src/runtime/pi/agent-runtime.mjs +111 -6
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/tool-arguments.mjs +5 -1
- package/src/surfaces/cli/tui.mjs +60 -1
|
@@ -183,6 +183,7 @@ function agentCapabilityAllowed(descriptor) {
|
|
|
183
183
|
(descriptor.capability_id.startsWith("netease.") &&
|
|
184
184
|
spotifyCapabilityEffects.has(descriptor.effect)) ||
|
|
185
185
|
(descriptor.capability_id === "music.preview.play" && descriptor.effect === "play_preview") ||
|
|
186
|
+
(descriptor.capability_id === "collection.play" && descriptor.effect === "play_library") ||
|
|
186
187
|
(["music.generate.local", "music.generate.replay", "music.generate.stream", "music.generate.stream.stop"].includes(descriptor.capability_id) &&
|
|
187
188
|
descriptor.effect === "generate_local")
|
|
188
189
|
);
|
|
@@ -3055,7 +3056,8 @@ function safeDomainFailure(error) {
|
|
|
3055
3056
|
}
|
|
3056
3057
|
const details = spotifyFailureDetails(error);
|
|
3057
3058
|
const evidence = [details.status ? `HTTP ${details.status}` : "", details.reason].filter(Boolean);
|
|
3058
|
-
|
|
3059
|
+
// The status is for the model's diagnosis; labelled so, it is not copied into a reply.
|
|
3060
|
+
return new Error(`${code}: ${message}${evidence.length ? ` [diagnosis only, never for the listener: ${evidence.join("; ")}]` : ""}${details.not_sent ? " No Spotify write was dispatched for this attempt. Correct the arguments using retained host references; this is not an uncertain external effect." : ""}`);
|
|
3059
3061
|
}
|
|
3060
3062
|
|
|
3061
3063
|
function spotifyFailureDetails(error) {
|
|
@@ -3487,7 +3489,7 @@ function projectSpotifyQueuePlan(value) {
|
|
|
3487
3489
|
result.stopped = projectQueueTrackList([value.stopped], "spotify_queue_stopped")[0];
|
|
3488
3490
|
if (value.failure) result.failure = { action: "playback.queue.add", ...spotifyFailureDetails(value.failure) };
|
|
3489
3491
|
}
|
|
3490
|
-
for (const field of ["skipped_duplicate_count", "skipped_avoided_count", "skipped_known_count", "skipped_uncertain_count", "not_added_count"]) {
|
|
3492
|
+
for (const field of ["skipped_duplicate_count", "skipped_avoided_count", "skipped_known_count", "skipped_uncertain_count", "skipped_unavailable_count", "not_added_count"]) {
|
|
3491
3493
|
if (value[field] !== undefined) result[field] = safeNonnegativeInteger(value[field], field);
|
|
3492
3494
|
}
|
|
3493
3495
|
if (["queue.similar", "queue.batch"].includes(value.action)) {
|
|
@@ -6606,6 +6608,93 @@ function createToolFactories(
|
|
|
6606
6608
|
),
|
|
6607
6609
|
}),
|
|
6608
6610
|
],
|
|
6611
|
+
[
|
|
6612
|
+
"collection.list",
|
|
6613
|
+
(descriptor) => ({
|
|
6614
|
+
name: descriptor.tool_name,
|
|
6615
|
+
label: descriptor.label,
|
|
6616
|
+
description:
|
|
6617
|
+
"Find playlists in the listener's own library on this Mac by words of their name, or list the newest ones without words. Saved is the built-in playlist of single songs and made tracks they kept. Each playlist says how many entries it holds and how many of them Moondog made. With playlist_id, it reads that playlist's entries in order.",
|
|
6618
|
+
parameters: Type.Object(
|
|
6619
|
+
{
|
|
6620
|
+
query: Type.Optional(Type.String({ maxLength: 120 })),
|
|
6621
|
+
playlist_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
6622
|
+
},
|
|
6623
|
+
{ additionalProperties: false },
|
|
6624
|
+
),
|
|
6625
|
+
execute: executeDomain(
|
|
6626
|
+
async (_toolCallId, parameters) => {
|
|
6627
|
+
if (parameters.playlist_id) {
|
|
6628
|
+
const playlist = await application.libraryPlaylist(parameters.playlist_id);
|
|
6629
|
+
if (!playlist) throw Object.assign(new Error("That playlist is not in the library."), { code: "library_playlist_not_found" });
|
|
6630
|
+
return { playlist: { id: playlist.id, name: playlist.saved ? "Saved" : playlist.name, origin: playlist.origin,
|
|
6631
|
+
entries: playlist.entries.slice(0, 60).map((entry) => ({ title: entry.title, artist: entry.artist_credit, ...(entry.kind === "made" ? { made_by_moondog: true } : {}) })),
|
|
6632
|
+
total: playlist.entries.length } };
|
|
6633
|
+
}
|
|
6634
|
+
return { playlists: application.findLibraryPlaylists(parameters.query ?? "") };
|
|
6635
|
+
},
|
|
6636
|
+
(value) => structuredClone(value),
|
|
6637
|
+
),
|
|
6638
|
+
}),
|
|
6639
|
+
],
|
|
6640
|
+
[
|
|
6641
|
+
"collection.save",
|
|
6642
|
+
(descriptor) => ({
|
|
6643
|
+
name: descriptor.tool_name,
|
|
6644
|
+
label: descriptor.label,
|
|
6645
|
+
description:
|
|
6646
|
+
"Keep music in the listener's own library on this Mac, only when they ask to save, keep, like or collect it. from now_playing keeps the song or made track playing now; from queue keeps it and what plays after it (the queue, or the rest of the playlist it plays from). Or give songs by title and artist, or made_track_id for a track Moondog made (product.made_tracks). With no playlist it goes in Saved; playlist_id adds to one of their playlists (from moondog_collection_list); playlist_name makes a new playlist with that name, in the listener's language. Saved holds each song once; already says how many were there.",
|
|
6647
|
+
parameters: Type.Object(
|
|
6648
|
+
{
|
|
6649
|
+
from: Type.Optional(Type.Union([Type.Literal("now_playing"), Type.Literal("queue")])),
|
|
6650
|
+
songs: Type.Optional(Type.Array(Type.Object({
|
|
6651
|
+
title: Type.String({ minLength: 1, maxLength: 200 }),
|
|
6652
|
+
artist: Type.String({ minLength: 1, maxLength: 200 }),
|
|
6653
|
+
album: Type.Optional(Type.String({ maxLength: 200 })),
|
|
6654
|
+
}, { additionalProperties: false }), { minItems: 1, maxItems: 50 })),
|
|
6655
|
+
made_track_id: Type.Optional(Type.String({ minLength: 36, maxLength: 36 })),
|
|
6656
|
+
playlist_id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
6657
|
+
playlist_name: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })),
|
|
6658
|
+
},
|
|
6659
|
+
{ additionalProperties: false },
|
|
6660
|
+
),
|
|
6661
|
+
executionMode: "sequential",
|
|
6662
|
+
execute: executeDomain(
|
|
6663
|
+
async (_toolCallId, parameters, signal) => application.saveToLibrary({
|
|
6664
|
+
from: parameters.from, songs: parameters.songs, madeTrackId: parameters.made_track_id,
|
|
6665
|
+
playlistId: parameters.playlist_id, playlistName: parameters.playlist_name,
|
|
6666
|
+
}, { by: "listener", signal }),
|
|
6667
|
+
(value) => ({
|
|
6668
|
+
playlist: { id: cleanOutputText(value.playlist?.id, 128, "playlist_id"), name: cleanOutputText(value.playlist?.name, 120, "playlist_name"),
|
|
6669
|
+
...(value.playlist?.created ? { created: true } : {}) },
|
|
6670
|
+
added: Number(value.added) || 0,
|
|
6671
|
+
already: Number(value.already) || 0,
|
|
6672
|
+
titles: (value.titles ?? []).slice(0, 50).map((title) => cleanOutputText(title, 200, "title")),
|
|
6673
|
+
}),
|
|
6674
|
+
),
|
|
6675
|
+
}),
|
|
6676
|
+
],
|
|
6677
|
+
[
|
|
6678
|
+
"collection.play",
|
|
6679
|
+
(descriptor) => ({
|
|
6680
|
+
name: descriptor.tool_name,
|
|
6681
|
+
label: descriptor.label,
|
|
6682
|
+
description:
|
|
6683
|
+
"Play a playlist from the listener's own library, by its id from moondog_collection_list, on the music service they chose. It is the listener's own music, so say its name and let it play; don't describe it as Moondog's pick. With Apple Music, songs and Moondog's tracks play in one queue in Moondog's player. With Spotify or NetEase its songs play there, and left_out_made says how many of Moondog's tracks in it could not play there. not_found counts songs no service found.",
|
|
6684
|
+
parameters: Type.Object(
|
|
6685
|
+
{
|
|
6686
|
+
playlist_id: Type.String({ minLength: 1, maxLength: 128 }),
|
|
6687
|
+
shuffle: Type.Optional(Type.Boolean()),
|
|
6688
|
+
},
|
|
6689
|
+
{ additionalProperties: false },
|
|
6690
|
+
),
|
|
6691
|
+
executionMode: "sequential",
|
|
6692
|
+
execute: executeDomain(
|
|
6693
|
+
async (_toolCallId, parameters, signal) => application.playLibrary({ playlistId: parameters.playlist_id, shuffle: parameters.shuffle === true }, { signal }),
|
|
6694
|
+
(value) => structuredClone(value),
|
|
6695
|
+
),
|
|
6696
|
+
}),
|
|
6697
|
+
],
|
|
6609
6698
|
[
|
|
6610
6699
|
"playlist.plan",
|
|
6611
6700
|
(descriptor) => ({
|
|
@@ -6823,6 +6912,7 @@ Public web research rules:
|
|
|
6823
6912
|
|
|
6824
6913
|
Grounding and evidence rules:
|
|
6825
6914
|
- Before naming or selecting tracks as present in the user's library, call moondog_library_search.
|
|
6915
|
+
- The listener's collection in Moondog (Saved and their own playlists, which can hold songs and tracks Moondog made) is separate from their imported library and from Spotify. When they ask to save, keep, collect or like music (收藏, 存下来) without naming Spotify, use moondog_collection_save; use moondog_spotify_library_save only when they name Spotify or Liked Songs. To play one of their playlists, find it with moondog_collection_list and play it with moondog_collection_play.
|
|
6826
6916
|
- When personalization matters, call moondog_profile_summary instead of assuming a profile.
|
|
6827
6917
|
- Before saying how often, when, or whether the listener played something, check the listening coverage. Plays inside a gap, outside every source's span, or after recent_plays_checked_at are unknown, not zero. When a gap could hold the plays in question, lead with that boundary, for example "I have no records from August 30 to October 21", and do not conclude that the listener rarely plays it or only just found it.
|
|
6828
6918
|
- The saved reading is enduring taste up to its listening date. Recent attention is the present: stored plays from the last four weeks, and Spotify's four-week top artists, which count listening on every device, including plays Moondog never stored and songs Moondog itself played or queued; stored plays count those apart as moondog_started_plays. When they disagree about what the listener plays lately, trust recent attention, and say which one you read.
|
|
@@ -7250,8 +7340,11 @@ function mergedQueueReceipt(plan, singles) {
|
|
|
7250
7340
|
// A batch's mix line does not cover the single adds beside it.
|
|
7251
7341
|
const { mix: _mix, ...base } = plan ?? { provider: "spotify", action: "playback.queue.add", state: "accepted", queued: [], unmatched: [], not_added: [] };
|
|
7252
7342
|
const checks = [base.verification, ...singles.map(receipt => receipt.verification)].filter(Boolean);
|
|
7343
|
+
const queued = [...base.queued, ...targets];
|
|
7253
7344
|
return { ...base, state: ["no_candidates", "no_playback"].includes(base.state) ? "accepted" : base.state,
|
|
7254
|
-
queued
|
|
7345
|
+
queued,
|
|
7346
|
+
// Single adds that filled a short batch count against its shortfall too.
|
|
7347
|
+
...(Number.isInteger(base.requested) ? { shortfall: Math.max(0, base.requested - queued.length) } : {}),
|
|
7255
7348
|
...(base.device ?? singles.find(receipt => receipt.device)?.device ? { device: base.device ?? singles.find(receipt => receipt.device).device } : {}),
|
|
7256
7349
|
...(base.started_playback ? {} : singles[0]?.started_playback ? { started_playback: targets[0] } : {}),
|
|
7257
7350
|
...(checks.length ? { verification: { checked: checks.every(check => check.checked),
|
|
@@ -7915,9 +8008,21 @@ export class PiAgentRuntime {
|
|
|
7915
8008
|
};
|
|
7916
8009
|
}
|
|
7917
8010
|
const state = this.activePromptState;
|
|
7918
|
-
|
|
8011
|
+
const batch = state?.spotifyQueuePlan?.action === "queue.batch" && state.spotifyQueuePlan.queued.length > 0 ? state.spotifyQueuePlan : null;
|
|
8012
|
+
// A batch that came up short may still be filled by single adds of other songs, one per
|
|
8013
|
+
// missing song; a song the batch queued is never added again.
|
|
8014
|
+
// The application counts the fills and checks each against the queue; this only lets one through.
|
|
8015
|
+
const shortfall = batch ? Math.max(0, (batch.requested ?? 0) - batch.queued.length) : 0;
|
|
8016
|
+
const fillRef = toolCall.arguments?.item_ref_id ?? toolCall.arguments?.track_ref_id;
|
|
8017
|
+
const fillItem = batch && descriptor.capability_id === "spotify.queue.add" && typeof fillRef === "string" && !toolCall.arguments?.pending_plan
|
|
8018
|
+
? application.spotifyHostReadItems?.().find((item) => item.item_ref_id === fillRef) : null;
|
|
8019
|
+
// Which song is a duplicate is the application's call, by title and artist, against the queue too.
|
|
8020
|
+
const fillsBatch = Boolean(fillItem?.type === "track" && shortfall > 0);
|
|
8021
|
+
if (batch && !fillsBatch &&
|
|
7919
8022
|
["spotify.player.control", "spotify.queue.add", "spotify.queue.batch", "spotify.device.transfer", "spotify.queue.similar"].includes(descriptor.capability_id)) {
|
|
7920
|
-
return { block: true, reason:
|
|
8023
|
+
return { block: true, reason: shortfall > 0 && descriptor.capability_id === "spotify.queue.add"
|
|
8024
|
+
? "That song is already in the batch's receipt, or its reference is unknown. Add only a missing song by its item_ref_id."
|
|
8025
|
+
: "The requested queue batch already has a receipt. Do not add, replay or replace its writes in this turn." };
|
|
7921
8026
|
}
|
|
7922
8027
|
const listeningContext = application.spotifyPlaybackContextStatus?.();
|
|
7923
8028
|
if (listeningContext?.queue_request && state) {
|
|
@@ -7927,7 +8032,7 @@ export class PiAgentRuntime {
|
|
|
7927
8032
|
if (listeningContext?.queue_request?.clarification_only && descriptor.effect === "write_external") {
|
|
7928
8033
|
return { block: true, reason: "The earlier queue already has an accepted, failed or uncertain receipt. A clarification about queue versus playlist must not replay it. Report the existing receipt." };
|
|
7929
8034
|
}
|
|
7930
|
-
if (listeningContext?.queue_request?.requested > 1 && descriptor.capability_id === "spotify.queue.add" && !toolCall.arguments?.pending_plan) {
|
|
8035
|
+
if (listeningContext?.queue_request?.requested > 1 && descriptor.capability_id === "spotify.queue.add" && !toolCall.arguments?.pending_plan && !fillsBatch) {
|
|
7931
8036
|
return { block: true, reason: "Use moondog_spotify_queue_batch for this multi-song queue request so Avoid, deduplication, count and partial receipts are preserved." };
|
|
7932
8037
|
}
|
|
7933
8038
|
// A refused or uncertain write ends the turn's playback writes; the model explains it.
|
|
@@ -89,11 +89,27 @@ function replayableBody(input, init) {
|
|
|
89
89
|
ArrayBuffer.isView(body) || body instanceof URLSearchParams || body instanceof Blob;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Statuses that say the provider is busy right now, not that the request is wrong.
|
|
93
|
+
const overloadStatuses = new Set([502, 503, 504, 529]);
|
|
94
|
+
const maximumOverloadRetries = 3;
|
|
95
|
+
|
|
96
|
+
// How long to wait before sending an overloaded request again, or null to return the response.
|
|
97
|
+
// A 429 is retried only when the provider asks for a short wait; a long one is usually a quota.
|
|
98
|
+
function overloadRetryDelay(response, retries) {
|
|
99
|
+
if (!response || typeof response.status !== "number") return null;
|
|
100
|
+
const value = response.headers?.get?.("retry-after");
|
|
101
|
+
const header = value === null || value === undefined || value === "" ? NaN : Number(value);
|
|
102
|
+
const asked = Number.isFinite(header) && header >= 0 ? header * 1000 : null;
|
|
103
|
+
if (response.status === 429) return asked !== null && asked <= 10_000 ? asked : null;
|
|
104
|
+
if (!overloadStatuses.has(response.status)) return null;
|
|
105
|
+
return Math.min(asked ?? 1000 * 2 ** retries, 10_000);
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
async function retryDelay(milliseconds, signal) {
|
|
93
109
|
await delay(milliseconds, undefined, { signal });
|
|
94
110
|
}
|
|
95
111
|
|
|
96
|
-
/** Recover
|
|
112
|
+
/** Recover rejected model HTTP fetches, and overloaded answers, before a stream exists. */
|
|
97
113
|
export function createModelFetch({
|
|
98
114
|
provider,
|
|
99
115
|
fetchImpl = globalThis.fetch,
|
|
@@ -104,11 +120,24 @@ export function createModelFetch({
|
|
|
104
120
|
return async (input, init) => {
|
|
105
121
|
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
106
122
|
const canReplay = replayableBody(input, init);
|
|
107
|
-
|
|
123
|
+
// Connection failures and overloaded answers each have their own small budget.
|
|
124
|
+
let overloadAttempts = 0;
|
|
125
|
+
let attempt = 0;
|
|
126
|
+
for (;;) {
|
|
108
127
|
signal?.throwIfAborted();
|
|
109
128
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
129
|
+
const response = await fetchImpl(input, init);
|
|
130
|
+
// A provider that is overloaded answers before it produces anything, so the same request
|
|
131
|
+
// is sent again after a short wait. Other status codes and body errors stay Pi's.
|
|
132
|
+
const retryAfter = overloadRetryDelay(response, overloadAttempts);
|
|
133
|
+
if (canReplay && retryAfter !== null && overloadAttempts < maximumOverloadRetries) {
|
|
134
|
+
overloadAttempts += 1;
|
|
135
|
+
await response.body?.cancel().catch(() => {});
|
|
136
|
+
onRetry?.({ attempt: overloadAttempts, maxRetries: maximumOverloadRetries, status: response.status });
|
|
137
|
+
await wait(retryAfter, signal);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
return response;
|
|
112
141
|
} catch (cause) {
|
|
113
142
|
if (signal?.aborted || cause?.name === "AbortError") throw cause;
|
|
114
143
|
const failure = connectionFailure(cause);
|
|
@@ -116,6 +145,7 @@ export function createModelFetch({
|
|
|
116
145
|
if (canReplay && failure.retryable && attempt < maximumRetries) {
|
|
117
146
|
onRetry?.({ attempt: attempt + 1, maxRetries: maximumRetries });
|
|
118
147
|
await wait(250 * 2 ** attempt, signal);
|
|
148
|
+
attempt += 1;
|
|
119
149
|
continue;
|
|
120
150
|
}
|
|
121
151
|
const error = createModelConnectionError({ provider, cause });
|
|
@@ -15,8 +15,12 @@ function passes(tool, args) {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// A key that is no identifier ("/invoke", "invoke name=\"…\"") is a model's own tool-call markup
|
|
19
|
+
// leaking into the arguments. No tool takes such a key, so it carries no meaning to refuse.
|
|
20
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
21
|
+
|
|
18
22
|
function withoutEmpty(args) {
|
|
19
|
-
return Object.fromEntries(Object.entries(args).filter(([, value]) =>
|
|
23
|
+
return Object.fromEntries(Object.entries(args).filter(([key, value]) => IDENTIFIER.test(key) &&
|
|
20
24
|
value !== null && value !== undefined && !(typeof value === "string" && !value.trim())));
|
|
21
25
|
}
|
|
22
26
|
|
package/src/surfaces/cli/tui.mjs
CHANGED
|
@@ -125,6 +125,7 @@ const slashCommands = [
|
|
|
125
125
|
{ name: "endpoint", description: N_("Add a model service by its address") },
|
|
126
126
|
{ name: "web", description: N_("Look things up on public music sites") },
|
|
127
127
|
{ name: "service", description: N_("Choose where Moondog plays music") },
|
|
128
|
+
{ name: "library", description: N_("Your saved songs and playlists") },
|
|
128
129
|
{ name: "generation", description: N_("Let Moondog make music on this Mac") },
|
|
129
130
|
{ name: "onair", description: N_("Go on air: Moondog keeps making music from your taste") },
|
|
130
131
|
{ name: "spotify", description: N_("Connect and control Spotify") },
|
|
@@ -250,7 +251,8 @@ export async function runMoondogTui({
|
|
|
250
251
|
// /onair needs both: the room's own player and music making here.
|
|
251
252
|
const onAirControls = previewControls && typeof generation?.save === "function";
|
|
252
253
|
const commands = slashCommands.filter(({ name }) => (previewControls || !["skip", "stop"].includes(name)) &&
|
|
253
|
-
(name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls)
|
|
254
|
+
(name !== "generation" || typeof generation?.save === "function") && (name !== "onair" || onAirControls) &&
|
|
255
|
+
(name !== "library" || application.libraryReady?.() === true));
|
|
254
256
|
const localizedCommands = commands.map(({ name, description }) => ({ name, get description() { return tr.marked(description); } }));
|
|
255
257
|
let activeRuntime = runtime;
|
|
256
258
|
application.startNewSession?.("tui_start");
|
|
@@ -1037,6 +1039,59 @@ export async function runMoondogTui({
|
|
|
1037
1039
|
generationFooter(status);
|
|
1038
1040
|
};
|
|
1039
1041
|
|
|
1042
|
+
// /library lists Saved and the listener's folders and playlists, plays one by name, or keeps the song
|
|
1043
|
+
// playing now, in Saved or in a playlist by name (made when there is none). The window shares this library.
|
|
1044
|
+
const manageLibrary = async (args) => {
|
|
1045
|
+
if (!application.libraryReady?.()) throw new Error(tr("The library isn't available here."));
|
|
1046
|
+
const [action, ...rest] = args;
|
|
1047
|
+
const words = rest.join(" ").trim();
|
|
1048
|
+
const nameOf = (playlist) => (playlist.saved ? tr("Saved") : playlist.name);
|
|
1049
|
+
if (!action) {
|
|
1050
|
+
const { folders, playlists } = application.libraryView();
|
|
1051
|
+
const count = (playlist) => tr.n(playlist.count, "{count} song", "{count} songs");
|
|
1052
|
+
const lines = [];
|
|
1053
|
+
const saved = playlists.find((playlist) => playlist.saved);
|
|
1054
|
+
if (saved) lines.push(`- **${nameOf(saved)}** · ${count(saved)}`);
|
|
1055
|
+
const walk = (parentId, depth) => {
|
|
1056
|
+
const nodes = [...folders.filter((folder) => folder.parentId === parentId).map((node) => ["folder", node]),
|
|
1057
|
+
...playlists.filter((playlist) => !playlist.saved && playlist.folderId === parentId).map((node) => ["playlist", node])]
|
|
1058
|
+
.sort((a, b) => a[1].position - b[1].position);
|
|
1059
|
+
for (const [kind, node] of nodes) {
|
|
1060
|
+
const indent = " ".repeat(depth);
|
|
1061
|
+
if (kind === "folder") { lines.push(`${indent}- ${node.name}/`); walk(node.id, depth + 1); }
|
|
1062
|
+
else lines.push(`${indent}- **${node.name}** · ${count(node)}`);
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
walk(null, 0);
|
|
1066
|
+
addMoondogMessage(`${lines.join("\n")}\n\n${tr("Play one with `/library play <name>`, or keep the song playing now with `/library save`.")}`);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (action === "play") {
|
|
1070
|
+
const [playlist] = application.findLibraryPlaylists(words);
|
|
1071
|
+
if (!playlist || !words) throw new Error(tr("No playlist is called “{name}”.", { name: words }));
|
|
1072
|
+
const played = await application.playLibrary({ playlistId: playlist.id });
|
|
1073
|
+
if (played.state !== "playing") {
|
|
1074
|
+
addMoondogMessage(played.state === "empty" ? tr("{name} has nothing in it yet.", { name: nameOf(playlist) }) : tr("None of its songs could be found."));
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
addMoondogMessage([tr("Playing {name}.", { name: nameOf(playlist) }),
|
|
1078
|
+
played.left_out_made ? tr("Moondog's tracks were left out. They play with Apple Music chosen.") : "",
|
|
1079
|
+
played.not_found ? tr("Some songs couldn't be found.") : ""].filter(Boolean).join(" "));
|
|
1080
|
+
setFooter(tr("Playing {name}.", { name: nameOf(playlist) }), success);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (action === "save") {
|
|
1084
|
+
const target = words ? application.findLibraryPlaylists(words).find((playlist) => playlist.name.toLocaleLowerCase() === words.toLocaleLowerCase() || (playlist.saved && /^saved$/iu.test(words))) : null;
|
|
1085
|
+
const saved = await application.saveToLibrary({ from: "now_playing", ...(target ? { playlistId: target.id } : words ? { playlistName: words } : {}) });
|
|
1086
|
+
const where = saved.playlist.saved ? tr("Saved") : saved.playlist.name;
|
|
1087
|
+
const text = !saved.added ? tr("Already in {name}.", { name: where }) : saved.playlist.saved ? tr("Saved.") : tr("Saved to {name}.", { name: where });
|
|
1088
|
+
addMoondogMessage(text);
|
|
1089
|
+
setFooter(text, success);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
throw new Error(tr("Use /library, /library play <name>, or /library save [playlist]."));
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1040
1095
|
const removeLifecycleListeners = () => {
|
|
1041
1096
|
signalTarget.removeListener("SIGTERM", handleSigterm);
|
|
1042
1097
|
signalTarget.removeListener("SIGHUP", handleSighup);
|
|
@@ -2499,6 +2554,10 @@ export async function runMoondogTui({
|
|
|
2499
2554
|
await manageGeneration(args);
|
|
2500
2555
|
return;
|
|
2501
2556
|
}
|
|
2557
|
+
if (command === "library") {
|
|
2558
|
+
await manageLibrary(args);
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2502
2561
|
if (command === "spotify") {
|
|
2503
2562
|
await runSpotifyCommand(args);
|
|
2504
2563
|
return;
|