Compare commits

..

7 Commits

Author SHA1 Message Date
Will Miao
237a015cde chore(release): bump version to v1.1.0 2026-06-12 23:38:16 +08:00
Will Miao
1ae2778baa feat(sidebar): add per-page hide toggle with more options dropdown
- Add ``` button in sidebar header with dropdown menu
- Add "Hide sidebar on this page" option with per-page localStorage state
- Show edge indicator (14px chevron) on left when hidden per-page
- Show brief toast notification when hiding
- Fix container margin not resetting when sidebar is per-page hidden
- Add i18n translations for all 10 locales
2026-06-12 18:27:54 +08:00
Will Miao
84fcdb5f20 fix(recipe): compute folder field on save to prevent reimported recipes disappearing from subfolder grid 2026-06-12 16:49:57 +08:00
Will Miao
8a0b368b44 feat(downloads): add persistent download queue/history with REST API 2026-06-12 15:00:21 +08:00
Will Miao
3990535505 fix(i18n): align bulk reimport label with single context menu, drop 'Metadata' for clarity 2026-06-12 10:19:33 +08:00
Will Miao
3e961a9860 fix(stats): load embeddings from saved stats on startup
_load_stats() was missing the embeddings section, so on every restart
the embeddings usage tracking hash would start from an empty dict.
This caused all previously saved embedding usage data to appear reset.

Added the missing load path for the 'embeddings' key, parallel to the
existing checkpoints and loras loading logic.
2026-06-12 08:57:25 +08:00
Will Miao
d6669f1d04 fix(ui): stabilize node selector ordering by type then ID 2026-06-12 08:47:11 +08:00
23 changed files with 1596 additions and 156 deletions

View File

@@ -6,22 +6,29 @@
"Scott R" "Scott R"
], ],
"allSupporters": [ "allSupporters": [
"Brennok",
"Insomnia Art Designs", "Insomnia Art Designs",
"2018cfh", "2018cfh",
"megakirbs", "megakirbs",
"Brennok", "Arlecchino Shion",
"Rob Williams",
"W+K+White", "W+K+White",
"wackop", "wackop",
"Phil", "Phil",
"Carl G.", "Carl G.",
"Arlecchino Shion",
"Charles Blakemore", "Charles Blakemore",
"Rob Williams",
"stone9k", "stone9k",
"itismyelement", "itismyelement",
"$MetaSamsara", "$MetaSamsara",
"Gingko Biloba",
"Kiba",
"onesecondinosaur", "onesecondinosaur",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"Rosenthal", "Rosenthal",
"ClockDaemon",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg", "Tobi_Swagg",
"Andrew Wilson", "Andrew Wilson",
@@ -30,32 +37,38 @@
"JongWon Han", "JongWon Han",
"VantAI", "VantAI",
"runte3221", "runte3221",
"Illrigger",
"Tom Corrigan",
"FreelancerZ", "FreelancerZ",
"Echo",
"Robert Stacey",
"Edgar Tejeda", "Edgar Tejeda",
"Fraser Cross", "Fraser Cross",
"Liam MacDougal", "Liam MacDougal",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Sterilized",
"JORGE+LUIZ+HUSSNI+MESSIAS",
"Marc Whiffen", "Marc Whiffen",
"Skalabananen", "Skalabananen",
"Birdy", "Birdy",
"Kiba",
"Mozzel", "Mozzel",
"Gingko Biloba",
"Reno Lam", "Reno Lam",
"JSST",
"sig", "sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"Takkan", "Takkan",
"Matt+J", "Matt+J",
"ClockDaemon", "Baekdoosixt",
"Jonathan Ross",
"KD", "KD",
"Omnidex", "Omnidex",
"Nazono_hito",
"daniel dove",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"JW Sin",
"Alex",
"SG", "SG",
"carozzz", "carozzz",
"James Dooley", "James Dooley",
@@ -70,77 +83,71 @@
"RedrockVP", "RedrockVP",
"Wolffen", "Wolffen",
"James Todd", "James Todd",
"Wicked Choices by ASLPro3D",
"Steven Pfeiffer", "Steven Pfeiffer",
"Tim", "レプサイ",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Tak",
"Lisster", "Lisster",
"Michael Wong", "Michael Wong",
"Illrigger", "Big Red",
"whudunit", "whudunit",
"Tom Corrigan", "dl0901dm",
"JackieWang", "JackieWang",
"fnkylove", "fnkylove",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"Echo", "Bishoujoker",
"Lilleman", "Lilleman",
"Robert Stacey",
"PM", "PM",
"Todd Keck", "Todd Keck",
"Briton Heilbrun", "Briton Heilbrun",
"Jorge Hussni", "wildnut",
"Sterilized", "Aleksander Wujczyk",
"AM Kuro",
"BadassArabianMofo", "BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"quarz", "quarz",
"Greg", "Greg",
"JSST",
"lmsupporter", "lmsupporter",
"andrew.tappan",
"zounic", "zounic",
"wfpearl", "wfpearl",
"Baekdoosixt",
"Jonathan Ross",
"Jack B Nimble", "Jack B Nimble",
"Nazono_hito",
"Melville Parrish", "Melville Parrish",
"daniel dove",
"Lustre", "Lustre",
"JW Sin", "JaxMax",
"contrite831", "contrite831",
"Alex",
"bh", "bh",
"Marlon Daniels", "Marlon Daniels",
"Starkselle", "Starkselle",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"greebles", "greebles",
"Some Guy Named Barry",
"M Postkasse", "M Postkasse",
"Gooohokrbe", "Gooohokrbe",
"Wicked Choices by ASLPro3D",
"OldBones", "OldBones",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Matt Wenzel",
"Weasyl", "Weasyl",
"Lex Song", "Lex Song",
"Cory Paza", "Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Zach Gonser", "Zach Gonser",
"Big Red",
"Jimmy Ledbetter", "Jimmy Ledbetter",
"Luc Job", "Luc Job",
"dl0901dm",
"Philip Hempel", "Philip Hempel",
"corde", "corde",
"Nick Walker", "Nick Walker",
"Bishoujoker", "dan",
"aai", "aai",
"Tori", "Tori",
"wildnut", "otaku fra",
"jean jahren", "jean jahren",
"Aleksander Wujczyk", "MiraiKuriyamaSy",
"AM Kuro",
"Ran C", "Ran C",
"ViperC", "ViperC",
"Penfore", "Penfore",
@@ -149,17 +156,22 @@
"Karl P.", "Karl P.",
"Akira_HentAI", "Akira_HentAI",
"Gordon Cole", "Gordon Cole",
"Adam Taylor",
"AbstractAss", "AbstractAss",
"andrew.tappan", "Weird_With_A_Beard",
"N/A", "N/A",
"The Spawn", "The Spawn",
"graysock", "graysock",
"Pozadine1",
"Qarob",
"AIGooner",
"Luc",
"Greenmoustache", "Greenmoustache",
"Jackthemind",
"fancypants", "fancypants",
"Eldithor", "Eldithor",
"Joboshy", "Joboshy",
"Digital", "Digital",
"JaxMax",
"takyamtom", "takyamtom",
"Bohemian Corporal", "Bohemian Corporal",
"Dan", "Dan",
@@ -170,42 +182,37 @@
"carey6409", "carey6409",
"Olive", "Olive",
"太郎 ゲーム", "太郎 ゲーム",
"Some Guy Named Barry", "Roslynd",
"jinxedx", "jinxedx",
"Cosmosis", "Cosmosis",
"AELOX", "AELOX",
"Dankin", "Dankin",
"Nicfit23", "Nicfit23",
"FloPro4Sho", "FloPro4Sho",
"Cristian Vazquez",
"wamekukyouzin", "wamekukyouzin",
"drum matthieu", "drum matthieu",
"Dogmaster", "Dogmaster",
"Matt Wenzel",
"Frank Nitty", "Frank Nitty",
"Magic Noob",
"Christopher Michel", "Christopher Michel",
"Serge Bekenkamp", "Serge Bekenkamp",
"DougPeterson",
"LeoZero", "LeoZero",
"Antonio Pontes", "Antonio Pontes",
"ApathyJones", "ApathyJones",
"Julian V", "Julian V",
"Steven Owens", "Steven Owens",
"nahinahi9", "nahinahi9",
"Kevin John Duck",
"Dustin Chen", "Dustin Chen",
"dan",
"Blackfish95", "Blackfish95",
"Mouthlessman", "Mouthlessman",
"Paul Kroll", "Paul Kroll",
"otaku fra",
"MiraiKuriyamaSy",
"Bas Imagineer", "Bas Imagineer",
"yuxz69", "yuxz69",
"Adam Taylor",
"Weird_With_A_Beard",
"esthe", "esthe",
"Pozadine1", "decoy",
"Qarob",
"AIGooner",
"Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"elu3199", "elu3199",
@@ -217,46 +224,50 @@
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
"Tyrswood", "Tyrswood",
"Ray Wing",
"Ranzitho",
"Gus",
"MJG",
"linnfrey", "linnfrey",
"IamAyam", "IamAyam",
"skaterb949", "skaterb949",
"Josef Lanzl", "Josef Lanzl",
"Nerezza",
"confiscated Zyra", "confiscated Zyra",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"aezin",
"jcay015",
"Gerald Welly", "Gerald Welly",
"Roslynd", "Erik Lopez",
"Mateo Curić",
"Tee Gee", "Tee Gee",
"Geolog", "Geolog",
"tarek helmi", "tarek helmi",
"Neco28", "Neco28",
"Eris3D",
"Max Marklund", "Max Marklund",
"David Ortega", "David Ortega",
"Cristian Vazquez",
"Magic Noob",
"Pronredn", "Pronredn",
"DougPeterson", "a _",
"Jeff", "Jeff",
"Bruce", "Bruce",
"lh qwe", "lh qwe",
"Kevin John Duck", "James Coleman",
"conner", "conner",
"Kevin Christopher", "Kevin Christopher",
"Chad Idk",
"dd", "dd",
"Princess Bright Eyes", "Princess Bright Eyes",
"Dušan Ryban", "Dušan Ryban",
"Felipe dos Santos", "Felipe dos Santos",
"sjon kreutz",
"John Statham", "John Statham",
"Douglas Gaspar", "Douglas Gaspar",
"Metryman55", "Metryman55",
"AlexDuKaNa", "AlexDuKaNa",
"George", "George",
"dw", "dw",
"decoy",
"Ray Wing",
"Ranzitho",
"Gus",
"地獄の禄", "地獄の禄",
"MJG",
"David LaVallee", "David LaVallee",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
@@ -273,19 +284,20 @@
"몽타주", "몽타주",
"Kland", "Kland",
"Hailshem", "Hailshem",
"kudari",
"Naomi Hale Danchi",
"epicgamer0020690",
"Richard",
"奚明 刘", "奚明 刘",
"Andrew",
"Brian M", "Brian M",
"Nerezza", "Robert Wegemund",
"sanborondon", "sanborondon",
"준희 김", "준희 김",
"Taylor Funk", "Taylor Funk",
"aezin",
"Thought2Form", "Thought2Form",
"jcay015",
"Kevin Picco", "Kevin Picco",
"Erik Lopez", "Sadlip",
"Mateo Curić",
"Eris3D",
"Tomohiro Baba", "Tomohiro Baba",
"m", "m",
"Noora", "Noora",
@@ -294,32 +306,30 @@
"Mattssn", "Mattssn",
"Mikko Hemilä", "Mikko Hemilä",
"Jamie Ogletree", "Jamie Ogletree",
"a _", "Michael Taylor",
"James Coleman",
"Martial", "Martial",
"Emil Andersson", "Emil Andersson",
"Ouro Boros", "Ouro Boros",
"Chad Idk", "Atilla Berke Pekduyar",
"Steam Steam", "Steam Steam",
"CryptoTraderJK", "CryptoTraderJK",
"Decx _",
"Yuji Kaneko", "Yuji Kaneko",
"Davaitamin", "Davaitamin",
"Rops Alot", "Rops Alot",
"tedcor", "tedcor",
"Sam", "Sam",
"Fotek Design", "Fotek Design",
"sjon kreutz",
"Ace Ventura", "Ace Ventura",
"LarsesFPC",
"MadSpin", "MadSpin",
"inbijiburu", "inbijiburu",
"Nick “Loadstone” D", "Nick “Loadstone” D",
"momokai", "momokai",
"starbugx", "starbugx",
"kudari",
"Naomi Hale Danchi",
"dc7431", "dc7431",
"ken", "ken",
"epicgamer0020690", "Crocket",
"Joshua Porrata", "Joshua Porrata",
"keemun", "keemun",
"SuBu", "SuBu",
@@ -339,22 +349,24 @@
"KitKatM", "KitKatM",
"socrasteeze", "socrasteeze",
"OrganicArtifact", "OrganicArtifact",
"MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"emadsultan",
"Vir", "Vir",
"gzmzmvp", "gzmzmvp",
"Richard",
"Andrew",
"Robert Wegemund",
"Littlehuggy", "Littlehuggy",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"Draven T", "Draven T",
"mrjuan", "mrjuan",
"Brian Buie", "Brian Buie",
"Sadlip",
"Eric Whitney", "Eric Whitney",
"Joey Callahan", "Joey Callahan",
"Aquatic Coffee", "Aquatic Coffee",
"Ivan Tadic", "Ivan Tadic",
"Mike Simone", "Mike Simone",
"John J Linehan",
"ethanfel", "ethanfel",
"Elliot E", "Elliot E",
"Morgandel", "Morgandel",
@@ -366,34 +378,30 @@
"Sloan Steddy", "Sloan Steddy",
"Temikus", "Temikus",
"Artokun", "Artokun",
"Michael Taylor", "hexxish",
"Derek Baker", "Derek Baker",
"Anthony Faxlandez", "Anthony Faxlandez",
"battu", "battu",
"Michael Anthony Scott", "Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Nathan", "Nathan",
"Decx _", "NICHOLAS BAXLEY",
"Pat Hen", "Pat Hen",
"Xeeosat",
"Ed Wang",
"Jordan Shaw", "Jordan Shaw",
"g unit",
"Srdb", "Srdb",
"四糸凜音", "四糸凜音",
"Nihongasuki", "Nihongasuki",
"LarsesFPC",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
"uwutismxd", "uwutismxd",
"FrxzenSnxw", "FrxzenSnxw",
"zenobeus", "zenobeus",
"Crocket",
"Jackthemind",
"ryoma", "ryoma",
"Stryker", "Stryker",
"ResidentDeviant", "ResidentDeviant",
"MudkipMedkitz", "Ginnie",
"deanbrian",
"Alex Wortman",
"Cody",
"Raku", "Raku",
"smart.edge5178", "smart.edge5178",
"InformedViewz", "InformedViewz",
@@ -415,6 +423,15 @@
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"Nathen+Choi",
"T",
"cocona",
"ElitaSSJ4", "ElitaSSJ4",
"David Schenck", "David Schenck",
"Wolfe7D1", "Wolfe7D1",
@@ -426,7 +443,6 @@
"Goldwaters", "Goldwaters",
"Kauffy", "Kauffy",
"Zude", "Zude",
"John J Linehan",
"Kyler", "Kyler",
"Edward Kennedy", "Edward Kennedy",
"Justin Blaylock", "Justin Blaylock",
@@ -435,17 +451,14 @@
"Vane Holzer", "Vane Holzer",
"psytrax", "psytrax",
"Cyrus Fett", "Cyrus Fett",
"hexxish", "Xenon Xue",
"notedfakes", "notedfakes",
"Billy Gladky", "Billy Gladky",
"NICHOLAS BAXLEY",
"Michael Scott", "Michael Scott",
"Probis", "Probis",
"Ed Wang",
"Wes Sims", "Wes Sims",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"Donor4115", "Donor4115",
"g unit",
"Distortik", "Distortik",
"Filippo Ferrari", "Filippo Ferrari",
"Youguang", "Youguang",
@@ -460,9 +473,14 @@
"Mitchell Robson", "Mitchell Robson",
"Whitepinetrader", "Whitepinetrader",
"POPPIN", "POPPIN",
"Ginnie",
"emadsultan",
"nanana", "nanana",
"ChaChanoKo",
"ghoulars",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy",
"g", "g",
"J", "J",
"Alan+Cano", "Alan+Cano",
@@ -474,15 +492,6 @@
"quantenmecha", "quantenmecha",
"Jason+Nash", "Jason+Nash",
"BillyBoy84", "BillyBoy84",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"Nathen+Choi",
"T",
"cocona",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
"John Martin", "John Martin",
@@ -491,6 +500,8 @@
"moranqianlong", "moranqianlong",
"Kalli Core", "Kalli Core",
"Time Valentine", "Time Valentine",
"Christian Schäfer",
"りん あめ",
"Михал Михалыч", "Михал Михалыч",
"Matt", "Matt",
"Frogmilk", "Frogmilk",
@@ -501,21 +512,26 @@
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
"Ezokewn", "Ezokewn",
"SendingRavens", "SendingRavens",
"Xenon Xue",
"JackJohnnyJim", "JackJohnnyJim",
"TenaciousD",
"Dmitry Ryzhov",
"Khánh Đặng",
"Edward Ten Eyck", "Edward Ten Eyck",
"Michael Docherty", "Michael Docherty",
"Jimmy Borup",
"Paul Hartsuyker", "Paul Hartsuyker",
"Henrique Faiolli",
"elitassj", "elitassj",
"Solixer", "Solixer",
"Pete Pain",
"Jacob Winter", "Jacob Winter",
"Ryan Presley Ng", "Ryan Presley Ng",
"jinksta187", "jinksta187",
"RHopkirk",
"Andrew Wilkinson", "Andrew Wilkinson",
"Manu Thetug", "Manu Thetug",
"Karlanx", "Karlanx",
"Lyavph", "Lyavph",
"Maxim",
"David", "David",
"Meilo", "Meilo",
"operationancut", "operationancut",
@@ -537,6 +553,17 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"2turbo",
"Somebody",
"Balut+Omelette",
"Dmitry+Viznesenskiy",
"tanjin90",
"sternenkrieger",
"eriick",
"Patrick+Bryan",
"Pascalou",
"lighthawke",
"Lev+Lanevskiy",
"low9", "low9",
"Winged", "Winged",
"YassineKhaled", "YassineKhaled",
@@ -552,13 +579,6 @@
"Alex", "Alex",
"Jacky+Ho", "Jacky+Ho",
"Karru", "Karru",
"ghoulars",
"ChaChanoKo",
"null",
"Beau",
"redcarrot",
"powerbot99",
"Fthehappy",
"generic404", "generic404",
"abattoirblues", "abattoirblues",
"zounik", "zounik",
@@ -568,9 +588,10 @@
"Bob Barker", "Bob Barker",
"edk", "edk",
"Tú Nguyễn Lý Hoàng", "Tú Nguyễn Lý Hoàng",
"shira1011",
"Ben D",
"G",
"Ronan Delevacq", "Ronan Delevacq",
"Christian Schäfer",
"りん あめ",
"ja s", "ja s",
"Doug Mason", "Doug Mason",
"Jeremy Townsend", "Jeremy Townsend",
@@ -580,38 +601,41 @@
"Sean voets", "Sean voets",
"Owen Gwosdz", "Owen Gwosdz",
"Jarrid Lee", "Jarrid Lee",
"Poophead27 Blyat",
"Kor", "Kor",
"Joseph Hanson", "Joseph Hanson",
"John Rednoulf", "John Rednoulf",
"Spire",
"Boba Smith", "Boba Smith",
"Devil Lude", "Devil Lude",
"David Murcko", "David Murcko",
"MR.Bear", "MR.Bear",
"Jack Dole", "Jack Dole",
"somethingtosay8",
"ivistorm",
"max blo", "max blo",
"Sauv", "Sauv",
"Steven", "Steven",
"CptNeo", "CptNeo",
"TenaciousD",
"Dmitry Ryzhov",
"Khánh Đặng",
"Maso", "Maso",
"Ted Cart",
"Sage Himeros",
"Eric Ketchum", "Eric Ketchum",
"Kevin Wallace", "Kevin Wallace",
"Jimmy Borup", "David Spearing",
"ChicRic", "ChicRic",
"Tigon", "Tigon",
"BastardSama", "BastardSama",
"mercur", "mercur",
"Pete Pain", "Tania Nayelli Fernandez",
"RHopkirk", "Draconach",
"Yavizu3d", "Yavizu3d",
"Maxim",
"Yves Poezevara", "Yves Poezevara",
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Вячеслав Маринин", "Вячеслав Маринин",
"Dkommander22",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
"Iain Wisely", "Iain Wisely",
@@ -655,6 +679,17 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Monix",
"Trolinka",
"IshouI;_;",
"PredragR",
"Clauzmak",
"Nerick",
"JoL",
"Gold_miner_ego",
"SundayRage",
"YoruHime",
"matter",
"SRCRCOSS", "SRCRCOSS",
"imer", "imer",
"Akkas+Haque", "Akkas+Haque",
@@ -675,18 +710,8 @@
"Sildoren", "Sildoren",
"Darvidous", "Darvidous",
"Seon+Song", "Seon+Song",
"2turbo",
"balut+omelette",
"Nebuleux", "Nebuleux",
"Dmitry+Viznesenskiy",
"Tanjin90",
"Somebody",
"sternenkrieger",
"eriick",
"Join+Chun", "Join+Chun",
"Pascalou",
"lighthawke",
"Terraformer",
"GDS+DEV", "GDS+DEV",
"4rt+r3d", "4rt+r3d",
"you+halo9", "you+halo9",
@@ -712,17 +737,16 @@
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Hans Meier", "Hans Meier",
"shira1011",
"sicarius", "sicarius",
"Michael Eid", "Michael Eid",
"Wolf and Fox Legends",
"beersandbacon", "beersandbacon",
"Neko Desco", "Neko Desco",
"Bob barker", "Bob barker",
"Ben D",
"Ninja Tom", "Ninja Tom",
"G",
"karim ben brik", "karim ben brik",
"Vinarus", "Vinarus",
"Josh Snyder",
"Michael Zhu", "Michael Zhu",
"Nemisu", "Nemisu",
"Seraphy", "Seraphy",
@@ -732,41 +756,42 @@
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"Room Light",
"Jairus Knudsen", "Jairus Knudsen",
"Poophead27 Blyat",
"Xan Dionysus", "Xan Dionysus",
"Patryk Serious",
"Nathan lee", "Nathan lee",
"Lyle Liston", "lylepaul",
"Middo", "Middo",
"Forbidden Atelier", "Forbidden Atelier",
"Thomas Sankowski", "Thomas Sankowski",
"Spire",
"DrB", "DrB",
"AZ Party Oasis", "AZ Party Oasis",
"Adictedtohumping", "Adictedtohumping",
"Snorklebort",
"Towelie", "Towelie",
"TheFusion", "TheFusion",
"matt", "matt",
"dsffsdfsdfsdfsdfsdf", "dsffsdfsdfsdfsdfsdf",
"somethingtosay8",
"Jean-françois SEMA", "Jean-françois SEMA",
"3zS4QNQ4", "3zS4QNQ4",
"Terminuz", "Terminuz",
"Kurt", "Kurt",
"ivistorm",
"Matt M.", "Matt M.",
"Ivan Imes", "Ivan Imes",
"J M",
"Bouya shaka",
"Faburizu", "Faburizu",
"Jack Lawfield", "Jack Lawfield",
"jimyjomson", "jimyjomson",
"Borte", "Borte",
"JaeHyun Jang",
"Chase Kwon", "Chase Kwon",
"Ted Cart", "yyuvuvu",
"Sage Himeros",
"Inyoshu", "Inyoshu",
"Chad Barnes", "Chad Barnes",
"Person Y", "Person Y",
"David Spearing", "Nomki",
"James Ming", "James Ming",
"vanditking", "vanditking",
"kripitonga", "kripitonga",
@@ -787,5 +812,5 @@
"Somebody", "Somebody",
"CK" "CK"
], ],
"totalCount": 784 "totalCount": 809
} }

View File

@@ -693,7 +693,7 @@
"copyAll": "Alle Syntax kopieren", "copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren", "refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren", "repairMetadata": "Metadaten der Auswahl reparieren",
"reimportMetadata": "Metadaten der Auswahl neu importieren", "reimportMetadata": "Aus Quelle neu importieren",
"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",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Stammverzeichnis", "modelRoot": "Stammverzeichnis",
"moreOptions": "Weitere Optionen",
"collapseAll": "Alle Ordner einklappen", "collapseAll": "Alle Ordner einklappen",
"pinSidebar": "Sidebar anheften", "pinSidebar": "Sidebar anheften",
"unpinSidebar": "Sidebar lösen", "unpinSidebar": "Sidebar lösen",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"switchToListView": "Zur Listenansicht wechseln", "switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln", "switchToTreeView": "Zur Baumansicht wechseln",
"recursiveOn": "Unterordner einbeziehen", "recursiveOn": "Unterordner einbeziehen",

View File

@@ -693,7 +693,7 @@
"copyAll": "Copy Selected Syntax", "copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata", "refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected", "repairMetadata": "Repair Metadata for Selected",
"reimportMetadata": "Re-import Metadata for Selected", "reimportMetadata": "Re-import from Source",
"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",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Root", "modelRoot": "Root",
"moreOptions": "More options",
"collapseAll": "Collapse All Folders", "collapseAll": "Collapse All Folders",
"pinSidebar": "Pin Sidebar", "pinSidebar": "Pin Sidebar",
"unpinSidebar": "Unpin Sidebar", "unpinSidebar": "Unpin Sidebar",
"hideOnThisPage": "Hide sidebar on this page",
"showSidebar": "Show sidebar",
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
"switchToListView": "Switch to List View", "switchToListView": "Switch to List View",
"switchToTreeView": "Switch to Tree View", "switchToTreeView": "Switch to Tree View",
"recursiveOn": "Include subfolders", "recursiveOn": "Include subfolders",

View File

@@ -693,7 +693,7 @@
"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", "repairMetadata": "Reparar metadatos de la selección",
"reimportMetadata": "Reimportar metadatos de la selección", "reimportMetadata": "Reimportar desde origen",
"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",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Raíz", "modelRoot": "Raíz",
"moreOptions": "Más opciones",
"collapseAll": "Colapsar todas las carpetas", "collapseAll": "Colapsar todas las carpetas",
"pinSidebar": "Fijar barra lateral", "pinSidebar": "Fijar barra lateral",
"unpinSidebar": "Desfijar barra lateral", "unpinSidebar": "Desfijar barra lateral",
"hideOnThisPage": "Ocultar barra lateral en esta página",
"showSidebar": "Mostrar barra lateral",
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
"switchToListView": "Cambiar a vista de lista", "switchToListView": "Cambiar a vista de lista",
"switchToTreeView": "Cambiar a vista de árbol", "switchToTreeView": "Cambiar a vista de árbol",
"recursiveOn": "Incluir subcarpetas", "recursiveOn": "Incluir subcarpetas",

View File

@@ -693,7 +693,7 @@
"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", "repairMetadata": "Réparer les métadonnées de la sélection",
"reimportMetadata": "Ré-importer les métadonnées de la sélection", "reimportMetadata": "Ré-importer depuis la source",
"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",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Racine", "modelRoot": "Racine",
"moreOptions": "Plus d'options",
"collapseAll": "Réduire tous les dossiers", "collapseAll": "Réduire tous les dossiers",
"pinSidebar": "Épingler la barre latérale", "pinSidebar": "Épingler la barre latérale",
"unpinSidebar": "Désépingler la barre latérale", "unpinSidebar": "Désépingler la barre latérale",
"hideOnThisPage": "Masquer la barre latérale sur cette page",
"showSidebar": "Afficher la barre latérale",
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
"switchToListView": "Passer en vue liste", "switchToListView": "Passer en vue liste",
"switchToTreeView": "Passer en vue arborescence", "switchToTreeView": "Passer en vue arborescence",
"recursiveOn": "Inclure les sous-dossiers", "recursiveOn": "Inclure les sous-dossiers",

View File

@@ -693,7 +693,7 @@
"copyAll": "העתק את כל התחבירים", "copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה", "refreshAll": "רענן את כל המטא-דאטה",
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים", "repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
"reimportMetadata": "ייבא מחדש מטא-דאטה עבור הנבחרים", "reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה", "checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה", "moveAll": "העבר הכל לתיקייה",
"autoOrganize": "ארגן אוטומטית נבחרים", "autoOrganize": "ארגן אוטומטית נבחרים",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
"moreOptions": "אפשרויות נוספות",
"collapseAll": "כווץ את כל התיקיות", "collapseAll": "כווץ את כל התיקיות",
"pinSidebar": "נעל סרגל צד", "pinSidebar": "נעל סרגל צד",
"unpinSidebar": "שחרר סרגל צד", "unpinSidebar": "שחרר סרגל צד",
"hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"switchToListView": "עבור לתצוגת רשימה", "switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "תצוגת עץ", "switchToTreeView": "תצוגת עץ",
"recursiveOn": "כלול תיקיות משנה", "recursiveOn": "כלול תיקיות משנה",

View File

@@ -693,7 +693,7 @@
"copyAll": "すべての構文をコピー", "copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新", "refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復", "repairMetadata": "選択したレシピのメタデータを修復",
"reimportMetadata": "選択したレシピを再インポート", "reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認", "checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動", "moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行", "autoOrganize": "自動整理を実行",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
"moreOptions": "その他のオプション",
"collapseAll": "すべてのフォルダを折りたたむ", "collapseAll": "すべてのフォルダを折りたたむ",
"pinSidebar": "サイドバーを固定", "pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除", "unpinSidebar": "サイドバーの固定を解除",
"hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"switchToListView": "リストビューに切り替え", "switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリー表示に切り替え", "switchToTreeView": "ツリー表示に切り替え",
"recursiveOn": "サブフォルダーを含める", "recursiveOn": "サブフォルダーを含める",

View File

@@ -693,7 +693,7 @@
"copyAll": "모든 문법 복사", "copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침", "refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구", "repairMetadata": "선택한 레시피 메타데이터 복구",
"reimportMetadata": "선택한 레시피 다시 가져오기", "reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인", "checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동", "moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택", "autoOrganize": "자동 정리 선택",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
"moreOptions": "더 많은 옵션",
"collapseAll": "모든 폴더 접기", "collapseAll": "모든 폴더 접기",
"pinSidebar": "사이드바 고정", "pinSidebar": "사이드바 고정",
"unpinSidebar": "사이드바 고정 해제", "unpinSidebar": "사이드바 고정 해제",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"switchToListView": "목록 보기로 전환", "switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환", "switchToTreeView": "트리 보기로 전환",
"recursiveOn": "하위 폴더 포함", "recursiveOn": "하위 폴더 포함",

View File

@@ -693,7 +693,7 @@
"copyAll": "Копировать весь синтаксис", "copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные", "refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных", "repairMetadata": "Восстановить метаданные для выбранных",
"reimportMetadata": "Переимпортировать метаданные для выбранных", "reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных", "checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку", "moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные", "autoOrganize": "Автоматически организовать выбранные",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
"moreOptions": "Дополнительные параметры",
"collapseAll": "Свернуть все папки", "collapseAll": "Свернуть все папки",
"pinSidebar": "Закрепить боковую панель", "pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель", "unpinSidebar": "Открепить боковую панель",
"hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"switchToListView": "Переключить на вид списка", "switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид", "switchToTreeView": "Переключить на древовидный вид",
"recursiveOn": "Включать вложенные папки", "recursiveOn": "Включать вложенные папки",

View File

@@ -693,7 +693,7 @@
"copyAll": "复制所选中语法", "copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据", "refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据", "repairMetadata": "修复所选中元数据",
"reimportMetadata": "重新导入所选配方元数据", "reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新", "checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹", "moveAll": "移动所选中到文件夹",
"autoOrganize": "自动整理所选模型", "autoOrganize": "自动整理所选模型",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
"moreOptions": "更多选项",
"collapseAll": "折叠所有文件夹", "collapseAll": "折叠所有文件夹",
"pinSidebar": "固定侧边栏", "pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏", "unpinSidebar": "取消固定侧边栏",
"hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"switchToListView": "切换到列表视图", "switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图", "switchToTreeView": "切换到树状视图",
"recursiveOn": "包含子文件夹", "recursiveOn": "包含子文件夹",

View File

@@ -693,7 +693,7 @@
"copyAll": "複製全部語法", "copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata", "refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據", "repairMetadata": "修復所選中元數據",
"reimportMetadata": "重新匯入所選配方元數據", "reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新", "checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾", "moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型", "autoOrganize": "自動整理所選模型",
@@ -953,9 +953,13 @@
}, },
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
"moreOptions": "更多選項",
"collapseAll": "全部摺疊資料夾", "collapseAll": "全部摺疊資料夾",
"pinSidebar": "固定側邊欄", "pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄", "unpinSidebar": "取消固定側邊欄",
"hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"switchToListView": "切換至列表檢視", "switchToListView": "切換至列表檢視",
"switchToTreeView": "切換到樹狀檢視", "switchToTreeView": "切換到樹狀檢視",
"recursiveOn": "包含子資料夾", "recursiveOn": "包含子資料夾",

View File

@@ -189,6 +189,10 @@ class LoraManager:
# Register DownloadManager with ServiceRegistry # Register DownloadManager with ServiceRegistry
await ServiceRegistry.get_download_manager() await ServiceRegistry.get_download_manager()
# Initialize DownloadQueueService for persistent queue/history
await ServiceRegistry.get_download_queue_service()
await ServiceRegistry.get_backup_service() await ServiceRegistry.get_backup_service()
from .services.metadata_service import initialize_metadata_providers from .services.metadata_service import initialize_metadata_providers

View File

@@ -37,6 +37,7 @@ from ...services.use_cases import (
) )
from ...services.websocket_manager import WebSocketManager from ...services.websocket_manager import WebSocketManager
from ...services.websocket_progress_callback import WebSocketProgressCallback from ...services.websocket_progress_callback import WebSocketProgressCallback
from ...services.download_queue_service import DownloadQueueService
from ...services.errors import RateLimitError, ResourceNotFoundError from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload from ...utils.civitai_utils import resolve_license_payload
from ...utils.file_utils import calculate_sha256 from ...utils.file_utils import calculate_sha256
@@ -1567,6 +1568,255 @@ class ModelDownloadHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
# ------------------------------------------------------------------
# Download queue / history handlers
# ------------------------------------------------------------------
async def get_download_queue(self, request: web.Request) -> web.Response:
try:
service = await DownloadQueueService.get_instance()
queue = await service.get_queue()
stats = await service.get_stats()
return web.json_response({"success": True, "queue": queue, "stats": stats})
except Exception as exc:
self._logger.error(
"Error getting download queue: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def add_to_download_queue(self, request: web.Request) -> web.Response:
try:
import uuid
download_id = request.query.get("download_id") or str(uuid.uuid4())
model_id_str = request.query.get("model_id")
model_version_id_str = request.query.get("model_version_id")
model_name = request.query.get("model_name", "")
version_name = request.query.get("version_name", "")
thumbnail_url = request.query.get("thumbnail_url", "")
source = request.query.get("source")
file_params_json = request.query.get("file_params")
model_id = int(model_id_str) if model_id_str else None
model_version_id = int(model_version_id_str) if model_version_id_str else None
file_params = json.loads(file_params_json) if file_params_json else None
service = await DownloadQueueService.get_instance()
item = await service.add_to_queue(
download_id=download_id,
model_id=model_id,
model_version_id=model_version_id,
model_name=model_name,
version_name=version_name,
thumbnail_url=thumbnail_url,
source=source,
file_params=file_params,
)
return web.json_response({"success": True, "item": item})
except Exception as exc:
self._logger.error(
"Error adding to download queue: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def remove_from_download_queue(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "download_id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
removed = await service.remove_from_queue(download_id)
return web.json_response({"success": removed})
except Exception as exc:
self._logger.error(
"Error removing from download queue: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_queue_item_to_top(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "download_id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
moved = await service.move_to_top(download_id)
return web.json_response({"success": moved})
except Exception as exc:
self._logger.error(
"Error moving queue item to top: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_queue_item_to_end(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "download_id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
moved = await service.move_to_end(download_id)
return web.json_response({"success": moved})
except Exception as exc:
self._logger.error(
"Error moving queue item to end: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def clear_download_queue(self, request: web.Request) -> web.Response:
try:
status_filter = request.query.get("status") or None
service = await DownloadQueueService.get_instance()
cleared = await service.clear_queue(status_filter=status_filter)
return web.json_response({"success": True, "cleared": cleared})
except Exception as exc:
self._logger.error(
"Error clearing download queue: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_download_history(self, request: web.Request) -> web.Response:
try:
limit = min(int(request.query.get("limit", "50")), 500)
offset = int(request.query.get("offset", "0"))
status_filter = request.query.get("status") or None
service = await DownloadQueueService.get_instance()
result = await service.get_history(
limit=limit, offset=offset, status_filter=status_filter
)
return web.json_response(
{
"success": True,
"items": result["items"],
"total": result["total"],
"limit": result["limit"],
"offset": result["offset"],
}
)
except Exception as exc:
self._logger.error(
"Error getting download history: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def clear_download_history(self, request: web.Request) -> web.Response:
try:
status_filter = request.query.get("status") or None
service = await DownloadQueueService.get_instance()
cleared = await service.clear_history(status_filter=status_filter)
return web.json_response({"success": True, "cleared": cleared})
except Exception as exc:
self._logger.error(
"Error clearing download history: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_download_history_item(self, request: web.Request) -> web.Response:
try:
item_id = int(request.query.get("id", "0"))
if not item_id:
return web.json_response(
{"success": False, "error": "id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
deleted = await service.delete_history_item(item_id)
return web.json_response({"success": deleted})
except Exception as exc:
self._logger.error(
"Error deleting download history item: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def retry_download_from_history(self, request: web.Request) -> web.Response:
try:
item_id = int(request.query.get("id", "0"))
if not item_id:
return web.json_response(
{"success": False, "error": "id is required"}, status=400
)
service = await DownloadQueueService.get_instance()
item = await service.retry_from_history(item_id)
if item is None:
return web.json_response(
{"success": False, "error": "History item not found or not retryable"},
status=404,
)
return web.json_response({"success": True, "item": item})
except Exception as exc:
self._logger.error(
"Error retrying download from history: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def retry_all_failed_downloads(self, request: web.Request) -> web.Response:
try:
service = await DownloadQueueService.get_instance()
retry_count = await service.retry_all_failed()
return web.json_response({"success": True, "retry_count": retry_count})
except Exception as exc:
self._logger.error(
"Error retrying all failed downloads: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def complete_download_in_queue(self, request: web.Request) -> web.Response:
"""Atomically move a download from queue to history with terminal status."""
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response(
{"success": False, "error": "download_id is required"}, status=400
)
status = request.query.get("status", "completed")
error = request.query.get("error")
file_path = request.query.get("file_path")
try:
bytes_downloaded = int(request.query.get("bytes_downloaded", "0"))
except (TypeError, ValueError):
bytes_downloaded = 0
total_bytes_raw = request.query.get("total_bytes")
total_bytes = int(total_bytes_raw) if total_bytes_raw else None
service = await DownloadQueueService.get_instance()
item = await service.complete_download(
download_id=download_id,
status=status,
error=error,
file_path=file_path,
bytes_downloaded=bytes_downloaded,
total_bytes=total_bytes,
)
if item is None:
return web.json_response(
{"success": False, "error": "Download not found in queue"}, status=404
)
return web.json_response({"success": True, "item": item})
except Exception as exc:
self._logger.error(
"Error completing download: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_download_stats(self, request: web.Request) -> web.Response:
try:
service = await DownloadQueueService.get_instance()
stats = await service.get_stats()
return web.json_response({"success": True, "stats": stats})
except Exception as exc:
self._logger.error(
"Error getting download stats: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelCivitaiHandler: class ModelCivitaiHandler:
"""CivitAI integration endpoints.""" """CivitAI integration endpoints."""
@@ -2596,6 +2846,19 @@ class ModelHandlerSet:
"pause_download_get": self.download.pause_download_get, "pause_download_get": self.download.pause_download_get,
"resume_download_get": self.download.resume_download_get, "resume_download_get": self.download.resume_download_get,
"get_download_progress": self.download.get_download_progress, "get_download_progress": self.download.get_download_progress,
"get_download_queue": self.download.get_download_queue,
"add_to_download_queue": self.download.add_to_download_queue,
"remove_from_download_queue": self.download.remove_from_download_queue,
"move_queue_item_to_top": self.download.move_queue_item_to_top,
"move_queue_item_to_end": self.download.move_queue_item_to_end,
"clear_download_queue": self.download.clear_download_queue,
"get_download_history": self.download.get_download_history,
"clear_download_history": self.download.clear_download_history,
"delete_download_history_item": self.download.delete_download_history_item,
"retry_download_from_history": self.download.retry_download_from_history,
"retry_all_failed_downloads": self.download.retry_all_failed_downloads,
"complete_download_in_queue": self.download.complete_download_in_queue,
"get_download_stats": self.download.get_download_stats,
"get_civitai_versions": self.civitai.get_civitai_versions, "get_civitai_versions": self.civitai.get_civitai_versions,
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version, "get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash, "get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,

View File

@@ -107,6 +107,37 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/download-progress/{download_id}", "get_download_progress" "GET", "/api/lm/download-progress/{download_id}", "get_download_progress"
), ),
RouteDefinition("GET", "/api/lm/downloads/queue", "get_download_queue"),
RouteDefinition("GET", "/api/lm/downloads/queue/add", "add_to_download_queue"),
RouteDefinition(
"GET", "/api/lm/downloads/queue/remove", "remove_from_download_queue"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/move-to-top", "move_queue_item_to_top"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/move-to-end", "move_queue_item_to_end"
),
RouteDefinition(
"GET", "/api/lm/downloads/queue/clear", "clear_download_queue"
),
RouteDefinition("GET", "/api/lm/downloads/history", "get_download_history"),
RouteDefinition(
"GET", "/api/lm/downloads/history/clear", "clear_download_history"
),
RouteDefinition(
"GET", "/api/lm/downloads/history/delete", "delete_download_history_item"
),
RouteDefinition(
"GET", "/api/lm/downloads/history/retry", "retry_download_from_history"
),
RouteDefinition(
"GET", "/api/lm/downloads/history/retry-all", "retry_all_failed_downloads"
),
RouteDefinition("GET", "/api/lm/downloads/stats", "get_download_stats"),
RouteDefinition(
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
),
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"), RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"), RouteDefinition("GET", "/{prefix}", "handle_models_page"),
) )

View File

@@ -0,0 +1,730 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import sqlite3
import time
from typing import Any, Optional
from ..utils.cache_paths import get_cache_base_dir
logger = logging.getLogger(__name__)
def _resolve_database_path() -> str:
base_dir = get_cache_base_dir(create=True)
history_dir = os.path.join(base_dir, "download_history")
os.makedirs(history_dir, exist_ok=True)
return os.path.join(history_dir, "download_queue.sqlite")
class DownloadQueueService:
"""Persistent download queue and history manager backed by SQLite.
Provides a singleton interface for managing a download queue and
corresponding history table, both stored in a single SQLite database
under the cache directory.
"""
_instance: Optional[DownloadQueueService] = None
_class_lock: asyncio.Lock = asyncio.Lock()
_SCHEMA = """
CREATE TABLE IF NOT EXISTS download_queue (
download_id TEXT PRIMARY KEY,
model_id INTEGER,
model_version_id INTEGER,
model_name TEXT NOT NULL DEFAULT '',
version_name TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '',
source TEXT,
file_params TEXT,
status TEXT NOT NULL DEFAULT 'queued',
priority INTEGER DEFAULT 0,
progress INTEGER DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
bytes_per_second REAL DEFAULT 0.0,
error TEXT,
file_path TEXT,
added_at REAL NOT NULL,
started_at REAL,
completed_at REAL
);
CREATE INDEX IF NOT EXISTS idx_dq_status ON download_queue(status);
CREATE INDEX IF NOT EXISTS idx_dq_added ON download_queue(added_at);
CREATE TABLE IF NOT EXISTS download_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
download_id TEXT,
model_id INTEGER,
model_version_id INTEGER,
model_name TEXT NOT NULL DEFAULT '',
version_name TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '',
status TEXT NOT NULL,
error TEXT,
file_path TEXT,
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
completed_at REAL NOT NULL,
is_already_exists INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
"""
@classmethod
async def get_instance(cls) -> DownloadQueueService:
"""Return the singleton instance, creating it if necessary."""
async with cls._class_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self, db_path: Optional[str] = None) -> None:
self._db_path = db_path or _resolve_database_path()
self._lock = asyncio.Lock()
self._conn: Optional[sqlite3.Connection] = None
self._schema_initialized = False
self._ensure_directory()
self._initialize_schema()
def _ensure_directory(self) -> None:
directory = os.path.dirname(self._db_path)
if directory:
os.makedirs(directory, exist_ok=True)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def _get_conn(self) -> sqlite3.Connection:
if self._conn is None:
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
return self._conn
def _initialize_schema(self) -> None:
if self._schema_initialized:
return
with self._connect() as conn:
conn.executescript(self._SCHEMA)
conn.commit()
self._schema_initialized = True
def get_database_path(self) -> str:
"""Return the resolved database file path."""
return self._db_path
def close(self) -> None:
"""Close the persistent SQLite connection, if open.
This is called before plugin update operations to release the
database file lock on Windows, allowing ``shutil.rmtree()`` to
succeed when the cache resides inside the plugin directory.
"""
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
finally:
self._conn = None
# ------------------------------------------------------------------
# Queue methods
# ------------------------------------------------------------------
async def add_to_queue(
self,
download_id: str,
model_id: Optional[int] = None,
model_version_id: Optional[int] = None,
model_name: str = "",
version_name: str = "",
thumbnail_url: str = "",
source: Optional[str] = None,
file_params: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Insert a new download into the queue.
Returns the inserted row as a dict (or an empty dict if the
download_id already exists).
"""
now = time.time()
file_params_json = json.dumps(file_params) if file_params is not None else None
async with self._lock:
conn = self._get_conn()
conn.execute(
"""
INSERT OR IGNORE INTO download_queue (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params,
status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
""",
(
download_id,
model_id,
model_version_id,
model_name,
version_name,
thumbnail_url,
source,
file_params_json,
now,
),
)
conn.commit()
row = conn.execute(
"SELECT * FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
return dict(row) if row else {}
async def get_queue(self) -> list[dict[str, Any]]:
"""Return all items in the queue ordered by priority then added time."""
async with self._lock:
conn = self._get_conn()
rows = conn.execute(
"SELECT * FROM download_queue ORDER BY priority DESC, added_at ASC"
).fetchall()
return [dict(row) for row in rows]
async def get_queued_count(self) -> int:
"""Return the number of items with status ``'queued'``."""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_queue WHERE status = 'queued'"
).fetchone()
return row["cnt"] if row else 0
async def update_status(
self,
download_id: str,
status: str,
**extra: Any,
) -> bool:
"""Update the status and/or extra fields of a queue item.
Accepted extra keyword arguments:
``progress``, ``error``, ``file_path``, ``bytes_downloaded``,
``total_bytes``, ``bytes_per_second``.
Returns ``True`` if a row was updated.
"""
allowed_extra = {
"progress",
"error",
"file_path",
"bytes_downloaded",
"total_bytes",
"bytes_per_second",
}
set_clauses: list[str] = ["status = ?"]
params: list[Any] = [status]
now = time.time()
if status in ("downloading",):
set_clauses.append("started_at = COALESCE(started_at, ?)")
params.append(now)
if status in ("completed", "failed", "canceled"):
set_clauses.append("completed_at = ?")
params.append(now)
for key, value in extra.items():
if key in allowed_extra:
set_clauses.append(f"{key} = ?")
params.append(value)
params.append(download_id)
async with self._lock:
conn = self._get_conn()
cursor = conn.execute(
f"UPDATE download_queue SET {', '.join(set_clauses)} "
"WHERE download_id = ?",
params,
)
conn.commit()
return cursor.rowcount > 0
async def remove_from_queue(self, download_id: str) -> bool:
"""Remove a single item from the queue by download_id.
Returns ``True`` if a row was deleted.
"""
async with self._lock:
conn = self._get_conn()
cursor = conn.execute(
"DELETE FROM download_queue WHERE download_id = ?",
(download_id,),
)
conn.commit()
return cursor.rowcount > 0
async def move_to_top(self, download_id: str) -> bool:
"""Move an item to the front of the queue (highest priority).
Returns ``True`` if the item was found and updated.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT priority FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
if row is None:
return False
max_row = conn.execute(
"SELECT MAX(priority) AS mx FROM download_queue"
).fetchone()
max_priority: int = max_row["mx"] if max_row["mx"] is not None else 0
conn.execute(
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
(max_priority + 1, download_id),
)
conn.commit()
return True
async def move_to_end(self, download_id: str) -> bool:
"""Move an item to the end of the queue (lowest priority).
Returns ``True`` if the item was found and updated.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT priority FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
if row is None:
return False
min_row = conn.execute(
"SELECT MIN(priority) AS mn FROM download_queue"
).fetchone()
min_priority: int = min_row["mn"] if min_row["mn"] is not None else 0
conn.execute(
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
(min_priority - 1, download_id),
)
conn.commit()
return True
async def clear_queue(self, status_filter: Optional[str] = None) -> int:
"""Remove items from the queue.
When *status_filter* is provided only items with that status are
deleted. Returns the number of deleted rows.
"""
async with self._lock:
conn = self._get_conn()
if status_filter is not None:
cursor = conn.execute(
"DELETE FROM download_queue WHERE status = ?",
(status_filter,),
)
else:
cursor = conn.execute("DELETE FROM download_queue")
conn.commit()
return cursor.rowcount
async def complete_download(
self,
download_id: str,
status: str = "completed",
error: Optional[str] = None,
file_path: Optional[str] = None,
bytes_downloaded: int = 0,
total_bytes: Optional[int] = None,
) -> Optional[dict[str, Any]]:
"""Atomically move a download from the queue into the history table.
Looks up the queue record by ``download_id``, deletes it from the
queue, and inserts a corresponding history entry with the given
terminal status (``completed``, ``failed``, or ``canceled``).
Returns the original queue record (before deletion) on success,
or ``None`` if the download was not found in the queue.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT * FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
if row is None:
return None
now = time.time()
conn.execute(
"DELETE FROM download_queue WHERE download_id = ?",
(download_id,),
)
conn.execute(
"""
INSERT INTO download_history (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path,
bytes_downloaded, total_bytes, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
row["download_id"],
row["model_id"],
row["model_version_id"],
row["model_name"],
row["version_name"],
row["thumbnail_url"],
status,
error,
file_path,
bytes_downloaded,
total_bytes,
now,
),
)
conn.commit()
return dict(row)
async def pop_next_download(self) -> Optional[dict[str, Any]]:
"""Atomically fetch and mark the next queued item as ``downloading``.
The item with the highest priority (and earliest ``added_at``
among ties) whose status is ``'queued'`` is selected, set to
``'downloading'``, and returned as a dict. Returns ``None`` if
the queue is empty.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"""
SELECT * FROM download_queue
WHERE status = 'queued'
ORDER BY priority DESC, added_at ASC
LIMIT 1
"""
).fetchone()
if row is None:
return None
download_id = row["download_id"]
now = time.time()
conn.execute(
"UPDATE download_queue SET status = 'downloading', "
"started_at = COALESCE(started_at, ?) "
"WHERE download_id = ?",
(now, download_id),
)
conn.commit()
updated = conn.execute(
"SELECT * FROM download_queue WHERE download_id = ?",
(download_id,),
).fetchone()
return dict(updated) if updated else None
# ------------------------------------------------------------------
# History methods
# ------------------------------------------------------------------
async def add_to_history(
self,
download_id: Optional[str] = None,
model_id: Optional[int] = None,
model_version_id: Optional[int] = None,
model_name: str = "",
version_name: str = "",
thumbnail_url: str = "",
status: str = "completed",
error: Optional[str] = None,
file_path: Optional[str] = None,
bytes_downloaded: int = 0,
total_bytes: Optional[int] = None,
is_already_exists: int = 0,
) -> int:
"""Insert a record into the download history.
Returns the ``id`` (AUTOINCREMENT primary key) of the newly
inserted row.
"""
now = time.time()
async with self._lock:
conn = self._get_conn()
cursor = conn.execute(
"""
INSERT INTO download_history (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path,
bytes_downloaded, total_bytes, completed_at, is_already_exists
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
download_id,
model_id,
model_version_id,
model_name,
version_name,
thumbnail_url,
status,
error,
file_path,
bytes_downloaded,
total_bytes,
now,
is_already_exists,
),
)
conn.commit()
return cursor.lastrowid or 0
async def get_history(
self,
limit: int = 50,
offset: int = 0,
status_filter: Optional[str] = None,
) -> dict[str, Any]:
"""Return a page of download history entries.
Returns a dict with keys ``items``, ``total``, ``limit``, and
``offset``.
"""
async with self._lock:
conn = self._get_conn()
if status_filter is not None:
count_row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
(status_filter,),
).fetchone()
rows = conn.execute(
"SELECT * FROM download_history WHERE status = ? "
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
(status_filter, limit, offset),
).fetchall()
else:
count_row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_history"
).fetchone()
rows = conn.execute(
"SELECT * FROM download_history "
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return {
"items": [dict(row) for row in rows],
"total": count_row["cnt"] if count_row else 0,
"limit": limit,
"offset": offset,
}
async def delete_history_item(self, id: int) -> bool:
"""Delete a single history entry by its *id*.
Returns ``True`` if a row was deleted.
"""
async with self._lock:
conn = self._get_conn()
cursor = conn.execute(
"DELETE FROM download_history WHERE id = ?",
(id,),
)
conn.commit()
return cursor.rowcount > 0
async def clear_history(
self,
status_filter: Optional[str] = None,
before_timestamp: Optional[float] = None,
) -> int:
"""Remove history entries matching the optional filters.
Both ``status_filter`` and ``before_timestamp`` can be combined
(AND logic). Returns the number of deleted rows.
"""
async with self._lock:
conn = self._get_conn()
clauses: list[str] = []
params: list[Any] = []
if status_filter is not None:
clauses.append("status = ?")
params.append(status_filter)
if before_timestamp is not None:
clauses.append("completed_at < ?")
params.append(before_timestamp)
where = ""
if clauses:
where = " WHERE " + " AND ".join(clauses)
cursor = conn.execute(
f"DELETE FROM download_history{where}",
params,
)
conn.commit()
return cursor.rowcount
async def get_history_count(self, status_filter: Optional[str] = None) -> int:
"""Return the number of history entries, optionally filtered by status."""
async with self._lock:
conn = self._get_conn()
if status_filter is not None:
row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
(status_filter,),
).fetchone()
else:
row = conn.execute(
"SELECT COUNT(*) AS cnt FROM download_history"
).fetchone()
return row["cnt"] if row else 0
# ------------------------------------------------------------------
# Retry
# ------------------------------------------------------------------
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]:
"""Re-queue a failed or canceled download from history.
Looks up the history record by its primary key. If the status is
``failed`` or ``canceled`` a new queue entry is created with the
same model metadata and a fresh download id.
"""
async with self._lock:
conn = self._get_conn()
row = conn.execute(
"SELECT * FROM download_history WHERE id = ?",
(item_id,),
).fetchone()
if row is None:
return None
status = str(row["status"])
if status not in ("failed", "canceled"):
return None
import uuid
new_id = str(uuid.uuid4())
now = time.time()
conn.execute(
"""
INSERT INTO download_queue (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params,
status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
""",
(
new_id,
row["model_id"],
row["model_version_id"],
row["model_name"],
row["version_name"],
row["thumbnail_url"],
"retry",
now,
),
)
conn.commit()
queued = conn.execute(
"SELECT * FROM download_queue WHERE download_id = ?",
(new_id,),
).fetchone()
return dict(queued) if queued else None
async def retry_all_failed(self) -> int:
"""Re-queue all failed and canceled downloads from history.
Returns the number of items that were re-queued.
"""
async with self._lock:
conn = self._get_conn()
rows = conn.execute(
"SELECT * FROM download_history WHERE status IN ('failed', 'canceled')"
).fetchall()
if not rows:
return 0
import uuid
now = time.time()
count = 0
for row in rows:
new_id = str(uuid.uuid4())
conn.execute(
"""
INSERT INTO download_queue (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params,
status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
""",
(
new_id,
row["model_id"],
row["model_version_id"],
row["model_name"],
row["version_name"],
row["thumbnail_url"],
"retry",
now,
),
)
count += 1
conn.commit()
return count
# ------------------------------------------------------------------
# Stats
# ------------------------------------------------------------------
async def get_stats(self) -> dict[str, int]:
"""Return aggregate counts across both tables.
Returns a dict with keys ``queued``, ``downloading``, ``paused``
(all from the queue table) and ``completed``, ``failed``,
``canceled`` (all from the history table).
"""
async with self._lock:
conn = self._get_conn()
queue_rows = conn.execute(
"SELECT status, COUNT(*) AS cnt FROM download_queue GROUP BY status"
).fetchall()
queue_stats: dict[str, int] = {}
for row in queue_rows:
queue_stats[str(row["status"])] = row["cnt"]
history_rows = conn.execute(
"SELECT status, COUNT(*) AS cnt FROM download_history GROUP BY status"
).fetchall()
history_stats: dict[str, int] = {}
for row in history_rows:
history_stats[str(row["status"])] = row["cnt"]
return {
"queued": queue_stats.get("queued", 0),
"downloading": queue_stats.get("downloading", 0),
"paused": queue_stats.get("paused", 0),
"completed": history_stats.get("completed", 0),
"failed": history_stats.get("failed", 0),
"canceled": history_stats.get("canceled", 0),
}

View File

@@ -129,6 +129,18 @@ class RecipePersistenceService:
if nsfw_level is not None and isinstance(nsfw_level, int): if nsfw_level is not None and isinstance(nsfw_level, int):
recipe_data["preview_nsfw_level"] = nsfw_level recipe_data["preview_nsfw_level"] = nsfw_level
# Compute recipe folder relative to recipes root, mirroring
# RecipeScanner._calculate_folder() which is only called during scan/load.
if recipe_scanner.recipes_dir:
recipe_file_dir = os.path.dirname(normalized_image_path)
try:
relative_folder = os.path.relpath(recipe_file_dir, recipe_scanner.recipes_dir)
if relative_folder in (".", ""):
relative_folder = ""
recipe_data["folder"] = relative_folder.replace(os.path.sep, "/")
except Exception:
recipe_data["folder"] = ""
json_filename = f"{recipe_id}.recipe.json" json_filename = f"{recipe_id}.recipe.json"
json_path = os.path.join(recipes_dir, json_filename) json_path = os.path.join(recipes_dir, json_filename)
json_path = os.path.normpath(json_path) json_path = os.path.normpath(json_path)

View File

@@ -188,6 +188,25 @@ class ServiceRegistry:
logger.debug(f"Created and registered {service_name}") logger.debug(f"Created and registered {service_name}")
return service return service
@classmethod
async def get_download_queue_service(cls):
"""Get or create the download queue service."""
service_name = "download_queue_service"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
if service_name in cls._services:
return cls._services[service_name]
from .download_queue_service import DownloadQueueService
service = await DownloadQueueService.get_instance()
cls._services[service_name] = service
logger.debug(f"Created and registered {service_name}")
return service
@classmethod @classmethod
async def get_backup_service(cls): async def get_backup_service(cls):
"""Get or create the backup service.""" """Get or create the backup service."""

View File

@@ -221,6 +221,9 @@ class UsageStats:
if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict): if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict):
self.stats["loras"] = loaded_stats["loras"] self.stats["loras"] = loaded_stats["loras"]
if "embeddings" in loaded_stats and isinstance(loaded_stats["embeddings"], dict):
self.stats["embeddings"] = loaded_stats["embeddings"]
if "total_executions" in loaded_stats: if "total_executions" in loaded_stats:
self.stats["total_executions"] = loaded_stats["total_executions"] self.stats["total_executions"] = loaded_stats["total_executions"]

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.11" version = "1.1.0"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -84,6 +84,7 @@
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
cursor: pointer; cursor: pointer;
transition: var(--transition-base); transition: var(--transition-base);
position: relative;
} }
.sidebar-header:hover { .sidebar-header:hover {
@@ -150,6 +151,120 @@
display: none; display: none;
} }
/* ===== Sidebar More Options Dropdown ===== */
.sidebar-more-dropdown {
position: absolute;
top: 100%;
right: 8px;
min-width: 190px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: var(--shadow-lg);
z-index: calc(var(--z-overlay) + 20);
display: none;
overflow: hidden;
margin-top: 2px;
}
.sidebar-more-dropdown.open {
display: block;
animation: dropdownFadeIn 0.15s ease;
}
@keyframes dropdownFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.sidebar-dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
font-size: 0.85em;
color: var(--text-color);
transition: var(--transition-base);
white-space: nowrap;
}
.sidebar-dropdown-item:hover {
background: var(--lora-surface);
}
.sidebar-dropdown-item i {
width: 16px;
text-align: center;
color: var(--text-muted);
font-size: 0.9em;
flex-shrink: 0;
}
.sidebar-dropdown-item:hover i {
color: var(--text-color);
}
.sidebar-dropdown-item.disabled {
opacity: 0.4;
pointer-events: none;
}
/* ===== Sidebar Hidden Indicator (left edge) ===== */
.sidebar-hidden-indicator {
position: fixed;
left: 0;
top: 50%;
transform: translateY(-50%);
z-index: var(--z-overlay);
width: 14px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--border-color);
opacity: 0.3;
border-radius: 0 4px 4px 0;
cursor: pointer;
transition: opacity 0.15s ease, background 0.15s ease;
}
.sidebar-hidden-indicator:hover {
opacity: 0.7;
background: var(--lora-accent);
}
.sidebar-hidden-indicator i {
font-size: 9px;
color: var(--text-muted);
transition: color 0.15s ease;
}
.sidebar-hidden-indicator:hover i {
color: white;
}
.sidebar-hidden-indicator-tooltip {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 8px;
padding: 4px 10px;
background: var(--text-color);
color: var(--bg-color);
font-size: 0.8em;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.sidebar-hidden-indicator:hover .sidebar-hidden-indicator-tooltip {
opacity: 1;
}
.sidebar-content { .sidebar-content {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;

View File

@@ -36,6 +36,8 @@ export class SidebarManager {
this.currentDropTarget = null; this.currentDropTarget = null;
this.lastPageControls = null; this.lastPageControls = null;
this.isDisabledBySetting = false; this.isDisabledBySetting = false;
this.isDisabledByPage = false;
this.isMoreDropdownOpen = false;
this.initializationPromise = null; this.initializationPromise = null;
this.isCreatingFolder = false; this.isCreatingFolder = false;
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态 this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
@@ -68,6 +70,10 @@ export class SidebarManager {
this.handleSidebarDrop = this.handleSidebarDrop.bind(this); this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this); this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this); this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
this.handleMoreToggle = this.handleMoreToggle.bind(this);
this.handleMoreDropdownItemClick = this.handleMoreDropdownItemClick.bind(this);
this.handleDocumentClickForMore = this.handleDocumentClickForMore.bind(this);
this.getPageDisplayName = this.getPageDisplayName.bind(this);
} }
setHostPageControls(pageControls) { setHostPageControls(pageControls) {
@@ -100,6 +106,8 @@ export class SidebarManager {
this.initializeDragAndDrop(); this.initializeDragAndDrop();
this.updateSidebarTitle(); this.updateSidebarTitle();
this.restoreSidebarState(); this.restoreSidebarState();
// Re-apply DOM visibility now that per-page state is known
this.updateDomVisibility(!this.isDisabledBySetting);
await this.loadFolderTree(); await this.loadFolderTree();
if (this.isDisabledBySetting && !forceInitialize) { if (this.isDisabledBySetting && !forceInitialize) {
this.cleanup(); this.cleanup();
@@ -143,6 +151,13 @@ export class SidebarManager {
this.sidebarDragHandlersInitialized = false; this.sidebarDragHandlersInitialized = false;
} }
const moreDropdown = document.getElementById('sidebarMoreDropdown');
if (moreDropdown) {
moreDropdown.classList.remove('open');
}
this.isMoreDropdownOpen = false;
this.hideSidebarHiddenIndicator();
// Reset state // Reset state
this.pageControls = null; this.pageControls = null;
this.pageType = null; this.pageType = null;
@@ -151,6 +166,7 @@ export class SidebarManager {
this.expandedNodes = new Set(); this.expandedNodes = new Set();
this.openDropdown = null; this.openDropdown = null;
this.isHovering = false; this.isHovering = false;
this.isDisabledByPage = false;
this.apiClient = null; this.apiClient = null;
this.isInitialized = false; this.isInitialized = false;
this.recursiveSearchEnabled = true; this.recursiveSearchEnabled = true;
@@ -217,6 +233,18 @@ export class SidebarManager {
if (recursiveToggleBtn) { if (recursiveToggleBtn) {
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle); recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
} }
const moreToggle = document.getElementById('sidebarMoreToggle');
if (moreToggle) {
moreToggle.removeEventListener('click', this.handleMoreToggle);
}
const moreDropdown = document.getElementById('sidebarMoreDropdown');
if (moreDropdown) {
moreDropdown.removeEventListener('click', this.handleMoreDropdownItemClick);
}
document.removeEventListener('click', this.handleDocumentClickForMore);
} }
initializeDragAndDrop() { initializeDragAndDrop() {
@@ -1045,6 +1073,19 @@ export class SidebarManager {
} }
}); });
} }
// More options dropdown
const moreToggle = document.getElementById('sidebarMoreToggle');
if (moreToggle) {
moreToggle.addEventListener('click', this.handleMoreToggle);
}
const moreDropdown = document.getElementById('sidebarMoreDropdown');
if (moreDropdown) {
moreDropdown.addEventListener('click', this.handleMoreDropdownItemClick);
}
document.addEventListener('click', this.handleDocumentClickForMore);
} }
handleDocumentClick(event) { handleDocumentClick(event) {
@@ -1066,6 +1107,7 @@ export class SidebarManager {
this.isPinned = !this.isPinned; this.isPinned = !this.isPinned;
this.updateAutoHideState(); this.updateAutoHideState();
this.updatePinButton(); this.updatePinButton();
this.updateMoreDropdownLabels();
this.saveSidebarState(); this.saveSidebarState();
this.updateContainerMargin(); this.updateContainerMargin();
} }
@@ -1129,7 +1171,7 @@ export class SidebarManager {
} }
updateAutoHideState() { updateAutoHideState() {
if (this.isDisabledBySetting) return; if (this.isDisabledBySetting || this.isDisabledByPage) return;
const sidebar = document.getElementById('folderSidebar'); const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea'); const hoverArea = document.getElementById('sidebarHoverArea');
@@ -1174,9 +1216,12 @@ export class SidebarManager {
if (!container || !sidebar || this.isDisabledBySetting) return; if (!container || !sidebar || this.isDisabledBySetting) return;
// Reset margin to default // Always reset margin first — needed when transitioning from visible to hidden
container.style.marginLeft = ''; container.style.marginLeft = '';
// When per-page disabled, skip adjustment but margin is already reset
if (this.isDisabledByPage) return;
// Only adjust margin if sidebar is visible and pinned // Only adjust margin if sidebar is visible and pinned
if ((this.isPinned || this.isHovering) && this.isVisible) { if ((this.isPinned || this.isHovering) && this.isVisible) {
const sidebarWidth = sidebar.offsetWidth; const sidebarWidth = sidebar.offsetWidth;
@@ -1193,20 +1238,29 @@ export class SidebarManager {
} }
updateDomVisibility(enabled) { updateDomVisibility(enabled) {
// Per-page disable adds on top of global setting
const isVisible = enabled && !this.isDisabledByPage;
const sidebar = document.getElementById('folderSidebar'); const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea'); const hoverArea = document.getElementById('sidebarHoverArea');
if (sidebar) { if (sidebar) {
sidebar.classList.toggle('hidden-by-setting', !enabled); sidebar.classList.toggle('hidden-by-setting', !isVisible);
sidebar.setAttribute('aria-hidden', (!enabled).toString()); sidebar.setAttribute('aria-hidden', (!isVisible).toString());
} }
if (hoverArea) { if (hoverArea) {
hoverArea.classList.toggle('hidden-by-setting', !enabled); hoverArea.classList.toggle('hidden-by-setting', !isVisible);
if (!enabled) { if (!isVisible) {
hoverArea.classList.add('disabled'); hoverArea.classList.add('disabled');
} }
} }
// Show or hide the "sidebar hidden" notification
if (enabled && this.isDisabledByPage) {
this.showSidebarHiddenIndicator();
} else {
this.hideSidebarHiddenIndicator();
}
} }
async setSidebarEnabled(enabled) { async setSidebarEnabled(enabled) {
@@ -1266,6 +1320,133 @@ export class SidebarManager {
} }
} }
// ===== More Options Dropdown =====
handleMoreToggle(event) {
event.stopPropagation();
const dropdown = document.getElementById('sidebarMoreDropdown');
if (!dropdown) return;
this.isMoreDropdownOpen = !dropdown.classList.contains('open');
dropdown.classList.toggle('open', this.isMoreDropdownOpen);
this.updateMoreDropdownLabels();
}
handleMoreDropdownItemClick(event) {
const item = event.target.closest('.sidebar-dropdown-item');
if (!item) return;
const action = item.dataset.action;
if (!action) return;
const dropdown = document.getElementById('sidebarMoreDropdown');
if (dropdown) {
dropdown.classList.remove('open');
this.isMoreDropdownOpen = false;
}
switch (action) {
case 'toggle-pin':
this.handlePinToggle(event);
break;
case 'toggle-hide':
this.toggleHideOnThisPage();
break;
}
}
handleDocumentClickForMore(event) {
const dropdown = document.getElementById('sidebarMoreDropdown');
const toggle = document.getElementById('sidebarMoreToggle');
if (!dropdown || !toggle) return;
if (!dropdown.contains(event.target) && !toggle.contains(event.target)) {
dropdown.classList.remove('open');
this.isMoreDropdownOpen = false;
}
}
updateMoreDropdownLabels() {
const pinLabel = document.getElementById('sidebarMorePinLabel');
if (pinLabel) {
pinLabel.textContent = this.isPinned
? translate('sidebar.unpinSidebar')
: translate('sidebar.pinSidebar');
}
const hideItem = document.querySelector('.sidebar-dropdown-item[data-action="toggle-hide"]');
if (hideItem) {
const hideIcon = hideItem.querySelector('i');
const hideLabel = hideItem.querySelector('span');
if (this.isDisabledByPage) {
hideLabel.textContent = translate('sidebar.showSidebar');
if (hideIcon) {
hideIcon.className = 'fas fa-eye';
}
} else {
hideLabel.textContent = translate('sidebar.hideOnThisPage');
if (hideIcon) {
hideIcon.className = 'fas fa-eye-slash';
}
}
}
}
toggleHideOnThisPage() {
this.isDisabledByPage = !this.isDisabledByPage;
setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage);
this.updateDomVisibility(!this.isDisabledBySetting);
this.updateAutoHideState();
this.updateContainerMargin();
this.updateMoreDropdownLabels();
if (!this.isDisabledByPage) {
this.hideSidebarHiddenIndicator();
} else {
showToast(
'sidebar.sidebarHiddenNotification',
{ page: this.getPageDisplayName() },
'info',
`Sidebar hidden on ${this.getPageDisplayName()} page`
);
}
}
getPageDisplayName() {
const names = {
loras: 'LoRAs',
recipes: 'Recipes',
checkpoints: 'Checkpoints',
embeddings: 'Embeddings',
};
return names[this.pageType] || this.pageType;
}
showSidebarHiddenIndicator() {
if (document.getElementById('sidebarHiddenIndicator')) return;
const indicator = document.createElement('div');
indicator.id = 'sidebarHiddenIndicator';
indicator.className = 'sidebar-hidden-indicator';
indicator.innerHTML = `
<i class="fas fa-chevron-right"></i>
<span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span>
`;
indicator.addEventListener('click', () => {
this.toggleHideOnThisPage();
});
document.body.appendChild(indicator);
}
hideSidebarHiddenIndicator() {
const indicator = document.getElementById('sidebarHiddenIndicator');
if (indicator) {
indicator.remove();
}
}
async loadFolderTree() { async loadFolderTree() {
try { try {
if (this.displayMode === 'tree') { if (this.displayMode === 'tree') {
@@ -1911,6 +2092,7 @@ export class SidebarManager {
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []); const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree' const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true); const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
this.isPinned = isPinned; this.isPinned = isPinned;
this.expandedNodes = new Set(expandedPaths); this.expandedNodes = new Set(expandedPaths);

View File

@@ -998,7 +998,9 @@ function showNodeSelector(nodes, options = {}) {
nodeSelectorState.enableSendAll = options.enableSendAll !== false; nodeSelectorState.enableSendAll = options.enableSendAll !== false;
// Generate node list HTML with icons and proper colors // Generate node list HTML with icons and proper colors
const nodeItems = Object.entries(safeNodes).map(([nodeKey, node]) => { const nodeItems = Object.entries(safeNodes)
.sort(([, a], [, b]) => a.type - b.type || a.id - b.id)
.map(([nodeKey, node]) => {
const iconClass = NODE_TYPE_ICONS[node.type] || 'fas fa-question-circle'; const iconClass = NODE_TYPE_ICONS[node.type] || 'fas fa-question-circle';
const bgColor = node.bgcolor || DEFAULT_NODE_COLOR; const bgColor = node.bgcolor || DEFAULT_NODE_COLOR;
const graphLabel = node.graph_name ? ` (${node.graph_name})` : ''; const graphLabel = node.graph_name ? ` (${node.graph_name})` : '';

View File

@@ -18,6 +18,20 @@
<button class="sidebar-action-btn" id="sidebarPinToggle" title="{{ t('sidebar.unpinSidebar') }}"> <button class="sidebar-action-btn" id="sidebarPinToggle" title="{{ t('sidebar.unpinSidebar') }}">
<i class="fas fa-thumbtack"></i> <i class="fas fa-thumbtack"></i>
</button> </button>
<button class="sidebar-action-btn" id="sidebarMoreToggle" title="{{ t('sidebar.moreOptions') }}">
<i class="fas fa-ellipsis-v"></i>
</button>
</div>
<!-- Dropdown menu for more options -->
<div class="sidebar-more-dropdown" id="sidebarMoreDropdown">
<div class="sidebar-dropdown-item" data-action="toggle-pin">
<i class="fas fa-thumbtack"></i>
<span id="sidebarMorePinLabel">{{ t('sidebar.pinSidebar') }}</span>
</div>
<div class="sidebar-dropdown-item" data-action="toggle-hide">
<i class="fas fa-eye-slash"></i>
<span>{{ t('sidebar.hideOnThisPage') }}</span>
</div>
</div> </div>
</div> </div>
<div class="sidebar-content"> <div class="sidebar-content">