Something went wrong. Try again.
[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
Something went wrong. Try again.
8.2 kB · 209 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210import { describe, it, afterEach } from 'mocha';import { loggerTest } from "@foxxmd/logging";import { expect } from 'chai';import EventEmitter from "events";import sinon from 'sinon';import clone from 'clone';import { SimpleIntervalJob } from 'toad-scheduler';import SpotifySource from "../../sources/SpotifySource.ts";import { envSchemas, type SpotifySourceConfig } from "../../common/infrastructure/config/source/spotify.ts";import currentlyPlayingNoIsrcPayload from '../plays/spotifyCurrentlyPlayingNoIsrc.json' with { type: "json" };import playbackState from '../plays/spotifyCurrentPlaybackState.json' with { type: "json" };
const createSpotifySource = (options: SpotifySourceConfig['options'] = {}): SpotifySource => { const config = { id: `test-${Date.now()}-${Math.random()}`, data: { clientId: 'test-client', clientSecret: 'test-secret', }, options } as unknown as SpotifySourceConfig;
return new SpotifySource('test', config, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter());}
describe('Spotify - ISRC Enrichment', function () {
afterEach(function () { sinon.restore(); });
it('Backfills ISRC from the tracks endpoint when currently-playing omits it', async function () { const payload = clone(currentlyPlayingNoIsrcPayload); payload.item.id = 'track-backfill';
const source = createSpotifySource(); const getTrackStub = sinon.stub().resolves({ body: { external_ids: { isrc: 'USRC17607839' } } }); (source as any).spotifyApi = { getMyCurrentPlayingTrack: sinon.stub().resolves({ body: payload }), getTrack: getTrackStub, };
const play = await source.getNowPlaying();
expect(play?.data.isrc).to.equal('USRC17607839'); expect(getTrackStub.calledOnceWith('track-backfill')).to.be.true; });
it('Does not re-fetch ISRC for the same track while it is still playing', async function () { const payload = clone(currentlyPlayingNoIsrcPayload); payload.item.id = 'track-cached';
const source = createSpotifySource(); const getTrackStub = sinon.stub().resolves({ body: { external_ids: { isrc: 'USRC17607840' } } }); (source as any).spotifyApi = { getMyCurrentPlayingTrack: sinon.stub().resolves({ body: payload }), getTrack: getTrackStub, };
const first = await source.getNowPlaying(); const second = await source.getNowPlaying();
expect(first?.data.isrc).to.equal('USRC17607840'); expect(second?.data.isrc).to.equal('USRC17607840'); expect(getTrackStub.callCount).to.equal(1); });
it('Does not call the tracks endpoint when enrichIsrc is disabled', async function () { const payload = clone(currentlyPlayingNoIsrcPayload); payload.item.id = 'track-disabled';
const source = createSpotifySource({ enrichIsrc: false }); const getTrackStub = sinon.stub(); (source as any).spotifyApi = { getMyCurrentPlayingTrack: sinon.stub().resolves({ body: payload }), getTrack: getTrackStub, };
const play = await source.getNowPlaying();
expect(play?.data.isrc).to.be.undefined; expect(getTrackStub.called).to.be.false; });
it('Does not call tracks endpoint when ISRC is already present', async function () { const payload = clone(playbackState); payload.item.id = 'playbackTest';
const source = createSpotifySource(); const getTrackStub = sinon.stub().resolves({ body: { external_ids: { isrc: 'USRC17607839' } } }); (source as any).spotifyApi = { getMyCurrentPlayingTrack: sinon.stub().resolves({ body: payload }), getTrack: getTrackStub, };
const play = await source.getNowPlaying();
expect(play?.data.isrc).to.equal('FR9W12915571'); expect(getTrackStub.called).to.be.false; });
it('Leaves the play scrobbleable when the backfill call fails', async function () { const payload = clone(currentlyPlayingNoIsrcPayload); payload.item.id = 'track-errors';
const source = createSpotifySource(); (source as any).spotifyApi = { getMyCurrentPlayingTrack: sinon.stub().resolves({ body: payload }), getTrack: sinon.stub().rejects(new Error('Spotify tracks endpoint unavailable')), };
const play = await source.getNowPlaying();
expect(play).to.not.be.undefined; expect(play?.data.isrc).to.be.undefined; expect(play?.data.track).to.equal('The Sandpits Of Zonhoven'); });});
describe('Spotify - Backlog Reconcile Task', function () {
afterEach(function () { sinon.restore(); });
it('adds reconcile task to scheduler by default with 15 minute interval', function () { const source = createSpotifySource(); source.initTasks();
expect(source.scheduler.existsById('reconcile')).to.be.true; const job = source.scheduler.getById('reconcile') as SimpleIntervalJob; expect((job as any).schedule).to.deep.equal({ minutes: 15, runImmediately: false }); });
it('adds reconcile task when scrobbleBacklog is explicitly true', function () { const source = createSpotifySource({ scrobbleBacklog: true }); source.initTasks();
expect(source.scheduler.existsById('reconcile')).to.be.true; });
it('does not add reconcile task when scrobbleBacklog is false', function () { const source = createSpotifySource({ scrobbleBacklog: false }); source.initTasks();
expect(source.scheduler.existsById('reconcile')).to.be.false; });
it('does not add duplicate task if initTasks is called again', function () { const source = createSpotifySource(); source.initTasks(); source.initTasks();
expect(source.scheduler.getAllJobs().filter(j => j.id === 'reconcile')).to.have.lengthOf(1); });
it('reconcile task executes processBacklog with Reconcile label when source is ready', async function () { const source = createSpotifySource(); const processStub = sinon.stub(source as any, 'processBacklog').resolves(); sinon.stub(source, 'isReady').returns(true);
source.initTasks(); const job = source.scheduler.getById('reconcile') as SimpleIntervalJob; await (job as any).task.executeAsync();
expect(processStub.calledOnce).to.be.true; expect(processStub.firstCall.args[1]).to.equal('Reconcile'); });
it('reconcile task does not execute processBacklog when source is not ready', async function () { const source = createSpotifySource(); const processStub = sinon.stub(source as any, 'processBacklog').resolves(); sinon.stub(source, 'isReady').returns(false);
source.initTasks(); const job = source.scheduler.getById('reconcile') as SimpleIntervalJob; await (job as any).task.executeAsync();
expect(processStub.called).to.be.false; });
it('reconcile task pushes to errors when processBacklog fails', async function () { const source = createSpotifySource(); const error = new Error('Spotify API error'); sinon.stub(source as any, 'processBacklog').rejects(error); sinon.stub(source, 'isReady').returns(true);
source.initTasks(); const job = source.scheduler.getById('reconcile') as SimpleIntervalJob; await (job as any).task.executeAsync();
expect(source.errors).to.include(error); });});
describe('Spotify - Configuration', function () {
it('Parses SPOTIFY_SCROBBLE_BACKLOG from ENV', function () { const parsed = envSchemas.toConfig({ SPOTIFY_CLIENT_ID: 'cid', SPOTIFY_CLIENT_SECRET: 'csec', SPOTIFY_REDIRECT_URI: 'http://localhost/callback', SPOTIFY_SCROBBLE_BACKLOG: false, });
expect(parsed.options?.scrobbleBacklog).to.be.false; });});