+32
-6
apps/cli/drizzle/0000_amazing_redwing.sql
apps/cli/drizzle/0000_parched_robbie_robertson.sql
+32
-6
apps/cli/drizzle/0000_amazing_redwing.sql
apps/cli/drizzle/0000_parched_robbie_robertson.sql
···
16
16
`year` integer,
17
17
`album_art` text,
18
18
`uri` text,
19
+
`cid` text NOT NULL,
19
20
`artist_uri` text,
20
21
`apple_music_link` text,
21
22
`spotify_link` text,
22
23
`tidal_link` text,
23
24
`youtube_link` text,
24
-
`sha256` text NOT NULL,
25
25
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
26
26
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
27
27
);
28
28
--> statement-breakpoint
29
29
CREATE UNIQUE INDEX `albums_uri_unique` ON `albums` (`uri`);--> statement-breakpoint
30
+
CREATE UNIQUE INDEX `albums_cid_unique` ON `albums` (`cid`);--> statement-breakpoint
30
31
CREATE UNIQUE INDEX `albums_apple_music_link_unique` ON `albums` (`apple_music_link`);--> statement-breakpoint
31
32
CREATE UNIQUE INDEX `albums_spotify_link_unique` ON `albums` (`spotify_link`);--> statement-breakpoint
32
33
CREATE UNIQUE INDEX `albums_tidal_link_unique` ON `albums` (`tidal_link`);--> statement-breakpoint
33
34
CREATE UNIQUE INDEX `albums_youtube_link_unique` ON `albums` (`youtube_link`);--> statement-breakpoint
34
-
CREATE UNIQUE INDEX `albums_sha256_unique` ON `albums` (`sha256`);--> statement-breakpoint
35
35
CREATE TABLE `artist_albums` (
36
36
`id` text PRIMARY KEY NOT NULL,
37
37
`artist_id` text NOT NULL,
···
60
60
`born_in` text,
61
61
`died` integer,
62
62
`picture` text,
63
-
`sha256` text NOT NULL,
64
63
`uri` text,
64
+
`cid` text NOT NULL,
65
65
`apple_music_link` text,
66
66
`spotify_link` text,
67
67
`tidal_link` text,
···
71
71
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
72
72
);
73
73
--> statement-breakpoint
74
-
CREATE UNIQUE INDEX `artists_sha256_unique` ON `artists` (`sha256`);--> statement-breakpoint
75
74
CREATE UNIQUE INDEX `artists_uri_unique` ON `artists` (`uri`);--> statement-breakpoint
75
+
CREATE UNIQUE INDEX `artists_cid_unique` ON `artists` (`cid`);--> statement-breakpoint
76
+
CREATE TABLE `auth_sessions` (
77
+
`key` text PRIMARY KEY NOT NULL,
78
+
`session` text NOT NULL,
79
+
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
80
+
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
81
+
);
82
+
--> statement-breakpoint
76
83
CREATE TABLE `loved_tracks` (
77
84
`id` text PRIMARY KEY NOT NULL,
78
85
`user_id` text NOT NULL,
···
84
91
);
85
92
--> statement-breakpoint
86
93
CREATE UNIQUE INDEX `loved_tracks_uri_unique` ON `loved_tracks` (`uri`);--> statement-breakpoint
94
+
CREATE TABLE `scrobbles` (
95
+
`xata_id` text PRIMARY KEY NOT NULL,
96
+
`user_id` text,
97
+
`track_id` text,
98
+
`album_id` text,
99
+
`artist_id` text,
100
+
`uri` text,
101
+
`cid` text,
102
+
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
103
+
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
104
+
`timestamp` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
105
+
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
106
+
FOREIGN KEY (`track_id`) REFERENCES `tracks`(`id`) ON UPDATE no action ON DELETE no action,
107
+
FOREIGN KEY (`album_id`) REFERENCES `albums`(`id`) ON UPDATE no action ON DELETE no action,
108
+
FOREIGN KEY (`artist_id`) REFERENCES `artists`(`id`) ON UPDATE no action ON DELETE no action
109
+
);
110
+
--> statement-breakpoint
111
+
CREATE UNIQUE INDEX `scrobbles_uri_unique` ON `scrobbles` (`uri`);--> statement-breakpoint
112
+
CREATE UNIQUE INDEX `scrobbles_cid_unique` ON `scrobbles` (`cid`);--> statement-breakpoint
87
113
CREATE TABLE `tracks` (
88
114
`id` text PRIMARY KEY NOT NULL,
89
115
`title` text NOT NULL,
···
98
124
`spotify_link` text,
99
125
`apple_music_link` text,
100
126
`tidal_link` text,
101
-
`sha256` text NOT NULL,
102
127
`disc_number` integer,
103
128
`lyrics` text,
104
129
`composer` text,
···
106
131
`label` text,
107
132
`copyright_message` text,
108
133
`uri` text,
134
+
`cid` text NOT NULL,
109
135
`album_uri` text,
110
136
`artist_uri` text,
111
137
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
···
117
143
CREATE UNIQUE INDEX `tracks_spotify_link_unique` ON `tracks` (`spotify_link`);--> statement-breakpoint
118
144
CREATE UNIQUE INDEX `tracks_apple_music_link_unique` ON `tracks` (`apple_music_link`);--> statement-breakpoint
119
145
CREATE UNIQUE INDEX `tracks_tidal_link_unique` ON `tracks` (`tidal_link`);--> statement-breakpoint
120
-
CREATE UNIQUE INDEX `tracks_sha256_unique` ON `tracks` (`sha256`);--> statement-breakpoint
121
146
CREATE UNIQUE INDEX `tracks_uri_unique` ON `tracks` (`uri`);--> statement-breakpoint
147
+
CREATE UNIQUE INDEX `tracks_cid_unique` ON `tracks` (`cid`);--> statement-breakpoint
122
148
CREATE TABLE `user_albums` (
123
149
`id` text PRIMARY KEY NOT NULL,
124
150
`user_id` text NOT NULL,
+231
-40
apps/cli/drizzle/meta/0000_snapshot.json
+231
-40
apps/cli/drizzle/meta/0000_snapshot.json
···
1
1
{
2
2
"version": "6",
3
3
"dialect": "sqlite",
4
-
"id": "a549f070-9d4d-4d38-bd6a-c04bc9a4889b",
4
+
"id": "571a287d-ea60-4ac4-847a-307da78c375c",
5
5
"prevId": "00000000-0000-0000-0000-000000000000",
6
6
"tables": {
7
7
"album_tracks": {
···
130
130
"notNull": false,
131
131
"autoincrement": false
132
132
},
133
+
"cid": {
134
+
"name": "cid",
135
+
"type": "text",
136
+
"primaryKey": false,
137
+
"notNull": true,
138
+
"autoincrement": false
139
+
},
133
140
"artist_uri": {
134
141
"name": "artist_uri",
135
142
"type": "text",
···
165
172
"notNull": false,
166
173
"autoincrement": false
167
174
},
168
-
"sha256": {
169
-
"name": "sha256",
170
-
"type": "text",
171
-
"primaryKey": false,
172
-
"notNull": true,
173
-
"autoincrement": false
174
-
},
175
175
"created_at": {
176
176
"name": "created_at",
177
177
"type": "integer",
···
197
197
],
198
198
"isUnique": true
199
199
},
200
+
"albums_cid_unique": {
201
+
"name": "albums_cid_unique",
202
+
"columns": [
203
+
"cid"
204
+
],
205
+
"isUnique": true
206
+
},
200
207
"albums_apple_music_link_unique": {
201
208
"name": "albums_apple_music_link_unique",
202
209
"columns": [
···
222
229
"name": "albums_youtube_link_unique",
223
230
"columns": [
224
231
"youtube_link"
225
-
],
226
-
"isUnique": true
227
-
},
228
-
"albums_sha256_unique": {
229
-
"name": "albums_sha256_unique",
230
-
"columns": [
231
-
"sha256"
232
232
],
233
233
"isUnique": true
234
234
}
···
438
438
"notNull": false,
439
439
"autoincrement": false
440
440
},
441
-
"sha256": {
442
-
"name": "sha256",
441
+
"uri": {
442
+
"name": "uri",
443
443
"type": "text",
444
444
"primaryKey": false,
445
-
"notNull": true,
445
+
"notNull": false,
446
446
"autoincrement": false
447
447
},
448
-
"uri": {
449
-
"name": "uri",
448
+
"cid": {
449
+
"name": "cid",
450
450
"type": "text",
451
451
"primaryKey": false,
452
-
"notNull": false,
452
+
"notNull": true,
453
453
"autoincrement": false
454
454
},
455
455
"apple_music_link": {
···
505
505
}
506
506
},
507
507
"indexes": {
508
-
"artists_sha256_unique": {
509
-
"name": "artists_sha256_unique",
508
+
"artists_uri_unique": {
509
+
"name": "artists_uri_unique",
510
510
"columns": [
511
-
"sha256"
511
+
"uri"
512
512
],
513
513
"isUnique": true
514
514
},
515
-
"artists_uri_unique": {
516
-
"name": "artists_uri_unique",
515
+
"artists_cid_unique": {
516
+
"name": "artists_cid_unique",
517
517
"columns": [
518
-
"uri"
518
+
"cid"
519
519
],
520
520
"isUnique": true
521
521
}
···
525
525
"uniqueConstraints": {},
526
526
"checkConstraints": {}
527
527
},
528
+
"auth_sessions": {
529
+
"name": "auth_sessions",
530
+
"columns": {
531
+
"key": {
532
+
"name": "key",
533
+
"type": "text",
534
+
"primaryKey": true,
535
+
"notNull": true,
536
+
"autoincrement": false
537
+
},
538
+
"session": {
539
+
"name": "session",
540
+
"type": "text",
541
+
"primaryKey": false,
542
+
"notNull": true,
543
+
"autoincrement": false
544
+
},
545
+
"created_at": {
546
+
"name": "created_at",
547
+
"type": "integer",
548
+
"primaryKey": false,
549
+
"notNull": true,
550
+
"autoincrement": false,
551
+
"default": "CURRENT_TIMESTAMP"
552
+
},
553
+
"updated_at": {
554
+
"name": "updated_at",
555
+
"type": "integer",
556
+
"primaryKey": false,
557
+
"notNull": true,
558
+
"autoincrement": false,
559
+
"default": "CURRENT_TIMESTAMP"
560
+
}
561
+
},
562
+
"indexes": {},
563
+
"foreignKeys": {},
564
+
"compositePrimaryKeys": {},
565
+
"uniqueConstraints": {},
566
+
"checkConstraints": {}
567
+
},
528
568
"loved_tracks": {
529
569
"name": "loved_tracks",
530
570
"columns": {
···
606
646
"uniqueConstraints": {},
607
647
"checkConstraints": {}
608
648
},
649
+
"scrobbles": {
650
+
"name": "scrobbles",
651
+
"columns": {
652
+
"xata_id": {
653
+
"name": "xata_id",
654
+
"type": "text",
655
+
"primaryKey": true,
656
+
"notNull": true,
657
+
"autoincrement": false
658
+
},
659
+
"user_id": {
660
+
"name": "user_id",
661
+
"type": "text",
662
+
"primaryKey": false,
663
+
"notNull": false,
664
+
"autoincrement": false
665
+
},
666
+
"track_id": {
667
+
"name": "track_id",
668
+
"type": "text",
669
+
"primaryKey": false,
670
+
"notNull": false,
671
+
"autoincrement": false
672
+
},
673
+
"album_id": {
674
+
"name": "album_id",
675
+
"type": "text",
676
+
"primaryKey": false,
677
+
"notNull": false,
678
+
"autoincrement": false
679
+
},
680
+
"artist_id": {
681
+
"name": "artist_id",
682
+
"type": "text",
683
+
"primaryKey": false,
684
+
"notNull": false,
685
+
"autoincrement": false
686
+
},
687
+
"uri": {
688
+
"name": "uri",
689
+
"type": "text",
690
+
"primaryKey": false,
691
+
"notNull": false,
692
+
"autoincrement": false
693
+
},
694
+
"cid": {
695
+
"name": "cid",
696
+
"type": "text",
697
+
"primaryKey": false,
698
+
"notNull": false,
699
+
"autoincrement": false
700
+
},
701
+
"created_at": {
702
+
"name": "created_at",
703
+
"type": "integer",
704
+
"primaryKey": false,
705
+
"notNull": true,
706
+
"autoincrement": false,
707
+
"default": "CURRENT_TIMESTAMP"
708
+
},
709
+
"updated_at": {
710
+
"name": "updated_at",
711
+
"type": "integer",
712
+
"primaryKey": false,
713
+
"notNull": true,
714
+
"autoincrement": false,
715
+
"default": "CURRENT_TIMESTAMP"
716
+
},
717
+
"timestamp": {
718
+
"name": "timestamp",
719
+
"type": "integer",
720
+
"primaryKey": false,
721
+
"notNull": true,
722
+
"autoincrement": false,
723
+
"default": "CURRENT_TIMESTAMP"
724
+
}
725
+
},
726
+
"indexes": {
727
+
"scrobbles_uri_unique": {
728
+
"name": "scrobbles_uri_unique",
729
+
"columns": [
730
+
"uri"
731
+
],
732
+
"isUnique": true
733
+
},
734
+
"scrobbles_cid_unique": {
735
+
"name": "scrobbles_cid_unique",
736
+
"columns": [
737
+
"cid"
738
+
],
739
+
"isUnique": true
740
+
}
741
+
},
742
+
"foreignKeys": {
743
+
"scrobbles_user_id_users_id_fk": {
744
+
"name": "scrobbles_user_id_users_id_fk",
745
+
"tableFrom": "scrobbles",
746
+
"tableTo": "users",
747
+
"columnsFrom": [
748
+
"user_id"
749
+
],
750
+
"columnsTo": [
751
+
"id"
752
+
],
753
+
"onDelete": "no action",
754
+
"onUpdate": "no action"
755
+
},
756
+
"scrobbles_track_id_tracks_id_fk": {
757
+
"name": "scrobbles_track_id_tracks_id_fk",
758
+
"tableFrom": "scrobbles",
759
+
"tableTo": "tracks",
760
+
"columnsFrom": [
761
+
"track_id"
762
+
],
763
+
"columnsTo": [
764
+
"id"
765
+
],
766
+
"onDelete": "no action",
767
+
"onUpdate": "no action"
768
+
},
769
+
"scrobbles_album_id_albums_id_fk": {
770
+
"name": "scrobbles_album_id_albums_id_fk",
771
+
"tableFrom": "scrobbles",
772
+
"tableTo": "albums",
773
+
"columnsFrom": [
774
+
"album_id"
775
+
],
776
+
"columnsTo": [
777
+
"id"
778
+
],
779
+
"onDelete": "no action",
780
+
"onUpdate": "no action"
781
+
},
782
+
"scrobbles_artist_id_artists_id_fk": {
783
+
"name": "scrobbles_artist_id_artists_id_fk",
784
+
"tableFrom": "scrobbles",
785
+
"tableTo": "artists",
786
+
"columnsFrom": [
787
+
"artist_id"
788
+
],
789
+
"columnsTo": [
790
+
"id"
791
+
],
792
+
"onDelete": "no action",
793
+
"onUpdate": "no action"
794
+
}
795
+
},
796
+
"compositePrimaryKeys": {},
797
+
"uniqueConstraints": {},
798
+
"checkConstraints": {}
799
+
},
609
800
"tracks": {
610
801
"name": "tracks",
611
802
"columns": {
···
700
891
"notNull": false,
701
892
"autoincrement": false
702
893
},
703
-
"sha256": {
704
-
"name": "sha256",
705
-
"type": "text",
706
-
"primaryKey": false,
707
-
"notNull": true,
708
-
"autoincrement": false
709
-
},
710
894
"disc_number": {
711
895
"name": "disc_number",
712
896
"type": "integer",
···
756
940
"notNull": false,
757
941
"autoincrement": false
758
942
},
943
+
"cid": {
944
+
"name": "cid",
945
+
"type": "text",
946
+
"primaryKey": false,
947
+
"notNull": true,
948
+
"autoincrement": false
949
+
},
759
950
"album_uri": {
760
951
"name": "album_uri",
761
952
"type": "text",
···
823
1014
],
824
1015
"isUnique": true
825
1016
},
826
-
"tracks_sha256_unique": {
827
-
"name": "tracks_sha256_unique",
1017
+
"tracks_uri_unique": {
1018
+
"name": "tracks_uri_unique",
828
1019
"columns": [
829
-
"sha256"
1020
+
"uri"
830
1021
],
831
1022
"isUnique": true
832
1023
},
833
-
"tracks_uri_unique": {
834
-
"name": "tracks_uri_unique",
1024
+
"tracks_cid_unique": {
1025
+
"name": "tracks_cid_unique",
835
1026
"columns": [
836
-
"uri"
1027
+
"cid"
837
1028
],
838
1029
"isUnique": true
839
1030
}
+2
-2
apps/cli/drizzle/meta/_journal.json
+2
-2
apps/cli/drizzle/meta/_journal.json
+5
apps/cli/package.json
+5
apps/cli/package.json
···
41
41
"commander": "^13.1.0",
42
42
"cors": "^2.8.5",
43
43
"dayjs": "^1.11.13",
44
+
"dotenv": "^16.4.7",
44
45
"drizzle-kit": "^0.31.1",
45
46
"drizzle-orm": "^0.45.1",
46
47
"effect": "^3.19.14",
47
48
"env-paths": "^3.0.0",
49
+
"envalid": "^8.0.0",
48
50
"express": "^5.1.0",
51
+
"kysely": "^0.27.5",
52
+
"lodash": "^4.17.21",
49
53
"md5": "^2.3.0",
50
54
"open": "^10.1.0",
51
55
"table": "^6.9.0",
56
+
"unstorage": "^1.14.4",
52
57
"zod": "^3.24.3"
53
58
},
54
59
"devDependencies": {
+352
-7
apps/cli/src/cmd/sync.ts
+352
-7
apps/cli/src/cmd/sync.ts
···
1
1
import { JetStreamClient, JetStreamEvent } from "jetstream";
2
+
import { logger } from "logger";
3
+
import { ctx } from "context";
4
+
import { isValidHandle } from "@atproto/syntax";
5
+
import { Agent } from "@atproto/api";
6
+
import { env } from "lib/env";
7
+
import { createAgent } from "lib/agent";
2
8
import chalk from "chalk";
3
-
import { logger } from "logger";
9
+
import * as Artist from "lexicon/types/app/rocksky/artist";
10
+
import * as Album from "lexicon/types/app/rocksky/album";
11
+
import * as Song from "lexicon/types/app/rocksky/song";
12
+
import * as Scrobble from "lexicon/types/app/rocksky/scrobble";
13
+
import { SelectUser } from "schema/users";
14
+
import schema from "schema";
15
+
import { createId } from "@paralleldrive/cuid2";
16
+
import _ from "lodash";
17
+
18
+
type Artists = { value: Artist.Record; uri: string; cid: string }[];
19
+
type Albums = { value: Album.Record; uri: string; cid: string }[];
20
+
type Songs = { value: Song.Record; uri: string; cid: string }[];
21
+
type Scrobbles = { value: Scrobble.Record; uri: string; cid: string }[];
22
+
23
+
export async function sync() {
24
+
const [did, handle] = await getDidAndHandle();
25
+
const agent: Agent = await createAgent(did, handle);
26
+
27
+
const user = await createUser(agent, did, handle);
28
+
subscribeToJetstream(did);
29
+
30
+
logger.info` DID: ${did}`;
31
+
logger.info` Handle: ${handle}`;
32
+
33
+
const [artists, albums, songs, scrobbles] = await Promise.all([
34
+
getRockskyUserArtists(agent),
35
+
getRockskyUserAlbums(agent),
36
+
getRockskyUserSongs(agent),
37
+
getRockskyUserScrobbles(agent),
38
+
]);
39
+
40
+
logger.info` Artists: ${artists.length}`;
41
+
logger.info` Albums: ${albums.length}`;
42
+
logger.info` Songs: ${songs.length}`;
43
+
logger.info` Scrobbles: ${scrobbles.length}`;
44
+
45
+
await createArtists(artists, user);
46
+
await createAlbums(albums, user);
47
+
await createSongs(songs, user);
48
+
await createScrobbles(scrobbles, user);
49
+
}
4
50
5
51
const getEndpoint = () => {
6
-
const endpoint = process.env.JETSTREAM_SERVER
7
-
? process.env.JETSTREAM_SERVER
8
-
: "wss://jetstream1.us-west.bsky.network/subscribe";
52
+
const endpoint = env.JETSTREAM_SERVER;
9
53
10
54
if (endpoint?.endsWith("/subscribe")) {
11
55
return endpoint;
···
14
58
return `${endpoint}/subscribe`;
15
59
};
16
60
17
-
export function sync() {
61
+
const getDidAndHandle = async (): Promise<[string, string]> => {
62
+
let handle = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER;
63
+
let did = env.ROCKSKY_HANDLE || env.ROCKSKY_IDENTIFIER;
64
+
65
+
if (handle.startsWith("did:plc:") || handle.startsWith("did:web:")) {
66
+
handle = await ctx.resolver.resolveDidToHandle(handle);
67
+
}
68
+
69
+
if (!isValidHandle(handle)) {
70
+
logger.error`❌ Invalid handle: ${handle}`;
71
+
process.exit(1);
72
+
}
73
+
74
+
if (!did.startsWith("did:plc:") && !did.startsWith("did:web:")) {
75
+
did = await ctx.baseIdResolver.handle.resolve(did);
76
+
}
77
+
78
+
return [did, handle];
79
+
};
80
+
81
+
const createUser = async (
82
+
agent: Agent,
83
+
did: string,
84
+
handle: string,
85
+
): Promise<SelectUser> => {
86
+
const { data: profileRecord } = await agent.com.atproto.repo.getRecord({
87
+
repo: agent.assertDid,
88
+
collection: "app.bsky.actor.profile",
89
+
rkey: "self",
90
+
});
91
+
92
+
const displayName = _.get(profileRecord, "value.displayName") as
93
+
| string
94
+
| undefined;
95
+
const avatar = `https://cdn.bsky.app/img/avatar/plain/${did}/${_.get(profileRecord, "value.avatar.ref", "").toString()}@jpeg`;
96
+
97
+
const [user] = await ctx.db
98
+
.insert(schema.users)
99
+
.values({
100
+
id: createId(),
101
+
did,
102
+
handle,
103
+
displayName,
104
+
avatar,
105
+
})
106
+
.onConflictDoUpdate({
107
+
target: schema.users.did,
108
+
set: {
109
+
handle,
110
+
displayName,
111
+
avatar,
112
+
updatedAt: new Date(),
113
+
},
114
+
})
115
+
.returning()
116
+
.execute();
117
+
118
+
return user;
119
+
};
120
+
121
+
const createArtists = async (artists: Artists, _user: SelectUser) => {
122
+
if (artists.length === 0) return;
123
+
124
+
await ctx.db
125
+
.insert(schema.artists)
126
+
.values(
127
+
artists.map((artist) => ({
128
+
id: createId(),
129
+
name: artist.value.name,
130
+
cid: artist.cid,
131
+
uri: artist.uri,
132
+
biography: artist.value.bio,
133
+
born: artist.value.born ? new Date(artist.value.born) : null,
134
+
bornIn: artist.value.bornIn,
135
+
died: artist.value.died ? new Date(artist.value.died) : null,
136
+
picture: artist.value.pictureUrl,
137
+
sha256: artist.value.sha256 as string,
138
+
genres: (artist.value.genres as string[]).join(", "),
139
+
})),
140
+
)
141
+
.onConflictDoNothing({
142
+
target: schema.artists.cid,
143
+
})
144
+
.returning()
145
+
.execute();
146
+
};
147
+
148
+
const createAlbums = async (albums: Albums, user: SelectUser) => {
149
+
if (albums.length === 0) return;
150
+
151
+
await ctx.db
152
+
.insert(schema.albums)
153
+
.values(
154
+
albums.map((album) => ({
155
+
id: createId(),
156
+
cid: album.cid,
157
+
title: "",
158
+
artist: "",
159
+
sha256: "",
160
+
uri: album.uri,
161
+
mbid: "",
162
+
description: "",
163
+
imageUrl: "",
164
+
spotifyId: "",
165
+
appleMusicId: "",
166
+
genres: "",
167
+
releaseDate: "",
168
+
year: undefined,
169
+
})),
170
+
)
171
+
.onConflictDoNothing({
172
+
target: schema.albums.cid,
173
+
})
174
+
.returning()
175
+
.execute();
176
+
};
177
+
178
+
const createSongs = async (songs: Songs, user: SelectUser) => {
179
+
if (songs.length === 0) return;
180
+
181
+
await ctx.db
182
+
.insert(schema.tracks)
183
+
.values(
184
+
songs.map((song) => ({
185
+
id: createId(),
186
+
cid: song.cid,
187
+
uri: song.uri,
188
+
title: song.value.title,
189
+
artist: song.value.artist,
190
+
albumArtist: song.value.albumArtist,
191
+
albumArt: song.value.albumArtUrl,
192
+
album: song.value.album,
193
+
trackNumber: song.value.trackNumber,
194
+
duration: song.value.duration,
195
+
mbId: song.value.mbid,
196
+
youtubeLink: song.value.youtubeLink,
197
+
spotifyLink: song.value.spotifyLink,
198
+
appleMusicLink: song.value.appleMusicLink,
199
+
tidalLink: song.value.tidalLink,
200
+
discNumber: song.value.discNumber,
201
+
lyrics: song.value.lyrics,
202
+
composer: song.value.composer,
203
+
genre: song.value.genre,
204
+
label: song.value.label,
205
+
copyrightMessage: song.value.copyrightMessage,
206
+
albumUri: "",
207
+
artistUri: "",
208
+
})),
209
+
)
210
+
.onConflictDoNothing({
211
+
target: schema.tracks.cid,
212
+
})
213
+
.returning()
214
+
.execute();
215
+
};
216
+
217
+
const createScrobbles = async (scrobbles: Scrobbles, user: SelectUser) => {
218
+
if (!scrobbles.length) return;
219
+
220
+
await ctx.db
221
+
.insert(schema.scrobbles)
222
+
.values(
223
+
scrobbles.map((scrobble) => ({
224
+
id: createId(),
225
+
trackId: "",
226
+
userId: user.id,
227
+
timestamp: new Date(),
228
+
})),
229
+
)
230
+
.onConflictDoNothing({
231
+
target: schema.scrobbles.cid,
232
+
})
233
+
.returning()
234
+
.execute();
235
+
};
236
+
237
+
const subscribeToJetstream = (_did: string) => {
18
238
const client = new JetStreamClient({
19
239
wantedCollections: [
20
240
"app.rocksky.scrobble",
···
25
245
endpoint: getEndpoint(),
26
246
27
247
// Optional: filter by specific DIDs
28
-
// wantedDids: ["did:plc:example123"],
248
+
// wantedDids: [did],
29
249
30
250
// Reconnection settings
31
251
maxReconnectAttempts: 10,
···
72
292
});
73
293
74
294
client.connect();
75
-
}
295
+
};
296
+
297
+
const getRockskyUserSongs = async (agent: Agent): Promise<Songs> => {
298
+
let results: {
299
+
value: Song.Record;
300
+
uri: string;
301
+
cid: string;
302
+
}[] = [];
303
+
let cursor: string | undefined;
304
+
let i = 1;
305
+
do {
306
+
const res = await agent.com.atproto.repo.listRecords({
307
+
repo: agent.assertDid,
308
+
collection: "app.rocksky.song",
309
+
limit: 100,
310
+
cursor,
311
+
});
312
+
const records = res.data.records as Array<{
313
+
uri: string;
314
+
cid: string;
315
+
value: Song.Record;
316
+
}>;
317
+
results = results.concat(records);
318
+
cursor = res.data.cursor;
319
+
logger.info(`${chalk.greenBright(i)} songs`);
320
+
i += 100;
321
+
} while (cursor);
322
+
323
+
return results;
324
+
};
325
+
326
+
const getRockskyUserAlbums = async (agent: Agent): Promise<Albums> => {
327
+
let results: {
328
+
value: Album.Record;
329
+
uri: string;
330
+
cid: string;
331
+
}[] = [];
332
+
let cursor: string | undefined;
333
+
let i = 1;
334
+
do {
335
+
const res = await agent.com.atproto.repo.listRecords({
336
+
repo: agent.assertDid,
337
+
collection: "app.rocksky.album",
338
+
limit: 100,
339
+
cursor,
340
+
});
341
+
342
+
const records = res.data.records as Array<{
343
+
uri: string;
344
+
cid: string;
345
+
value: Album.Record;
346
+
}>;
347
+
348
+
results = results.concat(records);
349
+
350
+
cursor = res.data.cursor;
351
+
logger.info(`${chalk.greenBright(i)} albums`);
352
+
i += 100;
353
+
} while (cursor);
354
+
355
+
return results;
356
+
};
357
+
358
+
const getRockskyUserArtists = async (agent: Agent): Promise<Artists> => {
359
+
let results: {
360
+
value: Artist.Record;
361
+
uri: string;
362
+
cid: string;
363
+
}[] = [];
364
+
let cursor: string | undefined;
365
+
let i = 1;
366
+
do {
367
+
const res = await agent.com.atproto.repo.listRecords({
368
+
repo: agent.assertDid,
369
+
collection: "app.rocksky.artist",
370
+
limit: 100,
371
+
cursor,
372
+
});
373
+
374
+
const records = res.data.records as Array<{
375
+
uri: string;
376
+
cid: string;
377
+
value: Artist.Record;
378
+
}>;
379
+
380
+
results = results.concat(records);
381
+
382
+
cursor = res.data.cursor;
383
+
logger.info(`${chalk.greenBright(i)} artists`);
384
+
i += 100;
385
+
} while (cursor);
386
+
387
+
return results;
388
+
};
389
+
390
+
const getRockskyUserScrobbles = async (agent: Agent): Promise<Scrobbles> => {
391
+
let results: {
392
+
value: Scrobble.Record;
393
+
uri: string;
394
+
cid: string;
395
+
}[] = [];
396
+
let cursor: string | undefined;
397
+
let i = 1;
398
+
do {
399
+
const res = await agent.com.atproto.repo.listRecords({
400
+
repo: agent.assertDid,
401
+
collection: "app.rocksky.scrobble",
402
+
limit: 100,
403
+
cursor,
404
+
});
405
+
406
+
const records = res.data.records as Array<{
407
+
uri: string;
408
+
cid: string;
409
+
value: Scrobble.Record;
410
+
}>;
411
+
412
+
results = results.concat(records);
413
+
414
+
cursor = res.data.cursor;
415
+
logger.info(`${chalk.greenBright(i)} scrobbles`);
416
+
i += 100;
417
+
} while (cursor);
418
+
419
+
return results;
420
+
};
+11
apps/cli/src/context.ts
+11
apps/cli/src/context.ts
···
1
1
import { logger } from "logger";
2
2
import drizzle from "./drizzle";
3
+
import sqliteKv from "sqliteKv";
4
+
import { createBidirectionalResolver, createIdResolver } from "lib/idResolver";
5
+
import { createStorage } from "unstorage";
6
+
7
+
const kv = createStorage({
8
+
driver: sqliteKv({ location: ":memory:", table: "kv" }),
9
+
});
10
+
11
+
const baseIdResolver = createIdResolver(kv);
3
12
4
13
export const ctx = {
5
14
db: drizzle.db,
15
+
resolver: createBidirectionalResolver(baseIdResolver),
16
+
baseIdResolver,
6
17
logger,
7
18
};
8
19
+55
apps/cli/src/lib/agent.ts
+55
apps/cli/src/lib/agent.ts
···
1
+
import { Agent, AtpAgent } from "@atproto/api";
2
+
import { ctx } from "context";
3
+
import { eq } from "drizzle-orm";
4
+
import authSessions from "schema/auth-session";
5
+
import extractPdsFromDid from "./extractPdsFromDid";
6
+
import { env } from "./env";
7
+
8
+
export async function createAgent(did: string, handle: string): Promise<Agent> {
9
+
const pds = await extractPdsFromDid(did);
10
+
const agent = new AtpAgent({
11
+
service: new URL(pds),
12
+
});
13
+
14
+
try {
15
+
const [data] = await ctx.db
16
+
.select()
17
+
.from(authSessions)
18
+
.where(eq(authSessions.key, did))
19
+
.execute();
20
+
21
+
if (!data) {
22
+
throw new Error("No session found");
23
+
}
24
+
25
+
await agent.resumeSession(JSON.parse(data.session));
26
+
return agent;
27
+
} catch (e) {
28
+
ctx.logger.error`resuming session ${did}`;
29
+
ctx.logger.error(e);
30
+
31
+
await ctx.db
32
+
.delete(authSessions)
33
+
.where(eq(authSessions.key, did))
34
+
.execute();
35
+
36
+
await agent.login({
37
+
identifier: handle,
38
+
password: env.ROCKSKY_PASSWORD,
39
+
});
40
+
41
+
await ctx.db
42
+
.insert(authSessions)
43
+
.values({
44
+
key: did,
45
+
session: JSON.stringify(agent.session),
46
+
})
47
+
.onConflictDoUpdate({
48
+
target: authSessions.key,
49
+
set: { session: JSON.stringify(agent.session) },
50
+
})
51
+
.execute();
52
+
53
+
return agent;
54
+
}
55
+
}
+72
apps/cli/src/lib/didUnstorageCache.ts
+72
apps/cli/src/lib/didUnstorageCache.ts
···
1
+
import type { CacheResult, DidCache, DidDocument } from "@atproto/identity";
2
+
import type { Storage } from "unstorage";
3
+
4
+
const HOUR = 60e3 * 60;
5
+
const DAY = HOUR * 24;
6
+
7
+
type CacheVal = {
8
+
doc: DidDocument;
9
+
updatedAt: number;
10
+
};
11
+
12
+
/**
13
+
* An unstorage based DidCache with staleness and max TTL
14
+
*/
15
+
export class StorageCache implements DidCache {
16
+
public staleTTL: number;
17
+
public maxTTL: number;
18
+
public cache: Storage<CacheVal>;
19
+
private prefix: string;
20
+
constructor({
21
+
store,
22
+
prefix,
23
+
staleTTL,
24
+
maxTTL,
25
+
}: {
26
+
store: Storage;
27
+
prefix: string;
28
+
staleTTL?: number;
29
+
maxTTL?: number;
30
+
}) {
31
+
this.cache = store as Storage<CacheVal>;
32
+
this.prefix = prefix;
33
+
this.staleTTL = staleTTL ?? HOUR;
34
+
this.maxTTL = maxTTL ?? DAY;
35
+
}
36
+
37
+
async cacheDid(did: string, doc: DidDocument): Promise<void> {
38
+
await this.cache.set(this.prefix + did, { doc, updatedAt: Date.now() });
39
+
}
40
+
41
+
async refreshCache(
42
+
did: string,
43
+
getDoc: () => Promise<DidDocument | null>,
44
+
): Promise<void> {
45
+
const doc = await getDoc();
46
+
if (doc) {
47
+
await this.cacheDid(did, doc);
48
+
}
49
+
}
50
+
51
+
async checkCache(did: string): Promise<CacheResult | null> {
52
+
const val = await this.cache.get<CacheVal>(this.prefix + did);
53
+
if (!val) return null;
54
+
const now = Date.now();
55
+
const expired = now > val.updatedAt + this.maxTTL;
56
+
const stale = now > val.updatedAt + this.staleTTL;
57
+
return {
58
+
...val,
59
+
did,
60
+
stale,
61
+
expired,
62
+
};
63
+
}
64
+
65
+
async clearEntry(did: string): Promise<void> {
66
+
await this.cache.remove(this.prefix + did);
67
+
}
68
+
69
+
async clear(): Promise<void> {
70
+
await this.cache.clear(this.prefix);
71
+
}
72
+
}
+13
apps/cli/src/lib/env.ts
+13
apps/cli/src/lib/env.ts
···
1
+
import dotenv from "dotenv";
2
+
import { cleanEnv, str } from "envalid";
3
+
4
+
dotenv.config();
5
+
6
+
export const env = cleanEnv(process.env, {
7
+
ROCKSKY_IDENTIFIER: str({}),
8
+
ROCKSKY_HANDLE: str({ default: "" }),
9
+
ROCKSKY_PASSWORD: str({}),
10
+
JETSTREAM_SERVER: str({
11
+
default: "wss://jetstream1.us-west.bsky.network/subscribe",
12
+
}),
13
+
});
+33
apps/cli/src/lib/extractPdsFromDid.ts
+33
apps/cli/src/lib/extractPdsFromDid.ts
···
1
+
export default async function extractPdsFromDid(
2
+
did: string,
3
+
): Promise<string | null> {
4
+
let didDocUrl: string;
5
+
6
+
if (did.startsWith("did:plc:")) {
7
+
didDocUrl = `https://plc.directory/${did}`;
8
+
} else if (did.startsWith("did:web:")) {
9
+
const domain = did.substring("did:web:".length);
10
+
didDocUrl = `https://${domain}/.well-known/did.json`;
11
+
} else {
12
+
throw new Error("Unsupported DID method");
13
+
}
14
+
15
+
const response = await fetch(didDocUrl);
16
+
if (!response.ok) throw new Error("Failed to fetch DID doc");
17
+
18
+
const doc: {
19
+
service?: Array<{
20
+
type: string;
21
+
id: string;
22
+
serviceEndpoint: string;
23
+
}>;
24
+
} = await response.json();
25
+
26
+
// Find the atproto PDS service
27
+
const pdsService = doc.service?.find(
28
+
(s: any) =>
29
+
s.type === "AtprotoPersonalDataServer" && s.id.endsWith("#atproto_pds"),
30
+
);
31
+
32
+
return pdsService?.serviceEndpoint ?? null;
33
+
}
+52
apps/cli/src/lib/idResolver.ts
+52
apps/cli/src/lib/idResolver.ts
···
1
+
import { IdResolver } from "@atproto/identity";
2
+
import type { Storage } from "unstorage";
3
+
import { StorageCache } from "./didUnstorageCache";
4
+
5
+
const HOUR = 60e3 * 60;
6
+
const DAY = HOUR * 24;
7
+
const WEEK = HOUR * 7;
8
+
9
+
export function createIdResolver(kv: Storage) {
10
+
return new IdResolver({
11
+
didCache: new StorageCache({
12
+
store: kv,
13
+
prefix: "didCache:",
14
+
staleTTL: DAY,
15
+
maxTTL: WEEK,
16
+
}),
17
+
});
18
+
}
19
+
20
+
export interface BidirectionalResolver {
21
+
resolveDidToHandle(did: string): Promise<string>;
22
+
resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>;
23
+
}
24
+
25
+
export function createBidirectionalResolver(resolver: IdResolver) {
26
+
return {
27
+
async resolveDidToHandle(did: string): Promise<string> {
28
+
const didDoc = await resolver.did.resolveAtprotoData(did);
29
+
30
+
// asynchronously double check that the handle resolves back
31
+
resolver.handle.resolve(didDoc.handle).then((resolvedHandle) => {
32
+
if (resolvedHandle !== did) {
33
+
resolver.did.ensureResolve(did, true);
34
+
}
35
+
});
36
+
return didDoc?.handle ?? did;
37
+
},
38
+
39
+
async resolveDidsToHandles(
40
+
dids: string[],
41
+
): Promise<Record<string, string>> {
42
+
const didHandleMap: Record<string, string> = {};
43
+
const resolves = await Promise.all(
44
+
dids.map((did) => this.resolveDidToHandle(did).catch((_) => did)),
45
+
);
46
+
for (let i = 0; i < dids.length; i++) {
47
+
didHandleMap[dids[i]] = resolves[i];
48
+
}
49
+
return didHandleMap;
50
+
},
51
+
};
52
+
}
+2
-2
apps/cli/src/schema/album-tracks.ts
+2
-2
apps/cli/src/schema/album-tracks.ts
···
1
1
import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
2
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
-
import albums from "./albums.js";
4
-
import tracks from "./tracks.js";
3
+
import albums from "./albums";
4
+
import tracks from "./tracks";
5
5
6
6
const albumTracks = sqliteTable("album_tracks", {
7
7
id: text("id").primaryKey().notNull(),
+1
-1
apps/cli/src/schema/albums.ts
+1
-1
apps/cli/src/schema/albums.ts
···
9
9
year: integer("year"),
10
10
albumArt: text("album_art"),
11
11
uri: text("uri").unique(),
12
+
cid: text("cid").unique().notNull(),
12
13
artistUri: text("artist_uri"),
13
14
appleMusicLink: text("apple_music_link").unique(),
14
15
spotifyLink: text("spotify_link").unique(),
15
16
tidalLink: text("tidal_link").unique(),
16
17
youtubeLink: text("youtube_link").unique(),
17
-
sha256: text("sha256").unique().notNull(),
18
18
createdAt: integer("created_at", { mode: "timestamp" })
19
19
.notNull()
20
20
.default(sql`CURRENT_TIMESTAMP`),
+2
-2
apps/cli/src/schema/artist-albums.ts
+2
-2
apps/cli/src/schema/artist-albums.ts
···
1
1
import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
2
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
-
import albums from "./albums.js";
4
-
import artists from "./artists.js";
3
+
import albums from "./albums";
4
+
import artists from "./artists";
5
5
6
6
const artistAlbums = sqliteTable("artist_albums", {
7
7
id: text("id").primaryKey().notNull(),
+2
-2
apps/cli/src/schema/artist-tracks.ts
+2
-2
apps/cli/src/schema/artist-tracks.ts
···
1
1
import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
2
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
-
import artists from "./artists.js";
4
-
import tracks from "./tracks.js";
3
+
import artists from "./artists";
4
+
import tracks from "./tracks";
5
5
6
6
const artistTracks = sqliteTable("artist_tracks", {
7
7
id: text("id").primaryKey().notNull(),
+1
-1
apps/cli/src/schema/artists.ts
+1
-1
apps/cli/src/schema/artists.ts
···
9
9
bornIn: text("born_in"),
10
10
died: integer("died", { mode: "timestamp" }),
11
11
picture: text("picture"),
12
-
sha256: text("sha256").unique().notNull(),
13
12
uri: text("uri").unique(),
13
+
cid: text("cid").unique().notNull(),
14
14
appleMusicLink: text("apple_music_link"),
15
15
spotifyLink: text("spotify_link"),
16
16
tidalLink: text("tidal_link"),
+18
apps/cli/src/schema/auth-session.ts
+18
apps/cli/src/schema/auth-session.ts
···
1
+
import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
+
4
+
const authSessions = sqliteTable("auth_sessions", {
5
+
key: text("key").primaryKey().notNull(),
6
+
session: text("session").notNull(),
7
+
createdAt: integer("created_at", { mode: "timestamp" })
8
+
.notNull()
9
+
.default(sql`CURRENT_TIMESTAMP`),
10
+
updatedAt: integer("updated_at", { mode: "timestamp" })
11
+
.notNull()
12
+
.default(sql`CURRENT_TIMESTAMP`),
13
+
});
14
+
15
+
export type SelectAuthSession = InferSelectModel<typeof authSessions>;
16
+
export type InsertAuthSession = InferInsertModel<typeof authSessions>;
17
+
18
+
export default authSessions;
+16
apps/cli/src/schema/index.ts
+16
apps/cli/src/schema/index.ts
···
1
1
import albumTracks from "./album-tracks";
2
2
import albums from "./albums";
3
+
import artistAlbums from "./artist-albums";
4
+
import artistTracks from "./artist-tracks";
3
5
import artists from "./artists";
6
+
import authSessions from "./auth-session";
7
+
import lovedTracks from "./loved-tracks";
8
+
import scrobbles from "./scrobbles";
4
9
import tracks from "./tracks";
10
+
import userAlbums from "./user-albums";
11
+
import userArtists from "./user-artists";
12
+
import userTracks from "./user-tracks";
5
13
import users from "./users";
6
14
7
15
export default {
···
10
18
artists,
11
19
albums,
12
20
albumTracks,
21
+
authSessions,
22
+
artistAlbums,
23
+
artistTracks,
24
+
lovedTracks,
25
+
scrobbles,
26
+
userAlbums,
27
+
userArtists,
28
+
userTracks,
13
29
};
+30
apps/cli/src/schema/scrobbles.ts
+30
apps/cli/src/schema/scrobbles.ts
···
1
+
import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
2
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
+
import albums from "./albums";
4
+
import artists from "./artists";
5
+
import tracks from "./tracks";
6
+
import users from "./users";
7
+
8
+
const scrobbles = sqliteTable("scrobbles", {
9
+
id: text("xata_id").primaryKey().notNull(),
10
+
userId: text("user_id").references(() => users.id),
11
+
trackId: text("track_id").references(() => tracks.id),
12
+
albumId: text("album_id").references(() => albums.id),
13
+
artistId: text("artist_id").references(() => artists.id),
14
+
uri: text("uri").unique(),
15
+
cid: text("cid").unique(),
16
+
createdAt: integer("created_at", { mode: "timestamp" })
17
+
.notNull()
18
+
.default(sql`CURRENT_TIMESTAMP`),
19
+
updatedAt: integer("updated_at", { mode: "timestamp" })
20
+
.notNull()
21
+
.default(sql`CURRENT_TIMESTAMP`),
22
+
timestamp: integer("timestamp", { mode: "timestamp" })
23
+
.notNull()
24
+
.default(sql`CURRENT_TIMESTAMP`),
25
+
});
26
+
27
+
export type SelectScrobble = InferSelectModel<typeof scrobbles>;
28
+
export type InsertScrobble = InferInsertModel<typeof scrobbles>;
29
+
30
+
export default scrobbles;
+1
-1
apps/cli/src/schema/tracks.ts
+1
-1
apps/cli/src/schema/tracks.ts
···
15
15
spotifyLink: text("spotify_link").unique(),
16
16
appleMusicLink: text("apple_music_link").unique(),
17
17
tidalLink: text("tidal_link").unique(),
18
-
sha256: text("sha256").unique().notNull(),
19
18
discNumber: integer("disc_number"),
20
19
lyrics: text("lyrics"),
21
20
composer: text("composer"),
···
23
22
label: text("label"),
24
23
copyrightMessage: text("copyright_message"),
25
24
uri: text("uri").unique(),
25
+
cid: text("cid").unique().notNull(),
26
26
albumUri: text("album_uri"),
27
27
artistUri: text("artist_uri"),
28
28
createdAt: integer("created_at", { mode: "timestamp" })
+173
apps/cli/src/sqliteKv.ts
+173
apps/cli/src/sqliteKv.ts
···
1
+
import Database from "better-sqlite3";
2
+
import { Kysely, SqliteDialect } from "kysely";
3
+
import { defineDriver } from "unstorage";
4
+
5
+
interface TableSchema {
6
+
[k: string]: {
7
+
id: string;
8
+
value: string;
9
+
created_at: string;
10
+
updated_at: string;
11
+
};
12
+
}
13
+
14
+
export type KvDb = Kysely<TableSchema>;
15
+
16
+
const DRIVER_NAME = "sqlite";
17
+
18
+
export default defineDriver<
19
+
{
20
+
location?: string;
21
+
table: string;
22
+
getDb?: () => KvDb;
23
+
},
24
+
KvDb
25
+
>(
26
+
({
27
+
location,
28
+
table = "kv",
29
+
getDb = (): KvDb => {
30
+
let _db: KvDb | null = null;
31
+
32
+
return (() => {
33
+
if (_db) {
34
+
return _db;
35
+
}
36
+
37
+
if (!location) {
38
+
throw new Error("SQLite location is required");
39
+
}
40
+
41
+
const sqlite = new Database(location, { fileMustExist: false });
42
+
43
+
// Enable WAL mode
44
+
sqlite.pragma("journal_mode = WAL");
45
+
46
+
_db = new Kysely<TableSchema>({
47
+
dialect: new SqliteDialect({
48
+
database: sqlite,
49
+
}),
50
+
});
51
+
52
+
// Create table if not exists
53
+
_db.schema
54
+
.createTable(table)
55
+
.ifNotExists()
56
+
.addColumn("id", "text", (col) => col.primaryKey())
57
+
.addColumn("value", "text", (col) => col.notNull())
58
+
.addColumn("created_at", "text", (col) => col.notNull())
59
+
.addColumn("updated_at", "text", (col) => col.notNull())
60
+
.execute();
61
+
62
+
return _db;
63
+
})();
64
+
},
65
+
}) => {
66
+
return {
67
+
name: DRIVER_NAME,
68
+
options: { location, table },
69
+
getInstance: getDb,
70
+
71
+
async hasItem(key) {
72
+
const result = await getDb()
73
+
.selectFrom(table)
74
+
.select(["id"])
75
+
.where("id", "=", key)
76
+
.executeTakeFirst();
77
+
return !!result;
78
+
},
79
+
80
+
async getItem(key) {
81
+
const result = await getDb()
82
+
.selectFrom(table)
83
+
.select(["value"])
84
+
.where("id", "=", key)
85
+
.executeTakeFirst();
86
+
return result?.value ?? null;
87
+
},
88
+
89
+
async setItem(key: string, value: string) {
90
+
const now = new Date().toISOString();
91
+
await getDb()
92
+
.insertInto(table)
93
+
.values({
94
+
id: key,
95
+
value,
96
+
created_at: now,
97
+
updated_at: now,
98
+
})
99
+
.onConflict((oc) =>
100
+
oc.column("id").doUpdateSet({
101
+
value,
102
+
updated_at: now,
103
+
}),
104
+
)
105
+
.execute();
106
+
},
107
+
108
+
async setItems(items) {
109
+
const now = new Date().toISOString();
110
+
111
+
await getDb()
112
+
.transaction()
113
+
.execute(async (trx) => {
114
+
await Promise.all(
115
+
items.map(({ key, value }) => {
116
+
return trx
117
+
.insertInto(table)
118
+
.values({
119
+
id: key,
120
+
value,
121
+
created_at: now,
122
+
updated_at: now,
123
+
})
124
+
.onConflict((oc) =>
125
+
oc.column("id").doUpdateSet({
126
+
value,
127
+
updated_at: now,
128
+
}),
129
+
)
130
+
.execute();
131
+
}),
132
+
);
133
+
});
134
+
},
135
+
136
+
async removeItem(key: string) {
137
+
await getDb().deleteFrom(table).where("id", "=", key).execute();
138
+
},
139
+
140
+
async getMeta(key: string) {
141
+
const result = await getDb()
142
+
.selectFrom(table)
143
+
.select(["created_at", "updated_at"])
144
+
.where("id", "=", key)
145
+
.executeTakeFirst();
146
+
if (!result) {
147
+
return null;
148
+
}
149
+
return {
150
+
birthtime: new Date(result.created_at),
151
+
mtime: new Date(result.updated_at),
152
+
};
153
+
},
154
+
155
+
async getKeys(base = "") {
156
+
const results = await getDb()
157
+
.selectFrom(table)
158
+
.select(["id"])
159
+
.where("id", "like", `${base}%`)
160
+
.execute();
161
+
return results.map((r) => r.id);
162
+
},
163
+
164
+
async clear() {
165
+
await getDb().deleteFrom(table).execute();
166
+
},
167
+
168
+
async dispose() {
169
+
await getDb().destroy();
170
+
},
171
+
};
172
+
},
173
+
);
+5
bun.lock
+5
bun.lock
···
125
125
"commander": "^13.1.0",
126
126
"cors": "^2.8.5",
127
127
"dayjs": "^1.11.13",
128
+
"dotenv": "^16.4.7",
128
129
"drizzle-kit": "^0.31.1",
129
130
"drizzle-orm": "^0.45.1",
130
131
"effect": "^3.19.14",
131
132
"env-paths": "^3.0.0",
133
+
"envalid": "^8.0.0",
132
134
"express": "^5.1.0",
135
+
"kysely": "^0.27.5",
136
+
"lodash": "^4.17.21",
133
137
"md5": "^2.3.0",
134
138
"open": "^10.1.0",
135
139
"table": "^6.9.0",
140
+
"unstorage": "^1.14.4",
136
141
"zod": "^3.24.3",
137
142
},
138
143
"devDependencies": {
+2
-1
crates/analytics/src/handlers/artists.rs
+2
-1
crates/analytics/src/handlers/artists.rs
···
345
345
LEFT JOIN tracks t ON at.track_id = t.id
346
346
LEFT JOIN artists a ON at.artist_id = a.id
347
347
LEFT JOIN scrobbles s ON s.track_id = t.id
348
-
WHERE at.artist_id = ? OR a.uri = ?
348
+
WHERE (a.id = ? OR a.uri = ?) AND (t.artist_uri = ?)
349
349
GROUP BY
350
350
t.id, t.title, t.artist, t.album_artist, t.album, t.uri, t.album_art, t.duration, t.disc_number, t.track_number, t.artist_uri, t.album_uri, t.sha256, t.copyright_message, t.label, t.created_at
351
351
ORDER BY play_count DESC
···
355
355
356
356
let tracks = stmt.query_map(
357
357
[
358
+
¶ms.artist_id,
358
359
¶ms.artist_id,
359
360
¶ms.artist_id,
360
361
&limit.to_string(),