diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
index 4606a82..b2b2e72 100644
--- a/backend/app/api/admin.py
+++ b/backend/app/api/admin.py
@@ -349,6 +349,7 @@ class AdminTranslationOut(BaseModel):
reachable: bool
installed: list[str] = []
installing: dict[str, InstallProgressOut] = {}
+ pending: list[str] = [] # waiting to be tried again (no network last time)
available: list[str] = [] # what Mozilla has models for (empty: offline)
errors: dict[str, str] = {} # why a language's last install failed
diff --git a/backend/tests/api/test_admin_translation.py b/backend/tests/api/test_admin_translation.py
index 9dd1b0c..c7a263a 100644
--- a/backend/tests/api/test_admin_translation.py
+++ b/backend/tests/api/test_admin_translation.py
@@ -76,6 +76,7 @@ async def test_unreachable_service_is_reported(
"reachable": False,
"installed": [],
"installing": {},
+ "pending": [],
"available": [],
"errors": {},
}
diff --git a/frontend/src/admin/AdminApp.tsx b/frontend/src/admin/AdminApp.tsx
index 7386034..0f1c6b2 100644
--- a/frontend/src/admin/AdminApp.tsx
+++ b/frontend/src/admin/AdminApp.tsx
@@ -300,8 +300,11 @@ function Translation() {
queryKey: ["adminTranslation"],
queryFn: api.adminTranslation,
// While something downloads, follow its progress.
- refetchInterval: (q) =>
- Object.keys((q.state.data as AdminTranslation | undefined)?.installing ?? {}).length ? 1500 : false,
+ refetchInterval: (q) => {
+ const d = q.state.data as AdminTranslation | undefined;
+ if (Object.keys(d?.installing ?? {}).length) return 1500;
+ return d?.pending.length ? 10000 : false; // retried in the background
+ },
});
const done = (data: AdminTranslation) => {
queryClient.setQueryData(["adminTranslation"], data);
@@ -321,7 +324,12 @@ function Translation() {
const busy = install.isPending || remove.isPending;
const installing = Object.entries(data?.installing ?? {});
const addable = (data?.available ?? [])
- .filter((c) => !data?.installed.includes(c) && !(c in (data?.installing ?? {})))
+ .filter(
+ (c) =>
+ !data?.installed.includes(c) &&
+ !(c in (data?.installing ?? {})) &&
+ !data?.pending.includes(c),
+ )
.sort((a, b) => name(a).localeCompare(name(b), locale));
const error = (install.error ?? remove.error) as unknown;
@@ -377,6 +385,13 @@ function Translation() {
))}
+ {data.pending.length > 0 && (
+
+ {t("admin.translationPending", {
+ languages: data.pending.map(name).join(", "),
+ })}
+
+ )}
{Object.entries(data.errors).map(([code, why]) => (
{t("admin.translationFailed", { language: name(code) })} {why}
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index e8fa0d5..c5c15fe 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -701,6 +701,7 @@ export const en = {
"admin.translationRemoveTitle": "Remove {language}?",
"admin.translationRemoveConfirm": "Its models are deleted. Mail in this language can no longer be translated until it is installed again.",
"admin.translationInstalling": "downloading… {percent}%",
+ "admin.translationPending": "Waiting to download: {languages}. Tried again in the background until the models can be fetched.",
"admin.translationFailed": "Installing {language} failed:",
"admin.translationChoose": "Add a language…",
"admin.translationInstall": "Install",
diff --git a/frontend/src/i18n/pl.ts b/frontend/src/i18n/pl.ts
index 4f13ea4..92a85c1 100644
--- a/frontend/src/i18n/pl.ts
+++ b/frontend/src/i18n/pl.ts
@@ -712,6 +712,7 @@ export const pl: Catalog = {
"admin.translationRemoveTitle": "Usunąć: {language}?",
"admin.translationRemoveConfirm": "Jego modele zostaną usunięte. Poczty w tym języku nie będzie można tłumaczyć, dopóki nie zostanie zainstalowany ponownie.",
"admin.translationInstalling": "pobieranie… {percent}%",
+ "admin.translationPending": "Czeka na pobranie: {languages}. Ponowne próby trwają w tle, aż modele uda się pobrać.",
"admin.translationFailed": "Instalacja nie powiodła się: {language}.",
"admin.translationChoose": "Dodaj język…",
"admin.translationInstall": "Zainstaluj",
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 1c09e93..33351b9 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -49,6 +49,8 @@ export interface AdminTranslation {
installed: string[];
/** Downloads in progress: bytes done of total (total 0 until known). */
installing: Record;
+ /** Asked for, not downloaded yet: tried again in the background. */
+ pending: string[];
/** Languages Mozilla has models for; empty when the catalogue is out of reach. */
available: string[];
errors: Record;
diff --git a/translator/translator.py b/translator/translator.py
index 337ca2e..9d1c30f 100644
--- a/translator/translator.py
+++ b/translator/translator.py
@@ -6,9 +6,12 @@ internal network. A message's text never leaves the server.
- Firefox's models translate to and from English; any other pair goes
through English (Polish → English → German). So a language is installed as
its two models, to and from English, and English is always there.
-- What is installed is kept in ``$DATA_DIR/state.json``. The first start
- installs ``TRANSLATOR_LANGUAGES``; after that the admin panel adds and
- removes languages (``POST``/``DELETE /languages/{code}``).
+- What is installed is kept in ``$DATA_DIR/state.json``, with the languages
+ still to install. The first start asks for ``TRANSLATOR_LANGUAGES``; after
+ that the admin panel adds and removes languages (``POST``/``DELETE
+ /languages/{code}``). A language that could not be downloaded (no network
+ yet) is tried again in the background, at growing intervals, until it is
+ in — across restarts too.
- Models come from Mozilla's Remote Settings (where Firefox gets them), each
file checked against its SHA-256; only release models (no test builds).
- Language identification: fastText's lid.176 (176 languages), baked into
@@ -113,13 +116,30 @@ def _installed() -> list[str]:
return [PIVOT] + [c for c in state.get("languages", []) if c != PIVOT]
-def _write_installed(codes: list[str]) -> None:
+def _pending() -> list[str]:
+ """Languages asked for and not installed yet (tried again until they are)."""
+ return list((_read_state() or {}).get("pending", []))
+
+
+def _write_state(installed: list[str], pending: list[str]) -> None:
DATA.mkdir(parents=True, exist_ok=True)
tmp = STATE.with_suffix(".tmp")
- tmp.write_text(json.dumps({"languages": sorted(set(codes) - {PIVOT})}))
+ done = set(installed) - {PIVOT}
+ tmp.write_text(
+ json.dumps(
+ {
+ "languages": sorted(done),
+ "pending": [c for c in dict.fromkeys(pending) if c not in done and c != PIVOT],
+ }
+ )
+ )
tmp.replace(STATE)
+def _write_installed(codes: list[str]) -> None:
+ _write_state(codes, _pending())
+
+
# --- Mozilla's model catalogue -----------------------------------------------------------
@@ -211,12 +231,12 @@ def _install(code: str) -> None:
for source, target in directions:
_install_direction(code, source, target)
with _lock:
- _write_installed(_installed() + [code])
+ _write_state(_installed() + [code], [c for c in _pending() if c != code])
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
_loaded.pop(pair, None) # a reinstall: load the new files
_errors.pop(code, None)
log.info("installed %s", code)
- except Exception as exc: # noqa: BLE001 — reported in /languages, tried again by hand
+ except Exception as exc: # noqa: BLE001 — reported in /languages, tried again later
log.warning("installing %s failed: %s", code, exc)
with _lock:
_errors[code] = str(exc)[:300]
@@ -225,34 +245,53 @@ def _install(code: str) -> None:
_installing.pop(code, None)
-def _start_install(code: str) -> bool:
+_wake = threading.Event() # a new language to install: try now, not after the pause
+_RETRY_FIRST, _RETRY_MAX = 30.0, 900.0
+
+
+def _installer() -> None:
+ """Install the pending languages, one after another; while some fail
+ (no network, Mozilla out of reach), try again after a pause that grows
+ from half a minute to a quarter of an hour."""
+ pause = _RETRY_FIRST
+ while True:
+ for code in _pending():
+ with _lock:
+ if code in _installing or code in _installed():
+ continue
+ _installing[code] = {"done": 0, "total": 0}
+ _install(code)
+ if not _pending():
+ pause = _RETRY_FIRST
+ _wake.wait() # nothing to do until a language is asked for
+ else:
+ _wake.wait(pause)
+ pause = min(pause * 2, _RETRY_MAX)
+ _wake.clear()
+
+
+def _ask_for(code: str) -> None:
with _lock:
- if code in _installing:
- return False
- _installing[code] = {"done": 0, "total": 0}
- threading.Thread(target=_install, args=(code,), daemon=True).start()
- return True
+ _write_state(_installed(), _pending() + [code])
+ _errors.pop(code, None)
+ _wake.set()
-def _first_start() -> None:
- """The first start installs the default languages, one after another."""
- if _read_state() is not None:
- return
- _write_installed([])
-
- def run() -> None:
- for code in DEFAULT_LANGUAGES:
- if code and code != PIVOT:
- with _lock:
- _installing[code] = {"done": 0, "total": 0}
- _install(code)
-
- threading.Thread(target=run, daemon=True).start()
+def _start() -> None:
+ """The first start asks for the default languages; every start resumes
+ what is still pending."""
+ state = _read_state()
+ # No state yet — or one from before pending languages were kept, with
+ # nothing installed (a first start that could not download): ask for the
+ # defaults.
+ if state is None or ("pending" not in state and not state.get("languages")):
+ _write_state([], [c for c in DEFAULT_LANGUAGES if c])
+ threading.Thread(target=_installer, daemon=True).start()
@asynccontextmanager
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
- _first_start()
+ _start()
yield
@@ -377,6 +416,7 @@ def languages() -> dict[str, Any]:
return {
"installed": _installed(),
"installing": {k: dict(v) for k, v in _installing.items()},
+ "pending": [c for c in _pending() if c not in _installing],
"available": available,
"errors": dict(_errors),
}
@@ -391,7 +431,10 @@ def install(code: str) -> dict[str, Any]:
_release_files(PIVOT, code)
except LookupError as exc:
raise HTTPException(404, str(exc)) from exc
- return {"started": _start_install(code)}
+ except Exception as exc: # noqa: BLE001 — Mozilla out of reach: queue it anyway
+ log.info("model catalogue unavailable, %s queued: %s", code, exc)
+ _ask_for(code)
+ return {"started": True}
@app.delete("/languages/{code}")
@@ -399,7 +442,8 @@ def remove(code: str) -> dict[str, bool]:
if code == PIVOT:
raise HTTPException(400, "English is always installed")
with _lock:
- _write_installed([c for c in _installed() if c != code])
+ _write_state([c for c in _installed() if c != code], [c for c in _pending() if c != code])
+ _errors.pop(code, None)
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
_loaded.pop(pair, None)
shutil.rmtree(MODELS / pair, ignore_errors=True)