A decentralized music tracking and discovery platform built on AT Protocol 🎵

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

+1111 -65
+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(),