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.
11 kB · 290 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291import 'dotenv/config';import { childLogger, type LogDataPretty, type Logger as FoxLogger } from "@foxxmd/logging";import dayjs from 'dayjs';import duration from 'dayjs/plugin/duration.js';import isBetween from 'dayjs/plugin/isBetween.js';import relativeTime from 'dayjs/plugin/relativeTime.js';import isToday from 'dayjs/plugin/isToday.js';import timezone from 'dayjs/plugin/timezone.js';import week from 'dayjs/plugin/weekOfYear.js';import utc from 'dayjs/plugin/utc.js';import { SimpleIntervalJob, ToadScheduler } from "toad-scheduler";import { getConfigDir, getDataDir } from "./common/index.ts";import type {AIOConfig} from "./common/infrastructure/config/aioConfig.ts";import { appLogger, initLogger as getInitLogger } from "./common/logging.ts";import { getRoot } from "./ioc.ts";import { parseVersion } from "./version.ts";import { initServer } from "./server/index.ts";import { isDebugMode, parseBool, sleep } from "./utils.ts";import { readJson } from './utils/DataUtils.ts';import ScrobbleClients from './scrobblers/ScrobbleClients.ts';import ScrobbleSources from './sources/ScrobbleSources.ts';import { Notifiers } from './notifier/Notifiers.ts';import { type DbConcrete, getMigratedDb } from './common/database/drizzle/drizzleUtils.ts';import { getDbPath } from './common/database/Database.ts';import { createRetentionCleanupTask } from './tasks/retentionCleanup.ts';import { parseUserConfig } from './common/Cache.ts';import { nonEmptyStringOrDefault } from '../core/StringUtils.ts';import { createDir, fileExists } from './utils/FSUtils.ts';
dayjs.extend(utc)dayjs.extend(isBetween);dayjs.extend(relativeTime);dayjs.extend(duration);dayjs.extend(timezone);dayjs.extend(isToday);dayjs.extend(week);
// eslint-disable-next-line prefer-arrow-functions/prefer-arrow-functions(async function () {
const scheduler = new ToadScheduler()
let output: LogDataPretty[] = []
const [parentInitLogger, initLoggerStream] = getInitLogger();const initLogger = childLogger(parentInitLogger, 'Init');initLoggerStream.on('data', (log: LogDataPretty) => {output.unshift(log);output = output.slice(0, 301);});
let logger: FoxLogger;
let db: DbConcrete;const dbConnectionsClosed = false;
process.on('uncaughtExceptionMonitor', (err, origin) => { const appError = new Error(`Uncaught exception is crashing the app! :( Type: ${origin}`, {cause: err}); if(logger !== undefined) { logger.error(appError) } else { initLogger.error(appError); } if(!dbConnectionsClosed) { const parts = []; if(db !== undefined && !db.$client.isOpen) { db.$client.close(); parts.push('Database'); } if(parts.length > 0 && logger !== undefined) { logger.info(`Closed ${parts.join(' and ')}`); } }});process.on('SIGINT', async () => { if(!dbConnectionsClosed) { const parts = []; if(db !== undefined && !db.$client.isOpen) { db.$client.close(); parts.push('Database'); } if(parts.length > 0 && logger !== undefined) { logger.info(`Closed ${parts.join(' and ')}`); } } process.exit(0);})
const configDir = getConfigDir();const dataDir = getDataDir();
try { initLogger.info(`Config Dir ENV : ${process.env.CONFIG_DIR} -> Resolved: ${configDir}`); try { const exists = fileExists(configDir); if(!exists) { initLogger.verbose(`Config Dir does not exist, creating now...`); await createDir(configDir); } } catch (e) { initLogger.warn(new Error('Could not access config dir. It is likely your config files will not be able to be read.', {cause: e})); } initLogger.info(`Data Dir ENV : ${process.env.DATA_DIR} -> Resolved: ${getDataDir()}`); try { const exists = fileExists(dataDir); if(!exists) { initLogger.verbose(`Data Dir does not exist, creating now...`); await createDir(dataDir); } } catch (e) { initLogger.warn(new Error('Could not access data dir. It is likely your data files will not be able to be read.', {cause: e})); } // try to read a configuration file let appConfigFail: Error | undefined = undefined; let config = {}; try { config = await readJson(`${configDir}/config.json`, {throwOnNotFound: false, logger: childLogger(initLogger, 'Secrets')}); if(config === undefined) { initLogger.verbose(`No AIO config found at ${configDir}/config.json`); } } catch (e) { appConfigFail = e; }
const { webhooks = [], logging = {}, debugMode, cache, } = (config || {}) as AIOConfig;
if (process.env.DEBUG_MODE === undefined && debugMode !== undefined) { process.env.DEBUG_MODE = debugMode.toString(); } if(process.env.DEBUG_MODE !== undefined) { // make sure value is legit const b = parseBool(process.env.DEBUG_MODE); process.env.DEBUG_MODE = b.toString(); }
initLogger.info(`Debug Mode: ${isDebugMode() ? 'YES' : 'NO'}`);
const version = await parseVersion();
initLogger.info(`Version: ${version}`);
const [aLogger, appLoggerStream] = await appLogger(logging) logger = childLogger(aLogger, 'App'); const dbPath = getDbPath('ms'); logger.info(`Using database at ${db}`); const [migratedDb, _] = await getMigratedDb(dbPath, {logger}); db = migratedDb;
const root = getRoot({ ...config, cache: parseUserConfig(cache, logger), logger, loggingConfig: logging, loggerStream: appLoggerStream, db });
const internalConfigOptional = { localUrl: root.get('localUrl'), configDir: root.get('configDir'), version: root.get('version') };
const scrobbleClients = new ScrobbleClients(root.get('clientEmitter'), root.get('sourceEmitter'), internalConfigOptional, root.get('logger')); const scrobbleSources = new ScrobbleSources(root.get('sourceEmitter'), internalConfigOptional, root.get('logger'));
await root.items.cache().init(true);
initServer({sources: scrobbleSources, clients: scrobbleClients},{logger, appLoggerStream, initialOutput: output});
if(process.env.IS_LOCAL === 'true') { logger.info('multi-scrobbler can be run as a background service! See: https://docs.multi-scrobbler.app/installation/service'); }
if(appConfigFail !== undefined) { logger.warn('App config file exists but could not be parsed!'); logger.warn(appConfigFail); }
const notifiers = new Notifiers(root.get('notifierEmitter'), root.get('clientEmitter'), root.get('sourceEmitter'), root.get('logger')); //root.get('notifiers'); await notifiers.buildWebhooks(webhooks);
await root.items.transformerManager.registerFromEnv();
/* * setup clients * */ await scrobbleClients.buildClientsFromConfig(); /* * setup sources * */ await scrobbleSources.buildSourcesFromConfig();
// check ambiguous client/source types like this for now const lastfmSources = scrobbleSources.getByType('lastfm'); const lastfmScrobbles = scrobbleClients.getByType('lastfm');
const scrobblerNames = lastfmScrobbles.map(x => x.name); const nameColl = lastfmSources.filter(x => scrobblerNames.includes(x.name)); if(nameColl.length > 0) { logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`); } const initOptions = {deadDelay: nonEmptyStringOrDefault(process.env.DEBUG_DEAD_DELAY, undefined) !== undefined ? Number.parseInt(process.env.DEBUG_DEAD_DELAY) : undefined}; for(const c of scrobbleClients.clients) { c.initTasks(initOptions); const res = await Promise.race([ sleep(2200), (async () => { while(!c.isReady()) { await sleep(400) } return true; })() ]); if(res === undefined) { logger.debug(`Not waiting for Client ${c.name} to finish init, moving on to the next Client...`); } }
for(const c of scrobbleSources.sources) { c.initTasks(initOptions); const res = await Promise.race([ sleep(2200), (async () => { while(!c.isReady()) { await sleep(400) } return true; })() ]); if(res === undefined) { logger.debug(`Not waiting for Source ${c.name} to finish init, moving on to the next Source...`); } }
const runRetentionNow = parseBool(process.env.RETENTION_IMMEDIATE, false);
const retentionTask = createRetentionCleanupTask(scrobbleSources, scrobbleClients, logger); let retentionJobAdded = false; const addJob = () => { retentionJobAdded = true; scheduler.addSimpleIntervalJob(new SimpleIntervalJob({ minutes: 60, runImmediately: runRetentionNow }, retentionTask, {id: 'retention', preventOverrun: true})); logger.debug('Added Retention Cleanup task to scheduler'); }; logger.debug('Added Client Heartbeat task to scheduler');
if(runRetentionNow === false || (scrobbleClients.clients.every(x => x.isReady()) && scrobbleSources.sources.every(x => x.isReady()))) { addJob(); }
logger.info('Scheduler started.');
if(runRetentionNow === true && !retentionJobAdded) { logger.info('Detected that Retention Cleanup should run immediately but all sources/clients have not started yet! Delaying retention cleanup by 1 minute to allow all sources/clients to finish starting.'); await sleep(60 * 1000); addJob(); }
} catch (e) { const appError = new Error('Exited with uncaught error', {cause: e}); if(logger !== undefined) { logger.error(appError); } else { initLogger.error(appError); } if(!dbConnectionsClosed) { const parts = []; if(db !== undefined && !db.$client.isOpen) { db.$client.close(); parts.push('Database'); } if(parts.length > 0 && logger !== undefined) { logger.info(`Closed ${parts.join(' and ')}`); } } process.exit(1); }}());