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
4 changes: 3 additions & 1 deletion src/app-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
// "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 } : {}),
Expand Down Expand Up @@ -220,7 +222,7 @@
// 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() {
Expand Down Expand Up @@ -280,245 +282,245 @@
});
}

export async function startAppFromConfig({ configFile, credentialStore, host = "127.0.0.1", port = DEFAULT_APP_PORT, platform = process.platform, fetchImpl = fetch, discord: discordOptions = {}, version = null, build = null, packageType = null, hostedCredentials, hosted: hostedOptions = {}, safeMode = false, logFile = null, startup = null, providerBackoff = {}, requestSetup = null, deviceId = null, shutdown = null, onConfigured = async () => {}, discoverServers, signIn, spotifySignIn, hostedSignIn, createServer = createHttpServer } = {}) {
if (typeof credentialStore?.read !== "function") throw new TypeError("credentialStore.read is required");
let config;
try { config = await loadAppConfig(configFile); }
catch (error) {
if (error?.startupCode !== "CONFIG_MISSING" || !configFile) throw error;
if (typeof credentialStore.save !== "function" || !/^[A-Za-z0-9_-]{8,128}$/.test(deviceId ?? "")) throw new StartupError("SETUP_UNAVAILABLE", "A credential store and stable device ID are needed to connect a server. Run `nowplaying setup` or install the desktop app.");
const services = createSettingsConnectedServices({ file: configFile, credentialStore, hostedCredentials, onConfigured, fetchImpl, ...(spotifySignIn ? { spotifySignIn } : {}), ...(hostedSignIn ? { hostedSignIn } : {}) });
const management = createSettingsServers({ file: configFile, credentialStore, deviceId, version: version ?? "0", onConfigured, fileQueue: services.serial, prepareConfig: services.prepareFirstServer, ...(discoverServers ? { discover: discoverServers } : {}), ...(signIn ? { signIn } : {}) });
const handler = createFirstRunSettingsHandler({ servers: async (request) => (await services.handler(request)) ?? management.handler(request) });
const server = createServer({ host, port, handler, sessionSecret: randomBytes(32).toString("base64url") });
let address;
try { address = await server.listen(); }
catch (listenError) { if (listenError?.code === "EADDRINUSE") throw new StartupError("PORT_IN_USE", `Port ${port} is already in use.`); throw listenError; }
const authority = address.family === "IPv6" ? `[${address.address}]` : address.address;
return Object.freeze({ firstRun: true, url: `http://${authority}:${address.port}`, close: () => server.close() });
}
// Safe mode (#122) is offline: no sign-in read and no server polling, so a
// broken sign-in or an unreachable server can't keep the start crashing. The
// card shows idle; the status, settings and logs pages still work.
// A failing server is retried with backoff (#153), shared by the card,
// Discord, hosted uploads and the status page.
// Every signed-in server is polled (#252); the first one's sign-in must
// work as before, the others show as unavailable on the status page instead
// of stopping the start.
const multi = safeMode ? null : await createServersProvider(config, credentialStore, fetchImpl, providerBackoff);
const provider0 = safeMode ? OFFLINE_PROVIDER : multi;
const status = createAppStatus({ config, version, build, packageType, safeMode });
const tracked = safeMode ? provider0 : status.wrapProvider(provider0);
if (multi) status.setServers(() => multi.servers());
if (multi) status.setCardArtwork(() => multi.artwork.status());
let current = config;
// The status page (local only) sees what's really playing; the card,
// Discord and hosted uploads get the privacy-filtered version.
const provider = withPrivacy(tracked, () => current);
// Spotify (#135) feeds the local card, its preview and the hosted card,
// never Discord (Rowan's call). When it and the media server are both
// playing, whichever started most recently shows. A Spotify problem never
// stops the start; it just isn't shown.
let spotify = null;
if (!safeMode && config.spotify) {
try { spotify = createSpotifySource(config, credentialStore, { fetchImpl, backoff: providerBackoff }); } catch { spotify = null; }
}
// YouTube (#136): the browser extension posts to /bridge/youtube. Same
// rules as Spotify: card only, never Discord, most recent start wins, and
// a problem here never stops the start.
let youtube = null;
if (!safeMode) {
try {
const token = await loadYouTubePairingToken(credentialStore);
youtube = { bridge: createYouTubeBridge({ token }), token };
} catch { youtube = null; }
}
let cardSource = tracked;
if (spotify) cardSource = combinePresence({ primary: cardSource, secondary: spotify });
if (youtube) cardSource = combinePresence({ primary: cardSource, secondary: youtube.bridge.provider });
const cardProvider = cardSource === tracked ? provider : withPrivacy(cardSource, () => current);
// Discord gets YouTube too (Rowan, #136), never Spotify. A plain YouTube
// video shows as "Watching"; YouTube Music stays "Listening". Shorts never
// reach the bridge.
const discordProvider = youtube ? withPrivacy(combinePresence({ primary: tracked, secondary: youtubeForDiscord(youtube.bridge.provider) }), () => current) : provider;
const resolveCard = createResilientCardResolver({ resolveCard: createCardPipeline({ provider: cardProvider, artworkService: multi?.artwork, defaults: () => cardRenderOptions(current.card) }), diagnostics: true });
let discord;
// Safe mode (#122, after repeated failed starts): only the local card and
// status page run. Discord and hosted uploads, which poll in the background,
// stay off until the user retries a normal start, even if Discord settings
// are changed from the settings page meanwhile.
const paused = Object.freeze({ status: "safe_mode", stop: async () => {}, cardUrl: async () => null, connection: () => null, refreshArtwork: async () => 0 });
const offHosted = Object.freeze({ status: "off", stop: async () => {}, cardUrl: async () => null, connection: () => null });
let hosted = offHosted;
const launchHosted = (settings) => {
if (safeMode) return paused;
try { return startHostedFromConfig(settings, cardProvider, { credentials: hostedCredentials, fetchImpl, settings: () => hostedUploadSettings(current), ...hostedOptions }); }
catch { return Object.freeze({ status: "failed", stop: async () => {}, cardUrl: async () => null, connection: () => null }); }
};
const launchDiscord = (settings) => {
if (safeMode) return paused;
try { return startDiscordFromConfig(settings, discordProvider, { ...(multi?.artwork ? { coverSource: (ref) => multi.artwork.resolve(ref) } : {}), fetchImpl, ...discordOptions }); }
catch { return Object.freeze({ status: "failed", stop: async () => {}, refreshArtwork: async () => 0 }); }
};
// Discord changes from the settings page are saved to config.json first,
// then the Discord loop restarts with them; no app restart needed.
const settingsStore = createAppSettingsStore({ file: configFile });
// Hosted card on/off works the same way. Disconnect stops uploads and
// revokes the device remotely before deleting its protected local key.
let revokePending = false;
const pendingRevoke = async () => revokePending || (!current.hosted?.enabled && typeof hostedCredentials?.load === "function" && Boolean(await hostedCredentials.load()));
const hostedView = async () => {
const pending = await pendingRevoke();
const view = { ...hostedSettingsView(current), state: pending ? "disconnect_pending" : hosted.status, lastSuccessAt: null, error: null, cardUrl: null };
if (pending || hosted.status !== "on") return view;
const connection = hosted.connection() ?? {};
let cardUrl = null;
try { cardUrl = await hosted.cardUrl(); } catch { cardUrl = null; }
return { ...view, state: connection.state ?? "idle", lastSuccessAt: connection.lastSuccessAt ? new Date(connection.lastSuccessAt).toISOString() : null, error: connection.lastError ?? null, cardUrl };
};
const startupView = async () => {
if (typeof startup?.isEnabled !== "function") return { available: false, enabled: false };
try {
const state = typeof startup.status === "function" ? await startup.status() : { enabled: await startup.isEnabled(), broken: false };
return { available: true, enabled: state.enabled, ...(state.broken ? { broken: true } : {}) };
}
catch { return { available: false, enabled: false }; }
};
const settings = Object.freeze({
read: async () => ({ discord: discordSettingsView(current), hosted: await hostedView(), startup: await startupView(), privacy: privacySettingsView(current), card: cardSettingsView(current) }),
// Startup launch is the platform autostart entry, not config.json:
// setup and this page change the same entry.
async updateStartup(changes) {
if (!changes || typeof changes !== "object" || Array.isArray(changes) || Object.keys(changes).length !== 1 || typeof changes.enabled !== "boolean") throw new TypeError("startup settings: expected enabled");
if (!startup) throw new TypeError("startup settings: not available");
await startup.setEnabled(changes.enabled);
},
async updateDiscord(changes) {
const next = await settingsStore.updateDiscord(changes);
current = next;
await discord.stop().catch(() => {});
discord = launchDiscord(next);
},
// Privacy applies on the next poll; Discord restarts so it updates now
// and drops any title lookup.
async updatePrivacy(changes) {
const next = await settingsStore.updatePrivacy(changes);
current = next;
resolveCard.invalidate();
if (hosted.status === "on") await hosted.privacyChanged();
await discord.stop().catch(() => {});
discord = launchDiscord(next);
},
// Card appearance (#94): discard prior rendered SVGs after a save so
// an offline last-good card cannot present the old design as current.
async updateCard(changes) {
const next = await settingsStore.updateCard(changes);
current = next;
resolveCard.invalidate();
},
// Preview for the settings page: what's playing now (privacy applied),
// or a sample track when nothing is, so layout changes are visible.
async previewCard(card) {
let presence = null;
try { presence = await cardProvider.getPresence(); } catch { presence = null; }
if (!presence || presence.state === "idle") presence = applyPrivacy(PREVIEW_SAMPLE, privacyPolicyFromConfig(current));
// A plain grey square stands in for artwork so placement and size
// show in the preview; real art is fetched for /card.svg (#449).
const svg = renderCard(presence, { ...cardRenderOptions(card), artworkDataUri: PREVIEW_ARTWORK });
const automaticWidth = presence.kind === "track" ? 100 : 68;
return svg.replace("<svg ", `<svg data-preview-artwork-width="${card.artworkWidth ?? automaticWidth}" `);
},
// Drops cached album art and updates Discord straight away (#154).
refreshArtwork: () => discord.refreshArtwork(),
// Servers (#253): adding or removing a server runs through setup. The
// host (the Windows tray session) opens it and restarts the app after.
...(typeof requestSetup === "function" ? { openSetup: () => { requestSetup(); } } : {}),
// Pairing token for the YouTube extension (#136): shown on the settings
// page for pasting into the extension. Reset makes a new one and cuts
// off the old extension.
...(youtube ? {
youtubePairing: async () => ({ token: youtube.token }),
async resetYouTubePairing() {
const token = await resetYouTubePairingToken(credentialStore);
youtube.bridge.setToken(token);
youtube.token = token;
return { token };
},
} : {}),
async updateHosted(changes) {
if (changes?.enabled === true && await pendingRevoke()) throw new Error("hosted disconnect is pending");
const next = await settingsStore.updateHosted(changes);
current = next;
await hosted.stop().catch(() => {});
hosted = launchHosted(next);
},
async disconnectHosted() {
const running = hosted;
await running.stop().catch(() => {});
hosted = offHosted;
// Pause future uploads durably before attempting remote deletion. On a
// network failure the card can still be live remotely; preserve the
// protected revocation key and let Disconnect this PC retry later.
if (current.hosted?.enabled) {
try { current = await settingsStore.updateHosted({ enabled: false }); }
catch (error) { revokePending = true; throw error; }
}
try {
if (typeof running.disconnect === "function") await running.disconnect();
else if (typeof hostedCredentials?.load === "function") await createHostedUploader({ baseUrl: current.hosted?.url ?? DEFAULT_HOSTED_URL, credentials: hostedCredentials, fetchImpl }).disconnect();
revokePending = false;
} catch (error) { revokePending = true; throw error; }
},
});
const statusHandler = createStatusPageHandler({ status, fallback: createCardHandler({ resolveCard, cacheControl: "no-store" }), ...(shutdown ? { shutdown } : {}) });
// The Logs page reads the app log (no log file, e.g. a dev checkout: empty).
const logsHandler = createLogsPageHandler({ readEvents: () => readLogTail(logFile), fallback: statusHandler });
// Hosted card devices on the settings page (#140).
const devicesHandler = createHostedDevicesHandler({
getClient: () => (typeof hostedCredentials?.load === "function" ? createHostedDevicesClient({ baseUrl: current.hosted?.url ?? DEFAULT_HOSTED_URL, credentials: hostedCredentials, fetchImpl }) : null),
onSignedOut: () => settings.disconnectHosted(),
fallback: logsHandler,
});
const services = typeof credentialStore.save === "function" ? createSettingsConnectedServices({ file: configFile, credentialStore, hostedCredentials, onConfigured, settingsStore, fetchImpl, ...(spotifySignIn ? { spotifySignIn } : {}), ...(hostedSignIn ? { hostedSignIn } : {}) }) : null;
const management = deviceId && typeof credentialStore.save === "function"
? createSettingsServers({ file: configFile, credentialStore, deviceId, version: version ?? "0", onConfigured, fileQueue: settingsStore.serial, ...(discoverServers ? { discover: discoverServers } : {}), ...(signIn ? { signIn } : {}) })
: null;
const managementFallback = async (request) => (await services?.handler(request)) ?? (await management?.handler(request)) ?? devicesHandler(request);
const pageHandler = createSettingsPageHandler({ settings, fallback: managementFallback, platform });
const handler = youtube ? createYouTubeBridgeHandler({ bridge: youtube.bridge, fallback: pageHandler }) : pageHandler;
// Saves need the cookie the app's own pages set, so another local program
// or web page can't change settings.
// The bridge checks its own pairing token and extension origin, so it
// skips the session check the settings pages use.
// The shutdown route authenticates with its own bearer token (#780), so
// it skips the WebUI session check the way the YouTube bridge does.
const writePaths = [...(youtube ? [YOUTUBE_BRIDGE_PATH] : []), ...(shutdown ? [SHUTDOWN_PATH] : [])];
const server = createServer({ host, port, handler, sessionSecret: randomBytes(32).toString("base64url"), openWritePaths: writePaths });
let address;
try {
address = await server.listen();
} catch (error) {
if (error?.code === "EADDRINUSE") throw new StartupError("PORT_IN_USE", `Port ${port} is already in use. Close the other program using it and try again.`);
// Reserved ports and OS permissions can reject a bind on any platform.
if (error?.code === "EACCES") throw new StartupError("PORT_BLOCKED", `The OS won't let NowPlaying use port ${port}. Set NOWPLAYING_PORT to another port (1024 to 65535) and try again.`);
throw new StartupError("SERVER_START_FAILED", "Couldn't start the local card server.");
}
const authority = address.family === "IPv6" ? `[${address.address}]` : address.address;
discord = launchDiscord(config);
status.setDiscord(() => discord.status === "on"
? { enabled: true, ...discord.connection() }
: { enabled: discord.status !== "off", state: discord.status });
hosted = launchHosted(config);
status.setHosted(() => hosted.status === "on"
? { enabled: true, ...hosted.connection() }
: { enabled: hosted.status !== "off", state: hosted.status });
const close = async () => {
await hosted.stop().catch(() => {});
await discord.stop().catch(() => {});
await server.close();
};
return Object.freeze({ config, servers: () => (multi ? multi.servers() : Object.freeze([])), url: `http://${authority}:${address.port}`, get discord() { return discord.status; }, get hosted() { return hosted.status; }, hostedCardUrl: () => hosted.cardUrl(), refreshArtwork: () => discord.refreshArtwork(), safeMode, status, close });
}

Check notice on line 523 in src/app-config.js

View check run for this annotation

codefactor.io / CodeFactor

src/app-config.js#L285-L523

Complex Method

export function classifyArtworkFailure(error) {
const message = String(error?.message ?? "");
Expand Down
10 changes: 9 additions & 1 deletion src/app-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
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"]]);
Expand All @@ -20,6 +21,8 @@
artworkLookup: config.discord?.artworkLookup ?? "off",
artworkUpload: config.discord?.artworkUpload !== false,
idleBehavior: config.discord?.idleBehavior ?? "clear",
name: config.discord?.name ?? "",
statusDisplayType: config.discord?.statusDisplayType ?? "name",
});
}

Expand All @@ -42,37 +45,37 @@
// shows them draws the same card as a config without a card section.
const CARD_KEYS = new Set(["theme", "width", "padding", "radius", "progressHeight", "showProgress", "artworkPosition", "artworkWidth", "artworkHeight", "fieldOrder", "textAlign", "progressPosition", "progressWidth", "direction", "artworkTint", "fontFamily", "statusStyle", "artShape", "progressStyle", "border", "background", "fontStack"]);
const AUTO_CARD_KEYS = ["artworkWidth", "artworkHeight"];
export function cardSettingsView(config) {
const card = config.card ?? {};
const theme = card.theme ?? "midnight-blue";
return Object.freeze({
theme,
width: card.width ?? 440,
padding: card.padding ?? 24,
radius: card.radius ?? 10,
progressHeight: card.progressHeight ?? 4,
// The compact theme hides progress unless it's turned on.
showProgress: card.showProgress ?? theme !== "compact",
artworkPosition: card.artworkPosition ?? "left",
// null means automatic: the renderer picks by media kind (square for
// music since #445), so a save never pins the old 68x100 default.
artworkWidth: card.artworkWidth ?? null,
artworkHeight: card.artworkHeight ?? null,
fieldOrder: card.fieldOrder ?? ["state", "title", "subtitle"],
textAlign: card.textAlign ?? "start",
progressPosition: card.progressPosition ?? "bottom",
progressWidth: card.progressWidth ?? "content",
direction: card.direction ?? "ltr",
artworkTint: card.artworkTint ?? true,
fontFamily: card.fontFamily ?? "system",
statusStyle: card.statusStyle ?? "plain",
artShape: card.artShape ?? "square",
progressStyle: card.progressStyle ?? "square",
border: card.border ?? "thin",
background: card.background ?? "solid",
fontStack: card.fontStack ?? "",
});
}

Check notice on line 78 in src/app-settings.js

View check run for this annotation

codefactor.io / CodeFactor

src/app-settings.js#L48-L78

Complex Method

function checkChanges(changes, allowed, name) {
if (!changes || typeof changes !== "object" || Array.isArray(changes)) throw new TypeError(`${name} settings: expected an object`);
Expand All @@ -91,6 +94,8 @@
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 } : {}),
Expand All @@ -104,6 +109,9 @@
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 } });
}

Expand Down
6 changes: 3 additions & 3 deletions src/discord-presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 19 additions & 7 deletions src/settings-page-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@
<option value="show">Show that nothing is playing</option>
<option value="recent">Show what I played last</option>
</select></p>
<p class="row"><label for="discord-name">Status text</label>
<input type="text" id="discord-name" name="name" maxlength="128" placeholder="{artist} on {service}" aria-describedby="discord-name-help"></p>
<p class="hint" id="discord-name-help">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 }}.</p>
<p class="row"><label for="discord-status-type">Status shows</label>
<select id="discord-status-type" name="statusDisplayType">
<option value="name">The status text above</option>
<option value="state">The second line (artist or episode)</option>
<option value="details">The first line (title)</option>
</select></p>
<p class="row"><label for="discord-upload">Show my server's cover</label>
<input type="checkbox" id="discord-upload" name="artworkUpload" aria-describedby="discord-upload-help"></p>
<p class="hint" id="discord-upload-help">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.</p>
Expand Down Expand Up @@ -481,11 +490,11 @@

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 {
Expand All @@ -510,13 +519,13 @@
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();
Expand Down Expand Up @@ -961,65 +970,68 @@
if (typeof result?.token !== "string") return json(500, { error: "read_failed" });
return json(200, { token: result.token });
}
return async function handle(request) {
const method = request?.method || "GET";
const url = new URL(request?.url || "/", "http://localhost");
const asset = assets[url.pathname];
if (url.pathname.startsWith("/api/info/")) {
const name = url.pathname.slice("/api/info/".length);
if (!Object.hasOwn(infoFiles, name)) return response(404, "Not Found");
if (method !== "GET" && method !== "HEAD") return response(405, "Method Not Allowed", { Allow: "GET, HEAD" });
const site = header(request?.headers, "sec-fetch-site");
if (site !== undefined && !SAFE_FETCH_SITES.has(String(site).toLowerCase())) return response(403, "Forbidden");
const text = infoFiles[name];
return text === null ? response(503, "File unavailable") : response(200, method === "HEAD" ? "" : text, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
}
const disconnect = url.pathname === "/api/settings/hosted/disconnect";
const refresh = url.pathname === "/api/settings/discord/refresh-artwork";
if (url.pathname === PREVIEW_PATH) return preview(request, method, url);
if (url.pathname === OPEN_SETUP_PATH) return openSetup(request, method);
if (url.pathname === YOUTUBE_PAIRING_PATH || url.pathname === YOUTUBE_PAIRING_RESET_PATH) return youtubePairing(request, method, url.pathname === YOUTUBE_PAIRING_RESET_PATH);
if (!asset && url.pathname !== "/api/settings" && !disconnect && !refresh) return fallback(request);
if (asset) {
if (method !== "GET" && method !== "HEAD") return response(405, "Method Not Allowed", { Allow: "GET, HEAD" });
const result = response(200, method === "HEAD" ? "" : asset.body, { "Content-Type": asset.type, "Cache-Control": "no-store" });
return asset.page ? { ...result, page: true } : result;
}
const action = disconnect || refresh;
if (action ? method !== "POST" : method !== "GET" && method !== "PUT") return response(405, "Method Not Allowed", { Allow: action ? "POST" : "GET, PUT" });
// Settings are 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");
if (method === "GET") return json(200, await read());
if (refresh) {
if (typeof settings.refreshArtwork !== "function") return response(404, "Not Found");
let dropped;
try { dropped = await settings.refreshArtwork(); } catch { return json(500, { error: "refresh_failed" }); }
return json(200, { dropped: Number.isInteger(dropped) && dropped >= 0 ? dropped : 0 });
}
if (disconnect) {
if (typeof settings.disconnectHosted !== "function") return response(404, "Not Found");
try { await settings.disconnectHosted(); } catch { return json(502, { error: "disconnect_failed" }); }
return json(200, await read());
}
let input;
try {
if (typeof request.body !== "string" || Buffer.byteLength(request.body) > MAX_BODY) throw new Error("body");
input = JSON.parse(request.body);
} catch {
return json(400, { error: "invalid_json" });
}
const keys = input && typeof input === "object" && !Array.isArray(input) ? Object.keys(input) : [];
if (keys.length !== 1 || !Object.hasOwn(SECTIONS, keys[0]) || typeof settings[SECTIONS[keys[0]]] !== "function") return json(400, { error: "invalid_settings" });
try {
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" });
}
return json(200, await read());
};

Check notice on line 1034 in src/settings-page-handler.js

View check run for this annotation

codefactor.io / CodeFactor

src/settings-page-handler.js#L973-L1034

Complex Method
}

function json(status, value) { return response(status, JSON.stringify(value), { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); }
Expand Down
2 changes: 2 additions & 0 deletions src/settings-servers.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
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 } : {}),
Expand Down Expand Up @@ -94,70 +96,70 @@
scheduleRestart();
},
});
async function handler(request = {}) {
const url = new URL(request.url || "/", "http://127.0.0.1");
const method = request.method ?? "GET";
if (!["/api/settings/servers", "/api/settings/servers/discover", "/api/settings/servers/cancel", "/api/setup/signin"].includes(url.pathname)) return null;
if (url.pathname === "/api/setup/signin") {
const site = request.headers?.["sec-fetch-site"];
if (site !== undefined && !SAFE.has(String(site).toLowerCase())) return json(403, { error: "forbidden" });
return signin(request);
}
const site = request.headers?.["sec-fetch-site"];
if (site !== undefined && !SAFE.has(String(site).toLowerCase())) return json(403, { error: "forbidden" });
if (url.pathname === "/api/settings/servers/discover") {
if (method !== "POST") return json(405, { error: "method_not_allowed" }, { Allow: "POST" });
if (scan) return json(409, { error: "scan_running" });
let subnet;
try { const input = JSON.parse(request.body ?? "{}"); if (!input || Object.keys(input).join() !== "subnet" || typeof input.subnet !== "string") throw Error(); subnet = subnetCandidates(input.subnet); }
catch { return json(400, { error: "invalid_subnet" }); }
const controller = new AbortController(); scan = controller;
try {
const probeFailures = [];
const found = await discover({ signal: controller.signal, hosts: subnet, onProbeFailure: (failure) => {
if (probeFailures.length < 64) probeFailures.push(failure);
} });
if (controller.signal.aborted) return json(200, { cancelled: true });
cached = found;
return json(200, { servers: cached ?? [], probeFailures });
}
catch { return controller.signal.aborted ? json(200, { cancelled: true }) : json(503, { error: "discovery_failed" }); }
finally { if (scan === controller) scan = null; }
}
if (url.pathname === "/api/settings/servers/cancel") {
if (method !== "POST") return json(405, { error: "method_not_allowed" }, { Allow: "POST" });
scan?.abort();
return json(200, { cancelled: Boolean(scan) });
}
if (method === "GET") {
const config = await read();
return json(200, { firstRun: !config, configured, activationFailed, servers: (config?.servers ?? []).map((s) => ({ provider: s.provider, id: s.identity.id, name: s.identity.displayName, baseUrl: s.serverUrl })), discovered: cached ?? [] });
}
if (method !== "DELETE") return json(405, { error: "method_not_allowed" }, { Allow: "GET, DELETE" });
let input;
try { input = JSON.parse(request.body ?? ""); } catch { return json(400, { error: "invalid_json" }); }
if (!input || Object.keys(input).sort().join(",") !== "id,provider" || !PROVIDERS.has(input.provider) || typeof input.id !== "string") return json(400, { error: "invalid_server" });
return serial(async () => {
const existing = await read();
if (!existing) return json(404, { error: "not_found" });
const removed = existing.servers.find((s) => s.provider === input.provider && s.identity.id === input.id);
const servers = existing.servers.filter((s) => s !== removed);
if (!removed) return json(404, { error: "not_found" });
if (!servers.length) return json(409, { error: "last_server" });
try { await save(servers, existing); }
catch { return json(500, { error: "save_failed" }); }
removals.set(identityKey(input.provider, input.id), ++removalSequence);
// Config is committed first: a keychain failure must not leave the app
// using a credential for a server the user has removed.
let tokenRemoved = false;
if (typeof credentialStore.remove === "function") {
try { tokenRemoved = await credentialStore.remove(removed.credentialRef); }
catch { /* The UI will tell the user to remove it from the OS store. */ }
}
scheduleRestart();
return json(200, { removed: true, tokenRemoved });
});
}

Check notice on line 162 in src/settings-servers.js

View check run for this annotation

codefactor.io / CodeFactor

src/settings-servers.js#L99-L162

Complex Method
return { handler };
}
function json(status, value, extra = {}) { return { status, headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...extra }, body: `${JSON.stringify(value)}\n` }; }
2 changes: 2 additions & 0 deletions src/setup-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
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 } : {}),
Expand All @@ -102,61 +104,61 @@
// Port 0 lets the OS pick a free port so a busy app port never blocks setup.
// `startup` (optional) manages "Start with Windows": { isEnabled(), setEnabled(bool) }.
// Without it the wizard doesn't offer the choice.
export async function startSetupApp({ draftFile, configFile, host = "127.0.0.1", port = 0, discover, credentialStore, hostedCredentials, deviceId, version, signIn: signInApi, spotifySignIn, startup, fetchImpl, discordTest: discordTestOptions } = {}) {
if (startup !== undefined && (typeof startup?.isEnabled !== "function" || typeof startup?.setEnabled !== "function")) throw new TypeError("startup is invalid");
const page = createSetupPageHandler();
// Finish writes the real config only when there is a signed-in account to
// point it at; without a credential store there is nothing to run from yet.
const writeConfig = configFile && credentialStore;
const store = withStartupState(writeConfig ? withInstalledConfig(createSetupDraftStore({ file: draftFile }), configFile) : createSetupDraftStore({ file: draftFile }), startup);
// Sign-in is only offered when a credential store is supplied, so a secret
// can never be obtained without somewhere safe to put it. Without one the
// wizard skips the sign-in step.
const onFinish = writeConfig || startup ? async (finished) => {
if (writeConfig) await writeSetupConfig(configFile, finished);
if (startup && typeof finished.startWithWindows === "boolean") await startup.setEnabled(finished.startWithWindows);
} : undefined;
const draft = createSetupDraftHandler({ store, beforeReset: async () => { await Promise.all([spotify.cancelPending?.(), signIn.cancelPending?.()]); }, signIn: Boolean(credentialStore), ...(onFinish ? { onFinish } : {}) });
// Example cards on the review step, in the installed card look if there is one.
const preview = createSetupPreviewHandler({ renderOptions: async () => (configFile ? cardRenderOptions((await readCurrentConfig(configFile))?.card) : {}) });
// "Test Discord" on the Discord step, separate from the media server test.
const discordTest = createSetupDiscordTestHandler(discordTestOptions);
// "Card hosting" step (#140): upload preview, self-hosted check, GitHub sign-in.
const hosted = createSetupHostedHandler({
settings: async () => hostedUploadSettings(configFile ? await readCurrentConfig(configFile) : null),
credentials: typeof hostedCredentials?.save === "function" ? hostedCredentials : null,
...(fetchImpl ? { fetchImpl } : {}),
});
const discovery = createSetupDiscoveryHandler(discover ? { discover } : {});
// A successful sign-in records who signed in on the draft (never the secret).
const onSignedIn = async ({ provider, identity, serverUrl }) => store.transaction(async () => {
const { draft: current } = await store.load();
await store.save({ ...current, provider, account: { provider, id: identity.id, displayName: identity.displayName, ...(serverUrl ? { serverUrl } : {}) } });
});
const signIn = credentialStore
? createSetupSignInHandler({ credentialStore, deviceId, version, onSignedIn, ...(signInApi ? { signIn: signInApi } : {}) })
: async () => null;
// Optional Spotify sign-in for the card (#135); like the media server
// sign-in it needs somewhere safe to keep the refresh token.
const onSpotifySignedIn = async ({ clientId, identity }) => store.transaction(async () => {
const { draft: current } = await store.load();
await store.save({ ...current, spotify: { clientId, identity } });
});
const spotify = credentialStore
? createSetupSpotifyHandler({ credentialStore, onSignedIn: onSpotifySignedIn, ...(spotifySignIn ? { signIn: spotifySignIn } : {}) })
: async () => null;
// "Test connection" needs the saved sign-in, so it exists only with a credential store.
const connectionTest = credentialStore?.read
? createSetupTestHandler({ store, credentialStore, ...(fetchImpl ? { fetchImpl } : {}) })
: async () => null;
// Per-run secret: the browser page gets it as a SameSite=Strict cookie, the
// native window gets it through its environment. Other local sites get neither.
const sessionSecret = randomBytes(32).toString("base64url");
const app = createHttpServer({ host, port, sessionSecret, handler: async (request) => (await page(request)) ?? (await preview(request)) ?? (await discordTest(request)) ?? (await hosted(request)) ?? (await discovery(request)) ?? (await signIn(request)) ?? (await spotify(request)) ?? (await connectionTest(request)) ?? (await draft(request)) });
const address = await app.listen();
const authority = address.family === "IPv6" ? `[${address.address}]` : address.address;
return Object.freeze({ url: `http://${authority}:${address.port}/setup`, sessionSecret, close: () => app.close() });
}

Check notice on line 161 in src/setup-app.js

View check run for this annotation

codefactor.io / CodeFactor

src/setup-app.js#L107-L161

Complex Method

// Running setup on an installed app starts from what is installed: with no
// draft in progress, the wizard opens with the configured servers, Discord
Expand Down
12 changes: 12 additions & 0 deletions src/setup-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
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"]);
Expand Down Expand Up @@ -37,37 +39,37 @@
const CARD_CHOICES = Object.freeze({ progressPosition: ["bottom", "text"], progressWidth: ["content", "full"], direction: ["ltr", "rtl", "auto"], fontFamily: ["system", "serif", "mono", "humanist"], statusStyle: ["plain", "caps", "dot"], artShape: ["square", "rounded", "circle"], progressStyle: ["square", "rounded"], border: ["none", "thin"], background: ["solid", "transparent"] });
const CARD_FIELDS = Object.freeze(["state", "title", "subtitle"]);
const LAYOUT_KEYS = Object.freeze(["padding", "radius", "progressHeight", "artworkPosition", "artworkWidth", "artworkHeight", "fieldOrder", "textAlign", "progressPosition", "progressWidth", "direction", "fontFamily", "statusStyle", "artShape", "progressStyle", "border", "background", "fontStack"]);
export function normalizeCard(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("setup config.card must be an object");
const card = {};
for (const key of Object.keys(value)) {
const item = value[key];
// An empty custom font box means "use the named font": nothing is stored.
if (key === "fontStack" && item === "") continue;
if (key === "theme") {
if (!CARD_THEMES.has(item)) throw new TypeError("setup config.card.theme must be midnight-blue, paper or compact");
} else if (key === "artworkPosition") {
if (!ARTWORK_POSITIONS.has(item)) throw new TypeError("setup config.card.artworkPosition must be left or right");
} else if (Object.hasOwn(CARD_CHOICES, key)) {
if (!CARD_CHOICES[key].includes(item)) throw new TypeError(`setup config.card.${key} must be ${CARD_CHOICES[key].join(" or ")}`);
} else if (key === "fontStack") {
if (!isFontStack(item)) throw new TypeError("setup config.card.fontStack must be up to 5 plain font names separated by commas");
} else if (key === "textAlign") {
if (!TEXT_ALIGNS.has(item)) throw new TypeError("setup config.card.textAlign must be start, middle or end");
} else if (key === "fieldOrder") {
if (!Array.isArray(item) || item.length !== 3 || new Set(item).size !== 3 || !item.every((field) => CARD_FIELDS.includes(field))) throw new TypeError("setup config.card.fieldOrder must list state, title and subtitle once each");
} else if (key === "showProgress" || key === "artworkTint") {
if (typeof item !== "boolean") throw new TypeError(`setup config.card.${key} must be a boolean`);
} else if (Object.hasOwn(CARD_NUMBERS, key)) {
const [min, max] = CARD_NUMBERS[key];
if (!Number.isInteger(item) || item < min || item > max) throw new TypeError(`setup config.card.${key} must be a whole number from ${min} to ${max}`);
} else {
throw new TypeError(`setup config.card.${key} is not a setting`);
}
card[key] = key === "fieldOrder" ? Object.freeze([...item]) : item;
}
return Object.freeze(card);
}

Check notice on line 72 in src/setup-config.js

View check run for this annotation

codefactor.io / CodeFactor

src/setup-config.js#L42-L72

Complex Method

// Renderer options for a saved card section: query options on /card.svg
// still win over these.
Expand Down Expand Up @@ -107,68 +109,78 @@
});
}

export function createSetupConfig(input = {}) {
if (input.credential !== undefined || input.token !== undefined || input.apiKey !== undefined) {
throw new TypeError("setup config cannot contain credentials");
}
if (input.credentialStored !== true) throw new TypeError("setup config requires a stored credential");
if (input.discordEnabled !== undefined && typeof input.discordEnabled !== "boolean") {
throw new TypeError("setup config.discordEnabled must be a boolean");
}
if (input.discordArtworkLookup !== undefined && !ARTWORK_LOOKUPS.has(input.discordArtworkLookup)) {
throw new TypeError("setup config.discordArtworkLookup must be off or musicbrainz");
}
if (input.discordArtworkUpload !== undefined && typeof input.discordArtworkUpload !== "boolean") {
throw new TypeError("setup config.discordArtworkUpload must be a boolean");
}
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");
}
let hostedUrl = null;
if (input.hostedUrl !== undefined && input.hostedUrl !== null) {
try { hostedUrl = normalizeHostedUrl(input.hostedUrl); } catch { throw new TypeError("setup config.hostedUrl is invalid"); }
}
const privacy = input.privacy === undefined ? null : normalizePrivacy(input.privacy);
const card = input.card === undefined ? null : normalizeCard(input.card);
const servers = normalizeServers(input);
const spotify = input.spotify === undefined || input.spotify === null ? null : normalizeSpotify(input.spotify);
const config = {
version: CONFIG_SCHEMA_VERSION,
servers,
discord: Object.freeze({
enabled: input.discordEnabled ?? true,
idleBehavior: input.discordIdleBehavior ?? "clear",
// Configs written before this field existed stay off: nothing new is
// sent until the user runs setup again or turns it on.
artworkLookup: input.discordArtworkLookup ?? "off",
// Whether the server's own cover is copied to a temporary public host. New
// setups write true; a file without the field is read as off unless it chose
// "upload" (see parseAppConfig), so an upgrade never starts sending covers.
...(typeof input.discordArtworkUpload === "boolean" ? { artworkUpload: input.discordArtworkUpload } : {}),
// 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 ? {
hosted: Object.freeze({ enabled: input.hostedEnabled ?? false, ...(hostedUrl ? { url: hostedUrl } : {}) }),
} : {}),
...(privacy ? { privacy } : {}),
...(card && Object.keys(card).length ? { card } : {}),
...(spotify ? { spotify } : {}),
};
// Until presence can choose between servers (#252), the rest of the app
// runs from the first server. These mirror servers[0] and are not
// enumerable, so they never get written back into config.json.
const primary = servers[0];
for (const key of ["provider", "serverUrl", "identity", "credentialRef"]) {
Object.defineProperty(config, key, { value: primary[key], enumerable: false });
}
return Object.freeze(config);
}

Check notice on line 183 in src/setup-config.js

View check run for this annotation

codefactor.io / CodeFactor

src/setup-config.js#L112-L183

Complex Method

// config.json schema version (v2: a list of servers, #252).
export const CONFIG_SCHEMA_VERSION = 2;
Expand Down
6 changes: 3 additions & 3 deletions test/app-settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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 }]);
Expand Down
Loading
Loading