unreleased-cli 0.1.3 → 0.1.5

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
@@ -52,9 +52,9 @@ Commands (type `help <command>` in the shell for the details of each one):
52
52
 
53
53
  - **Files:** `cd`, `ls`, `pwd`, `lcd`, `get`, `cat`, `head`, `tail`, `wc`, `grep`, `locate`, `tree`, `du`
54
54
  - **Library:** `find`, `song`, `like`, `unlike`, `liked`, `playlists`, `playlist`, `stats`
55
- - **Player:** `play`, `pause`, `toggle`, `next`, `prev`, `seek`, `volume`, `speed`, `shuffle`, `repeat`, `queue`, `status`, `sleep`, `stop`
55
+ - **Player:** `play`, `pause`, `toggle`, `next`, `prev`, `seek`, `volume`, `speed`, `eq`, `shuffle`, `repeat`, `queue`, `status`, `sleep`, `stop`
56
56
  - **People:** `user`, `lookup`
57
- - **Admin** (administrators only): `pending`, `proposals`, `comps`, `applications`, `inspect`, `approve`, `reject`, `reverse`, `users`, `sitebans`, `siteunban`
57
+ - **Admin** (administrators only): `pending`, `proposals`, `comps`, `applications`, `cdn`, `inspect`, `approve`, `reject`, `reverse`, `users`, `sitebans`, `siteunban`
58
58
  - **Fun:** `neofetch`, `fortune`, `juicesay`, `matrix`, `visualizer`, `karaoke`, `wordle`, `heardle`
59
59
  - **Settings:** `set`, `settings`, `termtheme`
60
60
  - **Shell:** `source`, `alias`, `unalias`, `history`, `echo`, `watch`, `full`, `date`, `clear`, `exit`, `help`
@@ -119,6 +119,7 @@ These cover the Admin page's review queues and site moderation, using the same e
119
119
  - `reverse [song|comp] <id>` undoes an approved proposal.
120
120
  - `users [role] [filter]` lists accounts. `user <name>` shows one person.
121
121
  - `sitebans` lists active site-wide bans, mutes and timeouts. `siteunban <user | #id>` lifts them.
122
+ - `cdn` shows the CDN stats and every node, pending ones first. `cdn <node>` shows one in full, where a node is its id, the start of the id, or its name. `cdn approve|revoke|enable|disable|reset|restore|delete <node>` does what the Admin page's CDN nodes tab does. `restore` is for a node the server pulled for hash violations: it switches it back on and resets its trust and violations. `delete` asks first unless you pass `-y`.
122
123
 
123
124
  `reverse` and `siteunban` ask before they act. Outside the interactive shell, such as in one-shot use or a script, they need `-y` instead.
124
125
 
@@ -130,6 +131,7 @@ These cover the Admin page's review queues and site moderation, using the same e
130
131
  - `matrix` is digital rain. Any key leaves.
131
132
  - `visualizer` (or `viz`) is a live spectrum of the song that's playing. Any key leaves. mpv can't hand its audio over, so a second mpv decodes the same stream to a temp file and the bars come from that, which means the song is downloaded a second time while the screen is open.
132
133
  - `karaoke` (or `lyrics`) shows the lyrics of the song that's playing. Synced lyrics highlight the line being sung and scroll along; plain ones scroll with the arrow keys and Page Up/Down. Space pauses, `q` leaves. It needs a song from the library (not a file played from the tree).
134
+ - `eq` shows the equalizer settings. `eq list` names the presets (the site's own), `eq rock` turns the equalizer on with one, and `eq off` / `eq reset` switch it off or flatten it. `eq boost 100-200`, `eq balance -100..100`, `eq speed 0.5-2` and `eq pitch [on|off]` (the same as the `speed` command and the `pitch-shift` setting), `eq mono [on|off]` and `eq reverb [on|off|0-100] [decay 1-8]` do the rest. It runs through mpv's audio filters, so the reverb is an echo approximation of the site's. Settings carry over between sessions, and changes apply to the song that's playing.
133
135
  - `wordle [daily | unlimited]` is the song-title Wordle, using the site's own puzzle logic, so the daily puzzle is the same one. Type a title and press Enter. Esc leaves, and your progress is saved.
134
136
  - `heardle` gives practice rounds: Tab plays the clip, Enter guesses (an empty Enter skips), and ↑↓ picks a suggestion. The clip plays through its own mpv. Music that was playing gets paused, and `play` resumes it.
135
137
 
@@ -82,7 +82,7 @@ function saveSettings(settings) {
82
82
 
83
83
  // src/api.ts
84
84
  var DEFAULT_API = "https://juicewrldapi.com/juicewrld";
85
- var VERSION = "0.1.3";
85
+ var VERSION = "0.1.5";
86
86
  function apiBase() {
87
87
  return (process.env.UNRELEASED_API || loadConfig().api || DEFAULT_API).replace(/\/+$/, "");
88
88
  }
@@ -253,12 +253,12 @@ var color = {
253
253
  errRed: paint("red", process.stderr),
254
254
  errDim: paint("dim", process.stderr)
255
255
  };
256
- function writeTone(text, tone) {
257
- if (tone === "error") {
256
+ function writeTone(text, tone2) {
257
+ if (tone2 === "error") {
258
258
  process.stderr.write(color.errRed(text) + "\n");
259
259
  return;
260
260
  }
261
- const painted = tone === "ok" ? color.green(text) : tone === "dim" ? color.dim(text) : text;
261
+ const painted = tone2 === "ok" ? color.green(text) : tone2 === "dim" ? color.dim(text) : text;
262
262
  process.stdout.write(painted + "\n");
263
263
  }
264
264
  function progressLine() {
@@ -488,6 +488,68 @@ function installHistorySearch(rl, history, idle, closed = () => void 0, stdin =
488
488
  }
489
489
  }
490
490
 
491
+ // src/effects.ts
492
+ var EQ_BANDS = [32, 64, 125, 250, 500, 1e3, 2e3, 4e3, 8e3, 16e3];
493
+ var EQ_PRESETS = [
494
+ { id: "flat", name: "Flat", gains: EQ_BANDS.map(() => 0) },
495
+ { id: "bass-boost", name: "Bass Boost", gains: [6, 5, 4, 2.5, 1, 0, 0, 0, 0, 0] },
496
+ { id: "bass-reducer", name: "Bass Reducer", gains: [-6, -5, -4, -2.5, -1, 0, 0, 0, 0, 0] },
497
+ { id: "treble-boost", name: "Treble Boost", gains: [0, 0, 0, 0, 0, 1, 2.5, 4, 5, 6] },
498
+ { id: "treble-reducer", name: "Treble Reducer", gains: [0, 0, 0, 0, 0, -1, -2.5, -4, -5, -6] },
499
+ { id: "vocal", name: "Vocal Boost", gains: [-2, -3, -3, 1, 4, 4, 3, 1.5, 0, -1.5] },
500
+ { id: "rock", name: "Rock", gains: [5, 4, 3, 1.5, -0.5, -1, 0.5, 2.5, 3.5, 4.5] },
501
+ { id: "pop", name: "Pop", gains: [-1.5, -1, 0, 2, 4, 4, 2, 0, -1, -1.5] },
502
+ { id: "hip-hop", name: "Hip-Hop", gains: [5, 4, 1.5, 3, -1, -1, 1.5, -0.5, 2, 3] },
503
+ { id: "electronic", name: "Electronic", gains: [4.5, 4, 1.5, 0, -2, 2, 1, 1.5, 4, 5] },
504
+ { id: "jazz", name: "Jazz", gains: [4, 3, 1.5, 2, -1.5, -1.5, 0, 1.5, 3, 4] },
505
+ { id: "classical", name: "Classical", gains: [4.5, 3.5, 3, 2.5, -1.5, -1.5, 0, 2, 3, 4] },
506
+ { id: "acoustic", name: "Acoustic", gains: [5, 5, 4, 1, 2, 1.5, 3.5, 4, 3.5, 2] },
507
+ { id: "small-speakers", name: "Small Speakers", gains: [5.5, 4.5, 4, 2.5, 1.5, 0, -1.5, -3, -4, -4.5] }
508
+ ];
509
+ var effects = () => ({
510
+ eq: pref("eq", false),
511
+ preset: pref("eq-preset", "flat"),
512
+ boost: pref("eq-boost", 1),
513
+ balance: pref("eq-balance", 0),
514
+ mono: pref("eq-mono", false),
515
+ reverb: pref("reverb", false),
516
+ reverbMix: pref("reverb-mix", 0.3),
517
+ reverbDecay: pref("reverb-decay", 2)
518
+ });
519
+ var KEYS = {
520
+ eq: "eq",
521
+ preset: "eq-preset",
522
+ boost: "eq-boost",
523
+ balance: "eq-balance",
524
+ mono: "eq-mono",
525
+ reverb: "reverb",
526
+ reverbMix: "reverb-mix",
527
+ reverbDecay: "reverb-decay"
528
+ };
529
+ function saveEffect(key, value) {
530
+ setPref(KEYS[key], value);
531
+ }
532
+ var num = (n) => String(Math.round(n * 1e3) / 1e3);
533
+ function filterChain(e = effects()) {
534
+ const f = [];
535
+ const gains = EQ_PRESETS.find((p) => p.id === e.preset)?.gains;
536
+ if (e.eq && gains) {
537
+ gains.forEach((g, i) => {
538
+ if (g) f.push(`equalizer=f=${EQ_BANDS[i]}:width_type=o:width=1:g=${num(g)}`);
539
+ });
540
+ }
541
+ if (e.mono) f.push("pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1");
542
+ if (e.balance) f.push(`stereotools=balance_out=${num(e.balance)}`);
543
+ if (e.reverb && e.reverbMix > 0) {
544
+ const delays = [0.04, 0.09, 0.17, 0.3].map((t) => Math.round(Math.min(1, t * e.reverbDecay / 2) * 1e3));
545
+ const decays = [0.6, 0.5, 0.4, 0.3].map((d) => num(d * e.reverbMix * 2 > 0.9 ? 0.9 : d * e.reverbMix * 2));
546
+ f.push(`aecho=in_gain=${num(1 - e.reverbMix * 0.4)}:out_gain=${num(0.6 + e.reverbMix * 0.3)}:delays=${delays.join("|")}:decays=${decays.join("|")}`);
547
+ }
548
+ if (e.boost > 1) f.push(`volume=${num(e.boost)}`);
549
+ if (f.length) f.push("alimiter=limit=0.95");
550
+ return f.length ? `lavfi=[${f.join(",")}]` : "";
551
+ }
552
+
491
553
  // src/mpv.ts
492
554
  import { spawn } from "node:child_process";
493
555
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
@@ -737,6 +799,8 @@ var Player = class {
737
799
  await mpv.set("mute", this.muted);
738
800
  await mpv.set("speed", this.speed);
739
801
  await mpv.set("audio-pitch-correction", !this.pitchShift);
802
+ const chain = filterChain();
803
+ if (chain) await mpv.set("af", chain);
740
804
  await mpv.command("observe_property", 1, "time-pos");
741
805
  await mpv.command("observe_property", 2, "duration");
742
806
  this.mpv = mpv;
@@ -958,6 +1022,10 @@ var Player = class {
958
1022
  setPref("speed", s);
959
1023
  if (this.mpv?.alive) await this.mpv.set("speed", s);
960
1024
  }
1025
+ /** Swaps the audio-filter chain (the equalizer and effects) while playing. */
1026
+ async setFilter(chain) {
1027
+ if (this.mpv?.alive) await this.mpv.set("af", chain);
1028
+ }
961
1029
  async setPitchShift(on) {
962
1030
  this.pitchShift = on;
963
1031
  setPref("pitch-shift", on);
@@ -1603,7 +1671,7 @@ var ADMIN_COMMANDS = [
1603
1671
  listSiteModeration()
1604
1672
  ]);
1605
1673
  for (const r of [song, comp, apps, mods]) if (r.status === "rejected" && r.reason?.name === "AbortError") throw r.reason;
1606
- const n = (r, pick) => r.status === "fulfilled" ? String(pick(r.value)) : "?";
1674
+ const n = (r, pick2) => r.status === "fulfilled" ? String(pick2(r.value)) : "?";
1607
1675
  sh.print([
1608
1676
  `song proposals ${n(song, (v) => v.pending)} pending`,
1609
1677
  `comp proposals ${n(comp, (v) => v.pending)} pending`,
@@ -2408,8 +2476,8 @@ async function randomLyricLine() {
2408
2476
  const pool = await loadPools(["released", "unreleased"]);
2409
2477
  if (pool.length === 0) fail("fortune: no songs to pick from");
2410
2478
  for (let attempt = 0; attempt < 10; attempt++) {
2411
- const pick = pool[Math.floor(Math.random() * pool.length)];
2412
- const song = await getSong(pick.id);
2479
+ const pick2 = pool[Math.floor(Math.random() * pool.length)];
2480
+ const song = await getSong(pick2.id);
2413
2481
  const lines = (song.lyrics ?? "").split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length >= 8 && l.length <= 120 && !/^[[(].*[\])]$/.test(l));
2414
2482
  if (lines.length) return `${lines[Math.floor(Math.random() * lines.length)]}
2415
2483
  - ${song.name}`;
@@ -2460,13 +2528,13 @@ function matrixScreen() {
2460
2528
  let heads = [];
2461
2529
  let age = new Uint16Array(0);
2462
2530
  let glyph = [];
2463
- const pick = () => RAIN[Math.floor(Math.random() * RAIN.length)];
2531
+ const pick2 = () => RAIN[Math.floor(Math.random() * RAIN.length)];
2464
2532
  const fit = (s) => {
2465
2533
  cols = s.cols;
2466
2534
  rows = s.rows;
2467
2535
  heads = Array.from({ length: cols }, () => -Math.floor(Math.random() * rows));
2468
2536
  age = new Uint16Array(cols * rows).fill(999);
2469
- glyph = Array.from({ length: cols * rows }, pick);
2537
+ glyph = Array.from({ length: cols * rows }, pick2);
2470
2538
  };
2471
2539
  const frame = (s) => {
2472
2540
  if (s.cols !== cols || s.rows !== rows) fit(s);
@@ -2477,12 +2545,12 @@ function matrixScreen() {
2477
2545
  const r = heads[c];
2478
2546
  if (r >= 0 && r < rows) {
2479
2547
  age[r * cols + c] = 0;
2480
- glyph[r * cols + c] = pick();
2548
+ glyph[r * cols + c] = pick2();
2481
2549
  }
2482
2550
  }
2483
2551
  for (let k = 0; k < cols / 4; k++) {
2484
2552
  const i = Math.floor(Math.random() * glyph.length);
2485
- if (age[i] < TRAIL) glyph[i] = pick();
2553
+ if (age[i] < TRAIL) glyph[i] = pick2();
2486
2554
  }
2487
2555
  const [dark, mid, bright, hot] = accentRamp().map((p) => `\x1B[0;${p}m`);
2488
2556
  const lines = [];
@@ -2704,9 +2772,9 @@ function letterHints(rows) {
2704
2772
  for (const row of rows) {
2705
2773
  for (let i = 0; i < row.key.length; i++) {
2706
2774
  const letter = row.key[i];
2707
- const state = row.states[i];
2775
+ const state2 = row.states[i];
2708
2776
  const known = out.get(letter);
2709
- if (!known || RANK[state] > RANK[known]) out.set(letter, state);
2777
+ if (!known || RANK[state2] > RANK[known]) out.set(letter, state2);
2710
2778
  }
2711
2779
  }
2712
2780
  return out;
@@ -2771,14 +2839,14 @@ function loadRound(dayKey, answerId) {
2771
2839
  if (!saved || saved.day !== dayKey || saved.answerId !== answerId) return null;
2772
2840
  return saved;
2773
2841
  }
2774
- function saveRound(state) {
2775
- lsSet2("round", state);
2842
+ function saveRound(state2) {
2843
+ lsSet2("round", state2);
2776
2844
  }
2777
2845
  function loadPracticeRound2() {
2778
2846
  return lsGet2("round:unlimited");
2779
2847
  }
2780
- function savePracticeRound2(state) {
2781
- lsSet2("round:unlimited", state);
2848
+ function savePracticeRound2(state2) {
2849
+ lsSet2("round:unlimited", state2);
2782
2850
  }
2783
2851
  function loadStats() {
2784
2852
  const saved = lsGet2("stats");
@@ -3102,7 +3170,7 @@ function heardleScreen(sh, pausedMusic) {
3102
3170
  let status = "playing";
3103
3171
  let startAt = 0;
3104
3172
  let input = "";
3105
- let pick = 0;
3173
+ let pick2 = 0;
3106
3174
  let failure = "";
3107
3175
  const render = () => {
3108
3176
  if (screen && !screen.closed) draw(screen);
@@ -3125,7 +3193,7 @@ function heardleScreen(sh, pausedMusic) {
3125
3193
  guesses = [];
3126
3194
  status = "playing";
3127
3195
  input = "";
3128
- pick = 0;
3196
+ pick2 = 0;
3129
3197
  startAt = clipStart(next, ladder[ladder.length - 1], settings.startPoint, null);
3130
3198
  save();
3131
3199
  };
@@ -3133,7 +3201,7 @@ function heardleScreen(sh, pausedMusic) {
3133
3201
  guesses = [...guesses, guess];
3134
3202
  status = correct ? "won" : guesses.length >= ladder.length ? "lost" : "playing";
3135
3203
  input = "";
3136
- pick = 0;
3204
+ pick2 = 0;
3137
3205
  if (status !== "playing") clip2.stop();
3138
3206
  save();
3139
3207
  };
@@ -3144,7 +3212,7 @@ function heardleScreen(sh, pausedMusic) {
3144
3212
  return;
3145
3213
  }
3146
3214
  const list = suggestions();
3147
- const song = list[Math.min(pick, list.length - 1)];
3215
+ const song = list[Math.min(pick2, list.length - 1)];
3148
3216
  if (!song) return;
3149
3217
  const correct = isCorrectGuess(song, answer, versions);
3150
3218
  record({
@@ -3186,7 +3254,7 @@ function heardleScreen(sh, pausedMusic) {
3186
3254
  suggestions().forEach((song, i) => {
3187
3255
  const alias = normalizeTitle(song.name).includes(normalizeTitle(input)) ? "" : " \xB7 alias";
3188
3256
  const text = `${song.name}${song.era ? ` (${song.era})` : ""}${alias}`;
3189
- lines.push(i === pick ? color.cyan(`\u203A ${text}`) : color.dim(` ${text}`));
3257
+ lines.push(i === pick2 ? color.cyan(`\u203A ${text}`) : color.dim(` ${text}`));
3190
3258
  });
3191
3259
  }
3192
3260
  s.draw(lines);
@@ -3228,23 +3296,23 @@ function heardleScreen(sh, pausedMusic) {
3228
3296
  } else if (answer) void clip2.play(streamUrl(answer.path), startAt, unlocked());
3229
3297
  return;
3230
3298
  }
3231
- if (key.name === "down") pick = Math.min(Math.max(0, suggestions().length - 1), pick + 1);
3232
- else if (key.name === "up") pick = Math.max(0, pick - 1);
3299
+ if (key.name === "down") pick2 = Math.min(Math.max(0, suggestions().length - 1), pick2 + 1);
3300
+ else if (key.name === "up") pick2 = Math.max(0, pick2 - 1);
3233
3301
  else if (key.name === "return") {
3234
3302
  if (finished()) {
3235
3303
  if (pool) deal(pool);
3236
3304
  } else submit();
3237
3305
  } else if (key.name === "backspace") {
3238
3306
  input = input.slice(0, -1);
3239
- pick = 0;
3307
+ pick2 = 0;
3240
3308
  } else if (key.ctrl && key.name === "u") {
3241
3309
  input = "";
3242
- pick = 0;
3310
+ pick2 = 0;
3243
3311
  } else {
3244
3312
  const ch = typed(str, key);
3245
3313
  if (ch && !finished() && input.length < 80) {
3246
3314
  input += ch;
3247
- pick = 0;
3315
+ pick2 = 0;
3248
3316
  }
3249
3317
  }
3250
3318
  render();
@@ -3810,8 +3878,8 @@ async function myPlaylists(fresh = false) {
3810
3878
  }
3811
3879
  async function playlistFromArg(arg) {
3812
3880
  const list = await myPlaylists();
3813
- const num = /^#?(\d+)$/.exec(arg.trim());
3814
- if (num) return list[Number(num[1]) - 1] ?? fail(`pick a playlist from 1 to ${list.length}`);
3881
+ const num2 = /^#?(\d+)$/.exec(arg.trim());
3882
+ if (num2) return list[Number(num2[1]) - 1] ?? fail(`pick a playlist from 1 to ${list.length}`);
3815
3883
  return pickByName(list, (p) => p.name, arg) ?? fail(`no single playlist matches "${arg.trim()}" (try: playlists)`);
3816
3884
  }
3817
3885
  async function playlistSongs(id) {
@@ -4492,6 +4560,221 @@ var UPDATE_COMMANDS = [
4492
4560
  }
4493
4561
  ];
4494
4562
 
4563
+ // src/cdn.ts
4564
+ var DEFAULT_TRUST = 100;
4565
+ var VIOLATION_LIMIT = 5;
4566
+ var ACTIONS = ["approve", "revoke", "enable", "disable", "reset", "restore", "delete"];
4567
+ var BASE2 = "/cdn/admin";
4568
+ var listNodes = async () => (await apiFetch(`${BASE2}/nodes/`))?.nodes ?? [];
4569
+ var nodePath = (n) => `${BASE2}/nodes/${encodeURIComponent(n.node_id)}/`;
4570
+ var patch = (n, body) => apiFetch(nodePath(n), {}, { method: "PATCH", body });
4571
+ var autoDisabled = (n) => n.trust_score <= 0 || n.hash_violations >= VIOLATION_LIMIT;
4572
+ var mbps = (v) => v ? `${Math.round(v * 10) / 10} Mbps` : "\u2014";
4573
+ function state(n) {
4574
+ if (!n.is_approved) return autoDisabled(n) ? "auto-disabled" : "pending";
4575
+ if (!n.is_active) return "disabled";
4576
+ return n.online ? "online" : "offline";
4577
+ }
4578
+ var tone = (s) => s === "online" ? color.green(s) : s === "offline" ? color.dim(s) : color.red(s);
4579
+ function pick(nodes, arg) {
4580
+ const q = arg.trim().replace(/^#/, "").toLowerCase();
4581
+ if (!q) fail("say which node (see: cdn)");
4582
+ const exact = nodes.filter((n) => n.node_id.toLowerCase() === q || n.name.toLowerCase() === q);
4583
+ const hits = exact.length ? exact : nodes.filter((n) => n.node_id.toLowerCase().startsWith(q) || n.name.toLowerCase().includes(q));
4584
+ if (hits.length === 0) fail(`no node matches "${arg.trim()}" (see: cdn)`);
4585
+ if (hits.length > 1) fail(`"${arg.trim()}" matches ${hits.length} nodes: ${hits.slice(0, 5).map((n) => n.name || n.node_id.slice(0, 8)).join(", ")}`);
4586
+ return hits[0];
4587
+ }
4588
+ function describeNode(n, latest) {
4589
+ const pct = n.max_storage_bytes > 0 ? ` of ${formatBytes(n.max_storage_bytes)} (${Math.min(100, Math.round(n.current_storage_bytes / n.max_storage_bytes * 100))}%)` : "";
4590
+ const newest = n.manifest_version ?? latest;
4591
+ const synced = n.synced_manifest_version === void 0 ? "" : `
4592
+ synced manifest ${n.synced_manifest_version == null ? "never" : `v${n.synced_manifest_version}`}${newest != null ? ` (latest v${newest})` : ""}`;
4593
+ const notes = [];
4594
+ if (!n.is_approved && autoDisabled(n)) notes.push("pulled for hash violations - restore it to reset trust and violations too");
4595
+ else if (!n.is_approved) notes.push("waiting for approval - it serves nothing until approved");
4596
+ else if (!n.is_active) notes.push("switched off by an admin");
4597
+ return [
4598
+ `${color.bold(n.name || n.node_id)} ${tone(state(n))}`,
4599
+ `node id ${n.node_id}`,
4600
+ `owner ${n.owner_username || "unclaimed"}`,
4601
+ `region ${n.region || "\u2014"} \xB7 ${n.is_public ? "public" : "private"}`,
4602
+ `address ${n.ip_address ? n.port ? `${n.ip_address}:${n.port}` : n.ip_address : "\u2014"}${n.public_base_url ? ` ${n.public_base_url}` : ""}`,
4603
+ `storage ${formatBytes(n.current_storage_bytes)}${pct} \xB7 ${n.file_count.toLocaleString()} files`,
4604
+ `speed up ${mbps(n.upload_speed_mbps)} \xB7 down ${mbps(n.download_speed_mbps)} \xB7 to listeners ${mbps(n.observed_download_speed_mbps)}`,
4605
+ `served ${formatBytes(n.total_bytes_served)} in ${n.total_requests.toLocaleString()} requests`,
4606
+ `trust ${Math.round(n.trust_score)} \xB7 ${plural(n.hash_violations, "violation")}`,
4607
+ `heartbeat ${relativeTime(n.last_heartbeat)} \xB7 registered ${new Date(n.created_at).toLocaleDateString()}${synced}`,
4608
+ ...notes.map((t) => color.yellow(`! ${t}`))
4609
+ ].join("\n");
4610
+ }
4611
+ async function act(action, n, sh, yes) {
4612
+ const name = n.name || n.node_id;
4613
+ switch (action) {
4614
+ case "approve":
4615
+ if (n.is_approved) fail(`${name} is already approved`);
4616
+ if (autoDisabled(n)) fail(`${name} was pulled for hash violations - use: cdn restore ${name}`);
4617
+ await patch(n, { is_approved: true });
4618
+ break;
4619
+ case "revoke":
4620
+ if (!n.is_approved) fail(`${name} isn't approved`);
4621
+ await patch(n, { is_approved: false });
4622
+ break;
4623
+ case "enable":
4624
+ if (n.is_active) fail(`${name} is already enabled`);
4625
+ if (autoDisabled(n)) fail(`${name} was pulled for hash violations - use: cdn restore ${name}`);
4626
+ await patch(n, { is_active: true });
4627
+ break;
4628
+ case "disable":
4629
+ if (!n.is_active) fail(`${name} is already disabled`);
4630
+ await patch(n, { is_active: false });
4631
+ break;
4632
+ case "reset":
4633
+ if (n.hash_violations === 0 && n.trust_score >= DEFAULT_TRUST) fail(`${name} has nothing to reset`);
4634
+ await patch(n, { trust_score: DEFAULT_TRUST, hash_violations: 0 });
4635
+ break;
4636
+ case "restore":
4637
+ await patch(n, { is_approved: true, is_active: true, trust_score: DEFAULT_TRUST, hash_violations: 0 });
4638
+ break;
4639
+ case "delete":
4640
+ if (!await confirm(sh, `Permanently delete ${name}? Its file list, history and API key go with it.`, yes, "cdn")) return;
4641
+ await apiFetch(nodePath(n), {}, { method: "DELETE" });
4642
+ sh.print(`deleted ${name}`, "ok");
4643
+ return;
4644
+ }
4645
+ sh.print(`${action} ${name}: done`, "ok");
4646
+ }
4647
+ var CDN_COMMANDS = [
4648
+ {
4649
+ name: "cdn",
4650
+ group: "Admin",
4651
+ usage: "cdn [<node>] \xB7 cdn approve|revoke|enable|disable|reset|restore|delete [-y] <node>",
4652
+ description: "The CDN node roster and stats; one node in full; or approve, revoke, enable, disable, reset trust, restore or delete a node (a node is its id, the start of it, or its name)",
4653
+ complete: kindComplete(ACTIONS),
4654
+ run: admin(async (args, sh) => {
4655
+ const words = args.trim().split(/\s+/).filter(Boolean);
4656
+ const first = words[0]?.toLowerCase();
4657
+ if (first && ACTIONS.includes(first)) {
4658
+ words.shift();
4659
+ const yes = words[0] === "-y" ? (words.shift(), true) : false;
4660
+ const nodes2 = await listNodes();
4661
+ await act(first, pick(nodes2, words.join(" ")), sh, yes);
4662
+ return;
4663
+ }
4664
+ const [stats, nodes] = await Promise.all([apiFetch(`${BASE2}/stats/`), listNodes()]);
4665
+ if (words.length) {
4666
+ sh.print(describeNode(pick(nodes, words.join(" ")), stats.manifest_version));
4667
+ return;
4668
+ }
4669
+ const lines = [
4670
+ `${plural(stats.total_nodes, "node")} \xB7 ${stats.online_nodes} online \xB7 ${stats.pending_nodes} awaiting approval \xB7 ${formatBytes(stats.total_bytes_served)} served in ${stats.total_requests.toLocaleString()} requests`,
4671
+ `master copy: ${stats.master_files.toLocaleString()} files, ${formatBytes(stats.master_bytes)} \xB7 manifest v${stats.manifest_version}`
4672
+ ];
4673
+ if (nodes.length === 0) {
4674
+ sh.print([...lines, "", color.dim("no nodes registered")].join("\n"));
4675
+ return;
4676
+ }
4677
+ const order = ["pending", "auto-disabled", "online", "offline", "disabled"];
4678
+ const sorted = [...nodes].sort((a, b) => order.indexOf(state(a)) - order.indexOf(state(b)) || (a.name || a.node_id).localeCompare(b.name || b.node_id));
4679
+ const rows = sorted.map((n) => {
4680
+ const s = state(n);
4681
+ return `${(n.name || n.node_id.slice(0, 8)).slice(0, 24).padEnd(26)}${tone(s)}${" ".repeat(Math.max(1, 15 - s.length))}${(n.owner_username || "unclaimed").slice(0, 16).padEnd(18)}${formatBytes(n.current_storage_bytes).padEnd(11)}${formatBytes(n.total_bytes_served).padEnd(11)}trust ${String(Math.round(n.trust_score)).padEnd(5)}${relativeTime(n.last_heartbeat)}`;
4682
+ });
4683
+ sh.print([...lines, "", ...rows].join("\n"));
4684
+ })
4685
+ }
4686
+ ];
4687
+
4688
+ // src/eq.ts
4689
+ var WORDS = ["on", "off", "list", "reset", "reverb", "boost", "balance", "speed", "mono", "pitch", ...EQ_PRESETS.map((p) => p.id)];
4690
+ function describe(speed, pitch) {
4691
+ const e = effects();
4692
+ const preset = EQ_PRESETS.find((p) => p.id === e.preset)?.id ?? e.preset;
4693
+ return `equalizer ${e.eq ? "on" : "off"} \xB7 preset ${preset} \xB7 boost ${Math.round(e.boost * 100)}% \xB7 balance ${Math.round(e.balance * 100)}
4694
+ reverb ${e.reverb ? "on" : "off"} \xB7 mix ${Math.round(e.reverbMix * 100)}% \xB7 decay ${e.reverbDecay}s \xB7 speed ${speed}x \xB7 mono ${e.mono ? "on" : "off"} \xB7 pitch follows speed ${pitch ? "on" : "off"}`;
4695
+ }
4696
+ var onOff = (v, cur) => v === "on" ? true : v === "off" ? false : v === void 0 ? !cur : fail("expected on or off");
4697
+ function numIn(v, lo, hi, what) {
4698
+ const n = Number(v);
4699
+ if (v === void 0 || v === "" || !Number.isFinite(n) || n < lo || n > hi) fail(`${what} must be ${lo} to ${hi}`);
4700
+ return n;
4701
+ }
4702
+ var EQ_COMMANDS = [
4703
+ {
4704
+ name: "eq",
4705
+ group: "Player",
4706
+ usage: "eq [on | off | list | reset | <preset> | reverb [on|off|<0-100>] [decay 1-8] | boost <100-200> | balance <-100..100> | speed <0.5-2> | mono [on|off] | pitch [on|off]]",
4707
+ description: "Show or change the equalizer, reverb and sound effects; a preset name turns it on with that preset",
4708
+ complete: async (arg) => /\s/.test(arg.trim()) ? [] : WORDS.filter((w) => w.startsWith(arg.trim().toLowerCase())),
4709
+ run: async (args, sh) => {
4710
+ const player = playerOf(sh);
4711
+ const arg = args.trim().toLowerCase();
4712
+ if (!arg) {
4713
+ sh.print(describe(player.speed, player.pitchShift));
4714
+ return;
4715
+ }
4716
+ const [sub, ...rest] = arg.split(/\s+/);
4717
+ const apply = async (message) => {
4718
+ await player.setFilter(filterChain());
4719
+ sh.print(message, "ok");
4720
+ };
4721
+ if (arg === "list") {
4722
+ sh.print(EQ_PRESETS.map((p) => `${p.id.padEnd(16)}${p.name}`).join("\n"));
4723
+ return;
4724
+ }
4725
+ if (sub === "reverb") {
4726
+ const [a, b, c] = rest;
4727
+ const mix = a === void 0 ? void 0 : a === "on" || a === "off" ? void 0 : numIn(a, 0, 100, "reverb amount") / 100;
4728
+ const decayArg = b === "decay" ? c : b;
4729
+ const decay = decayArg === void 0 ? void 0 : numIn(decayArg, 1, 8, "reverb decay");
4730
+ const on = a === void 0 ? !effects().reverb : a === "off" ? false : true;
4731
+ if (mix !== void 0) saveEffect("reverbMix", mix);
4732
+ if (decay !== void 0) saveEffect("reverbDecay", decay);
4733
+ saveEffect("reverb", on);
4734
+ const e = effects();
4735
+ await apply(`reverb ${e.reverb ? "on" : "off"} \xB7 mix ${Math.round(e.reverbMix * 100)}% \xB7 decay ${e.reverbDecay}s`);
4736
+ return;
4737
+ }
4738
+ if (sub === "boost") {
4739
+ saveEffect("boost", numIn(rest[0], 100, 200, "boost") / 100);
4740
+ await apply(describe(player.speed, player.pitchShift));
4741
+ return;
4742
+ }
4743
+ if (sub === "balance") {
4744
+ saveEffect("balance", numIn(rest[0], -100, 100, "balance") / 100);
4745
+ await apply(describe(player.speed, player.pitchShift));
4746
+ return;
4747
+ }
4748
+ if (sub === "speed") {
4749
+ const v = rest[0] === "reset" ? 1 : numIn(rest[0]?.replace(/x$/, ""), 0.5, 2, "speed");
4750
+ await player.setSpeed(Math.round(v * 100) / 100);
4751
+ sh.print(`speed ${player.speed}x`, "ok");
4752
+ return;
4753
+ }
4754
+ if (sub === "pitch") {
4755
+ await player.setPitchShift(onOff(rest[0], player.pitchShift));
4756
+ sh.print(`pitch follows speed: ${player.pitchShift ? "on" : "off"}`, "ok");
4757
+ return;
4758
+ }
4759
+ if (sub === "mono") {
4760
+ saveEffect("mono", onOff(rest[0], effects().mono));
4761
+ await apply(`mono ${effects().mono ? "on" : "off"}`);
4762
+ return;
4763
+ }
4764
+ if (arg === "on" || arg === "off") {
4765
+ saveEffect("eq", arg === "on");
4766
+ await apply(describe(player.speed, player.pitchShift));
4767
+ return;
4768
+ }
4769
+ const preset = arg === "reset" ? EQ_PRESETS[0] : pickByName(EQ_PRESETS, (p) => p.id, arg) ?? pickByName(EQ_PRESETS, (p) => p.name, arg);
4770
+ if (!preset) fail(`no preset "${args.trim()}" (try: eq list)`);
4771
+ saveEffect("preset", preset.id);
4772
+ saveEffect("eq", true);
4773
+ await apply(describe(player.speed, player.pitchShift));
4774
+ }
4775
+ }
4776
+ ];
4777
+
4495
4778
  // src/karaoke.ts
4496
4779
  var TIME = /\[(\d{1,2}):(\d{2})[.:](\d{2,3})\]/g;
4497
4780
  function parseLrc(lrc) {
@@ -4809,12 +5092,12 @@ function visualizerScreen(sh, binary) {
4809
5092
  const time = track ? `${clock(now)}${track.duration ? ` / ${clock(track.duration)}` : ""}` : "";
4810
5093
  lines.push(`${color.bold(title)} ${color.dim(time)}${status ? ` ${color.dim(status)}` : ""}`, "");
4811
5094
  for (let row = height - 1; row >= 0; row--) {
4812
- const tone = row >= height * 0.85 ? hot : row >= height * 0.55 ? bright : row >= height * 0.25 ? mid : dark;
5095
+ const tone2 = row >= height * 0.85 ? hot : row >= height * 0.55 ? bright : row >= height * 0.25 ? mid : dark;
4813
5096
  let line = " ";
4814
5097
  for (let i = 0; i < bars; i++) {
4815
5098
  const filled = levels[i] * height;
4816
5099
  const cell = Math.max(0, Math.min(8, Math.round((filled - row) * 8)));
4817
- line += cell === 0 ? " " : `${tone}${BLOCKS[cell].repeat(2)}\x1B[0m`;
5100
+ line += cell === 0 ? " " : `${tone2}${BLOCKS[cell].repeat(2)}\x1B[0m`;
4818
5101
  }
4819
5102
  lines.push(line);
4820
5103
  }
@@ -5055,8 +5338,10 @@ ${color.dim(`local ${sh.localDir}`)}`)
5055
5338
  { name: "du", group: "Files", usage: "du [folder]", path: "dir", description: "How much is in each subfolder, and in total", run: fileTool(diskUsage) },
5056
5339
  ...LIBRARY_COMMANDS,
5057
5340
  ...PLAYER_COMMANDS,
5341
+ ...EQ_COMMANDS,
5058
5342
  ...PEOPLE_COMMANDS,
5059
5343
  ...ADMIN_COMMANDS,
5344
+ ...CDN_COMMANDS,
5060
5345
  ...FUN_COMMANDS,
5061
5346
  ...VISUALIZER_COMMANDS,
5062
5347
  ...KARAOKE_COMMANDS,
@@ -5343,11 +5628,11 @@ var Shell = class {
5343
5628
  const user2 = getToken() ? loadConfig().user?.name ?? "user" : "guest";
5344
5629
  return `${color.user(`${user2}@unreleased`)}:${color.blue(filesPathString(this.cwd))}$ `;
5345
5630
  }
5346
- print(text, tone = "plain") {
5347
- if (tone === "error") this.errors++;
5631
+ print(text, tone2 = "plain") {
5632
+ if (tone2 === "error") this.errors++;
5348
5633
  const sink = this.sinks[this.sinks.length - 1];
5349
- if (sink && (tone !== "error" || sink.keepErrors)) sink.lines.push(...text.split("\n"));
5350
- else writeTone(text, tone);
5634
+ if (sink && (tone2 !== "error" || sink.keepErrors)) sink.lines.push(...text.split("\n"));
5635
+ else writeTone(text, tone2);
5351
5636
  }
5352
5637
  /** A progress note ("loading…"): straight to stderr, never into a pipe or
5353
5638
  * a one-shot command's stdout - and not at all over a full screen. */
@@ -5570,17 +5855,17 @@ async function startRepl() {
5570
5855
  const flushHeld = () => {
5571
5856
  for (const n of held.splice(0)) writeTone(n.text, n.tone);
5572
5857
  };
5573
- const notify = (text, tone = "dim") => {
5858
+ const notify = (text, tone2 = "dim") => {
5574
5859
  if (screenActive() || historySearchActive()) {
5575
- held.push({ text, tone });
5860
+ held.push({ text, tone: tone2 });
5576
5861
  return;
5577
5862
  }
5578
5863
  if (running) {
5579
- writeTone(text, tone);
5864
+ writeTone(text, tone2);
5580
5865
  return;
5581
5866
  }
5582
5867
  process.stdout.write("\r\x1B[2K");
5583
- writeTone(text, tone);
5868
+ writeTone(text, tone2);
5584
5869
  rl.prompt(true);
5585
5870
  };
5586
5871
  shell.player = new Player(notify);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unreleased-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "The Unreleased site terminal as a command-line shell",
5
5
  "keywords": [
6
6
  "juice-wrld",