Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,26 @@ Everything runs from the keyboard, with controls that are quick to pick up. Pres
<img src="preview/keys.svg" alt="The keyboard cheatsheet: navigate, player, and download keys" style="max-width: 832px; width: 100%; height: auto;">
</p>

## Prefer a window?

```
sndcli --gui
```

Opens the same library, player and download queue in your browser. Songs play right in the page, so mpv isn't needed and no other player opens. Options: `--port <n>` to pick a port, `--no-open` to print the link without opening it. Press `ctrl-c` in the terminal to quit.

Running from a clone of this repo, one command builds the page and starts it:

```
npm run gui
npm run gui -- --port 4000
npm run gui -- --no-open
```

The page is served only on your own computer (`127.0.0.1`) and the link carries a one-time token, so other sites and other machines can't reach it. Run either the terminal app or the browser one at a time, with a single browser tab open; both write to the same library.

Changing the library folder and converting formats are still terminal-only for now.

## Contributing

Issues and pull requests are welcome. soundcli is TypeScript with an Ink
Expand Down
474 changes: 242 additions & 232 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
},
"scripts": {
"dev": "tsx src/index.tsx",
"build": "tsup",
"build": "tsup && npm run build:web",
"build:web": "vite build --config web/vite.config.ts",
"gui": "vite build --config web/vite.config.ts && tsx src/index.tsx --gui",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc --noEmit -p web",
"test": "vitest run",
"previews": "tsx scripts/render-previews.ts",
"prepublishOnly": "npm run build && tsx scripts/check-dist-imports.ts"
Expand Down Expand Up @@ -53,16 +55,22 @@
"execa": "^9.6.1",
"ink": "^7.0.5",
"react": "^19.2.7",
"string-width": "^8.2.1"
"string-width": "^8.2.1",
"ws": "^8.22.0"
},
"devDependencies": {
"@inkjs/ui": "^2.0.0",
"@types/node": "^25.9.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.3.0",
"@types/ws": "^8.18.2",
"@vitejs/plugin-react": "^6.1.2",
"ink-testing-library": "^4.0.0",
"react-dom": "^19.3.0",
"tsup": "^8.5.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^8.3.4",
"vitest": "^4.1.8"
}
}
25 changes: 25 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ export type CliCommand =
| { kind: "version" }
| { kind: "help" }
| { kind: "run"; initialAdd?: string }
| { kind: "gui"; port?: number; open: boolean }
| { kind: "invalid"; arg: string };

export function parseCliArgs(argv: string[]): CliCommand {
const args = argv.filter((a) => a.trim() !== "");
if (args.length === 0) return { kind: "run" };
if (args[0] === "--gui") return parseGuiArgs(args.slice(1));
if (args.length > 1) return { kind: "invalid", arg: args[1]! };
const a = args[0]!;
if (a === "--version" || a === "-v") return { kind: "version" };
Expand All @@ -19,11 +21,34 @@ export function parseCliArgs(argv: string[]): CliCommand {
return { kind: "run", initialAdd: a };
}

function parseGuiArgs(rest: string[]): CliCommand {
let port: number | undefined;
let open = true;
for (let i = 0; i < rest.length; i++) {
const a = rest[i]!;
if (a === "--no-open") {
open = false;
} else if (a === "--port") {
const v = rest[++i];
const n = v === undefined || !/^\d+$/.test(v) ? NaN : Number(v);
if (!Number.isInteger(n) || n < 1 || n > 65535) {
return { kind: "invalid", arg: v === undefined ? a : `${a} ${v}` };
}
port = n;
} else {
return { kind: "invalid", arg: a };
}
}
return { kind: "gui", port, open };
}

export const HELP_TEXT = `soundcli, own your music

usage
soundcli open the dashboard
soundcli <link> download that song on launch
soundcli --gui open the dashboard in your browser
(--port <n>, --no-open)
soundcli --version print the version

tip: quote links that contain & (e.g. "https://...?list=...")
Expand Down
227 changes: 227 additions & 0 deletions src/core/createApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// UI-agnostic bootstrap: builds the library, player, download queue and
// history and wires them together. Both front ends (the Ink dashboard and the
// local web GUI) start from here, so none of this may touch the terminal.

import { promises as fs } from "node:fs";
import { ensureBinaries, type Binaries } from "../bin/binaries";
import { ensureFfmpeg } from "../bin/ffmpeg-fetch";
import { ensureMpvInstalled } from "../bin/mpv-install";
import { finalizeStagedYtDlp } from "../bin/ytdlp-fetch";
import { maybeUpdateYtDlp } from "../bin/ytdlp-update";
import { loadConfig, saveConfig, type Config } from "../config/config";
import { legacyArchiveFile } from "../config/paths";
import { DownloadQueue } from "../download/queue";
import { loadQueue } from "../download/persist";
import { Library } from "../library/library";
import { migrateOwnerLayout } from "../library/migrate";
import { reconcileLibrary } from "../library/reconcile";
import { Playback, type PlaybackState } from "../player/playback";
import { PlayHistory } from "../player/history";

export interface CreateAppOptions {
/** A link passed on the command line: counts as self-onboarded. */
initialAdd?: string;
/** Progress text while binaries are fetched on first boot. */
onStatus?: (text: string) => void;
/**
* Status line while mpv auto-installs in the background (null clears it).
* `transient` marks a failure message that the front end should clear itself
* after a short while.
*/
onMpvStatus?: (text: string | null, opts?: { transient?: boolean }) => void;
/** The front end plays the audio itself (the GUI page): skip mpv entirely. */
browserAudio?: boolean;
}

export interface App {
config: Config;
library: Library;
binaries: Binaries;
queue: DownloadQueue;
playback: Playback;
history: PlayHistory;
/**
* Silent drift hygiene (drop dead entries + dupes). Runs behind the first
* paint so a full music-dir walk on a big library never holds up boot.
* Call once after the front end is up.
*/
startBackgroundReconcile(): void;
/**
* Re-sync the library with disk without a restart. Guarded so runs never
* stack, and coalesced so rapid section hops don't restat. Pass the live
* `libraryDir` since Settings can change it after boot.
*/
reconcile(libraryDir?: string): void;
/** Flush and stop everything; safe to call on exit. */
dispose(): void;
}

export async function createApp(opts: CreateAppOptions = {}): Promise<App> {
const { initialAdd, onStatus, onMpvStatus, browserAudio } = opts;

const config = await loadConfig();
// A link passed on the command line means the user has self-onboarded:
// skip the welcome tour and drop straight into downloading it (the
// saveConfig below persists the flag).
if (initialAdd) config.firstRunComplete = true;
const library = await Library.load();
// One-time layout migration (pre-owner files move into their handle
// folder) must finish before anything reads the library; the existence
// cache it fills feeds the deferred reconcile below so each file is
// still stat'd only once per boot.
const exists = new Map<string, boolean>();
await migrateOwnerLayout(library, config, exists);
// Library is now the source of truth, so drop the legacy yt-dlp archive.
void fs.rm(legacyArchiveFile, { force: true }).catch(() => {});
const binaries = await ensureBinaries(onStatus);
const playback = new Playback(browserAudio ? null : binaries.mpv);
if (browserAudio) playback.enableBrowser();

// Recently played: record every track the player actually starts,
// including auto-advance and next/prev, not just explicit picks.
const history = await PlayHistory.load();
let lastPlayedId: string | undefined;

const lastId = history.ids()[0];
if (lastId) {
const lastTrack = library.all().find((t) => t.id === lastId);
if (lastTrack) {
// Reconcile now runs behind the first paint, so a file deleted
// while the app was closed hasn't been pruned yet; one stat keeps
// the resume from loading a dead row (and feeds the exists cache
// so the deferred reconcile doesn't stat it again).
let present = exists.get(lastTrack.filePath);
if (present === undefined) {
present = await fs
.access(lastTrack.filePath)
.then(() => true)
.catch(() => false);
exists.set(lastTrack.filePath, present);
}
if (present) {
lastPlayedId = lastTrack.id;
void playback.play(lastTrack, [lastTrack], 0, true).catch(() => {});
}
}
}

playback.on("state", (s: PlaybackState) => {
const id = s.track?.id;
if (id && id !== lastPlayedId) {
lastPlayedId = id;
history.record(id);
}
});

// Backfill durations the player discovers: adopted files never carry
// one (adoption can't know it without probing audio), so their rows
// render no time. mpv reports the real duration on load; teach the
// library once per track and the row shows a time forever.
let lastHealedId: string | undefined;
playback.on("state", (s: PlaybackState) => {
const id = s.track?.id;
if (!id || s.duration <= 0 || id === lastHealedId) return;
const entry = library.get(id);
if (!entry || entry.durationSec !== undefined) return;
lastHealedId = id;
void library.upsert({ ...entry, durationSec: s.duration }).catch(() => {});
});

// Auto-install mpv in the background so the rich player "just works".
// Retried on every launch while it's missing: a one-time flag here once
// left playback silently broken forever after a single failed attempt.
if (!binaries.mpv && !browserAudio) {
const failed = (): void => {
// One calm line, then quiet again: the NowPlayingBar hint stays the
// durable nudge while songs keep opening in the OS default app.
onMpvStatus?.("Couldn't set up the player, songs open in your default app", {
transient: true,
});
};
void ensureMpvInstalled((t) => onMpvStatus?.(t))
.then((p) => {
if (p) {
onMpvStatus?.(null);
playback.enableMpv(p);
} else if (process.platform === "linux") {
// Linux never auto-installs (needs sudo); the install hint is
// the durable surface there, so saying "couldn't set up" about
// an attempt we never made would just be noise.
onMpvStatus?.(null);
} else {
failed();
}
})
.catch(failed);
}

// Downloads hard-gate on the audio engine (fetched in the background by
// ensureBinaries); undefined keeps the default concurrency.
const queue = new DownloadQueue(config, library, undefined, () =>
ensureFfmpeg(),
);
// Bring back last session's queue; pending items resume from their .part.
queue.restore(await loadQueue());

// Check for a newer yt-dlp on every boot (a stale extractor turns every
// download from a source into "failed"). Async and silent: a newer
// binary is staged, then promoted right away unless a download is
// already running the current one (then it applies next launch).
// Offline stays completely normal.
if (config.ytdlpAutoUpdate !== false) {
void maybeUpdateYtDlp()
.then(async (staged) => {
if (staged && queue.stats().downloading === 0) {
await finalizeStagedYtDlp();
}
})
.catch(() => {});
}

// Reconcile state. A failure stays silent: the app is fully usable with a
// stale index and the next run retries anyway.
let reconciling = false;
let lastReconcile = 0;
let firstCache: Map<string, boolean> | null = exists;
const runReconcile = (force: boolean, libraryDir?: string): void => {
if (reconciling) return;
if (!force && Date.now() - lastReconcile < 5_000) return;
reconciling = true;
// A fresh cache per run so the missing-check and prune passes share stats
// (one access per track, not two), while each run still sees current disk.
// The first run reuses the boot cache so each file is stat'd once per boot.
const cache = firstCache ?? new Map<string, boolean>();
firstCache = null;
void reconcileLibrary(library, cache, libraryDir ?? config.libraryDir)
.then((r) => {
// Only touch recently-played when a prune actually removed tracks, so
// a reconcile that changed nothing writes nothing.
if (r.prunedMissing > 0) history.retain((id) => library.has(id));
})
.catch(() => {})
.finally(() => {
lastReconcile = Date.now();
reconciling = false;
});
};

await saveConfig(config).catch(() => {});

return {
config,
library,
binaries,
queue,
playback,
history,
startBackgroundReconcile: () => runReconcile(true),
reconcile: (libraryDir) => runReconcile(false, libraryDir),
dispose: () => {
queue.suspend();
// Library writes are debounced; flush synchronously so a mutation made
// in the last half second still lands before the process goes away.
library.flushSync();
playback.quit();
},
};
}
56 changes: 56 additions & 0 deletions src/gui/protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Messages between the local GUI server and the web page. Types only, so the
// web bundle can import this without pulling in any Node code.

import type { Config } from "../config/config";
import type { QueueItem, QueueStats } from "../download/queue";
import type { Track } from "../library/types";
import type { PlaybackState } from "../player/playback";

export interface Snapshot {
tracks: Track[];
queue: QueueItem[];
stats: QueueStats;
playback: PlaybackState;
/** Recently played track ids, newest first. */
history: string[];
config: Config;
mpvStatus: string | null;
version: string;
}

/** Server -> page. */
export type ServerEvent =
| { type: "snapshot"; data: Snapshot }
| { type: "playback"; data: PlaybackState }
| { type: "queue"; data: { queue: QueueItem[]; stats: QueueStats } }
| { type: "library"; data: { tracks: Track[]; history: string[] } }
| { type: "mpv"; data: string | null }
| { type: "result"; id: number; ok: boolean; message?: string };

/** Page -> server. `id` is echoed back in a `result` event. */
export type ClientCommand = { id: number } & (
| { cmd: "play"; trackId: string; listIds?: string[] }
| { cmd: "togglePause" }
| { cmd: "report"; trackId: string; position: number; duration: number }
| { cmd: "ended"; trackId: string }
| { cmd: "seek"; seconds: number }
| { cmd: "setVolume"; volume: number }
| { cmd: "next" }
| { cmd: "prev" }
| { cmd: "cycleRepeat" }
| { cmd: "toggleShuffle" }
| { cmd: "stop" }
| { cmd: "enqueueUrl"; url: string }
| { cmd: "queuePause"; itemId: string }
| { cmd: "queueResume"; itemId: string }
| { cmd: "queueCancel"; itemId: string }
| { cmd: "queuePauseAll" }
| { cmd: "queueResumeAll" }
| { cmd: "queueRetryFailed" }
| { cmd: "queueClearFinished" }
| { cmd: "deleteTracks"; trackIds: string[] }
| { cmd: "renameTrack"; trackId: string; title: string }
| { cmd: "setConfig"; patch: Partial<Config> }
);

export type CommandName = ClientCommand["cmd"];
Loading