silver-music-notifier 0.1.0
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 +87 -0
- package/dist/cli/index.js +760 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/web/assets/index-DPovoyOV.css +1 -0
- package/dist/web/assets/index-xYrc5ayg.js +124 -0
- package/dist/web/index.html +13 -0
- package/package.json +99 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/server/index.ts
|
|
7
|
+
import express from "express";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { existsSync } from "fs";
|
|
10
|
+
import { dirname, join as join2 } from "path";
|
|
11
|
+
|
|
12
|
+
// src/lib/musicbrainz.ts
|
|
13
|
+
import { MusicBrainzApi } from "musicbrainz-api";
|
|
14
|
+
|
|
15
|
+
// src/lib/db.ts
|
|
16
|
+
import Database from "better-sqlite3";
|
|
17
|
+
|
|
18
|
+
// src/lib/paths.ts
|
|
19
|
+
import envPaths from "env-paths";
|
|
20
|
+
import { mkdirSync } from "fs";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
function dataDir() {
|
|
23
|
+
const dir = process.env.SMN_DATA_DIR ?? envPaths("silver-music-notifier", { suffix: "" }).data;
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
function dbPath() {
|
|
28
|
+
return join(dataDir(), "data.db");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/lib/db.ts
|
|
32
|
+
var AppDb = class {
|
|
33
|
+
connection;
|
|
34
|
+
constructor(path = dbPath()) {
|
|
35
|
+
this.connection = new Database(path);
|
|
36
|
+
this.initialize();
|
|
37
|
+
}
|
|
38
|
+
close() {
|
|
39
|
+
this.connection.close();
|
|
40
|
+
}
|
|
41
|
+
initialize() {
|
|
42
|
+
this.connection.pragma("journal_mode = WAL");
|
|
43
|
+
this.connection.pragma("foreign_keys = ON");
|
|
44
|
+
this.connection.exec(`
|
|
45
|
+
CREATE TABLE IF NOT EXISTS artists (
|
|
46
|
+
mbid TEXT PRIMARY KEY,
|
|
47
|
+
name TEXT NOT NULL,
|
|
48
|
+
sort_name TEXT,
|
|
49
|
+
disambiguation TEXT,
|
|
50
|
+
added_at TEXT NOT NULL
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS release_groups (
|
|
54
|
+
mbid TEXT PRIMARY KEY,
|
|
55
|
+
artist_mbid TEXT NOT NULL REFERENCES artists(mbid) ON DELETE CASCADE,
|
|
56
|
+
title TEXT NOT NULL,
|
|
57
|
+
primary_type TEXT,
|
|
58
|
+
secondary_types TEXT,
|
|
59
|
+
first_release_date TEXT,
|
|
60
|
+
first_seen_at TEXT NOT NULL,
|
|
61
|
+
last_seen_at TEXT NOT NULL
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_rg_artist ON release_groups(artist_mbid);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
67
|
+
key TEXT PRIMARY KEY,
|
|
68
|
+
value TEXT NOT NULL
|
|
69
|
+
);
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var appDb = null;
|
|
74
|
+
function openDb(path = dbPath()) {
|
|
75
|
+
return new AppDb(path);
|
|
76
|
+
}
|
|
77
|
+
function getDb() {
|
|
78
|
+
appDb ??= openDb();
|
|
79
|
+
return appDb.connection;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/lib/settings.ts
|
|
83
|
+
var DEFAULT_SETTINGS = {
|
|
84
|
+
notify: {
|
|
85
|
+
inPage: true,
|
|
86
|
+
desktop: true,
|
|
87
|
+
email: false
|
|
88
|
+
},
|
|
89
|
+
smtp: {
|
|
90
|
+
host: "",
|
|
91
|
+
port: 587,
|
|
92
|
+
secure: false,
|
|
93
|
+
user: "",
|
|
94
|
+
pass: "",
|
|
95
|
+
from: "",
|
|
96
|
+
to: ""
|
|
97
|
+
},
|
|
98
|
+
musicbrainz: {
|
|
99
|
+
contact: ""
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
var CONFIG_KEY = "config";
|
|
103
|
+
var LAST_REFRESH_KEY = "last_refresh_at";
|
|
104
|
+
function readRaw(key) {
|
|
105
|
+
const row = getDb().prepare("SELECT value FROM settings WHERE key = ?").get(key);
|
|
106
|
+
return row?.value;
|
|
107
|
+
}
|
|
108
|
+
function writeRaw(key, value) {
|
|
109
|
+
getDb().prepare(
|
|
110
|
+
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
111
|
+
).run(key, value);
|
|
112
|
+
}
|
|
113
|
+
function getSettings() {
|
|
114
|
+
const raw = readRaw(CONFIG_KEY);
|
|
115
|
+
if (!raw) {
|
|
116
|
+
return structuredClone(DEFAULT_SETTINGS);
|
|
117
|
+
}
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
return structuredClone(DEFAULT_SETTINGS);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
notify: { ...DEFAULT_SETTINGS.notify, ...parsed.notify },
|
|
126
|
+
smtp: { ...DEFAULT_SETTINGS.smtp, ...parsed.smtp },
|
|
127
|
+
musicbrainz: { ...DEFAULT_SETTINGS.musicbrainz, ...parsed.musicbrainz }
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function saveSettings(patch) {
|
|
131
|
+
const current = getSettings();
|
|
132
|
+
const next = {
|
|
133
|
+
notify: { ...current.notify, ...patch.notify },
|
|
134
|
+
smtp: { ...current.smtp, ...patch.smtp },
|
|
135
|
+
musicbrainz: { ...current.musicbrainz, ...patch.musicbrainz }
|
|
136
|
+
};
|
|
137
|
+
writeRaw(CONFIG_KEY, JSON.stringify(next));
|
|
138
|
+
return next;
|
|
139
|
+
}
|
|
140
|
+
function smtpIsConfigured(s) {
|
|
141
|
+
return Boolean(s.smtp.host && s.smtp.user && s.smtp.to);
|
|
142
|
+
}
|
|
143
|
+
function getLastRefreshAt() {
|
|
144
|
+
return readRaw(LAST_REFRESH_KEY) ?? null;
|
|
145
|
+
}
|
|
146
|
+
function setLastRefreshAt(iso) {
|
|
147
|
+
writeRaw(LAST_REFRESH_KEY, iso);
|
|
148
|
+
}
|
|
149
|
+
function mbContact() {
|
|
150
|
+
const s = getSettings();
|
|
151
|
+
return s.musicbrainz.contact || process.env.SMN_MB_CONTACT || "silver-music-notifier (no contact configured)";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/lib/musicbrainz.ts
|
|
155
|
+
var APP_VERSION = "0.1.0";
|
|
156
|
+
var client = null;
|
|
157
|
+
function api() {
|
|
158
|
+
const contact = mbContact();
|
|
159
|
+
if (!client || client._contact !== contact) {
|
|
160
|
+
client = new MusicBrainzApi({
|
|
161
|
+
appName: "silver-music-notifier",
|
|
162
|
+
appVersion: APP_VERSION,
|
|
163
|
+
appContactInfo: contact
|
|
164
|
+
});
|
|
165
|
+
client._contact = contact;
|
|
166
|
+
}
|
|
167
|
+
return client;
|
|
168
|
+
}
|
|
169
|
+
async function searchArtist(query) {
|
|
170
|
+
const result = await api().search("artist", { query, limit: 10 });
|
|
171
|
+
return (result.artists ?? []).map((a) => ({
|
|
172
|
+
mbid: a.id,
|
|
173
|
+
name: a.name,
|
|
174
|
+
sortName: a["sort-name"],
|
|
175
|
+
disambiguation: a.disambiguation,
|
|
176
|
+
country: a.country,
|
|
177
|
+
type: a.type
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
async function fetchReleaseGroups(artistMbid) {
|
|
181
|
+
const out = [];
|
|
182
|
+
const limit = 100;
|
|
183
|
+
let offset = 0;
|
|
184
|
+
for (; ; ) {
|
|
185
|
+
const res = await api().browse("release-group", {
|
|
186
|
+
artist: artistMbid,
|
|
187
|
+
limit,
|
|
188
|
+
offset
|
|
189
|
+
});
|
|
190
|
+
const groups = res["release-groups"] ?? [];
|
|
191
|
+
for (const g of groups) {
|
|
192
|
+
out.push({
|
|
193
|
+
mbid: g.id,
|
|
194
|
+
title: g.title,
|
|
195
|
+
primaryType: g["primary-type"] ?? null,
|
|
196
|
+
secondaryTypes: g["secondary-types"] ?? [],
|
|
197
|
+
firstReleaseDate: g["first-release-date"] || null
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const total = res["release-group-count"] ?? out.length;
|
|
201
|
+
offset += groups.length;
|
|
202
|
+
if (groups.length === 0 || offset >= total) {
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/lib/notify.ts
|
|
210
|
+
import nodemailer from "nodemailer";
|
|
211
|
+
import notifier from "node-notifier";
|
|
212
|
+
function summaryLine(newReleases) {
|
|
213
|
+
const n = newReleases.length;
|
|
214
|
+
const artists = [...new Set(newReleases.map((r) => r.artistName))];
|
|
215
|
+
const who = artists.length <= 3 ? artists.join(", ") : `${artists.slice(0, 3).join(", ")} +${artists.length - 3} more`;
|
|
216
|
+
return `${n} new release${n === 1 ? "" : "s"} from ${who}`;
|
|
217
|
+
}
|
|
218
|
+
function desktopNotify(newReleases) {
|
|
219
|
+
notifier.notify({
|
|
220
|
+
title: "New music releases",
|
|
221
|
+
message: summaryLine(newReleases)
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function emailHtml(newReleases) {
|
|
225
|
+
const rows = newReleases.map((r) => {
|
|
226
|
+
const type = [r.primaryType, ...r.secondaryTypes].filter(Boolean).join(" / ");
|
|
227
|
+
const date = r.firstReleaseDate ?? "\u2014";
|
|
228
|
+
return `<tr>
|
|
229
|
+
<td style="padding:4px 12px 4px 0">${escapeHtml(r.artistName)}</td>
|
|
230
|
+
<td style="padding:4px 12px 4px 0"><strong>${escapeHtml(r.title)}</strong></td>
|
|
231
|
+
<td style="padding:4px 12px 4px 0">${escapeHtml(type)}</td>
|
|
232
|
+
<td style="padding:4px 0">${escapeHtml(date)}</td>
|
|
233
|
+
</tr>`;
|
|
234
|
+
}).join("");
|
|
235
|
+
return `<div style="font-family:system-ui,sans-serif">
|
|
236
|
+
<h2>${escapeHtml(summaryLine(newReleases))}</h2>
|
|
237
|
+
<table style="border-collapse:collapse">
|
|
238
|
+
<thead><tr>
|
|
239
|
+
<th align="left" style="padding:4px 12px 4px 0">Artist</th>
|
|
240
|
+
<th align="left" style="padding:4px 12px 4px 0">Title</th>
|
|
241
|
+
<th align="left" style="padding:4px 12px 4px 0">Type</th>
|
|
242
|
+
<th align="left" style="padding:4px 0">Released</th>
|
|
243
|
+
</tr></thead>
|
|
244
|
+
<tbody>${rows}</tbody>
|
|
245
|
+
</table>
|
|
246
|
+
</div>`;
|
|
247
|
+
}
|
|
248
|
+
function escapeHtml(s) {
|
|
249
|
+
return s.replace(
|
|
250
|
+
/[&<>"']/g,
|
|
251
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
function transport(s) {
|
|
255
|
+
return nodemailer.createTransport({
|
|
256
|
+
host: s.smtp.host,
|
|
257
|
+
port: s.smtp.port,
|
|
258
|
+
secure: s.smtp.secure,
|
|
259
|
+
auth: s.smtp.user ? { user: s.smtp.user, pass: s.smtp.pass } : void 0
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async function emailNotify(newReleases, s) {
|
|
263
|
+
await transport(s).sendMail({
|
|
264
|
+
from: s.smtp.from || s.smtp.user,
|
|
265
|
+
to: s.smtp.to,
|
|
266
|
+
subject: summaryLine(newReleases),
|
|
267
|
+
html: emailHtml(newReleases)
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async function notifyNewReleases(newReleases) {
|
|
271
|
+
if (newReleases.length === 0) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const s = getSettings();
|
|
275
|
+
if (s.notify.desktop && !process.env.SMN_DISABLE_DESKTOP) {
|
|
276
|
+
try {
|
|
277
|
+
desktopNotify(newReleases);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
console.error("Desktop notification failed:", errMsg(err));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (s.notify.email) {
|
|
283
|
+
if (!smtpIsConfigured(s)) {
|
|
284
|
+
console.warn("Email enabled but SMTP not configured \u2014 skipping email.");
|
|
285
|
+
} else {
|
|
286
|
+
try {
|
|
287
|
+
await emailNotify(newReleases, s);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
console.error("Email notification failed:", errMsg(err));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function sendTestEmail(override) {
|
|
295
|
+
const s = override ?? getSettings();
|
|
296
|
+
if (!smtpIsConfigured(s)) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"SMTP is not configured (host, user, and recipient required)."
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
await transport(s).sendMail({
|
|
302
|
+
from: s.smtp.from || s.smtp.user,
|
|
303
|
+
to: s.smtp.to,
|
|
304
|
+
subject: "silver-music-notifier test email",
|
|
305
|
+
text: "This is a test email from silver-music-notifier. SMTP is working."
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
function errMsg(err) {
|
|
309
|
+
return err instanceof Error ? err.message : String(err);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/lib/refresh.ts
|
|
313
|
+
async function refresh(opts = {}) {
|
|
314
|
+
const db = getDb();
|
|
315
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
316
|
+
const artists = db.prepare("SELECT * FROM artists ORDER BY name COLLATE NOCASE").all();
|
|
317
|
+
const existing = db.prepare("SELECT 1 FROM release_groups WHERE mbid = ?");
|
|
318
|
+
const insert = db.prepare(`
|
|
319
|
+
INSERT INTO release_groups
|
|
320
|
+
(mbid, artist_mbid, title, primary_type, secondary_types,
|
|
321
|
+
first_release_date, first_seen_at, last_seen_at)
|
|
322
|
+
VALUES (@mbid, @artist_mbid, @title, @primary_type, @secondary_types,
|
|
323
|
+
@first_release_date, @now, @now)
|
|
324
|
+
ON CONFLICT(mbid) DO UPDATE SET
|
|
325
|
+
title = excluded.title,
|
|
326
|
+
primary_type = excluded.primary_type,
|
|
327
|
+
secondary_types = excluded.secondary_types,
|
|
328
|
+
first_release_date = excluded.first_release_date,
|
|
329
|
+
last_seen_at = excluded.last_seen_at
|
|
330
|
+
`);
|
|
331
|
+
const newReleases = [];
|
|
332
|
+
const errors = [];
|
|
333
|
+
for (let i = 0; i < artists.length; i++) {
|
|
334
|
+
const artist = artists[i];
|
|
335
|
+
opts.onProgress?.(artist, i, artists.length);
|
|
336
|
+
try {
|
|
337
|
+
const groups = await fetchReleaseGroups(artist.mbid);
|
|
338
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
339
|
+
const apply = db.transaction(() => {
|
|
340
|
+
for (const g of groups) {
|
|
341
|
+
const seen = existing.get(g.mbid);
|
|
342
|
+
insert.run({
|
|
343
|
+
mbid: g.mbid,
|
|
344
|
+
artist_mbid: artist.mbid,
|
|
345
|
+
title: g.title,
|
|
346
|
+
primary_type: g.primaryType,
|
|
347
|
+
secondary_types: g.secondaryTypes.join(", ") || null,
|
|
348
|
+
first_release_date: g.firstReleaseDate,
|
|
349
|
+
now
|
|
350
|
+
});
|
|
351
|
+
if (!seen) {
|
|
352
|
+
newReleases.push({
|
|
353
|
+
mbid: g.mbid,
|
|
354
|
+
artistMbid: artist.mbid,
|
|
355
|
+
artistName: artist.name,
|
|
356
|
+
title: g.title,
|
|
357
|
+
primaryType: g.primaryType,
|
|
358
|
+
secondaryTypes: g.secondaryTypes,
|
|
359
|
+
firstReleaseDate: g.firstReleaseDate
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
apply();
|
|
365
|
+
} catch (err) {
|
|
366
|
+
errors.push({
|
|
367
|
+
artist: artist.name,
|
|
368
|
+
message: err instanceof Error ? err.message : String(err)
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
setLastRefreshAt(startedAt);
|
|
373
|
+
const summary = {
|
|
374
|
+
scannedArtists: artists.length,
|
|
375
|
+
newCount: newReleases.length,
|
|
376
|
+
newReleases,
|
|
377
|
+
errors,
|
|
378
|
+
startedAt
|
|
379
|
+
};
|
|
380
|
+
if (opts.notify !== false && newReleases.length > 0) {
|
|
381
|
+
await notifyNewReleases(newReleases);
|
|
382
|
+
}
|
|
383
|
+
return summary;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/lib/store.ts
|
|
387
|
+
function listArtists() {
|
|
388
|
+
return getDb().prepare("SELECT * FROM artists ORDER BY name COLLATE NOCASE").all();
|
|
389
|
+
}
|
|
390
|
+
function addArtist(input) {
|
|
391
|
+
const res = getDb().prepare(
|
|
392
|
+
`INSERT INTO artists (mbid, name, sort_name, disambiguation, added_at)
|
|
393
|
+
VALUES (?, ?, ?, ?, ?)
|
|
394
|
+
ON CONFLICT(mbid) DO NOTHING`
|
|
395
|
+
).run(
|
|
396
|
+
input.mbid,
|
|
397
|
+
input.name,
|
|
398
|
+
input.sortName ?? null,
|
|
399
|
+
input.disambiguation ?? null,
|
|
400
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
401
|
+
);
|
|
402
|
+
return res.changes > 0;
|
|
403
|
+
}
|
|
404
|
+
function removeArtist(idOrName) {
|
|
405
|
+
const db = getDb();
|
|
406
|
+
const found = db.prepare("SELECT * FROM artists WHERE mbid = ?").get(idOrName) ?? db.prepare("SELECT * FROM artists WHERE name = ? COLLATE NOCASE").get(idOrName);
|
|
407
|
+
if (!found) {
|
|
408
|
+
return void 0;
|
|
409
|
+
}
|
|
410
|
+
db.prepare("DELETE FROM artists WHERE mbid = ?").run(found.mbid);
|
|
411
|
+
return found;
|
|
412
|
+
}
|
|
413
|
+
function listReleases(opts = {}) {
|
|
414
|
+
const lastRefresh = getLastRefreshAt();
|
|
415
|
+
const rows = getDb().prepare(
|
|
416
|
+
`SELECT rg.mbid, rg.artist_mbid, a.name AS artist_name, rg.title,
|
|
417
|
+
rg.primary_type, rg.secondary_types, rg.first_release_date,
|
|
418
|
+
rg.first_seen_at
|
|
419
|
+
FROM release_groups rg
|
|
420
|
+
JOIN artists a ON a.mbid = rg.artist_mbid
|
|
421
|
+
ORDER BY (rg.first_release_date IS NULL), rg.first_release_date DESC, rg.title`
|
|
422
|
+
).all();
|
|
423
|
+
const items = rows.map((r) => ({
|
|
424
|
+
mbid: r.mbid,
|
|
425
|
+
artistMbid: r.artist_mbid,
|
|
426
|
+
artistName: r.artist_name,
|
|
427
|
+
title: r.title,
|
|
428
|
+
primaryType: r.primary_type,
|
|
429
|
+
secondaryTypes: r.secondary_types,
|
|
430
|
+
firstReleaseDate: r.first_release_date,
|
|
431
|
+
firstSeenAt: r.first_seen_at,
|
|
432
|
+
isNew: lastRefresh != null && r.first_seen_at >= lastRefresh
|
|
433
|
+
}));
|
|
434
|
+
const filtered = opts.onlyNew ? items.filter((i) => i.isNew) : items;
|
|
435
|
+
return opts.limit ? filtered.slice(0, opts.limit) : filtered;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/server/index.ts
|
|
439
|
+
function webDir() {
|
|
440
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
441
|
+
const candidates = [
|
|
442
|
+
join2(here, "..", "web"),
|
|
443
|
+
// dist/cli/index.js -> dist/web
|
|
444
|
+
join2(here, "..", "..", "dist", "web")
|
|
445
|
+
// src/server/index.ts -> dist/web
|
|
446
|
+
];
|
|
447
|
+
return candidates.find((c) => existsSync(c)) ?? null;
|
|
448
|
+
}
|
|
449
|
+
function asyncRoute(fn) {
|
|
450
|
+
return (req, res) => {
|
|
451
|
+
fn(req, res).catch((err) => {
|
|
452
|
+
console.error(err);
|
|
453
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
454
|
+
});
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function createApp() {
|
|
458
|
+
const app = express();
|
|
459
|
+
app.use(express.json());
|
|
460
|
+
const api2 = express.Router();
|
|
461
|
+
api2.get("/artists", (_req, res) => {
|
|
462
|
+
res.json(listArtists());
|
|
463
|
+
});
|
|
464
|
+
api2.get(
|
|
465
|
+
"/artists/search",
|
|
466
|
+
asyncRoute(async (req, res) => {
|
|
467
|
+
const q = String(req.query.q ?? "").trim();
|
|
468
|
+
if (!q) {
|
|
469
|
+
res.json([]);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
res.json(await searchArtist(q));
|
|
473
|
+
})
|
|
474
|
+
);
|
|
475
|
+
api2.post("/artists", (req, res) => {
|
|
476
|
+
const { mbid, name, sortName, disambiguation } = req.body ?? {};
|
|
477
|
+
if (!mbid || !name) {
|
|
478
|
+
res.status(400).json({ error: "mbid and name are required" });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const added = addArtist({ mbid, name, sortName, disambiguation });
|
|
482
|
+
res.json({ added });
|
|
483
|
+
});
|
|
484
|
+
api2.delete("/artists/:mbid", (req, res) => {
|
|
485
|
+
const removed = removeArtist(req.params.mbid);
|
|
486
|
+
if (!removed) {
|
|
487
|
+
res.status(404).json({ error: "artist not tracked" });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
res.json({ removed });
|
|
491
|
+
});
|
|
492
|
+
api2.get("/releases", (req, res) => {
|
|
493
|
+
const onlyNew = req.query.new === "1" || req.query.new === "true";
|
|
494
|
+
const limit = req.query.limit ? Number(req.query.limit) : void 0;
|
|
495
|
+
res.json(listReleases({ onlyNew, limit }));
|
|
496
|
+
});
|
|
497
|
+
api2.post(
|
|
498
|
+
"/refresh",
|
|
499
|
+
asyncRoute(async (_req, res) => {
|
|
500
|
+
const summary = await refresh();
|
|
501
|
+
res.json(summary);
|
|
502
|
+
})
|
|
503
|
+
);
|
|
504
|
+
api2.get("/settings", (_req, res) => {
|
|
505
|
+
res.json(getSettings());
|
|
506
|
+
});
|
|
507
|
+
api2.put("/settings", (req, res) => {
|
|
508
|
+
const patch = req.body;
|
|
509
|
+
res.json(saveSettings(patch));
|
|
510
|
+
});
|
|
511
|
+
api2.post(
|
|
512
|
+
"/settings/test-email",
|
|
513
|
+
asyncRoute(async (req, res) => {
|
|
514
|
+
const patch = req.body;
|
|
515
|
+
const settings = patch && Object.keys(patch).length ? saveSettings(patch) : getSettings();
|
|
516
|
+
await sendTestEmail(settings);
|
|
517
|
+
res.json({ ok: true });
|
|
518
|
+
})
|
|
519
|
+
);
|
|
520
|
+
app.use("/api", api2);
|
|
521
|
+
const dir = webDir();
|
|
522
|
+
if (dir) {
|
|
523
|
+
app.use(express.static(dir));
|
|
524
|
+
app.get("*", (_req, res) => {
|
|
525
|
+
res.sendFile(join2(dir, "index.html"));
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return app;
|
|
529
|
+
}
|
|
530
|
+
function startServer(port) {
|
|
531
|
+
return new Promise((resolve) => {
|
|
532
|
+
createApp().listen(port, () => resolve());
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/cli/commands/web.ts
|
|
537
|
+
function registerWeb(program2) {
|
|
538
|
+
program2.command("web").description("Launch the local web UI").option("-p, --port <port>", "port to listen on", "3001").option("--no-open", "do not open a browser window").action(async (opts) => {
|
|
539
|
+
const port = Number(opts.port);
|
|
540
|
+
await startServer(port);
|
|
541
|
+
const url = `http://localhost:${port}`;
|
|
542
|
+
console.log(`silver-music-notifier web UI running at ${url}`);
|
|
543
|
+
if (opts.open) {
|
|
544
|
+
try {
|
|
545
|
+
const { default: open } = await import("open");
|
|
546
|
+
await open(url);
|
|
547
|
+
} catch {
|
|
548
|
+
console.log("(could not open browser automatically)");
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/cli/commands/list.ts
|
|
555
|
+
function registerList(program2) {
|
|
556
|
+
program2.command("list").description("List tracked artists").action(() => {
|
|
557
|
+
const artists = listArtists();
|
|
558
|
+
if (artists.length === 0) {
|
|
559
|
+
console.log(
|
|
560
|
+
"No artists tracked yet. Add one with: silver-music-notifier add <name>"
|
|
561
|
+
);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
for (const a of artists) {
|
|
565
|
+
const extra = a.disambiguation ? ` (${a.disambiguation})` : "";
|
|
566
|
+
console.log(`${a.name}${extra} \u2014 ${a.mbid}`);
|
|
567
|
+
}
|
|
568
|
+
console.log(
|
|
569
|
+
`
|
|
570
|
+
${artists.length} artist${artists.length === 1 ? "" : "s"} tracked.`
|
|
571
|
+
);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/cli/commands/add.ts
|
|
576
|
+
function registerAdd(program2) {
|
|
577
|
+
program2.command("add").description("Search MusicBrainz and add an artist to track").argument("<query>", "artist name to search for").option(
|
|
578
|
+
"--mbid <mbid>",
|
|
579
|
+
"add this exact MusicBrainz artist MBID, skipping search"
|
|
580
|
+
).option("-y, --yes", "add the top search result without prompting").action(async (query, opts) => {
|
|
581
|
+
if (opts.mbid) {
|
|
582
|
+
const added2 = addArtist({ mbid: opts.mbid, name: query });
|
|
583
|
+
console.log(added2 ? `Added ${query}.` : `${query} is already tracked.`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const results = await searchArtist(query);
|
|
587
|
+
if (results.length === 0) {
|
|
588
|
+
console.log(`No MusicBrainz artists found for "${query}".`);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
let chosen = results[0];
|
|
592
|
+
if (!opts.yes && results.length > 1) {
|
|
593
|
+
const { select } = await import("@inquirer/prompts");
|
|
594
|
+
const mbid = await select({
|
|
595
|
+
message: "Which artist?",
|
|
596
|
+
choices: results.map((r) => ({
|
|
597
|
+
name: [
|
|
598
|
+
r.name,
|
|
599
|
+
r.disambiguation ? `(${r.disambiguation})` : "",
|
|
600
|
+
r.type ? `\xB7 ${r.type}` : "",
|
|
601
|
+
r.country ? `\xB7 ${r.country}` : ""
|
|
602
|
+
].filter(Boolean).join(" "),
|
|
603
|
+
value: r.mbid
|
|
604
|
+
}))
|
|
605
|
+
});
|
|
606
|
+
chosen = results.find((r) => r.mbid === mbid);
|
|
607
|
+
}
|
|
608
|
+
const added = addArtist({
|
|
609
|
+
mbid: chosen.mbid,
|
|
610
|
+
name: chosen.name,
|
|
611
|
+
sortName: chosen.sortName,
|
|
612
|
+
disambiguation: chosen.disambiguation
|
|
613
|
+
});
|
|
614
|
+
console.log(
|
|
615
|
+
added ? `Added ${chosen.name} (${chosen.mbid}).` : `${chosen.name} is already tracked.`
|
|
616
|
+
);
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/cli/commands/remove.ts
|
|
621
|
+
function registerRemove(program2) {
|
|
622
|
+
program2.command("remove").alias("rm").description("Stop tracking an artist (by MBID or name)").argument("<idOrName>", "MusicBrainz MBID or exact artist name").action((idOrName) => {
|
|
623
|
+
const removed = removeArtist(idOrName);
|
|
624
|
+
if (!removed) {
|
|
625
|
+
console.log(`No tracked artist matched "${idOrName}".`);
|
|
626
|
+
process.exitCode = 1;
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
console.log(`Removed ${removed.name} (${removed.mbid}).`);
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/cli/commands/refresh.ts
|
|
634
|
+
function registerRefresh(program2) {
|
|
635
|
+
program2.command("refresh").description(
|
|
636
|
+
"Fetch releases for all tracked artists and notify on new ones"
|
|
637
|
+
).option("--no-notify", "skip desktop/email notifications").action(async (opts) => {
|
|
638
|
+
const summary = await refresh({
|
|
639
|
+
notify: opts.notify,
|
|
640
|
+
onProgress: (artist, i, total) => {
|
|
641
|
+
process.stdout.write(
|
|
642
|
+
`\r[${i + 1}/${total}] ${artist.name}`.padEnd(60)
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
process.stdout.write("\r".padEnd(60) + "\r");
|
|
647
|
+
console.log(
|
|
648
|
+
`Scanned ${summary.scannedArtists} artist${summary.scannedArtists === 1 ? "" : "s"}; ${summary.newCount} new release${summary.newCount === 1 ? "" : "s"}.`
|
|
649
|
+
);
|
|
650
|
+
for (const r of summary.newReleases) {
|
|
651
|
+
const type = [r.primaryType, ...r.secondaryTypes].filter(Boolean).join(" / ");
|
|
652
|
+
console.log(
|
|
653
|
+
` + ${r.artistName} \u2014 ${r.title}` + (type ? ` [${type}]` : "") + (r.firstReleaseDate ? ` (${r.firstReleaseDate})` : "")
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
for (const e of summary.errors) {
|
|
657
|
+
console.error(` ! ${e.artist}: ${e.message}`);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/cli/commands/releases.ts
|
|
663
|
+
function registerReleases(program2) {
|
|
664
|
+
program2.command("releases").description("List known release-groups, newest first").option("--new", "only show releases discovered in the last refresh").option("-n, --limit <n>", "limit the number of rows", (v) => Number(v)).action((opts) => {
|
|
665
|
+
const items = listReleases({ onlyNew: opts.new, limit: opts.limit });
|
|
666
|
+
if (items.length === 0) {
|
|
667
|
+
console.log(
|
|
668
|
+
opts.new ? "No new releases since the last refresh." : "No releases yet. Run: silver-music-notifier refresh"
|
|
669
|
+
);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
for (const r of items) {
|
|
673
|
+
const date = r.firstReleaseDate ?? "\u2014".padEnd(10);
|
|
674
|
+
const type = [r.primaryType, r.secondaryTypes].filter(Boolean).join(" / ");
|
|
675
|
+
const flag = r.isNew ? "NEW " : " ";
|
|
676
|
+
console.log(
|
|
677
|
+
`${flag}${date.padEnd(11)} ${r.artistName} \u2014 ${r.title}${type ? ` [${type}]` : ""}`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// src/cli/commands/config.ts
|
|
684
|
+
function flatten(s) {
|
|
685
|
+
return {
|
|
686
|
+
"notify.inPage": String(s.notify.inPage),
|
|
687
|
+
"notify.desktop": String(s.notify.desktop),
|
|
688
|
+
"notify.email": String(s.notify.email),
|
|
689
|
+
"smtp.host": s.smtp.host,
|
|
690
|
+
"smtp.port": String(s.smtp.port),
|
|
691
|
+
"smtp.secure": String(s.smtp.secure),
|
|
692
|
+
"smtp.user": s.smtp.user,
|
|
693
|
+
"smtp.pass": s.smtp.pass ? "********" : "",
|
|
694
|
+
"smtp.from": s.smtp.from,
|
|
695
|
+
"smtp.to": s.smtp.to,
|
|
696
|
+
"musicbrainz.contact": s.musicbrainz.contact
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
function coerce(key, value) {
|
|
700
|
+
if (/^(notify\.|smtp\.secure$)/.test(key)) {
|
|
701
|
+
return value === "true" || value === "1";
|
|
702
|
+
}
|
|
703
|
+
if (key === "smtp.port") {
|
|
704
|
+
return Number(value);
|
|
705
|
+
}
|
|
706
|
+
return value;
|
|
707
|
+
}
|
|
708
|
+
function patchFor(key, value) {
|
|
709
|
+
const [group, field] = key.split(".");
|
|
710
|
+
return { [group]: { [field]: value } };
|
|
711
|
+
}
|
|
712
|
+
function registerConfig(program2) {
|
|
713
|
+
const config = program2.command("config").description("View or edit settings");
|
|
714
|
+
config.command("get", { isDefault: true }).description("Print settings (or a single key)").argument("[key]", "dotted key, e.g. notify.email").action((key) => {
|
|
715
|
+
const flat = flatten(getSettings());
|
|
716
|
+
if (key) {
|
|
717
|
+
if (!(key in flat)) {
|
|
718
|
+
console.error(`Unknown key: ${key}`);
|
|
719
|
+
process.exitCode = 1;
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
console.log(flat[key]);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
for (const [k, v] of Object.entries(flat)) {
|
|
726
|
+
console.log(`${k} = ${v}`);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
config.command("set").description("Set a settings key").argument("<key>", "dotted key, e.g. smtp.host").argument("<value>", "new value").action((key, value) => {
|
|
730
|
+
const valid = new Set(Object.keys(flatten(getSettings())));
|
|
731
|
+
if (!valid.has(key)) {
|
|
732
|
+
console.error(
|
|
733
|
+
`Unknown key: ${key}
|
|
734
|
+
Valid keys: ${[...valid].join(", ")}`
|
|
735
|
+
);
|
|
736
|
+
process.exitCode = 1;
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
saveSettings(patchFor(key, coerce(key, value)));
|
|
740
|
+
console.log(`Set ${key}.`);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/cli/index.ts
|
|
745
|
+
var program = new Command();
|
|
746
|
+
program.name("silver-music-notifier").description(
|
|
747
|
+
"Track artists and get notified of their new music releases from MusicBrainz."
|
|
748
|
+
).version("0.1.0");
|
|
749
|
+
registerWeb(program);
|
|
750
|
+
registerList(program);
|
|
751
|
+
registerAdd(program);
|
|
752
|
+
registerRemove(program);
|
|
753
|
+
registerRefresh(program);
|
|
754
|
+
registerReleases(program);
|
|
755
|
+
registerConfig(program);
|
|
756
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
757
|
+
console.error(err instanceof Error ? err.message : err);
|
|
758
|
+
process.exit(1);
|
|
759
|
+
});
|
|
760
|
+
//# sourceMappingURL=index.js.map
|