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 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 a command that hits the API (`add` or `refresh`), the CLI **prompts you for a
23
- contact** and saves it. You can also set it ahead of time:
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. In a non-interactive context (no TTY), the
30
- command errors with this guidance instead of prompting.
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** — an HTML summary, sent once SMTP is configured and the email toggle
69
- is on. Configure it in the **Settings** view or via `config set smtp.*`.
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.0.0",
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
- function summaryLine(newReleases) {
438
- const n = newReleases.length;
439
- const artists = [...new Set(newReleases.map((r) => r.artistName))];
440
- const who = artists.length <= 3 ? artists.join(", ") : `${artists.slice(0, 3).join(", ")} +${artists.length - 3} more`;
441
- return `${n} new release${n === 1 ? "" : "s"} from ${who}`;
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(newReleases) {
444
- const rows = newReleases.map((r) => {
445
- const type = [r.primaryType, ...r.secondaryTypes].filter(Boolean).join(" / ");
446
- const date = r.firstReleaseDate ?? "\u2014";
447
- return `<tr>
448
- <td style="padding:4px 12px 4px 0">${escapeHtml(r.artistName)}</td>
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
- <h2>${escapeHtml(summaryLine(newReleases))}</h2>
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 emailNotify(newReleases, s) {
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: summaryLine(newReleases),
487
- html: emailHtml(newReleases)
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
- try {
500
- await emailNotify(newReleases, s);
501
- } catch (err) {
502
- console.error("Email notification failed:", errMsg(err));
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 transport(s).sendMail({
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, () => resolve());
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 port = Number(opts.port);
860
- await startServer(port);
861
- const url = `http://localhost:${port}`;
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("0.1.0");
1355
+ ).version(package_default2.version);
1186
1356
  registerWeb(program);
1187
1357
  registerList(program);
1188
1358
  registerAdd(program);