diff --git a/main.js b/main.js index 838411b9..0aa1c80b 100644 --- a/main.js +++ b/main.js @@ -524,7 +524,20 @@ const SETTINGS_DEFAULTS = { // (same contract: a function that takes an array of strings and a target // tag, returns an array of translations). translateBackend: "libretranslate", // libretranslate | (future) bergamot - translateEndpoint: "https://libretranslate.com/translate", + // Ordered peer list — tried in sequence, the first one that answers + // wins. The free public LibreTranslate tiers go dark every few months + // (the libretranslate.com free tier moved behind an API key in late + // 2026, several mirrors 502 at any given time), so a list beats one + // endpoint: a dead mirror doesn't kill the feature, it just loses the + // round. Silent Mode's own instances (silentmode.st + the BNS name) + // come first so they take the load whenever they're up; the public + // mirrors are a safety net for when they're not. Users can edit the + // list in Settings › General › Translate pages. + translateEndpoints: [ + "https://translate.silentmode.st/translate", + "https://translate.x/translate", + "https://libretranslate.com/translate", + ], translateApiKey: "", // Light up the URL-bar translate chip when the loaded page's language // differs from the user's preferred one. Clicking the chip translates @@ -607,6 +620,19 @@ function loadSettings() { settings.quickLinks = SETTINGS_DEFAULTS.quickLinks.map((L) => ({ ...L })); } } catch (e) { console.warn("quickLinks migrate:", e?.message); } + // Translator moved from a single `translateEndpoint` to a peer list in + // the same release that shipped the chip. A custom endpoint comes along + // as the primary peer; the historical LibreTranslate.com default is + // dropped so the user picks up the fresh default list (silentmode.st + + // the BNS name + the public mirror). + try { + const legacy = typeof settings.translateEndpoint === "string" ? settings.translateEndpoint.trim() : ""; + if (legacy && legacy !== "https://libretranslate.com/translate") { + const have = Array.isArray(settings.translateEndpoints) ? settings.translateEndpoints : SETTINGS_DEFAULTS.translateEndpoints.slice(); + if (!have.includes(legacy)) settings.translateEndpoints = [legacy, ...have]; + } + if ("translateEndpoint" in settings) delete settings.translateEndpoint; + } catch (e) { console.warn("translateEndpoints migrate:", e?.message); } } function saveSettings() { try { fs.writeFileSync(settingsFile(), JSON.stringify(settings, null, 2)); } catch (e) { console.error("settings save failed:", e.message); } @@ -4543,8 +4569,7 @@ function translationTargetBase() { // LibreTranslate POST /translate. `q` may be a single string or an array; the // response's `translatedText` is a string or array to match. One POST per // call — the caller chunks when the batch would exceed the request budget. -async function translatorLibreTranslate(texts, from, to) { - const url = settings.translateEndpoint || SETTINGS_DEFAULTS.translateEndpoint; +async function translatorPostOnce(url, texts, from, to) { const body = { q: texts, source: from || "auto", @@ -4564,14 +4589,37 @@ async function translatorLibreTranslate(texts, from, to) { }); if (!r.ok) { let detail = ""; try { detail = (await r.text()).slice(0, 300); } catch {} - throw new Error(`translator HTTP ${r.status}${detail ? ": " + detail : ""}`); + throw new Error(`HTTP ${r.status}${detail ? ": " + detail : ""}`); } const data = await r.json(); if (Array.isArray(data.translatedText)) return data.translatedText; if (typeof data.translatedText === "string") return [data.translatedText]; // Some LibreTranslate deployments return a bare array of {translatedText}. if (Array.isArray(data)) return data.map((d) => d?.translatedText ?? ""); - throw new Error("translator: unexpected response shape"); + throw new Error("unexpected response shape"); +} +// The configured peer list, sanitised: a legacy single `translateEndpoint` +// migrates to list[0], an empty list falls back to the shipped defaults. +function translatorPeers() { + const list = Array.isArray(settings.translateEndpoints) ? settings.translateEndpoints : []; + const peers = list.map((s) => String(s || "").trim()).filter(Boolean); + if (peers.length) return peers; + const legacy = String(settings.translateEndpoint || "").trim(); + if (legacy) return [legacy]; + return SETTINGS_DEFAULTS.translateEndpoints.slice(); +} +// Try each peer in order; return the first good answer, or throw the last +// error. A peer that answers with a non-2xx (needs-API-key, 502, rate limit) +// is skipped. The ordering is preserved — a user who put their own instance +// first keeps it as the primary; the list is a fallback chain, not a pool. +async function translatorLibreTranslate(texts, from, to) { + const peers = translatorPeers(); + const errors = []; + for (const url of peers) { + try { return await translatorPostOnce(url, texts, from, to); } + catch (e) { errors.push(`${url}: ${e?.message || e}`); console.warn(`[translate] peer failed ${url}:`, e?.message || e); } + } + throw new Error(`all ${peers.length} translator peer(s) failed — ${errors[errors.length - 1] || "no detail"}`); } // Chunk a texts array so each POST stays under a reasonable size — LibreTranslate // instances vary (3-5 KB is a safe shared floor), and a monolithic 50-page POST diff --git a/settings.html b/settings.html index 6764bfaf..286081b9 100644 --- a/settings.html +++ b/settings.html @@ -284,12 +284,17 @@