[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): Stub out jobs for later use

Creating the majority of it now, esp with jobId in plays table, will make later migrations/usage easier

FoxxMD (May 6, 2026, 2:42 PM UTC) 7a7d7bb9 d772c8c8

+321 -5
+24 -1
src/backend/common/database/drizzle/migrations/20260505172702_bored_blob/migration.sql src/backend/common/database/drizzle/migrations/20260506143841_grey_polaris/migration.sql
··· 9 9 `createdAt` number 10 10 ); 11 11 --> statement-breakpoint 12 + CREATE TABLE `jobs` ( 13 + `id` integer PRIMARY KEY, 14 + `componentFromId` integer NOT NULL, 15 + `componentToId` integer NOT NULL, 16 + `name` text(50) NOT NULL, 17 + `status` text DEFAULT 'idle' NOT NULL, 18 + `retries` integer DEFAULT 0 NOT NULL, 19 + `error` text, 20 + `transformOptions` text, 21 + `initialParameters` text, 22 + `cursor` text, 23 + `total` integer, 24 + `imported` integer DEFAULT 0 NOT NULL, 25 + `scrobbled` integer DEFAULT 0 NOT NULL, 26 + `createdAt` number NOT NULL, 27 + `updatedAt` number NOT NULL, 28 + `completedAt` number, 29 + CONSTRAINT `fk_jobs_componentFromId_components_id_fk` FOREIGN KEY (`componentFromId`) REFERENCES `components`(`id`) ON UPDATE CASCADE ON DELETE CASCADE, 30 + CONSTRAINT `fk_jobs_componentToId_components_id_fk` FOREIGN KEY (`componentToId`) REFERENCES `components`(`id`) ON UPDATE CASCADE ON DELETE CASCADE 31 + ); 32 + --> statement-breakpoint 12 33 CREATE TABLE `play_inputs` ( 13 34 `id` integer PRIMARY KEY, 14 35 `playId` integer NOT NULL, ··· 29 50 `play` text NOT NULL, 30 51 `state` text NOT NULL, 31 52 `parentId` integer, 53 + `jobId` integer, 32 54 `playHash` text, 33 55 `mbidIdentifier` text, 34 56 `compacted` text, 35 57 CONSTRAINT `fk_plays_componentId_components_id_fk` FOREIGN KEY (`componentId`) REFERENCES `components`(`id`) ON UPDATE CASCADE ON DELETE CASCADE, 36 - CONSTRAINT `fk_plays_parentId_plays_id_fk` FOREIGN KEY (`parentId`) REFERENCES `plays`(`id`) ON UPDATE CASCADE ON DELETE SET NULL 58 + CONSTRAINT `fk_plays_parentId_plays_id_fk` FOREIGN KEY (`parentId`) REFERENCES `plays`(`id`) ON UPDATE CASCADE ON DELETE SET NULL, 59 + CONSTRAINT `fk_plays_jobId_jobs_id_fk` FOREIGN KEY (`jobId`) REFERENCES `jobs`(`id`) ON UPDATE CASCADE ON DELETE CASCADE 37 60 ); 38 61 --> statement-breakpoint 39 62 CREATE TABLE `play_queue_states` (
+229 -1
src/backend/common/database/drizzle/migrations/20260505172702_bored_blob/snapshot.json src/backend/common/database/drizzle/migrations/20260506143841_grey_polaris/snapshot.json
··· 1 1 { 2 2 "version": "7", 3 3 "dialect": "sqlite", 4 - "id": "48966f21-b4ad-4399-b9a1-8852f8460808", 4 + "id": "5655103c-e7d9-4026-9e71-9480ce6fb036", 5 5 "prevIds": [ 6 6 "00000000-0000-0000-0000-000000000000" 7 7 ], 8 8 "ddl": [ 9 9 { 10 10 "name": "components", 11 + "entityType": "tables" 12 + }, 13 + { 14 + "name": "jobs", 11 15 "entityType": "tables" 12 16 }, 13 17 { ··· 110 114 "generated": null, 111 115 "name": "id", 112 116 "entityType": "columns", 117 + "table": "jobs" 118 + }, 119 + { 120 + "type": "integer", 121 + "notNull": true, 122 + "autoincrement": false, 123 + "default": null, 124 + "generated": null, 125 + "name": "componentFromId", 126 + "entityType": "columns", 127 + "table": "jobs" 128 + }, 129 + { 130 + "type": "integer", 131 + "notNull": true, 132 + "autoincrement": false, 133 + "default": null, 134 + "generated": null, 135 + "name": "componentToId", 136 + "entityType": "columns", 137 + "table": "jobs" 138 + }, 139 + { 140 + "type": "text(50)", 141 + "notNull": true, 142 + "autoincrement": false, 143 + "default": null, 144 + "generated": null, 145 + "name": "name", 146 + "entityType": "columns", 147 + "table": "jobs" 148 + }, 149 + { 150 + "type": "text", 151 + "notNull": true, 152 + "autoincrement": false, 153 + "default": "'idle'", 154 + "generated": null, 155 + "name": "status", 156 + "entityType": "columns", 157 + "table": "jobs" 158 + }, 159 + { 160 + "type": "integer", 161 + "notNull": true, 162 + "autoincrement": false, 163 + "default": "0", 164 + "generated": null, 165 + "name": "retries", 166 + "entityType": "columns", 167 + "table": "jobs" 168 + }, 169 + { 170 + "type": "text", 171 + "notNull": false, 172 + "autoincrement": false, 173 + "default": null, 174 + "generated": null, 175 + "name": "error", 176 + "entityType": "columns", 177 + "table": "jobs" 178 + }, 179 + { 180 + "type": "text", 181 + "notNull": false, 182 + "autoincrement": false, 183 + "default": null, 184 + "generated": null, 185 + "name": "transformOptions", 186 + "entityType": "columns", 187 + "table": "jobs" 188 + }, 189 + { 190 + "type": "text", 191 + "notNull": false, 192 + "autoincrement": false, 193 + "default": null, 194 + "generated": null, 195 + "name": "initialParameters", 196 + "entityType": "columns", 197 + "table": "jobs" 198 + }, 199 + { 200 + "type": "text", 201 + "notNull": false, 202 + "autoincrement": false, 203 + "default": null, 204 + "generated": null, 205 + "name": "cursor", 206 + "entityType": "columns", 207 + "table": "jobs" 208 + }, 209 + { 210 + "type": "integer", 211 + "notNull": false, 212 + "autoincrement": false, 213 + "default": null, 214 + "generated": null, 215 + "name": "total", 216 + "entityType": "columns", 217 + "table": "jobs" 218 + }, 219 + { 220 + "type": "integer", 221 + "notNull": true, 222 + "autoincrement": false, 223 + "default": "0", 224 + "generated": null, 225 + "name": "imported", 226 + "entityType": "columns", 227 + "table": "jobs" 228 + }, 229 + { 230 + "type": "integer", 231 + "notNull": true, 232 + "autoincrement": false, 233 + "default": "0", 234 + "generated": null, 235 + "name": "scrobbled", 236 + "entityType": "columns", 237 + "table": "jobs" 238 + }, 239 + { 240 + "type": "number", 241 + "notNull": true, 242 + "autoincrement": false, 243 + "default": null, 244 + "generated": null, 245 + "name": "createdAt", 246 + "entityType": "columns", 247 + "table": "jobs" 248 + }, 249 + { 250 + "type": "number", 251 + "notNull": true, 252 + "autoincrement": false, 253 + "default": null, 254 + "generated": null, 255 + "name": "updatedAt", 256 + "entityType": "columns", 257 + "table": "jobs" 258 + }, 259 + { 260 + "type": "number", 261 + "notNull": false, 262 + "autoincrement": false, 263 + "default": null, 264 + "generated": null, 265 + "name": "completedAt", 266 + "entityType": "columns", 267 + "table": "jobs" 268 + }, 269 + { 270 + "type": "integer", 271 + "notNull": false, 272 + "autoincrement": false, 273 + "default": null, 274 + "generated": null, 275 + "name": "id", 276 + "entityType": "columns", 113 277 "table": "play_inputs" 114 278 }, 115 279 { ··· 253 417 "table": "plays" 254 418 }, 255 419 { 420 + "type": "integer", 421 + "notNull": false, 422 + "autoincrement": false, 423 + "default": null, 424 + "generated": null, 425 + "name": "jobId", 426 + "entityType": "columns", 427 + "table": "plays" 428 + }, 429 + { 256 430 "type": "text", 257 431 "notNull": false, 258 432 "autoincrement": false, ··· 374 548 }, 375 549 { 376 550 "columns": [ 551 + "componentFromId" 552 + ], 553 + "tableTo": "components", 554 + "columnsTo": [ 555 + "id" 556 + ], 557 + "onUpdate": "CASCADE", 558 + "onDelete": "CASCADE", 559 + "nameExplicit": false, 560 + "name": "fk_jobs_componentFromId_components_id_fk", 561 + "entityType": "fks", 562 + "table": "jobs" 563 + }, 564 + { 565 + "columns": [ 566 + "componentToId" 567 + ], 568 + "tableTo": "components", 569 + "columnsTo": [ 570 + "id" 571 + ], 572 + "onUpdate": "CASCADE", 573 + "onDelete": "CASCADE", 574 + "nameExplicit": false, 575 + "name": "fk_jobs_componentToId_components_id_fk", 576 + "entityType": "fks", 577 + "table": "jobs" 578 + }, 579 + { 580 + "columns": [ 377 581 "playId" 378 582 ], 379 583 "tableTo": "plays", ··· 419 623 }, 420 624 { 421 625 "columns": [ 626 + "jobId" 627 + ], 628 + "tableTo": "jobs", 629 + "columnsTo": [ 630 + "id" 631 + ], 632 + "onUpdate": "CASCADE", 633 + "onDelete": "CASCADE", 634 + "nameExplicit": false, 635 + "name": "fk_plays_jobId_jobs_id_fk", 636 + "entityType": "fks", 637 + "table": "plays" 638 + }, 639 + { 640 + "columns": [ 422 641 "playId" 423 642 ], 424 643 "tableTo": "plays", ··· 454 673 "nameExplicit": false, 455 674 "name": "components_pk", 456 675 "table": "components", 676 + "entityType": "pks" 677 + }, 678 + { 679 + "columns": [ 680 + "id" 681 + ], 682 + "nameExplicit": false, 683 + "name": "jobs_pk", 684 + "table": "jobs", 457 685 "entityType": "pks" 458 686 }, 459 687 {
+35 -3
src/backend/common/database/drizzle/schema/schema.ts
··· 3 3 import dayjs, { Dayjs } from "dayjs"; 4 4 import { nanoid } from "nanoid"; 5 5 import { ErrorLike, PlayObject } from "../../../../../core/Atomic.js"; 6 - import { asPlay, asPlayCheap } from "../../../../../core/PlayMarshalUtils.js"; 6 + import { asPlayCheap } from "../../../../../core/PlayMarshalUtils.js"; 7 + import { ExternalMetadataTerm, PlayTransformPartsConfig, SearchAndReplaceTerm } from "../../../infrastructure/Transform.js"; 8 + import { JobRangeCount, JobRangeTime } from "../../../infrastructure/Job.js"; 7 9 8 10 const DayjsTimestamp = customType< 9 11 { ··· 62 64 state: text({enum: ['queued','discovered','discarded','scrobbled','failed','duped']}).notNull(), 63 65 // https://orm.drizzle.team/docs/indexes-constraints#foreign-key 64 66 parentId: integer().references((): AnySQLiteColumn => plays.id, {onDelete: 'set null', onUpdate: 'cascade'}), 67 + jobId: integer().references(() => jobs.id, {onDelete: 'cascade', onUpdate: 'cascade'}), 65 68 playHash: text(), 66 69 mbidIdentifier: text(), 67 70 compacted: text() ··· 150 153 uniqueIndex('uid_mode_type_idx').on(table.uid,table.mode,table.type) 151 154 ]); 152 155 153 - const playRelations = defineRelations({ plays, queueStates, playInputs, components }, (r) => ({ 156 + export const jobs = sqliteTable("jobs", { 157 + id: integer({ mode: 'number' }).primaryKey(), 158 + componentFromId: integer().notNull().references(() => components.id, {onDelete: 'cascade', onUpdate: 'cascade'}), 159 + componentToId: integer().notNull().references(() => components.id, {onDelete: 'cascade', onUpdate: 'cascade'}), 160 + name: text({length: 50}).notNull(), 161 + status: text({enum: ['idle','completed','failed','processing']}).notNull().default('idle'), 162 + retries: integer().notNull().default(0), 163 + error: text({ mode: 'json' }).$type<ErrorLike>(), 164 + transformOptions: text({ mode: 'json' }).$type<PlayTransformPartsConfig<SearchAndReplaceTerm[] | ExternalMetadataTerm>>(), 165 + initialParameters: text({ mode: 'json' }).$type<JobRangeCount | JobRangeTime>(), 166 + cursor: text({ mode: 'json' }), 167 + total: integer(), 168 + imported: integer().notNull().default(0), 169 + scrobbled: integer().notNull().default(0), 170 + createdAt: DayjsTimestamp('createdAt').notNull().$defaultFn(() => dayjs()), 171 + updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()), 172 + completedAt: DayjsTimestamp('completedAt') 173 + }); 174 + 175 + const playRelations = defineRelations({ plays, queueStates, playInputs, components, jobs }, (r) => ({ 154 176 plays: { 155 177 queueStates: r.many.queueStates(), 156 178 input: r.one.playInputs({ ··· 167 189 from: r.plays.componentId, 168 190 to: r.components.id, 169 191 optional: true 192 + }), 193 + job: r.one.jobs({ 194 + from: r.plays.jobId, 195 + to: r.jobs.id, 196 + optional: true 170 197 }) 171 198 }, 172 199 queueStates: { ··· 182 209 components: { 183 210 plays: r.many.plays(), 184 211 queueStates: r.many.queueStates(), 212 + }, 213 + jobs: { 214 + plays: r.many.plays() 185 215 } 186 216 })); 187 217 ··· 197 227 return playInputs; 198 228 case 'queueStates': 199 229 return queueStates; 230 + case 'jobs': 231 + return jobs; 200 232 } 201 233 } 202 234 203 - const schema = {playInputs, plays, components, queueStates}; 235 + const schema = {playInputs, plays, components, queueStates, jobs}; 204 236 205 237 export type TSchema = typeof relations; 206 238 export type Schema = typeof schema;
+33
src/backend/common/infrastructure/Job.ts
··· 1 + import { UnixTimestamp } from "../../../core/Atomic.js" 2 + 3 + export interface JobParameters { 4 + /** maximum number of results to get for the entire job */ 5 + fetchMax?: number 6 + order?: 'asc' | 'desc' 7 + /** Whether to increment targeted scrobbler client scrobble count */ 8 + countInScrobbler?: boolean 9 + maxRetries?: number 10 + /** Whether to keep non-failed plays in db after scrobbling has occurred 11 + * 12 + * * `true` => keep all plays 13 + * * `false` => keep no plays 14 + * * number => max number of play to keep based on import date 15 + */ 16 + keepPlays?: boolean | number 17 + } 18 + 19 + export interface JobRangeCount extends JobParameters { 20 + order: 'asc' | 'desc' 21 + } 22 + 23 + export interface JobRangeTime extends JobParameters { 24 + /** Unix timestamp */ 25 + from: UnixTimestamp 26 + /** Unix timestamp */ 27 + to: UnixTimestamp 28 + } 29 + 30 + // TODO 31 + export interface JobCursor { 32 + 33 + }