@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.
- package/README.md +9 -6
- package/dist/drizzle/0001_awesome_gabe_jones.sql +1 -0
- package/dist/drizzle/meta/0001_snapshot.json +1569 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/index.js +316 -96
- package/drizzle/0001_awesome_gabe_jones.sql +1 -0
- package/drizzle/meta/0001_snapshot.json +1569 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -1
- package/preview.png +0 -0
- package/src/cmd/scrobble-api.ts +8 -0
- package/src/cmd/sync.ts +356 -86
- package/src/schema/tracks.ts +40 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rocksky/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Command-line interface for Rocksky – scrobble tracks, view stats, and manage your listening history",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -32,8 +32,11 @@
|
|
|
32
32
|
"@atproto/jwk-jose": "0.1.5",
|
|
33
33
|
"@atproto/lex-cli": "^0.5.6",
|
|
34
34
|
"@atproto/lexicon": "^0.4.5",
|
|
35
|
+
"@atproto/repo": "^0.6.2",
|
|
35
36
|
"@atproto/sync": "^0.1.11",
|
|
36
37
|
"@atproto/syntax": "^0.3.1",
|
|
38
|
+
"@ipld/car": "^3.2.4",
|
|
39
|
+
"@ipld/dag-cbor": "^9.2.2",
|
|
37
40
|
"@hono/node-server": "^1.13.8",
|
|
38
41
|
"@logtape/logtape": "^1.3.6",
|
|
39
42
|
"@modelcontextprotocol/sdk": "^1.10.2",
|
|
@@ -56,6 +59,7 @@
|
|
|
56
59
|
"kysely": "^0.27.5",
|
|
57
60
|
"lodash": "^4.17.21",
|
|
58
61
|
"md5": "^2.3.0",
|
|
62
|
+
"multiformats": "^9.9.0",
|
|
59
63
|
"open": "^10.1.0",
|
|
60
64
|
"table": "^6.9.0",
|
|
61
65
|
"unstorage": "^1.14.4",
|
package/preview.png
ADDED
|
Binary file
|
package/src/cmd/scrobble-api.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { matchTrack } from "lib/matchTrack";
|
|
|
11
11
|
import _ from "lodash";
|
|
12
12
|
import { publishScrobble } from "scrobble";
|
|
13
13
|
import { validateLastfmSignature } from "lib/lastfm";
|
|
14
|
+
import { sync } from "./sync";
|
|
14
15
|
|
|
15
16
|
export async function scrobbleApi({ port }) {
|
|
16
17
|
const [, handle] = await getDidAndHandle();
|
|
@@ -453,5 +454,12 @@ export async function scrobbleApi({ port }) {
|
|
|
453
454
|
|
|
454
455
|
log.info`lastfm/listenbrainz/webscrobbler scrobble API listening on ${"http://localhost:" + port}`;
|
|
455
456
|
|
|
457
|
+
new Promise(async () => {
|
|
458
|
+
try {
|
|
459
|
+
await sync();
|
|
460
|
+
} catch (err) {
|
|
461
|
+
log.warn`Error during initial sync: ${err}`;
|
|
462
|
+
}
|
|
463
|
+
});
|
|
456
464
|
serve({ fetch: app.fetch, port });
|
|
457
465
|
}
|
package/src/cmd/sync.ts
CHANGED
|
@@ -21,8 +21,8 @@ import path from "node:path";
|
|
|
21
21
|
import { getDidAndHandle } from "lib/getDidAndHandle";
|
|
22
22
|
import { cleanUpJetstreamLockOnExit } from "lib/cleanUpJetstreamLock";
|
|
23
23
|
import { cleanUpSyncLockOnExit } from "lib/cleanUpSyncLock";
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
import { CarReader } from "@ipld/car";
|
|
25
|
+
import * as cbor from "@ipld/dag-cbor";
|
|
26
26
|
|
|
27
27
|
type Artists = { value: Artist.Record; uri: string; cid: string }[];
|
|
28
28
|
type Albums = { value: Album.Record; uri: string; cid: string }[];
|
|
@@ -39,11 +39,13 @@ export async function sync() {
|
|
|
39
39
|
logger.info` DID: ${did}`;
|
|
40
40
|
logger.info` Handle: ${handle}`;
|
|
41
41
|
|
|
42
|
+
const carReader = await downloadCarFile(agent);
|
|
43
|
+
|
|
42
44
|
const [artists, albums, songs, scrobbles] = await Promise.all([
|
|
43
|
-
getRockskyUserArtists(agent),
|
|
44
|
-
getRockskyUserAlbums(agent),
|
|
45
|
-
getRockskyUserSongs(agent),
|
|
46
|
-
getRockskyUserScrobbles(agent),
|
|
45
|
+
getRockskyUserArtists(agent, carReader),
|
|
46
|
+
getRockskyUserAlbums(agent, carReader),
|
|
47
|
+
getRockskyUserSongs(agent, carReader),
|
|
48
|
+
getRockskyUserScrobbles(agent, carReader),
|
|
47
49
|
]);
|
|
48
50
|
|
|
49
51
|
logger.info` Artists: ${artists.length}`;
|
|
@@ -133,7 +135,7 @@ const createArtists = async (artists: Artists, user: SelectUser) => {
|
|
|
133
135
|
name: tag,
|
|
134
136
|
}));
|
|
135
137
|
|
|
136
|
-
const BATCH_SIZE =
|
|
138
|
+
const BATCH_SIZE = 1000;
|
|
137
139
|
for (let i = 0; i < uniqueTags.length; i += BATCH_SIZE) {
|
|
138
140
|
const batch = uniqueTags.slice(i, i + BATCH_SIZE);
|
|
139
141
|
await ctx.db
|
|
@@ -241,7 +243,7 @@ const createAlbums = async (albums: Albums, user: SelectUser) => {
|
|
|
241
243
|
.filter(({ artist }) => artist);
|
|
242
244
|
|
|
243
245
|
// Process albums in batches
|
|
244
|
-
const BATCH_SIZE =
|
|
246
|
+
const BATCH_SIZE = 1000;
|
|
245
247
|
let totalAlbumsImported = 0;
|
|
246
248
|
|
|
247
249
|
for (let i = 0; i < validAlbumData.length; i += BATCH_SIZE) {
|
|
@@ -332,7 +334,7 @@ const createSongs = async (songs: Songs, user: SelectUser) => {
|
|
|
332
334
|
.filter(({ artist, album }) => artist && album);
|
|
333
335
|
|
|
334
336
|
// Process in batches to avoid stack overflow with large datasets
|
|
335
|
-
const BATCH_SIZE =
|
|
337
|
+
const BATCH_SIZE = 1000;
|
|
336
338
|
let totalTracksImported = 0;
|
|
337
339
|
|
|
338
340
|
for (let i = 0; i < validSongData.length; i += BATCH_SIZE) {
|
|
@@ -481,7 +483,7 @@ const createScrobbles = async (scrobbles: Scrobbles, user: SelectUser) => {
|
|
|
481
483
|
.filter(({ track, album, artist }) => track && album && artist);
|
|
482
484
|
|
|
483
485
|
// Process in batches to avoid stack overflow with large datasets
|
|
484
|
-
const BATCH_SIZE =
|
|
486
|
+
const BATCH_SIZE = 1000;
|
|
485
487
|
let totalScrobblesImported = 0;
|
|
486
488
|
|
|
487
489
|
for (let i = 0; i < validScrobbleData.length; i += BATCH_SIZE) {
|
|
@@ -624,6 +626,16 @@ const onNewSong = async (
|
|
|
624
626
|
) => {
|
|
625
627
|
const { title, artist, album } = record;
|
|
626
628
|
logger.info` New song: ${title} by ${artist} from ${album}`;
|
|
629
|
+
await createSongs(
|
|
630
|
+
[
|
|
631
|
+
{
|
|
632
|
+
cid,
|
|
633
|
+
uri,
|
|
634
|
+
value: record,
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
user,
|
|
638
|
+
);
|
|
627
639
|
};
|
|
628
640
|
|
|
629
641
|
const onNewAlbum = async (
|
|
@@ -672,8 +684,192 @@ const onNewScrobble = async (
|
|
|
672
684
|
uri: string,
|
|
673
685
|
user: SelectUser,
|
|
674
686
|
) => {
|
|
675
|
-
const { title, createdAt } = record;
|
|
687
|
+
const { title, createdAt, artist, album, albumArtist } = record;
|
|
676
688
|
logger.info` New scrobble: ${title} at ${createdAt}`;
|
|
689
|
+
|
|
690
|
+
// Check if the artist exists, create if not
|
|
691
|
+
let [artistRecord] = await ctx.db
|
|
692
|
+
.select()
|
|
693
|
+
.from(schema.artists)
|
|
694
|
+
.where(eq(schema.artists.name, record.albumArtist))
|
|
695
|
+
.execute();
|
|
696
|
+
|
|
697
|
+
if (!artistRecord) {
|
|
698
|
+
logger.info` ⚙️ Artist not found, creating: "${albumArtist}"`;
|
|
699
|
+
|
|
700
|
+
// Create a synthetic artist record from scrobble data
|
|
701
|
+
const artistUri = `at://${user.did}/app.rocksky.artist/${createId()}`;
|
|
702
|
+
const artistCid = createId();
|
|
703
|
+
|
|
704
|
+
await createArtists(
|
|
705
|
+
[
|
|
706
|
+
{
|
|
707
|
+
cid: artistCid,
|
|
708
|
+
uri: artistUri,
|
|
709
|
+
value: {
|
|
710
|
+
$type: "app.rocksky.artist",
|
|
711
|
+
name: record.albumArtist,
|
|
712
|
+
createdAt: new Date().toISOString(),
|
|
713
|
+
tags: record.tags || [],
|
|
714
|
+
} as Artist.Record,
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
user,
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
[artistRecord] = await ctx.db
|
|
721
|
+
.select()
|
|
722
|
+
.from(schema.artists)
|
|
723
|
+
.where(eq(schema.artists.name, record.albumArtist))
|
|
724
|
+
.execute();
|
|
725
|
+
|
|
726
|
+
if (!artistRecord) {
|
|
727
|
+
logger.error` ❌ Failed to create artist. Skipping scrobble.`;
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Check if the album exists, create if not
|
|
733
|
+
let [albumRecord] = await ctx.db
|
|
734
|
+
.select()
|
|
735
|
+
.from(schema.albums)
|
|
736
|
+
.where(
|
|
737
|
+
and(
|
|
738
|
+
eq(schema.albums.title, record.album),
|
|
739
|
+
eq(schema.albums.artist, record.albumArtist),
|
|
740
|
+
),
|
|
741
|
+
)
|
|
742
|
+
.execute();
|
|
743
|
+
|
|
744
|
+
if (!albumRecord) {
|
|
745
|
+
logger.info` ⚙️ Album not found, creating: "${album}" by ${albumArtist}`;
|
|
746
|
+
|
|
747
|
+
// Create a synthetic album record from scrobble data
|
|
748
|
+
const albumUri = `at://${user.did}/app.rocksky.album/${createId()}`;
|
|
749
|
+
const albumCid = createId();
|
|
750
|
+
|
|
751
|
+
await createAlbums(
|
|
752
|
+
[
|
|
753
|
+
{
|
|
754
|
+
cid: albumCid,
|
|
755
|
+
uri: albumUri,
|
|
756
|
+
value: {
|
|
757
|
+
$type: "app.rocksky.album",
|
|
758
|
+
title: record.album,
|
|
759
|
+
artist: record.albumArtist,
|
|
760
|
+
createdAt: new Date().toISOString(),
|
|
761
|
+
releaseDate: record.releaseDate,
|
|
762
|
+
year: record.year,
|
|
763
|
+
albumArt: record.albumArt,
|
|
764
|
+
artistUri: artistRecord.uri,
|
|
765
|
+
spotifyLink: record.spotifyLink,
|
|
766
|
+
appleMusicLink: record.appleMusicLink,
|
|
767
|
+
tidalLink: record.tidalLink,
|
|
768
|
+
youtubeLink: record.youtubeLink,
|
|
769
|
+
} as Album.Record,
|
|
770
|
+
},
|
|
771
|
+
],
|
|
772
|
+
user,
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
// Fetch the newly created album
|
|
776
|
+
[albumRecord] = await ctx.db
|
|
777
|
+
.select()
|
|
778
|
+
.from(schema.albums)
|
|
779
|
+
.where(
|
|
780
|
+
and(
|
|
781
|
+
eq(schema.albums.title, record.album),
|
|
782
|
+
eq(schema.albums.artist, record.albumArtist),
|
|
783
|
+
),
|
|
784
|
+
)
|
|
785
|
+
.execute();
|
|
786
|
+
|
|
787
|
+
if (!albumRecord) {
|
|
788
|
+
logger.error` ❌ Failed to create album. Skipping scrobble.`;
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Check if the track exists, create if not
|
|
794
|
+
let [track] = await ctx.db
|
|
795
|
+
.select()
|
|
796
|
+
.from(schema.tracks)
|
|
797
|
+
.where(
|
|
798
|
+
and(
|
|
799
|
+
eq(schema.tracks.title, record.title),
|
|
800
|
+
eq(schema.tracks.artist, record.artist),
|
|
801
|
+
eq(schema.tracks.album, record.album),
|
|
802
|
+
eq(schema.tracks.albumArtist, record.albumArtist),
|
|
803
|
+
),
|
|
804
|
+
)
|
|
805
|
+
.execute();
|
|
806
|
+
|
|
807
|
+
if (!track) {
|
|
808
|
+
logger.info` ⚙️ Track not found, creating: "${title}" by ${artist} from ${album}`;
|
|
809
|
+
|
|
810
|
+
// Create a synthetic track record from scrobble data
|
|
811
|
+
const trackUri = `at://${user.did}/app.rocksky.song/${createId()}`;
|
|
812
|
+
const trackCid = createId();
|
|
813
|
+
|
|
814
|
+
await createSongs(
|
|
815
|
+
[
|
|
816
|
+
{
|
|
817
|
+
cid: trackCid,
|
|
818
|
+
uri: trackUri,
|
|
819
|
+
value: {
|
|
820
|
+
$type: "app.rocksky.song",
|
|
821
|
+
title: record.title,
|
|
822
|
+
artist: record.artist,
|
|
823
|
+
albumArtist: record.albumArtist,
|
|
824
|
+
album: record.album,
|
|
825
|
+
duration: record.duration,
|
|
826
|
+
trackNumber: record.trackNumber,
|
|
827
|
+
discNumber: record.discNumber,
|
|
828
|
+
releaseDate: record.releaseDate,
|
|
829
|
+
year: record.year,
|
|
830
|
+
genre: record.genre,
|
|
831
|
+
tags: record.tags,
|
|
832
|
+
composer: record.composer,
|
|
833
|
+
lyrics: record.lyrics,
|
|
834
|
+
copyrightMessage: record.copyrightMessage,
|
|
835
|
+
albumArt: record.albumArt,
|
|
836
|
+
youtubeLink: record.youtubeLink,
|
|
837
|
+
spotifyLink: record.spotifyLink,
|
|
838
|
+
tidalLink: record.tidalLink,
|
|
839
|
+
appleMusicLink: record.appleMusicLink,
|
|
840
|
+
createdAt: new Date().toISOString(),
|
|
841
|
+
mbId: record.mbid,
|
|
842
|
+
label: record.label,
|
|
843
|
+
albumUri: albumRecord.uri,
|
|
844
|
+
artistUri: artistRecord.uri,
|
|
845
|
+
} as Song.Record,
|
|
846
|
+
},
|
|
847
|
+
],
|
|
848
|
+
user,
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
// Fetch the newly created track
|
|
852
|
+
[track] = await ctx.db
|
|
853
|
+
.select()
|
|
854
|
+
.from(schema.tracks)
|
|
855
|
+
.where(
|
|
856
|
+
and(
|
|
857
|
+
eq(schema.tracks.title, record.title),
|
|
858
|
+
eq(schema.tracks.artist, record.artist),
|
|
859
|
+
eq(schema.tracks.album, record.album),
|
|
860
|
+
eq(schema.tracks.albumArtist, record.albumArtist),
|
|
861
|
+
),
|
|
862
|
+
)
|
|
863
|
+
.execute();
|
|
864
|
+
|
|
865
|
+
if (!track) {
|
|
866
|
+
logger.error` ❌ Failed to create track. Skipping scrobble.`;
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
logger.info` ✓ All required entities ready. Creating scrobble...`;
|
|
872
|
+
|
|
677
873
|
await createScrobbles(
|
|
678
874
|
[
|
|
679
875
|
{
|
|
@@ -686,127 +882,201 @@ const onNewScrobble = async (
|
|
|
686
882
|
);
|
|
687
883
|
};
|
|
688
884
|
|
|
689
|
-
const
|
|
690
|
-
|
|
885
|
+
const downloadCarFile = async (agent: Agent) => {
|
|
886
|
+
logger.info(`Fetching repository CAR file ...`);
|
|
887
|
+
|
|
888
|
+
const repoRes = await agent.com.atproto.sync.getRepo({
|
|
889
|
+
did: agent.assertDid,
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
return CarReader.fromBytes(new Uint8Array(repoRes.data));
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
const getRockskyUserSongs = async (
|
|
896
|
+
agent: Agent,
|
|
897
|
+
carReader: CarReader,
|
|
898
|
+
): Promise<Songs> => {
|
|
899
|
+
const results: {
|
|
691
900
|
value: Song.Record;
|
|
692
901
|
uri: string;
|
|
693
902
|
cid: string;
|
|
694
903
|
}[] = [];
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
904
|
+
|
|
905
|
+
try {
|
|
906
|
+
const collection = "app.rocksky.song";
|
|
907
|
+
|
|
908
|
+
for await (const { cid, bytes } of carReader.blocks()) {
|
|
909
|
+
try {
|
|
910
|
+
const decoded = cbor.decode(bytes);
|
|
911
|
+
|
|
912
|
+
// Check if this is a record with $type matching our collection
|
|
913
|
+
if (decoded && typeof decoded === "object" && "$type" in decoded) {
|
|
914
|
+
if (decoded.$type === collection) {
|
|
915
|
+
const value = decoded as unknown as Song.Record;
|
|
916
|
+
// Extract rkey from uri if present in the block, otherwise use cid
|
|
917
|
+
const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
|
|
918
|
+
|
|
919
|
+
results.push({
|
|
920
|
+
value,
|
|
921
|
+
uri,
|
|
922
|
+
cid: cid.toString(),
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
} catch (e) {
|
|
927
|
+
logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
710
932
|
logger.info(
|
|
711
933
|
`${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} songs`,
|
|
712
934
|
);
|
|
713
|
-
}
|
|
935
|
+
} catch (error) {
|
|
936
|
+
logger.error(`Error fetching songs from CAR: ${error}`);
|
|
937
|
+
throw error;
|
|
938
|
+
}
|
|
714
939
|
|
|
715
940
|
return results;
|
|
716
941
|
};
|
|
717
942
|
|
|
718
|
-
const getRockskyUserAlbums = async (
|
|
719
|
-
|
|
943
|
+
const getRockskyUserAlbums = async (
|
|
944
|
+
agent: Agent,
|
|
945
|
+
carReader: CarReader,
|
|
946
|
+
): Promise<Albums> => {
|
|
947
|
+
const results: {
|
|
720
948
|
value: Album.Record;
|
|
721
949
|
uri: string;
|
|
722
950
|
cid: string;
|
|
723
951
|
}[] = [];
|
|
724
|
-
let cursor: string | undefined;
|
|
725
|
-
do {
|
|
726
|
-
const res = await agent.com.atproto.repo.listRecords({
|
|
727
|
-
repo: agent.assertDid,
|
|
728
|
-
collection: "app.rocksky.album",
|
|
729
|
-
limit: PAGE_SIZE,
|
|
730
|
-
cursor,
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
const records = res.data.records as Array<{
|
|
734
|
-
uri: string;
|
|
735
|
-
cid: string;
|
|
736
|
-
value: Album.Record;
|
|
737
|
-
}>;
|
|
738
952
|
|
|
739
|
-
|
|
953
|
+
try {
|
|
954
|
+
const collection = "app.rocksky.album";
|
|
955
|
+
logger.info`Extracting ${collection} records from CAR file ...`;
|
|
956
|
+
|
|
957
|
+
for await (const { cid, bytes } of carReader.blocks()) {
|
|
958
|
+
try {
|
|
959
|
+
const decoded = cbor.decode(bytes);
|
|
960
|
+
|
|
961
|
+
if (decoded && typeof decoded === "object" && "$type" in decoded) {
|
|
962
|
+
if (decoded.$type === collection) {
|
|
963
|
+
const value = decoded as unknown as Album.Record;
|
|
964
|
+
const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
|
|
965
|
+
|
|
966
|
+
results.push({
|
|
967
|
+
value,
|
|
968
|
+
uri,
|
|
969
|
+
cid: cid.toString(),
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
} catch (e) {
|
|
974
|
+
logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
740
978
|
|
|
741
|
-
cursor = res.data.cursor;
|
|
742
979
|
logger.info(
|
|
743
980
|
`${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} albums`,
|
|
744
981
|
);
|
|
745
|
-
}
|
|
982
|
+
} catch (error) {
|
|
983
|
+
logger.error(`Error fetching albums from CAR: ${error}`);
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
746
986
|
|
|
747
987
|
return results;
|
|
748
988
|
};
|
|
749
989
|
|
|
750
|
-
const getRockskyUserArtists = async (
|
|
751
|
-
|
|
990
|
+
const getRockskyUserArtists = async (
|
|
991
|
+
agent: Agent,
|
|
992
|
+
carReader: CarReader,
|
|
993
|
+
): Promise<Artists> => {
|
|
994
|
+
const results: {
|
|
752
995
|
value: Artist.Record;
|
|
753
996
|
uri: string;
|
|
754
997
|
cid: string;
|
|
755
998
|
}[] = [];
|
|
756
|
-
let cursor: string | undefined;
|
|
757
|
-
do {
|
|
758
|
-
const res = await agent.com.atproto.repo.listRecords({
|
|
759
|
-
repo: agent.assertDid,
|
|
760
|
-
collection: "app.rocksky.artist",
|
|
761
|
-
limit: PAGE_SIZE,
|
|
762
|
-
cursor,
|
|
763
|
-
});
|
|
764
999
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
|
|
1000
|
+
try {
|
|
1001
|
+
const collection = "app.rocksky.artist";
|
|
1002
|
+
logger.info`Extracting ${collection} records from CAR file ...`;
|
|
1003
|
+
|
|
1004
|
+
for await (const { cid, bytes } of carReader.blocks()) {
|
|
1005
|
+
try {
|
|
1006
|
+
const decoded = cbor.decode(bytes);
|
|
1007
|
+
|
|
1008
|
+
if (decoded && typeof decoded === "object" && "$type" in decoded) {
|
|
1009
|
+
if (decoded.$type === collection) {
|
|
1010
|
+
const value = decoded as unknown as Artist.Record;
|
|
1011
|
+
const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
|
|
1012
|
+
|
|
1013
|
+
results.push({
|
|
1014
|
+
value,
|
|
1015
|
+
uri,
|
|
1016
|
+
cid: cid.toString(),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
} catch (e) {
|
|
1021
|
+
// Skip blocks that can't be decoded
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
772
1025
|
|
|
773
|
-
cursor = res.data.cursor;
|
|
774
1026
|
logger.info(
|
|
775
1027
|
`${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} artists`,
|
|
776
1028
|
);
|
|
777
|
-
}
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
logger.error(`Error fetching artists from CAR: ${error}`);
|
|
1031
|
+
throw error;
|
|
1032
|
+
}
|
|
778
1033
|
|
|
779
1034
|
return results;
|
|
780
1035
|
};
|
|
781
1036
|
|
|
782
|
-
const getRockskyUserScrobbles = async (
|
|
783
|
-
|
|
1037
|
+
const getRockskyUserScrobbles = async (
|
|
1038
|
+
agent: Agent,
|
|
1039
|
+
carReader: CarReader,
|
|
1040
|
+
): Promise<Scrobbles> => {
|
|
1041
|
+
const results: {
|
|
784
1042
|
value: Scrobble.Record;
|
|
785
1043
|
uri: string;
|
|
786
1044
|
cid: string;
|
|
787
1045
|
}[] = [];
|
|
788
|
-
let cursor: string | undefined;
|
|
789
|
-
do {
|
|
790
|
-
const res = await agent.com.atproto.repo.listRecords({
|
|
791
|
-
repo: agent.assertDid,
|
|
792
|
-
collection: "app.rocksky.scrobble",
|
|
793
|
-
limit: PAGE_SIZE,
|
|
794
|
-
cursor,
|
|
795
|
-
});
|
|
796
1046
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
|
|
1047
|
+
try {
|
|
1048
|
+
const collection = "app.rocksky.scrobble";
|
|
1049
|
+
logger.info`Extracting ${collection} records from CAR file ...`;
|
|
1050
|
+
|
|
1051
|
+
for await (const { cid, bytes } of carReader.blocks()) {
|
|
1052
|
+
try {
|
|
1053
|
+
const decoded = cbor.decode(bytes);
|
|
1054
|
+
|
|
1055
|
+
if (decoded && typeof decoded === "object" && "$type" in decoded) {
|
|
1056
|
+
if (decoded.$type === collection) {
|
|
1057
|
+
const value = decoded as unknown as Scrobble.Record;
|
|
1058
|
+
const uri = `at://${agent.assertDid}/${collection}/${cid.toString()}`;
|
|
1059
|
+
|
|
1060
|
+
results.push({
|
|
1061
|
+
value,
|
|
1062
|
+
uri,
|
|
1063
|
+
cid: cid.toString(),
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
logger.warn` Skipping block with CID ${cid.toString()} due to decode error: ${e}`;
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
804
1072
|
|
|
805
|
-
cursor = res.data.cursor;
|
|
806
1073
|
logger.info(
|
|
807
1074
|
`${chalk.cyanBright(agent.assertDid)} ${chalk.greenBright(results.length)} scrobbles`,
|
|
808
1075
|
);
|
|
809
|
-
}
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
logger.error(`Error fetching scrobbles from CAR: ${error}`);
|
|
1078
|
+
throw error;
|
|
1079
|
+
}
|
|
810
1080
|
|
|
811
1081
|
return results;
|
|
812
1082
|
};
|
package/src/schema/tracks.ts
CHANGED
|
@@ -1,37 +1,45 @@
|
|
|
1
1
|
import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
|
|
2
|
-
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
import { integer, sqliteTable, text, index } from "drizzle-orm/sqlite-core";
|
|
3
3
|
|
|
4
|
-
const tracks = sqliteTable(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
4
|
+
const tracks = sqliteTable(
|
|
5
|
+
"tracks",
|
|
6
|
+
{
|
|
7
|
+
id: text("id").primaryKey().notNull(),
|
|
8
|
+
title: text("title").notNull(),
|
|
9
|
+
artist: text("artist").notNull(),
|
|
10
|
+
albumArtist: text("album_artist").notNull(),
|
|
11
|
+
albumArt: text("album_art"),
|
|
12
|
+
album: text("album").notNull(),
|
|
13
|
+
trackNumber: integer("track_number"),
|
|
14
|
+
duration: integer("duration").notNull(),
|
|
15
|
+
mbId: text("mb_id").unique(),
|
|
16
|
+
youtubeLink: text("youtube_link").unique(),
|
|
17
|
+
spotifyLink: text("spotify_link").unique(),
|
|
18
|
+
appleMusicLink: text("apple_music_link").unique(),
|
|
19
|
+
tidalLink: text("tidal_link").unique(),
|
|
20
|
+
discNumber: integer("disc_number"),
|
|
21
|
+
lyrics: text("lyrics"),
|
|
22
|
+
composer: text("composer"),
|
|
23
|
+
genre: text("genre"),
|
|
24
|
+
label: text("label"),
|
|
25
|
+
copyrightMessage: text("copyright_message"),
|
|
26
|
+
uri: text("uri").unique(),
|
|
27
|
+
cid: text("cid").unique().notNull(),
|
|
28
|
+
albumUri: text("album_uri"),
|
|
29
|
+
artistUri: text("artist_uri"),
|
|
30
|
+
createdAt: integer("created_at", { mode: "timestamp" })
|
|
31
|
+
.notNull()
|
|
32
|
+
.default(sql`(unixepoch())`),
|
|
33
|
+
updatedAt: integer("updated_at", { mode: "timestamp" })
|
|
34
|
+
.notNull()
|
|
35
|
+
.default(sql`(unixepoch())`),
|
|
36
|
+
},
|
|
37
|
+
(t) => ({
|
|
38
|
+
idx_title_artist_album_albumartist: index(
|
|
39
|
+
"idx_title_artist_album_albumartist",
|
|
40
|
+
).on(t.title, t.artist, t.album, t.albumArtist),
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
35
43
|
|
|
36
44
|
export type SelectTrack = InferSelectModel<typeof tracks>;
|
|
37
45
|
export type InsertTrack = InferInsertModel<typeof tracks>;
|