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.
- package/dist/commands/create.d.ts +17 -0
- package/dist/commands/create.js +221 -0
- package/dist/commands/interactive.d.ts +16 -0
- package/dist/commands/interactive.js +636 -0
- package/dist/commands/list.d.ts +4 -0
- package/dist/commands/list.js +50 -0
- package/dist/commands/local-link.d.ts +7 -0
- package/dist/commands/local-link.js +71 -0
- package/dist/commands/seed.d.ts +6 -0
- package/dist/commands/seed.js +65 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +56 -0
- package/package.json +32 -0
|
@@ -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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|