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

Compare changes

Choose any two refs to compare.

Changed files
+401 -9865
apps
api
feeds
web
crates
analytics
src
handlers
subscriber
types
jetstream
spotify
src
+2 -7
README.md
··· 51 51 - Go 52 52 - Turbo 53 53 - Docker 54 - - Wasm Pack https://github.com/drager/wasm-pack 54 + - Wasm Pack https://rustwasm.github.io/wasm-pack/installer/ 55 55 - DuckDB https://duckdb.org/docs/installation `1.2.0` 56 56 - Spotify `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` from setup in [Spotify developer dashboard](https://developer.spotify.com/documentation/web-api/tutorials/getting-started) 57 57 ··· 72 72 ```bash 73 73 cp apps/api/.env.example apps/api/.env 74 74 cp apps/web/.env.example apps/web/.env 75 - cp apps/feeds/.env.example apps/feeds/.env 76 75 cp .env.example .env 77 76 # Edit the .env files to add your configurations 78 77 ``` ··· 106 105 ```bash 107 106 bun run mb 108 107 ``` 109 - 11. Start feeds: 110 - ```bash 111 - bun run feeds 112 - ``` 113 - 12. Start the development server: 108 + 11. Start the development server: 114 109 ```bash 115 110 turbo dev --filter=@rocksky/api --filter=@rocksky/web 116 111 ```
-12
apps/api/drizzle/0002_robust_wong.sql
··· 1 - CREATE TABLE "follows" ( 2 - "xata_id" text PRIMARY KEY DEFAULT xata_id() NOT NULL, 3 - "uri" text NOT NULL, 4 - "follower_did" text NOT NULL, 5 - "subject_did" text NOT NULL, 6 - "xata_version" integer, 7 - "xata_createdat" timestamp DEFAULT now() NOT NULL, 8 - "xata_updatedat" timestamp DEFAULT now() NOT NULL, 9 - CONSTRAINT "follows_uri_unique" UNIQUE("uri") 10 - ); 11 - --> statement-breakpoint 12 - CREATE UNIQUE INDEX "follows_follower_subject_unique" ON "follows" USING btree ("follower_did","subject_did");
-3424
apps/api/drizzle/meta/0002_snapshot.json
··· 1 - { 2 - "id": "ba060d7c-5d31-44b1-abd4-8de1cd5357a7", 3 - "prevId": "6d2ede58-9041-48a4-92fc-e8995d6bc049", 4 - "version": "7", 5 - "dialect": "postgresql", 6 - "tables": { 7 - "public.album_tracks": { 8 - "name": "album_tracks", 9 - "schema": "", 10 - "columns": { 11 - "xata_id": { 12 - "name": "xata_id", 13 - "type": "text", 14 - "primaryKey": true, 15 - "notNull": true, 16 - "default": "xata_id()" 17 - }, 18 - "album_id": { 19 - "name": "album_id", 20 - "type": "text", 21 - "primaryKey": false, 22 - "notNull": true 23 - }, 24 - "track_id": { 25 - "name": "track_id", 26 - "type": "text", 27 - "primaryKey": false, 28 - "notNull": true 29 - }, 30 - "xata_createdat": { 31 - "name": "xata_createdat", 32 - "type": "timestamp", 33 - "primaryKey": false, 34 - "notNull": true, 35 - "default": "now()" 36 - }, 37 - "xata_updatedat": { 38 - "name": "xata_updatedat", 39 - "type": "timestamp", 40 - "primaryKey": false, 41 - "notNull": true, 42 - "default": "now()" 43 - }, 44 - "xata_version": { 45 - "name": "xata_version", 46 - "type": "integer", 47 - "primaryKey": false, 48 - "notNull": false 49 - } 50 - }, 51 - "indexes": {}, 52 - "foreignKeys": { 53 - "album_tracks_album_id_albums_xata_id_fk": { 54 - "name": "album_tracks_album_id_albums_xata_id_fk", 55 - "tableFrom": "album_tracks", 56 - "tableTo": "albums", 57 - "columnsFrom": [ 58 - "album_id" 59 - ], 60 - "columnsTo": [ 61 - "xata_id" 62 - ], 63 - "onDelete": "no action", 64 - "onUpdate": "no action" 65 - }, 66 - "album_tracks_track_id_tracks_xata_id_fk": { 67 - "name": "album_tracks_track_id_tracks_xata_id_fk", 68 - "tableFrom": "album_tracks", 69 - "tableTo": "tracks", 70 - "columnsFrom": [ 71 - "track_id" 72 - ], 73 - "columnsTo": [ 74 - "xata_id" 75 - ], 76 - "onDelete": "no action", 77 - "onUpdate": "no action" 78 - } 79 - }, 80 - "compositePrimaryKeys": {}, 81 - "uniqueConstraints": {}, 82 - "policies": {}, 83 - "checkConstraints": {}, 84 - "isRLSEnabled": false 85 - }, 86 - "public.albums": { 87 - "name": "albums", 88 - "schema": "", 89 - "columns": { 90 - "xata_id": { 91 - "name": "xata_id", 92 - "type": "text", 93 - "primaryKey": true, 94 - "notNull": true, 95 - "default": "xata_id()" 96 - }, 97 - "title": { 98 - "name": "title", 99 - "type": "text", 100 - "primaryKey": false, 101 - "notNull": true 102 - }, 103 - "artist": { 104 - "name": "artist", 105 - "type": "text", 106 - "primaryKey": false, 107 - "notNull": true 108 - }, 109 - "release_date": { 110 - "name": "release_date", 111 - "type": "text", 112 - "primaryKey": false, 113 - "notNull": false 114 - }, 115 - "year": { 116 - "name": "year", 117 - "type": "integer", 118 - "primaryKey": false, 119 - "notNull": false 120 - }, 121 - "album_art": { 122 - "name": "album_art", 123 - "type": "text", 124 - "primaryKey": false, 125 - "notNull": false 126 - }, 127 - "uri": { 128 - "name": "uri", 129 - "type": "text", 130 - "primaryKey": false, 131 - "notNull": false 132 - }, 133 - "artist_uri": { 134 - "name": "artist_uri", 135 - "type": "text", 136 - "primaryKey": false, 137 - "notNull": false 138 - }, 139 - "apple_music_link": { 140 - "name": "apple_music_link", 141 - "type": "text", 142 - "primaryKey": false, 143 - "notNull": false 144 - }, 145 - "spotify_link": { 146 - "name": "spotify_link", 147 - "type": "text", 148 - "primaryKey": false, 149 - "notNull": false 150 - }, 151 - "tidal_link": { 152 - "name": "tidal_link", 153 - "type": "text", 154 - "primaryKey": false, 155 - "notNull": false 156 - }, 157 - "youtube_link": { 158 - "name": "youtube_link", 159 - "type": "text", 160 - "primaryKey": false, 161 - "notNull": false 162 - }, 163 - "sha256": { 164 - "name": "sha256", 165 - "type": "text", 166 - "primaryKey": false, 167 - "notNull": true 168 - }, 169 - "xata_createdat": { 170 - "name": "xata_createdat", 171 - "type": "timestamp", 172 - "primaryKey": false, 173 - "notNull": true, 174 - "default": "now()" 175 - }, 176 - "xata_updatedat": { 177 - "name": "xata_updatedat", 178 - "type": "timestamp", 179 - "primaryKey": false, 180 - "notNull": true, 181 - "default": "now()" 182 - }, 183 - "xata_version": { 184 - "name": "xata_version", 185 - "type": "integer", 186 - "primaryKey": false, 187 - "notNull": false 188 - } 189 - }, 190 - "indexes": {}, 191 - "foreignKeys": {}, 192 - "compositePrimaryKeys": {}, 193 - "uniqueConstraints": { 194 - "albums_uri_unique": { 195 - "name": "albums_uri_unique", 196 - "nullsNotDistinct": false, 197 - "columns": [ 198 - "uri" 199 - ] 200 - }, 201 - "albums_apple_music_link_unique": { 202 - "name": "albums_apple_music_link_unique", 203 - "nullsNotDistinct": false, 204 - "columns": [ 205 - "apple_music_link" 206 - ] 207 - }, 208 - "albums_spotify_link_unique": { 209 - "name": "albums_spotify_link_unique", 210 - "nullsNotDistinct": false, 211 - "columns": [ 212 - "spotify_link" 213 - ] 214 - }, 215 - "albums_tidal_link_unique": { 216 - "name": "albums_tidal_link_unique", 217 - "nullsNotDistinct": false, 218 - "columns": [ 219 - "tidal_link" 220 - ] 221 - }, 222 - "albums_youtube_link_unique": { 223 - "name": "albums_youtube_link_unique", 224 - "nullsNotDistinct": false, 225 - "columns": [ 226 - "youtube_link" 227 - ] 228 - }, 229 - "albums_sha256_unique": { 230 - "name": "albums_sha256_unique", 231 - "nullsNotDistinct": false, 232 - "columns": [ 233 - "sha256" 234 - ] 235 - } 236 - }, 237 - "policies": {}, 238 - "checkConstraints": {}, 239 - "isRLSEnabled": false 240 - }, 241 - "public.api_keys": { 242 - "name": "api_keys", 243 - "schema": "", 244 - "columns": { 245 - "xata_id": { 246 - "name": "xata_id", 247 - "type": "text", 248 - "primaryKey": true, 249 - "notNull": true, 250 - "default": "xata_id()" 251 - }, 252 - "name": { 253 - "name": "name", 254 - "type": "text", 255 - "primaryKey": false, 256 - "notNull": true 257 - }, 258 - "api_key": { 259 - "name": "api_key", 260 - "type": "text", 261 - "primaryKey": false, 262 - "notNull": true 263 - }, 264 - "shared_secret": { 265 - "name": "shared_secret", 266 - "type": "text", 267 - "primaryKey": false, 268 - "notNull": true 269 - }, 270 - "description": { 271 - "name": "description", 272 - "type": "text", 273 - "primaryKey": false, 274 - "notNull": false 275 - }, 276 - "enabled": { 277 - "name": "enabled", 278 - "type": "boolean", 279 - "primaryKey": false, 280 - "notNull": true, 281 - "default": true 282 - }, 283 - "user_id": { 284 - "name": "user_id", 285 - "type": "text", 286 - "primaryKey": false, 287 - "notNull": true 288 - }, 289 - "xata_createdat": { 290 - "name": "xata_createdat", 291 - "type": "timestamp", 292 - "primaryKey": false, 293 - "notNull": true, 294 - "default": "now()" 295 - }, 296 - "xata_updatedat": { 297 - "name": "xata_updatedat", 298 - "type": "timestamp", 299 - "primaryKey": false, 300 - "notNull": true, 301 - "default": "now()" 302 - } 303 - }, 304 - "indexes": {}, 305 - "foreignKeys": { 306 - "api_keys_user_id_users_xata_id_fk": { 307 - "name": "api_keys_user_id_users_xata_id_fk", 308 - "tableFrom": "api_keys", 309 - "tableTo": "users", 310 - "columnsFrom": [ 311 - "user_id" 312 - ], 313 - "columnsTo": [ 314 - "xata_id" 315 - ], 316 - "onDelete": "no action", 317 - "onUpdate": "no action" 318 - } 319 - }, 320 - "compositePrimaryKeys": {}, 321 - "uniqueConstraints": {}, 322 - "policies": {}, 323 - "checkConstraints": {}, 324 - "isRLSEnabled": false 325 - }, 326 - "public.artist_albums": { 327 - "name": "artist_albums", 328 - "schema": "", 329 - "columns": { 330 - "xata_id": { 331 - "name": "xata_id", 332 - "type": "text", 333 - "primaryKey": true, 334 - "notNull": true, 335 - "default": "xata_id()" 336 - }, 337 - "artist_id": { 338 - "name": "artist_id", 339 - "type": "text", 340 - "primaryKey": false, 341 - "notNull": true 342 - }, 343 - "album_id": { 344 - "name": "album_id", 345 - "type": "text", 346 - "primaryKey": false, 347 - "notNull": true 348 - }, 349 - "xata_createdat": { 350 - "name": "xata_createdat", 351 - "type": "timestamp", 352 - "primaryKey": false, 353 - "notNull": true, 354 - "default": "now()" 355 - }, 356 - "xata_updatedat": { 357 - "name": "xata_updatedat", 358 - "type": "timestamp", 359 - "primaryKey": false, 360 - "notNull": true, 361 - "default": "now()" 362 - }, 363 - "xata_version": { 364 - "name": "xata_version", 365 - "type": "integer", 366 - "primaryKey": false, 367 - "notNull": false 368 - } 369 - }, 370 - "indexes": {}, 371 - "foreignKeys": { 372 - "artist_albums_artist_id_artists_xata_id_fk": { 373 - "name": "artist_albums_artist_id_artists_xata_id_fk", 374 - "tableFrom": "artist_albums", 375 - "tableTo": "artists", 376 - "columnsFrom": [ 377 - "artist_id" 378 - ], 379 - "columnsTo": [ 380 - "xata_id" 381 - ], 382 - "onDelete": "no action", 383 - "onUpdate": "no action" 384 - }, 385 - "artist_albums_album_id_albums_xata_id_fk": { 386 - "name": "artist_albums_album_id_albums_xata_id_fk", 387 - "tableFrom": "artist_albums", 388 - "tableTo": "albums", 389 - "columnsFrom": [ 390 - "album_id" 391 - ], 392 - "columnsTo": [ 393 - "xata_id" 394 - ], 395 - "onDelete": "no action", 396 - "onUpdate": "no action" 397 - } 398 - }, 399 - "compositePrimaryKeys": {}, 400 - "uniqueConstraints": {}, 401 - "policies": {}, 402 - "checkConstraints": {}, 403 - "isRLSEnabled": false 404 - }, 405 - "public.artist_tracks": { 406 - "name": "artist_tracks", 407 - "schema": "", 408 - "columns": { 409 - "xata_id": { 410 - "name": "xata_id", 411 - "type": "text", 412 - "primaryKey": true, 413 - "notNull": true, 414 - "default": "xata_id()" 415 - }, 416 - "artist_id": { 417 - "name": "artist_id", 418 - "type": "text", 419 - "primaryKey": false, 420 - "notNull": true 421 - }, 422 - "track_id": { 423 - "name": "track_id", 424 - "type": "text", 425 - "primaryKey": false, 426 - "notNull": true 427 - }, 428 - "xata_createdat": { 429 - "name": "xata_createdat", 430 - "type": "timestamp", 431 - "primaryKey": false, 432 - "notNull": true, 433 - "default": "now()" 434 - }, 435 - "xata_updatedat": { 436 - "name": "xata_updatedat", 437 - "type": "timestamp", 438 - "primaryKey": false, 439 - "notNull": true, 440 - "default": "now()" 441 - }, 442 - "xata_version": { 443 - "name": "xata_version", 444 - "type": "integer", 445 - "primaryKey": false, 446 - "notNull": false 447 - } 448 - }, 449 - "indexes": {}, 450 - "foreignKeys": { 451 - "artist_tracks_artist_id_artists_xata_id_fk": { 452 - "name": "artist_tracks_artist_id_artists_xata_id_fk", 453 - "tableFrom": "artist_tracks", 454 - "tableTo": "artists", 455 - "columnsFrom": [ 456 - "artist_id" 457 - ], 458 - "columnsTo": [ 459 - "xata_id" 460 - ], 461 - "onDelete": "no action", 462 - "onUpdate": "no action" 463 - }, 464 - "artist_tracks_track_id_tracks_xata_id_fk": { 465 - "name": "artist_tracks_track_id_tracks_xata_id_fk", 466 - "tableFrom": "artist_tracks", 467 - "tableTo": "tracks", 468 - "columnsFrom": [ 469 - "track_id" 470 - ], 471 - "columnsTo": [ 472 - "xata_id" 473 - ], 474 - "onDelete": "no action", 475 - "onUpdate": "no action" 476 - } 477 - }, 478 - "compositePrimaryKeys": {}, 479 - "uniqueConstraints": {}, 480 - "policies": {}, 481 - "checkConstraints": {}, 482 - "isRLSEnabled": false 483 - }, 484 - "public.artists": { 485 - "name": "artists", 486 - "schema": "", 487 - "columns": { 488 - "xata_id": { 489 - "name": "xata_id", 490 - "type": "text", 491 - "primaryKey": true, 492 - "notNull": true, 493 - "default": "xata_id()" 494 - }, 495 - "name": { 496 - "name": "name", 497 - "type": "text", 498 - "primaryKey": false, 499 - "notNull": true 500 - }, 501 - "biography": { 502 - "name": "biography", 503 - "type": "text", 504 - "primaryKey": false, 505 - "notNull": false 506 - }, 507 - "born": { 508 - "name": "born", 509 - "type": "timestamp", 510 - "primaryKey": false, 511 - "notNull": false 512 - }, 513 - "born_in": { 514 - "name": "born_in", 515 - "type": "text", 516 - "primaryKey": false, 517 - "notNull": false 518 - }, 519 - "died": { 520 - "name": "died", 521 - "type": "timestamp", 522 - "primaryKey": false, 523 - "notNull": false 524 - }, 525 - "picture": { 526 - "name": "picture", 527 - "type": "text", 528 - "primaryKey": false, 529 - "notNull": false 530 - }, 531 - "sha256": { 532 - "name": "sha256", 533 - "type": "text", 534 - "primaryKey": false, 535 - "notNull": true 536 - }, 537 - "uri": { 538 - "name": "uri", 539 - "type": "text", 540 - "primaryKey": false, 541 - "notNull": false 542 - }, 543 - "apple_music_link": { 544 - "name": "apple_music_link", 545 - "type": "text", 546 - "primaryKey": false, 547 - "notNull": false 548 - }, 549 - "spotify_link": { 550 - "name": "spotify_link", 551 - "type": "text", 552 - "primaryKey": false, 553 - "notNull": false 554 - }, 555 - "tidal_link": { 556 - "name": "tidal_link", 557 - "type": "text", 558 - "primaryKey": false, 559 - "notNull": false 560 - }, 561 - "youtube_link": { 562 - "name": "youtube_link", 563 - "type": "text", 564 - "primaryKey": false, 565 - "notNull": false 566 - }, 567 - "genres": { 568 - "name": "genres", 569 - "type": "text[]", 570 - "primaryKey": false, 571 - "notNull": false 572 - }, 573 - "xata_createdat": { 574 - "name": "xata_createdat", 575 - "type": "timestamp", 576 - "primaryKey": false, 577 - "notNull": true, 578 - "default": "now()" 579 - }, 580 - "xata_updatedat": { 581 - "name": "xata_updatedat", 582 - "type": "timestamp", 583 - "primaryKey": false, 584 - "notNull": true, 585 - "default": "now()" 586 - }, 587 - "xata_version": { 588 - "name": "xata_version", 589 - "type": "integer", 590 - "primaryKey": false, 591 - "notNull": false 592 - } 593 - }, 594 - "indexes": {}, 595 - "foreignKeys": {}, 596 - "compositePrimaryKeys": {}, 597 - "uniqueConstraints": { 598 - "artists_sha256_unique": { 599 - "name": "artists_sha256_unique", 600 - "nullsNotDistinct": false, 601 - "columns": [ 602 - "sha256" 603 - ] 604 - }, 605 - "artists_uri_unique": { 606 - "name": "artists_uri_unique", 607 - "nullsNotDistinct": false, 608 - "columns": [ 609 - "uri" 610 - ] 611 - } 612 - }, 613 - "policies": {}, 614 - "checkConstraints": {}, 615 - "isRLSEnabled": false 616 - }, 617 - "public.dropbox_accounts": { 618 - "name": "dropbox_accounts", 619 - "schema": "", 620 - "columns": { 621 - "xata_id": { 622 - "name": "xata_id", 623 - "type": "text", 624 - "primaryKey": true, 625 - "notNull": true, 626 - "default": "xata_id()" 627 - }, 628 - "email": { 629 - "name": "email", 630 - "type": "text", 631 - "primaryKey": false, 632 - "notNull": true 633 - }, 634 - "is_beta_user": { 635 - "name": "is_beta_user", 636 - "type": "boolean", 637 - "primaryKey": false, 638 - "notNull": true, 639 - "default": false 640 - }, 641 - "user_id": { 642 - "name": "user_id", 643 - "type": "text", 644 - "primaryKey": false, 645 - "notNull": true 646 - }, 647 - "xata_version": { 648 - "name": "xata_version", 649 - "type": "text", 650 - "primaryKey": false, 651 - "notNull": false 652 - }, 653 - "xata_createdat": { 654 - "name": "xata_createdat", 655 - "type": "timestamp", 656 - "primaryKey": false, 657 - "notNull": true, 658 - "default": "now()" 659 - }, 660 - "xata_updatedat": { 661 - "name": "xata_updatedat", 662 - "type": "timestamp", 663 - "primaryKey": false, 664 - "notNull": true, 665 - "default": "now()" 666 - } 667 - }, 668 - "indexes": {}, 669 - "foreignKeys": { 670 - "dropbox_accounts_user_id_users_xata_id_fk": { 671 - "name": "dropbox_accounts_user_id_users_xata_id_fk", 672 - "tableFrom": "dropbox_accounts", 673 - "tableTo": "users", 674 - "columnsFrom": [ 675 - "user_id" 676 - ], 677 - "columnsTo": [ 678 - "xata_id" 679 - ], 680 - "onDelete": "no action", 681 - "onUpdate": "no action" 682 - } 683 - }, 684 - "compositePrimaryKeys": {}, 685 - "uniqueConstraints": { 686 - "dropbox_accounts_email_unique": { 687 - "name": "dropbox_accounts_email_unique", 688 - "nullsNotDistinct": false, 689 - "columns": [ 690 - "email" 691 - ] 692 - } 693 - }, 694 - "policies": {}, 695 - "checkConstraints": {}, 696 - "isRLSEnabled": false 697 - }, 698 - "public.dropbox_directories": { 699 - "name": "dropbox_directories", 700 - "schema": "", 701 - "columns": { 702 - "xata_id": { 703 - "name": "xata_id", 704 - "type": "text", 705 - "primaryKey": true, 706 - "notNull": true, 707 - "default": "xata_id()" 708 - }, 709 - "name": { 710 - "name": "name", 711 - "type": "text", 712 - "primaryKey": false, 713 - "notNull": true 714 - }, 715 - "path": { 716 - "name": "path", 717 - "type": "text", 718 - "primaryKey": false, 719 - "notNull": true 720 - }, 721 - "parent_id": { 722 - "name": "parent_id", 723 - "type": "text", 724 - "primaryKey": false, 725 - "notNull": false 726 - }, 727 - "dropbox_id": { 728 - "name": "dropbox_id", 729 - "type": "text", 730 - "primaryKey": false, 731 - "notNull": true 732 - }, 733 - "file_id": { 734 - "name": "file_id", 735 - "type": "text", 736 - "primaryKey": false, 737 - "notNull": true 738 - }, 739 - "xata_version": { 740 - "name": "xata_version", 741 - "type": "text", 742 - "primaryKey": false, 743 - "notNull": false 744 - }, 745 - "xata_createdat": { 746 - "name": "xata_createdat", 747 - "type": "timestamp", 748 - "primaryKey": false, 749 - "notNull": true, 750 - "default": "now()" 751 - }, 752 - "xata_updatedat": { 753 - "name": "xata_updatedat", 754 - "type": "timestamp", 755 - "primaryKey": false, 756 - "notNull": true, 757 - "default": "now()" 758 - } 759 - }, 760 - "indexes": {}, 761 - "foreignKeys": { 762 - "dropbox_directories_parent_id_dropbox_directories_xata_id_fk": { 763 - "name": "dropbox_directories_parent_id_dropbox_directories_xata_id_fk", 764 - "tableFrom": "dropbox_directories", 765 - "tableTo": "dropbox_directories", 766 - "columnsFrom": [ 767 - "parent_id" 768 - ], 769 - "columnsTo": [ 770 - "xata_id" 771 - ], 772 - "onDelete": "no action", 773 - "onUpdate": "no action" 774 - } 775 - }, 776 - "compositePrimaryKeys": {}, 777 - "uniqueConstraints": { 778 - "dropbox_directories_file_id_unique": { 779 - "name": "dropbox_directories_file_id_unique", 780 - "nullsNotDistinct": false, 781 - "columns": [ 782 - "file_id" 783 - ] 784 - } 785 - }, 786 - "policies": {}, 787 - "checkConstraints": {}, 788 - "isRLSEnabled": false 789 - }, 790 - "public.dropbox_paths": { 791 - "name": "dropbox_paths", 792 - "schema": "", 793 - "columns": { 794 - "xata_id": { 795 - "name": "xata_id", 796 - "type": "text", 797 - "primaryKey": true, 798 - "notNull": true, 799 - "default": "xata_id()" 800 - }, 801 - "path": { 802 - "name": "path", 803 - "type": "text", 804 - "primaryKey": false, 805 - "notNull": true 806 - }, 807 - "name": { 808 - "name": "name", 809 - "type": "text", 810 - "primaryKey": false, 811 - "notNull": true 812 - }, 813 - "dropbox_id": { 814 - "name": "dropbox_id", 815 - "type": "text", 816 - "primaryKey": false, 817 - "notNull": true 818 - }, 819 - "track_id": { 820 - "name": "track_id", 821 - "type": "text", 822 - "primaryKey": false, 823 - "notNull": true 824 - }, 825 - "directory_id": { 826 - "name": "directory_id", 827 - "type": "text", 828 - "primaryKey": false, 829 - "notNull": false 830 - }, 831 - "file_id": { 832 - "name": "file_id", 833 - "type": "text", 834 - "primaryKey": false, 835 - "notNull": true 836 - }, 837 - "xata_version": { 838 - "name": "xata_version", 839 - "type": "text", 840 - "primaryKey": false, 841 - "notNull": false 842 - }, 843 - "xata_createdat": { 844 - "name": "xata_createdat", 845 - "type": "timestamp", 846 - "primaryKey": false, 847 - "notNull": true, 848 - "default": "now()" 849 - }, 850 - "xata_updatedat": { 851 - "name": "xata_updatedat", 852 - "type": "timestamp", 853 - "primaryKey": false, 854 - "notNull": true, 855 - "default": "now()" 856 - } 857 - }, 858 - "indexes": {}, 859 - "foreignKeys": { 860 - "dropbox_paths_directory_id_dropbox_directories_xata_id_fk": { 861 - "name": "dropbox_paths_directory_id_dropbox_directories_xata_id_fk", 862 - "tableFrom": "dropbox_paths", 863 - "tableTo": "dropbox_directories", 864 - "columnsFrom": [ 865 - "directory_id" 866 - ], 867 - "columnsTo": [ 868 - "xata_id" 869 - ], 870 - "onDelete": "no action", 871 - "onUpdate": "no action" 872 - } 873 - }, 874 - "compositePrimaryKeys": {}, 875 - "uniqueConstraints": { 876 - "dropbox_paths_file_id_unique": { 877 - "name": "dropbox_paths_file_id_unique", 878 - "nullsNotDistinct": false, 879 - "columns": [ 880 - "file_id" 881 - ] 882 - } 883 - }, 884 - "policies": {}, 885 - "checkConstraints": {}, 886 - "isRLSEnabled": false 887 - }, 888 - "public.dropbox_tokens": { 889 - "name": "dropbox_tokens", 890 - "schema": "", 891 - "columns": { 892 - "xata_id": { 893 - "name": "xata_id", 894 - "type": "text", 895 - "primaryKey": true, 896 - "notNull": true, 897 - "default": "xata_id()" 898 - }, 899 - "refresh_token": { 900 - "name": "refresh_token", 901 - "type": "text", 902 - "primaryKey": false, 903 - "notNull": true 904 - }, 905 - "xata_createdat": { 906 - "name": "xata_createdat", 907 - "type": "timestamp", 908 - "primaryKey": false, 909 - "notNull": true, 910 - "default": "now()" 911 - }, 912 - "xata_updatedat": { 913 - "name": "xata_updatedat", 914 - "type": "timestamp", 915 - "primaryKey": false, 916 - "notNull": true, 917 - "default": "now()" 918 - } 919 - }, 920 - "indexes": {}, 921 - "foreignKeys": {}, 922 - "compositePrimaryKeys": {}, 923 - "uniqueConstraints": {}, 924 - "policies": {}, 925 - "checkConstraints": {}, 926 - "isRLSEnabled": false 927 - }, 928 - "public.dropbox": { 929 - "name": "dropbox", 930 - "schema": "", 931 - "columns": { 932 - "xata_id": { 933 - "name": "xata_id", 934 - "type": "text", 935 - "primaryKey": true, 936 - "notNull": true, 937 - "default": "xata_id()" 938 - }, 939 - "user_id": { 940 - "name": "user_id", 941 - "type": "text", 942 - "primaryKey": false, 943 - "notNull": true 944 - }, 945 - "dropbox_token_id": { 946 - "name": "dropbox_token_id", 947 - "type": "text", 948 - "primaryKey": false, 949 - "notNull": true 950 - }, 951 - "xata_version": { 952 - "name": "xata_version", 953 - "type": "text", 954 - "primaryKey": false, 955 - "notNull": false 956 - }, 957 - "xata_createdat": { 958 - "name": "xata_createdat", 959 - "type": "timestamp", 960 - "primaryKey": false, 961 - "notNull": true, 962 - "default": "now()" 963 - }, 964 - "xata_updatedat": { 965 - "name": "xata_updatedat", 966 - "type": "timestamp", 967 - "primaryKey": false, 968 - "notNull": true, 969 - "default": "now()" 970 - } 971 - }, 972 - "indexes": {}, 973 - "foreignKeys": { 974 - "dropbox_user_id_users_xata_id_fk": { 975 - "name": "dropbox_user_id_users_xata_id_fk", 976 - "tableFrom": "dropbox", 977 - "tableTo": "users", 978 - "columnsFrom": [ 979 - "user_id" 980 - ], 981 - "columnsTo": [ 982 - "xata_id" 983 - ], 984 - "onDelete": "no action", 985 - "onUpdate": "no action" 986 - }, 987 - "dropbox_dropbox_token_id_dropbox_tokens_xata_id_fk": { 988 - "name": "dropbox_dropbox_token_id_dropbox_tokens_xata_id_fk", 989 - "tableFrom": "dropbox", 990 - "tableTo": "dropbox_tokens", 991 - "columnsFrom": [ 992 - "dropbox_token_id" 993 - ], 994 - "columnsTo": [ 995 - "xata_id" 996 - ], 997 - "onDelete": "no action", 998 - "onUpdate": "no action" 999 - } 1000 - }, 1001 - "compositePrimaryKeys": {}, 1002 - "uniqueConstraints": {}, 1003 - "policies": {}, 1004 - "checkConstraints": {}, 1005 - "isRLSEnabled": false 1006 - }, 1007 - "public.feeds": { 1008 - "name": "feeds", 1009 - "schema": "", 1010 - "columns": { 1011 - "xata_id": { 1012 - "name": "xata_id", 1013 - "type": "text", 1014 - "primaryKey": true, 1015 - "notNull": true, 1016 - "default": "xata_id()" 1017 - }, 1018 - "display_name": { 1019 - "name": "display_name", 1020 - "type": "text", 1021 - "primaryKey": false, 1022 - "notNull": true 1023 - }, 1024 - "description": { 1025 - "name": "description", 1026 - "type": "text", 1027 - "primaryKey": false, 1028 - "notNull": false 1029 - }, 1030 - "did": { 1031 - "name": "did", 1032 - "type": "text", 1033 - "primaryKey": false, 1034 - "notNull": true 1035 - }, 1036 - "uri": { 1037 - "name": "uri", 1038 - "type": "text", 1039 - "primaryKey": false, 1040 - "notNull": true 1041 - }, 1042 - "avatar": { 1043 - "name": "avatar", 1044 - "type": "text", 1045 - "primaryKey": false, 1046 - "notNull": false 1047 - }, 1048 - "user_id": { 1049 - "name": "user_id", 1050 - "type": "text", 1051 - "primaryKey": false, 1052 - "notNull": true 1053 - }, 1054 - "xata_version": { 1055 - "name": "xata_version", 1056 - "type": "integer", 1057 - "primaryKey": false, 1058 - "notNull": false 1059 - }, 1060 - "xata_createdat": { 1061 - "name": "xata_createdat", 1062 - "type": "timestamp", 1063 - "primaryKey": false, 1064 - "notNull": true, 1065 - "default": "now()" 1066 - }, 1067 - "xata_updatedat": { 1068 - "name": "xata_updatedat", 1069 - "type": "timestamp", 1070 - "primaryKey": false, 1071 - "notNull": true, 1072 - "default": "now()" 1073 - } 1074 - }, 1075 - "indexes": {}, 1076 - "foreignKeys": { 1077 - "feeds_user_id_users_xata_id_fk": { 1078 - "name": "feeds_user_id_users_xata_id_fk", 1079 - "tableFrom": "feeds", 1080 - "tableTo": "users", 1081 - "columnsFrom": [ 1082 - "user_id" 1083 - ], 1084 - "columnsTo": [ 1085 - "xata_id" 1086 - ], 1087 - "onDelete": "no action", 1088 - "onUpdate": "no action" 1089 - } 1090 - }, 1091 - "compositePrimaryKeys": {}, 1092 - "uniqueConstraints": { 1093 - "feeds_uri_unique": { 1094 - "name": "feeds_uri_unique", 1095 - "nullsNotDistinct": false, 1096 - "columns": [ 1097 - "uri" 1098 - ] 1099 - } 1100 - }, 1101 - "policies": {}, 1102 - "checkConstraints": {}, 1103 - "isRLSEnabled": false 1104 - }, 1105 - "public.follows": { 1106 - "name": "follows", 1107 - "schema": "", 1108 - "columns": { 1109 - "xata_id": { 1110 - "name": "xata_id", 1111 - "type": "text", 1112 - "primaryKey": true, 1113 - "notNull": true, 1114 - "default": "xata_id()" 1115 - }, 1116 - "uri": { 1117 - "name": "uri", 1118 - "type": "text", 1119 - "primaryKey": false, 1120 - "notNull": true 1121 - }, 1122 - "follower_did": { 1123 - "name": "follower_did", 1124 - "type": "text", 1125 - "primaryKey": false, 1126 - "notNull": true 1127 - }, 1128 - "subject_did": { 1129 - "name": "subject_did", 1130 - "type": "text", 1131 - "primaryKey": false, 1132 - "notNull": true 1133 - }, 1134 - "xata_version": { 1135 - "name": "xata_version", 1136 - "type": "integer", 1137 - "primaryKey": false, 1138 - "notNull": false 1139 - }, 1140 - "xata_createdat": { 1141 - "name": "xata_createdat", 1142 - "type": "timestamp", 1143 - "primaryKey": false, 1144 - "notNull": true, 1145 - "default": "now()" 1146 - }, 1147 - "xata_updatedat": { 1148 - "name": "xata_updatedat", 1149 - "type": "timestamp", 1150 - "primaryKey": false, 1151 - "notNull": true, 1152 - "default": "now()" 1153 - } 1154 - }, 1155 - "indexes": { 1156 - "follows_follower_subject_unique": { 1157 - "name": "follows_follower_subject_unique", 1158 - "columns": [ 1159 - { 1160 - "expression": "follower_did", 1161 - "isExpression": false, 1162 - "asc": true, 1163 - "nulls": "last" 1164 - }, 1165 - { 1166 - "expression": "subject_did", 1167 - "isExpression": false, 1168 - "asc": true, 1169 - "nulls": "last" 1170 - } 1171 - ], 1172 - "isUnique": true, 1173 - "concurrently": false, 1174 - "method": "btree", 1175 - "with": {} 1176 - } 1177 - }, 1178 - "foreignKeys": {}, 1179 - "compositePrimaryKeys": {}, 1180 - "uniqueConstraints": { 1181 - "follows_uri_unique": { 1182 - "name": "follows_uri_unique", 1183 - "nullsNotDistinct": false, 1184 - "columns": [ 1185 - "uri" 1186 - ] 1187 - } 1188 - }, 1189 - "policies": {}, 1190 - "checkConstraints": {}, 1191 - "isRLSEnabled": false 1192 - }, 1193 - "public.google_drive_accounts": { 1194 - "name": "google_drive_accounts", 1195 - "schema": "", 1196 - "columns": { 1197 - "xata_id": { 1198 - "name": "xata_id", 1199 - "type": "text", 1200 - "primaryKey": true, 1201 - "notNull": true, 1202 - "default": "xata_id()" 1203 - }, 1204 - "email": { 1205 - "name": "email", 1206 - "type": "text", 1207 - "primaryKey": false, 1208 - "notNull": true 1209 - }, 1210 - "is_beta_user": { 1211 - "name": "is_beta_user", 1212 - "type": "boolean", 1213 - "primaryKey": false, 1214 - "notNull": true, 1215 - "default": false 1216 - }, 1217 - "user_id": { 1218 - "name": "user_id", 1219 - "type": "text", 1220 - "primaryKey": false, 1221 - "notNull": true 1222 - }, 1223 - "xata_version": { 1224 - "name": "xata_version", 1225 - "type": "text", 1226 - "primaryKey": false, 1227 - "notNull": false 1228 - }, 1229 - "xata_createdat": { 1230 - "name": "xata_createdat", 1231 - "type": "timestamp", 1232 - "primaryKey": false, 1233 - "notNull": true, 1234 - "default": "now()" 1235 - }, 1236 - "xata_updatedat": { 1237 - "name": "xata_updatedat", 1238 - "type": "timestamp", 1239 - "primaryKey": false, 1240 - "notNull": true, 1241 - "default": "now()" 1242 - } 1243 - }, 1244 - "indexes": {}, 1245 - "foreignKeys": { 1246 - "google_drive_accounts_user_id_users_xata_id_fk": { 1247 - "name": "google_drive_accounts_user_id_users_xata_id_fk", 1248 - "tableFrom": "google_drive_accounts", 1249 - "tableTo": "users", 1250 - "columnsFrom": [ 1251 - "user_id" 1252 - ], 1253 - "columnsTo": [ 1254 - "xata_id" 1255 - ], 1256 - "onDelete": "no action", 1257 - "onUpdate": "no action" 1258 - } 1259 - }, 1260 - "compositePrimaryKeys": {}, 1261 - "uniqueConstraints": { 1262 - "google_drive_accounts_email_unique": { 1263 - "name": "google_drive_accounts_email_unique", 1264 - "nullsNotDistinct": false, 1265 - "columns": [ 1266 - "email" 1267 - ] 1268 - } 1269 - }, 1270 - "policies": {}, 1271 - "checkConstraints": {}, 1272 - "isRLSEnabled": false 1273 - }, 1274 - "public.google_drive_directories": { 1275 - "name": "google_drive_directories", 1276 - "schema": "", 1277 - "columns": { 1278 - "xata_id": { 1279 - "name": "xata_id", 1280 - "type": "text", 1281 - "primaryKey": true, 1282 - "notNull": true, 1283 - "default": "xata_id()" 1284 - }, 1285 - "name": { 1286 - "name": "name", 1287 - "type": "text", 1288 - "primaryKey": false, 1289 - "notNull": true 1290 - }, 1291 - "path": { 1292 - "name": "path", 1293 - "type": "text", 1294 - "primaryKey": false, 1295 - "notNull": true 1296 - }, 1297 - "parent_id": { 1298 - "name": "parent_id", 1299 - "type": "text", 1300 - "primaryKey": false, 1301 - "notNull": false 1302 - }, 1303 - "google_drive_id": { 1304 - "name": "google_drive_id", 1305 - "type": "text", 1306 - "primaryKey": false, 1307 - "notNull": true 1308 - }, 1309 - "file_id": { 1310 - "name": "file_id", 1311 - "type": "text", 1312 - "primaryKey": false, 1313 - "notNull": true 1314 - }, 1315 - "xata_version": { 1316 - "name": "xata_version", 1317 - "type": "text", 1318 - "primaryKey": false, 1319 - "notNull": false 1320 - }, 1321 - "xata_createdat": { 1322 - "name": "xata_createdat", 1323 - "type": "timestamp", 1324 - "primaryKey": false, 1325 - "notNull": true, 1326 - "default": "now()" 1327 - }, 1328 - "xata_updatedat": { 1329 - "name": "xata_updatedat", 1330 - "type": "timestamp", 1331 - "primaryKey": false, 1332 - "notNull": true, 1333 - "default": "now()" 1334 - } 1335 - }, 1336 - "indexes": {}, 1337 - "foreignKeys": { 1338 - "google_drive_directories_parent_id_google_drive_directories_xata_id_fk": { 1339 - "name": "google_drive_directories_parent_id_google_drive_directories_xata_id_fk", 1340 - "tableFrom": "google_drive_directories", 1341 - "tableTo": "google_drive_directories", 1342 - "columnsFrom": [ 1343 - "parent_id" 1344 - ], 1345 - "columnsTo": [ 1346 - "xata_id" 1347 - ], 1348 - "onDelete": "no action", 1349 - "onUpdate": "no action" 1350 - } 1351 - }, 1352 - "compositePrimaryKeys": {}, 1353 - "uniqueConstraints": { 1354 - "google_drive_directories_file_id_unique": { 1355 - "name": "google_drive_directories_file_id_unique", 1356 - "nullsNotDistinct": false, 1357 - "columns": [ 1358 - "file_id" 1359 - ] 1360 - } 1361 - }, 1362 - "policies": {}, 1363 - "checkConstraints": {}, 1364 - "isRLSEnabled": false 1365 - }, 1366 - "public.google_drive_paths": { 1367 - "name": "google_drive_paths", 1368 - "schema": "", 1369 - "columns": { 1370 - "xata_id": { 1371 - "name": "xata_id", 1372 - "type": "text", 1373 - "primaryKey": true, 1374 - "notNull": true, 1375 - "default": "xata_id()" 1376 - }, 1377 - "google_drive_id": { 1378 - "name": "google_drive_id", 1379 - "type": "text", 1380 - "primaryKey": false, 1381 - "notNull": true 1382 - }, 1383 - "track_id": { 1384 - "name": "track_id", 1385 - "type": "text", 1386 - "primaryKey": false, 1387 - "notNull": true 1388 - }, 1389 - "name": { 1390 - "name": "name", 1391 - "type": "text", 1392 - "primaryKey": false, 1393 - "notNull": true 1394 - }, 1395 - "directory_id": { 1396 - "name": "directory_id", 1397 - "type": "text", 1398 - "primaryKey": false, 1399 - "notNull": false 1400 - }, 1401 - "file_id": { 1402 - "name": "file_id", 1403 - "type": "text", 1404 - "primaryKey": false, 1405 - "notNull": true 1406 - }, 1407 - "xata_version": { 1408 - "name": "xata_version", 1409 - "type": "text", 1410 - "primaryKey": false, 1411 - "notNull": false 1412 - }, 1413 - "xata_createdat": { 1414 - "name": "xata_createdat", 1415 - "type": "timestamp", 1416 - "primaryKey": false, 1417 - "notNull": true, 1418 - "default": "now()" 1419 - }, 1420 - "xata_updatedat": { 1421 - "name": "xata_updatedat", 1422 - "type": "timestamp", 1423 - "primaryKey": false, 1424 - "notNull": true, 1425 - "default": "now()" 1426 - } 1427 - }, 1428 - "indexes": {}, 1429 - "foreignKeys": { 1430 - "google_drive_paths_directory_id_google_drive_directories_xata_id_fk": { 1431 - "name": "google_drive_paths_directory_id_google_drive_directories_xata_id_fk", 1432 - "tableFrom": "google_drive_paths", 1433 - "tableTo": "google_drive_directories", 1434 - "columnsFrom": [ 1435 - "directory_id" 1436 - ], 1437 - "columnsTo": [ 1438 - "xata_id" 1439 - ], 1440 - "onDelete": "no action", 1441 - "onUpdate": "no action" 1442 - } 1443 - }, 1444 - "compositePrimaryKeys": {}, 1445 - "uniqueConstraints": { 1446 - "google_drive_paths_file_id_unique": { 1447 - "name": "google_drive_paths_file_id_unique", 1448 - "nullsNotDistinct": false, 1449 - "columns": [ 1450 - "file_id" 1451 - ] 1452 - } 1453 - }, 1454 - "policies": {}, 1455 - "checkConstraints": {}, 1456 - "isRLSEnabled": false 1457 - }, 1458 - "public.google_drive_tokens": { 1459 - "name": "google_drive_tokens", 1460 - "schema": "", 1461 - "columns": { 1462 - "xata_id": { 1463 - "name": "xata_id", 1464 - "type": "text", 1465 - "primaryKey": true, 1466 - "notNull": true, 1467 - "default": "xata_id()" 1468 - }, 1469 - "refresh_token": { 1470 - "name": "refresh_token", 1471 - "type": "text", 1472 - "primaryKey": false, 1473 - "notNull": true 1474 - }, 1475 - "xata_createdat": { 1476 - "name": "xata_createdat", 1477 - "type": "timestamp", 1478 - "primaryKey": false, 1479 - "notNull": true, 1480 - "default": "now()" 1481 - }, 1482 - "xata_updatedat": { 1483 - "name": "xata_updatedat", 1484 - "type": "timestamp", 1485 - "primaryKey": false, 1486 - "notNull": true, 1487 - "default": "now()" 1488 - } 1489 - }, 1490 - "indexes": {}, 1491 - "foreignKeys": {}, 1492 - "compositePrimaryKeys": {}, 1493 - "uniqueConstraints": {}, 1494 - "policies": {}, 1495 - "checkConstraints": {}, 1496 - "isRLSEnabled": false 1497 - }, 1498 - "public.google_drive": { 1499 - "name": "google_drive", 1500 - "schema": "", 1501 - "columns": { 1502 - "xata_id": { 1503 - "name": "xata_id", 1504 - "type": "text", 1505 - "primaryKey": true, 1506 - "notNull": true, 1507 - "default": "xata_id()" 1508 - }, 1509 - "google_drive_token_id": { 1510 - "name": "google_drive_token_id", 1511 - "type": "text", 1512 - "primaryKey": false, 1513 - "notNull": true 1514 - }, 1515 - "user_id": { 1516 - "name": "user_id", 1517 - "type": "text", 1518 - "primaryKey": false, 1519 - "notNull": true 1520 - }, 1521 - "xata_version": { 1522 - "name": "xata_version", 1523 - "type": "text", 1524 - "primaryKey": false, 1525 - "notNull": false 1526 - }, 1527 - "xata_createdat": { 1528 - "name": "xata_createdat", 1529 - "type": "timestamp", 1530 - "primaryKey": false, 1531 - "notNull": true, 1532 - "default": "now()" 1533 - }, 1534 - "xata_updatedat": { 1535 - "name": "xata_updatedat", 1536 - "type": "timestamp", 1537 - "primaryKey": false, 1538 - "notNull": true, 1539 - "default": "now()" 1540 - } 1541 - }, 1542 - "indexes": {}, 1543 - "foreignKeys": { 1544 - "google_drive_google_drive_token_id_google_drive_tokens_xata_id_fk": { 1545 - "name": "google_drive_google_drive_token_id_google_drive_tokens_xata_id_fk", 1546 - "tableFrom": "google_drive", 1547 - "tableTo": "google_drive_tokens", 1548 - "columnsFrom": [ 1549 - "google_drive_token_id" 1550 - ], 1551 - "columnsTo": [ 1552 - "xata_id" 1553 - ], 1554 - "onDelete": "no action", 1555 - "onUpdate": "no action" 1556 - }, 1557 - "google_drive_user_id_users_xata_id_fk": { 1558 - "name": "google_drive_user_id_users_xata_id_fk", 1559 - "tableFrom": "google_drive", 1560 - "tableTo": "users", 1561 - "columnsFrom": [ 1562 - "user_id" 1563 - ], 1564 - "columnsTo": [ 1565 - "xata_id" 1566 - ], 1567 - "onDelete": "no action", 1568 - "onUpdate": "no action" 1569 - } 1570 - }, 1571 - "compositePrimaryKeys": {}, 1572 - "uniqueConstraints": {}, 1573 - "policies": {}, 1574 - "checkConstraints": {}, 1575 - "isRLSEnabled": false 1576 - }, 1577 - "public.loved_tracks": { 1578 - "name": "loved_tracks", 1579 - "schema": "", 1580 - "columns": { 1581 - "xata_id": { 1582 - "name": "xata_id", 1583 - "type": "text", 1584 - "primaryKey": true, 1585 - "notNull": true, 1586 - "default": "xata_id()" 1587 - }, 1588 - "user_id": { 1589 - "name": "user_id", 1590 - "type": "text", 1591 - "primaryKey": false, 1592 - "notNull": true 1593 - }, 1594 - "track_id": { 1595 - "name": "track_id", 1596 - "type": "text", 1597 - "primaryKey": false, 1598 - "notNull": true 1599 - }, 1600 - "uri": { 1601 - "name": "uri", 1602 - "type": "text", 1603 - "primaryKey": false, 1604 - "notNull": false 1605 - }, 1606 - "xata_createdat": { 1607 - "name": "xata_createdat", 1608 - "type": "timestamp", 1609 - "primaryKey": false, 1610 - "notNull": true, 1611 - "default": "now()" 1612 - } 1613 - }, 1614 - "indexes": {}, 1615 - "foreignKeys": { 1616 - "loved_tracks_user_id_users_xata_id_fk": { 1617 - "name": "loved_tracks_user_id_users_xata_id_fk", 1618 - "tableFrom": "loved_tracks", 1619 - "tableTo": "users", 1620 - "columnsFrom": [ 1621 - "user_id" 1622 - ], 1623 - "columnsTo": [ 1624 - "xata_id" 1625 - ], 1626 - "onDelete": "no action", 1627 - "onUpdate": "no action" 1628 - }, 1629 - "loved_tracks_track_id_tracks_xata_id_fk": { 1630 - "name": "loved_tracks_track_id_tracks_xata_id_fk", 1631 - "tableFrom": "loved_tracks", 1632 - "tableTo": "tracks", 1633 - "columnsFrom": [ 1634 - "track_id" 1635 - ], 1636 - "columnsTo": [ 1637 - "xata_id" 1638 - ], 1639 - "onDelete": "no action", 1640 - "onUpdate": "no action" 1641 - } 1642 - }, 1643 - "compositePrimaryKeys": {}, 1644 - "uniqueConstraints": { 1645 - "loved_tracks_uri_unique": { 1646 - "name": "loved_tracks_uri_unique", 1647 - "nullsNotDistinct": false, 1648 - "columns": [ 1649 - "uri" 1650 - ] 1651 - } 1652 - }, 1653 - "policies": {}, 1654 - "checkConstraints": {}, 1655 - "isRLSEnabled": false 1656 - }, 1657 - "public.playlist_tracks": { 1658 - "name": "playlist_tracks", 1659 - "schema": "", 1660 - "columns": { 1661 - "xata_id": { 1662 - "name": "xata_id", 1663 - "type": "text", 1664 - "primaryKey": true, 1665 - "notNull": true, 1666 - "default": "xata_id()" 1667 - }, 1668 - "playlist_id": { 1669 - "name": "playlist_id", 1670 - "type": "text", 1671 - "primaryKey": false, 1672 - "notNull": true 1673 - }, 1674 - "track_id": { 1675 - "name": "track_id", 1676 - "type": "text", 1677 - "primaryKey": false, 1678 - "notNull": true 1679 - }, 1680 - "xata_createdat": { 1681 - "name": "xata_createdat", 1682 - "type": "timestamp", 1683 - "primaryKey": false, 1684 - "notNull": true, 1685 - "default": "now()" 1686 - } 1687 - }, 1688 - "indexes": {}, 1689 - "foreignKeys": { 1690 - "playlist_tracks_playlist_id_playlists_xata_id_fk": { 1691 - "name": "playlist_tracks_playlist_id_playlists_xata_id_fk", 1692 - "tableFrom": "playlist_tracks", 1693 - "tableTo": "playlists", 1694 - "columnsFrom": [ 1695 - "playlist_id" 1696 - ], 1697 - "columnsTo": [ 1698 - "xata_id" 1699 - ], 1700 - "onDelete": "no action", 1701 - "onUpdate": "no action" 1702 - }, 1703 - "playlist_tracks_track_id_tracks_xata_id_fk": { 1704 - "name": "playlist_tracks_track_id_tracks_xata_id_fk", 1705 - "tableFrom": "playlist_tracks", 1706 - "tableTo": "tracks", 1707 - "columnsFrom": [ 1708 - "track_id" 1709 - ], 1710 - "columnsTo": [ 1711 - "xata_id" 1712 - ], 1713 - "onDelete": "no action", 1714 - "onUpdate": "no action" 1715 - } 1716 - }, 1717 - "compositePrimaryKeys": {}, 1718 - "uniqueConstraints": {}, 1719 - "policies": {}, 1720 - "checkConstraints": {}, 1721 - "isRLSEnabled": false 1722 - }, 1723 - "public.playlists": { 1724 - "name": "playlists", 1725 - "schema": "", 1726 - "columns": { 1727 - "xata_id": { 1728 - "name": "xata_id", 1729 - "type": "text", 1730 - "primaryKey": true, 1731 - "notNull": true, 1732 - "default": "xata_id()" 1733 - }, 1734 - "name": { 1735 - "name": "name", 1736 - "type": "text", 1737 - "primaryKey": false, 1738 - "notNull": true 1739 - }, 1740 - "picture": { 1741 - "name": "picture", 1742 - "type": "text", 1743 - "primaryKey": false, 1744 - "notNull": false 1745 - }, 1746 - "description": { 1747 - "name": "description", 1748 - "type": "text", 1749 - "primaryKey": false, 1750 - "notNull": false 1751 - }, 1752 - "uri": { 1753 - "name": "uri", 1754 - "type": "text", 1755 - "primaryKey": false, 1756 - "notNull": false 1757 - }, 1758 - "spotify_link": { 1759 - "name": "spotify_link", 1760 - "type": "text", 1761 - "primaryKey": false, 1762 - "notNull": false 1763 - }, 1764 - "tidal_link": { 1765 - "name": "tidal_link", 1766 - "type": "text", 1767 - "primaryKey": false, 1768 - "notNull": false 1769 - }, 1770 - "apple_music_link": { 1771 - "name": "apple_music_link", 1772 - "type": "text", 1773 - "primaryKey": false, 1774 - "notNull": false 1775 - }, 1776 - "created_by": { 1777 - "name": "created_by", 1778 - "type": "text", 1779 - "primaryKey": false, 1780 - "notNull": true 1781 - }, 1782 - "xata_createdat": { 1783 - "name": "xata_createdat", 1784 - "type": "timestamp", 1785 - "primaryKey": false, 1786 - "notNull": true, 1787 - "default": "now()" 1788 - }, 1789 - "xata_updatedat": { 1790 - "name": "xata_updatedat", 1791 - "type": "timestamp", 1792 - "primaryKey": false, 1793 - "notNull": true, 1794 - "default": "now()" 1795 - } 1796 - }, 1797 - "indexes": {}, 1798 - "foreignKeys": { 1799 - "playlists_created_by_users_xata_id_fk": { 1800 - "name": "playlists_created_by_users_xata_id_fk", 1801 - "tableFrom": "playlists", 1802 - "tableTo": "users", 1803 - "columnsFrom": [ 1804 - "created_by" 1805 - ], 1806 - "columnsTo": [ 1807 - "xata_id" 1808 - ], 1809 - "onDelete": "no action", 1810 - "onUpdate": "no action" 1811 - } 1812 - }, 1813 - "compositePrimaryKeys": {}, 1814 - "uniqueConstraints": { 1815 - "playlists_uri_unique": { 1816 - "name": "playlists_uri_unique", 1817 - "nullsNotDistinct": false, 1818 - "columns": [ 1819 - "uri" 1820 - ] 1821 - } 1822 - }, 1823 - "policies": {}, 1824 - "checkConstraints": {}, 1825 - "isRLSEnabled": false 1826 - }, 1827 - "public.profile_shouts": { 1828 - "name": "profile_shouts", 1829 - "schema": "", 1830 - "columns": { 1831 - "xata_id": { 1832 - "name": "xata_id", 1833 - "type": "text", 1834 - "primaryKey": true, 1835 - "notNull": true, 1836 - "default": "xata_id()" 1837 - }, 1838 - "user_id": { 1839 - "name": "user_id", 1840 - "type": "text", 1841 - "primaryKey": false, 1842 - "notNull": true 1843 - }, 1844 - "shout_id": { 1845 - "name": "shout_id", 1846 - "type": "text", 1847 - "primaryKey": false, 1848 - "notNull": true 1849 - }, 1850 - "xata_createdat": { 1851 - "name": "xata_createdat", 1852 - "type": "timestamp", 1853 - "primaryKey": false, 1854 - "notNull": true, 1855 - "default": "now()" 1856 - } 1857 - }, 1858 - "indexes": {}, 1859 - "foreignKeys": { 1860 - "profile_shouts_user_id_users_xata_id_fk": { 1861 - "name": "profile_shouts_user_id_users_xata_id_fk", 1862 - "tableFrom": "profile_shouts", 1863 - "tableTo": "users", 1864 - "columnsFrom": [ 1865 - "user_id" 1866 - ], 1867 - "columnsTo": [ 1868 - "xata_id" 1869 - ], 1870 - "onDelete": "no action", 1871 - "onUpdate": "no action" 1872 - }, 1873 - "profile_shouts_shout_id_shouts_xata_id_fk": { 1874 - "name": "profile_shouts_shout_id_shouts_xata_id_fk", 1875 - "tableFrom": "profile_shouts", 1876 - "tableTo": "shouts", 1877 - "columnsFrom": [ 1878 - "shout_id" 1879 - ], 1880 - "columnsTo": [ 1881 - "xata_id" 1882 - ], 1883 - "onDelete": "no action", 1884 - "onUpdate": "no action" 1885 - } 1886 - }, 1887 - "compositePrimaryKeys": {}, 1888 - "uniqueConstraints": {}, 1889 - "policies": {}, 1890 - "checkConstraints": {}, 1891 - "isRLSEnabled": false 1892 - }, 1893 - "public.queue_tracks": { 1894 - "name": "queue_tracks", 1895 - "schema": "", 1896 - "columns": { 1897 - "xata_id": { 1898 - "name": "xata_id", 1899 - "type": "text", 1900 - "primaryKey": true, 1901 - "notNull": true, 1902 - "default": "xata_id()" 1903 - }, 1904 - "user_id": { 1905 - "name": "user_id", 1906 - "type": "text", 1907 - "primaryKey": false, 1908 - "notNull": true 1909 - }, 1910 - "track_id": { 1911 - "name": "track_id", 1912 - "type": "text", 1913 - "primaryKey": false, 1914 - "notNull": true 1915 - }, 1916 - "position": { 1917 - "name": "position", 1918 - "type": "integer", 1919 - "primaryKey": false, 1920 - "notNull": true 1921 - }, 1922 - "file_uri": { 1923 - "name": "file_uri", 1924 - "type": "text", 1925 - "primaryKey": false, 1926 - "notNull": true 1927 - }, 1928 - "xata_version": { 1929 - "name": "xata_version", 1930 - "type": "integer", 1931 - "primaryKey": false, 1932 - "notNull": true, 1933 - "default": 0 1934 - }, 1935 - "xata_createdat": { 1936 - "name": "xata_createdat", 1937 - "type": "timestamp", 1938 - "primaryKey": false, 1939 - "notNull": true, 1940 - "default": "now()" 1941 - }, 1942 - "xata_updatedat": { 1943 - "name": "xata_updatedat", 1944 - "type": "timestamp", 1945 - "primaryKey": false, 1946 - "notNull": true, 1947 - "default": "now()" 1948 - } 1949 - }, 1950 - "indexes": {}, 1951 - "foreignKeys": { 1952 - "queue_tracks_user_id_users_xata_id_fk": { 1953 - "name": "queue_tracks_user_id_users_xata_id_fk", 1954 - "tableFrom": "queue_tracks", 1955 - "tableTo": "users", 1956 - "columnsFrom": [ 1957 - "user_id" 1958 - ], 1959 - "columnsTo": [ 1960 - "xata_id" 1961 - ], 1962 - "onDelete": "no action", 1963 - "onUpdate": "no action" 1964 - }, 1965 - "queue_tracks_track_id_tracks_xata_id_fk": { 1966 - "name": "queue_tracks_track_id_tracks_xata_id_fk", 1967 - "tableFrom": "queue_tracks", 1968 - "tableTo": "tracks", 1969 - "columnsFrom": [ 1970 - "track_id" 1971 - ], 1972 - "columnsTo": [ 1973 - "xata_id" 1974 - ], 1975 - "onDelete": "no action", 1976 - "onUpdate": "no action" 1977 - } 1978 - }, 1979 - "compositePrimaryKeys": {}, 1980 - "uniqueConstraints": {}, 1981 - "policies": {}, 1982 - "checkConstraints": {}, 1983 - "isRLSEnabled": false 1984 - }, 1985 - "public.scrobbles": { 1986 - "name": "scrobbles", 1987 - "schema": "", 1988 - "columns": { 1989 - "xata_id": { 1990 - "name": "xata_id", 1991 - "type": "text", 1992 - "primaryKey": true, 1993 - "notNull": true, 1994 - "default": "xata_id()" 1995 - }, 1996 - "user_id": { 1997 - "name": "user_id", 1998 - "type": "text", 1999 - "primaryKey": false, 2000 - "notNull": false 2001 - }, 2002 - "track_id": { 2003 - "name": "track_id", 2004 - "type": "text", 2005 - "primaryKey": false, 2006 - "notNull": false 2007 - }, 2008 - "album_id": { 2009 - "name": "album_id", 2010 - "type": "text", 2011 - "primaryKey": false, 2012 - "notNull": false 2013 - }, 2014 - "artist_id": { 2015 - "name": "artist_id", 2016 - "type": "text", 2017 - "primaryKey": false, 2018 - "notNull": false 2019 - }, 2020 - "uri": { 2021 - "name": "uri", 2022 - "type": "text", 2023 - "primaryKey": false, 2024 - "notNull": false 2025 - }, 2026 - "xata_createdat": { 2027 - "name": "xata_createdat", 2028 - "type": "timestamp", 2029 - "primaryKey": false, 2030 - "notNull": true, 2031 - "default": "now()" 2032 - }, 2033 - "xata_updatedat": { 2034 - "name": "xata_updatedat", 2035 - "type": "timestamp", 2036 - "primaryKey": false, 2037 - "notNull": true, 2038 - "default": "now()" 2039 - }, 2040 - "xata_version": { 2041 - "name": "xata_version", 2042 - "type": "integer", 2043 - "primaryKey": false, 2044 - "notNull": false 2045 - }, 2046 - "timestamp": { 2047 - "name": "timestamp", 2048 - "type": "timestamp", 2049 - "primaryKey": false, 2050 - "notNull": true, 2051 - "default": "now()" 2052 - } 2053 - }, 2054 - "indexes": {}, 2055 - "foreignKeys": { 2056 - "scrobbles_user_id_users_xata_id_fk": { 2057 - "name": "scrobbles_user_id_users_xata_id_fk", 2058 - "tableFrom": "scrobbles", 2059 - "tableTo": "users", 2060 - "columnsFrom": [ 2061 - "user_id" 2062 - ], 2063 - "columnsTo": [ 2064 - "xata_id" 2065 - ], 2066 - "onDelete": "no action", 2067 - "onUpdate": "no action" 2068 - }, 2069 - "scrobbles_track_id_tracks_xata_id_fk": { 2070 - "name": "scrobbles_track_id_tracks_xata_id_fk", 2071 - "tableFrom": "scrobbles", 2072 - "tableTo": "tracks", 2073 - "columnsFrom": [ 2074 - "track_id" 2075 - ], 2076 - "columnsTo": [ 2077 - "xata_id" 2078 - ], 2079 - "onDelete": "no action", 2080 - "onUpdate": "no action" 2081 - }, 2082 - "scrobbles_album_id_albums_xata_id_fk": { 2083 - "name": "scrobbles_album_id_albums_xata_id_fk", 2084 - "tableFrom": "scrobbles", 2085 - "tableTo": "albums", 2086 - "columnsFrom": [ 2087 - "album_id" 2088 - ], 2089 - "columnsTo": [ 2090 - "xata_id" 2091 - ], 2092 - "onDelete": "no action", 2093 - "onUpdate": "no action" 2094 - }, 2095 - "scrobbles_artist_id_artists_xata_id_fk": { 2096 - "name": "scrobbles_artist_id_artists_xata_id_fk", 2097 - "tableFrom": "scrobbles", 2098 - "tableTo": "artists", 2099 - "columnsFrom": [ 2100 - "artist_id" 2101 - ], 2102 - "columnsTo": [ 2103 - "xata_id" 2104 - ], 2105 - "onDelete": "no action", 2106 - "onUpdate": "no action" 2107 - } 2108 - }, 2109 - "compositePrimaryKeys": {}, 2110 - "uniqueConstraints": { 2111 - "scrobbles_uri_unique": { 2112 - "name": "scrobbles_uri_unique", 2113 - "nullsNotDistinct": false, 2114 - "columns": [ 2115 - "uri" 2116 - ] 2117 - } 2118 - }, 2119 - "policies": {}, 2120 - "checkConstraints": {}, 2121 - "isRLSEnabled": false 2122 - }, 2123 - "public.shout_likes": { 2124 - "name": "shout_likes", 2125 - "schema": "", 2126 - "columns": { 2127 - "xata_id": { 2128 - "name": "xata_id", 2129 - "type": "text", 2130 - "primaryKey": true, 2131 - "notNull": true, 2132 - "default": "xata_id()" 2133 - }, 2134 - "user_id": { 2135 - "name": "user_id", 2136 - "type": "text", 2137 - "primaryKey": false, 2138 - "notNull": true 2139 - }, 2140 - "shout_id": { 2141 - "name": "shout_id", 2142 - "type": "text", 2143 - "primaryKey": false, 2144 - "notNull": true 2145 - }, 2146 - "xata_createdat": { 2147 - "name": "xata_createdat", 2148 - "type": "timestamp", 2149 - "primaryKey": false, 2150 - "notNull": true, 2151 - "default": "now()" 2152 - }, 2153 - "uri": { 2154 - "name": "uri", 2155 - "type": "text", 2156 - "primaryKey": false, 2157 - "notNull": true 2158 - } 2159 - }, 2160 - "indexes": {}, 2161 - "foreignKeys": { 2162 - "shout_likes_user_id_users_xata_id_fk": { 2163 - "name": "shout_likes_user_id_users_xata_id_fk", 2164 - "tableFrom": "shout_likes", 2165 - "tableTo": "users", 2166 - "columnsFrom": [ 2167 - "user_id" 2168 - ], 2169 - "columnsTo": [ 2170 - "xata_id" 2171 - ], 2172 - "onDelete": "no action", 2173 - "onUpdate": "no action" 2174 - }, 2175 - "shout_likes_shout_id_shouts_xata_id_fk": { 2176 - "name": "shout_likes_shout_id_shouts_xata_id_fk", 2177 - "tableFrom": "shout_likes", 2178 - "tableTo": "shouts", 2179 - "columnsFrom": [ 2180 - "shout_id" 2181 - ], 2182 - "columnsTo": [ 2183 - "xata_id" 2184 - ], 2185 - "onDelete": "no action", 2186 - "onUpdate": "no action" 2187 - } 2188 - }, 2189 - "compositePrimaryKeys": {}, 2190 - "uniqueConstraints": { 2191 - "shout_likes_uri_unique": { 2192 - "name": "shout_likes_uri_unique", 2193 - "nullsNotDistinct": false, 2194 - "columns": [ 2195 - "uri" 2196 - ] 2197 - } 2198 - }, 2199 - "policies": {}, 2200 - "checkConstraints": {}, 2201 - "isRLSEnabled": false 2202 - }, 2203 - "public.shout_reports": { 2204 - "name": "shout_reports", 2205 - "schema": "", 2206 - "columns": { 2207 - "xata_id": { 2208 - "name": "xata_id", 2209 - "type": "text", 2210 - "primaryKey": true, 2211 - "notNull": true, 2212 - "default": "xata_id()" 2213 - }, 2214 - "user_id": { 2215 - "name": "user_id", 2216 - "type": "text", 2217 - "primaryKey": false, 2218 - "notNull": true 2219 - }, 2220 - "shout_id": { 2221 - "name": "shout_id", 2222 - "type": "text", 2223 - "primaryKey": false, 2224 - "notNull": true 2225 - }, 2226 - "xata_createdat": { 2227 - "name": "xata_createdat", 2228 - "type": "timestamp", 2229 - "primaryKey": false, 2230 - "notNull": true, 2231 - "default": "now()" 2232 - } 2233 - }, 2234 - "indexes": {}, 2235 - "foreignKeys": { 2236 - "shout_reports_user_id_users_xata_id_fk": { 2237 - "name": "shout_reports_user_id_users_xata_id_fk", 2238 - "tableFrom": "shout_reports", 2239 - "tableTo": "users", 2240 - "columnsFrom": [ 2241 - "user_id" 2242 - ], 2243 - "columnsTo": [ 2244 - "xata_id" 2245 - ], 2246 - "onDelete": "no action", 2247 - "onUpdate": "no action" 2248 - }, 2249 - "shout_reports_shout_id_shouts_xata_id_fk": { 2250 - "name": "shout_reports_shout_id_shouts_xata_id_fk", 2251 - "tableFrom": "shout_reports", 2252 - "tableTo": "shouts", 2253 - "columnsFrom": [ 2254 - "shout_id" 2255 - ], 2256 - "columnsTo": [ 2257 - "xata_id" 2258 - ], 2259 - "onDelete": "no action", 2260 - "onUpdate": "no action" 2261 - } 2262 - }, 2263 - "compositePrimaryKeys": {}, 2264 - "uniqueConstraints": {}, 2265 - "policies": {}, 2266 - "checkConstraints": {}, 2267 - "isRLSEnabled": false 2268 - }, 2269 - "public.shouts": { 2270 - "name": "shouts", 2271 - "schema": "", 2272 - "columns": { 2273 - "xata_id": { 2274 - "name": "xata_id", 2275 - "type": "text", 2276 - "primaryKey": true, 2277 - "notNull": true, 2278 - "default": "xata_id()" 2279 - }, 2280 - "content": { 2281 - "name": "content", 2282 - "type": "text", 2283 - "primaryKey": false, 2284 - "notNull": true 2285 - }, 2286 - "track_id": { 2287 - "name": "track_id", 2288 - "type": "text", 2289 - "primaryKey": false, 2290 - "notNull": false 2291 - }, 2292 - "artist_id": { 2293 - "name": "artist_id", 2294 - "type": "text", 2295 - "primaryKey": false, 2296 - "notNull": false 2297 - }, 2298 - "album_id": { 2299 - "name": "album_id", 2300 - "type": "text", 2301 - "primaryKey": false, 2302 - "notNull": false 2303 - }, 2304 - "scrobble_id": { 2305 - "name": "scrobble_id", 2306 - "type": "text", 2307 - "primaryKey": false, 2308 - "notNull": false 2309 - }, 2310 - "uri": { 2311 - "name": "uri", 2312 - "type": "text", 2313 - "primaryKey": false, 2314 - "notNull": true 2315 - }, 2316 - "author_id": { 2317 - "name": "author_id", 2318 - "type": "text", 2319 - "primaryKey": false, 2320 - "notNull": true 2321 - }, 2322 - "parent_id": { 2323 - "name": "parent_id", 2324 - "type": "text", 2325 - "primaryKey": false, 2326 - "notNull": false 2327 - }, 2328 - "xata_createdat": { 2329 - "name": "xata_createdat", 2330 - "type": "timestamp", 2331 - "primaryKey": false, 2332 - "notNull": true, 2333 - "default": "now()" 2334 - }, 2335 - "xata_updatedat": { 2336 - "name": "xata_updatedat", 2337 - "type": "timestamp", 2338 - "primaryKey": false, 2339 - "notNull": true, 2340 - "default": "now()" 2341 - } 2342 - }, 2343 - "indexes": {}, 2344 - "foreignKeys": { 2345 - "shouts_track_id_tracks_xata_id_fk": { 2346 - "name": "shouts_track_id_tracks_xata_id_fk", 2347 - "tableFrom": "shouts", 2348 - "tableTo": "tracks", 2349 - "columnsFrom": [ 2350 - "track_id" 2351 - ], 2352 - "columnsTo": [ 2353 - "xata_id" 2354 - ], 2355 - "onDelete": "no action", 2356 - "onUpdate": "no action" 2357 - }, 2358 - "shouts_artist_id_users_xata_id_fk": { 2359 - "name": "shouts_artist_id_users_xata_id_fk", 2360 - "tableFrom": "shouts", 2361 - "tableTo": "users", 2362 - "columnsFrom": [ 2363 - "artist_id" 2364 - ], 2365 - "columnsTo": [ 2366 - "xata_id" 2367 - ], 2368 - "onDelete": "no action", 2369 - "onUpdate": "no action" 2370 - }, 2371 - "shouts_album_id_albums_xata_id_fk": { 2372 - "name": "shouts_album_id_albums_xata_id_fk", 2373 - "tableFrom": "shouts", 2374 - "tableTo": "albums", 2375 - "columnsFrom": [ 2376 - "album_id" 2377 - ], 2378 - "columnsTo": [ 2379 - "xata_id" 2380 - ], 2381 - "onDelete": "no action", 2382 - "onUpdate": "no action" 2383 - }, 2384 - "shouts_scrobble_id_scrobbles_xata_id_fk": { 2385 - "name": "shouts_scrobble_id_scrobbles_xata_id_fk", 2386 - "tableFrom": "shouts", 2387 - "tableTo": "scrobbles", 2388 - "columnsFrom": [ 2389 - "scrobble_id" 2390 - ], 2391 - "columnsTo": [ 2392 - "xata_id" 2393 - ], 2394 - "onDelete": "no action", 2395 - "onUpdate": "no action" 2396 - }, 2397 - "shouts_author_id_users_xata_id_fk": { 2398 - "name": "shouts_author_id_users_xata_id_fk", 2399 - "tableFrom": "shouts", 2400 - "tableTo": "users", 2401 - "columnsFrom": [ 2402 - "author_id" 2403 - ], 2404 - "columnsTo": [ 2405 - "xata_id" 2406 - ], 2407 - "onDelete": "no action", 2408 - "onUpdate": "no action" 2409 - }, 2410 - "shouts_parent_id_shouts_xata_id_fk": { 2411 - "name": "shouts_parent_id_shouts_xata_id_fk", 2412 - "tableFrom": "shouts", 2413 - "tableTo": "shouts", 2414 - "columnsFrom": [ 2415 - "parent_id" 2416 - ], 2417 - "columnsTo": [ 2418 - "xata_id" 2419 - ], 2420 - "onDelete": "no action", 2421 - "onUpdate": "no action" 2422 - } 2423 - }, 2424 - "compositePrimaryKeys": {}, 2425 - "uniqueConstraints": { 2426 - "shouts_uri_unique": { 2427 - "name": "shouts_uri_unique", 2428 - "nullsNotDistinct": false, 2429 - "columns": [ 2430 - "uri" 2431 - ] 2432 - } 2433 - }, 2434 - "policies": {}, 2435 - "checkConstraints": {}, 2436 - "isRLSEnabled": false 2437 - }, 2438 - "public.spotify_accounts": { 2439 - "name": "spotify_accounts", 2440 - "schema": "", 2441 - "columns": { 2442 - "xata_id": { 2443 - "name": "xata_id", 2444 - "type": "text", 2445 - "primaryKey": true, 2446 - "notNull": true, 2447 - "default": "xata_id()" 2448 - }, 2449 - "xata_version": { 2450 - "name": "xata_version", 2451 - "type": "integer", 2452 - "primaryKey": false, 2453 - "notNull": false 2454 - }, 2455 - "email": { 2456 - "name": "email", 2457 - "type": "text", 2458 - "primaryKey": false, 2459 - "notNull": true 2460 - }, 2461 - "user_id": { 2462 - "name": "user_id", 2463 - "type": "text", 2464 - "primaryKey": false, 2465 - "notNull": true 2466 - }, 2467 - "is_beta_user": { 2468 - "name": "is_beta_user", 2469 - "type": "boolean", 2470 - "primaryKey": false, 2471 - "notNull": true, 2472 - "default": false 2473 - }, 2474 - "spotify_app_id": { 2475 - "name": "spotify_app_id", 2476 - "type": "text", 2477 - "primaryKey": false, 2478 - "notNull": false 2479 - }, 2480 - "xata_createdat": { 2481 - "name": "xata_createdat", 2482 - "type": "timestamp", 2483 - "primaryKey": false, 2484 - "notNull": true, 2485 - "default": "now()" 2486 - }, 2487 - "xata_updatedat": { 2488 - "name": "xata_updatedat", 2489 - "type": "timestamp", 2490 - "primaryKey": false, 2491 - "notNull": true, 2492 - "default": "now()" 2493 - } 2494 - }, 2495 - "indexes": {}, 2496 - "foreignKeys": { 2497 - "spotify_accounts_user_id_users_xata_id_fk": { 2498 - "name": "spotify_accounts_user_id_users_xata_id_fk", 2499 - "tableFrom": "spotify_accounts", 2500 - "tableTo": "users", 2501 - "columnsFrom": [ 2502 - "user_id" 2503 - ], 2504 - "columnsTo": [ 2505 - "xata_id" 2506 - ], 2507 - "onDelete": "no action", 2508 - "onUpdate": "no action" 2509 - } 2510 - }, 2511 - "compositePrimaryKeys": {}, 2512 - "uniqueConstraints": {}, 2513 - "policies": {}, 2514 - "checkConstraints": {}, 2515 - "isRLSEnabled": false 2516 - }, 2517 - "public.spotify_apps": { 2518 - "name": "spotify_apps", 2519 - "schema": "", 2520 - "columns": { 2521 - "xata_id": { 2522 - "name": "xata_id", 2523 - "type": "text", 2524 - "primaryKey": true, 2525 - "notNull": true, 2526 - "default": "xata_id()" 2527 - }, 2528 - "xata_version": { 2529 - "name": "xata_version", 2530 - "type": "integer", 2531 - "primaryKey": false, 2532 - "notNull": false 2533 - }, 2534 - "spotify_app_id": { 2535 - "name": "spotify_app_id", 2536 - "type": "text", 2537 - "primaryKey": false, 2538 - "notNull": true 2539 - }, 2540 - "spotify_secret": { 2541 - "name": "spotify_secret", 2542 - "type": "text", 2543 - "primaryKey": false, 2544 - "notNull": true 2545 - }, 2546 - "xata_createdat": { 2547 - "name": "xata_createdat", 2548 - "type": "timestamp", 2549 - "primaryKey": false, 2550 - "notNull": true, 2551 - "default": "now()" 2552 - }, 2553 - "xata_updatedat": { 2554 - "name": "xata_updatedat", 2555 - "type": "timestamp", 2556 - "primaryKey": false, 2557 - "notNull": true, 2558 - "default": "now()" 2559 - } 2560 - }, 2561 - "indexes": {}, 2562 - "foreignKeys": {}, 2563 - "compositePrimaryKeys": {}, 2564 - "uniqueConstraints": { 2565 - "spotify_apps_spotify_app_id_unique": { 2566 - "name": "spotify_apps_spotify_app_id_unique", 2567 - "nullsNotDistinct": false, 2568 - "columns": [ 2569 - "spotify_app_id" 2570 - ] 2571 - } 2572 - }, 2573 - "policies": {}, 2574 - "checkConstraints": {}, 2575 - "isRLSEnabled": false 2576 - }, 2577 - "public.spotify_tokens": { 2578 - "name": "spotify_tokens", 2579 - "schema": "", 2580 - "columns": { 2581 - "xata_id": { 2582 - "name": "xata_id", 2583 - "type": "text", 2584 - "primaryKey": true, 2585 - "notNull": true, 2586 - "default": "xata_id()" 2587 - }, 2588 - "xata_version": { 2589 - "name": "xata_version", 2590 - "type": "integer", 2591 - "primaryKey": false, 2592 - "notNull": false 2593 - }, 2594 - "access_token": { 2595 - "name": "access_token", 2596 - "type": "text", 2597 - "primaryKey": false, 2598 - "notNull": true 2599 - }, 2600 - "refresh_token": { 2601 - "name": "refresh_token", 2602 - "type": "text", 2603 - "primaryKey": false, 2604 - "notNull": true 2605 - }, 2606 - "user_id": { 2607 - "name": "user_id", 2608 - "type": "text", 2609 - "primaryKey": false, 2610 - "notNull": true 2611 - }, 2612 - "spotify_app_id": { 2613 - "name": "spotify_app_id", 2614 - "type": "text", 2615 - "primaryKey": false, 2616 - "notNull": true 2617 - }, 2618 - "xata_createdat": { 2619 - "name": "xata_createdat", 2620 - "type": "timestamp", 2621 - "primaryKey": false, 2622 - "notNull": true, 2623 - "default": "now()" 2624 - }, 2625 - "xata_updatedat": { 2626 - "name": "xata_updatedat", 2627 - "type": "timestamp", 2628 - "primaryKey": false, 2629 - "notNull": true, 2630 - "default": "now()" 2631 - } 2632 - }, 2633 - "indexes": {}, 2634 - "foreignKeys": { 2635 - "spotify_tokens_user_id_users_xata_id_fk": { 2636 - "name": "spotify_tokens_user_id_users_xata_id_fk", 2637 - "tableFrom": "spotify_tokens", 2638 - "tableTo": "users", 2639 - "columnsFrom": [ 2640 - "user_id" 2641 - ], 2642 - "columnsTo": [ 2643 - "xata_id" 2644 - ], 2645 - "onDelete": "no action", 2646 - "onUpdate": "no action" 2647 - } 2648 - }, 2649 - "compositePrimaryKeys": {}, 2650 - "uniqueConstraints": {}, 2651 - "policies": {}, 2652 - "checkConstraints": {}, 2653 - "isRLSEnabled": false 2654 - }, 2655 - "public.tracks": { 2656 - "name": "tracks", 2657 - "schema": "", 2658 - "columns": { 2659 - "xata_id": { 2660 - "name": "xata_id", 2661 - "type": "text", 2662 - "primaryKey": true, 2663 - "notNull": true, 2664 - "default": "xata_id()" 2665 - }, 2666 - "title": { 2667 - "name": "title", 2668 - "type": "text", 2669 - "primaryKey": false, 2670 - "notNull": true 2671 - }, 2672 - "artist": { 2673 - "name": "artist", 2674 - "type": "text", 2675 - "primaryKey": false, 2676 - "notNull": true 2677 - }, 2678 - "album_artist": { 2679 - "name": "album_artist", 2680 - "type": "text", 2681 - "primaryKey": false, 2682 - "notNull": true 2683 - }, 2684 - "album_art": { 2685 - "name": "album_art", 2686 - "type": "text", 2687 - "primaryKey": false, 2688 - "notNull": false 2689 - }, 2690 - "album": { 2691 - "name": "album", 2692 - "type": "text", 2693 - "primaryKey": false, 2694 - "notNull": true 2695 - }, 2696 - "track_number": { 2697 - "name": "track_number", 2698 - "type": "integer", 2699 - "primaryKey": false, 2700 - "notNull": false 2701 - }, 2702 - "duration": { 2703 - "name": "duration", 2704 - "type": "integer", 2705 - "primaryKey": false, 2706 - "notNull": true 2707 - }, 2708 - "mb_id": { 2709 - "name": "mb_id", 2710 - "type": "text", 2711 - "primaryKey": false, 2712 - "notNull": false 2713 - }, 2714 - "youtube_link": { 2715 - "name": "youtube_link", 2716 - "type": "text", 2717 - "primaryKey": false, 2718 - "notNull": false 2719 - }, 2720 - "spotify_link": { 2721 - "name": "spotify_link", 2722 - "type": "text", 2723 - "primaryKey": false, 2724 - "notNull": false 2725 - }, 2726 - "apple_music_link": { 2727 - "name": "apple_music_link", 2728 - "type": "text", 2729 - "primaryKey": false, 2730 - "notNull": false 2731 - }, 2732 - "tidal_link": { 2733 - "name": "tidal_link", 2734 - "type": "text", 2735 - "primaryKey": false, 2736 - "notNull": false 2737 - }, 2738 - "sha256": { 2739 - "name": "sha256", 2740 - "type": "text", 2741 - "primaryKey": false, 2742 - "notNull": true 2743 - }, 2744 - "disc_number": { 2745 - "name": "disc_number", 2746 - "type": "integer", 2747 - "primaryKey": false, 2748 - "notNull": false 2749 - }, 2750 - "lyrics": { 2751 - "name": "lyrics", 2752 - "type": "text", 2753 - "primaryKey": false, 2754 - "notNull": false 2755 - }, 2756 - "composer": { 2757 - "name": "composer", 2758 - "type": "text", 2759 - "primaryKey": false, 2760 - "notNull": false 2761 - }, 2762 - "genre": { 2763 - "name": "genre", 2764 - "type": "text", 2765 - "primaryKey": false, 2766 - "notNull": false 2767 - }, 2768 - "label": { 2769 - "name": "label", 2770 - "type": "text", 2771 - "primaryKey": false, 2772 - "notNull": false 2773 - }, 2774 - "copyright_message": { 2775 - "name": "copyright_message", 2776 - "type": "text", 2777 - "primaryKey": false, 2778 - "notNull": false 2779 - }, 2780 - "uri": { 2781 - "name": "uri", 2782 - "type": "text", 2783 - "primaryKey": false, 2784 - "notNull": false 2785 - }, 2786 - "album_uri": { 2787 - "name": "album_uri", 2788 - "type": "text", 2789 - "primaryKey": false, 2790 - "notNull": false 2791 - }, 2792 - "artist_uri": { 2793 - "name": "artist_uri", 2794 - "type": "text", 2795 - "primaryKey": false, 2796 - "notNull": false 2797 - }, 2798 - "xata_createdat": { 2799 - "name": "xata_createdat", 2800 - "type": "timestamp", 2801 - "primaryKey": false, 2802 - "notNull": true, 2803 - "default": "now()" 2804 - }, 2805 - "xata_updatedat": { 2806 - "name": "xata_updatedat", 2807 - "type": "timestamp", 2808 - "primaryKey": false, 2809 - "notNull": true, 2810 - "default": "now()" 2811 - }, 2812 - "xata_version": { 2813 - "name": "xata_version", 2814 - "type": "integer", 2815 - "primaryKey": false, 2816 - "notNull": false 2817 - } 2818 - }, 2819 - "indexes": {}, 2820 - "foreignKeys": {}, 2821 - "compositePrimaryKeys": {}, 2822 - "uniqueConstraints": { 2823 - "tracks_mb_id_unique": { 2824 - "name": "tracks_mb_id_unique", 2825 - "nullsNotDistinct": false, 2826 - "columns": [ 2827 - "mb_id" 2828 - ] 2829 - }, 2830 - "tracks_youtube_link_unique": { 2831 - "name": "tracks_youtube_link_unique", 2832 - "nullsNotDistinct": false, 2833 - "columns": [ 2834 - "youtube_link" 2835 - ] 2836 - }, 2837 - "tracks_spotify_link_unique": { 2838 - "name": "tracks_spotify_link_unique", 2839 - "nullsNotDistinct": false, 2840 - "columns": [ 2841 - "spotify_link" 2842 - ] 2843 - }, 2844 - "tracks_apple_music_link_unique": { 2845 - "name": "tracks_apple_music_link_unique", 2846 - "nullsNotDistinct": false, 2847 - "columns": [ 2848 - "apple_music_link" 2849 - ] 2850 - }, 2851 - "tracks_tidal_link_unique": { 2852 - "name": "tracks_tidal_link_unique", 2853 - "nullsNotDistinct": false, 2854 - "columns": [ 2855 - "tidal_link" 2856 - ] 2857 - }, 2858 - "tracks_sha256_unique": { 2859 - "name": "tracks_sha256_unique", 2860 - "nullsNotDistinct": false, 2861 - "columns": [ 2862 - "sha256" 2863 - ] 2864 - }, 2865 - "tracks_uri_unique": { 2866 - "name": "tracks_uri_unique", 2867 - "nullsNotDistinct": false, 2868 - "columns": [ 2869 - "uri" 2870 - ] 2871 - } 2872 - }, 2873 - "policies": {}, 2874 - "checkConstraints": {}, 2875 - "isRLSEnabled": false 2876 - }, 2877 - "public.user_albums": { 2878 - "name": "user_albums", 2879 - "schema": "", 2880 - "columns": { 2881 - "xata_id": { 2882 - "name": "xata_id", 2883 - "type": "text", 2884 - "primaryKey": true, 2885 - "notNull": true, 2886 - "default": "xata_id()" 2887 - }, 2888 - "user_id": { 2889 - "name": "user_id", 2890 - "type": "text", 2891 - "primaryKey": false, 2892 - "notNull": true 2893 - }, 2894 - "album_id": { 2895 - "name": "album_id", 2896 - "type": "text", 2897 - "primaryKey": false, 2898 - "notNull": true 2899 - }, 2900 - "xata_createdat": { 2901 - "name": "xata_createdat", 2902 - "type": "timestamp", 2903 - "primaryKey": false, 2904 - "notNull": true, 2905 - "default": "now()" 2906 - }, 2907 - "xata_updatedat": { 2908 - "name": "xata_updatedat", 2909 - "type": "timestamp", 2910 - "primaryKey": false, 2911 - "notNull": true, 2912 - "default": "now()" 2913 - }, 2914 - "xata_version": { 2915 - "name": "xata_version", 2916 - "type": "integer", 2917 - "primaryKey": false, 2918 - "notNull": false 2919 - }, 2920 - "scrobbles": { 2921 - "name": "scrobbles", 2922 - "type": "integer", 2923 - "primaryKey": false, 2924 - "notNull": false 2925 - }, 2926 - "uri": { 2927 - "name": "uri", 2928 - "type": "text", 2929 - "primaryKey": false, 2930 - "notNull": true 2931 - } 2932 - }, 2933 - "indexes": {}, 2934 - "foreignKeys": { 2935 - "user_albums_user_id_users_xata_id_fk": { 2936 - "name": "user_albums_user_id_users_xata_id_fk", 2937 - "tableFrom": "user_albums", 2938 - "tableTo": "users", 2939 - "columnsFrom": [ 2940 - "user_id" 2941 - ], 2942 - "columnsTo": [ 2943 - "xata_id" 2944 - ], 2945 - "onDelete": "no action", 2946 - "onUpdate": "no action" 2947 - }, 2948 - "user_albums_album_id_albums_xata_id_fk": { 2949 - "name": "user_albums_album_id_albums_xata_id_fk", 2950 - "tableFrom": "user_albums", 2951 - "tableTo": "albums", 2952 - "columnsFrom": [ 2953 - "album_id" 2954 - ], 2955 - "columnsTo": [ 2956 - "xata_id" 2957 - ], 2958 - "onDelete": "no action", 2959 - "onUpdate": "no action" 2960 - } 2961 - }, 2962 - "compositePrimaryKeys": {}, 2963 - "uniqueConstraints": { 2964 - "user_albums_uri_unique": { 2965 - "name": "user_albums_uri_unique", 2966 - "nullsNotDistinct": false, 2967 - "columns": [ 2968 - "uri" 2969 - ] 2970 - } 2971 - }, 2972 - "policies": {}, 2973 - "checkConstraints": {}, 2974 - "isRLSEnabled": false 2975 - }, 2976 - "public.user_artists": { 2977 - "name": "user_artists", 2978 - "schema": "", 2979 - "columns": { 2980 - "xata_id": { 2981 - "name": "xata_id", 2982 - "type": "text", 2983 - "primaryKey": true, 2984 - "notNull": true, 2985 - "default": "xata_id()" 2986 - }, 2987 - "user_id": { 2988 - "name": "user_id", 2989 - "type": "text", 2990 - "primaryKey": false, 2991 - "notNull": true 2992 - }, 2993 - "artist_id": { 2994 - "name": "artist_id", 2995 - "type": "text", 2996 - "primaryKey": false, 2997 - "notNull": true 2998 - }, 2999 - "xata_createdat": { 3000 - "name": "xata_createdat", 3001 - "type": "timestamp", 3002 - "primaryKey": false, 3003 - "notNull": true, 3004 - "default": "now()" 3005 - }, 3006 - "xata_updatedat": { 3007 - "name": "xata_updatedat", 3008 - "type": "timestamp", 3009 - "primaryKey": false, 3010 - "notNull": true, 3011 - "default": "now()" 3012 - }, 3013 - "xata_version": { 3014 - "name": "xata_version", 3015 - "type": "integer", 3016 - "primaryKey": false, 3017 - "notNull": false 3018 - }, 3019 - "scrobbles": { 3020 - "name": "scrobbles", 3021 - "type": "integer", 3022 - "primaryKey": false, 3023 - "notNull": false 3024 - }, 3025 - "uri": { 3026 - "name": "uri", 3027 - "type": "text", 3028 - "primaryKey": false, 3029 - "notNull": true 3030 - } 3031 - }, 3032 - "indexes": {}, 3033 - "foreignKeys": { 3034 - "user_artists_user_id_users_xata_id_fk": { 3035 - "name": "user_artists_user_id_users_xata_id_fk", 3036 - "tableFrom": "user_artists", 3037 - "tableTo": "users", 3038 - "columnsFrom": [ 3039 - "user_id" 3040 - ], 3041 - "columnsTo": [ 3042 - "xata_id" 3043 - ], 3044 - "onDelete": "no action", 3045 - "onUpdate": "no action" 3046 - }, 3047 - "user_artists_artist_id_artists_xata_id_fk": { 3048 - "name": "user_artists_artist_id_artists_xata_id_fk", 3049 - "tableFrom": "user_artists", 3050 - "tableTo": "artists", 3051 - "columnsFrom": [ 3052 - "artist_id" 3053 - ], 3054 - "columnsTo": [ 3055 - "xata_id" 3056 - ], 3057 - "onDelete": "no action", 3058 - "onUpdate": "no action" 3059 - } 3060 - }, 3061 - "compositePrimaryKeys": {}, 3062 - "uniqueConstraints": { 3063 - "user_artists_uri_unique": { 3064 - "name": "user_artists_uri_unique", 3065 - "nullsNotDistinct": false, 3066 - "columns": [ 3067 - "uri" 3068 - ] 3069 - } 3070 - }, 3071 - "policies": {}, 3072 - "checkConstraints": {}, 3073 - "isRLSEnabled": false 3074 - }, 3075 - "public.user_playlists": { 3076 - "name": "user_playlists", 3077 - "schema": "", 3078 - "columns": { 3079 - "xata_id": { 3080 - "name": "xata_id", 3081 - "type": "text", 3082 - "primaryKey": true, 3083 - "notNull": true, 3084 - "default": "xata_id()" 3085 - }, 3086 - "user_id": { 3087 - "name": "user_id", 3088 - "type": "text", 3089 - "primaryKey": false, 3090 - "notNull": true 3091 - }, 3092 - "playlist_id": { 3093 - "name": "playlist_id", 3094 - "type": "text", 3095 - "primaryKey": false, 3096 - "notNull": true 3097 - }, 3098 - "xata_createdat": { 3099 - "name": "xata_createdat", 3100 - "type": "timestamp", 3101 - "primaryKey": false, 3102 - "notNull": true, 3103 - "default": "now()" 3104 - }, 3105 - "uri": { 3106 - "name": "uri", 3107 - "type": "text", 3108 - "primaryKey": false, 3109 - "notNull": false 3110 - } 3111 - }, 3112 - "indexes": {}, 3113 - "foreignKeys": { 3114 - "user_playlists_user_id_users_xata_id_fk": { 3115 - "name": "user_playlists_user_id_users_xata_id_fk", 3116 - "tableFrom": "user_playlists", 3117 - "tableTo": "users", 3118 - "columnsFrom": [ 3119 - "user_id" 3120 - ], 3121 - "columnsTo": [ 3122 - "xata_id" 3123 - ], 3124 - "onDelete": "no action", 3125 - "onUpdate": "no action" 3126 - }, 3127 - "user_playlists_playlist_id_playlists_xata_id_fk": { 3128 - "name": "user_playlists_playlist_id_playlists_xata_id_fk", 3129 - "tableFrom": "user_playlists", 3130 - "tableTo": "playlists", 3131 - "columnsFrom": [ 3132 - "playlist_id" 3133 - ], 3134 - "columnsTo": [ 3135 - "xata_id" 3136 - ], 3137 - "onDelete": "no action", 3138 - "onUpdate": "no action" 3139 - } 3140 - }, 3141 - "compositePrimaryKeys": {}, 3142 - "uniqueConstraints": { 3143 - "user_playlists_uri_unique": { 3144 - "name": "user_playlists_uri_unique", 3145 - "nullsNotDistinct": false, 3146 - "columns": [ 3147 - "uri" 3148 - ] 3149 - } 3150 - }, 3151 - "policies": {}, 3152 - "checkConstraints": {}, 3153 - "isRLSEnabled": false 3154 - }, 3155 - "public.user_tracks": { 3156 - "name": "user_tracks", 3157 - "schema": "", 3158 - "columns": { 3159 - "xata_id": { 3160 - "name": "xata_id", 3161 - "type": "text", 3162 - "primaryKey": true, 3163 - "notNull": true, 3164 - "default": "xata_id()" 3165 - }, 3166 - "user_id": { 3167 - "name": "user_id", 3168 - "type": "text", 3169 - "primaryKey": false, 3170 - "notNull": true 3171 - }, 3172 - "track_id": { 3173 - "name": "track_id", 3174 - "type": "text", 3175 - "primaryKey": false, 3176 - "notNull": true 3177 - }, 3178 - "xata_createdat": { 3179 - "name": "xata_createdat", 3180 - "type": "timestamp", 3181 - "primaryKey": false, 3182 - "notNull": true, 3183 - "default": "now()" 3184 - }, 3185 - "xata_updatedat": { 3186 - "name": "xata_updatedat", 3187 - "type": "timestamp", 3188 - "primaryKey": false, 3189 - "notNull": true, 3190 - "default": "now()" 3191 - }, 3192 - "xata_version": { 3193 - "name": "xata_version", 3194 - "type": "integer", 3195 - "primaryKey": false, 3196 - "notNull": false 3197 - }, 3198 - "uri": { 3199 - "name": "uri", 3200 - "type": "text", 3201 - "primaryKey": false, 3202 - "notNull": true 3203 - }, 3204 - "scrobbles": { 3205 - "name": "scrobbles", 3206 - "type": "integer", 3207 - "primaryKey": false, 3208 - "notNull": false 3209 - } 3210 - }, 3211 - "indexes": {}, 3212 - "foreignKeys": { 3213 - "user_tracks_user_id_users_xata_id_fk": { 3214 - "name": "user_tracks_user_id_users_xata_id_fk", 3215 - "tableFrom": "user_tracks", 3216 - "tableTo": "users", 3217 - "columnsFrom": [ 3218 - "user_id" 3219 - ], 3220 - "columnsTo": [ 3221 - "xata_id" 3222 - ], 3223 - "onDelete": "no action", 3224 - "onUpdate": "no action" 3225 - }, 3226 - "user_tracks_track_id_tracks_xata_id_fk": { 3227 - "name": "user_tracks_track_id_tracks_xata_id_fk", 3228 - "tableFrom": "user_tracks", 3229 - "tableTo": "tracks", 3230 - "columnsFrom": [ 3231 - "track_id" 3232 - ], 3233 - "columnsTo": [ 3234 - "xata_id" 3235 - ], 3236 - "onDelete": "no action", 3237 - "onUpdate": "no action" 3238 - } 3239 - }, 3240 - "compositePrimaryKeys": {}, 3241 - "uniqueConstraints": { 3242 - "user_tracks_uri_unique": { 3243 - "name": "user_tracks_uri_unique", 3244 - "nullsNotDistinct": false, 3245 - "columns": [ 3246 - "uri" 3247 - ] 3248 - } 3249 - }, 3250 - "policies": {}, 3251 - "checkConstraints": {}, 3252 - "isRLSEnabled": false 3253 - }, 3254 - "public.users": { 3255 - "name": "users", 3256 - "schema": "", 3257 - "columns": { 3258 - "xata_id": { 3259 - "name": "xata_id", 3260 - "type": "text", 3261 - "primaryKey": true, 3262 - "notNull": true, 3263 - "default": "xata_id()" 3264 - }, 3265 - "did": { 3266 - "name": "did", 3267 - "type": "text", 3268 - "primaryKey": false, 3269 - "notNull": true 3270 - }, 3271 - "display_name": { 3272 - "name": "display_name", 3273 - "type": "text", 3274 - "primaryKey": false, 3275 - "notNull": false 3276 - }, 3277 - "handle": { 3278 - "name": "handle", 3279 - "type": "text", 3280 - "primaryKey": false, 3281 - "notNull": true 3282 - }, 3283 - "avatar": { 3284 - "name": "avatar", 3285 - "type": "text", 3286 - "primaryKey": false, 3287 - "notNull": true 3288 - }, 3289 - "xata_createdat": { 3290 - "name": "xata_createdat", 3291 - "type": "timestamp", 3292 - "primaryKey": false, 3293 - "notNull": true, 3294 - "default": "now()" 3295 - }, 3296 - "xata_updatedat": { 3297 - "name": "xata_updatedat", 3298 - "type": "timestamp", 3299 - "primaryKey": false, 3300 - "notNull": true, 3301 - "default": "now()" 3302 - }, 3303 - "xata_version": { 3304 - "name": "xata_version", 3305 - "type": "integer", 3306 - "primaryKey": false, 3307 - "notNull": false 3308 - } 3309 - }, 3310 - "indexes": {}, 3311 - "foreignKeys": {}, 3312 - "compositePrimaryKeys": {}, 3313 - "uniqueConstraints": { 3314 - "users_did_unique": { 3315 - "name": "users_did_unique", 3316 - "nullsNotDistinct": false, 3317 - "columns": [ 3318 - "did" 3319 - ] 3320 - }, 3321 - "users_handle_unique": { 3322 - "name": "users_handle_unique", 3323 - "nullsNotDistinct": false, 3324 - "columns": [ 3325 - "handle" 3326 - ] 3327 - } 3328 - }, 3329 - "policies": {}, 3330 - "checkConstraints": {}, 3331 - "isRLSEnabled": false 3332 - }, 3333 - "public.webscrobblers": { 3334 - "name": "webscrobblers", 3335 - "schema": "", 3336 - "columns": { 3337 - "xata_id": { 3338 - "name": "xata_id", 3339 - "type": "text", 3340 - "primaryKey": true, 3341 - "notNull": true, 3342 - "default": "xata_id()" 3343 - }, 3344 - "name": { 3345 - "name": "name", 3346 - "type": "text", 3347 - "primaryKey": false, 3348 - "notNull": true 3349 - }, 3350 - "uuid": { 3351 - "name": "uuid", 3352 - "type": "text", 3353 - "primaryKey": false, 3354 - "notNull": true 3355 - }, 3356 - "description": { 3357 - "name": "description", 3358 - "type": "text", 3359 - "primaryKey": false, 3360 - "notNull": false 3361 - }, 3362 - "enabled": { 3363 - "name": "enabled", 3364 - "type": "boolean", 3365 - "primaryKey": false, 3366 - "notNull": true, 3367 - "default": true 3368 - }, 3369 - "user_id": { 3370 - "name": "user_id", 3371 - "type": "text", 3372 - "primaryKey": false, 3373 - "notNull": true 3374 - }, 3375 - "xata_createdat": { 3376 - "name": "xata_createdat", 3377 - "type": "timestamp", 3378 - "primaryKey": false, 3379 - "notNull": true, 3380 - "default": "now()" 3381 - }, 3382 - "xata_updatedat": { 3383 - "name": "xata_updatedat", 3384 - "type": "timestamp", 3385 - "primaryKey": false, 3386 - "notNull": true, 3387 - "default": "now()" 3388 - } 3389 - }, 3390 - "indexes": {}, 3391 - "foreignKeys": { 3392 - "webscrobblers_user_id_users_xata_id_fk": { 3393 - "name": "webscrobblers_user_id_users_xata_id_fk", 3394 - "tableFrom": "webscrobblers", 3395 - "tableTo": "users", 3396 - "columnsFrom": [ 3397 - "user_id" 3398 - ], 3399 - "columnsTo": [ 3400 - "xata_id" 3401 - ], 3402 - "onDelete": "no action", 3403 - "onUpdate": "no action" 3404 - } 3405 - }, 3406 - "compositePrimaryKeys": {}, 3407 - "uniqueConstraints": {}, 3408 - "policies": {}, 3409 - "checkConstraints": {}, 3410 - "isRLSEnabled": false 3411 - } 3412 - }, 3413 - "enums": {}, 3414 - "schemas": {}, 3415 - "sequences": {}, 3416 - "roles": {}, 3417 - "policies": {}, 3418 - "views": {}, 3419 - "_meta": { 3420 - "columns": {}, 3421 - "schemas": {}, 3422 - "tables": {} 3423 - } 3424 - }
-7
apps/api/drizzle/meta/_journal.json
··· 15 15 "when": 1766863536746, 16 16 "tag": "0001_parched_random", 17 17 "breakpoints": true 18 - }, 19 - { 20 - "idx": 2, 21 - "version": "7", 22 - "when": 1767252686993, 23 - "tag": "0002_robust_wong", 24 - "breakpoints": true 25 18 } 26 19 ] 27 20 }
-106
apps/api/lexicons/actor/defs.json
··· 73 73 "format": "datetime" 74 74 } 75 75 } 76 - }, 77 - "neighbourViewBasic": { 78 - "type": "object", 79 - "properties": { 80 - "userId": { 81 - "type": "string" 82 - }, 83 - "did": { 84 - "type": "string" 85 - }, 86 - "handle": { 87 - "type": "string" 88 - }, 89 - "displayName": { 90 - "type": "string" 91 - }, 92 - "avatar": { 93 - "type": "string", 94 - "description": "The URL of the actor's avatar image.", 95 - "format": "uri" 96 - }, 97 - "sharedArtistsCount": { 98 - "type": "integer", 99 - "description": "The number of artists shared with the actor." 100 - }, 101 - "similarityScore": { 102 - "type": "integer", 103 - "description": "The similarity score with the actor." 104 - }, 105 - "topSharedArtistNames": { 106 - "type": "array", 107 - "description": "The top shared artist names with the actor.", 108 - "items": { 109 - "type": "string" 110 - } 111 - }, 112 - "topSharedArtistsDetails": { 113 - "type": "array", 114 - "description": "The top shared artist details with the actor.", 115 - "items": { 116 - "type": "ref", 117 - "ref": "app.rocksky.artist.defs#artistViewBasic" 118 - } 119 - } 120 - } 121 - }, 122 - "compatibilityViewBasic": { 123 - "type": "object", 124 - "properties": { 125 - "compatibilityLevel": { 126 - "type": "integer" 127 - }, 128 - "compatibilityPercentage": { 129 - "type": "integer" 130 - }, 131 - "sharedArtists": { 132 - "type": "integer" 133 - }, 134 - "topSharedArtistNames": { 135 - "type": "array", 136 - "items": { 137 - "type": "string" 138 - } 139 - }, 140 - "topSharedDetailedArtists": { 141 - "type": "array", 142 - "items": { 143 - "type": "ref", 144 - "ref": "app.rocksky.actor.defs#artistViewBasic" 145 - } 146 - }, 147 - "user1ArtistCount": { 148 - "type": "integer" 149 - }, 150 - "user2ArtistCount": { 151 - "type": "integer" 152 - } 153 - } 154 - }, 155 - "artistViewBasic": { 156 - "type": "object", 157 - "properties": { 158 - "id": { 159 - "type": "string" 160 - }, 161 - "name": { 162 - "type": "string" 163 - }, 164 - "picture": { 165 - "type": "string", 166 - "format": "uri" 167 - }, 168 - "uri": { 169 - "type": "string", 170 - "format": "at-uri" 171 - }, 172 - "user1Rank": { 173 - "type": "integer" 174 - }, 175 - "user2Rank": { 176 - "type": "integer" 177 - }, 178 - "weight": { 179 - "type": "integer" 180 - } 181 - } 182 76 } 183 77 } 184 78 }
-35
apps/api/lexicons/actor/getActorCompatibility.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.rocksky.actor.getActorCompatibility", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "Get compatibility for an actor", 8 - "parameters": { 9 - "type": "params", 10 - "required": [ 11 - "did" 12 - ], 13 - "properties": { 14 - "did": { 15 - "type": "string", 16 - "description": "DID or handle to get compatibility for", 17 - "format": "at-identifier" 18 - } 19 - } 20 - }, 21 - "output": { 22 - "encoding": "application/json", 23 - "schema": { 24 - "type": "object", 25 - "properties": { 26 - "compatibility": { 27 - "type": "ref", 28 - "ref": "app.rocksky.actor.defs#compatibilityViewBasic" 29 - } 30 - } 31 - } 32 - } 33 - } 34 - } 35 - }
-38
apps/api/lexicons/actor/getActorNeighbours.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.rocksky.actor.getActorNeighbours", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "Get neighbours for an actor", 8 - "parameters": { 9 - "type": "params", 10 - "required": [ 11 - "did" 12 - ], 13 - "properties": { 14 - "did": { 15 - "type": "string", 16 - "description": "The DID or handle of the actor", 17 - "format": "at-identifier" 18 - } 19 - } 20 - }, 21 - "output": { 22 - "encoding": "application/json", 23 - "schema": { 24 - "type": "object", 25 - "properties": { 26 - "neighbours": { 27 - "type": "array", 28 - "items": { 29 - "type": "ref", 30 - "ref": "app.rocksky.actor.defs#neighbourViewBasic" 31 - } 32 - } 33 - } 34 - } 35 - } 36 - } 37 - } 38 - }
-4
apps/api/lexicons/feed/defs.json
··· 167 167 "type": "ref", 168 168 "ref": "app.rocksky.feed.defs#feedItemView" 169 169 } 170 - }, 171 - "cursor": { 172 - "type": "string", 173 - "description": "The pagination cursor for the next set of results." 174 170 } 175 171 } 176 172 }
-45
apps/api/lexicons/graph/defs.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.rocksky.graph.defs", 4 - "defs": { 5 - "notFoundActor": { 6 - "type": "object", 7 - "description": "indicates that a handle or DID could not be resolved", 8 - "required": [ 9 - "actor", 10 - "notFound" 11 - ], 12 - "properties": { 13 - "actor": { 14 - "type": "string", 15 - "format": "at-identifier" 16 - }, 17 - "notFound": { 18 - "type": "boolean" 19 - } 20 - } 21 - }, 22 - "relationship": { 23 - "type": "object", 24 - "required": [ 25 - "did" 26 - ], 27 - "properties": { 28 - "did": { 29 - "type": "string", 30 - "format": "did" 31 - }, 32 - "following": { 33 - "type": "string", 34 - "description": "if the actor follows this DID, this is the AT-URI of the follow record", 35 - "format": "at-uri" 36 - }, 37 - "followedBy": { 38 - "type": "string", 39 - "description": "if the actor is followed by this DID, contains the AT-URI of the follow record", 40 - "format": "at-uri" 41 - } 42 - } 43 - } 44 - } 45 - }
-32
apps/api/lexicons/graph/follow.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.rocksky.graph.follow", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "Record declaring a social 'follow' relationship of another account.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": [ 12 - "createdAt", 13 - "subject" 14 - ], 15 - "properties": { 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime" 19 - }, 20 - "subject": { 21 - "type": "string", 22 - "format": "did" 23 - }, 24 - "via": { 25 - "type": "ref", 26 - "ref": "com.atproto.repo.strongRef" 27 - } 28 - } 29 - } 30 - } 31 - } 32 - }
-49
apps/api/lexicons/graph/followAccount.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.rocksky.graph.followAccount", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Creates a 'follow' relationship from the authenticated account to a specified account.", 8 - "parameters": { 9 - "type": "params", 10 - "required": [ 11 - "account" 12 - ], 13 - "properties": { 14 - "account": { 15 - "type": "string", 16 - "format": "at-identifier" 17 - } 18 - } 19 - }, 20 - "output": { 21 - "encoding": "application/json", 22 - "schema": { 23 - "type": "object", 24 - "required": [ 25 - "subject", 26 - "followers" 27 - ], 28 - "properties": { 29 - "subject": { 30 - "type": "ref", 31 - "ref": "app.rocksky.actor.defs#profileViewBasic" 32 - }, 33 - "followers": { 34 - "type": "array", 35 - "items": { 36 - "type": "ref", 37 - "ref": "app.rocksky.actor.defs#profileViewBasic" 38 - } 39 - }, 40 - "cursor": { 41 - "type": "string", 42 - "description": "A cursor value to pass to subsequent calls to get the next page of results." 43 - } 44 - } 45 - } 46 - } 47 - } 48 - } 49 - }
-70
apps/api/lexicons/graph/getFollowers.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.rocksky.graph.getFollowers", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "Enumerates accounts which follow a specified account (actor).", 8 - "parameters": { 9 - "type": "params", 10 - "required": [ 11 - "actor" 12 - ], 13 - "properties": { 14 - "actor": { 15 - "type": "string", 16 - "format": "at-identifier" 17 - }, 18 - "limit": { 19 - "type": "integer", 20 - "maximum": 100, 21 - "minimum": 1, 22 - "default": 50 23 - }, 24 - "dids": { 25 - "type": "array", 26 - "description": "If provided, filters the followers to only include those with DIDs in this list.", 27 - "items": { 28 - "type": "string", 29 - "format": "did" 30 - } 31 - }, 32 - "cursor": { 33 - "type": "string" 34 - } 35 - } 36 - }, 37 - "output": { 38 - "encoding": "application/json", 39 - "schema": { 40 - "type": "object", 41 - "required": [ 42 - "subject", 43 - "followers" 44 - ], 45 - "properties": { 46 - "subject": { 47 - "type": "ref", 48 - "ref": "app.rocksky.actor.defs#profileViewBasic" 49 - }, 50 - "followers": { 51 - "type": "array", 52 - "items": { 53 - "type": "ref", 54 - "ref": "app.rocksky.actor.defs#profileViewBasic" 55 - } 56 - }, 57 - "cursor": { 58 - "type": "string", 59 - "description": "A cursor value to pass to subsequent calls to get the next page of results." 60 - }, 61 - "count": { 62 - "type": "integer", 63 - "description": "The total number of followers." 64 - } 65 - } 66 - } 67 - } 68 - } 69 - } 70 - }
-70
apps/api/lexicons/graph/getFollows.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.rocksky.graph.getFollows", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "Enumerates accounts which a specified account (actor) follows.", 8 - "parameters": { 9 - "type": "params", 10 - "required": [ 11 - "actor" 12 - ], 13 - "properties": { 14 - "actor": { 15 - "type": "string", 16 - "format": "at-identifier" 17 - }, 18 - "limit": { 19 - "type": "integer", 20 - "maximum": 100, 21 - "minimum": 1, 22 - "default": 50 23 - }, 24 - "dids": { 25 - "type": "array", 26 - "description": "If provided, filters the follows to only include those with DIDs in this list.", 27 - "items": { 28 - "type": "string", 29 - "format": "did" 30 - } 31 - }, 32 - "cursor": { 33 - "type": "string" 34 - } 35 - } 36 - }, 37 - "output": { 38 - "encoding": "application/json", 39 - "schema": { 40 - "type": "object", 41 - "required": [ 42 - "subject", 43 - "follows" 44 - ], 45 - "properties": { 46 - "subject": { 47 - "type": "ref", 48 - "ref": "app.rocksky.actor.defs#profileViewBasic" 49 - }, 50 - "follows": { 51 - "type": "array", 52 - "items": { 53 - "type": "ref", 54 - "ref": "app.rocksky.actor.defs#profileViewBasic" 55 - } 56 - }, 57 - "cursor": { 58 - "type": "string", 59 - "description": "A cursor value to pass to subsequent calls to get the next page of results." 60 - }, 61 - "count": { 62 - "type": "integer", 63 - "description": "The total number of follows." 64 - } 65 - } 66 - } 67 - } 68 - } 69 - } 70 - }
-58
apps/api/lexicons/graph/getKnownFollowers.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.rocksky.graph.getKnownFollowers", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "Enumerates accounts which follow a specified account (actor) and are followed by the viewer.", 8 - "parameters": { 9 - "type": "params", 10 - "required": [ 11 - "actor" 12 - ], 13 - "properties": { 14 - "actor": { 15 - "type": "string", 16 - "format": "at-identifier" 17 - }, 18 - "limit": { 19 - "type": "integer", 20 - "maximum": 100, 21 - "minimum": 1, 22 - "default": 50 23 - }, 24 - "cursor": { 25 - "type": "string" 26 - } 27 - } 28 - }, 29 - "output": { 30 - "encoding": "application/json", 31 - "schema": { 32 - "type": "object", 33 - "required": [ 34 - "subject", 35 - "followers" 36 - ], 37 - "properties": { 38 - "subject": { 39 - "type": "ref", 40 - "ref": "app.rocksky.actor.defs#profileViewBasic" 41 - }, 42 - "followers": { 43 - "type": "array", 44 - "items": { 45 - "type": "ref", 46 - "ref": "app.rocksky.actor.defs#profileViewBasic" 47 - } 48 - }, 49 - "cursor": { 50 - "type": "string", 51 - "description": "A cursor value to pass to subsequent calls to get the next page of results." 52 - } 53 - } 54 - } 55 - } 56 - } 57 - } 58 - }
-49
apps/api/lexicons/graph/unfollowAccount.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.rocksky.graph.unfollowAccount", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Removes a 'follow' relationship from the authenticated account to a specified account.", 8 - "parameters": { 9 - "type": "params", 10 - "required": [ 11 - "account" 12 - ], 13 - "properties": { 14 - "account": { 15 - "type": "string", 16 - "format": "at-identifier" 17 - } 18 - } 19 - }, 20 - "output": { 21 - "encoding": "application/json", 22 - "schema": { 23 - "type": "object", 24 - "required": [ 25 - "subject", 26 - "followers" 27 - ], 28 - "properties": { 29 - "subject": { 30 - "type": "ref", 31 - "ref": "app.rocksky.actor.defs#profileViewBasic" 32 - }, 33 - "followers": { 34 - "type": "array", 35 - "items": { 36 - "type": "ref", 37 - "ref": "app.rocksky.actor.defs#profileViewBasic" 38 - } 39 - }, 40 - "cursor": { 41 - "type": "string", 42 - "description": "A cursor value to pass to subsequent calls to get the next page of results." 43 - } 44 - } 45 - } 46 - } 47 - } 48 - } 49 - }
-4
apps/api/lexicons/scrobble/getScrobbles.json
··· 13 13 "description": "The DID or handle of the actor", 14 14 "format": "at-identifier" 15 15 }, 16 - "following": { 17 - "type": "boolean", 18 - "description": "If true, only return scrobbles from actors the viewer is following." 19 - }, 20 16 "limit": { 21 17 "type": "integer", 22 18 "description": "The maximum number of scrobbles to return",
+1 -2
apps/api/package.json
··· 27 27 "lint": "biome lint src", 28 28 "feed": "tsx ./src/scripts/feed.ts", 29 29 "dedup": "bun ./src/scripts/dedup.ts", 30 - "seed:feed": "tsx ./src/scripts/seed-feed.ts", 31 - "likes": "tsx ./src/scripts/likes.ts" 30 + "seed:feed": "tsx ./src/scripts/seed-feed.ts" 32 31 }, 33 32 "dependencies": { 34 33 "@atproto/api": "^0.13.31",
+3 -120
apps/api/pkl/defs/actor/defs.pkl
··· 1 - amends "../../schema/lexicon.pkl" 1 + amends "../../schema/lexicon.pkl" 2 2 3 3 lexicon = 1 4 4 id = "app.rocksky.actor.defs" ··· 43 43 format = "datetime" 44 44 description = "The date and time when the actor was last updated." 45 45 } 46 + 46 47 } 47 48 } 48 49 ["profileViewBasic"] { ··· 87 88 } 88 89 } 89 90 } 90 - ["neighbourViewBasic"] { 91 - type = "object" 92 - properties { 93 - ["userId"] = new StringType { 94 - type = "string" 95 - } 96 - 97 - ["did"] = new StringType { 98 - type = "string" 99 - } 100 - 101 - ["handle"] = new StringType { 102 - type = "string" 103 - } 104 - 105 - ["displayName"] = new StringType { 106 - type = "string" 107 - } 108 - 109 - ["avatar"] = new StringType { 110 - type = "string" 111 - format = "uri" 112 - description = "The URL of the actor's avatar image." 113 - } 114 - 115 - ["sharedArtistsCount"] = new IntegerType { 116 - type = "integer" 117 - description = "The number of artists shared with the actor." 118 - } 119 - 120 - ["similarityScore"] = new IntegerType { 121 - type = "integer" 122 - description = "The similarity score with the actor." 123 - } 124 - 125 - ["topSharedArtistNames"] = new Array { 126 - type = "array" 127 - items = new StringType { 128 - type = "string" 129 - } 130 - description = "The top shared artist names with the actor." 131 - } 132 - 133 - ["topSharedArtistsDetails"] = new Array { 134 - type = "array" 135 - items = new Ref { 136 - ref = "app.rocksky.artist.defs#artistViewBasic" 137 - } 138 - description = "The top shared artist details with the actor." 139 - } 140 - } 141 - } 142 - ["compatibilityViewBasic"] { 143 - type = "object" 144 - properties { 145 - ["compatibilityLevel"] = new IntegerType { 146 - type = "integer" 147 - } 148 - ["compatibilityPercentage"] = new IntegerType { 149 - type = "integer" 150 - } 151 - ["sharedArtists"] = new IntegerType { 152 - type = "integer" 153 - } 154 - ["topSharedArtistNames"] = new Array { 155 - type = "array" 156 - items = new StringType { 157 - type = "string" 158 - } 159 - } 160 - ["topSharedDetailedArtists"] = new Array { 161 - type = "array" 162 - items = new Ref { 163 - ref = "app.rocksky.actor.defs#artistViewBasic" 164 - } 165 - } 166 - ["user1ArtistCount"] = new IntegerType { 167 - type = "integer" 168 - } 169 - ["user2ArtistCount"] = new IntegerType { 170 - type = "integer" 171 - } 172 - } 173 - } 174 - ["artistViewBasic"] { 175 - type = "object" 176 - properties { 177 - ["id"] = new StringType { 178 - type = "string" 179 - } 180 - 181 - ["name"] = new StringType { 182 - type = "string" 183 - } 184 - 185 - ["picture"] = new StringType { 186 - type = "string" 187 - format = "uri" 188 - } 189 - 190 - ["uri"] = new StringType { 191 - type = "string" 192 - format = "at-uri" 193 - } 194 - 195 - ["user1Rank"] = new IntegerType { 196 - type = "integer" 197 - } 198 - 199 - ["user2Rank"] = new IntegerType { 200 - type = "integer" 201 - } 202 - 203 - ["weight"] = new IntegerType { 204 - type = "integer" 205 - } 206 - } 207 - } 208 - } 91 + }
-31
apps/api/pkl/defs/actor/getActorCompatibility.pkl
··· 1 - amends "../../schema/lexicon.pkl" 2 - 3 - lexicon = 1 4 - id = "app.rocksky.actor.getActorCompatibility" 5 - defs = new Mapping<String, Query> { 6 - ["main"] { 7 - type = "query" 8 - description = "Get compatibility for an actor" 9 - parameters = new Params { 10 - required = List("did") 11 - properties { 12 - ["did"] = new StringType { 13 - description = "DID or handle to get compatibility for" 14 - format = "at-identifier" 15 - } 16 - } 17 - } 18 - output { 19 - encoding = "application/json" 20 - schema = new ObjectType { 21 - type = "object" 22 - properties = new Mapping<String, Ref> { 23 - ["compatibility"] = new Ref { 24 - type = "ref" 25 - ref = "app.rocksky.actor.defs#compatibilityViewBasic" 26 - } 27 - } 28 - } 29 - } 30 - } 31 - }
-33
apps/api/pkl/defs/actor/getActorNeighbours.pkl
··· 1 - amends "../../schema/lexicon.pkl" 2 - 3 - lexicon = 1 4 - id = "app.rocksky.actor.getActorNeighbours" 5 - defs = new Mapping<String, Query> { 6 - ["main"] { 7 - type = "query" 8 - description = "Get neighbours for an actor" 9 - parameters = new Params { 10 - required = List("did") 11 - properties { 12 - ["did"] = new StringType { 13 - description = "The DID or handle of the actor" 14 - format = "at-identifier" 15 - } 16 - } 17 - } 18 - output { 19 - encoding = "application/json" 20 - schema = new ObjectType { 21 - type = "object" 22 - properties = new Mapping<String, Array> { 23 - ["neighbours"] = new Array { 24 - type = "array" 25 - items = new Ref { 26 - ref = "app.rocksky.actor.defs#neighbourViewBasic" 27 - } 28 - } 29 - } 30 - } 31 - } 32 - } 33 - }
-4
apps/api/pkl/defs/feed/defs.pkl
··· 171 171 ref = "app.rocksky.feed.defs#feedItemView" 172 172 } 173 173 } 174 - ["cursor"] = new StringType { 175 - type = "string" 176 - description = "The pagination cursor for the next set of results." 177 - } 178 174 } 179 175 } 180 176 }
-41
apps/api/pkl/defs/graph/defs.pkl
··· 1 - amends "../../schema/lexicon.pkl" 2 - 3 - lexicon = 1 4 - id = "app.rocksky.graph.defs" 5 - defs = new Mapping<String, ObjectType> { 6 - ["notFoundActor"] = new ObjectType { 7 - type = "object" 8 - description = "indicates that a handle or DID could not be resolved" 9 - required = List("actor", "notFound") 10 - properties { 11 - ["actor"] = new StringType { 12 - type = "string" 13 - format = "at-identifier" 14 - } 15 - ["notFound"] = new BooleanType { 16 - type = "boolean" 17 - } 18 - } 19 - } 20 - ["relationship"] { 21 - type = "object" 22 - required = List("did") 23 - properties { 24 - ["did"] = new StringType { 25 - type = "string" 26 - format = "did" 27 - } 28 - ["following"] = new StringType { 29 - type = "string" 30 - format = "at-uri" 31 - description = "if the actor follows this DID, this is the AT-URI of the follow record" 32 - } 33 - ["followedBy"] = new StringType { 34 - type = "string" 35 - format = "at-uri" 36 - description = 37 - "if the actor is followed by this DID, contains the AT-URI of the follow record" 38 - } 39 - } 40 - } 41 - }
-31
apps/api/pkl/defs/graph/follow.pkl
··· 1 - amends "../../schema/lexicon.pkl" 2 - 3 - lexicon = 1 4 - id = "app.rocksky.graph.follow" 5 - defs = new Mapping<String, Record> { 6 - ["main"] { 7 - type = "record" 8 - key = "tid" 9 - description = "Record declaring a social 'follow' relationship of another account." 10 - `record` { 11 - type = "object" 12 - required = List("createdAt", "subject") 13 - properties { 14 - ["createdAt"] = new StringType { 15 - type = "string" 16 - format = "datetime" 17 - } 18 - 19 - ["subject"] = new StringType { 20 - type = "string" 21 - format = "did" 22 - } 23 - 24 - ["via"] = new Ref { 25 - type = "ref" 26 - ref = "com.atproto.repo.strongRef" 27 - } 28 - } 29 - } 30 - } 31 - }
-46
apps/api/pkl/defs/graph/followAccount.pkl
··· 1 - amends "../../schema/lexicon.pkl" 2 - 3 - lexicon = 1 4 - id = "app.rocksky.graph.followAccount" 5 - defs = new Mapping<String, Procedure> { 6 - ["main"] { 7 - type = "procedure" 8 - description = 9 - "Creates a 'follow' relationship from the authenticated account to a specified account." 10 - parameters = new Params { 11 - required = List("account") 12 - properties { 13 - ["account"] = new StringType { 14 - type = "string" 15 - format = "at-identifier" 16 - } 17 - } 18 - } 19 - 20 - output { 21 - encoding = "application/json" 22 - schema = new ObjectType { 23 - type = "object" 24 - required = List("subject", "followers") 25 - properties { 26 - ["subject"] = new Ref { 27 - type = "ref" 28 - ref = "app.rocksky.actor.defs#profileViewBasic" 29 - } 30 - ["followers"] = new Array { 31 - type = "array" 32 - items = new Ref { 33 - type = "ref" 34 - ref = "app.rocksky.actor.defs#profileViewBasic" 35 - } 36 - } 37 - ["cursor"] = new StringType { 38 - type = "string" 39 - description = 40 - "A cursor value to pass to subsequent calls to get the next page of results." 41 - } 42 - } 43 - } 44 - } 45 - } 46 - }
-67
apps/api/pkl/defs/graph/getFollowers.pkl
··· 1 - amends "../../schema/lexicon.pkl" 2 - 3 - lexicon = 1 4 - id = "app.rocksky.graph.getFollowers" 5 - defs = new Mapping<String, Query> { 6 - ["main"] { 7 - type = "query" 8 - description = "Enumerates accounts which follow a specified account (actor)." 9 - parameters = new Params { 10 - required = List("actor") 11 - properties { 12 - ["actor"] = new StringType { 13 - type = "string" 14 - format = "at-identifier" 15 - } 16 - ["limit"] = new IntegerType { 17 - type = "integer" 18 - maximum = 100 19 - minimum = 1 20 - default = 50 21 - } 22 - ["dids"] = new Array { 23 - type = "array" 24 - items = new StringType { 25 - type = "string" 26 - format = "did" 27 - } 28 - description = 29 - "If provided, filters the followers to only include those with DIDs in this list." 30 - } 31 - ["cursor"] = new StringType { 32 - type = "string" 33 - } 34 - } 35 - } 36 - 37 - output { 38 - encoding = "application/json" 39 - schema = new ObjectType { 40 - type = "object" 41 - required = List("subject", "followers") 42 - properties { 43 - ["subject"] = new Ref { 44 - type = "ref" 45 - ref = "app.rocksky.actor.defs#profileViewBasic" 46 - } 47 - ["followers"] = new Array { 48 - type = "array" 49 - items = new Ref { 50 - type = "ref" 51 - ref = "app.rocksky.actor.defs#profileViewBasic" 52 - } 53 - } 54 - ["cursor"] = new StringType { 55 - type = "string" 56 - description = 57 - "A cursor value to pass to subsequent calls to get the next page of results." 58 - } 59 - ["count"] = new IntegerType { 60 - type = "integer" 61 - description = "The total number of followers." 62 - } 63 - } 64 - } 65 - } 66 - } 67 - }
-67
apps/api/pkl/defs/graph/getFollows.pkl
··· 1 - amends "../../schema/lexicon.pkl" 2 - 3 - lexicon = 1 4 - id = "app.rocksky.graph.getFollows" 5 - defs = new Mapping<String, Query> { 6 - ["main"] { 7 - type = "query" 8 - description = "Enumerates accounts which a specified account (actor) follows." 9 - parameters = new Params { 10 - required = List("actor") 11 - properties { 12 - ["actor"] = new StringType { 13 - type = "string" 14 - format = "at-identifier" 15 - } 16 - ["limit"] = new IntegerType { 17 - type = "integer" 18 - maximum = 100 19 - minimum = 1 20 - default = 50 21 - } 22 - ["dids"] = new Array { 23 - type = "array" 24 - items = new StringType { 25 - type = "string" 26 - format = "did" 27 - } 28 - description = 29 - "If provided, filters the follows to only include those with DIDs in this list." 30 - } 31 - ["cursor"] = new StringType { 32 - type = "string" 33 - } 34 - } 35 - } 36 - 37 - output { 38 - encoding = "application/json" 39 - schema = new ObjectType { 40 - type = "object" 41 - required = List("subject", "follows") 42 - properties { 43 - ["subject"] = new Ref { 44 - type = "ref" 45 - ref = "app.rocksky.actor.defs#profileViewBasic" 46 - } 47 - ["follows"] = new Array { 48 - type = "array" 49 - items = new Ref { 50 - type = "ref" 51 - ref = "app.rocksky.actor.defs#profileViewBasic" 52 - } 53 - } 54 - ["cursor"] = new StringType { 55 - type = "string" 56 - description = 57 - "A cursor value to pass to subsequent calls to get the next page of results." 58 - } 59 - ["count"] = new IntegerType { 60 - type = "integer" 61 - description = "The total number of follows." 62 - } 63 - } 64 - } 65 - } 66 - } 67 - }
-55
apps/api/pkl/defs/graph/getKnownFollowers.pkl
··· 1 - amends "../../schema/lexicon.pkl" 2 - 3 - lexicon = 1 4 - id = "app.rocksky.graph.getKnownFollowers" 5 - defs = new Mapping<String, Query> { 6 - ["main"] { 7 - type = "query" 8 - description = 9 - "Enumerates accounts which follow a specified account (actor) and are followed by the viewer." 10 - parameters = new Params { 11 - required = List("actor") 12 - properties { 13 - ["actor"] = new StringType { 14 - type = "string" 15 - format = "at-identifier" 16 - } 17 - ["limit"] = new IntegerType { 18 - type = "integer" 19 - maximum = 100 20 - minimum = 1 21 - default = 50 22 - } 23 - ["cursor"] = new StringType { 24 - type = "string" 25 - } 26 - } 27 - } 28 - 29 - output { 30 - encoding = "application/json" 31 - schema = new ObjectType { 32 - type = "object" 33 - required = List("subject", "followers") 34 - properties { 35 - ["subject"] = new Ref { 36 - type = "ref" 37 - ref = "app.rocksky.actor.defs#profileViewBasic" 38 - } 39 - ["followers"] = new Array { 40 - type = "array" 41 - items = new Ref { 42 - type = "ref" 43 - ref = "app.rocksky.actor.defs#profileViewBasic" 44 - } 45 - } 46 - ["cursor"] = new StringType { 47 - type = "string" 48 - description = 49 - "A cursor value to pass to subsequent calls to get the next page of results." 50 - } 51 - } 52 - } 53 - } 54 - } 55 - }
-46
apps/api/pkl/defs/graph/unfollowAccount.pkl
··· 1 - amends "../../schema/lexicon.pkl" 2 - 3 - lexicon = 1 4 - id = "app.rocksky.graph.unfollowAccount" 5 - defs = new Mapping<String, Procedure> { 6 - ["main"] { 7 - type = "procedure" 8 - description = 9 - "Removes a 'follow' relationship from the authenticated account to a specified account." 10 - parameters = new Params { 11 - required = List("account") 12 - properties { 13 - ["account"] = new StringType { 14 - type = "string" 15 - format = "at-identifier" 16 - } 17 - } 18 - } 19 - 20 - output { 21 - encoding = "application/json" 22 - schema = new ObjectType { 23 - type = "object" 24 - required = List("subject", "followers") 25 - properties { 26 - ["subject"] = new Ref { 27 - type = "ref" 28 - ref = "app.rocksky.actor.defs#profileViewBasic" 29 - } 30 - ["followers"] = new Array { 31 - type = "array" 32 - items = new Ref { 33 - type = "ref" 34 - ref = "app.rocksky.actor.defs#profileViewBasic" 35 - } 36 - } 37 - ["cursor"] = new StringType { 38 - type = "string" 39 - description = 40 - "A cursor value to pass to subsequent calls to get the next page of results." 41 - } 42 - } 43 - } 44 - } 45 - } 46 - }
+2 -6
apps/api/pkl/defs/scrobble/getScrobbles.pkl
··· 1 - amends "../../schema/lexicon.pkl" 1 + amends "../../schema/lexicon.pkl" 2 2 3 3 lexicon = 1 4 4 id = "app.rocksky.scrobble.getScrobbles" ··· 11 11 ["did"] = new StringType { 12 12 description = "The DID or handle of the actor" 13 13 format = "at-identifier" 14 - } 15 - ["following"] = new BooleanType { 16 - type = "boolean" 17 - description = "If true, only return scrobbles from actors the viewer is following." 18 14 } 19 15 ["limit"] = new IntegerType { 20 16 type = "integer" ··· 43 39 } 44 40 } 45 41 } 46 - } 42 + }
+1 -3
apps/api/pkl/schema/lexicon.pkl
··· 13 13 14 14 class IntegerType extends BaseType { 15 15 type: "integer" 16 - maximum: Int? 17 16 minimum: Int? 18 - default: Int? 19 17 } 20 18 21 19 class BooleanType extends BaseType { ··· 42 40 class ObjectType extends BaseType { 43 41 type: "object" 44 42 required: List<String>? 45 - properties: Mapping<String, StringType | IntegerType | BooleanType | Blob | Ref | Union | Array> 43 + properties: Mapping<String, StringType | IntegerType | Blob | Ref | Union | Array> 46 44 } 47 45 48 46 class Union extends BaseType {
+1 -50
apps/api/src/bsky/app.ts
··· 1 - import { AtpAgent } from "@atproto/api"; 2 1 import type { BlobRef } from "@atproto/lexicon"; 3 2 import { isValidHandle } from "@atproto/syntax"; 4 3 import { ctx } from "context"; ··· 9 8 import { deepSnakeCaseKeys } from "lib"; 10 9 import { createAgent } from "lib/agent"; 11 10 import { env } from "lib/env"; 12 - import extractPdsFromDid from "lib/extractPdsFromDid"; 13 11 import { requestCounter } from "metrics"; 14 12 import dropboxAccounts from "schema/dropbox-accounts"; 15 13 import googleDriveAccounts from "schema/google-drive-accounts"; ··· 42 40 43 41 app.post("/login", async (c) => { 44 42 requestCounter.add(1, { method: "POST", route: "/login" }); 45 - const { handle, cli, password } = await c.req.json(); 43 + const { handle, cli } = await c.req.json(); 46 44 if (typeof handle !== "string" || !isValidHandle(handle)) { 47 45 c.status(400); 48 46 return c.text("Invalid handle"); 49 47 } 50 48 51 49 try { 52 - if (password) { 53 - const defaultAgent = new AtpAgent({ 54 - service: new URL("https://bsky.social"), 55 - }); 56 - const { 57 - data: { did }, 58 - } = await defaultAgent.resolveHandle({ handle }); 59 - 60 - let pds = await ctx.redis.get(`pds:${did}`); 61 - if (!pds) { 62 - pds = await extractPdsFromDid(did); 63 - await ctx.redis.setEx(`pds:${did}`, 60 * 15, pds); 64 - } 65 - 66 - const agent = new AtpAgent({ 67 - service: new URL(pds), 68 - }); 69 - 70 - await agent.login({ 71 - identifier: handle, 72 - password, 73 - }); 74 - 75 - await ctx.sqliteDb 76 - .insertInto("auth_session") 77 - .values({ 78 - key: `atp:${did}`, 79 - session: JSON.stringify(agent.session), 80 - }) 81 - .onConflict((oc) => 82 - oc 83 - .column("key") 84 - .doUpdateSet({ session: JSON.stringify(agent.session) }), 85 - ) 86 - .execute(); 87 - 88 - const token = jwt.sign( 89 - { 90 - did, 91 - exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, 92 - }, 93 - env.JWT_SECRET, 94 - ); 95 - 96 - return c.text(`jwt:${token}`); 97 - } 98 - 99 50 const url = await ctx.oauthClient.authorize(handle, { 100 51 scope: "atproto transition:generic", 101 52 });
-2
apps/api/src/context.ts
··· 44 44 headers: { Authorization: `Bearer ${env.MEILISEARCH_API_KEY}` }, 45 45 }), 46 46 authVerifier, 47 - sqliteDb: db, 48 - sqliteKv: kv, 49 47 }; 50 48 51 49 export type Context = typeof ctx;
-94
apps/api/src/lexicon/index.ts
··· 16 16 import type * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay"; 17 17 import type * as AppRockskyActorGetActorAlbums from "./types/app/rocksky/actor/getActorAlbums"; 18 18 import type * as AppRockskyActorGetActorArtists from "./types/app/rocksky/actor/getActorArtists"; 19 - import type * as AppRockskyActorGetActorCompatibility from "./types/app/rocksky/actor/getActorCompatibility"; 20 19 import type * as AppRockskyActorGetActorLovedSongs from "./types/app/rocksky/actor/getActorLovedSongs"; 21 - import type * as AppRockskyActorGetActorNeighbours from "./types/app/rocksky/actor/getActorNeighbours"; 22 20 import type * as AppRockskyActorGetActorPlaylists from "./types/app/rocksky/actor/getActorPlaylists"; 23 21 import type * as AppRockskyActorGetActorScrobbles from "./types/app/rocksky/actor/getActorScrobbles"; 24 22 import type * as AppRockskyActorGetActorSongs from "./types/app/rocksky/actor/getActorSongs"; ··· 50 48 import type * as AppRockskyGoogledriveDownloadFile from "./types/app/rocksky/googledrive/downloadFile"; 51 49 import type * as AppRockskyGoogledriveGetFile from "./types/app/rocksky/googledrive/getFile"; 52 50 import type * as AppRockskyGoogledriveGetFiles from "./types/app/rocksky/googledrive/getFiles"; 53 - import type * as AppRockskyGraphFollowAccount from "./types/app/rocksky/graph/followAccount"; 54 - import type * as AppRockskyGraphGetFollowers from "./types/app/rocksky/graph/getFollowers"; 55 - import type * as AppRockskyGraphGetFollows from "./types/app/rocksky/graph/getFollows"; 56 - import type * as AppRockskyGraphGetKnownFollowers from "./types/app/rocksky/graph/getKnownFollowers"; 57 - import type * as AppRockskyGraphUnfollowAccount from "./types/app/rocksky/graph/unfollowAccount"; 58 51 import type * as AppRockskyLikeDislikeShout from "./types/app/rocksky/like/dislikeShout"; 59 52 import type * as AppRockskyLikeDislikeSong from "./types/app/rocksky/like/dislikeSong"; 60 53 import type * as AppRockskyLikeLikeShout from "./types/app/rocksky/like/likeShout"; ··· 244 237 dropbox: AppRockskyDropboxNS; 245 238 feed: AppRockskyFeedNS; 246 239 googledrive: AppRockskyGoogledriveNS; 247 - graph: AppRockskyGraphNS; 248 240 like: AppRockskyLikeNS; 249 241 player: AppRockskyPlayerNS; 250 242 playlist: AppRockskyPlaylistNS; ··· 264 256 this.dropbox = new AppRockskyDropboxNS(server); 265 257 this.feed = new AppRockskyFeedNS(server); 266 258 this.googledrive = new AppRockskyGoogledriveNS(server); 267 - this.graph = new AppRockskyGraphNS(server); 268 259 this.like = new AppRockskyLikeNS(server); 269 260 this.player = new AppRockskyPlayerNS(server); 270 261 this.playlist = new AppRockskyPlaylistNS(server); ··· 305 296 return this._server.xrpc.method(nsid, cfg); 306 297 } 307 298 308 - getActorCompatibility<AV extends AuthVerifier>( 309 - cfg: ConfigOf< 310 - AV, 311 - AppRockskyActorGetActorCompatibility.Handler<ExtractAuth<AV>>, 312 - AppRockskyActorGetActorCompatibility.HandlerReqCtx<ExtractAuth<AV>> 313 - >, 314 - ) { 315 - const nsid = "app.rocksky.actor.getActorCompatibility"; // @ts-ignore 316 - return this._server.xrpc.method(nsid, cfg); 317 - } 318 - 319 299 getActorLovedSongs<AV extends AuthVerifier>( 320 300 cfg: ConfigOf< 321 301 AV, ··· 324 304 >, 325 305 ) { 326 306 const nsid = "app.rocksky.actor.getActorLovedSongs"; // @ts-ignore 327 - return this._server.xrpc.method(nsid, cfg); 328 - } 329 - 330 - getActorNeighbours<AV extends AuthVerifier>( 331 - cfg: ConfigOf< 332 - AV, 333 - AppRockskyActorGetActorNeighbours.Handler<ExtractAuth<AV>>, 334 - AppRockskyActorGetActorNeighbours.HandlerReqCtx<ExtractAuth<AV>> 335 - >, 336 - ) { 337 - const nsid = "app.rocksky.actor.getActorNeighbours"; // @ts-ignore 338 307 return this._server.xrpc.method(nsid, cfg); 339 308 } 340 309 ··· 732 701 >, 733 702 ) { 734 703 const nsid = "app.rocksky.googledrive.getFiles"; // @ts-ignore 735 - return this._server.xrpc.method(nsid, cfg); 736 - } 737 - } 738 - 739 - export class AppRockskyGraphNS { 740 - _server: Server; 741 - 742 - constructor(server: Server) { 743 - this._server = server; 744 - } 745 - 746 - followAccount<AV extends AuthVerifier>( 747 - cfg: ConfigOf< 748 - AV, 749 - AppRockskyGraphFollowAccount.Handler<ExtractAuth<AV>>, 750 - AppRockskyGraphFollowAccount.HandlerReqCtx<ExtractAuth<AV>> 751 - >, 752 - ) { 753 - const nsid = "app.rocksky.graph.followAccount"; // @ts-ignore 754 - return this._server.xrpc.method(nsid, cfg); 755 - } 756 - 757 - getFollowers<AV extends AuthVerifier>( 758 - cfg: ConfigOf< 759 - AV, 760 - AppRockskyGraphGetFollowers.Handler<ExtractAuth<AV>>, 761 - AppRockskyGraphGetFollowers.HandlerReqCtx<ExtractAuth<AV>> 762 - >, 763 - ) { 764 - const nsid = "app.rocksky.graph.getFollowers"; // @ts-ignore 765 - return this._server.xrpc.method(nsid, cfg); 766 - } 767 - 768 - getFollows<AV extends AuthVerifier>( 769 - cfg: ConfigOf< 770 - AV, 771 - AppRockskyGraphGetFollows.Handler<ExtractAuth<AV>>, 772 - AppRockskyGraphGetFollows.HandlerReqCtx<ExtractAuth<AV>> 773 - >, 774 - ) { 775 - const nsid = "app.rocksky.graph.getFollows"; // @ts-ignore 776 - return this._server.xrpc.method(nsid, cfg); 777 - } 778 - 779 - getKnownFollowers<AV extends AuthVerifier>( 780 - cfg: ConfigOf< 781 - AV, 782 - AppRockskyGraphGetKnownFollowers.Handler<ExtractAuth<AV>>, 783 - AppRockskyGraphGetKnownFollowers.HandlerReqCtx<ExtractAuth<AV>> 784 - >, 785 - ) { 786 - const nsid = "app.rocksky.graph.getKnownFollowers"; // @ts-ignore 787 - return this._server.xrpc.method(nsid, cfg); 788 - } 789 - 790 - unfollowAccount<AV extends AuthVerifier>( 791 - cfg: ConfigOf< 792 - AV, 793 - AppRockskyGraphUnfollowAccount.Handler<ExtractAuth<AV>>, 794 - AppRockskyGraphUnfollowAccount.HandlerReqCtx<ExtractAuth<AV>> 795 - >, 796 - ) { 797 - const nsid = "app.rocksky.graph.unfollowAccount"; // @ts-ignore 798 704 return this._server.xrpc.method(nsid, cfg); 799 705 } 800 706 }
-549
apps/api/src/lexicon/lexicons.ts
··· 664 664 }, 665 665 }, 666 666 }, 667 - neighbourViewBasic: { 668 - type: "object", 669 - properties: { 670 - userId: { 671 - type: "string", 672 - }, 673 - did: { 674 - type: "string", 675 - }, 676 - handle: { 677 - type: "string", 678 - }, 679 - displayName: { 680 - type: "string", 681 - }, 682 - avatar: { 683 - type: "string", 684 - description: "The URL of the actor's avatar image.", 685 - format: "uri", 686 - }, 687 - sharedArtistsCount: { 688 - type: "integer", 689 - description: "The number of artists shared with the actor.", 690 - }, 691 - similarityScore: { 692 - type: "integer", 693 - description: "The similarity score with the actor.", 694 - }, 695 - topSharedArtistNames: { 696 - type: "array", 697 - description: "The top shared artist names with the actor.", 698 - items: { 699 - type: "string", 700 - }, 701 - }, 702 - topSharedArtistsDetails: { 703 - type: "array", 704 - description: "The top shared artist details with the actor.", 705 - items: { 706 - type: "ref", 707 - ref: "lex:app.rocksky.artist.defs#artistViewBasic", 708 - }, 709 - }, 710 - }, 711 - }, 712 - compatibilityViewBasic: { 713 - type: "object", 714 - properties: { 715 - compatibilityLevel: { 716 - type: "integer", 717 - }, 718 - compatibilityPercentage: { 719 - type: "integer", 720 - }, 721 - sharedArtists: { 722 - type: "integer", 723 - }, 724 - topSharedArtistNames: { 725 - type: "array", 726 - items: { 727 - type: "string", 728 - }, 729 - }, 730 - topSharedDetailedArtists: { 731 - type: "array", 732 - items: { 733 - type: "ref", 734 - ref: "lex:app.rocksky.actor.defs#artistViewBasic", 735 - }, 736 - }, 737 - user1ArtistCount: { 738 - type: "integer", 739 - }, 740 - user2ArtistCount: { 741 - type: "integer", 742 - }, 743 - }, 744 - }, 745 - artistViewBasic: { 746 - type: "object", 747 - properties: { 748 - id: { 749 - type: "string", 750 - }, 751 - name: { 752 - type: "string", 753 - }, 754 - picture: { 755 - type: "string", 756 - format: "uri", 757 - }, 758 - uri: { 759 - type: "string", 760 - format: "at-uri", 761 - }, 762 - user1Rank: { 763 - type: "integer", 764 - }, 765 - user2Rank: { 766 - type: "integer", 767 - }, 768 - weight: { 769 - type: "integer", 770 - }, 771 - }, 772 - }, 773 667 }, 774 668 }, 775 669 AppRockskyActorGetActorAlbums: { ··· 864 758 }, 865 759 }, 866 760 }, 867 - AppRockskyActorGetActorCompatibility: { 868 - lexicon: 1, 869 - id: "app.rocksky.actor.getActorCompatibility", 870 - defs: { 871 - main: { 872 - type: "query", 873 - description: "Get compatibility for an actor", 874 - parameters: { 875 - type: "params", 876 - required: ["did"], 877 - properties: { 878 - did: { 879 - type: "string", 880 - description: "DID or handle to get compatibility for", 881 - format: "at-identifier", 882 - }, 883 - }, 884 - }, 885 - output: { 886 - encoding: "application/json", 887 - schema: { 888 - type: "object", 889 - properties: { 890 - compatibility: { 891 - type: "ref", 892 - ref: "lex:app.rocksky.actor.defs#compatibilityViewBasic", 893 - }, 894 - }, 895 - }, 896 - }, 897 - }, 898 - }, 899 - }, 900 761 AppRockskyActorGetActorLovedSongs: { 901 762 lexicon: 1, 902 763 id: "app.rocksky.actor.getActorLovedSongs", ··· 935 796 items: { 936 797 type: "ref", 937 798 ref: "lex:app.rocksky.song.defs#songViewBasic", 938 - }, 939 - }, 940 - }, 941 - }, 942 - }, 943 - }, 944 - }, 945 - }, 946 - AppRockskyActorGetActorNeighbours: { 947 - lexicon: 1, 948 - id: "app.rocksky.actor.getActorNeighbours", 949 - defs: { 950 - main: { 951 - type: "query", 952 - description: "Get neighbours for an actor", 953 - parameters: { 954 - type: "params", 955 - required: ["did"], 956 - properties: { 957 - did: { 958 - type: "string", 959 - description: "The DID or handle of the actor", 960 - format: "at-identifier", 961 - }, 962 - }, 963 - }, 964 - output: { 965 - encoding: "application/json", 966 - schema: { 967 - type: "object", 968 - properties: { 969 - neighbours: { 970 - type: "array", 971 - items: { 972 - type: "ref", 973 - ref: "lex:app.rocksky.actor.defs#neighbourViewBasic", 974 799 }, 975 800 }, 976 801 }, ··· 2518 2343 ref: "lex:app.rocksky.feed.defs#feedItemView", 2519 2344 }, 2520 2345 }, 2521 - cursor: { 2522 - type: "string", 2523 - description: "The pagination cursor for the next set of results.", 2524 - }, 2525 2346 }, 2526 2347 }, 2527 2348 }, ··· 2914 2735 }, 2915 2736 }, 2916 2737 }, 2917 - AppRockskyGraphDefs: { 2918 - lexicon: 1, 2919 - id: "app.rocksky.graph.defs", 2920 - defs: { 2921 - notFoundActor: { 2922 - type: "object", 2923 - description: "indicates that a handle or DID could not be resolved", 2924 - required: ["actor", "notFound"], 2925 - properties: { 2926 - actor: { 2927 - type: "string", 2928 - format: "at-identifier", 2929 - }, 2930 - notFound: { 2931 - type: "boolean", 2932 - }, 2933 - }, 2934 - }, 2935 - relationship: { 2936 - type: "object", 2937 - required: ["did"], 2938 - properties: { 2939 - did: { 2940 - type: "string", 2941 - format: "did", 2942 - }, 2943 - following: { 2944 - type: "string", 2945 - description: 2946 - "if the actor follows this DID, this is the AT-URI of the follow record", 2947 - format: "at-uri", 2948 - }, 2949 - followedBy: { 2950 - type: "string", 2951 - description: 2952 - "if the actor is followed by this DID, contains the AT-URI of the follow record", 2953 - format: "at-uri", 2954 - }, 2955 - }, 2956 - }, 2957 - }, 2958 - }, 2959 - AppRockskyGraphFollow: { 2960 - lexicon: 1, 2961 - id: "app.rocksky.graph.follow", 2962 - defs: { 2963 - main: { 2964 - type: "record", 2965 - description: 2966 - "Record declaring a social 'follow' relationship of another account.", 2967 - key: "tid", 2968 - record: { 2969 - type: "object", 2970 - required: ["createdAt", "subject"], 2971 - properties: { 2972 - createdAt: { 2973 - type: "string", 2974 - format: "datetime", 2975 - }, 2976 - subject: { 2977 - type: "string", 2978 - format: "did", 2979 - }, 2980 - via: { 2981 - type: "ref", 2982 - ref: "lex:com.atproto.repo.strongRef", 2983 - }, 2984 - }, 2985 - }, 2986 - }, 2987 - }, 2988 - }, 2989 - AppRockskyGraphFollowAccount: { 2990 - lexicon: 1, 2991 - id: "app.rocksky.graph.followAccount", 2992 - defs: { 2993 - main: { 2994 - type: "procedure", 2995 - description: 2996 - "Creates a 'follow' relationship from the authenticated account to a specified account.", 2997 - parameters: { 2998 - type: "params", 2999 - required: ["account"], 3000 - properties: { 3001 - account: { 3002 - type: "string", 3003 - format: "at-identifier", 3004 - }, 3005 - }, 3006 - }, 3007 - output: { 3008 - encoding: "application/json", 3009 - schema: { 3010 - type: "object", 3011 - required: ["subject", "followers"], 3012 - properties: { 3013 - subject: { 3014 - type: "ref", 3015 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3016 - }, 3017 - followers: { 3018 - type: "array", 3019 - items: { 3020 - type: "ref", 3021 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3022 - }, 3023 - }, 3024 - cursor: { 3025 - type: "string", 3026 - description: 3027 - "A cursor value to pass to subsequent calls to get the next page of results.", 3028 - }, 3029 - }, 3030 - }, 3031 - }, 3032 - }, 3033 - }, 3034 - }, 3035 - AppRockskyGraphGetFollowers: { 3036 - lexicon: 1, 3037 - id: "app.rocksky.graph.getFollowers", 3038 - defs: { 3039 - main: { 3040 - type: "query", 3041 - description: 3042 - "Enumerates accounts which follow a specified account (actor).", 3043 - parameters: { 3044 - type: "params", 3045 - required: ["actor"], 3046 - properties: { 3047 - actor: { 3048 - type: "string", 3049 - format: "at-identifier", 3050 - }, 3051 - limit: { 3052 - type: "integer", 3053 - maximum: 100, 3054 - minimum: 1, 3055 - default: 50, 3056 - }, 3057 - dids: { 3058 - type: "array", 3059 - description: 3060 - "If provided, filters the followers to only include those with DIDs in this list.", 3061 - items: { 3062 - type: "string", 3063 - format: "did", 3064 - }, 3065 - }, 3066 - cursor: { 3067 - type: "string", 3068 - }, 3069 - }, 3070 - }, 3071 - output: { 3072 - encoding: "application/json", 3073 - schema: { 3074 - type: "object", 3075 - required: ["subject", "followers"], 3076 - properties: { 3077 - subject: { 3078 - type: "ref", 3079 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3080 - }, 3081 - followers: { 3082 - type: "array", 3083 - items: { 3084 - type: "ref", 3085 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3086 - }, 3087 - }, 3088 - cursor: { 3089 - type: "string", 3090 - description: 3091 - "A cursor value to pass to subsequent calls to get the next page of results.", 3092 - }, 3093 - count: { 3094 - type: "integer", 3095 - description: "The total number of followers.", 3096 - }, 3097 - }, 3098 - }, 3099 - }, 3100 - }, 3101 - }, 3102 - }, 3103 - AppRockskyGraphGetFollows: { 3104 - lexicon: 1, 3105 - id: "app.rocksky.graph.getFollows", 3106 - defs: { 3107 - main: { 3108 - type: "query", 3109 - description: 3110 - "Enumerates accounts which a specified account (actor) follows.", 3111 - parameters: { 3112 - type: "params", 3113 - required: ["actor"], 3114 - properties: { 3115 - actor: { 3116 - type: "string", 3117 - format: "at-identifier", 3118 - }, 3119 - limit: { 3120 - type: "integer", 3121 - maximum: 100, 3122 - minimum: 1, 3123 - default: 50, 3124 - }, 3125 - dids: { 3126 - type: "array", 3127 - description: 3128 - "If provided, filters the follows to only include those with DIDs in this list.", 3129 - items: { 3130 - type: "string", 3131 - format: "did", 3132 - }, 3133 - }, 3134 - cursor: { 3135 - type: "string", 3136 - }, 3137 - }, 3138 - }, 3139 - output: { 3140 - encoding: "application/json", 3141 - schema: { 3142 - type: "object", 3143 - required: ["subject", "follows"], 3144 - properties: { 3145 - subject: { 3146 - type: "ref", 3147 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3148 - }, 3149 - follows: { 3150 - type: "array", 3151 - items: { 3152 - type: "ref", 3153 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3154 - }, 3155 - }, 3156 - cursor: { 3157 - type: "string", 3158 - description: 3159 - "A cursor value to pass to subsequent calls to get the next page of results.", 3160 - }, 3161 - count: { 3162 - type: "integer", 3163 - description: "The total number of follows.", 3164 - }, 3165 - }, 3166 - }, 3167 - }, 3168 - }, 3169 - }, 3170 - }, 3171 - AppRockskyGraphGetKnownFollowers: { 3172 - lexicon: 1, 3173 - id: "app.rocksky.graph.getKnownFollowers", 3174 - defs: { 3175 - main: { 3176 - type: "query", 3177 - description: 3178 - "Enumerates accounts which follow a specified account (actor) and are followed by the viewer.", 3179 - parameters: { 3180 - type: "params", 3181 - required: ["actor"], 3182 - properties: { 3183 - actor: { 3184 - type: "string", 3185 - format: "at-identifier", 3186 - }, 3187 - limit: { 3188 - type: "integer", 3189 - maximum: 100, 3190 - minimum: 1, 3191 - default: 50, 3192 - }, 3193 - cursor: { 3194 - type: "string", 3195 - }, 3196 - }, 3197 - }, 3198 - output: { 3199 - encoding: "application/json", 3200 - schema: { 3201 - type: "object", 3202 - required: ["subject", "followers"], 3203 - properties: { 3204 - subject: { 3205 - type: "ref", 3206 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3207 - }, 3208 - followers: { 3209 - type: "array", 3210 - items: { 3211 - type: "ref", 3212 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3213 - }, 3214 - }, 3215 - cursor: { 3216 - type: "string", 3217 - description: 3218 - "A cursor value to pass to subsequent calls to get the next page of results.", 3219 - }, 3220 - }, 3221 - }, 3222 - }, 3223 - }, 3224 - }, 3225 - }, 3226 - AppRockskyGraphUnfollowAccount: { 3227 - lexicon: 1, 3228 - id: "app.rocksky.graph.unfollowAccount", 3229 - defs: { 3230 - main: { 3231 - type: "procedure", 3232 - description: 3233 - "Removes a 'follow' relationship from the authenticated account to a specified account.", 3234 - parameters: { 3235 - type: "params", 3236 - required: ["account"], 3237 - properties: { 3238 - account: { 3239 - type: "string", 3240 - format: "at-identifier", 3241 - }, 3242 - }, 3243 - }, 3244 - output: { 3245 - encoding: "application/json", 3246 - schema: { 3247 - type: "object", 3248 - required: ["subject", "followers"], 3249 - properties: { 3250 - subject: { 3251 - type: "ref", 3252 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3253 - }, 3254 - followers: { 3255 - type: "array", 3256 - items: { 3257 - type: "ref", 3258 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3259 - }, 3260 - }, 3261 - cursor: { 3262 - type: "string", 3263 - description: 3264 - "A cursor value to pass to subsequent calls to get the next page of results.", 3265 - }, 3266 - }, 3267 - }, 3268 - }, 3269 - }, 3270 - }, 3271 - }, 3272 2738 AppRockskyLikeDislikeShout: { 3273 2739 lexicon: 1, 3274 2740 id: "app.rocksky.like.dislikeShout", ··· 4558 4024 type: "string", 4559 4025 description: "The DID or handle of the actor", 4560 4026 format: "at-identifier", 4561 - }, 4562 - following: { 4563 - type: "boolean", 4564 - description: 4565 - "If true, only return scrobbles from actors the viewer is following.", 4566 4027 }, 4567 4028 limit: { 4568 4029 type: "integer", ··· 5890 5351 AppRockskyActorDefs: "app.rocksky.actor.defs", 5891 5352 AppRockskyActorGetActorAlbums: "app.rocksky.actor.getActorAlbums", 5892 5353 AppRockskyActorGetActorArtists: "app.rocksky.actor.getActorArtists", 5893 - AppRockskyActorGetActorCompatibility: 5894 - "app.rocksky.actor.getActorCompatibility", 5895 5354 AppRockskyActorGetActorLovedSongs: "app.rocksky.actor.getActorLovedSongs", 5896 - AppRockskyActorGetActorNeighbours: "app.rocksky.actor.getActorNeighbours", 5897 5355 AppRockskyActorGetActorPlaylists: "app.rocksky.actor.getActorPlaylists", 5898 5356 AppRockskyActorGetActorScrobbles: "app.rocksky.actor.getActorScrobbles", 5899 5357 AppRockskyActorGetActorSongs: "app.rocksky.actor.getActorSongs", ··· 5937 5395 AppRockskyGoogledriveDownloadFile: "app.rocksky.googledrive.downloadFile", 5938 5396 AppRockskyGoogledriveGetFile: "app.rocksky.googledrive.getFile", 5939 5397 AppRockskyGoogledriveGetFiles: "app.rocksky.googledrive.getFiles", 5940 - AppRockskyGraphDefs: "app.rocksky.graph.defs", 5941 - AppRockskyGraphFollow: "app.rocksky.graph.follow", 5942 - AppRockskyGraphFollowAccount: "app.rocksky.graph.followAccount", 5943 - AppRockskyGraphGetFollowers: "app.rocksky.graph.getFollowers", 5944 - AppRockskyGraphGetFollows: "app.rocksky.graph.getFollows", 5945 - AppRockskyGraphGetKnownFollowers: "app.rocksky.graph.getKnownFollowers", 5946 - AppRockskyGraphUnfollowAccount: "app.rocksky.graph.unfollowAccount", 5947 5398 AppRockskyLikeDislikeShout: "app.rocksky.like.dislikeShout", 5948 5399 AppRockskyLikeDislikeSong: "app.rocksky.like.dislikeSong", 5949 5400 AppRockskyLike: "app.rocksky.like",
-79
apps/api/src/lexicon/types/app/rocksky/actor/defs.ts
··· 5 5 import { lexicons } from "../../../../lexicons"; 6 6 import { isObj, hasProp } from "../../../../util"; 7 7 import { CID } from "multiformats/cid"; 8 - import type * as AppRockskyArtistDefs from "../artist/defs"; 9 8 10 9 export interface ProfileViewDetailed { 11 10 /** The unique identifier of the actor. */ ··· 66 65 export function validateProfileViewBasic(v: unknown): ValidationResult { 67 66 return lexicons.validate("app.rocksky.actor.defs#profileViewBasic", v); 68 67 } 69 - 70 - export interface NeighbourViewBasic { 71 - userId?: string; 72 - did?: string; 73 - handle?: string; 74 - displayName?: string; 75 - /** The URL of the actor's avatar image. */ 76 - avatar?: string; 77 - /** The number of artists shared with the actor. */ 78 - sharedArtistsCount?: number; 79 - /** The similarity score with the actor. */ 80 - similarityScore?: number; 81 - /** The top shared artist names with the actor. */ 82 - topSharedArtistNames?: string[]; 83 - /** The top shared artist details with the actor. */ 84 - topSharedArtistsDetails?: AppRockskyArtistDefs.ArtistViewBasic[]; 85 - [k: string]: unknown; 86 - } 87 - 88 - export function isNeighbourViewBasic(v: unknown): v is NeighbourViewBasic { 89 - return ( 90 - isObj(v) && 91 - hasProp(v, "$type") && 92 - v.$type === "app.rocksky.actor.defs#neighbourViewBasic" 93 - ); 94 - } 95 - 96 - export function validateNeighbourViewBasic(v: unknown): ValidationResult { 97 - return lexicons.validate("app.rocksky.actor.defs#neighbourViewBasic", v); 98 - } 99 - 100 - export interface CompatibilityViewBasic { 101 - compatibilityLevel?: number; 102 - compatibilityPercentage?: number; 103 - sharedArtists?: number; 104 - topSharedArtistNames?: string[]; 105 - topSharedDetailedArtists?: ArtistViewBasic[]; 106 - user1ArtistCount?: number; 107 - user2ArtistCount?: number; 108 - [k: string]: unknown; 109 - } 110 - 111 - export function isCompatibilityViewBasic( 112 - v: unknown, 113 - ): v is CompatibilityViewBasic { 114 - return ( 115 - isObj(v) && 116 - hasProp(v, "$type") && 117 - v.$type === "app.rocksky.actor.defs#compatibilityViewBasic" 118 - ); 119 - } 120 - 121 - export function validateCompatibilityViewBasic(v: unknown): ValidationResult { 122 - return lexicons.validate("app.rocksky.actor.defs#compatibilityViewBasic", v); 123 - } 124 - 125 - export interface ArtistViewBasic { 126 - id?: string; 127 - name?: string; 128 - picture?: string; 129 - uri?: string; 130 - user1Rank?: number; 131 - user2Rank?: number; 132 - weight?: number; 133 - [k: string]: unknown; 134 - } 135 - 136 - export function isArtistViewBasic(v: unknown): v is ArtistViewBasic { 137 - return ( 138 - isObj(v) && 139 - hasProp(v, "$type") && 140 - v.$type === "app.rocksky.actor.defs#artistViewBasic" 141 - ); 142 - } 143 - 144 - export function validateArtistViewBasic(v: unknown): ValidationResult { 145 - return lexicons.validate("app.rocksky.actor.defs#artistViewBasic", v); 146 - }
-48
apps/api/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import type express from "express"; 5 - import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 - import { lexicons } from "../../../../lexicons"; 7 - import { isObj, hasProp } from "../../../../util"; 8 - import { CID } from "multiformats/cid"; 9 - import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 - import type * as AppRockskyActorDefs from "./defs"; 11 - 12 - export interface QueryParams { 13 - /** DID or handle to get compatibility for */ 14 - did: string; 15 - } 16 - 17 - export type InputSchema = undefined; 18 - 19 - export interface OutputSchema { 20 - compatibility?: AppRockskyActorDefs.CompatibilityViewBasic; 21 - [k: string]: unknown; 22 - } 23 - 24 - export type HandlerInput = undefined; 25 - 26 - export interface HandlerSuccess { 27 - encoding: "application/json"; 28 - body: OutputSchema; 29 - headers?: { [key: string]: string }; 30 - } 31 - 32 - export interface HandlerError { 33 - status: number; 34 - message?: string; 35 - } 36 - 37 - export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 38 - export type HandlerReqCtx<HA extends HandlerAuth = never> = { 39 - auth: HA; 40 - params: QueryParams; 41 - input: HandlerInput; 42 - req: express.Request; 43 - res: express.Response; 44 - resetRouteRateLimits: () => Promise<void>; 45 - }; 46 - export type Handler<HA extends HandlerAuth = never> = ( 47 - ctx: HandlerReqCtx<HA>, 48 - ) => Promise<HandlerOutput> | HandlerOutput;
-48
apps/api/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import type express from "express"; 5 - import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 - import { lexicons } from "../../../../lexicons"; 7 - import { isObj, hasProp } from "../../../../util"; 8 - import { CID } from "multiformats/cid"; 9 - import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 - import type * as AppRockskyActorDefs from "./defs"; 11 - 12 - export interface QueryParams { 13 - /** The DID or handle of the actor */ 14 - did: string; 15 - } 16 - 17 - export type InputSchema = undefined; 18 - 19 - export interface OutputSchema { 20 - neighbours?: AppRockskyActorDefs.NeighbourViewBasic[]; 21 - [k: string]: unknown; 22 - } 23 - 24 - export type HandlerInput = undefined; 25 - 26 - export interface HandlerSuccess { 27 - encoding: "application/json"; 28 - body: OutputSchema; 29 - headers?: { [key: string]: string }; 30 - } 31 - 32 - export interface HandlerError { 33 - status: number; 34 - message?: string; 35 - } 36 - 37 - export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 38 - export type HandlerReqCtx<HA extends HandlerAuth = never> = { 39 - auth: HA; 40 - params: QueryParams; 41 - input: HandlerInput; 42 - req: express.Request; 43 - res: express.Response; 44 - resetRouteRateLimits: () => Promise<void>; 45 - }; 46 - export type Handler<HA extends HandlerAuth = never> = ( 47 - ctx: HandlerReqCtx<HA>, 48 - ) => Promise<HandlerOutput> | HandlerOutput;
-2
apps/api/src/lexicon/types/app/rocksky/feed/defs.ts
··· 164 164 165 165 export interface FeedView { 166 166 feed?: FeedItemView[]; 167 - /** The pagination cursor for the next set of results. */ 168 - cursor?: string; 169 167 [k: string]: unknown; 170 168 } 171 169
-47
apps/api/src/lexicon/types/app/rocksky/graph/defs.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { type ValidationResult, BlobRef } from "@atproto/lexicon"; 5 - import { lexicons } from "../../../../lexicons"; 6 - import { isObj, hasProp } from "../../../../util"; 7 - import { CID } from "multiformats/cid"; 8 - 9 - /** indicates that a handle or DID could not be resolved */ 10 - export interface NotFoundActor { 11 - actor: string; 12 - notFound: boolean; 13 - [k: string]: unknown; 14 - } 15 - 16 - export function isNotFoundActor(v: unknown): v is NotFoundActor { 17 - return ( 18 - isObj(v) && 19 - hasProp(v, "$type") && 20 - v.$type === "app.rocksky.graph.defs#notFoundActor" 21 - ); 22 - } 23 - 24 - export function validateNotFoundActor(v: unknown): ValidationResult { 25 - return lexicons.validate("app.rocksky.graph.defs#notFoundActor", v); 26 - } 27 - 28 - export interface Relationship { 29 - did: string; 30 - /** if the actor follows this DID, this is the AT-URI of the follow record */ 31 - following?: string; 32 - /** if the actor is followed by this DID, contains the AT-URI of the follow record */ 33 - followedBy?: string; 34 - [k: string]: unknown; 35 - } 36 - 37 - export function isRelationship(v: unknown): v is Relationship { 38 - return ( 39 - isObj(v) && 40 - hasProp(v, "$type") && 41 - v.$type === "app.rocksky.graph.defs#relationship" 42 - ); 43 - } 44 - 45 - export function validateRelationship(v: unknown): ValidationResult { 46 - return lexicons.validate("app.rocksky.graph.defs#relationship", v); 47 - }
-28
apps/api/src/lexicon/types/app/rocksky/graph/follow.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { type ValidationResult, BlobRef } from "@atproto/lexicon"; 5 - import { lexicons } from "../../../../lexicons"; 6 - import { isObj, hasProp } from "../../../../util"; 7 - import { CID } from "multiformats/cid"; 8 - import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef"; 9 - 10 - export interface Record { 11 - createdAt: string; 12 - subject: string; 13 - via?: ComAtprotoRepoStrongRef.Main; 14 - [k: string]: unknown; 15 - } 16 - 17 - export function isRecord(v: unknown): v is Record { 18 - return ( 19 - isObj(v) && 20 - hasProp(v, "$type") && 21 - (v.$type === "app.rocksky.graph.follow#main" || 22 - v.$type === "app.rocksky.graph.follow") 23 - ); 24 - } 25 - 26 - export function validateRecord(v: unknown): ValidationResult { 27 - return lexicons.validate("app.rocksky.graph.follow#main", v); 28 - }
-50
apps/api/src/lexicon/types/app/rocksky/graph/followAccount.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import type express from "express"; 5 - import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 - import { lexicons } from "../../../../lexicons"; 7 - import { isObj, hasProp } from "../../../../util"; 8 - import { CID } from "multiformats/cid"; 9 - import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 - import type * as AppRockskyActorDefs from "../actor/defs"; 11 - 12 - export interface QueryParams { 13 - account: string; 14 - } 15 - 16 - export type InputSchema = undefined; 17 - 18 - export interface OutputSchema { 19 - subject: AppRockskyActorDefs.ProfileViewBasic; 20 - followers: AppRockskyActorDefs.ProfileViewBasic[]; 21 - /** A cursor value to pass to subsequent calls to get the next page of results. */ 22 - cursor?: string; 23 - [k: string]: unknown; 24 - } 25 - 26 - export type HandlerInput = undefined; 27 - 28 - export interface HandlerSuccess { 29 - encoding: "application/json"; 30 - body: OutputSchema; 31 - headers?: { [key: string]: string }; 32 - } 33 - 34 - export interface HandlerError { 35 - status: number; 36 - message?: string; 37 - } 38 - 39 - export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 40 - export type HandlerReqCtx<HA extends HandlerAuth = never> = { 41 - auth: HA; 42 - params: QueryParams; 43 - input: HandlerInput; 44 - req: express.Request; 45 - res: express.Response; 46 - resetRouteRateLimits: () => Promise<void>; 47 - }; 48 - export type Handler<HA extends HandlerAuth = never> = ( 49 - ctx: HandlerReqCtx<HA>, 50 - ) => Promise<HandlerOutput> | HandlerOutput;
-56
apps/api/src/lexicon/types/app/rocksky/graph/getFollowers.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import type express from "express"; 5 - import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 - import { lexicons } from "../../../../lexicons"; 7 - import { isObj, hasProp } from "../../../../util"; 8 - import { CID } from "multiformats/cid"; 9 - import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 - import type * as AppRockskyActorDefs from "../actor/defs"; 11 - 12 - export interface QueryParams { 13 - actor: string; 14 - limit: number; 15 - /** If provided, filters the followers to only include those with DIDs in this list. */ 16 - dids?: string[]; 17 - cursor?: string; 18 - } 19 - 20 - export type InputSchema = undefined; 21 - 22 - export interface OutputSchema { 23 - subject: AppRockskyActorDefs.ProfileViewBasic; 24 - followers: AppRockskyActorDefs.ProfileViewBasic[]; 25 - /** A cursor value to pass to subsequent calls to get the next page of results. */ 26 - cursor?: string; 27 - /** The total number of followers. */ 28 - count?: number; 29 - [k: string]: unknown; 30 - } 31 - 32 - export type HandlerInput = undefined; 33 - 34 - export interface HandlerSuccess { 35 - encoding: "application/json"; 36 - body: OutputSchema; 37 - headers?: { [key: string]: string }; 38 - } 39 - 40 - export interface HandlerError { 41 - status: number; 42 - message?: string; 43 - } 44 - 45 - export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 46 - export type HandlerReqCtx<HA extends HandlerAuth = never> = { 47 - auth: HA; 48 - params: QueryParams; 49 - input: HandlerInput; 50 - req: express.Request; 51 - res: express.Response; 52 - resetRouteRateLimits: () => Promise<void>; 53 - }; 54 - export type Handler<HA extends HandlerAuth = never> = ( 55 - ctx: HandlerReqCtx<HA>, 56 - ) => Promise<HandlerOutput> | HandlerOutput;
-56
apps/api/src/lexicon/types/app/rocksky/graph/getFollows.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import type express from "express"; 5 - import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 - import { lexicons } from "../../../../lexicons"; 7 - import { isObj, hasProp } from "../../../../util"; 8 - import { CID } from "multiformats/cid"; 9 - import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 - import type * as AppRockskyActorDefs from "../actor/defs"; 11 - 12 - export interface QueryParams { 13 - actor: string; 14 - limit: number; 15 - /** If provided, filters the follows to only include those with DIDs in this list. */ 16 - dids?: string[]; 17 - cursor?: string; 18 - } 19 - 20 - export type InputSchema = undefined; 21 - 22 - export interface OutputSchema { 23 - subject: AppRockskyActorDefs.ProfileViewBasic; 24 - follows: AppRockskyActorDefs.ProfileViewBasic[]; 25 - /** A cursor value to pass to subsequent calls to get the next page of results. */ 26 - cursor?: string; 27 - /** The total number of follows. */ 28 - count?: number; 29 - [k: string]: unknown; 30 - } 31 - 32 - export type HandlerInput = undefined; 33 - 34 - export interface HandlerSuccess { 35 - encoding: "application/json"; 36 - body: OutputSchema; 37 - headers?: { [key: string]: string }; 38 - } 39 - 40 - export interface HandlerError { 41 - status: number; 42 - message?: string; 43 - } 44 - 45 - export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 46 - export type HandlerReqCtx<HA extends HandlerAuth = never> = { 47 - auth: HA; 48 - params: QueryParams; 49 - input: HandlerInput; 50 - req: express.Request; 51 - res: express.Response; 52 - resetRouteRateLimits: () => Promise<void>; 53 - }; 54 - export type Handler<HA extends HandlerAuth = never> = ( 55 - ctx: HandlerReqCtx<HA>, 56 - ) => Promise<HandlerOutput> | HandlerOutput;
-52
apps/api/src/lexicon/types/app/rocksky/graph/getKnownFollowers.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import type express from "express"; 5 - import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 - import { lexicons } from "../../../../lexicons"; 7 - import { isObj, hasProp } from "../../../../util"; 8 - import { CID } from "multiformats/cid"; 9 - import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 - import type * as AppRockskyActorDefs from "../actor/defs"; 11 - 12 - export interface QueryParams { 13 - actor: string; 14 - limit: number; 15 - cursor?: string; 16 - } 17 - 18 - export type InputSchema = undefined; 19 - 20 - export interface OutputSchema { 21 - subject: AppRockskyActorDefs.ProfileViewBasic; 22 - followers: AppRockskyActorDefs.ProfileViewBasic[]; 23 - /** A cursor value to pass to subsequent calls to get the next page of results. */ 24 - cursor?: string; 25 - [k: string]: unknown; 26 - } 27 - 28 - export type HandlerInput = undefined; 29 - 30 - export interface HandlerSuccess { 31 - encoding: "application/json"; 32 - body: OutputSchema; 33 - headers?: { [key: string]: string }; 34 - } 35 - 36 - export interface HandlerError { 37 - status: number; 38 - message?: string; 39 - } 40 - 41 - export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 42 - export type HandlerReqCtx<HA extends HandlerAuth = never> = { 43 - auth: HA; 44 - params: QueryParams; 45 - input: HandlerInput; 46 - req: express.Request; 47 - res: express.Response; 48 - resetRouteRateLimits: () => Promise<void>; 49 - }; 50 - export type Handler<HA extends HandlerAuth = never> = ( 51 - ctx: HandlerReqCtx<HA>, 52 - ) => Promise<HandlerOutput> | HandlerOutput;
-50
apps/api/src/lexicon/types/app/rocksky/graph/unfollowAccount.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import type express from "express"; 5 - import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 - import { lexicons } from "../../../../lexicons"; 7 - import { isObj, hasProp } from "../../../../util"; 8 - import { CID } from "multiformats/cid"; 9 - import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 - import type * as AppRockskyActorDefs from "../actor/defs"; 11 - 12 - export interface QueryParams { 13 - account: string; 14 - } 15 - 16 - export type InputSchema = undefined; 17 - 18 - export interface OutputSchema { 19 - subject: AppRockskyActorDefs.ProfileViewBasic; 20 - followers: AppRockskyActorDefs.ProfileViewBasic[]; 21 - /** A cursor value to pass to subsequent calls to get the next page of results. */ 22 - cursor?: string; 23 - [k: string]: unknown; 24 - } 25 - 26 - export type HandlerInput = undefined; 27 - 28 - export interface HandlerSuccess { 29 - encoding: "application/json"; 30 - body: OutputSchema; 31 - headers?: { [key: string]: string }; 32 - } 33 - 34 - export interface HandlerError { 35 - status: number; 36 - message?: string; 37 - } 38 - 39 - export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 40 - export type HandlerReqCtx<HA extends HandlerAuth = never> = { 41 - auth: HA; 42 - params: QueryParams; 43 - input: HandlerInput; 44 - req: express.Request; 45 - res: express.Response; 46 - resetRouteRateLimits: () => Promise<void>; 47 - }; 48 - export type Handler<HA extends HandlerAuth = never> = ( 49 - ctx: HandlerReqCtx<HA>, 50 - ) => Promise<HandlerOutput> | HandlerOutput;
-2
apps/api/src/lexicon/types/app/rocksky/scrobble/getScrobbles.ts
··· 12 12 export interface QueryParams { 13 13 /** The DID or handle of the actor */ 14 14 did?: string; 15 - /** If true, only return scrobbles from actors the viewer is following. */ 16 - following?: boolean; 17 15 /** The maximum number of scrobbles to return */ 18 16 limit?: number; 19 17 /** The offset for pagination */
+2 -33
apps/api/src/lib/agent.ts
··· 1 - import { Agent, AtpAgent } from "@atproto/api"; 1 + import { Agent } from "@atproto/api"; 2 2 import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 - import extractPdsFromDid from "./extractPdsFromDid"; 4 - import { ctx } from "context"; 5 3 6 4 export async function createAgent( 7 5 oauthClient: NodeOAuthClient, 8 6 did: string, 9 7 ): Promise<Agent | null> { 10 - let agent: Agent | null = null; 8 + let agent = null; 11 9 let retry = 0; 12 10 do { 13 11 try { 14 - const result = await ctx.sqliteDb 15 - .selectFrom("auth_session") 16 - .selectAll() 17 - .where("key", "=", `atp:${did}`) 18 - .executeTakeFirst(); 19 - if (result) { 20 - let pds = await ctx.redis.get(`pds:${did}`); 21 - if (!pds) { 22 - pds = await extractPdsFromDid(did); 23 - await ctx.redis.setEx(`pds:${did}`, 60 * 15, pds); 24 - } 25 - const atpAgent = new AtpAgent({ 26 - service: new URL(pds), 27 - }); 28 - 29 - try { 30 - await atpAgent.resumeSession(JSON.parse(result.session)); 31 - } catch (e) { 32 - console.log("Error resuming session"); 33 - console.log(did); 34 - console.log(e); 35 - await ctx.sqliteDb 36 - .deleteFrom("auth_session") 37 - .where("key", "=", `atp:${did}`) 38 - .execute(); 39 - } 40 - 41 - return atpAgent; 42 - } 43 12 const oauthSession = await oauthClient.restore(did); 44 13 agent = oauthSession ? new Agent(oauthSession) : null; 45 14 if (agent === null) {
-1
apps/api/src/lib/env.ts
··· 41 41 PRIVATE_KEY_3: str({}), 42 42 MEILISEARCH_URL: str({ devDefault: "http://localhost:7700" }), 43 43 MEILISEARCH_API_KEY: str({}), 44 - DISABLED_TEALFM: str({ default: "" }), 45 44 });
-33
apps/api/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 - }
+4 -32
apps/api/src/lovedtracks/lovedtracks.service.ts
··· 1 - import { AtpAgent, type Agent } from "@atproto/api"; 1 + import type { Agent } from "@atproto/api"; 2 2 import { TID } from "@atproto/common"; 3 3 import type { Context } from "context"; 4 4 import { and, desc, eq, type SQLWrapper } from "drizzle-orm"; ··· 248 248 249 249 if (trackWithUri?.uri) { 250 250 const rkey = TID.nextStr(); 251 - const subjectAgent = new AtpAgent({ 252 - service: new URL("https://bsky.social"), 253 - }); 254 - const subjectRecord = await subjectAgent.com.atproto.repo.getRecord({ 251 + const subjectRecord = await agent.com.atproto.repo.getRecord({ 255 252 repo: trackWithUri.uri.split("/").slice(0, 3).join("/").split("at://")[1], 256 253 collection: "app.rocksky.song", 257 254 rkey: trackWithUri.uri.split("/").pop(), ··· 297 294 } 298 295 } 299 296 300 - const lovedTrack = await ctx.db 301 - .select() 302 - .from(lovedTracks) 303 - .where( 304 - and(eq(lovedTracks.userId, user.id), eq(lovedTracks.trackId, trackId)), 305 - ) 306 - .limit(1) 307 - .then((rows) => rows[0]); 308 - 309 - const message = JSON.stringify({ 310 - uri: lovedTrack.uri, 311 - user_id: { xata_id: user.id }, 312 - track_id: { xata_id: trackId }, 313 - xata_createdat: lovedTrack.createdAt.toISOString(), 314 - xata_id: lovedTrack.id, 315 - xata_updatedat: lovedTrack.createdAt.toISOString(), 316 - xata_version: 0, 317 - }); 297 + const message = JSON.stringify(created); 318 298 ctx.nc.publish("rocksky.like", Buffer.from(message)); 319 299 320 300 return created; ··· 363 343 ctx.db.delete(lovedTracks).where(eq(lovedTracks.id, lovedTrack.id)), 364 344 ]); 365 345 366 - const message = JSON.stringify({ 367 - uri: lovedTrack.uri, 368 - user_id: { xata_id: user.id }, 369 - track_id: { xata_id: track.id }, 370 - xata_createdat: lovedTrack.createdAt.toISOString(), 371 - xata_id: lovedTrack.id, 372 - xata_updatedat: lovedTrack.createdAt.toISOString(), 373 - xata_version: 0, 374 - }); 346 + const message = JSON.stringify(lovedTrack); 375 347 ctx.nc.publish("rocksky.unlike", Buffer.from(message)); 376 348 } 377 349
-33
apps/api/src/schema/follows.ts
··· 1 - import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; 2 - import { sql } from "drizzle-orm"; 3 - import { 4 - integer, 5 - pgTable, 6 - text, 7 - timestamp, 8 - uniqueIndex, 9 - } from "drizzle-orm/pg-core"; 10 - 11 - const follows = pgTable( 12 - "follows", 13 - { 14 - id: text("xata_id").primaryKey().default(sql`xata_id()`), 15 - uri: text("uri").notNull().unique(), 16 - follower_did: text("follower_did").notNull(), 17 - subject_did: text("subject_did").notNull(), 18 - xataVersion: integer("xata_version"), 19 - createdAt: timestamp("xata_createdat").defaultNow().notNull(), 20 - updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 21 - }, 22 - (t) => [ 23 - uniqueIndex("follows_follower_subject_unique").on( 24 - t.follower_did, 25 - t.subject_did, 26 - ), 27 - ], 28 - ); 29 - 30 - export type SelectFollows = InferSelectModel<typeof follows>; 31 - export type InsertFollows = InferInsertModel<typeof follows>; 32 - 33 - export default follows;
-2
apps/api/src/schema/index.ts
··· 10 10 import dropboxPaths from "./dropbox-paths"; 11 11 import dropboxTokens from "./dropbox-tokens"; 12 12 import feeds from "./feeds"; 13 - import follows from "./follows"; 14 13 import googleDriveAccounts from "./google-drive-accounts"; 15 14 import googleDriveDirectories from "./google-drive-directories"; 16 15 import googleDrivePaths from "./google-drive-paths"; ··· 71 70 googleDrive, 72 71 queueTracks, 73 72 feeds, 74 - follows, 75 73 };
+4 -1
apps/api/src/scripts/avatar.ts
··· 55 55 }; 56 56 57 57 console.log(userPayload); 58 - ctx.nc.publish("rocksky.user", Buffer.from(JSON.stringify(userPayload))); 58 + await ctx.nc.publish( 59 + "rocksky.user", 60 + Buffer.from(JSON.stringify(userPayload)), 61 + ); 59 62 } 60 63 61 64 if (args.length > 0) {
-25
apps/api/src/scripts/likes.ts
··· 1 - import { ctx } from "context"; 2 - import lovedTracks from "../schema/loved-tracks"; 3 - import chalk from "chalk"; 4 - 5 - const likes = await ctx.db.select().from(lovedTracks).execute(); 6 - 7 - for (const like of likes) { 8 - const message = JSON.stringify({ 9 - uri: like.uri, 10 - user_id: { xata_id: like.userId }, 11 - track_id: { xata_id: like.trackId }, 12 - xata_createdat: like.createdAt.toISOString(), 13 - xata_id: like.id, 14 - xata_updatedat: like.createdAt.toISOString(), 15 - xata_version: 0, 16 - }); 17 - console.log("Publishing like:", chalk.cyanBright(like.uri)); 18 - ctx.nc.publish("rocksky.like", Buffer.from(message)); 19 - } 20 - 21 - await ctx.nc.flush(); 22 - 23 - console.log("Done"); 24 - 25 - process.exit(0);
-6
apps/api/src/tealfm/index.ts
··· 4 4 import type * as Status from "lexicon/types/fm/teal/alpha/actor/status"; 5 5 import type { PlayView } from "lexicon/types/fm/teal/alpha/feed/defs"; 6 6 import * as Play from "lexicon/types/fm/teal/alpha/feed/play"; 7 - import { env } from "lib/env"; 8 7 import type { MusicbrainzTrack } from "types/track"; 9 8 10 9 const SUBMISSION_CLIENT_AGENT = "rocksky/v0.0.1"; ··· 23 22 track: MusicbrainzTrack, 24 23 duration: number, 25 24 ) { 26 - if (env.DISABLED_TEALFM.includes(agent.assertDid)) { 27 - console.log(`teal.fm is disabled for ${chalk.cyanBright(agent.assertDid)}`); 28 - return; 29 - } 30 - 31 25 try { 32 26 // wait 60 seconds to ensure the track is actually being played 33 27 await new Promise((resolve) => setTimeout(resolve, 60000));
+1 -1
apps/api/src/xrpc/app/rocksky/actor/getActorAlbums.ts
··· 6 6 import { deepCamelCaseKeys } from "lib"; 7 7 8 8 export default function (server: Server, ctx: Context) { 9 - const getActorAlbums = (params: QueryParams) => 9 + const getActorAlbums = (params) => 10 10 pipe( 11 11 { params, ctx }, 12 12 retrieve,
+1 -1
apps/api/src/xrpc/app/rocksky/actor/getActorArtists.ts
··· 6 6 import { deepCamelCaseKeys } from "lib"; 7 7 8 8 export default function (server: Server, ctx: Context) { 9 - const getActorArtists = (params: QueryParams) => 9 + const getActorArtists = (params) => 10 10 pipe( 11 11 { params, ctx }, 12 12 retrieve,
-108
apps/api/src/xrpc/app/rocksky/actor/getActorCompatibility.ts
··· 1 - import type { Context } from "context"; 2 - import { Effect, pipe } from "effect"; 3 - import type { Server } from "lexicon"; 4 - import type { QueryParams } from "lexicon/types/app/rocksky/actor/getActorCompatibility"; 5 - import type { CompatibilityViewBasic } from "lexicon/types/app/rocksky/actor/defs"; 6 - import { deepCamelCaseKeys } from "lib"; 7 - import users from "schema/users"; 8 - import { eq, or } from "drizzle-orm"; 9 - import { HandlerAuth } from "@atproto/xrpc-server"; 10 - 11 - export default function (server: Server, ctx: Context) { 12 - const getActorCompatibility = (params: QueryParams, auth: HandlerAuth) => 13 - pipe( 14 - { params, ctx, did: auth.credentials?.did }, 15 - retrieve, 16 - Effect.flatMap(presentation), 17 - Effect.retry({ times: 3 }), 18 - Effect.timeout("120 seconds"), 19 - Effect.catchAll((err) => { 20 - console.error(err); 21 - return Effect.succeed({ comptibility: null }); 22 - }), 23 - ); 24 - server.app.rocksky.actor.getActorCompatibility({ 25 - auth: ctx.authVerifier, 26 - handler: async ({ params, auth }) => { 27 - const result = await Effect.runPromise( 28 - getActorCompatibility(params, auth), 29 - ); 30 - return { 31 - encoding: "application/json", 32 - body: result, 33 - }; 34 - }, 35 - }); 36 - } 37 - 38 - const retrieve = ({ 39 - params, 40 - ctx, 41 - did, 42 - }: { 43 - params: QueryParams; 44 - ctx: Context; 45 - did: string | undefined; 46 - }): Effect.Effect<{ data: Compatibility[] }, Error> => { 47 - return Effect.tryPromise({ 48 - try: async () => { 49 - if (!did) { 50 - throw new Error(`User not authenticated`); 51 - } 52 - 53 - const user1 = await ctx.db 54 - .select() 55 - .from(users) 56 - .where(eq(users.did, did)) 57 - .execute() 58 - .then((rows) => rows[0]); 59 - 60 - if (!user1) { 61 - throw new Error(`User1 not found`); 62 - } 63 - 64 - const user2 = await ctx.db 65 - .select() 66 - .from(users) 67 - .where(or(eq(users.did, params.did), eq(users.handle, params.did))) 68 - .execute() 69 - .then((rows) => rows[0]); 70 - 71 - if (!user2) { 72 - throw new Error(`User2 not found`); 73 - } 74 - 75 - return ctx.analytics.post("library.getCompatibility", { 76 - user_id1: user1.id, 77 - user_id2: user2.id, 78 - }); 79 - }, 80 - catch: (error) => new Error(`Failed to retrieve compatibility: ${error}`), 81 - }); 82 - }; 83 - 84 - const presentation = ({ 85 - data, 86 - }: { 87 - data: Compatibility[]; 88 - }): Effect.Effect<{ compatibility: CompatibilityViewBasic }, never> => { 89 - return Effect.sync(() => ({ compatibility: deepCamelCaseKeys(data) })); 90 - }; 91 - 92 - type Compatibility = { 93 - compatibility_level: number; 94 - compatibility_percentage: number; 95 - shared_artists: number; 96 - top_shared_artists: string[]; 97 - top_shared_detailed_artists: { 98 - id: string; 99 - name: string; 100 - picture: string; 101 - uri: string; 102 - user1_rank: number; 103 - user2_rank: number; 104 - weight: number; 105 - }[]; 106 - user1_artist_count: number; 107 - user2_artist_count: number; 108 - };
+5 -8
apps/api/src/xrpc/app/rocksky/actor/getActorLovedSongs.ts
··· 1 1 import type { Context } from "context"; 2 - import { and, desc, eq, not, or } from "drizzle-orm"; 2 + import { desc, eq, or } from "drizzle-orm"; 3 3 import { Effect, pipe } from "effect"; 4 4 import type { Server } from "lexicon"; 5 5 import type { QueryParams } from "lexicon/types/app/rocksky/actor/getActorLovedSongs"; ··· 8 8 import type { SelectTrack } from "schema/tracks"; 9 9 10 10 export default function (server: Server, ctx: Context) { 11 - const getActorLovedSongs = (params: QueryParams) => 11 + const getActorLovedSongs = (params) => 12 12 pipe( 13 13 { params, ctx }, 14 14 retrieve, ··· 49 49 ) 50 50 .leftJoin(tables.users, eq(tables.lovedTracks.userId, tables.users.id)) 51 51 .where( 52 - and( 53 - or( 54 - eq(tables.users.did, params.did), 55 - eq(tables.users.handle, params.did), 56 - ), 57 - not(eq(tables.lovedTracks.uri, null)), 52 + or( 53 + eq(tables.users.did, params.did), 54 + eq(tables.users.handle, params.did), 58 55 ), 59 56 ) 60 57 .limit(params.limit ?? 10)
-86
apps/api/src/xrpc/app/rocksky/actor/getActorNeighbours.ts
··· 1 - import type { Context } from "context"; 2 - import { Effect, pipe } from "effect"; 3 - import type { Server } from "lexicon"; 4 - import type { QueryParams } from "lexicon/types/app/rocksky/actor/getActorNeighbours"; 5 - import type { NeighbourViewBasic } from "lexicon/types/app/rocksky/actor/defs"; 6 - import { deepCamelCaseKeys } from "lib"; 7 - import users from "schema/users"; 8 - import { eq, or } from "drizzle-orm"; 9 - 10 - export default function (server: Server, ctx: Context) { 11 - const getActorNeighbours = (params: QueryParams) => 12 - pipe( 13 - { params, ctx }, 14 - retrieve, 15 - Effect.flatMap(presentation), 16 - Effect.retry({ times: 3 }), 17 - Effect.timeout("120 seconds"), 18 - Effect.catchAll((err) => { 19 - console.error(err); 20 - return Effect.succeed({ neighbours: [] }); 21 - }), 22 - ); 23 - server.app.rocksky.actor.getActorNeighbours({ 24 - handler: async ({ params }) => { 25 - const result = await Effect.runPromise(getActorNeighbours(params)); 26 - return { 27 - encoding: "application/json", 28 - body: result, 29 - }; 30 - }, 31 - }); 32 - } 33 - 34 - const retrieve = ({ 35 - params, 36 - ctx, 37 - }: { 38 - params: QueryParams; 39 - ctx: Context; 40 - }): Effect.Effect<{ data: Neighbour[] }, Error> => { 41 - return Effect.tryPromise({ 42 - try: async () => { 43 - const user = await ctx.db 44 - .select() 45 - .from(users) 46 - .where(or(eq(users.did, params.did), eq(users.handle, params.did))) 47 - .execute() 48 - .then((rows) => rows[0]); 49 - 50 - if (!user) { 51 - throw new Error(`User not found`); 52 - } 53 - 54 - return ctx.analytics.post("library.getNeighbours", { 55 - user_id: user.id, 56 - }); 57 - }, 58 - catch: (error) => new Error(`Failed to retrieve neighbours: ${error}`), 59 - }); 60 - }; 61 - 62 - const presentation = ({ 63 - data, 64 - }: { 65 - data: Neighbour[]; 66 - }): Effect.Effect<{ neighbours: NeighbourViewBasic[] }, never> => { 67 - return Effect.sync(() => ({ neighbours: deepCamelCaseKeys(data) })); 68 - }; 69 - 70 - type Neighbour = { 71 - id: string; 72 - avatar: string; 73 - did: string; 74 - displayName: string; 75 - handle: string; 76 - sharedArtistsCount: number; 77 - similarityScore: number; 78 - topSharedArtistNames: string[]; 79 - topSharedArtistsDetails: { 80 - id: string; 81 - name: string; 82 - picture: string; 83 - uri: string; 84 - }[]; 85 - userId: string; 86 - };
+1 -1
apps/api/src/xrpc/app/rocksky/actor/getActorPlaylists.ts
··· 8 8 import type { SelectPlaylist } from "schema/playlists"; 9 9 10 10 export default function (server: Server, ctx: Context) { 11 - const getActorPlaylists = (params: QueryParams) => 11 + const getActorPlaylists = (params) => 12 12 pipe( 13 13 { params, ctx }, 14 14 retrieve,
+1 -1
apps/api/src/xrpc/app/rocksky/actor/getActorScrobbles.ts
··· 6 6 import { deepCamelCaseKeys } from "lib"; 7 7 8 8 export default function (server: Server, ctx: Context) { 9 - const getActorScrobbles = (params: QueryParams) => 9 + const getActorScrobbles = (params) => 10 10 pipe( 11 11 { params, ctx }, 12 12 retrieve,
+1 -1
apps/api/src/xrpc/app/rocksky/actor/getActorSongs.ts
··· 6 6 import { deepCamelCaseKeys } from "lib"; 7 7 8 8 export default function (server: Server, ctx: Context) { 9 - const getActorSongs = (params: QueryParams) => 9 + const getActorSongs = (params) => 10 10 pipe( 11 11 { params, ctx }, 12 12 retrieve,
+1 -1
apps/api/src/xrpc/app/rocksky/actor/getProfile.ts
··· 18 18 import type { SelectUser } from "schema/users"; 19 19 20 20 export default function (server: Server, ctx: Context) { 21 - const getActorProfile = (params: QueryParams, auth: HandlerAuth) => 21 + const getActorProfile = (params, auth: HandlerAuth) => 22 22 pipe( 23 23 { params, ctx, did: auth.credentials?.did }, 24 24 resolveHandleToDid,
+23 -61
apps/api/src/xrpc/app/rocksky/feed/getFeed.ts
··· 10 10 import type { SelectTrack } from "schema/tracks"; 11 11 import type { SelectUser } from "schema/users"; 12 12 import axios from "axios"; 13 - import type { HandlerAuth } from "@atproto/xrpc-server"; 13 + import { HandlerAuth } from "@atproto/xrpc-server"; 14 14 import { env } from "lib/env"; 15 15 16 16 export default function (server: Server, ctx: Context) { ··· 62 62 ? "http://localhost:8002" 63 63 : `https://${feed.did.split("did:web:")[1]}`; 64 64 const response = await axios.get<{ 65 - cursor?: string; 65 + cusrsor: string; 66 66 feed: { scrobble: string }[]; 67 67 }>(`${feedUrl}/xrpc/app.rocksky.feed.getFeedSkeleton`, { 68 68 params: { ··· 73 73 }); 74 74 return { 75 75 uris: response.data.feed.map(({ scrobble }) => scrobble), 76 - cursor: response.data.cursor, 77 76 ctx, 78 77 did, 79 78 }; ··· 84 83 85 84 const hydrate = ({ 86 85 uris, 87 - cursor, 88 86 ctx, 89 87 did, 90 88 }: { 91 89 uris: string[]; 92 - cursor?: string; 93 90 ctx: Context; 94 91 did?: string; 95 - }): Effect.Effect<ScrobblesWithCursor | undefined, Error> => { 92 + }): Effect.Effect<Scrobbles | undefined, Error> => { 96 93 return Effect.tryPromise({ 97 94 try: async () => { 98 95 const scrobbles = await ctx.db ··· 131 128 liked: likesMap.get(row.tracks?.id)?.liked ?? false, 132 129 })); 133 130 134 - if (did) { 135 - const [u] = await ctx.db 136 - .select() 137 - .from(tables.users) 138 - .where(eq(tables.users.did, did)) 139 - .limit(1) 140 - .execute(); 141 - 142 - const userPayload = { 143 - xata_id: u.id, 144 - did: u.did, 145 - handle: u.handle, 146 - display_name: u.displayName, 147 - avatar: u.avatar, 148 - xata_createdat: u.createdAt.toISOString(), 149 - xata_updatedat: u.updatedAt.toISOString(), 150 - xata_version: u.xataVersion, 151 - }; 152 - 153 - ctx.nc.publish( 154 - "rocksky.user", 155 - Buffer.from(JSON.stringify(userPayload)), 156 - ); 157 - } 158 - 159 - return { scrobbles: result, cursor }; 131 + return result; 160 132 }, 161 133 162 134 catch: (error) => new Error(`Failed to hydrate feed: ${error}`), 163 135 }); 164 136 }; 165 137 166 - const presentation = ( 167 - data: ScrobblesWithCursor, 168 - ): Effect.Effect<FeedView, never> => { 138 + const presentation = (data: Scrobbles): Effect.Effect<FeedView, never> => { 169 139 return Effect.sync(() => ({ 170 - feed: data.scrobbles.map( 171 - ({ scrobbles, tracks, users, likesCount, liked }) => ({ 172 - scrobble: { 173 - ...R.omit(["albumArt", "id", "lyrics"])(tracks), 174 - cover: tracks.albumArt, 175 - date: scrobbles.timestamp.toISOString(), 176 - user: users.handle, 177 - userDisplayName: users.displayName, 178 - userAvatar: users.avatar, 179 - uri: scrobbles.uri, 180 - tags: [], 181 - likesCount, 182 - liked, 183 - trackUri: tracks.uri, 184 - createdAt: scrobbles.createdAt.toISOString(), 185 - updatedAt: scrobbles.updatedAt.toISOString(), 186 - id: scrobbles.id, 187 - }, 188 - }), 189 - ), 190 - cursor: data.cursor, 140 + feed: data.map(({ scrobbles, tracks, users, likesCount, liked }) => ({ 141 + scrobble: { 142 + ...R.omit(["albumArt", "id", "lyrics"])(tracks), 143 + cover: tracks.albumArt, 144 + date: scrobbles.timestamp.toISOString(), 145 + user: users.handle, 146 + userDisplayName: users.displayName, 147 + userAvatar: users.avatar, 148 + uri: scrobbles.uri, 149 + tags: [], 150 + likesCount, 151 + liked, 152 + trackUri: tracks.uri, 153 + createdAt: scrobbles.createdAt.toISOString(), 154 + updatedAt: scrobbles.updatedAt.toISOString(), 155 + id: scrobbles.id, 156 + }, 157 + })), 191 158 })); 192 159 }; 193 160 ··· 198 165 likesCount: number; 199 166 liked: boolean; 200 167 }[]; 201 - 202 - type ScrobblesWithCursor = { 203 - scrobbles: Scrobbles; 204 - cursor?: string; 205 - };
-173
apps/api/src/xrpc/app/rocksky/graph/followAccount.ts
··· 1 - import { TID } from "@atproto/common"; 2 - import type { HandlerAuth } from "@atproto/xrpc-server"; 3 - import type { Context } from "context"; 4 - import { and, eq, desc } from "drizzle-orm"; 5 - import { Effect, pipe } from "effect"; 6 - import type { Server } from "lexicon"; 7 - import type { ProfileViewBasic } from "lexicon/types/app/rocksky/actor/defs"; 8 - import type { QueryParams } from "lexicon/types/app/rocksky/graph/followAccount"; 9 - import { createAgent } from "lib/agent"; 10 - import tables from "schema"; 11 - import type { SelectUser } from "schema/users"; 12 - import * as FollowLexicon from "lexicon/types/app/rocksky/graph/follow"; 13 - 14 - export default function (server: Server, ctx: Context) { 15 - const followAccount = (params: QueryParams, auth: HandlerAuth) => 16 - pipe( 17 - { params, ctx, did: auth.credentials?.did }, 18 - handleFollow, 19 - Effect.flatMap(presentation), 20 - Effect.retry({ times: 3 }), 21 - Effect.timeout("120 seconds"), 22 - Effect.catchAll((err) => { 23 - console.error(err); 24 - return Effect.succeed({ 25 - subject: {} satisfies ProfileViewBasic, 26 - followers: [], 27 - }); 28 - }), 29 - ); 30 - server.app.rocksky.graph.followAccount({ 31 - auth: ctx.authVerifier, 32 - handler: async ({ params, auth }) => { 33 - const result = await Effect.runPromise(followAccount(params, auth)); 34 - return { 35 - encoding: "application/json", 36 - body: result, 37 - }; 38 - }, 39 - }); 40 - } 41 - 42 - const handleFollow = ({ 43 - params, 44 - ctx, 45 - did, 46 - }: { 47 - params: QueryParams; 48 - ctx: Context; 49 - did?: string; 50 - }): Effect.Effect<[SelectUser | undefined, SelectUser[]], Error> => { 51 - return Effect.tryPromise({ 52 - try: async () => { 53 - if (!did) { 54 - throw new Error("User is not authenticated"); 55 - } 56 - if (params.account === did) { 57 - throw new Error("User cannot follow themselves"); 58 - } 59 - 60 - if (await isFollowing(ctx, did, params.account)) { 61 - throw new Error("User is already following"); 62 - } 63 - 64 - const agent = await createAgent(ctx.oauthClient, did); 65 - if (!agent) { 66 - throw new Error("Unauthorized"); 67 - } 68 - 69 - const rkey = TID.nextStr(); 70 - 71 - const record = { 72 - $type: "app.rocksky.graph.follow", 73 - subject: params.account, 74 - createdAt: new Date().toISOString(), 75 - }; 76 - 77 - if (!FollowLexicon.validateRecord(record).success) { 78 - console.log(FollowLexicon.validateRecord(record)); 79 - throw new Error("Invalid record"); 80 - } 81 - 82 - const res = await agent.com.atproto.repo.createRecord({ 83 - repo: agent.assertDid, 84 - collection: "app.rocksky.graph.follow", 85 - rkey, 86 - record, 87 - validate: false, 88 - }); 89 - const uri = res.data.uri; 90 - console.log(`Follow record created at: ${uri}`); 91 - 92 - await ctx.db 93 - .insert(tables.follows) 94 - .values({ 95 - uri, 96 - subject_did: params.account, 97 - follower_did: did, 98 - }) 99 - .onConflictDoNothing() 100 - .execute(); 101 - 102 - return Promise.all([ 103 - ctx.db 104 - .select() 105 - .from(tables.users) 106 - .where(eq(tables.users.did, params.account)) 107 - .execute() 108 - .then((rows) => rows[0]), 109 - ctx.db 110 - .select() 111 - .from(tables.follows) 112 - .where(eq(tables.follows.subject_did, params.account)) 113 - .leftJoin( 114 - tables.users, 115 - eq(tables.users.did, tables.follows.follower_did), 116 - ) 117 - .orderBy(desc(tables.follows.createdAt)) 118 - .limit(50) 119 - .execute() 120 - .then((rows) => rows.map(({ users }) => users)), 121 - ]); 122 - }, 123 - catch: (error) => new Error(`Failed to retrieve follow: ${error}`), 124 - }); 125 - }; 126 - 127 - const presentation = ([user, followers]: [ 128 - SelectUser | undefined, 129 - SelectUser[], 130 - ]): Effect.Effect< 131 - { subject: ProfileViewBasic; followers: ProfileViewBasic[] }, 132 - never 133 - > => { 134 - return Effect.sync(() => ({ 135 - subject: { 136 - id: user?.id, 137 - did: user?.did, 138 - handle: user?.handle, 139 - displayName: user?.displayName, 140 - avatar: user?.avatar, 141 - createdAt: user?.createdAt.toISOString(), 142 - updatedAt: user?.updatedAt.toISOString(), 143 - }, 144 - followers: followers.map((follower) => ({ 145 - id: follower.id, 146 - did: follower.did, 147 - handle: follower.handle, 148 - displayName: follower.displayName, 149 - avatar: follower.avatar, 150 - createdAt: follower.createdAt.toISOString(), 151 - updatedAt: follower.updatedAt.toISOString(), 152 - })), 153 - })); 154 - }; 155 - 156 - const isFollowing = async ( 157 - ctx: Context, 158 - followerDid: string, 159 - subjectDid: string, 160 - ): Promise<boolean> => { 161 - const result = await ctx.db 162 - .select() 163 - .from(tables.follows) 164 - .where( 165 - and( 166 - eq(tables.follows.follower_did, followerDid), 167 - eq(tables.follows.subject_did, subjectDid), 168 - ), 169 - ) 170 - .execute(); 171 - 172 - return result.length > 0; 173 - };
-179
apps/api/src/xrpc/app/rocksky/graph/getFollowers.ts
··· 1 - import type { Context } from "context"; 2 - import { eq, desc, and, lt, inArray, count } from "drizzle-orm"; 3 - import { Effect, pipe } from "effect"; 4 - import type { Server } from "lexicon"; 5 - import type { QueryParams } from "lexicon/types/app/rocksky/graph/getFollowers"; 6 - import type { ProfileViewBasic } from "lexicon/types/app/rocksky/actor/defs"; 7 - import tables from "schema"; 8 - import type { SelectUser } from "schema/users"; 9 - 10 - export default function (server: Server, ctx: Context) { 11 - const getFollowers = (params: QueryParams) => 12 - pipe( 13 - { params, ctx }, 14 - retrieve, 15 - Effect.flatMap(presentation), 16 - Effect.retry({ times: 3 }), 17 - Effect.timeout("120 seconds"), 18 - Effect.catchAll((err) => { 19 - console.error(err); 20 - return Effect.succeed({ 21 - subject: {} satisfies ProfileViewBasic, 22 - followers: [] as ProfileViewBasic[], 23 - count: 0, 24 - }); 25 - }), 26 - ); 27 - server.app.rocksky.graph.getFollowers({ 28 - handler: async ({ params }) => { 29 - const result = await Effect.runPromise(getFollowers(params)); 30 - return { 31 - encoding: "application/json", 32 - body: result, 33 - }; 34 - }, 35 - }); 36 - } 37 - 38 - const retrieve = ({ 39 - params, 40 - ctx, 41 - }: { 42 - params: QueryParams; 43 - ctx: Context; 44 - }): Effect.Effect< 45 - [SelectUser | undefined, SelectUser[], string | undefined, number], 46 - Error 47 - > => { 48 - return Effect.tryPromise({ 49 - try: () => 50 - Promise.all([ 51 - ctx.db 52 - .select() 53 - .from(tables.users) 54 - .where(eq(tables.users.did, params.actor)) 55 - .execute() 56 - .then((rows) => rows[0]), 57 - ctx.db 58 - .select() 59 - .from(tables.follows) 60 - .where( 61 - params.cursor 62 - ? and( 63 - ...[ 64 - lt( 65 - tables.follows.createdAt, 66 - new Date(Number(params.cursor)), 67 - ), 68 - eq(tables.follows.subject_did, params.actor), 69 - params.dids && params.dids?.length > 0 70 - ? inArray(tables.follows.follower_did, params.dids) 71 - : undefined, 72 - ], 73 - ) 74 - : and( 75 - ...[ 76 - eq(tables.follows.subject_did, params.actor), 77 - params.dids && params.dids?.length > 0 78 - ? inArray(tables.follows.follower_did, params.dids) 79 - : undefined, 80 - ], 81 - ), 82 - ) 83 - .leftJoin( 84 - tables.users, 85 - eq(tables.users.did, tables.follows.follower_did), 86 - ) 87 - .orderBy(desc(tables.follows.createdAt)) 88 - .limit(params.limit ?? 50) 89 - .execute() 90 - .then((rows) => rows.map(({ users }) => users)), 91 - ctx.db 92 - .select() 93 - .from(tables.follows) 94 - .where( 95 - params.cursor 96 - ? and( 97 - ...[ 98 - lt( 99 - tables.follows.createdAt, 100 - new Date(Number(params.cursor)), 101 - ), 102 - eq(tables.follows.subject_did, params.actor), 103 - params.dids && 104 - params.dids?.length > 0 && 105 - inArray(tables.follows.follower_did, params.dids), 106 - ], 107 - ) 108 - : and( 109 - ...[ 110 - eq(tables.follows.subject_did, params.actor), 111 - params.dids && params.dids?.length > 0 112 - ? inArray(tables.follows.follower_did, params.dids) 113 - : undefined, 114 - ], 115 - ), 116 - ) 117 - .orderBy(desc(tables.follows.createdAt)) 118 - .limit(params.limit ?? 50) 119 - .execute() 120 - .then((rows) => 121 - rows.length > 0 122 - ? rows[rows.length - 1]?.createdAt.getTime().toString(10) 123 - : undefined, 124 - ), 125 - ctx.db 126 - .select({ count: count() }) 127 - .from(tables.follows) 128 - .where( 129 - params.dids && params.dids?.length > 0 130 - ? and( 131 - eq(tables.follows.subject_did, params.actor), 132 - inArray(tables.follows.follower_did, params.dids), 133 - ) 134 - : eq(tables.follows.subject_did, params.actor), 135 - ) 136 - .execute() 137 - .then((rows) => rows[0]?.count ?? 0), 138 - ]), 139 - catch: (error) => new Error(`Failed to retrieve user followers: ${error}`), 140 - }); 141 - }; 142 - 143 - const presentation = ([user, followers, cursor, totalCount]: [ 144 - SelectUser | undefined, 145 - SelectUser[], 146 - string | undefined, 147 - number, 148 - ]): Effect.Effect< 149 - { 150 - subject: ProfileViewBasic; 151 - followers: ProfileViewBasic[]; 152 - cursor?: string; 153 - count: number; 154 - }, 155 - never 156 - > => { 157 - return Effect.sync(() => ({ 158 - subject: { 159 - id: user?.id, 160 - did: user?.did, 161 - handle: user?.handle, 162 - displayName: user?.displayName, 163 - avatar: user?.avatar, 164 - createdAt: user?.createdAt.toISOString(), 165 - updatedAt: user?.updatedAt.toISOString(), 166 - }, 167 - followers: followers.map((follower) => ({ 168 - id: follower.id, 169 - did: follower.did, 170 - handle: follower.handle, 171 - displayName: follower.displayName, 172 - avatar: follower.avatar, 173 - createdAt: follower.createdAt.toISOString(), 174 - updatedAt: follower.updatedAt.toISOString(), 175 - })), 176 - cursor, 177 - count: totalCount, 178 - })); 179 - };
-162
apps/api/src/xrpc/app/rocksky/graph/getFollows.ts
··· 1 - import type { Context } from "context"; 2 - import { eq, desc, and, lt, inArray, count } from "drizzle-orm"; 3 - import { Effect, pipe } from "effect"; 4 - import type { Server } from "lexicon"; 5 - import type { QueryParams } from "lexicon/types/app/rocksky/graph/getFollowers"; 6 - import type { ProfileViewBasic } from "lexicon/types/app/rocksky/actor/defs"; 7 - import tables from "schema"; 8 - import type { SelectUser } from "schema/users"; 9 - 10 - export default function (server: Server, ctx: Context) { 11 - const getFollows = (params: QueryParams) => 12 - pipe( 13 - { params, ctx }, 14 - retrieve, 15 - Effect.flatMap(([user, follows, cursor, totalCount]) => 16 - presentation(user, follows, cursor, totalCount), 17 - ), 18 - Effect.retry({ times: 3 }), 19 - Effect.timeout("120 seconds"), 20 - Effect.catchAll((err) => { 21 - console.error(err); 22 - return Effect.succeed({ 23 - subject: undefined, 24 - follows: [], 25 - count: 0, 26 - }); 27 - }), 28 - ); 29 - server.app.rocksky.graph.getFollows({ 30 - handler: async ({ params }) => { 31 - const result = await Effect.runPromise(getFollows(params)); 32 - return { 33 - encoding: "application/json", 34 - body: result, 35 - }; 36 - }, 37 - }); 38 - } 39 - 40 - const retrieve = ({ 41 - params, 42 - ctx, 43 - }: { 44 - params: QueryParams; 45 - ctx: Context; 46 - }): Effect.Effect< 47 - [SelectUser | undefined, SelectUser[], string | undefined, number], 48 - Error 49 - > => { 50 - // Build where conditions dynamically 51 - const buildWhereConditions = () => { 52 - const conditions = [eq(tables.follows.follower_did, params.actor)]; 53 - 54 - if (params.cursor) { 55 - conditions.push( 56 - lt(tables.follows.createdAt, new Date(Number(params.cursor))), 57 - ); 58 - } 59 - 60 - if (params.dids && params.dids.length > 0) { 61 - conditions.push(inArray(tables.follows.subject_did, params.dids)); 62 - } 63 - 64 - return conditions.length > 1 ? and(...conditions) : conditions[0]; 65 - }; 66 - 67 - const whereConditions = buildWhereConditions(); 68 - 69 - // Build where conditions for count (without cursor) 70 - const buildCountWhereConditions = () => { 71 - const conditions = [eq(tables.follows.follower_did, params.actor)]; 72 - 73 - if (params.dids && params.dids.length > 0) { 74 - conditions.push(inArray(tables.follows.subject_did, params.dids)); 75 - } 76 - 77 - return conditions.length > 1 ? and(...conditions) : conditions[0]; 78 - }; 79 - 80 - const countWhereConditions = buildCountWhereConditions(); 81 - 82 - return Effect.tryPromise({ 83 - try: () => 84 - Promise.all([ 85 - ctx.db 86 - .select() 87 - .from(tables.users) 88 - .where(eq(tables.users.did, params.actor)) 89 - .execute() 90 - .then((rows) => rows[0]), 91 - ctx.db 92 - .select() 93 - .from(tables.follows) 94 - .where(whereConditions) 95 - .leftJoin( 96 - tables.users, 97 - eq(tables.users.did, tables.follows.subject_did), 98 - ) 99 - .orderBy(desc(tables.follows.createdAt)) 100 - .limit(params.limit ?? 50) 101 - .execute() 102 - .then((rows) => rows.map(({ users }) => users)), 103 - ctx.db 104 - .select() 105 - .from(tables.follows) 106 - .where(whereConditions) 107 - .orderBy(desc(tables.follows.createdAt)) 108 - .limit(params.limit ?? 50) 109 - .execute() 110 - .then((rows) => 111 - rows?.length > 0 112 - ? rows[rows.length - 1]?.createdAt.getTime().toString(10) 113 - : undefined, 114 - ), 115 - ctx.db 116 - .select({ count: count() }) 117 - .from(tables.follows) 118 - .where(countWhereConditions) 119 - .execute() 120 - .then((rows) => rows[0]?.count ?? 0), 121 - ]), 122 - catch: (error) => new Error(`Failed to retrieve user follows: ${error}`), 123 - }); 124 - }; 125 - 126 - const presentation = ( 127 - user: SelectUser | undefined, 128 - follows: SelectUser[], 129 - cursor: string | undefined, 130 - count: number, 131 - ): Effect.Effect< 132 - { 133 - subject: ProfileViewBasic; 134 - follows: ProfileViewBasic[]; 135 - cursor?: string; 136 - count: number; 137 - }, 138 - never 139 - > => { 140 - return Effect.sync(() => ({ 141 - subject: { 142 - id: user?.id, 143 - did: user?.did, 144 - handle: user?.handle, 145 - displayName: user?.displayName, 146 - avatar: user?.avatar, 147 - createdAt: user?.createdAt.toISOString(), 148 - updatedAt: user?.updatedAt.toISOString(), 149 - }, 150 - follows: follows.map((follow) => ({ 151 - id: follow.id, 152 - did: follow.did, 153 - handle: follow.handle, 154 - displayName: follow.displayName, 155 - avatar: follow.avatar, 156 - createdAt: follow.createdAt.toISOString(), 157 - updatedAt: follow.updatedAt.toISOString(), 158 - })), 159 - cursor, 160 - count, 161 - })); 162 - };
-135
apps/api/src/xrpc/app/rocksky/graph/getKnownFollowers.ts
··· 1 - import type { Context } from "context"; 2 - import { and, eq, sql, desc, lt } from "drizzle-orm"; 3 - import { Effect, pipe } from "effect"; 4 - import type { Server } from "lexicon"; 5 - import type { QueryParams } from "lexicon/types/app/rocksky/graph/getKnownFollowers"; 6 - import type { ProfileViewBasic } from "lexicon/types/app/rocksky/actor/defs"; 7 - import tables from "schema"; 8 - import type { HandlerAuth } from "@atproto/xrpc-server"; 9 - import type { SelectUser } from "schema/users"; 10 - 11 - export default function (server: Server, ctx: Context) { 12 - const getKnownFollowers = (params: QueryParams, auth: HandlerAuth) => 13 - pipe( 14 - { params, ctx, viewerDid: auth.credentials?.did }, 15 - retrieve, 16 - Effect.flatMap(presentation), 17 - Effect.retry({ times: 3 }), 18 - Effect.timeout("120 seconds"), 19 - Effect.catchAll((err) => { 20 - console.error("getKnownFollowers error:", err); 21 - return Effect.succeed({ 22 - subject: {} satisfies ProfileViewBasic, 23 - followers: [] as ProfileViewBasic[], 24 - }); 25 - }), 26 - ); 27 - 28 - server.app.rocksky.graph.getKnownFollowers({ 29 - auth: ctx.authVerifier, 30 - handler: async ({ params, auth }) => { 31 - const result = await Effect.runPromise(getKnownFollowers(params, auth)); 32 - return { 33 - encoding: "application/json", 34 - body: result, 35 - }; 36 - }, 37 - }); 38 - } 39 - 40 - const retrieve = ({ 41 - params, 42 - ctx, 43 - viewerDid, 44 - }: { 45 - params: QueryParams; 46 - ctx: Context; 47 - viewerDid?: string; 48 - }): Effect.Effect< 49 - [SelectUser | undefined, SelectUser[], string | undefined], 50 - Error 51 - > => { 52 - if (!viewerDid) { 53 - return Effect.succeed([undefined, [], undefined]); 54 - } 55 - 56 - return Effect.tryPromise({ 57 - try: async () => { 58 - const user = await ctx.db 59 - .select() 60 - .from(tables.users) 61 - .where(eq(tables.users.did, params.actor)) 62 - .execute() 63 - .then((rows) => rows[0]); 64 - const knownFollowers = await ctx.db 65 - .select() 66 - .from(tables.follows) 67 - .innerJoin( 68 - tables.users, 69 - eq(tables.users.did, tables.follows.follower_did), 70 - ) 71 - .where( 72 - params.cursor 73 - ? and( 74 - lt(tables.follows.createdAt, new Date(Number(params.cursor))), 75 - eq(tables.follows.subject_did, params.actor), 76 - sql`EXISTS ( 77 - SELECT 1 FROM ${tables.follows} f2 78 - WHERE f2.subject_did = ${tables.users.did} 79 - AND f2.follower_did = ${viewerDid} 80 - )`, 81 - ) 82 - : and( 83 - eq(tables.follows.subject_did, params.actor), 84 - sql`EXISTS ( 85 - SELECT 1 FROM ${tables.follows} f2 86 - WHERE f2.subject_did = ${tables.users.did} 87 - AND f2.follower_did = ${viewerDid} 88 - )`, 89 - ), 90 - ) 91 - .orderBy(desc(tables.follows.createdAt)) 92 - .limit(params.limit ?? 50) 93 - .execute(); 94 - const cursor = 95 - knownFollowers?.length > 0 96 - ? knownFollowers[knownFollowers.length - 1].follows.createdAt 97 - .getTime() 98 - .toString(10) 99 - : undefined; 100 - return [user, knownFollowers.map((row) => row.users), cursor]; 101 - }, 102 - catch: (error) => new Error(`Failed to retrieve known followers: ${error}`), 103 - }); 104 - }; 105 - 106 - const presentation = ([user, followers, cursor]: [ 107 - SelectUser | undefined, 108 - SelectUser[], 109 - string | undefined, 110 - ]): Effect.Effect< 111 - { subject: ProfileViewBasic; followers: ProfileViewBasic[] }, 112 - never 113 - > => { 114 - return Effect.sync(() => ({ 115 - subject: { 116 - id: user?.id, 117 - did: user?.did, 118 - handle: user?.handle, 119 - displayName: user?.displayName, 120 - avatar: user?.avatar, 121 - createdAt: user?.createdAt.toISOString(), 122 - updatedAt: user?.updatedAt.toISOString(), 123 - }, 124 - followers: followers.map((follower) => ({ 125 - id: follower.id, 126 - did: follower.did, 127 - handle: follower.handle, 128 - displayName: follower.displayName, 129 - avatar: follower.avatar, 130 - createdAt: follower.createdAt.toISOString(), 131 - updatedAt: follower.updatedAt.toISOString(), 132 - })), 133 - cursor, 134 - })); 135 - };
-176
apps/api/src/xrpc/app/rocksky/graph/unfollowAccount.ts
··· 1 - import type { HandlerAuth } from "@atproto/xrpc-server"; 2 - import type { Context } from "context"; 3 - import { and, eq, desc } from "drizzle-orm"; 4 - import { Effect, pipe } from "effect"; 5 - import type { Server } from "lexicon"; 6 - import type { ProfileViewBasic } from "lexicon/types/app/rocksky/actor/defs"; 7 - import type { QueryParams } from "lexicon/types/app/rocksky/graph/followAccount"; 8 - import { createAgent } from "lib/agent"; 9 - import tables from "schema"; 10 - import type { SelectUser } from "schema/users"; 11 - 12 - export default function (server: Server, ctx: Context) { 13 - const unfollowAccount = (params: QueryParams, auth: HandlerAuth) => 14 - pipe( 15 - { params, ctx, did: auth.credentials?.did }, 16 - handleFollow, 17 - Effect.flatMap(presentation), 18 - Effect.retry({ times: 3 }), 19 - Effect.timeout("120 seconds"), 20 - Effect.catchAll((err) => { 21 - console.error(err); 22 - return Effect.succeed({ 23 - subject: {} satisfies ProfileViewBasic, 24 - followers: [], 25 - }); 26 - }), 27 - ); 28 - server.app.rocksky.graph.unfollowAccount({ 29 - auth: ctx.authVerifier, 30 - handler: async ({ params, auth }) => { 31 - const result = await Effect.runPromise(unfollowAccount(params, auth)); 32 - return { 33 - encoding: "application/json", 34 - body: result, 35 - }; 36 - }, 37 - }); 38 - } 39 - 40 - const handleFollow = ({ 41 - params, 42 - ctx, 43 - did, 44 - }: { 45 - params: QueryParams; 46 - ctx: Context; 47 - did?: string; 48 - }): Effect.Effect<[SelectUser | undefined, SelectUser[]], Error> => { 49 - return Effect.tryPromise({ 50 - try: async () => { 51 - if (!did) { 52 - throw new Error("User is not authenticated"); 53 - } 54 - if (params.account === did) { 55 - throw new Error("User cannot follow themselves"); 56 - } 57 - 58 - if (!(await isFollowing(ctx, did, params.account))) { 59 - throw new Error("User is not following"); 60 - } 61 - 62 - const agent = await createAgent(ctx.oauthClient, did); 63 - if (!agent) { 64 - throw new Error("Unauthorized"); 65 - } 66 - 67 - const follow = await ctx.db 68 - .select() 69 - .from(tables.follows) 70 - .where( 71 - and( 72 - eq(tables.follows.subject_did, params.account), 73 - eq(tables.follows.follower_did, did), 74 - ), 75 - ) 76 - .execute() 77 - .then((rows) => rows[0]); 78 - 79 - if (!follow) { 80 - throw new Error("Follow not found"); 81 - } 82 - 83 - const rkey = follow.uri.split("/").pop(); 84 - 85 - await agent.com.atproto.repo.deleteRecord({ 86 - repo: agent.assertDid, 87 - collection: "app.rocksky.graph.follow", 88 - rkey, 89 - }); 90 - 91 - await ctx.db 92 - .delete(tables.follows) 93 - .where( 94 - and( 95 - eq(tables.follows.subject_did, params.account), 96 - eq(tables.follows.follower_did, did), 97 - ), 98 - ) 99 - .execute(); 100 - 101 - return Promise.all([ 102 - ctx.db 103 - .select() 104 - .from(tables.users) 105 - .where(eq(tables.users.did, params.account)) 106 - .execute() 107 - .then((rows) => rows[0]), 108 - ctx.db 109 - .select() 110 - .from(tables.follows) 111 - .where(eq(tables.follows.subject_did, params.account)) 112 - .leftJoin( 113 - tables.users, 114 - eq(tables.users.did, tables.follows.follower_did), 115 - ) 116 - .orderBy(desc(tables.follows.createdAt)) 117 - .limit(50) 118 - .execute() 119 - .then((rows) => rows.map(({ users }) => users)), 120 - ]); 121 - }, 122 - catch: (error) => new Error(`Failed to retrieve follow: ${error}`), 123 - }); 124 - }; 125 - 126 - const presentation = ([user, followers]: [ 127 - SelectUser | undefined, 128 - SelectUser[], 129 - ]): Effect.Effect< 130 - { subject: ProfileViewBasic; followers: ProfileViewBasic[] }, 131 - never 132 - > => { 133 - return Effect.sync(() => ({ 134 - subject: { 135 - id: user?.id, 136 - did: user?.did, 137 - handle: user?.handle, 138 - displayName: user?.displayName, 139 - avatar: user?.avatar, 140 - createdAt: user?.createdAt.toISOString(), 141 - updatedAt: user?.updatedAt.toISOString(), 142 - }, 143 - followers: followers.map((follower) => ({ 144 - id: follower.id, 145 - did: follower.did, 146 - handle: follower.handle, 147 - displayName: follower.displayName, 148 - avatar: follower.avatar, 149 - createdAt: follower.createdAt.toISOString(), 150 - updatedAt: follower.updatedAt.toISOString(), 151 - })), 152 - cursor: 153 - followers.length === 50 154 - ? followers[49].createdAt.getTime().toString(10) 155 - : undefined, 156 - })); 157 - }; 158 - 159 - const isFollowing = async ( 160 - ctx: Context, 161 - followerDid: string, 162 - subjectDid: string, 163 - ): Promise<boolean> => { 164 - const result = await ctx.db 165 - .select() 166 - .from(tables.follows) 167 - .where( 168 - and( 169 - eq(tables.follows.follower_did, followerDid), 170 - eq(tables.follows.subject_did, subjectDid), 171 - ), 172 - ) 173 - .execute(); 174 - 175 - return result.length > 0; 176 - };
+6 -30
apps/api/src/xrpc/app/rocksky/scrobble/getScrobbles.ts
··· 1 1 import type { Context } from "context"; 2 - import { and, desc, eq, inArray } from "drizzle-orm"; 2 + import { desc, eq } from "drizzle-orm"; 3 3 import { Effect, pipe } from "effect"; 4 4 import type { Server } from "lexicon"; 5 5 import type { ScrobbleViewBasic } from "lexicon/types/app/rocksky/scrobble/defs"; ··· 11 11 import type { SelectUser } from "schema/users"; 12 12 13 13 export default function (server: Server, ctx: Context) { 14 - const getScrobbles = (params: QueryParams) => 14 + const getScrobbles = (params) => 15 15 pipe( 16 16 { params, ctx }, 17 17 retrieve, ··· 42 42 ctx: Context; 43 43 }): Effect.Effect<Scrobbles | undefined, Error> => { 44 44 return Effect.tryPromise({ 45 - try: async () => { 46 - const baseQuery = ctx.db 45 + try: () => 46 + ctx.db 47 47 .select() 48 48 .from(tables.scrobbles) 49 49 .leftJoin(tables.tracks, eq(tables.scrobbles.trackId, tables.tracks.id)) 50 - .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)); 51 - 52 - if (params.did && params.following) { 53 - const followedUsers = await ctx.db 54 - .select({ subjectDid: tables.follows.subject_did }) 55 - .from(tables.follows) 56 - .where(eq(tables.follows.follower_did, params.did)) 57 - .execute(); 58 - 59 - const followedDids = followedUsers.map((f) => f.subjectDid); 60 - 61 - if (followedDids.length > 0) { 62 - return baseQuery 63 - .where(inArray(tables.users.did, followedDids)) 64 - .orderBy(desc(tables.scrobbles.timestamp)) 65 - .offset(params.offset || 0) 66 - .limit(params.limit || 20) 67 - .execute(); 68 - } else { 69 - return []; 70 - } 71 - } 72 - 73 - return baseQuery 50 + .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)) 74 51 .orderBy(desc(tables.scrobbles.timestamp)) 75 52 .offset(params.offset || 0) 76 53 .limit(params.limit || 20) 77 - .execute(); 78 - }, 54 + .execute(), 79 55 80 56 catch: (error) => new Error(`Failed to retrieve scrobbles: ${error}`), 81 57 });
-14
apps/api/src/xrpc/index.ts
··· 75 75 import getFeedGenerators from "./app/rocksky/feed/getFeedGenerators"; 76 76 import getFeedGenerator from "./app/rocksky/feed/getFeedGenerator"; 77 77 import getFeed from "./app/rocksky/feed/getFeed"; 78 - import followAccount from "./app/rocksky/graph/followAccount"; 79 - import getFollowers from "./app/rocksky/graph/getFollowers"; 80 - import getFollows from "./app/rocksky/graph/getFollows"; 81 - import getKnownFollowers from "./app/rocksky/graph/getKnownFollowers"; 82 - import unfollowAccount from "./app/rocksky/graph/unfollowAccount"; 83 - import getActorNeighbours from "./app/rocksky/actor/getActorNeighbours"; 84 - import getActorCompatibility from "./app/rocksky/actor/getActorCompatibility"; 85 78 86 79 export default function (server: Server, ctx: Context) { 87 80 // app.rocksky ··· 160 153 getFeedGenerators(server, ctx); 161 154 getFeedGenerator(server, ctx); 162 155 getFeed(server, ctx); 163 - followAccount(server, ctx); 164 - getFollowers(server, ctx); 165 - getFollows(server, ctx); 166 - getKnownFollowers(server, ctx); 167 - unfollowAccount(server, ctx); 168 - getActorNeighbours(server, ctx); 169 - getActorCompatibility(server, ctx); 170 156 171 157 return server; 172 158 }
+1 -1
apps/feeds/src/algos/afrobeat.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/afrobeats.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/alternative-metal.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["alternative metal"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["alternative metal"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+4 -2
apps/feeds/src/algos/alternative-rnb.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["alternative rnb"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["alternative rnb"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/anime.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+3 -2
apps/feeds/src/algos/art-pop.ts
··· 1 1 import { Context } from "../context.ts"; 2 2 import { Algorithm, feedParams } from "./types.ts"; 3 3 import schema from "../schema/mod.ts"; 4 - import { and, arrayContains, desc, eq, lt } from "drizzle-orm"; 4 + import { and, arrayContains, desc, eq } from "drizzle-orm"; 5 + import { lt } from "drizzle-orm/sql"; 5 6 6 7 const handler = async ( 7 8 ctx: Context, ··· 20 21 const scrobbles = await ctx.db 21 22 .select() 22 23 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 25 .where(and(...whereConditions)) 25 26 .orderBy(desc(schema.scrobbles.timestamp)) 26 27 .limit(limit)
+1 -1
apps/feeds/src/algos/breakcore.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/chicago-drill.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["chicago drill"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["chicago drill"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/chillwave.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/country-hip-hop.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["country hip hop"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["country hip hop"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/crunk.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/dance-pop.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/deep-house.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["deep house"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["deep house"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/drill.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+3 -3
apps/feeds/src/algos/dubstep.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["dupstep"])]; 13 + const whereConditions = [arrayContains(schema.artists.genres, ["dubstep"])]; 14 14 15 15 if (cursor) { 16 16 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit) ··· 41 41 }; 42 42 43 43 export const publisherDid = "did:plc:vegqomyce4ssoqs7zwqvgqty"; 44 - export const rkey = "dupstep"; 44 + export const rkey = "dubstep"; 45 45 46 46 export const info = { 47 47 handler,
+1 -1
apps/feeds/src/algos/emo.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/grunge.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/hard-rock.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/heavy-metal.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["heavy metal"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["heavy metal"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/hip-hop.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/house.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/hyperpop.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/indie-rock.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["indie rock"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["indie rock"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/indie.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/j-pop.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/j-rock.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/jazz.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/k-pop.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/lo-fi.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/metal.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/metalcore.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/midwest-emo.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["midwest emo"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["midwest emo"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/nu-metal.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/pop-punk.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/post-grunge.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["post-grunge"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["post-grunge"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/rap-metal.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/rap.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/rnb.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/rock.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/southern-hip-hop.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["southern hip hop"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["southern hip hop"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/speedcore.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/swedish-pop.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["swedish pop"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["swedish pop"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/synthwave.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/thrash-metal.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["thrash metal"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["thrash metal"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/trap-soul.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+1 -1
apps/feeds/src/algos/trap.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/tropical-house.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["tropical house"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["tropical house"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/vaporwave.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/visual-kei.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["visual kei"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["visual kei"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+1 -1
apps/feeds/src/algos/vocaloid.ts
··· 20 20 const scrobbles = await ctx.db 21 21 .select() 22 22 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 23 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 24 .where(and(...whereConditions)) 25 25 .orderBy(desc(schema.scrobbles.timestamp)) 26 26 .limit(limit)
+4 -2
apps/feeds/src/algos/west-coast-hip-hop.ts
··· 10 10 ) => { 11 11 const { limit = 50, cursor } = params; 12 12 13 - const whereConditions = [arrayContains(schema.artists.genres, ["west coast hip hop"])]; 13 + const whereConditions = [ 14 + arrayContains(schema.artists.genres, ["west coast hip hop"]), 15 + ]; 14 16 15 17 if (cursor) { 16 18 const cursorDate = new Date(parseInt(cursor, 10)); ··· 20 22 const scrobbles = await ctx.db 21 23 .select() 22 24 .from(schema.scrobbles) 23 - .innerJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 25 + .leftJoin(schema.artists, eq(schema.scrobbles.artistId, schema.artists.id)) 24 26 .where(and(...whereConditions)) 25 27 .orderBy(desc(schema.scrobbles.timestamp)) 26 28 .limit(limit)
+2 -69
apps/web/src/api/feed.ts
··· 92 92 id: string; 93 93 }; 94 94 }[]; 95 - cursor?: string; 96 95 }>("/xrpc/app.rocksky.feed.getFeed", { 97 96 params: { 98 97 feed: uri, ··· 107 106 }); 108 107 109 108 if (response.status !== 200) { 110 - return { songs: [], cursor: undefined }; 109 + return []; 111 110 } 112 111 113 - return { 114 - songs: response.data.feed.map(({ scrobble }) => scrobble), 115 - cursor: response.data.cursor, 116 - }; 117 - }; 118 - 119 - export const getScrobbles = async ( 120 - did: string, 121 - following: boolean = false, 122 - offset: number = 0, 123 - limit: number = 50, 124 - ) => { 125 - const response = await client.get<{ 126 - scrobbles: { 127 - title: string; 128 - artist: string; 129 - albumArtist: string; 130 - album: string; 131 - trackNumber: number; 132 - duration: number; 133 - mbId: string | null; 134 - youtubeLink: string | null; 135 - spotifyLink: string | null; 136 - appleMusicLink: string | null; 137 - tidalLink: string | null; 138 - sha256: string; 139 - discNumber: number; 140 - composer: string | null; 141 - genre: string | null; 142 - label: string | null; 143 - copyrightMessage: string | null; 144 - uri: string; 145 - albumUri: string; 146 - artistUri: string; 147 - trackUri: string; 148 - xataVersion: number; 149 - cover: string; 150 - date: string; 151 - user: string; 152 - userDisplayName: string; 153 - userAvatar: string; 154 - tags: string[]; 155 - likesCount: number; 156 - liked: boolean; 157 - id: string; 158 - }[]; 159 - }>("/xrpc/app.rocksky.scrobble.getScrobbles", { 160 - params: { 161 - did, 162 - following, 163 - offset, 164 - limit, 165 - }, 166 - headers: { 167 - Authorization: localStorage.getItem("token") 168 - ? `Bearer ${localStorage.getItem("token")}` 169 - : undefined, 170 - }, 171 - }); 172 - 173 - if (response.status !== 200) { 174 - return { scrobbles: [] }; 175 - } 176 - 177 - return { 178 - scrobbles: response.data.scrobbles, 179 - }; 112 + return response.data.feed.map(({ scrobble }) => scrobble); 180 113 };
-72
apps/web/src/api/graph.ts
··· 1 - import { client } from "."; 2 - 3 - export const getFollows = async ( 4 - actor: string, 5 - limit: number, 6 - dids?: string[], 7 - cursor?: string, 8 - ) => { 9 - const response = await client.get("/xrpc/app.rocksky.graph.getFollows", { 10 - params: { actor, limit: limit > 0 ? limit : 1, dids, cursor }, 11 - }); 12 - 13 - return response.data; 14 - }; 15 - 16 - export const getKnownFollowers = async ( 17 - actor: string, 18 - limit: number, 19 - cursor?: string, 20 - ) => { 21 - const response = await client.get( 22 - "/xrpc/app.rocksky.graph.getKnownFollowers", 23 - { 24 - params: { actor, limit: limit > 0 ? limit : 1, cursor }, 25 - }, 26 - ); 27 - 28 - return response.data; 29 - }; 30 - 31 - export const getFollowers = async ( 32 - actor: string, 33 - limit: number, 34 - dids?: string[], 35 - cursor?: string, 36 - ) => { 37 - const response = await client.get("/xrpc/app.rocksky.graph.getFollowers", { 38 - params: { actor, limit: limit > 0 ? limit : 1, dids, cursor }, 39 - }); 40 - 41 - return response.data; 42 - }; 43 - 44 - export const followAccount = async (account: string) => { 45 - const response = await client.post( 46 - "/xrpc/app.rocksky.graph.followAccount", 47 - undefined, 48 - { 49 - params: { account }, 50 - headers: { 51 - Authorization: `Bearer ${localStorage.getItem("token")}`, 52 - }, 53 - }, 54 - ); 55 - 56 - return response.data; 57 - }; 58 - 59 - export const unfollowAccount = async (account: string) => { 60 - const response = await client.post( 61 - "/xrpc/app.rocksky.graph.unfollowAccount", 62 - undefined, 63 - { 64 - params: { account }, 65 - headers: { 66 - Authorization: `Bearer ${localStorage.getItem("token")}`, 67 - }, 68 - }, 69 - ); 70 - 71 - return response.data; 72 - };
+3 -32
apps/web/src/api/profile.ts
··· 1 1 import { client } from "."; 2 - import { Compatibility } from "../types/compatibility"; 3 - import { Neighbour } from "../types/neighbour"; 4 - import { Profile } from "../types/profile"; 5 2 import { Scrobble } from "../types/scrobble"; 6 3 7 4 export const getProfileByDid = async (did: string) => { 8 - const response = await client.get<Profile>( 9 - "/xrpc/app.rocksky.actor.getProfile", 10 - { 11 - params: { did }, 12 - }, 13 - ); 5 + const response = await client.get("/xrpc/app.rocksky.actor.getProfile", { 6 + params: { did }, 7 + }); 14 8 return response.data; 15 9 }; 16 10 ··· 34 28 ); 35 29 return response.data.scrobbles || []; 36 30 }; 37 - 38 - export const getActorNeighbours = async (did: string) => { 39 - const response = await client.get<{ neighbours: Neighbour[] }>( 40 - "/xrpc/app.rocksky.actor.getActorNeighbours", 41 - { 42 - params: { did }, 43 - }, 44 - ); 45 - return response.data; 46 - }; 47 - 48 - export const getActorCompatibility = async (did: string) => { 49 - const response = await client.get<{ compatibility: Compatibility | null }>( 50 - "/xrpc/app.rocksky.actor.getActorCompatibility", 51 - { 52 - params: { did }, 53 - headers: { 54 - Authorization: `Bearer ${localStorage.getItem("token")}`, 55 - }, 56 - }, 57 - ); 58 - return response.data; 59 - };
-3
apps/web/src/atoms/followingFeed.ts
··· 1 - import { atom } from "jotai"; 2 - 3 - export const followingFeedAtom = atom<boolean>(false);
-3
apps/web/src/atoms/follows.ts
··· 1 - import { atom } from "jotai"; 2 - 3 - export const followsAtom = atom<Set<string>>(new Set<string>());
-4
apps/web/src/atoms/tab.ts
··· 1 - import { atom } from "jotai"; 2 - import { Key } from "react"; 3 - 4 - export const activeTabAtom = atom<Key>("0");
+44 -180
apps/web/src/components/Handle/Handle.tsx
··· 4 4 import { StatefulPopover, TRIGGER_TYPE } from "baseui/popover"; 5 5 import { LabelMedium, LabelSmall } from "baseui/typography"; 6 6 import { useAtom } from "jotai"; 7 - import { useEffect, useState } from "react"; 7 + import { useEffect } from "react"; 8 8 import { profilesAtom } from "../../atoms/profiles"; 9 9 import { statsAtom } from "../../atoms/stats"; 10 10 import { ··· 13 13 } from "../../hooks/useProfile"; 14 14 import Stats from "../Stats"; 15 15 import NowPlaying from "./NowPlaying"; 16 - import { followsAtom } from "../../atoms/follows"; 17 - import { IconCheck, IconPlus } from "@tabler/icons-react"; 18 - import { Button } from "baseui/button"; 19 - import SignInModal from "../SignInModal"; 20 - import { 21 - useFollowAccountMutation, 22 - useFollowersQuery, 23 - useUnfollowAccountMutation, 24 - } from "../../hooks/useGraph"; 25 16 26 17 export type HandleProps = { 27 18 link: string; ··· 29 20 }; 30 21 31 22 function Handle(props: HandleProps) { 32 - const [follows, setFollows] = useAtom(followsAtom); 33 - const [isSignInOpen, setIsSignInOpen] = useState(false); 34 23 const { link, did } = props; 35 24 const [profiles, setProfiles] = useAtom(profilesAtom); 36 25 const profile = useProfileByDidQuery(did); 37 26 const profileStats = useProfileStatsByDidQuery(did); 38 27 const [stats, setStats] = useAtom(statsAtom); 39 - const { mutate: followAccount } = useFollowAccountMutation(); 40 - const { mutate: unfollowAccount } = useUnfollowAccountMutation(); 41 - const currentDid = localStorage.getItem("did"); 42 - const { data, isLoading } = useFollowersQuery( 43 - profile.data?.did, 44 - 1, 45 - currentDid ? [currentDid] : undefined, 46 - ); 47 - 48 - const onFollow = () => { 49 - if (!localStorage.getItem("token")) { 50 - setIsSignInOpen(true); 51 - return; 52 - } 53 - if (!profile.data?.did) { 54 - return; 55 - } 56 - setFollows((prev) => new Set(prev).add(profile.data?.did)); 57 - followAccount(profile.data?.did); 58 - }; 59 - 60 - const onUnfollow = () => { 61 - if (!localStorage.getItem("token")) { 62 - setIsSignInOpen(true); 63 - return; 64 - } 65 - if (!profile.data?.did) { 66 - return; 67 - } 68 - 69 - setFollows((prev) => { 70 - if (!profile.data?.did) { 71 - return prev; 72 - } 73 - const newSet = new Set(prev); 74 - newSet.delete(profile.data?.did); 75 - return newSet; 76 - }); 77 - unfollowAccount(profile.data?.did); 78 - }; 79 - 80 - useEffect(() => { 81 - if (!data || isLoading) { 82 - return; 83 - } 84 - setFollows((prev) => { 85 - const newSet = new Set(prev); 86 - if (!profile.data?.did) { 87 - return newSet; 88 - } 89 - if ( 90 - data.followers.some( 91 - (follower: { did: string }) => follower.did === currentDid, 92 - ) 93 - ) { 94 - newSet.add(profile.data?.did); 95 - } else { 96 - newSet.delete(profile.data?.did); 97 - } 98 - return newSet; 99 - }); 100 - }, [data, isLoading, currentDid, setFollows, profile.data?.did]); 101 28 102 29 useEffect(() => { 103 30 if (profile.isLoading || profile.isError) { ··· 146 73 }, [profileStats.data, profileStats.isLoading, profileStats.isError, did]); 147 74 148 75 return ( 149 - <> 150 - <StatefulPopover 151 - content={() => ( 152 - <Block className="!bg-[var(--color-background)] !text-[var(--color-text)] p-[15px] w-[380px] rounded-[6px] border-[1px] border-[var(--color-border)]"> 153 - <div className="flex flex-row items-start justify-between"> 154 - <div className="flex flex-row items-center"> 155 - <Link to={link} className="no-underline"> 156 - <Avatar 157 - src={profiles[did]?.avatar} 158 - name={profiles[did]?.displayName} 159 - size={"60px"} 160 - /> 161 - </Link> 162 - <div className="ml-[16px]"> 163 - <Link to={link} className="no-underline"> 164 - <LabelMedium 165 - marginTop={"10px"} 166 - className="!text-[var(--color-text)]" 167 - > 168 - {profiles[did]?.displayName} 169 - </LabelMedium> 170 - </Link> 171 - <a 172 - href={`https://bsky.app/profile/${profiles[did]?.handle}`} 173 - className="no-underline text-[var(--color-primary)]" 174 - target="_blank" 175 - > 176 - <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 177 - @{did} 178 - </LabelSmall> 179 - </a> 180 - </div> 181 - </div> 182 - {(profile.data?.did !== localStorage.getItem("did") || 183 - !localStorage.getItem("did")) && ( 184 - <div className="ml-auto mt-[10px]"> 185 - {!follows.has(profile.data?.did || "") && !isLoading && ( 186 - <Button 187 - shape="pill" 188 - size="mini" 189 - startEnhancer={<IconPlus size={16} />} 190 - onClick={onFollow} 191 - overrides={{ 192 - BaseButton: { 193 - style: { 194 - minWidth: "90px", 195 - backgroundColor: "#ff2876", 196 - ":hover": { 197 - backgroundColor: "#ff2876", 198 - }, 199 - ":focus": { 200 - backgroundColor: "#ff2876", 201 - }, 202 - }, 203 - }, 204 - }} 205 - > 206 - Follow 207 - </Button> 208 - )} 209 - {follows.has(profile.data?.did || "") && !isLoading && ( 210 - <Button 211 - shape="pill" 212 - size="mini" 213 - startEnhancer={<IconCheck size={16} />} 214 - onClick={onUnfollow} 215 - overrides={{ 216 - BaseButton: { 217 - style: { 218 - backgroundColor: "var(--color-default-button)", 219 - color: "var(--color-text)", 220 - ":hover": { 221 - backgroundColor: "var(--color-default-button)", 222 - }, 223 - ":focus": { 224 - backgroundColor: "var(--color-default-button)", 225 - }, 226 - }, 227 - }, 228 - }} 229 - > 230 - Following 231 - </Button> 232 - )} 233 - </div> 234 - )} 76 + <StatefulPopover 77 + content={() => ( 78 + <Block className="!bg-[var(--color-background)] !text-[var(--color-text)] p-[15px] w-[380px] rounded-[6px] border-[1px] border-[var(--color-border)]"> 79 + <div className="flex flex-row items-center"> 80 + <Link to={link} className="no-underline"> 81 + <Avatar 82 + src={profiles[did]?.avatar} 83 + name={profiles[did]?.displayName} 84 + size={"60px"} 85 + /> 86 + </Link> 87 + <div className="ml-[16px]"> 88 + <Link to={link} className="no-underline"> 89 + <LabelMedium 90 + marginTop={"10px"} 91 + className="!text-[var(--color-text)]" 92 + > 93 + {profiles[did]?.displayName} 94 + </LabelMedium> 95 + </Link> 96 + <a 97 + href={`https://bsky.app/profile/${profiles[did]?.handle}`} 98 + className="no-underline text-[var(--color-primary)]" 99 + > 100 + <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 101 + @{did} 102 + </LabelSmall> 103 + </a> 235 104 </div> 105 + </div> 236 106 237 - {stats[did] && <Stats stats={stats[did]} mb={1} />} 107 + {stats[did] && <Stats stats={stats[did]} mb={1} />} 238 108 239 - <NowPlaying did={did} /> 240 - </Block> 241 - )} 242 - triggerType={TRIGGER_TYPE.hover} 243 - autoFocus={false} 244 - focusLock={false} 245 - > 246 - <Link to={link} className="no-underline"> 247 - <LabelMedium className="!text-[var(--color-primary)] !overflow-hidden !text-ellipsis !max-w-[220px] !text-[14px]"> 248 - @{did} 249 - </LabelMedium> 250 - </Link> 251 - </StatefulPopover> 252 - <SignInModal 253 - isOpen={isSignInOpen} 254 - onClose={() => setIsSignInOpen(false)} 255 - follow 256 - /> 257 - </> 109 + <NowPlaying did={did} /> 110 + </Block> 111 + )} 112 + triggerType={TRIGGER_TYPE.hover} 113 + autoFocus={false} 114 + focusLock={false} 115 + > 116 + <Link to={link} className="no-underline"> 117 + <LabelMedium className="!text-[var(--color-primary)] !overflow-hidden !text-ellipsis !max-w-[220px] !text-[14px]"> 118 + @{did} 119 + </LabelMedium> 120 + </Link> 121 + </StatefulPopover> 258 122 ); 259 123 } 260 124
+3 -12
apps/web/src/components/Shout/ShoutList/Shout/ReplyModal/ReplyModal.tsx
··· 11 11 } from "baseui/modal"; 12 12 import { Spinner } from "baseui/spinner"; 13 13 import { Textarea } from "baseui/textarea"; 14 - import { LabelMedium, LabelSmall } from "baseui/typography"; 14 + import { LabelMedium } from "baseui/typography"; 15 15 import { useAtomValue, useSetAtom } from "jotai"; 16 16 import { useState } from "react"; 17 17 import { Controller, useForm } from "react-hook-form"; ··· 19 19 import { profileAtom } from "../../../../../atoms/profile"; 20 20 import { shoutsAtom } from "../../../../../atoms/shouts"; 21 21 import useShout from "../../../../../hooks/useShout"; 22 - import scrollToTop from "../../../../../lib/scrollToTop"; 23 22 24 23 const ShoutSchema = z.object({ 25 24 message: z.string().min(1).max(1000), ··· 239 238 <div className="ml-[20px] w-full"> 240 239 <Header> 241 240 <div> 242 - <Link 243 - to={`/profile/${shout.user.handle}`} 244 - className="flex no-underline" 245 - style={{ textDecoration: "none" }} 246 - onClick={() => scrollToTop()} 247 - > 248 - <LabelMedium className="!text-[var(--color-text)] no-underline"> 241 + <Link to={`/profile/${shout.user.handle}`} onClick={close}> 242 + <LabelMedium className="!text-[var(--color-text)]"> 249 243 {shout.user.displayName} 250 244 </LabelMedium> 251 - <LabelSmall className="ml-[5px] mt-[4px] no-underline !text-[var(--color-text-muted)]"> 252 - @{shout.user.handle} 253 - </LabelSmall> 254 245 </Link> 255 246 </div> 256 247 </Header>
+3 -12
apps/web/src/components/Shout/ShoutList/Shout/Shout.tsx
··· 6 6 import { NestedMenus, StatefulMenu } from "baseui/menu"; 7 7 import { PLACEMENT, StatefulPopover } from "baseui/popover"; 8 8 import { StatefulTooltip } from "baseui/tooltip"; 9 - import { LabelMedium, LabelSmall } from "baseui/typography"; 9 + import { LabelMedium } from "baseui/typography"; 10 10 import dayjs from "dayjs"; 11 11 import { useAtomValue } from "jotai"; 12 12 import { useState } from "react"; ··· 17 17 import SignInModal from "../../../SignInModal"; 18 18 import DeleteShoutModal from "./DeleteShoutModal"; 19 19 import ReplyModal from "./ReplyModal"; 20 - import scrollToTop from "../../../../lib/scrollToTop"; 21 20 22 21 const Link = styled(DefaultLink)` 23 22 color: inherit; ··· 184 183 <div className="ml-[20px] w-full"> 185 184 <Header> 186 185 <div> 187 - <Link 188 - to={`/profile/${shout.user.handle}`} 189 - className="flex no-underline" 190 - style={{ textDecoration: "none" }} 191 - onClick={() => scrollToTop()} 192 - > 193 - <LabelMedium className="!text-[var(--color-text)] no-underline"> 186 + <Link to={`/profile/${shout.user.handle}`}> 187 + <LabelMedium className="!text-[var(--color-text)]"> 194 188 {shout.user.displayName} 195 189 </LabelMedium> 196 - <LabelSmall className="ml-[5px] mt-[4px] no-underline !text-[var(--color-text-muted)]"> 197 - @{shout.user.handle} 198 - </LabelSmall> 199 190 </Link> 200 191 </div> 201 192 <div>
+8 -14
apps/web/src/components/SignInModal/SignInModal.tsx
··· 8 8 isOpen: boolean; 9 9 onClose: () => void; 10 10 like?: boolean; 11 - follow?: boolean; 12 11 } 13 12 14 13 function SignInModal(props: SignInModalProps) { 15 - const { isOpen, onClose, like, follow } = props; 14 + const { isOpen, onClose, like } = props; 16 15 const [handle, setHandle] = useState(""); 17 16 18 17 const onLogin = async () => { ··· 34 33 overrides={{ 35 34 Root: { 36 35 style: { 37 - zIndex: 50, 36 + zIndex: 1, 38 37 }, 39 38 }, 40 39 Dialog: { ··· 58 57 <h1 style={{ color: "#ff2876", textAlign: "center" }}>Rocksky</h1> 59 58 <p className="text-[var(--color-text)] text-[18px] mt-[40px] mb-[20px]"> 60 59 {!like 61 - ? !follow 62 - ? "Sign in or create your account to join the conversation!" 63 - : "Sign in or create your account to follow users!" 60 + ? "Sign in or create your account to join the conversation!" 64 61 : "Sign in or create your account to like songs!"} 65 62 </p> 66 63 <div style={{ marginBottom: 20 }}> 67 64 <div style={{ marginBottom: 15 }}> 68 - <LabelMedium className="!text-[var(--color-text)]"> 69 - Handle 70 - </LabelMedium> 65 + <LabelMedium>Bluesky handle</LabelMedium> 71 66 </div> 72 67 <Input 73 68 name="handle" ··· 128 123 marginTop={"20px"} 129 124 className="!text-[var(--color-text-muted)] text-center" 130 125 > 131 - Don't have an atproto handle yet? 126 + Don't have an account? 132 127 </LabelMedium> 133 - <div className="text-[var(--color-text-muted)] text-center text-[16px]"> 134 - You can create one at{" "} 128 + <div className="text-[var(--color-text-muted)] text-center"> 135 129 <a 136 130 href="https://bsky.app" 137 131 className="text-[var(--color-primary)] no-underline cursor-pointer text-center" 138 132 target="_blank" 139 133 > 140 - Bluesky 134 + Sign up for Bluesky 141 135 </a>{" "} 142 - or any other AT Protocol service. 136 + to create one now! 143 137 </div> 144 138 </ModalBody> 145 139 </Modal>
+1 -1
apps/web/src/components/SongCover/InteractionBar/InteractionBar.tsx
··· 23 23 {liked && <HeartFilled color="#fff" />} 24 24 </span> 25 25 {likesCount > 0 && ( 26 - <span className="ml-[5px] mt-[-4px] text-sm !text-[#ffffff]"> 26 + <span className="ml-[5px] mt-[-4px] text-sm text-white"> 27 27 {likesCount} 28 28 </span> 29 29 )}
+11 -7
apps/web/src/components/Stats/Stats.tsx
··· 1 1 import { css } from "@emotion/react"; 2 2 import styled from "@emotion/styled"; 3 - import { HeadingSmall } from "baseui/typography"; 3 + import { HeadingSmall, LabelMedium } from "baseui/typography"; 4 4 import numeral from "numeral"; 5 5 6 6 const Group = styled.div<{ mb?: number }>` ··· 27 27 function Stats(props: StatsProps) { 28 28 const { stats, mb } = props; 29 29 return ( 30 - <Group mb={mb} className="!mb-[0px]"> 30 + <Group mb={mb}> 31 31 <div className="mr-[20px]"> 32 - <b className="!text-[var(--color-text-muted)] text-[13px]">SCROBBLES</b> 32 + <LabelMedium className="!text-[var(--color-text-muted)]"> 33 + SCROBBLES 34 + </LabelMedium> 33 35 <HeadingSmall margin={0} className="!text-[var(--color-text)]"> 34 36 {numeral(stats?.scrobbles).format("0,0")} 35 37 </HeadingSmall> 36 38 </div> 37 39 <div className="mr-[20px]"> 38 - <b className="!text-[var(--color-text-muted)] text-[13px]">ARTISTS</b> 40 + <LabelMedium className="!text-[var(--color-text-muted)]"> 41 + ARTISTS 42 + </LabelMedium> 39 43 <HeadingSmall margin={0} className="!text-[var(--color-text)]"> 40 44 {numeral(stats?.artists).format("0,0")} 41 45 </HeadingSmall> 42 46 </div> 43 - <div> 44 - <b className="!text-[var(--color-text-muted)] text-[13px]"> 47 + <div className="mr-[20px]"> 48 + <LabelMedium className="!text-[var(--color-text-muted)]"> 45 49 LOVED TRACKS 46 - </b> 50 + </LabelMedium> 47 51 <HeadingSmall margin={0} className="!text-[var(--color-text)]"> 48 52 {numeral(stats?.lovedTracks).format("0,0")} 49 53 </HeadingSmall>
+3 -58
apps/web/src/hooks/useFeed.tsx
··· 1 - import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 - import { 3 - getFeedByUri, 4 - getFeedGenerators, 5 - getFeed, 6 - getScrobbles, 7 - } from "../api/feed"; 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { getFeedByUri, getFeedGenerators, getFeed } from "../api/feed"; 8 3 9 4 export const useFeedQuery = (feed: string, limit = 114, cursor?: string) => 10 5 useQuery({ 11 6 queryKey: ["feed", feed], 12 - queryFn: async () => { 13 - const data = await getFeed(feed, limit, cursor); 14 - return data.songs; 15 - }, 7 + queryFn: () => getFeed(feed, limit, cursor), 16 8 }); 17 9 18 10 export const useFeedByUriQuery = (uri: string) => ··· 26 18 queryKey: ["feedGenerators"], 27 19 queryFn: () => getFeedGenerators(), 28 20 }); 29 - 30 - export const useFeedInfiniteQuery = (feed: string, limit = 30) => 31 - useInfiniteQuery({ 32 - queryKey: ["infiniteFeed", feed], 33 - queryFn: async ({ pageParam }) => { 34 - const data = await getFeed(feed, limit, pageParam); 35 - return { 36 - feed: data.songs, 37 - nextCursor: data.cursor, 38 - }; 39 - }, 40 - getNextPageParam: (lastPage) => lastPage.nextCursor, 41 - initialPageParam: undefined as string | undefined, 42 - }); 43 - 44 - export const useScrobblesQuery = ( 45 - did: string, 46 - following = false, 47 - offset = 0, 48 - limit = 50, 49 - ) => 50 - useQuery({ 51 - queryKey: ["scrobbles", did, following, offset, limit], 52 - queryFn: async () => { 53 - const data = await getScrobbles(did, following, offset, limit); 54 - return data.scrobbles; 55 - }, 56 - }); 57 - 58 - export const useScrobbleInfiniteQuery = ( 59 - did: string, 60 - following = false, 61 - limit = 50, 62 - ) => 63 - useInfiniteQuery({ 64 - queryKey: ["infiniteScrobbles", did, following], 65 - queryFn: async ({ pageParam }) => { 66 - const data = await getScrobbles(did, following, pageParam, limit); 67 - return { 68 - scrobbles: data.scrobbles, 69 - nextOffset: 70 - data.scrobbles.length === limit ? pageParam + limit : undefined, 71 - }; 72 - }, 73 - getNextPageParam: (lastPage) => lastPage.nextOffset, 74 - initialPageParam: 0, 75 - });
-92
apps/web/src/hooks/useGraph.tsx
··· 1 - import { 2 - useInfiniteQuery, 3 - useMutation, 4 - useQuery, 5 - useQueryClient, 6 - } from "@tanstack/react-query"; 7 - import { 8 - getFollowers, 9 - getFollows, 10 - followAccount, 11 - unfollowAccount, 12 - } from "../api/graph"; 13 - 14 - export const useFollowsQuery = ( 15 - actor: string, 16 - limit: number, 17 - dids?: string[], 18 - cursor?: string, 19 - ) => 20 - useQuery({ 21 - queryKey: ["follows", actor, limit, dids, cursor], 22 - queryFn: () => getFollows(actor, limit, dids, cursor), 23 - }); 24 - 25 - export const useFollowsInfiniteQuery = ( 26 - actor: string, 27 - limit: number, 28 - dids?: string[], 29 - ) => 30 - useInfiniteQuery({ 31 - queryKey: ["follows-infinite", actor, dids, limit], 32 - queryFn: ({ pageParam }) => getFollows(actor, limit, dids, pageParam), 33 - getNextPageParam: (lastPage) => lastPage.cursor, 34 - initialPageParam: undefined as string | undefined, 35 - }); 36 - 37 - export const useFollowersQuery = ( 38 - actor: string | undefined, 39 - limit: number, 40 - dids?: string[], 41 - cursor?: string, 42 - ) => 43 - useQuery({ 44 - queryKey: ["followers", actor, limit, dids, cursor], 45 - queryFn: () => getFollowers(actor!, limit, dids, cursor), 46 - }); 47 - 48 - export const useFollowersInfiniteQuery = ( 49 - actor: string, 50 - limit: number, 51 - dids?: string[], 52 - ) => 53 - useInfiniteQuery({ 54 - queryKey: ["followers-infinite", actor, limit, dids], 55 - queryFn: ({ pageParam }) => getFollowers(actor, limit, dids, pageParam), 56 - getNextPageParam: (lastPage) => lastPage.cursor, 57 - initialPageParam: undefined as string | undefined, 58 - }); 59 - 60 - export const useFollowAccountMutation = () => { 61 - const queryClient = useQueryClient(); 62 - return useMutation({ 63 - mutationFn: followAccount, 64 - onSuccess: (_data, account) => { 65 - queryClient.invalidateQueries({ queryKey: ["follows"] }); 66 - queryClient.invalidateQueries({ queryKey: ["followers", account] }); 67 - queryClient.invalidateQueries({ 68 - queryKey: ["follows-infinite"], 69 - }); 70 - queryClient.invalidateQueries({ 71 - queryKey: ["followers-infinite"], 72 - }); 73 - }, 74 - }); 75 - }; 76 - 77 - export const useUnfollowAccountMutation = () => { 78 - const queryClient = useQueryClient(); 79 - return useMutation({ 80 - mutationFn: unfollowAccount, 81 - onSuccess: (_data, account) => { 82 - queryClient.invalidateQueries({ queryKey: ["follows"] }); 83 - queryClient.invalidateQueries({ queryKey: ["followers", account] }); 84 - queryClient.invalidateQueries({ 85 - queryKey: ["follows-infinite"], 86 - }); 87 - queryClient.invalidateQueries({ 88 - queryKey: ["followers-infinite"], 89 - }); 90 - }, 91 - }); 92 - };
+1 -1
apps/web/src/hooks/useNowPlaying.tsx
··· 22 22 queryFn: () => 23 23 client.get<{ nowPlayings: NowPlayings }>( 24 24 "/xrpc/app.rocksky.feed.getNowPlayings", 25 - { params: { size: 47 } }, 25 + { params: { size: 7 } }, 26 26 ), 27 27 select: (res) => res.data.nowPlayings || [], 28 28 });
-16
apps/web/src/hooks/useProfile.tsx
··· 2 2 import { useSetAtom } from "jotai"; 3 3 import { useEffect, useState } from "react"; 4 4 import { 5 - getActorCompatibility, 6 - getActorNeighbours, 7 5 getProfileByDid, 8 6 getProfileStatsByDid, 9 7 getRecentTracksByDid, ··· 29 27 useQuery({ 30 28 queryKey: ["profile", "recent-tracks", did, offset, size], 31 29 queryFn: () => getRecentTracksByDid(did, offset, size), 32 - enabled: !!did, 33 - }); 34 - 35 - export const useActorNeighboursQuery = (did: string) => 36 - useQuery({ 37 - queryKey: ["profile", "neighbours", did], 38 - queryFn: () => getActorNeighbours(did), 39 - enabled: !!did, 40 - }); 41 - 42 - export const useActorCompatibilityQuery = (did: string | undefined) => 43 - useQuery({ 44 - queryKey: ["profile", "compatibility", did], 45 - queryFn: () => getActorCompatibility(did!), 46 30 enabled: !!did, 47 31 }); 48 32
+1 -1
apps/web/src/index.css
··· 103 103 --color-placeholder: #ffffffb4; 104 104 --color-clear-input: #ffffff; 105 105 --color-menu-hover: #1f0d3c; 106 - --color-default-button: rgba(255, 255, 255, 0.06); 106 + --color-default-button: rgba(255, 255, 255, 0.05); 107 107 --color-purple: oklch(0.5478 0.201 296.07); 108 108 --color-blue: oklch(0.789 0.154 211.53); 109 109 --color-pink: oklch(0.6372 0.229 346.83);
+7 -116
apps/web/src/layouts/Main.tsx
··· 16 16 import Navbar from "./Navbar"; 17 17 import Search from "./Search"; 18 18 import SpotifyLogin from "./SpotifyLogin"; 19 - import { IconEye, IconEyeOff, IconLock } from "@tabler/icons-react"; 20 19 21 20 const Container = styled.div` 22 21 display: flex; ··· 60 59 const { children } = props; 61 60 const withRightPane = props.withRightPane ?? true; 62 61 const [handle, setHandle] = useState(""); 63 - const [password, setPassword] = useState(""); 64 62 const jwt = localStorage.getItem("token"); 65 63 const profile = useAtomValue(profileAtom); 66 64 const [token, setToken] = useState<string | null>(null); 67 65 const { did, cli } = useSearch({ strict: false }); 68 - const [passwordLogin, setPasswordLogin] = useState(false); 69 66 70 67 useEffect(() => { 71 68 if (did && did !== "null") { ··· 112 109 return; 113 110 } 114 111 115 - if (passwordLogin) { 116 - if (!password.trim()) { 117 - return; 118 - } 119 - 120 - const response = await fetch(`${API_URL}/login`, { 121 - method: "POST", 122 - headers: { 123 - "Content-Type": "application/json", 124 - }, 125 - body: JSON.stringify({ handle, password }), 126 - }); 127 - 128 - if (!response.ok) { 129 - const error = await response.text(); 130 - alert(error); 131 - return; 132 - } 133 - 134 - const data = await response.text(); 135 - const newToken = data.split("jwt:")[1]; 136 - localStorage.setItem("token", newToken); 137 - setToken(data); 138 - 139 - if (cli) { 140 - await fetch("http://localhost:6996/token", { 141 - method: "POST", 142 - headers: { 143 - "Content-Type": "application/json", 144 - }, 145 - body: JSON.stringify({ token: newToken }), 146 - }); 147 - } 148 - 149 - if (!jwt && newToken) { 150 - window.location.href = "/"; 151 - } 152 - 153 - return; 154 - } 155 - 156 112 if (API_URL.includes("localhost")) { 157 113 window.location.href = `${API_URL}/login?handle=${handle}`; 158 114 return; ··· 162 118 }; 163 119 164 120 return ( 165 - <Container 166 - id="app-container" 167 - className="bg-[var(--color-background)] text-[var(--color-text)]" 168 - > 121 + <Container className="bg-[var(--color-background)] text-[var(--color-text)]"> 169 122 <ToasterContainer 170 123 placement={PLACEMENT.top} 171 124 overrides={{ ··· 198 151 {!jwt && ( 199 152 <div className="mt-[40px]"> 200 153 <div className="mb-[20px]"> 201 - <div className="flex flex-row mb-[15px]"> 202 - <LabelMedium className="!text-[var(--color-text)] flex-1"> 203 - Handle 204 - </LabelMedium> 205 - <LabelMedium 206 - className="!text-[var(--color-primary)] cursor-pointer" 207 - onClick={() => setPasswordLogin(!passwordLogin)} 208 - > 209 - {passwordLogin ? "OAuth Login" : "Password Login"} 154 + <div className="mb-[15px]"> 155 + <LabelMedium className="!text-[var(--color-text)]"> 156 + Bluesky handle 210 157 </LabelMedium> 211 158 </div> 212 159 <Input ··· 244 191 }, 245 192 }} 246 193 /> 247 - {passwordLogin && ( 248 - <Input 249 - name="password" 250 - startEnhancer={ 251 - <div className="text-[var(--color-text-muted)] bg-[var(--color-input-background)]"> 252 - <IconLock size={19} className="mt-[8px]" /> 253 - </div> 254 - } 255 - type="password" 256 - placeholder="Password" 257 - value={password} 258 - onChange={(e) => setPassword(e.target.value)} 259 - overrides={{ 260 - Root: { 261 - style: { 262 - backgroundColor: "var(--color-input-background)", 263 - borderColor: "var(--color-input-background)", 264 - marginTop: "1rem", 265 - }, 266 - }, 267 - StartEnhancer: { 268 - style: { 269 - backgroundColor: "var(--color-input-background)", 270 - }, 271 - }, 272 - InputContainer: { 273 - style: { 274 - backgroundColor: "var(--color-input-background)", 275 - }, 276 - }, 277 - Input: { 278 - style: { 279 - color: "var(--color-text)", 280 - caretColor: "var(--color-text)", 281 - }, 282 - }, 283 - MaskToggleHideIcon: { 284 - component: () => ( 285 - <IconEyeOff 286 - className="text-[var(--color-text-muted)]" 287 - size={20} 288 - /> 289 - ), 290 - }, 291 - MaskToggleShowIcon: { 292 - component: () => ( 293 - <IconEye 294 - className="text-[var(--color-text-muted)]" 295 - size={20} 296 - /> 297 - ), 298 - }, 299 - }} 300 - /> 301 - )} 302 194 </div> 303 195 <Button 304 196 onClick={onLogin} ··· 320 212 Sign In 321 213 </Button> 322 214 <LabelMedium className="text-center mt-[20px] !text-[var(--color-text-muted)]"> 323 - Don't have an atproto handle yet? 215 + Don't have an account? 324 216 </LabelMedium> 325 217 <div className="text-center text-[var(--color-text-muted)] "> 326 - You can create one at{" "} 327 218 <a 328 219 href="https://bsky.app" 329 220 className="no-underline cursor-pointer !text-[var(--color-primary)]" 330 221 target="_blank" 331 222 > 332 - Bluesky 223 + Sign up for Bluesky 333 224 </a>{" "} 334 - or any other AT Protocol service. 225 + to create one now! 335 226 </div> 336 227 </div> 337 228 )}
+1 -1
apps/web/src/layouts/Navbar/Navbar.tsx
··· 319 319 overrides={{ 320 320 Root: { 321 321 style: { 322 - zIndex: 50, 322 + zIndex: 1, 323 323 }, 324 324 }, 325 325 Dialog: {
-6
apps/web/src/lib/scrollToTop.ts
··· 1 - export default function scrollToTop() { 2 - const container = document.querySelector("#app-container"); 3 - if (container) { 4 - container.scrollTo({ top: 0, behavior: "smooth" }); 5 - } 6 - }
+3 -78
apps/web/src/pages/apikeys/ApiKeys.tsx
··· 305 305 overrides={{ 306 306 Root: { 307 307 style: { 308 - zIndex: 50, 309 - }, 310 - }, 311 - Dialog: { 312 - style: { 313 - backgroundColor: "var(--color-background)", 314 - }, 315 - }, 316 - Close: { 317 - style: { 318 - color: "var(--color-text)", 319 - ":hover": { 320 - color: "var(--color-text)", 321 - opacity: 0.8, 322 - }, 308 + zIndex: 1, 323 309 }, 324 310 }, 325 311 }} 326 312 > 327 - <ModalHeader className="!text-[var(--color-text)]"> 328 - Create a new API key 329 - </ModalHeader> 313 + <ModalHeader>Create a new API key</ModalHeader> 330 314 <ModalBody> 331 315 <Controller 332 316 name="name" ··· 337 321 placeholder="Name" 338 322 clearOnEscape 339 323 error={!!errors.name} 340 - overrides={{ 341 - Root: { 342 - style: { 343 - backgroundColor: "var(--color-input-background)", 344 - borderColor: "var(--color-input-background)", 345 - }, 346 - }, 347 - StartEnhancer: { 348 - style: { 349 - backgroundColor: "var(--color-input-background)", 350 - }, 351 - }, 352 - InputContainer: { 353 - style: { 354 - backgroundColor: "var(--color-input-background)", 355 - }, 356 - }, 357 - Input: { 358 - style: { 359 - color: "var(--color-text)", 360 - caretColor: "var(--color-text)", 361 - }, 362 - }, 363 - }} 364 324 /> 365 325 )} 366 326 /> ··· 376 336 overrides={{ 377 337 Root: { 378 338 style: { 379 - backgroundColor: "var(--color-input-background)", 380 - borderColor: "var(--color-input-background)", 381 339 marginTop: "20px", 382 340 }, 383 341 }, 384 - InputContainer: { 385 - style: { 386 - backgroundColor: "var(--color-input-background)", 387 - }, 388 - }, 389 - Input: { 390 - style: { 391 - color: "var(--color-text)", 392 - caretColor: "var(--color-text)", 393 - }, 394 - }, 395 342 }} 396 343 /> 397 344 )} ··· 405 352 BaseButton: { 406 353 style: { 407 354 marginRight: "10px", 408 - backgroundColor: "var(--color-background) !important", 409 - color: "var(--color-text) !important", 410 - ":hover": { 411 - backgroundColor: "var(--color-background)", 412 - }, 413 355 }, 414 356 }, 415 357 }} 416 358 > 417 359 Cancel 418 360 </Button> 419 - <Button 420 - onClick={onCreate} 421 - shape="pill" 422 - overrides={{ 423 - BaseButton: { 424 - style: { 425 - backgroundColor: "var(--color-purple) !important", 426 - color: "var(--color-button-text) !important", 427 - ":hover": { 428 - backgroundColor: "var(--color-purple)", 429 - color: "var(--color-button-text) !important", 430 - }, 431 - }, 432 - }, 433 - }} 434 - > 435 - Create 436 - </Button> 361 + <Button onClick={onCreate}>Create</Button> 437 362 </ModalFooter> 438 363 </Modal> 439 364 </Main>
+73 -190
apps/web/src/pages/home/feed/Feed.tsx
··· 10 10 import ContentLoader from "react-content-loader"; 11 11 import Handle from "../../../components/Handle"; 12 12 import SongCover from "../../../components/SongCover"; 13 - import { 14 - useFeedInfiniteQuery, 15 - useScrobbleInfiniteQuery, 16 - } from "../../../hooks/useFeed"; 13 + import { useFeedQuery } from "../../../hooks/useFeed"; 17 14 import { useEffect, useRef } from "react"; 18 15 import { WS_URL } from "../../../consts"; 19 16 import { useQueryClient } from "@tanstack/react-query"; 20 17 import FeedGenerators from "./FeedGenerators"; 21 18 import { useAtomValue } from "jotai"; 22 19 import { feedGeneratorUriAtom } from "../../../atoms/feed"; 23 - import { followingFeedAtom } from "../../../atoms/followingFeed"; 24 20 25 21 dayjs.extend(relativeTime); 26 22 ··· 41 37 const queryClient = useQueryClient(); 42 38 const socketRef = useRef<WebSocket | null>(null); 43 39 const heartbeatInterval = useRef<number | null>(null); 44 - const loadMoreRef = useRef<HTMLDivElement | null>(null); 45 40 const feedUri = useAtomValue(feedGeneratorUriAtom); 46 - const followingFeed = useAtomValue(followingFeedAtom); 47 - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = 48 - useFeedInfiniteQuery(feedUri, 30); 49 - const { 50 - data: scrobbleData, 51 - isLoading: scrobbleLoading, 52 - fetchNextPage: scrobbleFetchNextPage, 53 - hasNextPage: scrobbleHasNextPage, 54 - isFetchingNextPage: scrobbleIsFetchingNextPage, 55 - } = useScrobbleInfiniteQuery(localStorage.getItem("did")!, true, 30); 56 - 57 - const allSongs = followingFeed 58 - ? scrobbleData?.pages.flatMap((page) => page.scrobbles) || [] 59 - : data?.pages.flatMap((page) => page.feed) || []; 41 + const { data, isLoading } = useFeedQuery(feedUri); 60 42 61 43 useEffect(() => { 62 44 const ws = new WebSocket(`${WS_URL.replace("http", "ws")}`); ··· 80 62 () => message.scrobblesChart, 81 63 ); 82 64 83 - await queryClient.invalidateQueries({ 84 - queryKey: ["infiniteFeed", feedUri], 85 - }); 65 + await queryClient.invalidateQueries({ queryKey: ["feed", feedUri] }); 86 66 await queryClient.invalidateQueries({ queryKey: ["now-playings"] }); 87 67 await queryClient.invalidateQueries({ queryKey: ["scrobblesChart"] }); 88 68 }; ··· 98 78 }; 99 79 }, [queryClient, feedUri]); 100 80 101 - // Intersection Observer for infinite scroll 102 - useEffect(() => { 103 - const currentHasNextPage = followingFeed 104 - ? scrobbleHasNextPage 105 - : hasNextPage; 106 - const currentIsFetchingNextPage = followingFeed 107 - ? scrobbleIsFetchingNextPage 108 - : isFetchingNextPage; 109 - 110 - if ( 111 - !loadMoreRef.current || 112 - !currentHasNextPage || 113 - currentIsFetchingNextPage 114 - ) 115 - return; 116 - 117 - const observer = new IntersectionObserver( 118 - (entries) => { 119 - if ( 120 - entries[0].isIntersecting && 121 - currentHasNextPage && 122 - !currentIsFetchingNextPage 123 - ) { 124 - if (followingFeed) { 125 - scrobbleFetchNextPage(); 126 - } else { 127 - fetchNextPage(); 128 - } 129 - } 130 - }, 131 - { threshold: 0.1 }, 132 - ); 133 - 134 - observer.observe(loadMoreRef.current); 135 - 136 - return () => observer.disconnect(); 137 - }, [ 138 - followingFeed, 139 - fetchNextPage, 140 - hasNextPage, 141 - isFetchingNextPage, 142 - scrobbleFetchNextPage, 143 - scrobbleHasNextPage, 144 - scrobbleIsFetchingNextPage, 145 - ]); 146 - 147 81 return ( 148 82 <Container> 149 83 <FeedGenerators /> 150 - {(isLoading || scrobbleLoading) && ( 84 + {isLoading && ( 151 85 <ContentLoader 152 - width="100%" 153 - height={800} 154 - viewBox="0 0 1100 800" 86 + width={800} 87 + height={575} 88 + viewBox="0 0 800 575" 155 89 backgroundColor="var(--color-skeleton-background)" 156 90 foregroundColor="var(--color-skeleton-foreground)" 157 - style={{ marginTop: "-100px" }} 158 91 > 159 - {/* First row - 3 items with 24px gap (scale800) */} 160 - <rect x="0" y="0" rx="2" ry="2" width="349" height="349" /> 161 - <rect x="373" y="0" rx="2" ry="2" width="349" height="349" /> 162 - <rect x="746" y="0" rx="2" ry="2" width="349" height="349" /> 163 - 164 - {/* Second row - 3 items with 32px row gap (scale1000) */} 165 - <rect x="0" y="381" rx="2" ry="2" width="349" height="349" /> 166 - <rect x="373" y="381" rx="2" ry="2" width="349" height="349" /> 167 - <rect x="746" y="381" rx="2" ry="2" width="349" height="349" /> 92 + <rect x="12" y="9" rx="2" ry="2" width="140" height="10" /> 93 + <rect x="14" y="30" rx="2" ry="2" width="667" height="11" /> 94 + <rect x="12" y="58" rx="2" ry="2" width="211" height="211" /> 95 + <rect x="240" y="57" rx="2" ry="2" width="211" height="211" /> 96 + <rect x="467" y="56" rx="2" ry="2" width="211" height="211" /> 97 + <rect x="12" y="283" rx="2" ry="2" width="211" height="211" /> 98 + <rect x="240" y="281" rx="2" ry="2" width="211" height="211" /> 99 + <rect x="468" y="279" rx="2" ry="2" width="211" height="211" /> 100 + <circle cx="286" cy="536" r="12" /> 101 + <circle cx="319" cy="535" r="12" /> 102 + <circle cx="353" cy="535" r="12" /> 103 + <rect x="378" y="524" rx="0" ry="0" width="52" height="24" /> 104 + <rect x="210" y="523" rx="0" ry="0" width="52" height="24" /> 105 + <circle cx="210" cy="535" r="12" /> 106 + <circle cx="428" cy="536" r="12" /> 168 107 </ContentLoader> 169 108 )} 170 109 171 - {!isLoading && !scrobbleLoading && ( 110 + {!isLoading && ( 172 111 <div className="pb-[100px] pt-[20px]"> 173 - {followingFeed && allSongs.length === 0 ? ( 174 - <div className="flex flex-col items-center justify-center py-20 mt-[100px]"> 175 - <LabelSmall className="!text-[var(--color-text-muted)] text-center"> 176 - No scrobbles from people you follow yet. 177 - <br /> 178 - Start following users to see their music activity here. 179 - </LabelSmall> 180 - </div> 181 - ) : ( 182 - <> 183 - <FlexGrid 184 - flexGridColumnCount={[1, 2, 3]} 185 - flexGridColumnGap="scale800" 186 - flexGridRowGap="scale1000" 187 - > 188 - { 189 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 190 - allSongs.map((song: any) => ( 191 - <FlexGridItem {...itemProps} key={song.id}> 192 - <Link 193 - to="/$did/scrobble/$rkey" 194 - params={{ 195 - did: song.uri?.split("at://")[1]?.split("/")[0] || "", 196 - rkey: song.uri?.split("/").pop() || "", 197 - }} 198 - className="no-underline text-[var(--color-text-primary)]" 199 - > 200 - <SongCover 201 - uri={song.trackUri} 202 - cover={song.cover} 203 - artist={song.artist} 204 - title={song.title} 205 - liked={song.liked} 206 - likesCount={song.likesCount} 207 - withLikeButton 208 - /> 209 - </Link> 210 - <div className="flex"> 211 - <div className="mr-[8px]"> 212 - <Avatar 213 - src={song.userAvatar} 214 - name={song.userDisplayName} 215 - size={"20px"} 216 - /> 217 - </div> 218 - <Handle 219 - link={`/profile/${song.user}`} 220 - did={song.user} 221 - />{" "} 222 - </div> 223 - <LabelSmall className="!text-[var(--color-text-primary)]"> 224 - recently played this song 225 - </LabelSmall> 226 - <StatefulTooltip 227 - content={dayjs(song.date).format( 228 - "MMMM D, YYYY [at] HH:mm A", 229 - )} 230 - returnFocus 231 - autoFocus 232 - > 233 - <LabelSmall className="!text-[var(--color-text-muted)]"> 234 - {dayjs(song.date).fromNow()} 235 - </LabelSmall> 236 - </StatefulTooltip> 237 - </FlexGridItem> 238 - )) 239 - } 240 - </FlexGrid> 241 - 242 - {/* Load more trigger */} 243 - <div 244 - ref={loadMoreRef} 245 - style={{ height: "20px", marginTop: "20px" }} 246 - > 247 - {(followingFeed 248 - ? scrobbleIsFetchingNextPage 249 - : isFetchingNextPage) && ( 250 - <ContentLoader 251 - width="100%" 252 - height={360} 253 - viewBox="0 0 1100 360" 254 - backgroundColor="var(--color-skeleton-background)" 255 - foregroundColor="var(--color-skeleton-foreground)" 256 - > 257 - {/* 3 items with 24px gap (scale800) */} 258 - <rect x="0" y="10" rx="2" ry="2" width="349" height="349" /> 259 - <rect 260 - x="373" 261 - y="10" 262 - rx="2" 263 - ry="2" 264 - width="349" 265 - height="349" 112 + <FlexGrid 113 + flexGridColumnCount={[1, 2, 3]} 114 + flexGridColumnGap="scale800" 115 + flexGridRowGap="scale1000" 116 + > 117 + {// eslint-disable-next-line @typescript-eslint/no-explicit-any 118 + data?.map((song: any) => ( 119 + <FlexGridItem {...itemProps} key={song.id}> 120 + <Link 121 + to="/$did/scrobble/$rkey" 122 + params={{ 123 + did: song.uri?.split("at://")[1]?.split("/")[0] || "", 124 + rkey: song.uri?.split("/").pop() || "", 125 + }} 126 + className="no-underline text-[var(--color-text-primary)]" 127 + > 128 + <SongCover 129 + uri={song.trackUri} 130 + cover={song.cover} 131 + artist={song.artist} 132 + title={song.title} 133 + liked={song.liked} 134 + likesCount={song.likesCount} 135 + withLikeButton 136 + /> 137 + </Link> 138 + <div className="flex"> 139 + <div className="mr-[8px]"> 140 + <Avatar 141 + src={song.userAvatar} 142 + name={song.userDisplayName} 143 + size={"20px"} 266 144 /> 267 - <rect 268 - x="746" 269 - y="10" 270 - rx="2" 271 - ry="2" 272 - width="349" 273 - height="349" 274 - /> 275 - </ContentLoader> 276 - )} 277 - </div> 278 - </> 279 - )} 145 + </div> 146 + <Handle link={`/profile/${song.user}`} did={song.user} />{" "} 147 + </div> 148 + <LabelSmall className="!text-[var(--color-text-primary)]"> 149 + recently played this song 150 + </LabelSmall> 151 + <StatefulTooltip 152 + content={dayjs(song.date).format("MMMM D, YYYY [at] HH:mm A")} 153 + returnFocus 154 + autoFocus 155 + > 156 + <LabelSmall className="!text-[var(--color-text-muted)]"> 157 + {dayjs(song.date).fromNow()} 158 + </LabelSmall> 159 + </StatefulTooltip> 160 + </FlexGridItem> 161 + ))} 162 + </FlexGrid> 280 163 </div> 281 164 )} 282 165 </Container>
+1 -8
apps/web/src/pages/home/feed/FeedGenerators/FeedGenerators.tsx
··· 9 9 } from "../../../../atoms/feed"; 10 10 import { useFeedGeneratorsQuery } from "../../../../hooks/useFeed"; 11 11 import * as R from "ramda"; 12 - import { followingFeedAtom } from "../../../../atoms/followingFeed"; 13 12 14 13 function FeedGenerators() { 15 14 const jwt = localStorage.getItem("token"); 16 15 const { data: feedGenerators } = useFeedGeneratorsQuery(); 17 16 const [feedUris, setFeedUris] = useAtom(feedUrisAtom); 18 17 const [, setFeedUri] = useAtom(feedGeneratorUriAtom); 19 - const [, setFollowingFeed] = useAtom(followingFeedAtom); 20 18 const [activeCategory, setActiveCategory] = useAtom(feedAtom); 21 19 const [showLeftChevron, setShowLeftChevron] = useState(false); 22 20 const [showRightChevron, setShowRightChevron] = useState(true); ··· 76 74 77 75 const handleCategoryClick = (category: string, index: number) => { 78 76 setActiveCategory(category); 79 - if (category !== "following") { 80 - setFeedUri(feedUris[category]); 81 - setFollowingFeed(false); 82 - } else { 83 - setFollowingFeed(true); 84 - } 77 + setFeedUri(feedUris[category]); 85 78 86 79 const container = scrollContainerRef.current; 87 80 if (container) {
+1 -4
apps/web/src/pages/home/feed/FeedGenerators/constants.ts
··· 1 1 export const categories = [ 2 2 "all", 3 - "following", 4 3 "afrobeat", 5 4 "afrobeats", 6 5 "alternative metal", ··· 51 50 "visual kei", 52 51 "vocaloid", 53 52 "west coast hip hop", 54 - ].filter((category) => 55 - localStorage.getItem("did") ? true : category !== "following", 56 - ); 53 + ];
+21 -152
apps/web/src/pages/home/nowplayings/NowPlayings.tsx
··· 7 7 import dayjs from "dayjs"; 8 8 import relativeTime from "dayjs/plugin/relativeTime"; 9 9 import utc from "dayjs/plugin/utc"; 10 - import { useEffect, useState, useRef } from "react"; 11 - import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; 10 + import { useEffect, useState } from "react"; 12 11 import { useNowPlayingsQuery } from "../../../hooks/useNowPlaying"; 13 - import styles, { getModalStyles } from "./styles"; 12 + import styles from "./styles"; 14 13 15 14 dayjs.extend(relativeTime); 16 15 dayjs.extend(utc); 17 16 18 17 const Container = styled.div` 18 + display: flex; 19 + flex-direction: row; 20 + align-items: center; 19 21 margin-bottom: 50px; 20 22 `; 21 23 ··· 34 36 align-items: center; 35 37 margin-right: 20px; 36 38 cursor: pointer; 37 - flex-shrink: 0; 38 39 `; 39 40 40 41 const Handle = styled.div` ··· 105 106 } | null>(null); 106 107 const [currentIndex, setCurrentIndex] = useState(0); 107 108 const [progress, setProgress] = useState(0); 108 - const [showLeftChevron, setShowLeftChevron] = useState(false); 109 - const [showRightChevron, setShowRightChevron] = useState(true); 110 - const [hasOverflow, setHasOverflow] = useState(false); 111 - const scrollContainerRef = useRef<HTMLDivElement>(null); 112 109 113 110 const onNext = () => { 114 111 const nextIndex = currentIndex + 1; ··· 135 132 setProgress(0); 136 133 }; 137 134 138 - // Check scroll position and update chevron visibility 139 - const handleScroll = () => { 140 - const container = scrollContainerRef.current; 141 - if (!container) return; 142 - 143 - const { scrollLeft, scrollWidth, clientWidth } = container; 144 - 145 - // Check if content overflows 146 - const overflow = scrollWidth > clientWidth; 147 - setHasOverflow(overflow); 148 - 149 - // Show left chevron if scrolled from the start 150 - setShowLeftChevron(scrollLeft > 10); 151 - 152 - // Show right chevron if not scrolled to the end 153 - setShowRightChevron(scrollLeft < scrollWidth - clientWidth - 10); 154 - }; 155 - 156 - // Scroll left/right 157 - const scroll = (direction: "left" | "right") => { 158 - const container = scrollContainerRef.current; 159 - if (!container) return; 160 - 161 - const scrollAmount = 300; 162 - const newScrollLeft = 163 - direction === "left" 164 - ? container.scrollLeft - scrollAmount 165 - : container.scrollLeft + scrollAmount; 166 - 167 - container.scrollTo({ 168 - left: newScrollLeft, 169 - behavior: "smooth", 170 - }); 171 - }; 172 - 173 - // Check overflow on mount and window resize 174 - useEffect(() => { 175 - handleScroll(); 176 - 177 - const handleResize = () => { 178 - handleScroll(); 179 - }; 180 - 181 - window.addEventListener("resize", handleResize); 182 - return () => window.removeEventListener("resize", handleResize); 183 - }, [nowPlayings]); 184 - 185 135 useEffect(() => { 186 136 if (!isOpen) { 187 137 setProgress(0); ··· 220 170 221 171 return ( 222 172 <Container> 223 - <style>{` 224 - .no-scrollbar::-webkit-scrollbar { 225 - display: none; 226 - } 227 - .no-scrollbar { 228 - -ms-overflow-style: none; 229 - scrollbar-width: none; 230 - } 231 - `}</style> 232 - 233 173 {!isLoading && ( 234 174 <> 235 - <div className="relative flex items-center h-[100px]"> 236 - {/* Left chevron */} 237 - {showLeftChevron && ( 238 - <button 239 - onClick={() => scroll("left")} 240 - className="flex-shrink-0 w-8 h-8 min-w-8 min-h-8 p-0 rounded-full bg-transparent hover:bg-[var(--color-input-background)] flex items-center justify-center transition-all outline-none border-none cursor-pointer shadow-md z-0 mt-[-20px]" 241 - style={{ padding: "5px" }} 242 - > 243 - <IconChevronLeft 244 - size={20} 245 - className="text-[var(--color-text)] flex-shrink-0" 246 - /> 247 - </button> 248 - )} 249 - 250 - <div 251 - className="relative flex-1 overflow-hidden" 252 - style={ 253 - hasOverflow 254 - ? { 255 - maskImage: 256 - showLeftChevron && showRightChevron 257 - ? "linear-gradient(to right, transparent, black 30px, black calc(100% - 30px), transparent)" 258 - : showLeftChevron 259 - ? "linear-gradient(to right, transparent, black 30px, black 100%)" 260 - : showRightChevron 261 - ? "linear-gradient(to right, black 0%, black calc(100% - 30px), transparent)" 262 - : undefined, 263 - WebkitMaskImage: 264 - showLeftChevron && showRightChevron 265 - ? "linear-gradient(to right, transparent, black 30px, black calc(100% - 30px), transparent)" 266 - : showLeftChevron 267 - ? "linear-gradient(to right, transparent, black 30px, black 100%)" 268 - : showRightChevron 269 - ? "linear-gradient(to right, black 0%, black calc(100% - 30px), transparent)" 270 - : undefined, 271 - } 272 - : undefined 273 - } 175 + {(nowPlayings || []).map((item, index) => ( 176 + <StoryContainer 177 + key={item.id} 178 + onClick={() => { 179 + setCurrentlyPlaying(item); 180 + setCurrentIndex(index); 181 + setIsOpen(true); 182 + }} 274 183 > 275 - <div 276 - ref={scrollContainerRef} 277 - onScroll={handleScroll} 278 - className="flex overflow-x-auto no-scrollbar h-full" 279 - style={{ scrollbarWidth: "none", msOverflowStyle: "none" }} 280 - > 281 - {(nowPlayings || []).map((item, index) => ( 282 - <StoryContainer 283 - key={item.id} 284 - onClick={() => { 285 - setCurrentlyPlaying(item); 286 - setCurrentIndex(index); 287 - setIsOpen(true); 288 - }} 289 - > 290 - <Story src={item.avatar} /> 291 - <StatefulTooltip 292 - content={item.handle} 293 - returnFocus 294 - autoFocus 295 - > 296 - <Handle>{item.handle}</Handle> 297 - </StatefulTooltip> 298 - </StoryContainer> 299 - ))} 300 - </div> 301 - </div> 302 - 303 - {/* Right chevron */} 304 - {showRightChevron && ( 305 - <button 306 - onClick={() => scroll("right")} 307 - className="flex-shrink-0 w-8 h-8 min-w-8 min-h-8 p-0 rounded-full bg-transparent hover:bg-[var(--color-input-background)] flex items-center justify-center transition-all outline-none border-none cursor-pointer shadow-md z-0 mt-[-20px]" 308 - style={{ padding: "5px" }} 309 - > 310 - <IconChevronRight 311 - size={20} 312 - className="text-[var(--color-text)] flex-shrink-0" 313 - /> 314 - </button> 315 - )} 316 - </div> 317 - 184 + <Story src={item.avatar} /> 185 + <StatefulTooltip content={item.handle} returnFocus autoFocus> 186 + <Handle>{item.handle}</Handle> 187 + </StatefulTooltip> 188 + </StoryContainer> 189 + ))} 318 190 <Modal 319 191 onClose={() => setIsOpen(false)} 320 192 closeable ··· 323 195 autoFocus={false} 324 196 size={"60vw"} 325 197 role={ROLE.dialog} 326 - overrides={getModalStyles(currentlyPlaying?.albumArt).modal} 198 + overrides={styles.modal} 327 199 > 328 200 <ModalHeader className="text-[#fff] text-[15px]"> 329 201 <div className="w-[380px] mx-auto"> ··· 338 210 @{currentlyPlaying?.handle} 339 211 </div> 340 212 </Link> 341 - <span 342 - className="ml-[10px] text-[15px]" 343 - style={{ color: "rgba(255, 255, 255, 0.7)" }} 344 - > 213 + <span className="ml-[10px] text-[15px] text-[var(--color-text-muted)]"> 345 214 {dayjs.utc(currentlyPlaying?.createdAt).local().fromNow()} 346 215 </span> 347 216 </div>
-44
apps/web/src/pages/home/nowplayings/styles.tsx
··· 1 - export const getModalStyles = (albumArt?: string) => ({ 2 - modal: { 3 - Root: { 4 - style: { 5 - zIndex: 60, 6 - }, 7 - }, 8 - Dialog: { 9 - style: { 10 - backgroundColor: albumArt ? "transparent" : "#000", 11 - backgroundImage: albumArt 12 - ? `linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), url(${albumArt})` 13 - : "none", 14 - backgroundSize: "cover", 15 - backgroundPosition: "center", 16 - backgroundRepeat: "no-repeat", 17 - }, 18 - }, 19 - Close: { 20 - style: { 21 - color: "#fff", 22 - }, 23 - }, 24 - }, 25 - progressbar: { 26 - BarContainer: { 27 - style: { 28 - marginLeft: 0, 29 - marginRight: 0, 30 - }, 31 - }, 32 - BarProgress: { 33 - style: () => ({ 34 - backgroundColor: "rgba(255, 255, 255, 0.2)", 35 - }), 36 - }, 37 - Bar: { 38 - style: () => ({ 39 - backgroundColor: "rgba(177, 178, 181, 0.218)", 40 - }), 41 - }, 42 - }, 43 - }); 44 - 45 1 export default { 46 2 modal: { 47 3 Root: {
+46 -229
apps/web/src/pages/profile/Profile.tsx
··· 7 7 import dayjs from "dayjs"; 8 8 import { useAtom, useSetAtom } from "jotai"; 9 9 import _ from "lodash"; 10 - import { useEffect, useState } from "react"; 10 + import { type Key, useEffect, useState } from "react"; 11 11 import { profilesAtom } from "../../atoms/profiles"; 12 12 import { userAtom } from "../../atoms/user"; 13 13 import Shout from "../../components/Shout/Shout"; ··· 17 17 import LovedTracks from "./lovedtracks"; 18 18 import Overview from "./overview"; 19 19 import Playlists from "./playlists"; 20 - import { Button } from "baseui/button"; 21 - import { IconPlus, IconCheck } from "@tabler/icons-react"; 22 - import { followsAtom } from "../../atoms/follows"; 23 - import SignInModal from "../../components/SignInModal"; 24 - import { 25 - useFollowAccountMutation, 26 - useFollowersQuery, 27 - useUnfollowAccountMutation, 28 - } from "../../hooks/useGraph"; 29 - import Follows from "./follows"; 30 - import Followers from "./followers"; 31 - import { activeTabAtom } from "../../atoms/tab"; 32 - import Circles from "./circles"; 33 20 34 21 const Group = styled.div` 35 22 display: flex; 36 23 flex-direction: row; 37 - justify-content: space-between; 38 - align-items: flex-start; 39 24 margin-top: 20px; 40 25 margin-bottom: 50px; 41 26 `; 42 27 43 - const ProfileInfo = styled.div` 44 - display: flex; 45 - flex-direction: row; 46 - flex: 1; 47 - `; 48 - 49 28 export type ProfileProps = { 50 29 activeKey?: string; 51 30 }; 52 31 53 32 function Profile(props: ProfileProps) { 54 - const [follows, setFollows] = useAtom(followsAtom); 55 - const [isSignInOpen, setIsSignInOpen] = useState(false); 56 33 const [profiles, setProfiles] = useAtom(profilesAtom); 57 - const [activeKey, setActiveKey] = useAtom(activeTabAtom); 34 + const [activeKey, setActiveKey] = useState<Key>( 35 + _.get(props, "activeKey", "0").split("/")[0], 36 + ); 58 37 const { did } = useParams({ strict: false }); 59 38 const profile = useProfileByDidQuery(did!); 60 39 const setUser = useSetAtom(userAtom); 61 40 const { tab } = useSearch({ strict: false }); 62 - const { mutate: followAccount } = useFollowAccountMutation(); 63 - const { mutate: unfollowAccount } = useUnfollowAccountMutation(); 64 - const currentDid = localStorage.getItem("did"); 65 - const { data, isLoading } = useFollowersQuery( 66 - profile.data?.did, 67 - 1, 68 - currentDid ? [currentDid] : undefined, 69 - ); 70 - 71 - const onFollow = () => { 72 - if (!localStorage.getItem("token")) { 73 - setIsSignInOpen(true); 74 - return; 75 - } 76 - 77 - if (!profile.data) return; 78 - 79 - setFollows((prev) => new Set(prev).add(profile.data.did)); 80 - followAccount(profile.data.did); 81 - }; 82 - 83 - const onUnfollow = () => { 84 - if (!localStorage.getItem("token")) { 85 - setIsSignInOpen(true); 86 - return; 87 - } 88 - if (!profile.data) return; 89 - 90 - setFollows((prev) => { 91 - const newSet = new Set(prev); 92 - newSet.delete(profile.data.did); 93 - return newSet; 94 - }); 95 - unfollowAccount(profile.data.did); 96 - }; 97 - 98 - useEffect(() => { 99 - if (!props.activeKey) { 100 - setActiveKey("0"); 101 - return; 102 - } 103 - setActiveKey(_.get(props, "activeKey", "0").split("/")[0]); 104 - }, [props.activeKey, setActiveKey, props]); 105 - 106 - useEffect(() => { 107 - if (!data || isLoading) { 108 - return; 109 - } 110 - setFollows((prev) => { 111 - const newSet = new Set(prev); 112 - if (!profile.data) return newSet; 113 - if ( 114 - data.followers.some( 115 - (follower: { did: string }) => follower.did === currentDid, 116 - ) 117 - ) { 118 - newSet.add(profile.data.did); 119 - } else { 120 - newSet.delete(profile.data.did); 121 - } 122 - return newSet; 123 - }); 124 - }, [ 125 - data, 126 - isLoading, 127 - currentDid, 128 - setFollows, 129 - profile.data?.did, 130 - profile.data, 131 - ]); 132 41 133 42 useEffect(() => { 134 43 if (tab === undefined) { ··· 136 45 } 137 46 138 47 setActiveKey(1); 139 - }, [tab, setActiveKey]); 48 + }, [tab]); 140 49 141 50 // biome-ignore lint/correctness/useExhaustiveDependencies: <reason>want to run only on profile.data changes</reason> 142 51 useEffect(() => { ··· 181 90 <Main> 182 91 <div className="pb-[100px] pt-[75px]"> 183 92 <Group> 184 - <ProfileInfo> 185 - <div className="mr-[20px]"> 186 - <Avatar 187 - name={profiles[did]?.displayName} 188 - src={profiles[did]?.avatar} 189 - size="150px" 190 - /> 191 - </div> 192 - <div style={{ marginTop: profiles[did]?.displayName ? 10 : 30 }}> 193 - <HeadingMedium 194 - marginTop="0px" 195 - marginBottom={0} 196 - className="!text-[var(--color-text)]" 93 + <div className="mr-[20px]"> 94 + <Avatar 95 + name={profiles[did]?.displayName} 96 + src={profiles[did]?.avatar} 97 + size="150px" 98 + /> 99 + </div> 100 + <div style={{ marginTop: profiles[did]?.displayName ? 10 : 30 }}> 101 + <HeadingMedium 102 + marginTop="0px" 103 + marginBottom={0} 104 + className="!text-[var(--color-text)]" 105 + > 106 + {profiles[did]?.displayName} 107 + </HeadingMedium> 108 + <LabelLarge> 109 + <a 110 + href={`https://bsky.app/profile/${profiles[did]?.handle}`} 111 + className="no-underline text-[var(--color-primary)]" 112 + > 113 + @{profiles[did]?.handle} 114 + </a> 115 + <span className="text-[var(--color-text-muted)] text-[15px]"> 116 + {" "} 117 + • scrobbling since{" "} 118 + {dayjs(profiles[did]?.createdAt).format("DD MMM YYYY")} 119 + </span> 120 + </LabelLarge> 121 + <div className="flex-1 mt-[30px] mr-[10px]"> 122 + <a 123 + href={`https://pdsls.dev/at/${profiles[did]?.did}`} 124 + target="_blank" 125 + className="no-underline text-[var(--color-text)] bg-[var(--color-default-button)] p-[16px] rounded-[10px] pl-[25px] pr-[25px]" 197 126 > 198 - {profiles[did]?.displayName} 199 - </HeadingMedium> 200 - <LabelLarge> 201 - <a 202 - href={`https://bsky.app/profile/${profiles[did]?.handle}`} 203 - className="no-underline text-[var(--color-primary)]" 204 - target="_blank" 205 - > 206 - @{profiles[did]?.handle} 207 - </a> 208 - <span className="text-[var(--color-text-muted)] text-[15px]"> 209 - {" "} 210 - • scrobbling since{" "} 211 - {dayjs(profiles[did]?.createdAt).format("DD MMM YYYY")} 212 - </span> 213 - </LabelLarge> 214 - <div className="flex-1 mt-[30px] mr-[10px]"> 215 - <a 216 - href={`https://pdsls.dev/at/${profiles[did]?.did}`} 217 - target="_blank" 218 - className="no-underline text-[var(--color-text)] bg-[var(--color-default-button)] p-[16px] rounded-[10px] pl-[25px] pr-[25px]" 219 - > 220 - <ExternalLink size={24} style={{ marginRight: 10 }} /> 221 - View on PDSls 222 - </a> 223 - </div> 127 + <ExternalLink size={24} style={{ marginRight: 10 }} /> 128 + View on PDSls 129 + </a> 224 130 </div> 225 - </ProfileInfo> 226 - {(profile.data?.did !== localStorage.getItem("did") || 227 - !localStorage.getItem("did")) && ( 228 - <> 229 - {!follows.has(profile.data?.did || "") && !isLoading && ( 230 - <Button 231 - shape="pill" 232 - size="compact" 233 - startEnhancer={<IconPlus size={18} />} 234 - onClick={onFollow} 235 - overrides={{ 236 - BaseButton: { 237 - style: { 238 - marginTop: "12px", 239 - minWidth: "120px", 240 - backgroundColor: "#ff2876", 241 - ":hover": { 242 - backgroundColor: "#ff2876", 243 - }, 244 - ":focus": { 245 - backgroundColor: "#ff2876", 246 - }, 247 - }, 248 - }, 249 - }} 250 - > 251 - Follow 252 - </Button> 253 - )} 254 - {follows.has(profile.data?.did || "") && !isLoading && ( 255 - <Button 256 - shape="pill" 257 - size="compact" 258 - startEnhancer={<IconCheck size={18} />} 259 - onClick={onUnfollow} 260 - overrides={{ 261 - BaseButton: { 262 - style: { 263 - marginTop: "12px", 264 - minWidth: "120px", 265 - backgroundColor: "var(--color-default-button)", 266 - color: "var(--color-text)", 267 - ":hover": { 268 - backgroundColor: "var(--color-default-button)", 269 - }, 270 - ":focus": { 271 - backgroundColor: "var(--color-default-button)", 272 - }, 273 - }, 274 - }, 275 - }} 276 - > 277 - Following 278 - </Button> 279 - )} 280 - </> 281 - )} 131 + </div> 282 132 </Group> 283 133 284 134 <Tabs ··· 329 179 /> 330 180 </Tab> 331 181 <Tab 332 - title="Followers" 333 - overrides={{ 334 - Tab: { 335 - style: { 336 - color: "var(--color-text)", 337 - backgroundColor: "var(--color-background) !important", 338 - }, 339 - }, 340 - }} 341 - > 342 - <Followers /> 343 - </Tab> 344 - <Tab 345 - title="Following" 346 - overrides={{ 347 - Tab: { 348 - style: { 349 - color: "var(--color-text)", 350 - backgroundColor: "var(--color-background) !important", 351 - }, 352 - }, 353 - }} 354 - > 355 - <Follows /> 356 - </Tab> 357 - <Tab 358 - title="Circles" 182 + title="Playlists" 359 183 overrides={{ 360 184 Tab: { 361 185 style: { ··· 365 189 }, 366 190 }} 367 191 > 368 - <Circles /> 192 + <Playlists /> 369 193 </Tab> 370 194 <Tab 371 195 title="Loved Tracks" ··· 381 205 <LovedTracks /> 382 206 </Tab> 383 207 <Tab 384 - title="Playlists" 208 + title="Tags" 385 209 overrides={{ 386 210 Tab: { 387 211 style: { ··· 390 214 }, 391 215 }, 392 216 }} 393 - > 394 - <Playlists /> 395 - </Tab> 217 + ></Tab> 396 218 </Tabs> 397 219 <Shout type="profile" /> 398 220 </div> 399 - <SignInModal 400 - isOpen={isSignInOpen} 401 - onClose={() => setIsSignInOpen(false)} 402 - follow 403 - /> 404 221 </Main> 405 222 ); 406 223 }
-215
apps/web/src/pages/profile/circles/Circles.tsx
··· 1 - import { HeadingSmall, LabelMedium, LabelSmall } from "baseui/typography"; 2 - import { 3 - useActorNeighboursQuery, 4 - useProfileByDidQuery, 5 - } from "../../../hooks/useProfile"; 6 - import { Link, useParams } from "@tanstack/react-router"; 7 - import { Neighbour } from "../../../types/neighbour"; 8 - import { Avatar } from "baseui/avatar"; 9 - import { IconCheck, IconPlus } from "@tabler/icons-react"; 10 - import { Button } from "baseui/button"; 11 - import { useAtom } from "jotai"; 12 - import { followsAtom } from "../../../atoms/follows"; 13 - import { 14 - useFollowAccountMutation, 15 - useUnfollowAccountMutation, 16 - } from "../../../hooks/useGraph"; 17 - import { useState } from "react"; 18 - import SignInModal from "../../../components/SignInModal"; 19 - import { activeTabAtom } from "../../../atoms/tab"; 20 - import scrollToTop from "../../../lib/scrollToTop"; 21 - 22 - function Circles() { 23 - const [, setActiveKey] = useAtom(activeTabAtom); 24 - const [follows, setFollows] = useAtom(followsAtom); 25 - const [isSignInOpen, setIsSignInOpen] = useState(false); 26 - const { did } = useParams({ strict: false }); 27 - const profile = useProfileByDidQuery(did!); 28 - const { data, isLoading } = useActorNeighboursQuery(did!); 29 - const { mutate: followAccount } = useFollowAccountMutation(); 30 - const { mutate: unfollowAccount } = useUnfollowAccountMutation(); 31 - 32 - const onFollow = (followerDid: string) => { 33 - if (!localStorage.getItem("token")) { 34 - setIsSignInOpen(true); 35 - return; 36 - } 37 - setFollows((prev) => new Set(prev).add(followerDid)); 38 - followAccount(followerDid); 39 - }; 40 - 41 - const onUnfollow = (followerDid: string) => { 42 - if (!localStorage.getItem("token")) { 43 - setIsSignInOpen(true); 44 - return; 45 - } 46 - setFollows((prev) => { 47 - const newSet = new Set(prev); 48 - newSet.delete(followerDid); 49 - return newSet; 50 - }); 51 - unfollowAccount(followerDid); 52 - }; 53 - 54 - return ( 55 - <> 56 - <HeadingSmall className="!text-[var(--color-text)] mb-[15px]"> 57 - Circles 58 - </HeadingSmall> 59 - <p> 60 - People on Rocksky with similar music taste to @{profile.data?.handle} 61 - </p> 62 - {!isLoading && data?.neighbours.length === 0 && ( 63 - <div className="mt-[40px] text-center py-[60px]"> 64 - <LabelMedium className="!text-[var(--color-text-muted)]"> 65 - No circles found yet. Check back later! 66 - </LabelMedium> 67 - </div> 68 - )} 69 - {!isLoading && (data?.neighbours || []).length > 0 && ( 70 - <div className="mt-[40px]"> 71 - {data?.neighbours.map((neighbour: Neighbour) => ( 72 - <div 73 - key={neighbour.did} 74 - className="flex items-start justify-between gap-2 mb-[40px]" 75 - > 76 - <div className="flex items-center gap-2"> 77 - <Link 78 - to={`/profile/${neighbour.handle}` as string} 79 - className="no-underline mt-[10px]" 80 - onClick={() => { 81 - setActiveKey(0); 82 - scrollToTop(); 83 - }} 84 - > 85 - <Avatar 86 - src={neighbour.avatar} 87 - name={neighbour.displayName} 88 - size={"60px"} 89 - /> 90 - </Link> 91 - <div className="ml-[16px]"> 92 - <div className="flex "> 93 - <Link 94 - to={`/profile/${neighbour.handle}` as string} 95 - className="no-underline" 96 - onClick={() => { 97 - setActiveKey(0); 98 - scrollToTop(); 99 - }} 100 - > 101 - <LabelMedium 102 - marginTop={"0px"} 103 - className="!text-[var(--color-text)] mr-[5px]" 104 - > 105 - {neighbour.displayName} 106 - </LabelMedium> 107 - </Link> 108 - <Link 109 - to={`/profile/${neighbour.handle}` as string} 110 - className="no-underline text-[var(--color-primary)]" 111 - onClick={() => { 112 - setActiveKey(0); 113 - scrollToTop(); 114 - }} 115 - > 116 - <LabelSmall className="!text-[var(--color-text-muted)] mt-[3px] mb-[5px]"> 117 - @{neighbour.handle} 118 - </LabelSmall> 119 - </Link> 120 - </div> 121 - <p className="mt-[0px] mb-[0px] text-[14px]"> 122 - {localStorage.getItem("did") === profile.data?.did 123 - ? "You" 124 - : "They"}{" "} 125 - both listen to{" "} 126 - {neighbour.topSharedArtistsDetails.map((artist, index) => ( 127 - <div key={artist.id} className="inline"> 128 - <Link 129 - to={ 130 - `/${artist.uri.split("at://")[1].replace("app.rocksky.", "")}` as string 131 - } 132 - className="no-underline" 133 - > 134 - <span className="mt-[0px] mb-[0px] text-[14px] !text-[var(--color-primary)]"> 135 - {artist.name} 136 - </span> 137 - </Link> 138 - {index !== 139 - neighbour.topSharedArtistsDetails.length - 1 && 140 - (index === 141 - neighbour.topSharedArtistsDetails.length - 2 142 - ? " and " 143 - : ", ")} 144 - </div> 145 - ))} 146 - </p> 147 - </div> 148 - </div> 149 - {(neighbour.did !== localStorage.getItem("did") || 150 - !localStorage.getItem("did")) && ( 151 - <div className="ml-[10px] mt-[10px]"> 152 - {!follows.has(neighbour.did) && ( 153 - <Button 154 - shape="pill" 155 - size="mini" 156 - startEnhancer={<IconPlus size={16} />} 157 - onClick={() => onFollow(neighbour.did)} 158 - overrides={{ 159 - BaseButton: { 160 - style: { 161 - minWidth: "90px", 162 - backgroundColor: "#ff2876", 163 - ":hover": { 164 - backgroundColor: "#ff2876", 165 - }, 166 - ":focus": { 167 - backgroundColor: "#ff2876", 168 - }, 169 - }, 170 - }, 171 - }} 172 - > 173 - Follow 174 - </Button> 175 - )} 176 - {follows.has(neighbour.did) && ( 177 - <Button 178 - shape="pill" 179 - size="mini" 180 - startEnhancer={<IconCheck size={16} />} 181 - onClick={() => onUnfollow(neighbour.did)} 182 - overrides={{ 183 - BaseButton: { 184 - style: { 185 - backgroundColor: "var(--color-default-button)", 186 - color: "var(--color-text)", 187 - ":hover": { 188 - backgroundColor: "var(--color-default-button)", 189 - }, 190 - ":focus": { 191 - backgroundColor: "var(--color-default-button)", 192 - }, 193 - }, 194 - }, 195 - }} 196 - > 197 - Following 198 - </Button> 199 - )} 200 - </div> 201 - )} 202 - </div> 203 - ))} 204 - </div> 205 - )} 206 - <SignInModal 207 - isOpen={isSignInOpen} 208 - onClose={() => setIsSignInOpen(false)} 209 - follow 210 - /> 211 - </> 212 - ); 213 - } 214 - 215 - export default Circles;
-3
apps/web/src/pages/profile/circles/index.tsx
··· 1 - import Circles from "./Circles"; 2 - 3 - export default Circles;
-240
apps/web/src/pages/profile/followers/Followers.tsx
··· 1 - import { HeadingSmall, LabelMedium, LabelSmall } from "baseui/typography"; 2 - import { 3 - useFollowAccountMutation, 4 - useFollowersInfiniteQuery, 5 - useFollowsQuery, 6 - useUnfollowAccountMutation, 7 - } from "../../../hooks/useGraph"; 8 - import { useProfileByDidQuery } from "../../../hooks/useProfile"; 9 - import { Link, useParams } from "@tanstack/react-router"; 10 - import { Avatar } from "baseui/avatar"; 11 - import { useAtom } from "jotai"; 12 - import { activeTabAtom } from "../../../atoms/tab"; 13 - import { followsAtom } from "../../../atoms/follows"; 14 - import { Button } from "baseui/button"; 15 - import { IconCheck, IconPlus } from "@tabler/icons-react"; 16 - import SignInModal from "../../../components/SignInModal"; 17 - import { useState, useEffect, useRef } from "react"; 18 - import numeral from "numeral"; 19 - import scrollToTop from "../../../lib/scrollToTop"; 20 - 21 - function Followers() { 22 - const [, setActiveKey] = useAtom(activeTabAtom); 23 - const [follows, setFollows] = useAtom(followsAtom); 24 - const [isSignInOpen, setIsSignInOpen] = useState(false); 25 - const { did } = useParams({ strict: false }); 26 - const profile = useProfileByDidQuery(did!); 27 - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = 28 - useFollowersInfiniteQuery(profile.data?.did || "", 20); 29 - const { mutate: followAccount } = useFollowAccountMutation(); 30 - const { mutate: unfollowAccount } = useUnfollowAccountMutation(); 31 - 32 - const loadMoreRef = useRef<HTMLDivElement>(null); 33 - 34 - // Intersection Observer for infinite scroll 35 - useEffect(() => { 36 - if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return; 37 - 38 - const observer = new IntersectionObserver( 39 - (entries) => { 40 - if (entries[0]?.isIntersecting) { 41 - fetchNextPage(); 42 - } 43 - }, 44 - { threshold: 0.1 }, 45 - ); 46 - 47 - observer.observe(loadMoreRef.current); 48 - 49 - return () => observer.disconnect(); 50 - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); 51 - 52 - const allFollowers = data?.pages.flatMap((page) => page.followers) ?? []; 53 - 54 - const { data: followsData } = useFollowsQuery( 55 - localStorage.getItem("did")!, 56 - allFollowers.length, 57 - allFollowers 58 - .map((follower) => follower.did) 59 - .filter((x) => x !== localStorage.getItem("did")), 60 - ); 61 - 62 - useEffect(() => { 63 - if (!followsData) return; 64 - setFollows((prev) => { 65 - const newSet = new Set(prev); 66 - followsData.follows.forEach((follow: { did: string }) => { 67 - newSet.add(follow.did); 68 - }); 69 - return newSet; 70 - }); 71 - }, [followsData, setFollows]); 72 - 73 - const onFollow = (followerDid: string) => { 74 - if (!localStorage.getItem("token")) { 75 - setIsSignInOpen(true); 76 - return; 77 - } 78 - setFollows((prev) => new Set(prev).add(followerDid)); 79 - followAccount(followerDid); 80 - }; 81 - 82 - const onUnfollow = (followerDid: string) => { 83 - if (!localStorage.getItem("token")) { 84 - setIsSignInOpen(true); 85 - return; 86 - } 87 - setFollows((prev) => { 88 - const newSet = new Set(prev); 89 - newSet.delete(followerDid); 90 - return newSet; 91 - }); 92 - unfollowAccount(followerDid); 93 - }; 94 - 95 - const count = data?.pages?.flatMap((page) => page.count)[0]; 96 - 97 - return ( 98 - <> 99 - <HeadingSmall className="!text-[var(--color-text)]"> 100 - Followers {count > 0 ? `(${numeral(count).format("0,0")})` : ""} 101 - </HeadingSmall> 102 - 103 - {allFollowers.length === 0 && data && ( 104 - <div className="text-center py-8"> 105 - <LabelMedium className="!text-[var(--color-text)] opacity-60"> 106 - No followers yet 107 - </LabelMedium> 108 - </div> 109 - )} 110 - 111 - {allFollowers.length > 0 && ( 112 - <div> 113 - {allFollowers.map((follower: any) => ( 114 - <div 115 - key={follower.did} 116 - className="flex items-start justify-between gap-2 mb-[20px]" 117 - > 118 - <div className="flex items-center gap-2"> 119 - <Link 120 - to={`/profile/${follower.handle}` as string} 121 - className="no-underline" 122 - onClick={() => { 123 - setActiveKey(0); 124 - scrollToTop(); 125 - }} 126 - > 127 - <Avatar 128 - src={follower.avatar} 129 - name={follower.displayName} 130 - size={"60px"} 131 - /> 132 - </Link> 133 - <div className="ml-[16px]"> 134 - <Link 135 - to={`/profile/${follower.handle}` as string} 136 - className="no-underline" 137 - onClick={() => { 138 - setActiveKey(0); 139 - scrollToTop(); 140 - }} 141 - > 142 - <LabelMedium 143 - marginTop={"10px"} 144 - className="!text-[var(--color-text)]" 145 - > 146 - {follower.displayName} 147 - </LabelMedium> 148 - </Link> 149 - <Link 150 - to={`/profile/${follower.handle}` as string} 151 - className="no-underline text-[var(--color-primary)]" 152 - onClick={() => { 153 - setActiveKey(0); 154 - scrollToTop(); 155 - }} 156 - > 157 - <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 158 - @{follower.handle} 159 - </LabelSmall> 160 - </Link> 161 - </div> 162 - </div> 163 - {(follower.did !== localStorage.getItem("did") || 164 - !localStorage.getItem("did")) && ( 165 - <div className="ml-auto mt-[10px]"> 166 - {!follows.has(follower.did) && ( 167 - <Button 168 - shape="pill" 169 - size="mini" 170 - startEnhancer={<IconPlus size={16} />} 171 - onClick={() => onFollow(follower.did)} 172 - overrides={{ 173 - BaseButton: { 174 - style: { 175 - minWidth: "90px", 176 - backgroundColor: "#ff2876", 177 - ":hover": { 178 - backgroundColor: "#ff2876", 179 - }, 180 - ":focus": { 181 - backgroundColor: "#ff2876", 182 - }, 183 - }, 184 - }, 185 - }} 186 - > 187 - Follow 188 - </Button> 189 - )} 190 - {follows.has(follower.did) && ( 191 - <Button 192 - shape="pill" 193 - size="mini" 194 - startEnhancer={<IconCheck size={16} />} 195 - onClick={() => onUnfollow(follower.did)} 196 - overrides={{ 197 - BaseButton: { 198 - style: { 199 - backgroundColor: "var(--color-default-button)", 200 - color: "var(--color-text)", 201 - ":hover": { 202 - backgroundColor: "var(--color-default-button)", 203 - }, 204 - ":focus": { 205 - backgroundColor: "var(--color-default-button)", 206 - }, 207 - }, 208 - }, 209 - }} 210 - > 211 - Following 212 - </Button> 213 - )} 214 - </div> 215 - )} 216 - </div> 217 - ))} 218 - 219 - {/* Infinite scroll trigger */} 220 - <div ref={loadMoreRef} className="h-[20px] w-full" /> 221 - 222 - {isFetchingNextPage && ( 223 - <div className="text-center py-4"> 224 - <LabelSmall className="!text-[var(--color-text)]"> 225 - Loading more... 226 - </LabelSmall> 227 - </div> 228 - )} 229 - </div> 230 - )} 231 - <SignInModal 232 - isOpen={isSignInOpen} 233 - onClose={() => setIsSignInOpen(false)} 234 - follow 235 - /> 236 - </> 237 - ); 238 - } 239 - 240 - export default Followers;
-3
apps/web/src/pages/profile/followers/index.tsx
··· 1 - import Followers from "./Followers"; 2 - 3 - export default Followers;
-241
apps/web/src/pages/profile/follows/Follows.tsx
··· 1 - import { HeadingSmall, LabelMedium, LabelSmall } from "baseui/typography"; 2 - import { useProfileByDidQuery } from "../../../hooks/useProfile"; 3 - import { Link, useParams } from "@tanstack/react-router"; 4 - import { 5 - useFollowAccountMutation, 6 - useFollowsInfiniteQuery, 7 - useFollowsQuery, 8 - useUnfollowAccountMutation, 9 - } from "../../../hooks/useGraph"; 10 - import { Avatar } from "baseui/avatar"; 11 - import { useAtom } from "jotai"; 12 - import { activeTabAtom } from "../../../atoms/tab"; 13 - import { followsAtom } from "../../../atoms/follows"; 14 - import { Button } from "baseui/button"; 15 - import { IconCheck, IconPlus } from "@tabler/icons-react"; 16 - import SignInModal from "../../../components/SignInModal"; 17 - import { useState, useEffect, useRef } from "react"; 18 - import numeral from "numeral"; 19 - import scrollToTop from "../../../lib/scrollToTop"; 20 - 21 - function Follows() { 22 - const [, setActiveKey] = useAtom(activeTabAtom); 23 - const [follows, setFollows] = useAtom(followsAtom); 24 - const [isSignInOpen, setIsSignInOpen] = useState(false); 25 - const { did } = useParams({ strict: false }); 26 - const profile = useProfileByDidQuery(did!); 27 - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = 28 - useFollowsInfiniteQuery(profile.data?.did!, 20); 29 - const { mutate: followAccount } = useFollowAccountMutation(); 30 - const { mutate: unfollowAccount } = useUnfollowAccountMutation(); 31 - 32 - const loadMoreRef = useRef<HTMLDivElement>(null); 33 - 34 - // Intersection Observer for infinite scroll 35 - useEffect(() => { 36 - if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return; 37 - 38 - const observer = new IntersectionObserver( 39 - (entries) => { 40 - if (entries[0]?.isIntersecting) { 41 - fetchNextPage(); 42 - } 43 - }, 44 - { threshold: 0.1 }, 45 - ); 46 - 47 - observer.observe(loadMoreRef.current); 48 - 49 - return () => observer.disconnect(); 50 - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); 51 - 52 - // Flatten all pages into a single array 53 - const allFollows = data?.pages.flatMap((page) => page.follows) ?? []; 54 - 55 - const { data: followsData } = useFollowsQuery( 56 - localStorage.getItem("did")!, 57 - allFollows.length, 58 - allFollows 59 - .map((follow) => follow.did) 60 - .filter((x) => x !== localStorage.getItem("did")), 61 - ); 62 - 63 - useEffect(() => { 64 - if (!followsData) return; 65 - setFollows((prev) => { 66 - const newSet = new Set(prev); 67 - followsData.follows.forEach((follow: { did: string }) => { 68 - newSet.add(follow.did); 69 - }); 70 - return newSet; 71 - }); 72 - }, [followsData, setFollows]); 73 - 74 - const onFollow = (followDid: string) => { 75 - if (!localStorage.getItem("token")) { 76 - setIsSignInOpen(true); 77 - return; 78 - } 79 - setFollows((prev) => new Set(prev).add(followDid)); 80 - followAccount(followDid); 81 - }; 82 - 83 - const onUnfollow = (followDid: string) => { 84 - if (!localStorage.getItem("token")) { 85 - setIsSignInOpen(true); 86 - return; 87 - } 88 - setFollows((prev) => { 89 - const newSet = new Set(prev); 90 - newSet.delete(followDid); 91 - return newSet; 92 - }); 93 - unfollowAccount(followDid); 94 - }; 95 - 96 - const count = data?.pages?.flatMap((page) => page.count)[0]; 97 - 98 - return ( 99 - <> 100 - <HeadingSmall className="!text-[var(--color-text)]"> 101 - Following {count > 0 ? `(${numeral(count).format("0,0")})` : ""} 102 - </HeadingSmall> 103 - 104 - {allFollows.length === 0 && data && ( 105 - <div className="text-center py-8"> 106 - <LabelMedium className="!text-[var(--color-text)] opacity-60"> 107 - Not following anyone yet 108 - </LabelMedium> 109 - </div> 110 - )} 111 - 112 - {allFollows.length > 0 && ( 113 - <div> 114 - {allFollows.map((follow: any) => ( 115 - <div 116 - key={follow.did} 117 - className="flex items-start justify-between gap-2 mb-[20px]" 118 - > 119 - <div className="flex items-center gap-2"> 120 - <Link 121 - to={`/profile/${follow.handle}` as any} 122 - className="no-underline" 123 - onClick={() => { 124 - setActiveKey(0); 125 - scrollToTop(); 126 - }} 127 - > 128 - <Avatar 129 - src={follow.avatar} 130 - name={follow.displayName} 131 - size={"60px"} 132 - /> 133 - </Link> 134 - <div className="ml-[16px]"> 135 - <Link 136 - to={`/profile/${follow.handle}` as string} 137 - className="no-underline" 138 - onClick={() => { 139 - setActiveKey(0); 140 - scrollToTop(); 141 - }} 142 - > 143 - <LabelMedium 144 - marginTop={"10px"} 145 - className="!text-[var(--color-text)]" 146 - > 147 - {follow.displayName} 148 - </LabelMedium> 149 - </Link> 150 - <Link 151 - to={`/profile/${follow.handle}` as string} 152 - className="no-underline text-[var(--color-primary)]" 153 - onClick={() => { 154 - setActiveKey(0); 155 - scrollToTop(); 156 - }} 157 - > 158 - <LabelSmall className="!text-[var(--color-primary)] mt-[3px] mb-[25px]"> 159 - @{follow.handle} 160 - </LabelSmall> 161 - </Link> 162 - </div> 163 - </div> 164 - {(follow.did !== localStorage.getItem("did") || 165 - !localStorage.getItem("did")) && ( 166 - <div className="ml-auto mt-[10px]"> 167 - {!follows.has(follow.did) && ( 168 - <Button 169 - shape="pill" 170 - size="mini" 171 - startEnhancer={<IconPlus size={16} />} 172 - onClick={() => onFollow(follow.did)} 173 - overrides={{ 174 - BaseButton: { 175 - style: { 176 - minWidth: "90px", 177 - backgroundColor: "#ff2876", 178 - ":hover": { 179 - backgroundColor: "#ff2876", 180 - }, 181 - ":focus": { 182 - backgroundColor: "#ff2876", 183 - }, 184 - }, 185 - }, 186 - }} 187 - > 188 - Follow 189 - </Button> 190 - )} 191 - {follows.has(follow.did) && ( 192 - <Button 193 - shape="pill" 194 - size="mini" 195 - startEnhancer={<IconCheck size={16} />} 196 - onClick={() => onUnfollow(follow.did)} 197 - overrides={{ 198 - BaseButton: { 199 - style: { 200 - backgroundColor: "var(--color-default-button)", 201 - color: "var(--color-text)", 202 - ":hover": { 203 - backgroundColor: "var(--color-default-button)", 204 - }, 205 - ":focus": { 206 - backgroundColor: "var(--color-default-button)", 207 - }, 208 - }, 209 - }, 210 - }} 211 - > 212 - Following 213 - </Button> 214 - )} 215 - </div> 216 - )} 217 - </div> 218 - ))} 219 - 220 - {/* Infinite scroll trigger */} 221 - <div ref={loadMoreRef} className="h-[20px] w-full" /> 222 - 223 - {isFetchingNextPage && ( 224 - <div className="text-center py-4"> 225 - <LabelSmall className="!text-[var(--color-text)]"> 226 - Loading more... 227 - </LabelSmall> 228 - </div> 229 - )} 230 - </div> 231 - )} 232 - <SignInModal 233 - isOpen={isSignInOpen} 234 - onClose={() => setIsSignInOpen(false)} 235 - follow 236 - /> 237 - </> 238 - ); 239 - } 240 - 241 - export default Follows;
-3
apps/web/src/pages/profile/follows/index.tsx
··· 1 - import Follows from "./Follows"; 2 - 3 - export default Follows;
+1 -7
apps/web/src/pages/profile/overview/Overview.tsx
··· 8 8 import TopAlbums from "./topalbums"; 9 9 import TopArtists from "./topartists"; 10 10 import TopTracks from "./toptracks"; 11 - import Compatibility from "./compatibility"; 12 11 13 12 function Overview() { 14 13 const { did } = useParams({ strict: false }); ··· 40 39 41 40 return ( 42 41 <> 43 - <div className="flex flex-row mb-[50px]"> 44 - {did && stats[did] && <Stats stats={stats[did]} />} 45 - <div className="flex-1"> 46 - <Compatibility /> 47 - </div> 48 - </div> 42 + {did && stats[did] && <Stats stats={stats[did]} />} 49 43 <div className="mb-20"> 50 44 <RecentTracks /> 51 45 </div>
-116
apps/web/src/pages/profile/overview/compatibility/Compatibility.tsx
··· 1 - import { useParams } from "@tanstack/react-router"; 2 - import { 3 - useActorCompatibilityQuery, 4 - useProfileByDidQuery, 5 - } from "../../../../hooks/useProfile"; 6 - import { Avatar } from "baseui/avatar"; 7 - import { Link } from "@tanstack/react-router"; 8 - 9 - function Compatibility() { 10 - const { did } = useParams({ strict: false }); 11 - const profile = useProfileByDidQuery(did!); 12 - const currentUser = useProfileByDidQuery(localStorage.getItem("did") || ""); 13 - const { data, isLoading } = useActorCompatibilityQuery(profile.data?.did); 14 - const currentUserDid = localStorage.getItem("did"); 15 - 16 - const getBorderColor = (percentage: number | undefined) => { 17 - if (!percentage) return "#666666"; 18 - if (percentage >= 80) return "#19d825"; // Green 19 - if (percentage >= 60) return "#ffd700"; // Gold 20 - if (percentage >= 40) return "#ff8c00"; // Orange 21 - return "#ff4500"; // Red 22 - }; 23 - 24 - const percentage = data?.compatibility?.compatibilityPercentage || 0; 25 - const borderColor = getBorderColor(percentage); 26 - 27 - return ( 28 - <> 29 - {currentUserDid && 30 - data && 31 - data.compatibility && 32 - currentUserDid != profile.data?.did && 33 - !isLoading && ( 34 - <div className="mt-[20px] ml-[19px] flex flex-row items-center"> 35 - <div 36 - style={{ 37 - position: "relative", 38 - width: "54px", 39 - height: "54px", 40 - flexShrink: 0, 41 - }} 42 - > 43 - <div 44 - style={{ 45 - position: "absolute", 46 - inset: 0, 47 - borderRadius: "50%", 48 - background: `conic-gradient(${borderColor} 0deg ${(percentage / 100) * 360}deg, transparent ${(percentage / 100) * 360}deg 360deg)`, 49 - display: "flex", 50 - alignItems: "center", 51 - justifyContent: "center", 52 - }} 53 - > 54 - <div 55 - style={{ 56 - width: "48px", 57 - height: "48px", 58 - borderRadius: "50%", 59 - background: "var(--color-background)", 60 - display: "flex", 61 - alignItems: "center", 62 - justifyContent: "center", 63 - }} 64 - > 65 - <Avatar 66 - name={currentUser.data?.displayName} 67 - src={currentUser.data?.avatar} 68 - size="46px" 69 - /> 70 - </div> 71 - </div> 72 - </div> 73 - <div className="ml-[10px] text-[14px]"> 74 - <div className="!text-[var(--color-text)]"> 75 - Your compatibility with <b>@{profile.data?.handle}</b> is{" "} 76 - <span style={{ color: borderColor }}> 77 - <b>{data.compatibility?.compatibilityLevel}</b> 78 - </span> 79 - </div> 80 - <div className="!text-[var(--color-text)] mt-[5px]"> 81 - You both listen to{" "} 82 - {(data.compatibility?.topSharedDetailedArtists || []).map( 83 - (artist, index) => ( 84 - <div key={artist.id} className="inline"> 85 - <Link 86 - to={ 87 - `/${artist.uri.split("at://")[1].replace("app.rocksky.", "")}` as string 88 - } 89 - className="no-underline" 90 - > 91 - <span className="mt-[0px] mb-[0px] text-[14px] !text-[var(--color-primary)]"> 92 - {artist.name} 93 - </span> 94 - </Link> 95 - {index !== 96 - (data.compatibility?.topSharedDetailedArtists || []) 97 - .length - 98 - 1 && 99 - (index === 100 - (data.compatibility?.topSharedDetailedArtists || []) 101 - .length - 102 - 2 103 - ? " and " 104 - : ", ")} 105 - </div> 106 - ), 107 - )} 108 - </div> 109 - </div> 110 - </div> 111 - )} 112 - </> 113 - ); 114 - } 115 - 116 - export default Compatibility;
-3
apps/web/src/pages/profile/overview/compatibility/index.tsx
··· 1 - import Compatibility from "./Compatibility"; 2 - 3 - export default Compatibility;
-17
apps/web/src/types/compatibility.ts
··· 1 - export type Compatibility = { 2 - compatibilityLevel: number; 3 - compatibilityPercentage: number; 4 - sharedArtists: number; 5 - topSharedArtists: string[]; 6 - topSharedDetailedArtists: { 7 - id: string; 8 - name: string; 9 - picture: string; 10 - uri: string; 11 - user1Rank: number; 12 - user2Rank: number; 13 - weight: number; 14 - }[]; 15 - user1ArtistCount: number; 16 - user2ArtistCount: number; 17 - };
-17
apps/web/src/types/neighbour.ts
··· 1 - export type Neighbour = { 2 - id: string; 3 - avatar: string; 4 - did: string; 5 - displayName: string; 6 - handle: string; 7 - sharedArtistsCount: number; 8 - similarityScore: number; 9 - topSharedArtistNames: string[]; 10 - topSharedArtistsDetails: { 11 - id: string; 12 - name: string; 13 - picture: string; 14 - uri: string; 15 - }[]; 16 - userId: string; 17 - };
-54
apps/web/src/types/profile.ts
··· 1 - export type Profile = { 2 - id: string; 3 - did: string; 4 - handle: string; 5 - displayName: string; 6 - avatar: string; 7 - createdAt: string; 8 - spotifyUser: { 9 - id: string; 10 - xataVersion: number; 11 - email: string; 12 - userId: string; 13 - isBetaUser: boolean; 14 - spotifyAppId: string; 15 - createdAt: string; 16 - updatedAt: string; 17 - }; 18 - spotifyToken: { 19 - id: string; 20 - xataVersion: number; 21 - userId: string; 22 - spotifyAppId: string; 23 - createdAt: string; 24 - updatedAt: string; 25 - }; 26 - spotifyConnected: boolean; 27 - googledrive: { 28 - id: string; 29 - email: string; 30 - isBetaUser: boolean; 31 - userId: string; 32 - xataVersion: number; 33 - createdAt: string; 34 - updatedAt: string; 35 - }; 36 - dropbox: { 37 - id: string; 38 - email: string; 39 - isBetaUser: boolean; 40 - userId: string; 41 - xataVersion: number; 42 - createdAt: string; 43 - updatedAt: string; 44 - }; 45 - googleDrive: { 46 - id: string; 47 - email: string; 48 - isBetaUser: boolean; 49 - userId: string; 50 - xataVersion: number; 51 - createdAt: string; 52 - updatedAt: string; 53 - }; 54 - };
+1 -6
crates/analytics/src/handlers/mod.rs
··· 12 12 }; 13 13 use tracks::{get_loved_tracks, get_top_tracks, get_tracks}; 14 14 15 - use crate::handlers::{ 16 - artists::get_artist_listeners, 17 - stats::{get_compatibility, get_neighbours}, 18 - }; 15 + use crate::handlers::artists::get_artist_listeners; 19 16 20 17 pub mod albums; 21 18 pub mod artists; ··· 64 61 "library.getArtistAlbums" => get_artist_albums(payload, req, conn.clone()).await, 65 62 "library.getArtistTracks" => get_artist_tracks(payload, req, conn.clone()).await, 66 63 "library.getArtistListeners" => get_artist_listeners(payload, req, conn.clone()).await, 67 - "library.getNeighbours" => get_neighbours(payload, req, conn.clone()).await, 68 - "library.getCompatibility" => get_compatibility(payload, req, conn.clone()).await, 69 64 _ => return Err(anyhow::anyhow!("Method not found")), 70 65 } 71 66 }
+1 -217
crates/analytics/src/handlers/stats.rs
··· 1 1 use std::sync::{Arc, Mutex}; 2 2 3 3 use crate::read_payload; 4 - use crate::types::stats::{ 5 - Compatibility, GetCompatibilityParams, GetNeighboursParams, SharedArtist, 6 - }; 7 4 use crate::types::{ 8 5 scrobble::{ScrobblesPerDay, ScrobblesPerMonth, ScrobblesPerYear}, 9 6 stats::{ 10 7 GetAlbumScrobblesParams, GetArtistScrobblesParams, GetScrobblesPerDayParams, 11 8 GetScrobblesPerMonthParams, GetScrobblesPerYearParams, GetStatsParams, 12 - GetTrackScrobblesParams, Neighbour, 9 + GetTrackScrobblesParams, 13 10 }, 14 11 }; 15 12 use actix_web::{web, HttpRequest, HttpResponse}; ··· 463 460 let scrobbles: Result<Vec<_>, _> = scrobbles.collect(); 464 461 Ok(HttpResponse::Ok().json(scrobbles?)) 465 462 } 466 - 467 - pub async fn get_neighbours( 468 - payload: &mut web::Payload, 469 - _req: &HttpRequest, 470 - conn: Arc<Mutex<Connection>>, 471 - ) -> Result<HttpResponse, Error> { 472 - let body = read_payload!(payload); 473 - let params = serde_json::from_slice::<GetNeighboursParams>(&body)?; 474 - let conn = conn.lock().unwrap(); 475 - tracing::info!(user_id = %params.user_id, "Get neighbours"); 476 - 477 - let mut stmt = conn.prepare( 478 - r#" 479 - WITH user_top_artists AS ( 480 - SELECT 481 - user_id, 482 - artist_id, 483 - COUNT(*) as play_count, 484 - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY COUNT(*) DESC) as artist_rank 485 - FROM scrobbles s 486 - INNER JOIN artists a ON a.id = s.artist_id 487 - WHERE s.artist_id IS NOT NULL 488 - AND a.name != 'Various Artists' 489 - GROUP BY user_id, artist_id 490 - ), 491 - weighted_similarity AS ( 492 - SELECT 493 - u1.user_id as target_user, 494 - u2.user_id as neighbor_user, 495 - SUM(1.0 / (u1.artist_rank + u2.artist_rank)) as similarity_score, 496 - COUNT(DISTINCT u1.artist_id) as shared_artists, 497 - ARRAY_AGG(DISTINCT u1.artist_id) FILTER (WHERE u1.artist_rank <= 20) as top_shared_artists 498 - FROM user_top_artists u1 499 - JOIN user_top_artists u2 500 - ON u1.artist_id = u2.artist_id 501 - AND u1.user_id != u2.user_id 502 - WHERE u1.user_id = ? 503 - AND u1.artist_rank <= 50 504 - AND u2.artist_rank <= 50 505 - GROUP BY u1.user_id, u2.user_id 506 - HAVING shared_artists >= 3 507 - AND top_shared_artists IS NOT NULL 508 - ) 509 - SELECT 510 - ws.neighbor_user, 511 - u.display_name, 512 - u.handle, 513 - u.did, 514 - u.avatar, 515 - ws.similarity_score, 516 - ws.shared_artists, 517 - to_json(LIST(a.name ORDER BY array_position(ws.top_shared_artists, a.id))) as top_shared_artist_names, 518 - to_json(LIST({'id': a.id, 'name': a.name, 'picture': a.picture, 'uri': a.uri} 519 - ORDER BY array_position(ws.top_shared_artists, a.id))) as top_shared_artists_details 520 - FROM weighted_similarity ws 521 - LEFT JOIN users u ON u.id = ws.neighbor_user 522 - INNER JOIN UNNEST(ws.top_shared_artists) AS t(artist_id) ON true 523 - INNER JOIN artists a ON a.id = t.artist_id 524 - GROUP BY ws.neighbor_user, u.display_name, u.handle, u.did, u.avatar, ws.similarity_score, ws.shared_artists, ws.top_shared_artists 525 - ORDER BY ws.similarity_score DESC 526 - LIMIT 20 527 - "#, 528 - )?; 529 - 530 - let neighbours = stmt.query_map([&params.user_id], |row| { 531 - let top_shared_artist_names_json: String = row.get(7)?; 532 - let top_shared_artists_details_json: String = row.get(8)?; 533 - 534 - let top_shared_artist_names: Vec<String> = 535 - serde_json::from_str(&top_shared_artist_names_json).unwrap_or_else(|_| Vec::new()); 536 - let top_shared_artists_details: Vec<crate::types::stats::NeighbourArtist> = 537 - serde_json::from_str(&top_shared_artists_details_json).unwrap_or_else(|_| Vec::new()); 538 - 539 - Ok(Neighbour { 540 - user_id: row.get(0)?, 541 - display_name: row.get(1)?, 542 - handle: row.get(2)?, 543 - did: row.get(3)?, 544 - avatar: row.get(4)?, 545 - similarity_score: row.get(5)?, 546 - shared_artists_count: row.get(6)?, 547 - top_shared_artist_names, 548 - top_shared_artists_details, 549 - }) 550 - })?; 551 - 552 - let neighbours: Result<Vec<_>, _> = neighbours.collect(); 553 - Ok(HttpResponse::Ok().json(neighbours?)) 554 - } 555 - 556 - pub async fn get_compatibility( 557 - payload: &mut web::Payload, 558 - _req: &HttpRequest, 559 - conn: Arc<Mutex<Connection>>, 560 - ) -> Result<HttpResponse, Error> { 561 - let body = read_payload!(payload); 562 - let params = serde_json::from_slice::<GetCompatibilityParams>(&body)?; 563 - let conn = conn.lock().unwrap(); 564 - tracing::info!(user_id_1 = %params.user_id1, user_id_2 = %params.user_id2, "Get compatibility"); 565 - 566 - let mut stmt = conn.prepare( 567 - r#" 568 - WITH user_top_artists AS ( 569 - SELECT 570 - user_id, 571 - artist_id, 572 - COUNT(*) as play_count, 573 - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY COUNT(*) DESC) as artist_rank 574 - FROM scrobbles s 575 - INNER JOIN artists a ON a.id = s.artist_id 576 - WHERE s.artist_id IS NOT NULL 577 - AND a.name != 'Various Artists' 578 - AND user_id IN (?, ?) 579 - GROUP BY user_id, artist_id 580 - ), 581 - user_totals AS ( 582 - SELECT 583 - user_id, 584 - COUNT(DISTINCT artist_id) as total_artists 585 - FROM user_top_artists 586 - WHERE artist_rank <= 50 587 - GROUP BY user_id 588 - ), 589 - shared_weighted AS ( 590 - SELECT 591 - u1.artist_id, 592 - u1.artist_rank as user1_rank, 593 - u2.artist_rank as user2_rank, 594 - (1.0 / u1.artist_rank) * (1.0 / u2.artist_rank) as artist_weight, 595 - ROW_NUMBER() OVER (ORDER BY (1.0 / u1.artist_rank) * (1.0 / u2.artist_rank) DESC) as weight_rank 596 - FROM user_top_artists u1 597 - INNER JOIN user_top_artists u2 598 - ON u1.artist_id = u2.artist_id 599 - AND u1.user_id = ? 600 - AND u2.user_id = ? 601 - WHERE u1.artist_rank <= 50 602 - AND u2.artist_rank <= 50 603 - ), 604 - compatibility_calc AS ( 605 - SELECT 606 - SUM(sw.artist_weight) as weighted_overlap, 607 - COUNT(*) as shared_count, 608 - (SELECT total_artists FROM user_totals WHERE user_id = ?) as user1_total, 609 - (SELECT total_artists FROM user_totals WHERE user_id = ?) as user2_total 610 - FROM shared_weighted sw 611 - ) 612 - SELECT 613 - ROUND( 614 - (shared_count * 1.0 / LEAST(user1_total, user2_total)) * 100, 615 - 1 616 - ) as compatibility_percentage, 617 - CASE 618 - WHEN (shared_count * 1.0 / LEAST(user1_total, user2_total)) * 100 < 20 THEN 'Low' 619 - WHEN (shared_count * 1.0 / LEAST(user1_total, user2_total)) * 100 < 40 THEN 'Medium' 620 - WHEN (shared_count * 1.0 / LEAST(user1_total, user2_total)) * 100 < 60 THEN 'High' 621 - WHEN (shared_count * 1.0 / LEAST(user1_total, user2_total)) * 100 < 75 THEN 'Very High' 622 - WHEN (shared_count * 1.0 / LEAST(user1_total, user2_total)) * 100 < 90 THEN 'Super' 623 - ELSE 'ZOMG!1!' 624 - END as compatibility_level, 625 - shared_count as shared_artists, 626 - user1_total as user1_artist_count, 627 - user2_total as user2_artist_count, 628 - to_json(LIST(a.name ORDER BY sw.artist_weight DESC) FILTER (WHERE sw.weight_rank <= 10)) as top_shared_artists, 629 - to_json(LIST({ 630 - 'id': a.id, 631 - 'name': a.name, 632 - 'picture': a.picture, 633 - 'uri': a.uri, 634 - 'user1_rank': sw.user1_rank, 635 - 'user2_rank': sw.user2_rank, 636 - 'weight': sw.artist_weight 637 - } ORDER BY sw.artist_weight DESC) FILTER (WHERE sw.weight_rank <= 10)) as top_shared_detailed_artists 638 - FROM compatibility_calc 639 - CROSS JOIN shared_weighted sw 640 - INNER JOIN artists a ON a.id = sw.artist_id 641 - GROUP BY weighted_overlap, shared_count, user1_total, user2_total; 642 - "#, 643 - )?; 644 - 645 - let compatibility = stmt.query_map( 646 - [ 647 - &params.user_id1, 648 - &params.user_id2, 649 - &params.user_id1, 650 - &params.user_id2, 651 - &params.user_id1, 652 - &params.user_id2, 653 - ], 654 - |row| { 655 - let top_shared_artists_json: String = row.get(5)?; 656 - let top_shared_artists: Vec<String> = 657 - serde_json::from_str(&top_shared_artists_json).unwrap_or_else(|_| Vec::new()); 658 - let top_shared_detailed_artists_json: String = row.get(6)?; 659 - let top_shared_detailed_artists: Vec<SharedArtist> = 660 - serde_json::from_str(&top_shared_detailed_artists_json) 661 - .unwrap_or_else(|_| Vec::new()); 662 - Ok(Compatibility { 663 - compatibility_percentage: row.get(0)?, 664 - compatibility_level: row.get(1)?, 665 - shared_artists: row.get(2)?, 666 - user1_artist_count: row.get(3)?, 667 - user2_artist_count: row.get(4)?, 668 - top_shared_artists, 669 - top_shared_detailed_artists, 670 - }) 671 - }, 672 - )?; 673 - 674 - let compatibility = compatibility.collect::<Result<Vec<_>, _>>()?; 675 - let compatibility = compatibility.into_iter().next(); 676 - 677 - Ok(HttpResponse::Ok().json(compatibility)) 678 - }
-42
crates/analytics/src/subscriber/mod.rs
··· 679 679 680 680 pub async fn like(conn: Arc<Mutex<Connection>>, payload: LikePayload) -> Result<(), Error> { 681 681 let conn = conn.lock().unwrap(); 682 - 683 - let exists: bool = conn.query_row( 684 - "SELECT EXISTS(SELECT 1 FROM loved_tracks WHERE user_id = ? AND track_id = ?)", 685 - params![payload.user_id.xata_id, payload.track_id.xata_id], 686 - |row| row.get(0), 687 - )?; 688 - 689 - if exists { 690 - tracing::warn!( 691 - "Like already exists, user_id = {} track_id = {}", 692 - payload.user_id.xata_id, 693 - payload.track_id.xata_id 694 - ); 695 - return Ok(()); 696 - } 697 - 698 682 match conn.execute( 699 683 "INSERT INTO loved_tracks ( 700 684 id, ··· 953 937 "#; 954 938 955 939 match serde_json::from_str::<types::ScrobblePayload>(data) { 956 - Err(e) => { 957 - tracing::error!("Error parsing payload: {}", e); 958 - tracing::error!("{}", data); 959 - } 960 - Ok(_) => {} 961 - } 962 - assert!(true); 963 - } 964 - 965 - #[test] 966 - fn test_parse_like() { 967 - let data = r#"{ 968 - "uri":"at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/app.rocksky.like/3mb6kxku6js2u", 969 - "user_id": { 970 - "xata_id": "rec_cug4h6ibhfbm7uq5dte0" 971 - }, 972 - "track_id": { 973 - "xata_id":"rec_d11h6cdqrj64hn24639g" 974 - }, 975 - "xata_createdat": "2025-12-30T04:59:55.203Z", 976 - "xata_id":"rec_d59loiod60d9sc81mc80", 977 - "xata_updatedat":"2025-12-30T04:59:55.203Z", 978 - "xata_version":0 979 - }"#; 980 - 981 - match serde_json::from_str::<types::LikePayload>(data) { 982 940 Err(e) => { 983 941 tracing::error!("Error parsing payload: {}", e); 984 942 tracing::error!("{}", data);
-54
crates/analytics/src/types/stats.rs
··· 128 128 } 129 129 } 130 130 } 131 - 132 - #[derive(Debug, Serialize, Deserialize)] 133 - pub struct GetNeighboursParams { 134 - pub user_id: String, 135 - } 136 - 137 - #[derive(Debug, Serialize, Deserialize)] 138 - pub struct NeighbourArtist { 139 - pub id: String, 140 - pub name: String, 141 - pub picture: Option<String>, 142 - pub uri: Option<String>, 143 - } 144 - 145 - #[derive(Debug, Serialize, Deserialize)] 146 - pub struct Neighbour { 147 - pub user_id: String, 148 - pub display_name: Option<String>, 149 - pub handle: String, 150 - pub did: String, 151 - pub avatar: String, 152 - pub similarity_score: f64, 153 - pub shared_artists_count: i64, 154 - pub top_shared_artist_names: Vec<String>, 155 - pub top_shared_artists_details: Vec<NeighbourArtist>, 156 - } 157 - 158 - #[derive(Debug, Serialize, Deserialize)] 159 - pub struct GetCompatibilityParams { 160 - pub user_id1: String, 161 - pub user_id2: String, 162 - } 163 - 164 - #[derive(Debug, Serialize, Deserialize)] 165 - pub struct SharedArtist { 166 - pub id: String, 167 - pub name: String, 168 - pub picture: Option<String>, 169 - pub uri: Option<String>, 170 - pub user1_rank: i64, 171 - pub user2_rank: i64, 172 - pub weight: f64, 173 - } 174 - 175 - #[derive(Debug, Serialize, Deserialize)] 176 - pub struct Compatibility { 177 - pub compatibility_percentage: f64, 178 - pub compatibility_level: String, 179 - pub shared_artists: i64, 180 - pub user1_artist_count: i64, 181 - pub user2_artist_count: i64, 182 - pub top_shared_artists: Vec<String>, 183 - pub top_shared_detailed_artists: Vec<SharedArtist>, 184 - }
+3 -50
crates/jetstream/src/repo.rs
··· 8 8 9 9 use crate::{ 10 10 profile::did_to_profile, 11 - subscriber::{ 12 - ALBUM_NSID, ARTIST_NSID, FEED_GENERATOR_NSID, FOLLOW_NSID, SCROBBLE_NSID, SONG_NSID, 13 - }, 14 - types::{ 15 - AlbumRecord, ArtistRecord, Commit, FeedGeneratorRecord, FollowRecord, ScrobbleRecord, 16 - SongRecord, 17 - }, 11 + subscriber::{ALBUM_NSID, ARTIST_NSID, FEED_GENERATOR_NSID, SCROBBLE_NSID, SONG_NSID}, 12 + types::{AlbumRecord, ArtistRecord, Commit, FeedGeneratorRecord, ScrobbleRecord, SongRecord}, 18 13 webhook::discord::{ 19 14 self, 20 15 model::{ScrobbleData, WebhookEnvelope}, ··· 40 35 ALBUM_NSID, 41 36 SONG_NSID, 42 37 FEED_GENERATOR_NSID, 43 - FOLLOW_NSID, 44 38 ] 45 39 .contains(&commit.collection.as_str()) 46 40 { ··· 192 186 let uri = format!("at://{}/app.rocksky.feed.generator/{}", did, commit.rkey); 193 187 194 188 let feed_generator_record: FeedGeneratorRecord = 195 - serde_json::from_value(commit.record.clone())?; 189 + serde_json::from_value(commit.record)?; 196 190 save_feed_generator(&mut tx, &user_id, feed_generator_record, &uri).await?; 197 - 198 - tx.commit().await?; 199 - } 200 - 201 - if commit.collection == FOLLOW_NSID { 202 - let mut tx = pool.begin().await?; 203 - 204 - save_user(&mut tx, did).await?; 205 - let uri = format!("at://{}/app.rocksky.graph.follow/{}", did, commit.rkey); 206 - 207 - let follow_record: FollowRecord = serde_json::from_value(commit.record)?; 208 - save_user(&mut tx, &follow_record.subject).await?; 209 - save_follow(&mut tx, did, follow_record, &uri).await?; 210 191 211 192 tx.commit().await?; 212 193 } ··· 1095 1076 .await?; 1096 1077 Ok(()) 1097 1078 } 1098 - 1099 - pub async fn save_follow( 1100 - tx: &mut sqlx::Transaction<'_, Postgres>, 1101 - did: &str, 1102 - record: FollowRecord, 1103 - uri: &str, 1104 - ) -> Result<(), Error> { 1105 - tracing::info!(did = %did, uri = %uri, "Saving follow"); 1106 - 1107 - sqlx::query( 1108 - r#" 1109 - INSERT INTO follows ( 1110 - follower_did, 1111 - subject_did, 1112 - uri 1113 - ) VALUES ( 1114 - $1, $2, $3 1115 - ) 1116 - ON CONFLICT (follower_did, subject_did) DO NOTHING 1117 - "#, 1118 - ) 1119 - .bind(did) 1120 - .bind(record.subject) 1121 - .bind(uri) 1122 - .execute(&mut **tx) 1123 - .await?; 1124 - Ok(()) 1125 - }
-1
crates/jetstream/src/subscriber.rs
··· 17 17 pub const LIKE_NSID: &str = "app.rocksky.like"; 18 18 pub const SHOUT_NSID: &str = "app.rocksky.shout"; 19 19 pub const FEED_GENERATOR_NSID: &str = "app.rocksky.feed.generator"; 20 - pub const FOLLOW_NSID: &str = "app.rocksky.graph.follow"; 21 20 22 21 pub struct ScrobbleSubscriber { 23 22 pub service_url: String,
-7
crates/jetstream/src/types.rs
··· 244 244 pub did: String, 245 245 pub created_at: String, 246 246 } 247 - 248 - #[derive(Debug, Deserialize, Clone)] 249 - #[serde(rename_all = "camelCase")] 250 - pub struct FollowRecord { 251 - pub subject: String, 252 - pub created_at: String, 253 - }
-12
crates/jetstream/src/xata/follow.rs
··· 1 - use chrono::{DateTime, Utc}; 2 - use serde::Deserialize; 3 - 4 - #[derive(Debug, sqlx::FromRow, Deserialize, Clone)] 5 - pub struct Follow { 6 - pub xata_id: String, 7 - pub uri: String, 8 - pub follower_did: String, 9 - pub subject_did: String, 10 - #[serde(with = "chrono::serde::ts_seconds")] 11 - pub xata_createdat: DateTime<Utc>, 12 - }
-1
crates/jetstream/src/xata/mod.rs
··· 4 4 pub mod artist_album; 5 5 pub mod artist_track; 6 6 pub mod feed; 7 - pub mod follow; 8 7 pub mod loved_track; 9 8 pub mod scrobble; 10 9 pub mod track;
+1 -1
crates/spotify/src/lib.rs
··· 528 528 previous_item.id != data_item.id 529 529 && previous.progress_ms.unwrap_or(0) != data.progress_ms.unwrap_or(0) 530 530 } 531 - _ => data.item.is_some(), 531 + _ => false, 532 532 }; 533 533 534 534 // save as previous song
+1 -2
package.json
··· 27 27 "dev:webscrobbler": "cargo run -p rockskyd --release -- webscrobbler", 28 28 "dev:tracklist": "cargo run -p rockskyd --release -- tracklist", 29 29 "db:pgpull": "cargo run -p rockskyd --release -- pull && rm -f rocksky-analytics.ddb* rocksky-feed.ddb* && curl -o rocksky-analytics.ddb https://backup.rocksky.app/rocksky-analytics.ddb && curl -o rocksky-feed.ddb https://backup.rocksky.app/rocksky-feed.ddb", 30 - "dev:feeds": "cd apps/feeds && deno task dev", 31 30 "mb": "cd musicbrainz && go run main.go", 32 31 "spotify": "cd apps/api && bun run spotify", 33 32 "build:raichu": "cd crates/raichu && wasm-pack build --release --target web && cp -r pkg ../../apps/web/src", ··· 44 43 "apps/uploads", 45 44 "apps/xata-proxy" 46 45 ] 47 - } 46 + }