[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: Separate config and data directories and use OS-standard paths, if present

* Break configDir into config and data directories so config and database/cache can be stored in different directories
* Use `env-paths` to fallback to OS-standard paths for config and data if CONFIG_DIR and DATA_DIR envs are not present
* Additionally fallback to process CWD/config if none of the above are present for some reason

#616

FoxxMD (Jul 16, 2026, 4:14 PM EDT) 26121ace 27e5656a

+51 -35
+1
Dockerfile
··· 56 56 ARG data_dir=/config 57 57 VOLUME $data_dir 58 58 ENV CONFIG_DIR=$data_dir 59 + ENV DATA_DIR=$data_dir 59 60 60 61 COPY docker/root / 61 62
+2 -2
drizzle.config.ts
··· 1 1 import 'dotenv/config'; 2 2 import { defineConfig } from 'drizzle-kit'; 3 - import { configDir } from './src/backend/common/index.js'; 3 + import { getDataDir } from './src/backend/common/index.js'; 4 4 import * as path from 'path'; 5 5 import { projectRootDir } from './src/core/Atomic.ts'; 6 6 ··· 9 9 out: path.resolve(projectRootDir, 'src/backend/common/database/drizzle/migrations'), 10 10 dialect: 'sqlite', 11 11 dbCredentials: { 12 - url: path.resolve(configDir, 'ms.db'), 12 + url: path.resolve(getDataDir(), 'ms.db'), 13 13 }, 14 14 });
+7 -7
src/backend/common/Cache.ts
··· 10 10 import utc from 'dayjs/plugin/utc.js'; 11 11 import clone from 'clone'; 12 12 import { childLogger, type Logger } from '@foxxmd/logging'; 13 - import { projectDir } from './index.ts'; 13 + import { getConfigDir } from './index.ts'; 14 14 import path from 'path'; 15 15 import { cacheFunctions } from "@foxxmd/regex-buddy-core"; 16 16 import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.ts'; ··· 18 18 import { Typeson } from 'typeson'; 19 19 import { builtin } from 'typeson-registry'; 20 20 import { loggerNoop } from './MaybeLogger.ts'; 21 - const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); 22 21 import type { Gauge } from 'prom-client'; 23 22 import prom from 'prom-client'; 24 23 import { nonEmptyStringOrDefault } from '../../core/StringUtils.ts'; ··· 277 276 throw e; 278 277 } 279 278 } 279 + const confDir = getConfigDir(); 280 280 if (config.provider === 'file') { 281 - logger.debug(`Building file cache from ${path.join(config.connection ?? configDir, `${namespace}.cache`)}`); 281 + logger.debug(`Building file cache from ${path.join(config.connection ?? confDir, `${namespace}.cache`)}`); 282 282 283 283 try { 284 - const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection ?? configDir, cacheId: `${namespace}.cache` }, {ttl: config.ttl}, logger); 284 + const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection ?? confDir, cacheId: `${namespace}.cache` }, {ttl: config.ttl}, logger); 285 285 return keyvFile; 286 286 } catch (e) { 287 287 throw e; ··· 538 538 // } = {}, 539 539 scrobble: { 540 540 provider: sProvider = (process.env.CACHE_SCROBBLE as (CacheScrobbleProvider | undefined) ?? 'file'), 541 - connection = (process.env.CACHE_SCROBBLE_CONN ?? configDir), 541 + connection = (process.env.CACHE_SCROBBLE_CONN ?? getConfigDir()), 542 542 ...restScrobble 543 543 } = {}, 544 544 auth: { ··· 563 563 if(authProvider === 'valkey') { 564 564 if(valkey === undefined) { 565 565 logger.warn(`Auth Provider set to 'valkey' but not valkey connection string was not provided, falling back to file.`); 566 - authConn = configDir; 566 + authConn = getConfigDir(); 567 567 authProvider = 'file'; 568 568 } else { 569 569 authConn = valkey; ··· 572 572 if(authProvider !== 'file') { 573 573 logger.warn(`Unsupported provider given for auth: ${authProvider}`); 574 574 } 575 - authConn = configDir; 575 + authConn = getConfigDir(); 576 576 authProvider = 'file'; 577 577 } 578 578
+2 -2
src/backend/common/database/Database.ts
··· 1 - import { configDir } from '../index.ts'; 1 + import { getDataDir } from '../index.ts'; 2 2 import * as path from 'path'; 3 3 import { childLogger, type Logger } from '@foxxmd/logging'; 4 4 import { loggerNoop } from '../MaybeLogger.ts'; ··· 19 19 if (isMemoryDb(name)) { 20 20 return MEMORY_DB_NAME; 21 21 } 22 - return path.resolve(workingDirectory ?? configDir, `${name}.db`); 22 + return path.resolve(workingDirectory ?? getDataDir(), `${name}.db`); 23 23 } 24 24 25 25 export const getDbBackupPath = (dbPath: string, suffix?: string): string => {
+24 -4
src/backend/common/index.ts
··· 1 1 import * as path from 'path'; 2 + import envPaths from 'env-paths'; 2 3 3 - //const __filename = fileURLToPath(import.meta.url); 4 - //const __dirname = path.dirname(__filename); 4 + const osPaths = envPaths('multi-scrobbler', {suffix: ''}); 5 5 6 - export const projectDir = process.cwd(); //path.resolve(__dirname, '../../../'); 7 - export const configDir: string = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); 6 + export const getConfigDir = (): string => { 7 + let configDirVal: string = process.env.CONFIG_DIR ?? osPaths.config; 8 + // this shouldn't happen... 9 + // but if it does we need to have some known path fallback so things don't explode 10 + if(configDirVal === undefined) { 11 + configDirVal = getPathFromCWD('./config'); // backwards compatibility 12 + } 13 + // resolve from relative directory, if one was used 14 + return path.resolve(configDirVal); 15 + } 16 + 17 + export const getDataDir = (): string => { 18 + let dataDirVal: string = process.env.DATA_DIR ?? osPaths.data; 19 + // this shouldn't happen... 20 + // but if it does we need to have some known path fallback so things don't explode 21 + if(dataDirVal === undefined) { 22 + dataDirVal = getPathFromCWD('./config'); // defaulting to same directory for backwards compatibility 23 + } 24 + // resolve from relative directory, if one was used 25 + return path.resolve(dataDirVal); 26 + } 27 + 8 28 export const getPathFromCWD = (...relativePaths: string[]) => path.resolve(process.cwd(), ...relativePaths);
+6 -9
src/backend/common/logging.ts
··· 4 4 import { PassThrough } from "node:stream"; 5 5 import path from "path"; 6 6 import process from "process"; 7 - import { projectDir } from "./index.ts"; 7 + import { getDataDir } from "./index.ts"; 8 8 import { isDebugMode } from '../utils.ts'; 9 9 10 - export let logPath = path.resolve(projectDir, `./logs`); 11 - if (typeof process.env.CONFIG_DIR === 'string') { 12 - logPath = path.resolve(process.env.CONFIG_DIR, './logs'); 13 - } 10 + const logPath = path.resolve(getDataDir(), `./logs`); 14 11 15 12 export const initLogger = (): [Logger, Transform] => { 16 13 const opts = parseLogOptions({file: false, console: 'trace'}) ··· 27 24 const { file } = config; 28 25 const opts = parseLogOptions(isDebugMode() ? {...config, file: typeof file === 'object' ? {...file, level: 'trace'} : 'trace', console: 'trace', level: 'trace'} : config); 29 26 const logger = await loggerAppRolling(opts, { 30 - logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined, 31 - logDefaultPath: './logs/scrobble.log', 27 + logBaseDir: logPath, 28 + logDefaultPath: './scrobble.log', 32 29 destinations: [ 33 30 buildDestinationJsonPrettyStream('trace', {destination: stream, object: true, colorize: true}) 34 31 ] ··· 38 35 39 36 export const componentFileLogger = async (type: string, name: string, fileConfig: true | LogLevel | FileLogOptions, config: LogOptions = {}): Promise<Logger> => { 40 37 const opts = parseLogOptions(config, { 41 - logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined, 42 - logDefaultPath: './logs/scrobble.log' 38 + logBaseDir: logPath, 39 + logDefaultPath: './scrobble.log' 43 40 }); 44 41 45 42 const base = path.dirname(typeof opts.file.path === 'function' ? opts.file.path() : opts.file.path);
+5 -5
src/backend/index.ts
··· 8 8 import timezone from 'dayjs/plugin/timezone.js'; 9 9 import week from 'dayjs/plugin/weekOfYear.js'; 10 10 import utc from 'dayjs/plugin/utc.js'; 11 - import * as path from "path"; 12 11 import { SimpleIntervalJob, ToadScheduler } from "toad-scheduler"; 13 - import { projectDir } from "./common/index.ts"; 12 + import { getConfigDir, getDataDir } from "./common/index.ts"; 14 13 import type {AIOConfig} from "./common/infrastructure/config/aioConfig.ts"; 15 14 import { appLogger, initLogger as getInitLogger } from "./common/logging.ts"; 16 15 import { getRoot } from "./ioc.ts"; ··· 87 86 }) 88 87 89 88 90 - const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); 89 + const configDir = getConfigDir() 91 90 92 91 try { 93 - initLogger.verbose(`Config Dir ENV: ${process.env.CONFIG_DIR} -> Resolved: ${configDir}`) 92 + initLogger.verbose(`Config Dir ENV : ${process.env.CONFIG_DIR} -> Resolved: ${configDir}`); 93 + initLogger.verbose(`Data Dir ENV : ${process.env.DATA_DIR} -> Resolved: ${getDataDir()}`); 94 94 // try to read a configuration file 95 95 let appConfigFail: Error | undefined = undefined; 96 96 let config = {}; ··· 127 127 128 128 const dbPath = getDbPath('ms'); 129 129 logger.info(`Using database at ${db}`); 130 - const [migratedDb, isNew] = await getMigratedDb(dbPath, {logger}); 130 + const [migratedDb, _] = await getMigratedDb(dbPath, {logger}); 131 131 db = migratedDb; 132 132 133 133 const root = getRoot({
+2 -4
src/backend/ioc.ts
··· 1 1 import { type Logger, loggerDebug, type LogOptions } from "@foxxmd/logging"; 2 2 import { EventEmitter } from "events"; 3 3 import { createContainer } from "iti"; 4 - import path from "path"; 5 - import { projectDir } from "./common/index.ts"; 4 + import { getConfigDir } from "./common/index.ts"; 6 5 import { WildcardEmitter } from "./common/WildcardEmitter.ts"; 7 6 8 7 import { generateBaseURL } from "./utils/NetworkUtils.ts"; ··· 64 63 db, 65 64 transformers = [] 66 65 } = options || {}; 67 - const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); 68 66 let disableWeb = dw; 69 67 if(disableWeb === undefined) { 70 68 disableWeb = process.env.DISABLE_WEB === 'true'; ··· 131 129 132 130 return createContainer().add({ 133 131 version, 134 - configDir: configDir, 132 + configDir: getConfigDir(), 135 133 isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'), 136 134 // @ts-ignore 137 135 port: (Number.isInteger(portVal) ? portVal : Number.parseInt(portVal)) as number,
+2 -2
src/backend/tests/tealfm/tealfm.test.ts
··· 9 9 import TealScrobbler from '../../scrobblers/TealfmScrobbler.ts'; 10 10 import { EventEmitter } from "events"; 11 11 import path from 'node:path'; 12 - import { configDir } from '../../common/index.ts'; 12 + import { getConfigDir } from '../../common/index.ts'; 13 13 import { loggerDebug } from '@foxxmd/logging'; 14 14 15 15 chai.use(asPromised); ··· 115 115 ); 116 116 await tfm.buildDatabase(); 117 117 118 - await tfm.parseScrobblesFromCar(path.resolve(configDir, 'tealfm-myteal-1778870858.car'), 100); 118 + await tfm.parseScrobblesFromCar(path.resolve(getConfigDir(), 'tealfm-myteal-1778870858.car'), 100); 119 119 }); 120 120 });