@rocksky/cli 0.2.0 → 0.3.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/README.md +21 -9
- package/dist/drizzle/0000_parallel_paper_doll.sql +220 -0
- package/dist/drizzle/meta/0000_snapshot.json +1559 -0
- package/dist/drizzle/meta/_journal.json +13 -0
- package/dist/index.js +8280 -254
- package/drizzle/0000_parallel_paper_doll.sql +220 -0
- package/drizzle/meta/0000_snapshot.json +1559 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +18 -0
- package/package.json +31 -3
- package/src/client.ts +32 -14
- package/src/cmd/scrobble-api.ts +457 -0
- package/src/cmd/scrobble.ts +14 -61
- package/src/cmd/search.ts +27 -25
- package/src/cmd/sync.ts +812 -0
- package/src/cmd/whoami.ts +36 -7
- package/src/context.ts +24 -0
- package/src/drizzle.ts +53 -0
- package/src/index.ts +66 -26
- package/src/jetstream.ts +285 -0
- package/src/lexicon/index.ts +1321 -0
- package/src/lexicon/lexicons.ts +5453 -0
- package/src/lexicon/types/app/bsky/actor/profile.ts +38 -0
- package/src/lexicon/types/app/rocksky/actor/defs.ts +146 -0
- package/src/lexicon/types/app/rocksky/actor/getActorAlbums.ts +56 -0
- package/src/lexicon/types/app/rocksky/actor/getActorArtists.ts +56 -0
- package/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts +48 -0
- package/src/lexicon/types/app/rocksky/actor/getActorLovedSongs.ts +52 -0
- package/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts +48 -0
- package/src/lexicon/types/app/rocksky/actor/getActorPlaylists.ts +52 -0
- package/src/lexicon/types/app/rocksky/actor/getActorScrobbles.ts +52 -0
- package/src/lexicon/types/app/rocksky/actor/getActorSongs.ts +56 -0
- package/src/lexicon/types/app/rocksky/actor/getProfile.ts +43 -0
- package/src/lexicon/types/app/rocksky/album/defs.ts +85 -0
- package/src/lexicon/types/app/rocksky/album/getAlbum.ts +43 -0
- package/src/lexicon/types/app/rocksky/album/getAlbumTracks.ts +48 -0
- package/src/lexicon/types/app/rocksky/album/getAlbums.ts +50 -0
- package/src/lexicon/types/app/rocksky/album.ts +51 -0
- package/src/lexicon/types/app/rocksky/apikey/createApikey.ts +51 -0
- package/src/lexicon/types/app/rocksky/apikey/defs.ts +31 -0
- package/src/lexicon/types/app/rocksky/apikey/getApikeys.ts +50 -0
- package/src/lexicon/types/app/rocksky/apikey/removeApikey.ts +43 -0
- package/src/lexicon/types/app/rocksky/apikey/updateApikey.ts +53 -0
- package/src/lexicon/types/app/rocksky/apikeys/defs.ts +7 -0
- package/src/lexicon/types/app/rocksky/artist/defs.ts +140 -0
- package/src/lexicon/types/app/rocksky/artist/getArtist.ts +43 -0
- package/src/lexicon/types/app/rocksky/artist/getArtistAlbums.ts +48 -0
- package/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts +52 -0
- package/src/lexicon/types/app/rocksky/artist/getArtistTracks.ts +52 -0
- package/src/lexicon/types/app/rocksky/artist/getArtists.ts +52 -0
- package/src/lexicon/types/app/rocksky/artist.ts +41 -0
- package/src/lexicon/types/app/rocksky/charts/defs.ts +44 -0
- package/src/lexicon/types/app/rocksky/charts/getScrobblesChart.ts +49 -0
- package/src/lexicon/types/app/rocksky/dropbox/defs.ts +71 -0
- package/src/lexicon/types/app/rocksky/dropbox/downloadFile.ts +42 -0
- package/src/lexicon/types/app/rocksky/dropbox/getFiles.ts +43 -0
- package/src/lexicon/types/app/rocksky/dropbox/getMetadata.ts +43 -0
- package/src/lexicon/types/app/rocksky/dropbox/getTemporaryLink.ts +43 -0
- package/src/lexicon/types/app/rocksky/feed/defs.ts +182 -0
- package/src/lexicon/types/app/rocksky/feed/describeFeedGenerator.ts +48 -0
- package/src/lexicon/types/app/rocksky/feed/generator.ts +29 -0
- package/src/lexicon/types/app/rocksky/feed/getFeed.ts +47 -0
- package/src/lexicon/types/app/rocksky/feed/getFeedGenerator.ts +48 -0
- package/src/lexicon/types/app/rocksky/feed/getFeedGenerators.ts +43 -0
- package/src/lexicon/types/app/rocksky/feed/getFeedSkeleton.ts +56 -0
- package/src/lexicon/types/app/rocksky/feed/getNowPlayings.ts +43 -0
- package/src/lexicon/types/app/rocksky/feed/search.ts +43 -0
- package/src/lexicon/types/app/rocksky/googledrive/defs.ts +42 -0
- package/src/lexicon/types/app/rocksky/googledrive/downloadFile.ts +42 -0
- package/src/lexicon/types/app/rocksky/googledrive/getFile.ts +43 -0
- package/src/lexicon/types/app/rocksky/googledrive/getFiles.ts +43 -0
- package/src/lexicon/types/app/rocksky/graph/defs.ts +47 -0
- package/src/lexicon/types/app/rocksky/graph/follow.ts +28 -0
- package/src/lexicon/types/app/rocksky/graph/followAccount.ts +50 -0
- package/src/lexicon/types/app/rocksky/graph/getFollowers.ts +56 -0
- package/src/lexicon/types/app/rocksky/graph/getFollows.ts +56 -0
- package/src/lexicon/types/app/rocksky/graph/getKnownFollowers.ts +52 -0
- package/src/lexicon/types/app/rocksky/graph/unfollowAccount.ts +50 -0
- package/src/lexicon/types/app/rocksky/like/dislikeShout.ts +49 -0
- package/src/lexicon/types/app/rocksky/like/dislikeSong.ts +49 -0
- package/src/lexicon/types/app/rocksky/like/likeShout.ts +49 -0
- package/src/lexicon/types/app/rocksky/like/likeSong.ts +49 -0
- package/src/lexicon/types/app/rocksky/like.ts +27 -0
- package/src/lexicon/types/app/rocksky/player/addDirectoryToQueue.ts +40 -0
- package/src/lexicon/types/app/rocksky/player/addItemsToQueue.ts +39 -0
- package/src/lexicon/types/app/rocksky/player/defs.ts +57 -0
- package/src/lexicon/types/app/rocksky/player/getCurrentlyPlaying.ts +44 -0
- package/src/lexicon/types/app/rocksky/player/getPlaybackQueue.ts +42 -0
- package/src/lexicon/types/app/rocksky/player/next.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/pause.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/play.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/playDirectory.ts +38 -0
- package/src/lexicon/types/app/rocksky/player/playFile.ts +35 -0
- package/src/lexicon/types/app/rocksky/player/previous.ts +34 -0
- package/src/lexicon/types/app/rocksky/player/seek.ts +36 -0
- package/src/lexicon/types/app/rocksky/playlist/createPlaylist.ts +37 -0
- package/src/lexicon/types/app/rocksky/playlist/defs.ts +86 -0
- package/src/lexicon/types/app/rocksky/playlist/getPlaylist.ts +43 -0
- package/src/lexicon/types/app/rocksky/playlist/getPlaylists.ts +50 -0
- package/src/lexicon/types/app/rocksky/playlist/insertDirectory.ts +39 -0
- package/src/lexicon/types/app/rocksky/playlist/insertFiles.ts +38 -0
- package/src/lexicon/types/app/rocksky/playlist/removePlaylist.ts +35 -0
- package/src/lexicon/types/app/rocksky/playlist/removeTrack.ts +37 -0
- package/src/lexicon/types/app/rocksky/playlist/startPlaylist.ts +39 -0
- package/src/lexicon/types/app/rocksky/playlist.ts +43 -0
- package/src/lexicon/types/app/rocksky/radio/defs.ts +63 -0
- package/src/lexicon/types/app/rocksky/radio.ts +37 -0
- package/src/lexicon/types/app/rocksky/scrobble/createScrobble.ts +91 -0
- package/src/lexicon/types/app/rocksky/scrobble/defs.ts +93 -0
- package/src/lexicon/types/app/rocksky/scrobble/getScrobble.ts +43 -0
- package/src/lexicon/types/app/rocksky/scrobble/getScrobbles.ts +54 -0
- package/src/lexicon/types/app/rocksky/scrobble.ts +75 -0
- package/src/lexicon/types/app/rocksky/shout/createShout.ts +49 -0
- package/src/lexicon/types/app/rocksky/shout/defs.ts +58 -0
- package/src/lexicon/types/app/rocksky/shout/getAlbumShouts.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getArtistShouts.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getProfileShouts.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getShoutReplies.ts +52 -0
- package/src/lexicon/types/app/rocksky/shout/getTrackShouts.ts +48 -0
- package/src/lexicon/types/app/rocksky/shout/removeShout.ts +43 -0
- package/src/lexicon/types/app/rocksky/shout/replyShout.ts +51 -0
- package/src/lexicon/types/app/rocksky/shout/reportShout.ts +51 -0
- package/src/lexicon/types/app/rocksky/shout.ts +30 -0
- package/src/lexicon/types/app/rocksky/song/createSong.ts +71 -0
- package/src/lexicon/types/app/rocksky/song/defs.ts +103 -0
- package/src/lexicon/types/app/rocksky/song/getSong.ts +43 -0
- package/src/lexicon/types/app/rocksky/song/getSongs.ts +50 -0
- package/src/lexicon/types/app/rocksky/song.ts +74 -0
- package/src/lexicon/types/app/rocksky/spotify/defs.ts +35 -0
- package/src/lexicon/types/app/rocksky/spotify/getCurrentlyPlaying.ts +43 -0
- package/src/lexicon/types/app/rocksky/spotify/next.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/pause.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/play.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/previous.ts +32 -0
- package/src/lexicon/types/app/rocksky/spotify/seek.ts +35 -0
- package/src/lexicon/types/app/rocksky/stats/defs.ts +33 -0
- package/src/lexicon/types/app/rocksky/stats/getStats.ts +43 -0
- package/src/lexicon/types/com/atproto/repo/strongRef.ts +26 -0
- package/src/lexicon/util.ts +13 -0
- package/src/lib/agent.ts +56 -0
- package/src/lib/cleanUpJetstreamLock.ts +66 -0
- package/src/lib/cleanUpSyncLock.ts +56 -0
- package/src/lib/didUnstorageCache.ts +72 -0
- package/src/lib/env.ts +25 -0
- package/src/lib/extractPdsFromDid.ts +33 -0
- package/src/lib/getDidAndHandle.ts +39 -0
- package/src/lib/idResolver.ts +52 -0
- package/src/lib/lastfm.ts +26 -0
- package/src/lib/matchTrack.ts +47 -0
- package/src/logger.ts +18 -0
- package/src/schema/album-tracks.ts +30 -0
- package/src/schema/albums.ts +29 -0
- package/src/schema/artist-albums.ts +29 -0
- package/src/schema/artist-genres.ts +17 -0
- package/src/schema/artist-tracks.ts +29 -0
- package/src/schema/artists.ts +30 -0
- package/src/schema/auth-session.ts +18 -0
- package/src/schema/genres.ts +18 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/loved-tracks.ts +27 -0
- package/src/schema/scrobbles.ts +30 -0
- package/src/schema/tracks.ts +39 -0
- package/src/schema/user-albums.ts +31 -0
- package/src/schema/user-artists.ts +32 -0
- package/src/schema/user-tracks.ts +31 -0
- package/src/schema/users.ts +21 -0
- package/src/scrobble.ts +410 -0
- package/src/sqliteKv.ts +173 -0
- package/src/types.ts +308 -0
- package/tsconfig.json +26 -29
package/src/cmd/whoami.ts
CHANGED
|
@@ -1,18 +1,47 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { RockskyClient } from "client";
|
|
3
3
|
import fs from "fs/promises";
|
|
4
|
+
import { createAgent } from "lib/agent";
|
|
5
|
+
import { env } from "lib/env";
|
|
6
|
+
import { getDidAndHandle } from "lib/getDidAndHandle";
|
|
4
7
|
import os from "os";
|
|
5
8
|
import path from "path";
|
|
9
|
+
import { createUser } from "./sync";
|
|
10
|
+
import { ctx } from "context";
|
|
11
|
+
import schema from "schema";
|
|
12
|
+
import { eq } from "drizzle-orm";
|
|
6
13
|
|
|
7
14
|
export async function whoami() {
|
|
15
|
+
if (env.ROCKSKY_IDENTIFIER && env.ROCKSKY_PASSWORD) {
|
|
16
|
+
const [did, handle] = await getDidAndHandle();
|
|
17
|
+
const agent = await createAgent(did, handle);
|
|
18
|
+
let user = await ctx.db
|
|
19
|
+
.select()
|
|
20
|
+
.from(schema.users)
|
|
21
|
+
.where(eq(schema.users.did, did))
|
|
22
|
+
.execute()
|
|
23
|
+
.then((rows) => rows[0]);
|
|
24
|
+
|
|
25
|
+
if (!user) {
|
|
26
|
+
user = await createUser(agent, did, handle);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(`You are logged in as ${user.handle} (${user.displayName}).`);
|
|
30
|
+
console.log(
|
|
31
|
+
`View your profile at: ${chalk.magenta(
|
|
32
|
+
`https://rocksky.app/profile/${user.handle}`,
|
|
33
|
+
)}`,
|
|
34
|
+
);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
8
37
|
const tokenPath = path.join(os.homedir(), ".rocksky", "token.json");
|
|
9
38
|
try {
|
|
10
39
|
await fs.access(tokenPath);
|
|
11
40
|
} catch (err) {
|
|
12
41
|
console.error(
|
|
13
42
|
`You are not logged in. Please run ${chalk.greenBright(
|
|
14
|
-
"`rocksky login <username>.bsky.social`"
|
|
15
|
-
)} first
|
|
43
|
+
"`rocksky login <username>.bsky.social`",
|
|
44
|
+
)} first.`,
|
|
16
45
|
);
|
|
17
46
|
return;
|
|
18
47
|
}
|
|
@@ -22,8 +51,8 @@ export async function whoami() {
|
|
|
22
51
|
if (!token) {
|
|
23
52
|
console.error(
|
|
24
53
|
`You are not logged in. Please run ${chalk.greenBright(
|
|
25
|
-
"`rocksky login <username>.bsky.social`"
|
|
26
|
-
)} first
|
|
54
|
+
"`rocksky login <username>.bsky.social`",
|
|
55
|
+
)} first.`,
|
|
27
56
|
);
|
|
28
57
|
return;
|
|
29
58
|
}
|
|
@@ -34,12 +63,12 @@ export async function whoami() {
|
|
|
34
63
|
console.log(`You are logged in as ${user.handle} (${user.displayName}).`);
|
|
35
64
|
console.log(
|
|
36
65
|
`View your profile at: ${chalk.magenta(
|
|
37
|
-
`https://rocksky.app/profile/${user.handle}
|
|
38
|
-
)}
|
|
66
|
+
`https://rocksky.app/profile/${user.handle}`,
|
|
67
|
+
)}`,
|
|
39
68
|
);
|
|
40
69
|
} catch (err) {
|
|
41
70
|
console.error(
|
|
42
|
-
`Failed to fetch user data. Please check your token and try again
|
|
71
|
+
`Failed to fetch user data. Please check your token and try again.`,
|
|
43
72
|
);
|
|
44
73
|
}
|
|
45
74
|
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import drizzle from "./drizzle";
|
|
2
|
+
import sqliteKv from "sqliteKv";
|
|
3
|
+
import { createBidirectionalResolver, createIdResolver } from "lib/idResolver";
|
|
4
|
+
import { createStorage } from "unstorage";
|
|
5
|
+
import envpaths from "env-paths";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
|
|
8
|
+
fs.mkdirSync(envpaths("rocksky", { suffix: "" }).data, { recursive: true });
|
|
9
|
+
const kvPath = `${envpaths("rocksky", { suffix: "" }).data}/rocksky-kv.sqlite`;
|
|
10
|
+
|
|
11
|
+
const kv = createStorage({
|
|
12
|
+
driver: sqliteKv({ location: kvPath, table: "kv" }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const baseIdResolver = createIdResolver(kv);
|
|
16
|
+
|
|
17
|
+
export const ctx = {
|
|
18
|
+
db: drizzle.db,
|
|
19
|
+
resolver: createBidirectionalResolver(baseIdResolver),
|
|
20
|
+
baseIdResolver,
|
|
21
|
+
kv,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type Context = typeof ctx;
|
package/src/drizzle.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
2
|
+
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import envpaths from "env-paths";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
fs.mkdirSync(envpaths("rocksky", { suffix: "" }).data, { recursive: true });
|
|
13
|
+
const url = `${envpaths("rocksky", { suffix: "" }).data}/rocksky.sqlite`;
|
|
14
|
+
|
|
15
|
+
const sqlite = new Database(url);
|
|
16
|
+
const db = drizzle(sqlite);
|
|
17
|
+
|
|
18
|
+
let initialized = false;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Initialize the database and run migrations
|
|
22
|
+
* This should be called before any database operations
|
|
23
|
+
*/
|
|
24
|
+
export async function initializeDatabase() {
|
|
25
|
+
if (initialized) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// In production (built), migrations are in ../drizzle
|
|
31
|
+
// In development (src), migrations are in ../../drizzle
|
|
32
|
+
let migrationsFolder = path.join(__dirname, "../drizzle");
|
|
33
|
+
|
|
34
|
+
if (!fs.existsSync(migrationsFolder)) {
|
|
35
|
+
// Try development path
|
|
36
|
+
migrationsFolder = path.join(__dirname, "../../drizzle");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (fs.existsSync(migrationsFolder)) {
|
|
40
|
+
migrate(db, { migrationsFolder });
|
|
41
|
+
initialized = true;
|
|
42
|
+
} else {
|
|
43
|
+
// No migrations folder found - this might be the first run
|
|
44
|
+
// or migrations haven't been generated yet
|
|
45
|
+
initialized = true;
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error("Failed to run migrations:", error);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default { db, initializeDatabase };
|
package/src/index.ts
CHANGED
|
@@ -13,46 +13,74 @@ import { stats } from "cmd/stats";
|
|
|
13
13
|
import { tracks } from "cmd/tracks";
|
|
14
14
|
import { whoami } from "cmd/whoami";
|
|
15
15
|
import { Command } from "commander";
|
|
16
|
-
import version from "../package.json" assert { type: "json" };
|
|
16
|
+
import { version } from "../package.json" assert { type: "json" };
|
|
17
17
|
import { login } from "./cmd/login";
|
|
18
|
+
import { sync } from "cmd/sync";
|
|
19
|
+
import { initializeDatabase } from "./drizzle";
|
|
20
|
+
import { scrobbleApi } from "cmd/scrobble-api";
|
|
21
|
+
|
|
22
|
+
await initializeDatabase();
|
|
18
23
|
|
|
19
24
|
const program = new Command();
|
|
20
25
|
|
|
21
26
|
program
|
|
22
27
|
.name("rocksky")
|
|
23
28
|
.description(
|
|
24
|
-
`
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
`
|
|
30
|
+
___ __ __ _______ ____
|
|
31
|
+
/ _ \\___ ____/ /__ ___ / /____ __ / ___/ / / _/
|
|
32
|
+
/ , _/ _ \\/ __/ '_/(_-</ '_/ // / / /__/ /___/ /
|
|
33
|
+
/_/|_|\\___/\\__/_/\\_\\/___/_/\\_\\\\_, / \\___/____/___/
|
|
34
|
+
/___/
|
|
35
|
+
Command-line interface for Rocksky ${chalk.magentaBright(
|
|
36
|
+
"https://rocksky.app",
|
|
37
|
+
)} – scrobble tracks, view stats, and manage your listening history.`,
|
|
27
38
|
)
|
|
28
|
-
.version(version
|
|
39
|
+
.version(version);
|
|
40
|
+
|
|
41
|
+
program.configureHelp({
|
|
42
|
+
styleTitle: (str) => chalk.bold.cyan(str),
|
|
43
|
+
styleCommandText: (str) => chalk.yellow(str),
|
|
44
|
+
styleDescriptionText: (str) => chalk.white(str),
|
|
45
|
+
styleOptionText: (str) => chalk.green(str),
|
|
46
|
+
styleArgumentText: (str) => chalk.magenta(str),
|
|
47
|
+
styleSubcommandText: (str) => chalk.blue(str),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
program.addHelpText(
|
|
51
|
+
"after",
|
|
52
|
+
`
|
|
53
|
+
${chalk.bold("\nLearn more about Rocksky:")} https://docs.rocksky.app
|
|
54
|
+
${chalk.bold("Join our Discord community:")} ${chalk.blueBright("https://discord.gg/EVcBy2fVa3")}
|
|
55
|
+
`,
|
|
56
|
+
);
|
|
29
57
|
|
|
30
58
|
program
|
|
31
59
|
.command("login")
|
|
32
|
-
.argument("<handle>", "your
|
|
33
|
-
.description("login with your
|
|
60
|
+
.argument("<handle>", "your AT Proto handle (e.g., <username>.bsky.social)")
|
|
61
|
+
.description("login with your AT Proto account and get a session token")
|
|
34
62
|
.action(login);
|
|
35
63
|
|
|
36
64
|
program
|
|
37
65
|
.command("whoami")
|
|
38
|
-
.description("get the current logged-in user
|
|
66
|
+
.description("get the current logged-in user")
|
|
39
67
|
.action(whoami);
|
|
40
68
|
|
|
41
69
|
program
|
|
42
70
|
.command("nowplaying")
|
|
43
71
|
.argument(
|
|
44
72
|
"[did]",
|
|
45
|
-
"the DID or handle of the user to get the now playing track for
|
|
73
|
+
"the DID or handle of the user to get the now playing track for",
|
|
46
74
|
)
|
|
47
|
-
.description("get the currently playing track
|
|
75
|
+
.description("get the currently playing track")
|
|
48
76
|
.action(nowplaying);
|
|
49
77
|
|
|
50
78
|
program
|
|
51
79
|
.command("scrobbles")
|
|
52
80
|
.option("-s, --skip <number>", "number of scrobbles to skip")
|
|
53
81
|
.option("-l, --limit <number>", "number of scrobbles to limit")
|
|
54
|
-
.argument("[did]", "the DID or handle of the user to get the scrobbles for
|
|
55
|
-
.description("display recently played tracks
|
|
82
|
+
.argument("[did]", "the DID or handle of the user to get the scrobbles for")
|
|
83
|
+
.description("display recently played tracks")
|
|
56
84
|
.action(scrobbles);
|
|
57
85
|
|
|
58
86
|
program
|
|
@@ -63,37 +91,37 @@ program
|
|
|
63
91
|
.option("-l, --limit <number>", "number of results to limit")
|
|
64
92
|
.argument(
|
|
65
93
|
"<query>",
|
|
66
|
-
"the search query, e.g., artist, album, title or account"
|
|
94
|
+
"the search query, e.g., artist, album, title or account",
|
|
67
95
|
)
|
|
68
|
-
.description("search for tracks, albums, or accounts
|
|
96
|
+
.description("search for tracks, albums, or accounts")
|
|
69
97
|
.action(search);
|
|
70
98
|
|
|
71
99
|
program
|
|
72
100
|
.command("stats")
|
|
73
101
|
.option("-l, --limit <number>", "number of results to limit")
|
|
74
|
-
.argument("[did]", "the DID or handle of the user to get stats for
|
|
75
|
-
.description("get the user's listening stats
|
|
102
|
+
.argument("[did]", "the DID or handle of the user to get stats for")
|
|
103
|
+
.description("get the user's listening stats")
|
|
76
104
|
.action(stats);
|
|
77
105
|
|
|
78
106
|
program
|
|
79
107
|
.command("artists")
|
|
80
108
|
.option("-l, --limit <number>", "number of results to limit")
|
|
81
|
-
.argument("[did]", "the DID or handle of the user to get artists for
|
|
82
|
-
.description("get the user's top artists
|
|
109
|
+
.argument("[did]", "the DID or handle of the user to get artists for")
|
|
110
|
+
.description("get the user's top artists")
|
|
83
111
|
.action(artists);
|
|
84
112
|
|
|
85
113
|
program
|
|
86
114
|
.command("albums")
|
|
87
115
|
.option("-l, --limit <number>", "number of results to limit")
|
|
88
|
-
.argument("[did]", "the DID or handle of the user to get albums for
|
|
89
|
-
.description("get the user's top albums
|
|
116
|
+
.argument("[did]", "the DID or handle of the user to get albums for")
|
|
117
|
+
.description("get the user's top albums")
|
|
90
118
|
.action(albums);
|
|
91
119
|
|
|
92
120
|
program
|
|
93
121
|
.command("tracks")
|
|
94
122
|
.option("-l, --limit <number>", "number of results to limit")
|
|
95
|
-
.argument("[did]", "the DID or handle of the user to get tracks for
|
|
96
|
-
.description("get the user's top tracks
|
|
123
|
+
.argument("[did]", "the DID or handle of the user to get tracks for")
|
|
124
|
+
.description("get the user's top tracks")
|
|
97
125
|
.action(tracks);
|
|
98
126
|
|
|
99
127
|
program
|
|
@@ -101,21 +129,33 @@ program
|
|
|
101
129
|
.argument("<track>", "the title of the track")
|
|
102
130
|
.argument("<artist>", "the artist of the track")
|
|
103
131
|
.option("-t, --timestamp <timestamp>", "the timestamp of the scrobble")
|
|
104
|
-
.
|
|
132
|
+
.option("-d, --dry-run", "simulate the scrobble without actually sending it")
|
|
133
|
+
.description("scrobble a track to your profile")
|
|
105
134
|
.action(scrobble);
|
|
106
135
|
|
|
107
136
|
program
|
|
108
137
|
.command("create")
|
|
109
|
-
.description("create a new API key
|
|
138
|
+
.description("create a new API key")
|
|
110
139
|
.command("apikey")
|
|
111
140
|
.argument("<name>", "the name of the API key")
|
|
112
141
|
.option("-d, --description <description>", "the description of the API key")
|
|
113
|
-
.description("create a new API key
|
|
142
|
+
.description("create a new API key")
|
|
114
143
|
.action(createApiKey);
|
|
115
144
|
|
|
116
145
|
program
|
|
117
146
|
.command("mcp")
|
|
118
|
-
.description("
|
|
147
|
+
.description("starts an MCP server to use with Claude or other LLMs")
|
|
119
148
|
.action(mcp);
|
|
120
149
|
|
|
150
|
+
program
|
|
151
|
+
.command("sync")
|
|
152
|
+
.description("sync your local Rocksky data from AT Protocol")
|
|
153
|
+
.action(sync);
|
|
154
|
+
|
|
155
|
+
program
|
|
156
|
+
.command("scrobble-api")
|
|
157
|
+
.description("start a local listenbrainz/lastfm compatibility server")
|
|
158
|
+
.option("-p, --port <port>", "the port to listen on", "8778")
|
|
159
|
+
.action(scrobbleApi);
|
|
160
|
+
|
|
121
161
|
program.parse(process.argv);
|
package/src/jetstream.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
export interface JetStreamEvent {
|
|
2
|
+
did: string;
|
|
3
|
+
time_us: number;
|
|
4
|
+
kind: "commit" | "identity" | "account";
|
|
5
|
+
commit?: {
|
|
6
|
+
rev: string;
|
|
7
|
+
operation: "create" | "update" | "delete";
|
|
8
|
+
collection: string;
|
|
9
|
+
rkey: string;
|
|
10
|
+
record?: Record<string, unknown>;
|
|
11
|
+
cid?: string;
|
|
12
|
+
};
|
|
13
|
+
identity?: {
|
|
14
|
+
did: string;
|
|
15
|
+
handle?: string;
|
|
16
|
+
seq?: number;
|
|
17
|
+
time?: string;
|
|
18
|
+
};
|
|
19
|
+
account?: {
|
|
20
|
+
active: boolean;
|
|
21
|
+
did: string;
|
|
22
|
+
seq: number;
|
|
23
|
+
time: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface JetStreamClientOptions {
|
|
28
|
+
endpoint?: string;
|
|
29
|
+
wantedCollections?: string[];
|
|
30
|
+
wantedDids?: string[];
|
|
31
|
+
maxReconnectAttempts?: number;
|
|
32
|
+
reconnectDelay?: number;
|
|
33
|
+
maxReconnectDelay?: number;
|
|
34
|
+
backoffMultiplier?: number;
|
|
35
|
+
debug?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type JetStreamEventType =
|
|
39
|
+
| "open"
|
|
40
|
+
| "message"
|
|
41
|
+
| "error"
|
|
42
|
+
| "close"
|
|
43
|
+
| "reconnect";
|
|
44
|
+
|
|
45
|
+
export class JetStreamClient {
|
|
46
|
+
private ws: WebSocket | null = null;
|
|
47
|
+
private options: Required<JetStreamClientOptions>;
|
|
48
|
+
private reconnectAttempts = 0;
|
|
49
|
+
private reconnectTimer: number | null = null;
|
|
50
|
+
private isManualClose = false;
|
|
51
|
+
private eventHandlers: Map<
|
|
52
|
+
JetStreamEventType,
|
|
53
|
+
Set<(data?: unknown) => void>
|
|
54
|
+
> = new Map();
|
|
55
|
+
private cursor: number | null = null;
|
|
56
|
+
|
|
57
|
+
constructor(options: JetStreamClientOptions = {}) {
|
|
58
|
+
this.options = {
|
|
59
|
+
endpoint:
|
|
60
|
+
options.endpoint || "wss://jetstream1.us-east.bsky.network/subscribe",
|
|
61
|
+
wantedCollections: options.wantedCollections || [],
|
|
62
|
+
wantedDids: options.wantedDids || [],
|
|
63
|
+
maxReconnectAttempts: options.maxReconnectAttempts ?? Infinity,
|
|
64
|
+
reconnectDelay: options.reconnectDelay ?? 1000,
|
|
65
|
+
maxReconnectDelay: options.maxReconnectDelay ?? 30000,
|
|
66
|
+
backoffMultiplier: options.backoffMultiplier ?? 1.5,
|
|
67
|
+
debug: options.debug ?? false,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Initialize event handler sets
|
|
71
|
+
["open", "message", "error", "close", "reconnect"].forEach((event) => {
|
|
72
|
+
this.eventHandlers.set(event as JetStreamEventType, new Set());
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register an event handler
|
|
78
|
+
*/
|
|
79
|
+
on(event: JetStreamEventType, handler: (data?: unknown) => void): this {
|
|
80
|
+
this.eventHandlers.get(event)?.add(handler);
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remove an event handler
|
|
86
|
+
*/
|
|
87
|
+
off(event: JetStreamEventType, handler: (data?: unknown) => void): this {
|
|
88
|
+
this.eventHandlers.get(event)?.delete(handler);
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Emit an event to all registered handlers
|
|
94
|
+
*/
|
|
95
|
+
private emit(event: JetStreamEventType, data?: unknown): void {
|
|
96
|
+
this.eventHandlers.get(event)?.forEach((handler) => {
|
|
97
|
+
try {
|
|
98
|
+
handler(data);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.log("error", `Handler error for ${event}:`, error);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the WebSocket URL with query parameters
|
|
107
|
+
*/
|
|
108
|
+
private buildUrl(): string {
|
|
109
|
+
const url = new URL(this.options.endpoint);
|
|
110
|
+
|
|
111
|
+
if (this.options.wantedCollections.length > 0) {
|
|
112
|
+
this.options.wantedCollections.forEach((collection) => {
|
|
113
|
+
url.searchParams.append("wantedCollections", collection);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (this.options.wantedDids.length > 0) {
|
|
118
|
+
this.options.wantedDids.forEach((did) => {
|
|
119
|
+
url.searchParams.append("wantedDids", did);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (this.cursor !== null) {
|
|
124
|
+
url.searchParams.set("cursor", this.cursor.toString());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return url.toString();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Connect to the JetStream WebSocket
|
|
132
|
+
*/
|
|
133
|
+
connect(): void {
|
|
134
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
135
|
+
this.log("warn", "Already connected");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.isManualClose = false;
|
|
140
|
+
const url = this.buildUrl();
|
|
141
|
+
this.log("info", `Connecting to ${url}`);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
this.ws = new WebSocket(url);
|
|
145
|
+
|
|
146
|
+
this.ws.onopen = () => {
|
|
147
|
+
this.log("info", "Connected successfully");
|
|
148
|
+
this.reconnectAttempts = 0;
|
|
149
|
+
this.emit("open");
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
this.ws.onmessage = (event) => {
|
|
153
|
+
try {
|
|
154
|
+
const data = JSON.parse(event.data) as JetStreamEvent;
|
|
155
|
+
|
|
156
|
+
// Update cursor for resumption
|
|
157
|
+
if (data.time_us) {
|
|
158
|
+
this.cursor = data.time_us;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.emit("message", data);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
this.log("error", "Failed to parse message:", error);
|
|
164
|
+
this.emit("error", { type: "parse_error", error });
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
this.ws.onerror = (event) => {
|
|
169
|
+
this.log("error", "WebSocket error:", event);
|
|
170
|
+
this.emit("error", event);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
this.ws.onclose = (event) => {
|
|
174
|
+
this.log("info", `Connection closed: ${event.code} ${event.reason}`);
|
|
175
|
+
this.emit("close", event);
|
|
176
|
+
|
|
177
|
+
if (!this.isManualClose) {
|
|
178
|
+
this.scheduleReconnect();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
} catch (error) {
|
|
182
|
+
this.log("error", "Failed to create WebSocket:", error);
|
|
183
|
+
this.emit("error", { type: "connection_error", error });
|
|
184
|
+
this.scheduleReconnect();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Schedule a reconnection attempt with exponential backoff
|
|
190
|
+
*/
|
|
191
|
+
private scheduleReconnect(): void {
|
|
192
|
+
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
|
193
|
+
this.log("error", "Max reconnection attempts reached");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const delay = Math.min(
|
|
198
|
+
this.options.reconnectDelay *
|
|
199
|
+
Math.pow(this.options.backoffMultiplier, this.reconnectAttempts),
|
|
200
|
+
this.options.maxReconnectDelay,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
this.reconnectAttempts++;
|
|
204
|
+
this.log(
|
|
205
|
+
"info",
|
|
206
|
+
`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
this.reconnectTimer = setTimeout(() => {
|
|
210
|
+
this.emit("reconnect", { attempt: this.reconnectAttempts });
|
|
211
|
+
this.connect();
|
|
212
|
+
}, delay) as unknown as number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Manually disconnect from the WebSocket
|
|
217
|
+
*/
|
|
218
|
+
disconnect(): void {
|
|
219
|
+
this.isManualClose = true;
|
|
220
|
+
|
|
221
|
+
if (this.reconnectTimer !== null) {
|
|
222
|
+
clearTimeout(this.reconnectTimer);
|
|
223
|
+
this.reconnectTimer = null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (this.ws) {
|
|
227
|
+
this.ws.close();
|
|
228
|
+
this.ws = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.log("info", "Disconnected");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Update subscription filters (requires reconnection)
|
|
236
|
+
*/
|
|
237
|
+
updateFilters(options: {
|
|
238
|
+
wantedCollections?: string[];
|
|
239
|
+
wantedDids?: string[];
|
|
240
|
+
}): void {
|
|
241
|
+
if (options.wantedCollections) {
|
|
242
|
+
this.options.wantedCollections = options.wantedCollections;
|
|
243
|
+
}
|
|
244
|
+
if (options.wantedDids) {
|
|
245
|
+
this.options.wantedDids = options.wantedDids;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Reconnect with new filters
|
|
249
|
+
if (this.ws) {
|
|
250
|
+
this.disconnect();
|
|
251
|
+
this.connect();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get current connection state
|
|
257
|
+
*/
|
|
258
|
+
get readyState(): number {
|
|
259
|
+
return this.ws?.readyState ?? WebSocket.CLOSED;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Check if currently connected
|
|
264
|
+
*/
|
|
265
|
+
get isConnected(): boolean {
|
|
266
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get current cursor position
|
|
271
|
+
*/
|
|
272
|
+
get currentCursor(): number | null {
|
|
273
|
+
return this.cursor;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Logging utility
|
|
278
|
+
*/
|
|
279
|
+
private log(level: "info" | "warn" | "error", ...args: unknown[]): void {
|
|
280
|
+
if (this.options.debug || level === "error") {
|
|
281
|
+
const prefix = `[JetStream ${level.toUpperCase()}]`;
|
|
282
|
+
console[level](prefix, ...args);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|