diff --git a/drizzle.config.ts b/drizzle.config.ts index bb1c4171..4ca638ee 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,11 +1,12 @@ import 'dotenv/config'; import { defineConfig } from 'drizzle-kit'; -import { configDir, projectDir } from './src/backend/common/index.js'; +import { configDir } from './src/backend/common/index.js'; import * as path from 'path'; +import { projectRootDir } from './src/core/Atomic.ts'; export default defineConfig({ - schema: path.resolve(projectDir, 'src/backend/common/database/drizzle/schema'), - out: path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations'), + schema: path.resolve(projectRootDir, 'src/backend/common/database/drizzle/schema'), + out: path.resolve(projectRootDir, 'src/backend/common/database/drizzle/migrations'), dialect: 'sqlite', dbCredentials: { url: path.resolve(configDir, 'ms.db'), diff --git a/package-lock.json b/package-lock.json index d7bb04ec..e76dd202 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "discord.js": "^14.26.0", "dotenv": "^10.0.0", "drizzle-orm": "^1.0.0-rc.2-c5a84d1", + "env-paths": "^4.0.0", "express": "^5.2.1", "express-session": "^1.19.0", "fast-equals": "^6.0.0", @@ -10176,6 +10177,21 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", + "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", + "license": "MIT", + "dependencies": { + "is-safe-filename": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -13025,6 +13041,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-safe-filename": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", + "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", diff --git a/package.json b/package.json index d7c37635..16b75814 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "discord.js": "^14.26.0", "dotenv": "^10.0.0", "drizzle-orm": "^1.0.0-rc.2-c5a84d1", + "env-paths": "^4.0.0", "express": "^5.2.1", "express-session": "^1.19.0", "fast-equals": "^6.0.0", diff --git a/src/backend/common/database/appMigrator.ts b/src/backend/common/database/appMigrator.ts index 3dd233be..8809cfbb 100644 --- a/src/backend/common/database/appMigrator.ts +++ b/src/backend/common/database/appMigrator.ts @@ -2,9 +2,9 @@ import type {DbConcrete} from "./drizzle/drizzleUtils.ts"; import { loggerNoop } from "../MaybeLogger.ts"; import * as path from 'path'; import { childLogger, type Logger } from "@foxxmd/logging"; -import { projectDir } from "../index.ts"; import { Migrator } from 'sqlite-up'; import type {MigrationStatus} from "../infrastructure/Atomic.ts"; +import { projectRootDir } from "../../../core/Atomic.ts"; export interface MigrateBaseContext { db: DbConcrete @@ -16,7 +16,7 @@ const migrationsTable = '__app_migrations'; export const getAppMigrationStatus = async (db: DbConcrete, opts: {logger?: Logger, migrationsAppFolder?: string} = {}): Promise => { const { logger: parentLogger = loggerNoop, - migrationsAppFolder = path.resolve(projectDir, 'src/backend/common/database/appMigrations') + migrationsAppFolder = path.resolve(projectRootDir, 'src/backend/common/database/appMigrations') } = opts; const logger = childLogger(parentLogger, 'App Migrations'); @@ -40,7 +40,7 @@ export const getAppMigrationStatus = async (db: DbConcrete, opts: {logger?: Logg export const migrateApp = async (db: DbConcrete, opts: {logger?: Logger, migrationsAppFolder?: string} = {}): Promise => { const { logger: parentLogger = loggerNoop, - migrationsAppFolder = path.resolve(projectDir, 'src/backend/common/database/appMigrations') + migrationsAppFolder = path.resolve(projectRootDir, 'src/backend/common/database/appMigrations') } = opts; const logger = childLogger(parentLogger, ['App', 'Migrations']); diff --git a/src/backend/common/database/drizzle/drizzleUtils.ts b/src/backend/common/database/drizzle/drizzleUtils.ts index 86986342..46252f22 100644 --- a/src/backend/common/database/drizzle/drizzleUtils.ts +++ b/src/backend/common/database/drizzle/drizzleUtils.ts @@ -8,16 +8,16 @@ import { backupDb, getDbBackupPath, MEMORY_DB_NAME } from '../Database.ts'; import { fileExists, fileOrDirectoryIsWriteable } from '../../../utils/FSUtils.ts'; import { childLogger, type Logger, type LogLevel } from '@foxxmd/logging'; import { loggerNoop } from '../../MaybeLogger.ts'; -import { projectDir } from '../../index.ts'; import { relations } from './schema/schema.ts'; import { addToContext, executeQuery } from './logContext.ts'; import { migrateApp, getAppMigrationStatus } from '../appMigrator.ts'; import type {MigrationStatus} from '../../infrastructure/Atomic.ts'; +import { projectRootDir } from '../../../../core/Atomic.ts'; export async function getDbMigrationStatus(dbVal: string | DbConcrete, opts: {logger?: Logger, migrationsFolder?: string} = {}): Promise { const { logger: parentLogger = loggerNoop, - migrationsFolder = path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations') + migrationsFolder = path.resolve(projectRootDir, 'src/backend/common/database/drizzle/migrations') } = opts; const logger = childLogger(parentLogger, 'Migrations'); @@ -91,7 +91,7 @@ export const migrateDb = async (db: DbConcrete, opts: {logger?: Logger, migratio try { logger.info('Starting migrations...'); - await executeQuery('migrations', async () => migrate(db, { migrationsFolder: migrationsFolder ?? path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations') }), logger, process.env.LOG_MIGRATION === 'true' ? true : 'error'); + await executeQuery('migrations', async () => migrate(db, { migrationsFolder: migrationsFolder ?? path.resolve(projectRootDir, 'src/backend/common/database/drizzle/migrations') }), logger, process.env.LOG_MIGRATION === 'true' ? true : 'error'); logger.info('Migrations complete'); } catch (e) { throw new Error('Failed to migrate database', { cause: e }); @@ -107,7 +107,7 @@ export const migrateDbSync = (db: ReturnType, opts: {logger?: Lo try { logger.info('Starting migrations...'); - migrate(db, { migrationsFolder: migrationsFolder ?? path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations') }); + migrate(db, { migrationsFolder: migrationsFolder ?? path.resolve(projectRootDir, 'src/backend/common/database/drizzle/migrations') }); logger.info('Migrations complete'); } catch (e) { throw new Error('Failed to migrate database', { cause: e }); diff --git a/src/backend/common/index.ts b/src/backend/common/index.ts index 234de76d..3124a3c0 100644 --- a/src/backend/common/index.ts +++ b/src/backend/common/index.ts @@ -1,8 +1,8 @@ import * as path from 'path'; -//import {fileURLToPath} from "url"; //const __filename = fileURLToPath(import.meta.url); //const __dirname = path.dirname(__filename); export const projectDir = process.cwd(); //path.resolve(__dirname, '../../../'); export const configDir: string = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); +export const getPathFromCWD = (...relativePaths: string[]) => path.resolve(process.cwd(), ...relativePaths); \ No newline at end of file diff --git a/src/backend/server/index.ts b/src/backend/server/index.ts index 74e8b106..052eea45 100644 --- a/src/backend/server/index.ts +++ b/src/backend/server/index.ts @@ -8,14 +8,13 @@ import passport from 'passport'; import path from "path"; import qs from 'qs'; import ViteExpress from "vite-express"; -import { projectDir } from "../common/index.ts"; import { getRoot } from "../ioc.ts"; import { parseBool } from "../utils.ts"; import { getAddress } from "../utils/NetworkUtils.ts"; import { setupApi } from "./api.ts"; import type ScrobbleSources from '../sources/ScrobbleSources.ts'; import type ScrobbleClients from '../scrobblers/ScrobbleClients.ts'; -import { qsOptions } from '../../core/Atomic.ts'; +import { projectRootDir, qsOptions } from '../../core/Atomic.ts'; const app = express(); const router = Router(); @@ -79,7 +78,7 @@ export const initServer = async (parentLogger: Logger, appLoggerStream: PassThro } } - app.use('/docs', express.static(path.resolve(projectDir, `./docsite/build`))); + app.use('/docs', express.static(path.resolve(projectRootDir, `./docsite/build`))); if(process.env.USE_HASH_ROUTER === undefined) { process.env.USE_HASH_ROUTER = root.get('isSubPath').toString(); diff --git a/src/backend/tests/config/config.test.ts b/src/backend/tests/config/config.test.ts index 16a9f720..f7e71940 100644 --- a/src/backend/tests/config/config.test.ts +++ b/src/backend/tests/config/config.test.ts @@ -4,19 +4,18 @@ 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 {projectDir} from '../../common/index.ts'; import ScrobbleClients from '../../scrobblers/ScrobbleClients.ts'; import ScrobbleSources from '../../sources/ScrobbleSources.ts'; import EventEmitter from "events"; import {loggerTest} from '@foxxmd/logging'; -import { clientTypes } from "../../../core/Atomic.ts"; +import { clientTypes, projectRootDir } from "../../../core/Atomic.ts"; import { sourceTypes } from "../../../core/Atomic.ts"; import { Notifiers } from '../../notifier/Notifiers.ts'; import { difference } from '../../utils.ts'; chai.use(asPromised); -const samplePath = (name: string) => path.resolve(projectDir, 'config', `${name}.json.example`); +const samplePath = (name: string) => path.resolve(projectRootDir, 'config', `${name}.json.example`); describe('Sample Configs', function () { diff --git a/src/backend/tests/database/drizzle.test.ts b/src/backend/tests/database/drizzle.test.ts index a6a50009..dab36e0a 100644 --- a/src/backend/tests/database/drizzle.test.ts +++ b/src/backend/tests/database/drizzle.test.ts @@ -8,7 +8,6 @@ import { getDbPath } from '../../common/database/Database.ts'; import { x } from 'tinyexec'; import * as path from 'path'; import * as fs from 'fs/promises'; -import { projectDir } from '../../common/index.ts'; import { fixtureCreateComponent, fixtureCreateInput, fixtureCreatePlay } from '../utils/databaseFixtures.ts'; import { DrizzlePlayRepository, type RepositoryCreatePlayOpts } from '../../common/database/drizzle/repositories/PlayRepository.ts'; import { generatePlayWithLifecycle, generateRandomObj } from '../../../core/tests/utils/fixtures.ts'; @@ -20,6 +19,7 @@ import { transientDb } from '../utils/TransientTestUtils.ts'; import { getRoot } from '../../ioc.ts'; import { after } from 'mocha'; import { migrateApp } from '../../common/database/appMigrator.ts'; +import { projectRootDir } from '../../../core/Atomic.ts'; // would be great to push migrations directly from schema but doesn't seem supported in newest beta // https://github.com/drizzle-team/drizzle-orm/discussions/4373 @@ -50,7 +50,7 @@ describe('Migrations', function () { it('Detects pending migrations', async function () { - const allFiles = await fs.readdir(path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations')); + const allFiles = await fs.readdir(path.resolve(projectRootDir, 'src/backend/common/database/drizzle/migrations')); const migrationFiles = allFiles .sort(); @@ -59,7 +59,7 @@ describe('Migrations', function () { // copy first migration await fs.mkdir('migrations'); try { - await fs.cp(path.resolve(projectDir, `src/backend/common/database/drizzle/migrations/${migrationFiles[0]}`), path.resolve('./migrations/', migrationFiles[0]), { recursive: true }); + await fs.cp(path.resolve(projectRootDir, `src/backend/common/database/drizzle/migrations/${migrationFiles[0]}`), path.resolve('./migrations/', migrationFiles[0]), { recursive: true }); const mf = path.resolve('./migrations'); const db = await getDb(':memory:'); await migrateDb(db, { migrationsFolder: mf }); @@ -71,7 +71,7 @@ describe('Migrations', function () { `${mf}`, '--custom', '--schema', - path.resolve(projectDir, 'src/backend/common/database/drizzle/schema'), + path.resolve(projectRootDir, 'src/backend/common/database/drizzle/schema'), '--dialect', 'sqlite' ], {throwOnError: true}); @@ -88,7 +88,7 @@ describe('Migrations', function () { it('Detects no pending migrations correctly', async function () { - const allFiles = await fs.readdir(path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations')); + const allFiles = await fs.readdir(path.resolve(projectRootDir, 'src/backend/common/database/drizzle/migrations')); const migrationFiles = allFiles .sort(); @@ -97,7 +97,7 @@ describe('Migrations', function () { // copy first migration await fs.mkdir('migrations'); try { - await fs.cp(path.resolve(projectDir, `src/backend/common/database/drizzle/migrations/${migrationFiles[0]}`), path.resolve('./migrations/', migrationFiles[0]), { recursive: true }); + await fs.cp(path.resolve(projectRootDir, `src/backend/common/database/drizzle/migrations/${migrationFiles[0]}`), path.resolve('./migrations/', migrationFiles[0]), { recursive: true }); const mf = path.resolve('./migrations'); const db = await getDb(':memory:'); await migrateDb(db, { migrationsFolder: mf }); @@ -113,7 +113,7 @@ describe('Migrations', function () { it('Backs up database when migrations are pending', async function () { - const allFiles = await fs.readdir(path.resolve(projectDir, 'src/backend/common/database/drizzle/migrations')); + const allFiles = await fs.readdir(path.resolve(projectRootDir, 'src/backend/common/database/drizzle/migrations')); const migrationFiles = allFiles .sort(); @@ -123,7 +123,7 @@ describe('Migrations', function () { // copy first migration await fs.mkdir('migrations'); try { - await fs.cp(path.resolve(projectDir, `src/backend/common/database/drizzle/migrations/${migrationFiles[0]}`), path.resolve('./migrations/', migrationFiles[0]), { recursive: true }); + await fs.cp(path.resolve(projectRootDir, `src/backend/common/database/drizzle/migrations/${migrationFiles[0]}`), path.resolve('./migrations/', migrationFiles[0]), { recursive: true }); const mf = path.resolve('./migrations'); const [db, _] = await getMigratedDb(dbPath, {migrationsFolder: mf}); await migrateDb(db, { migrationsFolder: mf }); @@ -135,7 +135,7 @@ describe('Migrations', function () { `${mf}`, '--custom', '--schema', - path.resolve(projectDir, 'src/backend/common/database/drizzle/schema'), + path.resolve(projectRootDir, 'src/backend/common/database/drizzle/schema'), '--dialect', 'sqlite' ], {throwOnError: true}); @@ -742,7 +742,7 @@ describe('App Migrations', function() { ] await repo.createPlays(playData); - await migrateApp(db, {migrationsAppFolder: path.resolve(projectDir, `src/backend/tests/database/testAppMigrations`)}); + await migrateApp(db, {migrationsAppFolder: path.resolve(projectRootDir, `src/backend/tests/database/testAppMigrations`)}); const plays = await repo.findPlays({}); diff --git a/src/backend/tests/musicbrainz/musicbrainz.test.ts b/src/backend/tests/musicbrainz/musicbrainz.test.ts index 2aeb5b5c..b2ecfa34 100644 --- a/src/backend/tests/musicbrainz/musicbrainz.test.ts +++ b/src/backend/tests/musicbrainz/musicbrainz.test.ts @@ -7,7 +7,7 @@ import { initMemoryCache } from "../../common/Cache.ts"; import { Cacheable } from "cacheable"; import MusicbrainzTransformer, { DEFAULT_SEARCHTYPE_ORDER, type MusicbrainzTransformerDataStage } from "../../common/transforms/MusicbrainzTransformer.ts"; import { DEFAULT_MISSING_TYPES, type PlayObject } from "../../../core/Atomic.ts"; -import { projectDir } from '../../common/index.ts'; +import { getPathFromCWD } from '../../common/index.ts'; import path from 'path'; import type {MusicbrainzApiConfigData} from '../../common/infrastructure/Atomic.ts'; import { MockNetworkError, withRequestInterception } from '../utils/networking.ts'; @@ -19,7 +19,7 @@ import { artistNamesToCredits, artistNameToCredit } from '../../../core/StringUt chai.use(asPromised); -const envPath = path.join(projectDir, '.env'); +const envPath = path.join(getPathFromCWD(), '.env'); dotenv.config({ path: envPath }); const memorycache = () => new Cacheable({ primary: initMemoryCache({ ttl: '1ms' }) }); diff --git a/src/backend/tests/sonos/sonos.test.ts b/src/backend/tests/sonos/sonos.test.ts index 879f99dc..a752f41c 100644 --- a/src/backend/tests/sonos/sonos.test.ts +++ b/src/backend/tests/sonos/sonos.test.ts @@ -5,9 +5,9 @@ import type {SonosData} from "../../common/infrastructure/config/source/sonos.ts import { SonosSource } from "../../sources/SonosSource.ts"; import * as dotenv from 'dotenv'; import path from 'path'; -import { projectDir } from "../../common/index.ts"; +import { getPathFromCWD } from "../../common/index.ts"; -const envPath = path.join(projectDir, '.env'); +const envPath = path.join(getPathFromCWD(), '.env'); dotenv.config({ path: envPath }); const createSource = async (data: SonosData): Promise => { diff --git a/src/backend/utils/SchemaAppStaticUtils.ts b/src/backend/utils/SchemaAppStaticUtils.ts index 21c2ce68..f06d4dbb 100644 --- a/src/backend/utils/SchemaAppStaticUtils.ts +++ b/src/backend/utils/SchemaAppStaticUtils.ts @@ -1,16 +1,16 @@ import { writeFileSync } from "node:fs"; import { resolve } from "path"; -import { projectDir } from "../common/index.ts"; import {getTypeSchemaFromConfigGenerator} from "./SchemaUtils.ts"; import { clientInterfaces } from '../common/infrastructure/config/client/clients.ts'; import { sourceInterfaces } from '../common/infrastructure/config/source/sources.ts'; +import { projectRootDir } from "../../core/Atomic.ts"; for(const inter of sourceInterfaces) { const schema = getTypeSchemaFromConfigGenerator(inter); - writeFileSync(resolve(projectDir, `src/backend/common/schema/${inter}.json`), JSON.stringify(schema)); + writeFileSync(resolve(projectRootDir, `src/backend/common/schema/${inter}.json`), JSON.stringify(schema)); } for(const inter of clientInterfaces) { const schema = getTypeSchemaFromConfigGenerator(inter); - writeFileSync(resolve(projectDir, `src/backend/common/schema/${inter}.json`), JSON.stringify(schema)); + writeFileSync(resolve(projectRootDir, `src/backend/common/schema/${inter}.json`), JSON.stringify(schema)); } \ No newline at end of file diff --git a/src/backend/utils/SchemaCompiledUtils.ts b/src/backend/utils/SchemaCompiledUtils.ts index c82ea156..7f8dac41 100644 --- a/src/backend/utils/SchemaCompiledUtils.ts +++ b/src/backend/utils/SchemaCompiledUtils.ts @@ -1,11 +1,11 @@ import { readFileSync, accessSync } from "node:fs"; import { constants } from "fs"; import { resolve } from "path"; -import { projectDir } from "../common/index.ts"; import { MaybeLogger } from '../common/MaybeLogger.ts'; +import { projectRootDir } from "../../core/Atomic.ts"; export const getSchemaForType = (type: string, logger: MaybeLogger = new MaybeLogger()): any => { - const path = resolve(projectDir, `src/backend/common/schema/${type}.json`); + const path = resolve(projectRootDir, `src/backend/common/schema/${type}.json`); try { accessSync(path, constants.R_OK); } catch (e) { diff --git a/src/backend/utils/SchemaDocsStaticUtils.ts b/src/backend/utils/SchemaDocsStaticUtils.ts index 88928bd6..ee8777bb 100644 --- a/src/backend/utils/SchemaDocsStaticUtils.ts +++ b/src/backend/utils/SchemaDocsStaticUtils.ts @@ -1,21 +1,21 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { resolve } from "path"; -import { projectDir } from "../common/index.ts"; import {getTypeSchemaFromConfigGenerator} from "./SchemaUtils.ts"; import { atomicClientInterfaces } from '../common/infrastructure/config/client/clients.ts'; import { atomicSourceInterfaces } from '../common/infrastructure/config/source/sources.ts'; +import { projectRootDir } from "../../core/Atomic.ts"; -mkdirSync(resolve(projectDir, 'docsite/static/schemas'), {recursive: true}); +mkdirSync(resolve(projectRootDir, 'docsite/static/schemas'), {recursive: true}); const aio = getTypeSchemaFromConfigGenerator('AIOConfig'); -writeFileSync(resolve(projectDir, 'docsite/static/schemas/aio.json'), JSON.stringify(aio)); +writeFileSync(resolve(projectRootDir, 'docsite/static/schemas/aio.json'), JSON.stringify(aio)); for(const inter of atomicSourceInterfaces) { const schema = getTypeSchemaFromConfigGenerator(`${inter}s`); - writeFileSync(resolve(projectDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); + writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); } for(const inter of atomicClientInterfaces) { const schema = getTypeSchemaFromConfigGenerator(`${inter}s`); - writeFileSync(resolve(projectDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); + writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); } \ No newline at end of file diff --git a/src/backend/utils/SchemaUtils.ts b/src/backend/utils/SchemaUtils.ts index 54f03582..38f4d3ef 100644 --- a/src/backend/utils/SchemaUtils.ts +++ b/src/backend/utils/SchemaUtils.ts @@ -1,5 +1,4 @@ import { resolve } from "path"; -import { projectDir } from "../common/index.ts"; import type { LiteralType} from "ts-json-schema-generator"; import { type Definition as TSJDefinition, @@ -11,6 +10,7 @@ import { SchemaGenerator, LiteralTypeFormatter } from "ts-json-schema-generator"; import { MaybeLogger } from '../common/MaybeLogger.ts'; +import { projectRootDir } from "../../core/Atomic.ts"; // https://github.com/vega/ts-json-schema-generator/issues/1899#issuecomment-2407674526 // https://github.com/vega/ts-json-schema-generator?tab=readme-ov-file#custom-formatting @@ -34,8 +34,8 @@ const tsjConfig: TSJCompletedConfig = { markdownDescription: true, minify: false, topRef: false, - path: resolve(projectDir, "src/backend/common/infrastructure/config/aioConfig.ts"), - tsconfig: resolve(projectDir, "src/backend/tsconfig.json"), + path: resolve(projectRootDir, "src/backend/common/infrastructure/config/aioConfig.ts"), + tsconfig: resolve(projectRootDir, "src/backend/tsconfig.json"), }; const formatter = createFormatter(tsjConfig, (fmt, circularReferenceTypeFormatter) => { diff --git a/src/backend/utils/ValidationUtils.ts b/src/backend/utils/ValidationUtils.ts index 5697ed53..6243bcf6 100644 --- a/src/backend/utils/ValidationUtils.ts +++ b/src/backend/utils/ValidationUtils.ts @@ -3,7 +3,7 @@ import type * as AjvNS from "ajv"; import Ajv from "ajv"; import f from "ajv-formats" import { resolve } from "path"; -import { projectDir } from "../common/index.ts"; +import { projectRootDir } from "../../core/Atomic.ts"; const ajvInstances: Record = {}; @@ -91,9 +91,9 @@ const getSchemaFunc = async () => { return schemaFetchFunc; } const useCompiled = process.env.NODE_ENV === 'production' || process.env.COMPILED_VALIDATION === 'true'; - const schemaFuncPath = resolve(projectDir, useCompiled ? compiledPath : dynamicPath); + const schemaFuncPath = resolve(projectRootDir, useCompiled ? compiledPath : dynamicPath); try { - const module = await import(resolve(projectDir, schemaFuncPath)) + const module = await import(resolve(projectRootDir, schemaFuncPath)) schemaFetchFunc = module.getSchemaForType; } catch (e) { throw new Error(`Could not load module from path: ${schemaFuncPath}`, {cause: e}); diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index c897ba91..46bf11b1 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -5,6 +5,11 @@ import type {ErrorObject} from "serialize-error"; import type { FlowControlTerm, TransformHook } from "./Transform.ts"; import type {Changeset} from "json-diff-ts"; import type {IParseBaseOptions} from 'qs'; +import { fileURLToPath } from 'url'; +import * as path from 'path'; + +const __filename = fileURLToPath(import.meta.url); +export const projectRootDir = path.resolve(__filename, '../../../'); export type ComponentTypeClient = 'client'; export const COMPONENT_TYPE_CLIENT: ComponentTypeClient = 'client'; -- 2.51.2 From 26121ace103109d8a651e2a49cb7336ecfc16992 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 14 Jul 2026 19:04:44 +0000 Subject: [PATCH 2/4] 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 --- Dockerfile | 1 + drizzle.config.ts | 4 ++-- src/backend/common/Cache.ts | 14 ++++++------- src/backend/common/database/Database.ts | 4 ++-- src/backend/common/index.ts | 28 +++++++++++++++++++++---- src/backend/common/logging.ts | 15 ++++++------- src/backend/index.ts | 10 ++++----- src/backend/ioc.ts | 6 ++---- src/backend/tests/tealfm/tealfm.test.ts | 4 ++-- 9 files changed, 51 insertions(+), 35 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3d04bb40..d429a237 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,7 @@ RUN npm install -g concurrently ARG data_dir=/config VOLUME $data_dir ENV CONFIG_DIR=$data_dir +ENV DATA_DIR=$data_dir COPY docker/root / diff --git a/drizzle.config.ts b/drizzle.config.ts index 4ca638ee..1da91e4f 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,6 +1,6 @@ import 'dotenv/config'; import { defineConfig } from 'drizzle-kit'; -import { configDir } from './src/backend/common/index.js'; +import { getDataDir } from './src/backend/common/index.js'; import * as path from 'path'; import { projectRootDir } from './src/core/Atomic.ts'; @@ -9,6 +9,6 @@ export default defineConfig({ out: path.resolve(projectRootDir, 'src/backend/common/database/drizzle/migrations'), dialect: 'sqlite', dbCredentials: { - url: path.resolve(configDir, 'ms.db'), + url: path.resolve(getDataDir(), 'ms.db'), }, }); \ No newline at end of file diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts index 5077c36a..f696573a 100644 --- a/src/backend/common/Cache.ts +++ b/src/backend/common/Cache.ts @@ -10,7 +10,7 @@ import timezone from 'dayjs/plugin/timezone.js'; import utc from 'dayjs/plugin/utc.js'; import clone from 'clone'; import { childLogger, type Logger } from '@foxxmd/logging'; -import { projectDir } from './index.ts'; +import { getConfigDir } from './index.ts'; import path from 'path'; import { cacheFunctions } from "@foxxmd/regex-buddy-core"; import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.ts'; @@ -18,7 +18,6 @@ import { asCacheConfig, type CacheAuthProvider, type CacheConfig, type CacheConf import { Typeson } from 'typeson'; import { builtin } from 'typeson-registry'; import { loggerNoop } from './MaybeLogger.ts'; -const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); import type { Gauge } from 'prom-client'; import prom from 'prom-client'; import { nonEmptyStringOrDefault } from '../../core/StringUtils.ts'; @@ -277,11 +276,12 @@ export class MSCache { throw e; } } + const confDir = getConfigDir(); if (config.provider === 'file') { - logger.debug(`Building file cache from ${path.join(config.connection ?? configDir, `${namespace}.cache`)}`); + logger.debug(`Building file cache from ${path.join(config.connection ?? confDir, `${namespace}.cache`)}`); try { - const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection ?? configDir, cacheId: `${namespace}.cache` }, {ttl: config.ttl}, logger); + const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection ?? confDir, cacheId: `${namespace}.cache` }, {ttl: config.ttl}, logger); return keyvFile; } catch (e) { throw e; @@ -538,7 +538,7 @@ export const parseUserConfig = (config: CacheConfigUser = {}, parentLogger: Logg // } = {}, scrobble: { provider: sProvider = (process.env.CACHE_SCROBBLE as (CacheScrobbleProvider | undefined) ?? 'file'), - connection = (process.env.CACHE_SCROBBLE_CONN ?? configDir), + connection = (process.env.CACHE_SCROBBLE_CONN ?? getConfigDir()), ...restScrobble } = {}, auth: { @@ -563,7 +563,7 @@ export const parseUserConfig = (config: CacheConfigUser = {}, parentLogger: Logg if(authProvider === 'valkey') { if(valkey === undefined) { logger.warn(`Auth Provider set to 'valkey' but not valkey connection string was not provided, falling back to file.`); - authConn = configDir; + authConn = getConfigDir(); authProvider = 'file'; } else { authConn = valkey; @@ -572,7 +572,7 @@ export const parseUserConfig = (config: CacheConfigUser = {}, parentLogger: Logg if(authProvider !== 'file') { logger.warn(`Unsupported provider given for auth: ${authProvider}`); } - authConn = configDir; + authConn = getConfigDir(); authProvider = 'file'; } diff --git a/src/backend/common/database/Database.ts b/src/backend/common/database/Database.ts index 5f3fd74b..22bb19da 100644 --- a/src/backend/common/database/Database.ts +++ b/src/backend/common/database/Database.ts @@ -1,4 +1,4 @@ -import { configDir } from '../index.ts'; +import { getDataDir } from '../index.ts'; import * as path from 'path'; import { childLogger, type Logger } from '@foxxmd/logging'; import { loggerNoop } from '../MaybeLogger.ts'; @@ -19,7 +19,7 @@ export const getDbPath = (name: string = 'ms', workingDirectory?: string): strin if (isMemoryDb(name)) { return MEMORY_DB_NAME; } - return path.resolve(workingDirectory ?? configDir, `${name}.db`); + return path.resolve(workingDirectory ?? getDataDir(), `${name}.db`); } export const getDbBackupPath = (dbPath: string, suffix?: string): string => { diff --git a/src/backend/common/index.ts b/src/backend/common/index.ts index 3124a3c0..c8f1f5ce 100644 --- a/src/backend/common/index.ts +++ b/src/backend/common/index.ts @@ -1,8 +1,28 @@ import * as path from 'path'; +import envPaths from 'env-paths'; -//const __filename = fileURLToPath(import.meta.url); -//const __dirname = path.dirname(__filename); +const osPaths = envPaths('multi-scrobbler', {suffix: ''}); + +export const getConfigDir = (): string => { + let configDirVal: string = process.env.CONFIG_DIR ?? osPaths.config; + // this shouldn't happen... + // but if it does we need to have some known path fallback so things don't explode + if(configDirVal === undefined) { + configDirVal = getPathFromCWD('./config'); // backwards compatibility + } + // resolve from relative directory, if one was used + return path.resolve(configDirVal); +} + +export const getDataDir = (): string => { + let dataDirVal: string = process.env.DATA_DIR ?? osPaths.data; + // this shouldn't happen... + // but if it does we need to have some known path fallback so things don't explode + if(dataDirVal === undefined) { + dataDirVal = getPathFromCWD('./config'); // defaulting to same directory for backwards compatibility + } + // resolve from relative directory, if one was used + return path.resolve(dataDirVal); +} -export const projectDir = process.cwd(); //path.resolve(__dirname, '../../../'); -export const configDir: string = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); export const getPathFromCWD = (...relativePaths: string[]) => path.resolve(process.cwd(), ...relativePaths); \ No newline at end of file diff --git a/src/backend/common/logging.ts b/src/backend/common/logging.ts index 0773beb8..123e1e3e 100644 --- a/src/backend/common/logging.ts +++ b/src/backend/common/logging.ts @@ -4,13 +4,10 @@ import type { Transform } from "node:stream"; import { PassThrough } from "node:stream"; import path from "path"; import process from "process"; -import { projectDir } from "./index.ts"; +import { getDataDir } from "./index.ts"; import { isDebugMode } from '../utils.ts'; -export let logPath = path.resolve(projectDir, `./logs`); -if (typeof process.env.CONFIG_DIR === 'string') { - logPath = path.resolve(process.env.CONFIG_DIR, './logs'); -} +const logPath = path.resolve(getDataDir(), `./logs`); export const initLogger = (): [Logger, Transform] => { const opts = parseLogOptions({file: false, console: 'trace'}) @@ -27,8 +24,8 @@ export const appLogger = async (config: LogOptions = {}): Promise<[Logger, PassT const { file } = config; const opts = parseLogOptions(isDebugMode() ? {...config, file: typeof file === 'object' ? {...file, level: 'trace'} : 'trace', console: 'trace', level: 'trace'} : config); const logger = await loggerAppRolling(opts, { - logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined, - logDefaultPath: './logs/scrobble.log', + logBaseDir: logPath, + logDefaultPath: './scrobble.log', destinations: [ buildDestinationJsonPrettyStream('trace', {destination: stream, object: true, colorize: true}) ] @@ -38,8 +35,8 @@ export const appLogger = async (config: LogOptions = {}): Promise<[Logger, PassT export const componentFileLogger = async (type: string, name: string, fileConfig: true | LogLevel | FileLogOptions, config: LogOptions = {}): Promise => { const opts = parseLogOptions(config, { - logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined, - logDefaultPath: './logs/scrobble.log' + logBaseDir: logPath, + logDefaultPath: './scrobble.log' }); const base = path.dirname(typeof opts.file.path === 'function' ? opts.file.path() : opts.file.path); diff --git a/src/backend/index.ts b/src/backend/index.ts index e8167a91..dfe66839 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -8,9 +8,8 @@ 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 * as path from "path"; import { SimpleIntervalJob, ToadScheduler } from "toad-scheduler"; -import { projectDir } from "./common/index.ts"; +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"; @@ -87,10 +86,11 @@ process.on('SIGINT', async () => { }) -const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); +const configDir = getConfigDir() try { - initLogger.verbose(`Config Dir ENV: ${process.env.CONFIG_DIR} -> Resolved: ${configDir}`) + initLogger.verbose(`Config Dir ENV : ${process.env.CONFIG_DIR} -> Resolved: ${configDir}`); + initLogger.verbose(`Data Dir ENV : ${process.env.DATA_DIR} -> Resolved: ${getDataDir()}`); // try to read a configuration file let appConfigFail: Error | undefined = undefined; let config = {}; @@ -127,7 +127,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) const dbPath = getDbPath('ms'); logger.info(`Using database at ${db}`); - const [migratedDb, isNew] = await getMigratedDb(dbPath, {logger}); + const [migratedDb, _] = await getMigratedDb(dbPath, {logger}); db = migratedDb; const root = getRoot({ diff --git a/src/backend/ioc.ts b/src/backend/ioc.ts index 28a5ce2b..4353643a 100644 --- a/src/backend/ioc.ts +++ b/src/backend/ioc.ts @@ -1,8 +1,7 @@ import { type Logger, loggerDebug, type LogOptions } from "@foxxmd/logging"; import { EventEmitter } from "events"; import { createContainer } from "iti"; -import path from "path"; -import { projectDir } from "./common/index.ts"; +import { getConfigDir } from "./common/index.ts"; import { WildcardEmitter } from "./common/WildcardEmitter.ts"; import { generateBaseURL } from "./utils/NetworkUtils.ts"; @@ -64,7 +63,6 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => { db, transformers = [] } = options || {}; - const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); let disableWeb = dw; if(disableWeb === undefined) { disableWeb = process.env.DISABLE_WEB === 'true'; @@ -131,7 +129,7 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => { return createContainer().add({ version, - configDir: configDir, + configDir: getConfigDir(), isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'), // @ts-ignore port: (Number.isInteger(portVal) ? portVal : Number.parseInt(portVal)) as number, diff --git a/src/backend/tests/tealfm/tealfm.test.ts b/src/backend/tests/tealfm/tealfm.test.ts index 1b9a262f..b75a331a 100644 --- a/src/backend/tests/tealfm/tealfm.test.ts +++ b/src/backend/tests/tealfm/tealfm.test.ts @@ -9,7 +9,7 @@ import { artistCreditsToNames } from '../../../core/StringUtils.ts'; import TealScrobbler from '../../scrobblers/TealfmScrobbler.ts'; import { EventEmitter } from "events"; import path from 'node:path'; -import { configDir } from '../../common/index.ts'; +import { getConfigDir } from '../../common/index.ts'; import { loggerDebug } from '@foxxmd/logging'; chai.use(asPromised); @@ -115,6 +115,6 @@ describe('#tealfmCar', function() { ); await tfm.buildDatabase(); - await tfm.parseScrobblesFromCar(path.resolve(configDir, 'tealfm-myteal-1778870858.car'), 100); + await tfm.parseScrobblesFromCar(path.resolve(getConfigDir(), 'tealfm-myteal-1778870858.car'), 100); }); }); \ No newline at end of file -- 2.51.2 From a6d33fc2ef362bee8fd380a4772212d4663e6164 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 14 Jul 2026 20:00:17 +0000 Subject: [PATCH 3/4] feat: Move config and data dir creation into app * only use docker init for fixing permission * don't rely on docker init for dir creation as other distros may need this functionality (nix) * check for and create config/data dirs recursively on app start #616 --- .../etc/s6-overlay/s6-rc.d/init-ms-config/run | 32 ++++++------------- src/backend/index.ts | 29 +++++++++++++++-- src/backend/utils/FSUtils.ts | 2 ++ 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/docker/root/etc/s6-overlay/s6-rc.d/init-ms-config/run b/docker/root/etc/s6-overlay/s6-rc.d/init-ms-config/run index 521bc927..cad8bfe8 100755 --- a/docker/root/etc/s6-overlay/s6-rc.d/init-ms-config/run +++ b/docker/root/etc/s6-overlay/s6-rc.d/init-ms-config/run @@ -1,33 +1,19 @@ #!/usr/bin/with-contenv bash -# used https://github.com/linuxserver/docker-plex as a template - -POPULATE_EXAMPLES=false - echo "-------------------------------------" -echo -e "Setting up multi-scrobbler config directory based on CONFIG_DIR env: ${CONFIG_DIR}\n" +echo -e "Verifying multi-scrobbler directory permissions based on CONFIG_DIR env: ${CONFIG_DIR} and DATA_DIR env: ${DATA_DIR}\n" -# make config folder if it does not exist -if [ ! -d "${CONFIG_DIR}" ]; then - echo "Directory does not exist! Creating..." - POPULATE_EXAMPLES=true - mkdir -p "${CONFIG_DIR}" -else - if [ "$(ls -A ${CONFIG_DIR})" ]; then - echo "Directory is not empty, not creating examples." - else - POPULATE_EXAMPLES=true - fi +if [ -d "${CONFIG_DIR}" ]; then + echo "chown'ing config directory to ensure correct permissions." + chown -R abc:abc "${CONFIG_DIR}" + echo "Done!" fi -# add example configs -if [ "$POPULATE_EXAMPLES" = true ]; then - echo "Directory is empty, adding examples..." - cp -r /app/config/. "${CONFIG_DIR}"/ +if [ -d "${DATA_DIR}" ]; then + echo "chown'ing data directory to ensure correct permissions." + chown -R abc:abc "${DATA_DIR}" + echo "Done!" fi -# permissions -echo "chown'ing directory to ensure correct permissions." -chown -R abc:abc "${CONFIG_DIR}" echo "Done!" echo -e "-------------------------------------\n" diff --git a/src/backend/index.ts b/src/backend/index.ts index dfe66839..2ce4c471 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -25,6 +25,7 @@ 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); @@ -86,16 +87,38 @@ process.on('SIGINT', async () => { }) -const configDir = getConfigDir() +const configDir = getConfigDir(); +const dataDir = getDataDir(); try { - initLogger.verbose(`Config Dir ENV : ${process.env.CONFIG_DIR} -> Resolved: ${configDir}`); - initLogger.verbose(`Data Dir ENV : ${process.env.DATA_DIR} -> Resolved: ${getDataDir()}`); + 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; } diff --git a/src/backend/utils/FSUtils.ts b/src/backend/utils/FSUtils.ts index d134a54a..59f861c5 100644 --- a/src/backend/utils/FSUtils.ts +++ b/src/backend/utils/FSUtils.ts @@ -34,6 +34,8 @@ export async function readText(path: any) { // }); } +export const createDir = (location: string) => promises.mkdir(location, {recursive: true}); + export const fileOrDirectoryIsWriteable = (location: string) => { const pathInfo = pathUtil.parse(location); const isDir = pathInfo.ext === ''; -- 2.51.2 From 6f8e7d3862b2f8fb27d2270f38558f8b21de977a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 14 Jul 2026 20:19:25 +0000 Subject: [PATCH 4/4] docs: Update intallation instructions with config/data dir guidance #616 --- docsite/docs/installation/installation.mdx | 47 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/docsite/docs/installation/installation.mdx b/docsite/docs/installation/installation.mdx index 93a29296..47fca7f5 100644 --- a/docsite/docs/installation/installation.mdx +++ b/docsite/docs/installation/installation.mdx @@ -42,7 +42,7 @@ Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi) -You **should** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated. +You **should** bind a host directory into the container for storing configurations, credentials, database, and other files. Otherwise, these will be lost when the container is updated. ```yaml title="docker-compose.yml" services: @@ -54,6 +54,24 @@ services: // highlight-end ``` +Optionally, use a separate directory for data (database, cache, etc...) by mounting an additional directory and setting the `DATA_DIR` ENV to the directory that should be used inside the container: + +```yaml title="docker-compose.yml" +services: + multi-scrobbler: + # ... + environment: + # ... + // highlight-start + - DATA_DIR=/msData + // highlight-end + volumes: + - "./config:/config" # used for config files + // highlight-start + - "./my/host/folder:/msData" # used for data files + // highlight-end +``` + If you are using a [bridge network](https://www.appsdeveloperblog.com/docker-networking-bridging-host-and-overlay/) (default docker setup) you must map a port to the container in order to access the dashboard and use MS with some sources (Webscrobbler, LFM/LZ Endpoints). The default container port is `9078`. @@ -231,6 +249,33 @@ npm run docs:install && npm run build npm run start ``` +#### Config And Data Directories + +The directories MS uses for **Configuration** (`config.json`, `jellyfin.json`, etc...) and **Data** (database, cache, etc...) can be specified by environmental variables passed directly to `npm`/`node`, or set in an `.env` file in the working directory. + +When parsing these ENVs MS will resolve relative directories but *not* bash expansions like `~` for home. Examples: + +* `CONFIG_DIR=/my/absolute/path` => sets directory to use for config files +* `DATA_DIR=./relative/data` => sets directory relative to working directory for data files + +If you do not set these environmental variables then MS will attempt to use OS-standardized directories: + + + + * Config => `$XDG_CONFIG_HOME/multi-scrobbler` or `~/.config/multi-scrobbler` + * Data => `$XDG_DATA_HOME/multi-scrobbler` or `~/.local/share/multi-scrobbler` + + + * Config => `~/Library/Preferences/multi-scrobbler` + * Data => `~/Library/Application Support/multi-scrobbler` + + + * Config => `%APPDATA%\multi-scrobbler\Config` + * Data => `%LOCALAPPDATA%\multi-scrobbler\Data` + + + + #### Rollup build error During building if you encounter an error like: `Your current platform "XXX" and architecture "XXX" combination is not yet supported by the native Rollup build.`