[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.

fix: Improved retention behavior

* Use new updatedAt column to determine if plays should be deleted
* Update plays on already discovered to keep them from being delted
* Fix various bugs and missed awaits in retention cleanup function

FoxxMD (May 6, 2026, 2:56 AM UTC) d9a5aa1c f6799955

+27 -13
+1
src/backend/common/database/drizzle/migrations/20260505165955_grey_hitman/migration.sql src/backend/common/database/drizzle/migrations/20260505172702_bored_blob/migration.sql
··· 25 25 `error` text, 26 26 `playedAt` number, 27 27 `seenAt` number, 28 + `updatedAt` number NOT NULL, 28 29 `play` text NOT NULL, 29 30 `state` text NOT NULL, 30 31 `parentId` integer,
+11 -1
src/backend/common/database/drizzle/migrations/20260505165955_grey_hitman/snapshot.json src/backend/common/database/drizzle/migrations/20260505172702_bored_blob/snapshot.json
··· 1 1 { 2 2 "version": "7", 3 3 "dialect": "sqlite", 4 - "id": "6daeb8f1-16a7-46c7-907b-9474e8a3ced0", 4 + "id": "48966f21-b4ad-4399-b9a1-8852f8460808", 5 5 "prevIds": [ 6 6 "00000000-0000-0000-0000-000000000000" 7 7 ], ··· 209 209 "default": null, 210 210 "generated": null, 211 211 "name": "seenAt", 212 + "entityType": "columns", 213 + "table": "plays" 214 + }, 215 + { 216 + "type": "number", 217 + "notNull": true, 218 + "autoincrement": false, 219 + "default": null, 220 + "generated": null, 221 + "name": "updatedAt", 212 222 "entityType": "columns", 213 223 "table": "plays" 214 224 },
+8 -9
src/backend/common/database/drizzle/repositories/PlayRepository.ts
··· 211 211 await this.db.delete(plays).where(inArray(plays.id, ids)); 212 212 } 213 213 214 - findPurgablePlayIds = async (olderThanDate: Dayjs, opts: { states?: PlaySelect['state'][], compacted?: string } & ComponentConstrainedRepoOpts = {}): Promise<number[]> => { 214 + findPurgablePlayIds = async (olderThanDate: Dayjs, opts: { states?: PlaySelect['state'][], compacted?: string, dateComparer?: 'updatedAt' | 'seenAt' } & ComponentConstrainedRepoOpts = {}): Promise<number[]> => { 215 215 216 216 const { 217 217 states, 218 218 compacted, 219 - componentId = this.componentId 219 + componentId = this.componentId, 220 + dateComparer = 'updatedAt' 220 221 } = opts; 221 222 222 223 let where: FindWhere<'plays'> = { 223 224 component: { 224 225 id: componentId 225 226 }, 226 - seenAt: { 227 + [dateComparer]: { 227 228 lte: olderThanDate 228 229 }, 229 230 NOT: { ··· 328 329 state = retentionType; 329 330 } 330 331 loggerCom.trace(`Finding '${retentionType}' plays older than ${shortTodayAwareFormat(date)}...`); 331 - const ids = await this.findPurgablePlayIds(date, {compacted: compactedFlags.join('-'), states: [state], componentId: retentionOpts.componentId}); 332 + const ids = await this.findPurgablePlayIds(date, {compacted: compactedFlags.join('-'), states: [state], dateComparer: 'seenAt', componentId: retentionOpts.componentId}); 332 333 loggerCom.trace(`Found ${ids.length} '${retentionType}' plays`); 333 334 if(ids.length === 0) { 334 335 summaryDelStates.push(`No '${retentionType}' Plays older than ${shortTodayAwareFormat(date)}`); ··· 336 337 for(const id of ids) { 337 338 let compactedPlay: PlayObject; 338 339 if(compactTypes.includes('input')) { 339 - this.db.update(playInputs).set({ 340 + await this.db.update(playInputs).set({ 340 341 data: {removedReason: 'Removed by compaction'} 341 342 }).where(eq(playInputs.playId, id)); 342 343 } ··· 348 349 continue; 349 350 } 350 351 351 - const compactedPlay: PlayObject = playRow.play; 352 + compactedPlay = playRow.play; 352 353 compactedPlay.meta.lifecycle.steps = compactedPlay.meta.lifecycle.steps.map(x => { 353 354 if(x.inputs == undefined) { 354 355 return x; ··· 364 365 if(compactedPlay !== undefined) { 365 366 vals.play = compactedPlay; 366 367 } 367 - this.db.update(plays).set(vals) 368 + await this.db.update(plays).set(vals).where(eq(plays.id, id)); 368 369 } 369 370 loggerCom.trace(`Compacted ${ids.length} '${retentionType}' plays`); 370 - await this.deletePlays(ids); 371 - loggerCom.trace(`'${retentionType}' plays deleted!`); 372 371 summaryCompactStates.push(`${ids.length} '${retentionType}' Plays older than ${shortTodayAwareFormat(date)}`) 373 372 } 374 373 } catch (e) {
+1
src/backend/common/database/drizzle/schema/schema.ts
··· 57 57 error: text({ mode: 'json' }).$type<ErrorLike>(), 58 58 playedAt: DayjsTimestamp('playedAt'), 59 59 seenAt: DayjsTimestamp('seenAt'), 60 + updatedAt: DayjsTimestamp('updatedAt').notNull().$defaultFn(() => dayjs()), 60 61 play: PlayJson('play').notNull(), // text({ mode: 'json' }).notNull().$type<PlayObject>(), 61 62 state: text({enum: ['queued','discovered','discarded','scrobbled','failed','duped']}).notNull(), 62 63 // https://orm.drizzle.team/docs/indexes-constraints#foreign-key
+6 -3
src/backend/sources/AbstractSource.ts
··· 268 268 return list; 269 269 } 270 270 271 - existingDiscovered = async (play: PlayObject, opts: {checkAll?: boolean} = {}): Promise<PlayObject | undefined> => { 271 + existingDiscovered = async (play: PlayObject): Promise<PlayObject | undefined> => { 272 272 const list: PlayObject[] = await this.getRecentlyDiscoveredPlays(); 273 273 const candidate = await this.transformPlay(play, TRANSFORM_HOOK.candidate); 274 274 const existing = await findAsync(list,async x => { ··· 282 282 } 283 283 284 284 alreadyDiscovered = async (play: PlayObject, opts: {checkAll?: boolean} = {}): Promise<boolean> => { 285 - const existing = await this.existingDiscovered(play, opts); 285 + const existing = await this.existingDiscovered(play); 286 286 return existing !== undefined; 287 287 } 288 288 ··· 291 291 292 292 for await(const play of pMapIterable(plays, this.staggerMappers.preCompare(async x => await this.transformPlay(x, TRANSFORM_HOOK.preCompare)), {concurrency: 3})) { 293 293 options.signal?.throwIfAborted(); 294 - if(!(await this.alreadyDiscovered(play, options))) { 294 + const existing = await this.existingDiscovered(play); 295 + if(existing === undefined) { 295 296 options.signal?.throwIfAborted() 296 297 const hydratedPlay = await this.addPlayToDiscovered(play); 297 298 newDiscoveredPlays.push(hydratedPlay); 299 + } else { 300 + this.playRepo.updateById(existing.id, {updatedAt: dayjs()}); 298 301 } 299 302 } 300 303 if(newDiscoveredPlays.length > 0) {