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.
14 kB · 309 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310import { loggerTest } from "@foxxmd/logging";import { assert, expect } from 'chai';import EventEmitter from "events";import { mkdtempSync, readFileSync, rmSync } from "fs";import { tmpdir } from "os";import { join } from "path";import { describe, it } from 'mocha';import { http, HttpResponse } from "msw";import { COMPONENT_AUTH_TYPE, PARSED_FROM } from "../../../core/Atomic.ts";import type { MixcloudData, MixcloudListen } from "../../common/infrastructure/config/source/mixcloud.ts";import MixcloudSource from "../../sources/MixcloudSource.ts";import { withRequestInterception } from "../utils/networking.ts";import listensResponse from './listens.json' with { type: "json" };import { creditsToNames } from "../../../core/MusicMetadata.ts";
// Mixcloud serves JSON with a text/javascript content-typeconst mixcloudJson = (body: object, init: ResponseInit = {}) => new HttpResponse(JSON.stringify(body), { ...init, headers: { 'Content-Type': 'text/javascript; charset=utf-8' }});
const listens = listensResponse.data as MixcloudListen[];
const appData: MixcloudData = { clientId: 'cid', clientSecret: 'secret' };
const createSource = (data: MixcloudData = { username: 'TestUser' }, name: string = 'Test'): MixcloudSource => new MixcloudSource(name, { id: `test-${Date.now()}`, data, options: {}}, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter());
/** A source with app credentials whose credentials file lives in a temp dir */const createAuthSource = (): { source: MixcloudSource, cleanup: () => void } => { const configDir = mkdtempSync(join(tmpdir(), 'ms-mixcloud-test-')); const source = new MixcloudSource('Test', { id: `test-${Date.now()}`, data: appData, options: {} }, { localUrl: new URL('http://test'), configDir, logger: loggerTest, version: 'test' }, new EventEmitter()); return { source, cleanup: () => rmSync(configDir, { recursive: true, force: true }) };};
describe('Mixcloud Source', function () {
describe('Play Parsing', function () {
it('Parses a listen as a single play for the entire mix', function () { const play = MixcloudSource.formatPlayObj(listens[0]);
assert.equal(play.data.track?.name, 'Late Night Session #12'); assert.equal(play.data.duration, 7198); assert.isUndefined(play.data.album); assert.equal(play.data.playDate!.toISOString(), '2026-06-15T13:38:24.000Z'); assert.equal(play.meta.trackId, '/SomeRadio/late-night-session-12/'); assert.equal(play.meta.url!.web, 'https://www.mixcloud.com/SomeRadio/late-night-session-12/'); assert.equal(play.data.track?.image, 'https://thumbnailer.mixcloud.com/unsafe/300x300/extaudio/a/b/c'); });
it('Uses credited hosts as artists', function () { const play = MixcloudSource.formatPlayObj(listens[0]); assert.sameOrderedMembers(creditsToNames(play.data.artists!), ['DJ One', 'DJ Two']); });
it('Falls back to the uploader as artist when there are no hosts', function () { const play = MixcloudSource.formatPlayObj(listens[1]); assert.sameOrderedMembers(creditsToNames(play.data.artists!), ['Solo DJ']); assert.equal(play.data.track?.image, 'https://thumbnailer.mixcloud.com/unsafe/100x100/extaudio/d/e/f'); }); });
describe('Startup', function () {
it('Fails init when username is empty', async function () { const source = createSource({ username: ' ' }); try { await source.buildInitData(); assert.fail('Expected init to throw'); } catch (e) { expect(source.buildOK).to.be.false; } });
it('Does not require auth with only a username', async function () { const source = createSource(); await source.buildInitData(); expect(source.authType).to.equal(COMPONENT_AUTH_TYPE.none); expect(source.requiresAuth).to.be.false; });
it('Fails init when neither username nor app credentials are defined', async function () { const source = createSource({}); try { await source.buildInitData(); assert.fail('Expected init to throw'); } catch (e) { expect(source.buildOK).to.be.false; expect(((e as Error).cause as Error).message).to.include('username must be defined'); } });
it('Fails init when clientId is defined without clientSecret', async function () { const source = createSource({ clientId: 'cid' }); try { await source.buildInitData(); assert.fail('Expected init to throw'); } catch (e) { expect(source.buildOK).to.be.false; expect(((e as Error).cause as Error).message).to.include('clientId and clientSecret must both be defined'); } });
it('Connection check succeeds when user exists', withRequestInterception( [ http.get('https://api.mixcloud.com/TestUser/', () => mixcloudJson({ key: '/testuser/', name: 'TestUser', username: 'testuser' })) ], async function () { const source = createSource(); await source.buildInitData(); await source.checkConnection(); expect(source.connectionOK).to.be.true; } ));
it('Connection check fails with Mixcloud error when user does not exist', withRequestInterception( [ http.get('https://api.mixcloud.com/TestUser/', () => mixcloudJson({ error: { type: 'ResourceNotFoundException', message: 'User does not exist.' } }, { status: 404 })) ], async function () { const source = createSource(); await source.buildInitData(); try { await source.checkConnection(); assert.fail('Expected connection check to throw'); } catch (e) { expect(source.connectionOK).to.be.false; expect((e as Error).cause).to.be.instanceOf(Error); expect(((e as Error).cause as Error).message).to.include('User does not exist.'); } } )); });
describe('Authentication', function () {
it('Requires interactive auth when app credentials are defined', function () { const { source, cleanup } = createAuthSource(); try { expect(source.authType).to.equal(COMPONENT_AUTH_TYPE.interactive); expect(source.requiresAuth).to.be.true; expect(source.requiresAuthInteraction).to.be.true; } finally { cleanup(); } });
it('Builds an authorize url with client id and redirect uri', function () { const { source, cleanup } = createAuthSource(); try { const url = new URL(source.createAuthUrl()!); expect(url.origin + url.pathname).to.equal('https://www.mixcloud.com/oauth/authorize'); expect(url.searchParams.get('client_id')).to.equal('cid'); expect(url.searchParams.get('redirect_uri')).to.equal('http://test/api/mixcloud/callback?name=Test'); } finally { cleanup(); } });
it('Authentication fails when no access token has been received', async function () { const { source, cleanup } = createAuthSource(); try { await source.buildInitData(); try { await source.testAuth(true); assert.fail('Expected auth to throw'); } catch (e) { expect(((e as Error).cause as Error).message).to.include('access token'); } } finally { cleanup(); } });
it('Exchanges an oauth code for a token, persists it, and resolves the user', withRequestInterception( [ http.get('https://www.mixcloud.com/oauth/access_token', ({ request }) => { const url = new URL(request.url); const expected = { client_id: 'cid', client_secret: 'secret', redirect_uri: 'http://test/api/mixcloud/callback?name=Test', code: 'thecode' }; for (const [key, value] of Object.entries(expected)) { if (url.searchParams.get(key) !== value) { return HttpResponse.json({ error: `unexpected ${key}` }, { status: 400 }); } } return HttpResponse.json({ access_token: 'tok123' }); }), http.get('https://api.mixcloud.com/me/', ({ request }) => { const url = new URL(request.url); if (url.searchParams.get('access_token') !== 'tok123') { return mixcloudJson({ error: { type: 'Test', message: 'unexpected access token' } }, { status: 400 }); } return mixcloudJson({ username: 'ProUser', name: 'Pro User' }); }) ], async function () { const { source, cleanup } = createAuthSource(); try { await source.buildInitData(); const result = await source.handleAuthCodeCallback({ code: 'thecode' }); expect(result).to.be.true; expect(source.accessToken).to.equal('tok123'); expect(source.authUsername).to.equal('ProUser'); const persisted = JSON.parse(readFileSync(source.workingCredsPath!, 'utf8')); expect(persisted).to.deep.equal({ token: 'tok123' }); } finally { cleanup(); } } ));
it('Returns the oauth error when the user denies access', async function () { const { source, cleanup } = createAuthSource(); try { const result = await source.handleAuthCodeCallback({ error: 'access_denied' }); expect(result).to.equal('access_denied'); } finally { cleanup(); } }); });
describe('Recently Played', function () {
it('Returns listens as history plays sorted oldest first', withRequestInterception( [ http.get('https://api.mixcloud.com/TestUser/listens/', ({ request }) => { const url = new URL(request.url); if (url.searchParams.get('limit') !== '20') { return mixcloudJson({ error: { type: 'Test', message: 'unexpected limit' } }, { status: 400 }); } return mixcloudJson(listensResponse); }) ], async function () { const source = createSource(); await source.buildInitData(); const plays = await source.getRecentlyPlayed();
expect(plays).to.have.length(2); assert.equal(plays[0].data.track?.name, 'Deep House Mix 2026'); assert.equal(plays[1].data.track?.name, 'Late Night Session #12'); for (const play of plays) { assert.equal(play.meta.parsedFrom, PARSED_FROM.history); } } ));
it('Returns no plays when listening history is empty', withRequestInterception( [ http.get('https://api.mixcloud.com/TestUser/listens/', () => mixcloudJson({ data: [], paging: {} })) ], async function () { const source = createSource(); await source.buildInitData(); const plays = await source.getRecentlyPlayed(); expect(plays).to.have.length(0); } ));
it('Reads listens from the authorized user with an access token', withRequestInterception( [ http.get('https://api.mixcloud.com/me/listens/', ({ request }) => { const url = new URL(request.url); if (url.searchParams.get('access_token') !== 'tok123') { return mixcloudJson({ error: { type: 'Test', message: 'unexpected access token' } }, { status: 400 }); } if (url.searchParams.get('limit') !== '20') { return mixcloudJson({ error: { type: 'Test', message: 'unexpected limit' } }, { status: 400 }); } return mixcloudJson(listensResponse); }) ], async function () { const { source, cleanup } = createAuthSource(); try { await source.buildInitData(); source.accessToken = 'tok123'; const plays = await source.getRecentlyPlayed(); expect(plays).to.have.length(2); assert.equal(plays[0].data.track?.name, 'Deep House Mix 2026'); } finally { cleanup(); } } )); });});