From 44a626122b3bccba5127b539f7a2858d313d6fa6 Mon Sep 17 00:00:00 2001
From: rowkav09
+
+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, /