UNPKG

@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
#!/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",