silver-music-notifier 1.0.0 → 1.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 +42 -26
- package/dist/cli/index.js +278 -108
- package/dist/cli/index.js.map +1 -1
- package/dist/web/assets/{index-NqandB-X.js → index-DIWztYL6.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,15 +19,17 @@ driver), which is built automatically on install.
|
|
|
19
19
|
|
|
20
20
|
MusicBrainz requires every API client to identify a contact (an email or URL) in
|
|
21
21
|
its User-Agent, and throttles or blocks requests without one. The first time you
|
|
22
|
-
run
|
|
23
|
-
|
|
22
|
+
run most CLI commands, the CLI **prompts you for a contact** and saves it.
|
|
23
|
+
Non-network setup commands such as `config set`, `clear-data`, and `dismiss`
|
|
24
|
+
can run before the contact is configured. You can also set it ahead of time:
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
27
|
silver-music-notifier config set musicbrainz.contact you@example.com
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
or in the web UI's **Settings** view
|
|
30
|
-
|
|
30
|
+
or in the web UI's **Settings** view after the app has launched. In a
|
|
31
|
+
non-interactive context (no TTY), commands that require the contact error with
|
|
32
|
+
this guidance instead of prompting.
|
|
31
33
|
|
|
32
34
|
## Usage
|
|
33
35
|
|
|
@@ -55,9 +57,12 @@ silver-music-notifier add "X" --mbid <mbid> # add an exact MBID
|
|
|
55
57
|
silver-music-notifier list # list tracked artists
|
|
56
58
|
silver-music-notifier remove "Radiohead" # stop tracking (by name or MBID)
|
|
57
59
|
silver-music-notifier refresh # fetch releases + notify on new ones
|
|
60
|
+
silver-music-notifier refresh --no-notify # fetch without sending email
|
|
58
61
|
silver-music-notifier releases --new --limit 20
|
|
62
|
+
silver-music-notifier dismiss <release-mbid> # hide a release's New badge
|
|
59
63
|
silver-music-notifier config get # show settings
|
|
60
64
|
silver-music-notifier config set notify.email true
|
|
65
|
+
silver-music-notifier clear-data # delete artists/releases, keep settings
|
|
61
66
|
```
|
|
62
67
|
|
|
63
68
|
## Notifications
|
|
@@ -65,12 +70,43 @@ silver-music-notifier config set notify.email true
|
|
|
65
70
|
When `refresh` finds releases it has never seen before, it can notify you two ways:
|
|
66
71
|
|
|
67
72
|
- **In-page badges** — "New" badges in the web UI (always available).
|
|
68
|
-
- **Email** —
|
|
69
|
-
is on. Configure it in the **Settings** view or via
|
|
73
|
+
- **Email** — one HTML email per new release, sent once SMTP is configured and
|
|
74
|
+
the email toggle is on. Configure it in the **Settings** view or via
|
|
75
|
+
`config set smtp.host`, `smtp.port`, `smtp.secure`, `smtp.user`, `smtp.pass`,
|
|
76
|
+
`smtp.from`, and `smtp.to`.
|
|
77
|
+
|
|
78
|
+
Adding a new artist refreshes that artist immediately, but treats the existing
|
|
79
|
+
catalog as your starting baseline: it does not send email for those releases or
|
|
80
|
+
mark them with "New" badges.
|
|
70
81
|
|
|
71
82
|
`refresh` is manual — run it from the CLI, the web button, or your own scheduler
|
|
72
83
|
(cron, systemd timer, etc.).
|
|
73
84
|
|
|
85
|
+
### Cron refresh
|
|
86
|
+
|
|
87
|
+
To check for new releases on a schedule, first make sure the CLI has the required
|
|
88
|
+
MusicBrainz contact and any notification settings configured:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
silver-music-notifier config set musicbrainz.contact you@example.com
|
|
92
|
+
silver-music-notifier config set notify.email true # optional, if SMTP is configured
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Then add a cron entry. This example refreshes every day at 9:00 AM:
|
|
96
|
+
|
|
97
|
+
```cron
|
|
98
|
+
0 9 * * * /usr/bin/env silver-music-notifier refresh >> "$HOME/.local/share/silver-music-notifier/cron.log" 2>&1
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If cron cannot find the command, use the full path from
|
|
102
|
+
`command -v silver-music-notifier`. To use a custom database location, set
|
|
103
|
+
`SILVER_MUSIC_NOTIFIER_DATA_DIR` in the cron line:
|
|
104
|
+
|
|
105
|
+
```cron
|
|
106
|
+
0 9 * * *
|
|
107
|
+
SILVER_MUSIC_NOTIFIER_DATA_DIR="$HOME/.local/share/silver-music-notifier" /usr/bin/env silver-music-notifier refresh >> "$HOME/.local/share/silver-music-notifier/cron.log" 2>&1
|
|
108
|
+
```
|
|
109
|
+
|
|
74
110
|
## Data & configuration
|
|
75
111
|
|
|
76
112
|
State lives in a single SQLite file (`data.db`) in your per-user data directory:
|
|
@@ -89,23 +125,3 @@ variable.
|
|
|
89
125
|
Notification methods (in-page / email) and the MusicBrainz contact are
|
|
90
126
|
configured in the web UI's **Settings** view or via `silver-music-notifier
|
|
91
127
|
config set …` — not through environment variables.
|
|
92
|
-
|
|
93
|
-
The only environment variable is:
|
|
94
|
-
|
|
95
|
-
- `SILVER_MUSIC_NOTIFIER_DATA_DIR` — override the data directory (must be an env
|
|
96
|
-
var, since all other settings are stored inside it).
|
|
97
|
-
|
|
98
|
-
## Development
|
|
99
|
-
|
|
100
|
-
```bash
|
|
101
|
-
npm install
|
|
102
|
-
npm run dev # backend (tsx watch, :3001) + Vite dev server (:5173, proxies /api)
|
|
103
|
-
npm run build # bundle CLI/server (tsup) + build web (vite) into dist/
|
|
104
|
-
npm run typecheck
|
|
105
|
-
npm run lint # eslint
|
|
106
|
-
npm run lint:fix # eslint --fix
|
|
107
|
-
npm run format # prettier --write .
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
A Husky `pre-commit` hook runs `lint-staged` (eslint `--fix` then prettier on
|
|
111
|
-
staged files) followed by `typecheck`, so commits are auto-formatted and linted.
|
package/dist/cli/index.js
CHANGED
|
@@ -12,10 +12,10 @@ import { dirname, join as join2 } from "path";
|
|
|
12
12
|
// src/lib/musicbrainz.ts
|
|
13
13
|
import { MusicBrainzApi } from "musicbrainz-api";
|
|
14
14
|
|
|
15
|
-
// package.json
|
|
15
|
+
// package.json with { type: 'json' }
|
|
16
16
|
var package_default = {
|
|
17
17
|
name: "silver-music-notifier",
|
|
18
|
-
version: "1.
|
|
18
|
+
version: "1.1.0",
|
|
19
19
|
description: "Track artists and get notified of their new music releases from MusicBrainz, via CLI or a local web UI.",
|
|
20
20
|
license: "MIT",
|
|
21
21
|
author: "Andrey Goder <andy.goder@gmail.com>",
|
|
@@ -434,34 +434,106 @@ async function fetchReleaseGroups(artistMbid) {
|
|
|
434
434
|
|
|
435
435
|
// src/lib/notify.ts
|
|
436
436
|
import nodemailer from "nodemailer";
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
437
|
+
|
|
438
|
+
// src/lib/Release.ts
|
|
439
|
+
var Release = class _Release {
|
|
440
|
+
mbid;
|
|
441
|
+
artistMbid;
|
|
442
|
+
artistName;
|
|
443
|
+
title;
|
|
444
|
+
primaryType;
|
|
445
|
+
secondaryTypes;
|
|
446
|
+
firstReleaseDate;
|
|
447
|
+
firstSeenAt;
|
|
448
|
+
isNew;
|
|
449
|
+
constructor(input) {
|
|
450
|
+
this.mbid = input.mbid;
|
|
451
|
+
this.artistMbid = input.artistMbid;
|
|
452
|
+
this.artistName = input.artistName;
|
|
453
|
+
this.title = input.title;
|
|
454
|
+
this.primaryType = input.primaryType;
|
|
455
|
+
this.secondaryTypes = input.secondaryTypes;
|
|
456
|
+
this.firstReleaseDate = input.firstReleaseDate;
|
|
457
|
+
this.firstSeenAt = input.firstSeenAt;
|
|
458
|
+
this.isNew = input.dismissedAt == null && input.lastRefresh != null && input.firstSeenAt >= input.lastRefresh;
|
|
459
|
+
}
|
|
460
|
+
static list(opts = {}) {
|
|
461
|
+
const lastRefresh = Settings.getLastRefreshAt();
|
|
462
|
+
const rows = AppDb.getDefault().prepare(
|
|
463
|
+
`SELECT rg.mbid, rg.artist_mbid, a.name AS artist_name, rg.title,
|
|
464
|
+
rg.primary_type, rg.secondary_types, rg.first_release_date,
|
|
465
|
+
rg.first_seen_at, rg.dismissed_at
|
|
466
|
+
FROM release_groups rg
|
|
467
|
+
JOIN artists a ON a.mbid = rg.artist_mbid
|
|
468
|
+
ORDER BY (rg.first_release_date IS NULL), rg.first_release_date DESC, rg.title`
|
|
469
|
+
).all();
|
|
470
|
+
const items = rows.map(
|
|
471
|
+
(row) => new _Release({
|
|
472
|
+
mbid: row.mbid,
|
|
473
|
+
artistMbid: row.artist_mbid,
|
|
474
|
+
artistName: row.artist_name,
|
|
475
|
+
title: row.title,
|
|
476
|
+
primaryType: row.primary_type,
|
|
477
|
+
secondaryTypes: row.secondary_types,
|
|
478
|
+
firstReleaseDate: row.first_release_date,
|
|
479
|
+
firstSeenAt: row.first_seen_at,
|
|
480
|
+
dismissedAt: row.dismissed_at,
|
|
481
|
+
lastRefresh
|
|
482
|
+
})
|
|
483
|
+
);
|
|
484
|
+
const filtered = opts.onlyNew ? items.filter((i) => i.isNew) : items;
|
|
485
|
+
return opts.limit ? filtered.slice(0, opts.limit) : filtered;
|
|
486
|
+
}
|
|
487
|
+
static dismiss(mbid) {
|
|
488
|
+
const res = AppDb.getDefault().prepare("UPDATE release_groups SET dismissed_at = ? WHERE mbid = ?").run((/* @__PURE__ */ new Date()).toISOString(), mbid);
|
|
489
|
+
return res.changes > 0;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// src/lib/formatReleaseDate.ts
|
|
494
|
+
var monthNames = [
|
|
495
|
+
"Jan",
|
|
496
|
+
"Feb",
|
|
497
|
+
"Mar",
|
|
498
|
+
"Apr",
|
|
499
|
+
"May",
|
|
500
|
+
"Jun",
|
|
501
|
+
"Jul",
|
|
502
|
+
"Aug",
|
|
503
|
+
"Sep",
|
|
504
|
+
"Oct",
|
|
505
|
+
"Nov",
|
|
506
|
+
"Dec"
|
|
507
|
+
];
|
|
508
|
+
function formatReleaseDate(value) {
|
|
509
|
+
if (!value) {
|
|
510
|
+
return "\u2014";
|
|
511
|
+
}
|
|
512
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
513
|
+
if (!year) {
|
|
514
|
+
return value;
|
|
515
|
+
}
|
|
516
|
+
if (month && !day) {
|
|
517
|
+
return `${monthNames[month - 1]} ${year}`;
|
|
518
|
+
}
|
|
519
|
+
if (!month || !day) {
|
|
520
|
+
return value;
|
|
521
|
+
}
|
|
522
|
+
return `${day} ${monthNames[month - 1]} ${year}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/lib/notify.ts
|
|
526
|
+
function subjectLine(r) {
|
|
527
|
+
return `New Release: ${r.title} by ${r.artistName}`;
|
|
442
528
|
}
|
|
443
|
-
function emailHtml(
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
<td style="padding:4px 12px 4px 0"><strong>${escapeHtml(r.title)}</strong></td>
|
|
450
|
-
<td style="padding:4px 12px 4px 0">${escapeHtml(type)}</td>
|
|
451
|
-
<td style="padding:4px 0">${escapeHtml(date)}</td>
|
|
452
|
-
</tr>`;
|
|
453
|
-
}).join("");
|
|
529
|
+
function emailHtml(r) {
|
|
530
|
+
const type = [r.primaryType, ...r.secondaryTypes].filter(Boolean).join(" / ");
|
|
531
|
+
const title = `<strong>${escapeHtml(r.title)}</strong>`;
|
|
532
|
+
const artist = escapeHtml(r.artistName);
|
|
533
|
+
const typeText = type ? ` (${escapeHtml(type)})` : "";
|
|
534
|
+
const dateText = r.firstReleaseDate ? ` was released on ${escapeHtml(formatReleaseDate(r.firstReleaseDate))}` : " is out";
|
|
454
535
|
return `<div style="font-family:system-ui,sans-serif">
|
|
455
|
-
<
|
|
456
|
-
<table style="border-collapse:collapse">
|
|
457
|
-
<thead><tr>
|
|
458
|
-
<th align="left" style="padding:4px 12px 4px 0">Artist</th>
|
|
459
|
-
<th align="left" style="padding:4px 12px 4px 0">Title</th>
|
|
460
|
-
<th align="left" style="padding:4px 12px 4px 0">Type</th>
|
|
461
|
-
<th align="left" style="padding:4px 0">Released</th>
|
|
462
|
-
</tr></thead>
|
|
463
|
-
<tbody>${rows}</tbody>
|
|
464
|
-
</table>
|
|
536
|
+
<p>${title} by ${artist}${typeText}${dateText}.</p>
|
|
465
537
|
</div>`;
|
|
466
538
|
}
|
|
467
539
|
function escapeHtml(s) {
|
|
@@ -479,12 +551,12 @@ function transport(s) {
|
|
|
479
551
|
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : void 0
|
|
480
552
|
});
|
|
481
553
|
}
|
|
482
|
-
async function
|
|
554
|
+
async function sendReleaseEmail(release, s, subjectPrefix = "") {
|
|
483
555
|
await transport(s).sendMail({
|
|
484
556
|
from: s.smtp.from || s.smtp.user,
|
|
485
557
|
to: s.smtp.to,
|
|
486
|
-
subject:
|
|
487
|
-
html: emailHtml(
|
|
558
|
+
subject: subjectPrefix + subjectLine(release),
|
|
559
|
+
html: emailHtml(release)
|
|
488
560
|
});
|
|
489
561
|
}
|
|
490
562
|
async function notifyNewReleases(newReleases) {
|
|
@@ -496,14 +568,42 @@ async function notifyNewReleases(newReleases) {
|
|
|
496
568
|
if (!s.smtpIsConfigured()) {
|
|
497
569
|
console.warn("Email enabled but SMTP not configured \u2014 skipping email.");
|
|
498
570
|
} else {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
571
|
+
for (const release of newReleases) {
|
|
572
|
+
try {
|
|
573
|
+
await sendReleaseEmail(release, s);
|
|
574
|
+
} catch (err) {
|
|
575
|
+
console.error(
|
|
576
|
+
`Email notification failed for "${release.title}":`,
|
|
577
|
+
errMsg(err)
|
|
578
|
+
);
|
|
579
|
+
}
|
|
503
580
|
}
|
|
504
581
|
}
|
|
505
582
|
}
|
|
506
583
|
}
|
|
584
|
+
function sampleRelease() {
|
|
585
|
+
const [latest] = Release.list({ limit: 1 });
|
|
586
|
+
if (latest) {
|
|
587
|
+
return {
|
|
588
|
+
mbid: latest.mbid,
|
|
589
|
+
artistMbid: latest.artistMbid,
|
|
590
|
+
artistName: latest.artistName,
|
|
591
|
+
title: latest.title,
|
|
592
|
+
primaryType: latest.primaryType,
|
|
593
|
+
secondaryTypes: latest.secondaryTypes ? latest.secondaryTypes.split(", ") : [],
|
|
594
|
+
firstReleaseDate: latest.firstReleaseDate
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
mbid: "sample",
|
|
599
|
+
artistMbid: "sample",
|
|
600
|
+
artistName: "Example Artist",
|
|
601
|
+
title: "Example Album",
|
|
602
|
+
primaryType: "Album",
|
|
603
|
+
secondaryTypes: [],
|
|
604
|
+
firstReleaseDate: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
605
|
+
};
|
|
606
|
+
}
|
|
507
607
|
async function sendTestEmail(override) {
|
|
508
608
|
const s = override ?? Settings.load();
|
|
509
609
|
if (!s.smtpIsConfigured()) {
|
|
@@ -511,12 +611,7 @@ async function sendTestEmail(override) {
|
|
|
511
611
|
"SMTP is not configured (host, user, and recipient required)."
|
|
512
612
|
);
|
|
513
613
|
}
|
|
514
|
-
await
|
|
515
|
-
from: s.smtp.from || s.smtp.user,
|
|
516
|
-
to: s.smtp.to,
|
|
517
|
-
subject: "silver-music-notifier test email",
|
|
518
|
-
text: "This is a test email from silver-music-notifier. SMTP is working."
|
|
519
|
-
});
|
|
614
|
+
await sendReleaseEmail(sampleRelease(), s, "[TEST] ");
|
|
520
615
|
}
|
|
521
616
|
function errMsg(err) {
|
|
522
617
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -586,7 +681,7 @@ var Artist = class _Artist {
|
|
|
586
681
|
};
|
|
587
682
|
|
|
588
683
|
// src/lib/refresh.ts
|
|
589
|
-
async function refreshArtists(artists, opts, persistLastRefresh) {
|
|
684
|
+
async function refreshArtists(artists, opts, persistLastRefresh, markReleasesSeen) {
|
|
590
685
|
const db = AppDb.getDefault();
|
|
591
686
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
592
687
|
const settings = Settings.load();
|
|
@@ -597,9 +692,9 @@ async function refreshArtists(artists, opts, persistLastRefresh) {
|
|
|
597
692
|
const insert = db.prepare(`
|
|
598
693
|
INSERT INTO release_groups
|
|
599
694
|
(mbid, artist_mbid, title, primary_type, secondary_types,
|
|
600
|
-
first_release_date, first_seen_at, last_seen_at)
|
|
695
|
+
first_release_date, first_seen_at, last_seen_at, dismissed_at)
|
|
601
696
|
VALUES (@mbid, @artist_mbid, @title, @primary_type, @secondary_types,
|
|
602
|
-
@first_release_date, @now, @now)
|
|
697
|
+
@first_release_date, @now, @now, @dismissed_at)
|
|
603
698
|
ON CONFLICT(mbid) DO UPDATE SET
|
|
604
699
|
title = excluded.title,
|
|
605
700
|
primary_type = excluded.primary_type,
|
|
@@ -628,7 +723,8 @@ async function refreshArtists(artists, opts, persistLastRefresh) {
|
|
|
628
723
|
primary_type: g.primaryType,
|
|
629
724
|
secondary_types: g.secondaryTypes.join(", ") || null,
|
|
630
725
|
first_release_date: g.firstReleaseDate,
|
|
631
|
-
now
|
|
726
|
+
now,
|
|
727
|
+
dismissed_at: markReleasesSeen ? now : null
|
|
632
728
|
});
|
|
633
729
|
if (!seen) {
|
|
634
730
|
newReleases.push({
|
|
@@ -667,67 +763,12 @@ async function refreshArtists(artists, opts, persistLastRefresh) {
|
|
|
667
763
|
return summary;
|
|
668
764
|
}
|
|
669
765
|
async function refreshArtist(artist, opts = {}) {
|
|
670
|
-
return refreshArtists([artist], opts, false);
|
|
766
|
+
return refreshArtists([artist], opts, false, true);
|
|
671
767
|
}
|
|
672
768
|
async function refresh(opts = {}) {
|
|
673
|
-
return refreshArtists(Artist.list(), opts, true);
|
|
769
|
+
return refreshArtists(Artist.list(), opts, true, false);
|
|
674
770
|
}
|
|
675
771
|
|
|
676
|
-
// src/lib/Release.ts
|
|
677
|
-
var Release = class _Release {
|
|
678
|
-
mbid;
|
|
679
|
-
artistMbid;
|
|
680
|
-
artistName;
|
|
681
|
-
title;
|
|
682
|
-
primaryType;
|
|
683
|
-
secondaryTypes;
|
|
684
|
-
firstReleaseDate;
|
|
685
|
-
firstSeenAt;
|
|
686
|
-
isNew;
|
|
687
|
-
constructor(input) {
|
|
688
|
-
this.mbid = input.mbid;
|
|
689
|
-
this.artistMbid = input.artistMbid;
|
|
690
|
-
this.artistName = input.artistName;
|
|
691
|
-
this.title = input.title;
|
|
692
|
-
this.primaryType = input.primaryType;
|
|
693
|
-
this.secondaryTypes = input.secondaryTypes;
|
|
694
|
-
this.firstReleaseDate = input.firstReleaseDate;
|
|
695
|
-
this.firstSeenAt = input.firstSeenAt;
|
|
696
|
-
this.isNew = input.dismissedAt == null && input.lastRefresh != null && input.firstSeenAt >= input.lastRefresh;
|
|
697
|
-
}
|
|
698
|
-
static list(opts = {}) {
|
|
699
|
-
const lastRefresh = Settings.getLastRefreshAt();
|
|
700
|
-
const rows = AppDb.getDefault().prepare(
|
|
701
|
-
`SELECT rg.mbid, rg.artist_mbid, a.name AS artist_name, rg.title,
|
|
702
|
-
rg.primary_type, rg.secondary_types, rg.first_release_date,
|
|
703
|
-
rg.first_seen_at, rg.dismissed_at
|
|
704
|
-
FROM release_groups rg
|
|
705
|
-
JOIN artists a ON a.mbid = rg.artist_mbid
|
|
706
|
-
ORDER BY (rg.first_release_date IS NULL), rg.first_release_date DESC, rg.title`
|
|
707
|
-
).all();
|
|
708
|
-
const items = rows.map(
|
|
709
|
-
(row) => new _Release({
|
|
710
|
-
mbid: row.mbid,
|
|
711
|
-
artistMbid: row.artist_mbid,
|
|
712
|
-
artistName: row.artist_name,
|
|
713
|
-
title: row.title,
|
|
714
|
-
primaryType: row.primary_type,
|
|
715
|
-
secondaryTypes: row.secondary_types,
|
|
716
|
-
firstReleaseDate: row.first_release_date,
|
|
717
|
-
firstSeenAt: row.first_seen_at,
|
|
718
|
-
dismissedAt: row.dismissed_at,
|
|
719
|
-
lastRefresh
|
|
720
|
-
})
|
|
721
|
-
);
|
|
722
|
-
const filtered = opts.onlyNew ? items.filter((i) => i.isNew) : items;
|
|
723
|
-
return opts.limit ? filtered.slice(0, opts.limit) : filtered;
|
|
724
|
-
}
|
|
725
|
-
static dismiss(mbid) {
|
|
726
|
-
const res = AppDb.getDefault().prepare("UPDATE release_groups SET dismissed_at = ? WHERE mbid = ?").run((/* @__PURE__ */ new Date()).toISOString(), mbid);
|
|
727
|
-
return res.changes > 0;
|
|
728
|
-
}
|
|
729
|
-
};
|
|
730
|
-
|
|
731
772
|
// src/server/index.ts
|
|
732
773
|
function webDir() {
|
|
733
774
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -848,17 +889,42 @@ function createApp() {
|
|
|
848
889
|
return app;
|
|
849
890
|
}
|
|
850
891
|
function startServer(port) {
|
|
851
|
-
return new Promise((resolve) => {
|
|
852
|
-
createApp().listen(port
|
|
892
|
+
return new Promise((resolve, reject) => {
|
|
893
|
+
const server = createApp().listen(port);
|
|
894
|
+
server.once("listening", () => {
|
|
895
|
+
server.removeListener("error", reject);
|
|
896
|
+
resolve(port);
|
|
897
|
+
});
|
|
898
|
+
server.once("error", reject);
|
|
853
899
|
});
|
|
854
900
|
}
|
|
855
901
|
|
|
856
902
|
// src/cli/commands/web.ts
|
|
903
|
+
var MAX_PORT_ATTEMPTS = 10;
|
|
904
|
+
function isAddrInUse(err) {
|
|
905
|
+
return typeof err === "object" && err !== null && err.code === "EADDRINUSE";
|
|
906
|
+
}
|
|
857
907
|
function registerWeb(program2) {
|
|
858
908
|
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) => {
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
909
|
+
const requested = Number(opts.port);
|
|
910
|
+
let bound;
|
|
911
|
+
for (let port = requested; port < requested + MAX_PORT_ATTEMPTS; port++) {
|
|
912
|
+
try {
|
|
913
|
+
bound = await startServer(port);
|
|
914
|
+
break;
|
|
915
|
+
} catch (err) {
|
|
916
|
+
if (!isAddrInUse(err)) {
|
|
917
|
+
throw err;
|
|
918
|
+
}
|
|
919
|
+
console.log(`Port ${port} is in use, trying ${port + 1}\u2026`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (bound === void 0) {
|
|
923
|
+
throw new Error(
|
|
924
|
+
`Could not find a free port in range ${requested}\u2013${requested + MAX_PORT_ATTEMPTS - 1}.`
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
const url = `http://localhost:${bound}`;
|
|
862
928
|
console.log(`silver-music-notifier web UI running at ${url}`);
|
|
863
929
|
if (opts.open) {
|
|
864
930
|
try {
|
|
@@ -1169,6 +1235,110 @@ async function ensureMbContact() {
|
|
|
1169
1235
|
);
|
|
1170
1236
|
}
|
|
1171
1237
|
|
|
1238
|
+
// package.json
|
|
1239
|
+
var package_default2 = {
|
|
1240
|
+
name: "silver-music-notifier",
|
|
1241
|
+
version: "1.1.0",
|
|
1242
|
+
description: "Track artists and get notified of their new music releases from MusicBrainz, via CLI or a local web UI.",
|
|
1243
|
+
license: "MIT",
|
|
1244
|
+
author: "Andrey Goder <andy.goder@gmail.com>",
|
|
1245
|
+
homepage: "https://github.com/czarandy/silver-music-notifier#readme",
|
|
1246
|
+
repository: {
|
|
1247
|
+
type: "git",
|
|
1248
|
+
url: "git+https://github.com/czarandy/silver-music-notifier.git"
|
|
1249
|
+
},
|
|
1250
|
+
bugs: {
|
|
1251
|
+
url: "https://github.com/czarandy/silver-music-notifier/issues"
|
|
1252
|
+
},
|
|
1253
|
+
keywords: [
|
|
1254
|
+
"musicbrainz",
|
|
1255
|
+
"music",
|
|
1256
|
+
"new-releases",
|
|
1257
|
+
"release-radar",
|
|
1258
|
+
"notifier",
|
|
1259
|
+
"cli",
|
|
1260
|
+
"sqlite"
|
|
1261
|
+
],
|
|
1262
|
+
type: "module",
|
|
1263
|
+
bin: {
|
|
1264
|
+
"silver-music-notifier": "dist/cli/index.js"
|
|
1265
|
+
},
|
|
1266
|
+
files: [
|
|
1267
|
+
"dist"
|
|
1268
|
+
],
|
|
1269
|
+
publishConfig: {
|
|
1270
|
+
access: "public"
|
|
1271
|
+
},
|
|
1272
|
+
engines: {
|
|
1273
|
+
node: ">=22.12.0"
|
|
1274
|
+
},
|
|
1275
|
+
scripts: {
|
|
1276
|
+
dev: 'concurrently -n server,web -c blue,magenta "tsx watch src/cli/index.ts web --no-open" "vite"',
|
|
1277
|
+
build: "npm run build:bundle && npm run build:web",
|
|
1278
|
+
"build:bundle": "tsup",
|
|
1279
|
+
"build:web": "vite build",
|
|
1280
|
+
prepare: "husky && npm run build",
|
|
1281
|
+
refresh: "tsx src/cli/index.ts refresh",
|
|
1282
|
+
typecheck: "tsc --noEmit",
|
|
1283
|
+
test: "vitest run",
|
|
1284
|
+
lint: "eslint .",
|
|
1285
|
+
"lint:fix": "eslint --fix .",
|
|
1286
|
+
format: "prettier --write .",
|
|
1287
|
+
"format:check": "prettier --check .",
|
|
1288
|
+
"check:exports": "publint",
|
|
1289
|
+
"smoke:package": "npm run build && node scripts/package-smoke-test.mjs",
|
|
1290
|
+
release: "bash scripts/release.sh"
|
|
1291
|
+
},
|
|
1292
|
+
"lint-staged": {
|
|
1293
|
+
"*.{ts,tsx}": [
|
|
1294
|
+
"eslint --fix --no-warn-ignored",
|
|
1295
|
+
"prettier --write"
|
|
1296
|
+
],
|
|
1297
|
+
"*.{js,jsx,json,css,md,html}": "prettier --write"
|
|
1298
|
+
},
|
|
1299
|
+
dependencies: {
|
|
1300
|
+
"@inquirer/prompts": "^7.2.0",
|
|
1301
|
+
"@tanstack/react-query": "^5.101.0",
|
|
1302
|
+
"better-sqlite3": "^11.7.0",
|
|
1303
|
+
commander: "^13.0.0",
|
|
1304
|
+
"env-paths": "^3.0.0",
|
|
1305
|
+
express: "^4.21.2",
|
|
1306
|
+
"musicbrainz-api": "^1.2.1",
|
|
1307
|
+
nodemailer: "^9.0.0",
|
|
1308
|
+
open: "^10.1.0"
|
|
1309
|
+
},
|
|
1310
|
+
devDependencies: {
|
|
1311
|
+
"@eslint/js": "^9.39.4",
|
|
1312
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
1313
|
+
"@types/express": "^4.17.21",
|
|
1314
|
+
"@types/node": "^22.10.0",
|
|
1315
|
+
"@types/nodemailer": "^6.4.17",
|
|
1316
|
+
"@types/react": "^19.0.0",
|
|
1317
|
+
"@types/react-dom": "^19.0.0",
|
|
1318
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
1319
|
+
concurrently: "^9.1.0",
|
|
1320
|
+
eslint: "^9.39.4",
|
|
1321
|
+
globals: "^15.15.0",
|
|
1322
|
+
husky: "^9.1.7",
|
|
1323
|
+
"lint-staged": "^15.5.2",
|
|
1324
|
+
prettier: "^3.8.4",
|
|
1325
|
+
publint: "^0.3.21",
|
|
1326
|
+
react: "^19.0.0",
|
|
1327
|
+
"react-dom": "^19.0.0",
|
|
1328
|
+
"silver-ui": "^0.7.1",
|
|
1329
|
+
tsup: "^8.3.5",
|
|
1330
|
+
tsx: "^4.19.2",
|
|
1331
|
+
typescript: "^5.7.2",
|
|
1332
|
+
"typescript-eslint": "^8.61.0",
|
|
1333
|
+
vite: "^8.0.16",
|
|
1334
|
+
vitest: "^4.1.8"
|
|
1335
|
+
},
|
|
1336
|
+
overrides: {
|
|
1337
|
+
esbuild: "0.28.1",
|
|
1338
|
+
"shell-quote": "1.8.4"
|
|
1339
|
+
}
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1172
1342
|
// src/cli/index.ts
|
|
1173
1343
|
var program = new Command();
|
|
1174
1344
|
var CONTACT_EXEMPT_COMMANDS = /* @__PURE__ */ new Set(["config", "clear-data", "dismiss"]);
|
|
@@ -1182,7 +1352,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
1182
1352
|
});
|
|
1183
1353
|
program.name("silver-music-notifier").description(
|
|
1184
1354
|
"Track artists and get notified of their new music releases from MusicBrainz."
|
|
1185
|
-
).version(
|
|
1355
|
+
).version(package_default2.version);
|
|
1186
1356
|
registerWeb(program);
|
|
1187
1357
|
registerList(program);
|
|
1188
1358
|
registerAdd(program);
|