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.
15 kB · 330 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330import { before, afterEach, describe, it } from 'mocha';import chai, { expect } from 'chai';import asPromised from 'chai-as-promised';import withLocalTmpDir from 'with-local-tmp-dir';import {constants, copyFile, access} from 'node:fs/promises';import path from "path";import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts';import ScrobbleSources from '../../sources/ScrobbleSources.ts';import EventEmitter from "events";import { WildcardEmitter } from '../../common/WildcardEmitter.ts';import {loggerTest} from '@foxxmd/logging';import { clientTypes } from "../../../core/Atomic.ts";import { projectRootDir } from "../../common/infrastructure/Atomic.ts";import { sourceTypes } from "../../../core/Atomic.ts";import { difference } from '../../utils.ts';import { getSourceEnvSchema, validateSourceJson } from '../../common/infrastructure/config/source/sourcesMap.ts';import { readJson } from '../../utils/DataUtils.ts';import { prettifyError, ZodError } from 'zod';import { getClientEnvSchema, validateClientJson } from '../../common/infrastructure/config/client/clientsMap.ts';import type { MSBackendEventMap } from '../../common/infrastructure/MSBackendEventMap.ts';import { zocker } from "zocker";import { generateCommonComponentEnvConfigSchema } from '../../common/infrastructure/config/common.ts';import { serializeError } from 'serialize-error';import { httpUrl, wsUrl } from '../../utils/ZodUtils.ts';import { generateNormalHttpUrl, generateNormalWSUrl } from '../../../core/tests/utils/apiFixtures.ts';
chai.use(asPromised);
const samplePath = (name: string) => path.resolve(projectRootDir, 'config', `${name}.json.example`);
describe('Sample Configs', function () {
describe('Exist', function() { describe('Source Configs', function () { for(const componentType of sourceTypes) { it(`Sample ${componentType}.json exists`, async function () { await access(samplePath(componentType), constants.F_OK); }); }
}); describe('Client Configs', function () { for(const componentType of clientTypes) { it(`Sample ${componentType}.json exists`, async function () { await access(samplePath(componentType), constants.F_OK); }); } }); });
describe('Parse and Validate Correctly', function () {
describe('Source Configs', function () { let reset: any; let ogKeys: string[] = [];
before(function() { ogKeys = Object.keys(process.env); });
beforeEach(async function() { reset = await withLocalTmpDir({unsafeCleanup: true, postfix: 'sourceConfigParse'}); });
afterEach(async function() { await reset(); const envKeys = Object.keys(process.env); const addedKeys = difference(envKeys, ogKeys); for(const k of addedKeys) { delete process.env[k]; } });
for(const componentType of sourceTypes) {
//trueName = componentType; it(`Sample ${componentType}.json parses and validates in isolation`, async function () { this.timeout(5000);
await copyFile(samplePath(componentType), `${componentType}.json`);
let fileContents = await readJson(`${componentType}.json`); fileContents = fileContents.filter(x => x.configureAs === undefined || x.configureAs === 'source'); for (const [i,rawConf] of fileContents.entries()) { try { await validateSourceJson(componentType, rawConf); } catch (e) { if(e instanceof ZodError) { expect.fail(`Validation failed for config entry ${i}:\n${prettifyError(e)}`); } else { throw e; } } } });
it(`Sample ${componentType}.json parses and validates in ScrobbleSources`, async function () { this.timeout(5000);
const emitter = new WildcardEmitter<MSBackendEventMap>(); await copyFile(samplePath(componentType), `${componentType}.json`); const sources = new ScrobbleSources(emitter, { localUrl: new URL('http://example.com'), configDir: process.cwd(), version: 'test' }, loggerTest);
await sources.buildSourcesFromConfig(); expect(sources.sources).length(1); for(const s of sources.sources) { await s.destroy(); } });
it(`Sample ${componentType} ENV parses and validates in ScrobbleSources`, async function () { this.timeout(5000);
const emitter = new WildcardEmitter<MSBackendEventMap>(); const envSchema = await getSourceEnvSchema(componentType); let zocObj = zocker(envSchema.env) .supply(httpUrl, () => generateNormalHttpUrl()) .supply(wsUrl, () => generateNormalWSUrl()) switch(componentType) { case 'azuracast': zocObj = zocObj.supply(envSchema.env.shape.AZURA_LISTENERS_NUM, true) break; case 'rocksky': zocObj = zocObj.supply(envSchema.env.shape.SOURCE_ROCKSKY_HANDLE, 'foxxmd.dev') break; case 'applemusic': delete zocObj.schema.shape.APPLEMUSIC_KEY_ID; delete zocObj.schema.shape.APPLEMUSIC_TEAM_ID; delete zocObj.schema.shape.APPLEMUSIC_KEY_P8 zocObj = zocObj.supply(envSchema.env.shape.APPLEMUSIC_MEDIA_USER_TOKEN, '1234') .supply(envSchema.env.shape.APPLEMUSIC_TOKEN, '5678') .supply(envSchema.env.shape.APPLEMUSIC_ORIGIN_HEADER, 'https://apple.music.com'); } const componentMockData = zocObj.generate(); const primitives = generateCommonComponentEnvConfigSchema(envSchema.prefix.toLocaleUpperCase()); const primitiveMockData = zocker(primitives).generate(); primitiveMockData[`${envSchema.prefix.toLocaleUpperCase()}_ENABLE`] = true;
const mockData = { ...componentMockData, ...primitiveMockData }
for(const key of Object.keys(mockData)) { if(mockData[key] === undefined || mockData[key] === null) { continue; } process.env[key] = mockData[key].toString(); }
const sources = new ScrobbleSources(emitter, { localUrl: new URL('http://example.com'), configDir: process.cwd(), version: 'test' }, loggerTest); await sources.buildSourcesFromConfig() expect(sources.configErrors,`${sources.configErrors.map(x => typeof x === 'string' ? x : JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty; expect(sources.instantiateErrors, `${sources.instantiateErrors.map(x => JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty; expect(sources.sources).length(1); expect(sources.sources[0].type).eq(componentType); for(const s of sources.sources) { await s.destroy(); } }); } });
describe('Client Configs', function () { let reset: any; let ogKeys: string[] = [];
beforeEach(async function() { reset = await withLocalTmpDir({unsafeCleanup: true, postfix: 'clientConfigParse'}); });
afterEach(async function() { await reset(); const envKeys = Object.keys(process.env); const addedKeys = difference(envKeys, ogKeys); for(const k of addedKeys) { delete process.env[k]; } });
for(const componentType of clientTypes) {
it(`Sample ${componentType}.json parses and validates in isolation`, async function () { this.timeout(5000);
const emitter = new EventEmitter(); await copyFile(samplePath(componentType), `${componentType}.json`);
let fileContents = await readJson(`${componentType}.json`); fileContents = fileContents.filter(x => x.configureAs === undefined || x.configureAs === 'client'); for (const [i,rawConf] of fileContents.entries()) { try { await validateClientJson(componentType, rawConf); } catch (e) { if(e instanceof ZodError) { expect.fail(`Validation failed for config entry ${i}:\n${prettifyError(e)}`); } else { throw e; } } } });
it(`Sample ${componentType}.json parses and validates in ScrobbleClients`, async function () { this.timeout(5000);
const emitter = new WildcardEmitter<MSBackendEventMap>(); await copyFile(samplePath(componentType), `${componentType}.json`); const clients = new ScrobbleClients(emitter, new WildcardEmitter<MSBackendEventMap>, { localUrl: new URL('http://example.com'), configDir: process.cwd(), version: 'test' }, loggerTest); await clients.buildClientsFromConfig(); expect(clients.clients).length(1); });
it(`Sample ${componentType} ENV parses and validates in ScrobbleClients`, async function () { this.timeout(5000);
const emitter = new WildcardEmitter<MSBackendEventMap>(); const envSchema = await getClientEnvSchema(componentType); let zocObj = zocker(envSchema.env) .supply(httpUrl, () => generateNormalHttpUrl()) .supply(wsUrl, () => generateNormalWSUrl()) switch(componentType) { case 'rocksky': zocObj = zocObj.supply(envSchema.env.shape.ROCKSKY_HANDLE, 'foxxmd.dev') zocObj = zocObj.supply(envSchema.env.shape.ROCKSKY_TOKEN, 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQ') break; case 'discord': zocObj = zocObj.supply(envSchema.env.shape.DISCORD_STATUS_OVERRIDE_ALLOW, 'online'); zocObj = zocObj.supply(envSchema.env.shape.DISCORD_IPC_LOCATIONS, '/path/to/ipc,192.168.0.100:8999') } const componentMockData = zocObj.generate(); const primitives = generateCommonComponentEnvConfigSchema(envSchema.prefix.toLocaleUpperCase()); const primitiveMockData = zocker(primitives).generate(); primitiveMockData[`${envSchema.prefix.toLocaleUpperCase()}_ENABLE`] = true;
const mockData = { ...componentMockData, ...primitiveMockData }
for(const key of Object.keys(mockData)) { if(mockData[key] === undefined || mockData[key] === null) { continue; } process.env[key] = mockData[key].toString(); }
const clients = new ScrobbleClients(emitter, new WildcardEmitter<MSBackendEventMap>, { localUrl: new URL('http://example.com'), configDir: process.cwd(), version: 'test' }, loggerTest); await clients.buildClientsFromConfig() expect(clients.configErrors,`${clients.configErrors.map(x => typeof x === 'string' ? x : JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty; expect(clients.instantiateErrors, `${clients.instantiateErrors.map(x => JSON.stringify(serializeError(x), undefined, 2)).join('\n')}`).is.empty; expect(clients.clients).length(1); expect(clients.clients[0].type).eq(componentType); }); } }); });});
describe('Global ENVs with Config', function () {
let baseEnvKeys: string[] = [];
before(function() { baseEnvKeys = Array.from(Object.keys(process.env)); });
afterEach(function() { const modified = difference(Array.from(Object.keys(process.env)), baseEnvKeys); for(const key of modified) { delete process.env[key]; } });
it('Parses default scrobble duration', async function () { process.env.SOURCE_SCROBBLE_DURATION = '20'; process.env.MPRIS_ID = 'test'; process.env.MPRIS_ENABLE = 'true';
const emitter = new WildcardEmitter<MSBackendEventMap>(); const sources = new ScrobbleSources(emitter, { localUrl: new URL('http://example.com'), configDir: process.cwd(), version: 'test' }, loggerTest); await sources.buildSourcesFromConfig();
expect(sources.sources).length(1); expect(sources.sources[0].config?.options?.scrobbleThresholds?.duration).to.eq(20); });
it('Parses default scrobble precentage', async function () { process.env.SOURCE_SCROBBLE_PERCENT = '20'; process.env.MPRIS_ID = 'test'; process.env.MPRIS_ENABLE = 'true';
const emitter = new WildcardEmitter<MSBackendEventMap>(); const sources = new ScrobbleSources(emitter, { localUrl: new URL('http://example.com'), configDir: process.cwd(), version: 'test' }, loggerTest); await sources.buildSourcesFromConfig();
expect(sources.sources).length(1); expect(sources.sources[0].config?.options?.scrobbleThresholds?.percent).to.eq(20); });
});