Something went wrong. Try again.
A decentralized music tracking and discovery platform built on AT Protocol ๐ต rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340#!/usr/bin/env node
import chalk from "chalk";import { Command } from "commander";import { version } from "../package.json" assert { type: "json" };import { c } from "./theme";
// Command modules are imported lazily inside each action so that a bare// `rocksky` (TUI) launch doesn't pay to load drizzle/better-sqlite3, express,// hono, the MCP SDK, etc. โ only the one command the user actually runs.const lazy = <T extends any[]>(load: () => Promise<(...args: T) => unknown>) => async (...args: T) => { await ensureDb(); return (await load())(...args); };
let dbReady: Promise<void> | null = null;function ensureDb() { if (!dbReady) { dbReady = import("./drizzle").then(({ initializeDatabase }) => initializeDatabase(), ); } return dbReady;}
const program = new Command();
program .name("rocksky") .description( ` ___ __ __ _______ ____ / _ \\___ ____/ /__ ___ / /____ __ / ___/ / / _/ / , _/ _ \\/ __/ '_/(_-</ '_/ // / / /__/ /___/ / /_/|_|\\___/\\__/_/\\_\\/___/_/\\_\\\\_, / \\___/____/___/ /___/ Command-line interface for Rocksky ${c.link( "https://rocksky.app", )} โ scrobble tracks, view stats, and manage your listening history.`, ) .version(version);
program.configureHelp({ styleTitle: (str) => chalk.bold(c.primary(str)), styleCommandText: (str) => c.secondary(str), styleDescriptionText: (str) => c.muted(str), styleOptionText: (str) => c.highlight(str), styleArgumentText: (str) => c.accent(str), styleSubcommandText: (str) => c.secondary(str),});
program.addHelpText( "after", `${chalk.bold(c.primary("\nLearn more about Rocksky:"))} ${c.link("https://docs.rocksky.app")}${chalk.bold(c.primary("Join our Discord community:"))} ${c.link("https://discord.gg/EVcBy2fVa3")}`,);
program .command("login") .argument("<handle>", "your AT Proto handle (e.g., <username>.bsky.social)") .description("login with your AT Proto account and get a session token") .action(lazy(async () => (await import("./cmd/login")).login));
program .command("whoami") .description("get the current logged-in user") .action(lazy(async () => (await import("cmd/whoami")).whoami));
program .command("nowplaying") .argument( "[did]", "the DID or handle of the user to get the now playing track for", ) .description("get the currently playing track") .action(lazy(async () => (await import("cmd/nowplaying")).nowplaying));
program .command("scrobbles") .option("-s, --skip <number>", "number of scrobbles to skip") .option("-l, --limit <number>", "number of scrobbles to limit") .argument("[did]", "the DID or handle of the user to get the scrobbles for") .description("display recently played tracks") .action(lazy(async () => (await import("cmd/scrobbles")).scrobbles));
program .command("search") .option("-a, --albums", "search for albums") .option("-t, --tracks", "search for tracks") .option("-u, --users", "search for users") .option("-l, --limit <number>", "number of results to limit") .argument( "<query>", "the search query, e.g., artist, album, title or account", ) .description("search for tracks, albums, or accounts") .action(lazy(async () => (await import("cmd/search")).search));
program .command("stats") .option("-l, --limit <number>", "number of results to limit") .argument("[did]", "the DID or handle of the user to get stats for") .description("get the user's listening stats") .action(lazy(async () => (await import("cmd/stats")).stats));
program .command("artists") .option("-l, --limit <number>", "number of results to limit") .argument("[did]", "the DID or handle of the user to get artists for") .description("get the user's top artists") .action(lazy(async () => (await import("cmd/artists")).artists));
program .command("albums") .option("-l, --limit <number>", "number of results to limit") .argument("[did]", "the DID or handle of the user to get albums for") .description("get the user's top albums") .action(lazy(async () => (await import("cmd/albums")).albums));
program .command("tracks") .option("-l, --limit <number>", "number of results to limit") .argument("[did]", "the DID or handle of the user to get tracks for") .description("get the user's top tracks") .action(lazy(async () => (await import("cmd/tracks")).tracks));
program .command("scrobble") .argument("<track>", "the title of the track") .argument("<artist>", "the artist of the track") .option( "-a, --album <album>", "the album of the track โ matching prefers this release over remaster/live/single editions", ) .option("-t, --timestamp <timestamp>", "the timestamp of the scrobble") .option("-d, --dry-run", "simulate the scrobble without actually sending it") .description("scrobble a track to your profile") .action(lazy(async () => (await import("cmd/scrobble")).scrobble));
program .command("create") .description("create a new API key") .command("apikey") .argument("<name>", "the name of the API key") .option("-d, --description <description>", "the description of the API key") .description("create a new API key") .action(lazy(async () => (await import("cmd/create")).createApiKey));
program .command("mcp") .description("starts an MCP server to use with Claude or other LLMs") .option( "-d, --device <device>", "player the command tools address when a call names none", ) .option("--api-url <url>", "Rocksky API base URL, for a dev server") .option("--ws-url <url>", "remote-control WebSocket URL, for a dev server") .option("--token-path <path>", "where the access token lives") // Not wrapped in `lazy`: the MCP server is a separate binary with its own // state, so initializing the CLI's local database here would be pure cost. .action(async (options) => (await import("cmd/mcp")).mcp(options));
program .command("sync") .description("sync your local Rocksky data from AT Protocol") .action(lazy(async () => (await import("cmd/sync")).sync));
program .command("scrobble-api") .description("start a local listenbrainz/lastfm compatibility server") .option("-p, --port <port>", "the port to listen on", "8778") .action(lazy(async () => (await import("cmd/scrobble-api")).scrobbleApi));
// The MPD server needs only the API + token + player, no local database.program .command("mpd") .description( "start an MPD-protocol server to control playback and browse your library", ) .option("-p, --port <port>", "port to listen on (defaults to settings.toml)") .option("-b, --bind <address>", "address to bind (defaults to settings.toml)") .option( "-o, --output <spec>", "audio output backend: default device, or stdout / fifo:<path> / unix:<path> / tcp:<host>:<port> (defaults to settings.toml)", ) .action(async (opts: { port?: string; bind?: string; output?: string }) => { const { mpd } = await import("cmd/mpd"); await mpd(opts); });
// NFC talks to the reader and the library API only โ no local database.const nfcCmd = program .command("nfc") .description( "read and write NFC tags that play a library album, playlist or your favorites", );
nfcCmd .command("status") .description("list connected NFC readers") .action(async () => { const { nfcStatus } = await import("cmd/nfc"); await nfcStatus(); });
nfcCmd .command("read") .option("-w, --watch", "keep reading tags until interrupted") .description("show what the tapped tag points at") .action(async (opts: { watch?: boolean }) => { const { nfcRead } = await import("cmd/nfc"); await nfcRead(opts); });
// Contact cards (SLE, ACOS) carry the same records as an NFC tag but speak// entirely different protocols, so they get their own command rather than// sharing the reader with the tag watcher.const cardCmd = program .command("card") .description( "read and write SLE and ACOS smart cards that play a library album or playlist", );
cardCmd .command("read") .description("show what the inserted card points at") .action(async () => { const { cardRead } = await import("cmd/card"); await cardRead(); });
cardCmd .command("write") .option("-a, --album <ref>", "album AT-URI or library id to write") .option("-p, --playlist <ref>", "playlist AT-URI or library id to write") .option("-f, --favorites", "write your favorites (needs no ref)") .option( "-s, --secret <code>", "the card's PSC (SLE, hex) or PIN (ACOS); defaults to the factory value", ) .description( "write a library album, playlist or your favorites onto the inserted card", ) .action( async (opts: { album?: string; playlist?: string; favorites?: boolean; secret?: string; }) => { const { cardWrite } = await import("cmd/card"); await cardWrite(opts); }, );
nfcCmd .command("write") .option("-a, --album <id>", "library album id to write") .option("-p, --playlist <id>", "library playlist id to write") .option("-f, --favorites", "write your favorites (needs no id)") .description( "write a library album, playlist or your favorites onto the tapped tag", ) .action( async (opts: { album?: string; playlist?: string; favorites?: boolean }) => { const { nfcWrite } = await import("cmd/nfc"); await nfcWrite(opts); }, );
// Import reads an export file and publishes via @rocksky/sdk โ no local DB.program .command("import") .argument( "<file>", "a Spotify or Last.fm export (JSON/CSV file, or the Spotify history folder)", ) .option("-f, --format <format>", "force the format: spotify or lastfm (default: autodetect)") .option("-n, --dry-run", "parse and print what would be published, without writing to your PDS") .option("-c, --concurrency <number>", "number of scrobbles to publish in parallel", "4") .option( "-r, --rate <perHour>", "scrobbles published per hour (hard-capped to stay under Bluesky PDS write limits; each scrobble writes up to 4 records)", ) .option( "--no-rate-limit", "disable the client-side write throttle โ self-hosted PDSes only; always ignored on *.bsky.network", ) .option("-l, --limit <number>", "only import the first N scrobbles") .option("--min-seconds <seconds>", "skip Spotify plays shorter than this (default: 30)") .option("--from <date>", "only import scrobbles on or after this date (e.g. 2024-01-01)") .option("--to <date>", "only import scrobbles on or before this date") .option("--restart", "ignore the saved checkpoint and import from the beginning") .description("import your listening history from a Spotify or Last.fm export") .action(async (file: string, opts) => { const { importCmd } = await import("cmd/import"); await importCmd(file, opts); });
// Upload needs only the API + token, no local database.program .command("upload") .argument("<files...>", "audio files or directories to upload") .option( "-s, --storage <ref>", "upload into your own connected storage (name, bucket or id) instead of Rocksky's", ) .description("upload audio files to your Rocksky library") .action(async (files: string[], opts: { storage?: string }) => { const { upload } = await import("cmd/upload"); await upload(files, opts); });
// The TUI needs no local database, so it skips `ensureDb()` entirely.program .command("tui") .description( "launch the interactive terminal UI to browse scrobbles and stream your music", ) .option( "-o, --output <spec>", "audio output backend: default device, or stdout / fifo:<path> / unix:<path> / tcp:<host>:<port> (defaults to settings.toml)", ) .action(async (opts: { output?: string }) => { const { tui } = await import("cmd/tui"); await tui(opts); });
// No subcommand given โ launch the interactive TUI (no DB init, lean startup).if (process.argv.slice(2).length === 0) { const { tui } = await import("cmd/tui"); await tui();} else { program.parse(process.argv);}