@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.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/core/apple-projection-domain-services.mjs +95 -27
- package/src/core/capability-catalog.mjs +12 -0
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-profile-domain-services.mjs +44 -8
- package/src/core/moondog-application.mjs +238 -34
- package/src/core/spotify-listening-intent.mjs +5 -2
- package/src/core/track-title.mjs +107 -0
- package/src/i18n/messages/de.mjs +7 -0
- package/src/i18n/messages/en.mjs +7 -0
- package/src/i18n/messages/es.mjs +7 -0
- package/src/i18n/messages/fr.mjs +7 -0
- package/src/i18n/messages/it.mjs +7 -0
- package/src/i18n/messages/ja.mjs +7 -0
- package/src/i18n/messages/ko.mjs +7 -0
- package/src/i18n/messages/pt.mjs +7 -0
- package/src/i18n/messages/zh-Hant.mjs +7 -0
- package/src/i18n/messages/zh.mjs +7 -0
- package/src/i18n/screen/es.mjs +1 -0
- package/src/i18n/screen/ja.mjs +1 -0
- package/src/i18n/screen/pt.mjs +1 -0
- package/src/i18n/screen/zh.mjs +1 -0
- package/src/integrations/apple-music/catalog.mjs +52 -9
- package/src/integrations/netease/ncm-cli-player.mjs +10 -3
- package/src/integrations/spotify/catalog-resolver.mjs +1 -2
- package/src/integrations/spotify/web-api-client.mjs +3 -0
- package/src/memory/local-memory-store.mjs +73 -16
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +92 -9
- package/src/profile/listener-profile-worker.mjs +7 -0
- package/src/profile/listening-history-store.mjs +231 -24
- package/src/profile/listening-profile-projection.mjs +212 -10
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/profile-exploration.mjs +16 -8
- package/src/runtime/pi/agent-runtime.mjs +363 -118
- package/src/runtime/pi/profile-build-runtime.mjs +2 -1
- package/src/runtime/pi/skills/listener-profile/SKILL.md +2 -0
- package/src/surfaces/cli/web-command.mjs +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// A remaster, a live take or a guest credit names a recording of the same song, so knownness,
|
|
2
|
+
// deduplication and Moondog's suggestion ledger compare titles without these suffixes:
|
|
3
|
+
// " - 2011 Remaster", "(Remastered 2009)", " - Live", "(feat. X)", "[Deluxe]". A remix stays
|
|
4
|
+
// its own song.
|
|
5
|
+
const DASH_SUFFIX = /\s+[-\u2013\u2014]\s+[^-\u2013\u2014]*\b(?:remaster(?:ed)?|live|mono|stereo|single|radio edit|edit|version|demo|acoustic|bonus track|\d{4} mix)\b[^-\u2013\u2014]*$/iu;
|
|
6
|
+
const BRACKET_SUFFIX = /\s*[([][^()[\]]*\b(?:remaster(?:ed)?|live|feat\.?|ft\.?|featuring|with|deluxe|mono|stereo|version|edit|demo|acoustic|bonus track)(?:\b|(?<=\.))[^()[\]]*[)\]]$/iu;
|
|
7
|
+
|
|
8
|
+
export function normalizedTrackTitle(value) {
|
|
9
|
+
let title = String(value ?? "").normalize("NFKC").toLocaleLowerCase("und").replace(/\s+/gu, " ").trim();
|
|
10
|
+
for (let previous = null; previous !== title;) {
|
|
11
|
+
previous = title;
|
|
12
|
+
const stripped = title.replace(DASH_SUFFIX, "").replace(BRACKET_SUFFIX, "").trim();
|
|
13
|
+
// A title made only of a version word stays as it is.
|
|
14
|
+
if (stripped) title = stripped;
|
|
15
|
+
}
|
|
16
|
+
return title;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizedArtistName(value) {
|
|
20
|
+
return String(value ?? "").normalize("NFKC").toLocaleLowerCase("und").replace(/\s+/gu, " ").trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One song by one primary artist, whatever the recording's version suffix. A unit separator
|
|
25
|
+
* joins the two, not a NUL: node:sqlite on Node 22 cuts a stored string at a NUL.
|
|
26
|
+
*/
|
|
27
|
+
export function trackMatchKey(title, artist) {
|
|
28
|
+
const name = normalizedTrackTitle(title);
|
|
29
|
+
const credit = normalizedArtistName(artist);
|
|
30
|
+
return name && credit ? `${name}\u001f${credit}` : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Chinese, Japanese and Korean text, which has no spaces between words (or words
|
|
34
|
+
// that take particles), so a name in it is found as a substring.
|
|
35
|
+
const CJK_TEXT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
36
|
+
|
|
37
|
+
// Folds case, width, Latin accents and punctuation, so "Beyoncé," and "beyonce" read alike.
|
|
38
|
+
// Kana voicing marks stay, because they change the word.
|
|
39
|
+
export function foldedWords(value) {
|
|
40
|
+
return String(value ?? "").normalize("NFKD").replace(/[̀-ͯ]/gu, "").normalize("NFC")
|
|
41
|
+
.toLocaleLowerCase("und").replace(/[^\p{L}\p{N}\p{M}]+/gu, " ").trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const isCjkText = (value) => CJK_TEXT.test(String(value ?? ""));
|
|
45
|
+
|
|
46
|
+
// A letter or digit of a script that writes its words apart. A name must not start or end
|
|
47
|
+
// next to one of these when its own edge is one too; next to a CJK character, a space or
|
|
48
|
+
// punctuation, it may, so "Coldplay" is named in "我不想再听Coldplay了".
|
|
49
|
+
const SPACED_LETTER = /[[\p{L}\p{N}\p{M}]--[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}\p{scx=Hangul}]]/v;
|
|
50
|
+
const joined = (left, right) => left !== undefined && right !== undefined && SPACED_LETTER.test(left) && SPACED_LETTER.test(right);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether text names a name. A script change (Latin next to CJK), a space or punctuation ends
|
|
54
|
+
* a word, and between two Latin letters a name matches only as whole words.
|
|
55
|
+
* A name shorter than 2 characters after folding names nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function textNames(text, name) {
|
|
58
|
+
const wanted = foldedWords(name);
|
|
59
|
+
if (Array.from(wanted.replace(/ /gu, "")).length < 2) return false;
|
|
60
|
+
const haystack = foldedWords(text);
|
|
61
|
+
const first = Array.from(wanted)[0];
|
|
62
|
+
const last = Array.from(wanted).at(-1);
|
|
63
|
+
for (let at = haystack.indexOf(wanted); at >= 0; at = haystack.indexOf(wanted, at + 1)) {
|
|
64
|
+
const before = Array.from(haystack.slice(0, at)).at(-1);
|
|
65
|
+
const after = Array.from(haystack.slice(at + wanted.length))[0];
|
|
66
|
+
if (!joined(before, first) && !joined(last, after)) return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether the listener's text names this song, by its title without version suffixes. */
|
|
72
|
+
export function textNamesTitle(text, title) {
|
|
73
|
+
return textNames(text, normalizedTrackTitle(title));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Articles that start band names in the supported languages: "Beatles" names "The Beatles".
|
|
77
|
+
const LEADING_ARTICLE = /^(?:the|an|a|los|las|el|la|les|le|l|die|der|das|il|lo|i|gli|os|as|o) (?=\S)/u;
|
|
78
|
+
|
|
79
|
+
/** Whether text names an artist, with or without a leading article. */
|
|
80
|
+
export function textNamesArtist(text, artist) {
|
|
81
|
+
const name = foldedWords(artist);
|
|
82
|
+
const bare = name.replace(LEADING_ARTICLE, "");
|
|
83
|
+
// "The The" keeps its article: a bare article names nothing.
|
|
84
|
+
return textNames(text, name) || (bare !== name && !LEADING_ARTICLE.test(`${bare} x`) && textNames(text, bare));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A quoted name: straight or curly double or single quotes, corner brackets, title marks or
|
|
88
|
+
// guillemets. A straight or curly single quote inside a word is an apostrophe.
|
|
89
|
+
const QUOTED = [/"([^"]+)"/gu, /“([^“”]+)”/gu, /„([^„“”]+)[“”]/gu, /«([^«»]+)»/gu, /「([^「」]+)」/gu, /『([^『』]+)』/gu,
|
|
90
|
+
/《([^《》]+)》/gu, /(?<![\p{L}\p{N}])'(?=(.+?)'(?![\p{L}\p{N}]))/gu, /‘(.+?)’(?![\p{L}\p{N}])/gu];
|
|
91
|
+
|
|
92
|
+
/** Whether text quotes this song's title, version suffixes aside. */
|
|
93
|
+
export function textQuotesTitle(text, title) {
|
|
94
|
+
const wanted = foldedWords(normalizedTrackTitle(title));
|
|
95
|
+
return Boolean(wanted) && QUOTED.some((pattern) => Array.from(String(text ?? "").matchAll(pattern))
|
|
96
|
+
.some(([, quoted]) => foldedWords(normalizedTrackTitle(quoted)) === wanted));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether the listener's text names this song as their own pick: its title with one of its
|
|
101
|
+
* credited artists, or its title in quotes. A title alone ("play Pink Moon") is not enough,
|
|
102
|
+
* because an open request shares words with titles ("a rainy day", "安静").
|
|
103
|
+
*/
|
|
104
|
+
export function textNamesSong(text, title, artists = []) {
|
|
105
|
+
return textQuotesTitle(text, title) ||
|
|
106
|
+
(textNamesTitle(text, title) && artists.some((artist) => textNamesArtist(text, artist)));
|
|
107
|
+
}
|
package/src/i18n/messages/de.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export default {
|
|
|
34
34
|
"failure.preparationStopped": ({ evidence }) => `Die Prüfung des Wiedergabegeräts wurde nicht abgeschlossen (${evidence}); es wurde keine Wiedergabe-Anfrage gesendet. Prüfe den Gerätestatus in Spotify, bevor du es erneut versuchst.`,
|
|
35
35
|
"failure.queueRequestRequired": "Es wurden keine Songs zur Warteschlange hinzugefügt: Ich konnte diese Antwort keiner Anfrage für die Warteschlange zuordnen. Um fortzufahren, braucht es eine ausdrückliche Bitte, Musik zur Warteschlange hinzuzufügen.",
|
|
36
36
|
"failure.notSent": ({ code }) => `An Spotify wurde nichts gesendet: Ich konnte das keinem der gefundenen Songs zuordnen (${code}). Frag noch einmal mit Songtitel oder Künstler, dann suche ich erneut.`,
|
|
37
|
+
"failure.avoided": "An Spotify wurde nichts gesendet: Du wolltest diesen Künstler oder diesen Song nicht mehr hören. Sag mir jederzeit, wenn du das ändern willst.",
|
|
37
38
|
"failure.outcome.lookup": "Die Spotify-Suche wurde nicht abgeschlossen",
|
|
38
39
|
"failure.outcome.unknown": "Das Ergebnis der Spotify-Aktion ist unklar",
|
|
39
40
|
"failure.outcome.unconfirmed": "Die Spotify-Wiedergabeaktion wurde nicht bestätigt",
|
|
@@ -61,7 +62,10 @@ export default {
|
|
|
61
62
|
"queue.skippedDuplicates": ({ count }) => `${count} doppelte Titel übersprungen.`,
|
|
62
63
|
"queue.skippedUncertain": ({ count }) => `${count} Titel mit unklarem früherem Ergebnis übersprungen, um Doppelungen zu vermeiden; prüfe die Spotify-Warteschlange.`,
|
|
63
64
|
"queue.skippedAvoided": ({ count }) => `${count} Titel übersprungen, die du vermeiden wolltest.`,
|
|
65
|
+
"plan.avoidedLeftOut": ({ titles }) => `Weggelassen, weil du sie nicht mehr hören wolltest: ${titles}.`,
|
|
64
66
|
"queue.skippedKnown": ({ count }) => `${count} Titel aus deinem Hörverlauf übersprungen, wie gewünscht.`,
|
|
67
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
68
|
+
`Laut deinem Hörverlauf: ${[familiar && `${familiar} gut bekannt`, returning && `${returning} nach längerer Zeit wieder`, heard && `${heard} ein- oder zweimal gehört`, knownArtist && `${knownArtist} neu von ${knownArtist === 1 ? "einem bekannten Künstler" : "bekannten Künstlern"}`, fresh && `${fresh} neu für dich`].filter(Boolean).join(", ")}.`,
|
|
65
69
|
"queue.observationTruncated": "Doppelungen wurden nur im begrenzten Ausschnitt der Warteschlange geprüft, den Spotify liefert; nicht sichtbare Einträge können noch doppelt sein.",
|
|
66
70
|
"queue.notAddedHeading": "Nicht hinzugefügt:",
|
|
67
71
|
"queue.earlierReceipt": "Das ist das Ergebnis der früheren Warteschlangen-Aktion; diese Klarstellung hat die Songs nicht erneut hinzugefügt.",
|
|
@@ -123,6 +127,9 @@ export default {
|
|
|
123
127
|
"selection.note": ({ playlist, track }) => `\n\nFür weitere Änderungen ausgewählt: Playlist ${playlist}; Song ${track}.`,
|
|
124
128
|
"memory.spotifyExcluded": "\n\nSpotify-Ergebnisse kommen nicht ins allgemeine Gedächtnis; nur deine eigenen, überprüften Worte konnten gespeichert werden, alles andere wurde nicht gespeichert.",
|
|
125
129
|
"memory.notSaved": "\n\nDie Antwort ist fertig, aber die Erinnerung wurde nicht gespeichert.",
|
|
130
|
+
"choice.like": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nGespeichert: Du magst ${name}. Sag mir jederzeit, wenn du das ändern willst.`; },
|
|
131
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nGespeichert: Ich lasse ${name} aus allem heraus, was ich spiele und vorschlage. Sag mir jederzeit, wenn du das ändern willst.`; },
|
|
132
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `„${title}“ von ${artist}` : artist; return `\n\nZurückgenommen: deine frühere Wahl zu ${name}.`; },
|
|
126
133
|
"turn.cancelled": "Weitere Schritte abgebrochen.",
|
|
127
134
|
"turn.interrupted": "Die Antwort wurde unterbrochen. Die erfassten Spotify-Ergebnisse stehen unten; keine Aktion wurde wiederholt.",
|
|
128
135
|
};
|
package/src/i18n/messages/en.mjs
CHANGED
|
@@ -35,6 +35,7 @@ export default {
|
|
|
35
35
|
"failure.preparationStopped": ({ evidence }) => `Playback device checks did not complete (${evidence}); no song play request was sent. Check Spotify device state before trying again.`,
|
|
36
36
|
"failure.queueRequestRequired": "No songs were queued: I could not connect this reply to a queue request. An explicit request to add music to the playback queue is needed to continue.",
|
|
37
37
|
"failure.notSent": ({ code }) => `Nothing was sent to Spotify: I couldn't match that to the songs I found (${code}). Ask again with the song or artist name, and I'll search again.`,
|
|
38
|
+
"failure.avoided": "Nothing was sent to Spotify: you asked me to keep that artist or song out of what I play. Tell me any time to change it.",
|
|
38
39
|
"failure.outcome.lookup": "The Spotify lookup did not complete",
|
|
39
40
|
"failure.outcome.unknown": "The Spotify action outcome is uncertain",
|
|
40
41
|
"failure.outcome.unconfirmed": "The Spotify playback action was not confirmed",
|
|
@@ -62,7 +63,10 @@ export default {
|
|
|
62
63
|
"queue.skippedDuplicates": ({ count }) => `Skipped ${count} duplicate tracks.`,
|
|
63
64
|
"queue.skippedUncertain": ({ count }) => `Skipped ${count} tracks with earlier uncertain queue outcomes to avoid replay; check the Spotify queue.`,
|
|
64
65
|
"queue.skippedAvoided": ({ count }) => `Skipped ${count} avoided tracks.`,
|
|
66
|
+
"plan.avoidedLeftOut": ({ titles }) => `Left out because you asked me to keep them out: ${titles}.`,
|
|
65
67
|
"queue.skippedKnown": ({ count }) => `Skipped ${count} tracks matched in retained history, as requested.`,
|
|
68
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
69
|
+
`The mix: ${[familiar && `${familiar} you know well`, returning && `${returning} back after a while`, heard && `${heard} you've heard once or twice`, knownArtist && `${knownArtist} new from ${knownArtist === 1 ? "an artist" : "artists"} you know`, fresh && `${fresh} new to you`].filter(Boolean).join(", ")}.`,
|
|
66
70
|
"queue.observationTruncated": "Deduplication covers Spotify's bounded queue snapshot; unseen entries may still duplicate a selection.",
|
|
67
71
|
"queue.notAddedHeading": "Not added:",
|
|
68
72
|
"queue.earlierReceipt": "This is the earlier queue receipt; this clarification did not add the songs again.",
|
|
@@ -124,6 +128,9 @@ export default {
|
|
|
124
128
|
"selection.note": ({ playlist, track }) => `\n\nSelected for follow-up edits: playlist ${playlist}; song ${track}.`,
|
|
125
129
|
"memory.spotifyExcluded": "\n\nSpotify results are excluded from generic memory; only verified user quotes were eligible, and other claims were not saved.",
|
|
126
130
|
"memory.notSaved": "\n\nThe response completed, but the memory was not saved.",
|
|
131
|
+
"choice.like": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nSaved: you like ${name}. Tell me any time to change it.`; },
|
|
132
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nSaved: I'll keep ${name} out of what I play and suggest. Tell me any time to change it.`; },
|
|
133
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" by ${artist}` : artist; return `\n\nUndone: your earlier choice about ${name}.`; },
|
|
127
134
|
"turn.cancelled": "Cancelled further work.",
|
|
128
135
|
"turn.interrupted": "The response was interrupted. The recorded Spotify outcomes are below; no action was replayed.",
|
|
129
136
|
};
|
package/src/i18n/messages/es.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export default {
|
|
|
34
34
|
"failure.preparationStopped": ({ evidence }) => `Las comprobaciones del dispositivo no terminaron (${evidence}); no se envió ninguna petición de reproducción. Revisa el estado del dispositivo en Spotify antes de volver a intentarlo.`,
|
|
35
35
|
"failure.queueRequestRequired": "No se añadió ninguna canción: no pude relacionar esta respuesta con una petición para la cola. Para seguir, pide de forma explícita añadir música a la cola.",
|
|
36
36
|
"failure.notSent": ({ code }) => `No envié nada a Spotify: no pude relacionarlo con las canciones que encontré (${code}). Vuelve a pedirlo con el nombre de la canción o del artista y la busco de nuevo.`,
|
|
37
|
+
"failure.avoided": "No envié nada a Spotify: me pediste dejar fuera a ese artista o esa canción. Dime cuando quieras cambiarlo.",
|
|
37
38
|
"failure.outcome.lookup": "La búsqueda en Spotify no terminó",
|
|
38
39
|
"failure.outcome.unknown": "No se sabe con certeza el resultado de la acción en Spotify",
|
|
39
40
|
"failure.outcome.unconfirmed": "Spotify no confirmó la acción de reproducción",
|
|
@@ -61,7 +62,10 @@ export default {
|
|
|
61
62
|
"queue.skippedDuplicates": ({ count }) => `Omití ${count} canciones repetidas.`,
|
|
62
63
|
"queue.skippedUncertain": ({ count }) => `Omití ${count} canciones cuyo resultado anterior en la cola no estaba claro, para no repetirlas; revisa la cola de Spotify.`,
|
|
63
64
|
"queue.skippedAvoided": ({ count }) => `Omití ${count} canciones que marcaste para evitar.`,
|
|
65
|
+
"plan.avoidedLeftOut": ({ titles }) => `Dejé fuera lo que me pediste evitar: ${titles}.`,
|
|
64
66
|
"queue.skippedKnown": ({ count }) => `Omití ${count} canciones que ya aparecen en tu historial, como pediste.`,
|
|
67
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
68
|
+
`Según tu historial: ${[familiar && `${familiar} que conoces bien`, returning && `${returning} ${returning === 1 ? "que vuelve" : "que vuelven"} después de un tiempo`, heard && `${heard} que has oído una o dos veces`, knownArtist && `${knownArtist} ${knownArtist === 1 ? "nueva de un artista" : "nuevas de artistas"} que conoces`, fresh && `${fresh} ${fresh === 1 ? "nueva" : "nuevas"} para ti`].filter(Boolean).join(", ")}.`,
|
|
65
69
|
"queue.observationTruncated": "La comprobación de repetidas solo cubre la parte de la cola que muestra Spotify; lo que no se ve podría repetir alguna.",
|
|
66
70
|
"queue.notAddedHeading": "Sin añadir:",
|
|
67
71
|
"queue.earlierReceipt": "Este es el resultado de la cola anterior; esta aclaración no volvió a añadir las canciones.",
|
|
@@ -123,6 +127,9 @@ export default {
|
|
|
123
127
|
"selection.note": ({ playlist, track }) => `\n\nSeleccionado para próximos cambios: playlist ${playlist}; canción ${track}.`,
|
|
124
128
|
"memory.spotifyExcluded": "\n\nLos resultados de Spotify no se guardan en la memoria general; solo se podían guardar citas tuyas verificables, y lo demás no se guardó.",
|
|
125
129
|
"memory.notSaved": "\n\nLa respuesta terminó, pero no se pudo guardar la memoria.",
|
|
130
|
+
"choice.like": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nGuardado: te gusta ${name}. Dímelo cuando quieras cambiarlo.`; },
|
|
131
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nGuardado: dejaré ${name} fuera de lo que pongo y te sugiero. Dímelo cuando quieras cambiarlo.`; },
|
|
132
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nDeshecho: tu elección anterior sobre ${name}.`; },
|
|
126
133
|
"turn.cancelled": "Cancelé el resto.",
|
|
127
134
|
"turn.interrupted": "La respuesta se interrumpió. Abajo están los resultados registrados en Spotify; no se repitió ninguna acción.",
|
|
128
135
|
};
|
package/src/i18n/messages/fr.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export default {
|
|
|
34
34
|
"failure.preparationStopped": ({ evidence }) => `Les vérifications de l'appareil de lecture n'ont pas abouti (${evidence}) ; aucune demande de lecture n'a été envoyée. Vérifiez l'état de l'appareil Spotify avant de réessayer.`,
|
|
35
35
|
"failure.queueRequestRequired": "Aucun morceau n'a été ajouté à la file d'attente : je n'ai pas pu relier cette réponse à une demande d'ajout. Il faut une demande explicite d'ajouter de la musique à la file d'attente pour continuer.",
|
|
36
36
|
"failure.notSent": ({ code }) => `Rien n'a été envoyé à Spotify : je n'ai pas pu relier cela aux morceaux trouvés (${code}). Redemandez avec le titre ou l'artiste, et je chercherai à nouveau.`,
|
|
37
|
+
"failure.avoided": "Rien n'a été envoyé à Spotify : vous m'avez demandé d'écarter cet artiste ou ce morceau. Dites-le-moi quand vous voulez pour changer cela.",
|
|
37
38
|
"failure.outcome.lookup": "La recherche Spotify n'a pas abouti",
|
|
38
39
|
"failure.outcome.unknown": "Le résultat de l'action Spotify est incertain",
|
|
39
40
|
"failure.outcome.unconfirmed": "L'action de lecture Spotify n'a pas été confirmée",
|
|
@@ -61,7 +62,10 @@ export default {
|
|
|
61
62
|
"queue.skippedDuplicates": ({ count }) => `${count} doublons ignorés.`,
|
|
62
63
|
"queue.skippedUncertain": ({ count }) => `${count} morceaux ignorés pour éviter un doublon, car leur ajout précédent est incertain ; vérifiez la file d'attente Spotify.`,
|
|
63
64
|
"queue.skippedAvoided": ({ count }) => `${count} morceaux à éviter ignorés.`,
|
|
65
|
+
"plan.avoidedLeftOut": ({ titles }) => `Laissés de côté, comme vous me l'avez demandé : ${titles}.`,
|
|
64
66
|
"queue.skippedKnown": ({ count }) => `${count} morceaux déjà présents dans votre historique ignorés, comme demandé.`,
|
|
67
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
68
|
+
`D'après votre historique : ${[familiar && `${familiar} que vous connaissez bien`, returning && `${returning} ${returning === 1 ? "qui revient" : "qui reviennent"} après un moment`, heard && `${heard} ${heard === 1 ? "entendu" : "entendus"} une ou deux fois`, knownArtist && `${knownArtist} ${knownArtist === 1 ? "nouveau d'un artiste" : "nouveaux d'artistes"} que vous connaissez`, fresh && `${fresh} ${fresh === 1 ? "nouveau" : "nouveaux"} pour vous`].filter(Boolean).join(", ")}.`,
|
|
65
69
|
"queue.observationTruncated": "Le dédoublonnage ne couvre que l'extrait limité de la file que Spotify renvoie ; des entrées non visibles peuvent encore faire doublon.",
|
|
66
70
|
"queue.notAddedHeading": "Non ajoutés :",
|
|
67
71
|
"queue.earlierReceipt": "Voici le reçu de l'ajout précédent ; cette précision n'a pas ajouté les morceaux une seconde fois.",
|
|
@@ -123,6 +127,9 @@ export default {
|
|
|
123
127
|
"selection.note": ({ playlist, track }) => `\n\nSélection pour les prochaines modifications : playlist ${playlist} ; morceau ${track}.`,
|
|
124
128
|
"memory.spotifyExcluded": "\n\nLes résultats Spotify sont exclus de la mémoire générale ; seules vos propres paroles vérifiées pouvaient être retenues, et le reste n'a pas été enregistré.",
|
|
125
129
|
"memory.notSaved": "\n\nLa réponse est terminée, mais la mémoire n'a pas été enregistrée.",
|
|
130
|
+
"choice.like": ({ title, artist }) => { const name = title ? `« ${title} » de ${artist}` : artist; return `\n\nEnregistré : vous aimez ${name}. Dites-le-moi quand vous voulez pour le changer.`; },
|
|
131
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `« ${title} » de ${artist}` : artist; return `\n\nEnregistré : je laisserai ${name} de côté dans ce que je joue et propose. Dites-le-moi quand vous voulez pour le changer.`; },
|
|
132
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `« ${title} » de ${artist}` : artist; return `\n\nAnnulé : votre choix précédent sur ${name}.`; },
|
|
126
133
|
"turn.cancelled": "Suite annulée.",
|
|
127
134
|
"turn.interrupted": "La réponse a été interrompue. Les résultats Spotify enregistrés figurent ci-dessous ; aucune action n'a été relancée.",
|
|
128
135
|
};
|
package/src/i18n/messages/it.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export default {
|
|
|
34
34
|
"failure.preparationStopped": ({ evidence }) => `I controlli del dispositivo di riproduzione non sono stati completati (${evidence}); non è stata inviata alcuna richiesta di riproduzione. Controlla lo stato del dispositivo Spotify prima di riprovare.`,
|
|
35
35
|
"failure.queueRequestRequired": "Nessun brano è stato messo in coda: non sono riuscito a collegare questa risposta a una richiesta di coda. Per continuare serve una richiesta esplicita di aggiungere musica alla coda di riproduzione.",
|
|
36
36
|
"failure.notSent": ({ code }) => `Non ho inviato nulla a Spotify: non sono riuscito a collegarlo ai brani trovati (${code}). Chiedimelo di nuovo con il titolo o l'artista e cercherò ancora.`,
|
|
37
|
+
"failure.avoided": "Non ho inviato nulla a Spotify: mi hai chiesto di lasciare fuori questo artista o questo brano. Dimmelo quando vuoi per cambiarlo.",
|
|
37
38
|
"failure.outcome.lookup": "La ricerca su Spotify non è stata completata",
|
|
38
39
|
"failure.outcome.unknown": "L'esito dell'azione su Spotify è incerto",
|
|
39
40
|
"failure.outcome.unconfirmed": "L'azione di riproduzione su Spotify non è stata confermata",
|
|
@@ -61,7 +62,10 @@ export default {
|
|
|
61
62
|
"queue.skippedDuplicates": ({ count }) => `Saltati ${count} brani duplicati.`,
|
|
62
63
|
"queue.skippedUncertain": ({ count }) => `Saltati ${count} brani con un esito precedente incerto, per evitare doppioni; controlla la coda di Spotify.`,
|
|
63
64
|
"queue.skippedAvoided": ({ count }) => `Saltati ${count} brani che volevi evitare.`,
|
|
65
|
+
"plan.avoidedLeftOut": ({ titles }) => `Lasciati fuori, come mi hai chiesto: ${titles}.`,
|
|
64
66
|
"queue.skippedKnown": ({ count }) => `Saltati ${count} brani già presenti nella tua cronologia, come richiesto.`,
|
|
67
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
68
|
+
`Secondo la tua cronologia: ${[familiar && `${familiar} che conosci bene`, returning && `${returning} ${returning === 1 ? "che torna" : "che tornano"} dopo un po'`, heard && `${heard} ${heard === 1 ? "sentito" : "sentiti"} una o due volte`, knownArtist && `${knownArtist} ${knownArtist === 1 ? "nuovo da un artista" : "nuovi da artisti"} che conosci`, fresh && `${fresh} ${fresh === 1 ? "nuovo" : "nuovi"} per te`].filter(Boolean).join(", ")}.`,
|
|
65
69
|
"queue.observationTruncated": "Il controllo dei doppioni copre solo la parte limitata della coda restituita da Spotify; le voci non visibili potrebbero ancora essere doppioni.",
|
|
66
70
|
"queue.notAddedHeading": "Non aggiunti:",
|
|
67
71
|
"queue.earlierReceipt": "Questo è l'esito della richiesta di coda precedente; questo chiarimento non ha aggiunto di nuovo i brani.",
|
|
@@ -123,6 +127,9 @@ export default {
|
|
|
123
127
|
"selection.note": ({ playlist, track }) => `\n\nSelezionati per le prossime modifiche: playlist ${playlist}; brano ${track}.`,
|
|
124
128
|
"memory.spotifyExcluded": "\n\nI risultati di Spotify sono esclusi dalla memoria generale; si potevano ricordare solo le tue parole verificate, e il resto non è stato salvato.",
|
|
125
129
|
"memory.notSaved": "\n\nLa risposta è completa, ma la memoria non è stata salvata.",
|
|
130
|
+
"choice.like": ({ title, artist }) => { const name = title ? `"${title}" di ${artist}` : artist; return `\n\nSalvato: ti piace ${name}. Dimmelo quando vuoi cambiarlo.`; },
|
|
131
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `"${title}" di ${artist}` : artist; return `\n\nSalvato: terrò ${name} fuori da ciò che suono e ti propongo. Dimmelo quando vuoi cambiarlo.`; },
|
|
132
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" di ${artist}` : artist; return `\n\nAnnullato: la tua scelta precedente su ${name}.`; },
|
|
126
133
|
"turn.cancelled": "Ho annullato il resto.",
|
|
127
134
|
"turn.interrupted": "La risposta si è interrotta. Gli esiti di Spotify registrati sono qui sotto; nessuna azione è stata ripetuta.",
|
|
128
135
|
};
|
package/src/i18n/messages/ja.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export default {
|
|
|
34
34
|
"failure.preparationStopped": ({ evidence }) => `再生前のデバイス確認が完了しませんでした(${evidence})。再生リクエストは送っていません。Spotify のデバイスの状態を確認してから、もう一度試してください。`,
|
|
35
35
|
"failure.queueRequestRequired": "曲は追加していません。この返事をキューへの追加リクエストと結びつけられませんでした。続けるには、キューに追加したいとはっきり伝えてください。",
|
|
36
36
|
"failure.notSent": ({ code }) => `Spotify には何も送っていません。さっき見つけた曲と結び付けられませんでした(${code})。曲名かアーティスト名を添えてもう一度頼んでください。改めて探します。`,
|
|
37
|
+
"failure.avoided": "Spotify には何も送っていません。このアーティストか曲は流さないように、と言われています。変えたいときはいつでも言ってください。",
|
|
37
38
|
"failure.outcome.lookup": "Spotify での検索が完了しませんでした",
|
|
38
39
|
"failure.outcome.unknown": "Spotify での操作結果がわかりません",
|
|
39
40
|
"failure.outcome.unconfirmed": "Spotify が再生操作を確認しませんでした",
|
|
@@ -61,7 +62,10 @@ export default {
|
|
|
61
62
|
"queue.skippedDuplicates": ({ count }) => `重複していた ${count} 曲を飛ばしました。`,
|
|
62
63
|
"queue.skippedUncertain": ({ count }) => `前回キューへの追加結果がはっきりしなかった ${count} 曲を、重複を避けるため飛ばしました。Spotify のキューを確認してください。`,
|
|
63
64
|
"queue.skippedAvoided": ({ count }) => `避けるよう指定された ${count} 曲を飛ばしました。`,
|
|
65
|
+
"plan.avoidedLeftOut": ({ titles }) => `流さないように言われていた曲は外しました:${titles}。`,
|
|
64
66
|
"queue.skippedKnown": ({ count }) => `ご希望どおり、再生履歴にある ${count} 曲を飛ばしました。`,
|
|
67
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
68
|
+
`再生履歴では: ${[familiar && `よく知っている曲 ${familiar} 曲`, returning && `久しぶりの曲 ${returning} 曲`, heard && `1、2 回聴いた曲 ${heard} 曲`, knownArtist && `知っているアーティストの新しい曲 ${knownArtist} 曲`, fresh && `初めての曲 ${fresh} 曲`].filter(Boolean).join("、")}。`,
|
|
65
69
|
"queue.observationTruncated": "重複の確認は Spotify が表示するキューの範囲だけです。表示されていない部分と重なる可能性があります。",
|
|
66
70
|
"queue.notAddedHeading": "まだ追加していない曲:",
|
|
67
71
|
"queue.earlierReceipt": "これは前回のキュー操作の結果です。今回の補足では曲を再度追加していません。",
|
|
@@ -123,6 +127,9 @@ export default {
|
|
|
123
127
|
"selection.note": ({ playlist, track }) => `\n\n続けて編集する対象: プレイリスト ${playlist}、曲 ${track}。`,
|
|
124
128
|
"memory.spotifyExcluded": "\n\nSpotify の検索結果は一般の記憶には残しません。確認できるあなたの発言だけが対象で、それ以外は保存していません。",
|
|
125
129
|
"memory.notSaved": "\n\n返事は完了しましたが、記憶は保存できませんでした。",
|
|
130
|
+
"choice.like": ({ title, artist }) => { const name = title ? `${artist}の「${title}」` : artist; return `\n\n保存しました: ${name}が好き。変えたいときはいつでも言ってください。`; },
|
|
131
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `${artist}の「${title}」` : artist; return `\n\n保存しました: ${name}は再生にもおすすめにも入れません。変えたいときはいつでも言ってください。`; },
|
|
132
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}の「${title}」` : artist; return `\n\n取り消しました: ${name}についての前の選択。`; },
|
|
126
133
|
"turn.cancelled": "以降の処理をキャンセルしました。",
|
|
127
134
|
"turn.interrupted": "返事が中断されました。記録された Spotify の操作結果は下のとおりです。操作は繰り返していません。",
|
|
128
135
|
};
|
package/src/i18n/messages/ko.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export default {
|
|
|
34
34
|
"failure.preparationStopped": ({ evidence }) => `재생 기기 확인을 마치지 못했습니다(${evidence}). 곡 재생 요청은 보내지 않았습니다. Spotify 기기 상태를 확인한 뒤 다시 시도해 주세요.`,
|
|
35
35
|
"failure.queueRequestRequired": "곡을 대기열에 넣지 않았습니다. 이 답변을 대기열 추가 요청과 연결하지 못했습니다. 계속하려면 재생 대기열에 음악을 넣어 달라고 분명히 요청해 주세요.",
|
|
36
36
|
"failure.notSent": ({ code }) => `Spotify에 아무것도 보내지 않았습니다. 방금 찾은 곡과 연결하지 못했습니다(${code}). 곡 제목이나 아티스트 이름을 넣어 다시 말해 주시면 다시 찾아볼게요.`,
|
|
37
|
+
"failure.avoided": "Spotify에 아무것도 보내지 않았습니다. 이 아티스트나 곡은 틀지 말라고 하셨어요. 바꾸고 싶으면 언제든 말해 주세요.",
|
|
37
38
|
"failure.outcome.lookup": "Spotify 검색을 마치지 못했습니다",
|
|
38
39
|
"failure.outcome.unknown": "Spotify 작업 결과가 확실하지 않습니다",
|
|
39
40
|
"failure.outcome.unconfirmed": "Spotify 재생 작업이 확인되지 않았습니다",
|
|
@@ -61,7 +62,10 @@ export default {
|
|
|
61
62
|
"queue.skippedDuplicates": ({ count }) => `중복된 곡 ${count}곡을 건너뛰었습니다.`,
|
|
62
63
|
"queue.skippedUncertain": ({ count }) => `이전 대기열 결과가 불확실한 곡 ${count}곡은 중복을 피하려고 건너뛰었습니다. Spotify 대기열을 확인해 주세요.`,
|
|
63
64
|
"queue.skippedAvoided": ({ count }) => `피해 달라고 한 곡 ${count}곡을 건너뛰었습니다.`,
|
|
65
|
+
"plan.avoidedLeftOut": ({ titles }) => `틀지 말라고 하신 곡은 뺐습니다: ${titles}.`,
|
|
64
66
|
"queue.skippedKnown": ({ count }) => `요청대로 청취 기록에 있는 곡 ${count}곡을 건너뛰었습니다.`,
|
|
67
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
68
|
+
`청취 기록 기준: ${[familiar && `잘 아는 곡 ${familiar}곡`, returning && `오랜만인 곡 ${returning}곡`, heard && `한두 번 들은 곡 ${heard}곡`, knownArtist && `아는 아티스트의 새 곡 ${knownArtist}곡`, fresh && `처음 듣는 곡 ${fresh}곡`].filter(Boolean).join(", ")}.`,
|
|
65
69
|
"queue.observationTruncated": "Spotify가 돌려준 대기열의 일부만 확인했습니다. 보이지 않는 부분에 같은 곡이 있을 수 있습니다.",
|
|
66
70
|
"queue.notAddedHeading": "추가하지 않은 곡:",
|
|
67
71
|
"queue.earlierReceipt": "이것은 앞선 대기열 작업의 결과입니다. 이번 설명으로 곡을 다시 넣지는 않았습니다.",
|
|
@@ -123,6 +127,9 @@ export default {
|
|
|
123
127
|
"selection.note": ({ playlist, track }) => `\n\n다음 편집에 쓸 항목: 플레이리스트 ${playlist}, 곡 ${track}.`,
|
|
124
128
|
"memory.spotifyExcluded": "\n\nSpotify 결과는 일반 기억에 넣지 않습니다. 확인된 사용자 본인의 말만 기억할 수 있으며, 다른 내용은 저장하지 않았습니다.",
|
|
125
129
|
"memory.notSaved": "\n\n답변은 끝났지만 기억을 저장하지 못했습니다.",
|
|
130
|
+
"choice.like": ({ title, artist }) => { const name = title ? `${artist}의 "${title}"` : artist; return `\n\n저장했습니다: ${name}을(를) 좋아하신다고요. 바꾸고 싶으면 언제든 말씀해 주세요.`; },
|
|
131
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `${artist}의 "${title}"` : artist; return `\n\n저장했습니다: ${name}은(는) 재생하거나 추천하지 않겠습니다. 바꾸고 싶으면 언제든 말씀해 주세요.`; },
|
|
132
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}의 "${title}"` : artist; return `\n\n취소했습니다: ${name}에 대한 이전 선택.`; },
|
|
126
133
|
"turn.cancelled": "남은 작업을 취소했습니다.",
|
|
127
134
|
"turn.interrupted": "답변이 중단되었습니다. 기록된 Spotify 결과는 아래와 같으며, 어떤 작업도 다시 실행하지 않았습니다.",
|
|
128
135
|
};
|
package/src/i18n/messages/pt.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export default {
|
|
|
34
34
|
"failure.preparationStopped": ({ evidence }) => `As verificações do dispositivo não terminaram (${evidence}); nenhum pedido de reprodução foi enviado. Confira o estado do dispositivo no Spotify antes de tentar de novo.`,
|
|
35
35
|
"failure.queueRequestRequired": "Nenhuma música foi adicionada: não consegui ligar esta resposta a um pedido de fila. Para continuar, peça explicitamente para adicionar música à fila.",
|
|
36
36
|
"failure.notSent": ({ code }) => `Não enviei nada ao Spotify: não consegui relacionar isso com as músicas que encontrei (${code}). Peça de novo com o nome da música ou do artista, e eu procuro outra vez.`,
|
|
37
|
+
"failure.avoided": "Não enviei nada ao Spotify: você pediu para eu deixar esse artista ou essa música de fora. Me diga quando quiser mudar isso.",
|
|
37
38
|
"failure.outcome.lookup": "A busca no Spotify não terminou",
|
|
38
39
|
"failure.outcome.unknown": "Não se sabe ao certo o resultado da ação no Spotify",
|
|
39
40
|
"failure.outcome.unconfirmed": "O Spotify não confirmou a ação de reprodução",
|
|
@@ -61,7 +62,10 @@ export default {
|
|
|
61
62
|
"queue.skippedDuplicates": ({ count }) => `Pulei ${count} músicas repetidas.`,
|
|
62
63
|
"queue.skippedUncertain": ({ count }) => `Pulei ${count} músicas cujo resultado anterior na fila não ficou claro, para não repetir; confira a fila do Spotify.`,
|
|
63
64
|
"queue.skippedAvoided": ({ count }) => `Pulei ${count} músicas que você marcou para evitar.`,
|
|
65
|
+
"plan.avoidedLeftOut": ({ titles }) => `Deixei de fora o que você pediu para evitar: ${titles}.`,
|
|
64
66
|
"queue.skippedKnown": ({ count }) => `Pulei ${count} músicas que já aparecem no seu histórico, como você pediu.`,
|
|
67
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
68
|
+
`Pelo seu histórico: ${[familiar && `${familiar} que você conhece bem`, returning && `${returning} ${returning === 1 ? "que volta" : "que voltam"} depois de um tempo`, heard && `${heard} que você ouviu uma ou duas vezes`, knownArtist && `${knownArtist} ${knownArtist === 1 ? "nova de um artista" : "novas de artistas"} que você conhece`, fresh && `${fresh} ${fresh === 1 ? "nova" : "novas"} para você`].filter(Boolean).join(", ")}.`,
|
|
65
69
|
"queue.observationTruncated": "A checagem de repetidas só cobre a parte da fila que o Spotify mostra; o que não aparece ainda pode repetir alguma.",
|
|
66
70
|
"queue.notAddedHeading": "Não adicionadas:",
|
|
67
71
|
"queue.earlierReceipt": "Este é o resultado da fila anterior; este esclarecimento não adicionou as músicas de novo.",
|
|
@@ -123,6 +127,9 @@ export default {
|
|
|
123
127
|
"selection.note": ({ playlist, track }) => `\n\nSelecionado para as próximas edições: playlist ${playlist}; música ${track}.`,
|
|
124
128
|
"memory.spotifyExcluded": "\n\nOs resultados do Spotify não vão para a memória geral; só citações suas verificáveis podiam ser guardadas, e o resto não foi salvo.",
|
|
125
129
|
"memory.notSaved": "\n\nA resposta terminou, mas não foi possível salvar a memória.",
|
|
130
|
+
"choice.like": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nSalvo: você gosta de ${name}. É só me dizer quando quiser mudar.`; },
|
|
131
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nSalvo: vou deixar ${name} fora do que eu toco e sugiro. É só me dizer quando quiser mudar.`; },
|
|
132
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `"${title}" de ${artist}` : artist; return `\n\nDesfeito: sua escolha anterior sobre ${name}.`; },
|
|
126
133
|
"turn.cancelled": "Cancelei o resto.",
|
|
127
134
|
"turn.interrupted": "A resposta foi interrompida. Abaixo estão os resultados registrados no Spotify; nenhuma ação foi repetida.",
|
|
128
135
|
};
|
|
@@ -34,6 +34,7 @@ export default {
|
|
|
34
34
|
"failure.preparationStopped": ({ evidence }) => `播放前的裝置檢查沒有完成(${evidence}),沒有送出歌曲播放請求。請檢查 Spotify 裝置狀態後再試。`,
|
|
35
35
|
"failure.queueRequestRequired": "這次沒有加入歌曲:我沒辦法把這則回覆對應到加入佇列的請求。需要明確請我加入播放佇列,才能繼續。",
|
|
36
36
|
"failure.notSent": ({ code }) => `沒有向 Spotify 送出任何內容:我沒辦法把它對應到剛才找到的歌曲(${code})。請帶上歌名或歌手再說一次,我會重新搜尋。`,
|
|
37
|
+
"failure.avoided": "沒有向 Spotify 送出任何內容:你要我別再放這位歌手或這首歌。想改的話隨時告訴我。",
|
|
37
38
|
"failure.outcome.lookup": "Spotify 查詢沒有完成",
|
|
38
39
|
"failure.outcome.unknown": "Spotify 操作結果尚不確定",
|
|
39
40
|
"failure.outcome.unconfirmed": "Spotify 播放操作未獲確認",
|
|
@@ -61,7 +62,10 @@ export default {
|
|
|
61
62
|
"queue.skippedDuplicates": ({ count }) => `略過 ${count} 首重複曲目。`,
|
|
62
63
|
"queue.skippedUncertain": ({ count }) => `略過 ${count} 首先前加入結果不明的曲目,避免重複;請檢查 Spotify 佇列。`,
|
|
63
64
|
"queue.skippedAvoided": ({ count }) => `略過 ${count} 首你要求避開的曲目。`,
|
|
65
|
+
"plan.avoidedLeftOut": ({ titles }) => `依你的要求略過了:${titles}。`,
|
|
64
66
|
"queue.skippedKnown": ({ count }) => `依你的要求,略過 ${count} 首聆聽紀錄裡已有的曲目。`,
|
|
67
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
68
|
+
`根據你的聆聽紀錄:${[familiar && `熟悉的 ${familiar} 首`, returning && `久違的 ${returning} 首`, heard && `聽過一兩次的 ${heard} 首`, knownArtist && `熟悉歌手的新歌 ${knownArtist} 首`, fresh && `沒聽過的 ${fresh} 首`].filter(Boolean).join(",")}。`,
|
|
65
69
|
"queue.observationTruncated": "只檢查了 Spotify 回傳的有限佇列片段,沒顯示的部分仍可能重複。",
|
|
66
70
|
"queue.notAddedHeading": "沒有加入:",
|
|
67
71
|
"queue.earlierReceipt": "這是上一次佇列操作的結果;這次說明沒有再次加入歌曲。",
|
|
@@ -123,6 +127,9 @@ export default {
|
|
|
123
127
|
"selection.note": ({ playlist, track }) => `\n\n接下來的編輯會用:播放清單 ${playlist};歌曲 ${track}。`,
|
|
124
128
|
"memory.spotifyExcluded": "\n\nSpotify 的結果不會進入一般記憶;只有可驗證的你的原話會被記住,其他內容沒有儲存。",
|
|
125
129
|
"memory.notSaved": "\n\n這則回覆已完成,但記憶沒有儲存成功。",
|
|
130
|
+
"choice.like": ({ title, artist }) => { const name = title ? `${artist}的《${title}》` : artist; return `\n\n已儲存:你喜歡${name}。想改的話隨時告訴我。`; },
|
|
131
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `${artist}的《${title}》` : artist; return `\n\n已儲存:我播放和推薦時都會避開${name}。想改的話隨時告訴我。`; },
|
|
132
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}的《${title}》` : artist; return `\n\n已撤銷:你之前對${name}的選擇。`; },
|
|
126
133
|
"turn.cancelled": "已取消後續操作。",
|
|
127
134
|
"turn.interrupted": "回覆中斷了。已記錄的 Spotify 結果如下;沒有重新執行任何操作。",
|
|
128
135
|
};
|
package/src/i18n/messages/zh.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export default {
|
|
|
34
34
|
"failure.preparationStopped": ({ evidence }) => `播放前的设备检查未完成(${evidence}),没有发送歌曲播放请求。请检查 Spotify 设备状态后再试。`,
|
|
35
35
|
"failure.queueRequestRequired": "这次没有加入歌曲:我没能把这条回复关联到入队请求。需要明确要加入播放队列,才能继续。",
|
|
36
36
|
"failure.notSent": ({ code }) => `没有向 Spotify 发送任何内容:我没能把它对应到刚才找到的歌曲(${code})。请带上歌名或歌手再说一次,我会重新搜索。`,
|
|
37
|
+
"failure.avoided": "没有向 Spotify 发送任何内容:你让我别再放这位歌手或这首歌。想改的话随时告诉我。",
|
|
37
38
|
"failure.outcome.lookup": "Spotify 查询未完成",
|
|
38
39
|
"failure.outcome.unknown": "Spotify 操作结果尚不确定",
|
|
39
40
|
"failure.outcome.unconfirmed": "Spotify 播放操作未获确认",
|
|
@@ -61,7 +62,10 @@ export default {
|
|
|
61
62
|
"queue.skippedDuplicates": ({ count }) => `跳过 ${count} 首重复曲目。`,
|
|
62
63
|
"queue.skippedUncertain": ({ count }) => `跳过 ${count} 首此前入队结果不明的曲目,避免重复;请先检查 Spotify 队列。`,
|
|
63
64
|
"queue.skippedAvoided": ({ count }) => `跳过 ${count} 首 Avoid 曲目。`,
|
|
65
|
+
"plan.avoidedLeftOut": ({ titles }) => `按你的要求略过了:${titles}。`,
|
|
64
66
|
"queue.skippedKnown": ({ count }) => `按你的要求跳过 ${count} 首有保留听歌记录的曲目。`,
|
|
67
|
+
"queue.mix": ({ familiar = 0, returning = 0, heard = 0, known_artist: knownArtist = 0, new: fresh = 0 }) =>
|
|
68
|
+
`根据你的听歌记录:${[familiar && `熟悉的 ${familiar} 首`, returning && `久违的 ${returning} 首`, heard && `听过一两次的 ${heard} 首`, knownArtist && `熟悉歌手的新歌 ${knownArtist} 首`, fresh && `没听过的 ${fresh} 首`].filter(Boolean).join(",")}。`,
|
|
65
69
|
"queue.observationTruncated": "仅检查了 Spotify 返回的有限队列片段,无法保证未显示部分没有重复。",
|
|
66
70
|
"queue.notAddedHeading": "还没加入:",
|
|
67
71
|
"queue.earlierReceipt": "这是上次队列操作的结果;这次澄清没有再次添加歌曲。",
|
|
@@ -123,6 +127,9 @@ export default {
|
|
|
123
127
|
"selection.note": ({ playlist, track }) => `\n\n后续编辑选择:歌单 ${playlist};歌曲 ${track}。`,
|
|
124
128
|
"memory.spotifyExcluded": "\n\nSpotify 查询结果不进入通用记忆;仅保存可验证的用户原话,其他记忆未保存。",
|
|
125
129
|
"memory.notSaved": "\n\n这条回复已经完成,但记忆没有成功保存。",
|
|
130
|
+
"choice.like": ({ title, artist }) => { const name = title ? `${artist}的《${title}》` : artist; return `\n\n已保存:你喜欢${name}。想改的话随时告诉我。`; },
|
|
131
|
+
"choice.avoid": ({ title, artist }) => { const name = title ? `${artist}的《${title}》` : artist; return `\n\n已保存:我播放和推荐时都会避开${name}。想改的话随时告诉我。`; },
|
|
132
|
+
"choice.retract": ({ title, artist }) => { const name = title ? `${artist}的《${title}》` : artist; return `\n\n已撤销:你之前对${name}的选择。`; },
|
|
126
133
|
"turn.cancelled": "已取消后续操作。",
|
|
127
134
|
"turn.interrupted": "回复中断;已确认的 Spotify 操作结果如下。未重新执行任何操作。",
|
|
128
135
|
};
|
package/src/i18n/screen/es.mjs
CHANGED
|
@@ -868,6 +868,7 @@ export default {
|
|
|
868
868
|
"Remember what you've told me": "Recordar lo que me has dicho",
|
|
869
869
|
"Remember something you said": "Recordar algo que dijiste",
|
|
870
870
|
"Forget something": "Olvidar algo",
|
|
871
|
+
"Save your choice": "Guardar tu elección",
|
|
871
872
|
"Check what Spotify is playing": "Ver qué suena en Spotify",
|
|
872
873
|
"See what's playing in detail": "Ver en detalle qué suena",
|
|
873
874
|
"Control Spotify playback": "Controlar la reproducción de Spotify",
|
package/src/i18n/screen/ja.mjs
CHANGED
|
@@ -868,6 +868,7 @@ export default {
|
|
|
868
868
|
"Remember what you've told me": "教えてもらったことを覚える",
|
|
869
869
|
"Remember something you said": "あなたの言ったことを覚える",
|
|
870
870
|
"Forget something": "忘れる",
|
|
871
|
+
"Save your choice": "あなたの選択を保存する",
|
|
871
872
|
"Check what Spotify is playing": "Spotify で再生中のものを確認",
|
|
872
873
|
"See what's playing in detail": "再生中の曲の詳細を見る",
|
|
873
874
|
"Control Spotify playback": "Spotify の再生を操作",
|
package/src/i18n/screen/pt.mjs
CHANGED
|
@@ -868,6 +868,7 @@ export default {
|
|
|
868
868
|
"Remember what you've told me": "Lembrar o que você me disse",
|
|
869
869
|
"Remember something you said": "Lembrar algo que você disse",
|
|
870
870
|
"Forget something": "Esquecer algo",
|
|
871
|
+
"Save your choice": "Salvar sua escolha",
|
|
871
872
|
"Check what Spotify is playing": "Ver o que está tocando no Spotify",
|
|
872
873
|
"See what's playing in detail": "Ver em detalhe o que está tocando",
|
|
873
874
|
"Control Spotify playback": "Controlar a reprodução do Spotify",
|
package/src/i18n/screen/zh.mjs
CHANGED
|
@@ -868,6 +868,7 @@ export default {
|
|
|
868
868
|
"Remember what you've told me": "记住你告诉我的",
|
|
869
869
|
"Remember something you said": "记住你说过的一件事",
|
|
870
870
|
"Forget something": "忘掉一件事",
|
|
871
|
+
"Save your choice": "保存你的选择",
|
|
871
872
|
"Check what Spotify is playing": "查看 Spotify 正在放什么",
|
|
872
873
|
"See what's playing in detail": "查看正在播放的详情",
|
|
873
874
|
"Control Spotify playback": "控制 Spotify 播放",
|
|
@@ -452,6 +452,15 @@ export function createAppleMusicCatalogClient({
|
|
|
452
452
|
});
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
// One artist's songs as Apple lists them. The keyword search has no entry for some albums, such as
|
|
456
|
+
// 刘森's 华北浪革, and their album lookup lists no songs, but the artist's lookup has them.
|
|
457
|
+
async function artistSongResults(artistId) {
|
|
458
|
+
const catalogId = cleanArtistCatalogId(artistId);
|
|
459
|
+
const key = `artist-songs|${storefront()}|${catalogId}`;
|
|
460
|
+
return cachedRequest(cache, key, cacheTtlMs, now, () =>
|
|
461
|
+
request("/lookup", { id: catalogId, country: storefront().toLowerCase(), entity: "song", limit: 200 }));
|
|
462
|
+
}
|
|
463
|
+
|
|
455
464
|
return Object.freeze({
|
|
456
465
|
async searchTracks(query) {
|
|
457
466
|
const cleaned = cleanInputText(
|
|
@@ -491,8 +500,13 @@ export function createAppleMusicCatalogClient({
|
|
|
491
500
|
return cachedRequest(cache, key, cacheTtlMs, now, async () => {
|
|
492
501
|
const results = await request("/lookup", { id: catalogId, country: storefront().toLowerCase(), entity: "song", limit: 200 });
|
|
493
502
|
const order = (raw) => (Number.isSafeInteger(raw?.discNumber) ? raw.discNumber : 1) * 1000 + (Number.isSafeInteger(raw?.trackNumber) ? raw.trackNumber : 999);
|
|
494
|
-
|
|
495
|
-
|
|
503
|
+
const song = (raw) => isPlainObject(raw) && raw.kind === "song";
|
|
504
|
+
let songs = results.filter(song);
|
|
505
|
+
const artistId = safeCatalogId(results.find((raw) => isPlainObject(raw) && raw.wrapperType === "collection")?.artistId);
|
|
506
|
+
if (!songs.length && artistId) {
|
|
507
|
+
songs = (await artistSongResults(artistId)).filter((raw) => song(raw) && safeCatalogId(raw.collectionId) === catalogId);
|
|
508
|
+
}
|
|
509
|
+
return songs
|
|
496
510
|
.sort((left, right) => order(left) - order(right))
|
|
497
511
|
.map(normalizeTrack)
|
|
498
512
|
.filter(Boolean);
|
|
@@ -528,6 +542,10 @@ export function createAppleMusicCatalogClient({
|
|
|
528
542
|
async artistReleases(artistId) {
|
|
529
543
|
return (await artistCatalog(artistId)).releases;
|
|
530
544
|
},
|
|
545
|
+
|
|
546
|
+
async artistSongs(artistId) {
|
|
547
|
+
return (await artistSongResults(artistId)).map(normalizeTrack).filter(Boolean);
|
|
548
|
+
},
|
|
531
549
|
});
|
|
532
550
|
}
|
|
533
551
|
|
|
@@ -555,6 +573,14 @@ export function createAppleMusicCatalog({
|
|
|
555
573
|
throw new TypeError("The Apple Music catalog client is invalid.");
|
|
556
574
|
}
|
|
557
575
|
|
|
576
|
+
// The artists with exactly this name, whose own catalog has what the keyword search misses.
|
|
577
|
+
async function namedArtists(name) {
|
|
578
|
+
const wanted = normalizeForMatch(name);
|
|
579
|
+
return (await client.searchArtists(name))
|
|
580
|
+
.filter((candidate) => normalizeForMatch(candidate.name) === wanted)
|
|
581
|
+
.slice(0, APPLE_MUSIC_CATALOG_LIMITS.exactArtistCandidatesMax);
|
|
582
|
+
}
|
|
583
|
+
|
|
558
584
|
return Object.freeze({
|
|
559
585
|
async searchTracks({ queries, limit = 8 } = {}) {
|
|
560
586
|
if (
|
|
@@ -621,10 +647,18 @@ export function createAppleMusicCatalog({
|
|
|
621
647
|
const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
|
|
622
648
|
const wantedTitle = normalizeForMatch(cleanedTitle);
|
|
623
649
|
const wantedArtist = normalizeForMatch(cleanedArtist);
|
|
624
|
-
const
|
|
625
|
-
.filter((track) => track.preview_url && normalizeForMatch(track.artist_credit).includes(wantedArtist));
|
|
626
|
-
|
|
627
|
-
|
|
650
|
+
const pick = (tracks) => {
|
|
651
|
+
const results = tracks.filter((track) => track.preview_url && normalizeForMatch(track.artist_credit).includes(wantedArtist));
|
|
652
|
+
return results.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
|
|
653
|
+
results.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
|
|
654
|
+
};
|
|
655
|
+
let track = pick(await client.searchTracks(`${cleanedArtist} ${cleanedTitle}`));
|
|
656
|
+
if (!track && typeof client.artistSongs === "function") {
|
|
657
|
+
for (const named of await namedArtists(cleanedArtist)) {
|
|
658
|
+
track = pick(await client.artistSongs(named.catalog_id));
|
|
659
|
+
if (track) break;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
628
662
|
return {
|
|
629
663
|
state: track ? "resolved" : "not_found",
|
|
630
664
|
source: sourceMetadata(now, storefront),
|
|
@@ -641,10 +675,19 @@ export function createAppleMusicCatalog({
|
|
|
641
675
|
const cleanedArtist = cleanInputText(artist, APPLE_MUSIC_CATALOG_LIMITS.discoveryQueryLengthMax, "apple_music_catalog_query_invalid", "artist");
|
|
642
676
|
const wantedTitle = normalizeForMatch(cleanedTitle);
|
|
643
677
|
const wantedArtist = normalizeForMatch(cleanedArtist);
|
|
644
|
-
const
|
|
645
|
-
.filter((candidate) => normalizeForMatch(candidate.artist_credit).includes(wantedArtist));
|
|
646
|
-
const album = albums.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
|
|
678
|
+
const pick = (albums) => albums.find((candidate) => normalizeForMatch(candidate.title) === wantedTitle) ??
|
|
647
679
|
albums.find((candidate) => normalizeForMatch(candidate.title).startsWith(wantedTitle));
|
|
680
|
+
let album = pick((await client.searchAlbums(`${cleanedArtist} ${cleanedTitle}`))
|
|
681
|
+
.filter((candidate) => normalizeForMatch(candidate.artist_credit).includes(wantedArtist)));
|
|
682
|
+
if (!album) {
|
|
683
|
+
for (const named of await namedArtists(cleanedArtist)) {
|
|
684
|
+
const release = pick(await client.artistReleases(named.catalog_id));
|
|
685
|
+
if (!release) continue;
|
|
686
|
+
album = { catalog_id: release.catalog_id, title: release.title, artist_credit: release.artist_name,
|
|
687
|
+
...(release.catalog_url ? { catalog_url: release.catalog_url } : {}) };
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
648
691
|
const tracks = album ? (await client.albumTracks(album.catalog_id)).filter((track) => track.preview_url).slice(0, 30) : [];
|
|
649
692
|
return {
|
|
650
693
|
state: tracks.length ? "resolved" : "not_found",
|
|
@@ -227,23 +227,29 @@ export function createNeteasePlayer({
|
|
|
227
227
|
/**
|
|
228
228
|
* Finds and plays songs: each item is a search result's id, or a title and artist to look
|
|
229
229
|
* up. mode "play" replaces the queue and starts the first; "queue" adds after what is
|
|
230
|
-
* playing. Songs NetEase marks as not playable are never sent to the player
|
|
230
|
+
* playing. Songs NetEase marks as not playable are never sent to the player, nor songs
|
|
231
|
+
* allow(song) refuses, such as the listener's Avoids; those are only counted. Before a lookup,
|
|
232
|
+
* allow gets the title and artist asked for, as { title, artist_credit }.
|
|
231
233
|
*/
|
|
232
|
-
async play({ items = [], mode = "play", intent } = {}) {
|
|
234
|
+
async play({ items = [], mode = "play", intent, allow = () => true } = {}) {
|
|
233
235
|
const found = [];
|
|
234
236
|
const unplayable = [];
|
|
235
237
|
const notFound = [];
|
|
238
|
+
let avoided = 0;
|
|
236
239
|
for (const item of items.slice(0, 20)) {
|
|
237
240
|
if (item?.id) {
|
|
238
241
|
const song = known.get(String(item.id));
|
|
239
242
|
if (!song) notFound.push({ id: String(item.id) });
|
|
240
243
|
else if (!song.playable) unplayable.push(song);
|
|
244
|
+
else if (!allow(song)) avoided += 1;
|
|
241
245
|
else found.push({ song, other_artist: false });
|
|
242
246
|
continue;
|
|
243
247
|
}
|
|
244
248
|
const wanted = { title: String(item?.title ?? ""), artist: String(item?.artist ?? "") };
|
|
249
|
+
if (!allow({ title: wanted.title, artist_credit: wanted.artist })) { avoided += 1; continue; }
|
|
245
250
|
const result = pick(await player.search({ query: `${wanted.title} ${wanted.artist}`.trim(), limit: 10, intent }), wanted);
|
|
246
|
-
if (result.song
|
|
251
|
+
if (result.song && !allow(result.song)) avoided += 1;
|
|
252
|
+
else if (result.song) found.push(result);
|
|
247
253
|
else if (result.unplayable) unplayable.push({ title: wanted.title, artists: [wanted.artist].filter(Boolean) });
|
|
248
254
|
else notFound.push(wanted);
|
|
249
255
|
}
|
|
@@ -266,6 +272,7 @@ export function createNeteasePlayer({
|
|
|
266
272
|
songs: found.map(({ song, other_artist: otherArtist }) => ({ ...song, other_artist: otherArtist })),
|
|
267
273
|
unplayable,
|
|
268
274
|
not_found: notFound,
|
|
275
|
+
...(avoided ? { skipped_avoided_count: avoided } : {}),
|
|
269
276
|
};
|
|
270
277
|
},
|
|
271
278
|
|