Compare commits

..

21 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
Will Miao
24e2909627 chore(release): bump version to v1.0.8 2026-05-26 21:27:29 +08:00
Will Miao
b768f1368f fix(i18n): update aria2 annotation from experimental to recommended across all locales 2026-05-26 20:22:25 +08:00
Will Miao
37ccd29fc0 feat(modal): make version name editable in model modal (#931) 2026-05-26 20:16:35 +08:00
36 changed files with 1083 additions and 321 deletions

File diff suppressed because one or more lines are too long

View File

@@ -15,227 +15,233 @@
"Phil", "Phil",
"Carl G.", "Carl G.",
"Arlecchino Shion", "Arlecchino Shion",
"$MetaSamsara", "Charles Blakemore",
"Rob Williams", "Rob Williams",
"$MetaSamsara",
"stone9k", "stone9k",
"Rosenthal",
"Francisco Tatis",
"JongWon Han",
"runte3221", "runte3221",
"FreelancerZ",
"Fraser Cross",
"Polymorphic Indeterminate",
"Marc Whiffen",
"Skalabananen",
"Birdy",
"Kiba", "Kiba",
"Mozzel", "Mozzel",
"itismyelement", "itismyelement",
"Gingko Biloba", "Gingko Biloba",
"Reno Lam",
"onesecondinosaur", "onesecondinosaur",
"sig",
"Christian Byrne", "Christian Byrne",
"DM", "DM",
"Sen314", "Sen314",
"Estragon", "Estragon",
"J\\B/ 8r0wns0n",
"Takkan", "Takkan",
"Charles Blakemore",
"Rosenthal",
"ClockDaemon", "ClockDaemon",
"Francisco Tatis", "KD",
"Omnidex",
"Tyler Trebuchon",
"Release Cabrakan",
"Tobi_Swagg", "Tobi_Swagg",
"SG", "SG",
"James Dooley",
"zenbound",
"Buzzard",
"jmack", "jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Mark Corneglio",
"SarcasticHashtag",
"iamresist", "iamresist",
"Wolffen", "Wolffen",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "James Todd",
"Steven Pfeiffer",
"VantAI", "VantAI",
"Tim", "Tim",
"Lisster",
"Michael Wong", "Michael Wong",
"Illrigger", "Illrigger",
"Tom Corrigan", "Tom Corrigan",
"JackieWang", "JackieWang",
"FreelancerZ",
"fnkylove", "fnkylove",
"Yushio",
"Vik71it",
"Echo", "Echo",
"Lilleman", "Lilleman",
"Robert Stacey", "Robert Stacey",
"PM", "PM",
"Todd Keck",
"Edgar Tejeda", "Edgar Tejeda",
"Jorge Hussni", "Jorge Hussni",
"Liam MacDougal", "Liam MacDougal",
"Sterilized", "Sterilized",
"Fraser Cross", "BadassArabianMofo",
"Polymorphic Indeterminate",
"Marc Whiffen",
"Birdy",
"Skalabananen",
"quarz", "quarz",
"Reno Lam", "Greg",
"JSST", "JSST",
"sig",
"J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"lmsupporter",
"wfpearl",
"Baekdoosixt", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
"KD", "Jack B Nimble",
"Omnidex",
"Nazono_hito", "Nazono_hito",
"Melville Parrish", "Melville Parrish",
"daniel dove", "daniel dove",
"Lustre", "Lustre",
"Tyler Trebuchon",
"Release Cabrakan",
"JW Sin", "JW Sin",
"contrite831", "contrite831",
"Alex", "Alex",
"bh",
"carozzz", "carozzz",
"Marlon Daniels", "Marlon Daniels",
"James Dooley", "Starkselle",
"zenbound", "Aaron Bleuer",
"Buzzard", "LacesOut!",
"greebles",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag",
"Anthony Rizzo", "Anthony Rizzo",
"M Postkasse",
"Gooohokrbe", "Gooohokrbe",
"RedrockVP", "RedrockVP",
"James Todd", "Wicked Choices by ASLPro3D",
"ASLPro3D",
"OldBones", "OldBones",
"Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Steven Pfeiffer", "Weasyl",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Cory Paza",
"Tak", "Tak",
"Lisster",
"Zach Gonser", "Zach Gonser",
"Big Red", "Big Red",
"whudunit", "whudunit",
"Luc Job", "Luc Job",
"dl0901dm", "dl0901dm",
"Philip Hempel",
"corde", "corde",
"Nick Walker", "Nick Walker",
"Yushio",
"Vik71it",
"Bishoujoker", "Bishoujoker",
"Todd Keck", "aai",
"Briton Heilbrun", "Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
"jean jahren", "jean jahren",
"Aleksander Wujczyk", "Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"Penfore", "Penfore",
"Greg", "Sangheili460",
"MagnaInsomnia", "MagnaInsomnia",
"Karl P.",
"Akira_HentAI", "Akira_HentAI",
"Gordon Cole", "Gordon Cole",
"AbstractAss", "AbstractAss",
"lmsupporter",
"andrew.tappan", "andrew.tappan",
"N/A", "N/A",
"The Spawn",
"graysock",
"Greenmoustache", "Greenmoustache",
"zounic", "zounic",
"wfpearl", "fancypants",
"Eldithor", "Eldithor",
"Jack B Nimble", "Digital",
"JaxMax", "JaxMax",
"bh", "takyamtom",
"Jwk0205", "Jwk0205",
"Starkselle", "Bro Xie",
"batblue",
"carey6409",
"Olive", "Olive",
"Aaron Bleuer", "太郎 ゲーム",
"LacesOut!",
"greebles",
"Some Guy Named Barry", "Some Guy Named Barry",
"Cosmosis", "Cosmosis",
"M Postkasse", "AELOX",
"Nicfit23",
"FloPro4Sho", "FloPro4Sho",
"wamekukyouzin", "wamekukyouzin",
"Jacob Hoehler", "drum matthieu",
"Dogmaster",
"Matt Wenzel", "Matt Wenzel",
"Weasyl",
"Lex Song", "Lex Song",
"Cory Paza", "Christopher Michel",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Serge Bekenkamp", "Serge Bekenkamp",
"Jimmy Ledbetter", "Jimmy Ledbetter",
"Philip Hempel", "LeoZero",
"Antonio Pontes",
"ApathyJones", "ApathyJones",
"Julian V", "Julian V",
"Steven Owens", "Steven Owens",
"nahinahi9",
"Dustin Chen",
"dan", "dan",
"aai",
"Mouthlessman", "Mouthlessman",
"otaku fra", "otaku fra",
"ViperC", "ViperC",
"Ran C", "Ran C",
"MiraiKuriyamaSy", "MiraiKuriyamaSy",
"Sangheili460",
"Karl P.",
"yuxz69", "yuxz69",
"Adam Taylor", "Adam Taylor",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"esthe", "esthe",
"The Spawn",
"graysock",
"Pozadine1", "Pozadine1",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"fancypants", "elu3199",
"Hasturkun",
"Jon Sandman",
"Ubivis",
"CloudValley",
"linnfrey",
"IamAyam", "IamAyam",
"skaterb949",
"Joboshy", "Joboshy",
"Digital",
"takyamtom",
"Bohemian Corporal", "Bohemian Corporal",
"Dan", "Dan",
"confiscated Zyra", "confiscated Zyra",
"Bro Xie",
"yer fey", "yer fey",
"batblue", "Error_Rule34_Not_found",
"carey6409",
"太郎 ゲーム",
"Roslynd", "Roslynd",
"Tee Gee", "Tee Gee",
"jinxedx", "jinxedx",
"tarek helmi", "tarek helmi",
"Neco28", "Neco28",
"Max Marklund", "Max Marklund",
"AELOX", "David Ortega",
"Dankin", "Dankin",
"Nicfit23",
"Cristian Vazquez", "Cristian Vazquez",
"drum matthieu",
"Dogmaster",
"Frank Nitty", "Frank Nitty",
"Magic Noob", "Magic Noob",
"Pronredn", "Pronredn",
"Christopher Michel",
"DougPeterson", "DougPeterson",
"LeoZero", "Jeff",
"Antonio Pontes",
"Bruce", "Bruce",
"nahinahi9",
"lh qwe", "lh qwe",
"Kevin John Duck", "Kevin John Duck",
"conner", "conner",
"Dustin Chen", "Kevin Christopher",
"Blackfish95", "Blackfish95",
"dd",
"Princess Bright Eyes", "Princess Bright Eyes",
"Paul Kroll", "Paul Kroll",
"Felipe dos Santos", "Felipe dos Santos",
"Bas Imagineer", "Bas Imagineer",
"Markus",
"John Statham", "John Statham",
"Douglas Gaspar", "Douglas Gaspar",
"AlexDuKaNa", "AlexDuKaNa",
"George", "George",
"dw", "dw",
"decoy", "decoy",
"elu3199",
"Hasturkun",
"Jon Sandman",
"Ubivis",
"CloudValley",
"thesoftwaredruid", "thesoftwaredruid",
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
@@ -243,61 +249,65 @@
"Ray Wing", "Ray Wing",
"Ranzitho", "Ranzitho",
"Gus", "Gus",
"地獄の禄",
"MJG", "MJG",
"David LaVallee", "David LaVallee",
"linnfrey",
"奚明 刘",
"Josef Lanzl",
"Nerezza",
"sanborondon",
"Griffin Dahlberg",
"준희 김",
"Error_Rule34_Not_found",
"Taylor Funk",
"aezin",
"jcay015",
"Gerald Welly",
"Erik Lopez",
"Mateo Curić",
"Geolog",
"Eris3D",
"Tomohiro Baba",
"David Ortega",
"Noora",
"Mattssn",
"a _",
"Jeff",
"James Coleman",
"Kevin Christopher",
"Emil Andersson",
"Ouro Boros",
"Chad Idk",
"dd",
"Steam Steam",
"CryptoTraderJK",
"Davaitamin",
"Dušan Ryban",
"tedcor",
"Sam",
"Fotek Design",
"sjon kreutz",
"MadSpin",
"Metryman55",
"inbijiburu",
"Nick “Loadstone” D",
"地獄の禄",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"Gamalonia",
"WRL_SPR", "WRL_SPR",
"capn", "capn",
"Joseph", "Joseph",
"momokai",
"Mirko Katzula", "Mirko Katzula",
"dan", "dan",
"Piccio08", "Piccio08",
"kumakichi", "kumakichi",
"cppbel", "cppbel",
"奚明 刘",
"Brian M",
"Josef Lanzl",
"Nerezza",
"sanborondon",
"준희 김",
"Taylor Funk",
"aezin",
"Thought2Form",
"jcay015",
"Gerald Welly",
"Kevin Picco",
"Erik Lopez",
"Mateo Curić",
"Geolog",
"Eris3D",
"Tomohiro Baba",
"m",
"Noora",
"Pierce McBride",
"Mattssn",
"Mikko Hemilä",
"Jamie Ogletree",
"a _",
"James Coleman",
"Martial",
"Emil Andersson",
"Ouro Boros",
"Chad Idk",
"Steam Steam",
"CryptoTraderJK",
"Yuji Kaneko",
"Davaitamin",
"Dušan Ryban",
"Rops Alot",
"tedcor",
"Sam",
"Fotek Design",
"sjon kreutz",
"Ace Ventura",
"MadSpin",
"Metryman55",
"inbijiburu",
"Nick “Loadstone” D",
"Gamalonia",
"momokai",
"starbugx", "starbugx",
"Moon Knight", "Moon Knight",
"몽타주", "몽타주",
@@ -306,59 +316,13 @@
"kudari", "kudari",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"dc7431", "dc7431",
"ken",
"epicgamer0020690", "epicgamer0020690",
"Joshua Porrata", "Joshua Porrata",
"keemun",
"SuBu", "SuBu",
"RedPIXel", "RedPIXel",
"Vir",
"Richard",
"Andrew",
"Brian M",
"Robert Wegemund",
"Littlehuggy",
"Draven T",
"mrjuan",
"Brian Buie",
"Thought2Form",
"Kevin Picco",
"Sadlip",
"Aquatic Coffee",
"m",
"ethanfel",
"Pierce McBride",
"Joshua Gray",
"Focuschannel",
"Mikko Hemilä",
"Jacob McDaniel",
"Jamie Ogletree",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"Martial",
"Anthony Faxlandez",
"battu",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Decx _",
"Yuji Kaneko",
"Pat Hen",
"Jordan Shaw",
"Rops Alot",
"Thesharingbrother",
"Ace Ventura",
"ResidentDeviant",
"四糸凜音",
"Nihongasuki",
"JC",
"Prompt Pirate",
"uwutismxd",
"zenobeus",
"ken",
"Crocket",
"keemun",
"Wind", "Wind",
"Jackthemind",
"Nexus", "Nexus",
"Ramneek“Guy”Ashok", "Ramneek“Guy”Ashok",
"squid_actually", "squid_actually",
@@ -369,6 +333,50 @@
"JohnDoe42054", "JohnDoe42054",
"BillyHill", "BillyHill",
"emyth", "emyth",
"Vir",
"gzmzmvp",
"Richard",
"Andrew",
"Robert Wegemund",
"Littlehuggy",
"Gregory Kozhemiak",
"Draven T",
"mrjuan",
"Brian Buie",
"Sadlip",
"Eric Whitney",
"Joey Callahan",
"Aquatic Coffee",
"Ivan Tadic",
"Mike Simone",
"ethanfel",
"Joshua Gray",
"Morgandel",
"Focuschannel",
"Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"Anthony Faxlandez",
"battu",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Decx _",
"Pat Hen",
"Jordan Shaw",
"四糸凜音",
"Nihongasuki",
"JC",
"Prompt Pirate",
"uwutismxd",
"FrxzenSnxw",
"zenobeus",
"Crocket",
"Jackthemind",
"chriphost", "chriphost",
"KitKatM", "KitKatM",
"ryoma", "ryoma",
@@ -388,43 +396,53 @@
"Menard", "Menard",
"Skyfire83", "Skyfire83",
"Adam Rinehart", "Adam Rinehart",
"gzmzmvp", "Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude",
"g9p0o",
"TheHolySheep",
"raf8osz", "raf8osz",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"ElitaSSJ4", "ElitaSSJ4",
"Wolfe7D1",
"blikkies", "blikkies",
"Chris", "Chris",
"Gregory Kozhemiak", "elleshar666",
"Shock Shockor", "Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe",
"Goldwaters", "Goldwaters",
"Eric Whitney", "Kauffy",
"Joey Callahan",
"Zude", "Zude",
"Ivan Tadic",
"Mike Simone",
"John J Linehan", "John J Linehan",
"Kyler", "Kyler",
"Elliot E", "Elliot E",
"Morgandel",
"Theerat Jiramate", "Theerat Jiramate",
"Edward Kennedy",
"Justin Blaylock",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Noah", "Vane Holzer",
"X", "psytrax",
"Sloan Steddy",
"hexxish", "hexxish",
"DarkSunset", "notedfakes",
"Nathan", "Nathan",
"Billy Gladky", "Billy Gladky",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"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",
"FrxzenSnxw",
"BossGame", "BossGame",
"lrdchs", "lrdchs",
"Tree Tagger", "Tree Tagger",
@@ -437,17 +455,13 @@
"Ginnie", "Ginnie",
"Raku", "Raku",
"emadsultan", "emadsultan",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"SomeDude",
"g9p0o",
"Pkrsky", "Pkrsky",
"TheHolySheep", "nanana",
"Monte Won", "FeralOpticsAI",
"SpringBootisTrash", "Pavlaki",
"carsten", "Doug+Rintoul",
"ikok", "Noor",
"Yorunai",
"quantenmecha", "quantenmecha",
"Jason+Nash", "Jason+Nash",
"BillyBoy84", "BillyBoy84",
@@ -465,43 +479,40 @@
"Welkor", "Welkor",
"David Schenck", "David Schenck",
"John Martin", "John Martin",
"Wolfe7D1",
"Ink Temptation", "Ink Temptation",
"moranqianlong", "moranqianlong",
"Kalli Core", "Kalli Core",
"Time Valentine", "Time Valentine",
"elleshar666",
"ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч", "Михал Михалыч",
"Matt", "Matt",
"Kauffy", "Frogmilk",
"SPJ",
"Kyron Mahan", "Kyron Mahan",
"Edward Kennedy", "Bryan Rutkowski",
"Justin Blaylock",
"Nick Kage", "Nick Kage",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
"Vane Holzer",
"psytrax",
"Cyrus Fett", "Cyrus Fett",
"Ezokewn", "Ezokewn",
"SendingRavens", "SendingRavens",
"Xenon Xue", "Xenon Xue",
"notedfakes", "JackJohnnyJim",
"Edward Ten Eyck",
"Michael Docherty", "Michael Docherty",
"Michael Scott",
"Paul Hartsuyker", "Paul Hartsuyker",
"Henrique Faiolli", "Henrique Faiolli",
"elitassj", "elitassj",
"Solixer", "Solixer",
"Jacob Winter", "Jacob Winter",
"Ryan Presley Ng", "Ryan Presley Ng",
"Wes Sims", "jinksta187",
"Donor4115", "Donor4115",
"Manu Thetug",
"Karlanx",
"Lyavph", "Lyavph",
"David", "David",
"Meilo", "Meilo",
"Filippo Ferrari", "operationancut",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
"MaartenAlbers", "MaartenAlbers",
@@ -509,6 +520,7 @@
"xybrightsummer", "xybrightsummer",
"jreedatchison", "jreedatchison",
"PhilW", "PhilW",
"Marcus thronico",
"Janik", "Janik",
"Cruel", "Cruel",
"MRBlack", "MRBlack",
@@ -519,7 +531,15 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"nanana", "YassineKhaled",
"Y",
"MatteKey",
"Flob",
"ShiroSenpai",
"Inkognito",
"G",
"Tan+Huynh",
"D",
"Dark_Pest", "Dark_Pest",
"Alex", "Alex",
"Jacky+Ho", "Jacky+Ho",
@@ -534,12 +554,7 @@
"rsamerica", "rsamerica",
"sfasdfasfdsa", "sfasdfasfdsa",
"Alan+Cano", "Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"generic404", "generic404",
"Doug+Rintoul",
"Noor",
"Yorunai",
"abattoirblues", "abattoirblues",
"zounik", "zounik",
"4IXplr0r3r", "4IXplr0r3r",
@@ -553,26 +568,27 @@
"ja s", "ja s",
"Doug Mason", "Doug Mason",
"Jeremy Townsend", "Jeremy Townsend",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Locrospiel", "Locrospiel",
"Frogmilk",
"Sean voets", "Sean voets",
"Owen Gwosdz", "Owen Gwosdz",
"SPJ", "Jarrid Lee",
"Kor", "Kor",
"Joseph Hanson", "Joseph Hanson",
"Bryan Rutkowski", "John Rednoulf",
"Boba Smith",
"Devil Lude", "Devil Lude",
"David Murcko", "David Murcko",
"Jack Dole", "Jack Dole",
"max blo", "max blo",
"Sauv",
"Steven", "Steven",
"CptNeo", "CptNeo",
"JackJohnnyJim",
"TenaciousD", "TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
"Khánh Đặng", "Khánh Đặng",
"Maso", "Maso",
"Edward Ten Eyck",
"Eric Ketchum", "Eric Ketchum",
"Kevin Wallace", "Kevin Wallace",
"Jimmy Borup", "Jimmy Borup",
@@ -580,14 +596,10 @@
"mercur", "mercur",
"Pete Pain", "Pete Pain",
"RHopkirk", "RHopkirk",
"jinksta187",
"Andrew Wilkinson", "Andrew Wilkinson",
"Yavizu3d", "Yavizu3d",
"Maxim", "Maxim",
"Manu Thetug",
"Karlanx",
"Yves Poezevara", "Yves Poezevara",
"operationancut",
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
@@ -611,7 +623,6 @@
"pixl", "pixl",
"Robin", "Robin",
"chahknoir", "chahknoir",
"Marcus thronico",
"nd", "nd",
"keno94d", "keno94d",
"James Melzer", "James Melzer",
@@ -625,6 +636,7 @@
"Captain_Swag", "Captain_Swag",
"obkircher", "obkircher",
"gwyar", "gwyar",
"ResidentDeviant",
"D", "D",
"edgecase", "edgecase",
"Neoxena", "Neoxena",
@@ -635,6 +647,19 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Kachac",
"tyrant2811",
"Kevin",
"Rune+Osnes",
"jcx29",
"cloudghost",
"Yongkwan+Lee",
"PoorStudent",
"lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"Drizzly", "Drizzly",
"Sildoren", "Sildoren",
"Darvidous", "Darvidous",
@@ -656,19 +681,10 @@
"low9", "low9",
"Winged", "Winged",
"you+halo9", "you+halo9",
"YassineKhaled",
"YK12",
"MatteKey",
"Flob",
"ShiroSenpai",
"Somebody", "Somebody",
"Inkognito",
"Somebody", "Somebody",
"Gramer+Gumbyte",
"Crescent~San", "Crescent~San",
"Tan+Huynh",
"AiGirlTS", "AiGirlTS",
"D",
"datasl4ve", "datasl4ve",
"Somebody", "Somebody",
"koopa990", "koopa990",
@@ -677,21 +693,26 @@
"Bula", "Bula",
"KUJYAKU", "KUJYAKU",
"Coeur+de+cochon", "Coeur+de+cochon",
"Obsidian.Studios",
"han b", "han b",
"Zomba Mann",
"Nico", "Nico",
"Maximilian Krischan", "Maximilian Krischan",
"Banana Joe", "Banana Joe",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Hans Meier",
"Tú Nguyễn Lý Hoàng", "Tú Nguyễn Lý Hoàng",
"shira1011", "shira1011",
"Michael Eid", "Michael Eid",
"beersandbacon", "beersandbacon",
"Neko Desco",
"Bob barker", "Bob barker",
"Ben D", "Ben D",
"G", "G",
"Ronan Delevacq", "Ronan Delevacq",
"james", "karim ben brik",
"Vinarus",
"Michael Zhu", "Michael Zhu",
"Nemisu", "Nemisu",
"Seraphy", "Seraphy",
@@ -701,30 +722,32 @@
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Jairus Knudsen", "Jairus Knudsen",
"Jarrid Lee",
"Poophead27 Blyat", "Poophead27 Blyat",
"Xan Dionysus", "Xan Dionysus",
"Nathan lee", "Nathan lee",
"Lyle Liston",
"Middo", "Middo",
"Forbidden Atelier", "Forbidden Atelier",
"John Rednoulf", "Thomas Sankowski",
"Spire", "Spire",
"DrB", "DrB",
"AZ Party Oasis", "AZ Party Oasis",
"Adictedtohumping", "Adictedtohumping",
"Boba Smith",
"Towelie", "Towelie",
"Ryan Smith",
"MR.Bear", "MR.Bear",
"matt", "matt",
"dsffsdfsdfsdfsdfsdf", "dsffsdfsdfsdfsdfsdf",
"somethingtosay8", "somethingtosay8",
"Jean-françois SEMA", "Jean-françois SEMA",
"3zS4QNQ4",
"Terminuz",
"Kurt", "Kurt",
"ivistorm", "ivistorm",
"Sauv", "Ivan Imes",
"Faburizu",
"Jack Lawfield",
"jimyjomson", "jimyjomson",
"Borte", "Borte",
"Chase Kwon", "Chase Kwon",
@@ -744,6 +767,7 @@
"hannibal", "hannibal",
"Jo+Example", "Jo+Example",
"BrentBertram", "BrentBertram",
"inusanorthcape",
"Tigon", "Tigon",
"eumelzocker", "eumelzocker",
"dxjaymz", "dxjaymz",
@@ -752,5 +776,5 @@
"Somebody", "Somebody",
"CK" "CK"
], ],
"totalCount": 749 "totalCount": 773
} }

View File

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Download-Backend", "label": "Download-Backend",
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den experimentellen externen Downloader-Prozess.", "help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den empfohlenen externen Downloader-Prozess.",
"options": { "options": {
"python": "Python (integriert)", "python": "Python (integriert)",
"aria2": "aria2 (experimentell)" "aria2": "aria2 (empfohlen)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -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",
@@ -1180,6 +1181,7 @@
"editModelName": "Modellname bearbeiten", "editModelName": "Modellname bearbeiten",
"editFileName": "Dateiname bearbeiten", "editFileName": "Dateiname bearbeiten",
"editBaseModel": "Basis-Modell bearbeiten", "editBaseModel": "Basis-Modell bearbeiten",
"editVersionName": "Versionsname bearbeiten",
"viewOnCivitai": "Auf Civitai anzeigen", "viewOnCivitai": "Auf Civitai anzeigen",
"viewOnCivitaiText": "Auf Civitai anzeigen", "viewOnCivitaiText": "Auf Civitai anzeigen",
"viewCreatorProfile": "Ersteller-Profil anzeigen", "viewCreatorProfile": "Ersteller-Profil anzeigen",
@@ -1692,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

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Download backend", "label": "Download backend",
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the experimental external downloader process.", "help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the recommended external downloader process.",
"options": { "options": {
"python": "Python (built-in)", "python": "Python (built-in)",
"aria2": "aria2 (experimental)" "aria2": "aria2 (recommended)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -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",
@@ -1180,6 +1181,7 @@
"editModelName": "Edit model name", "editModelName": "Edit model name",
"editFileName": "Edit file name", "editFileName": "Edit file name",
"editBaseModel": "Edit base model", "editBaseModel": "Edit base model",
"editVersionName": "Edit version name",
"viewOnCivitai": "View on Civitai", "viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai", "viewOnCivitaiText": "View on Civitai",
"viewCreatorProfile": "View Creator Profile", "viewCreatorProfile": "View Creator Profile",
@@ -1692,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

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Backend de descarga", "label": "Backend de descarga",
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo experimental de descarga.", "help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo recomendado de descarga.",
"options": { "options": {
"python": "Python (integrado)", "python": "Python (integrado)",
"aria2": "aria2 (experimental)" "aria2": "aria2 (recomendado)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -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",
@@ -1180,6 +1181,7 @@
"editModelName": "Editar nombre del modelo", "editModelName": "Editar nombre del modelo",
"editFileName": "Editar nombre de archivo", "editFileName": "Editar nombre de archivo",
"editBaseModel": "Editar modelo base", "editBaseModel": "Editar modelo base",
"editVersionName": "Editar nombre de versión",
"viewOnCivitai": "Ver en Civitai", "viewOnCivitai": "Ver en Civitai",
"viewOnCivitaiText": "Ver en Civitai", "viewOnCivitaiText": "Ver en Civitai",
"viewCreatorProfile": "Ver perfil del creador", "viewCreatorProfile": "Ver perfil del creador",
@@ -1692,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

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Moteur de téléchargement", "label": "Moteur de téléchargement",
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe expérimental de téléchargement.", "help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe recommandé de téléchargement.",
"options": { "options": {
"python": "Python (intégré)", "python": "Python (intégré)",
"aria2": "aria2 (expérimental)" "aria2": "aria2 (recommandé)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -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",
@@ -1180,6 +1181,7 @@
"editModelName": "Modifier le nom du modèle", "editModelName": "Modifier le nom du modèle",
"editFileName": "Modifier le nom de fichier", "editFileName": "Modifier le nom de fichier",
"editBaseModel": "Modifier le modèle de base", "editBaseModel": "Modifier le modèle de base",
"editVersionName": "Modifier le nom de la version",
"viewOnCivitai": "Voir sur Civitai", "viewOnCivitai": "Voir sur Civitai",
"viewOnCivitaiText": "Voir sur Civitai", "viewOnCivitaiText": "Voir sur Civitai",
"viewCreatorProfile": "Voir le profil du créateur", "viewCreatorProfile": "Voir le profil du créateur",
@@ -1692,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

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "מנגנון הורדה", "label": "מנגנון הורדה",
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני ניסיוני.", "help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני מומלץ.",
"options": { "options": {
"python": "Python (מובנה)", "python": "Python (מובנה)",
"aria2": "aria2 (ניסיוני)" "aria2": "aria2 (מומלץ)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -689,6 +689,7 @@
"setContentRating": "הגדר דירוג תוכן לכל המודלים", "setContentRating": "הגדר דירוג תוכן לכל המודלים",
"copyAll": "העתק את כל התחבירים", "copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה", "refreshAll": "רענן את כל המטא-דאטה",
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
"checkUpdates": "בדוק עדכונים לבחירה", "checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה", "moveAll": "העבר הכל לתיקייה",
"autoOrganize": "ארגן אוטומטית נבחרים", "autoOrganize": "ארגן אוטומטית נבחרים",
@@ -1180,6 +1181,7 @@
"editModelName": "ערוך שם מודל", "editModelName": "ערוך שם מודל",
"editFileName": "ערוך שם קובץ", "editFileName": "ערוך שם קובץ",
"editBaseModel": "ערוך מודל בסיס", "editBaseModel": "ערוך מודל בסיס",
"editVersionName": "ערוך שם גרסה",
"viewOnCivitai": "הצג ב-Civitai", "viewOnCivitai": "הצג ב-Civitai",
"viewOnCivitaiText": "הצג ב-Civitai", "viewOnCivitaiText": "הצג ב-Civitai",
"viewCreatorProfile": "הצג פרופיל יוצר", "viewCreatorProfile": "הצג פרופיל יוצר",
@@ -1692,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

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "ダウンロードバックエンド", "label": "ダウンロードバックエンド",
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は実験的な外部ダウンローダープロセスを使用します。", "help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は推奨の外部ダウンローダープロセスを使用します。",
"options": { "options": {
"python": "Python内蔵", "python": "Python内蔵",
"aria2": "aria2実験的" "aria2": "aria2推奨"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -689,6 +689,7 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定", "setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー", "copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新", "refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"checkUpdates": "選択項目の更新を確認", "checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動", "moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行", "autoOrganize": "自動整理を実行",
@@ -1180,6 +1181,7 @@
"editModelName": "モデル名を編集", "editModelName": "モデル名を編集",
"editFileName": "ファイル名を編集", "editFileName": "ファイル名を編集",
"editBaseModel": "ベースモデルを編集", "editBaseModel": "ベースモデルを編集",
"editVersionName": "バージョン名を編集",
"viewOnCivitai": "Civitaiで表示", "viewOnCivitai": "Civitaiで表示",
"viewOnCivitaiText": "Civitaiで表示", "viewOnCivitaiText": "Civitaiで表示",
"viewCreatorProfile": "作成者プロフィールを表示", "viewCreatorProfile": "作成者プロフィールを表示",
@@ -1692,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

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "다운로드 백엔드", "label": "다운로드 백엔드",
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 실험적인 외부 다운로더 프로세스를 사용합니다.", "help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 권장되는 외부 다운로더 프로세스를 사용합니다.",
"options": { "options": {
"python": "Python(내장)", "python": "Python(내장)",
"aria2": "aria2(실험적)" "aria2": "aria2(권장)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -689,6 +689,7 @@
"setContentRating": "모든 모델에 콘텐츠 등급 설정", "setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사", "copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침", "refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"checkUpdates": "선택 항목 업데이트 확인", "checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동", "moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택", "autoOrganize": "자동 정리 선택",
@@ -1180,6 +1181,7 @@
"editModelName": "모델명 편집", "editModelName": "모델명 편집",
"editFileName": "파일명 편집", "editFileName": "파일명 편집",
"editBaseModel": "베이스 모델 편집", "editBaseModel": "베이스 모델 편집",
"editVersionName": "버전명 편집",
"viewOnCivitai": "Civitai에서 보기", "viewOnCivitai": "Civitai에서 보기",
"viewOnCivitaiText": "Civitai에서 보기", "viewOnCivitaiText": "Civitai에서 보기",
"viewCreatorProfile": "제작자 프로필 보기", "viewCreatorProfile": "제작자 프로필 보기",
@@ -1692,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

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "Бэкенд загрузки", "label": "Бэкенд загрузки",
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует экспериментальный внешний процесс загрузки.", "help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует рекомендуемый внешний процесс загрузки.",
"options": { "options": {
"python": "Python (встроенный)", "python": "Python (встроенный)",
"aria2": "aria2 (экспериментальный)" "aria2": "aria2 (рекомендуемый)"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -689,6 +689,7 @@
"setContentRating": "Установить рейтинг контента для всех", "setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис", "copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные", "refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"checkUpdates": "Проверить обновления для выбранных", "checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку", "moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные", "autoOrganize": "Автоматически организовать выбранные",
@@ -1180,6 +1181,7 @@
"editModelName": "Редактировать название модели", "editModelName": "Редактировать название модели",
"editFileName": "Редактировать имя файла", "editFileName": "Редактировать имя файла",
"editBaseModel": "Редактировать базовую модель", "editBaseModel": "Редактировать базовую модель",
"editVersionName": "Редактировать название версии",
"viewOnCivitai": "Посмотреть на Civitai", "viewOnCivitai": "Посмотреть на Civitai",
"viewOnCivitaiText": "Посмотреть на Civitai", "viewOnCivitaiText": "Посмотреть на Civitai",
"viewCreatorProfile": "Посмотреть профиль создателя", "viewCreatorProfile": "Посмотреть профиль создателя",
@@ -1692,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

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "下载后端", "label": "下载后端",
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用实验性的外部下载进程。", "help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用推荐的外部下载进程。",
"options": { "options": {
"python": "Python内置", "python": "Python内置",
"aria2": "aria2实验性" "aria2": "aria2推荐"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -689,6 +689,7 @@
"setContentRating": "为所选中设置内容评级", "setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法", "copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据", "refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"checkUpdates": "检查所选更新", "checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹", "moveAll": "移动所选中到文件夹",
"autoOrganize": "自动整理所选模型", "autoOrganize": "自动整理所选模型",
@@ -1180,6 +1181,7 @@
"editModelName": "编辑模型名称", "editModelName": "编辑模型名称",
"editFileName": "编辑文件名", "editFileName": "编辑文件名",
"editBaseModel": "编辑基础模型", "editBaseModel": "编辑基础模型",
"editVersionName": "编辑版本名称",
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看创作者主页", "viewCreatorProfile": "查看创作者主页",
@@ -1692,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

@@ -269,10 +269,10 @@
}, },
"downloadBackend": { "downloadBackend": {
"label": "下載後端", "label": "下載後端",
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用實驗性的外部下載程序。", "help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用推薦的外部下載程序。",
"options": { "options": {
"python": "Python內建", "python": "Python內建",
"aria2": "aria2實驗性" "aria2": "aria2推薦"
} }
}, },
"aria2cPath": { "aria2cPath": {
@@ -689,6 +689,7 @@
"setContentRating": "為全部設定內容分級", "setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法", "copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata", "refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"checkUpdates": "檢查所選更新", "checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾", "moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型", "autoOrganize": "自動整理所選模型",
@@ -1180,6 +1181,7 @@
"editModelName": "編輯模型名稱", "editModelName": "編輯模型名稱",
"editFileName": "編輯檔案名稱", "editFileName": "編輯檔案名稱",
"editBaseModel": "編輯基礎模型", "editBaseModel": "編輯基礎模型",
"editVersionName": "編輯版本名稱",
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看創作者個人檔案", "viewCreatorProfile": "查看創作者個人檔案",
@@ -1692,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/",
@@ -39,7 +57,7 @@ class Aria2Transfer:
class Aria2Downloader: class Aria2Downloader:
"""Manage an aria2 RPC daemon for experimental model downloads.""" """Manage an aria2 RPC daemon for recommended model downloads."""
_instance = None _instance = None
_lock = asyncio.Lock() _lock = asyncio.Lock()
@@ -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"""
@@ -158,6 +162,11 @@ class ModelHashIndex:
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:
return True
if len(normalized) == 10:
return normalized in self._autov2_to_path
return False
def get_path(self, sha256: str) -> Optional[str]: def get_path(self, hash_value: str) -> Optional[str]:
"""Get file path for a hash""" """Get file path for a hash (SHA256 or AutoV2)"""
return self._hash_to_path.get(sha256.lower()) 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.7" 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

@@ -255,25 +255,28 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
/* File name copy styles */ /* Editable inline field styles (file name, version name, etc.) */
.file-name-wrapper { .file-name-wrapper,
.version-name-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px; padding: 4px 0;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
transition: background-color 0.2s; transition: background-color 0.2s;
position: relative; position: relative;
} }
.file-name-content { .file-name-content,
padding: 2px 4px; .version-name-content {
padding: 2px 4px 2px 0;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
border: 1px solid transparent; border: 1px solid transparent;
flex: 1; flex: 1;
} }
.file-name-wrapper.editing .file-name-content { .file-name-wrapper.editing .file-name-content,
.version-name-wrapper.editing .version-name-content {
border: 1px solid var(--lora-accent); border: 1px solid var(--lora-accent);
background: var(--bg-color); background: var(--bg-color);
outline: none; outline: none;
@@ -283,7 +286,8 @@
.edit-model-name-btn, .edit-model-name-btn,
.edit-file-name-btn, .edit-file-name-btn,
.edit-base-model-btn, .edit-base-model-btn,
.edit-model-description-btn { .edit-model-description-btn,
.edit-version-name-btn {
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-color); color: var(--text-color);
@@ -299,9 +303,11 @@
.edit-file-name-btn.visible, .edit-file-name-btn.visible,
.edit-base-model-btn.visible, .edit-base-model-btn.visible,
.edit-model-description-btn.visible, .edit-model-description-btn.visible,
.edit-version-name-btn.visible,
.model-name-header:hover .edit-model-name-btn, .model-name-header:hover .edit-model-name-btn,
.file-name-wrapper:hover .edit-file-name-btn, .file-name-wrapper:hover .edit-file-name-btn,
.base-model-display:hover .edit-base-model-btn, .base-model-display:hover .edit-base-model-btn,
.version-name-wrapper:hover .edit-version-name-btn,
.model-name-header:hover .edit-model-description-btn { .model-name-header:hover .edit-model-description-btn {
opacity: 0.5; opacity: 0.5;
} }
@@ -309,14 +315,16 @@
.edit-model-name-btn:hover, .edit-model-name-btn:hover,
.edit-file-name-btn:hover, .edit-file-name-btn:hover,
.edit-base-model-btn:hover, .edit-base-model-btn:hover,
.edit-model-description-btn:hover { .edit-model-description-btn:hover,
.edit-version-name-btn:hover {
opacity: 0.8 !important; opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
} }
[data-theme="dark"] .edit-model-name-btn:hover, [data-theme="dark"] .edit-model-name-btn:hover,
[data-theme="dark"] .edit-file-name-btn:hover, [data-theme="dark"] .edit-file-name-btn:hover,
[data-theme="dark"] .edit-base-model-btn:hover { [data-theme="dark"] .edit-base-model-btn:hover,
[data-theme="dark"] .edit-version-name-btn:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
@@ -338,7 +346,7 @@
} }
.base-model-content { .base-model-content {
padding: 2px 4px; padding: 2px 4px 2px 0;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
border: 1px solid transparent; border: 1px solid transparent;
color: var(--text-color); color: var(--text-color);

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,6 +132,10 @@ 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) {
if (!config.skipMetadataRefresh) {
skipMetadataRefreshItem.style.display = 'none';
resumeMetadataRefreshItem.style.display = 'none';
} else {
const skipCount = this.countSkipStatus(true); const skipCount = this.countSkipStatus(true);
const resumeCount = this.countSkipStatus(false); const resumeCount = this.countSkipStatus(false);
const totalCount = skipCount + resumeCount; const totalCount = skipCount + resumeCount;
@@ -156,6 +165,7 @@ export class BulkContextMenu extends BaseContextMenu {
); );
} }
} }
}
// Hide empty sections // Hide empty sections
this.menu.querySelectorAll('.context-menu-section').forEach(section => { this.menu.querySelectorAll('.context-menu-section').forEach(section => {
@@ -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

@@ -66,6 +66,12 @@ function updateModalFilePathReferences(newFilePath) {
fileNameContent.setAttribute('data-file-path', newFilePath); fileNameContent.setAttribute('data-file-path', newFilePath);
} }
const versionNameContent = scopedQuery('.version-name-content');
if (versionNameContent && versionNameContent.dataset) {
versionNameContent.dataset.filePath = newFilePath;
versionNameContent.setAttribute('data-file-path', newFilePath);
}
const editTagsBtn = scopedQuery('.edit-tags-btn'); const editTagsBtn = scopedQuery('.edit-tags-btn');
if (editTagsBtn) { if (editTagsBtn) {
editTagsBtn.dataset.filePath = newFilePath; editTagsBtn.dataset.filePath = newFilePath;
@@ -516,3 +522,127 @@ export function setupFileNameEditing(filePath) {
editBtn.classList.remove('visible'); editBtn.classList.remove('visible');
} }
} }
/**
* Set up version name editing functionality
* @param {string} filePath - File path
*/
export function setupVersionNameEditing(filePath) {
const versionNameContent = document.querySelector('.version-name-content');
const editBtn = document.querySelector('.edit-version-name-btn');
if (!versionNameContent || !editBtn) return;
// Store the file path in a data attribute for later use
versionNameContent.dataset.filePath = filePath;
// Show edit button on hover
const versionNameWrapper = document.querySelector('.version-name-wrapper');
versionNameWrapper.addEventListener('mouseenter', () => {
editBtn.classList.add('visible');
});
versionNameWrapper.addEventListener('mouseleave', () => {
if (!versionNameWrapper.classList.contains('editing')) {
editBtn.classList.remove('visible');
}
});
// Handle edit button click
editBtn.addEventListener('click', () => {
versionNameWrapper.classList.add('editing');
versionNameContent.setAttribute('contenteditable', 'true');
// Store original value for comparison later
versionNameContent.dataset.originalValue = versionNameContent.textContent.trim();
versionNameContent.focus();
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
if (versionNameContent.childNodes.length > 0) {
range.setStart(versionNameContent.childNodes[0], versionNameContent.textContent.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
editBtn.classList.add('visible');
});
// Handle keyboard events in edit mode
versionNameContent.addEventListener('keydown', function(e) {
if (!this.getAttribute('contenteditable')) return;
if (e.key === 'Enter') {
e.preventDefault();
this.blur(); // Trigger save on Enter
} else if (e.key === 'Escape') {
e.preventDefault();
// Restore original value
this.textContent = this.dataset.originalValue;
exitEditMode();
}
});
// Limit version name length
versionNameContent.addEventListener('input', function() {
if (!this.getAttribute('contenteditable')) return;
if (this.textContent.length > 100) {
this.textContent = this.textContent.substring(0, 100);
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
range.setStart(this.childNodes[0], 100);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
showToast('toast.models.nameTooLong', {}, 'warning');
}
});
// Handle focus out - save changes
versionNameContent.addEventListener('blur', async function() {
if (!this.getAttribute('contenteditable')) return;
const newVersionName = this.textContent.trim();
const originalValue = this.dataset.originalValue;
// Basic validation
if (!newVersionName) {
// Restore original value if empty
this.textContent = originalValue;
showToast('toast.models.nameCannotBeEmpty', {}, 'error');
exitEditMode();
return;
}
if (newVersionName === originalValue) {
// No changes, just exit edit mode
exitEditMode();
return;
}
try {
// Resolve current file path from modal state
const filePath = getActiveModalFilePath(this.dataset.filePath);
await getModelApiClient().saveModelMetadata(filePath, { civitai: { name: newVersionName } });
showToast('toast.models.nameUpdatedSuccessfully', {}, 'success');
} catch (error) {
console.error('Error updating version name:', error);
this.textContent = originalValue; // Restore original version name
showToast('toast.models.nameUpdateFailed', {}, 'error');
} finally {
exitEditMode();
}
});
function exitEditMode() {
versionNameContent.removeAttribute('contenteditable');
versionNameWrapper.classList.remove('editing');
editBtn.classList.remove('visible');
}
}

View File

@@ -11,7 +11,8 @@ import { setupTabSwitching } from './ModelDescription.js';
import { import {
setupModelNameEditing, setupModelNameEditing,
setupBaseModelEditing, setupBaseModelEditing,
setupFileNameEditing setupFileNameEditing,
setupVersionNameEditing
} from './ModelMetadata.js'; } from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js'; import { setupTagEditMode } from './ModelTags.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
@@ -466,7 +467,12 @@ export async function showModelModal(model, modelType) {
<div class="info-grid"> <div class="info-grid">
<div class="info-item"> <div class="info-item">
<label>${translate('modals.model.metadata.version', {}, 'Version')}</label> <label>${translate('modals.model.metadata.version', {}, 'Version')}</label>
<span>${modelWithFullData.civitai?.name || 'N/A'}</span> <div class="version-name-wrapper">
<span class="version-name-content">${modelWithFullData.civitai?.name || 'N/A'}</span>
<button class="edit-version-name-btn" title="${translate('modals.model.actions.editVersionName', {}, 'Edit version name')}">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<label>${translate('modals.model.metadata.fileName', {}, 'File Name')}</label> <label>${translate('modals.model.metadata.fileName', {}, 'File Name')}</label>
@@ -660,6 +666,7 @@ export async function showModelModal(model, modelType) {
setupTagTooltip(); setupTagTooltip();
setupTagEditMode(modelType); setupTagEditMode(modelType);
setupModelNameEditing(modelWithFullData.file_path); setupModelNameEditing(modelWithFullData.file_path);
setupVersionNameEditing(modelWithFullData.file_path);
setupBaseModelEditing(modelWithFullData.file_path); setupBaseModelEditing(modelWithFullData.file_path);
setupFileNameEditing(modelWithFullData.file_path); setupFileNameEditing(modelWithFullData.file_path);
setupEventHandlers(modelWithFullData.file_path, modelType); setupEventHandlers(modelWithFullData.file_path, modelType);

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)