Commit Graph
158 Commits
Author SHA1 Message Date
Will Miao 09323bdc6f i18n(settings): translate directory picker folder creation strings
Fill in the 9 settings.directoryPicker.* creation keys in all 9 locales,
reusing the sidebar's existing folder vocabulary so the two folder-creation
surfaces stay consistent:

- newFolder matches sidebar.createFolder, nameInvalid matches
  sidebar.dragDrop.invalidFolderName, createError is
  sidebar.createFolderResult.failed without its {message} suffix, and
  alreadyExists drops the "here" of sidebar.renameFolderResult.targetExists.
- German keeps the formal register, Spanish the tú form.
- Document the renderings in the i18n guidelines (§2) and record the feature
  in the status block.

No [TODO: Translate] placeholder remains in any locale.
2026-11-06 12:49:25 +08:00
Will Miao 14e90006de fix(previews): detect video previews from the versioned preview URL
Preview URLs now carry a cache-busting `&v=<mtime_ns>` suffix, so the media
type could no longer be read from the end of the string. Model cards,
duplicates, recipe cards and modals, the showcase thumbnail and the ComfyUI
preview tooltip all fell into the image branch and rendered `<img>` for video
previews, which browsers cannot decode.

Move the query-aware video detection that already lived in the versions tab
into a shared mediaTypes helper and use it everywhere a server-provided
preview URL is inspected. The web UI and the ComfyUI widget bundle each keep a
copy because they are served from different mounts. Video previews that turn
out to be webm now also get the right `<source type>`.

Add regression coverage for versioned mp4/webm/image URLs.

Fixes #1168
2026-10-31 16:46:04 +08:00
Will Miao 1c017a8e27 fix(previews): version preview URLs by mtime to bust stale browser cache
Preview responses are cached for a day (Cache-Control: max-age=86400),
so rewriting a preview file under the same path (e.g. delete + recreate
an LM-managed workflow) kept showing the old image. Append the file's
mtime as a query param in get_preview_static_url so any rewrite yields a
fresh URL; the previews handler ignores the extra param.
2026-10-31 08:42:25 +08:00
Will Miao 9daa9d7dd8 fix(workflows): remove copy filename from the card context menu 2026-10-31 08:31:39 +08:00
Will Miao e11c0e21e1 fix(ui): name each page's items instead of models in shared chrome
Shared chrome borrowed the model-library nouns on the Recipes and Workflows
pages:

- The header filter panel is titled per page (`recipes.filter.title`,
  `workflows.filter.title`) instead of "Filter Models".
- Bulk guards and move failures use page-specific copy
  (`recipes.bulk.noSelection`, `recipes.move.failed`,
  `workflows.bulk.noSelection`) instead of "No models selected" / "Failed to
  move model(s)", covering the bulk menu, the sidebar drag-and-drop path and
  the recipe move client.
- Workflows got a bulk-action config, so model-only bulk entries (send to
  workflow, auto-organize, re-import/rematch, copy syntax) stay hidden
  instead of appearing unfiltered.
- The workflows page's global context menu no longer offers the model-only
  example-image entries.
- MoveManager no longer assumes every API client exposes `modelType`: the
  recipe client has none, which made the "use default path" preview throw
  and stored the toggle under `use_default_path_undefined`.

Recipes card copy is translatable now too: the card tooltips (favorites,
share, send, delete, blur, NSFW levels) and the delete dialog go through
i18n instead of hard-coded English.

10 new keys (the move-only noSelection key folded into
`workflows.bulk.noSelection`), translated in all 9 locales; no
[TODO: Translate] placeholder remains.
2026-10-30 20:35:45 +08:00
Will Miao 3b38bacc31 i18n(workflows): translate the workflow card action strings
The 19 keys added with the workflow card wording (itemLabel, copy-filename /
move / delete menu labels, delete + move + bulk-delete dialog copy, relink
toasts) are now translated in all 9 locales, following the §2 "Workflows
feature" terminology (工作流 / 工作流 / ワークフロー / 워크플로 / Workflow /
workflow) and each locale's existing colon and {message} conventions.

docs/i18n-translation-guidelines.md: new status note, and the Workflows
section now lists the card-action / dialog renderings and records that
workflows (like recipes) have no exclude copy.
2026-10-30 20:16:03 +08:00
Will Miao aafd00ff22 i18n(workflows): translate the single-folder workflow strings
Translate the six workflow keys left as [TODO: Translate] in all nine
locales by the single-folder change:

- settings.folderSettings.workflowFolder / .workflowFolderHelp /
  .enableWorkflowsHelp
- workflows.noPaths.title / .description / .hint

The copy follows each locale's existing workflow terminology (工作流 /
ワークフロー / 워크플로 / Workflow / workflow), keeps Workflows, ComfyUI,
CivitAI Workflow resources and LoRA Manager untranslated, names the folder
in the singular, points at Settings → Library with the locale's existing
nav-path style, and never asks the user to edit settings.json.

Update the i18n status note accordingly; no [TODO: Translate] placeholder
remains in any locale.
2026-10-30 17:44:03 +08:00
Will Miao 874d0e96d8 feat(workflows): single library folder + create from ComfyUI
- Replace the multi-root folder_paths.workflows workflow library with a
  scalar workflow_path setting (Settings > Library); remove the
  extra_folder_paths.workflows path and the standalone Model Paths editor.
- Migrate pre-release settings.json files that still keep
  folder_paths.workflows / extra_folder_paths.workflows on startup, so an
  upgrade keeps the configured folder instead of silently losing it.
- Add "Create LM-managed workflow" to the ComfyUI Graph menu and the File
  menu/command palette: it writes the serialized graph into the configured
  folder, stamps extra.lm_source and registers the entry in the scanner
  cache without forcing a rescan.
- Attach the decode image of the client's last executed prompt as the
  workflow preview. The prompt id is sent with the create request and the
  backend only honors it while it is still the registry's current prompt,
  so a tab never borrows another tab's output; otherwise the card keeps the
  placeholder preview.
- Detect library entries deleted/renamed while a graph still carries the
  marker: a link-status endpoint clears the stale extra.lm_source (on graph
  load, menu open and before create/update), and the write-back route
  returns 404 instead of recreating the document.
- Collapse the workflows.noPaths standalone/ComfyUI split into a single
  description/hint; the folder is edited in Settings > Library in both
  modes, so the manual settings.json guidance and the standalone
  open-settings-folder fallback are gone.
- Add the preview tensor conversion as a shared utility (reused by the
  recipe widget save path) and cover create, preview gating, link status,
  migration and menu injection with tests.
2026-10-30 17:35:53 +08:00
Will Miao eebdfcb0b6 fix(widgets): theme ComfyUI node widgets from palette tokens
LM widgets were painted with a fixed dark palette: translucent slate fills
plus light text. On the dark palettes those values happened to blend into the
node, but on the Light palette the translucency composited into grey slabs and
light text landed on light surfaces. The prompt textarea was the clearest
case: it used the fixed --color-charcoal-400 scale token, which never flips
with the theme, so it stayed charcoal inside a white node.

Add web/comfyui/lm_theme.css as the single colour layer. Every value aliases the
active ComfyUI palette (--comfy-input-bg, --component-node-*, --comfy-menu-bg,
...) and derives raised/hover/selected surfaces with color-mix, so built-in and
user-imported palettes are covered without per-theme rules. Contexts select the
token family the neighbouring built-in widget uses: lm-widget-root for canvas
DOM widgets, lm-vue-node for the Vue node renderer, lm-popover for menus
appended to <body>. Only status colours, shadows and scrims keep a light/dark
variant. Whole-node containers no longer paint a background, so the node
surface shows through and only inputs, cards and buttons are filled.

lm_styles.css, the canvas widget JS (autocomplete dropdown, tag chips, group
editor) and all Vue SFC styles now consume these tokens; tag category badges
derive their hue from oklch tokens so the tint and text contrast flip with the
theme. Empty states get an explicit 12px size: the canvas DOM overlay inherits
the 16px body size while Vue nodes render at text-xs, which made the
placeholders oversized and heavy in canvas mode.

A regression test rejects new colour literals outside the preview-image
overlays, and the DOM widget guide documents the token map and the
blend-don't-overlay rule. Rebuilt the committed vue-widgets bundle.
2026-10-30 09:48:29 +08:00
Will Miao e1f3d28107 Merge branch 'feature/workflow-management' 2026-10-29 16:44:08 +08:00
Will Miao 327edbce71 i18n(workflows): translate the workflow management strings
Fill the 41 [TODO: Translate] placeholders left by the workflow
management series across all 9 locales: the Workflows page
(workflows.* - title, context menu, the send/update/copy/restore
toasts, the restore confirmation modal, the disabled state and the
"no workflow folders" empty state), the settings toggle and folder
list (settings.folderSettings.enableWorkflows*, .workflowFolders*),
the Workflow Paths folder-key label, the two nav paths and
initialization.workflows.*. No [TODO: Translate] remains.

Workflow follows the term already fixed per locale: 工作流 in
zh-CN/zh-TW, ワークフロー in ja, 워크플로 in ko, and the Latin
loanword in fr/de/es/ru/he - so header.navigation.workflows and
workflows.title stay "Workflows" in those five. The copy reuses
existing renderings rather than inventing terms: workflows.disabled.*
mirrors other.disabled.* ("management is off", "Enable it to ...",
"Failed to enable ..."), workflows.noPaths.* mirrors other.noPaths.*
and its three open* buttons are byte-identical to their
other.noPaths.open* twins, the nav-path hints follow
other.disabled.hint ("Settings > Library") and
other.noPaths.descriptionStandalone ("Settings -> Model Paths"), and
the restoring line follows recipesPathMigrating.

"CivitAI Workflow resources" keeps Workflow in Latin even in the CJK
locales because it names CivitAI's resource type, and
folder_paths.workflows stays byte-identical everywhere. Failure toasts
keep {message} verbatim; CJK uses the full-width colon, the other
locales ASCII (fr keeps its space before it). The guidelines doc gains
a "Workflows feature" section in the term maps, plus a 2026-10 status
entry.

Verified with pytest tests/i18n (21 passed) and
scripts/sync_translation_keys.py --dry-run (all locales up to date).
2026-10-29 16:28:42 +08:00
Will Miao 5b0fdc8f17 feat(lora-compat): auto-remap legacy Anima LoRA block indices
Anima 2.9B/3.8B insert blocks between the original 28, so old LoRA keys land on the wrong layers and ComfyUI emits no missing-key warning. Add a model-family adapter that rewrites block indices in memory before the generic LoRA loader runs.

- 28->40, 40->52 and composed 28->52 mappings, with anchored kohya/generic key patterns
- Anima detection from image_model/llm_adapter and block count from blocks/num_blocks
- skip text encoder, llm_adapter and semantic connector keys; collision and newer-LoRA errors
- ComfyUI setting (default on) synced to the LM settings API; no base-model-specific node toggle
- docs, unit and loader integration tests

Reuses the manifest data from PR #1104; supersedes its node-level toggle design.
2026-10-28 20:00:20 +08:00
Will Miao d29b10b815 i18n: translate the first-run welcome page and fetch-status strings
Fill the 36 [TODO: Translate] placeholders left by the first-run series
across all 9 locales: firstRun.* (30, the welcome page that gates the
initial scan), initialization.error.*, banners.bulkFetchPrompt.*,
modals.metadataFetchSummary.rateLimitedNotice,
toast.api.metadataFetchRateLimited, the two
modals.download.errors.linkedFile* messages and
toast.duplicates.deletePartialFailed/.cacheUpdateFailed. The same pass
re-translates the three keys whose English text changed without being
re-queued: settings.folderSettings.activeLibrary/.activeLibraryHelp
(multi-library deprecation) and toast.contextMenu.linkHfSuccess (model
sources beyond Hugging Face). No [TODO: Translate] remains.

Most of the copy reuses existing renderings rather than inventing terms:
firstRun.title mirrors onboarding.languageSelection.title, libraries.*
the header.navigation.* model-type names, libraryStats the rootModels
count shape, "~{minutes} min" the eta.minutes abbreviation, and
firstRun.sidecar.* the sidecarStorage.modeOptions.* choice plus
"(recommended)". The rate-limit copy quotes each locale's
loras.controls.fetch.title verbatim (zh-TW therefore keeps its own
"... metadata" label instead of a second rendering), cacheUpdateFailed
quotes refresh.full, and the sidecar descriptions use the §5 metadata
noun with the §2 sidecar rendering.

Guidelines doc: new §2 "First-run welcome page and fetch-status copy"
terminology subsection, a 2026-10 status entry, and the leaf-key count
updated to 2308.
2026-10-25 21:52:20 +08:00
Will Miao 3937ad37d0 feat(download): opt-in file-type routing for bundled component files
When file_params explicitly selects a file whose Civitai type maps to an
other-model sub_type (VAE/Text Encoder/CLIPVision/ControlNet/Upscaler)
and the caller passes file_type_routing=true, reroute the download to
the other-model pipeline: sub_type resolved from the file type, full
other-models gating (other_models_disabled / other_sub_type_disabled /
other_no_default_root), storage under default_other_roots, and
file-level dedup in the other scanner.

Default behavior is unchanged: without the flag, file type never
overrides the mapped model.type.
2026-10-19 11:56:11 +08:00
Will Miao f0615f769d i18n: translate the count tooltip and folder-removal strings
Fill in the two keys added by 965bfd33 (the always-on hover text of the counted
filter chips, All Types options and Refresh scope rows) and sweep the four
leftover sidebar.deleteFolder* placeholders from the folder-removal flow
(missingMessage, removeFromList, forgotten, exists), so no [TODO: Translate]
remains in any locale.

"models" reuses each locale's loras.controls.refresh.rootModels noun and
"recipes" the R4 noun in its plain plural (fr keeps "Recipes"); the separator
follows the sidebar.folderRoot.*Status precedent (CJK ——, all others —), and
ko puts the count after the noun as in rootModels. The folder strings mirror
each locale's existing deleteFolderResult.success / .missing renderings.

Terminology recorded in docs/i18n-translation-guidelines.md §2.
2026-10-18 18:35:01 +08:00
Will Miao dabaf32858 docs(update): mark the threshold material in §10-§11 as historical
The status header still advertised "P5 (alerts panel) planned" while §11 opened
with "P5a implemented", so a user reading the plan to learn the UI could not
tell that the panel, the numeric threshold, the alerts query, the two alert
endpoints and getPriceAlerts were built on a branch and removed in e42d649d,
before v1.2.5 shipped. §10 is stale in the same way: it lists the alerts query,
price_alert_* and getPriceAlerts among what shipped.

The header now states the released behaviour (no independent surface, no
threshold, no price-drop alert), §10 and §11 carry a superseded note pointing at
§12.4/§12.7, and the §11 "Owner decision" paragraph is marked reversed by
§12.3 D1. Documentation only; no code or behaviour changes.
2026-10-14 09:19:28 +08:00
Will Miao e27500c80f Merge branch 'feat/download-root-rules'
Download Root Rules: route downloads to a non-default root by ordered
base-model/tag rules per model type (loras/checkpoints/unet/embeddings),
with a rules editor in Settings > Organization, a default-root cue, and
the matched rule shown in the download modal. Translated in all locales.

Conflict note: settings_manager.py adopted the shared
utils.root_paths.normalize_root_identity from main (issue #1146).
2026-10-13 09:23:35 +08:00
Will Miao 0508daa0fb fix(scanner): verify recorded folders per entry, not per scan (#1145)
`_reconcile_cache()` replaced the recorded folder list only when *nothing* was left
unverified: one switched-off drive, one directory `os.walk` could not enter, or one
symlink whose target was gone, and the whole list fell back to a union with what
the walk found. A folder moved with a file manager therefore stayed in the sidebar
for good — the walk kept adding the new location while the old entry was never
dropped, a restart re-hydrated the union from SQLite, and the ghost could not be
deleted in the UI either because its directory was gone.

* `_UnreachablePaths.covers_folder()` answers "could this specific entry have been
  read?" for a library-relative folder: it is kept only while a directory the walk
  failed to enter (or an offline symlink) may still hold it. A root that is
  unreadable *as a whole* deliberately does not count: library-relative names carry
  no root, so a missing drive would trivially cover every entry and no ghost could
  ever be cleared. Its folders still show while they hold cached models
  (`get_all_folders()` unions the model-derived folders) and a genuinely empty one
  comes back the next time that root is walked.
* The merge keeps today's union for a scoped scan, and for a root-scoped scan that
  does not cover every root: those cannot speak for the roots they were not asked
  about. Asking for every root is a full refresh and cleans again.

Verified: a folder moved externally is dropped while another root is offline, the
offline root keeps its own folders and cached models, and a scoped scan still never
drops a folder of a root it did not scan. The Windows-only half of this (a
backslash `os.sep` making the configured twin shadow the live root, so deleted
models were never pruned) is now reproduced in CI by patching `os.sep` instead of
needing a Windows runner.
2026-10-12 09:31:17 +08:00
Will Miao 2e5f1db6df i18n(download): translate download root rules strings in all locales
Fill the 26 [TODO: Translate] placeholders per locale (24
settings.downloadRootRules.* keys + modals.download.routedByRule.*)
across zh-CN, zh-TW, ja, ko, fr, de, es, ru, he, following the
i18n guidelines: routing/root/base-model/tag terminology per §2,
R3 Latin model types and example token lists kept verbatim, R6
register, per-locale UI-label quoting in routedByRule, and fragment
style for the summary/chip strings. The es tag chip uses "Tag" (the
full "etiqueta" overflows the badge; the tooltip keeps the full term).

Source fix per R9/R7: en.json summary.selectBaseModels/enterTags used
U+2026 — now ASCII "..." (JS fallbacks and vitest assertions moved
with it; truncateMiddlePath's … stays, it is a path-elision character,
not a locale string).

Guidelines doc: new §2 "Download root rules feature" terminology
subsection + 2026-10 status entry.
2026-10-10 08:15:35 +08:00
Will Miao af90fe873d feat(modal): open model metadata sidecar location from the model modal
Centralized sidecar storage moves .metadata.json into a mirror tree, so
the Location link in the model modal could no longer reveal a model's
sidecar. The Location row now shows an inline metadata icon button
(centralized mode only) that calls a new
POST /api/lm/models/open-sidecar-location endpoint: the backend resolves
the sidecar through py/utils/sidecar_paths, reveals the .metadata.json
selected in the file manager (falling back to the sidecar directory when
it has not been written yet), and returns the path for clipboard mode on
headless/Docker sessions. The platform reveal logic is factored into a
shared FileSystemHandler._reveal_file helper. All 9 locales translated.
2026-10-10 07:41:48 +08:00
Will Miao 1a6b0f78a3 chore(libraries): deprecate multi-library support ahead of removal
Warn at startup (log + dismissible startup message) when settings.json
contains more than one library, marking the feature as deprecated while
keeping it fully functional. Removal is planned for one of the next
releases, at which point all libraries' folder paths will be merged into
a single library. Multiple folder roots within one library remain fully
supported and unaffected.

- SettingsManager: add _warn_if_multi_library() invoked from
  _migrate_to_library_registry
- Settings UI: label the Active Library selector as deprecated and note
  the removal plan in its help text (en.json)
- Docs: add deprecation banners to multi_library_design.md and
  library-switching.md
- Remove dead code: SettingsManager.update_active_library_paths had no
  call sites
2026-10-09 11:10:04 +08:00
Will Miao acefe1a348 fix(metadata): fail loudly when the sidecar write cannot land
update_model_metadata ignored the save_metadata result, so a fetch on a
drive that went offline mid-run was counted as success: the cache got
the fresh CivitAI payload while the durable sidecar never landed, and
every later fetch skipped the model as already fetched.

The save result now raises MetadataPersistError. In the bulk fetch path
this fails the item (reported in the summary, cache untouched, model
stays eligible for the next run); relink surfaces an honest 500.

Also corrects the stale known-limitation note in the scoped-scan plan:
a startup-offline root has been listed and re-admitted since Wave 6.
2026-10-08 07:55:47 +08:00
Will Miao 842ffb1df8 i18n(sidebar): translate the recipes-folder protection strings 2026-10-07 19:39:20 +08:00
Will Miao f4d0228d6b feat(scanner): report and re-admit model roots that are unavailable (#1108)
`Config._dedupe_existing_paths()` drops roots whose directory does not exist
when the root list is built, so a drive that is switched off while LM starts was
not "an offline root" — it was not a root at all. The Refresh ▾ menu had no row
for it in either state, a full refresh reported nothing (its cached entries
survived only because they fall outside every configured prefix), and plugging
the drive back in changed nothing until the next restart.

* `Config` now records the configured (existence-unfiltered) paths per model
  type — from ComfyUI's list in plugin mode, from the active library snapshot in
  standalone mode, where the host mock filters non-existent paths itself — and
  exposes them through `configured_roots_for()`.
* `ModelScanner.describe_model_roots()` appends configured-but-unavailable roots
  with a live `reachable`, an `available` flag and their cached entry count, so
  `/roots` (and therefore the menu) offers a row in both states: greyed while the
  directory is missing, normal and clickable once it is back.
* `_reconcile_cache()` reports them as `skipped_roots` / `unavailable_paths` with
  reason `root_unavailable` and counts their cached entries in `kept_unreachable`,
  which is what makes the "N models kept" toast appear in the startup-offline
  case. Report-only: they stay outside the scan scope, so nothing about pruning
  changes. A root-scoped scan stays quiet about roots it was not asked about, and
  a folder-scoped scan only mentions a missing root that holds cached entries of
  that folder.
* `Config.admit_configured_roots()` re-runs the per-type prepare helpers against
  the configured paths and APPENDS what is readable now (plus the
  checkpoint/unet/other side maps), then refreshes the preview allowlist, so a
  drive plugged in mid-session can be scanned without restarting. Append-only and
  order-preserving by design: removing a root mid-session would let a later
  settings save persist the loss, and re-sorting would move `*_roots[0]`, which
  derives the recipes directory and the usage-stats file location. `_roots[0]`
  never moves.
* `/roots` and `/scan` admit first, so `describe_model_roots()` stays a pure read
  and a caller that never opens the menu (the browser extension) still gets a
  drive that came back.

Verified on the sandbox with drive-Z switched off before startup: `/roots`
reports `drive-Z reachable=false available=false models=60`, a full refresh
returns `skipped_roots=[drive-Z root_unavailable]`,
`unavailable_paths=[{... kept: 60}]`, `kept_unreachable=60`, and all 432 models
stay cached. Renaming the drive back while the server runs admits it
(`available=true`) and `GET /scan?roots=<drive-Z>` walks it with no restart.
3714 passed, 7 skipped; frontend 1495 passed (149 files); vue widgets 96 passed.
2026-10-07 16:38:38 +08:00
Will Miao 0941f99226 i18n(sidebar): translate the folder-scan strings
The sidebar's "Scan this folder" entry and its vanished-folder warning were the
last 2 [TODO: Translate] keys, so grep -c "TODO: Translate" locales/*.json is 0
everywhere again.

  scanFolder              zh-CN 扫描此文件夹 · zh-TW 掃描此資料夾 · ja このフォルダをスキャン
                          ko 이 폴더 스캔 · fr Analyser ce dossier · de Diesen Ordner scannen
                          es Escanear esta carpeta · ru Сканировать эту папку · he סרוק תיקייה זו
  scanFolderResult.missing
                          reused verbatim from each locale's existing
                          renameFolderResult.missing / deleteFolderResult.missing — the situation
                          is identical ("the folder vanished before the action ran"), so a third
                          variant of the same sentence would only be a translation drift risk.

The label follows each locale's "Check for updates in this folder" phrasing
(检查此文件夹的更新 → 扫描此文件夹, Vérifier les mises à jour dans ce dossier → Analyser ce
dossier) so the two refresh entries in the same menu read as a pair.
docs/i18n-translation-guidelines.md gains the two rows in "Scoped scan and root
availability" plus the updated status note.

Verified: python scripts/sync_translation_keys.py --dry-run reports no pending
changes, pytest tests/i18n 20 passed, frontend 1495 passed.
2026-10-07 15:26:08 +08:00
Will Miao 12930ce78c feat(sidebar): scan a single folder from the folder context menu (#1108)
The Refresh menu can scope a scan to one model root, but the folder a user is
looking at lives in the sidebar's unified tree, which merges every root into one
relative-path namespace. "Scan this folder" therefore addresses the folder, not a
root: the backend walks that relative path under every root that holds it, which
is also what makes the action safe while another drive is switched off.

Backend:
* GET /scan accepts `folder=<rel>` (alone or with `roots=`), rejected together
  with full_rebuild=true like the roots parameter. Validation reuses
  normalize_relative_folder(), extracted to module scope from ModelMoveService so
  the folder operations and the scan endpoint reject the same input (absolute
  paths, drive letters, `..` climbing) instead of each carrying its own copy.
* The reconcile summary carries `scope_label` (the folder) for a folder scope, so
  the result toast names the folder the user clicked instead of the roots it
  happens to live under; the completed WS payload carries it too.
* `folder` is a scope prefix exactly like a root: only that subtree is re-read or
  pruned, and an unreachable root keeps the entries that fall inside it.

Frontend:
* The sidebar folder context menu gains "Scan this folder" above "Check for
  updates in this folder" (they share the refresh divider); the entry is gated by
  the same supportsFolderManagement flag as the other folder operations.
* SidebarManager.scanFolder() resolves the node through the existing
  _resolveFolderCandidates() before doing anything: a folder no root holds any
  more explains itself ("no longer exists on disk") instead of scanning nothing,
  and an unresolvable multi-root node is refused rather than guessed.
* PageControls.refreshModels() and BaseModelApi.refreshModels() forward the folder
  scope, and _showRefreshSummary() prefers scope_label over the walked roots.

Verified live on the three-root sandbox with one drive switched off:
GET /scan?folder=pack000 walks drive-G and drive-Y, reports scope_label=pack000,
keeps drive-Z's 6 entries under that folder (kept_unreachable=6) and leaves all
420 models cached. 3704 passed, 7 skipped; frontend 1495 passed (150 files); vue
widgets 96 passed. The 2 new sidebar keys are [TODO: Translate] placeholders
pending the feature owner's go-ahead.
2026-10-07 15:15:35 +08:00
Will Miao bd184559bf i18n(scanner): translate the scoped-scan strings
The scoped-scan feature left exactly 6 [TODO: Translate] keys behind, so this
is the whole pending set for all 9 locales. Renderings reuse the established
nouns (folder: zh-CN 文件夹 / ja フォルダ / ru папка / he תיקייה; "model" counts
from sidebar.deleteFolderModal) and introduce one new term, "Offline" for a
configured root that cannot be read right now:

  scopeSection        zh-CN 只扫描一个文件夹 · ja フォルダを 1 つだけスキャン
                      fr Analyser un seul dossier · ru Сканировать только одну папку
  rootOffline         zh-CN 离线 · ja オフライン · fr Hors ligne · ru Недоступен
  rootModels          zh-CN {count} 个模型 · ko 모델 {count}개 · he {count} מודלים
  refreshCompleteScoped / refreshKeptUnreachable / scanRootUnreachable
                      (counts and {scope}/{paths} stay verbatim)

Every placeholder set matches en.json, fr keeps the typographic apostrophe, and
the three CJK locales keep full-width punctuation while ko uses ASCII like the
rest of that file. docs/i18n-translation-guidelines.md gains the
"Scoped scan and root availability" terminology table plus the updated status
note, so the "no remaining placeholders" claim holds again.

Verified: python scripts/sync_translation_keys.py --dry-run reports no pending
changes, pytest tests/i18n 20 passed, and grep -c "TODO: Translate"
locales/*.json is 0 everywhere.
2026-10-07 15:04:16 +08:00
Will Miao 470d85cca6 feat(scanner): scan a single root and keep unreachable entries (#1108)
Refreshing had no way to say "scan only this drive": a user with three external
drives had to spin all of them up for every refresh, and switching a drive off
made the next refresh treat its whole library as deleted (rows pruned from the
memory cache and the SQLite cache, preview_url stripped on the next scroll).

Backend (py/services/model_scanner.py, py/config.py):
* ReconcileScope(roots, folder) + _reconcile_cache(scope=...): files inside the
  scope reconcile normally, everything outside is neither re-read nor removed.
  The folder half is plumbing for the sidebar entry in the next change.
* Path-level pruning guard: cached entries under a path this walk could not
  read are kept and reported instead of removed. Sources: a configured root that
  is not reachable (drive switched off while LM runs), a directory os.walk
  failed to enter (permissions / I/O error / Windows junction to an offline
  drive), and a known first-level symlink whose target is gone
  (Config.iter_path_mappings()).
* The recorded folder list is unioned instead of replaced whenever the scan did
  not verify every root, so a scoped scan cannot empty the sidebar.
* _reconcile_cache returns a summary (added / removed / repaired /
  scanned_roots / skipped_roots / unavailable_paths / kept_unreachable),
  exposed as ModelScanner.last_reconcile_summary, returned by
  BaseModelService.scan_models() and broadcast in the completed WS payload.
* _root_display_labels(): set-aware labels ("G: loras", "usb/loras") grown
  leftwards with real parent segments until unique, shared by the walk-progress
  line and the roots API.
* GET /scan accepts repeated `roots` (400 for unknown roots, 400 combined with
  full_rebuild=true); GET /roots gains root_details (label / reachable / cached
  count) while `roots` stays a plain path list for existing callers.
* serve_preview: a 404 no longer clears the cached preview_url when the file's
  own directory is unreachable - browsing the grid with a drive off used to
  strip preview references from the persistent cache.

Frontend:
* Refresh ▾ gains a "Scan one folder" section listing the page's roots with
  their cached counts; offline roots stay clickable and explain themselves; rows
  are wired by delegation (new static/js/components/controls/ScanScopeMenu.js).
* A scoped scan reports "Scanned <root>: N new, M removed"; a scan that kept
  entries reports "<N> models kept: <paths> not reachable".
* registerAPI() now injects the two cross-page passthroughs (fetchModelRoots and
  an argument-forwarding refreshModels) so a page facade cannot drop them: the
  first version rendered an empty menu and would have run a full refresh.
* createToastElement whitelists toast types, so a wrong `type` argument degrades
  to the info style instead of rendering an unstyled box.

Verified in a sandbox instance with three roots: a scoped scan walks only the
requested root (progress roots=0/1, 240 files); a full refresh with one root
offline reports kept_unreachable=60 and leaves all 420 models cached; /roots
reports the offline root with its cached count. 3699 passed, 7 skipped;
frontend 1488 passed (148 files); vue widgets 96 passed.
2026-10-07 15:01:26 +08:00
Will Miao 2dcaf6a30e feat(sidebar): manage folders that live under several model roots
The sidebar's folder tree merges every model root into one relative-path
namespace, but folder operations turned a node into a path by prefixing
default_*_root. A folder living under another root failed to delete with
"Folder no longer exists" (recipes under the primary lora root while
default_lora_root is the extra one), and where the same relative folder
exists in both roots the operation silently hit the other copy — 14 of the
16 top-level folders in the reporting library are shared, so guessing a root
was never safe.

Backend:
* ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder
  answer which directories a library-relative folder maps to
  (folder_path/root/is_symlink), in scanner root order, skipping directories
  no root holds and refusing absolute or climbing paths.
* delete_folder/rename_folder tag a vanished directory with code "missing"
  so the sidebar can tell "this node is stale, refresh" from a failed
  operation.

Frontend:
* _resolveFolderCandidates() is the single place that turns a node into
  absolute paths: default root first, the old root-prefix fallback only
  while a single root is configured, and an explicit unresolved error for a
  multi-root library — nothing is guessed silently any more.
* One copy keeps the single-target modal, which now names the resolved
  absolute path. Several copies render one checkbox row per copy, each
  dry-run against the delete guard ("no models" / "contains N model
  file(s)..." / a deletion is still pending / no longer exists / symbolic
  link): a blocked copy is unticked, disabled and explained, the button
  reads "Delete N folders", every ticked copy is deleted and guarded on its
  own, and a partial failure is reported without discarding the successes.
* Rows are built once per open and only their status text is updated, so
  ticking a box no longer rebuilds the list, steals focus or resizes the
  modal mid-click; the action row keeps a fixed button width.
* Rename offers a root picker in its inline row, create inherits the
  parent's root when the parent resolves to exactly one directory, and the
  undo restores every copy a delete removed.

i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*,
.renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9
locales, with the en wording normalized to the established "model root" noun
(it had said "library root") and the new terminology recorded in
docs/i18n-translation-guidelines.md.

Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management
suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run
clean. A sandboxed standalone instance with two roots confirmed that deleting
one copy leaves the node in place, that the twin's model card survives the
purge, and that deleting both copies and undoing restores both directories.
2026-10-06 17:14:07 +08:00
Will Miao a48855d576 i18n(scanner): translate the reconcile walk progress strings
The walk-progress feature left exactly one [TODO: Translate] key behind,
common.scanProgress.walkFiles ("{count} files"), so this is the whole
pending set for all 9 locales. Each rendering reuses the locale's
stages.count_models noun for "files":

  zh-CN {count} 个文件          zh-TW {count} 個檔案
  ja    {count} 件のファイル      ko    파일 {count}개
  fr    {count} fichiers        de    {count} Dateien
  es    {count} archivos        ru    {count} файл(ов)
  he    {count} קבצים

{count} arrives pre-formatted (toLocaleString), so no locale adds digit
grouping, and the string stays a bare fragment: the root labels, the
parentheses and the " | " before the ETA are composed by
static/js/api/baseModelApi.js. ru uses файл(ов) because the counter ticks
live (notEmptyMessageCount precedent).

Also document the new surface in docs/i18n-translation-guidelines.md:
a status note plus the "Scan progress (walk phase)" terminology section
recording the per-locale renderings and the fragment/composition rule.

Verified: python scripts/sync_translation_keys.py --dry-run reports no
pending changes, pytest tests/i18n 20 passed, and
grep -c "TODO: Translate" locales/*.json is 0 everywhere.
2026-10-06 12:42:08 +08:00
Will Miao 14e660e876 feat(scanner): report and parallelize the reconcile walk
A regular Refresh walked every configured root sequentially, so a full
25 TB drive delayed the roots behind it, and the dialog sat on "Checking
for changes..." at 0 % for the whole walk with no way to tell it was
working. On a cold external drive the walk itself dominates the cost, so
the fix is to overlap the drives and to show what the walk is doing.

Backend (py/services/model_scanner.py):
* Extract the per-root walk into the synchronous _walk_root_for_reconcile()
  worker and merge its results on the event loop afterwards, in configured
  root order: which business path wins a file reachable through several
  roots must not depend on the order the workers happened to finish in.
* Group roots by device (_root_device_key: drive letter on Windows, st_dev
  on POSIX) and run one worker per device. Roots sharing a device stay
  sequential, so directory claims and the overlap dedup (#871, #1041) keep
  their configured-order semantics; different devices run in parallel.
* Track walk progress per root (_ReconcileWalkTracker), weighted by each
  root's cached entry count because the real file count is only known once
  the walk ends. The bar splits into walk (0-50 %) and new-file (50-99 %)
  phases so it never jumps backwards, and the walk broadcasts files seen,
  active roots and the ETA counters.
* Replace the Windows case-insensitive fallback -- a scan of every cached
  path per miss, i.e. O(files x cached) -- with a lazily built lower-cased
  index (_CachedPathLookups, also now guarding the realpath alias index for
  worker threads), and lift the os.name == "nt" gate into the module-level
  _CASE_INSENSITIVE_PATHS so the branch is testable off Windows.
* Excluded-model membership is a set lookup instead of a list scan.

Frontend:
* render the walk phase as "Checking for changes... <roots> (N files)" with
  the ETA, and reset the ETA tracker when the stage changes: the per-file
  rate of counting files says nothing about processing them.
* add common.scanProgress.walkFiles; the other locales keep the sanctioned
  [TODO: Translate] placeholder until the translation pass.

Verified: no-change reconcile over 10k files/500 dirs 101 ms and 10k/5000
dirs 230 ms (was 93/229 ms, within noise); a two-device sandbox walk runs
both roots concurrently, names them in the progress messages and finishes
with added=15, removed=0; 3665 passed, 7 skipped; frontend 1456 passed,
vue widgets 96 passed.
2026-10-06 12:40:57 +08:00
Will Miao db50632e83 docs(update): link the filed upstream request (civitai/civitai#5384)
Filed as willmiao with the 138-word text from §13.1; the internal notes in §13.2
(write/read asymmetry, evidence table) stayed out of the issue.
2026-10-05 14:52:13 +08:00
Will Miao d141c29ced docs(update): cut the upstream request down to the ask
The first draft read as a design document for the CivitAI team: it explained how
to change toPublicPaidAccessDto, cited discountedTerms, and described how we read
prices from model pages today. Two of those do not belong in an issue.

- no implementation guidance: they know their service, and withholding prices may
  be a deliberate product decision (the code comment says pricing belongs to the
  purchase flow), so the request has to argue the need rather than the diff
- no description of our current page reading: it is our approach, it shifts the
  thread from the feature to our behaviour, and it invites an objection that has
  nothing to do with the ask

The filed text is now 138 words: ask, why (the API gives the gate and the early
access end date but not the price, which is the other half of "wait or pay now"),
one credibility clause naming the integration, and one scope concession (public or
authenticated-only). The evidence table and the write/read asymmetry stay in the
plan as internal notes, explicitly marked as not for the issue.
2026-10-05 14:42:37 +08:00
Will Miao dcc0add6b0 docs(update): draft the upstream API request for public Buzz prices
No existing civitai/civitai issue asks for this (searched paidAccess, "download
price", "buzz price API" and every open [API Feature Request]), so the draft is a
new issue using that repo's title convention.

The argument leans on how small the change is there: toPublicPaidAccessDto
already receives a PaidAccessRow carrying `terms` and `sales` and returns only
{permanent, endsAt}; discountedTerms already resolves sale prices; and the write
path already accepts the same terms via updateModelVersionPaidAccessSchema. The
reads withhold exactly what the writes accept.

It also documents why the page is not a workaround: civitai.red challenges
non-browser clients (403 for any User-Agent), while civitai.com and civitai.green
404 mature models to anonymous visitors, so mature models have no readable price
source at all.

Also marks the P4 upstream task as drafted.
2026-10-05 14:37:18 +08:00
Will Miao fbd2e6a2eb Merge remote-tracking branch 'origin/main' into feat/paid-model-price-tracking
# Conflicts:
#	docs/i18n-translation-guidelines.md
2026-10-05 12:28:35 +08:00
Will Miao 9dacaf06f7 feat(ui): add optional vertical list layout for model modal showcase
Bring back the pre-v1.2.2 classic vertical list for example images as a
persisted showcase_layout setting (gallery stays the default), switchable
from both a Settings select and an in-modal segmented toggle.

- Revive the vertical list renderer (adapted from 4a6042d0) as
  VerticalListView.js, reusing the shared MediaUtils/MetadataPanel
  infrastructure; legacy CSS scoped under .showcase-vertical
- Dispatch showcase rendering on state.settings.showcase_layout;
  collapsed bar, empty/filtered states and import flow stay shared
- Keep the back-to-top button available in vertical mode (the
  showcase-expanded class that hides it now only applies to the gallery,
  whose thumbnail strip occupies that corner)
- Persist the setting via DEFAULT_SETTINGS + DEFAULT_SETTINGS_BASE and a
  new select under Settings > Layout Settings
- i18n: 6 new keys translated in all 9 locales; terminology recorded in
  docs/i18n-translation-guidelines.md

Closes #1136
2026-10-05 09:38:48 +08:00
Will Miao 98ba319735 i18n(update): translate the Buzz download price strings
Completes the 13 keys the obtainability feature left as [TODO: Translate], in all
9 locales: the two gate-event counts, the sale / Blue Buzz / "Free Now" / early
access end-date badge strings, and the six settings.priceTracking strings plus the
section header.

- Buzz and Blue Buzz stay as-is everywhere: they are CivitAI currency names, not
  translatable words (R3)
- register follows each file's existing norm (你 zh-CN, 您 zh-TW, Sie de, tú es,
  вы ru); fr keeps the file's ASCII apostrophe style; no full-width punctuation
  leaked into the Latin/Cyrillic/Hebrew locales
- two source fixes came with the pass: the unused settings.priceTracking.label key
  is removed (no template renders it - the toggle uses enabled/enabledHelp), and
  settings.sections.priceTracking now reads "Buzz Download Prices" so the header
  matches what the feature does: prices are displayed, nothing is tracked for
  alerts
- the locale files were edited by exact-value replacement rather than re-serialised,
  so each diff is 13 lines and the per-file indentation is untouched (R1)
2026-10-05 09:13:58 +08:00
Will Miao cbe9d58522 docs(update): the model card cannot carry a single price
Live model 958009 has 37 gated versions across 6 price points, and the owner's
own library has a model whose two early access versions end on different dates,
so an 'Update - 500 Buzz' badge would be fabricated rather than summarised. The
uniformity that made it look plausible is an artefact of floor pricing. Prices
stay in the version list; only a deadline is well defined at model level.
2026-10-05 09:05:58 +08:00
Will Miao e42d649df0 refactor(update)!: make obtainability a property of updates, not a surface
The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the
"Price alert threshold" number meant, or what "Price Alerts" was alerting about.
That was not a copy problem: the implementation exposed our mechanism (a page
scrape) and our SQL predicates as the user's concepts. Two concrete defects came
from the same root:

- the alert population included versions the user already owns (neither the event
  generator nor the query filtered on is_in_library; in the owner's library 28 of
  52 gated versions were already downloaded, so most "alerts" were about files
  already on disk, which cannot become cheaper *for them*);
- a threshold-filtered state list lived in a notification surface, so an empty
  panel had three indistinguishable causes and read as a broken feature.

The information model is now the version plus ownership: cost is shown only where
a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and
gated -> the price when it is known, `Paid` without a number when it is not, and
early access keeps its countdown because "free on <date>" decides between waiting
and paying. The numeric threshold has no place in that model: every decision is
categorical (wait / pay / skip), so the setting, the comparison and the whole
alert-state machine are gone.

- both alert-state columns are removed from the schema rather than left dead; a
  database created by an unreleased build has them dropped on open (native
  ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else
- gate events are emitted only for versions the user does not have, and the
  price-drop event goes with the threshold it belonged to
- both alert endpoints, PriceAlertsHandler and the service-registry adapter field
  it needed are removed: events already reach the UI through the refresh response
- the bell tab, panel, CSS, both entry points, the unread watermark and their
  locales are removed; the setting keeps only the enable flag and the refresh
  interval and is framed as plumbing
- "Price unavailable" is replaced by `Paid`: the gate is certain from the public
  API, only the number is best-effort, and that is our plumbing, not the user's
  problem

Verified against a copy of the owner's real database: 52 gated versions ->
28 owned (now silent) + 24 the feature is actually about; the drop migration ran
and both removed endpoints 404.
2026-10-05 08:54:08 +08:00
Will Miao 156d2e5eb9 fix(ui): explain a 0 Buzz threshold in the price alerts panel
The default threshold is 0 ("only tell me when a version becomes free") and the
settings copy says so, but the panel did not: a real instance with 52 priced paid
versions and an untouched threshold showed "Nothing is under your price threshold
right now" with only a small "Alert threshold: 0 Buzz" in the corner, which reads
as a broken feature.

- the payload now carries pricedCount, so the empty state can say how many paid
  versions already have a known price
- when the threshold is 0 and prices are known, the empty state says so and
  points at Settings - Library instead of implying there is nothing to show
- the read-time threshold comparison means setting one takes effect immediately;
  measured on a copy of that instance: 0 Buzz -> 0 alerts, 100 -> 44,
  500 -> 48, 5000 -> 51
2026-10-04 20:28:57 +08:00
Will Miao 425d90a912 fix(update): decouple price capture from the metadata TTL
Found in a real instance: after enabling price tracking, a normal "Check updates"
captured exactly one price out of 718 models, so the alerts panel looked broken
while the log said the refresh completed.

Price capture only ran when the version list was re-fetched, so it inherited the
metadata TTL: with 24 h metadata and 24 h price TTLs, only the handful of models
whose metadata happened to be stale that round were ever priced.

- the cached record already carries the gate, so the price pass now runs off
  whichever version list is available (freshly fetched or stored) and applies the
  result without touching last_checked_at, so a price-only pass cannot silently
  extend the metadata TTL
- a failed attempt now satisfies the price TTL, so a mature model whose page no
  host will serve is not retried on every single update check
- an explicitly forced check re-prices within the TTL

Verified by copying a real instance's update DB into a sandbox and running a
non-forced check: bulk metadata fetches 0 (version lists entirely from cache)
while priced versions went 1 -> 20 and the panel listed 19 alerts.
2026-10-04 20:20:50 +08:00
Will Miao ec5fef512b fix(update): read model-page prices from a host that answers
End-to-end verification against the live site found the price capture broken for
a whole class of users: the civitai page hosts are not interchangeable, and the
user's civitai_host preference was silently fatal. With civitai_host=civitai.red
the update DB held zero prices even with tracking enabled.

- civitai.red refuses non-browser HTTP clients outright (Cloudflare challenge,
  403 for any User-Agent, aiohttp and httpx alike), while civitai.com and
  civitai.green answer normally for anonymously visible models and 404 for
  mature ones. An earlier manual check with curl passed on TLS fingerprint luck,
  which is why this was missed.
- get_model_prices now tries the configured host first, then the others, and
  takes the first parseable payload. The host that worked is remembered, and a
  host that refuses outright is parked for 15 minutes so a library full of
  mature models does not pay three requests each; a 404 is model-specific and
  does not park the host. Links keep using the configured host, which is where
  the user's own browser has clearance.
- Mature models still have no price source anywhere, so that is now stated
  instead of silent: price_check_attempted_at separates "tried and unreadable"
  from "never looked", gated versions show a muted "Price unavailable" badge,
  and the alerts panel reports unavailableCount.
- Failures are logged at warning level, once per host per TTL, with the
  per-host reason, instead of only at debug level.
- The recorded alternatives (internal tRPC with the user's API key, or an
  extension-assisted fetch from the user's browser) and the strengthened
  upstream ask for a public price field are documented in the plan.
2026-10-04 20:00:29 +08:00
Will Miao 7ed19c185c feat(update): add a Buzz price alerts panel to the notification bell
P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what
got cheaper / became free", without a permanent button (the grid filter was
dropped by owner decision, so the panel carries the actions itself).

- price_alert_since records when an alert started, so the panel can say
  "dropped 3 d ago" and count what is new since the user last looked; it is set
  on the first sight of an already-cheap version, preserved while the alert
  stands, and cleared when the price rises back above the threshold
- get_price_alerts() compares the threshold at read time (editing it takes
  effect immediately, no refresh needed) and returns both kinds in one list;
  model_type=None covers every type, which the shared update DB makes a single
  query. "became free" needs no price data, so it is reported even while price
  tracking is off
- GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model
  type, decorating rows best-effort with the local model name and file path from
  the scanner indexes (a cold cache just omits them)
- a third tab in the notification bell: segments for under-threshold and
  became-free, the three states (tracking off / nothing matching / stale), and
  per-row actions (CivitAI always, Open when the model is local)
- two non-permanent entry points share one helper: the controls-bar updates
  dropdown and the global context menu, whose label carries the unread count
- unread state stays client-side (localStorage watermark); the count is fetched
  once on init and only when price tracking is enabled
- the per-type frontend client method is removed as dead code; the per-type
  backend route stays for the companion extension
2026-10-04 15:08:48 +08:00
Will Miao ad2402724b feat(update): track buzz prices and alert below a threshold
CivitAI's public API deliberately omits prices — paidAccess is trimmed to
{permanent, endsAt} because "pricing belongs to the purchase flow" — but the
public model page embeds the site's own model.getById result, including
paidAccess.terms, in its server-rendered payload. That is read anonymously
(no API key, no internal endpoint, no forged Origin), one request per gated
model, so only the ~2% of models that actually carry a gate pay for it.

- optional capture, off by default: price_tracking_enabled,
  price_alert_threshold_buzz (0 = alert on "became free" only) and
  price_check_ttl_hours; prices refresh on their own TTL and immediately when a
  gate changes, and a failed fetch keeps the stored price instead of blanking it
- versions that stop carrying a gate are marked free (persisted gate_lapsed_at)
  and gate transitions are reported as events on the refresh response, so a
  version already in the library can announce that it became free
- price_alert_state plus a price_drop edge event; new
  GET /api/lm/{type}/updates/price-alerts lists what is under the threshold
- versions tab shows the price (effective, with the list price struck through
  and a Blue Buzz note) and a Free Now badge; an update check toasts the
  transitions in one message
- the parser and the alerts query are unit-tested against a trimmed page
  fixture, and every route definition is now asserted to resolve to a handler

Plan, verification notes and the deviations from it are in
docs/plans/paid-model-price-tracking.md.
2026-10-04 08:53:24 +08:00
Will Miao cf3bff9922 Merge remote-tracking branch 'origin/main' into feat/openmodeldb-support
# Conflicts:
#	docs/i18n-translation-guidelines.md
2026-10-03 21:48:37 +08:00
Will Miao 43ef1e86f6 feat(i18n): translate OpenModelDB settings keys in all 9 locales 2026-10-03 21:19:24 +08:00
Will Miao 35f1ced41a feat(metadata): add OpenModelDB metadata provider and model source for upscalers
Add OpenModelDB (openmodeldb.info) as a metadata and download source for
the existing upscaler model type.

Metadata:
- New OpenModelDBClient: fetches the site's bulk JSON dumps, caches them
  on disk (24h TTL + ETag revalidation), and builds a local sha256 index
- New OpenModelDBModelMetadataProvider adapts catalogue entries to the
  CivitAI-shaped version dict contract; registered in the fallback chain
  behind the enable_openmodeldb_api setting (default on), gated to the
  upscaler sub-type so other model types never trigger the dump download
- Persisted provenance uses metadata_source "openmodeldb" plus a nested
  openmodeldb block (page URL, architecture, scale, license)

Images: paired-image LR/SR URLs are ephemeral imgdiff.net sessions, so
displayable images come from the site-hosted auto-generated thumbnails
(model-level cover leads images[], per-image thumbs for the rest); the
original comparison URL is kept in meta.comparisonUrl.

Downloads:
- New OpenModelDBSource (flat model ids, omdb: group prefix) with
  resource filename derivation that recovers names hidden mid-path
  (mediafire) or synthesizes {id}.{type} for folder links
- HTML-gateway mirrors (mediafire/mega/drive) are rejected with a clear
  manual-download hint instead of silently saving an HTML page as .pth
- ModelSource base gains is_valid_source_id / default_subdir_parts /
  resolve_download_url hooks so flat-id sources need no platform branches

UI: "View on OpenModelDB" link in the model modal (downloaded and
hash-enriched models), settings toggle next to the CivArchive one.
2026-10-03 21:13:07 +08:00
Will Miao f8aba393fe feat(models): show Civitai model/version ids in model modal, always-on hash/id search
- Model modal hash footnote now shows Civitai model id and version id
  (right-aligned, quick-copy buttons); hidden for non-Civitai models
- Hash/id exact search (sha256/autov2/autov3/civitai ids) is now always
  on: the search-options "hash" toggle is removed and the search_hash
  query param is silently ignored for API compatibility
- Footnote render condition relaxed so autov3-only and id-only models
  still show the line
- i18n: 4 new keys translated in all 9 locales; filters.hash key removed
2026-10-03 09:46:46 +08:00
Will Miao 515469054c feat(i18n): translate routing-override keys in all 9 locales 2026-10-02 21:50:19 +08:00
Will Miao 693e7e8dca feat(i18n): translate unknown-base-model routing keys in all 9 locales
Fills the 4 settings.unknownBaseModelRouting.* placeholders plus the
leftover doctor.issues.sidecar_mirror_orphans.title placeholder per the
feature owner's request, following docs/i18n-translation-guidelines.md:
option labels reuse each locale's checkpoints.modelTypes renderings,
base model follows the §5 matrix, and punctuation/register match each
file's conventions. Adds the feature's Status note and a §2 term-map
subsection for 'routing'.
2026-10-02 10:58:54 +08:00