From 0b365329220d89ac15171ecc01a039761bb56530 Mon Sep 17 00:00:00 2001 From: Silent Mode Date: Sat, 3 Oct 2026 15:19:09 +0200 Subject: [PATCH] =?UTF-8?q?Theseus:=20translator=20peers=20list=20?= =?UTF-8?q?=E2=80=94=20fall=20back=20when=20a=20mirror=20is=20down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip ran against a single endpoint, which is the fastest way to go dark: libretranslate.com's public tier moved behind an API key in late 2026, and most of the historical public mirrors (libretranslate.de, argosopentech, lt.vern.cc, translate.terraprint.co) either 502 at any given time, serve a parked page, or started requiring a key of their own. One URL in settings meant one of those going down meant the chip stopped working. Settings.translateEndpoints is now an ordered list. The translator tries each peer in order and returns the first non-error answer; a dead peer is logged and skipped. Order is preserved — the first entry is the primary. Shipped defaults put Silent Mode's own instances (translate.silentmode.st, the BNS name translate.x) at the top and keep libretranslate.com as the last-resort entry; neither Silent Mode URL answers today, but a user's chip starts working as soon as either goes live without a browser release. Settings › General › Translate pages grew a list editor (same shape as the quick-links one): PRIMARY tag on row 0, add a peer, remove any row; bns:// URLs are accepted so a BNS translator doesn't have to be fronted by an https host. The single-URL `translateEndpoint` setting carried over from the earlier draft is migrated on load — a custom URL goes to the front of the list, the historical default is dropped. --- main.js | 58 ++++++++++++++++++++++++++++++++++++++++++----- settings.html | 62 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 110 insertions(+), 10 deletions(-) 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 @@
Offer to translate
Light up a translate chip in the address bar when the page's declared language is different from the one you picked above.
-
-
Translation engine
LibreTranslate-compatible endpoint. The public free tier is rate-limited; point this at a self-hosted LibreTranslate for unlimited use and privacy. On-device Bergamot (WASM) is coming in a later release.
- +
+
Translation peers
+
Ordered list of LibreTranslate-compatible endpoints. The first one that answers wins; a dead or rate-limited peer is skipped and the next is tried. Keep Silent Mode's own instances at the top; add your own self-hosted LibreTranslate for privacy or unlimited use. On-device Bergamot (WASM) is coming in a later release.
+
+
+ + +
-
API key (optional)
Some LibreTranslate instances need an API key for the paid tier or to raise the rate limit. Leave blank for the public endpoint.
+
API key (optional)
Shared across all peers in the list. Some LibreTranslate instances need a key for the paid tier or to raise the rate limit; leave blank for peers that don't.

Startup

@@ -1042,7 +1047,7 @@ // Text inputs that round-trip through settings-set on change. Trimmed; a // cleared field writes an empty string, which main re-defaults from // SETTINGS_DEFAULTS on next launch. - const TEXT_FIELDS = ["translateEndpoint", "translateApiKey"]; + const TEXT_FIELDS = ["translateApiKey"]; C.get().then((s) => { for (const k of TOGGLES) { const el = document.getElementById(k); if (!el) continue; @@ -1101,6 +1106,53 @@ // Live-sync from main (edits from other surfaces, or the strip's own add). if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.quickLinks || [])); })(); + // ---- Translation-peer list editor (General) --------------------------- + // Same shape as the Quick-links editor above: add a row, trash a row, + // each change writes the whole array. The chip + translator service read + // the list in order and skip dead peers; the top entry is the primary. + (function () { + const list = document.getElementById("translateEndpointList"); + const urlIn = document.getElementById("translatePeerUrl"); + const addBtn = document.getElementById("translatePeerAdd"); + if (!list || !addBtn) return; + const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">","\"":""","'":"'" })[c]); + function hostOf(u) { try { return new URL(u).host; } catch { return u; } } + function render(peers) { + list.innerHTML = ""; + if (!peers.length) { + const empty = document.createElement("div"); + empty.className = "pmuted"; empty.style.fontSize = "12.5px"; + empty.textContent = "No peers configured — add one below, or restart to restore the defaults."; + list.appendChild(empty); + return; + } + for (let i = 0; i < peers.length; i++) { + const url = peers[i]; + const row = document.createElement("div"); + row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px"; + const primary = i === 0 ? 'PRIMARY' : ''; + row.innerHTML = `
${primary}${esc(hostOf(url))}
${esc(url)}
+ `; + list.appendChild(row); + } + list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => { + const i = Number(b.dataset.del); + const cur = (await C.get()).translateEndpoints || []; + C.set("translateEndpoints", cur.filter((_, idx) => idx !== i)); + }); + } + render(s.translateEndpoints || []); + addBtn.onclick = async () => { + const u = String(urlIn.value || "").trim(); + if (!u) return; + const url = /^https?:\/\//i.test(u) || /^bns:\/\//i.test(u) ? u : "https://" + u; + const cur = (await C.get()).translateEndpoints || []; + if (cur.includes(url)) { urlIn.value = ""; return; } + C.set("translateEndpoints", [...cur, url]); + urlIn.value = ""; + }; + if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.translateEndpoints || [])); + })(); // ---- website language (General page) — friendly wrapper over the // same languageMode/languageValue setting the Anti-fingerprinting Language // row edits. "Auto" = languageMode="show" (follow OS); anything else