Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ Discord Rich Presence is local. A server process running without a desktop sessi

The updater is notify-only unless install behavior has been explicitly enabled.

## Config backups and recovery

NowPlaying keeps its settings in `config.json` (on Windows: `%LOCALAPPDATA%\nowplaying\config.json`). When a new version changes the config format, NowPlaying upgrades the file at start-up. Before it changes anything, it saves a copy next to it as `config.json.backup-<date and time>`. The new file only replaces the old one after it has been checked.

- If the upgrade fails, the original file is left exactly as it was.
- If the config was saved by a newer NowPlaying (for example after going back to an older version), the app won't start and says so, and the file is not changed. Update NowPlaying, or run `nowplaying.exe setup` to start again.
- To go back to a backup: close NowPlaying, delete `config.json`, rename the backup you want to `config.json`, then start NowPlaying again.

Backups never contain your sign-in. That stays in Windows Credential Manager.

## Safe diagnostic information

The status page (the app's local address in your browser, ending in `/status`) has a **Copy diagnostics** button and a **Download** link under "Reporting a problem". The report has exactly these fields and nothing else:
Expand Down
34 changes: 34 additions & 0 deletions src/app-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createAppStatus } from "./app-status.js";
import { createStatusPageHandler } from "./status-page-handler.js";
import { createResilientCardResolver } from "./resilient-card.js";
import { createSetupConfig } from "./setup-config.js";
import { createConfigMigrationStore } from "./config-migration-store.js";
import { createEmbyProvider } from "./providers/emby.js";
import { createJellyfinProvider } from "./providers/jellyfin.js";
import { createNavidromeProvider } from "./providers/navidrome.js";
Expand Down Expand Up @@ -71,7 +72,40 @@ export function parseAppConfig(text) {
return config;
}

// Config schema version this build writes and reads. When it goes up, add a
Comment thread
mira-reviewer-rk[bot] marked this conversation as resolved.
// migration for the old version below; the store backs the file up first
// (config.json.backup-<time>) and only replaces it with a validated result.
export const CONFIG_VERSION = 1;
const CONFIG_MIGRATIONS = Object.freeze([
// 0 -> 1: no version-0 file was ever released.
() => { throw new Error("unsupported"); },
]);

/**
* Upgrades config.json to CONFIG_VERSION if needed (backup first).
* Throws CONFIG_TOO_NEW for a newer file; any other failure leaves the file
* alone and returns status "failed" so loadAppConfig reports it as usual.
*/
export async function migrateAppConfig(file, { clock } = {}) {
Comment thread
mira-reviewer-rk[bot] marked this conversation as resolved.
const store = createConfigMigrationStore({
file,
currentVersion: CONFIG_VERSION,
migrations: CONFIG_MIGRATIONS,
validate: (document) => { parseAppConfig(JSON.stringify(document)); return document; },
...(clock ? { clock } : {}),
});
try {
return await store.migrate();
} catch (error) {
if (error instanceof RangeError) throw new StartupError("CONFIG_TOO_NEW", "This config was saved by a newer NowPlaying. Update NowPlaying, or run `nowplaying.exe setup` to start again.");
// Anything else (malformed, unreadable, failed migration) is reported by
// loadAppConfig with the usual message; the file is left untouched.
return Object.freeze({ changed: false, status: "failed", backup: null });
}
}

export async function loadAppConfig(file) {
Comment thread
mira-reviewer-rk[bot] marked this conversation as resolved.
await migrateAppConfig(file);
let text;
try {
text = await readFile(file, "utf8");
Expand Down
17 changes: 16 additions & 1 deletion test/app-config.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { join } from "node:path";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -180,3 +180,18 @@ test("plex: follows the signed-in user by plex.tv ID, and the owner's local id 1
app = createProviderFromConfig(config, "plex-token", { fetchImpl });
assert.equal((await app.getPresence()).state, "idle");
});

test("start-up migration leaves a current config alone and refuses a newer one (#120)", async () => {
const { migrateAppConfig } = await import("../src/app-config.js");
const dir = await mkdtemp(join(tmpdir(), "np-migrate-"));
const file = join(dir, "config.json");
await writeFile(file, serializeSetupConfig(JELLYFIN));
assert.equal((await migrateAppConfig(file)).status, "current");
assert.equal((await readdir(dir)).length, 1);
await writeFile(file, JSON.stringify({ ...JSON.parse(serializeSetupConfig(JELLYFIN)), version: 2 }));
await assert.rejects(loadAppConfig(file), code("CONFIG_TOO_NEW"));
assert.equal(JSON.parse(await readFile(file, "utf8")).version, 2);
await writeFile(file, JSON.stringify({ ...JSON.parse(serializeSetupConfig(JELLYFIN)), version: 0 }));
Comment thread
mira-reviewer-rk[bot] marked this conversation as resolved.
await assert.rejects(loadAppConfig(file), code("CONFIG_INVALID"));
assert.deepEqual((await readdir(dir)).sort(), ["config.json"]);
});
Loading