[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(database): Implement cached scrobble migration per-component

FoxxMD (May 13, 2026, 9:00 PM UTC) c86819b0 5228d489

+1096 -103
+5 -2
src/backend/common/database/drizzle/drizzleTypes.ts
··· 1 1 import { DBQueryConfig, DBQueryConfigWith, ExtractTablesFromSchema, KnownKeysOnly, RelationFieldsFilterInternals, Many, InferSelectModel, ExtractTablesWithRelations, type BuildQueryResult, RelationsFilter } from "drizzle-orm"; 2 - import { components, playInputs, plays, queueStates, relations } from "./schema/schema.js"; 2 + import { components, componentMigrations, playInputs, plays, queueStates, relations } from "./schema/schema.js"; 3 3 import {TSchema, TableName, Schema } from "./schema/schema.js"; 4 4 import { MarkOptional, MarkRequired } from "ts-essentials"; 5 5 6 6 7 7 export type ComponentNew = typeof components.$inferInsert; 8 - export type ComponentSelect = typeof components.$inferSelect; 8 + export type ComponentSelect = GenericRelationResult<'components', 'migrations'>; 9 + 10 + export type ComponentMigrationNew = typeof componentMigrations.$inferInsert; 11 + export type ComponentMigrationSelect = typeof componentMigrations.$inferSelect; 9 12 10 13 export type QueueStateNew = typeof queueStates.$inferInsert; 11 14 export type QueueStateSelect = typeof queueStates.$inferSelect;
+9
src/backend/common/database/drizzle/migrations/20260513202707_chubby_jack_murdock/migration.sql
··· 1 + CREATE TABLE `component_migrations` ( 2 + `id` integer PRIMARY KEY, 3 + `componentId` integer NOT NULL, 4 + `name` text NOT NULL, 5 + `success` integer, 6 + `error` text, 7 + `attemptedAt` number NOT NULL, 8 + CONSTRAINT `fk_component_migrations_componentId_components_id_fk` FOREIGN KEY (`componentId`) REFERENCES `components`(`id`) ON UPDATE CASCADE ON DELETE CASCADE 9 + );
+924
src/backend/common/database/drizzle/migrations/20260513202707_chubby_jack_murdock/snapshot.json
··· 1 + { 2 + "version": "7", 3 + "dialect": "sqlite", 4 + "id": "68ef2e2f-c7b2-41bb-9487-2d49bd04d70d", 5 + "prevIds": [ 6 + "5655103c-e7d9-4026-9e71-9480ce6fb036" 7 + ], 8 + "ddl": [ 9 + { 10 + "name": "component_migrations", 11 + "entityType": "tables" 12 + }, 13 + { 14 + "name": "components", 15 + "entityType": "tables" 16 + }, 17 + { 18 + "name": "jobs", 19 + "entityType": "tables" 20 + }, 21 + { 22 + "name": "play_inputs", 23 + "entityType": "tables" 24 + }, 25 + { 26 + "name": "plays", 27 + "entityType": "tables" 28 + }, 29 + { 30 + "name": "play_queue_states", 31 + "entityType": "tables" 32 + }, 33 + { 34 + "type": "integer", 35 + "notNull": false, 36 + "autoincrement": false, 37 + "default": null, 38 + "generated": null, 39 + "name": "id", 40 + "entityType": "columns", 41 + "table": "component_migrations" 42 + }, 43 + { 44 + "type": "integer", 45 + "notNull": true, 46 + "autoincrement": false, 47 + "default": null, 48 + "generated": null, 49 + "name": "componentId", 50 + "entityType": "columns", 51 + "table": "component_migrations" 52 + }, 53 + { 54 + "type": "text", 55 + "notNull": true, 56 + "autoincrement": false, 57 + "default": null, 58 + "generated": null, 59 + "name": "name", 60 + "entityType": "columns", 61 + "table": "component_migrations" 62 + }, 63 + { 64 + "type": "integer", 65 + "notNull": false, 66 + "autoincrement": false, 67 + "default": null, 68 + "generated": null, 69 + "name": "success", 70 + "entityType": "columns", 71 + "table": "component_migrations" 72 + }, 73 + { 74 + "type": "text", 75 + "notNull": false, 76 + "autoincrement": false, 77 + "default": null, 78 + "generated": null, 79 + "name": "error", 80 + "entityType": "columns", 81 + "table": "component_migrations" 82 + }, 83 + { 84 + "type": "number", 85 + "notNull": true, 86 + "autoincrement": false, 87 + "default": null, 88 + "generated": null, 89 + "name": "attemptedAt", 90 + "entityType": "columns", 91 + "table": "component_migrations" 92 + }, 93 + { 94 + "type": "integer", 95 + "notNull": false, 96 + "autoincrement": false, 97 + "default": null, 98 + "generated": null, 99 + "name": "id", 100 + "entityType": "columns", 101 + "table": "components" 102 + }, 103 + { 104 + "type": "text(200)", 105 + "notNull": true, 106 + "autoincrement": false, 107 + "default": null, 108 + "generated": null, 109 + "name": "uid", 110 + "entityType": "columns", 111 + "table": "components" 112 + }, 113 + { 114 + "type": "text", 115 + "notNull": true, 116 + "autoincrement": false, 117 + "default": null, 118 + "generated": null, 119 + "name": "mode", 120 + "entityType": "columns", 121 + "table": "components" 122 + }, 123 + { 124 + "type": "text(50)", 125 + "notNull": true, 126 + "autoincrement": false, 127 + "default": null, 128 + "generated": null, 129 + "name": "type", 130 + "entityType": "columns", 131 + "table": "components" 132 + }, 133 + { 134 + "type": "text", 135 + "notNull": true, 136 + "autoincrement": false, 137 + "default": null, 138 + "generated": null, 139 + "name": "name", 140 + "entityType": "columns", 141 + "table": "components" 142 + }, 143 + { 144 + "type": "integer", 145 + "notNull": true, 146 + "autoincrement": false, 147 + "default": "0", 148 + "generated": null, 149 + "name": "countLive", 150 + "entityType": "columns", 151 + "table": "components" 152 + }, 153 + { 154 + "type": "integer", 155 + "notNull": true, 156 + "autoincrement": false, 157 + "default": "0", 158 + "generated": null, 159 + "name": "countNonLive", 160 + "entityType": "columns", 161 + "table": "components" 162 + }, 163 + { 164 + "type": "number", 165 + "notNull": false, 166 + "autoincrement": false, 167 + "default": null, 168 + "generated": null, 169 + "name": "createdAt", 170 + "entityType": "columns", 171 + "table": "components" 172 + }, 173 + { 174 + "type": "integer", 175 + "notNull": false, 176 + "autoincrement": false, 177 + "default": null, 178 + "generated": null, 179 + "name": "id", 180 + "entityType": "columns", 181 + "table": "jobs" 182 + }, 183 + { 184 + "type": "integer", 185 + "notNull": true, 186 + "autoincrement": false, 187 + "default": null, 188 + "generated": null, 189 + "name": "componentFromId", 190 + "entityType": "columns", 191 + "table": "jobs" 192 + }, 193 + { 194 + "type": "integer", 195 + "notNull": true, 196 + "autoincrement": false, 197 + "default": null, 198 + "generated": null, 199 + "name": "componentToId", 200 + "entityType": "columns", 201 + "table": "jobs" 202 + }, 203 + { 204 + "type": "text(50)", 205 + "notNull": true, 206 + "autoincrement": false, 207 + "default": null, 208 + "generated": null, 209 + "name": "name", 210 + "entityType": "columns", 211 + "table": "jobs" 212 + }, 213 + { 214 + "type": "text", 215 + "notNull": true, 216 + "autoincrement": false, 217 + "default": "'idle'", 218 + "generated": null, 219 + "name": "status", 220 + "entityType": "columns", 221 + "table": "jobs" 222 + }, 223 + { 224 + "type": "integer", 225 + "notNull": true, 226 + "autoincrement": false, 227 + "default": "0", 228 + "generated": null, 229 + "name": "retries", 230 + "entityType": "columns", 231 + "table": "jobs" 232 + }, 233 + { 234 + "type": "text", 235 + "notNull": false, 236 + "autoincrement": false, 237 + "default": null, 238 + "generated": null, 239 + "name": "error", 240 + "entityType": "columns", 241 + "table": "jobs" 242 + }, 243 + { 244 + "type": "text", 245 + "notNull": false, 246 + "autoincrement": false, 247 + "default": null, 248 + "generated": null, 249 + "name": "transformOptions", 250 + "entityType": "columns", 251 + "table": "jobs" 252 + }, 253 + { 254 + "type": "text", 255 + "notNull": false, 256 + "autoincrement": false, 257 + "default": null, 258 + "generated": null, 259 + "name": "initialParameters", 260 + "entityType": "columns", 261 + "table": "jobs" 262 + }, 263 + { 264 + "type": "text", 265 + "notNull": false, 266 + "autoincrement": false, 267 + "default": null, 268 + "generated": null, 269 + "name": "cursor", 270 + "entityType": "columns", 271 + "table": "jobs" 272 + }, 273 + { 274 + "type": "integer", 275 + "notNull": false, 276 + "autoincrement": false, 277 + "default": null, 278 + "generated": null, 279 + "name": "total", 280 + "entityType": "columns", 281 + "table": "jobs" 282 + }, 283 + { 284 + "type": "integer", 285 + "notNull": true, 286 + "autoincrement": false, 287 + "default": "0", 288 + "generated": null, 289 + "name": "imported", 290 + "entityType": "columns", 291 + "table": "jobs" 292 + }, 293 + { 294 + "type": "integer", 295 + "notNull": true, 296 + "autoincrement": false, 297 + "default": "0", 298 + "generated": null, 299 + "name": "scrobbled", 300 + "entityType": "columns", 301 + "table": "jobs" 302 + }, 303 + { 304 + "type": "number", 305 + "notNull": true, 306 + "autoincrement": false, 307 + "default": null, 308 + "generated": null, 309 + "name": "createdAt", 310 + "entityType": "columns", 311 + "table": "jobs" 312 + }, 313 + { 314 + "type": "number", 315 + "notNull": true, 316 + "autoincrement": false, 317 + "default": null, 318 + "generated": null, 319 + "name": "updatedAt", 320 + "entityType": "columns", 321 + "table": "jobs" 322 + }, 323 + { 324 + "type": "number", 325 + "notNull": false, 326 + "autoincrement": false, 327 + "default": null, 328 + "generated": null, 329 + "name": "completedAt", 330 + "entityType": "columns", 331 + "table": "jobs" 332 + }, 333 + { 334 + "type": "integer", 335 + "notNull": false, 336 + "autoincrement": false, 337 + "default": null, 338 + "generated": null, 339 + "name": "id", 340 + "entityType": "columns", 341 + "table": "play_inputs" 342 + }, 343 + { 344 + "type": "integer", 345 + "notNull": true, 346 + "autoincrement": false, 347 + "default": null, 348 + "generated": null, 349 + "name": "playId", 350 + "entityType": "columns", 351 + "table": "play_inputs" 352 + }, 353 + { 354 + "type": "text", 355 + "notNull": false, 356 + "autoincrement": false, 357 + "default": null, 358 + "generated": null, 359 + "name": "data", 360 + "entityType": "columns", 361 + "table": "play_inputs" 362 + }, 363 + { 364 + "type": "text", 365 + "notNull": true, 366 + "autoincrement": false, 367 + "default": null, 368 + "generated": null, 369 + "name": "play", 370 + "entityType": "columns", 371 + "table": "play_inputs" 372 + }, 373 + { 374 + "type": "number", 375 + "notNull": false, 376 + "autoincrement": false, 377 + "default": null, 378 + "generated": null, 379 + "name": "createdAt", 380 + "entityType": "columns", 381 + "table": "play_inputs" 382 + }, 383 + { 384 + "type": "integer", 385 + "notNull": false, 386 + "autoincrement": false, 387 + "default": null, 388 + "generated": null, 389 + "name": "id", 390 + "entityType": "columns", 391 + "table": "plays" 392 + }, 393 + { 394 + "type": "text(30)", 395 + "notNull": true, 396 + "autoincrement": false, 397 + "default": null, 398 + "generated": null, 399 + "name": "uid", 400 + "entityType": "columns", 401 + "table": "plays" 402 + }, 403 + { 404 + "type": "integer", 405 + "notNull": false, 406 + "autoincrement": false, 407 + "default": null, 408 + "generated": null, 409 + "name": "componentId", 410 + "entityType": "columns", 411 + "table": "plays" 412 + }, 413 + { 414 + "type": "text", 415 + "notNull": false, 416 + "autoincrement": false, 417 + "default": null, 418 + "generated": null, 419 + "name": "error", 420 + "entityType": "columns", 421 + "table": "plays" 422 + }, 423 + { 424 + "type": "number", 425 + "notNull": false, 426 + "autoincrement": false, 427 + "default": null, 428 + "generated": null, 429 + "name": "playedAt", 430 + "entityType": "columns", 431 + "table": "plays" 432 + }, 433 + { 434 + "type": "number", 435 + "notNull": false, 436 + "autoincrement": false, 437 + "default": null, 438 + "generated": null, 439 + "name": "seenAt", 440 + "entityType": "columns", 441 + "table": "plays" 442 + }, 443 + { 444 + "type": "number", 445 + "notNull": true, 446 + "autoincrement": false, 447 + "default": null, 448 + "generated": null, 449 + "name": "updatedAt", 450 + "entityType": "columns", 451 + "table": "plays" 452 + }, 453 + { 454 + "type": "text", 455 + "notNull": true, 456 + "autoincrement": false, 457 + "default": null, 458 + "generated": null, 459 + "name": "play", 460 + "entityType": "columns", 461 + "table": "plays" 462 + }, 463 + { 464 + "type": "text", 465 + "notNull": true, 466 + "autoincrement": false, 467 + "default": null, 468 + "generated": null, 469 + "name": "state", 470 + "entityType": "columns", 471 + "table": "plays" 472 + }, 473 + { 474 + "type": "integer", 475 + "notNull": false, 476 + "autoincrement": false, 477 + "default": null, 478 + "generated": null, 479 + "name": "parentId", 480 + "entityType": "columns", 481 + "table": "plays" 482 + }, 483 + { 484 + "type": "integer", 485 + "notNull": false, 486 + "autoincrement": false, 487 + "default": null, 488 + "generated": null, 489 + "name": "jobId", 490 + "entityType": "columns", 491 + "table": "plays" 492 + }, 493 + { 494 + "type": "text", 495 + "notNull": false, 496 + "autoincrement": false, 497 + "default": null, 498 + "generated": null, 499 + "name": "playHash", 500 + "entityType": "columns", 501 + "table": "plays" 502 + }, 503 + { 504 + "type": "text", 505 + "notNull": false, 506 + "autoincrement": false, 507 + "default": null, 508 + "generated": null, 509 + "name": "mbidIdentifier", 510 + "entityType": "columns", 511 + "table": "plays" 512 + }, 513 + { 514 + "type": "text", 515 + "notNull": false, 516 + "autoincrement": false, 517 + "default": null, 518 + "generated": null, 519 + "name": "compacted", 520 + "entityType": "columns", 521 + "table": "plays" 522 + }, 523 + { 524 + "type": "integer", 525 + "notNull": false, 526 + "autoincrement": false, 527 + "default": null, 528 + "generated": null, 529 + "name": "id", 530 + "entityType": "columns", 531 + "table": "play_queue_states" 532 + }, 533 + { 534 + "type": "integer", 535 + "notNull": true, 536 + "autoincrement": false, 537 + "default": null, 538 + "generated": null, 539 + "name": "playId", 540 + "entityType": "columns", 541 + "table": "play_queue_states" 542 + }, 543 + { 544 + "type": "integer", 545 + "notNull": true, 546 + "autoincrement": false, 547 + "default": null, 548 + "generated": null, 549 + "name": "componentId", 550 + "entityType": "columns", 551 + "table": "play_queue_states" 552 + }, 553 + { 554 + "type": "text(50)", 555 + "notNull": true, 556 + "autoincrement": false, 557 + "default": null, 558 + "generated": null, 559 + "name": "queueName", 560 + "entityType": "columns", 561 + "table": "play_queue_states" 562 + }, 563 + { 564 + "type": "text", 565 + "notNull": true, 566 + "autoincrement": false, 567 + "default": "'queued'", 568 + "generated": null, 569 + "name": "queueStatus", 570 + "entityType": "columns", 571 + "table": "play_queue_states" 572 + }, 573 + { 574 + "type": "integer", 575 + "notNull": true, 576 + "autoincrement": false, 577 + "default": "0", 578 + "generated": null, 579 + "name": "retries", 580 + "entityType": "columns", 581 + "table": "play_queue_states" 582 + }, 583 + { 584 + "type": "text", 585 + "notNull": false, 586 + "autoincrement": false, 587 + "default": null, 588 + "generated": null, 589 + "name": "error", 590 + "entityType": "columns", 591 + "table": "play_queue_states" 592 + }, 593 + { 594 + "type": "number", 595 + "notNull": true, 596 + "autoincrement": false, 597 + "default": null, 598 + "generated": null, 599 + "name": "createdAt", 600 + "entityType": "columns", 601 + "table": "play_queue_states" 602 + }, 603 + { 604 + "type": "number", 605 + "notNull": true, 606 + "autoincrement": false, 607 + "default": null, 608 + "generated": null, 609 + "name": "updatedAt", 610 + "entityType": "columns", 611 + "table": "play_queue_states" 612 + }, 613 + { 614 + "columns": [ 615 + "componentId" 616 + ], 617 + "tableTo": "components", 618 + "columnsTo": [ 619 + "id" 620 + ], 621 + "onUpdate": "CASCADE", 622 + "onDelete": "CASCADE", 623 + "nameExplicit": false, 624 + "name": "fk_component_migrations_componentId_components_id_fk", 625 + "entityType": "fks", 626 + "table": "component_migrations" 627 + }, 628 + { 629 + "columns": [ 630 + "componentFromId" 631 + ], 632 + "tableTo": "components", 633 + "columnsTo": [ 634 + "id" 635 + ], 636 + "onUpdate": "CASCADE", 637 + "onDelete": "CASCADE", 638 + "nameExplicit": false, 639 + "name": "fk_jobs_componentFromId_components_id_fk", 640 + "entityType": "fks", 641 + "table": "jobs" 642 + }, 643 + { 644 + "columns": [ 645 + "componentToId" 646 + ], 647 + "tableTo": "components", 648 + "columnsTo": [ 649 + "id" 650 + ], 651 + "onUpdate": "CASCADE", 652 + "onDelete": "CASCADE", 653 + "nameExplicit": false, 654 + "name": "fk_jobs_componentToId_components_id_fk", 655 + "entityType": "fks", 656 + "table": "jobs" 657 + }, 658 + { 659 + "columns": [ 660 + "playId" 661 + ], 662 + "tableTo": "plays", 663 + "columnsTo": [ 664 + "id" 665 + ], 666 + "onUpdate": "CASCADE", 667 + "onDelete": "CASCADE", 668 + "nameExplicit": false, 669 + "name": "fk_play_inputs_playId_plays_id_fk", 670 + "entityType": "fks", 671 + "table": "play_inputs" 672 + }, 673 + { 674 + "columns": [ 675 + "componentId" 676 + ], 677 + "tableTo": "components", 678 + "columnsTo": [ 679 + "id" 680 + ], 681 + "onUpdate": "CASCADE", 682 + "onDelete": "CASCADE", 683 + "nameExplicit": false, 684 + "name": "fk_plays_componentId_components_id_fk", 685 + "entityType": "fks", 686 + "table": "plays" 687 + }, 688 + { 689 + "columns": [ 690 + "parentId" 691 + ], 692 + "tableTo": "plays", 693 + "columnsTo": [ 694 + "id" 695 + ], 696 + "onUpdate": "CASCADE", 697 + "onDelete": "SET NULL", 698 + "nameExplicit": false, 699 + "name": "fk_plays_parentId_plays_id_fk", 700 + "entityType": "fks", 701 + "table": "plays" 702 + }, 703 + { 704 + "columns": [ 705 + "jobId" 706 + ], 707 + "tableTo": "jobs", 708 + "columnsTo": [ 709 + "id" 710 + ], 711 + "onUpdate": "CASCADE", 712 + "onDelete": "CASCADE", 713 + "nameExplicit": false, 714 + "name": "fk_plays_jobId_jobs_id_fk", 715 + "entityType": "fks", 716 + "table": "plays" 717 + }, 718 + { 719 + "columns": [ 720 + "playId" 721 + ], 722 + "tableTo": "plays", 723 + "columnsTo": [ 724 + "id" 725 + ], 726 + "onUpdate": "CASCADE", 727 + "onDelete": "CASCADE", 728 + "nameExplicit": false, 729 + "name": "fk_play_queue_states_playId_plays_id_fk", 730 + "entityType": "fks", 731 + "table": "play_queue_states" 732 + }, 733 + { 734 + "columns": [ 735 + "componentId" 736 + ], 737 + "tableTo": "components", 738 + "columnsTo": [ 739 + "id" 740 + ], 741 + "onUpdate": "CASCADE", 742 + "onDelete": "CASCADE", 743 + "nameExplicit": false, 744 + "name": "fk_play_queue_states_componentId_components_id_fk", 745 + "entityType": "fks", 746 + "table": "play_queue_states" 747 + }, 748 + { 749 + "columns": [ 750 + "id" 751 + ], 752 + "nameExplicit": false, 753 + "name": "component_migrations_pk", 754 + "table": "component_migrations", 755 + "entityType": "pks" 756 + }, 757 + { 758 + "columns": [ 759 + "id" 760 + ], 761 + "nameExplicit": false, 762 + "name": "components_pk", 763 + "table": "components", 764 + "entityType": "pks" 765 + }, 766 + { 767 + "columns": [ 768 + "id" 769 + ], 770 + "nameExplicit": false, 771 + "name": "jobs_pk", 772 + "table": "jobs", 773 + "entityType": "pks" 774 + }, 775 + { 776 + "columns": [ 777 + "id" 778 + ], 779 + "nameExplicit": false, 780 + "name": "play_inputs_pk", 781 + "table": "play_inputs", 782 + "entityType": "pks" 783 + }, 784 + { 785 + "columns": [ 786 + "id" 787 + ], 788 + "nameExplicit": false, 789 + "name": "plays_pk", 790 + "table": "plays", 791 + "entityType": "pks" 792 + }, 793 + { 794 + "columns": [ 795 + "id" 796 + ], 797 + "nameExplicit": false, 798 + "name": "play_queue_states_pk", 799 + "table": "play_queue_states", 800 + "entityType": "pks" 801 + }, 802 + { 803 + "columns": [ 804 + { 805 + "value": "uid", 806 + "isExpression": false 807 + }, 808 + { 809 + "value": "mode", 810 + "isExpression": false 811 + }, 812 + { 813 + "value": "type", 814 + "isExpression": false 815 + } 816 + ], 817 + "isUnique": true, 818 + "where": null, 819 + "origin": "manual", 820 + "name": "uid_mode_type_idx", 821 + "entityType": "indexes", 822 + "table": "components" 823 + }, 824 + { 825 + "columns": [ 826 + { 827 + "value": "playId", 828 + "isExpression": false 829 + } 830 + ], 831 + "isUnique": true, 832 + "where": null, 833 + "origin": "manual", 834 + "name": "play_input_id_idx", 835 + "entityType": "indexes", 836 + "table": "play_inputs" 837 + }, 838 + { 839 + "columns": [ 840 + { 841 + "value": "parentId", 842 + "isExpression": false 843 + } 844 + ], 845 + "isUnique": false, 846 + "where": null, 847 + "origin": "manual", 848 + "name": "play_parent_id_idx", 849 + "entityType": "indexes", 850 + "table": "plays" 851 + }, 852 + { 853 + "columns": [ 854 + { 855 + "value": "componentId", 856 + "isExpression": false 857 + } 858 + ], 859 + "isUnique": false, 860 + "where": null, 861 + "origin": "manual", 862 + "name": "play_component_id_idx", 863 + "entityType": "indexes", 864 + "table": "plays" 865 + }, 866 + { 867 + "columns": [ 868 + { 869 + "value": "uid", 870 + "isExpression": false 871 + } 872 + ], 873 + "isUnique": true, 874 + "where": null, 875 + "origin": "manual", 876 + "name": "play_uid_idx", 877 + "entityType": "indexes", 878 + "table": "plays" 879 + }, 880 + { 881 + "columns": [ 882 + { 883 + "value": "playedAt", 884 + "isExpression": false 885 + } 886 + ], 887 + "isUnique": false, 888 + "where": null, 889 + "origin": "manual", 890 + "name": "play_playedAt_idx", 891 + "entityType": "indexes", 892 + "table": "plays" 893 + }, 894 + { 895 + "columns": [ 896 + { 897 + "value": "seenAt", 898 + "isExpression": false 899 + } 900 + ], 901 + "isUnique": false, 902 + "where": null, 903 + "origin": "manual", 904 + "name": "play_seenAt_idx", 905 + "entityType": "indexes", 906 + "table": "plays" 907 + }, 908 + { 909 + "columns": [ 910 + { 911 + "value": "playId", 912 + "isExpression": false 913 + } 914 + ], 915 + "isUnique": false, 916 + "where": null, 917 + "origin": "manual", 918 + "name": "play_queue_state_id_idx", 919 + "entityType": "indexes", 920 + "table": "play_queue_states" 921 + } 922 + ], 923 + "renames": [] 924 + }
+8 -3
src/backend/common/database/drizzle/repositories/ComponentRepository.ts
··· 18 18 uid: data.uid ?? data.name 19 19 }; 20 20 const component = await this.db.query.components.findFirst({ 21 - where 21 + where, 22 + with: { 23 + migrations: true 24 + } 22 25 }); 23 26 if (component !== undefined) { 24 27 return component; 25 28 } 26 29 27 - return (await this.db.insert(components).values(generateComponentEntity({ 30 + const componentNew = (await this.db.insert(components).values(generateComponentEntity({ 28 31 uid: data.uid ?? data.name, 29 32 mode: data.mode, 30 33 type: data.type, 31 34 name: data.name 32 - })).returning())[0]; 35 + })).returning())[0] as ComponentSelect; 36 + componentNew.migrations = []; 37 + return componentNew; 33 38 } 34 39 }
+20 -2
src/backend/common/database/drizzle/schema/schema.ts
··· 153 153 uniqueIndex('uid_mode_type_idx').on(table.uid,table.mode,table.type) 154 154 ]); 155 155 156 + export const componentMigrations = sqliteTable("component_migrations", { 157 + id: integer({ mode: 'number' }).primaryKey(), 158 + componentId: integer().notNull().references(() => components.id, {onDelete: 'cascade', onUpdate: 'cascade'}), 159 + name: text().notNull(), 160 + success: integer({mode: 'boolean'}), 161 + error: text({ mode: 'json' }).$type<ErrorLike>(), 162 + attemptedAt: DayjsTimestamp('attemptedAt').notNull().$defaultFn(() => dayjs()), 163 + }); 164 + 156 165 export const jobs = sqliteTable("jobs", { 157 166 id: integer({ mode: 'number' }).primaryKey(), 158 167 componentFromId: integer().notNull().references(() => components.id, {onDelete: 'cascade', onUpdate: 'cascade'}), ··· 172 181 completedAt: DayjsTimestamp('completedAt') 173 182 }); 174 183 175 - const playRelations = defineRelations({ plays, queueStates, playInputs, components, jobs }, (r) => ({ 184 + const playRelations = defineRelations({ plays, queueStates, playInputs, components, jobs, componentMigrations }, (r) => ({ 176 185 plays: { 177 186 queueStates: r.many.queueStates(), 178 187 input: r.one.playInputs({ ··· 209 218 components: { 210 219 plays: r.many.plays(), 211 220 queueStates: r.many.queueStates(), 221 + migrations: r.many.componentMigrations(), 222 + }, 223 + componentMigrations: { 224 + component: r.one.components({ 225 + from: r.componentMigrations.componentId, 226 + to: r.components.id 227 + }) 212 228 }, 213 229 jobs: { 214 230 plays: r.many.plays() ··· 227 243 return playInputs; 228 244 case 'queueStates': 229 245 return queueStates; 246 + case 'componentMigrations': 247 + return componentMigrations; 230 248 case 'jobs': 231 249 return jobs; 232 250 } 233 251 } 234 252 235 - const schema = {playInputs, plays, components, queueStates, jobs}; 253 + const schema = {playInputs, plays, components, componentMigrations, queueStates, jobs}; 236 254 237 255 export type TSchema = typeof relations; 238 256 export type Schema = typeof schema;
+130 -96
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 74 74 import { DEFAULT_NEW_PADDING, groupPlaysToTimeRanges } from "../utils/ListenFetchUtils.js"; 75 75 import { spawn, catchAbortError, isAbortError, rethrowAbortError, delay, forever, AbortError, throwIfAborted } from 'abort-controller-x'; 76 76 import { DrizzlePlayRepository, playToRepositoryCreatePlayOpts, QueryPlaysOpts } from "../common/database/drizzle/repositories/PlayRepository.js"; 77 - import { PlaySelect, PlaySelectWithQueueStates, QueueStateNew, QueueStateSelect } from "../common/database/drizzle/drizzleTypes.js"; 77 + import { ComponentMigrationNew, PlaySelect, PlaySelectWithQueueStates, QueueStateNew, QueueStateSelect } from "../common/database/drizzle/drizzleTypes.js"; 78 78 import { asPlay } from "../../core/PlayMarshalUtils.js"; 79 79 import { DrizzleQueueRepository } from "../common/database/drizzle/repositories/QueueRepository.js"; 80 - import { SourceType } from "../common/infrastructure/config/source/sources.js"; 80 + import { GenericRepository } from "../common/database/drizzle/repositories/BaseRepository.js"; 81 81 82 82 type PlatformMappedPlays = Map<string, {player: SourcePlayerObj, source: SourceIdentifier}>; 83 83 type NowPlayingQueue = Map<string, PlatformMappedPlays>; ··· 154 154 155 155 protected playRepo!: DrizzlePlayRepository; 156 156 protected queueRepo!: DrizzleQueueRepository; 157 + protected migrationRepo!: GenericRepository<'componentMigrations'>; 157 158 158 159 constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, emitter: EventEmitter, logger: Logger) { 159 160 super(config); ··· 321 322 322 323 protected async postCache(): Promise<void> { 323 324 await super.postCache(); 325 + try { 326 + await this.migrateCachedScrobbles(); 327 + } catch (e) { 328 + this.logger.warn(new Error('Unable to migrate cached scrobbles (if any). Will continue init and ignore this error.', {cause: e})); 329 + } 324 330 this.generateStaggerMappers(); 325 331 } 326 332 327 333 protected async postDatabase(): Promise<void> { 328 334 this.playRepo = new DrizzlePlayRepository(this.db, {logger: this.logger}); 329 335 this.queueRepo = new DrizzleQueueRepository(this.db, {logger: this.logger}); 336 + this.migrationRepo = new GenericRepository<'componentMigrations'>(this.db, 'componentMigrations', 'Component Migrations', {logger: this.logger}); 330 337 this.playRepo.componentId = this.dbComponent.id; 331 338 this.queueRepo.componentId = this.dbComponent.id; 332 339 this.tracksScrobbled = this.dbComponent.countLive + this.dbComponent.countNonLive; ··· 540 547 } 541 548 } 542 549 543 - protected async doParseCache(): Promise<true | string | undefined> { 544 - const cachedQueue = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-queue`) as QueuedScrobble<PlayObject>[] ?? []); 545 - if (cachedQueue.length > 0) { 546 - this.logger.info('Migrating cached scrobbles to database...'); 547 - let allGood = true; 548 - for (const cachedQueuedScrobble of cachedQueue) { 549 - const play = asPlay(cachedQueuedScrobble.play); 550 - const { 551 - meta: { 552 - lifecycle, 553 - ...metaRest 554 - }, 555 - } = play; 556 - try { 557 - const res = await this.playRepo.createPlays([ 558 - playToRepositoryCreatePlayOpts({ 559 - play: { 560 - ...play, 561 - data: { 562 - ...play.data, 563 - artists: play.data?.artists === undefined ? undefined : artistNamesToCredits(play.data?.artists as unknown as string[]), 564 - albumArtists: play.data?.albumArtists === undefined ? undefined : artistNamesToCredits(play.data?.albumArtists as unknown as string[]) 565 - }, 566 - meta: { 567 - ...metaRest, 568 - lifecycle: { 569 - steps: [] 570 - } 571 - } 550 + protected async migrateCachedScrobbles(): Promise<void> { 551 + let shouldMigrate: boolean = false; 552 + const migration = this.dbComponent.migrations.find(x => x.name === 'cachedScrobbles'); 553 + if (migration === undefined) { 554 + this.logger.verbose('No Cached Scrobble Migration has run yet, running now...'); 555 + shouldMigrate = true; 556 + } else if (migration.success === false) { 557 + this.logger.verbose('Re-running previously failed Cached Scrobble Migration now...'); 558 + shouldMigrate = true; 559 + } 560 + if (shouldMigrate) { 561 + const migrationEntry: ComponentMigrationNew = migration !== undefined ? migration : {componentId: this.dbComponent.id, name: 'cachedScrobbles'}; 562 + try { 563 + const cachedQueue = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-queue`) as QueuedScrobble<PlayObject>[] ?? []); 564 + if (cachedQueue.length > 0) { 565 + this.logger.info('Migrating cached scrobbles to database...'); 566 + let allGood = true; 567 + for (const cachedQueuedScrobble of cachedQueue) { 568 + const play = asPlay(cachedQueuedScrobble.play); 569 + const { 570 + meta: { 571 + lifecycle, 572 + ...metaRest 572 573 }, 573 - componentId: this.dbComponent.id, 574 - state: 'queued', 575 - parentId: play.id 576 - }) 577 - ]); 578 - this.logger.verbose(`Migrated Play ${res[0].uid} => ${buildTrackString(play)}`); 579 - } catch (e) { 580 - allGood = false; 581 - this.logger.verbose(new Error(`Failed to migrate Play ${buildTrackString(play)}`, {cause: e})); 574 + } = play; 575 + try { 576 + const res = await this.playRepo.createPlays([ 577 + playToRepositoryCreatePlayOpts({ 578 + play: { 579 + ...play, 580 + data: { 581 + ...play.data, 582 + artists: play.data?.artists === undefined ? undefined : artistNamesToCredits(play.data?.artists as unknown as string[]), 583 + albumArtists: play.data?.albumArtists === undefined ? undefined : artistNamesToCredits(play.data?.albumArtists as unknown as string[]) 584 + }, 585 + meta: { 586 + ...metaRest, 587 + lifecycle: { 588 + steps: [] 589 + } 590 + } 591 + }, 592 + componentId: this.dbComponent.id, 593 + state: 'queued', 594 + parentId: play.id 595 + }) 596 + ]); 597 + this.logger.verbose(`Migrated Play ${res[0].uid} => ${buildTrackString(play)}`); 598 + } catch (e) { 599 + allGood = false; 600 + this.logger.verbose(new Error(`Failed to migrate Play ${buildTrackString(play)}`, { cause: e })); 601 + } 602 + } 603 + this.logger[allGood ? 'info' : 'warn'](allGood ? 'Finished migrating all queued scrobbles.' : 'Migrated queued scrobbles with errors'); 604 + await this.cache.cacheScrobble.delete(`${this.getMachineId()}-queue`); 605 + this.logger.info('Deleted legacy cached queued scrobbles'); 606 + } else { 607 + this.logger.info('No cached scrobbles to migrate'); 582 608 } 583 - } 584 - this.logger[allGood ? 'info' : 'warn'](allGood ? 'Finished migrating all queued scrobbles.' : 'Migrated queued scrobbles with errors'); 585 - await this.cache.cacheScrobble.delete(`${this.getMachineId()}-queue`); 586 - this.logger.info('Deleted legacy cached queued scrobbles'); 587 - } 588 609 589 - const cachedDead = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-dead`) as DeadLetterScrobble<PlayObject>[] ?? []); 590 - if(cachedDead.length > 0) { 591 - this.logger.info('Migrating failed scrobbles to database...'); 592 - let allGood = true; 593 - for(const cDeadScrobble of cachedDead) { 594 - const play = asPlay(cDeadScrobble.play); 595 - const { 596 - meta: { 597 - lifecycle, 598 - ...metaRest 599 - }, 600 - } = play; 601 - try { 602 - const res = await this.playRepo.createPlays([ 603 - playToRepositoryCreatePlayOpts({ 604 - play: { 605 - ...play, 606 - data: { 607 - ...play.data, 608 - artists: play.data?.artists === undefined ? undefined : artistNamesToCredits(play.data?.artists as unknown as string[]), 609 - albumArtists: play.data?.albumArtists === undefined ? undefined : artistNamesToCredits(play.data?.albumArtists as unknown as string[]) 610 - }, 611 - meta: { 612 - ...metaRest, 613 - lifecycle: { 614 - steps: [] 615 - } 616 - } 610 + const cachedDead = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-dead`) as DeadLetterScrobble<PlayObject>[] ?? []); 611 + if (cachedDead.length > 0) { 612 + this.logger.info('Migrating failed scrobbles to database...'); 613 + let allGood = true; 614 + for (const cDeadScrobble of cachedDead) { 615 + const play = asPlay(cDeadScrobble.play); 616 + const { 617 + meta: { 618 + lifecycle, 619 + ...metaRest 617 620 }, 618 - componentId: this.dbComponent.id, 619 - state: 'failed', 620 - parentId: play.id 621 - }) 622 - ]); 623 - this.logger.verbose(`Added Play ${res[0].uid} to database => ${buildTrackString(play)}`); 624 - await this.queueRepo.create({ 625 - componentId: this.dbComponent.id, 626 - playId: res[0].id, 627 - queueName: CLIENT_DEAD_QUEUE, 628 - queueStatus: 'queued', 629 - retries: cDeadScrobble.retries, 630 - error: cDeadScrobble.error !== undefined ? {message: cDeadScrobble.error } : undefined 631 - }); 632 - this.logger.verbose(`Added Play ${res[0].uid} to Failed Queue`); 633 - } catch (e) { 634 - allGood = false; 635 - this.logger.verbose(new Error(`Failed to migrate Play to failed queued ${buildTrackString(play)}`, {cause: e})); 621 + } = play; 622 + try { 623 + const res = await this.playRepo.createPlays([ 624 + playToRepositoryCreatePlayOpts({ 625 + play: { 626 + ...play, 627 + data: { 628 + ...play.data, 629 + artists: play.data?.artists === undefined ? undefined : artistNamesToCredits(play.data?.artists as unknown as string[]), 630 + albumArtists: play.data?.albumArtists === undefined ? undefined : artistNamesToCredits(play.data?.albumArtists as unknown as string[]) 631 + }, 632 + meta: { 633 + ...metaRest, 634 + lifecycle: { 635 + steps: [] 636 + } 637 + } 638 + }, 639 + componentId: this.dbComponent.id, 640 + state: 'failed', 641 + parentId: play.id 642 + }) 643 + ]); 644 + this.logger.verbose(`Added Play ${res[0].uid} to database => ${buildTrackString(play)}`); 645 + await this.queueRepo.create({ 646 + componentId: this.dbComponent.id, 647 + playId: res[0].id, 648 + queueName: CLIENT_DEAD_QUEUE, 649 + queueStatus: 'queued', 650 + retries: cDeadScrobble.retries, 651 + error: cDeadScrobble.error !== undefined ? { message: cDeadScrobble.error } : undefined 652 + }); 653 + this.logger.verbose(`Added Play ${res[0].uid} to Failed Queue`); 654 + } catch (e) { 655 + allGood = false; 656 + this.logger.verbose(new Error(`Failed to migrate Play to failed queued ${buildTrackString(play)}`, { cause: e })); 657 + } 658 + this.logger[allGood ? 'info' : 'warn'](allGood ? 'Finished migrating all failed scrobbles.' : 'Migrated failed scrobbles with errors'); 659 + await this.cache.cacheScrobble.delete(`${this.getMachineId()}-dead`); 660 + this.logger.info('Deleted legacy cached failed scrobbles'); 661 + } 662 + } else { 663 + this.logger.info('No dead scrobbles to migrate'); 636 664 } 637 - this.logger[allGood ? 'info' : 'warn'](allGood ? 'Finished migrating all failed scrobbles.' : 'Migrated failed scrobbles with errors'); 638 - await this.cache.cacheScrobble.delete(`${this.getMachineId()}-dead`); 639 - this.logger.info('Deleted legacy cached failed scrobbles'); 665 + await this.migrationRepo.create({...migrationEntry, success: true}); 666 + this.logger.info('Cached Scrobble Migration done'); 667 + } catch (e) { 668 + if(migration === undefined) { 669 + this.migrationRepo.create({...migrationEntry, success: false, error: e}); 670 + } else { 671 + this.migrationRepo.updateById(migration.id, {success: false, error: e}); 672 + } 673 + throw e; 640 674 } 675 + } else { 676 + this.logger.debug('Cached Scrobbles Migration already run!'); 641 677 } 642 - 643 - return; 644 678 } 645 679 646 680 protected async postInitialize(): Promise<void> {