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
+760 -10545
apps
api
drizzle
lexicons
pkl
src
feeds
web
crates
analytics
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 }
-10
apps/api/lexicons/actor/getActorAlbums.json
··· 25 25 "type": "integer", 26 26 "description": "The offset for pagination", 27 27 "minimum": 0 28 - }, 29 - "startDate": { 30 - "type": "string", 31 - "description": "The start date to filter albums from (ISO 8601 format)", 32 - "format": "datetime" 33 - }, 34 - "endDate": { 35 - "type": "string", 36 - "description": "The end date to filter albums to (ISO 8601 format)", 37 - "format": "datetime" 38 28 } 39 29 } 40 30 },
-10
apps/api/lexicons/actor/getActorArtists.json
··· 25 25 "type": "integer", 26 26 "description": "The offset for pagination", 27 27 "minimum": 0 28 - }, 29 - "startDate": { 30 - "type": "string", 31 - "description": "The start date to filter albums from (ISO 8601 format)", 32 - "format": "datetime" 33 - }, 34 - "endDate": { 35 - "type": "string", 36 - "description": "The end date to filter albums to (ISO 8601 format)", 37 - "format": "datetime" 38 28 } 39 29 } 40 30 },
-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 - }
-10
apps/api/lexicons/actor/getActorSongs.json
··· 25 25 "type": "integer", 26 26 "description": "The offset for pagination", 27 27 "minimum": 0 28 - }, 29 - "startDate": { 30 - "type": "string", 31 - "description": "The start date to filter albums from (ISO 8601 format)", 32 - "format": "datetime" 33 - }, 34 - "endDate": { 35 - "type": "string", 36 - "description": "The end date to filter albums to (ISO 8601 format)", 37 - "format": "datetime" 38 28 } 39 29 } 40 30 },
-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 - }
-6
apps/api/lexicons/scrobble/defs.json
··· 62 62 "sha256": { 63 63 "type": "string", 64 64 "description": "The SHA256 hash of the scrobble data." 65 - }, 66 - "liked": { 67 - "type": "boolean" 68 - }, 69 - "likesCount": { 70 - "type": "integer" 71 65 } 72 66 } 73 67 },
-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 + }
+3 -11
apps/api/pkl/defs/actor/getActorAlbums.pkl
··· 1 - amends "../../schema/lexicon.pkl" 1 + amends "../../schema/lexicon.pkl" 2 2 3 3 lexicon = 1 4 4 id = "app.rocksky.actor.getActorAlbums" ··· 13 13 description = "The DID or handle of the actor" 14 14 format = "at-identifier" 15 15 } 16 - ["limit"] = new IntegerType { 16 + ["limit"] = new IntegerType { 17 17 type = "integer" 18 18 description = "The maximum number of albums to return" 19 19 minimum = 1 ··· 23 23 description = "The offset for pagination" 24 24 minimum = 0 25 25 } 26 - ["startDate"] = new StringType { 27 - description = "The start date to filter albums from (ISO 8601 format)" 28 - format = "datetime" 29 - } 30 - ["endDate"] = new StringType { 31 - description = "The end date to filter albums to (ISO 8601 format)" 32 - format = "datetime" 33 - } 34 26 } 35 27 } 36 28 output { ··· 48 40 } 49 41 } 50 42 } 51 - } 43 + }
+3 -11
apps/api/pkl/defs/actor/getActorArtists.pkl
··· 1 - amends "../../schema/lexicon.pkl" 1 + amends "../../schema/lexicon.pkl" 2 2 3 3 lexicon = 1 4 4 id = "app.rocksky.actor.getActorArtists" ··· 13 13 description = "The DID or handle of the actor" 14 14 format = "at-identifier" 15 15 } 16 - ["limit"] = new IntegerType { 16 + ["limit"] = new IntegerType { 17 17 type = "integer" 18 18 description = "The maximum number of albums to return" 19 19 minimum = 1 ··· 23 23 description = "The offset for pagination" 24 24 minimum = 0 25 25 } 26 - ["startDate"] = new StringType { 27 - description = "The start date to filter albums from (ISO 8601 format)" 28 - format = "datetime" 29 - } 30 - ["endDate"] = new StringType { 31 - description = "The end date to filter albums to (ISO 8601 format)" 32 - format = "datetime" 33 - } 34 26 } 35 27 } 36 28 output { ··· 48 40 } 49 41 } 50 42 } 51 - } 43 + }
-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 - }
+3 -11
apps/api/pkl/defs/actor/getActorSongs.pkl
··· 1 - amends "../../schema/lexicon.pkl" 1 + amends "../../schema/lexicon.pkl" 2 2 3 3 lexicon = 1 4 4 id = "app.rocksky.actor.getActorSongs" ··· 23 23 description = "The offset for pagination" 24 24 minimum = 0 25 25 } 26 - ["startDate"] = new StringType { 27 - description = "The start date to filter albums from (ISO 8601 format)" 28 - format = "datetime" 29 - } 30 - ["endDate"] = new StringType { 31 - description = "The end date to filter albums to (ISO 8601 format)" 32 - format = "datetime" 33 - } 34 26 } 35 27 } 36 - output { 28 + output { 37 29 encoding = "application/json" 38 30 schema = new ObjectType { 39 31 type = "object" ··· 48 40 } 49 41 } 50 42 } 51 - } 43 + }
-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 - }
+1 -8
apps/api/pkl/defs/scrobble/defs.pkl
··· 73 73 description = "The SHA256 hash of the scrobble data." 74 74 } 75 75 76 - ["liked"] = new BooleanType { 77 - type = "boolean" 78 - } 79 - 80 - ["likesCount"] = new IntegerType { 81 - type = "integer" 82 - } 83 76 } 84 77 } 85 78 ··· 155 148 } 156 149 } 157 150 } 158 - } 151 + }
+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 }
-588
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: { ··· 797 691 type: "integer", 798 692 description: "The offset for pagination", 799 693 minimum: 0, 800 - }, 801 - startDate: { 802 - type: "string", 803 - description: 804 - "The start date to filter albums from (ISO 8601 format)", 805 - format: "datetime", 806 - }, 807 - endDate: { 808 - type: "string", 809 - description: "The end date to filter albums to (ISO 8601 format)", 810 - format: "datetime", 811 694 }, 812 695 }, 813 696 }, ··· 855 738 description: "The offset for pagination", 856 739 minimum: 0, 857 740 }, 858 - startDate: { 859 - type: "string", 860 - description: 861 - "The start date to filter albums from (ISO 8601 format)", 862 - format: "datetime", 863 - }, 864 - endDate: { 865 - type: "string", 866 - description: "The end date to filter albums to (ISO 8601 format)", 867 - format: "datetime", 868 - }, 869 741 }, 870 742 }, 871 743 output: { ··· 886 758 }, 887 759 }, 888 760 }, 889 - AppRockskyActorGetActorCompatibility: { 890 - lexicon: 1, 891 - id: "app.rocksky.actor.getActorCompatibility", 892 - defs: { 893 - main: { 894 - type: "query", 895 - description: "Get compatibility for an actor", 896 - parameters: { 897 - type: "params", 898 - required: ["did"], 899 - properties: { 900 - did: { 901 - type: "string", 902 - description: "DID or handle to get compatibility for", 903 - format: "at-identifier", 904 - }, 905 - }, 906 - }, 907 - output: { 908 - encoding: "application/json", 909 - schema: { 910 - type: "object", 911 - properties: { 912 - compatibility: { 913 - type: "ref", 914 - ref: "lex:app.rocksky.actor.defs#compatibilityViewBasic", 915 - }, 916 - }, 917 - }, 918 - }, 919 - }, 920 - }, 921 - }, 922 761 AppRockskyActorGetActorLovedSongs: { 923 762 lexicon: 1, 924 763 id: "app.rocksky.actor.getActorLovedSongs", ··· 957 796 items: { 958 797 type: "ref", 959 798 ref: "lex:app.rocksky.song.defs#songViewBasic", 960 - }, 961 - }, 962 - }, 963 - }, 964 - }, 965 - }, 966 - }, 967 - }, 968 - AppRockskyActorGetActorNeighbours: { 969 - lexicon: 1, 970 - id: "app.rocksky.actor.getActorNeighbours", 971 - defs: { 972 - main: { 973 - type: "query", 974 - description: "Get neighbours for an actor", 975 - parameters: { 976 - type: "params", 977 - required: ["did"], 978 - properties: { 979 - did: { 980 - type: "string", 981 - description: "The DID or handle of the actor", 982 - format: "at-identifier", 983 - }, 984 - }, 985 - }, 986 - output: { 987 - encoding: "application/json", 988 - schema: { 989 - type: "object", 990 - properties: { 991 - neighbours: { 992 - type: "array", 993 - items: { 994 - type: "ref", 995 - ref: "lex:app.rocksky.actor.defs#neighbourViewBasic", 996 799 }, 997 800 }, 998 801 }, ··· 1118 921 type: "integer", 1119 922 description: "The offset for pagination", 1120 923 minimum: 0, 1121 - }, 1122 - startDate: { 1123 - type: "string", 1124 - description: 1125 - "The start date to filter albums from (ISO 8601 format)", 1126 - format: "datetime", 1127 - }, 1128 - endDate: { 1129 - type: "string", 1130 - description: "The end date to filter albums to (ISO 8601 format)", 1131 - format: "datetime", 1132 924 }, 1133 925 }, 1134 926 }, ··· 2551 2343 ref: "lex:app.rocksky.feed.defs#feedItemView", 2552 2344 }, 2553 2345 }, 2554 - cursor: { 2555 - type: "string", 2556 - description: "The pagination cursor for the next set of results.", 2557 - }, 2558 2346 }, 2559 2347 }, 2560 2348 }, ··· 2947 2735 }, 2948 2736 }, 2949 2737 }, 2950 - AppRockskyGraphDefs: { 2951 - lexicon: 1, 2952 - id: "app.rocksky.graph.defs", 2953 - defs: { 2954 - notFoundActor: { 2955 - type: "object", 2956 - description: "indicates that a handle or DID could not be resolved", 2957 - required: ["actor", "notFound"], 2958 - properties: { 2959 - actor: { 2960 - type: "string", 2961 - format: "at-identifier", 2962 - }, 2963 - notFound: { 2964 - type: "boolean", 2965 - }, 2966 - }, 2967 - }, 2968 - relationship: { 2969 - type: "object", 2970 - required: ["did"], 2971 - properties: { 2972 - did: { 2973 - type: "string", 2974 - format: "did", 2975 - }, 2976 - following: { 2977 - type: "string", 2978 - description: 2979 - "if the actor follows this DID, this is the AT-URI of the follow record", 2980 - format: "at-uri", 2981 - }, 2982 - followedBy: { 2983 - type: "string", 2984 - description: 2985 - "if the actor is followed by this DID, contains the AT-URI of the follow record", 2986 - format: "at-uri", 2987 - }, 2988 - }, 2989 - }, 2990 - }, 2991 - }, 2992 - AppRockskyGraphFollow: { 2993 - lexicon: 1, 2994 - id: "app.rocksky.graph.follow", 2995 - defs: { 2996 - main: { 2997 - type: "record", 2998 - description: 2999 - "Record declaring a social 'follow' relationship of another account.", 3000 - key: "tid", 3001 - record: { 3002 - type: "object", 3003 - required: ["createdAt", "subject"], 3004 - properties: { 3005 - createdAt: { 3006 - type: "string", 3007 - format: "datetime", 3008 - }, 3009 - subject: { 3010 - type: "string", 3011 - format: "did", 3012 - }, 3013 - via: { 3014 - type: "ref", 3015 - ref: "lex:com.atproto.repo.strongRef", 3016 - }, 3017 - }, 3018 - }, 3019 - }, 3020 - }, 3021 - }, 3022 - AppRockskyGraphFollowAccount: { 3023 - lexicon: 1, 3024 - id: "app.rocksky.graph.followAccount", 3025 - defs: { 3026 - main: { 3027 - type: "procedure", 3028 - description: 3029 - "Creates a 'follow' relationship from the authenticated account to a specified account.", 3030 - parameters: { 3031 - type: "params", 3032 - required: ["account"], 3033 - properties: { 3034 - account: { 3035 - type: "string", 3036 - format: "at-identifier", 3037 - }, 3038 - }, 3039 - }, 3040 - output: { 3041 - encoding: "application/json", 3042 - schema: { 3043 - type: "object", 3044 - required: ["subject", "followers"], 3045 - properties: { 3046 - subject: { 3047 - type: "ref", 3048 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3049 - }, 3050 - followers: { 3051 - type: "array", 3052 - items: { 3053 - type: "ref", 3054 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3055 - }, 3056 - }, 3057 - cursor: { 3058 - type: "string", 3059 - description: 3060 - "A cursor value to pass to subsequent calls to get the next page of results.", 3061 - }, 3062 - }, 3063 - }, 3064 - }, 3065 - }, 3066 - }, 3067 - }, 3068 - AppRockskyGraphGetFollowers: { 3069 - lexicon: 1, 3070 - id: "app.rocksky.graph.getFollowers", 3071 - defs: { 3072 - main: { 3073 - type: "query", 3074 - description: 3075 - "Enumerates accounts which follow a specified account (actor).", 3076 - parameters: { 3077 - type: "params", 3078 - required: ["actor"], 3079 - properties: { 3080 - actor: { 3081 - type: "string", 3082 - format: "at-identifier", 3083 - }, 3084 - limit: { 3085 - type: "integer", 3086 - maximum: 100, 3087 - minimum: 1, 3088 - default: 50, 3089 - }, 3090 - dids: { 3091 - type: "array", 3092 - description: 3093 - "If provided, filters the followers to only include those with DIDs in this list.", 3094 - items: { 3095 - type: "string", 3096 - format: "did", 3097 - }, 3098 - }, 3099 - cursor: { 3100 - type: "string", 3101 - }, 3102 - }, 3103 - }, 3104 - output: { 3105 - encoding: "application/json", 3106 - schema: { 3107 - type: "object", 3108 - required: ["subject", "followers"], 3109 - properties: { 3110 - subject: { 3111 - type: "ref", 3112 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3113 - }, 3114 - followers: { 3115 - type: "array", 3116 - items: { 3117 - type: "ref", 3118 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3119 - }, 3120 - }, 3121 - cursor: { 3122 - type: "string", 3123 - description: 3124 - "A cursor value to pass to subsequent calls to get the next page of results.", 3125 - }, 3126 - count: { 3127 - type: "integer", 3128 - description: "The total number of followers.", 3129 - }, 3130 - }, 3131 - }, 3132 - }, 3133 - }, 3134 - }, 3135 - }, 3136 - AppRockskyGraphGetFollows: { 3137 - lexicon: 1, 3138 - id: "app.rocksky.graph.getFollows", 3139 - defs: { 3140 - main: { 3141 - type: "query", 3142 - description: 3143 - "Enumerates accounts which a specified account (actor) follows.", 3144 - parameters: { 3145 - type: "params", 3146 - required: ["actor"], 3147 - properties: { 3148 - actor: { 3149 - type: "string", 3150 - format: "at-identifier", 3151 - }, 3152 - limit: { 3153 - type: "integer", 3154 - maximum: 100, 3155 - minimum: 1, 3156 - default: 50, 3157 - }, 3158 - dids: { 3159 - type: "array", 3160 - description: 3161 - "If provided, filters the follows to only include those with DIDs in this list.", 3162 - items: { 3163 - type: "string", 3164 - format: "did", 3165 - }, 3166 - }, 3167 - cursor: { 3168 - type: "string", 3169 - }, 3170 - }, 3171 - }, 3172 - output: { 3173 - encoding: "application/json", 3174 - schema: { 3175 - type: "object", 3176 - required: ["subject", "follows"], 3177 - properties: { 3178 - subject: { 3179 - type: "ref", 3180 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3181 - }, 3182 - follows: { 3183 - type: "array", 3184 - items: { 3185 - type: "ref", 3186 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3187 - }, 3188 - }, 3189 - cursor: { 3190 - type: "string", 3191 - description: 3192 - "A cursor value to pass to subsequent calls to get the next page of results.", 3193 - }, 3194 - count: { 3195 - type: "integer", 3196 - description: "The total number of follows.", 3197 - }, 3198 - }, 3199 - }, 3200 - }, 3201 - }, 3202 - }, 3203 - }, 3204 - AppRockskyGraphGetKnownFollowers: { 3205 - lexicon: 1, 3206 - id: "app.rocksky.graph.getKnownFollowers", 3207 - defs: { 3208 - main: { 3209 - type: "query", 3210 - description: 3211 - "Enumerates accounts which follow a specified account (actor) and are followed by the viewer.", 3212 - parameters: { 3213 - type: "params", 3214 - required: ["actor"], 3215 - properties: { 3216 - actor: { 3217 - type: "string", 3218 - format: "at-identifier", 3219 - }, 3220 - limit: { 3221 - type: "integer", 3222 - maximum: 100, 3223 - minimum: 1, 3224 - default: 50, 3225 - }, 3226 - cursor: { 3227 - type: "string", 3228 - }, 3229 - }, 3230 - }, 3231 - output: { 3232 - encoding: "application/json", 3233 - schema: { 3234 - type: "object", 3235 - required: ["subject", "followers"], 3236 - properties: { 3237 - subject: { 3238 - type: "ref", 3239 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3240 - }, 3241 - followers: { 3242 - type: "array", 3243 - items: { 3244 - type: "ref", 3245 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3246 - }, 3247 - }, 3248 - cursor: { 3249 - type: "string", 3250 - description: 3251 - "A cursor value to pass to subsequent calls to get the next page of results.", 3252 - }, 3253 - }, 3254 - }, 3255 - }, 3256 - }, 3257 - }, 3258 - }, 3259 - AppRockskyGraphUnfollowAccount: { 3260 - lexicon: 1, 3261 - id: "app.rocksky.graph.unfollowAccount", 3262 - defs: { 3263 - main: { 3264 - type: "procedure", 3265 - description: 3266 - "Removes a 'follow' relationship from the authenticated account to a specified account.", 3267 - parameters: { 3268 - type: "params", 3269 - required: ["account"], 3270 - properties: { 3271 - account: { 3272 - type: "string", 3273 - format: "at-identifier", 3274 - }, 3275 - }, 3276 - }, 3277 - output: { 3278 - encoding: "application/json", 3279 - schema: { 3280 - type: "object", 3281 - required: ["subject", "followers"], 3282 - properties: { 3283 - subject: { 3284 - type: "ref", 3285 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3286 - }, 3287 - followers: { 3288 - type: "array", 3289 - items: { 3290 - type: "ref", 3291 - ref: "lex:app.rocksky.actor.defs#profileViewBasic", 3292 - }, 3293 - }, 3294 - cursor: { 3295 - type: "string", 3296 - description: 3297 - "A cursor value to pass to subsequent calls to get the next page of results.", 3298 - }, 3299 - }, 3300 - }, 3301 - }, 3302 - }, 3303 - }, 3304 - }, 3305 2738 AppRockskyLikeDislikeShout: { 3306 2739 lexicon: 1, 3307 2740 id: "app.rocksky.like.dislikeShout", ··· 4483 3916 type: "string", 4484 3917 description: "The SHA256 hash of the scrobble data.", 4485 3918 }, 4486 - liked: { 4487 - type: "boolean", 4488 - }, 4489 - likesCount: { 4490 - type: "integer", 4491 - }, 4492 3919 }, 4493 3920 }, 4494 3921 scrobbleViewDetailed: { ··· 4597 4024 type: "string", 4598 4025 description: "The DID or handle of the actor", 4599 4026 format: "at-identifier", 4600 - }, 4601 - following: { 4602 - type: "boolean", 4603 - description: 4604 - "If true, only return scrobbles from actors the viewer is following.", 4605 4027 }, 4606 4028 limit: { 4607 4029 type: "integer", ··· 5929 5351 AppRockskyActorDefs: "app.rocksky.actor.defs", 5930 5352 AppRockskyActorGetActorAlbums: "app.rocksky.actor.getActorAlbums", 5931 5353 AppRockskyActorGetActorArtists: "app.rocksky.actor.getActorArtists", 5932 - AppRockskyActorGetActorCompatibility: 5933 - "app.rocksky.actor.getActorCompatibility", 5934 5354 AppRockskyActorGetActorLovedSongs: "app.rocksky.actor.getActorLovedSongs", 5935 - AppRockskyActorGetActorNeighbours: "app.rocksky.actor.getActorNeighbours", 5936 5355 AppRockskyActorGetActorPlaylists: "app.rocksky.actor.getActorPlaylists", 5937 5356 AppRockskyActorGetActorScrobbles: "app.rocksky.actor.getActorScrobbles", 5938 5357 AppRockskyActorGetActorSongs: "app.rocksky.actor.getActorSongs", ··· 5976 5395 AppRockskyGoogledriveDownloadFile: "app.rocksky.googledrive.downloadFile", 5977 5396 AppRockskyGoogledriveGetFile: "app.rocksky.googledrive.getFile", 5978 5397 AppRockskyGoogledriveGetFiles: "app.rocksky.googledrive.getFiles", 5979 - AppRockskyGraphDefs: "app.rocksky.graph.defs", 5980 - AppRockskyGraphFollow: "app.rocksky.graph.follow", 5981 - AppRockskyGraphFollowAccount: "app.rocksky.graph.followAccount", 5982 - AppRockskyGraphGetFollowers: "app.rocksky.graph.getFollowers", 5983 - AppRockskyGraphGetFollows: "app.rocksky.graph.getFollows", 5984 - AppRockskyGraphGetKnownFollowers: "app.rocksky.graph.getKnownFollowers", 5985 - AppRockskyGraphUnfollowAccount: "app.rocksky.graph.unfollowAccount", 5986 5398 AppRockskyLikeDislikeShout: "app.rocksky.like.dislikeShout", 5987 5399 AppRockskyLikeDislikeSong: "app.rocksky.like.dislikeSong", 5988 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 - }
-4
apps/api/src/lexicon/types/app/rocksky/actor/getActorAlbums.ts
··· 16 16 limit?: number; 17 17 /** The offset for pagination */ 18 18 offset?: number; 19 - /** The start date to filter albums from (ISO 8601 format) */ 20 - startDate?: string; 21 - /** The end date to filter albums to (ISO 8601 format) */ 22 - endDate?: string; 23 19 } 24 20 25 21 export type InputSchema = undefined;
-4
apps/api/src/lexicon/types/app/rocksky/actor/getActorArtists.ts
··· 16 16 limit?: number; 17 17 /** The offset for pagination */ 18 18 offset?: number; 19 - /** The start date to filter albums from (ISO 8601 format) */ 20 - startDate?: string; 21 - /** The end date to filter albums to (ISO 8601 format) */ 22 - endDate?: string; 23 19 } 24 20 25 21 export type InputSchema = undefined;
-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;
-4
apps/api/src/lexicon/types/app/rocksky/actor/getActorSongs.ts
··· 16 16 limit?: number; 17 17 /** The offset for pagination */ 18 18 offset?: number; 19 - /** The start date to filter albums from (ISO 8601 format) */ 20 - startDate?: string; 21 - /** The end date to filter albums to (ISO 8601 format) */ 22 - endDate?: string; 23 19 } 24 20 25 21 export type InputSchema = undefined;
-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/defs.ts
··· 33 33 uri?: string; 34 34 /** The SHA256 hash of the scrobble data. */ 35 35 sha256?: string; 36 - liked?: boolean; 37 - likesCount?: number; 38 36 [k: string]: unknown; 39 37 } 40 38
-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 - }
+5 -40
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"; ··· 13 13 import artists from "../schema/artists"; 14 14 import lovedTracks from "../schema/loved-tracks"; 15 15 import tracks from "../schema/tracks"; 16 - import extractPdsFromDid from "lib/extractPdsFromDid"; 17 16 18 17 export async function likeTrack( 19 18 ctx: Context, ··· 249 248 250 249 if (trackWithUri?.uri) { 251 250 const rkey = TID.nextStr(); 252 - const repo = trackWithUri.uri 253 - .split("/") 254 - .slice(0, 3) 255 - .join("/") 256 - .split("at://")[1]; 257 - const pds = await extractPdsFromDid(repo); 258 - const subjectAgent = new AtpAgent({ 259 - service: new URL(pds), 260 - }); 261 - const subjectRecord = await subjectAgent.com.atproto.repo.getRecord({ 262 - repo, 251 + const subjectRecord = await agent.com.atproto.repo.getRecord({ 252 + repo: trackWithUri.uri.split("/").slice(0, 3).join("/").split("at://")[1], 263 253 collection: "app.rocksky.song", 264 254 rkey: trackWithUri.uri.split("/").pop(), 265 255 }); ··· 304 294 } 305 295 } 306 296 307 - const lovedTrack = await ctx.db 308 - .select() 309 - .from(lovedTracks) 310 - .where( 311 - and(eq(lovedTracks.userId, user.id), eq(lovedTracks.trackId, trackId)), 312 - ) 313 - .limit(1) 314 - .then((rows) => rows[0]); 315 - 316 - const message = JSON.stringify({ 317 - uri: lovedTrack.uri, 318 - user_id: { xata_id: user.id }, 319 - track_id: { xata_id: trackId }, 320 - xata_createdat: lovedTrack.createdAt.toISOString(), 321 - xata_id: lovedTrack.id, 322 - xata_updatedat: lovedTrack.createdAt.toISOString(), 323 - xata_version: 0, 324 - }); 297 + const message = JSON.stringify(created); 325 298 ctx.nc.publish("rocksky.like", Buffer.from(message)); 326 299 327 300 return created; ··· 370 343 ctx.db.delete(lovedTracks).where(eq(lovedTracks.id, lovedTrack.id)), 371 344 ]); 372 345 373 - const message = JSON.stringify({ 374 - uri: lovedTrack.uri, 375 - user_id: { xata_id: user.id }, 376 - track_id: { xata_id: track.id }, 377 - xata_createdat: lovedTrack.createdAt.toISOString(), 378 - xata_id: lovedTrack.id, 379 - xata_updatedat: lovedTrack.createdAt.toISOString(), 380 - xata_version: 0, 381 - }); 346 + const message = JSON.stringify(lovedTrack); 382 347 ctx.nc.publish("rocksky.unlike", Buffer.from(message)); 383 348 } 384 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 -3
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, ··· 45 45 skip: params.offset || 0, 46 46 take: params.limit || 10, 47 47 }, 48 - start_date: params.startDate, 49 - end_date: params.endDate, 50 48 }), 51 49 catch: (error) => new Error(`Failed to retrieve albums: ${error}`), 52 50 });
+1 -3
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, ··· 44 44 skip: params.offset || 0, 45 45 take: params.limit || 10, 46 46 }, 47 - start_date: params.startDate, 48 - end_date: params.endDate, 49 47 }), 50 48 catch: (error) => new Error(`Failed to retrieve artists: ${error}`), 51 49 });
-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 type { 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, isNotNull, 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 - isNotNull(tables.lovedTracks.uri), 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 -3
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, ··· 44 44 skip: params.offset || 0, 45 45 take: params.limit || 10, 46 46 }, 47 - start_date: params.startDate, 48 - end_date: params.endDate, 49 47 }), 50 48 catch: (error) => new Error(`Failed to retrieve tracks: ${error}`), 51 49 });
+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 - };
+13 -93
apps/api/src/xrpc/app/rocksky/scrobble/getScrobbles.ts
··· 1 1 import type { Context } from "context"; 2 - import { 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 filterDids = await getFilterDids(ctx, params); 45 + try: () => 46 + ctx.db 47 + .select() 48 + .from(tables.scrobbles) 49 + .leftJoin(tables.tracks, eq(tables.scrobbles.trackId, tables.tracks.id)) 50 + .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)) 51 + .orderBy(desc(tables.scrobbles.timestamp)) 52 + .offset(params.offset || 0) 53 + .limit(params.limit || 20) 54 + .execute(), 47 55 48 - if (filterDids !== null && filterDids.length === 0) { 49 - return []; 50 - } 51 - 52 - const scrobbles = await fetchScrobbles(ctx, params, filterDids); 53 - return enrichWithLikes(ctx, scrobbles, params.did); 54 - }, 55 56 catch: (error) => new Error(`Failed to retrieve scrobbles: ${error}`), 56 57 }); 57 58 }; 58 59 59 - const getFilterDids = async ( 60 - ctx: Context, 61 - params: QueryParams, 62 - ): Promise<string[] | null> => { 63 - if (!params.did || !params.following) { 64 - return null; // No filtering needed 65 - } 66 - 67 - const followedUsers = await ctx.db 68 - .select({ subjectDid: tables.follows.subject_did }) 69 - .from(tables.follows) 70 - .where(eq(tables.follows.follower_did, params.did)) 71 - .execute(); 72 - 73 - return followedUsers.map((f) => f.subjectDid); 74 - }; 75 - 76 - const fetchScrobbles = async ( 77 - ctx: Context, 78 - params: QueryParams, 79 - filterDids: string[] | null, 80 - ) => { 81 - const baseQuery = ctx.db 82 - .select() 83 - .from(tables.scrobbles) 84 - .leftJoin(tables.tracks, eq(tables.scrobbles.trackId, tables.tracks.id)) 85 - .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)); 86 - 87 - const query = filterDids 88 - ? baseQuery.where(inArray(tables.users.did, filterDids)) 89 - : baseQuery; 90 - 91 - return query 92 - .orderBy(desc(tables.scrobbles.timestamp)) 93 - .offset(params.offset || 0) 94 - .limit(params.limit || 20) 95 - .execute(); 96 - }; 97 - 98 - const enrichWithLikes = async ( 99 - ctx: Context, 100 - scrobbles: Awaited<ReturnType<typeof fetchScrobbles>>, 101 - currentUserDid?: string, 102 - ) => { 103 - const trackIds = scrobbles 104 - .map((row) => row.tracks?.id) 105 - .filter((id): id is string => Boolean(id)); 106 - 107 - if (trackIds.length === 0) { 108 - return scrobbles.map((row) => ({ ...row, likesCount: 0, liked: false })); 109 - } 110 - 111 - const likes = await ctx.db 112 - .select() 113 - .from(tables.lovedTracks) 114 - .leftJoin(tables.users, eq(tables.lovedTracks.userId, tables.users.id)) 115 - .where(inArray(tables.lovedTracks.trackId, trackIds)) 116 - .execute(); 117 - 118 - const likesMap = new Map<string, { count: number; liked: boolean }>(); 119 - 120 - for (const trackId of trackIds) { 121 - const trackLikes = likes.filter((l) => l.loved_tracks.trackId === trackId); 122 - likesMap.set(trackId, { 123 - count: trackLikes.length, 124 - liked: trackLikes.some((l) => l.users?.did === currentUserDid), 125 - }); 126 - } 127 - 128 - return scrobbles.map((row) => ({ 129 - ...row, 130 - likesCount: likesMap.get(row.tracks?.id ?? "")?.count ?? 0, 131 - liked: likesMap.get(row.tracks?.id ?? "")?.liked ?? false, 132 - })); 133 - }; 134 - 135 60 const presentation = ( 136 61 data: Scrobbles, 137 62 ): Effect.Effect<{ scrobbles: ScrobbleViewBasic[] }, never> => { 138 63 return Effect.sync(() => ({ 139 - scrobbles: data.map(({ scrobbles, tracks, users, liked, likesCount }) => ({ 64 + scrobbles: data.map(({ scrobbles, tracks, users }) => ({ 140 65 ...R.omit(["albumArt", "id", "lyrics"])(tracks), 141 66 cover: tracks.albumArt, 142 67 date: scrobbles.timestamp.toISOString(), ··· 146 71 uri: scrobbles.uri, 147 72 tags: [], 148 73 id: scrobbles.id, 149 - trackUri: tracks.uri, 150 - likesCount, 151 - liked, 152 74 })), 153 75 })); 154 76 }; ··· 157 79 scrobbles: SelectScrobble; 158 80 tracks: SelectTrack; 159 81 users: SelectUser; 160 - liked: boolean; 161 - likesCount: number; 162 82 }[];
-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 -4
apps/feeds/src/algos/dubstep.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) ··· 28 28 29 29 const feed = scrobbles.map(({ scrobbles }) => ({ scrobble: scrobbles.uri })); 30 30 31 - const { scrobbles: lastScrobble } = scrobbles.length > 0 32 - ? scrobbles.at(-1)! 33 - : { scrobbles: null }; 31 + const { scrobbles: lastScrobble } = 32 + scrobbles.length > 0 ? scrobbles.at(-1)! : { scrobbles: null }; 34 33 const nextCursor = lastScrobble 35 34 ? lastScrobble.timestamp.getTime().toString(10) 36 35 : undefined;
+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 - };
+85 -110
crates/analytics/src/handlers/albums.rs
··· 115 115 ) -> Result<HttpResponse, Error> { 116 116 let body = read_payload!(payload); 117 117 let params = serde_json::from_slice::<GetTopAlbumsParams>(&body)?; 118 - 119 - let pagination = params.pagination.clone().unwrap_or_default(); 120 - let offset: i64 = pagination.skip.unwrap_or(0) as i64; 121 - let limit: i64 = pagination.take.unwrap_or(20) as i64; 122 - 123 - let did = params.user_did.clone(); 124 - 125 - // Bind Option<&str> so None becomes SQL NULL (DuckDB-friendly) 126 - let start_date: Option<&str> = params.start_date.as_deref(); 127 - let end_date: Option<&str> = params.end_date.as_deref(); 128 - 129 - tracing::info!( 130 - limit, 131 - offset, 132 - user_did = ?did, 133 - start_date = ?params.start_date, 134 - end_date = ?params.end_date, 135 - "Get top albums" 136 - ); 118 + let pagination = params.pagination.unwrap_or_default(); 119 + let offset = pagination.skip.unwrap_or(0); 120 + let limit = pagination.take.unwrap_or(20); 121 + let did = params.user_did; 122 + tracing::info!(limit, offset, user_did = ?did, "Get top albums"); 137 123 138 124 let conn = conn.lock().unwrap(); 125 + let mut stmt = match did { 126 + Some(_) => conn.prepare( 127 + r#" 128 + SELECT 129 + s.album_id AS id, 130 + a.title AS title, 131 + ar.name AS artist, 132 + ar.uri AS artist_uri, 133 + a.album_art AS album_art, 134 + a.release_date, 135 + a.year, 136 + a.uri, 137 + a.sha256, 138 + COUNT(DISTINCT s.created_at) AS play_count, 139 + COUNT(DISTINCT s.user_id) AS unique_listeners 140 + FROM 141 + scrobbles s 142 + LEFT JOIN 143 + albums a ON s.album_id = a.id 144 + LEFT JOIN 145 + artists ar ON a.artist = ar.name 146 + LEFT JOIN 147 + users u ON s.user_id = u.id 148 + WHERE s.album_id IS NOT NULL AND (u.did = ? OR u.handle = ?) AND ar.name IS NOT NULL 149 + GROUP BY 150 + s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art, a.sha256, ar.uri 151 + ORDER BY 152 + play_count DESC 153 + OFFSET ? 154 + LIMIT ?; 155 + "#, 156 + )?, 157 + None => conn.prepare( 158 + r#" 159 + SELECT 160 + s.album_id AS id, 161 + a.title AS title, 162 + ar.name AS artist, 163 + ar.uri AS artist_uri, 164 + a.album_art AS album_art, 165 + a.release_date, 166 + a.year, 167 + a.uri, 168 + a.sha256, 169 + COUNT(*) AS play_count, 170 + COUNT(DISTINCT s.user_id) AS unique_listeners 171 + FROM 172 + scrobbles s 173 + LEFT JOIN 174 + albums a ON s.album_id = a.id 175 + LEFT JOIN 176 + artists ar ON a.artist = ar.name WHERE s.album_id IS NOT NULL 177 + GROUP BY 178 + s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art, a.sha256, ar.uri 179 + ORDER BY 180 + play_count DESC 181 + OFFSET ? 182 + LIMIT ?; 183 + "#, 184 + )?, 185 + }; 139 186 140 187 match did { 141 188 Some(did) => { 142 - let mut stmt = conn.prepare( 143 - r#" 144 - SELECT 145 - s.album_id AS id, 146 - a.title AS title, 147 - ar.name AS artist, 148 - ar.uri AS artist_uri, 149 - a.album_art AS album_art, 150 - a.release_date, 151 - a.year, 152 - a.uri, 153 - a.sha256, 154 - -- return "last played" (scrobble time), not album row time 155 - MAX(s.created_at) AS created_at, 156 - COUNT(DISTINCT s.created_at) AS play_count, 157 - COUNT(DISTINCT s.user_id) AS unique_listeners 158 - FROM scrobbles s 159 - LEFT JOIN albums a ON s.album_id = a.id 160 - LEFT JOIN artists ar ON a.artist = ar.name 161 - LEFT JOIN users u ON s.user_id = u.id 162 - WHERE 163 - s.album_id IS NOT NULL 164 - AND (u.did = ? OR u.handle = ?) 165 - AND ar.name IS NOT NULL 166 - AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) 167 - AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) 168 - GROUP BY 169 - s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art, a.sha256, ar.uri 170 - ORDER BY play_count DESC 171 - LIMIT ? 172 - OFFSET ?; 173 - "#, 174 - )?; 175 - 176 189 let albums = stmt.query_map( 177 - duckdb::params![ 178 - did, did, start_date, start_date, end_date, end_date, limit, offset 179 - ], 190 + [&did, &did, &limit.to_string(), &offset.to_string()], 180 191 |row| { 181 192 Ok(Album { 182 193 id: row.get(0)?, ··· 188 199 year: row.get(6)?, 189 200 uri: row.get(7)?, 190 201 sha256: row.get(8)?, 191 - play_count: Some(row.get(10)?), 192 - unique_listeners: Some(row.get(11)?), 202 + play_count: Some(row.get(9)?), 203 + unique_listeners: Some(row.get(10)?), 193 204 ..Default::default() 194 205 }) 195 206 }, 196 207 )?; 197 - 198 208 let albums: Result<Vec<_>, _> = albums.collect(); 199 209 Ok(HttpResponse::Ok().json(web::Json(albums?))) 200 210 } 201 - 202 211 None => { 203 - let mut stmt = conn.prepare( 204 - r#" 205 - SELECT 206 - s.album_id AS id, 207 - a.title AS title, 208 - ar.name AS artist, 209 - ar.uri AS artist_uri, 210 - a.album_art AS album_art, 211 - a.release_date, 212 - a.year, 213 - a.uri, 214 - a.sha256, 215 - MAX(s.created_at) AS created_at, 216 - COUNT(*) AS play_count, 217 - COUNT(DISTINCT s.user_id) AS unique_listeners 218 - FROM scrobbles s 219 - LEFT JOIN albums a ON s.album_id = a.id 220 - LEFT JOIN artists ar ON a.artist = ar.name 221 - WHERE 222 - s.album_id IS NOT NULL 223 - AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) 224 - AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) 225 - GROUP BY 226 - s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art, a.sha256, ar.uri 227 - ORDER BY play_count DESC 228 - LIMIT ? 229 - OFFSET ?; 230 - "#, 231 - )?; 232 - 233 - let albums = stmt.query_map( 234 - duckdb::params![start_date, start_date, end_date, end_date, limit, offset], 235 - |row| { 236 - Ok(Album { 237 - id: row.get(0)?, 238 - title: row.get(1)?, 239 - artist: row.get(2)?, 240 - artist_uri: row.get(3)?, 241 - album_art: row.get(4)?, 242 - release_date: row.get(5)?, 243 - year: row.get(6)?, 244 - uri: row.get(7)?, 245 - sha256: row.get(8)?, 246 - play_count: Some(row.get(10)?), 247 - unique_listeners: Some(row.get(11)?), 248 - ..Default::default() 249 - }) 250 - }, 251 - )?; 252 - 212 + let albums = stmt.query_map([limit, offset], |row| { 213 + Ok(Album { 214 + id: row.get(0)?, 215 + title: row.get(1)?, 216 + artist: row.get(2)?, 217 + artist_uri: row.get(3)?, 218 + album_art: row.get(4)?, 219 + release_date: row.get(5)?, 220 + year: row.get(6)?, 221 + uri: row.get(7)?, 222 + sha256: row.get(8)?, 223 + play_count: Some(row.get(9)?), 224 + unique_listeners: Some(row.get(10)?), 225 + ..Default::default() 226 + }) 227 + })?; 253 228 let albums: Result<Vec<_>, _> = albums.collect(); 254 229 Ok(HttpResponse::Ok().json(web::Json(albums?))) 255 230 }
+65 -89
crates/analytics/src/handlers/artists.rs
··· 165 165 ) -> Result<HttpResponse, Error> { 166 166 let body = read_payload!(payload); 167 167 let params = serde_json::from_slice::<GetTopArtistsParams>(&body)?; 168 - 169 - let pagination = params.pagination.clone().unwrap_or_default(); 170 - let offset: i64 = pagination.skip.unwrap_or(0) as i64; 171 - let limit: i64 = pagination.take.unwrap_or(20) as i64; 172 - 173 - let did = params.user_did.clone(); 174 - 175 - // DuckDB-friendly optional bindings (None -> SQL NULL) 176 - let start_date: Option<&str> = params.start_date.as_deref(); 177 - let end_date: Option<&str> = params.end_date.as_deref(); 178 - 179 - tracing::info!( 180 - limit, 181 - offset, 182 - user_did = ?did, 183 - start_date = ?params.start_date, 184 - end_date = ?params.end_date, 185 - "Get top artists" 186 - ); 168 + let pagination = params.pagination.unwrap_or_default(); 169 + let offset = pagination.skip.unwrap_or(0); 170 + let limit = pagination.take.unwrap_or(20); 171 + let did = params.user_did; 187 172 188 173 let conn = conn.lock().unwrap(); 189 - 190 - match did { 191 - Some(did) => { 192 - let mut stmt = conn.prepare( 193 - r#" 174 + let mut stmt = match did { 175 + Some(_) => conn.prepare( 176 + r#" 194 177 SELECT 195 178 s.artist_id AS id, 196 179 ar.name AS artist_name, ··· 198 181 ar.sha256 AS sha256, 199 182 ar.uri AS uri, 200 183 ar.genres AS genres, 201 - -- "created_at" reflects scrobble time (last played) 202 - MAX(s.created_at) AS created_at, 203 184 COUNT(DISTINCT s.created_at) AS play_count, 204 185 COUNT(DISTINCT s.user_id) AS unique_listeners 205 - FROM scrobbles s 206 - LEFT JOIN artists ar ON s.artist_id = ar.id 207 - LEFT JOIN users u ON s.user_id = u.id 186 + FROM 187 + scrobbles s 188 + LEFT JOIN 189 + artists ar ON s.artist_id = ar.id 190 + LEFT JOIN 191 + users u ON s.user_id = u.id 208 192 WHERE 209 - s.artist_id IS NOT NULL 210 - AND (u.did = ? OR u.handle = ?) 211 - AND ar.name != 'Various Artists' 212 - AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) 213 - AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) 193 + s.artist_id IS NOT NULL AND (u.did = ? OR u.handle = ?) AND ar.name != 'Various Artists' 214 194 GROUP BY 215 195 s.artist_id, ar.name, ar.uri, ar.picture, ar.sha256, ar.genres 216 - ORDER BY play_count DESC 217 - LIMIT ? 218 - OFFSET ?; 219 - "#, 220 - )?; 221 - 222 - let artists = stmt.query_map( 223 - duckdb::params![ 224 - did, did, start_date, start_date, end_date, end_date, limit, offset 225 - ], 226 - |row| { 227 - let genres = extract_genres_from_value(row.get(5)?); 228 - Ok(Artist { 229 - id: row.get(0)?, 230 - name: row.get(1)?, 231 - biography: None, 232 - born: None, 233 - born_in: None, 234 - died: None, 235 - picture: row.get(2)?, 236 - sha256: row.get(3)?, 237 - spotify_link: None, 238 - tidal_link: None, 239 - youtube_link: None, 240 - apple_music_link: None, 241 - uri: row.get(4)?, 242 - genres, 243 - play_count: Some(row.get(7)?), 244 - unique_listeners: Some(row.get(8)?), 245 - }) 246 - }, 247 - )?; 248 - 249 - let artists: Result<Vec<_>, _> = artists.collect(); 250 - Ok(HttpResponse::Ok().json(artists?)) 251 - } 252 - 253 - None => { 254 - let mut stmt = conn.prepare( 255 - r#" 196 + ORDER BY 197 + play_count DESC 198 + OFFSET ? 199 + LIMIT ?; 200 + "#, 201 + )?, 202 + None => conn.prepare( 203 + r#" 256 204 SELECT 257 205 s.artist_id AS id, 258 206 ar.name AS artist_name, ··· 260 208 ar.sha256 AS sha256, 261 209 ar.uri AS uri, 262 210 ar.genres AS genres, 263 - MAX(s.created_at) AS created_at, 264 211 COUNT(*) AS play_count, 265 212 COUNT(DISTINCT s.user_id) AS unique_listeners 266 - FROM scrobbles s 267 - LEFT JOIN artists ar ON s.artist_id = ar.id 213 + FROM 214 + scrobbles s 215 + LEFT JOIN 216 + artists ar ON s.artist_id = ar.id 268 217 WHERE 269 - s.artist_id IS NOT NULL 270 - AND ar.name != 'Various Artists' 271 - AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) 272 - AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) 218 + s.artist_id IS NOT NULL AND ar.name != 'Various Artists' 273 219 GROUP BY 274 220 s.artist_id, ar.name, ar.uri, ar.picture, ar.sha256, ar.genres 275 - ORDER BY play_count DESC 276 - LIMIT ? 277 - OFFSET ?; 278 - "#, 279 - )?; 221 + ORDER BY 222 + play_count DESC 223 + OFFSET ? 224 + LIMIT ?; 225 + "#, 226 + )?, 227 + }; 280 228 229 + match did { 230 + Some(did) => { 281 231 let artists = stmt.query_map( 282 - duckdb::params![start_date, start_date, end_date, end_date, limit, offset], 232 + [&did, &did, &limit.to_string(), &offset.to_string()], 283 233 |row| { 284 234 let genres = extract_genres_from_value(row.get(5)?); 285 235 Ok(Artist { ··· 297 247 apple_music_link: None, 298 248 uri: row.get(4)?, 299 249 genres, 300 - play_count: Some(row.get(7)?), 301 - unique_listeners: Some(row.get(8)?), 250 + play_count: Some(row.get(6)?), 251 + unique_listeners: Some(row.get(7)?), 302 252 }) 303 253 }, 304 254 )?; 255 + 256 + let artists: Result<Vec<_>, _> = artists.collect(); 257 + Ok(HttpResponse::Ok().json(artists?)) 258 + } 259 + None => { 260 + let artists = stmt.query_map([limit, offset], |row| { 261 + let genres = extract_genres_from_value(row.get(5)?); 262 + Ok(Artist { 263 + id: row.get(0)?, 264 + name: row.get(1)?, 265 + biography: None, 266 + born: None, 267 + born_in: None, 268 + died: None, 269 + picture: row.get(2)?, 270 + sha256: row.get(3)?, 271 + spotify_link: None, 272 + tidal_link: None, 273 + youtube_link: None, 274 + apple_music_link: None, 275 + uri: row.get(4)?, 276 + genres, 277 + play_count: Some(row.get(6)?), 278 + unique_listeners: Some(row.get(7)?), 279 + }) 280 + })?; 305 281 306 282 let artists: Result<Vec<_>, _> = artists.collect(); 307 283 Ok(HttpResponse::Ok().json(artists?))
+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 -93
crates/analytics/src/handlers/tracks.rs
··· 259 259 ) -> Result<HttpResponse, Error> { 260 260 let body = read_payload!(payload); 261 261 let params = serde_json::from_slice::<GetTopTracksParams>(&body)?; 262 - 263 - let pagination = params.pagination.clone().unwrap_or_default(); 264 - let offset: i64 = pagination.skip.unwrap_or(0) as i64; 265 - let limit: i64 = pagination.take.unwrap_or(20) as i64; 266 - 267 - let did = params.user_did.clone(); 268 - 269 - let start_date: Option<&str> = params.start_date.as_deref(); 270 - let end_date: Option<&str> = params.end_date.as_deref(); 271 - 272 - tracing::info!( 273 - limit, 274 - offset, 275 - user_did = ?did, 276 - start_date = ?params.start_date, 277 - end_date = ?params.end_date, 278 - "Get top tracks" 279 - ); 262 + let pagination = params.pagination.unwrap_or_default(); 263 + let offset = pagination.skip.unwrap_or(0); 264 + let limit = pagination.take.unwrap_or(20); 265 + let did = params.user_did; 266 + tracing::info!(limit, offset, user_did = ?did, "Get top tracks"); 280 267 281 268 let conn = conn.lock().unwrap(); 282 - 283 269 match did { 284 270 Some(did) => { 285 - let mut stmt = conn.prepare( 286 - r#" 271 + let mut stmt = conn.prepare(r#" 287 272 SELECT 288 273 t.id, 289 274 t.title, ··· 306 291 LEFT JOIN artists ar ON s.artist_id = ar.id 307 292 LEFT JOIN albums a ON s.album_id = a.id 308 293 LEFT JOIN users u ON s.user_id = u.id 309 - WHERE 310 - (u.did = ? OR u.handle = ?) 311 - AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) 312 - AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) 313 - GROUP BY 314 - t.id, s.track_id, t.title, ar.name, a.title, t.artist, t.uri, 315 - t.album_art, t.duration, t.disc_number, t.track_number, 316 - t.artist_uri, t.album_uri, t.created_at, t.sha256, 317 - t.album_artist, t.album 294 + WHERE u.did = ? OR u.handle = ? 295 + GROUP BY t.id, s.track_id, t.title, ar.name, a.title, t.artist, t.uri, t.album_art, t.duration, t.disc_number, t.track_number, t.artist_uri, t.album_uri, t.created_at, t.sha256, t.album_artist, t.album 318 296 ORDER BY play_count DESC 319 - LIMIT ? 320 - OFFSET ?; 321 - "#, 322 - )?; 323 - 324 - let rows = stmt.query_map( 325 - duckdb::params![ 326 - did, // u.did = ? 327 - did, // u.handle = ? 328 - start_date, // ? IS NULL 329 - start_date, // CAST(? AS TIMESTAMP) 330 - end_date, // ? IS NULL 331 - end_date, // CAST(? AS TIMESTAMP) 332 - limit, // LIMIT ? 333 - offset // OFFSET ? 334 - ], 297 + OFFSET ? 298 + LIMIT ?; 299 + "#)?; 300 + let top_tracks = stmt.query_map( 301 + [&did, &did, &limit.to_string(), &offset.to_string()], 335 302 |row| { 336 303 Ok(Track { 337 304 id: row.get(0)?, ··· 354 321 }) 355 322 }, 356 323 )?; 357 - 358 - let top_tracks: Result<Vec<_>, _> = rows.collect(); 324 + let top_tracks: Result<Vec<_>, _> = top_tracks.collect(); 359 325 Ok(HttpResponse::Ok().json(top_tracks?)) 360 326 } 361 - 362 327 None => { 363 - let mut stmt = conn.prepare( 364 - r#" 328 + let mut stmt = conn.prepare(r#" 365 329 SELECT 366 330 t.id, 367 331 t.title, ··· 383 347 LEFT JOIN tracks t ON s.track_id = t.id 384 348 LEFT JOIN artists ar ON s.artist_id = ar.id 385 349 LEFT JOIN albums a ON s.album_id = a.id 386 - WHERE 387 - s.track_id IS NOT NULL 388 - AND s.artist_id IS NOT NULL 389 - AND s.album_id IS NOT NULL 390 - AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) 391 - AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) 392 - GROUP BY 393 - t.id, s.track_id, t.title, ar.name, a.title, t.artist, t.uri, 394 - t.album_art, t.duration, t.disc_number, t.track_number, 395 - t.artist_uri, t.album_uri, t.created_at, t.sha256, 396 - t.album_artist, t.album 350 + WHERE s.track_id IS NOT NULL AND s.artist_id IS NOT NULL AND s.album_id IS NOT NULL 351 + GROUP BY t.id, s.track_id, t.title, ar.name, a.title, t.artist, t.uri, t.album_art, t.duration, t.disc_number, t.track_number, t.artist_uri, t.album_uri, t.created_at, t.sha256, t.album_artist, t.album 397 352 ORDER BY play_count DESC 398 - LIMIT ? 399 - OFFSET ?; 400 - "#, 401 - )?; 402 - 403 - let rows = stmt.query_map( 404 - duckdb::params![start_date, start_date, end_date, end_date, limit, offset], 405 - |row| { 406 - Ok(Track { 407 - id: row.get(0)?, 408 - title: row.get(1)?, 409 - artist: row.get(2)?, 410 - album_artist: row.get(3)?, 411 - album: row.get(4)?, 412 - uri: row.get(5)?, 413 - album_art: row.get(6)?, 414 - duration: row.get(7)?, 415 - disc_number: row.get(8)?, 416 - track_number: row.get(9)?, 417 - artist_uri: row.get(10)?, 418 - album_uri: row.get(11)?, 419 - sha256: row.get(12)?, 420 - created_at: row.get(13)?, 421 - play_count: row.get(14)?, 422 - unique_listeners: row.get(15)?, 423 - ..Default::default() 424 - }) 425 - }, 426 - )?; 427 - 428 - let top_tracks: Result<Vec<_>, _> = rows.collect(); 353 + OFFSET ? 354 + LIMIT ?; 355 + "#)?; 356 + let top_tracks = stmt.query_map([limit, offset], |row| { 357 + Ok(Track { 358 + id: row.get(0)?, 359 + title: row.get(1)?, 360 + artist: row.get(2)?, 361 + album_artist: row.get(3)?, 362 + album: row.get(4)?, 363 + uri: row.get(5)?, 364 + album_art: row.get(6)?, 365 + duration: row.get(7)?, 366 + disc_number: row.get(8)?, 367 + track_number: row.get(9)?, 368 + artist_uri: row.get(10)?, 369 + album_uri: row.get(11)?, 370 + sha256: row.get(12)?, 371 + created_at: row.get(13)?, 372 + play_count: row.get(14)?, 373 + unique_listeners: row.get(15)?, 374 + ..Default::default() 375 + }) 376 + })?; 377 + let top_tracks: Result<Vec<_>, _> = top_tracks.collect(); 429 378 Ok(HttpResponse::Ok().json(top_tracks?)) 430 379 } 431 380 }
-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);
-2
crates/analytics/src/types/album.rs
··· 42 42 pub struct GetTopAlbumsParams { 43 43 pub user_did: Option<String>, 44 44 pub pagination: Option<Pagination>, 45 - pub start_date: Option<String>, 46 - pub end_date: Option<String>, 47 45 } 48 46 49 47 #[derive(Debug, Serialize, Deserialize)]
-2
crates/analytics/src/types/artist.rs
··· 69 69 pub struct GetTopArtistsParams { 70 70 pub user_did: Option<String>, 71 71 pub pagination: Option<Pagination>, 72 - pub start_date: Option<String>, 73 - pub end_date: Option<String>, 74 72 } 75 73 76 74 #[derive(Debug, Serialize, Deserialize, Default)]
-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 - }
-2
crates/analytics/src/types/track.rs
··· 63 63 pub struct GetTopTracksParams { 64 64 pub user_did: Option<String>, 65 65 pub pagination: Option<Pagination>, 66 - pub start_date: Option<String>, 67 - pub end_date: Option<String>, 68 66 } 69 67 70 68 #[derive(Debug, Serialize, Deserialize, Default)]
+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;
+150 -175
crates/spotify/src/lib.rs
··· 53 53 let thread_map: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>> = 54 54 Arc::new(Mutex::new(HashMap::new())); 55 55 56 - // Helper function to start a user thread with auto-recovery 57 - let start_user_thread = |email: String, 58 - token: String, 59 - did: String, 60 - client_id: String, 61 - client_secret: String, 62 - stop_flag: Arc<AtomicBool>, 63 - cache: Cache, 64 - nc: async_nats::Client| { 65 - thread::spawn(move || { 66 - let rt = tokio::runtime::Runtime::new().unwrap(); 67 - let mut retry_count = 0; 68 - let max_retries = 5; 69 - 70 - loop { 71 - if stop_flag.load(std::sync::atomic::Ordering::Relaxed) { 72 - println!( 73 - "{} Stop flag set, exiting recovery loop", 74 - format!("[{}]", email).bright_green() 75 - ); 76 - break; 77 - } 78 - 79 - match rt.block_on(async { 80 - watch_currently_playing( 81 - email.clone(), 82 - token.clone(), 83 - did.clone(), 84 - stop_flag.clone(), 85 - cache.clone(), 86 - client_id.clone(), 87 - client_secret.clone(), 88 - ) 89 - .await 90 - }) { 91 - Ok(_) => { 92 - println!( 93 - "{} Thread completed normally", 94 - format!("[{}]", email).bright_green() 95 - ); 96 - break; 97 - } 98 - Err(e) => { 99 - retry_count += 1; 100 - println!( 101 - "{} Thread crashed (attempt {}/{}): {}", 102 - format!("[{}]", email).bright_green(), 103 - retry_count, 104 - max_retries, 105 - e.to_string().bright_red() 106 - ); 107 - 108 - if retry_count >= max_retries { 109 - println!( 110 - "{} Max retries reached, publishing to NATS for external restart", 111 - format!("[{}]", email).bright_green() 112 - ); 113 - match rt 114 - .block_on(nc.publish("rocksky.spotify.user", email.clone().into())) 115 - { 116 - Ok(_) => { 117 - println!( 118 - "{} Published message to restart thread", 119 - format!("[{}]", email).bright_green() 120 - ); 121 - } 122 - Err(e) => { 123 - println!( 124 - "{} Error publishing message to restart thread: {}", 125 - format!("[{}]", email).bright_green(), 126 - e.to_string().bright_red() 127 - ); 128 - } 129 - } 130 - break; 131 - } 132 - 133 - // Exponential backoff: 2^retry_count seconds, max 60 seconds 134 - let backoff_seconds = std::cmp::min(2_u64.pow(retry_count as u32), 60); 135 - println!( 136 - "{} Retrying in {} seconds...", 137 - format!("[{}]", email).bright_green(), 138 - backoff_seconds 139 - ); 140 - std::thread::sleep(std::time::Duration::from_secs(backoff_seconds)); 141 - } 142 - } 143 - } 144 - }) 145 - }; 146 - 147 56 // Start threads for all users 148 57 for user in users { 149 58 let email = user.0.clone(); ··· 161 70 .unwrap() 162 71 .insert(email.clone(), Arc::clone(&stop_flag)); 163 72 164 - start_user_thread( 165 - email, 166 - token, 167 - did, 168 - client_id, 169 - client_secret, 170 - stop_flag, 171 - cache, 172 - nc, 173 - ); 73 + thread::spawn(move || { 74 + let rt = tokio::runtime::Runtime::new().unwrap(); 75 + match rt.block_on(async { 76 + watch_currently_playing( 77 + email.clone(), 78 + token, 79 + did, 80 + stop_flag, 81 + cache.clone(), 82 + client_id, 83 + client_secret, 84 + ) 85 + .await?; 86 + Ok::<(), Error>(()) 87 + }) { 88 + Ok(_) => {} 89 + Err(e) => { 90 + println!( 91 + "{} Error starting thread for user: {} - {}", 92 + format!("[{}]", email).bright_green(), 93 + email.bright_green(), 94 + e.to_string().bright_red() 95 + ); 96 + 97 + // If there's an error, publish a message to restart the thread 98 + match rt.block_on(nc.publish("rocksky.spotify.user", email.clone().into())) { 99 + Ok(_) => { 100 + println!( 101 + "{} Published message to restart thread for user: {}", 102 + format!("[{}]", email).bright_green(), 103 + email.bright_green() 104 + ); 105 + } 106 + Err(e) => { 107 + println!( 108 + "{} Error publishing message to restart thread: {}", 109 + format!("[{}]", email).bright_green(), 110 + e.to_string().bright_red() 111 + ); 112 + } 113 + } 114 + } 115 + } 116 + }); 174 117 } 175 118 176 119 // Handle subscription messages ··· 210 153 let client_id = user.3.clone(); 211 154 let client_secret = user.4.clone(); 212 155 let cache = cache.clone(); 213 - let nc = nc.clone(); 214 156 215 - start_user_thread( 216 - email, 217 - token, 218 - did, 219 - client_id, 220 - client_secret, 221 - new_stop_flag, 222 - cache, 223 - nc, 224 - ); 157 + thread::spawn(move || { 158 + let rt = tokio::runtime::Runtime::new().unwrap(); 159 + match rt.block_on(async { 160 + watch_currently_playing( 161 + email.clone(), 162 + token, 163 + did, 164 + new_stop_flag, 165 + cache.clone(), 166 + client_id, 167 + client_secret, 168 + ) 169 + .await?; 170 + Ok::<(), Error>(()) 171 + }) { 172 + Ok(_) => {} 173 + Err(e) => { 174 + println!( 175 + "{} Error restarting thread for user: {} - {}", 176 + format!("[{}]", email).bright_green(), 177 + email.bright_green(), 178 + e.to_string().bright_red() 179 + ); 180 + } 181 + } 182 + }); 225 183 226 184 println!("Restarted thread for user: {}", user_id.bright_green()); 227 185 } else { ··· 242 200 243 201 thread_map.insert(email.clone(), Arc::clone(&stop_flag)); 244 202 245 - start_user_thread( 246 - email, 247 - token, 248 - did, 249 - client_id, 250 - client_secret, 251 - stop_flag, 252 - cache, 253 - nc, 254 - ); 203 + thread::spawn(move || { 204 + let rt = tokio::runtime::Runtime::new().unwrap(); 205 + match rt.block_on(async { 206 + watch_currently_playing( 207 + email.clone(), 208 + token, 209 + did, 210 + stop_flag, 211 + cache.clone(), 212 + client_id, 213 + client_secret, 214 + ) 215 + .await?; 216 + Ok::<(), Error>(()) 217 + }) { 218 + Ok(_) => {} 219 + Err(e) => { 220 + println!( 221 + "{} Error starting thread for user: {} - {}", 222 + format!("[{}]", email).bright_green(), 223 + email.bright_green(), 224 + e.to_string().bright_red() 225 + ); 226 + match rt 227 + .block_on(nc.publish("rocksky.spotify.user", email.clone().into())) 228 + { 229 + Ok(_) => {} 230 + Err(e) => { 231 + println!( 232 + "{} Error publishing message to restart thread: {}", 233 + format!("[{}]", email).bright_green(), 234 + e.to_string().bright_red() 235 + ); 236 + } 237 + } 238 + } 239 + } 240 + }); 255 241 } 256 242 } 257 243 } ··· 542 528 previous_item.id != data_item.id 543 529 && previous.progress_ms.unwrap_or(0) != data.progress_ms.unwrap_or(0) 544 530 } 545 - _ => data.item.is_some(), 531 + _ => false, 546 532 }; 547 533 548 534 // save as previous song ··· 824 810 let spotify_email_clone = spotify_email.clone(); 825 811 let cache_clone = cache.clone(); 826 812 thread::spawn(move || { 827 - // Inner thread with error recovery 828 - let result: Result<(), Error> = (|| { 829 - loop { 830 - if stop_flag_clone.load(std::sync::atomic::Ordering::Relaxed) { 831 - println!( 832 - "{} Stopping progress tracker thread", 833 - format!("[{}]", spotify_email_clone).bright_green() 834 - ); 835 - break; 813 + loop { 814 + if stop_flag_clone.load(std::sync::atomic::Ordering::Relaxed) { 815 + println!( 816 + "{} Stopping Thread", 817 + format!("[{}]", spotify_email_clone).bright_green() 818 + ); 819 + break; 820 + } 821 + if let Ok(Some(cached)) = cache_clone.get(&format!("{}:current", spotify_email_clone)) { 822 + if serde_json::from_str::<CurrentlyPlaying>(&cached).is_err() { 823 + thread::sleep(std::time::Duration::from_millis(800)); 824 + continue; 836 825 } 837 826 838 - if let Ok(Some(cached)) = 839 - cache_clone.get(&format!("{}:current", spotify_email_clone)) 840 - { 841 - if let Ok(mut current_song) = serde_json::from_str::<CurrentlyPlaying>(&cached) 827 + let mut current_song = serde_json::from_str::<CurrentlyPlaying>(&cached)?; 828 + 829 + if let Some(item) = current_song.item.clone() { 830 + if current_song.is_playing 831 + && current_song.progress_ms.unwrap_or(0) < item.duration_ms.into() 842 832 { 843 - if let Some(item) = current_song.item.clone() { 844 - if current_song.is_playing 845 - && current_song.progress_ms.unwrap_or(0) < item.duration_ms.into() 846 - { 847 - current_song.progress_ms = 848 - Some(current_song.progress_ms.unwrap_or(0) + 800); 849 - match cache_clone.setex( 850 - &format!("{}:current", spotify_email_clone), 851 - &serde_json::to_string(&current_song).unwrap_or_default(), 852 - 16, 853 - ) { 854 - Ok(_) => {} 855 - Err(e) => { 856 - println!( 857 - "{} redis error: {}", 858 - format!("[{}]", spotify_email_clone).bright_green(), 859 - e.to_string().bright_red() 860 - ); 861 - } 862 - } 863 - thread::sleep(std::time::Duration::from_millis(800)); 864 - continue; 865 - } 866 - } 867 - } 868 - } 869 - 870 - if let Ok(Some(cached)) = cache_clone.get(&spotify_email_clone) { 871 - if cached != "No content" { 833 + current_song.progress_ms = 834 + Some(current_song.progress_ms.unwrap_or(0) + 800); 872 835 match cache_clone.setex( 873 836 &format!("{}:current", spotify_email_clone), 874 - &cached, 837 + &serde_json::to_string(&current_song)?, 875 838 16, 876 839 ) { 877 840 Ok(_) => {} ··· 883 846 ); 884 847 } 885 848 } 849 + thread::sleep(std::time::Duration::from_millis(800)); 850 + continue; 886 851 } 887 852 } 853 + continue; 854 + } 888 855 889 - thread::sleep(std::time::Duration::from_millis(800)); 856 + if let Ok(Some(cached)) = cache_clone.get(&spotify_email_clone) { 857 + if cached == "No content" { 858 + thread::sleep(std::time::Duration::from_millis(800)); 859 + continue; 860 + } 861 + match cache_clone.setex(&format!("{}:current", spotify_email_clone), &cached, 16) { 862 + Ok(_) => {} 863 + Err(e) => { 864 + println!( 865 + "{} redis error: {}", 866 + format!("[{}]", spotify_email_clone).bright_green(), 867 + e.to_string().bright_red() 868 + ); 869 + } 870 + } 890 871 } 891 - Ok(()) 892 - })(); 893 872 894 - if let Err(e) = result { 895 - println!( 896 - "{} Progress tracker thread error: {}", 897 - format!("[{}]", spotify_email_clone).bright_green(), 898 - e.to_string().bright_red() 899 - ); 873 + thread::sleep(std::time::Duration::from_millis(800)); 900 874 } 875 + Ok::<(), Error>(()) 901 876 }); 902 877 903 878 loop {
+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 + }