@rocksky/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@rocksky/cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line interface for Rocksky – scrobble tracks, view stats, and manage your listening history",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "rocksky": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "test": "echo \"Error: no test specified\" && exit 1",
12
+ "dev": "tsx ./src/index.ts",
13
+ "build": "pkgroll && chmod +x ./dist/index.js"
14
+ },
15
+ "keywords": [
16
+ "audioscrobbler",
17
+ "last.fm",
18
+ "atproto",
19
+ "bluesky",
20
+ "cli"
21
+ ],
22
+ "author": "Tsiry Sandratraina <tsiry.sndr@rocksky.app>",
23
+ "license": "Apache-2.0",
24
+ "dependencies": {
25
+ "axios": "^1.8.4",
26
+ "chalk": "^5.4.1",
27
+ "commander": "^13.1.0",
28
+ "cors": "^2.8.5",
29
+ "dayjs": "^1.11.13",
30
+ "express": "^5.1.0",
31
+ "md5": "^2.3.0",
32
+ "open": "^10.1.0",
33
+ "table": "^6.9.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/express": "^5.0.1",
37
+ "@types/node": "^22.14.1",
38
+ "pkgroll": "^2.12.1",
39
+ "tsx": "^4.19.3",
40
+ "typescript": "^5.8.3"
41
+ },
42
+ "exports": {
43
+ ".": {
44
+ "import": "./dist/index.js"
45
+ }
46
+ }
47
+ }
package/src/client.ts ADDED
@@ -0,0 +1,324 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+
5
+ export const ROCKSKY_API_URL = "https://api.rocksky.app";
6
+
7
+ export class RockskyClient {
8
+ constructor(private readonly token?: string) {
9
+ this.token = token;
10
+ }
11
+
12
+ async getCurrentUser() {
13
+ const response = await fetch(`${ROCKSKY_API_URL}/profile`, {
14
+ method: "GET",
15
+ headers: {
16
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
17
+ "Content-Type": "application/json",
18
+ },
19
+ });
20
+
21
+ if (!response.ok) {
22
+ throw new Error(`Failed to fetch user data: ${response.statusText}`);
23
+ }
24
+
25
+ return response.json();
26
+ }
27
+
28
+ async getSpotifyNowPlaying(did?: string) {
29
+ const response = await fetch(
30
+ `${ROCKSKY_API_URL}/spotify/currently-playing` +
31
+ (did ? `?did=${did}` : ""),
32
+ {
33
+ method: "GET",
34
+ headers: {
35
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
36
+ "Content-Type": "application/json",
37
+ },
38
+ }
39
+ );
40
+
41
+ if (!response.ok) {
42
+ throw new Error(
43
+ `Failed to fetch now playing data: ${response.statusText}`
44
+ );
45
+ }
46
+
47
+ return response.json();
48
+ }
49
+
50
+ async getNowPlaying(did?: string) {
51
+ const response = await fetch(
52
+ `${ROCKSKY_API_URL}/now-playing` + (did ? `?did=${did}` : ""),
53
+ {
54
+ method: "GET",
55
+ headers: {
56
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
57
+ "Content-Type": "application/json",
58
+ },
59
+ }
60
+ );
61
+
62
+ if (!response.ok) {
63
+ throw new Error(
64
+ `Failed to fetch now playing data: ${response.statusText}`
65
+ );
66
+ }
67
+
68
+ return response.json();
69
+ }
70
+
71
+ async scrobbles(did?: string, { skip = 0, limit = 20 } = {}) {
72
+ if (did) {
73
+ const response = await fetch(
74
+ `${ROCKSKY_API_URL}/users/${did}/scrobbles?offset=${skip}&size=${limit}`,
75
+ {
76
+ method: "GET",
77
+ headers: {
78
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
79
+ "Content-Type": "application/json",
80
+ },
81
+ }
82
+ );
83
+ if (!response.ok) {
84
+ throw new Error(
85
+ `Failed to fetch scrobbles data: ${response.statusText}`
86
+ );
87
+ }
88
+ return response.json();
89
+ }
90
+
91
+ const response = await fetch(
92
+ `${ROCKSKY_API_URL}/public/scrobbles?offset=${skip}&size=${limit}`,
93
+ {
94
+ method: "GET",
95
+ headers: {
96
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
97
+ "Content-Type": "application/json",
98
+ },
99
+ }
100
+ );
101
+ if (!response.ok) {
102
+ throw new Error(`Failed to fetch scrobbles data: ${response.statusText}`);
103
+ }
104
+
105
+ return response.json();
106
+ }
107
+
108
+ async search(query: string, { size }) {
109
+ const response = await fetch(
110
+ `${ROCKSKY_API_URL}/search?q=${query}&size=${size}`,
111
+ {
112
+ method: "GET",
113
+ headers: {
114
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
115
+ "Content-Type": "application/json",
116
+ },
117
+ }
118
+ );
119
+
120
+ if (!response.ok) {
121
+ throw new Error(`Failed to fetch search data: ${response.statusText}`);
122
+ }
123
+
124
+ return response.json();
125
+ }
126
+
127
+ async stats(did?: string) {
128
+ if (!did) {
129
+ const didFile = path.join(os.homedir(), ".rocksky", "did");
130
+ try {
131
+ await fs.promises.access(didFile);
132
+ did = await fs.promises.readFile(didFile, "utf-8");
133
+ } catch (err) {
134
+ const user = await this.getCurrentUser();
135
+ did = user.did;
136
+ const didPath = path.join(os.homedir(), ".rocksky");
137
+ fs.promises.mkdir(didPath, { recursive: true });
138
+ await fs.promises.writeFile(didFile, did);
139
+ }
140
+ }
141
+
142
+ const response = await fetch(`${ROCKSKY_API_URL}/users/${did}/stats`, {
143
+ method: "GET",
144
+ headers: {
145
+ "Content-Type": "application/json",
146
+ },
147
+ });
148
+
149
+ if (!response.ok) {
150
+ throw new Error(`Failed to fetch stats data: ${response.statusText}`);
151
+ }
152
+
153
+ return response.json();
154
+ }
155
+
156
+ async getArtists(did?: string, { skip = 0, limit = 20 } = {}) {
157
+ if (!did) {
158
+ const didFile = path.join(os.homedir(), ".rocksky", "did");
159
+ try {
160
+ await fs.promises.access(didFile);
161
+ did = await fs.promises.readFile(didFile, "utf-8");
162
+ } catch (err) {
163
+ const user = await this.getCurrentUser();
164
+ did = user.did;
165
+ const didPath = path.join(os.homedir(), ".rocksky");
166
+ fs.promises.mkdir(didPath, { recursive: true });
167
+ await fs.promises.writeFile(didFile, did);
168
+ }
169
+ }
170
+
171
+ const response = await fetch(
172
+ `${ROCKSKY_API_URL}/users/${did}/artists?offset=${skip}&size=${limit}`,
173
+ {
174
+ method: "GET",
175
+ headers: {
176
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
177
+ "Content-Type": "application/json",
178
+ },
179
+ }
180
+ );
181
+ if (!response.ok) {
182
+ throw new Error(`Failed to fetch artists data: ${response.statusText}`);
183
+ }
184
+ return response.json();
185
+ }
186
+
187
+ async getAlbums(did?: string, { skip = 0, limit = 20 } = {}) {
188
+ if (!did) {
189
+ const didFile = path.join(os.homedir(), ".rocksky", "did");
190
+ try {
191
+ await fs.promises.access(didFile);
192
+ did = await fs.promises.readFile(didFile, "utf-8");
193
+ } catch (err) {
194
+ const user = await this.getCurrentUser();
195
+ did = user.did;
196
+ const didPath = path.join(os.homedir(), ".rocksky");
197
+ fs.promises.mkdir(didPath, { recursive: true });
198
+ await fs.promises.writeFile(didFile, did);
199
+ }
200
+ }
201
+
202
+ const response = await fetch(
203
+ `${ROCKSKY_API_URL}/users/${did}/albums?offset=${skip}&size=${limit}`,
204
+ {
205
+ method: "GET",
206
+ headers: {
207
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
208
+ "Content-Type": "application/json",
209
+ },
210
+ }
211
+ );
212
+ if (!response.ok) {
213
+ throw new Error(`Failed to fetch albums data: ${response.statusText}`);
214
+ }
215
+ return response.json();
216
+ }
217
+
218
+ async getTracks(did?: string, { skip = 0, limit = 20 } = {}) {
219
+ if (!did) {
220
+ const didFile = path.join(os.homedir(), ".rocksky", "did");
221
+ try {
222
+ await fs.promises.access(didFile);
223
+ did = await fs.promises.readFile(didFile, "utf-8");
224
+ } catch (err) {
225
+ const user = await this.getCurrentUser();
226
+ did = user.did;
227
+ const didPath = path.join(os.homedir(), ".rocksky");
228
+ fs.promises.mkdir(didPath, { recursive: true });
229
+ await fs.promises.writeFile(didFile, did);
230
+ }
231
+ }
232
+
233
+ const response = await fetch(
234
+ `${ROCKSKY_API_URL}/users/${did}/tracks?offset=${skip}&size=${limit}`,
235
+ {
236
+ method: "GET",
237
+ headers: {
238
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
239
+ "Content-Type": "application/json",
240
+ },
241
+ }
242
+ );
243
+ if (!response.ok) {
244
+ throw new Error(`Failed to fetch tracks data: ${response.statusText}`);
245
+ }
246
+ return response.json();
247
+ }
248
+
249
+ async scrobble(api_key, api_sig, track, artist, timestamp) {
250
+ const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
251
+ try {
252
+ await fs.promises.access(tokenPath);
253
+ } catch (err) {
254
+ console.error(
255
+ `You are not logged in. Please run the login command first.`
256
+ );
257
+ return;
258
+ }
259
+ const tokenData = await fs.promises.readFile(tokenPath, "utf-8");
260
+ const { token: sk } = JSON.parse(tokenData);
261
+ const response = await fetch("https://audioscrobbler.rocksky.app/2.0", {
262
+ method: "POST",
263
+ headers: {
264
+ "Content-Type": "application/x-www-form-urlencoded",
265
+ },
266
+ body: new URLSearchParams({
267
+ method: "track.scrobble",
268
+ "track[0]": track,
269
+ "artist[0]": artist,
270
+ "timestamp[0]": timestamp || Math.floor(Date.now() / 1000),
271
+ api_key,
272
+ api_sig,
273
+ sk,
274
+ format: "json",
275
+ }),
276
+ });
277
+
278
+ if (!response.ok) {
279
+ throw new Error(
280
+ `Failed to scrobble track: ${
281
+ response.statusText
282
+ } ${await response.text()}`
283
+ );
284
+ }
285
+
286
+ return response.json();
287
+ }
288
+
289
+ async getApiKeys() {
290
+ const response = await fetch(`${ROCKSKY_API_URL}/apikeys`, {
291
+ method: "GET",
292
+ headers: {
293
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
294
+ "Content-Type": "application/json",
295
+ },
296
+ });
297
+
298
+ if (!response.ok) {
299
+ throw new Error(`Failed to fetch API keys: ${response.statusText}`);
300
+ }
301
+
302
+ return response.json();
303
+ }
304
+
305
+ async createApiKey(name: string, description?: string) {
306
+ const response = await fetch(`${ROCKSKY_API_URL}/apikeys`, {
307
+ method: "POST",
308
+ headers: {
309
+ Authorization: this.token ? `Bearer ${this.token}` : undefined,
310
+ "Content-Type": "application/json",
311
+ },
312
+ body: JSON.stringify({
313
+ name,
314
+ description,
315
+ }),
316
+ });
317
+
318
+ if (!response.ok) {
319
+ throw new Error(`Failed to create API key: ${response.statusText}`);
320
+ }
321
+
322
+ return response.json();
323
+ }
324
+ }
@@ -0,0 +1,16 @@
1
+ import chalk from "chalk";
2
+ import { RockskyClient } from "client";
3
+
4
+ export async function albums(did, { skip, limit }) {
5
+ const client = new RockskyClient();
6
+ const albums = await client.getAlbums(did, { skip, limit });
7
+ let rank = 1;
8
+ for (const album of albums) {
9
+ console.log(
10
+ `${rank} ${chalk.magenta(album.title)} ${album.artist} ${chalk.yellow(
11
+ album.play_count + " plays"
12
+ )}`
13
+ );
14
+ rank++;
15
+ }
16
+ }
@@ -0,0 +1,16 @@
1
+ import chalk from "chalk";
2
+ import { RockskyClient } from "client";
3
+
4
+ export async function artists(did, { skip, limit }) {
5
+ const client = new RockskyClient();
6
+ const artists = await client.getArtists(did, { skip, limit });
7
+ let rank = 1;
8
+ for (const artist of artists) {
9
+ console.log(
10
+ `${rank} ${chalk.magenta(artist.name)} ${chalk.yellow(
11
+ artist.play_count + " plays"
12
+ )}`
13
+ );
14
+ rank++;
15
+ }
16
+ }
@@ -0,0 +1,45 @@
1
+ import chalk from "chalk";
2
+ import { RockskyClient } from "client";
3
+ import fs from "fs/promises";
4
+ import os from "os";
5
+ import path from "path";
6
+
7
+ export async function createApiKey(name, { description }) {
8
+ const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
9
+ try {
10
+ await fs.access(tokenPath);
11
+ } catch (err) {
12
+ console.error(
13
+ `You are not logged in. Please run ${chalk.greenBright(
14
+ "`rocksky login <username>.bsky.social`"
15
+ )} first.`
16
+ );
17
+ return;
18
+ }
19
+
20
+ const tokenData = await fs.readFile(tokenPath, "utf-8");
21
+ const { token } = JSON.parse(tokenData);
22
+ if (!token) {
23
+ console.error(
24
+ `You are not logged in. Please run ${chalk.greenBright(
25
+ "`rocksky login <username>.bsky.social`"
26
+ )} first.`
27
+ );
28
+ return;
29
+ }
30
+
31
+ const client = new RockskyClient(token);
32
+ const apikey = await client.createApiKey(name, description);
33
+ if (!apikey) {
34
+ console.error(`Failed to create API key. Please try again later.`);
35
+ return;
36
+ }
37
+
38
+ console.log(`API key created successfully!`);
39
+ console.log(`Name: ${chalk.greenBright(apikey.name)}`);
40
+ if (apikey.description) {
41
+ console.log(`Description: ${chalk.greenBright(apikey.description)}`);
42
+ }
43
+ console.log(`Key: ${chalk.greenBright(apikey.api_key)}`);
44
+ console.log(`Secret: ${chalk.greenBright(apikey.shared_secret)}`);
45
+ }
@@ -0,0 +1,55 @@
1
+ import axios from "axios";
2
+ import chalk from "chalk";
3
+ import cors from "cors";
4
+ import express, { Request, Response } from "express";
5
+ import fs from "fs/promises";
6
+ import open from "open";
7
+ import os from "os";
8
+ import path from "path";
9
+
10
+ export async function login(handle: string): Promise<void> {
11
+ const app = express();
12
+ app.use(cors());
13
+ app.use(express.json());
14
+
15
+ const server = app.listen(6996);
16
+
17
+ app.post("/token", async (req: Request, res: Response) => {
18
+ console.log(chalk.bold(chalk.greenBright("Login successful!\n")));
19
+ console.log(
20
+ "You can use this session key (Token) to authenticate with the API."
21
+ );
22
+ console.log("Received token (session key):", chalk.green(req.body.token));
23
+
24
+ const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
25
+ await fs.mkdir(path.dirname(tokenPath), { recursive: true });
26
+ await fs.writeFile(
27
+ tokenPath,
28
+ JSON.stringify({ token: req.body.token }, null, 2)
29
+ );
30
+
31
+ res.json({
32
+ ok: 1,
33
+ });
34
+
35
+ server.close();
36
+ });
37
+
38
+ const response = await axios.post("https://api.rocksky.app/login", {
39
+ handle,
40
+ cli: true,
41
+ });
42
+
43
+ const redirectUrl = response.data;
44
+
45
+ if (!redirectUrl.includes("authorize")) {
46
+ console.error("Failed to login, please check your handle and try again.");
47
+ server.close();
48
+ return;
49
+ }
50
+
51
+ console.log("Please visit this URL to authorize the app:");
52
+ console.log(chalk.cyan(redirectUrl));
53
+
54
+ open(redirectUrl);
55
+ }
@@ -0,0 +1,61 @@
1
+ import chalk from "chalk";
2
+ import { RockskyClient } from "client";
3
+ import fs from "fs/promises";
4
+ import os from "os";
5
+ import path from "path";
6
+
7
+ export async function nowplaying(did?: string) {
8
+ const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
9
+ try {
10
+ await fs.access(tokenPath);
11
+ } catch (err) {
12
+ if (!did) {
13
+ console.error(
14
+ `You are not logged in. Please run ${chalk.greenBright(
15
+ "`rocksky login <username>.bsky.social`"
16
+ )} first.`
17
+ );
18
+ return;
19
+ }
20
+ }
21
+
22
+ const tokenData = await fs.readFile(tokenPath, "utf-8");
23
+ const { token } = JSON.parse(tokenData);
24
+ if (!token && !did) {
25
+ console.error(
26
+ `You are not logged in. Please run ${chalk.greenBright(
27
+ "`rocksky login <username>.bsky.social`"
28
+ )} first.`
29
+ );
30
+ return;
31
+ }
32
+
33
+ const client = new RockskyClient(token);
34
+ try {
35
+ const nowPlaying = await client.getSpotifyNowPlaying(did);
36
+ if (!nowPlaying || Object.keys(nowPlaying).length === 0) {
37
+ const nowPlaying = await client.getNowPlaying(did);
38
+ if (!nowPlaying || Object.keys(nowPlaying).length === 0) {
39
+ console.log("No track is currently playing.");
40
+ return;
41
+ }
42
+ console.log(chalk.magenta(`${nowPlaying.title} - ${nowPlaying.artist}`));
43
+ console.log(`${nowPlaying.album}`);
44
+ return;
45
+ }
46
+
47
+ console.log(
48
+ chalk.magenta(
49
+ `${nowPlaying.item.name} - ${nowPlaying.item.artists
50
+ .map((a) => a.name)
51
+ .join(", ")}`
52
+ )
53
+ );
54
+ console.log(`${nowPlaying.item.album.name}`);
55
+ } catch (err) {
56
+ console.log(err);
57
+ console.error(
58
+ `Failed to fetch now playing data. Please check your token and try again.`
59
+ );
60
+ }
61
+ }
@@ -0,0 +1,69 @@
1
+ import chalk from "chalk";
2
+ import { RockskyClient } from "client";
3
+ import fs from "fs/promises";
4
+ import md5 from "md5";
5
+ import os from "os";
6
+ import path from "path";
7
+
8
+ export async function scrobble(track, artist, { timestamp }) {
9
+ const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
10
+ try {
11
+ await fs.access(tokenPath);
12
+ } catch (err) {
13
+ console.error(
14
+ `You are not logged in. Please run ${chalk.greenBright(
15
+ "`rocksky login <username>.bsky.social`"
16
+ )} first.`
17
+ );
18
+ return;
19
+ }
20
+
21
+ const tokenData = await fs.readFile(tokenPath, "utf-8");
22
+ const { token } = JSON.parse(tokenData);
23
+ if (!token) {
24
+ console.error(
25
+ `You are not logged in. Please run ${chalk.greenBright(
26
+ "`rocksky login <username>.bsky.social`"
27
+ )} first.`
28
+ );
29
+ return;
30
+ }
31
+
32
+ const client = new RockskyClient(token);
33
+ const apikeys = await client.getApiKeys();
34
+
35
+ if (!apikeys || apikeys.length === 0 || !apikeys[0].enabled) {
36
+ console.error(
37
+ `You don't have any API keys. Please create one using ${chalk.greenBright(
38
+ "`rocksky create apikey`"
39
+ )} command.`
40
+ );
41
+ return;
42
+ }
43
+
44
+ const signature = md5(
45
+ `api_key${
46
+ apikeys[0].apiKey
47
+ }artist[0]${artist}methodtrack.scrobblesk${token}timestamp[0]${
48
+ timestamp || Math.floor(Date.now() / 1000)
49
+ }track[0]${track}${apikeys[0].sharedSecret}`
50
+ );
51
+
52
+ const response = await client.scrobble(
53
+ apikeys[0].apiKey,
54
+ signature,
55
+ track,
56
+ artist,
57
+ timestamp
58
+ );
59
+
60
+ console.log(
61
+ `Scrobbled ${chalk.greenBright(track)} by ${chalk.greenBright(
62
+ artist
63
+ )} at ${chalk.greenBright(
64
+ new Date(
65
+ (timestamp || Math.floor(Date.now() / 1000)) * 1000
66
+ ).toLocaleString()
67
+ )}`
68
+ );
69
+ }
@@ -0,0 +1,30 @@
1
+ import chalk from "chalk";
2
+ import { RockskyClient } from "client";
3
+ import dayjs from "dayjs";
4
+ import relative from "dayjs/plugin/relativeTime.js";
5
+
6
+ dayjs.extend(relative);
7
+
8
+ export async function scrobbles(did, { skip, limit }) {
9
+ const client = new RockskyClient();
10
+ const scrobbles = await client.scrobbles(did, { skip, limit });
11
+
12
+ for (const scrobble of scrobbles) {
13
+ if (did) {
14
+ console.log(
15
+ `${chalk.bold.magenta(scrobble.title)} ${
16
+ scrobble.artist
17
+ } ${chalk.yellow(dayjs(scrobble.created_at + "Z").fromNow())}`
18
+ );
19
+ continue;
20
+ }
21
+ const handle = `@${scrobble.user}`;
22
+ console.log(
23
+ `${chalk.italic.magentaBright(
24
+ handle
25
+ )} is listening to ${chalk.bold.magenta(scrobble.title)} ${
26
+ scrobble.artist
27
+ } ${chalk.yellow(dayjs(scrobble.date).fromNow())}`
28
+ );
29
+ }
30
+ }