Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.
transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
-> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
as immutable audit trail). Includes architecture-map references
to magnotia_core::*, magnotia_storage::*, etc. now pointing at
lumotia_*; dev-setup.md tracing output examples (lumotia_startup
target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
("lumotia_task_sync"); magnotia_locale i18n localStorage key
renamed + migration shim added; CSS keyframe names
magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
to "lumotia era" earlier — restored).
Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
legacy detection strings in legacy_and_target_paths() so the
migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
— migration call sites reference the legacy magnotia keys
deliberately.
- docs/handovers/ — historical audit trail.
cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
8.6 KiB
Markdown
133 lines
8.6 KiB
Markdown
---
|
||
name: Model manager (download, verify, locate)
|
||
type: architecture-map-page
|
||
slice: 03-audio-transcription
|
||
last_verified: 2026/05/09
|
||
---
|
||
|
||
# Model manager (download, verify, locate)
|
||
|
||
> **Where you are:** [Architecture map](../README.md) → [Audio + Transcription](README.md) → Model manager
|
||
|
||
**Plain English summary.** Speech models live on disk under a per-platform path. `model_manager` resolves those paths, checks "is this model fully downloaded", lists what is downloaded, and downloads missing files with HTTP Range resume + per-chunk SHA256 verification. A single in-process `HashSet` reservation prevents concurrent downloads of the same model. After a successful download a `.lumotia-verified` manifest is written so a subsequent process restart can short-circuit re-hashing.
|
||
|
||
## At a glance
|
||
|
||
- Crate: `lumotia-transcription`
|
||
- Path: `crates/transcription/src/model_manager.rs`
|
||
- LOC: 617 (production code + extensive in-tree download server tests)
|
||
- External deps: `reqwest 0.12` (rustls-tls, stream), `futures-util 0.3` (StreamExt), `sha2 0.10`. Path resolution comes from `lumotia_core::paths::app_paths`. Catalogue from `lumotia_core::model_registry`.
|
||
- Internal callers (best effort, slice 2 reconciles): the `models` Tauri command consumes all five public functions. The frontend Settings → Models view drives `download` with a progress callback.
|
||
|
||
Public surface:
|
||
|
||
- `pub fn models_dir() -> PathBuf` — `crates/transcription/src/model_manager.rs:42`. Resolves to `%LOCALAPPDATA%/lumotia/models` on Windows, `~/.lumotia/models` on Unix (delegates to `lumotia_core::paths::app_paths`).
|
||
- `pub fn model_dir(id: &ModelId) -> PathBuf` — `crates/transcription/src/model_manager.rs:47`.
|
||
- `pub fn is_downloaded(id: &ModelId) -> bool` — `crates/transcription/src/model_manager.rs:52`.
|
||
- `pub fn list_downloaded() -> Vec<ModelId>` — `crates/transcription/src/model_manager.rs:63`.
|
||
- `pub async fn download(id: &ModelId, progress: impl Fn(DownloadProgress) + Send + 'static) -> Result<()>` — `crates/transcription/src/model_manager.rs:78`.
|
||
|
||
Private helpers:
|
||
|
||
- `DownloadReservation` — RAII guard (`model_manager.rs:12`).
|
||
- `verified_manifest_path` / `verified_manifest_matches` / `write_verified_manifest` (`model_manager.rs:115`–`:153`).
|
||
- `sha256_of_file` (`model_manager.rs:157`).
|
||
- `download_file` (`model_manager.rs:178`).
|
||
|
||
## What's in here
|
||
|
||
### Path resolution
|
||
|
||
`models_dir` and `model_dir` defer entirely to `lumotia_core::paths::app_paths()`. Anything platform-specific lives in slice 5; this crate just consumes the resolution.
|
||
|
||
### Download reservation
|
||
|
||
`DownloadReservation::acquire(id)` (`model_manager.rs:17`) inserts the model id into a process-wide `HashSet` guarded by a `LazyLock<Mutex<...>>`. Returns `Err(MagnotiaError::DownloadFailed("download already in progress for {id}"))` if the id is already present. Drop removes the entry. This protects against re-entry from the same process; cross-process races are still possible but the atomic `.part → dest` rename closes the most damaging window.
|
||
|
||
### `is_downloaded`
|
||
|
||
`model_manager.rs:52`. Two-step:
|
||
|
||
1. Every file declared in the registry entry must exist at `model_dir(id).join(filename)`.
|
||
2. The verified-manifest at `model_dir.join(".lumotia-verified")` must record the same `filename\tsha256\tsize` triple per file.
|
||
|
||
A missing or stale manifest causes `is_downloaded` to return `false` even if the bytes are correct. The next `download` call will revalidate and rewrite the manifest. A truncated or tampered file fails the existence + size check, so a subsequent `download` redownloads it.
|
||
|
||
### `list_downloaded`
|
||
|
||
`model_manager.rs:63`. Filters the static `lumotia_core::model_registry::all_models()` catalogue by `is_downloaded`.
|
||
|
||
### `download` (the orchestrator)
|
||
|
||
`model_manager.rs:78`. For each `ModelFile` in the registry entry:
|
||
|
||
1. If `dest` exists, hash it with `sha256_of_file`. Match → skip the download. Mismatch → delete and re-fetch. Hash IO error → propagate as `MagnotiaError::DownloadFailed`.
|
||
2. Otherwise call `download_file(file, dest, id, &progress)`.
|
||
|
||
After all files are present and verified, `write_verified_manifest` writes the `version\t1` header and per-file `filename\tsha256\tsize` lines.
|
||
|
||
### `download_file` (single file with resume + hash)
|
||
|
||
`model_manager.rs:178`. Implements the Buzz-style resume pattern:
|
||
|
||
1. Build a `reqwest::Client` with a 30 s connect timeout.
|
||
2. If `dest.with_extension("part")` exists, send a `Range: bytes={existing}-` header.
|
||
3. Inspect the response status:
|
||
- `206 Partial Content` → server is resuming. Continue appending.
|
||
- `200 OK` after a Range request → server ignored Range. Discard the stale `.part` and start fresh (regression-tested at `model_manager.rs:494`).
|
||
- `4xx`/`5xx` (non-resume path) → `MagnotiaError::DownloadFailed("download returned HTTP {status} for {filename}")`. Crucial because `reqwest` does not auto-error on bad status; without this check, a 404 body would be streamed into `.part` and renamed over the destination (regression at `model_manager.rs:556`).
|
||
- Other status on resume → propagate as unexpected.
|
||
4. Determine `total_bytes` from `Content-Range` (resume) or `Content-Length` (fresh).
|
||
5. Hasher state: when resuming, hash the on-disk `.part` first to catch the hash up to where the network stream is about to resume. Otherwise start fresh.
|
||
6. Stream the body in chunks, writing each to the file, updating the running SHA256, emitting `DownloadProgress` per percent step.
|
||
7. On stream end, finalise the hash. Mismatch → remove `.part` and `MagnotiaError::DownloadFailed("SHA256 mismatch for {filename}: expected ..., got ...")`.
|
||
8. `std::fs::rename(.part, dest)` — atomic finalise.
|
||
|
||
### `sha256_of_file`
|
||
|
||
`model_manager.rs:157`. 8 KiB buffer streaming hash. Used by `download` to validate an existing complete file before trusting it.
|
||
|
||
## Data flow
|
||
|
||
```
|
||
ModelId
|
||
└─ models_dir / model_dir → on-disk directory
|
||
└─ is_downloaded:
|
||
all files exist AND verified-manifest matches
|
||
→ true / false
|
||
└─ list_downloaded:
|
||
filter all_models() by is_downloaded
|
||
└─ download:
|
||
acquire reservation
|
||
for file in entry.files:
|
||
if dest exists:
|
||
sha256_of_file → match? skip : delete + re-fetch
|
||
else:
|
||
download_file:
|
||
[.part exists? Range request : full GET]
|
||
stream body → file + hasher + progress callback
|
||
match SHA256 → atomic rename
|
||
else → cleanup, error
|
||
write_verified_manifest
|
||
drop reservation
|
||
```
|
||
|
||
## Watch-outs
|
||
|
||
- **Reservation is per-process.** Two Lumotia instances (dev + release, two cargo runs) racing on the same model dir can collide. The `.part → dest` atomic rename is the only thing standing between them and a torn file.
|
||
- **`DownloadProgress` is emitted per percent step, not per byte.** A 10 MB file at 4G download speeds emits ~100 events; a 5 GB file emits the same 100 events. UI must not assume tight cadence.
|
||
- **Manifest schema is `version\t1` + `filename\tsha256\tsize` lines.** Bumping the version is a manual operation; a stale manifest after a registry update will simply trigger a redownload (waste, not corruption).
|
||
- **Cross-process file locking is not used.** A user opening Settings → Models in two windows of the same desktop session will trip the in-process reservation, but two physical machines syncing the same network home would not be detected.
|
||
- **Reqwest connect timeout is 30 s, no read timeout.** A stalled mid-stream connection blocks indefinitely. Acceptable for a foreground UI flow today; would need a `tokio::time::timeout` wrap if the download ever became headless.
|
||
- **`ModelFile::sha256` is `&'static str`.** The `leak` helper in tests reflects that production code reads a static catalogue compiled into the binary. Tampering with the catalogue requires a code change, not a runtime config.
|
||
- **`reqwest` is configured `default-features = false, features = ["rustls-tls", "stream"]`.** No native-tls fallback. Linux without ca-certificates will fail at connect.
|
||
- **`models_dir` is the user-data dir, not a cache dir.** A "clear cache" button in the OS would not nuke models. Documented behaviour.
|
||
|
||
## See also
|
||
|
||
- [Transcription engines overview](transcription-engines-overview.md) — `load_whisper` / `load_parakeet` are called against `model_dir(id)` outputs.
|
||
- [Tests and fixtures](tests-and-fixtures.md) — in-tree TcpListener-backed servers that exercise the resume + sha-mismatch + 5xx paths.
|
||
- `lumotia_core::model_registry::{find_model, all_models, ModelEntry, ModelFile}` (slice 5) — the source of URLs and hashes.
|
||
- `lumotia_core::paths::app_paths` (slice 5) — platform path resolution.
|
||
- `lumotia_core::types::DownloadProgress` (slice 5) — the progress event shape.
|