@audiofool/moondog 0.3.0-beta.7 → 0.3.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/moondog.mjs +6 -2
- package/src/core/capability-catalog.mjs +33 -0
- package/src/core/library-store.mjs +500 -0
- package/src/core/listening-now.mjs +7 -2
- package/src/core/moondog-application.mjs +280 -8
- package/src/i18n/screen/es.mjs +17 -0
- package/src/i18n/screen/ja.mjs +17 -0
- package/src/i18n/screen/pt.mjs +17 -0
- package/src/i18n/screen/zh.mjs +17 -0
- package/src/integrations/apple-music/music-app.mjs +9 -3
- package/src/integrations/apple-music/terminal-preview-player.mjs +22 -2
- package/src/integrations/stable-audio/local-generator.mjs +8 -1
- package/src/runtime/pi/agent-runtime.mjs +111 -6
- package/src/runtime/pi/model-transport.mjs +34 -4
- package/src/runtime/pi/tool-arguments.mjs +5 -1
- package/src/surfaces/cli/tui.mjs +60 -1
package/README.md
CHANGED
package/package.json
CHANGED
package/scripts/moondog.mjs
CHANGED
|
@@ -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,
|
|
@@ -903,6 +904,9 @@ async function main() {
|
|
|
903
904
|
|
|
904
905
|
const domainState = await loadDomainServices();
|
|
905
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);
|
|
906
910
|
const spotifyConnection = await loadSpotifyConnection();
|
|
907
911
|
const musicCatalog = createAppleMusicCatalog();
|
|
908
912
|
const artistIdentityResolver = createWikidataArtistResolver();
|
|
@@ -928,7 +932,7 @@ async function main() {
|
|
|
928
932
|
// that can play what it makes: Moondog's own install, downloaded when they ask, or their folder.
|
|
929
933
|
// The terminal and the window each keep one generator, so each makes one track at a time; a
|
|
930
934
|
// choice saved on one side is checked again on the other.
|
|
931
|
-
const localGenerator = previewPlayer ? createLocalGenerator() : null;
|
|
935
|
+
const localGenerator = previewPlayer ? createLocalGenerator({ keeps: (id) => libraryStore?.holdsMade(id) === true }) : null;
|
|
932
936
|
const generationSetup = localGenerator ? createStableAudioSetup() : null;
|
|
933
937
|
// A model set up or removed, here or on the other side, is checked again.
|
|
934
938
|
const unwatchGenerationSetup = generationSetup?.subscribe(() => { void localGenerator.check().catch(() => {}); }) ?? (() => {});
|
|
@@ -969,7 +973,7 @@ async function main() {
|
|
|
969
973
|
const applicationOptions = ({ domainServices, domainServicesError, spotifyConnection, locale, playbackService }) => ({
|
|
970
974
|
domainServices, domainServicesError, spotifyConnection, locale, playbackService, playbackSelection, languageSelection, modelSwitch,
|
|
971
975
|
webResearch, memoryStore, musicCatalog, musicPreviewPlayer: previewPlayer, neteasePlayer, localGenerator, atmosphereStream, musicSimilarity, artistIdentityResolver,
|
|
972
|
-
spotifyTopArtists, musicApp,
|
|
976
|
+
spotifyTopArtists, musicApp, libraryStore,
|
|
973
977
|
});
|
|
974
978
|
const application = new MoondogApplication(applicationOptions({
|
|
975
979
|
...domainState, spotifyConnection, locale: await resolveListenerLocale(), playbackService,
|
|
@@ -606,6 +606,39 @@ const catalog = [
|
|
|
606
606
|
label: N_("Save the playlist to Spotify"),
|
|
607
607
|
},
|
|
608
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
|
+
},
|
|
609
642
|
{
|
|
610
643
|
id: "music.generate.local",
|
|
611
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
|
+
}
|
|
@@ -78,7 +78,8 @@ async function spotify(application, { signal }) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
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] : []
|
|
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 } : {}) });
|
|
82
83
|
function moondogPlayer(application) {
|
|
83
84
|
const now = application.musicPreviewPlayer.nowPlaying();
|
|
84
85
|
if (!now?.song?.title) return { state: "stopped" };
|
|
@@ -132,7 +133,7 @@ export async function readListeningNow(application, { signal } = {}) {
|
|
|
132
133
|
const entry = (track) => ({
|
|
133
134
|
title: String(track?.title ?? "").trim(),
|
|
134
135
|
artist_credit: String(track?.artist_credit ?? "").trim(),
|
|
135
|
-
...(track?.generated === true ? { generated: true } : {}),
|
|
136
|
+
...(track?.generated === true ? { generated: true, ...(typeof track.id === "string" && track.id ? { id: track.id } : {}) } : {}),
|
|
136
137
|
});
|
|
137
138
|
|
|
138
139
|
export function createPlayerNow() {
|
|
@@ -161,6 +162,10 @@ export function createPlayerNow() {
|
|
|
161
162
|
clear() {
|
|
162
163
|
queue = null;
|
|
163
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
|
+
},
|
|
164
169
|
/** null when nothing is queued, or { state, song, position, count, next } with up to ten songs after it. */
|
|
165
170
|
read() {
|
|
166
171
|
if (!queue) return null;
|