From 44a626122b3bccba5127b539f7a2858d313d6fa6 Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Fri, 2 Oct 2026 17:36:26 +0100 Subject: [PATCH] Add a Discord status text field to Settings --- src/app-config.js | 4 +- src/app-settings.js | 10 ++++- src/discord-presence.js | 6 +-- src/settings-page-handler.js | 26 +++++++++--- src/settings-servers.js | 2 + src/setup-app.js | 2 + src/setup-config.js | 12 ++++++ test/app-settings.test.js | 6 +-- test/discord-name-settings.test.js | 68 ++++++++++++++++++++++++++++++ 9 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 test/discord-name-settings.test.js diff --git a/src/app-config.js b/src/app-config.js index 1e7fd1e9..3d50de75 100644 --- a/src/app-config.js +++ b/src/app-config.js @@ -80,6 +80,8 @@ export function parseAppConfig(text) { // "upload"; keep that, so an upgrade never starts sending covers. discordArtworkUpload: parsed.discord?.artworkUpload ?? parsed.discord?.artworkLookup === "upload", discordTimestamps: parsed.discord?.timestamps, + discordName: parsed.discord?.name, + discordStatusDisplayType: parsed.discord?.statusDisplayType, ...(parsed.hosted ? { hostedEnabled: parsed.hosted.enabled, hostedUrl: parsed.hosted.url } : {}), ...(parsed.privacy !== undefined ? { privacy: parsed.privacy } : {}), ...(parsed.card !== undefined ? { card: parsed.card } : {}), @@ -220,7 +222,7 @@ export function startDiscordFromConfig(config, provider, { env = process.env, bu // Hidden titles or album art: never look covers up by title. const policy = privacyPolicyFromConfig(config); const artwork = createArtwork(policy.redactTitles || policy.hideArtwork ? { ...config.discord, artworkLookup: "off", artworkUpload: false } : config.discord); - const loop = createDiscordPresenceLoop({ getPresence: () => provider.getPresence(), client, artwork, idleBehavior: config.discord.idleBehavior, timestamps: config.discord.timestamps ?? "both", ...(intervalMs ? { intervalMs } : {}), ...(stuckAfterMs ? { stuckAfterMs } : {}), ...(now ? { now } : {}) }); + const loop = createDiscordPresenceLoop({ getPresence: () => provider.getPresence(), client, artwork, idleBehavior: config.discord.idleBehavior, timestamps: config.discord.timestamps ?? "both", activityFields: { ...(config.discord.name ? { name: config.discord.name } : {}), ...(config.discord.statusDisplayType ? { statusDisplayType: config.discord.statusDisplayType } : {}) }, ...(intervalMs ? { intervalMs } : {}), ...(stuckAfterMs ? { stuckAfterMs } : {}), ...(now ? { now } : {}) }); loop.start(); // Refresh artwork: forget cached covers, then update Discord straight away. async function refreshArtwork() { diff --git a/src/app-settings.js b/src/app-settings.js index b5c5e4a2..4f3ca3fa 100644 --- a/src/app-settings.js +++ b/src/app-settings.js @@ -3,12 +3,13 @@ import { readFile, rename, rm, writeFile } from "node:fs/promises"; import { parseAppConfig } from "./app-config.js"; import { normalizeCard, serializeSetupConfig } from "./setup-config.js"; import { IDLE_BEHAVIORS } from "./discord-presence.js"; +import { validateTemplate } from "./template.js"; // Settings the web UI can change while the app runs (#253). Each change is // checked against the same rules as setup, then config.json is replaced in one // step (temp file + rename) so a crash never leaves half a file. -const DISCORD_KEYS = new Set(["enabled", "timestamps", "artworkLookup", "artworkUpload", "idleBehavior"]); +const DISCORD_KEYS = new Set(["enabled", "timestamps", "artworkLookup", "artworkUpload", "idleBehavior", "name", "statusDisplayType"]); const HOSTED_KEYS = new Set(["enabled"]); const PRIVACY_KEYS = new Set(["hideTitles", "hideArtwork", "hideProgress", "hideMovies", "hideEpisodes", "hideMusic"]); const PRIVACY_KIND_KEYS = Object.freeze([["hideMovies", "movie"], ["hideEpisodes", "episode"], ["hideMusic", "track"]]); @@ -20,6 +21,8 @@ export function discordSettingsView(config) { artworkLookup: config.discord?.artworkLookup ?? "off", artworkUpload: config.discord?.artworkUpload !== false, idleBehavior: config.discord?.idleBehavior ?? "clear", + name: config.discord?.name ?? "", + statusDisplayType: config.discord?.statusDisplayType ?? "name", }); } @@ -91,6 +94,8 @@ function rewrite(config, { servers = config.servers, discord = { ...discordSetti discordArtworkLookup: discord.artworkLookup, discordArtworkUpload: discord.artworkUpload, discordTimestamps: discord.timestamps, + discordName: discord.name, + discordStatusDisplayType: discord.statusDisplayType, ...(hosted ? { hostedEnabled: hosted.enabled, ...(hosted.url ? { hostedUrl: hosted.url } : {}) } : {}), ...(privacy ? { privacy } : {}), ...(card ? { card } : {}), @@ -104,6 +109,9 @@ export function applyDiscordChanges(config, changes) { checkChanges(changes, DISCORD_KEYS, "discord"); if (changes.idleBehavior !== undefined && !IDLE_BEHAVIORS.includes(changes.idleBehavior)) throw new TypeError("discord settings: idleBehavior is invalid"); if (changes.artworkUpload !== undefined && typeof changes.artworkUpload !== "boolean") throw new TypeError("discord settings: artworkUpload must be true or false"); + if (changes.name !== undefined && (typeof changes.name !== "string" || changes.name.length > 128)) throw new TypeError("discord settings: name must be text of at most 128 characters"); + if (changes.name !== undefined) validateTemplate(changes.name, "discord settings: status text"); + if (changes.statusDisplayType !== undefined && !["name", "state", "details"].includes(changes.statusDisplayType)) throw new TypeError("discord settings: statusDisplayType is invalid"); return rewrite(config, { discord: { ...discordSettingsView(config), ...changes } }); } diff --git a/src/discord-presence.js b/src/discord-presence.js index 5796ff6a..c82e9e8c 100644 --- a/src/discord-presence.js +++ b/src/discord-presence.js @@ -23,14 +23,14 @@ function validateLoopOptions({ getPresence, client, idleBehavior, timestamps, in } export function createDiscordPresenceLoop({ - getPresence, client, idleBehavior = "clear", timestamps = "both", artwork, + getPresence, client, idleBehavior = "clear", timestamps = "both", artwork, activityFields = {}, intervalMs = 15_000, graceMs = 120_000, stuckAfterMs = 300_000, stallAfterMs = 60_000, now = () => performance.now(), setTimer = setTimeout, clearTimer = clearTimeout, } = {}) { validateLoopOptions({ getPresence, client, idleBehavior, timestamps, intervalMs, graceMs, stuckAfterMs, stallAfterMs }); - const live = createDiscordController({ client, settings: { idleBehavior: idleBehavior === "show" ? "show" : "clear", timestamps }, ...(artwork ? { artwork } : {}) }); - const frozen = createDiscordController({ client, settings: { timestamps: "none" }, ...(artwork ? { artwork } : {}) }); + const live = createDiscordController({ client, settings: { ...activityFields, idleBehavior: idleBehavior === "show" ? "show" : "clear", timestamps }, ...(artwork ? { artwork } : {}) }); + const frozen = createDiscordController({ client, settings: { ...activityFields, timestamps: "none" }, ...(artwork ? { artwork } : {}) }); let lastActive = null; let idleSince = null; let timer = null; diff --git a/src/settings-page-handler.js b/src/settings-page-handler.js index e99a6cc3..472af709 100644 --- a/src/settings-page-handler.js +++ b/src/settings-page-handler.js @@ -71,6 +71,15 @@ const buildPage = (startupCopy) => `

+

+

+

What Discord shows in your status, next to your name. Leave it empty for "{artist} on {service}". Fields you can use: {artist}, {title}, {subtitle}, {album}, {year}, {series}, {season}, {episode}, {episodeCode}, {service} (Spotify, Navidrome and so on), {provider}, {mediaType}, {state}, {stateLabel}, {position}, {duration} and {progressPercent}. Anything else you type is kept as it is. To show a literal brace, write {{ or }}.

+

+

Discord can only show a picture from a public address, so this uploads just the cover image to litterbox.catbox.moe, where it is kept for 72 hours. No title, artist, token or server address is sent. Turn it off and a private server's art is never sent to Discord.

@@ -481,11 +490,11 @@ refreshVersion(); refreshLogs(); setInterval(refreshLogs, 3000); setInterval(ref const buildScript = (startupCopy) => `"use strict"; const form = document.getElementById("discord-form"); -const fields = { enabled: document.getElementById("discord-enabled"), timestamps: document.getElementById("discord-timestamps"), artworkLookup: document.getElementById("discord-artwork"), artworkUpload: document.getElementById("discord-upload"), idleBehavior: document.getElementById("discord-idle") }; +const fields = { enabled: document.getElementById("discord-enabled"), timestamps: document.getElementById("discord-timestamps"), artworkLookup: document.getElementById("discord-artwork"), artworkUpload: document.getElementById("discord-upload"), idleBehavior: document.getElementById("discord-idle"), name: document.getElementById("discord-name"), statusDisplayType: document.getElementById("discord-status-type") }; const save = document.getElementById("discord-save"); function say(text, tone) { const el = document.getElementById("discord-result"); el.textContent = text; el.className = tone || ""; } -function show(d) { fields.enabled.checked = d.enabled; fields.timestamps.value = d.timestamps; fields.artworkLookup.value = d.artworkLookup; fields.artworkUpload.checked = d.artworkUpload !== false; fields.idleBehavior.value = d.idleBehavior || "clear"; toggle(); } -function toggle() { fields.timestamps.disabled = fields.artworkLookup.disabled = fields.artworkUpload.disabled = fields.idleBehavior.disabled = !fields.enabled.checked; } +function show(d) { fields.enabled.checked = d.enabled; fields.timestamps.value = d.timestamps; fields.artworkLookup.value = d.artworkLookup; fields.artworkUpload.checked = d.artworkUpload !== false; fields.idleBehavior.value = d.idleBehavior || "clear"; fields.name.value = d.name || ""; fields.statusDisplayType.value = d.statusDisplayType || "name"; toggle(); } +function toggle() { fields.timestamps.disabled = fields.artworkLookup.disabled = fields.artworkUpload.disabled = fields.idleBehavior.disabled = fields.name.disabled = fields.statusDisplayType.disabled = !fields.enabled.checked; } fields.enabled.addEventListener("change", toggle); async function load() { try { @@ -510,13 +519,13 @@ form.addEventListener("submit", async (event) => { save.disabled = true; say("Saving...", "warn"); try { - const body = { discord: { enabled: fields.enabled.checked, timestamps: fields.timestamps.value, artworkLookup: fields.artworkLookup.value, artworkUpload: fields.artworkUpload.checked, idleBehavior: fields.idleBehavior.value } }; + const body = { discord: { enabled: fields.enabled.checked, timestamps: fields.timestamps.value, artworkLookup: fields.artworkLookup.value, artworkUpload: fields.artworkUpload.checked, idleBehavior: fields.idleBehavior.value, name: fields.name.value.trim(), statusDisplayType: fields.statusDisplayType.value } }; const res = await fetch("/api/settings", { method: "PUT", cache: "no-store", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify(body) }); - if (!res.ok) throw new Error(String(res.status)); + if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(typeof err.message === "string" ? err.message : String(res.status)); } show((await res.json()).discord); say("Saved. Discord is using the new settings.", "ok"); - } catch { - say("Couldn't save. Nothing was changed.", "bad"); + } catch (error) { + say(/^Status text:/.test(error.message) ? error.message + " Nothing was changed." : "Couldn't save. Nothing was changed.", "bad"); } finally { save.disabled = false; if (restoreSaveFocus && document.activeElement === document.body) save.focus(); @@ -1015,6 +1024,9 @@ export function createSettingsPageHandler({ settings, fallback, platform = proce await settings[SECTIONS[keys[0]]](input[keys[0]]); } catch (error) { // Bad values are the caller's fault; anything else (disk, file) is ours. + // An unknown {field} in the status text is the one message safe to show: it only repeats what was typed. + const unknown = error instanceof TypeError ? /^discord settings: status text: (unknown field \{[A-Za-z0-9]+\})$/.exec(error.message) : null; + if (unknown) return json(400, { error: "invalid_settings", message: `Status text: ${unknown[1]}. Check the list of fields below it.` }); if (error instanceof TypeError || error instanceof RangeError) return json(400, { error: "invalid_settings" }); return json(500, { error: "save_failed" }); } diff --git a/src/settings-servers.js b/src/settings-servers.js index ff9a71d9..00b1259f 100644 --- a/src/settings-servers.js +++ b/src/settings-servers.js @@ -46,6 +46,8 @@ export function createSettingsServers({ file, credentialStore, deviceId, version discordArtworkLookup: existing?.discord?.artworkLookup ?? "off", ...(typeof existing?.discord?.artworkUpload === "boolean" ? { discordArtworkUpload: existing.discord.artworkUpload } : {}), ...(existing?.discord?.timestamps ? { discordTimestamps: existing.discord.timestamps } : {}), + ...(existing?.discord?.name ? { discordName: existing.discord.name } : {}), + ...(existing?.discord?.statusDisplayType ? { discordStatusDisplayType: existing.discord.statusDisplayType } : {}), ...(existing?.hosted ? { hostedEnabled: existing.hosted.enabled, ...(existing.hosted.url ? { hostedUrl: existing.hosted.url } : {}) } : {}), ...(existing?.privacy ? { privacy: existing.privacy } : {}), ...(existing?.card ? { card: existing.card } : {}), diff --git a/src/setup-app.js b/src/setup-app.js index 7c50f015..5d788b58 100644 --- a/src/setup-app.js +++ b/src/setup-app.js @@ -76,6 +76,8 @@ function keptSettings(current) { return { ...(typeof current.discord?.artworkUpload === "boolean" ? { discordArtworkUpload: current.discord.artworkUpload } : {}), ...(current.discord?.timestamps !== undefined ? { discordTimestamps: current.discord.timestamps } : {}), + ...(current.discord?.name ? { discordName: current.discord.name } : {}), + ...(current.discord?.statusDisplayType ? { discordStatusDisplayType: current.discord.statusDisplayType } : {}), ...(current.hosted ? { hostedEnabled: current.hosted.enabled, ...(current.hosted.url ? { hostedUrl: current.hosted.url } : {}) } : {}), ...(current.privacy ? { privacy: current.privacy } : {}), ...(current.card ? { card: current.card } : {}), diff --git a/src/setup-config.js b/src/setup-config.js index e123741b..32345a00 100644 --- a/src/setup-config.js +++ b/src/setup-config.js @@ -2,9 +2,11 @@ import { createProviderIdentity } from "./provider-identity.js"; import { isFontStack } from "./card.js"; import { isServerUrl } from "./setup.js"; import { normalizeHostedUrl } from "./hosted-uploader.js"; +import { validateTemplate } from "./template.js"; const PROVIDERS = new Set(["plex", "jellyfin", "navidrome", "emby"]); const ARTWORK_LOOKUPS = new Set(["off", "musicbrainz"]); +const STATUS_DISPLAY_TYPES = new Set(["name", "state", "details"]); const TIMESTAMP_MODES = new Set(["elapsed", "remaining", "both", "none"]); const PRIVACY_FLAGS = ["redactTitles", "hideArtwork", "hideProgress"]; const PRIVACY_KINDS = new Set(["movie", "episode", "track"]); @@ -124,6 +126,13 @@ export function createSetupConfig(input = {}) { if (input.discordTimestamps !== undefined && !TIMESTAMP_MODES.has(input.discordTimestamps)) { throw new TypeError("setup config.discordTimestamps must be elapsed, remaining, both or none"); } + if (input.discordName !== undefined && (typeof input.discordName !== "string" || input.discordName.length > 128)) { + throw new TypeError("setup config.discordName must be a string of at most 128 characters"); + } + if (input.discordName !== undefined) validateTemplate(input.discordName, "setup config.discordName"); + if (input.discordStatusDisplayType !== undefined && !STATUS_DISPLAY_TYPES.has(input.discordStatusDisplayType)) { + throw new TypeError("setup config.discordStatusDisplayType must be name, state or details"); + } if (input.hostedEnabled !== undefined && typeof input.hostedEnabled !== "boolean") { throw new TypeError("setup config.hostedEnabled must be a boolean"); } @@ -151,6 +160,9 @@ export function createSetupConfig(input = {}) { // Discord timer (set from the settings page): elapsed, remaining, both // or none. Left out means "both", so setup's own output is unchanged. ...(input.discordTimestamps !== undefined ? { timestamps: input.discordTimestamps } : {}), + // The Discord header text template and which field it shows (left out = defaults). + ...(input.discordName ? { name: input.discordName } : {}), + ...(input.discordStatusDisplayType !== undefined && input.discordStatusDisplayType !== "name" ? { statusDisplayType: input.discordStatusDisplayType } : {}), }), // Hosted card upload (#140) is off unless the user turns it on. ...(input.hostedEnabled !== undefined || hostedUrl ? { diff --git a/test/app-settings.test.js b/test/app-settings.test.js index 4822736a..44fdb22f 100644 --- a/test/app-settings.test.js +++ b/test/app-settings.test.js @@ -18,7 +18,7 @@ async function configFile(input = JELLYFIN) { test("shows the Discord settings with defaults for older configs", () => { const config = parseAppConfig(serializeSetupConfig({ ...JELLYFIN, discordArtworkLookup: undefined })); - assert.deepEqual({ ...discordSettingsView(config) }, { enabled: true, timestamps: "both", artworkLookup: "off", artworkUpload: false, idleBehavior: "clear" }); + assert.deepEqual({ ...discordSettingsView(config) }, { enabled: true, timestamps: "both", artworkLookup: "off", artworkUpload: false, idleBehavior: "clear", name: "", statusDisplayType: "name" }); }); test("applies Discord changes and keeps everything else", () => { @@ -74,7 +74,7 @@ test("the running app saves Discord settings from its own page only", async () = assert.equal(page.status, 200); assert.match(page.headers.get("content-security-policy"), /script-src 'self'/); const cookie = page.headers.get("set-cookie").split(";")[0]; - assert.deepEqual((await (await fetch(`${app.url}/api/settings`)).json()).discord, { enabled: true, timestamps: "both", artworkLookup: "musicbrainz", artworkUpload: false, idleBehavior: "clear" }); + assert.deepEqual((await (await fetch(`${app.url}/api/settings`)).json()).discord, { enabled: true, timestamps: "both", artworkLookup: "musicbrainz", artworkUpload: false, idleBehavior: "clear", name: "", statusDisplayType: "name" }); const put = (headers, body = { discord: { enabled: false, timestamps: "elapsed" } }) => fetch(`${app.url}/api/settings`, { method: "PUT", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body) }); // No session cookie, or a request from another site: refused, nothing saved. assert.equal((await put({})).status, 403); @@ -85,7 +85,7 @@ test("the running app saves Discord settings from its own page only", async () = assert.equal((await put({ Cookie: cookie }, { discord: { enabled: true }, hosted: { enabled: true } })).status, 400); const saved = await put({ Cookie: cookie }); assert.equal(saved.status, 200); - assert.deepEqual((await saved.json()).discord, { enabled: false, timestamps: "elapsed", artworkLookup: "musicbrainz", artworkUpload: false, idleBehavior: "clear" }); + assert.deepEqual((await saved.json()).discord, { enabled: false, timestamps: "elapsed", artworkLookup: "musicbrainz", artworkUpload: false, idleBehavior: "clear", name: "", statusDisplayType: "name" }); assert.equal(parseAppConfig(await readFile(file, "utf8")).discord.timestamps, "elapsed"); const refresh = await fetch(`${app.url}/api/settings/discord/refresh-artwork`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: "{}" }); assert.deepEqual([refresh.status, await refresh.json()], [200, { dropped: 0 }]); diff --git a/test/discord-name-settings.test.js b/test/discord-name-settings.test.js new file mode 100644 index 00000000..fecd7ac6 --- /dev/null +++ b/test/discord-name-settings.test.js @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyDiscordChanges, discordSettingsView } from "../src/app-settings.js"; +import { parseAppConfig, startDiscordFromConfig } from "../src/app-config.js"; +import { serializeSetupConfig } from "../src/setup-config.js"; +import { createSettingsPageHandler } from "../src/settings-page-handler.js"; + +const BASE = { provider: "jellyfin", serverUrl: "http://127.0.0.1:8096", identity: { id: "u1", displayName: "Rowan" }, credentialStored: true }; +const song = { state: "playing", kind: "track", title: "Mr. Chow", subtitle: "fakemink", artist: "fakemink", provider: "spotify", positionMs: 1000, durationMs: 5000, updatedAt: "2026-10-02T12:00:00Z" }; + +test("the status text and which field Discord shows are saved, kept and read back", () => { + const before = parseAppConfig(serializeSetupConfig(BASE)); + assert.equal(discordSettingsView(before).name, ""); + assert.equal(discordSettingsView(before).statusDisplayType, "name"); + const { config, text } = applyDiscordChanges(before, { name: "{artist} on {service}!", statusDisplayType: "state" }); + assert.equal(config.discord.name, "{artist} on {service}!"); + assert.equal(config.discord.statusDisplayType, "state"); + const again = applyDiscordChanges(parseAppConfig(text), { enabled: false }); + assert.equal(again.config.discord.name, "{artist} on {service}!"); + const cleared = applyDiscordChanges(config, { name: "", statusDisplayType: "name" }); + assert.equal("name" in cleared.config.discord, false); + assert.equal("statusDisplayType" in cleared.config.discord, false); + assert.throws(() => applyDiscordChanges(before, { statusDisplayType: "title" }), /statusDisplayType/); + assert.throws(() => applyDiscordChanges(before, { name: "x".repeat(129) }), /name/); +}); + +test("a saved status text reaches the Discord wire payload", async () => { + const sets = []; + const transport = { connect: async () => {}, setActivity: async (a) => sets.push(a), clearActivity: async () => {}, close: async () => {} }; + const config = { discord: { enabled: true, idleBehavior: "clear", name: "{artist} @ {service}", statusDisplayType: "details" } }; + const d = startDiscordFromConfig(config, { getPresence: async () => song }, { env: { NOWPLAYING_DISCORD_CLIENT_ID: "123456789012345678" }, createTransport: () => transport, intervalMs: 60_000 }); + await d.stop(); + assert.equal(sets[0].name, "fakemink @ Spotify"); + assert.equal(sets[0].statusDisplayType, "details"); +}); + +test("the page has the status text field with its tokens and brace help", async () => { + const h = createSettingsPageHandler({ settings: { read: () => ({ discord: { enabled: true } }), updateDiscord: async () => {} }, fallback: async () => ({ status: 299 }) }); + const page = (await h({ url: "/settings" })).body; + assert.match(page, /