@rocksky/cli 0.3.0 → 0.3.1

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);
@@ -7636,7 +7645,7 @@ const createArtists = async (artists, user) => {
7636
7645
  id: createId(),
7637
7646
  name: tag
7638
7647
  }));
7639
- const BATCH_SIZE = 500;
7648
+ const BATCH_SIZE = 1e3;
7640
7649
  for (let i = 0; i < uniqueTags.length; i += BATCH_SIZE) {
7641
7650
  const batch = uniqueTags.slice(i, i + BATCH_SIZE);
7642
7651
  await ctx.db.insert(schema.genres).values(batch).onConflictDoNothing({
@@ -7699,7 +7708,7 @@ const createAlbums = async (albums, user) => {
7699
7708
  )
7700
7709
  );
7701
7710
  const validAlbumData = albums.map((album, index) => ({ album, artist: artists[index] })).filter(({ artist }) => artist);
7702
- const BATCH_SIZE = 500;
7711
+ const BATCH_SIZE = 1e3;
7703
7712
  let totalAlbumsImported = 0;
7704
7713
  for (let i = 0; i < validAlbumData.length; i += BATCH_SIZE) {
7705
7714
  const batch = validAlbumData.slice(i, i + BATCH_SIZE);
@@ -7759,7 +7768,7 @@ const createSongs = async (songs, user) => {
7759
7768
  artist: artists[index],
7760
7769
  album: albums[index]
7761
7770
  })).filter(({ artist, album }) => artist && album);
7762
- const BATCH_SIZE = 500;
7771
+ const BATCH_SIZE = 1e3;
7763
7772
  let totalTracksImported = 0;
7764
7773
  for (let i = 0; i < validSongData.length; i += BATCH_SIZE) {
7765
7774
  const batch = validSongData.slice(i, i + BATCH_SIZE);
@@ -7862,7 +7871,7 @@ const createScrobbles = async (scrobbles, user) => {
7862
7871
  album: albums[index],
7863
7872
  artist: artists[index]
7864
7873
  })).filter(({ track, album, artist }) => track && album && artist);
7865
- const BATCH_SIZE = 500;
7874
+ const BATCH_SIZE = 1e3;
7866
7875
  let totalScrobblesImported = 0;
7867
7876
  for (let i = 0; i < validScrobbleData.length; i += BATCH_SIZE) {
7868
7877
  const batch = validScrobbleData.slice(i, i + BATCH_SIZE);
@@ -7951,7 +7960,7 @@ const subscribeToJetstream = (user) => {
7951
7960
  const onNewCollection = async (record, cid, uri, user) => {
7952
7961
  switch (record.$type) {
7953
7962
  case "app.rocksky.song":
7954
- await onNewSong(record);
7963
+ await onNewSong(record, cid, uri, user);
7955
7964
  break;
7956
7965
  case "app.rocksky.album":
7957
7966
  await onNewAlbum(record, cid, uri, user);
@@ -7969,6 +7978,16 @@ const onNewCollection = async (record, cid, uri, user) => {
7969
7978
  const onNewSong = async (record, cid, uri, user) => {
7970
7979
  const { title, artist, album } = record;
7971
7980
  logger.info` New song: ${title} by ${artist} from ${album}`;
7981
+ await createSongs(
7982
+ [
7983
+ {
7984
+ cid,
7985
+ uri,
7986
+ value: record
7987
+ }
7988
+ ],
7989
+ user
7990
+ );
7972
7991
  };
7973
7992
  const onNewAlbum = async (record, cid, uri, user) => {
7974
7993
  const { title, artist } = record;
@@ -7999,8 +8018,140 @@ const onNewArtist = async (record, cid, uri, user) => {
7999
8018
  );
8000
8019
  };
8001
8020
  const onNewScrobble = async (record, cid, uri, user) => {
8002
- const { title, createdAt } = record;
8021
+ const { title, createdAt, artist, album, albumArtist } = record;
8003
8022
  logger.info` New scrobble: ${title} at ${createdAt}`;
8023
+ let [artistRecord] = await ctx.db.select().from(schema.artists).where(eq(schema.artists.name, record.albumArtist)).execute();
8024
+ if (!artistRecord) {
8025
+ logger.info` ⚙️ Artist not found, creating: "${albumArtist}"`;
8026
+ const artistUri = `at://${user.did}/app.rocksky.artist/${createId()}`;
8027
+ const artistCid = createId();
8028
+ await createArtists(
8029
+ [
8030
+ {
8031
+ cid: artistCid,
8032
+ uri: artistUri,
8033
+ value: {
8034
+ $type: "app.rocksky.artist",
8035
+ name: record.albumArtist,
8036
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8037
+ tags: record.tags || []
8038
+ }
8039
+ }
8040
+ ],
8041
+ user
8042
+ );
8043
+ [artistRecord] = await ctx.db.select().from(schema.artists).where(eq(schema.artists.name, record.albumArtist)).execute();
8044
+ if (!artistRecord) {
8045
+ logger.error` ❌ Failed to create artist. Skipping scrobble.`;
8046
+ return;
8047
+ }
8048
+ }
8049
+ let [albumRecord] = await ctx.db.select().from(schema.albums).where(
8050
+ and(
8051
+ eq(schema.albums.title, record.album),
8052
+ eq(schema.albums.artist, record.albumArtist)
8053
+ )
8054
+ ).execute();
8055
+ if (!albumRecord) {
8056
+ logger.info` ⚙️ Album not found, creating: "${album}" by ${albumArtist}`;
8057
+ const albumUri = `at://${user.did}/app.rocksky.album/${createId()}`;
8058
+ const albumCid = createId();
8059
+ await createAlbums(
8060
+ [
8061
+ {
8062
+ cid: albumCid,
8063
+ uri: albumUri,
8064
+ value: {
8065
+ $type: "app.rocksky.album",
8066
+ title: record.album,
8067
+ artist: record.albumArtist,
8068
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8069
+ releaseDate: record.releaseDate,
8070
+ year: record.year,
8071
+ albumArt: record.albumArt,
8072
+ artistUri: artistRecord.uri,
8073
+ spotifyLink: record.spotifyLink,
8074
+ appleMusicLink: record.appleMusicLink,
8075
+ tidalLink: record.tidalLink,
8076
+ youtubeLink: record.youtubeLink
8077
+ }
8078
+ }
8079
+ ],
8080
+ user
8081
+ );
8082
+ [albumRecord] = await ctx.db.select().from(schema.albums).where(
8083
+ and(
8084
+ eq(schema.albums.title, record.album),
8085
+ eq(schema.albums.artist, record.albumArtist)
8086
+ )
8087
+ ).execute();
8088
+ if (!albumRecord) {
8089
+ logger.error` ❌ Failed to create album. Skipping scrobble.`;
8090
+ return;
8091
+ }
8092
+ }
8093
+ let [track] = await ctx.db.select().from(schema.tracks).where(
8094
+ and(
8095
+ eq(schema.tracks.title, record.title),
8096
+ eq(schema.tracks.artist, record.artist),
8097
+ eq(schema.tracks.album, record.album),
8098
+ eq(schema.tracks.albumArtist, record.albumArtist)
8099
+ )
8100
+ ).execute();
8101
+ if (!track) {
8102
+ logger.info` ⚙️ Track not found, creating: "${title}" by ${artist} from ${album}`;
8103
+ const trackUri = `at://${user.did}/app.rocksky.song/${createId()}`;
8104
+ const trackCid = createId();
8105
+ await createSongs(
8106
+ [
8107
+ {
8108
+ cid: trackCid,
8109
+ uri: trackUri,
8110
+ value: {
8111
+ $type: "app.rocksky.song",
8112
+ title: record.title,
8113
+ artist: record.artist,
8114
+ albumArtist: record.albumArtist,
8115
+ album: record.album,
8116
+ duration: record.duration,
8117
+ trackNumber: record.trackNumber,
8118
+ discNumber: record.discNumber,
8119
+ releaseDate: record.releaseDate,
8120
+ year: record.year,
8121
+ genre: record.genre,
8122
+ tags: record.tags,
8123
+ composer: record.composer,
8124
+ lyrics: record.lyrics,
8125
+ copyrightMessage: record.copyrightMessage,
8126
+ albumArt: record.albumArt,
8127
+ youtubeLink: record.youtubeLink,
8128
+ spotifyLink: record.spotifyLink,
8129
+ tidalLink: record.tidalLink,
8130
+ appleMusicLink: record.appleMusicLink,
8131
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8132
+ mbId: record.mbid,
8133
+ label: record.label,
8134
+ albumUri: albumRecord.uri,
8135
+ artistUri: artistRecord.uri
8136
+ }
8137
+ }
8138
+ ],
8139
+ user
8140
+ );
8141
+ [track] = await ctx.db.select().from(schema.tracks).where(
8142
+ and(
8143
+ eq(schema.tracks.title, record.title),
8144
+ eq(schema.tracks.artist, record.artist),
8145
+ eq(schema.tracks.album, record.album),
8146
+ eq(schema.tracks.albumArtist, record.albumArtist)
8147
+ )
8148
+ ).execute();
8149
+ if (!track) {
8150
+ logger.error` ❌ Failed to create track. Skipping scrobble.`;
8151
+ return;
8152
+ }
8153
+ }
8154
+ logger.info` ✓ All required entities ready. Creating scrobble...`;
8004
8155
  await createScrobbles(
8005
8156
  [
8006
8157
  {
@@ -8013,79 +8164,150 @@ const onNewScrobble = async (record, cid, uri, user) => {
8013
8164
  );
8014
8165
  };
8015
8166
  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
8167
+ const results = [];
8168
+ try {
8169
+ logger.info(`Fetching repository CAR file for songs...`);
8170
+ const repoRes = await agent.com.atproto.sync.getRepo({
8171
+ did: agent.assertDid
8024
8172
  });
8025
- const records = res.data.records;
8026
- results = results.concat(records);
8027
- cursor = res.data.cursor;
8173
+ const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
8174
+ const collection = "app.rocksky.song";
8175
+ for await (const { cid, bytes } of carReader.blocks()) {
8176
+ try {
8177
+ const decoded = cbor.decode(bytes);
8178
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
8179
+ if (decoded.$type === collection) {
8180
+ const value = decoded;
8181
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
8182
+ results.push({
8183
+ value,
8184
+ uri,
8185
+ cid: cid.toString()
8186
+ });
8187
+ }
8188
+ }
8189
+ } catch (e) {
8190
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
8191
+ continue;
8192
+ }
8193
+ }
8028
8194
  logger.info(
8029
8195
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} songs`
8030
8196
  );
8031
- } while (cursor);
8197
+ } catch (error) {
8198
+ logger.error(`Error fetching songs from CAR: ${error}`);
8199
+ throw error;
8200
+ }
8032
8201
  return results;
8033
8202
  };
8034
8203
  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
8204
+ const results = [];
8205
+ try {
8206
+ logger.info(`Fetching repository CAR file for albums...`);
8207
+ const repoRes = await agent.com.atproto.sync.getRepo({
8208
+ did: agent.assertDid
8043
8209
  });
8044
- const records = res.data.records;
8045
- results = results.concat(records);
8046
- cursor = res.data.cursor;
8210
+ const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
8211
+ const collection = "app.rocksky.album";
8212
+ for await (const { cid, bytes } of carReader.blocks()) {
8213
+ try {
8214
+ const decoded = cbor.decode(bytes);
8215
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
8216
+ if (decoded.$type === collection) {
8217
+ const value = decoded;
8218
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
8219
+ results.push({
8220
+ value,
8221
+ uri,
8222
+ cid: cid.toString()
8223
+ });
8224
+ }
8225
+ }
8226
+ } catch (e) {
8227
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
8228
+ continue;
8229
+ }
8230
+ }
8047
8231
  logger.info(
8048
8232
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} albums`
8049
8233
  );
8050
- } while (cursor);
8234
+ } catch (error) {
8235
+ logger.error(`Error fetching albums from CAR: ${error}`);
8236
+ throw error;
8237
+ }
8051
8238
  return results;
8052
8239
  };
8053
8240
  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
8241
+ const results = [];
8242
+ try {
8243
+ logger.info(`Fetching repository CAR file for artists...`);
8244
+ const repoRes = await agent.com.atproto.sync.getRepo({
8245
+ did: agent.assertDid
8062
8246
  });
8063
- const records = res.data.records;
8064
- results = results.concat(records);
8065
- cursor = res.data.cursor;
8247
+ const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
8248
+ const collection = "app.rocksky.artist";
8249
+ for await (const { cid, bytes } of carReader.blocks()) {
8250
+ try {
8251
+ const decoded = cbor.decode(bytes);
8252
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
8253
+ if (decoded.$type === collection) {
8254
+ const value = decoded;
8255
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
8256
+ results.push({
8257
+ value,
8258
+ uri,
8259
+ cid: cid.toString()
8260
+ });
8261
+ }
8262
+ }
8263
+ } catch (e) {
8264
+ continue;
8265
+ }
8266
+ }
8066
8267
  logger.info(
8067
8268
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} artists`
8068
8269
  );
8069
- } while (cursor);
8270
+ } catch (error) {
8271
+ logger.error(`Error fetching artists from CAR: ${error}`);
8272
+ throw error;
8273
+ }
8070
8274
  return results;
8071
8275
  };
8072
8276
  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
8277
+ const results = [];
8278
+ try {
8279
+ logger.info(`Fetching repository CAR file for scrobbles...`);
8280
+ const repoRes = await agent.com.atproto.sync.getRepo({
8281
+ did: agent.assertDid
8081
8282
  });
8082
- const records = res.data.records;
8083
- results = results.concat(records);
8084
- cursor = res.data.cursor;
8283
+ const carReader = await CarReader.fromBytes(new Uint8Array(repoRes.data));
8284
+ const collection = "app.rocksky.scrobble";
8285
+ for await (const { cid, bytes } of carReader.blocks()) {
8286
+ try {
8287
+ const decoded = cbor.decode(bytes);
8288
+ if (decoded && typeof decoded === "object" && "$type" in decoded) {
8289
+ if (decoded.$type === collection) {
8290
+ const value = decoded;
8291
+ const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
8292
+ results.push({
8293
+ value,
8294
+ uri,
8295
+ cid: cid.toString()
8296
+ });
8297
+ }
8298
+ }
8299
+ } catch (e) {
8300
+ logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
8301
+ continue;
8302
+ }
8303
+ }
8085
8304
  logger.info(
8086
8305
  `${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} scrobbles`
8087
8306
  );
8088
- } while (cursor);
8307
+ } catch (error) {
8308
+ logger.error(`Error fetching scrobbles from CAR: ${error}`);
8309
+ throw error;
8310
+ }
8089
8311
  return results;
8090
8312
  };
8091
8313
 
@@ -8571,7 +8793,7 @@ async function whoami() {
8571
8793
  }
8572
8794
  }
8573
8795
 
8574
- var version = "0.3.0";
8796
+ var version = "0.3.1";
8575
8797
 
8576
8798
  async function login(handle) {
8577
8799
  const app = express();
@@ -9201,6 +9423,13 @@ Welcome to the lastfm compatibility API
9201
9423
  return c.text("Scrobble received");
9202
9424
  });
9203
9425
  logger.info`lastfm/listenbrainz/webscrobbler scrobble API listening on ${"http://localhost:" + port}`;
9426
+ new Promise(async () => {
9427
+ try {
9428
+ await sync();
9429
+ } catch (err) {
9430
+ logger.warn`Error during initial sync: ${err}`;
9431
+ }
9432
+ });
9204
9433
  serve({ fetch: app.fetch, port });
9205
9434
  }
9206
9435
 
@@ -0,0 +1 @@
1
+ CREATE INDEX `idx_title_artist_album_albumartist` ON `tracks` (`title`,`artist`,`album`,`album_artist`);