@audiofool/moondog 0.3.0-beta.6 → 0.3.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h1 align="center">Moondog</h1>
6
6
 
7
- <p align="center">Source candidate 0.3.0-beta.6</p>
7
+ <p align="center">Source candidate 0.3.0-beta.8</p>
8
8
 
9
9
  <p align="center"><strong>A listening room that shows its work.</strong></p>
10
10
 
@@ -203,6 +203,7 @@ Your profile, and anything Moondog remembers, carry over.
203
203
  - [Product charter](docs/PRODUCT_CHARTER.md) and [public roadmap](docs/ROADMAP.md): where Moondog is headed, and what it will not do.
204
204
  - [Voice](docs/VOICE.md): how Moondog talks, and where Pink Floyd shows up.
205
205
  - [The listening loop](docs/LISTENING_LOOP.md): how the agent keeps your choices, your recent listening, and its own picks apart.
206
+ - [The agent harness](docs/AGENT_HARNESS.md): how the tools keep the model's part small, so every supported model can play what you ask, and how each model is measured.
206
207
  - [Runtime architecture](docs/ADR_0001_AGENT_RUNTIME_AND_CLI.md) and [data contracts](contracts/README.md): how the pieces fit together.
207
208
  - [Contribution guide](CONTRIBUTING.md): local setup, checks, and how private data is kept out.
208
209
  - [Release status](docs/PUBLIC_RELEASE_READINESS.md): what is published, and what the checks cover.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audiofool/moondog",
3
- "version": "0.3.0-beta.6",
3
+ "version": "0.3.0-beta.8",
4
4
  "type": "module",
5
5
  "description": "Local-first personal AI music agent.",
6
6
  "license": "Apache-2.0",
@@ -7,6 +7,7 @@ import { runUpdateCommand } from "../src/surfaces/cli/update-command.mjs";
7
7
 
8
8
  import { MoondogApplication } from "../src/core/moondog-application.mjs";
9
9
  import { openLocalMemoryStore } from "../src/memory/local-memory-store.mjs";
10
+ import { openLibraryStore } from "../src/core/library-store.mjs";
10
11
  import {
11
12
  openAppleProjectionDomainServices,
12
13
  resolveAppleMusicSubjectId,
@@ -71,7 +72,8 @@ import {
71
72
  SPOTIFY_ARCHIVE_SET_MAXIMUM,
72
73
  } from "../src/profile/spotify-archive-taste.mjs";
73
74
  import { deriveArtistReleaseHintsFromSpotifyArchive } from "../src/profile/spotify-archive-catalog-hints.mjs";
74
- import { resolveListenerLocale, writeLanguagePreference } from "../src/i18n/preferences.mjs";
75
+ import { createLanguageSelection, resolveListenerLocale, writeLanguagePreference } from "../src/i18n/preferences.mjs";
76
+ import { createModelSwitch } from "../src/runtime/pi/model-switch.mjs";
75
77
  import { createPlaybackSelection, resolvePlaybackService, watchPlaybackPreference, writePlaybackPreference } from "../src/core/playback-preferences.mjs";
76
78
  import { createFirstRun } from "../src/core/first-run.mjs";
77
79
 
@@ -902,6 +904,9 @@ async function main() {
902
904
 
903
905
  const domainState = await loadDomainServices();
904
906
  const memoryStore = await openLocalMemoryStore();
907
+ // The listener's library: Saved, their playlists and folders, on one file the terminal and the window share.
908
+ // A library that can't open leaves the rest of Moondog working, without it.
909
+ const libraryStore = await openLibraryStore().catch(() => null);
905
910
  const spotifyConnection = await loadSpotifyConnection();
906
911
  const musicCatalog = createAppleMusicCatalog();
907
912
  const artistIdentityResolver = createWikidataArtistResolver();
@@ -927,7 +932,7 @@ async function main() {
927
932
  // that can play what it makes: Moondog's own install, downloaded when they ask, or their folder.
928
933
  // The terminal and the window each keep one generator, so each makes one track at a time; a
929
934
  // choice saved on one side is checked again on the other.
930
- const localGenerator = previewPlayer ? createLocalGenerator() : null;
935
+ const localGenerator = previewPlayer ? createLocalGenerator({ keeps: (id) => libraryStore?.holdsMade(id) === true }) : null;
931
936
  const generationSetup = localGenerator ? createStableAudioSetup() : null;
932
937
  // A model set up or removed, here or on the other side, is checked again.
933
938
  const unwatchGenerationSetup = generationSetup?.subscribe(() => { void localGenerator.check().catch(() => {}); }) ?? (() => {});
@@ -949,6 +954,11 @@ async function main() {
949
954
  // stream runs it reads the Music app itself, and music the listener starts there ends the stream.
950
955
  const unwatchStreamMusicApp = atmosphereStream && !host ? watchMusicAppForStream(atmosphereStream, { musicApp }) : null;
951
956
  const playbackSelection = createPlaybackSelection({ service: playbackService, save: writePlaybackPreference });
957
+ // A reply language the listener asks for in words is saved like the picker's and reaches every surface.
958
+ const languageSelection = createLanguageSelection({ save: (locale) => writeLanguagePreference(locale) });
959
+ // The listener's model menu, shared by the window, the terminal and a model asked for in words.
960
+ const modelSettings = createModelSettings({ environment: process.env });
961
+ const modelSwitch = createModelSwitch({ list: () => modelSettings.list() });
952
962
  // The terminal and the window each run this core on one playback.json. A service chosen on the
953
963
  // other side reaches this side's picker and agents, and stops the Apple Music songs this side plays.
954
964
  // Moondog's stream is not a service: its made tracks play on.
@@ -961,9 +971,9 @@ async function main() {
961
971
  // Spotify's four-week top artists, as the listening upkeep last read them.
962
972
  const spotifyTopArtists = { read_at: null, artists: [] };
963
973
  const applicationOptions = ({ domainServices, domainServicesError, spotifyConnection, locale, playbackService }) => ({
964
- domainServices, domainServicesError, spotifyConnection, locale, playbackService, playbackSelection,
974
+ domainServices, domainServicesError, spotifyConnection, locale, playbackService, playbackSelection, languageSelection, modelSwitch,
965
975
  webResearch, memoryStore, musicCatalog, musicPreviewPlayer: previewPlayer, neteasePlayer, localGenerator, atmosphereStream, musicSimilarity, artistIdentityResolver,
966
- spotifyTopArtists, musicApp,
976
+ spotifyTopArtists, musicApp, libraryStore,
967
977
  });
968
978
  const application = new MoondogApplication(applicationOptions({
969
979
  ...domainState, spotifyConnection, locale: await resolveListenerLocale(), playbackService,
@@ -1198,7 +1208,7 @@ async function main() {
1198
1208
  return createConfiguredRuntime(target, process.env, { selection, toolTrace });
1199
1209
  },
1200
1210
  // The desktop window lists models and signs in here, with no terminal.
1201
- modelSettings: createModelSettings({ environment: process.env }),
1211
+ modelSettings,
1202
1212
  // A model chosen or a sign-in made on the other surface. Model variables fix the model at
1203
1213
  // launch, so a saved choice has nothing to change then.
1204
1214
  watchModelSettings: (onChange) => (process.env.MOONDOG_PROVIDER !== undefined || process.env.MOONDOG_MODEL !== undefined
@@ -7,6 +7,11 @@ const catalog = [
7
7
  description: "Inspect or change the current music service through the same saved selection as the host's service picker.",
8
8
  agent_tool: { name: "moondog_playback_service", label: N_("Choose a music service") },
9
9
  },
10
+ {
11
+ id: "settings", version: "1", state: "enabled", effect: "write_local",
12
+ description: "Read Moondog's own setup (model, music service, reply language) and change the settings the listener may change by asking.",
13
+ agent_tool: { name: "moondog_settings", label: N_("Read or change Moondog's settings") },
14
+ },
10
15
  {
11
16
  id: "spotify.discovery.search", version: "1", state: "enabled", effect: "read_external", requires_spotify: true,
12
17
  description: "Verify open-world music hypotheses with bounded Spotify queries, retaining a shared candidate pool.",
@@ -601,6 +606,39 @@ const catalog = [
601
606
  label: N_("Save the playlist to Spotify"),
602
607
  },
603
608
  },
609
+ {
610
+ id: "collection.list",
611
+ version: "1",
612
+ state: "enabled",
613
+ effect: "read_local",
614
+ description: "Find playlists in the listener's own library on this Mac, by name, with what each holds.",
615
+ agent_tool: {
616
+ name: "moondog_collection_list",
617
+ label: N_("Look in the library"),
618
+ },
619
+ },
620
+ {
621
+ id: "collection.save",
622
+ version: "1",
623
+ state: "enabled",
624
+ effect: "write_local",
625
+ description: "Keep songs or tracks Moondog made in the listener's library on this Mac: in Saved, in one of their playlists, or in a new playlist.",
626
+ agent_tool: {
627
+ name: "moondog_collection_save",
628
+ label: N_("Save to the library"),
629
+ },
630
+ },
631
+ {
632
+ id: "collection.play",
633
+ version: "1",
634
+ state: "enabled",
635
+ effect: "play_library",
636
+ description: "Play a playlist from the listener's library on the music service they chose.",
637
+ agent_tool: {
638
+ name: "moondog_collection_play",
639
+ label: N_("Play from the library"),
640
+ },
641
+ },
604
642
  {
605
643
  id: "music.generate.local",
606
644
  version: "1",
@@ -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", "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
+ }