[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(client): Implement createdAt dates for scrobble actions

FoxxMD (Jul 7, 2026, 4:37 PM UTC) e6f4a1db 3d6e86f2

+29 -16
+19 -10
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 19 19 CLIENT_DEAD_QUEUE, 20 20 PlayOriginal, 21 21 PlayLifecycle, 22 - SourcePlayerJson 22 + SourcePlayerJson, 23 + QUEUE_STATUS_COMPLETED 23 24 } from "../../core/Atomic.js"; 24 25 import { artistNamesToCredits, buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; 25 26 import AbstractComponent from "../common/AbstractComponent.js"; ··· 932 933 ...scrobble, 933 934 payload: result.payload, 934 935 warnings: result.warnings, 936 + createdAt: dayjs(), 935 937 response: result.response, 936 938 mergedScrobble: result.mergedScrobble !== undefined ? statefulInvariantTransform(result.mergedScrobble) : undefined 937 939 } ··· 1145 1147 const { summary, ...matchResult } = await this.existingScrobble(currQueuedPlay.play, historicalPlays); 1146 1148 currQueuedPlay.play.scrobble = { 1147 1149 ...(currQueuedPlay.play.scrobble ?? {}), 1148 - match: matchResult 1150 + match: matchResult, 1151 + createdAt: dayjs() 1149 1152 } 1150 1153 signal.throwIfAborted(); 1151 1154 if (!matchResult.match) { ··· 1158 1161 //handledShiftedPlay = true; 1159 1162 } catch (e) { 1160 1163 currQueuedPlay.play.scrobble = { 1164 + createdAt: dayjs() 1161 1165 }; 1162 1166 1163 1167 const submitError = findCauseByReference(e, ScrobbleSubmitError); ··· 1210 1214 queueState.error = queueError; 1211 1215 await this.playRepo.updateById(currQueuedPlay.id, {state: 'failed', error: queueError, play: currQueuedPlay.play}); 1212 1216 currQueuedPlay.state = 'failed'; 1213 - currQueuedPlay.error = queueError; 1217 + //currQueuedPlay.error = queueError; 1214 1218 } else { 1215 1219 await this.queueRepo.updateById(queueState.id, {queueStatus: 'completed'}); 1216 1220 await this.playRepo.updateById(currQueuedPlay.id, {state: successState ?? 'scrobbled', play: currQueuedPlay.play}); ··· 1314 1318 // return [false]; 1315 1319 // } 1316 1320 1317 - let deadQueueState: QueueStateSelect; 1318 - let deadScrobble: PlaySelectWithQueueStates = await this.playRepo.findByUid(uid, {hydrate: ['asPlay']}); 1321 + const deadScrobble: PlaySelectWithQueueStates = await this.playRepo.findByUid(uid, {hydrate: ['asPlay']}); 1319 1322 if(deadScrobble === undefined) { 1320 1323 throw new Error(`Play ${uid} does not exist for ${this.name}`); 1321 1324 } 1322 1325 if(deadScrobble.state === 'scrobbled') { 1323 1326 throw new Error(`Play ${uid} is already scrobbled.`); 1324 1327 } 1325 - deadQueueState = deadScrobble.queueStates.find(x => x.queueName === CLIENT_DEAD_QUEUE); 1328 + const deadQueueState: QueueStateSelect = deadScrobble.queueStates.find(x => x.queueName === CLIENT_DEAD_QUEUE); 1326 1329 if(deadQueueState === undefined) { 1327 1330 throw new Error(`Play ${uid} is not currently queued in dead letter.`); 1328 1331 } ··· 1352 1355 } 1353 1356 1354 1357 this.queueRepo.updateById(deadQueueState.id, {retries: deadQueueState.retries + 1, error: e, updatedAt: dayjs(), queueStatus: 'failed'}); 1355 - this.playRepo.updateById(deadScrobble.id, {error: e}); 1358 + //this.playRepo.updateById(deadScrobble.id, {error: e}); 1356 1359 // deadScrobble.retries++; 1357 1360 // deadScrobble.error = messageWithCauses(e); 1358 1361 // deadScrobble.lastRetry = dayjs(); ··· 1365 1368 const {summary, ...matchResult} = await this.existingScrobble(deadScrobble.play, historicalPlays); 1366 1369 deadScrobble.play.scrobble = { 1367 1370 ...(deadScrobble.play.scrobble ?? {}), 1368 - match: matchResult 1371 + match: matchResult, 1372 + createdAt: dayjs() 1369 1373 } 1370 1374 if(!matchResult.match) { 1371 1375 const transformedScrobble = await this.transformPlay(deadScrobble.play, TRANSFORM_HOOK.postCompare); 1372 1376 signal?.throwIfAborted(); 1373 1377 try { 1374 1378 const scrobbledPlay = await this.scrobble(transformedScrobble); 1379 + deadScrobble.play = scrobbledPlay; 1375 1380 await this.addScrobbledTrack(scrobbledPlay); 1381 + this.playRepo.updateById(deadScrobble.id, {play: deadScrobble.play}); 1382 + this.queueRepo.updateById(deadQueueState.id, {error: null, updatedAt: dayjs(), queueStatus: QUEUE_STATUS_COMPLETED}); 1376 1383 this.removeDeadLetterScrobble(deadScrobble, 'scrobbled', true); 1377 1384 } catch (e) { 1378 1385 deadScrobble.play.scrobble = { 1379 - ...(deadScrobble.play.scrobble ?? {}) 1386 + ...(deadScrobble.play.scrobble ?? {}), 1387 + createdAt: dayjs() 1380 1388 } 1381 1389 const submitError = findCauseByReference(e, ScrobbleSubmitError); 1382 1390 if(submitError !== undefined) { ··· 1389 1397 } 1390 1398 1391 1399 this.queueRepo.updateById(deadQueueState.id, {retries: deadQueueState.retries + 1, error: e, updatedAt: dayjs(), queueStatus: 'failed'}); 1392 - this.playRepo.updateById(deadScrobble.id, {error: e}); 1400 + this.playRepo.updateById(deadScrobble.id, {play: deadScrobble.play}); 1393 1401 // deadScrobble.retries++; 1394 1402 // deadScrobble.error = messageWithCauses(e); 1395 1403 // deadScrobble.lastRetry = dayjs(); ··· 1399 1407 return [false, deadScrobble]; 1400 1408 } 1401 1409 } else { 1410 + this.playRepo.updateById(deadScrobble.id, {play: deadScrobble.play}); 1402 1411 this.deadLogger.verbose(`Looks like ${buildTrackString(deadScrobble.play)} was already scrobbled!\n${summary}`); 1403 1412 this.removeDeadLetterScrobble(deadScrobble, 'duped', true); 1404 1413 }
+1
src/core/Atomic.ts
··· 313 313 error?: Error | ErrorObject 314 314 response?: ScrobbleResponse 315 315 mergedScrobble?: AmbPlayObjectMinimal<D> 316 + createdAt?: D 316 317 } 317 318 318 319 export interface PlayLifecycle<D extends DateLike = Dayjs> {
+9 -6
src/core/tests/utils/fixtures.ts
··· 1 - import { Traverse, TraverseContext } from 'neotraverse/modern'; 1 + import { Traverse } from 'neotraverse/modern'; 2 2 import { faker } from '@faker-js/faker'; 3 3 import { AmbPlayObject, DateLike, LifecycleInput, LifecycleStep, ObjectPlayData, PLAY_STATES, PlayMeta, PlayObject, PlayOriginal, PlayState, ScrobbleResult } from '../../Atomic.js'; 4 - import { MarkOptional } from 'ts-essentials'; 5 4 import { generateBrainz, generateMbid, generatePlay, GeneratePlayOpts, generatePlays } from '../../PlayTestUtils.js'; 6 5 import { statefulInvariantTransform } from '../../PlayUtils.js'; 7 6 import clone from 'clone'; ··· 52 51 //lplay.meta.lifecycle.original = lifecyclelessInvariantTransform(original); 53 52 //lplay.meta.lifecycle.input = generateRandomObj(); 54 53 55 - let steps: LifecycleStep[] = []; 54 + const steps: LifecycleStep[] = []; 56 55 let transformedPlay = clone(original); 57 56 58 57 if(preCompare !== undefined) { ··· 115 114 error = false, 116 115 } = opts; 117 116 118 - const scrobbleRes: ScrobbleResult = {}; 117 + const scrobbleRes: ScrobbleResult = { 118 + createdAt: dayjs() 119 + }; 119 120 120 121 const existingPlays = generatePlays(2); 121 122 ··· 278 279 case 7: 279 280 return generateRandomObj(depth + 1, opt); 280 281 case 8: 281 - const typeId = faker.number.int({ min: 4, max: depth > (opt.maxDepth ?? 3) ? 6 : 7 }); 282 - return faker.helpers.multiple(() => generateRandomVal(depth + 1, opt, typeId), { count: { min: 1, max: opt.maxObjSize ?? 7 } }) 282 + { 283 + const typeId = faker.number.int({ min: 4, max: depth > (opt.maxDepth ?? 3) ? 6 : 7 }); 284 + return faker.helpers.multiple(() => generateRandomVal(depth + 1, opt, typeId), { count: { min: 1, max: opt.maxObjSize ?? 7 } }) 285 + } 283 286 } 284 287 } 285 288