deadman-fm 0.1.1

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.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * deadman create — Create a station and upload audio files
3
+ *
4
+ * 1. Scans the music folder for audio files (mp3, wav, flac, ogg, m4a)
5
+ * 2. Creates a Hypercore feed
6
+ * 3. Transcodes each file → MPEG-TS segments → encrypts → appends to feed
7
+ * 4. Joins Hyperswarm to start seeding
8
+ * 5. If --watch: monitors folder for new files
9
+ */
10
+ interface CreateOptions {
11
+ name: string;
12
+ genre: string;
13
+ music: string;
14
+ watch?: boolean;
15
+ }
16
+ export declare function createStation(opts: CreateOptions): Promise<void>;
17
+ export {};
@@ -0,0 +1,221 @@
1
+ /**
2
+ * deadman create — Create a station and upload audio files
3
+ *
4
+ * 1. Scans the music folder for audio files (mp3, wav, flac, ogg, m4a)
5
+ * 2. Creates a Hypercore feed
6
+ * 3. Transcodes each file → MPEG-TS segments → encrypts → appends to feed
7
+ * 4. Joins Hyperswarm to start seeding
8
+ * 5. If --watch: monitors folder for new files
9
+ */
10
+ import { readdirSync, statSync } from "node:fs";
11
+ import { join, extname } from "node:path";
12
+ import { spawn } from "node:child_process";
13
+ import { createCipheriv, randomBytes } from "node:crypto";
14
+ import { homedir } from "node:os";
15
+ import { mkdirSync, writeFileSync } from "node:fs";
16
+ import Hypercore from "hypercore";
17
+ import Hyperswarm from "hyperswarm";
18
+ const AUDIO_EXTS = new Set([
19
+ ".mp3",
20
+ ".wav",
21
+ ".flac",
22
+ ".ogg",
23
+ ".m4a",
24
+ ".aac",
25
+ ".opus",
26
+ ]);
27
+ const SEGMENT_BYTES = 96_000;
28
+ const ALGO = "chacha20-poly1305";
29
+ const KEY_BYTES = 32;
30
+ const NONCE_BYTES = 12;
31
+ const TAG_BYTES = 16;
32
+ export async function createStation(opts) {
33
+ const musicPath = opts.music.replace("~", homedir());
34
+ console.log(`💀 Creating station: ${opts.name}`);
35
+ console.log(` Genre: ${opts.genre}`);
36
+ console.log(` Music: ${musicPath}`);
37
+ // Find audio files
38
+ const files = scanForAudio(musicPath);
39
+ if (files.length === 0) {
40
+ console.error("❌ No audio files found in", musicPath);
41
+ process.exit(1);
42
+ }
43
+ console.log(` Found ${files.length} audio file(s)`);
44
+ // Create storage
45
+ const stationDir = join(homedir(), ".deadman-fm", "stations", slugify(opts.name));
46
+ mkdirSync(stationDir, { recursive: true });
47
+ // Generate station key
48
+ const stationKey = randomBytes(KEY_BYTES);
49
+ writeFileSync(join(stationDir, "station.key"), stationKey.toString("hex"));
50
+ console.log(` Key: ${stationKey.toString("hex").slice(0, 16)}...`);
51
+ // Create Hypercore feed
52
+ const feedPath = join(stationDir, "feed");
53
+ const core = new Hypercore(feedPath);
54
+ await core.ready();
55
+ console.log(` Feed: ${core.key.toString("hex").slice(0, 16)}...`);
56
+ // Transcode and ingest each file
57
+ let totalSegments = 0;
58
+ for (const file of files) {
59
+ const pretty = prettifyPath(file);
60
+ const label = [pretty.artist, pretty.album, pretty.title]
61
+ .filter(Boolean)
62
+ .join(" — ");
63
+ console.log(` 📀 ${label}`);
64
+ const segments = await transcodeFile(join(musicPath, file), core, stationKey, totalSegments);
65
+ totalSegments += segments;
66
+ console.log(` → ${segments} segments`);
67
+ }
68
+ console.log(`\n Total: ${totalSegments} segments (~${Math.round(totalSegments * 6)}s of audio)`);
69
+ // Save station metadata
70
+ const meta = {
71
+ name: opts.name,
72
+ genre: opts.genre,
73
+ feedKey: core.key.toString("hex"),
74
+ totalSegments,
75
+ createdAt: new Date().toISOString(),
76
+ };
77
+ writeFileSync(join(stationDir, "station.json"), JSON.stringify(meta, null, 2));
78
+ // Join swarm
79
+ const swarm = new Hyperswarm();
80
+ swarm.on("connection", (socket) => {
81
+ console.log(" 🔗 Peer connected");
82
+ core.replicate(socket);
83
+ });
84
+ const topic = core.discoveryKey;
85
+ swarm.join(topic, { server: true, client: true });
86
+ await swarm.flush();
87
+ console.log(`\n💀 Station "${opts.name}" is live and seeding`);
88
+ console.log(` Feed key: ${core.key.toString("hex")}`);
89
+ console.log(` ${totalSegments} segments on the network`);
90
+ console.log(`\n Press Ctrl+C to stop seeding`);
91
+ // Watch mode
92
+ if (opts.watch) {
93
+ console.log(` 👁 Watching ${musicPath} for new files...`);
94
+ const { watch } = await import("chokidar");
95
+ const watcher = watch(musicPath, {
96
+ ignoreInitial: true,
97
+ awaitWriteFinish: { stabilityThreshold: 2000 },
98
+ });
99
+ watcher.on("add", async (filePath) => {
100
+ const ext = extname(filePath).toLowerCase();
101
+ if (!AUDIO_EXTS.has(ext))
102
+ return;
103
+ console.log(` 📀 New file: ${filePath}`);
104
+ const segments = await transcodeFile(filePath, core, stationKey, totalSegments);
105
+ totalSegments += segments;
106
+ meta.totalSegments = totalSegments;
107
+ writeFileSync(join(stationDir, "station.json"), JSON.stringify(meta, null, 2));
108
+ console.log(` → ${segments} segments (total: ${totalSegments})`);
109
+ });
110
+ }
111
+ // Keep alive
112
+ await new Promise(() => { });
113
+ }
114
+ function scanForAudio(dir) {
115
+ const results = [];
116
+ function walk(d, prefix) {
117
+ try {
118
+ for (const entry of readdirSync(d)) {
119
+ const full = join(d, entry);
120
+ try {
121
+ const s = statSync(full);
122
+ if (s.isDirectory()) {
123
+ walk(full, prefix ? `${prefix}/${entry}` : entry);
124
+ }
125
+ else if (s.isFile() &&
126
+ AUDIO_EXTS.has(extname(entry).toLowerCase())) {
127
+ results.push(prefix ? `${prefix}/${entry}` : entry);
128
+ }
129
+ }
130
+ catch { }
131
+ }
132
+ }
133
+ catch { }
134
+ }
135
+ walk(dir, "");
136
+ return results.sort();
137
+ }
138
+ function prettifyName(raw) {
139
+ return raw
140
+ .replace(/\.[^.]+$/, "") // remove extension
141
+ .replace(/[_-]+/g, " ") // underscores/hyphens → spaces
142
+ .replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase → spaces
143
+ .replace(/#\d+$/, "") // remove trailing #01 etc
144
+ .replace(/\s+/g, " ") // collapse whitespace
145
+ .trim()
146
+ .replace(/\b\w/g, (c) => c.toUpperCase()); // title case
147
+ }
148
+ function prettifyPath(filePath) {
149
+ const parts = filePath.split("/");
150
+ // Root folder is ignored (that's just ~/Music or whatever they picked)
151
+ // Structure: Artist/Album/Song.mp3
152
+ if (parts.length >= 3) {
153
+ return {
154
+ artist: prettifyName(parts[parts.length - 3]),
155
+ album: prettifyName(parts[parts.length - 2]),
156
+ title: prettifyName(parts[parts.length - 1]),
157
+ };
158
+ }
159
+ if (parts.length === 2) {
160
+ return {
161
+ artist: prettifyName(parts[0]),
162
+ album: "",
163
+ title: prettifyName(parts[1]),
164
+ };
165
+ }
166
+ return { artist: "", album: "", title: prettifyName(parts[0]) };
167
+ }
168
+ function slugify(s) {
169
+ return s
170
+ .toLowerCase()
171
+ .replace(/[^a-z0-9]+/g, "-")
172
+ .replace(/(^-|-$)/g, "");
173
+ }
174
+ function encryptSegment(key, plaintext, segIndex) {
175
+ const nonce = Buffer.alloc(NONCE_BYTES);
176
+ nonce.writeUInt32BE(segIndex, NONCE_BYTES - 4);
177
+ const cipher = createCipheriv(ALGO, key, nonce, {
178
+ authTagLength: TAG_BYTES,
179
+ });
180
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
181
+ const tag = cipher.getAuthTag();
182
+ return Buffer.concat([nonce, encrypted, tag]);
183
+ }
184
+ function transcodeFile(filePath, core, stationKey, startIndex) {
185
+ return new Promise((resolve, reject) => {
186
+ const ffmpeg = spawn("ffmpeg", ["-i", filePath, "-c:a", "aac", "-b:a", "128k", "-f", "mpegts", "pipe:1"], { stdio: ["pipe", "pipe", "pipe"] });
187
+ let segmentBuffer = [];
188
+ let accumulated = 0;
189
+ let segmentCount = 0;
190
+ const appendQueue = [];
191
+ ffmpeg.stdout.on("data", (chunk) => {
192
+ segmentBuffer.push(chunk);
193
+ accumulated += chunk.length;
194
+ if (accumulated >= SEGMENT_BYTES) {
195
+ const plaintext = Buffer.concat(segmentBuffer);
196
+ segmentBuffer = [];
197
+ accumulated = 0;
198
+ const idx = startIndex + segmentCount++;
199
+ const encrypted = encryptSegment(stationKey, plaintext, idx);
200
+ appendQueue.push(core.append(encrypted).then(() => { }));
201
+ }
202
+ });
203
+ ffmpeg.stdout.on("end", async () => {
204
+ // Flush remaining
205
+ if (segmentBuffer.length > 0) {
206
+ const plaintext = Buffer.concat(segmentBuffer);
207
+ const idx = startIndex + segmentCount++;
208
+ const encrypted = encryptSegment(stationKey, plaintext, idx);
209
+ appendQueue.push(core.append(encrypted).then(() => { }));
210
+ }
211
+ await Promise.all(appendQueue);
212
+ resolve(segmentCount);
213
+ });
214
+ ffmpeg.stderr.on("data", () => { }); // suppress ffmpeg output
215
+ ffmpeg.on("error", reject);
216
+ ffmpeg.on("close", (code) => {
217
+ if (code !== 0 && code !== null)
218
+ reject(new Error(`ffmpeg exit ${code}`));
219
+ });
220
+ });
221
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * deadman — Interactive menu mode
3
+ *
4
+ * The hero experience. Run `deadman` with no args.
5
+ * Walks through everything: create, sync, connect, list, status.
6
+ *
7
+ * Design principles:
8
+ * - No flags to memorize
9
+ * - Arrow-key navigation via @clack/prompts
10
+ * - Every step confirms before acting
11
+ * - Progress visible at all times
12
+ * - Errors explain what to do next
13
+ */
14
+ export declare function loadApiKey(): Promise<string | null>;
15
+ export declare function saveApiKey(key: string): Promise<void>;
16
+ export declare function interactive(): Promise<void>;
@@ -0,0 +1,636 @@
1
+ /**
2
+ * deadman — Interactive menu mode
3
+ *
4
+ * The hero experience. Run `deadman` with no args.
5
+ * Walks through everything: create, sync, connect, list, status.
6
+ *
7
+ * Design principles:
8
+ * - No flags to memorize
9
+ * - Arrow-key navigation via @clack/prompts
10
+ * - Every step confirms before acting
11
+ * - Progress visible at all times
12
+ * - Errors explain what to do next
13
+ */
14
+ import * as p from "@clack/prompts";
15
+ import { isCancel } from "@clack/prompts";
16
+ import { readdirSync, statSync, existsSync, readFileSync } from "node:fs";
17
+ import { join, extname, basename } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { execSync } from "node:child_process";
20
+ // ---------------------------------------------------------------------------
21
+ // Audio file scanner (mirrors logic in create.ts without the transcode step)
22
+ // ---------------------------------------------------------------------------
23
+ const AUDIO_EXTS = new Set([
24
+ ".mp3",
25
+ ".wav",
26
+ ".flac",
27
+ ".ogg",
28
+ ".m4a",
29
+ ".aac",
30
+ ".opus",
31
+ ]);
32
+ function scanMusicFolder(dir) {
33
+ const results = [];
34
+ function walk(d, relPrefix) {
35
+ try {
36
+ for (const entry of readdirSync(d)) {
37
+ const full = join(d, entry);
38
+ try {
39
+ const s = statSync(full);
40
+ const rel = relPrefix ? `${relPrefix}/${entry}` : entry;
41
+ if (s.isDirectory()) {
42
+ walk(full, rel);
43
+ }
44
+ else if (s.isFile() &&
45
+ AUDIO_EXTS.has(extname(entry).toLowerCase())) {
46
+ results.push({ relativePath: rel, ...parsePath(rel) });
47
+ }
48
+ }
49
+ catch {
50
+ // skip unreadable entries
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // skip unreadable dirs
56
+ }
57
+ }
58
+ walk(dir, "");
59
+ return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
60
+ }
61
+ function prettifyName(raw) {
62
+ return raw
63
+ .replace(/\.[^.]+$/, "")
64
+ .replace(/[_-]+/g, " ")
65
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
66
+ .replace(/#\d+$/, "")
67
+ .replace(/\s+/g, " ")
68
+ .trim()
69
+ .replace(/\b\w/g, (ch) => ch.toUpperCase());
70
+ }
71
+ function parsePath(filePath) {
72
+ const parts = filePath.split("/");
73
+ if (parts.length >= 3) {
74
+ return {
75
+ artist: prettifyName(parts[parts.length - 3]),
76
+ album: prettifyName(parts[parts.length - 2]),
77
+ title: prettifyName(parts[parts.length - 1]),
78
+ };
79
+ }
80
+ if (parts.length === 2) {
81
+ return {
82
+ artist: prettifyName(parts[0]),
83
+ album: "",
84
+ title: prettifyName(parts[1]),
85
+ };
86
+ }
87
+ return { artist: "", album: "", title: prettifyName(parts[0]) };
88
+ }
89
+ function loadLocalStations() {
90
+ const stationsDir = join(homedir(), ".deadman-fm", "stations");
91
+ if (!existsSync(stationsDir))
92
+ return [];
93
+ const results = [];
94
+ try {
95
+ for (const slug of readdirSync(stationsDir)) {
96
+ const metaPath = join(stationsDir, slug, "station.json");
97
+ if (existsSync(metaPath)) {
98
+ try {
99
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
100
+ results.push({ slug, meta });
101
+ }
102
+ catch {
103
+ // skip corrupt
104
+ }
105
+ }
106
+ }
107
+ }
108
+ catch {
109
+ // no stations dir
110
+ }
111
+ return results;
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Keychain helpers
115
+ // ---------------------------------------------------------------------------
116
+ export async function loadApiKey() {
117
+ const { platform } = await import("node:os");
118
+ const plat = platform();
119
+ if (plat === "darwin") {
120
+ try {
121
+ const key = execSync('security find-generic-password -a "deadman-fm" -s "openhome-api-key" -w 2>/dev/null', { encoding: "utf8" }).trim();
122
+ if (key)
123
+ return key;
124
+ }
125
+ catch {
126
+ // not found
127
+ }
128
+ }
129
+ if (plat === "linux") {
130
+ try {
131
+ const key = execSync("secret-tool lookup service deadman-fm key openhome-api-key 2>/dev/null", { encoding: "utf8" }).trim();
132
+ if (key)
133
+ return key;
134
+ }
135
+ catch {
136
+ // not found
137
+ }
138
+ }
139
+ const configPath = join(homedir(), ".deadman-fm", "config.json");
140
+ if (existsSync(configPath)) {
141
+ try {
142
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
143
+ if (config.apiKey)
144
+ return config.apiKey;
145
+ }
146
+ catch {
147
+ // corrupt config
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ export async function saveApiKey(key) {
153
+ const { platform } = await import("node:os");
154
+ const { mkdirSync, writeFileSync, chmodSync } = await import("node:fs");
155
+ const plat = platform();
156
+ if (plat === "darwin") {
157
+ try {
158
+ try {
159
+ execSync('security delete-generic-password -a "deadman-fm" -s "openhome-api-key" 2>/dev/null');
160
+ }
161
+ catch {
162
+ // already absent
163
+ }
164
+ execSync(`security add-generic-password -a "deadman-fm" -s "openhome-api-key" -w "${key}" -U`);
165
+ return;
166
+ }
167
+ catch {
168
+ // fall through to file
169
+ }
170
+ }
171
+ if (plat === "linux") {
172
+ try {
173
+ execSync(`echo -n "${key}" | secret-tool store --label "deadman.fm" service deadman-fm key openhome-api-key 2>/dev/null`);
174
+ return;
175
+ }
176
+ catch {
177
+ // fall through to file
178
+ }
179
+ }
180
+ const configDir = join(homedir(), ".deadman-fm");
181
+ const configPath = join(configDir, "config.json");
182
+ try {
183
+ mkdirSync(configDir, { recursive: true });
184
+ writeFileSync(configPath, JSON.stringify({ apiKey: key }, null, 2));
185
+ try {
186
+ chmodSync(configPath, 0o600);
187
+ }
188
+ catch {
189
+ // permissions not critical
190
+ }
191
+ p.log.info("Saved to ~/.deadman-fm/config.json");
192
+ }
193
+ catch {
194
+ p.log.warn("Could not save key. You will need to enter it next time.");
195
+ }
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // Status checks
199
+ // ---------------------------------------------------------------------------
200
+ function checkRelay() {
201
+ try {
202
+ execSync("curl -sf http://localhost:4040/health --max-time 1 > /dev/null 2>&1");
203
+ return true;
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
209
+ function checkLocalLink() {
210
+ try {
211
+ const out = execSync("pgrep -f 'deadman' 2>/dev/null || true", {
212
+ encoding: "utf8",
213
+ }).trim();
214
+ return out.split("\n").filter(Boolean).length > 1;
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
220
+ function loadSavedWallet() {
221
+ const configPath = join(homedir(), ".deadman-fm", "config.json");
222
+ if (existsSync(configPath)) {
223
+ try {
224
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
225
+ return config.walletAddress ?? null;
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ }
231
+ return null;
232
+ }
233
+ // ---------------------------------------------------------------------------
234
+ // Cancel guard — exits gracefully on Ctrl+C
235
+ // ---------------------------------------------------------------------------
236
+ function guardCancel(value) {
237
+ if (isCancel(value)) {
238
+ p.cancel("Cancelled.");
239
+ process.exit(0);
240
+ }
241
+ }
242
+ // ---------------------------------------------------------------------------
243
+ // Main interactive entry point
244
+ // ---------------------------------------------------------------------------
245
+ export async function interactive() {
246
+ p.intro("💀 deadman.fm — Radio that plays forever. Even after we're gone.");
247
+ while (true) {
248
+ const choice = await p.select({
249
+ message: "What do you want to do?",
250
+ options: [
251
+ {
252
+ value: "create",
253
+ label: "Create a station",
254
+ hint: "pick genre, sync music",
255
+ },
256
+ {
257
+ value: "sync",
258
+ label: "Sync music",
259
+ hint: "upload tracks to your station",
260
+ },
261
+ {
262
+ value: "connect",
263
+ label: "Connect speaker",
264
+ hint: "link your OpenHome device",
265
+ },
266
+ { value: "list", label: "List stations", hint: "browse the network" },
267
+ {
268
+ value: "status",
269
+ label: "Check status",
270
+ hint: "relay, speaker, wallet",
271
+ },
272
+ { value: "exit", label: "Exit" },
273
+ ],
274
+ });
275
+ if (isCancel(choice) || choice === "exit") {
276
+ p.outro("Signing off.");
277
+ process.exit(0);
278
+ }
279
+ switch (choice) {
280
+ case "create":
281
+ await handleCreate();
282
+ break;
283
+ case "sync":
284
+ await handleSync();
285
+ break;
286
+ case "connect":
287
+ await handleConnect();
288
+ break;
289
+ case "list":
290
+ await handleList();
291
+ break;
292
+ case "status":
293
+ await handleStatus();
294
+ break;
295
+ }
296
+ }
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // Option 1 — Create a station
300
+ // ---------------------------------------------------------------------------
301
+ async function handleCreate() {
302
+ // Station name
303
+ const rawName = await p.text({
304
+ message: "Station name",
305
+ placeholder: "My Pirate Radio",
306
+ validate: (val) => {
307
+ if (!val || !val.trim())
308
+ return "Station name is required.";
309
+ },
310
+ });
311
+ guardCancel(rawName);
312
+ const stationName = rawName.trim();
313
+ // Genre picker
314
+ const genre = await p.select({
315
+ message: "Genre",
316
+ options: [
317
+ { value: "Talk", label: "Talk" },
318
+ { value: "Music", label: "Music" },
319
+ { value: "Live DJ", label: "Live DJ" },
320
+ { value: "Other", label: "Other" },
321
+ ],
322
+ });
323
+ guardCancel(genre);
324
+ // On-chain registration
325
+ p.note(`Your station needs to be registered on Base so others can find it.\nOpening https://deadman.fm/create in your browser...`, "On-chain registration");
326
+ try {
327
+ const opener = process.platform === "darwin"
328
+ ? "open"
329
+ : process.platform === "win32"
330
+ ? "start"
331
+ : "xdg-open";
332
+ execSync(`${opener} "https://deadman.fm/create?name=${encodeURIComponent(stationName)}&genre=${encodeURIComponent(genre)}" 2>/dev/null`);
333
+ }
334
+ catch {
335
+ p.log.warn(`Visit: https://deadman.fm/create\nName: ${stationName} Genre: ${genre}`);
336
+ }
337
+ const stationIdRaw = await p.text({
338
+ message: "Station ID from the page (leave blank to skip)",
339
+ placeholder: "42",
340
+ });
341
+ guardCancel(stationIdRaw);
342
+ const stationIdParsed = stationIdRaw
343
+ ? parseInt(stationIdRaw, 10)
344
+ : NaN;
345
+ const stationId = !isNaN(stationIdParsed) ? stationIdParsed : null;
346
+ if (stationId !== null) {
347
+ p.log.success(`Station #${stationId} created — "${stationName}" (${genre})`);
348
+ }
349
+ else {
350
+ p.log.success(`Station "${stationName}" ready.`);
351
+ p.log.info("Run Sync music once your on-chain tx confirms.");
352
+ }
353
+ // Auto-flow into sync
354
+ const goSync = await p.confirm({
355
+ message: "Sync music to this station now?",
356
+ initialValue: true,
357
+ });
358
+ guardCancel(goSync);
359
+ if (goSync) {
360
+ await handleSyncForStation(stationName, stationId);
361
+ }
362
+ else {
363
+ p.log.info("Pick Sync music any time to upload tracks.");
364
+ }
365
+ }
366
+ // ---------------------------------------------------------------------------
367
+ // Option 2 — Sync music (standalone entry point)
368
+ // ---------------------------------------------------------------------------
369
+ async function handleSync() {
370
+ const stations = loadLocalStations();
371
+ if (stations.length === 0) {
372
+ p.log.warn("No local stations found. Create one first.");
373
+ return;
374
+ }
375
+ let stationName;
376
+ let stationId = null;
377
+ if (stations.length === 1) {
378
+ stationName = stations[0].meta.name;
379
+ stationId = stations[0].meta.stationId ?? null;
380
+ p.log.info(`Using station: ${stationName}`);
381
+ }
382
+ else {
383
+ const selected = await p.select({
384
+ message: "Which station?",
385
+ options: stations.map(({ meta }) => ({
386
+ value: meta.name,
387
+ label: meta.name,
388
+ hint: meta.stationId !== undefined
389
+ ? `#${meta.stationId} · ${meta.genre}`
390
+ : meta.genre,
391
+ })),
392
+ });
393
+ guardCancel(selected);
394
+ const found = stations.find((s) => s.meta.name === selected);
395
+ stationName = found?.meta.name ?? selected;
396
+ stationId = found?.meta.stationId ?? null;
397
+ }
398
+ await handleSyncForStation(stationName, stationId);
399
+ }
400
+ // ---------------------------------------------------------------------------
401
+ // Shared sync logic
402
+ // ---------------------------------------------------------------------------
403
+ async function handleSyncForStation(stationName, stationId) {
404
+ const idLabel = stationId !== null ? `#${stationId}` : `"${stationName}"`;
405
+ const rawPath = await p.text({
406
+ message: "Path to music folder",
407
+ placeholder: "~/Music",
408
+ validate: (val) => {
409
+ if (!val || !val.trim())
410
+ return "Path is required.";
411
+ },
412
+ });
413
+ guardCancel(rawPath);
414
+ const musicPath = rawPath.trim().replace(/^~/, homedir());
415
+ if (!existsSync(musicPath)) {
416
+ p.log.error(`Folder not found: ${musicPath}`);
417
+ return;
418
+ }
419
+ // Scan
420
+ const scanSpinner = p.spinner();
421
+ scanSpinner.start("Scanning folder...");
422
+ const tracks = scanMusicFolder(musicPath);
423
+ scanSpinner.stop(`Found ${tracks.length} track${tracks.length === 1 ? "" : "s"}`);
424
+ if (tracks.length === 0) {
425
+ p.log.warn("No audio files found (mp3, flac, wav, ogg, m4a, aac, opus).");
426
+ return;
427
+ }
428
+ // Summary
429
+ const artistSet = new Set(tracks.map((t) => t.artist).filter(Boolean));
430
+ const artistCount = artistSet.size || 1;
431
+ const MAX_DISPLAY = 20;
432
+ const shown = tracks.slice(0, MAX_DISPLAY);
433
+ const trackLines = shown
434
+ .map((t) => {
435
+ const parts = [t.artist, t.album, t.title].filter(Boolean);
436
+ return parts.length > 0 ? parts.join(" — ") : basename(t.relativePath);
437
+ })
438
+ .join("\n");
439
+ const overflow = tracks.length > MAX_DISPLAY
440
+ ? `\n… and ${tracks.length - MAX_DISPLAY} more`
441
+ : "";
442
+ p.note(`${tracks.length} track${tracks.length === 1 ? "" : "s"} by ${artistCount} artist${artistCount === 1 ? "" : "s"}\n\n${trackLines}${overflow}`, "Tracks found");
443
+ const go = await p.confirm({
444
+ message: `Sync ${tracks.length} tracks to station ${idLabel}?`,
445
+ initialValue: true,
446
+ });
447
+ guardCancel(go);
448
+ if (!go) {
449
+ p.log.info("Cancelled.");
450
+ return;
451
+ }
452
+ const syncSpinner = p.spinner();
453
+ syncSpinner.start(`Syncing ${tracks.length} tracks to ${idLabel}...`);
454
+ const { createStation } = await import("./create.js");
455
+ const stations = loadLocalStations();
456
+ const found = stations.find((s) => s.meta.name === stationName ||
457
+ (stationId !== null && s.meta.stationId === stationId));
458
+ const genre = found?.meta.genre ?? "Electronic";
459
+ const originalLog = console.log;
460
+ let totalSegments = 0;
461
+ let currentTrackIdx = 0;
462
+ console.log = (...args) => {
463
+ const msg = args.map((a) => String(a)).join(" ");
464
+ if (msg.includes("📀")) {
465
+ currentTrackIdx++;
466
+ const pct = Math.round((currentTrackIdx / tracks.length) * 100);
467
+ const label = msg.replace(/\s*📀\s*/, "").trim();
468
+ syncSpinner.message(`[${pct}%] 📀 ${label}`);
469
+ }
470
+ else if (msg.includes("→") && msg.includes("segments")) {
471
+ const match = msg.match(/(\d+)\s+segments/);
472
+ if (match) {
473
+ totalSegments += parseInt(match[1], 10);
474
+ }
475
+ }
476
+ // suppress other createStation output
477
+ };
478
+ try {
479
+ await createStation({
480
+ name: stationName,
481
+ genre,
482
+ music: musicPath,
483
+ watch: false,
484
+ });
485
+ }
486
+ catch (err) {
487
+ console.log = originalLog;
488
+ const message = err instanceof Error ? err.message : String(err);
489
+ syncSpinner.stop("Sync failed.");
490
+ p.log.error(`Error during sync: ${message}`);
491
+ return;
492
+ }
493
+ console.log = originalLog;
494
+ syncSpinner.stop(`Station ${idLabel} is live. ${totalSegments} segments on the network.`);
495
+ p.log.info("Press Ctrl+C to stop seeding.");
496
+ // Keep alive — seeding runs inside createStation's swarm
497
+ await new Promise(() => { });
498
+ }
499
+ // ---------------------------------------------------------------------------
500
+ // Option 3 — Connect to speaker
501
+ // ---------------------------------------------------------------------------
502
+ async function handleConnect() {
503
+ p.note('This links your Mac to your OpenHome speaker over WebSocket.\nOnce connected, say "deadman radio" on your speaker to control stations.', "Connect speaker");
504
+ let apiKey = await loadApiKey();
505
+ if (apiKey) {
506
+ p.log.success("Using saved API key from Keychain.");
507
+ }
508
+ else {
509
+ p.log.info("Get your API key at app.openhome.com → Settings → API Keys");
510
+ const rawKey = await p.text({
511
+ message: "OpenHome API key",
512
+ validate: (val) => {
513
+ if (!val || !val.trim())
514
+ return "API key is required.";
515
+ },
516
+ });
517
+ guardCancel(rawKey);
518
+ apiKey = rawKey.trim();
519
+ const save = await p.confirm({
520
+ message: "Save to Keychain so you don't need to enter it again?",
521
+ initialValue: true,
522
+ });
523
+ guardCancel(save);
524
+ if (save) {
525
+ await saveApiKey(apiKey);
526
+ p.log.success("Saved to Keychain.");
527
+ }
528
+ }
529
+ const connectSpinner = p.spinner();
530
+ connectSpinner.start("Starting Local Link bridge...");
531
+ const { startLocalLink } = await import("./local-link.js");
532
+ connectSpinner.stop('Speaker ↔ Mac bridge active. Say "deadman radio" on your speaker.');
533
+ p.log.info("Press Ctrl+C to stop.");
534
+ await startLocalLink(apiKey);
535
+ }
536
+ // ---------------------------------------------------------------------------
537
+ // Option 4 — List stations on the network
538
+ // ---------------------------------------------------------------------------
539
+ async function handleList() {
540
+ const localStations = loadLocalStations();
541
+ const localNames = new Set(localStations.map((s) => s.meta.name));
542
+ const { createPublicClient, http, parseAbiItem } = await import("viem");
543
+ const { base } = await import("viem/chains");
544
+ const REGISTRY = "0x3b9b6c9BBa15173ba4EF544c17Cb2Ba969d8b585";
545
+ const DEPLOY_BLOCK = 43856547n;
546
+ const client = createPublicClient({
547
+ chain: base,
548
+ transport: http("https://mainnet.base.org"),
549
+ });
550
+ const event = parseAbiItem("event StationCreated(uint256 indexed stationId, address indexed creator, string name, bytes32 genre, bytes32 hypercorePubKey, address curve)");
551
+ const listSpinner = p.spinner();
552
+ listSpinner.start("Fetching stations from Base chain...");
553
+ let logs;
554
+ try {
555
+ const CHUNK = 9500n;
556
+ const currentBlock = await client.getBlockNumber();
557
+ const allLogs = [];
558
+ let from = DEPLOY_BLOCK;
559
+ while (from <= currentBlock) {
560
+ const to = from + CHUNK > currentBlock ? currentBlock : from + CHUNK;
561
+ const chunk = (await client.getLogs({
562
+ address: REGISTRY,
563
+ event,
564
+ fromBlock: from,
565
+ toBlock: to,
566
+ }));
567
+ allLogs.push(...chunk);
568
+ from = to + 1n;
569
+ }
570
+ logs = allLogs;
571
+ }
572
+ catch (err) {
573
+ const message = err instanceof Error ? err.message : String(err);
574
+ listSpinner.stop("Failed.");
575
+ p.log.error(`Could not reach Base chain: ${message}`);
576
+ return;
577
+ }
578
+ listSpinner.stop(`Found ${logs.length} station${logs.length === 1 ? "" : "s"} on Base`);
579
+ if (logs.length === 0) {
580
+ p.log.info("No stations yet. Run Create a station to be the first.");
581
+ return;
582
+ }
583
+ const lines = [];
584
+ for (const log of logs) {
585
+ const args = log.args;
586
+ const id = args.stationId?.toString() ?? "?";
587
+ const name = args.name ?? "Unnamed";
588
+ const creator = args.creator ?? "0x???";
589
+ const genre = args.genre
590
+ ? Buffer.from(args.genre.replace(/^0x/, ""), "hex")
591
+ .toString("utf8")
592
+ .replace(/\0/g, "")
593
+ : "Other";
594
+ const isMine = localNames.has(name);
595
+ const star = isMine ? " ★" : "";
596
+ const shortAddr = `${creator.slice(0, 6)}…${creator.slice(-4)}`;
597
+ lines.push(`#${id} ${name}${star}\n ${genre} · ${shortAddr}`);
598
+ }
599
+ const footer = localNames.size > 0
600
+ ? `${logs.length} station${logs.length === 1 ? "" : "s"} total · ★ = your station`
601
+ : `${logs.length} station${logs.length === 1 ? "" : "s"} total`;
602
+ p.note(lines.join("\n\n"), footer);
603
+ }
604
+ // ---------------------------------------------------------------------------
605
+ // Option 5 — Status
606
+ // ---------------------------------------------------------------------------
607
+ async function handleStatus() {
608
+ const statusSpinner = p.spinner();
609
+ statusSpinner.start("Checking status...");
610
+ const relayUp = checkRelay();
611
+ const linkUp = checkLocalLink();
612
+ const stations = loadLocalStations();
613
+ const wallet = loadSavedWallet();
614
+ const apiKey = await loadApiKey();
615
+ statusSpinner.stop("Status");
616
+ const relayLine = `Relay (localhost:4040) ${relayUp ? "running" : "not running"}`;
617
+ const linkLine = `Speaker bridge ${linkUp ? "connected" : "not running"}`;
618
+ let stationLines;
619
+ if (stations.length === 0) {
620
+ stationLines = "Stations seeding none";
621
+ }
622
+ else {
623
+ const detail = stations
624
+ .map(({ meta }) => {
625
+ const idStr = meta.stationId !== undefined ? ` #${meta.stationId}` : "";
626
+ return ` · ${meta.name}${idStr} (${meta.totalSegments} segments)`;
627
+ })
628
+ .join("\n");
629
+ stationLines = `Local stations ${stations.length}\n${detail}`;
630
+ }
631
+ const walletLine = wallet
632
+ ? `Wallet ${wallet.slice(0, 6)}…${wallet.slice(-4)}`
633
+ : "Wallet not saved";
634
+ const apiLine = `OpenHome API key ${apiKey ? "saved" : "not saved"}`;
635
+ p.note([relayLine, linkLine, stationLines, walletLine, apiLine].join("\n"), "System status");
636
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * deadman list — List all stations from Base chain
3
+ */
4
+ export declare function listStations(): Promise<void>;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * deadman list — List all stations from Base chain
3
+ */
4
+ import { createPublicClient, http, parseAbiItem } from "viem";
5
+ import { base } from "viem/chains";
6
+ const REGISTRY = "0x3b9b6c9BBa15173ba4EF544c17Cb2Ba969d8b585";
7
+ const DEPLOY_BLOCK = 43856547n;
8
+ const client = createPublicClient({
9
+ chain: base,
10
+ transport: http("https://mainnet.base.org"),
11
+ });
12
+ const event = parseAbiItem("event StationCreated(uint256 indexed stationId, address indexed creator, string name, bytes32 genre, bytes32 hypercorePubKey, address curve)");
13
+ export async function listStations() {
14
+ console.log("💀 Fetching stations from Base chain...\n");
15
+ // Base RPC limits eth_getLogs to 10,000 blocks. Paginate.
16
+ const CHUNK = 9500n;
17
+ const currentBlock = await client.getBlockNumber();
18
+ const allLogs = [];
19
+ let from = DEPLOY_BLOCK;
20
+ while (from <= currentBlock) {
21
+ const to = from + CHUNK > currentBlock ? currentBlock : from + CHUNK;
22
+ const chunk = await client.getLogs({
23
+ address: REGISTRY,
24
+ event,
25
+ fromBlock: from,
26
+ toBlock: to,
27
+ });
28
+ allLogs.push(...chunk);
29
+ from = to + 1n;
30
+ }
31
+ const logs = allLogs;
32
+ if (logs.length === 0) {
33
+ console.log(' No stations yet. Be the first: deadman create --name "My Station" --genre Electronic --music ~/Music/');
34
+ return;
35
+ }
36
+ for (const log of logs) {
37
+ const id = log.args.stationId?.toString() ?? "?";
38
+ const name = log.args.name ?? "Unnamed";
39
+ const creator = log.args.creator ?? "0x?";
40
+ const genre = log.args.genre
41
+ ? Buffer.from(log.args.genre.replace(/^0x/, ""), "hex")
42
+ .toString("utf8")
43
+ .replace(/\0/g, "")
44
+ : "Other";
45
+ console.log(` #${id} ${name}`);
46
+ console.log(` ${genre} · ${creator.slice(0, 6)}…${creator.slice(-4)}`);
47
+ console.log();
48
+ }
49
+ console.log(` ${logs.length} station(s) total`);
50
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Local Link bridge — connects your OpenHome speaker to your Mac
3
+ *
4
+ * WebSocket client that receives commands from the speaker's ability
5
+ * and executes them locally. This is how "sync" works.
6
+ */
7
+ export declare function startLocalLink(apiKey: string): Promise<void>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Local Link bridge — connects your OpenHome speaker to your Mac
3
+ *
4
+ * WebSocket client that receives commands from the speaker's ability
5
+ * and executes them locally. This is how "sync" works.
6
+ */
7
+ import { execSync } from "node:child_process";
8
+ import WebSocket from "ws";
9
+ export async function startLocalLink(apiKey) {
10
+ const host = "app.openhome.com";
11
+ const port = 8769;
12
+ const clientId = "deadman-cli";
13
+ const role = "agent";
14
+ const url = `ws://${host}:${port}/?api_key=${apiKey}&client_id=${clientId}&role=${role}`;
15
+ console.log(`💀 Connecting to OpenHome...`);
16
+ const ws = new WebSocket(url);
17
+ ws.on("open", () => {
18
+ console.log("💀 Connected. Speaker ↔ Mac bridge active.");
19
+ console.log(" Waiting for commands from your speaker...\n");
20
+ });
21
+ ws.on("message", (raw) => {
22
+ const msg = raw.toString();
23
+ let data;
24
+ try {
25
+ data = JSON.parse(msg);
26
+ }
27
+ catch {
28
+ return;
29
+ }
30
+ const type = data.type;
31
+ if (type === "ping") {
32
+ ws.send(JSON.stringify({ type: "pong" }));
33
+ return;
34
+ }
35
+ if (type === "command" || type === "relay") {
36
+ const payload = data.data;
37
+ const command = typeof payload === "object" ? payload.cmd : String(payload || "");
38
+ if (!command)
39
+ return;
40
+ console.log(` 📡 Command: ${command}`);
41
+ try {
42
+ const stdout = execSync(command, {
43
+ timeout: 30000,
44
+ encoding: "utf8",
45
+ maxBuffer: 1024 * 1024,
46
+ });
47
+ console.log(` ✅ Done`);
48
+ ws.send(JSON.stringify({
49
+ type: "response",
50
+ data: { ok: true, returncode: 0, stdout, stderr: "" },
51
+ }));
52
+ }
53
+ catch (err) {
54
+ console.log(` ❌ Failed: ${err.message?.slice(0, 100)}`);
55
+ ws.send(JSON.stringify({
56
+ type: "response",
57
+ data: { ok: false, error: err.message },
58
+ }));
59
+ }
60
+ }
61
+ });
62
+ ws.on("error", (err) => {
63
+ console.error(`💀 Connection error: ${err.message}`);
64
+ });
65
+ ws.on("close", () => {
66
+ console.log("💀 Disconnected. Reconnecting in 5s...");
67
+ setTimeout(() => startLocalLink(apiKey), 5000);
68
+ });
69
+ // Keep alive
70
+ await new Promise(() => { });
71
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * deadman seed — Join swarm and serve an existing station's content
3
+ */
4
+ export declare function seedStation(opts: {
5
+ station: string;
6
+ }): Promise<void>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * deadman seed — Join swarm and serve an existing station's content
3
+ */
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { readdirSync, readFileSync } from "node:fs";
7
+ import Hypercore from "hypercore";
8
+ import Hyperswarm from "hyperswarm";
9
+ export async function seedStation(opts) {
10
+ const stationsDir = join(homedir(), ".deadman-fm", "stations");
11
+ // Find station by ID or name
12
+ let stationDir = null;
13
+ let meta = null;
14
+ try {
15
+ const dirs = readdirSync(stationsDir);
16
+ for (const dir of dirs) {
17
+ const metaPath = join(stationsDir, dir, "station.json");
18
+ try {
19
+ const m = JSON.parse(readFileSync(metaPath, "utf8"));
20
+ if (dir === opts.station ||
21
+ m.name === opts.station ||
22
+ String(m.stationId) === opts.station) {
23
+ stationDir = join(stationsDir, dir);
24
+ meta = m;
25
+ break;
26
+ }
27
+ }
28
+ catch {
29
+ continue;
30
+ }
31
+ }
32
+ }
33
+ catch {
34
+ console.error("❌ No stations found. Create one first: deadman create ...");
35
+ process.exit(1);
36
+ }
37
+ if (!stationDir || !meta) {
38
+ console.error(`❌ Station "${opts.station}" not found`);
39
+ process.exit(1);
40
+ }
41
+ console.log(`💀 Seeding: ${meta.name}`);
42
+ console.log(` ${meta.totalSegments} segments`);
43
+ const feedPath = join(stationDir, "feed");
44
+ const core = new Hypercore(feedPath);
45
+ await core.ready();
46
+ console.log(` Feed: ${core.key.toString("hex").slice(0, 16)}...`);
47
+ console.log(` Length: ${core.length} blocks`);
48
+ const swarm = new Hyperswarm();
49
+ let peerCount = 0;
50
+ swarm.on("connection", (socket) => {
51
+ peerCount++;
52
+ console.log(` 🔗 Peer connected (${peerCount} total)`);
53
+ core.replicate(socket);
54
+ socket.on("close", () => {
55
+ peerCount--;
56
+ console.log(` 💤 Peer disconnected (${peerCount} total)`);
57
+ });
58
+ });
59
+ swarm.join(core.discoveryKey, { server: true, client: true });
60
+ await swarm.flush();
61
+ console.log(`\n💀 Seeding "${meta.name}" on the network`);
62
+ console.log(` Press Ctrl+C to stop\n`);
63
+ // Keep alive
64
+ await new Promise(() => { });
65
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 💀.fm CLI — Decentralized pirate radio
4
+ *
5
+ * Run with no arguments for interactive menu.
6
+ * Or use commands directly:
7
+ * deadman create --name "Station" --genre Electronic --music ~/Music/
8
+ * deadman list
9
+ * deadman seed --station 0
10
+ * deadman connect
11
+ */
12
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 💀.fm CLI — Decentralized pirate radio
4
+ *
5
+ * Run with no arguments for interactive menu.
6
+ * Or use commands directly:
7
+ * deadman create --name "Station" --genre Electronic --music ~/Music/
8
+ * deadman list
9
+ * deadman seed --station 0
10
+ * deadman connect
11
+ */
12
+ import { Command } from "commander";
13
+ import { createStation } from "./commands/create.js";
14
+ import { listStations } from "./commands/list.js";
15
+ import { seedStation } from "./commands/seed.js";
16
+ import { interactive } from "./commands/interactive.js";
17
+ const program = new Command();
18
+ program
19
+ .name("deadman")
20
+ .description("💀.fm — Decentralized pirate radio")
21
+ .version("0.1.0");
22
+ program
23
+ .command("create")
24
+ .description("Create a new radio station")
25
+ .requiredOption("--name <name>", "Station name")
26
+ .requiredOption("--genre <genre>", "Genre")
27
+ .requiredOption("--music <path>", "Path to audio files folder")
28
+ .option("--watch", "Watch folder for new files")
29
+ .action(createStation);
30
+ program
31
+ .command("seed")
32
+ .description("Seed an existing station")
33
+ .requiredOption("--station <id>", "Station ID")
34
+ .action(seedStation);
35
+ program.command("list").description("List all stations").action(listStations);
36
+ program
37
+ .command("connect")
38
+ .description("Connect to your OpenHome speaker")
39
+ .action(async () => {
40
+ const { startLocalLink } = await import("./commands/local-link.js");
41
+ const readline = await import("node:readline");
42
+ const rl = readline.createInterface({
43
+ input: process.stdin,
44
+ output: process.stdout,
45
+ });
46
+ const key = await new Promise((r) => rl.question("OpenHome API key: ", r));
47
+ rl.close();
48
+ await startLocalLink(key.trim());
49
+ });
50
+ // No arguments = interactive mode
51
+ if (process.argv.length <= 2) {
52
+ interactive();
53
+ }
54
+ else {
55
+ program.parse();
56
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "deadman-fm",
3
+ "version": "0.1.1",
4
+ "description": "💀.fm — Create and seed decentralized radio stations",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "bin": {
10
+ "deadman-fm": "dist/index.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "start": "node src/index.js"
15
+ },
16
+ "dependencies": {
17
+ "@clack/prompts": "^1.1.0",
18
+ "@types/ws": "^8.18.1",
19
+ "chokidar": "^4.0.0",
20
+ "commander": "^13.0.0",
21
+ "fluent-ffmpeg": "^2.1.3",
22
+ "hypercore": "^11.27.0",
23
+ "hyperswarm": "^4.9.0",
24
+ "viem": "^2.23.0",
25
+ "ws": "^8.20.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/fluent-ffmpeg": "^2.1.27",
29
+ "@types/node": "^22.0.0",
30
+ "typescript": "^5.7.0"
31
+ }
32
+ }