@rocksky/cli
Version:
Command-line interface for Rocksky – scrobble tracks, view stats, and manage your listening history
1,230 lines (1,203 loc) • 38.8 kB
JavaScript
import chalk from 'chalk';
import fs from 'fs';
import os from 'os';
import path from 'path';
import fs$1 from 'fs/promises';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import dayjs from 'dayjs';
import relative from 'dayjs/plugin/relativeTime.js';
import md5 from 'md5';
import { table, getBorderCharacters } from 'table';
import { Command } from 'commander';
import axios from 'axios';
import cors from 'cors';
import express from 'express';
import open from 'open';
const ROCKSKY_API_URL = "https://api.rocksky.app";
class RockskyClient {
constructor(token) {
this.token = token;
this.token = token;
}
async getCurrentUser() {
const response = await fetch(`${ROCKSKY_API_URL}/profile`, {
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user data: ${response.statusText}`);
}
return response.json();
}
async getSpotifyNowPlaying(did) {
const response = await fetch(
`${ROCKSKY_API_URL}/spotify/currently-playing` + (did ? `?did=${did}` : ""),
{
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(
`Failed to fetch now playing data: ${response.statusText}`
);
}
return response.json();
}
async getNowPlaying(did) {
const response = await fetch(
`${ROCKSKY_API_URL}/now-playing` + (did ? `?did=${did}` : ""),
{
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(
`Failed to fetch now playing data: ${response.statusText}`
);
}
return response.json();
}
async scrobbles(did, { skip = 0, limit = 20 } = {}) {
if (did) {
const response2 = await fetch(
`${ROCKSKY_API_URL}/users/${did}/scrobbles?offset=${skip}&size=${limit}`,
{
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
}
);
if (!response2.ok) {
throw new Error(
`Failed to fetch scrobbles data: ${response2.statusText}`
);
}
return response2.json();
}
const response = await fetch(
`${ROCKSKY_API_URL}/public/scrobbles?offset=${skip}&size=${limit}`,
{
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(`Failed to fetch scrobbles data: ${response.statusText}`);
}
return response.json();
}
async search(query, { size }) {
const response = await fetch(
`${ROCKSKY_API_URL}/search?q=${query}&size=${size}`,
{
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(`Failed to fetch search data: ${response.statusText}`);
}
return response.json();
}
async stats(did) {
if (!did) {
const didFile = path.join(os.homedir(), ".rocksky", "did");
try {
await fs.promises.access(didFile);
did = await fs.promises.readFile(didFile, "utf-8");
} catch (err) {
const user = await this.getCurrentUser();
did = user.did;
const didPath = path.join(os.homedir(), ".rocksky");
fs.promises.mkdir(didPath, { recursive: true });
await fs.promises.writeFile(didFile, did);
}
}
const response = await fetch(`${ROCKSKY_API_URL}/users/${did}/stats`, {
method: "GET",
headers: {
"Content-Type": "application/json"
}
});
if (!response.ok) {
throw new Error(`Failed to fetch stats data: ${response.statusText}`);
}
return response.json();
}
async getArtists(did, { skip = 0, limit = 20 } = {}) {
if (!did) {
const didFile = path.join(os.homedir(), ".rocksky", "did");
try {
await fs.promises.access(didFile);
did = await fs.promises.readFile(didFile, "utf-8");
} catch (err) {
const user = await this.getCurrentUser();
did = user.did;
const didPath = path.join(os.homedir(), ".rocksky");
fs.promises.mkdir(didPath, { recursive: true });
await fs.promises.writeFile(didFile, did);
}
}
const response = await fetch(
`${ROCKSKY_API_URL}/users/${did}/artists?offset=${skip}&size=${limit}`,
{
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(`Failed to fetch artists data: ${response.statusText}`);
}
return response.json();
}
async getAlbums(did, { skip = 0, limit = 20 } = {}) {
if (!did) {
const didFile = path.join(os.homedir(), ".rocksky", "did");
try {
await fs.promises.access(didFile);
did = await fs.promises.readFile(didFile, "utf-8");
} catch (err) {
const user = await this.getCurrentUser();
did = user.did;
const didPath = path.join(os.homedir(), ".rocksky");
fs.promises.mkdir(didPath, { recursive: true });
await fs.promises.writeFile(didFile, did);
}
}
const response = await fetch(
`${ROCKSKY_API_URL}/users/${did}/albums?offset=${skip}&size=${limit}`,
{
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(`Failed to fetch albums data: ${response.statusText}`);
}
return response.json();
}
async getTracks(did, { skip = 0, limit = 20 } = {}) {
if (!did) {
const didFile = path.join(os.homedir(), ".rocksky", "did");
try {
await fs.promises.access(didFile);
did = await fs.promises.readFile(didFile, "utf-8");
} catch (err) {
const user = await this.getCurrentUser();
did = user.did;
const didPath = path.join(os.homedir(), ".rocksky");
fs.promises.mkdir(didPath, { recursive: true });
await fs.promises.writeFile(didFile, did);
}
}
const response = await fetch(
`${ROCKSKY_API_URL}/users/${did}/tracks?offset=${skip}&size=${limit}`,
{
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(`Failed to fetch tracks data: ${response.statusText}`);
}
return response.json();
}
async scrobble(api_key, api_sig, track, artist, timestamp) {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs.promises.access(tokenPath);
} catch (err) {
console.error(
`You are not logged in. Please run the login command first.`
);
return;
}
const tokenData = await fs.promises.readFile(tokenPath, "utf-8");
const { token: sk } = JSON.parse(tokenData);
const response = await fetch("https://audioscrobbler.rocksky.app/2.0", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({
method: "track.scrobble",
"track[0]": track,
"artist[0]": artist,
"timestamp[0]": timestamp || Math.floor(Date.now() / 1e3),
api_key,
api_sig,
sk,
format: "json"
})
});
if (!response.ok) {
throw new Error(
`Failed to scrobble track: ${response.statusText} ${await response.text()}`
);
}
return response.json();
}
async getApiKeys() {
const response = await fetch(`${ROCKSKY_API_URL}/apikeys`, {
method: "GET",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
}
});
if (!response.ok) {
throw new Error(`Failed to fetch API keys: ${response.statusText}`);
}
return response.json();
}
async createApiKey(name, description) {
const response = await fetch(`${ROCKSKY_API_URL}/apikeys`, {
method: "POST",
headers: {
Authorization: this.token ? `Bearer ${this.token}` : void 0,
"Content-Type": "application/json"
},
body: JSON.stringify({
name,
description
})
});
if (!response.ok) {
throw new Error(`Failed to create API key: ${response.statusText}`);
}
return response.json();
}
}
async function albums$1(did, { skip, limit }) {
const client = new RockskyClient();
const albums2 = await client.getAlbums(did, { skip, limit });
let rank = 1;
for (const album of albums2) {
console.log(
`${rank} ${chalk.magenta(album.title)} ${album.artist} ${chalk.yellow(
album.play_count + " plays"
)}`
);
rank++;
}
}
async function artists$1(did, { skip, limit }) {
const client = new RockskyClient();
const artists2 = await client.getArtists(did, { skip, limit });
let rank = 1;
for (const artist of artists2) {
console.log(
`${rank} ${chalk.magenta(artist.name)} ${chalk.yellow(
artist.play_count + " plays"
)}`
);
rank++;
}
}
async function createApiKey$1(name, { description }) {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
const client = new RockskyClient(token);
const apikey = await client.createApiKey(name, description);
if (!apikey) {
console.error(`Failed to create API key. Please try again later.`);
return;
}
console.log(`API key created successfully!`);
console.log(`Name: ${chalk.greenBright(apikey.name)}`);
if (apikey.description) {
console.log(`Description: ${chalk.greenBright(apikey.description)}`);
}
console.log(`Key: ${chalk.greenBright(apikey.api_key)}`);
console.log(`Secret: ${chalk.greenBright(apikey.shared_secret)}`);
}
async function albums(did, { skip, limit = 20 }) {
const client = new RockskyClient();
const albums2 = await client.getAlbums(did, { skip, limit });
let rank = 1;
let response = `Top ${limit} albums:
`;
for (const album of albums2) {
response += `${rank} ${album.title} - ${album.artist} - ${album.play_count} plays
`;
rank++;
}
return response;
}
async function artists(did, { skip, limit = 20 }) {
try {
const client = new RockskyClient();
const artists2 = await client.getArtists(did, { skip, limit });
let rank = 1;
let response = `Top ${limit} artists:
`;
for (const artist of artists2) {
response += `${rank} ${artist.name} - ${artist.play_count} plays
`;
rank++;
}
return response;
} catch (err) {
return `Failed to fetch artists data. Please check your token and try again, error: ${err.message}`;
}
}
async function createApiKey(name, { description }) {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
const client = new RockskyClient(token);
const apikey = await client.createApiKey(name, description);
if (!apikey) {
return "Failed to create API key. Please try again later.";
}
return "API key created successfully!, navigate to your Rocksky account to view it.";
}
dayjs.extend(relative);
async function myscrobbles({ skip, limit }) {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
const client = new RockskyClient(token);
try {
const { did } = await client.getCurrentUser();
const scrobbles = await client.scrobbles(did, { skip, limit });
return JSON.stringify(
scrobbles.map((scrobble) => ({
title: scrobble.title,
artist: scrobble.artist,
date: dayjs(scrobble.created_at + "Z").fromNow(),
isoDate: scrobble.created_at
})),
null,
2
);
} catch (err) {
return `Failed to fetch scrobbles data. Please check your token and try again, error: ${err.message}`;
}
}
async function nowplaying$1(did) {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
if (!did) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token && !did) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
const client = new RockskyClient(token);
try {
const nowPlaying = await client.getSpotifyNowPlaying(did);
if (!nowPlaying || Object.keys(nowPlaying).length === 0) {
const nowPlaying2 = await client.getNowPlaying(did);
if (!nowPlaying2 || Object.keys(nowPlaying2).length === 0) {
return "No track is currently playing.";
}
return JSON.stringify(
{
title: nowPlaying2.title,
artist: nowPlaying2.artist,
album: nowPlaying2.album
},
null,
2
);
}
return JSON.stringify(
{
title: nowPlaying.item.name,
artist: nowPlaying.item.artists.map((a) => a.name).join(", "),
album: nowPlaying.item.album.name
},
null,
2
);
} catch (err) {
return `Failed to fetch now playing data. Please check your token and try again, error: ${err.message}`;
}
}
dayjs.extend(relative);
async function scrobbles$1(did, { skip, limit }) {
try {
const client = new RockskyClient();
const scrobbles2 = await client.scrobbles(did, { skip, limit });
if (did) {
return JSON.stringify(
scrobbles2.map((scrobble) => ({
title: scrobble.title,
artist: scrobble.artist,
date: dayjs(scrobble.created_at + "Z").fromNow(),
isoDate: scrobble.created_at
})),
null,
2
);
}
return JSON.stringify(
scrobbles2.map((scrobble) => ({
user: `@${scrobble.user}`,
title: scrobble.title,
artist: scrobble.artist,
date: dayjs(scrobble.date).fromNow(),
isoDate: scrobble.date
})),
null,
2
);
} catch (err) {
return `Failed to fetch scrobbles data. Please check your token and try again, error: ${err.message}`;
}
}
async function search$1(query, { limit = 20, albums = false, artists = false, tracks = false, users = false }) {
const client = new RockskyClient();
const results = await client.search(query, { size: limit });
if (results.records.length === 0) {
return `No results found for ${query}.`;
}
let mergedResults = results.records.map((record) => ({
...record,
type: record.table
}));
if (albums) {
mergedResults = mergedResults.filter((record) => record.table === "albums");
}
if (artists) {
mergedResults = mergedResults.filter(
(record) => record.table === "artists"
);
}
if (tracks) {
mergedResults = mergedResults.filter(({ table }) => table === "tracks");
}
if (users) {
mergedResults = mergedResults.filter(({ table }) => table === "users");
}
mergedResults.sort((a, b) => b.xata_score - a.xata_score);
const responses = [];
for (const { table, record } of mergedResults) {
if (table === "users") {
responses.push({
handle: record.handle,
display_name: record.display_name,
did: record.did,
link: `https://rocksky.app/profile/${record.did}`,
type: "account"
});
}
if (table === "albums") {
const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : "";
responses.push({
title: record.title,
artist: record.artist,
link,
type: "album"
});
}
if (table === "tracks") {
const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : "";
responses.push({
title: record.title,
artist: record.artist,
link,
type: "track"
});
}
if (table === "artists") {
const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : "";
responses.push({
name: record.name,
link,
type: "artist"
});
}
}
return JSON.stringify(responses, null, 2);
}
async function stats$1(did) {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
if (!did) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token && !did) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
try {
const client = new RockskyClient(token);
const stats2 = await client.stats(did);
return JSON.stringify(
{
scrobbles: stats2.scrobbles,
tracks: stats2.tracks,
albums: stats2.albums,
artists: stats2.artists,
lovedTracks: stats2.lovedTracks
},
null,
2
);
} catch (err) {
return `Failed to fetch stats data. Please check your token and try again, error: ${err.message}`;
}
}
async function tracks$1(did, { skip, limit = 20 }) {
const client = new RockskyClient();
const tracks2 = await client.getTracks(did, { skip, limit });
let rank = 1;
let response = `Top ${limit} tracks:
`;
for (const track of tracks2) {
response += `${rank} ${track.title} - ${track.artist} - ${track.play_count} plays
`;
rank++;
}
return response;
}
async function whoami$1() {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token) {
return "You are not logged in. Please run `rocksky login <username>.bsky.social` first.";
}
const client = new RockskyClient(token);
try {
const user = await client.getCurrentUser();
return `You are logged in as ${user.handle} (${user.displayName}).
View your profile at: https://rocksky.app/profile/${user.handle}`;
} catch (err) {
return "Failed to fetch user data. Please check your token and try again.";
}
}
class RockskyMcpServer {
server;
client;
constructor() {
this.server = new McpServer({
name: "rocksky-mcp",
version: "0.1.0"
});
this.setupTools();
}
setupTools() {
this.server.tool("whoami", "get the current logged-in user.", async () => {
return {
content: [
{
type: "text",
text: await whoami$1()
}
]
};
});
this.server.tool(
"nowplaying",
"get the currently playing track.",
{
did: z.string().optional().describe(
"the DID or handle of the user to get the now playing track for."
)
},
async ({ did }) => {
return {
content: [
{
type: "text",
text: await nowplaying$1(did)
}
]
};
}
);
this.server.tool(
"scrobbles",
"display recently played tracks (recent scrobbles).",
{
did: z.string().optional().describe("the DID or handle of the user to get the scrobbles for."),
skip: z.number().optional().describe("number of scrobbles to skip"),
limit: z.number().optional().describe("number of scrobbles to limit")
},
async ({ did, skip = 0, limit = 10 }) => {
return {
content: [
{
type: "text",
text: await scrobbles$1(did, { skip, limit })
}
]
};
}
);
this.server.tool(
"my-scrobbles",
"display my recently played tracks (recent scrobbles).",
{
skip: z.number().optional().describe("number of scrobbles to skip"),
limit: z.number().optional().describe("number of scrobbles to limit")
},
async ({ skip = 0, limit = 10 }) => {
return {
content: [
{
type: "text",
text: await myscrobbles({ skip, limit })
}
]
};
}
);
this.server.tool(
"search",
"search for tracks, artists, albums or users.",
{
query: z.string().describe("the search query, e.g., artist, album, title or account"),
limit: z.number().optional().describe("number of results to limit"),
albums: z.boolean().optional().describe("search for albums"),
tracks: z.boolean().optional().describe("search for tracks"),
users: z.boolean().optional().describe("search for users"),
artists: z.boolean().optional().describe("search for artists")
},
async ({
query,
limit = 10,
albums: albums2 = false,
tracks: tracks2 = false,
users = false,
artists: artists2 = false
}) => {
return {
content: [
{
type: "text",
text: await search$1(query, {
limit,
albums: albums2,
tracks: tracks2,
users,
artists: artists2
})
}
]
};
}
);
this.server.tool(
"artists",
"get the user's top artists or current user's artists if no did is provided.",
{
did: z.string().optional().describe("the DID or handle of the user to get artists for."),
limit: z.number().optional().describe("number of results to limit")
},
async ({ did, limit }) => {
return {
content: [
{
type: "text",
text: await artists(did, { skip: 0, limit })
}
]
};
}
);
this.server.tool(
"albums",
"get the user's top albums or current user's albums if no did is provided.",
{
did: z.string().optional().describe("the DID or handle of the user to get albums for."),
limit: z.number().optional().describe("number of results to limit")
},
async ({ did, limit }) => {
return {
content: [
{
type: "text",
text: await albums(did, { skip: 0, limit })
}
]
};
}
);
this.server.tool(
"tracks",
"get the user's top tracks or current user's tracks if no did is provided.",
{
did: z.string().optional().describe("the DID or handle of the user to get tracks for."),
limit: z.number().optional().describe("number of results to limit")
},
async ({ did, limit }) => {
return {
content: [
{
type: "text",
text: await tracks$1(did, { skip: 0, limit })
}
]
};
}
);
this.server.tool(
"stats",
"get the user's listening stats or current user's stats if no did is provided.",
{
did: z.string().optional().describe("the DID or handle of the user to get stats for.")
},
async ({ did }) => {
return {
content: [
{
type: "text",
text: await stats$1(did)
}
]
};
}
);
this.server.tool(
"create-apikey",
"create an API key.",
{
name: z.string().describe("the name of the API key"),
description: z.string().optional().describe("the description of the API key")
},
async ({ name, description }) => {
return {
content: [
{
type: "text",
text: await createApiKey(name, { description })
}
]
};
}
);
}
async run() {
const stdioTransport = new StdioServerTransport();
try {
await this.server.connect(stdioTransport);
} catch (error) {
process.exit(1);
}
}
getServer() {
return this.server;
}
}
const rockskyMcpServer = new RockskyMcpServer();
function mcp() {
rockskyMcpServer.run().catch((error) => {
console.error("Failed to run Rocksky MCP server", { error });
process.exit(1);
});
}
async function nowplaying(did) {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
if (!did) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token && !did) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
const client = new RockskyClient(token);
try {
const nowPlaying = await client.getSpotifyNowPlaying(did);
if (!nowPlaying || Object.keys(nowPlaying).length === 0) {
const nowPlaying2 = await client.getNowPlaying(did);
if (!nowPlaying2 || Object.keys(nowPlaying2).length === 0) {
console.log("No track is currently playing.");
return;
}
console.log(chalk.magenta(`${nowPlaying2.title} - ${nowPlaying2.artist}`));
console.log(`${nowPlaying2.album}`);
return;
}
console.log(
chalk.magenta(
`${nowPlaying.item.name} - ${nowPlaying.item.artists.map((a) => a.name).join(", ")}`
)
);
console.log(`${nowPlaying.item.album.name}`);
} catch (err) {
console.log(err);
console.error(
`Failed to fetch now playing data. Please check your token and try again.`
);
}
}
async function scrobble(track, artist, { timestamp }) {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
const client = new RockskyClient(token);
const apikeys = await client.getApiKeys();
if (!apikeys || apikeys.length === 0 || !apikeys[0].enabled) {
console.error(
`You don't have any API keys. Please create one using ${chalk.greenBright(
"`rocksky create apikey`"
)} command.`
);
return;
}
const signature = md5(
`api_key${apikeys[0].apiKey}artist[0]${artist}methodtrack.scrobblesk${token}timestamp[0]${timestamp || Math.floor(Date.now() / 1e3)}track[0]${track}${apikeys[0].sharedSecret}`
);
await client.scrobble(
apikeys[0].apiKey,
signature,
track,
artist,
timestamp
);
console.log(
`Scrobbled ${chalk.greenBright(track)} by ${chalk.greenBright(
artist
)} at ${chalk.greenBright(
new Date(
(timestamp || Math.floor(Date.now() / 1e3)) * 1e3
).toLocaleString()
)}`
);
}
dayjs.extend(relative);
async function scrobbles(did, { skip, limit }) {
const client = new RockskyClient();
const scrobbles2 = await client.scrobbles(did, { skip, limit });
for (const scrobble of scrobbles2) {
if (did) {
console.log(
`${chalk.bold.magenta(scrobble.title)} ${scrobble.artist} ${chalk.yellow(dayjs(scrobble.created_at + "Z").fromNow())}`
);
continue;
}
const handle = `@${scrobble.user}`;
console.log(
`${chalk.italic.magentaBright(
handle
)} is listening to ${chalk.bold.magenta(scrobble.title)} ${scrobble.artist} ${chalk.yellow(dayjs(scrobble.date).fromNow())}`
);
}
}
async function search(query, { limit = 20, albums = false, artists = false, tracks = false, users = false }) {
const client = new RockskyClient();
const results = await client.search(query, { size: limit });
if (results.records.length === 0) {
console.log(`No results found for ${chalk.magenta(query)}.`);
return;
}
let mergedResults = results.records.map((record) => ({
...record,
type: record.table
}));
if (albums) {
mergedResults = mergedResults.filter((record) => record.table === "albums");
}
if (artists) {
mergedResults = mergedResults.filter(
(record) => record.table === "artists"
);
}
if (tracks) {
mergedResults = mergedResults.filter(({ table }) => table === "tracks");
}
if (users) {
mergedResults = mergedResults.filter(({ table }) => table === "users");
}
mergedResults.sort((a, b) => b.xata_score - a.xata_score);
for (const { table, record } of mergedResults) {
if (table === "users") {
console.log(
`${chalk.bold.magenta(record.handle)} ${record.display_name} ${chalk.yellow(`https://rocksky.app/profile/${record.did}`)}`
);
}
if (table === "albums") {
const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : "";
console.log(
`${chalk.bold.magenta(record.title)} ${record.artist} ${chalk.yellow(
link
)}`
);
}
if (table === "tracks") {
const link = record.uri ? `https://rocksky.app/${record.uri?.split("at://")[1]}` : "";
console.log(
`${chalk.bold.magenta(record.title)} ${record.artist} ${chalk.yellow(
link
)}`
);
}
}
}
async function stats(did) {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
if (!did) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token && !did) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
const client = new RockskyClient(token);
const stats2 = await client.stats(did);
console.log(
table(
[
["Scrobbles", chalk.magenta(stats2.scrobbles)],
["Tracks", chalk.magenta(stats2.tracks)],
["Albums", chalk.magenta(stats2.albums)],
["Artists", chalk.magenta(stats2.artists)],
["Loved Tracks", chalk.magenta(stats2.lovedTracks)]
],
{
border: getBorderCharacters("void"),
columnDefault: {
paddingLeft: 0,
paddingRight: 1
},
drawHorizontalLine: () => false
}
)
);
}
async function tracks(did, { skip, limit }) {
const client = new RockskyClient();
const tracks2 = await client.getTracks(did, { skip, limit });
let rank = 1;
for (const track of tracks2) {
console.log(
`${rank} ${chalk.magenta(track.title)} ${track.artist} ${chalk.yellow(
track.play_count + " plays"
)}`
);
rank++;
}
}
async function whoami() {
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
try {
await fs$1.access(tokenPath);
} catch (err) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
const tokenData = await fs$1.readFile(tokenPath, "utf-8");
const { token } = JSON.parse(tokenData);
if (!token) {
console.error(
`You are not logged in. Please run ${chalk.greenBright(
"`rocksky login <username>.bsky.social`"
)} first.`
);
return;
}
const client = new RockskyClient(token);
try {
const user = await client.getCurrentUser();
console.log(`You are logged in as ${user.handle} (${user.displayName}).`);
console.log(
`View your profile at: ${chalk.magenta(
`https://rocksky.app/profile/${user.handle}`
)}`
);
} catch (err) {
console.error(
`Failed to fetch user data. Please check your token and try again.`
);
}
}
var version = "0.2.0";
var version$1 = {
version: version};
async function login(handle) {
const app = express();
app.use(cors());
app.use(express.json());
const server = app.listen(6996);
app.post("/token", async (req, res) => {
console.log(chalk.bold(chalk.greenBright("Login successful!\n")));
console.log(
"You can use this session key (Token) to authenticate with the API."
);
console.log("Received token (session key):", chalk.green(req.body.token));
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
await fs$1.mkdir(path.dirname(tokenPath), { recursive: true });
await fs$1.writeFile(
tokenPath,
JSON.stringify({ token: req.body.token }, null, 2)
);
res.json({
ok: 1
});
server.close();
});
const response = await axios.post("https://api.rocksky.app/login", {
handle,
cli: true
});
const redirectUrl = response.data;
if (!redirectUrl.includes("authorize")) {
console.error("Failed to login, please check your handle and try again.");
server.close();
return;
}
console.log("Please visit this URL to authorize the app:");
console.log(chalk.cyan(redirectUrl));
open(redirectUrl);
}
const program = new Command();
program.name("rocksky").description(
`Command-line interface for Rocksky (${chalk.underline(
"https://rocksky.app"
)}) \u2013 scrobble tracks, view stats, and manage your listening history.`
).version(version$1.version);
program.command("login").argument("<handle>", "your BlueSky handle (e.g., <username>.bsky.social)").description("login with your BlueSky account and get a session token.").action(login);
program.command("whoami").description("get the current logged-in user.").action(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(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(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(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(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(artists$1);
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(albums$1);
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(tracks);
program.command("scrobble").argument("<track>", "the title of the track").argument("<artist>", "the artist of the track").option("-t, --timestamp <timestamp>", "the timestamp of the scrobble").description("scrobble a track to your profile.").action(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(createApiKey$1);
program.command("mcp").description("Starts an MCP server to use with Claude or other LLMs.").action(mcp);
program.parse(process.argv);