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
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Then check, in order:

## Windows logs

nowplaying's privacy-safe Windows log lives at `%LOCALAPPDATA%\nowplaying\logs\nowplaying.log`. Right-click the tray icon and choose **Open log folder** to open it in File Explorer. `nowplaying.exe start` records startup, successful start, stop and startup failures (with a short code such as `CONFIG_LOAD_FAILED` or `CONFIG_INVALID`). Provider and updater events are still being connected for v0.2.
nowplaying's privacy-safe Windows log lives at `%LOCALAPPDATA%\nowplaying\logs\nowplaying.log`. Right-click the tray icon and choose **Open log folder** to open it in File Explorer, or open the **Logs** page from the status page to see recent events. `nowplaying.exe start` records startup, successful start, stop and startup failures (with a short code such as `CONFIG_LOAD_FAILED` or `CONFIG_INVALID`). Provider and updater events are still being connected for v0.2.

- Each entry records only the time, level, component (`startup`, `provider`, `discord`, `updater`, `tray`), a status and an optional short error code. Titles, usernames, provider URLs and credentials are never written.
- When the file reaches 1 MB it rotates to `nowplaying.log.1`, and older files shift up. Three rotated files are kept, so the folder stays around 4 MB at most.
Expand Down
4 changes: 2 additions & 2 deletions scripts/windows-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createAppLogger } from "../src/app-log.js";
import { createAppLogger, windowsLogPath } from "../src/app-log.js";
import { StartupError, resolveAppPort, startAppFromConfig } from "../src/app-config.js";
import { createCredentialStore } from "../src/credential-store.js";
import { createHostedCredentials } from "../src/hosted-credentials.js";
Expand Down Expand Up @@ -171,7 +171,7 @@ async function startFromWizardConfig(configFile, { safeMode = false } = {}) {
let build = null;
try { build = JSON.parse(await readFile(resolve("app", "build-info.json"), "utf8")); } catch { build = null; }
const packageType = existsSync(resolve("unins000.exe")) ? "installer" : "portable";
const app = await startAppFromConfig({ configFile, credentialStore, hostedCredentials, port: resolveAppPort(), version: typeof manifest.version === "string" ? manifest.version : null, build, packageType, safeMode });
const app = await startAppFromConfig({ configFile, credentialStore, hostedCredentials, port: resolveAppPort(), version: typeof manifest.version === "string" ? manifest.version : null, build, packageType, safeMode, logFile: process.env.LOCALAPPDATA ? windowsLogPath({ localAppData: process.env.LOCALAPPDATA }) : null });
console.log(safeMode
? `NowPlaying started in safe mode after repeated failed starts: Discord and hosted uploads are off. Run setup again from the tray to go back to normal. Card: ${app.url}/card.svg`
: `NowPlaying is running. Card: ${app.url}/card.svg`);
Expand Down
8 changes: 6 additions & 2 deletions src/app-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { createHttpServer } from "./http-server.js";
import { createAppStatus } from "./app-status.js";
import { createStatusPageHandler } from "./status-page-handler.js";
import { createSettingsPageHandler } from "./settings-page-handler.js";
import { createLogsPageHandler } from "./logs-page-handler.js";
import { readLogTail } from "./log-tail.js";
import { createResilientCardResolver } from "./resilient-card.js";
import { createSetupConfig } from "./setup-config.js";
import { createConfigMigrationStore } from "./config-migration-store.js";
Expand Down Expand Up @@ -189,7 +191,7 @@ export function resolveAppPort(env = process.env) {
return port;
}

export async function startAppFromConfig({ configFile, credentialStore, host = "127.0.0.1", port = DEFAULT_APP_PORT, fetchImpl = fetch, discord: discordOptions = {}, version = null, build = null, packageType = null, hostedCredentials, hosted: hostedOptions = {}, safeMode = false } = {}) {
export async function startAppFromConfig({ configFile, credentialStore, host = "127.0.0.1", port = DEFAULT_APP_PORT, fetchImpl = fetch, discord: discordOptions = {}, version = null, build = null, packageType = null, hostedCredentials, hosted: hostedOptions = {}, safeMode = false, logFile = null } = {}) {
if (typeof credentialStore?.read !== "function") throw new TypeError("credentialStore.read is required");
const config = await loadAppConfig(configFile);
let secret;
Expand Down Expand Up @@ -234,7 +236,9 @@ export async function startAppFromConfig({ configFile, credentialStore, host = "
},
});
const statusHandler = createStatusPageHandler({ status, fallback: createCardHandler({ resolveCard }) });
const handler = createSettingsPageHandler({ settings, fallback: statusHandler });
// The Logs page reads the app log (no log file, e.g. a dev checkout: empty).
const logsHandler = createLogsPageHandler({ readEvents: () => readLogTail(logFile), fallback: statusHandler });
const handler = createSettingsPageHandler({ settings, fallback: logsHandler });
// Saves need the cookie the app's own pages set, so another local program
// or web page can't change settings.
const server = createHttpServer({ host, port, handler, sessionSecret: randomBytes(32).toString("base64url") });
Expand Down
41 changes: 41 additions & 0 deletions src/log-tail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { open } from "node:fs/promises";

// Reads the newest events from the app log for the local Logs page (#253).
// Only the last maxBytes are read, and each line is checked again against the
// log's own shape, so nothing but time, level, component, status and code can
// reach the page even if the file was edited by hand.

const LEVELS = new Set(["info", "warn", "error"]);
const COMPONENTS = new Set(["startup", "provider", "discord", "updater", "tray"]);
const STATUSES = new Set(["starting", "ok", "idle", "degraded", "failed", "stopped"]);
const CODE = /^[A-Z][A-Z0-9_]{0,47}$/;

export function parseLogLine(line) {
let value;
try { value = JSON.parse(line); } catch { return null; }
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const time = typeof value.time === "string" ? new Date(value.time) : null;
if (!time || !Number.isFinite(time.getTime())) return null;
if (!LEVELS.has(value.level) || !COMPONENTS.has(value.component) || !STATUSES.has(value.status)) return null;
if (value.code !== undefined && (typeof value.code !== "string" || !CODE.test(value.code))) return null;
return Object.freeze({ time: time.toISOString(), level: value.level, component: value.component, status: value.status, ...(value.code ? { code: value.code } : {}) });
}

export async function readLogTail(file, { maxLines = 200, maxBytes = 64 * 1024 } = {}) {
if (typeof file !== "string" || !file) return [];
let handle;
try { handle = await open(file, "r"); }
catch (error) { if (error?.code === "ENOENT") return []; throw error; }
try {
const { size } = await handle.stat();
const length = Math.min(size, maxBytes);
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, size - length);
const lines = buffer.toString("utf8").split("\n");
// The first line may be cut off when only part of the file was read.
if (length < size) lines.shift();
return lines.map((line) => line.trim()).filter(Boolean).map(parseLogLine).filter(Boolean).slice(-maxLines);
} finally {
await handle.close();
}
}
112 changes: 112 additions & 0 deletions src/logs-page-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Local Logs page (#253). Shows the newest app log events and refreshes every
// few seconds. The log only holds time, level, component, status and a short
// code, never server addresses, names, titles or sign-in details.

const PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>NowPlaying logs</title><link rel="stylesheet" href="/status.css"><link rel="stylesheet" href="/logs.css"></head>
<body><main>
<nav><a href="/">Status</a> <a href="/settings">Settings</a> <span aria-current="page">Logs</span></nav>
<h1>Logs</h1>
<section aria-labelledby="h-log"><h2 id="h-log">Recent events</h2>
<p class="row"><label for="level">Show</label>
<select id="level"><option value="info">Everything</option><option value="warn">Warnings and errors</option><option value="error">Errors only</option></select>
<button type="button" id="copy-log">Copy</button> <span id="log-result" role="status" aria-live="polite"></span></p>
<p id="log-empty" hidden>Nothing logged yet.</p>
<table id="log"><thead><tr><th scope="col">Time</th><th scope="col">Level</th><th scope="col">Part</th><th scope="col">Event</th></tr></thead><tbody></tbody></table>
<p class="hint">The log never includes your server address, user name, what you're playing or sign-in details. Use Copy diagnostics on the Status page for bug reports.</p>
</section>
</main><script src="/logs.js"></script></body></html>
`;

const CSS = `.row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:0 0 12px}.row label{color:#555}
select{font:inherit;padding:4px 8px;border:1px solid #888;border-radius:6px;background:#fff;color:inherit}
table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:4px 8px;border-bottom:1px solid #e4e4e8;vertical-align:top}
th{color:#555;font-weight:600}td:first-child{white-space:nowrap;font-variant-numeric:tabular-nums}
.hint{color:#555;font-size:13px;margin:12px 0 0}
@media (prefers-color-scheme:dark){select{background:#2c2c31;border-color:#555}th,.row label,.hint{color:#aaa}th,td{border-color:#333}}
`;

const SCRIPT = `"use strict";
const RANK = { info: 0, warn: 1, error: 2 };
const LEVEL_WORDS = { info: ["Info", ""], warn: ["Warning", "warn"], error: ["Error", "bad"] };
const PARTS = { startup: "App", provider: "Media server", discord: "Discord", updater: "Updates", tray: "Tray" };
const level = document.getElementById("level");
const body = document.querySelector("#log tbody");
let events = [];
function say(text, tone) { const el = document.getElementById("log-result"); el.textContent = text; el.className = tone || ""; }
function cell(row, text, tone) { const td = document.createElement("td"); td.textContent = text; if (tone) td.className = tone; row.appendChild(td); }
function shown() { return events.filter((e) => RANK[e.level] >= RANK[level.value]).slice().reverse(); }
function render() {
const rows = shown();
body.replaceChildren();
for (const e of rows) {
const tr = document.createElement("tr");
const words = LEVEL_WORDS[e.level] || LEVEL_WORDS.info;
cell(tr, new Date(e.time).toLocaleString());
cell(tr, words[0], words[1]);
cell(tr, PARTS[e.component] || e.component);
cell(tr, e.status + (e.code ? " (" + e.code + ")" : ""));
body.appendChild(tr);
}
document.getElementById("log-empty").hidden = rows.length > 0;
}
async function load() {
try {
const res = await fetch("/api/logs", { cache: "no-store", headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(String(res.status));
events = (await res.json()).events;
render();
} catch {
say("Can't reach NowPlaying. It may have been closed.", "bad");
}
}
level.addEventListener("change", render);
document.getElementById("copy-log").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(shown().map((e) => [e.time, e.level, e.component, e.status, e.code || ""].join(" ").trim()).join("\\n"));
say("Copied.", "ok");
} catch {
say("Couldn't copy.", "bad");
}
});
load();
setInterval(load, 3000);
`;

const SAFE_FETCH_SITES = new Set(["same-origin", "none"]);

export function createLogsPageHandler({ readEvents, fallback } = {}) {
if (typeof readEvents !== "function") throw new TypeError("readEvents: expected a function");
if (typeof fallback !== "function") throw new TypeError("fallback: expected a handler");
const assets = {
"/logs": { body: PAGE, type: "text/html; charset=utf-8", page: true },
"/logs.css": { body: CSS, type: "text/css; charset=utf-8" },
"/logs.js": { body: SCRIPT, type: "text/javascript; charset=utf-8" },
};
return async function handle(request) {
const method = request?.method || "GET";
const url = new URL(request?.url || "/", "http://localhost");
const asset = assets[url.pathname];
if (!asset && url.pathname !== "/api/logs") return fallback(request);
if (method !== "GET" && method !== "HEAD") return response(405, "Method Not Allowed", { Allow: "GET, HEAD" });
if (asset) {
const result = response(200, method === "HEAD" ? "" : asset.body, { "Content-Type": asset.type, "Cache-Control": "no-store" });
return asset.page ? { ...result, page: true } : result;
}
// The log is for this app's own page: refuse other sites' requests.
const site = header(request?.headers, "sec-fetch-site");
if (site !== undefined && !SAFE_FETCH_SITES.has(String(site).toLowerCase())) return response(403, "Forbidden");
let events;
try { events = await readEvents(); } catch { return json(500, { error: "log_unreadable" }); }
return json(200, { events }, method);
};
}

function json(status, value, method = "GET") { return response(status, method === "HEAD" ? "" : JSON.stringify(value), { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); }
function header(headers, name) {
if (!headers) return undefined;
const key = Object.keys(headers).find((value) => value.toLowerCase() === name);
return key ? headers[key] : undefined;
}
function response(status, body, headers = {}) { return Object.freeze({ status, headers: Object.freeze(headers), body }); }
2 changes: 1 addition & 1 deletion src/settings-page-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>NowPlaying settings</title><link rel="stylesheet" href="/status.css"><link rel="stylesheet" href="/settings.css"></head>
<body><main>
<nav><a href="/">Status</a> <span aria-current="page">Settings</span></nav>
<nav><a href="/">Status</a> <span aria-current="page">Settings</span> <a href="/logs">Logs</a></nav>
<h1>Settings</h1>
<form id="discord-form">
<section aria-labelledby="h-discord"><h2 id="h-discord">Discord</h2>
Expand Down
2 changes: 1 addition & 1 deletion src/status-page-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>NowPlaying status</title><link rel="stylesheet" href="/status.css"></head>
<body><main>
<nav><span aria-current="page">Status</span> <a href="/settings">Settings</a></nav>
<nav><span aria-current="page">Status</span> <a href="/settings">Settings</a> <a href="/logs">Logs</a></nav>
<h1>NowPlaying</h1>
<p id="summary" role="status" aria-live="polite">Loading status...</p>
<section aria-labelledby="h-playing"><h2 id="h-playing">Now playing</h2><p id="playing">-</p></section>
Expand Down
72 changes: 72 additions & 0 deletions test/logs-page.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { parseLogLine, readLogTail } from "../src/log-tail.js";
import { createLogsPageHandler } from "../src/logs-page-handler.js";
import { startAppFromConfig } from "../src/app-config.js";
import { serializeLogEvent } from "../src/app-log.js";
import { serializeSetupConfig } from "../src/setup-config.js";

const line = (status, extra = {}) => serializeLogEvent({ time: new Date("2026-09-24T00:00:00Z"), level: "info", component: "startup", status, ...extra });

async function logFile(text) {
const dir = await mkdtemp(join(tmpdir(), "np-logs-"));
const file = join(dir, "nowplaying.log");
await writeFile(file, text);
return file;
}

test("keeps only the log's own fields and drops anything else", () => {
assert.deepEqual({ ...parseLogLine(line("ok", { level: "error", code: "PORT_IN_USE" })) }, { time: "2026-09-24T00:00:00.000Z", level: "error", component: "startup", status: "ok", code: "PORT_IN_USE" });
const extra = JSON.stringify({ time: "2026-09-24T00:00:00Z", level: "info", component: "startup", status: "ok", serverUrl: "http://10.0.0.2:32400" });
assert.equal(JSON.stringify(parseLogLine(extra)).includes("10.0.0.2"), false);
for (const bad of ["not json", "[]", "null", JSON.stringify({ time: "x", level: "info", component: "startup", status: "ok" }), JSON.stringify({ time: "2026-09-24T00:00:00Z", level: "debug", component: "startup", status: "ok" }), JSON.stringify({ time: "2026-09-24T00:00:00Z", level: "info", component: "startup", status: "ok", code: "token=abc" })]) {
assert.equal(parseLogLine(bad), null, bad);
}
});

test("reads the newest events and skips a cut-off first line", async () => {
const file = await logFile(Array.from({ length: 50 }, () => line("ok")).join("") + line("stopped"));
const all = await readLogTail(file, { maxLines: 10 });
assert.equal(all.length, 10);
assert.equal(all.at(-1).status, "stopped");
const part = await readLogTail(file, { maxBytes: 300 });
assert.ok(part.length >= 1 && part.every((event) => event.component === "startup"));
assert.deepEqual(await readLogTail(join(file, "..", "missing.log")), []);
assert.deepEqual(await readLogTail(null), []);
});

test("serves the page and events, GET only, same origin only", async () => {
const h = createLogsPageHandler({ readEvents: async () => [{ time: "t", level: "info", component: "startup", status: "ok" }], fallback: async () => ({ status: 299 }) });
const page = await h({ url: "/logs" });
assert.equal(page.page, true);
assert.equal(page.body.toLowerCase().split("<script").length, 2);
assert.equal((await h({ url: "/logs.js" })).status, 200);
assert.equal((await h({ url: "/logs.css" })).status, 200);
assert.equal(JSON.parse((await h({ url: "/api/logs" })).body).events.length, 1);
assert.equal((await h({ method: "HEAD", url: "/api/logs" })).body, "");
assert.equal((await h({ method: "POST", url: "/api/logs" })).status, 405);
assert.equal((await h({ url: "/api/logs", headers: { "Sec-Fetch-Site": "cross-site" } })).status, 403);
assert.equal((await h({ url: "/card.svg" })).status, 299);
const broken = createLogsPageHandler({ readEvents: async () => { throw new Error("EACCES C:\\Users\\rowan"); }, fallback: async () => null });
const failed = await broken({ url: "/api/logs" });
assert.deepEqual([failed.status, failed.body], [500, '{"error":"log_unreadable"}']);
assert.throws(() => createLogsPageHandler({ fallback: async () => null }), TypeError);
assert.throws(() => createLogsPageHandler({ readEvents: async () => [] }), TypeError);
});

test("the running app shows its log on /logs", async () => {
const dir = await mkdtemp(join(tmpdir(), "np-logs-app-"));
const configFile = join(dir, "config.json");
await writeFile(configFile, serializeSetupConfig({ provider: "jellyfin", serverUrl: "http://127.0.0.1:8096", identity: { id: "u1", displayName: "Rowan" }, credentialStored: true }));
const file = await logFile(line("starting") + line("ok"));
const app = await startAppFromConfig({ configFile, credentialStore: { read: async () => "jf-token" }, port: 0, fetchImpl: async () => Response.json([]), discord: { env: {}, builtInClientId: "" }, logFile: file });
try {
assert.equal((await fetch(`${app.url}/logs`)).status, 200);
assert.deepEqual((await (await fetch(`${app.url}/api/logs`)).json()).events.map((event) => event.status), ["starting", "ok"]);
} finally {
await app.close();
}
});
Loading