Compare commits

...

18 Commits

Author SHA1 Message Date
Will Miao
b509f27cb7 chore(release): bump version to v1.0.10 2026-05-31 09:39:26 +08:00
Will Miao
5c2ef48917 fix(aria2): apply certifi CA bundle to aria2c via --ca-certificate
When certifi is available, pass its CA bundle path as --ca-certificate
to the aria2c subprocess so that aria2 downloads use the same
certificate store as Python aiohttp downloads. Graceful fallback when
certifi is not installed.
2026-05-30 21:47:13 +08:00
Will Miao
ad2bd82c67 fix(downloader): use certifi CA bundle as SSL fallback and log SSL error diagnostics
- Prefer certifi's CA bundle in aiohttp SSL context with graceful
  fallback to system default when certifi is unavailable
- Add is_ssl_cert_verify_error() helper for SSL cert failure detection
- Log actionable error message (pip install --upgrade certifi /
  pip install pip-system-certs) when SSL certificate verification fails
- Apply same diagnostic logging to aria2 redirect resolution path
2026-05-30 21:28:18 +08:00
willmiao
17ba350153 docs: auto-update supporters list in README 2026-05-28 13:47:09 +00:00
Will Miao
60175334b5 chore(release): bump version to v1.0.9 2026-05-28 21:46:46 +08:00
Will Miao
f65a01df00 feat(recipe): add bulk Repair Metadata for Selected operation to recipes page
Adds a new bulk operation in the recipes page that allows users to select
multiple recipes and repair their metadata in batch.

Backend:
- New POST /api/lm/recipes/repair-bulk endpoint accepting recipe_ids array
- repair_recipes_bulk handler iterates repair_recipe_by_id for each recipe
- Response includes per-recipe updated data for frontend card refresh

Frontend:
- Bulk context menu: new 'Repair Metadata for Selected' item in Metadata section
- BulkManager.repairSelectedRecipes() with loading/toast flow
- Uses VirtualScroller.updateSingleItem() per repaired recipe (no full reload)
- Visibility controlled via repairMetadata actionConfig flag

Locales:
- Added repairMetadata, repairBulkComplete, repairBulkSkipped, repairBulkFailed
- Translated across all 9 supported languages
2026-05-28 20:16:59 +08:00
Will Miao
430e24d70b fix(ui): hide skip-metadata-refresh bulk menu items for recipes 2026-05-28 19:11:49 +08:00
Will Miao
14f0c48fdd fix(recipe): detect and repair corrupted checkpoints in repair flow
Add corruption detection to _repair_single_recipe: if checkpoint.modelVersionId matches any LoRA's modelVersionId, the checkpoint is corrupted (a LoRA was saved as checkpoint). Clear the checkpoint and remove the matching LoRA entry, then let enrichment re-resolve the correct checkpoint from CivitAI metadata.

This fixes the retroactive repair path for the modelVersionIds[0] fallback bug.
2026-05-28 17:19:27 +08:00
Will Miao
34791c2ad7 fix(recipe): use resources type field to identify checkpoint instead of modelVersionIds[0]
When importing a CivitAI image as a recipe, modelVersionIds[0] was blindly used as the checkpoint version ID. This array mixes checkpoints and LoRAs without ordering guarantees, causing LoRAs to be saved as the recipe checkpoint.

Fix by:
1. Removing the modelVersionIds[0] fallback in _download_remote_media
2. Parsing resources entries with type:"model" as the checkpoint
3. Adding model type validation in populate_checkpoint_from_civitai

Also add 2 tests for the new behavior and fix 3 tests whose mocks lacked the required model.type field.
2026-05-28 15:46:38 +08:00
Will Miao
3f6824eef6 fix(example-images): exclude failed_models from check_pending_models pending count
Previously check_pending_models() only skipped models already in
processed_models, so models that had permanently failed (no CivitAI
images available, download errors) were forever reported as "pending".
This caused repeated auto-download cycles with no actual work to do.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:00:25 +08:00
Will Miao
3919dfa3f4 fix(metadata): suppress rate-limit propagation when model already confirmed deleted
When CivitAI returns 404 (ResourceNotFoundError) and a fallback provider
like CivArchive subsequently rate-limits, the ChainedMetadataProvider
now suppresses the RateLimitError instead of propagating it. Previously,
the rate-limit error would bubble up through _refresh_single_model and
cause the outer retry loop to re-process the same model repeatedly,
producing dozens of duplicate "Model X is no longer available" log
messages and wasting API quota.

The model is NOT permanently marked as ignored — its last_checked_at
timestamp is preserved, so it will be retried on the next refresh cycle
when the rate limit has cleared and CivArchive may still have the data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:56:22 +08:00
Will Miao
7124b5293f chore(settings): remove unused example_images config, add unet folder_paths example 2026-05-27 19:58:56 +08:00
Will Miao
d2a04f8993 fix(model-hash-index): clean up AutoV2 entry in remove_by_hash 2026-05-27 19:38:08 +08:00
pixelpaws
7027a7c270 Merge pull request #946 from 1756141021/fix/autov2-hash-matching
fix: match local LoRAs by AutoV2 hash when Civitai model is deleted
2026-05-27 19:20:31 +08:00
hein
0a1d7dfd4c fix: match local LoRAs by AutoV2 hash when Civitai model is deleted
When recipe metadata contains AutoV2 hashes (10-char short hash from
image metadata) and the Civitai API cannot resolve them to SHA256
(model deleted, API offline), the local hash index failed to match
because it only stored full SHA256 hashes.

AutoV2 is simply SHA256[:10], so we derive it automatically in
add_entry() — no extra file I/O or schema changes needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 14:15:01 +08:00
Will Miao
3962b1a96d fix(civitai): fall back to direct version fetch when modelVersions is empty for newly published models 2026-05-27 06:40:13 +08:00
Will Miao
8b856276bf fix(ui): escape HTML entities in parseMarkdown to prevent swallowed angle brackets 2026-05-27 06:40:13 +08:00
willmiao
c97c802956 docs: auto-update supporters list in README 2026-05-26 13:27:45 +00:00
33 changed files with 676 additions and 93 deletions

File diff suppressed because one or more lines are too long

View File

@@ -21,7 +21,9 @@
"stone9k", "stone9k",
"Rosenthal", "Rosenthal",
"Francisco Tatis", "Francisco Tatis",
"JongWon Han",
"runte3221", "runte3221",
"FreelancerZ",
"Fraser Cross", "Fraser Cross",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Marc Whiffen", "Marc Whiffen",
@@ -43,11 +45,13 @@
"ClockDaemon", "ClockDaemon",
"KD", "KD",
"Omnidex", "Omnidex",
"Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"Tobi_Swagg", "Tobi_Swagg",
"SG", "SG",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard",
"jmack", "jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
@@ -57,7 +61,7 @@
"Wolffen", "Wolffen",
"Ricky Carter", "Ricky Carter",
"James Todd", "James Todd",
"JongWon Han", "Steven Pfeiffer",
"VantAI", "VantAI",
"Tim", "Tim",
"Lisster", "Lisster",
@@ -65,7 +69,6 @@
"Illrigger", "Illrigger",
"Tom Corrigan", "Tom Corrigan",
"JackieWang", "JackieWang",
"FreelancerZ",
"fnkylove", "fnkylove",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
@@ -73,6 +76,7 @@
"Lilleman", "Lilleman",
"Robert Stacey", "Robert Stacey",
"PM", "PM",
"Todd Keck",
"Edgar Tejeda", "Edgar Tejeda",
"Jorge Hussni", "Jorge Hussni",
"Liam MacDougal", "Liam MacDougal",
@@ -91,7 +95,6 @@
"Melville Parrish", "Melville Parrish",
"daniel dove", "daniel dove",
"Lustre", "Lustre",
"Tyler Trebuchon",
"JW Sin", "JW Sin",
"contrite831", "contrite831",
"Alex", "Alex",
@@ -99,20 +102,19 @@
"carozzz", "carozzz",
"Marlon Daniels", "Marlon Daniels",
"Starkselle", "Starkselle",
"Buzzard",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"greebles", "greebles",
"Adam Shaw", "Adam Shaw",
"Anthony Rizzo", "Anthony Rizzo",
"M Postkasse",
"Gooohokrbe", "Gooohokrbe",
"RedrockVP", "RedrockVP",
"ASLPro3D", "Wicked Choices by ASLPro3D",
"OldBones", "OldBones",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Weasyl", "Weasyl",
"Steven Pfeiffer",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Cory Paza", "Cory Paza",
@@ -126,7 +128,7 @@
"corde", "corde",
"Nick Walker", "Nick Walker",
"Bishoujoker", "Bishoujoker",
"Todd Keck", "aai",
"Briton Heilbrun", "Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
@@ -153,12 +155,13 @@
"JaxMax", "JaxMax",
"takyamtom", "takyamtom",
"Jwk0205", "Jwk0205",
"Bro Xie",
"batblue", "batblue",
"carey6409", "carey6409",
"Olive", "Olive",
"太郎 ゲーム",
"Some Guy Named Barry", "Some Guy Named Barry",
"Cosmosis", "Cosmosis",
"M Postkasse",
"AELOX", "AELOX",
"Nicfit23", "Nicfit23",
"FloPro4Sho", "FloPro4Sho",
@@ -172,13 +175,13 @@
"Serge Bekenkamp", "Serge Bekenkamp",
"Jimmy Ledbetter", "Jimmy Ledbetter",
"LeoZero", "LeoZero",
"Antonio Pontes",
"ApathyJones", "ApathyJones",
"Julian V", "Julian V",
"Steven Owens", "Steven Owens",
"nahinahi9", "nahinahi9",
"Dustin Chen", "Dustin Chen",
"dan", "dan",
"aai",
"Mouthlessman", "Mouthlessman",
"otaku fra", "otaku fra",
"ViperC", "ViperC",
@@ -199,15 +202,15 @@
"Jon Sandman", "Jon Sandman",
"Ubivis", "Ubivis",
"CloudValley", "CloudValley",
"linnfrey",
"IamAyam", "IamAyam",
"skaterb949",
"Joboshy", "Joboshy",
"Bohemian Corporal", "Bohemian Corporal",
"Dan", "Dan",
"confiscated Zyra", "confiscated Zyra",
"Bro Xie",
"yer fey", "yer fey",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"太郎 ゲーム",
"Roslynd", "Roslynd",
"Tee Gee", "Tee Gee",
"jinxedx", "jinxedx",
@@ -221,7 +224,7 @@
"Magic Noob", "Magic Noob",
"Pronredn", "Pronredn",
"DougPeterson", "DougPeterson",
"Antonio Pontes", "Jeff",
"Bruce", "Bruce",
"lh qwe", "lh qwe",
"Kevin John Duck", "Kevin John Duck",
@@ -249,19 +252,21 @@
"地獄の禄", "地獄の禄",
"MJG", "MJG",
"David LaVallee", "David LaVallee",
"linnfrey",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"WRL_SPR", "WRL_SPR",
"capn", "capn",
"Joseph", "Joseph",
"Mirko Katzula", "Mirko Katzula",
"dan",
"Piccio08",
"kumakichi",
"cppbel",
"奚明 刘", "奚明 刘",
"Brian M", "Brian M",
"Josef Lanzl", "Josef Lanzl",
"Nerezza", "Nerezza",
"sanborondon", "sanborondon",
"Griffin Dahlberg",
"준희 김", "준희 김",
"Taylor Funk", "Taylor Funk",
"aezin", "aezin",
@@ -278,10 +283,11 @@
"Noora", "Noora",
"Pierce McBride", "Pierce McBride",
"Mattssn", "Mattssn",
"Mikko Hemilä",
"Jamie Ogletree", "Jamie Ogletree",
"a _", "a _",
"Jeff",
"James Coleman", "James Coleman",
"Martial",
"Emil Andersson", "Emil Andersson",
"Ouro Boros", "Ouro Boros",
"Chad Idk", "Chad Idk",
@@ -302,10 +308,6 @@
"Nick “Loadstone” D", "Nick “Loadstone” D",
"Gamalonia", "Gamalonia",
"momokai", "momokai",
"dan",
"Piccio08",
"kumakichi",
"cppbel",
"starbugx", "starbugx",
"Moon Knight", "Moon Knight",
"몽타주", "몽타주",
@@ -337,6 +339,7 @@
"Andrew", "Andrew",
"Robert Wegemund", "Robert Wegemund",
"Littlehuggy", "Littlehuggy",
"Gregory Kozhemiak",
"Draven T", "Draven T",
"mrjuan", "mrjuan",
"Brian Buie", "Brian Buie",
@@ -350,7 +353,6 @@
"Joshua Gray", "Joshua Gray",
"Morgandel", "Morgandel",
"Focuschannel", "Focuschannel",
"Mikko Hemilä",
"Noah", "Noah",
"Jacob McDaniel", "Jacob McDaniel",
"X", "X",
@@ -359,7 +361,6 @@
"Artokun", "Artokun",
"Michael Taylor", "Michael Taylor",
"Derek Baker", "Derek Baker",
"Martial",
"Anthony Faxlandez", "Anthony Faxlandez",
"battu", "battu",
"Michael Anthony Scott", "Michael Anthony Scott",
@@ -367,8 +368,6 @@
"Decx _", "Decx _",
"Pat Hen", "Pat Hen",
"Jordan Shaw", "Jordan Shaw",
"Thesharingbrother",
"ResidentDeviant",
"四糸凜音", "四糸凜音",
"Nihongasuki", "Nihongasuki",
"JC", "JC",
@@ -412,11 +411,11 @@
"Wolfe7D1", "Wolfe7D1",
"blikkies", "blikkies",
"Chris", "Chris",
"Gregory Kozhemiak",
"elleshar666", "elleshar666",
"Shock Shockor", "Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Goldwaters", "Goldwaters",
"Kauffy",
"Zude", "Zude",
"John J Linehan", "John J Linehan",
"Kyler", "Kyler",
@@ -426,19 +425,21 @@
"Justin Blaylock", "Justin Blaylock",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Vane Holzer", "Vane Holzer",
"psytrax",
"hexxish", "hexxish",
"notedfakes", "notedfakes",
"DarkSunset",
"Nathan", "Nathan",
"Billy Gladky", "Billy Gladky",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Michael Scott", "Michael Scott",
"Probis", "Probis",
"Ed Wang", "Ed Wang",
"Wes Sims",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"SRDB", "SRDB",
"g unit", "g unit",
"Distortik", "Distortik",
"Filippo Ferrari",
"Youguang", "Youguang",
"Saya", "Saya",
"andrewzpong", "andrewzpong",
@@ -456,6 +457,7 @@
"emadsultan", "emadsultan",
"Pkrsky", "Pkrsky",
"nanana", "nanana",
"FeralOpticsAI",
"Pavlaki", "Pavlaki",
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
@@ -483,7 +485,6 @@
"Time Valentine", "Time Valentine",
"Михал Михалыч", "Михал Михалыч",
"Matt", "Matt",
"Kauffy",
"Frogmilk", "Frogmilk",
"SPJ", "SPJ",
"Kyron Mahan", "Kyron Mahan",
@@ -491,11 +492,11 @@
"Nick Kage", "Nick Kage",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
"psytrax",
"Cyrus Fett", "Cyrus Fett",
"Ezokewn", "Ezokewn",
"SendingRavens", "SendingRavens",
"Xenon Xue", "Xenon Xue",
"JackJohnnyJim",
"Edward Ten Eyck", "Edward Ten Eyck",
"Michael Docherty", "Michael Docherty",
"Paul Hartsuyker", "Paul Hartsuyker",
@@ -504,15 +505,14 @@
"Solixer", "Solixer",
"Jacob Winter", "Jacob Winter",
"Ryan Presley Ng", "Ryan Presley Ng",
"Wes Sims",
"jinksta187", "jinksta187",
"Donor4115", "Donor4115",
"Manu Thetug", "Manu Thetug",
"Karlanx",
"Lyavph", "Lyavph",
"David", "David",
"Meilo", "Meilo",
"operationancut", "operationancut",
"Filippo Ferrari",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
"MaartenAlbers", "MaartenAlbers",
@@ -531,6 +531,8 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"YassineKhaled",
"Y",
"MatteKey", "MatteKey",
"Flob", "Flob",
"ShiroSenpai", "ShiroSenpai",
@@ -552,7 +554,6 @@
"rsamerica", "rsamerica",
"sfasdfasfdsa", "sfasdfasfdsa",
"Alan+Cano", "Alan+Cano",
"FeralOpticsAI",
"generic404", "generic404",
"abattoirblues", "abattoirblues",
"zounik", "zounik",
@@ -584,7 +585,6 @@
"Sauv", "Sauv",
"Steven", "Steven",
"CptNeo", "CptNeo",
"JackJohnnyJim",
"TenaciousD", "TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
"Khánh Đặng", "Khánh Đặng",
@@ -599,7 +599,6 @@
"Andrew Wilkinson", "Andrew Wilkinson",
"Yavizu3d", "Yavizu3d",
"Maxim", "Maxim",
"Karlanx",
"Yves Poezevara", "Yves Poezevara",
"Teriak47", "Teriak47",
"Just me", "Just me",
@@ -637,6 +636,7 @@
"Captain_Swag", "Captain_Swag",
"obkircher", "obkircher",
"gwyar", "gwyar",
"ResidentDeviant",
"D", "D",
"edgecase", "edgecase",
"Neoxena", "Neoxena",
@@ -681,8 +681,6 @@
"low9", "low9",
"Winged", "Winged",
"you+halo9", "you+halo9",
"YassineKhaled",
"YK12",
"Somebody", "Somebody",
"Somebody", "Somebody",
"Crescent~San", "Crescent~San",
@@ -697,6 +695,7 @@
"Coeur+de+cochon", "Coeur+de+cochon",
"Obsidian.Studios", "Obsidian.Studios",
"han b", "han b",
"Zomba Mann",
"Nico", "Nico",
"Maximilian Krischan", "Maximilian Krischan",
"Banana Joe", "Banana Joe",
@@ -714,7 +713,6 @@
"Ronan Delevacq", "Ronan Delevacq",
"karim ben brik", "karim ben brik",
"Vinarus", "Vinarus",
"james",
"Michael Zhu", "Michael Zhu",
"Nemisu", "Nemisu",
"Seraphy", "Seraphy",
@@ -743,9 +741,11 @@
"dsffsdfsdfsdfsdfsdf", "dsffsdfsdfsdfsdfsdf",
"somethingtosay8", "somethingtosay8",
"Jean-françois SEMA", "Jean-françois SEMA",
"3zS4QNQ4",
"Terminuz", "Terminuz",
"Kurt", "Kurt",
"ivistorm", "ivistorm",
"Ivan Imes",
"Faburizu", "Faburizu",
"Jack Lawfield", "Jack Lawfield",
"jimyjomson", "jimyjomson",

View File

@@ -689,6 +689,7 @@
"setContentRating": "Inhaltsbewertung für alle festlegen", "setContentRating": "Inhaltsbewertung für alle festlegen",
"copyAll": "Alle Syntax kopieren", "copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren", "refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren",
"checkUpdates": "Auswahl auf Updates prüfen", "checkUpdates": "Auswahl auf Updates prüfen",
"moveAll": "Alle in Ordner verschieben", "moveAll": "Alle in Ordner verschieben",
"autoOrganize": "Automatisch organisieren", "autoOrganize": "Automatisch organisieren",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt", "noRecipesSelected": "Keine Rezepte ausgewählt",
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden", "noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest." "noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
}, },

View File

@@ -689,6 +689,7 @@
"setContentRating": "Set Content Rating for Selected", "setContentRating": "Set Content Rating for Selected",
"copyAll": "Copy Selected Syntax", "copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata", "refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected",
"checkUpdates": "Check Updates for Selected", "checkUpdates": "Check Updates for Selected",
"moveAll": "Move Selected to Folder", "moveAll": "Move Selected to Folder",
"autoOrganize": "Auto-Organize Selected", "autoOrganize": "Auto-Organize Selected",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected", "noRecipesSelected": "No recipes selected",
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}",
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes", "noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings." "noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
}, },

View File

@@ -689,6 +689,7 @@
"setContentRating": "Establecer clasificación de contenido para todos", "setContentRating": "Establecer clasificación de contenido para todos",
"copyAll": "Copiar toda la sintaxis", "copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos", "refreshAll": "Actualizar todos los metadatos",
"repairMetadata": "Reparar metadatos de la selección",
"checkUpdates": "Comprobar actualizaciones para la selección", "checkUpdates": "Comprobar actualizaciones para la selección",
"moveAll": "Mover todos a carpeta", "moveAll": "Mover todos a carpeta",
"autoOrganize": "Auto-organizar seleccionados", "autoOrganize": "Auto-organizar seleccionados",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No se han seleccionado recetas", "noRecipesSelected": "No se han seleccionado recetas",
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas", "noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración." "noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
}, },

View File

@@ -689,6 +689,7 @@
"setContentRating": "Définir la classification du contenu pour tous", "setContentRating": "Définir la classification du contenu pour tous",
"copyAll": "Copier toute la syntaxe", "copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées", "refreshAll": "Actualiser toutes les métadonnées",
"repairMetadata": "Réparer les métadonnées de la sélection",
"checkUpdates": "Vérifier les mises à jour pour la sélection", "checkUpdates": "Vérifier les mises à jour pour la sélection",
"moveAll": "Déplacer tout vers un dossier", "moveAll": "Déplacer tout vers un dossier",
"autoOrganize": "Auto-organiser la sélection", "autoOrganize": "Auto-organiser la sélection",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Aucune recette sélectionnée", "noRecipesSelected": "Aucune recette sélectionnée",
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées", "noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres." "noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
}, },

View File

@@ -689,6 +689,7 @@
"setContentRating": "הגדר דירוג תוכן לכל המודלים", "setContentRating": "הגדר דירוג תוכן לכל המודלים",
"copyAll": "העתק את כל התחבירים", "copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה", "refreshAll": "רענן את כל המטא-דאטה",
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
"checkUpdates": "בדוק עדכונים לבחירה", "checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה", "moveAll": "העבר הכל לתיקייה",
"autoOrganize": "ארגן אוטומטית נבחרים", "autoOrganize": "ארגן אוטומטית נבחרים",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "לא נבחרו מתכונים", "noRecipesSelected": "לא נבחרו מתכונים",
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו", "noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות." "noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
}, },

View File

@@ -689,6 +689,7 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定", "setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー", "copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新", "refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"checkUpdates": "選択項目の更新を確認", "checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動", "moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行", "autoOrganize": "自動整理を実行",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "レシピが選択されていません", "noRecipesSelected": "レシピが選択されていません",
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした", "noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。" "noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
}, },

View File

@@ -689,6 +689,7 @@
"setContentRating": "모든 모델에 콘텐츠 등급 설정", "setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사", "copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침", "refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"checkUpdates": "선택 항목 업데이트 확인", "checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동", "moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택", "autoOrganize": "자동 정리 선택",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다", "noRecipesSelected": "선택한 레시피가 없습니다",
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다", "noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요." "noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
}, },

View File

@@ -689,6 +689,7 @@
"setContentRating": "Установить рейтинг контента для всех", "setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис", "copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные", "refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"checkUpdates": "Проверить обновления для выбранных", "checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку", "moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные", "autoOrganize": "Автоматически организовать выбранные",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Рецепты не выбраны", "noRecipesSelected": "Рецепты не выбраны",
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs", "noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках." "noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
}, },

View File

@@ -689,6 +689,7 @@
"setContentRating": "为所选中设置内容评级", "setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法", "copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据", "refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"checkUpdates": "检查所选更新", "checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹", "moveAll": "移动所选中到文件夹",
"autoOrganize": "自动整理所选模型", "autoOrganize": "自动整理所选模型",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "浏览目录失败:{message}", "batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}", "batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方", "noRecipesSelected": "未选择任何配方",
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs", "noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。" "noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
}, },

View File

@@ -689,6 +689,7 @@
"setContentRating": "為全部設定內容分級", "setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法", "copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata", "refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"checkUpdates": "檢查所選更新", "checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾", "moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型", "autoOrganize": "自動整理所選模型",
@@ -1693,6 +1694,9 @@
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}", "batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}", "batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜", "noRecipesSelected": "未選取任何食譜",
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs", "noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。" "noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
}, },

View File

@@ -7,7 +7,7 @@ import re
from typing import Dict, List, Any, Optional, Tuple from typing import Dict, List, Any, Optional, Tuple
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ..config import config from ..config import config
from ..utils.constants import VALID_LORA_TYPES from ..utils.constants import VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -173,6 +173,20 @@ class RecipeMetadataParser(ABC):
checkpoint['isDeleted'] = True checkpoint['isDeleted'] = True
return checkpoint return checkpoint
# Validate that the model type is actually a checkpoint.
# Unlike populate_lora_from_civitai which has this check,
# this function was missing type validation — allowing LoRA
# version data to be saved as the recipe's checkpoint when the
# wrong version ID was passed downstream (fixed in v2.7+).
model_type = civitai_data.get('model', {}).get('type', '').lower()
if model_type not in VALID_CHECKPOINT_SUB_TYPES:
logger.warning(
f"Cannot populate checkpoint: model version {civitai_data.get('id')} "
f"has type '{model_type}', expected one of {VALID_CHECKPOINT_SUB_TYPES}. "
f"Skipping checkpoint enrichment."
)
return checkpoint
if 'model' in civitai_data and 'name' in civitai_data['model']: if 'model' in civitai_data and 'name' in civitai_data['model']:
checkpoint['name'] = civitai_data['model']['name'] checkpoint['name'] = civitai_data['model']['name']

View File

@@ -185,8 +185,67 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Process standard resources array # Process standard resources array
if "resources" in metadata and isinstance(metadata["resources"], list): if "resources" in metadata and isinstance(metadata["resources"], list):
for resource in metadata["resources"]: for resource in metadata["resources"]:
resource_type = resource.get("type", "lora")
# Track resources with type "model" — these are checkpoint models.
# The resources array is the most reliable source for checkpoint
# identification because it has an explicit type field and hash,
# unlike modelVersionIds which is a flat list with no type info.
if resource_type == "model":
checkpoint_entry = {
"id": 0,
"modelId": 0,
"name": resource.get("name", "Unknown Model"),
"version": "",
"type": resource.get("type", "model"),
"existsLocally": False,
"localPath": None,
"file_name": resource.get("name", ""),
"hash": resource.get("hash", "") or "",
"thumbnailUrl": "/loras_static/images/no-preview.png",
"baseModel": "",
"size": 0,
"downloadUrl": "",
"isDeleted": False,
}
# Try to look up base model from the checkpoint hash
if checkpoint_entry["hash"] and metadata_provider:
try:
civitai_info = (
await metadata_provider.get_model_by_hash(
checkpoint_entry["hash"]
)
)
civitai_data, error_msg = (
(civitai_info, None)
if not isinstance(civitai_info, tuple)
else civitai_info
)
if civitai_data and error_msg != "Model not found":
if 'model' in civitai_data and 'name' in civitai_data['model']:
checkpoint_entry['name'] = civitai_data['model']['name']
checkpoint_entry['id'] = civitai_data.get('id', 0)
checkpoint_entry['modelId'] = civitai_data.get('modelId', 0)
if 'name' in civitai_data:
checkpoint_entry['version'] = civitai_data['name']
base_model = civitai_data.get('baseModel', '')
if base_model:
checkpoint_entry['baseModel'] = base_model
if not result['base_model']:
result['base_model'] = base_model
except Exception as e:
logger.error(
f"Error fetching checkpoint info for hash "
f"{checkpoint_entry['hash']}: {e}"
)
if result["model"] is None:
result["model"] = checkpoint_entry
continue
# Modified to process resources without a type field as potential LoRAs # Modified to process resources without a type field as potential LoRAs
if resource.get("type", "lora") == "lora": if resource_type == "lora":
lora_hash = resource.get("hash", "") lora_hash = resource.get("hash", "")
# Try to get hash from the hashes field if not present in resource # Try to get hash from the hashes field if not present in resource

View File

@@ -87,6 +87,7 @@ class RecipeHandlerSet:
"repair_recipes": self.management.repair_recipes, "repair_recipes": self.management.repair_recipes,
"cancel_repair": self.management.cancel_repair, "cancel_repair": self.management.cancel_repair,
"repair_recipe": self.management.repair_recipe, "repair_recipe": self.management.repair_recipe,
"repair_recipes_bulk": self.management.repair_recipes_bulk,
"get_repair_progress": self.management.get_repair_progress, "get_repair_progress": self.management.get_repair_progress,
"start_batch_import": self.batch_import.start_batch_import, "start_batch_import": self.batch_import.start_batch_import,
"get_batch_import_progress": self.batch_import.get_batch_import_progress, "get_batch_import_progress": self.batch_import.get_batch_import_progress,
@@ -706,6 +707,69 @@ class RecipeManagementHandler:
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True) self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def repair_recipes_bulk(self, request: web.Request) -> web.Response:
"""Bulk repair metadata for multiple recipes by their IDs.
Accepts a JSON body with a "recipe_ids" array and iterates
repair_recipe_by_id over each entry, collecting statistics.
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
data = await request.json()
recipe_ids = data.get("recipe_ids", [])
if not recipe_ids:
return web.json_response(
{"success": False, "error": "recipe_ids are required"},
status=400,
)
total = len(recipe_ids)
repaired = 0
skipped = 0
errors = 0
recipes = []
for recipe_id in recipe_ids:
try:
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
if result.get("success"):
repaired += result.get("repaired", 0)
skipped += result.get("skipped", 0)
if result.get("recipe"):
recipes.append(result["recipe"])
else:
errors += 1
except RecipeNotFoundError:
skipped += 1
except Exception as exc:
self._logger.error(
"Error repairing recipe %s: %s", recipe_id, exc
)
errors += 1
return web.json_response({
"success": True,
"total": total,
"repaired": repaired,
"skipped": skipped,
"errors": errors,
"recipes": recipes,
})
except Exception as exc:
self._logger.error(
"Error performing bulk repair: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def repair_recipe(self, request: web.Request) -> web.Response: async def repair_recipe(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -1293,11 +1357,18 @@ class RecipeManagementHandler:
image_info.get("meta") if civitai_image_id and image_info else None image_info.get("meta") if civitai_image_id and image_info else None
) )
if civitai_image_id and image_info: if civitai_image_id and image_info:
# modelVersionId (singular) — the primary version for this
# image on CivitAI. May be absent, or may *not* be the
# checkpoint (e.g. when the image was generated with a LoRA
# as the primary subject). When absent, DO NOT fall back to
# modelVersionIds[0] — that array mixes checkpoints, LoRAs,
# and other model version IDs without ordering guarantees.
# The downstream enrichment flow will find the real
# checkpoint via meta.resources (type:"model" hash) or
# meta.civitaiResources (type:"checkpoint" version ID), so
# leaving model_ver_id as None is safe and avoids the bug
# where a LoRA version ID was treated as the checkpoint.
model_ver_id = image_info.get("modelVersionId") 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]
# Inject root-level modelVersionIds into meta so downstream # Inject root-level modelVersionIds into meta so downstream
# parsers (CivitaiApiMetadataParser) can discover ALL resources # parsers (CivitaiApiMetadataParser) can discover ALL resources

View File

@@ -58,6 +58,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"), RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"), RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"), RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"), RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"), RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
RouteDefinition( RouteDefinition(

View File

@@ -14,12 +14,30 @@ from typing import Any, Dict, Optional, Tuple
import aiohttp import aiohttp
from .downloader import DownloadProgress, get_downloader from .downloader import DownloadProgress, get_downloader, is_ssl_cert_verify_error
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _try_certifi_ca_path() -> str | None:
"""Return the certifi CA bundle path if available, else None."""
try:
import certifi # type: ignore[import-untyped]
path = certifi.where()
if os.path.isfile(path):
logger.debug(
"aria2 --ca-certificate: using certifi CA bundle at %s", path
)
return path
except ImportError:
pass
logger.debug("aria2 --ca-certificate: certifi not available")
return None
CIVITAI_DOWNLOAD_URL_PREFIXES = ( CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.com/api/download/", "https://civitai.com/api/download/",
"https://civitai.red/api/download/", "https://civitai.red/api/download/",
@@ -391,6 +409,15 @@ class Aria2Downloader:
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}" f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
) )
except aiohttp.ClientError as exc: except aiohttp.ClientError as exc:
if is_ssl_cert_verify_error(exc):
logger.error(
"SSL certificate verification failed during Civitai redirect "
"resolution for %s. This is usually caused by an outdated CA "
"certificate bundle. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n"
" 2. pip install pip-system-certs",
url,
)
raise Aria2Error( raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: {exc}" f"Failed to resolve authenticated Civitai redirect: {exc}"
) from exc ) from exc
@@ -414,6 +441,11 @@ class Aria2Downloader:
f"--rpc-listen-port={self._rpc_port}", f"--rpc-listen-port={self._rpc_port}",
f"--rpc-secret={self._rpc_secret}", f"--rpc-secret={self._rpc_secret}",
"--check-certificate=true", "--check-certificate=true",
# Point aria2 at certifi's CA bundle when available so it uses
# the same certificate store as Python downloads.
*((
f"--ca-certificate={ca_cert}",
) if (ca_cert := _try_certifi_ca_path()) else ()),
"--allow-overwrite=true", "--allow-overwrite=true",
"--auto-file-renaming=false", "--auto-file-renaming=false",
"--file-allocation=none", "--file-allocation=none",

View File

@@ -410,6 +410,25 @@ class CivitaiClient:
return None return None
target_version = self._select_target_version(model_data, model_id, version_id) target_version = self._select_target_version(model_data, model_id, version_id)
# If modelVersions is empty (e.g. CivitAI cache lag for newly published
# models) but a specific version_id is known, fall back to fetching the
# version directly via the individual model-versions endpoint, then
# enrich it with the model-level data we already have.
if target_version is None and version_id is not None:
logger.info(
"modelVersions empty for model %s; falling back to direct "
"version lookup for %s",
model_id,
version_id,
)
version = await self._fetch_version_by_id(version_id)
if version:
self._enrich_version_with_model_data(version, model_data)
self._remove_comfy_metadata(version)
return version
return None
if target_version is None: if target_version is None:
return None return None

View File

@@ -13,6 +13,7 @@ This module provides a centralized download service with:
import os import os
import logging import logging
import asyncio import asyncio
import ssl
import aiohttp import aiohttp
from collections import deque from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
@@ -31,6 +32,20 @@ from .errors import RateLimitError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def is_ssl_cert_verify_error(exc: BaseException) -> bool:
"""Check if an exception represents an SSL certificate verification failure.
Matches ``ssl.SSLCertVerificationError``, ``aiohttp.ClientConnectorCertificateError``
(which wraps the former), and falls back to the standard OpenSSL error text.
"""
if isinstance(exc, ssl.SSLCertVerificationError):
return True
cert_error = getattr(exc, "certificate_error", None)
if isinstance(cert_error, ssl.SSLCertVerificationError):
return True
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
@dataclass(frozen=True) @dataclass(frozen=True)
class DownloadProgress: class DownloadProgress:
"""Snapshot of a download transfer at a moment in time.""" """Snapshot of a download transfer at a moment in time."""
@@ -265,9 +280,22 @@ class Downloader:
logger.debug( logger.debug(
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment." "Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
) )
# Build SSL context: prefer certifi's CA bundle for broader
# CA coverage across different Python environments (especially
# embedded/compatibility Python builds).
try:
import certifi # type: ignore[import-untyped]
ca_path = certifi.where()
ssl_context = ssl.create_default_context(cafile=ca_path)
logger.debug("SSL: using certifi CA bundle at %s", ca_path)
except (ImportError, FileNotFoundError, ValueError, OSError):
ssl_context = ssl.create_default_context()
logger.debug("SSL: certifi unavailable; using system default CA bundle")
# Optimize TCP connection parameters # Optimize TCP connection parameters
connector = aiohttp.TCPConnector( connector = aiohttp.TCPConnector(
ssl=True, ssl=ssl_context,
limit=8, # Concurrent connections limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse force_close=False, # Keep connections for reuse
@@ -736,6 +764,17 @@ class Downloader:
DownloadRestartRequested, DownloadRestartRequested,
) as e: ) as e:
retry_count += 1 retry_count += 1
if is_ssl_cert_verify_error(e):
logger.error(
"SSL certificate verification failed when connecting to %s. "
"This is usually caused by an outdated CA certificate bundle "
"in the Python environment. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n"
" 2. pip install pip-system-certs",
url,
)
logger.warning( logger.warning(
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}" f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
) )

View File

@@ -7,6 +7,7 @@ class ModelHashIndex:
def __init__(self): def __init__(self):
self._hash_to_path: Dict[str, str] = {} self._hash_to_path: Dict[str, str] = {}
self._filename_to_hash: Dict[str, str] = {} self._filename_to_hash: Dict[str, str] = {}
self._autov2_to_path: Dict[str, str] = {}
# New data structures for tracking duplicates # New data structures for tracking duplicates
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
@@ -63,6 +64,9 @@ class ModelHashIndex:
# Add new mappings # Add new mappings
self._hash_to_path[sha256] = file_path self._hash_to_path[sha256] = file_path
self._filename_to_hash[filename] = sha256 self._filename_to_hash[filename] = sha256
# AutoV2 = first 10 chars of SHA256
if len(sha256) >= 10:
self._autov2_to_path[sha256[:10]] = file_path
def _get_filename_from_path(self, file_path: str) -> str: def _get_filename_from_path(self, file_path: str) -> str:
"""Extract filename without extension from path""" """Extract filename without extension from path"""
@@ -157,7 +161,12 @@ class ModelHashIndex:
del self._duplicate_filenames[filename] del self._duplicate_filenames[filename]
if filename in self._filename_to_hash: if filename in self._filename_to_hash:
del self._filename_to_hash[filename] del self._filename_to_hash[filename]
# Remove from AutoV2 index
autov2_keys_to_remove = [k for k, v in self._autov2_to_path.items() if v == file_path]
for k in autov2_keys_to_remove:
del self._autov2_to_path[k]
def remove_by_hash(self, sha256: str) -> None: def remove_by_hash(self, sha256: str) -> None:
"""Remove entry by hash""" """Remove entry by hash"""
sha256 = sha256.lower() sha256 = sha256.lower()
@@ -177,6 +186,10 @@ class ModelHashIndex:
# Remove hash-to-path mapping # Remove hash-to-path mapping
del self._hash_to_path[sha256] del self._hash_to_path[sha256]
autov2_key = sha256[:10]
if autov2_key in self._autov2_to_path:
del self._autov2_to_path[autov2_key]
# Update filename-to-hash and duplicate filenames for all paths # Update filename-to-hash and duplicate filenames for all paths
for path_to_remove in paths_to_remove: for path_to_remove in paths_to_remove:
fname = self._get_filename_from_path(path_to_remove) fname = self._get_filename_from_path(path_to_remove)
@@ -195,13 +208,24 @@ class ModelHashIndex:
# If only one entry remains, it's no longer a duplicate # If only one entry remains, it's no longer a duplicate
del self._duplicate_filenames[fname] del self._duplicate_filenames[fname]
def has_hash(self, sha256: str) -> bool: def has_hash(self, hash_value: str) -> bool:
"""Check if hash exists in index""" """Check if hash exists in index (SHA256 or AutoV2)"""
return sha256.lower() in self._hash_to_path normalized = hash_value.lower()
if normalized in self._hash_to_path:
def get_path(self, sha256: str) -> Optional[str]: return True
"""Get file path for a hash""" if len(normalized) == 10:
return self._hash_to_path.get(sha256.lower()) return normalized in self._autov2_to_path
return False
def get_path(self, hash_value: str) -> Optional[str]:
"""Get file path for a hash (SHA256 or AutoV2)"""
normalized = hash_value.lower()
path = self._hash_to_path.get(normalized)
if path is not None:
return path
if len(normalized) == 10:
return self._autov2_to_path.get(normalized)
return None
def get_hash(self, file_path: str) -> Optional[str]: def get_hash(self, file_path: str) -> Optional[str]:
"""Get hash for a file path""" """Get hash for a file path"""
@@ -218,6 +242,7 @@ class ModelHashIndex:
"""Clear all entries""" """Clear all entries"""
self._hash_to_path.clear() self._hash_to_path.clear()
self._filename_to_hash.clear() self._filename_to_hash.clear()
self._autov2_to_path.clear()
self._duplicate_hashes.clear() self._duplicate_hashes.clear()
self._duplicate_filenames.clear() self._duplicate_filenames.clear()

View File

@@ -5,7 +5,7 @@ import logging
import random import random
from typing import Optional, Dict, Tuple, Any, List, Sequence from typing import Optional, Dict, Tuple, Any, List, Sequence
from .downloader import get_downloader from .downloader import get_downloader
from .errors import RateLimitError from .errors import RateLimitError, ResourceNotFoundError
try: try:
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -482,6 +482,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
return None, "Model not found" return None, "Model not found"
async def get_model_versions(self, model_id: str) -> Optional[Dict]: async def get_model_versions(self, model_id: str) -> Optional[Dict]:
not_found_confirmed = False
for provider, label in self._iter_providers(): for provider, label in self._iter_providers():
try: try:
result = await self._call_with_rate_limit( result = await self._call_with_rate_limit(
@@ -492,8 +493,24 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result: if result:
return result return result
except RateLimitError as exc: except RateLimitError as exc:
if not_found_confirmed:
logger.debug(
"Suppressing rate limit from %s for model %s: "
"already confirmed as not found by another provider",
label,
model_id,
)
return None
exc.provider = exc.provider or label exc.provider = exc.provider or label
raise exc raise exc
except ResourceNotFoundError:
not_found_confirmed = True
logger.debug(
"Provider %s reports model %s as not found",
label,
model_id,
)
continue
except Exception as e: except Exception as e:
logger.debug("Provider %s failed for get_model_versions: %s", label, e) logger.debug("Provider %s failed for get_model_versions: %s", label, e)
continue continue

View File

@@ -65,7 +65,7 @@ class RecipeScanner:
cls._instance._civitai_client = None # Will be lazily initialized cls._instance._civitai_client = None # Will be lazily initialized
return cls._instance return cls._instance
REPAIR_VERSION = 3 REPAIR_VERSION = 4
def __init__( def __init__(
self, self,
@@ -292,6 +292,32 @@ class RecipeScanner:
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION: if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
return False return False
# 1.5 Detect and clear corrupted checkpoint (LoRA data saved as checkpoint).
# A checkpoint whose modelVersionId also appears in a LoRA entry is
# definitely wrong — the CivitAI import code used to pick
# modelVersionIds[0] as the checkpoint, which was often a LoRA.
# Clearing it lets the enrichment flow re-resolve the correct
# checkpoint from CivitAI image metadata.
cp = recipe.get("checkpoint")
lora_mvids = {
l.get("modelVersionId")
for l in recipe.get("loras", [])
if l.get("modelVersionId")
}
if cp and cp.get("modelVersionId") and cp["modelVersionId"] in lora_mvids:
cp_mvid = cp["modelVersionId"]
logger.info(
"Recipe %s: checkpoint modelVersionId %s matches a LoRA — "
"clearing corrupted checkpoint and removing matching LoRA entry",
recipe.get("id"),
cp_mvid,
)
recipe["checkpoint"] = None
recipe["loras"] = [
l for l in recipe.get("loras", [])
if l.get("modelVersionId") != cp_mvid
]
# 2. Identification: Is repair needed? # 2. Identification: Is repair needed?
has_checkpoint = ( has_checkpoint = (
"checkpoint" in recipe "checkpoint" in recipe

View File

@@ -397,13 +397,12 @@ class DownloadManager:
models_with_hash = len(all_models_with_hash) models_with_hash = len(all_models_with_hash)
# Calculate pending count: check which models actually need processing # Calculate pending count: check which models actually need processing.
# A model is pending if it has a hash, is not in processed_models, # A model is pending if it has a hash, is not already processed or known-failed,
# and its folder doesn't exist or is empty # and its folder doesn't exist or is empty.
pending_hashes = set() pending_hashes = set()
for model_hash, model_name in all_models_with_hash: for model_hash, model_name in all_models_with_hash:
if model_hash not in processed_models: if model_hash not in processed_models and model_hash not in failed_models:
# Check if model folder exists with files
model_dir = ExampleImagePathResolver.get_model_folder( model_dir = ExampleImagePathResolver.get_model_folder(
model_hash, active_library model_hash, active_library
) )

View File

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

View File

@@ -10,13 +10,14 @@
"C:/path/to/your/checkpoints_folder", "C:/path/to/your/checkpoints_folder",
"C:/path/to/another/checkpoints_folder" "C:/path/to/another/checkpoints_folder"
], ],
"unet": [
"C:/path/to/your/diffusion_models_folder",
"C:/path/to/another/diffusion_models_folder"
],
"embeddings": [ "embeddings": [
"C:/path/to/your/embeddings_folder", "C:/path/to/your/embeddings_folder",
"C:/path/to/another/embeddings_folder" "C:/path/to/another/embeddings_folder"
] ]
}, },
"example_images_open_mode": "system",
"example_images_local_root": "",
"example_images_open_uri_template": "",
"auto_organize_exclusions": [] "auto_organize_exclusions": []
} }

View File

@@ -15,6 +15,7 @@ const RECIPE_ENDPOINTS = {
move: '/api/lm/recipe/move', move: '/api/lm/recipe/move',
moveBulk: '/api/lm/recipes/move-bulk', moveBulk: '/api/lm/recipes/move-bulk',
bulkDelete: '/api/lm/recipes/bulk-delete', bulkDelete: '/api/lm/recipes/bulk-delete',
repairBulk: '/api/lm/recipes/repair-bulk',
}; };
const RECIPE_SIDEBAR_CONFIG = { const RECIPE_SIDEBAR_CONFIG = {
@@ -557,6 +558,38 @@ export class RecipeSidebarApiClient {
}; };
} }
async repairBulkModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
throw new Error('No recipe IDs could be derived from file paths');
}
const response = await fetch(this.apiConfig.endpoints.repairBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to repair recipes');
}
return result;
}
async bulkDeleteModels(filePaths) { async bulkDeleteModels(filePaths) {
if (!filePaths || filePaths.length === 0) { if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided'); throw new Error('No file paths provided');

View File

@@ -41,6 +41,11 @@ export class BulkContextMenu extends BaseContextMenu {
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]'); const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]'); const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]'); const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]');
if (repairMetadataItem) {
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
}
if (sendToWorkflowAppendItem) { if (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none'; sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
@@ -127,33 +132,38 @@ export class BulkContextMenu extends BaseContextMenu {
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]'); const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
if (skipMetadataRefreshItem && resumeMetadataRefreshItem) { if (skipMetadataRefreshItem && resumeMetadataRefreshItem) {
const skipCount = this.countSkipStatus(true); if (!config.skipMetadataRefresh) {
const resumeCount = this.countSkipStatus(false);
const totalCount = skipCount + resumeCount;
if (skipCount === totalCount) {
skipMetadataRefreshItem.style.display = 'none'; skipMetadataRefreshItem.style.display = 'none';
resumeMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.resumeMetadataRefresh'
);
} else if (resumeCount === totalCount) {
skipMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.style.display = 'none'; resumeMetadataRefreshItem.style.display = 'none';
skipMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.skipMetadataRefresh'
);
} else { } else {
skipMetadataRefreshItem.style.display = 'flex'; const skipCount = this.countSkipStatus(true);
resumeMetadataRefreshItem.style.display = 'flex'; const resumeCount = this.countSkipStatus(false);
skipMetadataRefreshItem.querySelector('span').textContent = translate( const totalCount = skipCount + resumeCount;
'loras.bulkOperations.skipMetadataRefreshCount',
{ count: resumeCount } if (skipCount === totalCount) {
); skipMetadataRefreshItem.style.display = 'none';
resumeMetadataRefreshItem.querySelector('span').textContent = translate( resumeMetadataRefreshItem.style.display = 'flex';
'loras.bulkOperations.resumeMetadataRefreshCount', resumeMetadataRefreshItem.querySelector('span').textContent = translate(
{ count: skipCount } 'loras.bulkOperations.resumeMetadataRefresh'
); );
} else if (resumeCount === totalCount) {
skipMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.style.display = 'none';
skipMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.skipMetadataRefresh'
);
} else {
skipMetadataRefreshItem.style.display = 'flex';
resumeMetadataRefreshItem.style.display = 'flex';
skipMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.skipMetadataRefreshCount',
{ count: resumeCount }
);
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
'loras.bulkOperations.resumeMetadataRefreshCount',
{ count: skipCount }
);
}
} }
} }
@@ -251,6 +261,9 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all': case 'delete-all':
bulkManager.showBulkDeleteModal(); bulkManager.showBulkDeleteModal();
break; break;
case 'repair-metadata':
bulkManager.repairSelectedRecipes();
break;
case 'set-favorite': { case 'set-favorite': {
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size; const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
bulkManager.setBulkFavorites(!allFavorited); bulkManager.setBulkFavorites(!allFavorited);

View File

@@ -85,7 +85,8 @@ export class BulkManager {
setContentRating: false, setContentRating: false,
skipMetadataRefresh: false, skipMetadataRefresh: false,
setFavorite: true, setFavorite: true,
unfavorite: true unfavorite: true,
repairMetadata: true
} }
}; };
@@ -656,6 +657,76 @@ export class BulkManager {
} }
} }
async repairSelectedRecipes() {
if (state.selectedModels.size === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
if (state.currentPageType !== 'recipes') {
showToast('This operation is only available for recipes', {}, 'warning');
return;
}
try {
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
if (typeof apiClient.repairBulkModels !== 'function') {
showToast('Bulk repair is not supported for this model type', {}, 'error');
return;
}
state.loadingManager.showSimpleLoading('Repairing recipe metadata...');
const result = await apiClient.repairBulkModels(filePaths);
if (result.success) {
const total = result.total || filePaths.length;
const repaired = result.repaired || 0;
const skipped = result.skipped || 0;
const recipes = result.recipes || [];
for (const recipe of recipes) {
if (recipe.file_path) {
state.virtualScroller.updateSingleItem(
recipe.file_path,
recipe
);
}
}
if (repaired > 0) {
showToast(
'toast.recipes.repairBulkComplete',
{ repaired, skipped, total },
'success'
);
} else {
showToast(
'toast.recipes.repairBulkSkipped',
{ total },
'info'
);
}
this.clearSelection();
} else {
throw new Error(result.error || 'Bulk repair failed');
}
} catch (error) {
console.error('Error during bulk recipe repair:', error);
showToast('toast.recipes.repairBulkFailed', { message: error.message }, 'error');
} finally {
if (state.loadingManager?.hide) {
state.loadingManager.hide();
}
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
state.loadingManager.restoreProgressBar();
}
}
}
async refreshAllMetadata() { async refreshAllMetadata() {
if (state.selectedModels.size === 0) { if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning'); showToast('toast.models.noModelsSelected', {}, 'warning');

View File

@@ -731,9 +731,16 @@ export class UpdateService {
} }
// Simple markdown parser for changelog items // Simple markdown parser for changelog items
// Simple markdown parser for changelog items
// Escape HTML entities first so angle brackets in content (e.g. `<lora:x>`)
// aren't swallowed by innerHTML's HTML parser as invalid tags
parseMarkdown(text) { parseMarkdown(text) {
if (!text) return ''; if (!text) return '';
text = text.replace(/&/g, '&amp;');
text = text.replace(/</g, '&lt;');
text = text.replace(/>/g, '&gt;');
// Handle bold text (**text**) // Handle bold text (**text**)
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');

View File

@@ -80,6 +80,9 @@
<div class="context-menu-item" data-action="check-updates"> <div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span> <i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
</div> </div>
<div class="context-menu-item" data-action="repair-metadata">
<i class="fas fa-tools"></i> <span>{{ t('loras.bulkOperations.repairMetadata') }}</span>
</div>
<div class="context-menu-item" data-action="skip-metadata-refresh"> <div class="context-menu-item" data-action="skip-metadata-refresh">
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span> <i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
</div> </div>

View File

@@ -467,7 +467,10 @@ async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
class Provider: class Provider:
async def get_model_version_info(self, model_version_id): async def get_model_version_info(self, model_version_id):
provider_calls.append(model_version_id) provider_calls.append(model_version_id)
return {"baseModel": "Flux Provider"}, None return {
"baseModel": "Flux Provider",
"model": {"type": "Checkpoint", "name": "Flux"},
}, None
async def fake_get_default_metadata_provider(): async def fake_get_default_metadata_provider():
return Provider() return Provider()

View File

@@ -298,3 +298,113 @@ async def test_parse_metadata_handles_modelVersionIds(monkeypatch):
assert lora2["type"] == "lora" assert lora2["type"] == "lora"
assert lora2["hash"] == "aabbccdd0022" assert lora2["hash"] == "aabbccdd0022"
assert lora2["baseModel"] == "SDXL" assert lora2["baseModel"] == "SDXL"
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_resources_model_type(monkeypatch):
"""resources entries with type:"model" should be captured as the checkpoint,
not skipped (which was the old buggy behavior), and not mixed into loras."""
captured_hashes = []
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
captured_hashes.append(model_hash)
if model_hash == "a1b2c3d4e5":
return ({
"id": 999,
"modelId": 888,
"name": "v1.0",
"model": {"name": "Real Checkpoint", "type": "Checkpoint"},
"baseModel": "SDXL 1.0",
"images": [{"url": "https://image.civitai.com/cp/original=true"}],
"files": [{"type": "Model", "primary": True, "sizeKB": 1024, "name": "cp.safetensors"}]
}, None)
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
fake_metadata_provider,
)
parser = CivitaiApiMetadataParser()
metadata = {
"prompt": "test",
"resources": [
{"hash": "a1b2c3d4e5", "name": "Real Checkpoint", "type": "model"},
{"hash": "f6g7h8i9j0", "name": "Some LoRA", "type": "lora", "weight": 0.8},
],
"Model hash": "a1b2c3d4e5",
}
result = await parser.parse_metadata(metadata)
# The type:"model" resource should be in result["model"], not in result["loras"]
assert result["model"] is not None, "checkpoint model should be extracted"
assert result["model"]["name"] == "Real Checkpoint"
assert result["model"]["hash"] == "a1b2c3d4e5"
assert result["model"]["type"] == "model"
# The LoRA resource should be in result["loras"]
assert len(result["loras"]) == 1
assert result["loras"][0]["name"] == "Some LoRA"
# The checkpoint hash should have triggered a lookup
assert "a1b2c3d4e5" in captured_hashes
@pytest.mark.asyncio
async def test_parse_metadata_resources_model_type_does_not_duplicate_checkpoint_in_loras(monkeypatch):
"""When a resources entry has type:"model", it should NOT also appear in loras.
Regression test for the bug where the checkpoint model appeared in both places."""
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
if model_hash == "cp123hash":
return ({
"id": 100,
"modelId": 200,
"name": "v2",
"model": {"name": "My Checkpoint", "type": "Checkpoint"},
"baseModel": "SDXL",
"files": [{"type": "Model", "primary": True, "sizeKB": 1024, "name": "cp.safetensors"}]
}, None)
if model_hash == "lora1hash":
return ({
"id": 300,
"modelId": 400,
"name": "v1",
"model": {"name": "Style LoRA", "type": "LORA"},
"baseModel": "SDXL",
"files": [{"type": "Model", "primary": True, "sizeKB": 512, "name": "style.safetensors"}]
}, None)
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
fake_metadata_provider,
)
parser = CivitaiApiMetadataParser()
metadata = {
"resources": [
{"hash": "cp123hash", "name": "My Checkpoint", "type": "model"},
{"hash": "lora1hash", "name": "Style LoRA", "type": "lora", "weight": 0.5},
],
}
result = await parser.parse_metadata(metadata)
# Checkpoint must NOT appear in loras
lora_names = {l["name"] for l in result["loras"]}
assert "My Checkpoint" not in lora_names
assert "Style LoRA" in lora_names
# Checkpoint must be in result["model"]
assert result["model"] is not None
assert result["model"]["name"] == "My Checkpoint"

View File

@@ -94,7 +94,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
"id": 5678, "id": 5678,
"modelId": 1234, "modelId": 1234,
"name": "v1.0", "name": "v1.0",
"model": {"name": "Full Model Name"}, "model": {"name": "Full Model Name", "type": "Checkpoint"},
"baseModel": "SDXL 1.0", "baseModel": "SDXL 1.0",
"images": [{"url": "https://image.url/thumb.jpg"}], "images": [{"url": "https://image.url/thumb.jpg"}],
"files": [{"type": "Model", "hashes": {"SHA256": "ABCDEF"}, "name": "full_filename.safetensors"}] "files": [{"type": "Model", "hashes": {"SHA256": "ABCDEF"}, "name": "full_filename.safetensors"}]
@@ -142,7 +142,7 @@ async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner)
"id": 5678, "id": 5678,
"modelId": 1234, "modelId": 1234,
"name": "v1.0", "name": "v1.0",
"model": {"name": "Full Model Name"}, "model": {"name": "Full Model Name", "type": "Checkpoint"},
"baseModel": "SDXL 1.0", "baseModel": "SDXL 1.0",
"images": [{"url": "https://image.url/thumb.jpg"}], "images": [{"url": "https://image.url/thumb.jpg"}],
"files": [ "files": [
@@ -183,7 +183,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
"id": 999, "id": 999,
"modelId": 888, "modelId": 888,
"name": "v2.0", "name": "v2.0",
"model": {"name": "Hashed Model"}, "model": {"name": "Hashed Model", "type": "Checkpoint"},
"baseModel": "SD 1.5", "baseModel": "SD 1.5",
"files": [{"type": "Model", "hashes": {"SHA256": "hash123"}, "name": "hashed.safetensors"}] "files": [{"type": "Model", "hashes": {"SHA256": "hash123"}, "name": "hashed.safetensors"}]
}, None) }, None)