Compare commits

..

24 Commits

Author SHA1 Message Date
Will Miao
1352c6ecbe fix(recipes): fall back to Civitai API meta when EXIF is empty, enrich checkpoint in analyze_remote_image
- When downloaded Civitai image has no embedded EXIF, parse the
  already-fetched Civitai API meta (resources, hashes) directly
  instead of skipping parser altogether.
- Extract loras and model from parser output to fill metadata gaps
  when the primary import path doesn't provide them.
- Read modelVersionIds[0] as fallback when modelVersionId is None
  (Civitai API returns both but the singular form can be absent).
- Run RecipeEnricher in analyze_remote_image before returning, so
  the LM UI receives complete metadata including checkpoint with
  zero additional API calls (reuses the image_info already fetched).
2026-05-15 20:31:34 +08:00
Will Miao
30b01b8a92 fix(recipes): offload EXIF to thread pool, throttle concurrent imports, eliminate duplicate Civitai API call
- Wrap ExifUtils.extract_image_metadata() with asyncio.to_thread() in
  both import handlers and analysis_service to prevent Pillow/piexif
  from blocking ComfyUI's event loop during batch imports.
- Add asyncio.Semaphore(2) to import_remote_recipe and import_from_url
  endpoints to cap concurrent heavy work and prevent event loop starvation.
- Pre-fetch Civitai image_info during download and pass it to the recipe
  enricher, eliminating a redundant get_image_info() API round-trip.
2026-05-15 18:29:54 +08:00
Will Miao
a105cb322b fix(metadata): prune stale example-image entries when files are deleted on disk (#927) 2026-05-14 20:51:33 +08:00
Will Miao
3bf396d003 feat(recipes): add toggle to strip <lora:> tags when copying prompt/negative_prompt
Adds a compact inline toggle in the Generation Parameters section of the
Recipe Modal that, when enabled, strips <lora:name:weight> tags and
cleans up residual punctuation before copying to clipboard. The setting
persists across sessions via localStorage.
2026-05-13 11:47:02 +08:00
Will Miao
60cfb3b8e0 chore: add .sisyphus/ to .gitignore 2026-05-13 09:30:26 +08:00
Will Miao
6763abb83c fix(test): update test recipes to use source_path instead of source_url
Follow-up to 86118d06 which consolidated on source_path but missed updating these two tests.
2026-05-13 09:27:05 +08:00
Will Miao
5c53968caa refactor(download-history): rename mark_not_downloaded to mark_as_deleted
The method mark_not_downloaded() was misleading — it doesn't negate
'downloaded' history (the model was indeed downloaded before), but
rather sets is_deleted_override = 1 to indicate the version was
downloaded and subsequently deleted. This flag allows re-download when
the 'skip previously downloaded' setting is enabled.

Rename to mark_as_deleted() to accurately reflect its semantics.
2026-05-12 22:50:30 +08:00
Will Miao
b4f7dd75af fix(persistent-cache): persist scanner cache after model deletion
After deleting a model, the in-memory scanner cache was updated but the
SQLite persistent cache was not. On server restart, the stale persistent
cache caused check_model_version_exists() to return True, blocking
re-download with 'Model version already exists'.

Add _persist_current_cache() calls in both deletion paths:
- ModelLifecycleService.delete_model() (used by versions tab delete)
- delete_model_version handler in MiscHandlers
2026-05-12 22:50:10 +08:00
Will Miao
86118d0654 fix(recipes): persist source_path in SQLite cache and eliminate source_url redundancy
- Add source_path column to PersistentRecipeCache SQLite schema with
  migration for existing databases (ALTER TABLE ADD COLUMN)
- Backfill source_path from recipe JSON files on first startup after
  migration to avoid requiring manual cache rebuild
- Remove all source_url recipe field references (import_remote_recipe,
  import_from_url, check_image_exists, enrichment, batch_import)
  and consolidate on source_path as the single source of truth
- Add civitai.green to supported Civitai page hosts
- Register check-image-exists and import-from-url recipe endpoints
2026-05-12 20:39:09 +08:00
Will Miao
df1410535e fix(ui): remove redundant Quick Refresh from Refresh split button dropdown
The main Refresh button and Quick Refresh dropdown item both called refreshModels(false). Split button dropdowns should only contain alternative actions (Hick's Law). Dropdown now has only Rebuild Cache (fullRebuild=true). Removed from 2 templates, 2 JS files, 1 test fixture, and 10 locale files.
2026-05-12 07:50:54 +08:00
Will Miao
75f74d54d8 feat(bulk): reorganize context menu with sections and submenu for workflow actions
Group 15 flat menu items into 5 logical sections (Workflow, Metadata,
Attributes, Organize, Download) with section headers to reduce cognitive
load. Nest the three workflow-related actions (Append, Replace, Copy
Syntax) into a single "Send to Workflow" hover-triggered submenu.

Add submenu infrastructure to BaseContextMenu with mouseover/mouseout
boundary detection, 250ms close delay, and viewport-aware positioning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:06:47 +08:00
Will Miao
ab6100f596 feat(bulk): add "Download Example Images" to bulk select context menu (#923)
Allows downloading example images only for selected models instead of
the entire library. Reuses the existing /api/lm/force-download-example-images
endpoint which already accepts an array of model hashes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 18:05:00 +08:00
Will Miao
5d3ab3bbf8 feat(showcase): click-to-view full-size image/video in recipe and model modals (#926)
- Add MediaViewer overlay for full-size image/video display with prev/next
  navigation, direction keys, counter, and adjacent preloading
- Recipe modal: click preview image/video opens full-size viewer
- Model showcase: click any example image/video opens viewer with full
  gallery navigation; blurred NSFW content opens directly to clear view
- Use Map<Element, number> for DOM-index mapping instead of URL comparison
  to avoid index mismatch from lazy-loaded vs data-attribute URLs
2026-05-10 22:22:24 +08:00
Will Miao
d9dc0dba8d perf(startup): load extra model paths during Config init to avoid double symlink scan
Move extra folder path resolution from _initialize_services (app.on_startup)
into Config.__init__ via new _load_extra_paths_from_settings() method.
This eliminates a redundant second symlink scan and consolidates all
'Found roots' / 'Found extra roots' logs into one contiguous block
during custom node import, before the ComfyUI server starts.
2026-05-08 14:55:53 +08:00
Will Miao
3631c5eb10 chore: bump version to 1.0.6 2026-05-07 18:59:00 +08:00
Will Miao
6d5b4b7312 fix(test): update drag interaction test to match 454210a4's renderFunction→setValue change
Commit 454210a4 replaced renderFunction() with widget.value setter +
widget.callback() in endDrag, so the test assertion should verify
callback invocation instead of the removed renderSpy call.
2026-05-07 11:03:38 +08:00
Will Miao
7803bd542d feat(base-models): add Ernie, Ernie Turbo, Nucleus base model types (#922)
- Ernie & Anima: auto-fetched via CivitaiBaseModelService from Civitai API
- Ernie Turbo & Nucleus: pre-added as hardcoded constants (not yet in Civitai API)
- Added abbreviations (ERNI, ETRB, NUCL) and category entries across all layers
2026-05-07 10:49:01 +08:00
Will Miao
f0a86dbbc0 feat(bulk): add bulk favorite/unfavorite toggle with context-sensitive single menu item
Replaces two separate menu items with a single smart item that dynamically
switches between 'Set as Favorite' and 'Remove from Favorites' based on
whether all selected items are already favorited. Shows a count badge
'(3/5)' when only some items are favorited in a mixed selection.

Supports all model types (LoRA, Checkpoint, Embedding) and recipes via
existing per-item save/update API — no backend changes needed.
2026-05-07 09:51:23 +08:00
Will Miao
682e964f89 fix(usage-control): enrich usageControl from CivitAI by-hash API for all model types
The model-level API (GET /api/v1/models/{id}) does not include usageControl
on version entries, causing generation-only models to show as downloadable.

Backend changes:
- Add get_model_versions_by_hashes() to CivitaiClient (POST by-hash batch)
- Propagate through all provider classes including RateLimitRetryingProvider
- Add _enrich_version_entries() pipeline: extract SHA256 from files[].hashes,
  batch-call by-hash endpoint, inject usageControl+earlyAccessEndsAt in-place
- Wire enrichment into both bulk (_fetch_model_versions_bulk) and individual
  (_refresh_single_model) refresh paths
- Fix _build_record_from_remote dropping usage_control field
- Fix POST by-hash request format (plain JSON array, not {hashes:[...]} object)

Frontend changes:
- Fix disabled download button tooltip: wrap in <span> since HTML title
  attribute does not fire on disabled elements
2026-05-07 08:56:19 +08:00
Will Miao
908464bc0a docs: remove inline release notes from README (now maintained via GitHub Releases) 2026-05-06 22:40:06 +08:00
willmiao
0ffee3a854 docs: auto-update supporters list in README 2026-05-06 10:29:43 +00:00
Will Miao
8aa9739c44 data: refresh supporters from license server (739 supporters, includes Patreon data) 2026-05-06 18:29:21 +08:00
Will Miao
50739bbb43 fix(css): remove dead CSS properties causing Biome errors
- batch-import-modal.css: add generic font family fallback to Font Awesome
- card.css: remove dead margin-left overridden by shorthand margin: 0
- shared.css: remove duplicate position: absolute overridden by position: fixed
2026-05-06 09:33:15 +08:00
Will Miao
e849303763 fix(header): eliminate search input focus layout shift and reduce focus ring size
- Remove transform: translateY(-1px) that caused layout shift on focus
- Reduce box-shadow focus ring from 2px to 1px for subtler appearance
- Tone down drop-shadow from 4px/16px to 2px/8px (matches base state)
2026-05-06 09:33:04 +08:00
64 changed files with 2379 additions and 732 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ model_cache/
# agent
.opencode/
.claude/
.sisyphus/
.codex
# Vue widgets development cache (but keep build output)

134
README.md

File diff suppressed because one or more lines are too long

View File

@@ -12,33 +12,39 @@
"2018cfh",
"W+K+White",
"wackop",
"Takkan",
"Phil",
"Carl G.",
"Arlecchino Shion",
"stone9k",
"$MetaSamsara",
"itismyelement",
"Gingko Biloba",
"onesecondinosaur",
"stone9k",
"Takkan",
"Charles Blakemore",
"Rob Williams",
"Rosenthal",
"Francisco Tatis",
"Tobi_Swagg",
"Andrew Wilson",
"Greybush",
"Gooohokrbe",
"Ricky Carter",
"JongWon Han",
"OldBones",
"VantAI",
"runte3221",
"Illrigger",
"FreelancerZ",
"Edgar Tejeda",
"Jorge Hussni",
"Liam MacDougal",
"Fraser Cross",
"Polymorphic Indeterminate",
"Birdy",
"Marc Whiffen",
"Jorge Hussni",
"Kiba",
"Birdy",
"Skalabananen",
"Kiba",
"Reno Lam",
"Mozzel",
"sig",
"Christian Byrne",
"DM",
@@ -46,39 +52,41 @@
"Estragon",
"J\\B/ 8r0wns0n",
"Snaggwort",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"ClockDaemon",
"Jonathan Ross",
"KD",
"Omnidex",
"Nazono_hito",
"Tyler Trebuchon",
"Release Cabrakan",
"Tobi_Swagg",
"contrite831",
"SG",
"carozzz",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag",
"Cosmosis",
"Anthony Rizzo",
"iamresist",
"Gooohokrbe",
"RedrockVP",
"Wolffen",
"FloPro4Sho",
"James Todd",
"OldBones",
"Steven Pfeiffer",
"Tim",
"Timmy",
"Johnny",
"Lisster",
"Michael Wong",
"Illrigger",
"whudunit",
"Tom Corrigan",
"dl0901dm",
"JackieWang",
"fnkylove",
"Julian V",
"Steven Owens",
"Yushio",
"Vik71it",
"Echo",
@@ -86,147 +94,137 @@
"Robert Stacey",
"PM",
"Todd Keck",
"Mozzel",
"Gingko Biloba",
"Sterilized",
"Briton Heilbrun",
"Aleksander Wujczyk",
"BadassArabianMofo",
"Sterilized",
"Pascal Dahle",
"quarz",
"Greg",
"Penfore",
"Greg",
"JSST",
"esthe",
"lmsupporter",
"IamAyam",
"zounic",
"wfpearl",
"Baekdoosixt",
"Jonathan Ross",
"Jack B Nimble",
"Nazono_hito",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"contrite831",
"Alex",
"bh",
"confiscated Zyra",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"greebles",
"Adam Shaw",
"Tee Gee",
"Anthony Rizzo",
"tarek helmi",
"Cosmosis",
"M Postkasse",
"FloPro4Sho",
"ASLPro3D",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
"Timmy",
"Johnny",
"Lex Song",
"Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez",
"Zach Gonser",
"Big Red",
"whudunit",
"Jimmy Ledbetter",
"Luc Job",
"dl0901dm",
"Philip Hempel",
"corde",
"Nick Walker",
"lh qwe",
"Julian V",
"Steven Owens",
"Bishoujoker",
"conner",
"aai",
"Briton Heilbrun",
"Tori",
"wildnut",
"Princess Bright Eyes",
"AbstractAss",
"Felipe dos Santos",
"ViperC",
"jean jahren",
"Aleksander Wujczyk",
"AM Kuro",
"Markus",
"S Sang",
"ViperC",
"Ran C",
"Sangheili460",
"MagnaInsomnia",
"Karl P.",
"Akira_HentAI",
"MagnaInsomnia",
"Gordon Cole",
"yuxz69",
"Douglas Gaspar",
"AlexDuKaNa",
"George",
"esthe",
"andrew.tappan",
"dw",
"N/A",
"The Spawn",
"Phil",
"graysock",
"Pozadine1",
"Greenmoustache",
"zounic",
"fancypants",
"IamAyam",
"Eldithor",
"Joboshy",
"Digital",
"JaxMax",
"takyamtom",
"奚明 刘",
"Bohemian Corporal",
"Dan",
"confiscated Zyra",
"Jwk0205",
"Bro Xie",
"준희 김",
"yer fey",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Tee Gee",
"Some Guy Named Barry",
"jinxedx",
"tarek helmi",
"Max Marklund",
"Tomohiro Baba",
"David Ortega",
"AELOX",
"Dankin",
"Nicfit23",
"Noora",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Mattssn",
"Lex Song",
"John Saveas",
"Frank Nitty",
"Pronredn",
"Christopher Michel",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"nahinahi9",
"lh qwe",
"Kevin John Duck",
"conner",
"Dustin Chen",
"dan",
"Yaboi",
"Blackfish95",
"Mouthlessman",
"Steam Steam",
"Damon Cunliffe",
"CryptoTraderJK",
"Davaitamin",
"Princess Bright Eyes",
"Paul Kroll",
"AbstractAss",
"otaku fra",
"Ran C",
"tedcor",
"Fotek Design",
"Felipe dos Santos",
"Bas Imagineer",
"Markus",
"MiraiKuriyamaSy",
"Adam Taylor",
"Douglas Gaspar",
"Weird_With_A_Beard",
"MadSpin",
"Pozadine1",
"AlexDuKaNa",
"George",
"dw",
"Qarob",
"AIGooner",
"inbijiburu",
"Luc",
"ProtonPrince",
"DiffDuck",
"elu3199",
"Nick “Loadstone” D",
"Hasturkun",
"Jon Sandman",
"Ubivis",
@@ -234,54 +232,45 @@
"thesoftwaredruid",
"wundershark",
"mr_dinosaur",
"Tyrswood",
"linnfrey",
"Gamalonia",
"Vir",
"Pkrsky",
"Joboshy",
"Bohemian Corporal",
"Dan",
"奚明 刘",
"Josef Lanzl",
"Seth Christensen",
"Nerezza",
"Griffin Dahlberg",
"Draven T",
"yer fey",
"준희 김",
"Error_Rule34_Not_found",
"Gerald Welly",
"Roslynd",
"Geolog",
"jinxedx",
"Neco28",
"Aquatic Coffee",
"Dankin",
"ethanfel",
"Tomohiro Baba",
"David Ortega",
"Noora",
"Cristian Vazquez",
"Frank Nitty",
"Mattssn",
"Magic Noob",
"Focuschannel",
"DougPeterson",
"Jeff",
"Bruce",
"Kevin John Duck",
"Anthony Faxlandez",
"Kevin Christopher",
"Ouro Boros",
"Blackfish95",
"Chad Idk",
"Yaboi",
"dd",
"Paul Kroll",
"MiraiKuriyamaSy",
"semicolon drainpipe",
"Thesharingbrother",
"Bas Imagineer",
"Pat Hen",
"Steam Steam",
"CryptoTraderJK",
"Davaitamin",
"Dušan Ryban",
"tedcor",
"Fotek Design",
"sjon kreutz",
"John Statham",
"ResidentDeviant",
"Nihongasuki",
"JC",
"Prompt Pirate",
"uwutismxd",
"MadSpin",
"Metryman55",
"inbijiburu",
"decoy",
"Tyrswood",
"Nick “Loadstone” D",
"Ray Wing",
"Ranzitho",
"Gus",
@@ -290,6 +279,7 @@
"David LaVallee",
"ae",
"Tr4shP4nda",
"Gamalonia",
"WRL_SPR",
"capn",
"Joseph",
@@ -302,77 +292,60 @@
"Moon Knight",
"몽타주",
"Kland",
"zenobeus",
"Jackthemind",
"ryoma",
"Stryker",
"raf8osz",
"ElitaSSJ4",
"blikkies",
"Chris",
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"Vir",
"Brian M",
"Nerezza",
"sanborondon",
"Seth Christensen",
"Draven T",
"Taylor Funk",
"aezin",
"Thought2Form",
"jcay015",
"Kevin Picco",
"Erik Lopez",
"Shock Shockor",
"Mateo Curić",
"Goldwaters",
"Zude",
"Aquatic Coffee",
"Eris3D",
"m",
"ethanfel",
"Pierce McBride",
"Joshua Gray",
"Kyler",
"Focuschannel",
"Mikko Hemilä",
"aRtFuL_DodGeR",
"Jamie Ogletree",
"a _",
"James Coleman",
"CrimsonDX",
"Martial",
"Anthony Faxlandez",
"battu",
"Emil Andersson",
"Chad Idk",
"DarkSunset",
"Billy Gladky",
"Yuji Kaneko",
"Probis",
"Dušan Ryban",
"ItsGeneralButtNaked",
"Pat Hen",
"semicolon drainpipe",
"Jordan Shaw",
"Rops Alot",
"Thesharingbrother",
"Sam",
"sjon kreutz",
"Nimess",
"SRDB",
"Ace Ventura",
"g unit",
"Youguang",
"Metryman55",
"andrewzpong",
"FrxzenSnxw",
"BossGame",
"lrdchs",
"ResidentDeviant",
"Nihongasuki",
"JC",
"Prompt Pirate",
"uwutismxd",
"momokai",
"Hailshem",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"zenobeus",
"ken",
"Inversity",
"AIVORY3D",
"epicgamer0020690",
"Joshua Porrata",
"keemun",
"SuBu",
"RedPIXel",
"Kevinj",
"Wind",
"Jackthemind",
"Nexus",
"Ramneek“Guy”Ashok",
"squid_actually",
@@ -385,80 +358,81 @@
"emyth",
"chriphost",
"KitKatM",
"ryoma",
"socrasteeze",
"ResidentDeviant",
"OrganicArtifact",
"Stryker",
"MudkipMedkitz",
"gzmzmvp",
"Welkor",
"John Martin",
"raf8osz",
"ElitaSSJ4",
"Richard",
"blikkies",
"Andrew",
"Chris",
"Robert Wegemund",
"Littlehuggy",
"moranqianlong",
"Gregory Kozhemiak",
"mrjuan",
"Brian Buie",
"Shock Shockor",
"Sadlip",
"Haru Yotu",
"Goldwaters",
"Eric Whitney",
"Joey Callahan",
"Zude",
"Ivan Tadic",
"Mike Simone",
"John J Linehan",
"Kyler",
"Elliot E",
"Morgandel",
"Kyron Mahan",
"Matura Arbeit",
"Theerat Jiramate",
"aRtFuL_DodGeR",
"Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Temikus",
"Artokun",
"Michael Taylor",
"SendingRavens",
"Derek Baker",
"CrimsonDX",
"Michael Anthony Scott",
"DarkSunset",
"Atilla Berke Pekduyar",
"Michael Docherty",
"Nathan",
"Billy Gladky",
"NICHOLAS BAXLEY",
"Decx _",
"Paul Hartsuyker",
"elitassj",
"Jacob Winter",
"Probis",
"Ed Wang",
"ItsGeneralButtNaked",
"Nimess",
"SRDB",
"g unit",
"Distortik",
"David",
"Meilo",
"Pen Bouryoung",
"Youguang",
"四糸凜音",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"Saya",
"andrewzpong",
"FrxzenSnxw",
"BossGame",
"lrdchs",
"Tree Tagger",
"Janik",
"Inversity",
"Crocket",
"Cruel",
"MRBlack",
"AIVORY3D",
"Kevinj",
"Mitchell Robson",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Whitepinetrader",
"OrganicArtifact",
"Scott",
"MudkipMedkitz",
"ResidentDeviant",
"deanbrian",
"POPPIN",
"Alex Wortman",
"Cody",
"Raku",
"smart.edge5178",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
@@ -466,76 +440,152 @@
"Menard",
"Skyfire83",
"Adam Rinehart",
"D",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude",
"g9p0o",
"nanana",
"TheHolySheep",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"Nathen+Choi",
"T",
"LarsesFPC",
"cocona",
"sfasdfasfdsa",
"Buecyb99",
"4IXplr0r3r",
"dfklsjfkljslfjd",
"hayden",
"ahoystan",
"Leland Saunders",
"Welkor",
"David Schenck",
"John Martin",
"Wolfe7D1",
"Ink Temptation",
"Bob Barker",
"edk",
"moranqianlong",
"Kalli Core",
"Aeternyx",
"elleshar666",
"YOU SINWOO",
"ja s",
"Doug Mason",
"ACTUALLY_the_Real_Willem_Dafoe",
"Haru Yotu",
"Kauffy",
"Jeremy Townsend",
"EpicElric",
"Sean voets",
"Owen Gwosdz",
"John J Linehan",
"Elliot E",
"Thomas Wanner",
"Theerat Jiramate",
"Kyron Mahan",
"Edward Kennedy",
"Justin Blaylock",
"Devil Lude",
"Matura Arbeit",
"Nick Kage",
"kevin stoddard",
"Jack Dole",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Vane Holzer",
"psytrax",
"Cyrus Fett",
"Ezokewn",
"SendingRavens",
"hexxish",
"CptNeo",
"notedfakes",
"Maso",
"Eric Ketchum",
"NICHOLAS BAXLEY",
"Michael Docherty",
"Michael Scott",
"Kevin Wallace",
"Matheus Couto",
"Saya",
"ChicRic",
"mercur",
"J C",
"Ed Wang",
"Paul Hartsuyker",
"elitassj",
"Jacob Winter",
"Ryan Presley Ng",
"Wes Sims",
"Donor4115",
"Lyavph",
"David",
"Meilo",
"Filippo Ferrari",
"Pen Bouryoung",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"Janik",
"Cruel",
"MRBlack",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Scott",
"Muratoraccio",
"Ginnie",
"emadsultan",
"D",
"nanana",
"Fthehappy",
"rsamerica",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"generic404",
"Doug+Rintoul",
"Noor",
"Yorunai",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"zounik",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"4IXplr0r3r",
"hayden",
"ahoystan",
"Leland Saunders",
"Bob Barker",
"edk",
"JBsuede",
"Time Valentine",
"Aeternyx",
"YOU SINWOO",
"りん あめ",
"ja s",
"Михал Михалыч",
"Matt",
"Doug Mason",
"Jeremy Townsend",
"Frogmilk",
"Sean voets",
"Owen Gwosdz",
"SPJ",
"Thomas Wanner",
"Bryan Rutkowski",
"Devil Lude",
"David Murcko",
"kevin stoddard",
"Jack Dole",
"max blo",
"Xenon Xue",
"CptNeo",
"JackJohnnyJim",
"Dmitry Ryzhov",
"Maso",
"Edward Ten Eyck",
"Eric Ketchum",
"Kevin Wallace",
"Matheus Couto",
"ChicRic",
"Henrique Faiolli",
"mercur",
"Solixer",
"J C",
"jinksta187",
"Andrew Wilkinson",
"Manu Thetug",
"Karlanx",
"Yves Poezevara",
"operationancut",
"Teriak47",
"Just me",
"Raf Stahelin",
"Вячеслав Маринин",
"Lyavph",
"Filippo Ferrari",
"Cola Matthew",
"OniNoKen",
"Iain Wisely",
@@ -576,98 +626,121 @@
"dg",
"Maarten Harms",
"Israel",
"Muratoraccio",
"SelfishMedic",
"Ginnie",
"adderleighn",
"EnragedAntelope",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"generic404",
"lighthawke",
"Terraformer",
"GDS+DEV",
"4rt+r3d",
"low9",
"Winged",
"you+halo9",
"YassineKhaled",
"YK12",
"MatteKey",
"Flob",
"ShiroSenpai",
"Somebody",
"Inkognito",
"Somebody",
"Gramer+Gumbyte",
"Crescent~San",
"Tan+Huynh",
"AiGirlTS",
"D",
"datasl4ve",
"Somebody",
"Dark_Pest",
"Aza",
"Jacky+Ho",
"koopa990",
"Karru",
"ChaChanoKo",
"null",
"bo",
"The+Forgetful+Dev",
"redcarrot",
"powerbot99",
"Mateusz+Kosela",
"Doug+Rintoul",
"Noor",
"Yorunai",
"Bula",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"zounik",
"letzte",
"Nasty+Hobbit",
"SgtFluffles",
"lrdchs2",
"Duk3+Rand0m",
"KUJYAKU",
"NathenChoi",
"Thomas+Reck",
"Larses",
"cocona",
"Coeur+de+cochon",
"David Schenck",
"han b",
"Nico",
"Banana Joe",
"_ G3n",
"Donovan Jenkins",
"JBsuede",
"Tú Nguyễn Lý Hoàng",
"Michael Eid",
"beersandbacon",
"Maximilian Pyko",
"Invis",
"Justin Houston",
"Time Valentine",
"Bob barker",
"Ben D",
"Garrett Wood",
"Ronan Delevacq",
"james",
"Christian Schäfer",
"OrochiNights",
"Michael Zhu",
"ACTUALLY_the_Real_Willem_Dafoe",
"gonzalo",
"Seraphy",
"Михал Михалыч",
"雨の心 落",
"Matt",
"AllTimeNoobie",
"jumpd",
"John C",
"Rim",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Dismem",
"Frogmilk",
"SPJ",
"Locrospiel",
"Jairus Knudsen",
"Jarrid Lee",
"Xan Dionysus",
"Nathan lee",
"Kor",
"Joseph Hanson",
"Mewtora",
"Middo",
"Forbidden Atelier",
"Bryan Rutkowski",
"John Rednoulf",
"Spire",
"Adictedtohumping",
"Boba Smith",
"Towelie",
"Cyrus Fett",
"MR.Bear",
"dsffsdfsdfsdfsdfsdf",
"Jean-françois SEMA",
"Kurt",
"max blo",
"Xenon Xue",
"JackJohnnyJim",
"Edward Ten Eyck",
"ivistorm",
"Sauv",
"Steven",
"TenaciousD",
"Khánh Đặng",
"Chase Kwon",
"Ted Cart",
"Inyoshu",
"Goober719",
"Chad Barnes",
"Person Y",
"David Spearing",
"James Ming",
"vanditking",
"kripitonga",
"Rizzi",
"nimin",
"OMAR LUCIANO",
"Ken+Suzuki",
"hannibal",
"Jo+Example",
"BrentBertram",
"Tigon",
"eumelzocker",
"dxjaymz",
"L C",
"Dude"
"Dude",
"CK"
],
"totalCount": 666
"totalCount": 739
}

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "Modelliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
"full": "Cache neu aufbauen",
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
},
@@ -687,11 +685,23 @@
"autoOrganize": "Automatisch organisieren",
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
"setFavorite": "Als Favorit setzen",
"setFavoriteCount": "Als Favorit setzen ({favorited}/{total})",
"unfavorite": "Aus Favoriten entfernen",
"deleteAll": "Ausgewählte löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"downloadExamples": "Beispielbilder herunterladen",
"clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
"sendToWorkflow": "An Workflow senden",
"sections": {
"workflow": "Workflow",
"metadata": "Metadaten",
"attributes": "Attribute",
"organize": "Organisieren",
"download": "Download"
},
"autoOrganizeProgress": {
"initializing": "Automatische Organisation wird initialisiert...",
"starting": "Automatische Organisation für {type} wird gestartet...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "Rezeptliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
"full": "Cache neu aufbauen",
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
"bulkFavoriteUpdating": "Füge {count} Modell(e) zu Favoriten hinzu...",
"bulkUnfavoriteUpdating": "Entferne {count} Modell(e) aus Favoriten...",
"bulkFavoritePartialAdded": "{success} Modell(e) zu Favoriten hinzugefügt, {failed} fehlgeschlagen",
"bulkFavoritePartialRemoved": "{success} Modell(e) aus Favoriten entfernt, {failed} fehlgeschlagen",
"bulkFavoriteFailed": "Fehler beim Aktualisieren des Favoritenstatus",
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "Refresh model list",
"quick": "Sync Changes",
"quickTooltip": "Scan for new or missing model files so the list stays current.",
"full": "Rebuild Cache",
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
},
@@ -687,11 +685,23 @@
"autoOrganize": "Auto-Organize Selected",
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
"setFavorite": "Set as Favorite",
"setFavoriteCount": "Set as Favorite ({favorited}/{total})",
"unfavorite": "Remove from Favorites",
"deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs",
"downloadExamples": "Download Example Images",
"clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)",
"sendToWorkflow": "Send to Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Metadata",
"attributes": "Attributes",
"organize": "Organize",
"download": "Download"
},
"autoOrganizeProgress": {
"initializing": "Initializing auto-organize...",
"starting": "Starting auto-organize for {type}...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "Refresh recipe list",
"quick": "Sync Changes",
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
"full": "Rebuild Cache",
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
"bulkContentRatingFailed": "Failed to update content rating for selected models",
"bulkFavoriteUpdating": "Adding {count} model(s) to favorites...",
"bulkUnfavoriteUpdating": "Removing {count} model(s) from favorites...",
"bulkFavoritePartialAdded": "Added {success} model(s) to favorites, {failed} failed",
"bulkFavoritePartialRemoved": "Removed {success} model(s) from favorites, {failed} failed",
"bulkFavoriteFailed": "Failed to update favorite status for selected models",
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
"bulkUpdatesNone": "No updates found for selected {type}(s)",

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "Actualizar lista de modelos",
"quick": "Sincronizar cambios",
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
"full": "Reconstruir caché",
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
},
@@ -687,11 +685,23 @@
"autoOrganize": "Auto-organizar seleccionados",
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"setFavorite": "Marcar como favorito",
"setFavoriteCount": "Marcar como favorito ({favorited}/{total})",
"unfavorite": "Quitar de favoritos",
"deleteAll": "Eliminar seleccionados",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"downloadExamples": "Descargar imágenes de ejemplo",
"clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos",
"sendToWorkflow": "Enviar al workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Metadatos",
"attributes": "Atributos",
"organize": "Organizar",
"download": "Descargar"
},
"autoOrganizeProgress": {
"initializing": "Inicializando auto-organización...",
"starting": "Iniciando auto-organización para {type}...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "Actualizar lista de recetas",
"quick": "Sincronizar cambios",
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
"full": "Reconstruir caché",
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
"bulkFavoriteUpdating": "Añadiendo {count} modelo(s) a favoritos...",
"bulkUnfavoriteUpdating": "Eliminando {count} modelo(s) de favoritos...",
"bulkFavoritePartialAdded": "{success} modelo(s) añadido(s) a favoritos, {failed} fallido(s)",
"bulkFavoritePartialRemoved": "{success} modelo(s) eliminado(s) de favoritos, {failed} fallido(s)",
"bulkFavoriteFailed": "Error al actualizar el estado de favorito",
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "Actualiser la liste des modèles",
"quick": "Synchroniser les changements",
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
"full": "Reconstruire le cache",
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
},
@@ -687,11 +685,23 @@
"autoOrganize": "Auto-organiser la sélection",
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"setFavorite": "Définir comme favori",
"setFavoriteCount": "Définir comme favori ({favorited}/{total})",
"unfavorite": "Retirer des favoris",
"deleteAll": "Supprimer la sélection",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"downloadExamples": "Télécharger les images d'exemple",
"clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles",
"sendToWorkflow": "Envoyer au workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Métadonnées",
"attributes": "Attributs",
"organize": "Organiser",
"download": "Télécharger"
},
"autoOrganizeProgress": {
"initializing": "Initialisation de l'auto-organisation...",
"starting": "Démarrage de l'auto-organisation pour {type}...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "Actualiser la liste des recipes",
"quick": "Synchroniser les changements",
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
"full": "Reconstruire le cache",
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
"bulkFavoriteUpdating": "Ajout de {count} modèle(s) aux favoris...",
"bulkUnfavoriteUpdating": "Suppression de {count} modèle(s) des favoris...",
"bulkFavoritePartialAdded": "{success} modèle(s) ajouté(s) aux favoris, {failed} échec(s)",
"bulkFavoritePartialRemoved": "{success} modèle(s) retiré(s) des favoris, {failed} échec(s)",
"bulkFavoriteFailed": "Échec de la mise à jour du statut de favori",
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "רענן רשימת מודלים",
"quick": "סנכרון שינויים",
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
"full": "בניית מטמון מחדש",
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
},
@@ -687,11 +685,23 @@
"autoOrganize": "ארגן אוטומטית נבחרים",
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"setFavorite": "הגדר כמועדף",
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
"unfavorite": "הסר ממועדפים",
"deleteAll": "מחק נבחרים",
"downloadMissingLoras": "הורדת LoRAs חסרים",
"downloadExamples": "הורד תמונות דוגמה",
"clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
"sendToWorkflow": "שלח ל-Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "מטא-נתונים",
"attributes": "מאפיינים",
"organize": "ארגן",
"download": "הורדה"
},
"autoOrganizeProgress": {
"initializing": "מאתחל ארגון אוטומטי...",
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "רענן רשימת מתכונים",
"quick": "סנכרן שינויים",
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
"full": "בנה מטמון מחדש",
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
"bulkFavoriteUpdating": "מוסיף {count} דגמים למועדפים...",
"bulkUnfavoriteUpdating": "מסיר {count} דגמים ממועדפים...",
"bulkFavoritePartialAdded": "{success} דגמים נוספו למועדפים, {failed} נכשלו",
"bulkFavoritePartialRemoved": "{success} דגמים הוסרו ממועדפים, {failed} נכשלו",
"bulkFavoriteFailed": "עדכון סטטוס מועדפים נכשל",
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "モデルリストを更新",
"quick": "変更を同期",
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
"full": "キャッシュを再構築",
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
},
@@ -687,11 +685,23 @@
"autoOrganize": "自動整理を実行",
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"setFavorite": "お気に入りに設定",
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
"unfavorite": "お気に入りから削除",
"deleteAll": "選択したものを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード",
"downloadExamples": "例画像をダウンロード",
"clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)",
"sendToWorkflow": "ワークフローに送信",
"sections": {
"workflow": "ワークフロー",
"metadata": "メタデータ",
"attributes": "属性",
"organize": "整理",
"download": "ダウンロード"
},
"autoOrganizeProgress": {
"initializing": "自動整理を初期化中...",
"starting": "{type}の自動整理を開始中...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "レシピリストを更新",
"quick": "変更を同期",
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
"full": "キャッシュを再構築",
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
"bulkFavoriteUpdating": "{count} 個のモデルをお気に入りに追加中...",
"bulkUnfavoriteUpdating": "{count} 個のモデルをお気に入りから削除中...",
"bulkFavoritePartialAdded": "{success} 個のモデルをお気に入りに追加、{failed} 個失敗",
"bulkFavoritePartialRemoved": "{success} 個のモデルをお気に入りから削除、{failed} 個失敗",
"bulkFavoriteFailed": "お気に入り状態の更新に失敗しました",
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "모델 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
"full": "캐시 재구성",
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
},
@@ -687,11 +685,23 @@
"autoOrganize": "자동 정리 선택",
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"setFavorite": "즐겨찾기로 설정",
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
"unfavorite": "즐겨찾기 해제",
"deleteAll": "선택된 항목 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"downloadExamples": "예시 이미지 다운로드",
"clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)",
"sendToWorkflow": "워크플로우로 보내기",
"sections": {
"workflow": "워크플로우",
"metadata": "메타데이터",
"attributes": "속성",
"organize": "정리",
"download": "다운로드"
},
"autoOrganizeProgress": {
"initializing": "자동 정리 초기화 중...",
"starting": "{type}에 대한 자동 정리 시작...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "레시피 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
"full": "캐시 재구성",
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
"bulkFavoriteUpdating": "{count}개 모델을 즐겨찾기에 추가 중...",
"bulkUnfavoriteUpdating": "{count}개 모델을 즐겨찾기에서 제거 중...",
"bulkFavoritePartialAdded": "{success}개 모델을 즐겨찾기에 추가, {failed}개 실패",
"bulkFavoritePartialRemoved": "{success}개 모델을 즐겨찾기에서 제거, {failed}개 실패",
"bulkFavoriteFailed": "즐겨찾기 상태 업데이트 실패",
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "Обновить список моделей",
"quick": "Синхронизировать изменения",
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
"full": "Перестроить кэш",
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
},
@@ -687,11 +685,23 @@
"autoOrganize": "Автоматически организовать выбранные",
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"setFavorite": "Добавить в избранное",
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
"unfavorite": "Удалить из избранного",
"deleteAll": "Удалить выбранные",
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
"downloadExamples": "Загрузить примеры изображений",
"clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
"sendToWorkflow": "Отправить в Workflow",
"sections": {
"workflow": "Workflow",
"metadata": "Метаданные",
"attributes": "Атрибуты",
"organize": "Организовать",
"download": "Скачать"
},
"autoOrganizeProgress": {
"initializing": "Инициализация автоматической организации...",
"starting": "Запуск автоматической организации для {type}...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "Обновить список рецептов",
"quick": "Синхронизировать изменения",
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
"full": "Перестроить кэш",
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
"bulkFavoriteUpdating": "Добавление {count} моделей в избранное...",
"bulkUnfavoriteUpdating": "Удаление {count} моделей из избранного...",
"bulkFavoritePartialAdded": "{success} моделей добавлено в избранное, {failed} не удалось",
"bulkFavoritePartialRemoved": "{success} моделей удалено из избранного, {failed} не удалось",
"bulkFavoriteFailed": "Не удалось обновить статус избранного",
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "刷新模型列表",
"quick": "同步变更",
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
"full": "重建缓存",
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
},
@@ -687,11 +685,23 @@
"autoOrganize": "自动整理所选模型",
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"setFavorite": "设为收藏",
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
"unfavorite": "取消收藏",
"deleteAll": "删除已选",
"downloadMissingLoras": "下载缺失的 LoRAs",
"downloadExamples": "下载示例图片",
"clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
"sendToWorkflow": "发送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元数据",
"attributes": "属性",
"organize": "整理",
"download": "下载"
},
"autoOrganizeProgress": {
"initializing": "正在初始化自动整理...",
"starting": "正在为 {type} 启动自动整理...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "刷新配方列表",
"quick": "同步变更",
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
"full": "重建缓存",
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level}{failed} 个失败",
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
"bulkFavoriteUpdating": "正在将 {count} 个模型添加到收藏...",
"bulkUnfavoriteUpdating": "正在将 {count} 个模型从收藏移除...",
"bulkFavoritePartialAdded": "已将 {success} 个模型添加到收藏,{failed} 个失败",
"bulkFavoritePartialRemoved": "已将 {success} 个模型从收藏移除,{failed} 个失败",
"bulkFavoriteFailed": "更新收藏状态失败",
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
"bulkUpdatesNone": "所选 {type} 未发现更新",

View File

@@ -640,8 +640,6 @@
},
"refresh": {
"title": "重新整理模型列表",
"quick": "同步變更",
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
"full": "重建快取",
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
},
@@ -687,11 +685,23 @@
"autoOrganize": "自動整理所選模型",
"skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
"setFavorite": "設為收藏",
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
"unfavorite": "取消收藏",
"deleteAll": "刪除所選",
"downloadMissingLoras": "下載缺失的 LoRAs",
"downloadExamples": "下載範例圖片",
"clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
"sendToWorkflow": "發送到工作流",
"sections": {
"workflow": "工作流",
"metadata": "元數據",
"attributes": "屬性",
"organize": "整理",
"download": "下載"
},
"autoOrganizeProgress": {
"initializing": "正在初始化自動整理...",
"starting": "正在開始自動整理 {type}...",
@@ -804,8 +814,6 @@
},
"refresh": {
"title": "重新整理配方列表",
"quick": "同步變更",
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
"full": "重建快取",
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
},
@@ -1699,6 +1707,11 @@
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level}{failed} 個失敗",
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
"bulkFavoriteUpdating": "正在將 {count} 個模型加入收藏...",
"bulkUnfavoriteUpdating": "正在將 {count} 個模型從收藏移除...",
"bulkFavoritePartialAdded": "已將 {success} 個模型加入收藏,{failed} 個失敗",
"bulkFavoritePartialRemoved": "已將 {success} 個模型從收藏移除,{failed} 個失敗",
"bulkFavoriteFailed": "更新收藏狀態失敗",
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
"bulkUpdatesNone": "所選 {type} 未找到更新",

View File

@@ -172,6 +172,12 @@ class Config:
self.extra_unet_roots: List[str] = []
self.extra_embeddings_roots: List[str] = []
self.recipes_path: str = ""
# Load extra folder paths from active library settings before symlink scan
# so both primary and extra paths are discovered in a single pass.
if not standalone_mode:
self._load_extra_paths_from_settings()
# Scan symbolic links during initialization
self._initialize_symlink_mappings()
@@ -179,6 +185,96 @@ class Config:
# Save the paths to settings.json when running in ComfyUI mode
self.save_folder_paths_to_settings()
def _load_extra_paths_from_settings(self) -> None:
"""Read extra folder paths from the active library and apply them.
Called during ``Config.__init__`` before the symlink scan so both primary and
extra paths are discovered in a single pass. Mirrors the extra-path
portion of ``_apply_library_paths`` without replacing the primary roots
that were already resolved from ComfyUI's ``folder_paths``.
"""
try:
from .services.settings_manager import get_settings_manager
settings_manager = get_settings_manager()
library_name = settings_manager.get_active_library_name()
libraries = settings_manager.get_libraries()
if not library_name or library_name not in libraries:
return
library_config = libraries[library_name]
if not isinstance(library_config, dict):
return
extra_folder_paths = library_config.get("extra_folder_paths")
if not isinstance(extra_folder_paths, dict):
return
extra_lora = extra_folder_paths.get("loras", []) or []
extra_checkpoint = extra_folder_paths.get("checkpoints", []) or []
extra_unet = extra_folder_paths.get("unet", []) or []
extra_embedding = extra_folder_paths.get("embeddings", []) or []
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding]):
return
filtered_extra_lora = self._filter_overlapping_extra_lora_paths(
self.loras_roots, extra_lora
)
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora)
(
_,
self.extra_checkpoints_roots,
self.extra_unet_roots,
) = self._prepare_checkpoint_paths(extra_checkpoint, extra_unet)
self.extra_embeddings_roots = self._prepare_embedding_paths(
extra_embedding
)
recipes_path = library_config.get("recipes_path", "")
if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path
if self.extra_loras_roots:
logger.info(
"Found extra LoRA roots:"
+ "\n - "
+ "\n - ".join(self.extra_loras_roots)
)
if self.extra_checkpoints_roots:
logger.info(
"Found extra checkpoint roots:"
+ "\n - "
+ "\n - ".join(self.extra_checkpoints_roots)
)
if self.extra_unet_roots:
logger.info(
"Found extra diffusion model roots:"
+ "\n - "
+ "\n - ".join(self.extra_unet_roots)
)
if self.extra_embeddings_roots:
logger.info(
"Found extra embedding roots:"
+ "\n - "
+ "\n - ".join(self.extra_embeddings_roots)
)
logger.info(
"Applied library settings for '%s' with extra paths: loras=%s, "
"checkpoints=%s, embeddings=%s",
library_name,
extra_lora,
extra_checkpoint,
extra_embedding,
)
except Exception as exc:
logger.debug(
"Could not load extra paths from library settings: %s", exc
)
def save_folder_paths_to_settings(self):
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
try:

View File

@@ -184,39 +184,6 @@ class LoraManager:
async def _initialize_services(cls):
"""Initialize all services using the ServiceRegistry"""
try:
# Apply library settings to load extra folder paths before scanning
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
try:
from .services.settings_manager import get_settings_manager
settings_manager = get_settings_manager()
library_name = settings_manager.get_active_library_name()
libraries = settings_manager.get_libraries()
if library_name and library_name in libraries:
library_config = libraries[library_name]
# Only apply settings if extra paths are not already configured
# This preserves values set by tests via monkeypatch
extra_paths = library_config.get("extra_folder_paths", {})
has_extra_paths = (
config.extra_loras_roots
or config.extra_checkpoints_roots
or config.extra_unet_roots
or config.extra_embeddings_roots
)
if not has_extra_paths and any(extra_paths.values()):
config.apply_library_settings(library_config)
logger.info(
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
library_name,
extra_paths.get("loras", []),
extra_paths.get("checkpoints", []),
extra_paths.get("embeddings", []),
)
except Exception as exc:
logger.warning(
"Failed to apply library settings during initialization: %s", exc
)
# Initialize CivitaiClient first to ensure it's ready for other services
await ServiceRegistry.get_civitai_client()

View File

@@ -16,7 +16,9 @@ class RecipeEnricher:
async def enrich_recipe(
recipe: Dict[str, Any],
civitai_client: Any,
request_params: Optional[Dict[str, Any]] = None
request_params: Optional[Dict[str, Any]] = None,
prefetched_civitai_meta_raw: Optional[Dict[str, Any]] = None,
prefetched_model_version_id: Optional[int] = None,
) -> bool:
"""
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
@@ -25,6 +27,9 @@ class RecipeEnricher:
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
civitai_client: Authenticated Civitai client instance.
request_params: (Optional) Parameters from a user request (e.g. import).
prefetched_civitai_meta_raw: (Optional) Pre-fetched raw meta from Civitai
get_image_info, avoiding a duplicate API call.
prefetched_model_version_id: (Optional) Pre-fetched model version ID.
Returns:
bool: True if the recipe was modified, False otherwise.
@@ -32,21 +37,27 @@ class RecipeEnricher:
updated = False
gen_params = recipe.get("gen_params", {})
# 1. Fetch Civitai Info if available
# 1. Obtain Civitai metadata
civitai_meta = None
model_version_id = None
model_version_id = prefetched_model_version_id
source_url = recipe.get("source_url") or recipe.get("source_path", "")
source_path = recipe.get("source_path", "")
# Check if it's a Civitai image URL
image_id = extract_civitai_image_id(str(source_url))
if prefetched_civitai_meta_raw is not None:
raw_meta = prefetched_civitai_meta_raw
if isinstance(raw_meta, dict):
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
civitai_meta = raw_meta["meta"]
else:
civitai_meta = raw_meta
else:
image_id = extract_civitai_image_id(str(source_path))
if image_id:
try:
image_info = await civitai_client.get_image_info(
image_id, source_url=str(source_url)
image_id, source_url=str(source_path)
)
if image_info:
# Handle nested meta often found in Civitai API responses
raw_meta = image_info.get("meta")
if isinstance(raw_meta, dict):
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
@@ -55,16 +66,15 @@ class RecipeEnricher:
civitai_meta = raw_meta
model_version_id = image_info.get("modelVersionId")
except Exception as e:
logger.warning(f"Failed to fetch Civitai image info: {e}")
# If not at top level, check resources in meta
if not model_version_id and civitai_meta:
resources = civitai_meta.get("civitaiResources", [])
for res in resources:
if res.get("type") == "checkpoint":
model_version_id = res.get("modelVersionId")
break
except Exception as e:
logger.warning(f"Failed to fetch Civitai image info: {e}")
# 2. Merge Parameters
# Priority: request_params > civitai_meta > embedded (existing gen_params)

View File

@@ -2065,7 +2065,7 @@ class ModelLibraryHandler:
file_path=file_path if isinstance(file_path, str) else None,
)
else:
await history_service.mark_not_downloaded(model_type, model_version_id)
await history_service.mark_as_deleted(model_type, model_version_id)
return web.json_response(
{
@@ -2139,8 +2139,19 @@ class ModelLibraryHandler:
]
await found_cache.resort()
scanner_map = {
"lora": lora_scanner,
"checkpoint": checkpoint_scanner,
"embedding": embedding_scanner,
}
scanner = scanner_map.get(found_type)
if scanner:
persist = getattr(scanner, "_persist_current_cache", None)
if callable(persist):
await persist()
history_service = await self._get_download_history_service()
await history_service.mark_not_downloaded(found_type, model_version_id)
await history_service.mark_as_deleted(found_type, model_version_id)
return web.json_response(
{

View File

@@ -93,6 +93,8 @@ class RecipeHandlerSet:
"cancel_batch_import": self.batch_import.cancel_batch_import,
"start_directory_import": self.batch_import.start_directory_import,
"browse_directory": self.batch_import.browse_directory,
"check_image_exists": self.management.check_image_exists,
"import_from_url": self.management.import_from_url,
}
@@ -541,7 +543,7 @@ class RecipeQueryHandler:
)
response_data.append(
{
"type": "source_url",
"type": "source_path",
"fingerprint": url,
"count": len(recipes),
"recipes": recipes,
@@ -607,6 +609,7 @@ class RecipeManagementHandler:
self._downloader_factory = downloader_factory
self._civitai_client_getter = civitai_client_getter
self._ws_manager = ws_manager
self._import_semaphore = asyncio.Semaphore(2)
async def save_recipe(self, request: web.Request) -> web.Response:
try:
@@ -767,25 +770,53 @@ class RecipeManagementHandler:
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
# 2. Initial Metadata Construction
# Throttle concurrent imports to avoid starving ComfyUI's event loop
async with self._import_semaphore:
return await self._do_import_remote_recipe(
image_url=image_url,
name=name,
lora_entries=lora_entries,
checkpoint_entry=checkpoint_entry,
gen_params_request=gen_params_request,
tags=self._parse_tags(params.get("tags")),
base_model=params.get("base_model", "") or "",
source_path=params.get("source_path") or image_url,
)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error importing recipe from remote source: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def _do_import_remote_recipe(
self,
*,
image_url: str,
name: str,
lora_entries: list,
checkpoint_entry: dict,
gen_params_request: dict,
tags: list,
base_model: str,
source_path: str,
) -> web.Response:
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
metadata: Dict[str, Any] = {
"base_model": params.get("base_model", "") or "",
"base_model": base_model,
"loras": lora_entries,
"gen_params": gen_params_request or {},
"source_url": image_url,
"source_path": source_path,
}
source_path = params.get("source_path")
if source_path:
metadata["source_path"] = source_path
# Checkpoint handling
if checkpoint_entry:
metadata["checkpoint"] = checkpoint_entry
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
# Actually enricher looks at metadata['checkpoint'], so this is fine.
# Try to resolve base model from checkpoint if not explicitly provided
if not metadata["base_model"]:
base_model_from_metadata = (
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
@@ -793,30 +824,17 @@ class RecipeManagementHandler:
if base_model_from_metadata:
metadata["base_model"] = base_model_from_metadata
tags = self._parse_tags(params.get("tags"))
# 3. Download Image
# Download image
(
image_bytes,
extension,
civitai_meta_from_download,
civitai_meta_raw,
model_version_id,
) = await self._download_remote_media(image_url)
# 4. Extract Embedded Metadata
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
# with embedded data if we want it to merge it.
# However, logic in Enricher merges: request > civitai > embedded.
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
# OR pass them to enricher to handle?
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
# Wait, `Enricher` fetches Civitai info internally based on URL.
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
# Let's extract embedded metadata first
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
embedded_gen_params = {}
parsed_embedded = None
try:
with tempfile.NamedTemporaryFile(
suffix=extension, delete=False
@@ -825,7 +843,9 @@ class RecipeManagementHandler:
temp_img_path = temp_img.name
try:
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
raw_embedded = await asyncio.to_thread(
ExifUtils.extract_image_metadata, temp_img_path
)
if raw_embedded:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
@@ -848,27 +868,44 @@ class RecipeManagementHandler:
"Failed to extract embedded metadata during import: %s", exc
)
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
# Fallback: if EXIF extraction yielded nothing, parse Civitai API meta directly
# (same approach as analyze_remote_image — downloaded Civitai images often
# have no embedded EXIF but the API meta contains resources/hashes)
if parsed_embedded is None and civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw["meta"]
if isinstance(civitai_inner_meta, dict):
parser = self._analysis_service._recipe_parser_factory.create_parser(
civitai_inner_meta
)
if parser:
parsed_embedded = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
if embedded_gen_params:
# Merge embedded into existing gen_params (which currently only has request params if any)
# But wait, we want request params to override everything.
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
metadata["gen_params"] = embedded_gen_params
# 5. Enrich with unified logic
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
if parsed_embedded:
parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"):
metadata["loras"] = parsed_loras
parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
recipe=metadata,
civitai_client=civitai_client,
request_params=gen_params_request, # Pass explicit request params here to override
request_params=gen_params_request,
prefetched_civitai_meta_raw=civitai_meta_raw,
prefetched_model_version_id=model_version_id,
)
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
# we might want to manually merge it?
# But usually `import_remote_recipe` is used with Civitai URLs.
# For now, relying on Enricher's internal fetch is consistent with repair.
result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
@@ -879,15 +916,6 @@ class RecipeManagementHandler:
extension=extension,
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error importing recipe from remote source: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def delete_recipe(self, request: web.Request) -> web.Response:
try:
@@ -1190,7 +1218,7 @@ class RecipeManagementHandler:
"exclude": False,
}
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]:
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
civitai_client = self._civitai_client_getter()
downloader = await self._downloader_factory()
temp_path = None
@@ -1238,10 +1266,18 @@ class RecipeManagementHandler:
extension = ".webp" # Default to webp if unknown
with open(temp_path, "rb") as file_obj:
model_ver_id = None
if civitai_image_id and image_info:
model_ver_id = image_info.get("modelVersionId")
if not model_ver_id:
ids = image_info.get("modelVersionIds")
if isinstance(ids, list) and ids:
model_ver_id = ids[0]
return (
file_obj.read(),
extension,
image_info.get("meta") if civitai_image_id and image_info else None,
model_ver_id,
)
except RecipeDownloadError:
raise
@@ -1289,6 +1325,205 @@ class RecipeManagementHandler:
return ""
async def check_image_exists(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
image_ids_raw = request.query.get("image_ids", "")
if not image_ids_raw:
return web.json_response({"success": True, "results": {}})
requested_ids = set()
for raw in image_ids_raw.split(","):
stripped = raw.strip()
if stripped and stripped.isdigit():
requested_ids.add(stripped)
if not requested_ids:
return web.json_response({"success": True, "results": {}})
cache = await recipe_scanner.get_cached_data()
# Build lookup: image_id -> recipe_id from stored source_path
image_to_recipe = {}
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in image_to_recipe:
image_to_recipe[image_id] = recipe.get("id")
results = {}
for img_id in requested_ids:
recipe_id = image_to_recipe.get(img_id)
results[img_id] = {
"in_library": recipe_id is not None,
"recipe_id": recipe_id,
}
return web.json_response({"success": True, "results": results})
except Exception as exc:
self._logger.error(
"Error checking image existence: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def import_from_url(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
image_url = request.query.get("image_url")
if not image_url:
raise RecipeValidationError("Missing required field: image_url")
image_id = extract_civitai_image_id(image_url)
if not image_id:
raise RecipeValidationError(
"Could not extract Civitai image ID from URL"
)
# Check for duplicate (fast, before acquiring semaphore)
cache = await recipe_scanner.get_cached_data()
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if source:
existing_id = extract_civitai_image_id(source)
if existing_id == image_id:
return web.json_response({
"success": True,
"recipe_id": recipe.get("id"),
"name": recipe.get("title", ""),
"already_exists": True,
})
async with self._import_semaphore:
return await self._do_import_from_url(image_url, recipe_scanner)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error importing recipe from URL: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def _do_import_from_url(
self,
image_url: str,
recipe_scanner: Any,
) -> web.Response:
image_id = extract_civitai_image_id(image_url)
if not image_id:
raise RecipeValidationError(
"Could not extract Civitai image ID from URL"
)
image_bytes, extension, civitai_meta_raw, model_version_id = (
await self._download_remote_media(image_url)
)
# Extract embedded EXIF metadata
embedded_gen_params = {}
parsed_embedded = None
try:
with tempfile.NamedTemporaryFile(
suffix=extension, delete=False
) as temp_img:
temp_img.write(image_bytes)
temp_img_path = temp_img.name
try:
raw_embedded = await asyncio.to_thread(
ExifUtils.extract_image_metadata, temp_img_path
)
if raw_embedded:
parser = (
self._analysis_service._recipe_parser_factory.create_parser(
raw_embedded
)
)
if parser:
parsed_embedded = await parser.parse_metadata(
raw_embedded, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
finally:
if os.path.exists(temp_img_path):
os.unlink(temp_img_path)
except Exception as exc:
self._logger.warning(
"Failed to extract embedded metadata: %s", exc
)
if parsed_embedded is None and civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
civitai_inner_meta = civitai_meta_raw["meta"]
if isinstance(civitai_inner_meta, dict):
parser = self._analysis_service._recipe_parser_factory.create_parser(
civitai_inner_meta
)
if parser:
parsed_embedded = await parser.parse_metadata(
civitai_inner_meta, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
metadata: Dict[str, Any] = {
"base_model": "",
"loras": [],
"gen_params": embedded_gen_params or {},
"source_path": image_url,
}
if parsed_embedded:
parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"):
metadata["loras"] = parsed_loras
parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
recipe=metadata,
civitai_client=civitai_client,
request_params={},
prefetched_civitai_meta_raw=civitai_meta_raw,
prefetched_model_version_id=model_version_id,
)
prompt = (
metadata.get("gen_params", {}).get("prompt")
or metadata.get("gen_params", {}).get("positivePrompt")
or ""
)
if prompt:
name = " ".join(str(prompt).split()[:10])
else:
name = f"Civitai Image {image_id}"
result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=None,
name=name,
tags=[],
metadata=metadata,
extension=extension,
)
return web.json_response(result.payload, status=result.status)
class RecipeAnalysisHandler:
"""Analyze images to extract recipe metadata."""

View File

@@ -70,6 +70,10 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
),
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
RouteDefinition(
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
),
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
)

View File

@@ -908,6 +908,17 @@ class BaseModelService(ABC):
)
if should_skip or metadata is None:
return None
# Prune stale example-image metadata entries whose files no longer
# exist on disk (e.g. a user deleted the files manually).
from ..utils.example_images_metadata import MetadataUpdater
was_modified = await MetadataUpdater.prune_stale_example_images(metadata)
if was_modified:
asyncio.create_task(
MetadataManager.save_metadata(file_path, metadata)
)
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_description(self, file_path: str) -> Optional[str]:

View File

@@ -224,7 +224,7 @@ class BatchImportService:
return False
for recipe in getattr(cache, "raw_data", []):
source_path = recipe.get("source_path") or recipe.get("source_url")
source_path = recipe.get("source_path")
if source_path and source_path == source:
return True
return False

View File

@@ -193,6 +193,9 @@ class CivitaiBaseModelService:
"zimageturbo": "ZIT",
"zimagebase": "ZIB",
"anima": "ANI",
"ernie": "ERNI",
"ernie turbo": "ETRB",
"nucleus": "NUCL",
"svd": "SVD",
"ltxv": "LTXV",
"ltxv2": "LTV2",
@@ -418,6 +421,9 @@ class CivitaiBaseModelService:
"Kolors",
"NoobAI",
"Anima",
"Ernie",
"Ernie Turbo",
"Nucleus",
],
}

View File

@@ -577,6 +577,59 @@ class CivitaiClient:
logger.error(error_msg)
return None
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
Uses POST /api/v1/model-versions/by-hash which returns full version
details including ``usageControl`` and ``earlyAccessEndsAt`` that are
not available from the model-level API.
Args:
hashes: List of SHA256 hashes (max 100 per batch; auto-split).
Returns:
List of version dicts or None on failure.
"""
if not hashes:
return []
BATCH_SIZE = 100
all_versions: List[Dict] = []
for start in range(0, len(hashes), BATCH_SIZE):
batch = hashes[start : start + BATCH_SIZE]
try:
success, result = await self._make_request(
"POST",
f"{self.base_url}/model-versions/by-hash",
use_auth=True,
json=batch,
)
if not success:
logger.warning(
"Batch by-hash request failed for %d hashes: %s",
len(batch),
result,
)
continue
if isinstance(result, list):
all_versions.extend(result)
else:
logger.debug(
"Unexpected by-hash response type: %s", type(result)
)
except RateLimitError:
raise
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error fetching model versions by hashes: %s", exc
)
return all_versions if all_versions else None
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
"""Fetch all models for a specific Civitai user."""
if not username:

View File

@@ -206,7 +206,7 @@ class DownloadedVersionHistoryService:
)
conn.commit()
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
if normalized_type is None or normalized_version_id is None:

View File

@@ -111,6 +111,11 @@ class ModelLifecycleService:
self._scanner._hash_index.remove_by_path(file_path)
await self._sync_update_for_model(model_id)
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
return {"success": True, "deleted_files": deleted_files}
@staticmethod

View File

@@ -109,6 +109,18 @@ class ModelMetadataProvider(ABC):
"""Fetch model versions for multiple model ids when supported."""
raise NotImplementedError
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
"""Fetch full version details for multiple SHA256 hashes.
Used specifically to retrieve ``usageControl`` which is only
available from the per-version / by-hash API, not from model-level
responses. Providers that cannot resolve hashes should let the
default ``NotImplementedError`` propagate.
"""
raise NotImplementedError
@abstractmethod
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
"""Get specific model version with additional metadata"""
@@ -141,6 +153,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
) -> Optional[Dict[int, Dict]]:
return await self.client.get_model_versions_bulk(model_ids)
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
return await self.client.get_model_versions_by_hashes(hashes)
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
return await self.client.get_model_version(model_id, version_id)
@@ -519,6 +536,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue
return None, "No provider could retrieve the data"
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
for provider, label in self._iter_providers():
try:
result = await self._call_with_rate_limit(
label,
provider.get_model_versions_by_hashes,
hashes,
)
if result is not None:
return result
except NotImplementedError:
continue
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
except Exception as e:
logger.debug(
"Provider %s failed for get_model_versions_by_hashes: %s",
label,
e,
)
continue
return None
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
for provider, label in self._iter_providers():
try:
@@ -593,6 +636,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
model_ids,
)
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[Dict]]:
return await self._rate_limit_helper.run(
self._label,
self._provider.get_model_versions_by_hashes,
hashes,
)
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
return await self._rate_limit_helper.run(
self._label,
@@ -669,6 +721,17 @@ class ModelMetadataProviderManager:
provider = self._get_provider(provider_name)
return await provider.get_model_version_info(version_id)
async def get_model_versions_by_hashes(
self,
hashes: List[str],
provider_name: str = None,
) -> Optional[List[Dict]]:
provider = self._get_provider(provider_name)
try:
return await provider.get_model_versions_by_hashes(hashes)
except NotImplementedError:
return None
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
"""Fetch models owned by the specified user"""
provider = self._get_provider(provider_name)

View File

@@ -989,6 +989,11 @@ class ModelUpdateService:
fallback_attempted = True
try:
response = await metadata_provider.get_model_versions(model_id)
if response is not None:
await self._enrich_version_entries(
metadata_provider,
{model_id: response},
)
except RateLimitError:
raise
except ResourceNotFoundError as exc:
@@ -1083,6 +1088,136 @@ class ModelUpdateService:
self._upsert_record(record)
return record
async def _enrich_version_entries(
self,
metadata_provider,
responses_by_model_id: Dict[int, Mapping],
) -> None:
"""Enrich version entries with ``usageControl`` via batch hash endpoint.
The model-level API does not include ``usageControl`` on version
entries. This method collects SHA256 hashes from every version's
primary model file, calls ``POST /api/v1/model-versions/by-hash``
(up to 100 hashes per request), and injects ``usageControl`` +
``earlyAccessEndsAt`` into each version entry dict in-place.
"""
if not metadata_provider or not responses_by_model_id:
return
hashes_by_version: Dict[int, str] = {}
for response in responses_by_model_id.values():
hashes_by_version.update(
self._collect_hashes_from_response(response)
)
if not hashes_by_version:
return
version_ids_by_hash: Dict[str, List[int]] = {}
for version_id, sha256 in hashes_by_version.items():
version_ids_by_hash.setdefault(sha256, []).append(version_id)
all_hashes = list(version_ids_by_hash.keys())
BATCH_SIZE = 100
enrichment: Dict[int, Dict] = {}
try:
for start in range(0, len(all_hashes), BATCH_SIZE):
batch = all_hashes[start : start + BATCH_SIZE]
try:
enriched = await metadata_provider.get_model_versions_by_hashes(
batch
)
except NotImplementedError:
return
except RateLimitError:
raise
except Exception:
continue
if not enriched:
continue
for entry in enriched:
if not isinstance(entry, dict):
continue
version_id = entry.get("id")
if version_id is None:
continue
enrichment[version_id] = {
"usageControl": _normalize_string(
entry.get("usageControl")
),
"earlyAccessEndsAt": _normalize_string(
entry.get("earlyAccessEndsAt")
),
}
except RateLimitError:
raise
if not enrichment:
return
for response in responses_by_model_id.values():
versions = response.get("modelVersions")
if not isinstance(versions, list):
continue
for version in versions:
if not isinstance(version, dict):
continue
version_id = version.get("id")
if version_id not in enrichment:
continue
extra = enrichment[version_id]
if extra.get("usageControl") and not version.get("usageControl"):
version["usageControl"] = extra["usageControl"]
if extra.get("earlyAccessEndsAt") and not version.get(
"earlyAccessEndsAt"
):
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
@staticmethod
def _collect_hashes_from_response(response: Mapping) -> Dict[int, str]:
"""Extract ``{version_id: sha256}`` from a model-level API response.
Returns an empty dict if the response structure is unexpected.
"""
result: Dict[int, str] = {}
versions = response.get("modelVersions")
if not isinstance(versions, list):
return result
for entry in versions:
if not isinstance(entry, dict):
continue
version_id = _normalize_int(entry.get("id"))
if version_id is None:
continue
sha256 = ModelUpdateService._extract_sha256_from_version_entry(entry)
if sha256:
result[version_id] = sha256
return result
@staticmethod
def _extract_sha256_from_version_entry(entry: Mapping) -> Optional[str]:
"""Return the SHA256 hash from the primary model file of a version entry."""
files = entry.get("files")
if not isinstance(files, list):
return None
for file_info in files:
if not isinstance(file_info, dict):
continue
if file_info.get("type") != "Model":
continue
primary = file_info.get("primary")
if primary is not True and str(primary).strip().lower() != "true":
continue
hashes = file_info.get("hashes")
if isinstance(hashes, dict):
sha256 = hashes.get("SHA256")
if sha256:
return sha256
return None
async def _fetch_model_versions_bulk(
self,
metadata_provider,
@@ -1134,6 +1269,7 @@ class ModelUpdateService:
len(aggregated),
provider_name,
)
await self._enrich_version_entries(metadata_provider, aggregated)
return aggregated
async def _collect_local_versions(
@@ -1261,6 +1397,7 @@ class ModelUpdateService:
sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access,
usage_control=remote_version.usage_control,
)
)

View File

@@ -38,6 +38,7 @@ class PersistentRecipeCache:
"json_path",
"title",
"folder",
"source_path",
"base_model",
"fingerprint",
"created_date",
@@ -334,6 +335,7 @@ class PersistentRecipeCache:
json_path TEXT,
title TEXT,
folder TEXT,
source_path TEXT,
base_model TEXT,
fingerprint TEXT,
created_date REAL,
@@ -358,6 +360,13 @@ class PersistentRecipeCache:
);
"""
)
# Migration: add source_path column to existing databases
try:
conn.execute(
"ALTER TABLE recipes ADD COLUMN source_path TEXT"
)
except Exception:
pass # column already exists
conn.commit()
self._schema_initialized = True
except Exception as exc:
@@ -406,6 +415,7 @@ class PersistentRecipeCache:
json_path,
recipe.get("title"),
recipe.get("folder"),
recipe.get("source_path"),
recipe.get("base_model"),
recipe.get("fingerprint"),
float(recipe.get("created_date") or 0.0),
@@ -456,6 +466,7 @@ class PersistentRecipeCache:
"file_path": row["file_path"] or "",
"title": row["title"] or "",
"folder": row["folder"] or "",
"source_path": row["source_path"] or "",
"base_model": row["base_model"] or "",
"fingerprint": row["fingerprint"] or "",
"created_date": row["created_date"] or 0.0,

View File

@@ -504,6 +504,9 @@ class RecipeScanner:
self._cache.raw_data = recipes
self._update_folder_metadata(self._cache)
self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration)
if self._backfill_source_path_if_needed(recipes, json_paths):
self._persistent_cache.save_cache(recipes, json_paths)
return self._cache
else:
# Partial update: some files changed
@@ -514,6 +517,8 @@ class RecipeScanner:
self._cache.raw_data = recipes
self._update_folder_metadata(self._cache)
self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration)
self._backfill_source_path_if_needed(recipes, json_paths)
# Persist updated cache
self._persistent_cache.save_cache(recipes, json_paths)
return self._cache
@@ -642,6 +647,34 @@ class RecipeScanner:
return recipes, changed, json_paths
def _backfill_source_path_if_needed(
self,
recipes: List[Dict],
json_paths: Dict[str, str],
) -> bool:
"""Backfill source_path from recipe JSON files if missing from cache.
Returns True if any recipes were updated (caller should persist cache).
"""
updated = False
for recipe in recipes:
if recipe.get("source_path"):
continue
recipe_id = str(recipe.get("id", ""))
json_path = json_paths.get(recipe_id)
if not json_path or not os.path.exists(json_path):
continue
try:
with open(json_path, "r", encoding="utf-8") as f:
json_data = json.load(f)
file_source_path = json_data.get("source_path")
if file_source_path:
recipe["source_path"] = file_source_path
updated = True
except Exception:
pass
return updated
def _full_directory_scan_sync(
self, recipes_dir: str
) -> Tuple[List[Dict], Dict[str, str]]:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import base64
import io
import os
@@ -14,6 +15,7 @@ from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from ...recipes.enrichment import RecipeEnricher
from .errors import (
RecipeDownloadError,
RecipeNotFoundError,
@@ -170,9 +172,11 @@ class RecipeAnalysisService:
await self._download_image(url, temp_path)
if metadata is None and not is_video:
metadata = self._exif_utils.extract_image_metadata(temp_path)
metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, temp_path
)
return await self._parse_metadata(
result = await self._parse_metadata(
metadata or {},
recipe_scanner=recipe_scanner,
image_path=temp_path,
@@ -180,6 +184,37 @@ class RecipeAnalysisService:
is_video=is_video,
extension=extension,
)
if civitai_image_id and image_info and not result.payload.get("error"):
mvid = image_info.get("modelVersionId")
if not mvid:
mvids = image_info.get("modelVersionIds")
if isinstance(mvids, list) and mvids:
mvid = mvids[0]
recipe_for_enrich = {
"gen_params": result.payload.get("gen_params", {}),
"loras": result.payload.get("loras", []),
"base_model": result.payload.get("base_model", "") or "",
"checkpoint": result.payload.get("checkpoint") or result.payload.get("model"),
"source_path": url,
}
await RecipeEnricher.enrich_recipe(
recipe=recipe_for_enrich,
civitai_client=civitai_client,
request_params=None,
prefetched_civitai_meta_raw=image_info.get("meta"),
prefetched_model_version_id=mvid,
)
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
if recipe_for_enrich.get("checkpoint"):
result.payload["checkpoint"] = recipe_for_enrich["checkpoint"]
if recipe_for_enrich.get("base_model"):
result.payload["base_model"] = recipe_for_enrich["base_model"]
return result
finally:
if temp_path:
self._safe_cleanup(temp_path)
@@ -199,7 +234,9 @@ class RecipeAnalysisService:
if not os.path.isfile(normalized_path):
raise RecipeNotFoundError("File not found")
metadata = self._exif_utils.extract_image_metadata(normalized_path)
metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, normalized_path
)
if not metadata:
return self._metadata_not_found_response(normalized_path)

View File

@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, Mapping, Sequence
from urllib.parse import parse_qs, urlparse, urlunparse
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red", "civitai.green"})
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
_LICENSE_DEFAULTS: Dict[str, Any] = {

View File

@@ -178,5 +178,8 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
"Wan Video 2.5 I2V",
"Hunyuan Video",
"Anima",
"Ernie",
"Ernie Turbo",
"Nucleus",
]
)

View File

@@ -452,3 +452,111 @@ class MetadataUpdater:
except Exception as e:
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
return None
@staticmethod
async def prune_stale_example_images(metadata) -> bool:
"""Remove example-image metadata entries whose files no longer exist on disk.
Checks ``civitai.customImages`` (by ``id``) and ``civitai.images`` entries
that have an empty ``url`` (no remote fallback) against actual files in
the model's example-image folder. Stale entries are removed in-place so
the caller can persist the cleaned metadata afterwards.
Args:
metadata: A ``BaseModelMetadata`` instance (modified in place).
Returns:
True if at least one entry was removed.
"""
from ..utils.example_images_paths import get_model_folder
model_hash = getattr(metadata, "sha256", None)
if not model_hash:
return False
model_folder = get_model_folder(model_hash)
if not model_folder:
return False
civitai = getattr(metadata, "civitai", None)
if not isinstance(civitai, dict):
return False
has_changes = False
custom_images = civitai.get("customImages")
if isinstance(custom_images, list) and custom_images:
stale: list[int] = []
for idx, img in enumerate(custom_images):
img_id = img.get("id", "")
if not img_id:
continue
if not os.path.isdir(model_folder):
stale.append(idx)
else:
found = False
try:
prefix = f"custom_{img_id}"
for fname in os.listdir(model_folder):
if fname.startswith(prefix) and os.path.isfile(
os.path.join(model_folder, fname)
):
found = True
break
except OSError:
stale.append(idx)
continue
if not found:
stale.append(idx)
if stale:
for idx in reversed(stale):
custom_images.pop(idx)
has_changes = True
logger.info(
"Pruned %d stale custom image(s) for %s",
len(stale),
getattr(metadata, "model_name", model_hash),
)
images = civitai.get("images")
if isinstance(images, list) and images:
stale: list[int] = []
for idx, img in enumerate(images):
if img.get("url", ""):
# Has a remote fallback keep it even if the local copy
# is gone.
continue
if not os.path.isdir(model_folder):
stale.append(idx)
else:
found = False
try:
prefix = f"image_{idx}."
for fname in os.listdir(model_folder):
if fname.startswith(prefix):
found = True
break
except OSError:
stale.append(idx)
continue
if not found:
stale.append(idx)
if stale:
for idx in reversed(stale):
images.pop(idx)
has_changes = True
logger.info(
"Pruned %d stale image entry(ies) for %s",
len(stale),
getattr(metadata, "model_name", model_hash),
)
return has_changes

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.0.5"
version = "1.0.6"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -87,7 +87,7 @@
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900;
color: var(--lora-text);
font-size: 12px;

View File

@@ -329,7 +329,6 @@
}
.card-actions i {
margin-left: var(--space-1);
cursor: pointer;
color: white;
transition: opacity 0.2s, transform 0.15s ease;

View File

@@ -141,8 +141,7 @@
.header-search .search-container:focus-within {
border-color: var(--lora-accent);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--lora-accent);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
}
.header-search input {

View File

@@ -387,6 +387,10 @@
cursor: not-allowed;
}
.version-action-disabled-wrapper {
display: inline-flex;
}
.versions-loading-state,
.versions-empty,
.versions-error {

View File

@@ -0,0 +1,124 @@
.media-viewer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.3s ease;
}
.media-viewer-overlay.active {
background: rgba(0, 0, 0, 0.92);
}
.media-viewer-close {
position: fixed;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10001;
transition: background 0.2s ease;
opacity: 0;
}
.media-viewer-overlay.active .media-viewer-close {
opacity: 1;
}
.media-viewer-close:hover {
background: rgba(255, 255, 255, 0.25);
}
.media-viewer-content-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 90vw;
max-height: 95vh;
cursor: default;
}
.media-viewer-media {
display: block;
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.media-viewer-video {
max-height: 80vh;
}
.media-viewer-counter {
margin-top: 8px;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85em;
text-align: center;
min-height: 1.2em;
}
.media-viewer-title {
margin-top: 4px;
color: rgba(255, 255, 255, 0.7);
font-size: 0.9em;
text-align: center;
max-width: 90vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-viewer-nav {
position: fixed;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 80px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
border: none;
color: #fff;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10001;
opacity: 0;
transition: opacity 0.2s ease, background 0.2s ease;
}
.media-viewer-overlay.active .media-viewer-nav {
opacity: 1;
}
.media-viewer-nav:hover {
background: rgba(255, 255, 255, 0.18);
}
.media-viewer-prev {
left: 16px;
}
.media-viewer-next {
right: 16px;
}

View File

@@ -41,6 +41,63 @@
text-align: center;
}
/* Section Headers */
.context-menu-section-header {
padding: 6px 12px 2px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
cursor: default;
user-select: none;
}
/* Submenu */
.context-menu-item.has-submenu {
position: relative;
justify-content: space-between;
}
.submenu-arrow {
margin-left: auto;
font-size: 10px;
width: auto !important;
}
.context-submenu {
position: absolute;
left: calc(100% - 4px);
top: -1px;
display: none;
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 0;
min-width: 200px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1001;
backdrop-filter: blur(10px);
}
.context-submenu .context-menu-item {
white-space: nowrap;
margin: 0;
}
.context-submenu .context-menu-item:first-child {
padding-top: 9px;
}
.context-submenu .context-menu-item:last-child {
padding-bottom: 9px;
}
.context-submenu.flip-left {
left: auto;
right: 100%;
}
/* NSFW Level Selector */
.nsfw-level-selector {
position: fixed;

View File

@@ -396,14 +396,54 @@
flex-direction: column;
}
.recipe-gen-params h3 {
margin-top: 0;
.gen-params-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: 1.2em;
color: var(--text-color);
padding-bottom: var(--space-1);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
gap: 8px;
}
.gen-params-header-row h3 {
margin: 0;
font-size: 1.2em;
color: var(--text-color);
}
/* Inline toggle for lora strip setting */
.lora-strip-toggle {
flex-shrink: 0;
gap: 6px;
}
.lora-strip-toggle .inline-toggle-label {
font-size: 0.78em;
white-space: nowrap;
opacity: 0.7;
transition: opacity 0.2s;
}
.lora-strip-toggle:hover .inline-toggle-label {
opacity: 1;
}
.lora-strip-toggle .toggle-switch {
width: 32px;
height: 16px;
}
.lora-strip-toggle .toggle-slider:before {
height: 10px;
width: 10px;
left: 3px;
bottom: 3px;
}
.lora-strip-toggle .toggle-switch input:checked + .toggle-slider:before {
transform: translateX(16px);
}
.gen-params-container {

View File

@@ -67,7 +67,6 @@
.early-access-info {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
@@ -97,7 +96,6 @@
.local-path {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);

View File

@@ -39,6 +39,7 @@
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */
@import 'components/sidebar.css'; /* Add sidebar component */
@import 'components/media-viewer.css';
.initialization-notice {
display: flex;

View File

@@ -3,6 +3,8 @@ export class BaseContextMenu {
this.menu = document.getElementById(menuId);
this.cardSelector = cardSelector;
this.currentCard = null;
this.submenuTimeout = null;
this.openSubmenu = null;
if (!this.menu) {
console.error(`Context menu element with ID ${menuId} not found`);
@@ -13,20 +15,99 @@ export class BaseContextMenu {
}
init() {
// Hide menu on regular clicks
document.addEventListener('click', () => this.hideMenu());
// Hide menu when clicking outside
document.addEventListener('click', (e) => {
if (!this.menu.contains(e.target)) {
this.hideMenu();
}
});
// Handle menu item clicks
// Handle menu item clicks (including submenu items)
this.menu.addEventListener('click', (e) => {
const menuItem = e.target.closest('.context-menu-item');
if (!menuItem || !this.currentCard) return;
// Ignore clicks on submenu trigger (has-submenu parent)
if (menuItem.classList.contains('has-submenu')) return;
const action = menuItem.dataset.action;
if (!action) return;
this.handleMenuAction(action, menuItem);
this.hideMenu();
});
// Submenu hover handling
// Use mouseover/mouseout (which bubble) with relatedTarget checks
// to reliably detect crossing the .has-submenu boundary
this.menu.addEventListener('mouseover', (e) => {
const trigger = e.target.closest('.has-submenu');
if (!trigger) return;
// Only act when entering from outside this trigger's tree
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
this._openSubmenu(trigger);
});
this.menu.addEventListener('mouseout', (e) => {
const trigger = e.target.closest('.has-submenu');
if (!trigger) return;
// Only close when leaving the trigger's tree entirely
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
this._scheduleSubmenuClose(trigger);
});
}
_openSubmenu(trigger) {
// Clear any pending close
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
this.submenuTimeout = null;
}
// Hide any previously open submenu
if (this.openSubmenu && this.openSubmenu !== trigger) {
this._hideSubmenu(this.openSubmenu);
}
const submenu = trigger.querySelector('.context-submenu');
if (!submenu) return;
submenu.style.display = 'block';
this.openSubmenu = trigger;
this._positionSubmenu(submenu);
}
_scheduleSubmenuClose(trigger) {
this.submenuTimeout = setTimeout(() => {
this._hideSubmenu(trigger);
this.submenuTimeout = null;
}, 250);
}
_hideSubmenu(trigger) {
const submenu = trigger.querySelector('.context-submenu');
if (submenu) {
submenu.style.display = 'none';
submenu.classList.remove('flip-left');
}
if (this.openSubmenu === trigger) {
this.openSubmenu = null;
}
}
_positionSubmenu(submenu) {
const submenuRect = submenu.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
if (submenuRect.right > viewportWidth) {
submenu.classList.add('flip-left');
} else {
submenu.classList.remove('flip-left');
}
}
handleMenuAction(action, menuItem) {
@@ -65,6 +146,13 @@ export class BaseContextMenu {
}
hideMenu() {
if (this.submenuTimeout) {
clearTimeout(this.submenuTimeout);
this.submenuTimeout = null;
}
if (this.openSubmenu) {
this._hideSubmenu(this.openSubmenu);
}
if (this.menu) {
this.menu.style.display = 'none';
}

View File

@@ -4,6 +4,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
export class BulkContextMenu extends BaseContextMenu {
constructor() {
@@ -50,6 +51,14 @@ export class BulkContextMenu extends BaseContextMenu {
if (copyAllItem) {
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
}
// Submenu parent visibility
const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]');
if (sendToWorkflowSubmenu) {
const hasWorkflowActions = config.sendToWorkflow || config.copyAll;
sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none';
}
if (refreshAllItem) {
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
}
@@ -74,11 +83,46 @@ export class BulkContextMenu extends BaseContextMenu {
if (setContentRatingItem) {
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
}
const setFavoriteItem = this.menu.querySelector('[data-action="set-favorite"]');
if (setFavoriteItem && config.setFavorite) {
setFavoriteItem.style.display = 'flex';
const total = state.selectedModels.size;
const favoritedCount = this.countFavoritedInSelection();
const allFavorited = total > 0 && favoritedCount === total;
const icon = setFavoriteItem.querySelector('i');
const label = setFavoriteItem.querySelector('span');
if (allFavorited) {
if (icon) { icon.className = 'far fa-star'; }
if (label) { label.textContent = translate('loras.bulkOperations.unfavorite'); }
} else {
if (icon) { icon.className = 'fas fa-star'; }
if (label) {
label.textContent = favoritedCount > 0
? translate('loras.bulkOperations.setFavoriteCount', { favorited: favoritedCount, total })
: translate('loras.bulkOperations.setFavorite');
}
}
} else if (setFavoriteItem) {
setFavoriteItem.style.display = 'none';
}
if (downloadMissingLorasItem) {
// Only show for recipes page
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
}
const downloadExampleImagesItem = this.menu.querySelector('[data-action="download-example-images"]');
if (downloadExampleImagesItem) {
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
const modelPages = ['loras', 'checkpoints', 'embeddings'];
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
}
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
@@ -112,6 +156,14 @@ export class BulkContextMenu extends BaseContextMenu {
);
}
}
// Hide empty sections
this.menu.querySelectorAll('.context-menu-section').forEach(section => {
const items = Array.from(section.querySelectorAll('.context-menu-item'))
.filter(item => !item.closest('.context-submenu'));
const allHidden = items.length > 0 && items.every(item => item.style.display === 'none');
section.style.display = allHidden ? 'none' : '';
});
}
updateSelectedCountHeader() {
@@ -138,6 +190,20 @@ export class BulkContextMenu extends BaseContextMenu {
return count;
}
countFavoritedInSelection() {
let count = 0;
for (const filePath of state.selectedModels) {
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card && card.dataset.favorite === 'true') {
count++;
}
}
return count;
}
showMenu(x, y, card) {
this.updateMenuItemsForModelType();
this.updateSelectedCountHeader();
@@ -185,9 +251,17 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
case 'set-favorite': {
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
bulkManager.setBulkFavorites(!allFavorited);
break;
}
case 'download-missing-loras':
this.handleDownloadMissingLoras();
break;
case 'download-example-images':
this.handleDownloadExampleImages();
break;
case 'clear':
bulkManager.clearSelection();
break;
@@ -230,4 +304,31 @@ export class BulkContextMenu extends BaseContextMenu {
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
}
async handleDownloadExampleImages() {
if (state.selectedModels.size === 0) {
return;
}
const hashes = new Set();
for (const filePath of state.selectedModels) {
const escapedPath = CSS.escape(filePath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card?.dataset?.sha256) {
hashes.add(card.dataset.sha256);
}
}
if (hashes.size === 0) {
showToast('No valid model hashes found in selection', {}, 'warning');
return;
}
try {
const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([...hashes]);
} catch (error) {
console.error('Bulk download example images failed:', error);
}
}
}

View File

@@ -2,10 +2,11 @@
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt',
@@ -104,6 +105,7 @@ class RecipeModal {
init() {
this.setupCopyButtons();
this.setupStripLoraToggle();
this.setupPromptEditors();
// Set up tooltip positioning handlers after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
@@ -112,6 +114,23 @@ class RecipeModal {
// Set up document click handler to close edit fields
document.addEventListener('click', (event) => {
const recipeModal = document.getElementById('recipeModal');
if (recipeModal && recipeModal.style.display !== 'none') {
const mediaEl = event.target.closest('.recipe-preview-media');
if (mediaEl && mediaEl.tagName) {
event.stopPropagation();
const isVideo = mediaEl.tagName === 'VIDEO';
const url = mediaEl.src || mediaEl.currentSrc;
if (url) {
openMediaViewer(url, {
type: isVideo ? 'video' : 'image',
title: document.getElementById('recipeModalTitle')?.textContent || ''
});
}
return;
}
}
// Handle title edit
const titleEditor = document.getElementById('recipeTitleEditor');
if (titleEditor && titleEditor.classList.contains('active') &&
@@ -1332,14 +1351,20 @@ class RecipeModal {
if (copyPromptBtn) {
copyPromptBtn.addEventListener('click', () => {
const promptText = this.currentRecipe?.gen_params?.prompt || '';
let promptText = this.currentRecipe?.gen_params?.prompt || '';
if (this.shouldStripLoraOnCopy()) {
promptText = RecipeModal.stripLoraTags(promptText);
}
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
});
}
if (copyNegativePromptBtn) {
copyNegativePromptBtn.addEventListener('click', () => {
const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
if (this.shouldStripLoraOnCopy()) {
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
}
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
});
}
@@ -1359,6 +1384,43 @@ class RecipeModal {
}
}
/**
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
* Handles both unescaped (<lora:...>) and HTML-escaped (&lt;lora:...&gt;) variants.
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
*/
static stripLoraTags(text) {
return text
.replace(/<lora:[^>]*>/gi, '')
.replace(/&lt;lora:[^&]*&gt;/gi, '')
.replace(/,(\s*,)+/g, ',')
.replace(/^,\s*/, '')
.replace(/,\s*$/, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
shouldStripLoraOnCopy() {
const toggle = document.getElementById('stripLoraOnCopyToggle');
return toggle ? toggle.checked : false;
}
setupStripLoraToggle() {
const toggle = document.getElementById('stripLoraOnCopyToggle');
if (!toggle) return;
const stored = getStorageItem('strip_lora_on_copy');
if (stored !== null) {
toggle.checked = stored === true;
}
toggle.addEventListener('change', () => {
const checked = toggle.checked;
setStorageItem('strip_lora_on_copy', checked);
state.global.settings.strip_lora_on_copy = checked;
});
}
// Fetch recipe syntax from backend and copy to clipboard
async fetchAndCopyRecipeSyntax() {
if (!this.recipeId) {

View File

@@ -166,17 +166,6 @@ export class PageControls {
});
});
// Handle quick refresh option
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
if (quickRefreshOption) {
quickRefreshOption.addEventListener('click', (e) => {
e.stopPropagation();
this.refreshModels(false);
// Close the dropdown
document.querySelector('.dropdown-group.active')?.classList.remove('active');
});
}
// Handle full rebuild option
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
if (fullRebuildOption) {

View File

@@ -0,0 +1,204 @@
let activeViewer = null;
function createMediaElement(item) {
const { url, type = 'image' } = item;
if (type === 'video') {
const el = document.createElement('video');
el.controls = true;
el.autoplay = true;
el.loop = true;
el.muted = true;
el.className = 'media-viewer-media media-viewer-video';
el.src = url;
return el;
}
const el = document.createElement('img');
el.className = 'media-viewer-media media-viewer-image';
el.src = url;
el.alt = 'Full size preview';
el.draggable = false;
return el;
}
function preloadAdjacent(items, index) {
[index - 1, index + 1].forEach(i => {
if (i >= 0 && i < items.length && items[i].type !== 'video') {
const preload = new Image();
preload.src = items[i].url;
}
});
}
export function openMediaViewer(arg1, arg2, arg3) {
closeMediaViewer();
let items, currentIndex, title = '';
if (Array.isArray(arg1)) {
items = arg1;
currentIndex = typeof arg2 === 'number' ? arg2 : 0;
title = (arg3 && arg3.title) || '';
} else {
items = [{ url: arg1, type: (arg2 && arg2.type) || 'image' }];
currentIndex = 0;
title = (arg2 && arg2.title) || '';
}
if (currentIndex < 0 || currentIndex >= items.length) currentIndex = 0;
const overlay = document.createElement('div');
overlay.className = 'media-viewer-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-label', title || 'Media viewer');
const closeBtn = document.createElement('button');
closeBtn.className = 'media-viewer-close';
closeBtn.innerHTML = '<i class="fas fa-times"></i>';
closeBtn.title = 'Close (Esc)';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
closeMediaViewer();
});
const contentContainer = document.createElement('div');
contentContainer.className = 'media-viewer-content-container';
let mediaElement = createMediaElement(items[currentIndex]);
contentContainer.appendChild(mediaElement);
const hasNavigation = items.length > 1;
const counter = document.createElement('div');
counter.className = 'media-viewer-counter';
counter.textContent = hasNavigation ? `${currentIndex + 1} / ${items.length}` : '';
contentContainer.appendChild(counter);
if (title) {
const titleBar = document.createElement('div');
titleBar.className = 'media-viewer-title';
titleBar.textContent = title;
contentContainer.appendChild(titleBar);
}
let prevBtn, nextBtn;
if (hasNavigation) {
prevBtn = document.createElement('button');
prevBtn.className = 'media-viewer-nav media-viewer-prev';
prevBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
prevBtn.title = 'Previous (←)';
nextBtn = document.createElement('button');
nextBtn.className = 'media-viewer-nav media-viewer-next';
nextBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
nextBtn.title = 'Next (→)';
const navigate = (delta) => {
const newIndex = (currentIndex + delta + items.length) % items.length;
currentIndex = newIndex;
const oldMedia = contentContainer.querySelector('.media-viewer-media');
const newMedia = createMediaElement(items[currentIndex]);
if (oldMedia) {
if (oldMedia.tagName === 'VIDEO') {
oldMedia.pause();
oldMedia.src = '';
}
oldMedia.replaceWith(newMedia);
}
mediaElement = newMedia;
counter.textContent = `${currentIndex + 1} / ${items.length}`;
preloadAdjacent(items, currentIndex);
};
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); });
overlay.appendChild(prevBtn);
overlay.appendChild(nextBtn);
}
overlay.appendChild(closeBtn);
overlay.appendChild(contentContainer);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay.classList.add('active');
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeMediaViewer();
}
});
const keyHandler = (e) => {
if (e.key === 'Escape') {
closeMediaViewer();
return;
}
if (hasNavigation) {
if (e.key === 'ArrowLeft') {
e.stopPropagation();
e.preventDefault();
prevBtn.click();
return;
}
if (e.key === 'ArrowRight') {
e.stopPropagation();
e.preventDefault();
nextBtn.click();
return;
}
}
};
document.addEventListener('keydown', keyHandler, true);
activeViewer = { overlay, keyHandler };
preloadAdjacent(items, currentIndex);
if (items[currentIndex].type === 'video') {
const recipeVideo = document.getElementById('recipeModalVideo');
if (recipeVideo && !recipeVideo.paused) {
recipeVideo.pause();
}
}
}
export function closeMediaViewer() {
if (!activeViewer) return;
const { overlay, keyHandler } = activeViewer;
const video = overlay.querySelector('video');
if (video) {
video.pause();
video.src = '';
}
const img = overlay.querySelector('img');
if (img) {
img.src = '';
}
document.removeEventListener('keydown', keyHandler, true);
overlay.classList.remove('active');
overlay.addEventListener('transitionend', () => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}, { once: true });
setTimeout(() => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}, 500);
activeViewer = null;
}
export function isMediaViewerOpen() {
return activeViewer !== null;
}

View File

@@ -241,7 +241,7 @@ function buildActionButton(label, variant, action, options = {}) {
if (action) {
attributes.push(`data-version-action="${escapeHtml(action)}"`);
}
if (options.title) {
if (!options.disabled && options.title) {
attributes.push(`title="${escapeHtml(options.title)}"`);
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
}
@@ -251,7 +251,11 @@ function buildActionButton(label, variant, action, options = {}) {
if (options.extraAttributes) {
attributes.push(options.extraAttributes);
}
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
const buttonHtml = `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
if (options.disabled && options.title) {
return `<span class="version-action-disabled-wrapper" title="${escapeHtml(options.title)}" aria-label="${escapeHtml(options.title)}">${buttonHtml}</span>`;
}
return buttonHtml;
}
const DISPLAY_FILTER_MODES = Object.freeze({

View File

@@ -17,6 +17,7 @@ import {
import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js';
export const showcaseListenerMetrics = {
wheelListeners: 0,
@@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) {
initMediaControlHandlers(carousel);
positionAllMediaControls(carousel);
// Click-to-view: open full-size media viewer when clicking showcase images/videos
const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video');
const allItems = [];
const elementIndexMap = new Map();
viewerElements.forEach((el) => {
const isVideo = el.tagName === 'VIDEO';
const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc;
if (url) {
elementIndexMap.set(el, allItems.length);
allItems.push({ url, type: isVideo ? 'video' : 'image' });
}
});
viewerElements.forEach((mediaEl) => {
const idx = elementIndexMap.get(mediaEl);
if (idx === undefined) return;
mediaEl.addEventListener('click', (e) => {
e.stopPropagation();
openMediaViewer(allItems, idx);
});
});
// Bind scroll-indicator click events
bindScrollIndicatorEvents(carousel);

View File

@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
import { RecipeSidebarApiClient, updateRecipeMetadata } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
@@ -41,7 +41,9 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
[MODEL_TYPES.EMBEDDING]: {
addTags: true,
@@ -53,7 +55,9 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: false,
skipMetadataRefresh: true
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
[MODEL_TYPES.CHECKPOINT]: {
addTags: true,
@@ -65,7 +69,9 @@ export class BulkManager {
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
recipes: {
addTags: false,
@@ -77,7 +83,9 @@ export class BulkManager {
autoOrganize: false,
deleteAll: true,
setContentRating: false,
skipMetadataRefresh: false
skipMetadataRefresh: false,
setFavorite: true,
unfavorite: true
}
};
@@ -1090,6 +1098,60 @@ export class BulkManager {
}
}
async setBulkFavorites(value) {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const totalCount = state.selectedModels.size;
const isRecipesPage = state.currentPageType === 'recipes';
state.loadingManager.showSimpleLoading(
translate(value ? 'toast.models.bulkFavoriteUpdating' : 'toast.models.bulkUnfavoriteUpdating', { count: totalCount })
);
let cancelled = false;
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
let successCount = 0;
let failureCount = 0;
try {
for (const filePath of state.selectedModels) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try {
if (isRecipesPage) {
await updateRecipeMetadata(filePath, { favorite: value });
} else {
const apiClient = getModelApiClient();
await apiClient.saveModelMetadata(filePath, { favorite: value });
}
successCount++;
} catch (error) {
failureCount++;
console.error(`Failed to set favorite=${value} for ${filePath}:`, error);
}
}
} finally {
state.loadingManager?.hide?.();
}
if (successCount === totalCount) {
const toastKey = value ? 'modelCard.favorites.added' : 'modelCard.favorites.removed';
showToast(toastKey, {}, 'success');
} else if (successCount > 0) {
const toastKey = value ? 'toast.models.bulkFavoritePartialAdded' : 'toast.models.bulkFavoritePartialRemoved';
showToast(toastKey, { success: successCount, failed: failureCount }, 'warning');
} else {
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
}
}
/**
* Show bulk base model modal
*/

View File

@@ -286,16 +286,6 @@ class RecipeManager {
});
});
// Handle quick refresh option (Sync Changes)
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
if (quickRefreshOption) {
quickRefreshOption.addEventListener('click', (e) => {
e.stopPropagation();
this.pageControls.refreshModels(false);
this.closeDropdowns();
});
}
// Handle full rebuild option (Rebuild Cache)
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
if (fullRebuildOption) {

View File

@@ -50,6 +50,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
download_skip_base_models: [],
backup_auto_enabled: true,
backup_retention_count: 5,
strip_lora_on_copy: false,
});
export function createDefaultSettings() {

View File

@@ -66,6 +66,9 @@ export const BASE_MODELS = {
HUNYUAN_VIDEO: "Hunyuan Video",
// Other models
ANIMA: "Anima",
ERNIE: "Ernie",
ERNIE_TURBO: "Ernie Turbo",
NUCLEUS: "Nucleus",
PONY_V7: "Pony V7",
// Default
UNKNOWN: "Other"
@@ -191,6 +194,9 @@ export const BASE_MODEL_ABBREVIATIONS = {
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
[BASE_MODELS.ANIMA]: 'ANI',
[BASE_MODELS.ERNIE]: 'ERNI',
[BASE_MODELS.ERNIE_TURBO]: 'ETRB',
[BASE_MODELS.NUCLEUS]: 'NUCL',
// Default
[BASE_MODELS.UNKNOWN]: 'OTH'
@@ -394,6 +400,7 @@ export const BASE_MODEL_CATEGORIES = {
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
BASE_MODELS.ERNIE, BASE_MODELS.ERNIE_TURBO, BASE_MODELS.NUCLEUS,
BASE_MODELS.UNKNOWN
]
};

View File

@@ -53,32 +53,32 @@
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="refresh-all">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
</div>
<div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
</div>
<div class="context-menu-item" data-action="copy-all">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</div>
<div class="context-menu-section" data-section="workflow">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.workflow') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="send-to-workflow">
<i class="fas fa-paper-plane"></i>
<span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="send-to-workflow-append">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow-replace">
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
</div>
<div class="context-menu-item" data-action="auto-organize">
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
<div class="context-menu-item" data-action="copy-all">
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
</div>
<div class="context-menu-item" data-action="add-tags">
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
</div>
<div class="context-menu-item" data-action="set-base-model">
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
</div>
<div class="context-menu-item" data-action="set-content-rating">
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
</div>
<div class="context-menu-section" data-section="metadata">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.metadata') }}</div>
<div class="context-menu-item" data-action="refresh-all">
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
</div>
<div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
</div>
<div class="context-menu-item" data-action="skip-metadata-refresh">
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
@@ -86,13 +86,41 @@
<div class="context-menu-item" data-action="resume-metadata-refresh">
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
</div>
<div class="context-menu-section" data-section="attributes">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.attributes') }}</div>
<div class="context-menu-item" data-action="add-tags">
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
</div>
<div class="context-menu-item" data-action="set-base-model">
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
</div>
<div class="context-menu-item" data-action="set-favorite">
<i class="fas fa-star"></i> <span>{{ t('loras.bulkOperations.setFavorite') }}</span>
</div>
<div class="context-menu-item" data-action="set-content-rating">
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
</div>
</div>
<div class="context-menu-section" data-section="organize">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.organize') }}</div>
<div class="context-menu-item" data-action="auto-organize">
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
</div>
<div class="context-menu-item" data-action="move-all">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</div>
</div>
<div class="context-menu-section" data-section="download">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
<div class="context-menu-item" data-action="download-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
</div>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
</div>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete-all">
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
</div>

View File

@@ -41,9 +41,6 @@
<i class="fas fa-caret-down"></i>
</button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('loras.controls.refresh.quickTooltip') }}">
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick') }}</span>
</div>
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
</div>

View File

@@ -22,7 +22,16 @@
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-header-row">
<h3>Generation Parameters</h3>
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, &lt;lora:...&gt; tags are removed from prompt text when copying">
<span class="inline-toggle-label">Strip &lt;lora:&gt;</span>
<div class="toggle-switch">
<input type="checkbox" id="stripLoraOnCopyToggle">
<span class="toggle-slider"></span>
</div>
</label>
</div>
<div class="gen-params-container">
<!-- Prompt -->

View File

@@ -75,9 +75,6 @@
<i class="fas fa-caret-down"></i>
</button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('recipes.controls.refresh.quickTooltip', default='Sync changes - quick refresh without rebuilding cache') }}">
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick', default='Sync Changes') }}</span>
</div>
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
</div>

View File

@@ -114,7 +114,8 @@ describe('LoRA widget drag interactions', () => {
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
expect(onDragEnd).toHaveBeenCalledTimes(1);
expect(renderSpy).toHaveBeenCalledWith(widget.value, widget);
// 454210a4 replaced renderFunction() with widget.value setter + widget.callback()
expect(widget.callback).toHaveBeenCalledWith(widget.value);
});
it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {

View File

@@ -135,7 +135,6 @@ function renderControlsDom(pageKey) {
<button data-action="refresh" class="dropdown-main"></button>
<button class="dropdown-toggle"></button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="quick-refresh"></div>
<div class="dropdown-item" data-action="full-rebuild"></div>
</div>
</div>

View File

@@ -79,7 +79,7 @@ class FakeDownloadHistoryService:
async def mark_downloaded(self, *_args, **_kwargs):
return None
async def mark_not_downloaded(self, *_args, **_kwargs):
async def mark_as_deleted(self, *_args, **_kwargs):
return None

View File

@@ -903,7 +903,7 @@ class FakeDownloadHistoryService:
(model_type, version_id, model_id, source, file_path)
)
async def mark_not_downloaded(self, model_type, version_id):
async def mark_as_deleted(self, model_type, version_id):
self.marked_not_downloaded.append((model_type, version_id))

View File

@@ -30,7 +30,7 @@ async def test_download_history_roundtrip_and_manual_override(tmp_path: Path) ->
assert await service.has_been_downloaded("lora", 101) is True
assert await service.get_downloaded_version_ids("lora", 11) == [101]
await service.mark_not_downloaded("lora", 101)
await service.mark_as_deleted("lora", 101)
assert await service.has_been_downloaded("lora", 101) is False
assert await service.get_downloaded_version_ids("lora", 11) == []

View File

@@ -77,7 +77,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
recipe = {
"id": "r1",
"title": "Old Recipe",
"source_url": "https://civitai.com/images/12345",
"source_path": "https://civitai.com/images/12345",
"checkpoint": None,
"gen_params": {"prompt": ""}
}
@@ -127,7 +127,7 @@ async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner)
recipe = {
"id": "r1",
"title": "Red Recipe",
"source_url": "https://civitai.red/images/12345",
"source_path": "https://civitai.red/images/12345",
"checkpoint": None,
"gen_params": {"prompt": ""},
}