@rocksky/cli
Version:
Command-line interface for Rocksky – scrobble tracks, view stats, and manage your listening history
1,604 lines (1,558 loc) • 276 kB
JavaScript
#!/usr/bin/env node
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 { drizzle as drizzle$1 } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import Database from 'better-sqlite3';
import envpaths from 'env-paths';
import fs$2 from 'node:fs';
import path$1 from 'node:path';
import { fileURLToPath } from 'node:url';
import { Kysely, SqliteDialect } from 'kysely';
import { defineDriver, createStorage } from 'unstorage';
import { IdResolver } from '@atproto/identity';
import { configure, getConsoleSink, getLogger } from '@logtape/logtape';
import { AtpAgent } from '@atproto/api';
import { sql, eq, and, or, gte, lte } from 'drizzle-orm';
import { sqliteTable, integer, text, index, unique } from 'drizzle-orm/sqlite-core';
import dotenv from 'dotenv';
import { cleanEnv, str } from 'envalid';
import crypto from 'node:crypto';
import { v4 } from 'uuid';
import { isValidHandle } from '@atproto/syntax';
import os$1 from 'node:os';
import { Lexicons } from '@atproto/lexicon';
import { TID } from '@atproto/common';
import { createId } from '@paralleldrive/cuid2';
import _ from 'lodash';
import { CarReader } from '@ipld/car';
import * as cbor from '@ipld/dag-cbor';
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';
import { Hono } from 'hono';
import { logger as logger$1 } from 'hono/logger';
import { cors as cors$1 } from 'hono/cors';
import { serve } from '@hono/node-server';
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}/xrpc/app.rocksky.feed.search?query=${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 matchSong(title, artist) {
const q = new URLSearchParams({
title,
artist
});
const response = await fetch(
`${ROCKSKY_API_URL}/xrpc/app.rocksky.song.matchSong?${q.toString()}`
);
if (!response.ok) {
throw new Error(
`Failed to match song: ${response.statusText} ${await response.text()}`
);
}
return response.json();
}
}
async function albums$2(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$2(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$1(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$1(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$2(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$2(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$2(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$1(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$1(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$2(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.`
);
}
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path$1.dirname(__filename);
fs$2.mkdirSync(envpaths("rocksky", { suffix: "" }).data, { recursive: true });
const url = `${envpaths("rocksky", { suffix: "" }).data}/rocksky.sqlite`;
const sqlite = new Database(url);
const db = drizzle$1(sqlite);
let initialized = false;
async function initializeDatabase() {
if (initialized) {
return;
}
try {
let migrationsFolder = path$1.join(__dirname, "../drizzle");
if (!fs$2.existsSync(migrationsFolder)) {
migrationsFolder = path$1.join(__dirname, "../../drizzle");
}
if (fs$2.existsSync(migrationsFolder)) {
migrate(db, { migrationsFolder });
initialized = true;
} else {
initialized = true;
}
} catch (error) {
console.error("Failed to run migrations:", error);
throw error;
}
}
var drizzle = { db};
const DRIVER_NAME = "sqlite";
var sqliteKv = defineDriver(
({
location,
table = "kv",
getDb = () => {
let _db = null;
return (() => {
if (_db) {
return _db;
}
if (!location) {
throw new Error("SQLite location is required");
}
const sqlite = new Database(location, { fileMustExist: false });
sqlite.pragma("journal_mode = WAL");
_db = new Kysely({
dialect: new SqliteDialect({
database: sqlite
})
});
_db.schema.createTable(table).ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("value", "text", (col) => col.notNull()).addColumn("created_at", "text", (col) => col.notNull()).addColumn("updated_at", "text", (col) => col.notNull()).execute();
return _db;
})();
}
}) => {
return {
name: DRIVER_NAME,
options: { location, table },
getInstance: getDb,
async hasItem(key) {
const result = await getDb().selectFrom(table).select(["id"]).where("id", "=", key).executeTakeFirst();
return !!result;
},
async getItem(key) {
const result = await getDb().selectFrom(table).select(["value"]).where("id", "=", key).executeTakeFirst();
return result?.value ?? null;
},
async setItem(key, value) {
const now = (/* @__PURE__ */ new Date()).toISOString();
await getDb().insertInto(table).values({
id: key,
value,
created_at: now,
updated_at: now
}).onConflict(
(oc) => oc.column("id").doUpdateSet({
value,
updated_at: now
})
).execute();
},
async setItems(items) {
const now = (/* @__PURE__ */ new Date()).toISOString();
await getDb().transaction().execute(async (trx) => {
await Promise.all(
items.map(({ key, value }) => {
return trx.insertInto(table).values({
id: key,
value,
created_at: now,
updated_at: now
}).onConflict(
(oc) => oc.column("id").doUpdateSet({
value,
updated_at: now
})
).execute();
})
);
});
},
async removeItem(key) {
await getDb().deleteFrom(table).where("id", "=", key).execute();
},
async getMeta(key) {
const result = await getDb().selectFrom(table).select(["created_at", "updated_at"]).where("id", "=", key).executeTakeFirst();
if (!result) {
return null;
}
return {
birthtime: new Date(result.created_at),
mtime: new Date(result.updated_at)
};
},
async getKeys(base = "") {
const results = await getDb().selectFrom(table).select(["id"]).where("id", "like", `${base}%`).execute();
return results.map((r) => r.id);
},
async clear() {
await getDb().deleteFrom(table).execute();
},
async dispose() {
await getDb().destroy();
}
};
}
);
const HOUR$1 = 6e4 * 60;
const DAY$1 = HOUR$1 * 24;
class StorageCache {
staleTTL;
maxTTL;
cache;
prefix;
constructor({
store,
prefix,
staleTTL,
maxTTL
}) {
this.cache = store;
this.prefix = prefix;
this.staleTTL = staleTTL ?? HOUR$1;
this.maxTTL = maxTTL ?? DAY$1;
}
async cacheDid(did, doc) {
await this.cache.set(this.prefix + did, { doc, updatedAt: Date.now() });
}
async refreshCache(did, getDoc) {
const doc = await getDoc();
if (doc) {
await this.cacheDid(did, doc);
}
}
async checkCache(did) {
const val = await this.cache.get(this.prefix + did);
if (!val) return null;
const now = Date.now();
const expired = now > val.updatedAt + this.maxTTL;
const stale = now > val.updatedAt + this.staleTTL;
return {
...val,
did,
stale,
expired
};
}
async clearEntry(did) {
await this.cache.remove(this.prefix + did);
}
async clear() {
await this.cache.clear(this.prefix);
}
}
const HOUR = 6e4 * 60;
const DAY = HOUR * 24;
const WEEK = HOUR * 7;
function createIdResolver(kv) {
return new IdResolver({
didCache: new StorageCache({
store: kv,
prefix: "didCache:",
staleTTL: DAY,
maxTTL: WEEK
})
});
}
function createBidirectionalResolver(resolver) {
return {
async resolveDidToHandle(did) {
const didDoc = await resolver.did.resolveAtprotoData(did);
resolver.handle.resolve(didDoc.handle).then((resolvedHandle) => {
if (resolvedHandle !== did) {
resolver.did.ensureResolve(did, true);
}
});
return didDoc?.handle ?? did;
},
async resolveDidsToHandles(dids) {
const didHandleMap = {};
const resolves = await Promise.all(
dids.map((did) => this.resolveDidToHandle(did).catch((_) => did))
);
for (let i = 0; i < dids.length; i++) {
didHandleMap[dids[i]] = resolves[i];
}
return didHandleMap;
}
};
}
fs$2.mkdirSync(envpaths("rocksky", { suffix: "" }).data, { recursive: true });
const kvPath = `${envpaths("rocksky", { suffix: "" }).data}/rocksky-kv.sqlite`;
const kv = createStorage({
driver: sqliteKv({ location: kvPath, table: "kv" })
});
const baseIdResolver = createIdResolver(kv);
const ctx = {
db: drizzle.db,
resolver: createBidirectionalResolver(baseIdResolver),
baseIdResolver,
kv
};
await configure({
sinks: {
console: getConsoleSink(),
meta: getConsoleSink()
},
loggers: [
{ category: "@rocksky/cli", lowestLevel: "debug", sinks: ["console"] },
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["meta"]
}
]
});
const logger = getLogger("@rocksky/cli");
async function matchTrack(track, artist) {
let match;
const cached = await ctx.kv.getItem(`${track} - ${artist}`);
const client = new RockskyClient();
if (cached) {
match = cached;
client.matchSong(track, artist).then((newMatch) => {
if (newMatch) {
ctx.kv.setItem(`${track} - ${artist}`.toLowerCase(), newMatch);
}
});
} else {
match = await client.matchSong(track, artist);
await ctx.kv.setItem(`${track} - ${artist}`.toLowerCase(), match);
}
if (!match.title || !match.artist) {
logger.error`Failed to match track ${track} by ${artist}`;
return null;
}
logger.info`💿 Matched track ${match.title} by ${match.artist}`;
return match;
}
const authSessions = sqliteTable("auth_sessions", {
key: text("key").primaryKey().notNull(),
session: text("session").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
});
async function extractPdsFromDid(did) {
let didDocUrl;
if (did.startsWith("did:plc:")) {
didDocUrl = `https://plc.directory/${did}`;
} else if (did.startsWith("did:web:")) {
const domain = did.substring("did:web:".length);
didDocUrl = `https://${domain}/.well-known/did.json`;
} else {
throw new Error("Unsupported DID method");
}
const response = await fetch(didDocUrl);
if (!response.ok) throw new Error("Failed to fetch DID doc");
const doc = await response.json();
const pdsService = doc.service?.find(
(s) => s.type === "AtprotoPersonalDataServer" && s.id.endsWith("#atproto_pds")
);
return pdsService?.serviceEndpoint ?? null;
}
dotenv.config();
const env = cleanEnv(process.env, {
ROCKSKY_IDENTIFIER: str({ default: "" }),
ROCKSKY_HANDLE: str({ default: "" }),
ROCKSKY_PASSWORD: str({ default: "" }),
JETSTREAM_SERVER: str({
default: "wss://jetstream1.us-west.bsky.network/subscribe"
}),
ROCKSKY_API_KEY: str({ default: crypto.randomBytes(16).toString("hex") }),
ROCKSKY_SHARED_SECRET: str({
default: crypto.randomBytes(16).toString("hex")
}),
ROCKSKY_SESSION_KEY: str({
default: crypto.randomBytes(16).toString("hex")
}),
ROCKSKY_WEBSCROBBLER_KEY: str({
default: v4()
})
});
async function createAgent(did, handle) {
const pds = await extractPdsFromDid(did);
const agent = new AtpAgent({
service: new URL(pds)
});
try {
const [data] = await ctx.db.select().from(authSessions).where(eq(authSessions.key, did)).execute();
if (!data) {
throw new Error("No session found");
}
await agent.resumeSession(JSON.parse(data.session));
return agent;
} catch (e) {
logger.error`Resuming session ${did}`;
await ctx.db.delete(authSessions).where(eq(authSessions.key, did)).execute();
await agent.login({
identifier: handle,
password: env.ROCKSKY_PASSWORD
});
await ctx.db.insert(authSessions).values({
key: did,
session: JSON.stringify(agent.session)
}).onConflictDoUpdate({
target: authSessions.key,
set: { session: JSON.stringify(agent.session) }
}).execute();
logger.info`Logged in as ${handle}`;
return agent;
}
}
async function getDidAndHandle() {
let handle = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER;
let did = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER;
if (!handle) {
console.error(
`\u274C No AT Proto handle or DID provided, please provide one in the environment variables ${chalk.bold("ROCKSKY_HANDLE")} or ${chalk.bold("ROCKSKY_IDENTIFIER")}`
);
process.exit(1);
}
if (!env.ROCKSKY_PASSWORD) {
console.error(
`\u274C No app password provided, please provide one in the environment variable ${chalk.bold("ROCKSKY_PASSWORD")}
You can create one at ${chalk.blueBright("https://bsky.app/settings/app-passwords")}`
);
process.exit(1);
}
if (handle.startsWith("did:plc:") || handle.startsWith("did:web:")) {
handle = await ctx.resolver.resolveDidToHandle(handle);
}
if (!isValidHandle(handle)) {
logger.error`❌ Invalid handle: ${handle}`;
process.exit(1);
}
if (!did.startsWith("did:plc:") && !did.startsWith("did:web:")) {
did = await ctx.baseIdResolver.handle.resolve(did);
}
return [did, handle];
}
const albums = sqliteTable("albums", {
id: text("id").primaryKey().notNull(),
title: text("title").notNull(),
artist: text("artist").notNull(),
releaseDate: text("release_date"),
year: integer("year"),
albumArt: text("album_art"),
uri: text("uri").unique(),
cid: text("cid").unique().notNull(),
artistUri: text("artist_uri"),
appleMusicLink: text("apple_music_link").unique(),
spotifyLink: text("spotify_link").unique(),
tidalLink: text("tidal_link").unique(),
youtubeLink: text("youtube_link").unique(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
});
const tracks$1 = sqliteTable(
"tracks",
{
id: text("id").primaryKey().notNull(),
title: text("title").notNull(),
artist: text("artist").notNull(),
albumArtist: text("album_artist").notNull(),
albumArt: text("album_art"),
album: text("album").notNull(),
trackNumber: integer("track_number"),
duration: integer("duration").notNull(),
mbId: text("mb_id").unique(),
youtubeLink: text("youtube_link").unique(),
spotifyLink: text("spotify_link").unique(),
appleMusicLink: text("apple_music_link").unique(),
tidalLink: text("tidal_link").unique(),
discNumber: integer("disc_number"),
lyrics: text("lyrics"),
composer: text("composer"),
genre: text("genre"),
label: text("label"),
copyrightMessage: text("copyright_message"),
uri: text("uri").unique(),
cid: text("cid").unique().notNull(),
albumUri: text("album_uri"),
artistUri: text("artist_uri"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
},
(t) => ({
idx_title_artist_album_albumartist: index(
"idx_title_artist_album_albumartist"
).on(t.title, t.artist, t.album, t.albumArtist)
})
);
const albumTracks = sqliteTable(
"album_tracks",
{
id: text("id").primaryKey().notNull(),
albumId: text("album_id").notNull().references(() => albums.id),
trackId: text("track_id").notNull().references(() => tracks$1.id),
createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at").notNull().default(sql`(unixepoch())`)
},
(t) => [unique("album_tracks_unique_index").on(t.albumId, t.trackId)]
);
const artists = sqliteTable("artists", {
id: text("id").primaryKey().notNull(),
name: text("name").notNull(),
biography: text("biography"),
born: integer("born", { mode: "timestamp" }),
bornIn: text("born_in"),
died: integer("died", { mode: "timestamp" }),
picture: text("picture"),
uri: text("uri").unique(),
cid: text("cid").unique().notNull(),
appleMusicLink: text("apple_music_link"),
spotifyLink: text("spotify_link"),
tidalLink: text("tidal_link"),
youtubeLink: text("youtube_link"),
genres: text("genres"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
});
sqliteTable(
"artist_albums",
{
id: text("id").primaryKey().notNull(),
artistId: text("artist_id").notNull().references(() => artists.id),
albumId: text("album_id").notNull().references(() => albums.id),
createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at").notNull().default(sql`(unixepoch())`)
},
(t) => [unique("artist_albums_unique_index").on(t.artistId, t.albumId)]
);
const artistGenres = sqliteTable(
"artist_genres ",
{
id: text("id").primaryKey().notNull(),
artistId: text("artist_id").notNull(),
genreId: text("genre_id").notNull()
},
(t) => [unique("artist_genre_unique_index").on(t.artistId, t.genreId)]
);
sqliteTable(
"artist_tracks",
{
id: text("id").primaryKey().notNull(),
artistId: text("artist_id").notNull().references(() => artists.id),
trackId: text("track_id").notNull().references(() => tracks$1.id),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
},
(t) => [unique("artist_tracks_unique_index").on(t.artistId, t.trackId)]
);
const genres = sqliteTable("genres", {
id: text("id").primaryKey().notNull(),
name: text("name").unique().notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
});
const users = sqliteTable("users", {
id: text("id").primaryKey().notNull(),
did: text("did").unique().notNull(),
displayName: text("display_name"),
handle: text("handle").unique().notNull(),
avatar: text("avatar").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
});
sqliteTable(
"loved_tracks",
{
id: text("id").primaryKey().notNull(),
userId: text("user_id").notNull().references(() => users.id),
trackId: text("track_id").notNull().references(() => tracks$1.id),
uri: text("uri").unique(),
createdAt: integer("created_at").notNull().default(sql`(unixepoch())`)
},
(t) => [unique("loved_tracks_unique_index").on(t.userId, t.trackId)]
);
const scrobbles$1 = sqliteTable("scrobbles", {
id: text("xata_id").primaryKey().notNull(),
userId: text("user_id").references(() => users.id),
trackId: text("track_id").references(() => tracks$1.id),
albumId: text("album_id").references(() => albums.id),
artistId: text("artist_id").references(() => artists.id),
uri: text("uri").unique(),
cid: text("cid").unique(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
timestamp: integer("timestamp", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
});
const userAlbums = sqliteTable(
"user_albums",
{
id: text("id").primaryKey().notNull(),
userId: text("user_id").notNull().references(() => users.id),
albumId: text("album_id").notNull().references(() => albums.id),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
scrobbles: integer("scrobbles"),
uri: text("uri").unique().notNull()
},
(t) => [unique("user_albums_unique_index").on(t.userId, t.albumId)]
);
const userArtists = sqliteTable(
"user_artists",
{
id: text("id").primaryKey().notNull(),
userId: text("user_id").notNull().references(() => users.id),
artistId: text("artist_id").notNull().references(() => artists.id),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
scrobbles: integer("scrobbles"),
uri: text("uri").unique().notNull()
},
(t) => [unique("user_artists_unique_index").on(t.userId, t.artistId)]
);
const userTracks = sqliteTable(
"user_tracks",
{
id: text("id").primaryKey().notNull(),
userId: text("user_id").notNull().references(() => users.id),
trackId: text("track_id").notNull().references(() => tracks$1.id),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
scrobbles: integer("scrobbles"),
uri: text("uri").unique().notNull()
},
(t) => [unique("user_tracks_unique_index").on(t.userId, t.trackId)]
);
var schema = {
users,
tracks: tracks$1,
artists,
albums,
albumTracks,
scrobbles: scrobbles$1,
userAlbums,
userArtists,
userTracks,
genres,
artistGenres
};
const schemaDict = {
AppRockskyActorDefs: {
lexicon: 1,
id: "app.rocksky.actor.defs",
defs: {
profileViewDetailed: {
type: "object",
properties: {
id: {
type: "string",
description: "The unique identifier of the actor."
},
did: {
type: "string",
description: "The DID of the actor."
},
handle: {
type: "string",
description: "The handle of the actor."
},
displayName: {
type: "string",
description: "The display name of the actor."
},
avatar: {
type: "string",
description: "The URL of the actor's avatar image.",
format: "uri"
},
createdAt: {
type: "string",