[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 migration path for cached queued scrobbles

FoxxMD (May 6, 2026, 2:56 AM UTC) 272c3cc8 54da8b0a

+94 -6
+8 -1
src/backend/common/Cache.ts
··· 13 13 import { projectDir } from './index.js'; 14 14 import path from 'path'; 15 15 import { cacheFunctions } from "@foxxmd/regex-buddy-core"; 16 - import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.js'; 16 + import { fileExists, fileOrDirectoryIsWriteable } from '../utils/FSUtils.js'; 17 + import { copyFile } from 'fs/promises'; 17 18 import { asCacheAuthProvider, asCacheConfig, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js'; 18 19 import { Typeson } from 'typeson'; 19 20 import { builtin } from 'typeson-registry'; ··· 382 383 fileOrDirectoryIsWriteable(cachePath); 383 384 } catch (e) { 384 385 throw new Error(`Unable to use path for file cache at ${cachePath}`, { cause: e }) 386 + } 387 + 388 + if(fileExists(cachePath) && !fileExists(`${cachePath}.bak`)) { 389 + logger.info(`Backing up ${cachePath} in preparation for migration to database...`); 390 + await copyFile(cachePath, `${cachePath}.bak`); 391 + logger.info(`Done! Backed up to ${cachePath}.bak\nAll data has been loaded into cache. It will be deleted (from cache) after migrating to database.\nIf there are migration issues or you wish to downgrade then overwrite ${cachePath} with the .bak backup copy`); 385 392 } 386 393 387 394 const streamPromise = new Promise((resolve, reject) => {
+86 -5
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 449 449 450 450 protected async doParseCache(): Promise<true | string | undefined> { 451 451 const cachedQueue = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-queue`) as QueuedScrobble<PlayObject>[] ?? []); 452 - const cachedQLength = cachedQueue.length; 453 - //this.queuedScrobbles = cachedQueue.map(x => ({...x, play: rehydratePlay(x.play)})); 452 + if (cachedQueue.length > 0) { 453 + this.logger.info('Migrating cached scrobbles to database...'); 454 + let allGood = true; 455 + for (const cachedQueuedScrobble of cachedQueue) { 456 + const play = asPlay(cachedQueuedScrobble.play); 457 + const { 458 + meta: { 459 + lifecycle, 460 + ...metaRest 461 + }, 462 + } = play; 463 + try { 464 + const res = await this.playRepo.createPlays([ 465 + playToRepositoryCreatePlayOpts({ 466 + play: { 467 + ...play, 468 + meta: { 469 + ...metaRest, 470 + lifecycle: { 471 + steps: [] 472 + } 473 + } 474 + }, 475 + componentId: this.dbComponent.id, 476 + state: 'queued', 477 + parentId: play.id 478 + }) 479 + ]); 480 + this.logger.verbose(`Migrated Play ${res[0].uid} => ${buildTrackString(play)}`); 481 + } catch (e) { 482 + allGood = false; 483 + this.logger.verbose(new Error(`Failed to migrate Play ${buildTrackString(play)}`, {cause: e})); 484 + } 485 + } 486 + this.logger[allGood ? 'info' : 'warn'](allGood ? 'Finished migrating all queued scrobbles.' : 'Migrated queued scrobbles with errors'); 487 + await this.cache.cacheScrobble.delete(`${this.getMachineId()}-queue`); 488 + this.logger.info('Deleted legacy cached queued scrobbles'); 489 + } 454 490 455 491 const cachedDead = (await this.cache.cacheScrobble.get(`${this.getMachineId()}-dead`) as DeadLetterScrobble<PlayObject>[] ?? []); 456 - const cachedDLength = cachedDead.length; 457 - //this.deadLetterScrobbles = cachedDead.map(x => ({...x, play: rehydratePlay(x.play), lastRetry: x.lastRetry !== undefined ? dayjs(x.lastRetry) : undefined})); 492 + if(cachedDead.length > 0) { 493 + this.logger.info('Migrating failed scrobbles to database...'); 494 + let allGood = true; 495 + for(const cDeadScrobble of cachedDead) { 496 + const play = asPlay(cDeadScrobble.play); 497 + const { 498 + meta: { 499 + lifecycle, 500 + ...metaRest 501 + }, 502 + } = play; 503 + try { 504 + const res = await this.playRepo.createPlays([ 505 + playToRepositoryCreatePlayOpts({ 506 + play: { 507 + ...play, 508 + meta: { 509 + ...metaRest, 510 + lifecycle: { 511 + steps: [] 512 + } 513 + } 514 + }, 515 + componentId: this.dbComponent.id, 516 + state: 'failed', 517 + parentId: play.id 518 + }) 519 + ]); 520 + this.logger.verbose(`Added Play ${res[0].uid} to database => ${buildTrackString(play)}`); 521 + await this.queueRepo.create({ 522 + componentId: this.dbComponent.id, 523 + playId: res[0].id, 524 + queueName: CLIENT_DEAD_QUEUE, 525 + queueStatus: 'queued', 526 + retries: cDeadScrobble.retries, 527 + error: cDeadScrobble.error !== undefined ? {message: cDeadScrobble.error } : undefined 528 + }); 529 + this.logger.verbose(`Added Play ${res[0].uid} to Failed Queue`); 530 + } catch (e) { 531 + allGood = false; 532 + this.logger.verbose(new Error(`Failed to migrate Play to failed queued ${buildTrackString(play)}`, {cause: e})); 533 + } 534 + this.logger[allGood ? 'info' : 'warn'](allGood ? 'Finished migrating all failed scrobbles.' : 'Migrated failed scrobbles with errors'); 535 + await this.cache.cacheScrobble.delete(`${this.getMachineId()}-dead`); 536 + this.logger.info('Deleted legacy cached failed scrobbles'); 537 + } 538 + } 458 539 459 - return `Scrobbles from Cache: ${cachedQLength} Queue | ${cachedDLength} Dead Letter`; 540 + return; 460 541 } 461 542 462 543 protected async postInitialize(): Promise<void> {