@audiofool/moondog 0.3.0-beta.1 → 0.3.0-beta.11
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 +30 -4
- package/package.json +1 -1
- package/scripts/moondog.mjs +148 -14
- package/src/core/apple-projection-domain-services.mjs +4 -0
- package/src/core/capability-catalog.mjs +49 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +80 -6
- package/src/core/listening-profile-domain-services.mjs +4 -0
- package/src/core/moondog-application.mjs +578 -32
- package/src/i18n/index.mjs +6 -3
- package/src/i18n/messages/de.mjs +1 -0
- package/src/i18n/messages/en.mjs +2 -0
- package/src/i18n/messages/es.mjs +1 -0
- package/src/i18n/messages/fr.mjs +1 -0
- package/src/i18n/messages/it.mjs +1 -0
- package/src/i18n/messages/ja.mjs +1 -0
- package/src/i18n/messages/ko.mjs +1 -0
- package/src/i18n/messages/pt.mjs +1 -0
- package/src/i18n/messages/zh-Hant.mjs +1 -0
- package/src/i18n/messages/zh.mjs +1 -0
- package/src/i18n/preferences.mjs +24 -0
- package/src/i18n/screen/es.mjs +94 -0
- package/src/i18n/screen/ja.mjs +94 -0
- package/src/i18n/screen/pt.mjs +94 -0
- package/src/i18n/screen/zh.mjs +94 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +31 -1
- package/src/integrations/netease/ncm-cli-player.mjs +12 -6
- package/src/integrations/playlist-links.mjs +102 -0
- package/src/integrations/public-playlists.mjs +39 -8
- package/src/integrations/spotify/service.mjs +22 -1
- package/src/integrations/stable-audio/cover.mjs +324 -0
- package/src/integrations/stable-audio/local-generator.mjs +259 -87
- package/src/integrations/stable-audio/setup.mjs +653 -0
- package/src/integrations/stable-audio/sources.mjs +106 -0
- package/src/integrations/web/deepseek-web.mjs +1 -1
- package/src/integrations/web/web-research.mjs +84 -17
- package/src/integrations/web/web-result.mjs +60 -12
- package/src/profile/listening-history-store.mjs +55 -1
- package/src/profile/listening-profile-projection.mjs +1 -1
- package/src/profile/music-import-bundle.mjs +3 -1
- package/src/profile/music-providers.mjs +3 -0
- package/src/profile/taste-compiler.mjs +14 -5
- package/src/runtime/pi/agent-runtime.mjs +359 -37
- package/src/runtime/pi/configured-runtime.mjs +3 -0
- package/src/runtime/pi/image-input.mjs +148 -0
- package/src/runtime/pi/model-roles.mjs +121 -0
- package/src/runtime/pi/model-settings.mjs +63 -2
- package/src/runtime/pi/model-switch.mjs +26 -0
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/record-name.mjs +80 -0
- package/src/runtime/pi/reply-language.mjs +45 -0
- package/src/runtime/pi/runtime-settings.mjs +4 -2
- package/src/runtime/pi/tool-arguments.mjs +89 -0
- package/src/surfaces/cli/command-completions.mjs +15 -1
- package/src/surfaces/cli/model-roles-command.mjs +60 -0
- package/src/surfaces/cli/tui.mjs +419 -19
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// The listener's library: their own collection, kept on this Mac and shared by the terminal and the window.
|
|
2
|
+
// Songs by artists and the tracks Moondog made sit side by side in it, each entry saying which it is, so a
|
|
3
|
+
// playlist can hold both and still tell "Moondog made this" from a song someone recorded.
|
|
4
|
+
//
|
|
5
|
+
// Folders hold playlists and other folders, up to MAX_DEPTH deep; inside a folder, folders and playlists share
|
|
6
|
+
// one order, which the listener sets by dragging. Saved is the one built-in playlist: single songs and made
|
|
7
|
+
// tracks the listener kept, first in the list, never renamed or deleted. An entry keeps what names the song
|
|
8
|
+
// (title, artist, album, ISRC) and the ids each service found for it, so the same playlist plays on whichever
|
|
9
|
+
// service the listener chose, and any one service can be dropped without losing it.
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { resolveMoondogStateDirectory } from "./state-directory.mjs";
|
|
14
|
+
|
|
15
|
+
export const LIBRARY_SCHEMA_VERSION = 1;
|
|
16
|
+
export const SAVED_PLAYLIST_ID = "saved";
|
|
17
|
+
export const MAX_DEPTH = 4;
|
|
18
|
+
export const MAX_ENTRIES = 2000;
|
|
19
|
+
const ORIGINS = new Set(["listener", "moondog", "spotify", "apple_music", "netease", "qq_music", "file"]);
|
|
20
|
+
const ADDERS = new Set(["listener", "moondog"]);
|
|
21
|
+
|
|
22
|
+
export const resolveLibraryPath = (environment = process.env) => path.join(resolveMoondogStateDirectory(environment), "library.sqlite");
|
|
23
|
+
|
|
24
|
+
const schema = `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS library_folders (
|
|
26
|
+
folder_id TEXT PRIMARY KEY NOT NULL,
|
|
27
|
+
parent_id TEXT REFERENCES library_folders(folder_id),
|
|
28
|
+
name TEXT NOT NULL,
|
|
29
|
+
position INTEGER NOT NULL,
|
|
30
|
+
created_at TEXT NOT NULL
|
|
31
|
+
) STRICT;
|
|
32
|
+
CREATE TABLE IF NOT EXISTS library_playlists (
|
|
33
|
+
playlist_id TEXT PRIMARY KEY NOT NULL,
|
|
34
|
+
folder_id TEXT REFERENCES library_folders(folder_id),
|
|
35
|
+
name TEXT NOT NULL,
|
|
36
|
+
origin TEXT NOT NULL,
|
|
37
|
+
source_json TEXT,
|
|
38
|
+
position INTEGER NOT NULL,
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
updated_at TEXT NOT NULL
|
|
41
|
+
) STRICT;
|
|
42
|
+
CREATE TABLE IF NOT EXISTS library_entries (
|
|
43
|
+
entry_id TEXT PRIMARY KEY NOT NULL,
|
|
44
|
+
playlist_id TEXT NOT NULL REFERENCES library_playlists(playlist_id) ON DELETE CASCADE,
|
|
45
|
+
position INTEGER NOT NULL,
|
|
46
|
+
kind TEXT NOT NULL CHECK (kind IN ('song', 'made')),
|
|
47
|
+
title TEXT NOT NULL,
|
|
48
|
+
artist_credit TEXT NOT NULL DEFAULT '',
|
|
49
|
+
album TEXT,
|
|
50
|
+
isrc TEXT,
|
|
51
|
+
made_id TEXT,
|
|
52
|
+
refs_json TEXT NOT NULL DEFAULT '{}',
|
|
53
|
+
added_by TEXT NOT NULL,
|
|
54
|
+
added_at TEXT NOT NULL
|
|
55
|
+
) STRICT;
|
|
56
|
+
CREATE INDEX IF NOT EXISTS library_entries_by_playlist ON library_entries(playlist_id, position);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS library_entries_by_made ON library_entries(made_id) WHERE made_id IS NOT NULL;
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const libraryError = (code, message) => Object.assign(new Error(message), { code });
|
|
61
|
+
|
|
62
|
+
function cleanText(value, maximum, label, { optional = false } = {}) {
|
|
63
|
+
if (optional && (value === undefined || value === null || (typeof value === "string" && !value.trim()))) return null;
|
|
64
|
+
if (typeof value !== "string") throw libraryError("library_invalid", `${label} must be text.`);
|
|
65
|
+
const cleaned = value.replace(/[\u0000-\u001f\u007f--]/gu, " ").replace(/\s+/gu, " ").trim();
|
|
66
|
+
if (!cleaned) throw libraryError("library_invalid", `${label} must not be empty.`);
|
|
67
|
+
return Array.from(cleaned).slice(0, maximum).join("");
|
|
68
|
+
}
|
|
69
|
+
const cleanId = (value, label) => cleanText(value, 128, label);
|
|
70
|
+
const madeIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
71
|
+
|
|
72
|
+
// The ids a service gave a song, kept so the next play skips the search. Only short plain strings are kept.
|
|
73
|
+
// A cached Spotify recording keeps its own title and credit, so a later Avoid is checked against it.
|
|
74
|
+
const REF_KEYS = ["spotify_uri", "spotify_title", "spotify_credit", "apple_music_id", "netease_id"];
|
|
75
|
+
function cleanRefs(refs) {
|
|
76
|
+
const kept = {};
|
|
77
|
+
if (!refs || typeof refs !== "object") return kept;
|
|
78
|
+
// A cover from the services' own image hosts, which the window already shows.
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(String(refs.artwork_url ?? ""));
|
|
81
|
+
if (url.protocol === "https:" && /(^|\.)(mzstatic\.com|scdn\.co|music\.126\.net)$/u.test(url.hostname) && url.href.length <= 500) kept.artwork_url = url.href;
|
|
82
|
+
} catch { /* No cover. */ }
|
|
83
|
+
for (const key of REF_KEYS) {
|
|
84
|
+
const value = refs[key];
|
|
85
|
+
if (typeof value === "string" && value.trim() && value.length <= 200) kept[key] = value.trim();
|
|
86
|
+
}
|
|
87
|
+
return kept;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One entry to add, as it is kept. A song has a title and an artist; a made track has its id and title. */
|
|
91
|
+
export function normalizeLibraryEntry(entry) {
|
|
92
|
+
if (!entry || typeof entry !== "object") throw libraryError("library_invalid", "An entry must be an object.");
|
|
93
|
+
const kind = entry.kind === "made" ? "made" : "song";
|
|
94
|
+
if (kind === "made") {
|
|
95
|
+
const id = String(entry.made_id ?? entry.id ?? "");
|
|
96
|
+
if (!madeIdPattern.test(id)) throw libraryError("library_invalid", "A made track needs its id.");
|
|
97
|
+
return { kind, title: cleanText(entry.title, 200, "Title"), artist_credit: "Moondog", album: null, isrc: null, made_id: id, refs: {} };
|
|
98
|
+
}
|
|
99
|
+
const isrc = typeof entry.isrc === "string" && /^[A-Z]{2}[A-Z0-9]{3}\d{7}$/u.test(entry.isrc.trim().toUpperCase()) ? entry.isrc.trim().toUpperCase() : null;
|
|
100
|
+
return {
|
|
101
|
+
kind,
|
|
102
|
+
title: cleanText(entry.title, 200, "Title"),
|
|
103
|
+
artist_credit: cleanText(entry.artist_credit ?? entry.artist ?? (Array.isArray(entry.artists) ? entry.artists.join(", ") : ""), 200, "Artist"),
|
|
104
|
+
album: cleanText(entry.album, 200, "Album", { optional: true }),
|
|
105
|
+
isrc,
|
|
106
|
+
made_id: null,
|
|
107
|
+
refs: cleanRefs(entry.refs),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The same song twice in Saved is one song; ISRC first, then title and artist without case or marks.
|
|
112
|
+
const fold = (value) => String(value ?? "").normalize("NFKD").replace(/\p{M}/gu, "").toLocaleLowerCase("en-US").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
113
|
+
export const entryIdentity = (entry) => entry.kind === "made" ? `made:${entry.made_id}` : entry.isrc ? `isrc:${entry.isrc}` : `song:${fold(entry.title)}|${fold(entry.artist_credit)}`;
|
|
114
|
+
|
|
115
|
+
const entryOf = (row) => ({
|
|
116
|
+
id: row.entry_id,
|
|
117
|
+
kind: row.kind,
|
|
118
|
+
title: row.title,
|
|
119
|
+
artist_credit: row.artist_credit,
|
|
120
|
+
...(row.album ? { album: row.album } : {}),
|
|
121
|
+
...(row.isrc ? { isrc: row.isrc } : {}),
|
|
122
|
+
...(row.made_id ? { made_id: row.made_id } : {}),
|
|
123
|
+
refs: JSON.parse(row.refs_json || "{}"),
|
|
124
|
+
added_by: row.added_by,
|
|
125
|
+
added_at: row.added_at,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export class LibraryStore {
|
|
129
|
+
#database;
|
|
130
|
+
#clock;
|
|
131
|
+
#closed = false;
|
|
132
|
+
#listeners = new Set();
|
|
133
|
+
|
|
134
|
+
constructor(database, { clock = () => new Date() } = {}) {
|
|
135
|
+
this.#database = database;
|
|
136
|
+
this.#clock = clock;
|
|
137
|
+
this.#ensureSaved();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#now() { return new Date(this.#clock()).toISOString(); }
|
|
141
|
+
#open() { if (this.#closed) throw libraryError("library_closed", "The library is closed."); }
|
|
142
|
+
#transaction(work) {
|
|
143
|
+
this.#open();
|
|
144
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
145
|
+
try {
|
|
146
|
+
const result = work();
|
|
147
|
+
this.#database.exec("COMMIT");
|
|
148
|
+
// This connection's own writes leave its data version alone, so its surfaces hear of them here.
|
|
149
|
+
for (const listener of this.#listeners) { try { listener(); } catch { /* A surface's redraw can't undo the write. */ } }
|
|
150
|
+
return result;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.#database.exec("ROLLBACK");
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
#ensureSaved() {
|
|
157
|
+
const at = this.#now();
|
|
158
|
+
this.#database.prepare(`INSERT OR IGNORE INTO library_playlists(playlist_id, folder_id, name, origin, source_json, position, created_at, updated_at)
|
|
159
|
+
VALUES (?, NULL, 'Saved', 'listener', NULL, -1, ?, ?)`).run(SAVED_PLAYLIST_ID, at, at);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Folders and playlists in one parent share one order, as the listener dragged them.
|
|
163
|
+
#children(parentId) {
|
|
164
|
+
const folders = this.#database.prepare(`SELECT folder_id AS id, position, 'folder' AS kind FROM library_folders WHERE parent_id IS ?`).all(parentId);
|
|
165
|
+
const playlists = this.#database.prepare(`SELECT playlist_id AS id, position, 'playlist' AS kind FROM library_playlists
|
|
166
|
+
WHERE folder_id IS ? AND playlist_id != ?`).all(parentId, SAVED_PLAYLIST_ID);
|
|
167
|
+
return [...folders, ...playlists].sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
|
|
168
|
+
}
|
|
169
|
+
#renumber(parentId, order) {
|
|
170
|
+
const folder = this.#database.prepare("UPDATE library_folders SET position = ? WHERE folder_id = ?");
|
|
171
|
+
const playlist = this.#database.prepare("UPDATE library_playlists SET position = ? WHERE playlist_id = ?");
|
|
172
|
+
order.forEach((node, index) => (node.kind === "folder" ? folder : playlist).run(index, node.id));
|
|
173
|
+
}
|
|
174
|
+
#requireFolder(id) {
|
|
175
|
+
if (id === null) return null;
|
|
176
|
+
const row = this.#database.prepare("SELECT folder_id, parent_id FROM library_folders WHERE folder_id = ?").get(id);
|
|
177
|
+
if (!row) throw libraryError("library_folder_not_found", "That folder is not in the library.");
|
|
178
|
+
return row;
|
|
179
|
+
}
|
|
180
|
+
#depth(folderId) {
|
|
181
|
+
let depth = 0;
|
|
182
|
+
for (let id = folderId; id !== null; depth += 1) id = this.#requireFolder(id).parent_id ?? null;
|
|
183
|
+
return depth;
|
|
184
|
+
}
|
|
185
|
+
// How many levels a folder holds below itself, counting itself.
|
|
186
|
+
#height(folderId) {
|
|
187
|
+
const kids = this.#database.prepare("SELECT folder_id FROM library_folders WHERE parent_id = ?").all(folderId);
|
|
188
|
+
return 1 + Math.max(0, ...kids.map((kid) => this.#height(kid.folder_id)));
|
|
189
|
+
}
|
|
190
|
+
#requirePlaylist(id) {
|
|
191
|
+
const row = this.#database.prepare("SELECT playlist_id, folder_id, name FROM library_playlists WHERE playlist_id = ?").get(id);
|
|
192
|
+
if (!row) throw libraryError("library_playlist_not_found", "That playlist is not in the library.");
|
|
193
|
+
return row;
|
|
194
|
+
}
|
|
195
|
+
// Puts a node in a parent before a sibling, or last; the parent's order is written again.
|
|
196
|
+
#place(node, parentId, beforeId) {
|
|
197
|
+
const order = this.#children(parentId).filter((each) => !(each.id === node.id && each.kind === node.kind));
|
|
198
|
+
const at = beforeId ? order.findIndex((each) => each.id === beforeId) : -1;
|
|
199
|
+
order.splice(at < 0 ? order.length : at, 0, node);
|
|
200
|
+
this.#renumber(parentId, order);
|
|
201
|
+
}
|
|
202
|
+
#touch(playlistId) {
|
|
203
|
+
this.#database.prepare("UPDATE library_playlists SET updated_at = ? WHERE playlist_id = ?").run(this.#now(), playlistId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The tree the sidebar draws: folders, and playlists with their counts and a cover (the first entry's
|
|
208
|
+
* artwork, or the first made track, whose cover the window draws), Saved among them.
|
|
209
|
+
*/
|
|
210
|
+
view() {
|
|
211
|
+
this.#open();
|
|
212
|
+
const folders = this.#database.prepare("SELECT folder_id, parent_id, name, position FROM library_folders").all()
|
|
213
|
+
.map((row) => ({ id: row.folder_id, parentId: row.parent_id ?? null, name: row.name, position: row.position }));
|
|
214
|
+
const firsts = this.#database.prepare("SELECT made_id, refs_json FROM library_entries WHERE playlist_id = ? ORDER BY position LIMIT 8");
|
|
215
|
+
const coverOf = (playlistId) => {
|
|
216
|
+
for (const row of firsts.all(playlistId)) {
|
|
217
|
+
if (row.made_id) return { made_id: row.made_id };
|
|
218
|
+
const artwork = JSON.parse(row.refs_json || "{}").artwork_url;
|
|
219
|
+
if (artwork) return { artwork_url: artwork };
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
};
|
|
223
|
+
const playlists = this.#database.prepare(`SELECT p.playlist_id, p.folder_id, p.name, p.origin, p.position, p.updated_at,
|
|
224
|
+
(SELECT COUNT(*) FROM library_entries e WHERE e.playlist_id = p.playlist_id) AS count,
|
|
225
|
+
(SELECT COUNT(*) FROM library_entries e WHERE e.playlist_id = p.playlist_id AND e.kind = 'made') AS made
|
|
226
|
+
FROM library_playlists p`).all()
|
|
227
|
+
.map((row) => ({ id: row.playlist_id, folderId: row.folder_id ?? null, name: row.name, origin: row.origin, position: row.position,
|
|
228
|
+
updatedAt: row.updated_at, count: row.count, made: row.made, cover: coverOf(row.playlist_id),
|
|
229
|
+
...(row.playlist_id === SAVED_PLAYLIST_ID ? { saved: true } : {}) }));
|
|
230
|
+
return { folders, playlists };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One playlist with its entries in order, or null. */
|
|
234
|
+
playlist(id) {
|
|
235
|
+
this.#open();
|
|
236
|
+
const row = this.#database.prepare("SELECT * FROM library_playlists WHERE playlist_id = ?").get(String(id ?? ""));
|
|
237
|
+
if (!row) return null;
|
|
238
|
+
const entries = this.#database.prepare("SELECT * FROM library_entries WHERE playlist_id = ? ORDER BY position, added_at").all(row.playlist_id).map(entryOf);
|
|
239
|
+
return { id: row.playlist_id, folderId: row.folder_id ?? null, name: row.name, origin: row.origin,
|
|
240
|
+
...(row.source_json ? { source: JSON.parse(row.source_json) } : {}), createdAt: row.created_at, updatedAt: row.updated_at,
|
|
241
|
+
...(row.playlist_id === SAVED_PLAYLIST_ID ? { saved: true } : {}), entries };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Playlists whose names hold the words, best first, for the agent and the terminal. */
|
|
245
|
+
findPlaylists(query, { limit = 8 } = {}) {
|
|
246
|
+
const wanted = fold(query);
|
|
247
|
+
const { playlists } = this.view();
|
|
248
|
+
if (!wanted) return playlists.slice(0, limit);
|
|
249
|
+
const scored = playlists.map((playlist) => {
|
|
250
|
+
const name = fold(playlist.saved ? "saved" : playlist.name);
|
|
251
|
+
return { playlist, score: name === wanted ? 3 : name.startsWith(wanted) ? 2 : name.includes(wanted) || wanted.includes(name) ? 1 : 0 };
|
|
252
|
+
}).filter((each) => each.score > 0);
|
|
253
|
+
return scored.sort((a, b) => b.score - a.score || b.playlist.updatedAt.localeCompare(a.playlist.updatedAt)).slice(0, limit).map((each) => each.playlist);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Makes a folder, or renames or moves one. Moving keeps it within MAX_DEPTH and never inside itself. */
|
|
257
|
+
saveFolder({ id, name, parentId, beforeId } = {}) {
|
|
258
|
+
return this.#transaction(() => {
|
|
259
|
+
const parent = parentId === undefined ? undefined : parentId === null ? null : cleanId(parentId, "Folder");
|
|
260
|
+
if (!id) {
|
|
261
|
+
const folderName = cleanText(name, 80, "Folder name");
|
|
262
|
+
const under = parent ?? null;
|
|
263
|
+
if (under !== null && this.#depth(under) >= MAX_DEPTH) throw libraryError("library_too_deep", `Folders go ${MAX_DEPTH} levels deep at most.`);
|
|
264
|
+
if (under !== null) this.#requireFolder(under);
|
|
265
|
+
const folderId = randomUUID();
|
|
266
|
+
this.#database.prepare("INSERT INTO library_folders(folder_id, parent_id, name, position, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
267
|
+
.run(folderId, under, folderName, 1e9, this.#now());
|
|
268
|
+
this.#place({ id: folderId, kind: "folder" }, under, beforeId ?? null);
|
|
269
|
+
return { id: folderId, parentId: under, name: folderName };
|
|
270
|
+
}
|
|
271
|
+
const folderId = cleanId(id, "Folder");
|
|
272
|
+
const current = this.#requireFolder(folderId);
|
|
273
|
+
if (name !== undefined) this.#database.prepare("UPDATE library_folders SET name = ? WHERE folder_id = ?").run(cleanText(name, 80, "Folder name"), folderId);
|
|
274
|
+
if (parent !== undefined || beforeId !== undefined) {
|
|
275
|
+
const under = parent === undefined ? current.parent_id ?? null : parent;
|
|
276
|
+
for (let at = under; at !== null; at = this.#requireFolder(at).parent_id ?? null) {
|
|
277
|
+
if (at === folderId) throw libraryError("library_folder_cycle", "A folder can't go inside itself.");
|
|
278
|
+
}
|
|
279
|
+
if ((under === null ? 0 : this.#depth(under)) + this.#height(folderId) > MAX_DEPTH) {
|
|
280
|
+
throw libraryError("library_too_deep", `Folders go ${MAX_DEPTH} levels deep at most.`);
|
|
281
|
+
}
|
|
282
|
+
this.#database.prepare("UPDATE library_folders SET parent_id = ? WHERE folder_id = ?").run(under, folderId);
|
|
283
|
+
this.#place({ id: folderId, kind: "folder" }, under, beforeId ?? null);
|
|
284
|
+
if ((current.parent_id ?? null) !== under) this.#renumber(current.parent_id ?? null, this.#children(current.parent_id ?? null));
|
|
285
|
+
}
|
|
286
|
+
const row = this.#requireFolder(folderId);
|
|
287
|
+
return { id: folderId, parentId: row.parent_id ?? null };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Deletes a folder; what it held moves up into its parent, in its place, so nothing is lost. */
|
|
292
|
+
deleteFolder(id) {
|
|
293
|
+
return this.#transaction(() => {
|
|
294
|
+
const folderId = cleanId(id, "Folder");
|
|
295
|
+
const folder = this.#requireFolder(folderId);
|
|
296
|
+
const parent = folder.parent_id ?? null;
|
|
297
|
+
const held = this.#children(folderId);
|
|
298
|
+
this.#database.prepare("UPDATE library_folders SET parent_id = ? WHERE parent_id = ?").run(parent, folderId);
|
|
299
|
+
this.#database.prepare("UPDATE library_playlists SET folder_id = ? WHERE folder_id = ?").run(parent, folderId);
|
|
300
|
+
const order = this.#children(parent).filter((node) => !held.some((each) => each.id === node.id));
|
|
301
|
+
const at = order.findIndex((node) => node.id === folderId);
|
|
302
|
+
order.splice(at, 1, ...held);
|
|
303
|
+
this.#database.prepare("DELETE FROM library_folders WHERE folder_id = ?").run(folderId);
|
|
304
|
+
this.#renumber(parent, order);
|
|
305
|
+
return { deleted: true, moved: held.length };
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Makes a playlist, optionally with entries, or renames or moves one. */
|
|
310
|
+
savePlaylist({ id, name, folderId, beforeId, origin, source, entries, addedBy = "listener" } = {}) {
|
|
311
|
+
return this.#transaction(() => {
|
|
312
|
+
const folder = folderId === undefined ? undefined : folderId === null ? null : cleanId(folderId, "Folder");
|
|
313
|
+
if (folder) this.#requireFolder(folder);
|
|
314
|
+
if (!id) {
|
|
315
|
+
const playlistId = randomUUID();
|
|
316
|
+
const at = this.#now();
|
|
317
|
+
const kept = ORIGINS.has(origin) ? origin : "listener";
|
|
318
|
+
this.#database.prepare(`INSERT INTO library_playlists(playlist_id, folder_id, name, origin, source_json, position, created_at, updated_at)
|
|
319
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(playlistId, folder ?? null, cleanText(name, 120, "Playlist name"), kept,
|
|
320
|
+
source && typeof source === "object" ? JSON.stringify(source).slice(0, 2000) : null, 1e9, at, at);
|
|
321
|
+
this.#place({ id: playlistId, kind: "playlist" }, folder ?? null, beforeId ?? null);
|
|
322
|
+
const added = entries?.length ? this.#add(playlistId, entries, { addedBy }) : { added: 0, already: 0 };
|
|
323
|
+
return { id: playlistId, ...added };
|
|
324
|
+
}
|
|
325
|
+
const playlistId = cleanId(id, "Playlist");
|
|
326
|
+
const current = this.#requirePlaylist(playlistId);
|
|
327
|
+
if (name !== undefined) {
|
|
328
|
+
if (playlistId === SAVED_PLAYLIST_ID) throw libraryError("library_saved_fixed", "Saved keeps its name.");
|
|
329
|
+
this.#database.prepare("UPDATE library_playlists SET name = ? WHERE playlist_id = ?").run(cleanText(name, 120, "Playlist name"), playlistId);
|
|
330
|
+
this.#touch(playlistId);
|
|
331
|
+
}
|
|
332
|
+
if (folder !== undefined || beforeId !== undefined) {
|
|
333
|
+
if (playlistId === SAVED_PLAYLIST_ID) throw libraryError("library_saved_fixed", "Saved stays at the top.");
|
|
334
|
+
const under = folder === undefined ? current.folder_id ?? null : folder;
|
|
335
|
+
this.#database.prepare("UPDATE library_playlists SET folder_id = ? WHERE playlist_id = ?").run(under, playlistId);
|
|
336
|
+
this.#place({ id: playlistId, kind: "playlist" }, under, beforeId ?? null);
|
|
337
|
+
if ((current.folder_id ?? null) !== under) this.#renumber(current.folder_id ?? null, this.#children(current.folder_id ?? null));
|
|
338
|
+
}
|
|
339
|
+
return { id: playlistId };
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
deletePlaylist(id) {
|
|
344
|
+
return this.#transaction(() => {
|
|
345
|
+
const playlistId = cleanId(id, "Playlist");
|
|
346
|
+
if (playlistId === SAVED_PLAYLIST_ID) throw libraryError("library_saved_fixed", "Saved can't be deleted.");
|
|
347
|
+
const current = this.#requirePlaylist(playlistId);
|
|
348
|
+
this.#database.prepare("DELETE FROM library_entries WHERE playlist_id = ?").run(playlistId);
|
|
349
|
+
this.#database.prepare("DELETE FROM library_playlists WHERE playlist_id = ?").run(playlistId);
|
|
350
|
+
this.#renumber(current.folder_id ?? null, this.#children(current.folder_id ?? null));
|
|
351
|
+
return { deleted: true };
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#add(playlistId, entries, { addedBy = "listener", beforeId = null } = {}) {
|
|
356
|
+
const list = (Array.isArray(entries) ? entries : [entries]).map(normalizeLibraryEntry);
|
|
357
|
+
const rows = this.#database.prepare("SELECT entry_id, kind, title, artist_credit, isrc, made_id FROM library_entries WHERE playlist_id = ? ORDER BY position").all(playlistId);
|
|
358
|
+
// Saved holds each song once; a playlist may hold one twice, as a set can.
|
|
359
|
+
const once = playlistId === SAVED_PLAYLIST_ID;
|
|
360
|
+
const known = new Set(once ? rows.map(entryIdentity) : []);
|
|
361
|
+
const fresh = [];
|
|
362
|
+
let already = 0;
|
|
363
|
+
for (const entry of list) {
|
|
364
|
+
const key = entryIdentity(entry);
|
|
365
|
+
if (once && known.has(key)) { already += 1; continue; }
|
|
366
|
+
known.add(key);
|
|
367
|
+
fresh.push(entry);
|
|
368
|
+
}
|
|
369
|
+
if (rows.length + fresh.length > MAX_ENTRIES) throw libraryError("library_full", `A playlist holds up to ${MAX_ENTRIES} entries.`);
|
|
370
|
+
const by = ADDERS.has(addedBy) ? addedBy : "listener";
|
|
371
|
+
const at = this.#now();
|
|
372
|
+
const insert = this.#database.prepare(`INSERT INTO library_entries(entry_id, playlist_id, position, kind, title, artist_credit, album, isrc, made_id, refs_json, added_by, added_at)
|
|
373
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
374
|
+
const ids = fresh.map((entry) => {
|
|
375
|
+
const entryId = randomUUID();
|
|
376
|
+
insert.run(entryId, playlistId, 1e9, entry.kind, entry.title, entry.artist_credit, entry.album, entry.isrc, entry.made_id, JSON.stringify(entry.refs), by, at);
|
|
377
|
+
return entryId;
|
|
378
|
+
});
|
|
379
|
+
// Saved shows the newest first; a playlist adds at the end, or before the entry it was dropped on.
|
|
380
|
+
const order = rows.map((row) => row.entry_id);
|
|
381
|
+
const before = beforeId ? order.indexOf(beforeId) : -1;
|
|
382
|
+
if (playlistId === SAVED_PLAYLIST_ID && !beforeId) order.unshift(...ids);
|
|
383
|
+
else order.splice(before < 0 ? order.length : before, 0, ...ids);
|
|
384
|
+
this.#renumberEntries(order);
|
|
385
|
+
this.#touch(playlistId);
|
|
386
|
+
return { added: ids.length, already, entry_ids: ids };
|
|
387
|
+
}
|
|
388
|
+
#renumberEntries(order) {
|
|
389
|
+
const update = this.#database.prepare("UPDATE library_entries SET position = ? WHERE entry_id = ?");
|
|
390
|
+
order.forEach((entryId, index) => update.run(index, entryId));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Adds songs or made tracks to a playlist (Saved when none is named). */
|
|
394
|
+
addEntries(playlistId = SAVED_PLAYLIST_ID, entries, { addedBy = "listener", beforeId = null } = {}) {
|
|
395
|
+
return this.#transaction(() => {
|
|
396
|
+
const id = cleanId(playlistId ?? SAVED_PLAYLIST_ID, "Playlist");
|
|
397
|
+
this.#requirePlaylist(id);
|
|
398
|
+
return { playlist_id: id, ...this.#add(id, entries, { addedBy, beforeId }) };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
removeEntries(playlistId, entryIds) {
|
|
403
|
+
return this.#transaction(() => {
|
|
404
|
+
const id = cleanId(playlistId, "Playlist");
|
|
405
|
+
this.#requirePlaylist(id);
|
|
406
|
+
const remove = this.#database.prepare("DELETE FROM library_entries WHERE playlist_id = ? AND entry_id = ?");
|
|
407
|
+
const removed = (Array.isArray(entryIds) ? entryIds : [entryIds]).reduce((count, entryId) => count + remove.run(id, String(entryId)).changes, 0);
|
|
408
|
+
this.#renumberEntries(this.#database.prepare("SELECT entry_id FROM library_entries WHERE playlist_id = ? ORDER BY position").all(id).map((row) => row.entry_id));
|
|
409
|
+
this.#touch(id);
|
|
410
|
+
return { removed };
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Moves entries, in their order, before another entry or to the end. */
|
|
415
|
+
moveEntries(playlistId, entryIds, beforeId = null) {
|
|
416
|
+
return this.#transaction(() => {
|
|
417
|
+
const id = cleanId(playlistId, "Playlist");
|
|
418
|
+
this.#requirePlaylist(id);
|
|
419
|
+
const moving = new Set((Array.isArray(entryIds) ? entryIds : [entryIds]).map(String));
|
|
420
|
+
const order = this.#database.prepare("SELECT entry_id FROM library_entries WHERE playlist_id = ? ORDER BY position").all(id).map((row) => row.entry_id);
|
|
421
|
+
const moved = order.filter((entryId) => moving.has(entryId));
|
|
422
|
+
const rest = order.filter((entryId) => !moving.has(entryId));
|
|
423
|
+
const at = beforeId && !moving.has(beforeId) ? rest.indexOf(beforeId) : -1;
|
|
424
|
+
rest.splice(at < 0 ? rest.length : at, 0, ...moved);
|
|
425
|
+
this.#renumberEntries(rest);
|
|
426
|
+
this.#touch(id);
|
|
427
|
+
return { moved: moved.length };
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Keeps the ids a service found for songs, so their next play skips the search. */
|
|
432
|
+
rememberRefs(updates) {
|
|
433
|
+
if (!Array.isArray(updates) || !updates.length) return;
|
|
434
|
+
this.#transaction(() => {
|
|
435
|
+
const read = this.#database.prepare("SELECT refs_json FROM library_entries WHERE entry_id = ?");
|
|
436
|
+
const write = this.#database.prepare("UPDATE library_entries SET refs_json = ? WHERE entry_id = ?");
|
|
437
|
+
for (const { entryId, refs } of updates) {
|
|
438
|
+
const row = read.get(String(entryId));
|
|
439
|
+
if (row) write.run(JSON.stringify({ ...JSON.parse(row.refs_json || "{}"), ...cleanRefs(refs) }), String(entryId));
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Whether a made track is in the library, so its audio is kept. */
|
|
445
|
+
holdsMade(madeId) {
|
|
446
|
+
this.#open();
|
|
447
|
+
return Boolean(this.#database.prepare("SELECT 1 FROM library_entries WHERE made_id = ? LIMIT 1").get(String(madeId ?? "")));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Which of these entries Saved already holds, by identity, so a surface can show a filled mark. */
|
|
451
|
+
savedKeys() {
|
|
452
|
+
this.#open();
|
|
453
|
+
return new Set(this.#database.prepare("SELECT kind, title, artist_credit, isrc, made_id FROM library_entries WHERE playlist_id = ?").all(SAVED_PLAYLIST_ID).map(entryIdentity));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Calls listener after each write through this connection; returns the unsubscribe. */
|
|
457
|
+
subscribe(listener) {
|
|
458
|
+
this.#listeners.add(listener);
|
|
459
|
+
return () => this.#listeners.delete(listener);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// SQLite's count of commits by other connections: the terminal's writes reach the window, and the window's the terminal.
|
|
463
|
+
dataVersion() {
|
|
464
|
+
this.#open();
|
|
465
|
+
return this.#database.prepare("PRAGMA data_version").get().data_version;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
close() {
|
|
469
|
+
if (this.#closed) return;
|
|
470
|
+
this.#closed = true;
|
|
471
|
+
this.#listeners.clear();
|
|
472
|
+
this.#database.close();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Opens the library at its path, or in memory for the simulated window and tests (":memory:"). */
|
|
477
|
+
export async function openLibraryStore({ databasePath = resolveLibraryPath(), clock } = {}) {
|
|
478
|
+
const memory = databasePath === ":memory:";
|
|
479
|
+
if (!memory) {
|
|
480
|
+
const parent = path.dirname(path.resolve(databasePath));
|
|
481
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
482
|
+
}
|
|
483
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
484
|
+
const database = new DatabaseSync(memory ? ":memory:" : path.resolve(databasePath));
|
|
485
|
+
try {
|
|
486
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
487
|
+
if (!memory) database.exec("PRAGMA journal_mode = WAL");
|
|
488
|
+
database.exec("PRAGMA foreign_keys = ON");
|
|
489
|
+
database.exec("PRAGMA trusted_schema = OFF");
|
|
490
|
+
const version = database.prepare("PRAGMA user_version").get().user_version;
|
|
491
|
+
if (version > LIBRARY_SCHEMA_VERSION) throw libraryError("library_newer", "This library was written by a newer Moondog.");
|
|
492
|
+
database.exec(schema);
|
|
493
|
+
if (version < LIBRARY_SCHEMA_VERSION) database.exec(`PRAGMA user_version = ${LIBRARY_SCHEMA_VERSION}`);
|
|
494
|
+
if (!memory && process.platform !== "win32") await chmod(path.resolve(databasePath), 0o600);
|
|
495
|
+
return new LibraryStore(database, { clock });
|
|
496
|
+
} catch (error) {
|
|
497
|
+
database.close();
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// What the listener plays right now, wherever it plays:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
1
|
+
// What the listener plays right now, wherever it plays: Moondog's own player (the songs it played or
|
|
2
|
+
// queued, the tracks it made, its stream), the Music app on this Mac, Spotify on its active device,
|
|
3
|
+
// or NetEase through ncm-cli, with the playlist, album or queue it plays from.
|
|
4
|
+
// Two players rarely play at once. When they do, one that plays wins over a paused one, then
|
|
5
|
+
// Moondog's own player, which pauses the others when it starts, then the listener's chosen service,
|
|
6
|
+
// then this order.
|
|
7
|
+
export const LISTENING_ORDER = Object.freeze(["moondog", "apple_music", "spotify", "netease"]);
|
|
6
8
|
|
|
7
9
|
export function activeListening(sources, chosen) {
|
|
8
|
-
const order = [...new Set([chosen, ...LISTENING_ORDER])];
|
|
10
|
+
const order = [...new Set(["moondog", chosen, ...LISTENING_ORDER])];
|
|
9
11
|
const heard = sources.filter((source) => source.song && (source.state === "playing" || source.state === "paused"));
|
|
10
12
|
return heard.sort((a, b) => (a.state === "playing" ? 0 : 1) - (b.state === "playing" ? 0 : 1) ||
|
|
11
13
|
order.indexOf(a.service) - order.indexOf(b.service))[0] ?? null;
|
|
@@ -75,6 +77,22 @@ async function spotify(application, { signal }) {
|
|
|
75
77
|
};
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
// Moondog's own player: Apple Music songs in the window or the terminal, and the tracks Moondog made.
|
|
81
|
+
const madeBy = (track) => ({ title: track.title, artists: track.generated ? ["Moondog"] : track.artist_credit ? [track.artist_credit] : [],
|
|
82
|
+
...(track.generated && track.id ? { made_track_id: track.id } : {}) });
|
|
83
|
+
function moondogPlayer(application) {
|
|
84
|
+
const now = application.musicPreviewPlayer.nowPlaying();
|
|
85
|
+
if (!now?.song?.title) return { state: "stopped" };
|
|
86
|
+
const kind = !now.song.generated ? "moondog_queue" : application.streamView?.() ? "moondog_stream" : "moondog_made";
|
|
87
|
+
return {
|
|
88
|
+
state: now.state,
|
|
89
|
+
player: "Moondog's own player",
|
|
90
|
+
song: madeBy(now.song),
|
|
91
|
+
from: { kind, name: null, tracks_total: now.count, position: now.position },
|
|
92
|
+
next: now.next.map(madeBy),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
78
96
|
async function netease(application) {
|
|
79
97
|
const now = await application.neteaseNowPlaying();
|
|
80
98
|
if (now.state === "stopped" || !now.song) return { state: "stopped" };
|
|
@@ -91,6 +109,7 @@ async function netease(application) {
|
|
|
91
109
|
/** Every player Moondog can read, the one the listener hears first: { active, sources }. */
|
|
92
110
|
export async function readListeningNow(application, { signal } = {}) {
|
|
93
111
|
const readers = [
|
|
112
|
+
typeof application.musicPreviewPlayer?.nowPlaying === "function" ? ["moondog", () => moondogPlayer(application)] : null,
|
|
94
113
|
application.musicApp?.available ? ["apple_music", () => musicApp(application)] : null,
|
|
95
114
|
application.spotifyReady() ? ["spotify", () => spotify(application, { signal })] : null,
|
|
96
115
|
application.neteaseReady() ? ["netease", () => netease(application)] : null,
|
|
@@ -106,3 +125,58 @@ export async function readListeningNow(application, { signal } = {}) {
|
|
|
106
125
|
const active = activeListening(sources, application.playbackService);
|
|
107
126
|
return { active: active?.service ?? null, sources: active ? [active, ...sources.filter((source) => source !== active)] : sources };
|
|
108
127
|
}
|
|
128
|
+
|
|
129
|
+
// What Moondog's own player plays: the queue it was sent last, and where in it the player's own
|
|
130
|
+
// events put it. The window and the terminal players keep one each, so the agent can read the songs
|
|
131
|
+
// it played or queued, and the tracks it made, as it reads the Music app or Spotify.
|
|
132
|
+
|
|
133
|
+
const entry = (track) => ({
|
|
134
|
+
title: String(track?.title ?? "").trim(),
|
|
135
|
+
artist_credit: String(track?.artist_credit ?? "").trim(),
|
|
136
|
+
...(track?.generated === true ? { generated: true, ...(typeof track.id === "string" && track.id ? { id: track.id } : {}) } : {}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
export function createPlayerNow() {
|
|
140
|
+
// null, or { tracks, index, state } where state is "loading", "playing" or "paused".
|
|
141
|
+
let queue = null;
|
|
142
|
+
return {
|
|
143
|
+
/** A new queue replaces the last one and starts from its first song. */
|
|
144
|
+
queued(tracks) {
|
|
145
|
+
const list = (Array.isArray(tracks) ? tracks : []).map(entry).filter((track) => track.title);
|
|
146
|
+
queue = list.length ? { tracks: list, index: 0, state: "loading" } : null;
|
|
147
|
+
},
|
|
148
|
+
/** A made track queued behind the last one, as Moondog's stream does. */
|
|
149
|
+
appended(track) {
|
|
150
|
+
const added = entry(track);
|
|
151
|
+
if (queue && added.title) queue.tracks.push(added);
|
|
152
|
+
},
|
|
153
|
+
/** One of the player's events: { type, position } moves it along; stopped empties it. */
|
|
154
|
+
observe(event) {
|
|
155
|
+
// The terminal's player announces each new queue with its songs; the window's does not.
|
|
156
|
+
if (event?.type === "queue" && Array.isArray(event.tracks)) { this.queued(event.tracks); return; }
|
|
157
|
+
if (!queue) return;
|
|
158
|
+
if (event?.type === "stopped") { queue = null; return; }
|
|
159
|
+
if (Number.isInteger(event?.position) && event.position >= 1 && event.position <= queue.tracks.length) queue.index = event.position - 1;
|
|
160
|
+
if (event?.type === "playing" || event?.type === "paused" || event?.type === "loading") queue.state = event.type;
|
|
161
|
+
},
|
|
162
|
+
clear() {
|
|
163
|
+
queue = null;
|
|
164
|
+
},
|
|
165
|
+
/** The song playing and every one after it, for keeping the whole queue: read() shows only ten. */
|
|
166
|
+
remaining() {
|
|
167
|
+
return queue ? queue.tracks.slice(queue.index).map((track) => ({ ...track })) : [];
|
|
168
|
+
},
|
|
169
|
+
/** null when nothing is queued, or { state, song, position, count, next } with up to ten songs after it. */
|
|
170
|
+
read() {
|
|
171
|
+
if (!queue) return null;
|
|
172
|
+
return {
|
|
173
|
+
// A song still loading is the one the listener is about to hear.
|
|
174
|
+
state: queue.state === "paused" ? "paused" : "playing",
|
|
175
|
+
song: { ...queue.tracks[queue.index] },
|
|
176
|
+
position: queue.index + 1,
|
|
177
|
+
count: queue.tracks.length,
|
|
178
|
+
next: queue.tracks.slice(queue.index + 1, queue.index + 11).map((track) => ({ ...track })),
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -506,6 +506,10 @@ export class ListeningProfileDomainServices {
|
|
|
506
506
|
return this.#store.recentAttention?.({ subjectId: this.#subjectId }) ?? null;
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
+
listeningDays() {
|
|
510
|
+
return this.#store.listeningDays?.({ subjectId: this.#subjectId }) ?? null;
|
|
511
|
+
}
|
|
512
|
+
|
|
509
513
|
// Moondog's suggestions ledger and the declared mix (src/profile/moondog-suggestions.mjs).
|
|
510
514
|
recordMoondogSuggestions(entries) {
|
|
511
515
|
return this.#store.recordMoondogSuggestions?.({ entries }) ?? null;
|