[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): Add basic repository and transaction support

FoxxMD (May 6, 2026, 2:56 AM UTC) d59cb266 3c51149d

+149 -4
+1 -1
src/core/DataUtils.ts
··· 82 82 return `${prefixStr}${localeString}${suffix}`; 83 83 }; 84 84 85 - export const generateArray = (size: number, gen: (index: number) => any) => { 85 + export const generateArray = <T = any>(size: number, gen: (index: number) => T): T[] => { 86 86 return Array.from(Array(size), (v,k) => gen(k)); 87 87 } 88 88
+39 -1
src/backend/tests/database/drizzle.test.ts
··· 13 13 import { projectDir } from '../../common/index.js'; 14 14 import { DatabaseSync } from 'node:sqlite'; 15 15 import { fixtureCreateComponent, fixtureCreateInput, fixtureCreatePlay } from '../utils/databaseFixtures.js'; 16 + import { DrizzleRepository, RepositoryCreatePlayOpts } from '../../common/database/drizzle/repository.js'; 17 + import { generateRandomObj } from '../../../core/tests/utils/fixtures.js'; 18 + import { generateArray } from '../../../core/DataUtils.js'; 19 + import { objectsEqual } from '../../utils/DataUtils.js'; 16 20 17 21 // would be great to push migrations directly from schema but doesn't seem supported in newest beta 18 22 // https://github.com/drizzle-team/drizzle-orm/discussions/4373 ··· 141 145 142 146 const component = await db.insert(components).values(fixtureCreateComponent()).returning(); 143 147 144 - const playRow = await db.insert(plays).values(fixtureCreatePlay({componentId: component[0].id})).returning(); 148 + const playRow = await db.insert(plays).values(fixtureCreatePlay({ componentId: component[0].id })).returning(); 145 149 146 150 const input = await db.insert(playInputs).values(fixtureCreateInput({ 147 151 playId: playRow[0].id, ··· 180 184 } 181 185 db.$client.close(); 182 186 }, { unsafeCleanup: true }); 187 + }); 188 + 189 + }); 190 + 191 + describe('Repository Operations', function () { 192 + 193 + it('creates Plays and inputs', async function () { 194 + 195 + const db = getDb(':memory:'); 196 + await migrateDb(db); 197 + 198 + const component = await db.insert(components).values(fixtureCreateComponent()).returning(); 199 + 200 + const repo = new DrizzleRepository(db); 201 + 202 + const numPlays = 3; 203 + 204 + const playData = generateArray<RepositoryCreatePlayOpts>(numPlays, () => ({ ...fixtureCreatePlay(), componentId: component[0].id, state: 'queued', input: { data: generateRandomObj(undefined, {allowUndefined: false}) } })) 205 + 206 + const rows = await repo.createPlays(playData); 207 + expect(rows).length(numPlays); 208 + const fullPlays = await db.query.plays.findMany({ 209 + with: { 210 + input: true 211 + } 212 + }); 213 + fullPlays.forEach((play, index) => { 214 + const ref = playData[index]; 215 + 216 + expect(play.play.data.track).eq(ref.play.data.track); 217 + expect(play.input).to.not.undefined; 218 + expect(objectsEqual(play.input.data, ref.input.data)).is.true; 219 + }) 220 + 183 221 }); 184 222 185 223 });
+2 -1
src/core/tests/utils/fixtures.ts
··· 244 244 maxKeyLength?: number 245 245 keyCount?: number 246 246 maxDepth?: number 247 + allowUndefined?: boolean 247 248 } 248 249 249 250 const generateRandomVal = (depth: number = 0, opt: RandomObjOptions = {}, typeId?: number) => { 250 251 const i = typeId ?? faker.number.int({ min: 1, max: depth > (opt.maxDepth ?? 3) ? 6 : 8 }); 251 252 switch (i) { 252 253 case 1: 253 - return undefined; 254 + return (opt.allowUndefined ?? true) ? undefined : null; 254 255 case 2: 255 256 return faker.datatype.boolean(); 256 257 case 3:
+42 -1
src/backend/common/database/drizzle/drizzleUtils.ts
··· 1 1 import { drizzle } from 'drizzle-orm/node-sqlite'; 2 2 import { migrate } from 'drizzle-orm/node-sqlite/migrator'; 3 + import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"; 3 4 import { sql as dsl } from 'drizzle-orm'; 4 5 import * as fs from 'fs/promises'; 5 6 import * as path from 'path'; ··· 85 86 } catch (e) { 86 87 throw new Error('Failed to migrate database', { cause: e }); 87 88 } 88 - } 89 + } 90 + 91 + 92 + 93 + // cannot really use transactions right now because async isn't supporting for sqlite 94 + // https://github.com/drizzle-team/drizzle-orm/issues/1472 95 + // https://github.com/drizzle-team/drizzle-orm/issues/2275 96 + // so use this workaround for now 97 + // https://github.com/drizzle-team/drizzle-orm/issues/2275#issuecomment-2496503801 98 + let currentTransaction: null | Promise<void> = null; 99 + export const runTransaction = async < 100 + T, 101 + TQueryResult, 102 + TSchema extends Record<string, unknown> = Record<string, never> 103 + >( 104 + db: BaseSQLiteDatabase<"sync", TQueryResult, TSchema>, 105 + executor: () => Promise<T> 106 + ) => { 107 + while (currentTransaction !== null) { 108 + await currentTransaction; 109 + } 110 + let resolve!: () => void; 111 + currentTransaction = new Promise<void>(_resolve => { 112 + resolve = _resolve; 113 + }); 114 + try { 115 + db.run(dsl.raw(`BEGIN`)) 116 + 117 + try { 118 + const result = await executor(); 119 + await db.run(dsl.raw(`COMMIT`)); 120 + return result; 121 + } catch (error) { 122 + await db.run(dsl.raw(`ROLLBACK`)); 123 + throw error; 124 + } 125 + } finally { 126 + resolve(); 127 + currentTransaction = null; 128 + } 129 + };
+65
src/backend/common/database/drizzle/repository.ts
··· 1 + import { Logger, LoggerAppExtras } from "@foxxmd/logging"; 2 + import { getDb, runTransaction } from "./drizzleUtils.js"; 3 + import { loggerNoop } from "../../MaybeLogger.js"; 4 + import { PlayObject } from "../../../../core/Atomic.js"; 5 + import { generateInputEntity, generatePlayEntity, PlayEntityOpts } from "./entityUtils.js"; 6 + import { PlayInputNew, playInputs, PlayNew, plays, PlaySelect } from "./schema/drizzlePlaysTable.js"; 7 + import { MarkOptional, MarkRequired } from "ts-essentials"; 8 + import { nanoid } from "nanoid"; 9 + 10 + export interface DrizzleRepositoryOpts { 11 + logger?: Logger 12 + } 13 + 14 + export type RepositoryCreatePlayOpts = PlayEntityOpts 15 + & { 16 + input: MarkOptional<PlayInputNew, 'playId' | 'play'> 17 + } 18 + & MarkRequired<Pick<PlayNew, 'play' | 'componentId'>, 'componentId'>; 19 + export class DrizzleRepository { 20 + 21 + logger: Logger; 22 + db: ReturnType<typeof getDb>; 23 + 24 + constructor(db: ReturnType<typeof getDb>, opts: DrizzleRepositoryOpts = {}) { 25 + this.db = db; 26 + this.logger = opts.logger ?? loggerNoop; 27 + } 28 + 29 + createPlays = async (entitiesOpts: RepositoryCreatePlayOpts[]) => { 30 + 31 + let playRows: PlaySelect[]; 32 + 33 + await runTransaction(this.db, async () => { 34 + 35 + const entitiesData = entitiesOpts.map((data) => { 36 + const { 37 + play, 38 + input, 39 + ...rest 40 + } = data; 41 + return generatePlayEntity(play, { ...rest}); 42 + }); 43 + 44 + playRows = await this.db.insert(plays).values(entitiesData).returning(); 45 + 46 + const inputDatas = playRows.map((x, index) => { 47 + const { 48 + play, 49 + input, 50 + } = entitiesOpts[index]; 51 + const { 52 + play: inputPlay = play, 53 + ...restInput 54 + } = input; 55 + 56 + return generateInputEntity({ play: inputPlay, playId: x.id, ...restInput }); 57 + }); 58 + 59 + const inputRow = await this.db.insert(playInputs).values(inputDatas); 60 + 61 + }); 62 + 63 + return playRows; 64 + } 65 + }