@rocksky/cli 0.3.0 → 0.3.2

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.
@@ -8,6 +8,13 @@
8
8
  "when": 1768065262210,
9
9
  "tag": "0000_parallel_paper_doll",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1768571013590,
16
+ "tag": "0001_awesome_gabe_jones",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ import { IdResolver } from '@atproto/identity';
22
22
  import { configure, getConsoleSink, getLogger } from '@logtape/logtape';
23
23
  import { AtpAgent } from '@atproto/api';
24
24
  import { sql, eq, and, or, gte, lte } from 'drizzle-orm';
25
- import { sqliteTable, integer, text, unique } from 'drizzle-orm/sqlite-core';
25
+ import { sqliteTable, integer, text, index, unique } from 'drizzle-orm/sqlite-core';
26
26
  import dotenv from 'dotenv';
27
27
  import { cleanEnv, str } from 'envalid';
28
28
  import crypto from 'node:crypto';
@@ -33,6 +33,8 @@ import { Lexicons } from '@atproto/lexicon';
33
33
  import { TID } from '@atproto/common';
34
34
  import { createId } from '@paralleldrive/cuid2';
35
35
  import _ from 'lodash';
36
+ import { CarReader } from '@ipld/car';
37
+ import * as cbor from '@ipld/dag-cbor';
36
38
  import { table, getBorderCharacters } from 'table';
37
39
  import { Command } from 'commander';
38
40
  import axios from 'axios';
@@ -1369,33 +1371,41 @@ const albums = sqliteTable("albums", {
1369
1371
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
1370
1372
  });
1371
1373
 
1372
- const tracks$1 = sqliteTable("tracks", {
1373
- id: text("id").primaryKey().notNull(),
1374
- title: text("title").notNull(),
1375
- artist: text("artist").notNull(),
1376
- albumArtist: text("album_artist").notNull(),
1377
- albumArt: text("album_art"),
1378
- album: text("album").notNull(),
1379
- trackNumber: integer("track_number"),
1380
- duration: integer("duration").notNull(),
1381
- mbId: text("mb_id").unique(),
1382
- youtubeLink: text("youtube_link").unique(),
1383
- spotifyLink: text("spotify_link").unique(),
1384
- appleMusicLink: text("apple_music_link").unique(),
1385
- tidalLink: text("tidal_link").unique(),
1386
- discNumber: integer("disc_number"),
1387
- lyrics: text("lyrics"),
1388
- composer: text("composer"),
1389
- genre: text("genre"),
1390
- label: text("label"),
1391
- copyrightMessage: text("copyright_message"),
1392
- uri: text("uri").unique(),
1393
- cid: text("cid").unique().notNull(),
1394
- albumUri: text("album_uri"),
1395
- artistUri: text("artist_uri"),
1396
- createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
1397
- updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
1398
- });
1374
+ const tracks$1 = sqliteTable(
1375
+ "tracks",
1376
+ {
1377
+ id: text("id").primaryKey().notNull(),
1378
+ title: text("title").notNull(),
1379
+ artist: text("artist").notNull(),
1380
+ albumArtist: text("album_artist").notNull(),
1381
+ albumArt: text("album_art"),
1382
+ album: text("album").notNull(),
1383
+ trackNumber: integer("track_number"),
1384
+ duration: integer("duration").notNull(),
1385
+ mbId: text("mb_id").unique(),
1386
+ youtubeLink: text("youtube_link").unique(),
1387
+ spotifyLink: text("spotify_link").unique(),
1388
+ appleMusicLink: text("apple_music_link").unique(),
1389
+ tidalLink: text("tidal_link").unique(),
1390
+ discNumber: integer("disc_number"),
1391
+ lyrics: text("lyrics"),
1392
+ composer: text("composer"),
1393
+ genre: text("genre"),
1394
+ label: text("label"),
1395
+ copyrightMessage: text("copyright_message"),
1396
+ uri: text("uri").unique(),
1397
+ cid: text("cid").unique().notNull(),
1398
+ albumUri: text("album_uri"),
1399
+ artistUri: text("artist_uri"),
1400
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
1401
+ updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`)
1402
+ },
1403
+ (t) => ({
1404
+ idx_title_artist_album_albumartist: index(
1405
+ "idx_title_artist_album_albumartist"
1406
+ ).on(t.title, t.artist, t.album, t.albumArtist)
1407
+ })
1408
+ );
1399
1409
 
1400
1410
  const albumTracks = sqliteTable(
1401
1411
  "album_tracks",
@@ -7566,7 +7576,6 @@ function cleanUpSyncLockOnExit(did) {
7566
7576
  });
7567
7577
  }
7568
7578
 
7569
- const PAGE_SIZE = 100;
7570
7579
  async function sync() {
7571
7580
  const [did, handle] = await getDidAndHandle();
7572
7581
  const agent = await createAgent(did, handle);
@@ -7574,11 +7583,12 @@ async function sync() {
7574
7583
  await subscribeToJetstream(user);
7575
7584
  logger.info` DID: ${did}`;
7576
7585
  logger.info` Handle: ${handle}`;
7586
+ const carReader = await downloadCarFile(agent);
7577
7587
  const [artists, albums, songs, scrobbles] = await Promise.all([
7578
- getRockskyUserArtists(agent),
7579
- getRockskyUserAlbums(agent),
7580
- getRockskyUserSongs(agent),
7581
- getRockskyUserScrobbles(agent)
7588
+ getRockskyUserArtists(agent, carReader),
7589
+ getRockskyUserAlbums(agent, carReader),
7590
+ getRockskyUserSongs(agent, carReader),
7591
+ getRockskyUserScrobbles(agent, carReader)
7582
7592
  ]);
7583
7593
  logger.info` Artists: ${artists.length}`;
7584
7594
  logger.info` Albums: ${albums.length}`;
@@ -7636,7 +7646,7 @@ const createArtists = async (artists, user) => {
7636
7646
  id: createId(),
7637
7647
  name: tag
7638
7648
  }));
7639
- const BATCH_SIZE = 500;
7649
+ const BATCH_SIZE = 1e3;
7640
7650
  for (let i = 0; i < uniqueTags.length; i += BATCH_SIZE) {
7641
7651
  const batch = uniqueTags.slice(i, i + BATCH_SIZE);
7642
7652
  await ctx.db.insert(schema.genres).values(batch).onConflictDoNothing({
@@ -7699,7 +7709,7 @@ const createAlbums = async (albums, user) => {
7699
7709
  )
7700
7710
  );
7701
7711
  const validAlbumData = albums.map((album, index) => ({ album, artist: artists[index] })).filter(({ artist }) => artist);
7702
- const BATCH_SIZE = 500;
7712
+ const BATCH_SIZE = 1e3;
7703
7713
  let totalAlbumsImported = 0;
7704
7714
  for (let i = 0; i < validAlbumData.length; i += BATCH_SIZE) {
7705
7715
  const batch = validAlbumData.slice(i, i + BATCH_SIZE);
@@ -7759,7 +7769,7 @@ const createSongs = async (songs, user) => {
7759
7769
  artist: artists[index],
7760
7770
  album: albums[index]
7761
7771
  })).filter(({ artist, album }) => artist && album);
7762
- const BATCH_SIZE = 500;
7772
+ const BATCH_SIZE = 1e3;
7763
7773
  let totalTracksImported = 0;
7764
7774
  for (let i = 0; i < validSongData.length; i += BATCH_SIZE) {
7765
7775
  const batch = validSongData.slice(i, i + BATCH_SIZE);
@@ -7862,7 +7872,7 @@ const createScrobbles = async (scrobbles, user) => {
7862
7872
  album: albums[index],
7863
7873
  artist: artists[index]
7864
7874
  })).filter(({ track, album, artist }) => track && album && artist);
7865
- const BATCH_SIZE = 500;
7875
+ const BATCH_SIZE = 1e3;
7866
7876
  let totalScrobblesImported = 0;
7867
7877
  for (let i = 0; i < validScrobbleData.length; i += BATCH_SIZE) {
7868
7878
  const batch = validScrobbleData.slice(i, i + BATCH_SIZE);
@@ -7951,7 +7961,7 @@ const subscribeToJetstream = (user) => {
7951
7961
  const onNewCollection = async (record, cid, uri, user) => {
7952
7962
  switch (record.$type) {
7953
7963
  case "app.rocksky.song":
7954
- await onNewSong(record);
7964
+ await onNewSong(record, cid, uri, user);
7955
7965
  break;
7956
7966
  case "app.rocksky.album":
7957
7967
  await onNewAlbum(record, cid, uri, user);
@@ -7969,6 +7979,16 @@ const onNewCollection = async (record, cid, uri, user) => {
7969
7979
  const onNewSong = async (record, cid, uri, user) => {
7970
7980
  const { title, artist, album } = record;
7971
7981
  logger.info` New song: ${title} by ${artist} from ${album}`;
7982
+ await createSongs(
7983
+ [
7984
+ {
7985
+ cid,
7986
+ uri,
7987
+ value: record
7988
+ }
7989
+ ],
7990
+ user
7991
+ );
7972
7992
  };
7973
7993
  const onNewAlbum = async (record, cid, uri, user) => {
7974
7994
  const { title, artist } = record;
@@ -7999,8 +8019,140 @@ const onNewArtist = async (record, cid, uri, user) => {
7999
8019
  );
8000
8020
  };
8001
8021
  const onNewScrobble = async (record, cid, uri, user) => {
8002
- const { title, createdAt } = record;
8022
+ const { title, createdAt, artist, album, albumArtist } = record;
8003
8023
  logger.info` New scrobble: ${title} at ${createdAt}`;
8024
+ let [artistRecord] = await ctx.db.select().from(schema.artists).where(eq(schema.artists.name, record.albumArtist)).execute();
8025
+ if (!artistRecord) {
8026
+ logger.info` ⚙️ Artist not found, creating: "${albumArtist}"`;
8027
+ const artistUri = `at://${user.did}/app.rocksky.artist/${createId()}`;
8028
+ const artistCid = createId();
8029
+ await createArtists(
8030
+ [
8031
+ {
8032
+ cid: artistCid,
8033
+ uri: artistUri,
8034
+ value: {
8035
+ $type: "app.rocksky.artist",
8036
+ name: record.albumArtist,
8037
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8038
+ tags: record.tags || []
8039
+ }
8040
+ }
8041
+ ],
8042
+ user
8043
+ );
8044
+ [artistRecord] = await ctx.db.select().from(schema.artists).where(eq(schema.artists.name, record.albumArtist)).execute();
8045
+ if (!artistRecord) {
8046
+ logger.error` ❌ Failed to create artist. Skipping scrobble.`;
8047
+ return;
8048
+ }
8049
+ }
8050
+ let [albumRecord] = await ctx.db.select().from(schema.albums).where(
8051
+ and(
8052
+ eq(schema.albums.title, record.album),
8053
+ eq(schema.albums.artist, record.albumArtist)
8054
+ )
8055
+ ).execute();
8056
+ if (!albumRecord) {
8057
+ logger.info` ⚙️ Album not found, creating: "${album}" by ${albumArtist}`;
8058
+ const albumUri = `at://${user.did}/app.rocksky.album/${createId()}`;
8059
+ const albumCid = createId();
8060
+ await createAlbums(
8061
+ [
8062
+ {
8063
+ cid: albumCid,
8064
+ uri: albumUri,
8065
+ value: {
8066
+ $type: "app.rocksky.album",
8067
+ title: record.album,
8068
+ artist: record.albumArtist,
8069
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8070
+ releaseDate: record.releaseDate,
8071
+ year: record.year,
8072
+ albumArt: record.albumArt,
8073
+ artistUri: artistRecord.uri,
8074
+ spotifyLink: record.spotifyLink,
8075
+ appleMusicLink: record.appleMusicLink,
8076
+ tidalLink: record.tidalLink,
8077
+ youtubeLink: record.youtubeLink
8078
+ }
8079
+ }
8080
+ ],
8081
+ user
8082
+ );
8083
+ [albumRecord] = await ctx.db.select().from(schema.albums).where(
8084
+ and(
8085
+ eq(schema.albums.title, record.album),
8086
+ eq(schema.albums.artist, record.albumArtist)
8087
+ )
8088
+ ).execute();
8089
+ if (!albumRecord) {
8090
+ logger.error` ❌ Failed to create album. Skipping scrobble.`;
8091
+ return;
8092
+ }
8093
+ }
8094
+ let [track] = await ctx.db.select().from(schema.tracks).where(
8095
+ and(
8096
+ eq(schema.tracks.title, record.title),
8097
+ eq(schema.tracks.artist, record.artist),
8098
+ eq(schema.tracks.album, record.album),
8099
+ eq(schema.tracks.albumArtist, record.albumArtist)
8100
+ )
8101
+ ).execute();
8102
+ if (!track) {
8103
+ logger.info` ⚙️ Track not found, creating: "${title}" by ${artist} from ${album}`;
8104
+ const trackUri = `at://${user.did}/app.rocksky.song/${createId()}`;
8105
+ const trackCid = createId();
8106
+ await createSongs(
8107
+ [
8108
+ {
8109
+ cid: trackCid,
8110
+ uri: trackUri,
8111
+ value: {
8112
+ $type: "app.rocksky.song",
8113
+ title: record.title,
8114
+ artist: record.artist,
8115
+ albumArtist: record.albumArtist,
8116
+ album: record.album,
8117
+ duration: record.duration,
8118
+ trackNumber: record.trackNumber,
8119
+ discNumber: record.discNumber,
8120
+ releaseDate: record.releaseDate,
8121
+ year: record.year,
8122
+ genre: record.genre,
8123
+ tags: record.tags,
8124
+ composer: record.composer,
8125
+ lyrics: record.lyrics,
8126
+ copyrightMessage: record.copyrightMessage,
8127
+ albumArt: record.albumArt,
8128
+ youtubeLink: record.youtubeLink,
8129
+ spotifyLink: record.spotifyLink,
8130
+ tidalLink: record.tidalLink,
8131
+ appleMusicLink: record.appleMusicLink,
8132
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8133
+ mbId: record.mbid,
8134
+ label: record.label,
8135
+ albumUri: albumRecord.uri,
8136
+ artistUri: artistRecord.uri
8137
+ }
8138
+ }
8139
+ ],
8140
+ user
8141
+ );
8142
+ [track] = await ctx.db.select().from(schema.tracks).where(
8143
+ and(
8144
+ eq(schema.tracks.title, record.title),
8145
+ eq(schema.tracks.artist, record.artist),
8146
+ eq(schema.tracks.album, record.album),
8147
+ eq(schema.tracks.albumArtist, record.albumArtist)
8148
+ )
8149
+ ).execute();
8150
+ if (!track) {
8151
+ logger.error` ❌ Failed to create track. Skipping scrobble.`;
8152
+ return;
8153
+ }
8154
+ }
8155
+ logger.info` ✓ All required entities ready. Creating scrobble...`;
8004
8156
  await createScrobbles(
8005
8157
  [
8006
8158
  {
@@ -8012,80 +8164,141 @@ const onNewScrobble = async (record, cid, uri, user) => {
8012
8164
  user
8013
8165
  );
8014
8166
  };
8015
- const getRockskyUserSongs = async (agent) => {
8016
- let results = [];
8017
- let cursor;
8018
- do {
8019
- const res = await agent.com.atproto.repo.listRecords({
8020
- repo: agent.assertDid,
8021
- collection: "app.rocksky.song",
8022
- limit: PAGE_SIZE,
8023
- cursor
8024
- });
8025
- const records = res.data.records;
8026
- results = results.concat(records);
8027
- cursor = res.data.cursor;
8167
+ const downloadCarFile = async (agent) => {
8168
+ logger.info(`Fetching repository CAR file ...`);
8169
+ const repoRes = await agent.com.atproto.sync.getRepo({
8170
+ did: agent.assertDid
8171
+ });
8172
+ return CarReader.fromBytes(new Uint8Array(repoRes.data));
8173
+ };
8174
+ const getRockskyUserSongs = async (agent, carReader) => {
8175
+ const results = [];
8176
+ try {
8177
+ const collection = "app.rocksky.song";
8178
+ for await (const { cid, bytes } of carReader.blocks()) {
8179
+ try {
8180
+ const decoded = cbor.decode(bytes);
8181
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
8182
+ if (decoded.$type === collection) {
8183
+ const value = decoded;
8184
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
8185
+ results.push({
8186
+ value,
8187
+ uri,
8188
+ cid: cid.toString()
8189
+ });
8190
+ }
8191
+ }
8192
+ } catch (e) {
8193
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
8194
+ continue;
8195
+ }
8196
+ }
8028
8197
  logger.info(
8029
8198
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} songs`
8030
8199
  );
8031
- } while (cursor);
8200
+ } catch (error) {
8201
+ logger.error(`Error fetching songs from CAR: ${error}`);
8202
+ throw error;
8203
+ }
8032
8204
  return results;
8033
8205
  };
8034
- const getRockskyUserAlbums = async (agent) => {
8035
- let results = [];
8036
- let cursor;
8037
- do {
8038
- const res = await agent.com.atproto.repo.listRecords({
8039
- repo: agent.assertDid,
8040
- collection: "app.rocksky.album",
8041
- limit: PAGE_SIZE,
8042
- cursor
8043
- });
8044
- const records = res.data.records;
8045
- results = results.concat(records);
8046
- cursor = res.data.cursor;
8206
+ const getRockskyUserAlbums = async (agent, carReader) => {
8207
+ const results = [];
8208
+ try {
8209
+ const collection = "app.rocksky.album";
8210
+ logger.info`Extracting ${collection} records from CAR file ...`;
8211
+ for await (const { cid, bytes } of carReader.blocks()) {
8212
+ try {
8213
+ const decoded = cbor.decode(bytes);
8214
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
8215
+ if (decoded.$type === collection) {
8216
+ const value = decoded;
8217
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
8218
+ results.push({
8219
+ value,
8220
+ uri,
8221
+ cid: cid.toString()
8222
+ });
8223
+ }
8224
+ }
8225
+ } catch (e) {
8226
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
8227
+ continue;
8228
+ }
8229
+ }
8047
8230
  logger.info(
8048
8231
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} albums`
8049
8232
  );
8050
- } while (cursor);
8233
+ } catch (error) {
8234
+ logger.error(`Error fetching albums from CAR: ${error}`);
8235
+ throw error;
8236
+ }
8051
8237
  return results;
8052
8238
  };
8053
- const getRockskyUserArtists = async (agent) => {
8054
- let results = [];
8055
- let cursor;
8056
- do {
8057
- const res = await agent.com.atproto.repo.listRecords({
8058
- repo: agent.assertDid,
8059
- collection: "app.rocksky.artist",
8060
- limit: PAGE_SIZE,
8061
- cursor
8062
- });
8063
- const records = res.data.records;
8064
- results = results.concat(records);
8065
- cursor = res.data.cursor;
8239
+ const getRockskyUserArtists = async (agent, carReader) => {
8240
+ const results = [];
8241
+ try {
8242
+ const collection = "app.rocksky.artist";
8243
+ logger.info`Extracting ${collection} records from CAR file ...`;
8244
+ for await (const { cid, bytes } of carReader.blocks()) {
8245
+ try {
8246
+ const decoded = cbor.decode(bytes);
8247
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
8248
+ if (decoded.$type === collection) {
8249
+ const value = decoded;
8250
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
8251
+ results.push({
8252
+ value,
8253
+ uri,
8254
+ cid: cid.toString()
8255
+ });
8256
+ }
8257
+ }
8258
+ } catch (e) {
8259
+ continue;
8260
+ }
8261
+ }
8066
8262
  logger.info(
8067
8263
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} artists`
8068
8264
  );
8069
- } while (cursor);
8265
+ } catch (error) {
8266
+ logger.error(`Error fetching artists from CAR: ${error}`);
8267
+ throw error;
8268
+ }
8070
8269
  return results;
8071
8270
  };
8072
- const getRockskyUserScrobbles = async (agent) => {
8073
- let results = [];
8074
- let cursor;
8075
- do {
8076
- const res = await agent.com.atproto.repo.listRecords({
8077
- repo: agent.assertDid,
8078
- collection: "app.rocksky.scrobble",
8079
- limit: PAGE_SIZE,
8080
- cursor
8081
- });
8082
- const records = res.data.records;
8083
- results = results.concat(records);
8084
- cursor = res.data.cursor;
8271
+ const getRockskyUserScrobbles = async (agent, carReader) => {
8272
+ const results = [];
8273
+ try {
8274
+ const collection = "app.rocksky.scrobble";
8275
+ logger.info`Extracting ${collection} records from CAR file ...`;
8276
+ for await (const { cid, bytes } of carReader.blocks()) {
8277
+ try {
8278
+ const decoded = cbor.decode(bytes);
8279
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
8280
+ if (decoded.$type === collection) {
8281
+ const value = decoded;
8282
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
8283
+ results.push({
8284
+ value,
8285
+ uri,
8286
+ cid: cid.toString()
8287
+ });
8288
+ }
8289
+ }
8290
+ } catch (e) {
8291
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
8292
+ continue;
8293
+ }
8294
+ }
8085
8295
  logger.info(
8086
8296
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} scrobbles`
8087
8297
  );
8088
- } while (cursor);
8298
+ } catch (error) {
8299
+ logger.error(`Error fetching scrobbles from CAR: ${error}`);
8300
+ throw error;
8301
+ }
8089
8302
  return results;
8090
8303
  };
8091
8304
 
@@ -8571,7 +8784,7 @@ async function whoami() {
8571
8784
  }
8572
8785
  }
8573
8786
 
8574
- var version = "0.3.0";
8787
+ var version = "0.3.2";
8575
8788
 
8576
8789
  async function login(handle) {
8577
8790
  const app = express();
@@ -9201,6 +9414,13 @@ Welcome to the lastfm compatibility API
9201
9414
  return c.text("Scrobble received");
9202
9415
  });
9203
9416
  logger.info`lastfm/listenbrainz/webscrobbler scrobble API listening on ${"http://localhost:" + port}`;
9417
+ new Promise(async () => {
9418
+ try {
9419
+ await sync();
9420
+ } catch (err) {
9421
+ logger.warn`Error during initial sync: ${err}`;
9422
+ }
9423
+ });
9204
9424
  serve({ fetch: app.fetch, port });
9205
9425
  }
9206
9426
 
@@ -0,0 +1 @@
1
+ CREATE INDEX `idx_title_artist_album_albumartist` ON `tracks` (`title`,`artist`,`album`,`album_artist`);