A decentralized music tracking and discovery platform built on AT Protocol 🎵
listenbrainz spotify atproto lastfm musicbrainz scrobbling

Add Rocksky sync, ID resolver, sqlite KV driver

Introduce Rocksky sync flow and Agent support. Replace sha256 columns
with cid across schemas, add scrobbles and auth_sessions tables, and add
an unstorage SQLite driver and DID cache. Add env parsing, ID
resolution/extraction utilities, update context, and adjust deps and
migration metadata

+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
··· 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
··· 5 5 { 6 6 "idx": 0, 7 7 "version": "6", 8 - "when": 1767904137315, 9 - "tag": "0000_amazing_redwing", 8 + "when": 1768034891092, 9 + "tag": "0000_parched_robbie_robertson", 10 10 "breakpoints": true 11 11 } 12 12 ]
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + &params.artist_id, 358 359 &params.artist_id, 359 360 &params.artist_id, 360 361 &limit.to_string(),