Compare commits

...

37 Commits

Author SHA1 Message Date
Will Miao
bf32d8b6fd chore(release): bump version to v1.1.2 2026-06-17 09:57:37 +08:00
Will Miao
8299881024 refactor(sidebar): remove pin/unpin and global hide, use per-page hide only
- Remove pin/unpin and auto-hide hover mechanism (isPinned, isHovering,
  hoverTimeout, showSidebar/hideSidebar, updateAutoHideState, etc.)
- Remove global show_folder_sidebar setting (SettingsManager,
  PageControls, recipes, backend default)
- Simplify sidebar visibility to a single per-page toggle:
  · Dedicated chevron-left button in header to hide sidebar
  · Edge indicator (chevron-right) to restore when hidden
  · No dropdown, no hover area, no pin button
- Add _migrateOldSettings() to convert old sidebarPinned and
  show_folder_sidebar states to per-page sidebarDisabled
- Fix sidebar flicker on page load: CSS defaults to off-screen,
  JS explicitly sets .visible or .hidden-by-setting
- Remove obsolete CSS classes: auto-hide, hover-active, collapsed
- Remove i18n keys: pinSidebar, unpinSidebar, moreOptions
- Update test mocks for the new initialize() interface
2026-06-17 09:49:24 +08:00
Will Miao
da02268196 fix(css): add top margin to stat-cards container for consistent spacing 2026-06-17 08:24:03 +08:00
Will Miao
8c4b9a1e70 fix(metadata-sync): persist not-found flags to SQLite cache on deleted-provider path
When a model is already classified as civitai_deleted=True via
.metadata.json but re-enters the failure block through the
civarchive/sqlite provider path (not the default provider),
needs_save was never set to True because civitai_api_not_found
and sqlite_attempted were both False. The flags were never
persisted to SQLite, causing the model to be re-fetched on
every restart.

Also demoted duplicate INFO/ERROR logging in fetch_and_update_model
to DEBUG (the use case already logs at WARNING), and added
exc_info=True to the fetch_all_civitai error handler.
2026-06-17 08:22:24 +08:00
Will Miao
0906c484e9 fix: actually halt bulk operations on cancel — frontend AbortController + backend guards (#986) 2026-06-17 07:20:32 +08:00
Will Miao
4199c30fec fix(metadata-sync): downgrade "Model not found" to INFO and replace model_name with file+sha256 in log 2026-06-17 00:06:43 +08:00
Will Miao
4a8084cdbc feat(save-image): support %NodeTitle.WidgetName% placeholders and fix %seed% None fallback (#314) 2026-06-16 23:48:44 +08:00
Will Miao
6263e6848c fix: move posix_fadvise(DONTNEED) after read loop so it actually evicts pages (#985) 2026-06-16 23:12:02 +08:00
Will Miao
58c266ad07 fix(scanner): respect lazy hash for checkpoints, add posix_fadvise, cancel on shutdown (#985) 2026-06-16 23:00:23 +08:00
Will Miao
2939813e1a feat(metadata-fetch): add result summary modal with i18n, fix contrast and counting bugs (#38) 2026-06-16 22:38:50 +08:00
Will Miao
a9e5ee7e79 fix: follow-up nits for AVIF/JXL brotli support
- Fix JXL container ftyp size check (==20 → >=16) to accept
  wider range of valid JXL files
- Add brotli decompression size limit (2 MB) to prevent OOM
- Add trailing newline to requirements.txt
- Add unit tests for new ISOBMFF/brotli extraction paths:
  JXL/AVIF happy paths, missing brob, corrupt payload,
  non-ISOBMFF fallthrough, write-skip on AVIF/JXL,
  JSON dict/list fields, and oversized decompression
2026-06-16 16:27:56 +08:00
Will Miao
a17b0e9901 Merge pull request #982 from koloved/main
Add AVIF and JXL image support with brotli metadata decompression
2026-06-16 16:24:30 +08:00
s.ivanov
8f23d966bf Update requirements.txt 2026-06-16 07:27:32 +02:00
Will Miao
7a76fc72d0 fix(rate-limit): continue to next provider on CivArchive 429 to prevent bulk refresh from freezing (#983)
When CivArchive returns HTTP 429 with a large retry_after, the bulk
metadata refresh would block for hours because:

1. FallbackMetadataProvider raised RateLimitError instead of continuing
   to the next provider (e.g., SQLite archive was never reached).

2. _RateLimitRetryHelper retried long-rate-limit 429s 3 times — all
   futile since the hourly cap hasn't reset.

3. The batch loop had no awareness of persistent rate-limiting,
   causing 192+ models to each hammer the same rate-limited endpoint.

Changes:
- FallbackMetadataProvider: all 6 methods now continue to next provider
  on RateLimitError instead of raising (model_metadata_provider.py)
- fetch_and_update_model: deleted-model path also continues on
  RateLimitError so sqlite provider gets a chance (metadata_sync_service.py)
- _RateLimitRetryHelper: when retry_after >= 120s, only 1 attempt is
  made — retries are futile for hour-scale rate limits
- BulkMetadataRefreshUseCase: tracks consecutive rate-limit failures
  and aborts early after 3 (bulk_metadata_refresh_use_case.py)

Tests: updated test_fallback_respects_retry_limit for new continue
behavior; added tests for large/small retry_after thresholds.
2026-06-16 13:08:34 +08:00
Will Miao
518a4dd5ee chore: add reasonix.toml and .codegraph/ to .gitignore 2026-06-16 13:05:11 +08:00
s.ivanov
2b6d4e5d8b Add AVIF and JXL image support with brotli metadata decompression 2026-06-15 09:28:49 +02:00
Will Miao
1f4edbeb9d chore(release): bump version to v1.1.1 2026-06-14 23:49:44 +08:00
Will Miao
a256558a0e fix(downloads): delete history entries on retry and add dedup for bug #980
- retry_from_history() and retry_all_failed() now DELETE the original
  history entry after re-queuing it. Previously the old entry stayed
  in history causing exponential growth on repeated retry→cancel→retry
  cycles.
- Add deduplicate() called once on singleton creation to clean up
  existing duplicate queue/history entries left by the bug:
  1. In-status dedup (keep highest id per model+version+status)
  2. Cross-status dedup (prefer completed > failed > canceled)
  3. Queue dedup (keep highest rowid per model+version)
  4. Orphan queue cleanup (source='retry' entries obsoleted by
     terminal history entries)
2026-06-14 22:52:44 +08:00
Will Miao
818b9113f0 fix(preview): add Cache-Control header to FileResponse for browser caching (#975)
Chrome does not cache 206 Partial Content responses for <video> elements
without an explicit Cache-Control header. When VirtualScroller recycles
cards and creates new <video> elements with the same URL, Chrome
re-downloads the full video (several MB each) instead of using the cache.

Verified via Chrome DevTools: same .mp4 URL appears 2-3 times in network
trace as separate requests with no cache hit, each returning 206. With
Cache-Control: max-age=86400, the browser will reuse the cached response
for 24 hours across scroll cycles.

Video preview files are ~3.5MB while image previews are ~50-100KB (due
to WebP optimization), making caching especially impactful for videos.
2026-06-14 17:36:59 +08:00
Will Miao
6a4fd020dc fix(api): return JSON error responses for all /api/* routes — prevent JSON.parse crashes on 404/500 2026-06-14 13:13:01 +08:00
Will Miao
7a23040452 fix(save-image): sanitize invalid filename chars from %pprompt%, %nprompt%, %model% patterns (#978) 2026-06-14 09:33:12 +08:00
Will Miao
138024aefe fix(preview): revert to FileResponse as default for all platforms (#975)
The previous commit (a19ddc14) restored Linux sendfile but kept the
manual streaming path for Windows via sys.platform guard. A Windows
user reports performance is still worse than v1.0.5.

Switch back to web.FileResponse for all files on all platforms as the
default. The IOCP crash is an edge case (fast scrolling through many
video previews) that affects few users, while the Python chunked I/O
performance penalty affects everyone.

_stream_file() is kept as an unused fallback for a future compat
setting toggle.
2026-06-13 21:43:44 +08:00
Will Miao
a19ddc14f6 perf(preview): restore Linux sendfile, add cache headers, increase chunk size (#975)
- Restrict manual video streaming to Windows only (sys.platform == 'win32');
  Linux/macOS now uses kernel sendfile (zero-copy DMA) via aiohttp FileResponse
- Add Cache-Control: public, max-age=86400 to streaming responses so browsers
  cache video previews across scroll cycles
- Increase chunk size from 256KB to 1MB to reduce async iteration overhead on
  Windows where streaming is still required
2026-06-13 20:06:58 +08:00
Will Miao
7001ced694 fix(rate-limit): respect server retry_after instead of capping at 30s 2026-06-13 18:01:13 +08:00
pixelpaws
a5c861646c Merge pull request #974 from itkitteh/fix/socks-proxy-support
fix: support SOCKS proxies for outbound requests
2026-06-13 14:15:02 +08:00
Artem Yakimenko
3e0bb73793 fix: support SOCKS proxies for outbound requests
The proxy settings allow selecting a SOCKS proxy type, but the SOCKS
URL was passed to aiohttp's per-request `proxy=` argument, which only
supports http(s) proxies. With a SOCKS proxy this opens a plain TCP
connection to the proxy port and sends an HTTP request; the SOCKS
server replies with its handshake bytes (e.g. b"\x05\xff") and aiohttp
fails with "Bad status line ... Expected HTTP/, RTSP/ or ICE/".

Route SOCKS proxy types through an aiohttp-socks ProxyConnector on the
session instead, leaving the `proxy=` kwarg for http(s) proxies only.
trust_env now keys off whether an app-level proxy is active. Adds
aiohttp-socks to requirements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:05:15 +10:00
Will Miao
ac51f6a2f6 feat(settings): add adjustable card overlay blur setting (#973) 2026-06-13 09:43:49 +08:00
Will Miao
bef222c77d perf(recipe): precompute image_id_map for O(1) CivitAI image existence checks
Build a civitai_image_id → recipe_id mapping once during cache
initialization instead of scanning all recipes on every
check_image_exists and import_from_url call.

- RecipeCache gains an image_id_map field populated by
  _build_image_id_map() during cache init
- check_image_exists and import_from_url duplicate detection
  now use the precomputed map (O(k) / O(1) vs O(n))
- Map is persisted in SQLite cache_metadata for fast startup
- Incrementally updated on add/remove/bulk_remove paths
- Fix: conn.close() before cache_metadata query (dead connection)
2026-06-13 08:32:03 +08:00
Will Miao
7cd6a53447 fix(downloads): accept optional completed_at in complete_download to preserve original timestamps 2026-06-13 07:06:59 +08:00
willmiao
6850b35770 docs: auto-update supporters list in README 2026-06-12 15:38:33 +00:00
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
70 changed files with 3822 additions and 899 deletions

4
.gitignore vendored
View File

@@ -12,12 +12,14 @@ coverage/
.coverage
model_cache/
# agent
# agent / dev tooling
.opencode/
.claude/
.sisyphus/
.codex
.omo
reasonix.toml
.codegraph/
# Vue widgets development cache (but keep build output)
vue-widgets/node_modules/

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "Wird geladen...",
"cancelling": "Abbrechen...",
"unknown": "Unbekannt",
"date": "Datum",
"version": "Version",
@@ -448,7 +449,9 @@
"modelName": "Modellname",
"fileName": "Dateiname"
},
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll"
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll",
"cardBlurAmount": "Karten-Overlay-Unschärfe",
"cardBlurAmountHelp": "Passen Sie die Unschärfeintensität der Kopf- und Fußzeilen-Overlays auf Modell- und Rezeptkarten an (0 = keine Unschärfe, 20 = maximale Unschärfe)."
},
"folderSettings": {
"activeLibrary": "Aktive Bibliothek",
@@ -693,7 +696,7 @@
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren",
"reimportMetadata": "Metadaten der Auswahl neu importieren",
"reimportMetadata": "Aus Quelle neu importieren",
"checkUpdates": "Auswahl auf Updates prüfen",
"moveAll": "Alle in Ordner verschieben",
"autoOrganize": "Automatisch organisieren",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen",
"pinSidebar": "Sidebar anheften",
"unpinSidebar": "Sidebar lösen",
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
"showSidebar": "Seitenleiste anzeigen",
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
"switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln",
"recursiveOn": "Unterordner einbeziehen",
@@ -1392,6 +1396,21 @@
"versionDeleted": "Version gelöscht"
}
}
},
"metadataFetchSummary": {
"title": "Metadaten abrufen — Zusammenfassung",
"statSuccess": "Erfolgreich",
"statFailed": "Fehlgeschlagen",
"statSkipped": "Übersprungen",
"statTotal": "Gesamt geprüft",
"statDuration": "Dauer",
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
"failedItems": "Fehlgeschlagene Elemente ({count})",
"close": "Schließen",
"copyReport": "Bericht kopieren",
"downloadCsv": "CSV herunterladen",
"columnModelName": "Modellname",
"columnError": "Fehler"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "In die Zwischenablage kopiert",
"downloadStarted": "Download gestartet"
}
},
"doctor": {

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "Loading...",
"cancelling": "Cancelling...",
"unknown": "Unknown",
"date": "Date",
"version": "Version",
@@ -448,7 +449,9 @@
"modelName": "Model Name",
"fileName": "File Name"
},
"modelNameDisplayHelp": "Choose what to display in the model card footer"
"modelNameDisplayHelp": "Choose what to display in the model card footer",
"cardBlurAmount": "Card Overlay Blur",
"cardBlurAmountHelp": "Adjust the blur intensity of the header and footer overlays on model and recipe cards (0 = no blur, 20 = maximum blur)."
},
"folderSettings": {
"activeLibrary": "Active Library",
@@ -693,7 +696,7 @@
"copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected",
"reimportMetadata": "Re-import Metadata for Selected",
"reimportMetadata": "Re-import from Source",
"checkUpdates": "Check Updates for Selected",
"moveAll": "Move Selected to Folder",
"autoOrganize": "Auto-Organize Selected",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "Root",
"collapseAll": "Collapse All Folders",
"pinSidebar": "Pin 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",
"switchToTreeView": "Switch to Tree View",
"recursiveOn": "Include subfolders",
@@ -1392,6 +1396,21 @@
"versionDeleted": "Version deleted"
}
}
},
"metadataFetchSummary": {
"title": "Metadata Fetch Summary",
"statSuccess": "Success",
"statFailed": "Failed",
"statSkipped": "Skipped",
"statTotal": "Total Scanned",
"statDuration": "Duration",
"successMessage": "All {count} {type}s updated successfully!",
"failedItems": "Failed Items ({count})",
"close": "Close",
"copyReport": "Copy Report",
"downloadCsv": "Download CSV",
"columnModelName": "Model Name",
"columnError": "Error"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copied to clipboard",
"downloadStarted": "Download started"
}
},
"doctor": {
@@ -2046,4 +2067,4 @@
"retry": "Retry"
}
}
}
}

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "Cargando...",
"cancelling": "Cancelando...",
"unknown": "Desconocido",
"date": "Fecha",
"version": "Versión",
@@ -448,7 +449,9 @@
"modelName": "Nombre del modelo",
"fileName": "Nombre del archivo"
},
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo"
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo",
"cardBlurAmount": "Desenfoque de superposición de tarjetas",
"cardBlurAmountHelp": "Ajuste la intensidad de desenfoque de las superposiciones del encabezado y pie de página en las tarjetas de modelos y recetas (0 = sin desenfoque, 20 = desenfoque máximo)."
},
"folderSettings": {
"activeLibrary": "Biblioteca activa",
@@ -693,7 +696,7 @@
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"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",
"moveAll": "Mover todos a carpeta",
"autoOrganize": "Auto-organizar seleccionados",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas",
"pinSidebar": "Fijar 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",
"switchToTreeView": "Cambiar a vista de árbol",
"recursiveOn": "Incluir subcarpetas",
@@ -1392,6 +1396,21 @@
"versionDeleted": "Versión eliminada"
}
}
},
"metadataFetchSummary": {
"title": "Resumen de obtención de metadatos",
"statSuccess": "Éxito",
"statFailed": "Fallido",
"statSkipped": "Omitido",
"statTotal": "Total escaneado",
"statDuration": "Duración",
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
"failedItems": "Elementos fallidos ({count})",
"close": "Cerrar",
"copyReport": "Copiar informe",
"downloadCsv": "Descargar CSV",
"columnModelName": "Nombre del modelo",
"columnError": "Error"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copiado al portapapeles",
"downloadStarted": "Descarga iniciada"
}
},
"doctor": {

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "Chargement...",
"cancelling": "Annulation...",
"unknown": "Inconnu",
"date": "Date",
"version": "Version",
@@ -448,7 +449,9 @@
"modelName": "Nom du modèle",
"fileName": "Nom du fichier"
},
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle"
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle",
"cardBlurAmount": "Flou de superposition des cartes",
"cardBlurAmountHelp": "Ajustez l'intensité du flou des superpositions d'en-tête et de pied de page sur les cartes de modèles et de recettes (0 = aucun flou, 20 = flou maximal)."
},
"folderSettings": {
"activeLibrary": "Bibliothèque active",
@@ -693,7 +696,7 @@
"copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées",
"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",
"moveAll": "Déplacer tout vers un dossier",
"autoOrganize": "Auto-organiser la sélection",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers",
"pinSidebar": "É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",
"switchToTreeView": "Passer en vue arborescence",
"recursiveOn": "Inclure les sous-dossiers",
@@ -1392,6 +1396,21 @@
"versionDeleted": "Version supprimée"
}
}
},
"metadataFetchSummary": {
"title": "Récapitulatif de la récupération des métadonnées",
"statSuccess": "Réussi",
"statFailed": "Échoué",
"statSkipped": "Ignoré",
"statTotal": "Total scanné",
"statDuration": "Durée",
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
"failedItems": "Éléments échoués ({count})",
"close": "Fermer",
"copyReport": "Copier le rapport",
"downloadCsv": "Télécharger CSV",
"columnModelName": "Nom du modèle",
"columnError": "Erreur"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copié dans le presse-papiers",
"downloadStarted": "Téléchargement démarré"
}
},
"doctor": {

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "טוען...",
"cancelling": "מבטל...",
"unknown": "לא ידוע",
"date": "תאריך",
"version": "גרסה",
@@ -448,7 +449,9 @@
"modelName": "שם מודל",
"fileName": "שם קובץ"
},
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל"
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל",
"cardBlurAmount": "עוצמת טשטוש שכבת-על בכרטיס",
"cardBlurAmountHelp": "כוונן את עוצמת הטשטוש של שכבת-העל בכותרת ובכותרות תחתונה בכרטיסי מודל ומתכונים (0 = ללא טשטוש, 20 = טשטוש מקסימלי)."
},
"folderSettings": {
"activeLibrary": "ספרייה פעילה",
@@ -693,7 +696,7 @@
"copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה",
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
"reimportMetadata": "ייבא מחדש מטא-דאטה עבור הנבחרים",
"reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה",
"autoOrganize": "ארגן אוטומטית נבחרים",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות",
"pinSidebar": "נעל סרגל צד",
"unpinSidebar": "שחרר סרגל צד",
"hideOnThisPage": "הסתר סרגל צד בדף זה",
"showSidebar": "הצג סרגל צד",
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
"switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "תצוגת עץ",
"recursiveOn": "כלול תיקיות משנה",
@@ -1392,6 +1396,21 @@
"versionDeleted": "הגרסה נמחקה"
}
}
},
"metadataFetchSummary": {
"title": "סיכום שליפת מטא-דאטה",
"statSuccess": "הצלחה",
"statFailed": "נכשל",
"statSkipped": "דולג",
"statTotal": "סה\"כ נסרק",
"statDuration": "משך",
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
"failedItems": "פריטים נכשלים ({count})",
"close": "סגור",
"copyReport": "העתק דוח",
"downloadCsv": "הורד CSV",
"columnModelName": "שם המודל",
"columnError": "שגיאה"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "הועתק ללוח",
"downloadStarted": "ההורדה החלה"
}
},
"doctor": {

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "読み込み中...",
"cancelling": "キャンセル中...",
"unknown": "不明",
"date": "日付",
"version": "バージョン",
@@ -448,7 +449,9 @@
"modelName": "モデル名",
"fileName": "ファイル名"
},
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択",
"cardBlurAmount": "カードオーバーレイのぼかし",
"cardBlurAmountHelp": "モデルカードとレシピカードのヘッダー・フッターオーバーレイのぼかし強度を調整します0 = ぼかしなし、20 = 最大ぼかし)。"
},
"folderSettings": {
"activeLibrary": "アクティブライブラリ",
@@ -693,7 +696,7 @@
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"reimportMetadata": "選択したレシピを再インポート",
"reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ",
"pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除",
"hideOnThisPage": "このページでサイドバーを非表示",
"showSidebar": "サイドバーを表示",
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
"switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリー表示に切り替え",
"recursiveOn": "サブフォルダーを含める",
@@ -1392,6 +1396,21 @@
"versionDeleted": "バージョンを削除しました"
}
}
},
"metadataFetchSummary": {
"title": "メタデータ取得サマリー",
"statSuccess": "成功",
"statFailed": "失敗",
"statSkipped": "スキップ",
"statTotal": "スキャン合計",
"statDuration": "所要時間",
"successMessage": "すべての{count}件の{type}を正常に更新しました",
"failedItems": "失敗したアイテム ({count})",
"close": "閉じる",
"copyReport": "レポートをコピー",
"downloadCsv": "CSVをダウンロード",
"columnModelName": "モデル名",
"columnError": "エラー"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "クリップボードにコピーしました",
"downloadStarted": "ダウンロードを開始しました"
}
},
"doctor": {

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "로딩 중...",
"cancelling": "취소 중...",
"unknown": "알 수 없음",
"date": "날짜",
"version": "버전",
@@ -448,7 +449,9 @@
"modelName": "모델명",
"fileName": "파일명"
},
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요",
"cardBlurAmount": "카드 오버레이 흐림 강도",
"cardBlurAmountHelp": "모델 및 레시피 카드의 헤더와 푸터 오버레이 흐림 강도를 조정합니다 (0 = 흐림 없음, 20 = 최대 흐림)."
},
"folderSettings": {
"activeLibrary": "활성 라이브러리",
@@ -693,7 +696,7 @@
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"reimportMetadata": "선택한 레시피 다시 가져오기",
"reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "루트",
"collapseAll": "모든 폴더 접기",
"pinSidebar": "사이드바 고정",
"unpinSidebar": "사이드바 고정 해제",
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
"showSidebar": "사이드바 표시",
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
"switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환",
"recursiveOn": "하위 폴더 포함",
@@ -1392,6 +1396,21 @@
"versionDeleted": "버전이 삭제되었습니다"
}
}
},
"metadataFetchSummary": {
"title": "메타데이터 가져오기 요약",
"statSuccess": "성공",
"statFailed": "실패",
"statSkipped": "건너뜀",
"statTotal": "총 스캔",
"statDuration": "소요 시간",
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
"failedItems": "실패한 항목 ({count})",
"close": "닫기",
"copyReport": "보고서 복사",
"downloadCsv": "CSV 다운로드",
"columnModelName": "모델 이름",
"columnError": "오류"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "클립보드에 복사됨",
"downloadStarted": "다운로드 시작됨"
}
},
"doctor": {

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "Загрузка...",
"cancelling": "Отмена...",
"unknown": "Неизвестно",
"date": "Дата",
"version": "Версия",
@@ -448,7 +449,9 @@
"modelName": "Название модели",
"fileName": "Имя файла"
},
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели",
"cardBlurAmount": "Размытие наложения карточек",
"cardBlurAmountHelp": "Настройте интенсивность размытия наложений верхнего и нижнего колонтитулов на карточках моделей и рецептов (0 = без размытия, 20 = максимальное размытие)."
},
"folderSettings": {
"activeLibrary": "Активная библиотека",
@@ -693,7 +696,7 @@
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"reimportMetadata": "Переимпортировать метаданные для выбранных",
"reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "Корень",
"collapseAll": "Свернуть все папки",
"pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель",
"hideOnThisPage": "Скрыть боковую панель на этой странице",
"showSidebar": "Показать боковую панель",
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
"switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид",
"recursiveOn": "Включать вложенные папки",
@@ -1392,6 +1396,21 @@
"versionDeleted": "Версия удалена"
}
}
},
"metadataFetchSummary": {
"title": "Сводка получения метаданных",
"statSuccess": "Успешно",
"statFailed": "Ошибка",
"statSkipped": "Пропущено",
"statTotal": "Всего проверено",
"statDuration": "Длительность",
"successMessage": "Все {count} {type}s успешно обновлены",
"failedItems": "Ошибочные элементы ({count})",
"close": "Закрыть",
"copyReport": "Копировать отчет",
"downloadCsv": "Скачать CSV",
"columnModelName": "Имя модели",
"columnError": "Ошибка"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Скопировано в буфер обмена",
"downloadStarted": "Загрузка начата"
}
},
"doctor": {

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "加载中...",
"cancelling": "取消中...",
"unknown": "未知",
"date": "日期",
"version": "版本",
@@ -448,7 +449,9 @@
"modelName": "模型名称",
"fileName": "文件名"
},
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容",
"cardBlurAmount": "卡片叠加模糊强度",
"cardBlurAmountHelp": "调整模型和配方卡片上页眉和页脚叠加层的模糊强度0 = 无模糊20 = 最大模糊)。"
},
"folderSettings": {
"activeLibrary": "活动库",
@@ -693,7 +696,7 @@
"copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"reimportMetadata": "重新导入所选配方元数据",
"reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹",
"autoOrganize": "自动整理所选模型",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "根目录",
"collapseAll": "折叠所有文件夹",
"pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏",
"hideOnThisPage": "隐藏此页面侧边栏",
"showSidebar": "显示侧边栏",
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
"switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图",
"recursiveOn": "包含子文件夹",
@@ -1392,6 +1396,21 @@
"versionDeleted": "版本已删除"
}
}
},
"metadataFetchSummary": {
"title": "元数据获取摘要",
"statSuccess": "成功",
"statFailed": "失败",
"statSkipped": "已跳过",
"statTotal": "总计扫描",
"statDuration": "耗时",
"successMessage": "全部 {count} 个 {type} 更新成功!",
"failedItems": "失败项目 ({count})",
"close": "关闭",
"copyReport": "复制报告",
"downloadCsv": "下载 CSV",
"columnModelName": "模型名称",
"columnError": "错误"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
"exampleImagesDownloadSuccess": "示例图片下载成功!",
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "已复制到剪贴板",
"downloadStarted": "下载已开始"
}
},
"doctor": {

View File

@@ -22,6 +22,7 @@
},
"status": {
"loading": "載入中...",
"cancelling": "取消中...",
"unknown": "未知",
"date": "日期",
"version": "版本",
@@ -448,7 +449,9 @@
"modelName": "模型名稱",
"fileName": "檔案名稱"
},
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容",
"cardBlurAmount": "卡片疊加模糊強度",
"cardBlurAmountHelp": "調整模型和配方卡片上頁首和頁尾疊加層的模糊強度0 = 無模糊20 = 最大模糊)。"
},
"folderSettings": {
"activeLibrary": "使用中的資料庫",
@@ -693,7 +696,7 @@
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"reimportMetadata": "重新匯入所選配方元數據",
"reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型",
@@ -954,8 +957,9 @@
"sidebar": {
"modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾",
"pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄",
"hideOnThisPage": "隱藏此頁面側邊欄",
"showSidebar": "顯示側邊欄",
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
"switchToListView": "切換至列表檢視",
"switchToTreeView": "切換到樹狀檢視",
"recursiveOn": "包含子資料夾",
@@ -1392,6 +1396,21 @@
"versionDeleted": "已刪除此版本"
}
}
},
"metadataFetchSummary": {
"title": "元資料獲取摘要",
"statSuccess": "成功",
"statFailed": "失敗",
"statSkipped": "已跳過",
"statTotal": "總計掃描",
"statDuration": "耗時",
"successMessage": "全部 {count} 個 {type} 更新成功!",
"failedItems": "失敗項目 ({count})",
"close": "關閉",
"copyReport": "複製報告",
"downloadCsv": "下載 CSV",
"columnModelName": "模型名稱",
"columnError": "錯誤"
}
},
"modelTags": {
@@ -1951,7 +1970,9 @@
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
"moveFailed": "Failed to move item: {message}"
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "已複製到剪貼簿",
"downloadStarted": "下載已開始"
}
},
"doctor": {

View File

@@ -33,6 +33,7 @@ from .utils.example_images_migration import ExampleImagesMigration
from .services.websocket_manager import ws_manager
from .services.example_images_cleanup_service import ExampleImagesCleanupService
from .middleware.csp_middleware import relax_csp_for_remote_media
from .middleware.error_middleware import api_json_error
logger = logging.getLogger(__name__)
@@ -76,6 +77,11 @@ class LoraManager:
"""Initialize and register all routes using the new refactored architecture"""
app = PromptServer.instance.app
# Register JSON error middleware for /api/* routes as the outermost
# middleware so it catches errors from all other middlewares.
if api_json_error not in app.middlewares:
app.middlewares.insert(0, api_json_error)
if relax_csp_for_remote_media not in app.middlewares:
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
# see and extend the restrictive header instead of being overwritten by it.
@@ -189,6 +195,10 @@ class LoraManager:
# Register DownloadManager with ServiceRegistry
await ServiceRegistry.get_download_manager()
# Initialize DownloadQueueService for persistent queue/history
await ServiceRegistry.get_download_queue_service()
await ServiceRegistry.get_backup_service()
from .services.metadata_service import initialize_metadata_providers
@@ -426,5 +436,14 @@ class LoraManager:
try:
logger.info("LoRA Manager: Cleaning up services")
# Cancel any in-flight scanner initialization tasks so thread-pool
# workers (e.g. _initialize_cache_sync) can break out of their loops
# when the server shuts down (e.g. Ctrl+C on WSL).
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
scanner = ServiceRegistry.get_service_sync(name)
if scanner is not None and hasattr(scanner, "cancel_task"):
scanner.cancel_task()
logger.debug("LoRA Manager: Cancelled %s", name)
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)

View File

@@ -16,6 +16,8 @@ IMG_EXTENSIONS = (
".tif",
".tiff",
".webp",
".avif",
".jxl",
".mp4"
)

View File

@@ -0,0 +1,71 @@
"""JSON error middleware for API routes.
Ensures all responses to /api/* requests return valid JSON that the
browser-extension frontend can JSON.parse() without crashing, even when
the route does not exist (404) or the handler raises an exception (500).
Extension consumers call response.json() unconditionally — an HTML error
page causes ``SyntaxError: unexpected end of data`` that leaks into the
popup UI as a toast notification.
"""
from __future__ import annotations
import logging
from typing import Awaitable, Callable
from aiohttp import web
logger = logging.getLogger(__name__)
@web.middleware
async def api_json_error(
request: web.Request,
handler: Callable[[web.Request], Awaitable[web.Response]],
) -> web.Response:
"""Return JSON ``{"success": false, "error": "..."}`` for API errors.
Only intercepts paths starting with ``/api/`` — all other routes
(frontend pages, static files, WebSocket upgrades) pass through
unchanged.
"""
if not request.path.startswith("/api/"):
return await handler(request)
try:
response = await handler(request)
return response
except web.HTTPException as exc:
# Let redirects (301, 302, 307, 308) propagate — they are not errors.
if exc.status < 400:
raise
logger.warning(
"API %s %s returned HTTP %d: %s",
request.method,
request.path,
exc.status,
exc.reason,
)
return web.json_response(
{"success": False, "error": f"{exc.status}: {exc.reason}"},
status=exc.status,
)
except Exception as exc:
logger.error(
"API %s %s raised unhandled exception: %s",
request.method,
request.path,
exc,
exc_info=True,
)
return web.json_response(
{
"success": False,
"error": f"500: Internal Server Error ({type(exc).__name__})",
},
status=500,
)

View File

@@ -11,7 +11,7 @@ from ..metadata_collector.metadata_processor import MetadataProcessor
from ..metadata_collector import get_metadata
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils
from ..utils.utils import calculate_recipe_fingerprint
from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
from PIL import Image, PngImagePlugin
import piexif
import logging
@@ -298,7 +298,12 @@ class SaveImageLM:
key = parts[0]
if key == "seed" and "seed" in metadata_dict:
filename = filename.replace(segment, str(metadata_dict.get("seed", "")))
seed_value = metadata_dict.get("seed")
if seed_value is not None:
filename = filename.replace(segment, str(seed_value))
else:
# Fallback if seed was not captured by metadata collector
filename = filename.replace(segment, "0")
elif key == "width" and "size" in metadata_dict:
size = metadata_dict.get("size", "x")
w = size.split("x")[0] if isinstance(size, str) else size[0]
@@ -309,12 +314,14 @@ class SaveImageLM:
filename = filename.replace(segment, str(h))
elif key == "pprompt" and "prompt" in metadata_dict:
prompt = metadata_dict.get("prompt", "").replace("\n", " ")
prompt = sanitize_folder_name(prompt)
if len(parts) >= 2:
length = int(parts[1])
prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip())
elif key == "nprompt" and "negative_prompt" in metadata_dict:
prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ")
prompt = sanitize_folder_name(prompt)
if len(parts) >= 2:
length = int(parts[1])
prompt = prompt[:length]
@@ -328,6 +335,7 @@ class SaveImageLM:
model = "model_unavailable"
else:
model = os.path.splitext(os.path.basename(model_value))[0]
model = sanitize_folder_name(model)
if len(parts) >= 2:
length = int(parts[1])
model = model[:length]

View File

@@ -37,6 +37,7 @@ from ...services.use_cases import (
)
from ...services.websocket_manager import WebSocketManager
from ...services.websocket_progress_callback import WebSocketProgressCallback
from ...services.download_queue_service import DownloadQueueService
from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload
from ...utils.file_utils import calculate_sha256
@@ -1567,6 +1568,258 @@ class ModelDownloadHandler:
)
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
completed_at_raw = request.query.get("completed_at")
completed_at = float(completed_at_raw) if completed_at_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,
completed_at=completed_at,
)
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:
"""CivitAI integration endpoints."""
@@ -1608,7 +1861,9 @@ class ModelCivitaiHandler:
return web.json_response(result)
except Exception as exc:
self._logger.error(
"Error in fetch_all_civitai for %ss: %s", self._service.model_type, exc
"Error in fetch_all_civitai for %ss: %s",
self._service.model_type, exc,
exc_info=True,
)
return web.Response(text=str(exc), status=500)
@@ -2596,6 +2851,19 @@ class ModelHandlerSet:
"pause_download_get": self.download.pause_download_get,
"resume_download_get": self.download.resume_download_get,
"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_model_by_version": self.civitai.get_civitai_model_by_version,
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,

View File

@@ -13,7 +13,7 @@ from ...config import config as global_config
logger = logging.getLogger(__name__)
_CHUNK_SIZE = 256 * 1024 # 256 KB
_CHUNK_SIZE = 1024 * 1024 # 1 MB — balance between streaming iteration overhead and per-chunk memory
# Video file extensions that bypass native sendfile on Windows
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
@@ -55,16 +55,19 @@ class PreviewHandler:
logger.debug("Preview file not found at %s", str(resolved))
raise web.HTTPNotFound(text="Preview file not found")
# Video files: stream manually to avoid Windows native sendfile crash.
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based),
# which breaks when the client disconnects mid-transfer — this happens
# constantly when users scroll through a gallery of animated previews.
suffix = resolved.suffix.lower()
if suffix in _VIDEO_EXTENSIONS:
return await self._stream_file(request, resolved)
# aiohttp's FileResponse handles range requests and content headers for us.
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
# aiohttp's FileResponse handles range requests, content headers, and
# uses kernel sendfile (zero-copy DMA) on Linux/macOS. On Windows it
# uses IOCP-based _sendfile_native which can crash when the client
# disconnects mid-transfer during fast scrolling. The _stream_file()
# fallback is kept for a future compat toggle.
#
# Set explicit Cache-Control so the browser can cache video (and image)
# previews across VirtualScroller recycling cycles. Without this,
# Chrome does not cache 206 Partial Content responses for <video>
# elements, causing the same video to be re-downloaded on every scroll.
resp = web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
async def _stream_file(
self, request: web.Request, path: Path
@@ -83,6 +86,10 @@ class PreviewHandler:
resp.content_type = content_type
resp.content_length = file_size
# Allow browser caching: video previews rarely change during a session.
# The frontend already appends ?t={version} to bust cache on update.
resp.headers["Cache-Control"] = "public, max-age=86400"
await resp.prepare(request)
try:

View File

@@ -1597,15 +1597,8 @@ class RecipeManagementHandler:
cache = await recipe_scanner.get_cached_data()
# Build lookup: image_id -> recipe_id from stored source_path
image_to_recipe = {}
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in image_to_recipe:
image_to_recipe[image_id] = recipe.get("id")
# Use precomputed image_id_map (built once at cache init)
image_to_recipe = getattr(cache, "image_id_map", {})
results = {}
for img_id in requested_ids:
@@ -1641,20 +1634,22 @@ class RecipeManagementHandler:
"Could not extract Civitai image ID from URL"
)
# Check for duplicate (fast, before acquiring semaphore), unless force
if not force:
cache = await recipe_scanner.get_cached_data()
for recipe in getattr(cache, "raw_data", []):
source = recipe.get("source_path")
if source:
existing_id = extract_civitai_image_id(source)
if existing_id == image_id:
return web.json_response({
"success": True,
"recipe_id": recipe.get("id"),
"name": recipe.get("title", ""),
"already_exists": True,
})
image_to_recipe = getattr(cache, "image_id_map", {})
existing_recipe_id = image_to_recipe.get(image_id)
if existing_recipe_id:
recipe_name = ""
for recipe in getattr(cache, "raw_data", []):
if str(recipe.get("id", "")) == existing_recipe_id:
recipe_name = recipe.get("title", "") or ""
break
return web.json_response({
"success": True,
"recipe_id": existing_recipe_id,
"name": recipe_name,
"already_exists": True,
})
async with self._import_semaphore:
return await self._do_import_from_url(image_url, recipe_scanner)

View File

@@ -107,6 +107,37 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"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("GET", "/{prefix}", "handle_models_page"),
)

View File

@@ -0,0 +1,871 @@
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()
await cls._instance.deduplicate()
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,
completed_at: Optional[float] = 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``).
When *completed_at* is provided it is used as the completion
timestamp; otherwise ``time.time()`` is used.
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 = completed_at if completed_at is not None else 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, and the original
history entry is **deleted** to prevent exponential growth when
the retried item is later canceled or fails again and re-retried.
"""
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.execute(
"DELETE FROM download_history WHERE id = ?",
(item_id,),
)
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.
Each history entry is **deleted** after being re-queued so that
repeated retry-all calls do not cause exponential growth.
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,
),
)
conn.execute(
"DELETE FROM download_history WHERE id = ?",
(row["id"],),
)
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),
}
# ------------------------------------------------------------------
# Deduplication (one-time cleanup for bug #980)
# ------------------------------------------------------------------
async def deduplicate(self) -> dict[str, int]:
"""Remove duplicate entries caused by the retry-amplification bug.
The bug (issue #980) caused the same download to appear N times in
both the queue and history tables when ``retry_all_failed`` was
called repeatedly without deleting the original history rows.
This method is called **once** when the singleton is first created.
It is idempotent — after the first run there will be no duplicates
to remove, so subsequent calls are a no-op.
Returns a dict with the count of removed rows per table.
"""
result: dict[str, int] = {
"removed_history": 0,
"removed_queue": 0,
"removed_orphan_queue": 0,
}
async with self._lock:
conn = self._get_conn()
# 1. History: for each (model_id, model_version_id, status) triplet
# keep only the row with the highest id (most recently inserted).
conn.execute("""
DELETE FROM download_history
WHERE id NOT IN (
SELECT MAX(id)
FROM download_history
GROUP BY model_id, model_version_id, status
)
""")
result["removed_history"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
# 2. Cross-status dedup: for each (model_id, model_version_id),
# keep only the entry with the highest-priority terminal status.
# Priority: completed (3) > failed (2) > canceled (1).
# This prevents the same model version from having both a
# 'failed' and a 'canceled' entry (or a 'completed' alongside
# either) after the bug-created duplicates are removed.
conn.execute("""
DELETE FROM download_history
WHERE id NOT IN (
SELECT dh.id
FROM download_history dh
INNER JOIN (
SELECT model_id, model_version_id,
MAX(CASE status
WHEN 'completed' THEN 3
WHEN 'failed' THEN 2
WHEN 'canceled' THEN 1
ELSE 0
END) AS best_prio
FROM download_history
GROUP BY model_id, model_version_id
) best
ON dh.model_id = best.model_id
AND dh.model_version_id = best.model_version_id
AND CASE dh.status
WHEN 'completed' THEN 3
WHEN 'failed' THEN 2
WHEN 'canceled' THEN 1
ELSE 0
END = best.best_prio
GROUP BY dh.model_id, dh.model_version_id
HAVING dh.id = MAX(dh.id)
)
""")
result["removed_history"] += conn.execute(
"SELECT changes()"
).fetchone()[0]
# 3. Queue: for each (model_id, model_version_id) keep only the
# row with the latest added_at (most recently enqueued).
conn.execute("""
DELETE FROM download_queue
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM download_queue
WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
GROUP BY model_id, model_version_id
)
AND status IN ('queued', 'downloading', 'paused', 'waiting')
""")
result["removed_queue"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
# 4. Remove orphaned queue entries — items that were re-queued
# (source='retry') but whose model version already has a
# terminal history entry. These are artifacts of the buggy
# retry cycle that were never cleaned up.
conn.execute("""
DELETE FROM download_queue
WHERE source = 'retry'
AND (model_id, model_version_id) IN (
SELECT model_id, model_version_id
FROM download_history
WHERE status IN ('failed', 'canceled')
)
AND status IN ('queued', 'waiting')
""")
result["removed_orphan_queue"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
conn.commit()
logger.info(
"Deduplicate: removed %s history rows, %s queue rows, "
"%s orphaned queue rows",
result["removed_history"],
result["removed_queue"],
result["removed_orphan_queue"],
)
return result

View File

@@ -256,7 +256,9 @@ class Downloader:
self._session = None
# Check for app-level proxy settings
proxy_url = None
proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
socks_proxy_url = None # SOCKS proxy, handled via aiohttp-socks connector
app_proxy_active = False
settings_manager = get_settings_manager()
if settings_manager.get("proxy_enabled", False):
proxy_host = settings_manager.get("proxy_host", "").strip()
@@ -268,9 +270,19 @@ class Downloader:
if proxy_host and proxy_port:
# Build proxy URL
if proxy_username and proxy_password:
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
full_proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
else:
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
full_proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
app_proxy_active = True
# aiohttp cannot tunnel SOCKS via the per-request `proxy=` kwarg
# (it would send HTTP to the SOCKS port and fail parsing the
# SOCKS handshake reply). SOCKS must be handled by an
# aiohttp-socks ProxyConnector instead.
if proxy_type.startswith("socks"):
socks_proxy_url = full_proxy_url
else:
proxy_url = full_proxy_url
logger.debug(
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
@@ -294,13 +306,27 @@ class Downloader:
logger.debug("SSL: certifi unavailable; using system default CA bundle")
# Optimize TCP connection parameters
connector = aiohttp.TCPConnector(
connector_kwargs = dict(
ssl=ssl_context,
limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse
enable_cleanup_closed=True,
)
if socks_proxy_url:
# Route all traffic through the SOCKS proxy via aiohttp-socks. The
# connector tunnels every connection, so no per-request `proxy=` is
# used (and must not be — see self._proxy_url below).
try:
from aiohttp_socks import ProxyConnector
except ImportError as e: # pragma: no cover
raise RuntimeError(
"A SOCKS proxy is configured but the 'aiohttp-socks' package "
"is not installed. Install it with: pip install aiohttp-socks"
) from e
connector = ProxyConnector.from_url(socks_proxy_url, **connector_kwargs)
else:
connector = aiohttp.TCPConnector(**connector_kwargs)
# Configure timeout parameters
timeout = aiohttp.ClientTimeout(
@@ -311,12 +337,14 @@ class Downloader:
self._session = aiohttp.ClientSession(
connector=connector,
trust_env=proxy_url
is None, # Only use system proxy if no app-level proxy is set
# Only fall back to system/env proxy when no app-level proxy is active
trust_env=not app_proxy_active,
timeout=timeout,
)
# Store proxy URL for use in requests
# Store proxy URL for per-request use. Stays None for SOCKS because the
# ProxyConnector already tunnels everything; passing proxy= for SOCKS
# would re-trigger the original aiohttp parse error.
self._proxy_url = proxy_url
self._session_created_at = datetime.now()

View File

@@ -216,13 +216,19 @@ class MetadataSyncService:
provider_used: Optional[str] = None
last_error: Optional[str] = None
civitai_api_not_found = False
any_rate_limited = False
for provider_name, provider in provider_attempts:
try:
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
except RateLimitError as exc:
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
raise
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
provider_name or provider.__class__.__name__,
exc.retry_after or 0,
)
any_rate_limited = True
continue
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
civitai_metadata_candidate, error = None, str(exc)
@@ -258,6 +264,14 @@ class MetadataSyncService:
model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True
# When the model was already classified as "not on CivitAI" via
# .metadata.json (civitai_deleted=True) but the SQLite cache is
# stale (because the pre-fix code never persisted these flags),
# ensure the flags are written to the scanner cache + SQLite.
if not needs_save and model_data.get("civitai_deleted") is True:
model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True
# Save metadata if any state was updated
if needs_save:
data_to_save = model_data.copy()
@@ -266,6 +280,7 @@ class MetadataSyncService:
if "last_checked_at" not in data_to_save:
data_to_save["last_checked_at"] = datetime.now().timestamp()
await self._metadata_manager.save_metadata(file_path, data_to_save)
await update_cache_func(file_path, file_path, data_to_save)
default_error = (
"CivitAI model is deleted and metadata archive DB is not enabled"
@@ -276,17 +291,18 @@ class MetadataSyncService:
)
resolved_error = last_error or default_error
if any_rate_limited and "Rate limited" not in resolved_error:
resolved_error = "Rate limited"
if is_expected_offline_error(resolved_error):
resolved_error = OFFLINE_FRIENDLY_MESSAGE
error_msg = (
f"Error fetching metadata: {resolved_error} "
f"(model_name={model_data.get('model_name', '')})"
f"(file={os.path.basename(file_path)}, sha256={sha256})"
)
if is_expected_offline_error(resolved_error):
logger.info(error_msg)
else:
logger.error(error_msg)
# Use case layer (BulkMetadataRefreshUseCase) logs failed models at WARNING level,
# so this level is demoted to DEBUG to avoid duplicate user-visible logging.
logger.debug(error_msg)
return False, error_msg
model_data["from_civitai"] = True

View File

@@ -65,7 +65,14 @@ class _RateLimitRetryHelper:
return await func(*args, **kwargs)
except RateLimitError as exc:
attempt += 1
if attempt >= self._retry_limit:
# Determine effective retry limit based on rate-limit magnitude
effective_retry_limit = self._retry_limit # default: 3
if exc.retry_after is not None and exc.retry_after >= 120.0:
# Long rate-limit window (>=2 min) — retries are futile
effective_retry_limit = 1 # total 1 attempt = 0 retries
if attempt >= effective_retry_limit:
exc.provider = exc.provider or label
raise
@@ -81,7 +88,11 @@ class _RateLimitRetryHelper:
def _calculate_delay(self, retry_after: Optional[float], attempt: int) -> float:
if retry_after is not None:
return min(self._max_delay, max(0.0, retry_after))
# Cap at 1800s (30 min) as a safety ceiling. The old 30s cap was
# too low — CivArchive can return retry_after ~1500s, causing all
# retries to fail. A generous ceiling protects against pathological
# server values while still respecting the server's guidance.
return min(1800.0, max(0.0, retry_after))
base_delay = self._base_delay * (2 ** max(0, attempt - 1))
jitter_span = base_delay * self._jitter_ratio
@@ -474,8 +485,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result, error
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
continue
@@ -493,16 +508,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result
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
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except ResourceNotFoundError:
not_found_confirmed = True
logger.debug(
@@ -528,8 +539,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_version: %s", label, e)
continue
@@ -546,8 +561,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result, error
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
continue
@@ -568,8 +587,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
except NotImplementedError:
continue
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug(
"Provider %s failed for get_model_versions_by_hashes: %s",
@@ -590,8 +613,12 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result is not None:
return result
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_user_models: %s", label, e)
continue

View File

@@ -532,6 +532,13 @@ class ModelScanner:
if not scan_result or not getattr(self, '_persistent_cache', None):
return
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
"after cancellation"
)
return
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
loop = asyncio.get_event_loop()
try:
@@ -705,14 +712,20 @@ class ModelScanner:
# Determine the page type based on model type
# Scan for new data
scan_result = await self._gather_model_data()
await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan')
if not self.is_cancelled():
await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan')
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
f"found {len(scan_result.raw_data)} models"
)
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
f"found {len(scan_result.raw_data)} models"
)
else:
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
f"after {time.time() - start_time:.2f} seconds"
)
except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
# Ensure cache is at least an empty structure on error
@@ -1067,8 +1080,11 @@ class ModelScanner:
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
if not model_data.get('sha256') and file_path:
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes).
# Respect hash_status='pending' (set by CheckpointScanner for large models) to defer
# hash calculation until on-demand — avoids reading entire checkpoint files at startup.
hash_status = model_data.get('hash_status', '')
if not model_data.get('sha256') and hash_status != 'pending' and file_path:
try:
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
sha256 = await calculate_sha256(file_path)
@@ -1093,6 +1109,13 @@ class ModelScanner:
if scan_result is None:
return
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _apply_scan_result "
"after cancellation"
)
return
self._hash_index = scan_result.hash_index
self._tags_count = dict(scan_result.tags_count)
self._excluded_models = list(scan_result.excluded_models)
@@ -1761,6 +1784,13 @@ class ModelScanner:
"""
if not file_paths or self._cache is None:
return False
if self.is_cancelled():
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping cache update "
"after cancelled bulk delete"
)
return False
try:
# Get all models that need to be removed from cache

View File

@@ -12,7 +12,7 @@ import logging
import os
import sqlite3
import threading
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
@@ -26,6 +26,8 @@ class PersistedRecipeData:
raw_data: List[Dict]
file_stats: Dict[str, Tuple[float, int]] # json_path -> (mtime, size)
image_id_map: Dict[str, str] = field(default_factory=dict)
"""Precomputed mapping of civitai image_id → recipe_id."""
class PersistentRecipeCache:
@@ -116,6 +118,20 @@ class PersistentRecipeCache:
if not rows:
return None
# Restore precomputed image_id_map if available
image_id_map: Dict[str, str] = {}
try:
meta_row = conn.execute(
"SELECT value FROM cache_metadata WHERE key = ?",
("image_id_map",),
).fetchone()
if meta_row:
parsed = json.loads(meta_row["value"])
if isinstance(parsed, dict):
image_id_map = parsed
except Exception:
pass # missing or corrupt — rebuilt on next cache refresh
finally:
conn.close()
except FileNotFoundError:
@@ -138,14 +154,24 @@ class PersistentRecipeCache:
row["file_size"] or 0,
)
return PersistedRecipeData(raw_data=raw_data, file_stats=file_stats)
return PersistedRecipeData(
raw_data=raw_data,
file_stats=file_stats,
image_id_map=image_id_map,
)
def save_cache(self, recipes: List[Dict], json_paths: Optional[Dict[str, str]] = None) -> None:
def save_cache(
self,
recipes: List[Dict],
json_paths: Optional[Dict[str, str]] = None,
image_id_map: Optional[Dict[str, str]] = None,
) -> None:
"""Save all recipes to SQLite cache.
Args:
recipes: List of recipe dictionaries to persist.
json_paths: Optional mapping of recipe_id -> json_path for file stats.
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
"""
if not self.is_enabled():
return
@@ -186,6 +212,12 @@ class PersistentRecipeCache:
recipe_rows,
)
# Persist image_id_map for O(1) lookups on cache load
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map or {})),
)
conn.commit()
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
finally:
@@ -273,6 +305,29 @@ class PersistentRecipeCache:
except Exception as exc:
logger.debug("Failed to remove recipe %s from cache: %s", recipe_id, exc)
def save_image_id_map(self, image_id_map: Dict[str, str]) -> None:
"""Persist the image_id_map to cache_metadata without rewriting the full cache.
This is called after ``add_recipe`` / ``remove_recipe`` mutations so
the persistent copy does not go stale between full ``save_cache`` calls.
"""
if not self.is_enabled() or not self._schema_initialized:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
("image_id_map", json.dumps(image_id_map)),
)
conn.commit()
finally:
conn.close()
except Exception as exc:
logger.debug("Failed to persist image_id_map: %s", exc)
def get_indexed_recipe_ids(self) -> Set[str]:
"""Return all recipe IDs in the cache.

View File

@@ -1,6 +1,6 @@
import asyncio
from typing import Iterable, List, Dict, Optional
from dataclasses import dataclass
from dataclasses import dataclass, field
from operator import itemgetter
from natsort import natsorted
@@ -14,6 +14,15 @@ class RecipeCache:
sorted_by_date: List[Dict]
folders: List[str] | None = None
folder_tree: Dict | None = None
image_id_map: Dict[str, str] = field(default_factory=dict)
"""Mapping of civitai image_id → recipe_id, precomputed at cache build time.
Built once during cache initialization (O(n)) so that
``check_image_exists`` and ``import_from_url`` duplicate checks
can look up image_id in O(1) instead of scanning all recipes.
Recipes imported from local files have no valid civitai image_id
and are naturally excluded from this map.
"""
def __post_init__(self):
self._lock = asyncio.Lock()

View File

@@ -20,6 +20,7 @@ from .metadata_service import get_default_metadata_provider
from .checkpoint_scanner import CheckpointScanner
from .settings_manager import get_settings_manager
from .recipes.errors import RecipeNotFoundError
from ..utils.civitai_utils import extract_civitai_image_id
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
from natsort import natsorted
import sys
@@ -532,7 +533,21 @@ class RecipeScanner:
self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration)
if self._backfill_source_path_if_needed(recipes, json_paths):
self._persistent_cache.save_cache(recipes, json_paths)
self._cache.image_id_map = self._build_image_id_map()
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
else:
# Use persisted map, or rebuild if empty (e.g. first startup
# after deploying the image_id_map feature).
if persisted.image_id_map:
self._cache.image_id_map = dict(persisted.image_id_map)
else:
self._cache.image_id_map = self._build_image_id_map()
if self._cache.image_id_map:
self._persistent_cache.save_image_id_map(
self._cache.image_id_map
)
return self._cache
else:
# Partial update: some files changed
@@ -545,8 +560,11 @@ class RecipeScanner:
self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration)
self._backfill_source_path_if_needed(recipes, json_paths)
self._cache.image_id_map = self._build_image_id_map()
# Persist updated cache
self._persistent_cache.save_cache(recipes, json_paths)
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
return self._cache
# Fall back to full directory scan
@@ -558,9 +576,12 @@ class RecipeScanner:
self._cache.raw_data = recipes
self._update_folder_metadata(self._cache)
self._sort_cache_sync()
self._cache.image_id_map = self._build_image_id_map()
# Persist for next startup
self._persistent_cache.save_cache(recipes, json_paths)
self._persistent_cache.save_cache(
recipes, json_paths, self._cache.image_id_map
)
return self._cache
except Exception as e:
@@ -832,6 +853,28 @@ class RecipeScanner:
except Exception as e:
logger.error(f"Error sorting recipe cache: {e}")
def _build_image_id_map(self) -> Dict[str, str]:
"""Build civitai image_id → recipe_id mapping from cached recipes.
Only recipes with a valid CivitAI image URL source_path produce an
entry. Recipes imported from local files are naturally excluded.
"""
mapping: Dict[str, str] = {}
if not self._cache:
return mapping
for recipe in getattr(self._cache, "raw_data", []):
if not isinstance(recipe, dict):
continue
source = recipe.get("source_path")
if not source:
continue
image_id = extract_civitai_image_id(source)
if image_id and image_id not in mapping:
recipe_id = recipe.get("id")
if recipe_id is not None:
mapping[image_id] = str(recipe_id)
return mapping
async def _wait_for_lora_scanner(self) -> None:
"""Ensure the LoRA scanner has initialized before recipe enrichment."""
@@ -1296,11 +1339,20 @@ class RecipeScanner:
# Update FTS index
self._update_fts_index_for_recipe(recipe_data, "add")
source = recipe_data.get("source_path")
if source:
image_id = extract_civitai_image_id(source)
if image_id:
recipe_id_value = recipe_data.get("id")
if recipe_id_value is not None:
cache.image_id_map[image_id] = str(recipe_id_value)
# Persist to SQLite cache
if self._persistent_cache:
recipe_id = str(recipe_data.get("id", ""))
json_path = self._json_path_map.get(recipe_id, "")
self._persistent_cache.update_recipe(recipe_data, json_path)
self._persistent_cache.save_image_id_map(cache.image_id_map)
async def remove_recipe(self, recipe_id: str) -> bool:
"""Remove a recipe from the cache by ID."""
@@ -1319,9 +1371,15 @@ class RecipeScanner:
# Update FTS index
self._update_fts_index_for_recipe(recipe_id, "remove")
# Remove any image_id entry pointing to this recipe
stale = [k for k, v in cache.image_id_map.items() if v == recipe_id]
for k in stale:
del cache.image_id_map[k]
# Remove from SQLite cache
if self._persistent_cache:
self._persistent_cache.remove_recipe(recipe_id)
self._persistent_cache.save_image_id_map(cache.image_id_map)
self._json_path_map.pop(recipe_id, None)
return True
@@ -1332,14 +1390,21 @@ class RecipeScanner:
cache = await self.get_cached_data()
removed = await cache.bulk_remove(recipe_ids, resort=False)
if removed:
removed_ids = {str(r.get("id", "")) for r in removed}
stale = [k for k, v in cache.image_id_map.items() if v in removed_ids]
for k in stale:
del cache.image_id_map[k]
self._schedule_resort()
# Update FTS index and persistent cache for each removed recipe
for recipe in removed:
recipe_id = str(recipe.get("id", ""))
self._update_fts_index_for_recipe(recipe_id, "remove")
if self._persistent_cache:
self._persistent_cache.remove_recipe(recipe_id)
self._json_path_map.pop(recipe_id, None)
if self._persistent_cache:
self._persistent_cache.save_image_id_map(cache.image_id_map)
return len(removed)
async def scan_all_recipes(self) -> List[Dict]:

View File

@@ -129,6 +129,18 @@ class RecipePersistenceService:
if nsfw_level is not None and isinstance(nsfw_level, int):
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_path = os.path.join(recipes_dir, json_filename)
json_path = os.path.normpath(json_path)

View File

@@ -188,6 +188,25 @@ class ServiceRegistry:
logger.debug(f"Created and registered {service_name}")
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
async def get_backup_service(cls):
"""Get or create the backup service."""

View File

@@ -91,7 +91,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"autoplay_on_hover": False,
"display_density": "default",
"card_info_display": "always",
"show_folder_sidebar": True,
"include_trigger_words": False,
"compact_mode": False,
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional, Protocol, Sequence
from ..metadata_sync_service import MetadataSyncService
@@ -62,26 +63,48 @@ class BulkMetadataRefreshUseCase:
]
total_to_process = len(to_process)
initial_skipped = total_models - total_to_process # models excluded from fetch queue
processed = 0
success = 0
skipped_count = initial_skipped
handled_count = initial_skipped
needs_resort = False
start_time = time.monotonic()
failures: List[Dict[str, str]] = []
self._service.scanner.reset_cancellation()
async def emit(status: str, **extra: Any) -> None:
if progress_callback is None:
return
payload = {"status": status, "total": total_to_process, "processed": processed, "success": success}
payload = {
"status": status,
"total": total_models,
"processed": processed,
"success": success,
"failure_count": len(failures),
"skipped_count": skipped_count,
"handled": handled_count,
"elapsed_seconds": int(time.monotonic() - start_time),
}
# Only include full failure details in terminal emits (completed,
# cancelled, rate_limited) to avoid serializing the list on every
# per-model progress update.
if failures and status in ("completed", "cancelled", "rate_limited"):
payload["failures"] = failures
payload.update(extra)
await progress_callback.on_progress(payload)
await emit("started")
RATE_LIMIT_ABORT_THRESHOLD = 3
consecutive_rate_limits = 0
for model in to_process:
if self._service.scanner.is_cancelled():
self._logger.info("Bulk metadata refresh cancelled by user")
await emit("cancelled", processed=processed, success=success)
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
try:
original_name = model.get("model_name")
@@ -101,31 +124,76 @@ class BulkMetadataRefreshUseCase:
model["hash_status"] = "completed"
else:
self._logger.error(f"Failed to calculate hash for {file_path}")
failures.append({"name": model.get("model_name", file_path or "Unknown"), "error": "Failed to calculate hash"})
processed += 1
handled_count += 1
continue
else:
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
skipped_count += 1
processed += 1
handled_count += 1
continue
# Skip models without valid hash
if not model.get("sha256"):
self._logger.warning(f"Skipping model without hash: {file_path}")
skipped_count += 1
processed += 1
handled_count += 1
continue
await MetadataManager.hydrate_model_data(model)
result, _ = await self._metadata_sync.fetch_and_update_model(
result, error_msg = await self._metadata_sync.fetch_and_update_model(
sha256=model["sha256"],
file_path=model["file_path"],
model_data=model,
update_cache_func=self._service.scanner.update_single_model_cache,
)
if not result and error_msg and "Rate limited" in error_msg:
consecutive_rate_limits += 1
else:
consecutive_rate_limits = 0
if not result:
current_name = model.get("model_name", file_path or "Unknown")
failures.append({"name": current_name, "error": error_msg or "Unknown error"})
self._logger.warning("Failed to fetch metadata for %s: %s", current_name, error_msg)
if consecutive_rate_limits >= RATE_LIMIT_ABORT_THRESHOLD:
# The current model was attempted and failed due to rate limiting;
# count it before aborting so the summary is consistent.
processed += 1
handled_count += 1
self._logger.warning(
"Bulk metadata refresh aborted: %d consecutive rate limits detected. "
"Processed %d/%d models.",
consecutive_rate_limits,
processed,
total_to_process,
)
await emit(
"rate_limited",
)
return {
"success": False,
"message": f"Rate limit detected; {total_to_process - processed} models skipped",
"processed": processed,
"updated": success,
"total": total_models,
"failures": failures,
"failure_count": len(failures),
"skipped_count": skipped_count,
"elapsed_seconds": int(time.monotonic() - start_time),
}
if result:
success += 1
if original_name != model.get("model_name"):
needs_resort = True
processed += 1
handled_count += 1
await emit(
"processing",
processed=processed,
@@ -134,6 +202,9 @@ class BulkMetadataRefreshUseCase:
)
except Exception as exc: # pragma: no cover - logging path
processed += 1
handled_count += 1
current_name = model.get("model_name", model.get("file_path", "Unknown"))
failures.append({"name": current_name, "error": str(exc)})
self._logger.error(
"Error fetching CivitAI data for %s: %s",
model.get("file_path"),
@@ -150,7 +221,7 @@ class BulkMetadataRefreshUseCase:
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
)
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models}
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
@staticmethod
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:

View File

@@ -31,6 +31,8 @@ PREVIEW_EXTENSIONS = [
".mp4",
".gif",
".webm",
".avif",
".jxl",
]
# Card preview image width
@@ -41,7 +43,7 @@ EXAMPLE_IMAGE_WIDTH = 832
# Supported media extensions for example downloads
SUPPORTED_MEDIA_EXTENSIONS = {
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif"],
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".jxl"],
"videos": [".mp4", ".webm"],
}

View File

@@ -62,6 +62,10 @@ class ExampleImagesProcessor:
return '.gif'
elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
return '.webp'
elif len(content) >= 12 and content[4:8] == b'ftyp' and b'avif' in content[8:24]:
return '.avif'
elif content.startswith(b'\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a'):
return '.jxl'
elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
return '.mp4'
elif content.startswith(b'\x1A\x45\xDF\xA3'):
@@ -75,6 +79,8 @@ class ExampleImagesProcessor:
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/avif': '.avif',
'image/jxl': '.jxl',
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/quicktime': '.mov'

View File

@@ -1,17 +1,125 @@
import json
import logging
import os
import struct
from io import BytesIO
from typing import Any, Optional
import piexif
from PIL import Image, PngImagePlugin
try:
import brotli
_BROTLI_AVAILABLE = True
except ImportError:
brotli = None
_BROTLI_AVAILABLE = False
logger = logging.getLogger(__name__)
class ExifUtils:
"""Utility functions for working with EXIF data in images"""
@staticmethod
def _parse_isobmff_boxes(data: bytes, offset: int = 0) -> list[dict]:
boxes = []
while offset + 8 <= len(data):
size = struct.unpack('>I', data[offset:offset + 4])[0]
box_type = data[offset + 4:offset + 8]
if size == 0:
break
if size < 8 or offset + size > len(data):
break
box_data = data[offset + 8:offset + size]
boxes.append({'type': box_type, 'data': box_data, 'size': size})
offset += size
return boxes
@staticmethod
def _is_jxl_container(data: bytes) -> bool:
if len(data) < 32:
return False
return (
struct.unpack('>I', data[:4])[0] == 12
and data[4:8] == b'JXL '
and data[8:12] == bytes([0x0d, 0x0a, 0x87, 0x0a])
and struct.unpack('>I', data[12:16])[0] >= 16
and data[16:20] == b'ftyp'
and data[20:24] == b'jxl '
)
@staticmethod
def _is_avif_container(data: bytes) -> bool:
if len(data) < 16:
return False
for box in ExifUtils._parse_isobmff_boxes(data):
if box['type'] == b'ftyp' and b'avif' in box['data']:
return True
return False
# Max decompressed size for brotli metadata (2 MB)
_BROTLI_MAX_DECOMPRESSED = 2 * 1024 * 1024
@staticmethod
def _extract_isobmff_brotli(image_path: str) -> Optional[dict]:
try:
with open(image_path, 'rb') as f:
data = f.read()
except Exception:
return None
if ExifUtils._is_jxl_container(data):
boxes = ExifUtils._parse_isobmff_boxes(data, offset=12)
elif ExifUtils._is_avif_container(data):
boxes = ExifUtils._parse_isobmff_boxes(data)
else:
return None
brob = None
for box in boxes:
if box['type'] == b'brob':
brob = box
break
if brob is None:
return None
payload = brob['data']
if payload[:4] != b'comf':
return None
compressed = payload[4:]
if _BROTLI_AVAILABLE:
try:
decompressed = brotli.decompress(compressed)
if len(decompressed) > ExifUtils._BROTLI_MAX_DECOMPRESSED:
logger.warning(
"Brotli metadata too large (%d bytes, max %d), ignoring",
len(decompressed),
ExifUtils._BROTLI_MAX_DECOMPRESSED,
)
decompressed = None
except Exception:
decompressed = None
else:
decompressed = None
raw = decompressed if decompressed is not None else compressed
try:
meta = json.loads(raw.decode('utf-8'))
except Exception:
return None
result = {"parameters": None, "prompt": None, "workflow": None, "comment": None}
if isinstance(meta.get("prompt"), (dict, list)):
result["prompt"] = json.dumps(meta["prompt"])
elif isinstance(meta.get("prompt"), str):
result["prompt"] = meta["prompt"]
if isinstance(meta.get("workflow"), (dict, list)):
result["workflow"] = json.dumps(meta["workflow"])
elif isinstance(meta.get("workflow"), str):
result["workflow"] = meta["workflow"]
return result
@staticmethod
def _decode_user_comment(user_comment: Any) -> Optional[str]:
if user_comment is None:
@@ -43,6 +151,12 @@ class ExifUtils:
"comment": None,
}
ext = os.path.splitext(image_path)[1].lower()
if ext in ('.avif', '.jxl'):
brotli_meta = ExifUtils._extract_isobmff_brotli(image_path)
if brotli_meta:
return brotli_meta
with Image.open(image_path) as img:
info = getattr(img, "info", {}) or {}
@@ -149,7 +263,6 @@ class ExifUtils:
Optional[str]: Extracted metadata or None if not found
"""
try:
# Skip for video files
if image_path:
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']:
@@ -177,10 +290,9 @@ class ExifUtils:
str: Path to the updated image
"""
try:
# Skip for video files
if image_path:
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']:
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
return image_path
metadata_fields = ExifUtils._load_structured_metadata(image_path)
@@ -212,10 +324,9 @@ class ExifUtils:
def append_recipe_metadata(image_path, recipe_data) -> str:
"""Append recipe metadata to an image's EXIF data"""
try:
# Skip for video files
if image_path:
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']:
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
return image_path
# First, extract existing metadata
@@ -327,10 +438,9 @@ class ExifUtils:
Tuple of (optimized_image_data, extension)
"""
try:
# Skip for video files early if it's a file path
if isinstance(image_data, str) and os.path.exists(image_data):
ext = os.path.splitext(image_data)[1].lower()
if ext in ['.mp4', '.webm']:
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
try:
with open(image_data, 'rb') as f:
return f.read(), ext

View File

@@ -34,12 +34,21 @@ def _get_hash_chunk_size_bytes() -> int:
async def calculate_sha256(file_path: str) -> str:
"""Calculate SHA256 hash of a file (full file content)."""
"""Calculate SHA256 hash of a file (full file content).
Uses ``posix_fadvise`` with ``POSIX_FADV_DONTNEED`` to avoid polluting the OS page
cache — critical on WSL where cached file pages live inside the VM and are not
accounted for in guest ``used`` memory, causing VmmemWSL to balloon.
"""
sha256_hash = hashlib.sha256()
chunk_size = _get_hash_chunk_size_bytes()
with open(file_path, "rb") as f:
fd = f.fileno()
for byte_block in iter(lambda: f.read(chunk_size), b""):
sha256_hash.update(byte_block)
# Evict pages after reading so the data doesn't linger in the kernel page
# cache — on WSL this otherwise appears as unreclaimable VmmemWSL growth.
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
return sha256_hash.hexdigest()

View File

@@ -221,6 +221,9 @@ class UsageStats:
if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict):
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:
self.stats["total_executions"] = loaded_stats["total_executions"]

View File

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

View File

@@ -1,4 +1,5 @@
aiohttp
aiohttp-socks
jinja2
safetensors
piexif
@@ -12,3 +13,5 @@ aiosqlite
beautifulsoup4
platformdirs
pyyaml
# brotli — ISOBMFF (AVIF/JXL) metadata decompression
brotli>=1.2.0

View File

@@ -2,6 +2,7 @@ import os
import sys
import json
from py.middleware.cache_middleware import cache_control
from py.middleware.error_middleware import api_json_error
from py.utils.settings_paths import ensure_settings_file
# Set environment variable to indicate standalone mode
@@ -157,7 +158,7 @@ class StandaloneServer:
def __init__(self):
self.app = web.Application(
logger=logger,
middlewares=[cache_control],
middlewares=[api_json_error, cache_control],
client_max_size=256 * 1024 * 1024,
handler_args={
"max_field_size": HEADER_SIZE_LIMIT,

View File

@@ -278,7 +278,7 @@
left: 0;
right: 0;
background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75));
backdrop-filter: blur(8px);
backdrop-filter: blur(var(--card-blur-amount, 8px));
color: white;
padding: var(--space-1);
display: flex;
@@ -294,7 +294,7 @@
left: 0;
right: 0;
background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%);
backdrop-filter: blur(8px);
backdrop-filter: blur(var(--card-blur-amount, 8px));
color: white;
padding: var(--space-1);
display: flex;

View File

@@ -0,0 +1,196 @@
/* Metadata Refresh Result Modal — component styles only */
.metadata-refresh-result-modal {
max-width: 700px;
}
.refresh-summary-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: var(--space-3) 0;
}
.stat-card {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-sm);
background: var(--surface-subtle);
border-left: 4px solid transparent;
font-size: var(--text-sm);
flex: 1;
min-width: 130px;
}
.stat-card > i {
font-size: 1.25em;
flex-shrink: 0;
}
.stat-card-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-card-label {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: var(--leading-tight);
}
.stat-card-value {
font-weight: var(--weight-bold);
font-size: var(--text-lg);
color: var(--lora-text);
line-height: var(--leading-tight);
}
.stat-card-success {
border-left-color: var(--color-success);
}
.stat-card-success > i {
color: var(--color-success);
}
.stat-card-failure {
border-left-color: var(--color-error);
}
.stat-card-failure > i {
color: var(--color-error);
}
.stat-card-skipped {
border-left-color: var(--color-warning);
}
.stat-card-skipped > i {
color: var(--color-warning);
}
.stat-card-total {
border-left-color: var(--color-info);
}
.stat-card-total > i {
color: var(--color-info);
}
.stat-card-time {
border-left-color: var(--color-accent);
}
.stat-card-time > i {
color: var(--color-accent);
}
.refresh-failures-section {
margin-bottom: var(--space-3);
}
.refresh-failures-section h4 {
margin: 0 0 var(--space-2) 0;
font-size: var(--text-base);
color: var(--color-error);
display: flex;
align-items: center;
gap: var(--space-1);
}
.refresh-failures-section h4 i {
font-size: 0.9em;
}
.failure-table-wrapper {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.failure-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.failure-table th {
position: sticky;
top: 0;
background: var(--lora-surface);
border-bottom: 2px solid var(--lora-border);
padding: var(--space-1) var(--space-2);
text-align: left;
font-weight: var(--weight-semibold);
color: var(--text-secondary);
z-index: 1;
}
.failure-table td {
padding: var(--space-1) var(--space-2);
border-bottom: 1px solid var(--lora-border);
vertical-align: top;
}
.failure-table tr:last-child td {
border-bottom: none;
}
.failure-table tr:hover td {
background: var(--surface-subtle);
}
.failure-index {
width: 30px;
text-align: center;
color: var(--text-secondary);
}
.failure-name {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.failure-error {
color: var(--color-error);
font-size: var(--text-xs);
}
.refresh-success-message {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
margin-bottom: var(--space-3);
background: var(--surface-subtle);
border-left: 4px solid var(--color-success);
color: var(--lora-text);
border-radius: var(--border-radius-sm);
font-weight: var(--weight-medium);
}
.refresh-success-message i {
font-size: 1.2em;
flex-shrink: 0;
color: var(--color-success);
}
[data-theme="dark"] .failure-table th {
background: var(--lora-surface);
}
[data-theme="dark"] .failure-table td {
border-bottom-color: var(--lora-border);
}
[data-theme="dark"] .failure-table tr:hover td {
background: var(--surface-subtle);
}

View File

@@ -813,6 +813,67 @@
outline: none;
}
/* Range Slider Control */
.range-control {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
justify-content: flex-end;
}
.range-control input[type="range"] {
width: 120px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 2px;
outline: none;
cursor: pointer;
flex-shrink: 0;
}
.range-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--lora-accent);
cursor: pointer;
border: 2px solid var(--lora-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
}
.range-control input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.range-control input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--lora-accent);
cursor: pointer;
border: 2px solid var(--lora-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.range-control .range-value {
min-width: 36px;
text-align: center;
font-size: 0.9em;
font-weight: 600;
color: var(--text-color);
font-variant-numeric: tabular-nums;
}
[data-theme="dark"] .range-control input[type="range"] {
background: rgba(255, 255, 255, 0.15);
}
/* Toggle Switch */
.toggle-switch {
position: relative;

View File

@@ -8,69 +8,28 @@
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
overflow: hidden;
transition: var(--transition-slow);
flex-shrink: 0;
z-index: var(--z-overlay);
box-shadow: var(--shadow-header);
display: flex;
flex-direction: column;
backdrop-filter: blur(8px);
/* Default state: hidden off-screen */
/* Default: hidden off-screen — prevents flash before JS runs */
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.folder-sidebar.hidden-by-setting {
display: none !important;
}
/* Visible state */
.folder-sidebar.visible {
transform: translateX(0);
opacity: 1;
pointer-events: all;
}
/* Auto-hide states */
.folder-sidebar.auto-hide {
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
.folder-sidebar.auto-hide.hover-active {
transform: translateX(0);
opacity: 1;
pointer-events: all;
}
.folder-sidebar.collapsed {
transform: translateX(-100%);
opacity: 0;
pointer-events: none;
}
/* Hover detection area for auto-hide */
.sidebar-hover-area {
position: fixed;
top: 68px;
left: 0;
width: 20px;
height: calc(100vh - 88px);
z-index: calc(var(--z-overlay) - 1);
background: transparent;
pointer-events: all;
}
.sidebar-hover-area.hidden-by-setting {
.folder-sidebar.hidden-by-setting {
display: none !important;
}
.sidebar-hover-area.disabled {
pointer-events: none;
}
.sidebar-header {
display: flex;
align-items: center;
@@ -84,6 +43,7 @@
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: var(--transition-base);
position: relative;
}
.sidebar-header:hover {
@@ -150,6 +110,61 @@
display: 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 {
flex: 1;
overflow: hidden;
@@ -515,7 +530,7 @@
opacity: 0.3;
}
/* Responsive Design */
/* Responsive Design — Mobile: overlay when shown */
@media (max-width: 1024px) {
.folder-sidebar {
top: 68px;
@@ -525,13 +540,9 @@
height: calc(100vh - 88px);
z-index: calc(var(--z-overlay) + 10);
}
.folder-sidebar.collapsed {
transform: translateX(-100%);
}
/* Mobile overlay */
.folder-sidebar:not(.collapsed)::before {
/* Mobile overlay when sidebar is shown */
.folder-sidebar.visible::before {
content: '';
position: fixed;
top: 0;
@@ -550,11 +561,11 @@
max-width: 280px;
left: 0px;
}
.sidebar-breadcrumb-nav {
font-size: 0.8em;
}
.sidebar-breadcrumb-item {
padding: 3px 6px;
}

View File

@@ -40,6 +40,7 @@
@import 'components/statistics.css'; /* Add statistics component */
@import 'components/sidebar.css'; /* Add sidebar component */
@import 'components/media-viewer.css';
@import 'components/metadata-refresh-result.css';
.initialization-notice {
display: flex;

View File

@@ -468,17 +468,21 @@ export class BaseModelApiClient {
}
async refreshModels(fullRebuild = false) {
const abortController = new AbortController();
try {
state.loadingManager.show(
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
0
);
state.loadingManager.showCancelButton(() => this.cancelTask());
state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url);
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
@@ -494,6 +498,10 @@ export class BaseModelApiClient {
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
} catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
console.error('Refresh failed:', error);
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
} finally {
@@ -547,6 +555,14 @@ export class BaseModelApiClient {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
// Wait for WebSocket connection to establish
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
// Now that we're connected, set up the message/error handlers
// for the actual operation (separate from connection errors)
const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
@@ -556,25 +572,39 @@ export class BaseModelApiClient {
loading.setStatus('Starting metadata fetch...');
break;
case 'processing':
const percent = ((data.processed / data.total) * 100).toFixed(1);
case 'processing': {
const handled = data.handled || data.processed;
const percent = ((handled / data.total) * 100).toFixed(1);
loading.setProgress(percent);
loading.setStatus(
`Processing (${data.processed}/${data.total}) ${data.current_name}`
);
let statusText = `Processing (${handled}/${data.total}) ${data.current_name || ''}`;
if (data.failure_count > 0) {
statusText += ` | ❌ ${data.failure_count} failed`;
}
if (data.skipped_count > 0) {
statusText += ` | ⏭️ ${data.skipped_count} skipped`;
}
loading.setStatus(statusText);
break;
}
case 'completed':
case 'completed': {
loading.setProgress(100);
loading.setStatus(
`Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`
);
let summaryText = `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`;
if (data.failure_count > 0) {
summaryText += ` | ❌ ${data.failure_count} failed`;
}
if (data.skipped_count > 0) {
summaryText += ` | ⏭️ ${data.skipped_count} skipped`;
}
summaryText += ` (⏱ ${data.elapsed_seconds || '?'}s)`;
loading.setStatus(summaryText);
resolve(data);
break;
}
case 'cancelled':
loading.setStatus('Operation cancelled by user');
resolve(data); // Consider it complete but marked as cancelled
resolve(data);
break;
case 'error':
@@ -588,12 +618,6 @@ export class BaseModelApiClient {
};
});
// Wait for WebSocket connection to establish
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -608,10 +632,10 @@ export class BaseModelApiClient {
const finalData = await operationComplete;
resetAndReload(false);
if (finalData && finalData.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success: finalData.success, total: finalData.total }, 'info');
} else {
showToast('toast.api.metadataUpdateComplete', {}, 'success');
// Show result summary with failure details
if (finalData) {
this._showMetadataRefreshResult(finalData);
}
} catch (error) {
console.error('Error fetching metadata:', error);
@@ -627,6 +651,210 @@ export class BaseModelApiClient {
});
}
_showMetadataRefreshResult(data) {
const { success, total } = data;
if (data.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success, total }, 'info');
return;
}
this._showFailureDetailsModal(data);
}
_showFailureDetailsModal(data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
// Build failure list HTML
const failureRows = failures.map((f, i) =>
`<tr>
<td class="failure-index">${i + 1}</td>
<td class="failure-name" title="${this._escapeHtml(f.name)}">${this._escapeHtml(f.name)}</td>
<td class="failure-error">${this._escapeHtml(f.error || 'Unknown')}</td>
</tr>`
).join('');
const modalHtml = `
<div id="metadataRefreshResultModal" class="modal" style="display: block;">
<div class="modal-content metadata-refresh-result-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2><i class="fas fa-sync-alt"></i> ${translate('modals.metadataFetchSummary.title', {}, 'Metadata Fetch Summary')}</h2>
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<i class="fas fa-check-circle"></i>
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSuccess', {}, 'Success')}</span>
<span class="stat-card-value">${success}</span>
</div>
</div>
<div class="stat-card stat-card-failure">
<i class="fas fa-times-circle"></i>
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statFailed', {}, 'Failed')}</span>
<span class="stat-card-value">${failure_count}</span>
</div>
</div>
<div class="stat-card stat-card-skipped">
<i class="fas fa-forward"></i>
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statSkipped', {}, 'Skipped')}</span>
<span class="stat-card-value">${skipped_count}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<i class="fas fa-database"></i>
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
<span class="stat-card-value">${total || processed}</span>
</div>
</div>
<div class="stat-card stat-card-time">
<i class="fas fa-clock"></i>
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statDuration', {}, 'Duration')}</span>
<span class="stat-card-value">${elapsed_seconds}s</span>
</div>
</div>
</div>
${failure_count > 0 ? `
<div class="refresh-failures-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.metadataFetchSummary.failedItems', { count: failure_count }, 'Failed Items (' + failure_count + ')')}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.metadataFetchSummary.columnModelName', {}, 'Model Name')}</th>
<th>${translate('modals.metadataFetchSummary.columnError', {}, 'Error')}</th>
</tr>
</thead>
<tbody>${failureRows}</tbody>
</table>
</div>
</div>
` : `
<div class="refresh-success-message">
<i class="fas fa-check-circle"></i> ${translate('modals.metadataFetchSummary.successMessage', { count: success, type: this.apiConfig.config.displayName }, 'All ' + success + ' ' + this.apiConfig.config.displayName + 's updated successfully!')}
</div>
`}
<div class="modal-actions">
<button class="cancel-btn" data-action="close-modal">${translate('modals.metadataFetchSummary.close', {}, 'Close')}</button>
${failure_count > 0 ? `
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.metadataFetchSummary.copyReport', {}, 'Copy Report')}</button>
<button class="secondary-btn" data-action="download-csv"><i class="fas fa-download"></i> ${translate('modals.metadataFetchSummary.downloadCsv', {}, 'Download CSV')}</button>
` : ''}
</div>
</div>
</div>
`;
const existing = document.getElementById('metadataRefreshResultModal');
if (existing) existing.remove();
const container = document.createElement('div');
container.innerHTML = modalHtml;
const modal = container.firstElementChild;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (!action) return;
e.preventDefault();
switch (action) {
case 'close-modal':
modal.remove();
break;
case 'copy-report':
BaseModelApiClient._copyRefreshReport(e.target.closest('[data-action]'), data);
break;
case 'download-csv':
BaseModelApiClient._downloadRefreshReport(data);
break;
}
});
}
_escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
static _copyRefreshReport(btn, data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
const lines = [
'=== Metadata Refresh Report ===',
`Date: ${new Date().toLocaleString()}`,
`Duration: ${elapsed_seconds}s`,
`Total scanned: ${total || processed}`,
`Successfully updated: ${success}`,
`Failed: ${failure_count}`,
`Skipped: ${skipped_count}`,
'',
];
if (failure_count > 0) {
lines.push('--- Failed Items ---');
failures.forEach((f, i) => {
lines.push(`${i + 1}. ${f.name || 'Unknown'}${f.error || 'Unknown error'}`);
});
lines.push('');
}
lines.push('====================');
const text = lines.join('\n');
navigator.clipboard.writeText(text).then(() => {
showToast('toast.api.copiedToClipboard', {}, 'success');
if (btn) {
const origHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
}
}).catch(() => {
// Fallback
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('toast.api.copiedToClipboard', {}, 'success');
});
}
static _downloadRefreshReport(data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
// CSV header
let csv = 'Model Name,Error\n';
failures.forEach(f => {
const name = (f.name || 'Unknown').replace(/"/g, '""');
const error = (f.error || 'Unknown').replace(/"/g, '""');
csv += `"${name}","${error}"\n`;
});
// Add summary as trailing comments
csv += `\n# Summary: ${success} success, ${failure_count} failed, ${skipped_count} skipped, ${elapsed_seconds}s\n`;
csv += `# Total scanned: ${total || processed}\n`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `metadata-refresh-failures-${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('toast.api.downloadStarted', {}, 'success');
}
async refreshBulkModelMetadata(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
@@ -728,13 +956,19 @@ export class BaseModelApiClient {
throw new Error('No model IDs provided');
}
const abortController = new AbortController();
try {
state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask());
state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({
model_ids: modelIds,
force
@@ -759,6 +993,10 @@ export class BaseModelApiClient {
return payload;
} catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
console.error('Error refreshing updates for models:', error);
throw error;
} finally {
@@ -771,13 +1009,19 @@ export class BaseModelApiClient {
throw new Error('No folder path provided');
}
const abortController = new AbortController();
try {
state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask());
state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({
folder_path: folderPath,
force
@@ -802,6 +1046,10 @@ export class BaseModelApiClient {
return payload;
} catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
console.error('Error refreshing updates for folder:', error);
throw error;
} finally {
@@ -1251,15 +1499,21 @@ export class BaseModelApiClient {
throw new Error('No file paths provided');
}
const abortController = new AbortController();
try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
state.loadingManager.showCancelButton(() => this.cancelTask());
state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
signal: abortController.signal,
body: JSON.stringify({
file_paths: filePaths
})
@@ -1282,6 +1536,10 @@ export class BaseModelApiClient {
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
}
} catch (error) {
if (error.name === 'AbortError') {
console.log(`Bulk delete cancelled by user for ${this.apiConfig.config.displayName.toLowerCase()}s`);
return { success: false, cancelled: true };
}
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
throw error;
} finally {

View File

@@ -17,12 +17,8 @@ export class SidebarManager {
this.treeData = {};
this.selectedPath = '';
this.expandedNodes = new Set();
this.isVisible = true;
this.isPinned = false;
this.apiClient = null;
this.openDropdown = null;
this.hoverTimeout = null;
this.isHovering = false;
this.isInitialized = false;
this.displayMode = 'tree'; // 'tree' or 'list'
this.foldersList = [];
@@ -35,7 +31,7 @@ export class SidebarManager {
this.folderTreeElement = null;
this.currentDropTarget = null;
this.lastPageControls = null;
this.isDisabledBySetting = false;
this.isDisabledByPage = false;
this.initializationPromise = null;
this.isCreatingFolder = false;
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
@@ -46,12 +42,7 @@ export class SidebarManager {
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
this.handlePinToggle = this.handlePinToggle.bind(this);
this.handleCollapseAll = this.handleCollapseAll.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.handleHoverAreaEnter = this.handleHoverAreaEnter.bind(this);
this.handleHoverAreaLeave = this.handleHoverAreaLeave.bind(this);
this.updateContainerMargin = this.updateContainerMargin.bind(this);
this.handleDisplayModeToggle = this.handleDisplayModeToggle.bind(this);
this.handleFolderListClick = this.handleFolderListClick.bind(this);
@@ -68,6 +59,8 @@ export class SidebarManager {
this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
this.handleHideToggle = this.handleHideToggle.bind(this);
this.getPageDisplayName = this.getPageDisplayName.bind(this);
}
setHostPageControls(pageControls) {
@@ -75,12 +68,6 @@ export class SidebarManager {
}
async initialize(pageControls, options = {}) {
const { forceInitialize = false } = options;
if (this.isDisabledBySetting && !forceInitialize) {
return;
}
// Clean up previous initialization if exists
if (this.isInitialized) {
this.cleanup();
@@ -93,23 +80,15 @@ export class SidebarManager {
|| pageControls?.sidebarApiClient
|| getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();
this.setupEventHandlers();
this.initializeDragAndDrop();
this.updateSidebarTitle();
this.restoreSidebarState();
// Apply DOM visibility based on per-page state
this.updateDomVisibility();
await this.loadFolderTree();
if (this.isDisabledBySetting && !forceInitialize) {
this.cleanup();
return;
}
this.restoreSelectedFolder();
// Apply final state with animation after everything is loaded
this.applyFinalSidebarState();
// Update container margin based on initial sidebar state
this.updateContainerMargin();
@@ -120,12 +99,6 @@ export class SidebarManager {
cleanup() {
if (!this.isInitialized) return;
// Clear any pending timeouts
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
// Clean up event handlers
this.removeEventHandlers();
@@ -143,6 +116,8 @@ export class SidebarManager {
this.sidebarDragHandlersInitialized = false;
}
this.hideSidebarHiddenIndicator();
// Reset state
this.pageControls = null;
this.pageType = null;
@@ -150,7 +125,7 @@ export class SidebarManager {
this.selectedPath = '';
this.expandedNodes = new Set();
this.openDropdown = null;
this.isHovering = false;
this.isDisabledByPage = false;
this.apiClient = null;
this.isInitialized = false;
this.recursiveSearchEnabled = true;
@@ -169,19 +144,13 @@ export class SidebarManager {
}
removeEventHandlers() {
const pinToggleBtn = document.getElementById('sidebarPinToggle');
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
const folderTree = document.getElementById('sidebarFolderTree');
const sidebarBreadcrumbNav = document.getElementById('sidebarBreadcrumbNav');
const sidebarHeader = document.getElementById('sidebarHeader');
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
const displayModeToggleBtn = document.getElementById('sidebarDisplayModeToggle');
const recursiveToggleBtn = document.getElementById('sidebarRecursiveToggle');
if (pinToggleBtn) {
pinToggleBtn.removeEventListener('click', this.handlePinToggle);
}
if (collapseAllBtn) {
collapseAllBtn.removeEventListener('click', this.handleCollapseAll);
}
@@ -196,14 +165,6 @@ export class SidebarManager {
if (sidebarHeader) {
sidebarHeader.removeEventListener('click', this.handleSidebarHeaderClick);
}
if (sidebar) {
sidebar.removeEventListener('mouseenter', this.handleMouseEnter);
sidebar.removeEventListener('mouseleave', this.handleMouseLeave);
}
if (hoverArea) {
hoverArea.removeEventListener('mouseenter', this.handleHoverAreaEnter);
hoverArea.removeEventListener('mouseleave', this.handleHoverAreaLeave);
}
// Remove document click handler
document.removeEventListener('click', this.handleDocumentClick);
@@ -217,6 +178,11 @@ export class SidebarManager {
if (recursiveToggleBtn) {
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
}
const hideToggle = document.getElementById('sidebarHideToggle');
if (hideToggle) {
hideToggle.removeEventListener('click', this.handleHideToggle);
}
}
initializeDragAndDrop() {
@@ -891,60 +857,6 @@ export class SidebarManager {
this.currentDropTarget = null;
}
async init() {
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
// Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState();
this.setupEventHandlers();
this.initializeDragAndDrop();
this.updateSidebarTitle();
this.restoreSidebarState();
await this.loadFolderTree();
this.restoreSelectedFolder();
// Apply final state with animation after everything is loaded
this.applyFinalSidebarState();
// Update container margin based on initial sidebar state
this.updateContainerMargin();
}
setInitialSidebarState() {
if (this.isDisabledBySetting) return;
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (!sidebar || !hoverArea) return;
// Get stored pin state
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
this.isPinned = isPinned;
// Sidebar starts hidden by default (CSS handles this)
// Just set up the hover area state
if (window.innerWidth <= 1024) {
hoverArea.classList.add('disabled');
} else if (this.isPinned) {
hoverArea.classList.add('disabled');
} else {
hoverArea.classList.remove('disabled');
}
}
applyFinalSidebarState() {
if (this.isDisabledBySetting) return;
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
this.updateAutoHideState();
});
}
updateSidebarTitle() {
const sidebarTitle = document.getElementById('sidebarTitle');
if (sidebarTitle) {
@@ -959,12 +871,6 @@ export class SidebarManager {
sidebarHeader.addEventListener('click', this.handleSidebarHeaderClick);
}
// Pin toggle button
const pinToggleBtn = document.getElementById('sidebarPinToggle');
if (pinToggleBtn) {
pinToggleBtn.addEventListener('click', this.handlePinToggle);
}
// Collapse all button
const collapseAllBtn = document.getElementById('sidebarCollapseAll');
if (collapseAllBtn) {
@@ -990,34 +896,18 @@ export class SidebarManager {
sidebarBreadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
}
// Hover detection for auto-hide
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (sidebar) {
sidebar.addEventListener('mouseenter', this.handleMouseEnter);
sidebar.addEventListener('mouseleave', this.handleMouseLeave);
}
if (hoverArea) {
hoverArea.addEventListener('mouseenter', this.handleHoverAreaEnter);
hoverArea.addEventListener('mouseleave', this.handleHoverAreaLeave);
}
// Close sidebar when clicking outside on mobile
document.addEventListener('click', (e) => {
if (window.innerWidth <= 1024 && this.isVisible) {
if (window.innerWidth <= 1024) {
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !sidebar.contains(e.target)) {
this.hideSidebar();
if (sidebar && !sidebar.contains(e.target) && !this.isDisabledByPage) {
sidebar.classList.remove('visible');
}
}
});
// Handle window resize
window.addEventListener('resize', () => {
this.updateAutoHideState();
this.updateContainerMargin();
});
@@ -1045,6 +935,12 @@ export class SidebarManager {
}
});
}
// Dedicated hide sidebar button
const hideToggle = document.getElementById('sidebarHideToggle');
if (hideToggle) {
hideToggle.addEventListener('click', this.handleHideToggle);
}
}
handleDocumentClick(event) {
@@ -1061,13 +957,9 @@ export class SidebarManager {
}
}
handlePinToggle(event) {
handleHideToggle(event) {
event.stopPropagation();
this.isPinned = !this.isPinned;
this.updateAutoHideState();
this.updatePinButton();
this.saveSidebarState();
this.updateContainerMargin();
this.toggleHideOnThisPage();
}
handleCollapseAll(event) {
@@ -1077,192 +969,88 @@ export class SidebarManager {
this.saveExpandedState();
}
handleMouseEnter() {
this.isHovering = true;
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
// ===== Sidebar visibility (per-page) and container margin =====
if (!this.isPinned) {
this.showSidebar();
}
}
handleMouseLeave() {
this.isHovering = false;
if (!this.isPinned) {
this.hoverTimeout = setTimeout(() => {
if (!this.isHovering) {
this.hideSidebar();
}
}, 300);
}
}
handleHoverAreaEnter() {
if (!this.isPinned) {
this.showSidebar();
}
}
handleHoverAreaLeave() {
// Let the sidebar's mouse leave handler deal with hiding
}
showSidebar() {
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !this.isPinned) {
sidebar.classList.add('hover-active');
this.isVisible = true;
this.updateContainerMargin();
}
}
hideSidebar() {
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !this.isPinned) {
sidebar.classList.remove('hover-active');
this.isVisible = false;
this.updateContainerMargin();
}
}
updateAutoHideState() {
if (this.isDisabledBySetting) return;
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (!sidebar || !hoverArea) return;
if (window.innerWidth <= 1024) {
// Mobile: always use collapsed state
sidebar.classList.remove('auto-hide', 'hover-active', 'visible');
sidebar.classList.add('collapsed');
hoverArea.classList.add('disabled');
this.isVisible = false;
} else if (this.isPinned) {
// Desktop pinned: always visible
sidebar.classList.remove('auto-hide', 'collapsed', 'hover-active');
sidebar.classList.add('visible');
hoverArea.classList.add('disabled');
this.isVisible = true;
} else {
// Desktop auto-hide: use hover detection
sidebar.classList.remove('collapsed', 'visible');
sidebar.classList.add('auto-hide');
hoverArea.classList.remove('disabled');
if (this.isHovering) {
sidebar.classList.add('hover-active');
this.isVisible = true;
} else {
sidebar.classList.remove('hover-active');
this.isVisible = false;
}
}
// Update container margin when sidebar state changes
this.updateContainerMargin();
}
// New method to update container margin based on sidebar state
updateContainerMargin() {
const container = document.querySelector('.container');
const sidebar = document.getElementById('folderSidebar');
if (!container || !sidebar || this.isDisabledBySetting) return;
if (!container || !sidebar) return;
// Reset margin to default
// Always reset margin first — needed when transitioning from visible to hidden
container.style.marginLeft = '';
// Only adjust margin if sidebar is visible and pinned
if ((this.isPinned || this.isHovering) && this.isVisible) {
const sidebarWidth = sidebar.offsetWidth;
const viewportWidth = window.innerWidth;
const containerWidth = container.offsetWidth;
// When per-page disabled, skip adjustment but margin is already reset
if (this.isDisabledByPage) return;
// Check if there's enough space for both sidebar and container
// We need: sidebar width + container width + some padding < viewport width
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
// Not enough space, push container to the right
container.style.marginLeft = `${sidebarWidth + 10}px`;
}
// Sidebar is visible — adjust margin if we need room
const sidebarWidth = sidebar.offsetWidth;
const viewportWidth = window.innerWidth;
const containerWidth = container.offsetWidth;
if (sidebarWidth + containerWidth + sidebarWidth > viewportWidth) {
container.style.marginLeft = `${sidebarWidth + 10}px`;
}
}
updateDomVisibility(enabled) {
updateDomVisibility() {
const isHidden = this.isDisabledByPage;
const sidebar = document.getElementById('folderSidebar');
const hoverArea = document.getElementById('sidebarHoverArea');
if (sidebar) {
sidebar.classList.toggle('hidden-by-setting', !enabled);
sidebar.setAttribute('aria-hidden', (!enabled).toString());
sidebar.classList.toggle('visible', !isHidden);
sidebar.classList.toggle('hidden-by-setting', isHidden);
sidebar.setAttribute('aria-hidden', isHidden.toString());
}
if (hoverArea) {
hoverArea.classList.toggle('hidden-by-setting', !enabled);
if (!enabled) {
hoverArea.classList.add('disabled');
}
// Show or hide the "sidebar hidden" edge indicator
if (isHidden) {
this.showSidebarHiddenIndicator();
} else {
this.hideSidebarHiddenIndicator();
}
}
async setSidebarEnabled(enabled) {
this.isDisabledBySetting = !enabled;
this.updateDomVisibility(enabled);
const shouldForceInitialization = !enabled && !this.isInitialized;
const needsInitialization = !this.isInitialized || shouldForceInitialization;
if (this.lastPageControls && needsInitialization) {
if (!this.initializationPromise) {
this.initializationPromise = this.initialize(this.lastPageControls, {
forceInitialize: shouldForceInitialization,
})
.catch((error) => {
console.error('Sidebar initialization failed:', error);
})
.finally(() => {
this.initializationPromise = null;
});
}
await this.initializationPromise;
} else if (this.initializationPromise) {
await this.initializationPromise;
}
if (!enabled) {
this.isHovering = false;
this.isVisible = false;
const container = document.querySelector('.container');
if (container) {
container.style.marginLeft = '';
}
if (this.isInitialized) {
this.updateBreadcrumbs();
this.updateSidebarHeader();
}
return;
}
if (this.isInitialized) {
this.updateAutoHideState();
}
toggleHideOnThisPage() {
this.isDisabledByPage = !this.isDisabledByPage;
setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage);
this.updateDomVisibility();
this.updateContainerMargin();
}
updatePinButton() {
const pinBtn = document.getElementById('sidebarPinToggle');
if (pinBtn) {
pinBtn.classList.toggle('active', this.isPinned);
pinBtn.title = this.isPinned
? translate('sidebar.unpinSidebar')
: translate('sidebar.pinSidebar');
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();
}
}
@@ -1552,11 +1340,6 @@ export class SidebarManager {
// Reload models with new filter
await this.pageControls.resetAndReload();
// Auto-hide sidebar on mobile after selection
if (window.innerWidth <= 1024) {
this.hideSidebar();
}
}
handleFolderListClick(event) {
@@ -1866,64 +1649,55 @@ export class SidebarManager {
}
}
toggleSidebar() {
const sidebar = document.getElementById('folderSidebar');
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
if (!sidebar) return;
this.isVisible = !this.isVisible;
if (this.isVisible) {
sidebar.classList.remove('collapsed');
sidebar.classList.add('visible');
} else {
sidebar.classList.remove('visible');
sidebar.classList.add('collapsed');
}
if (toggleBtn) {
toggleBtn.classList.toggle('active', this.isVisible);
}
this.saveSidebarState();
}
closeSidebar() {
const sidebar = document.getElementById('folderSidebar');
const toggleBtn = document.querySelector('.sidebar-toggle-btn');
if (!sidebar) return;
this.isVisible = false;
sidebar.classList.remove('visible');
sidebar.classList.add('collapsed');
if (toggleBtn) {
toggleBtn.classList.remove('active');
}
this.saveSidebarState();
}
restoreSidebarState() {
const isPinned = getStorageItem(`${this.pageType}_sidebarPinned`, true);
// Migration: old pin/unpin and global hide → per-page hide
this._migrateOldSettings();
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
this.isPinned = isPinned;
this.expandedNodes = new Set(expandedPaths);
this.displayMode = displayMode;
this.recursiveSearchEnabled = recursiveSearchEnabled;
this.updatePinButton();
this.updateDisplayModeButton();
this.updateCollapseAllButton();
this.updateSearchRecursiveOption();
this.updateRecursiveToggleButton();
}
/**
* One-time migration: old pin/unpin and global show_folder_sidebar → per-page hide
* - sidebarPinned=false (was auto-hide) → sidebarDisabled=true for that page
* - show_folder_sidebar=false (global) → sidebarDisabled=true for ALL pages
*/
_migrateOldSettings() {
if (getStorageItem('_sidebar_migration_done')) return;
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
// 1. Migrate global hide setting to per-page
if (state?.global?.settings?.show_folder_sidebar === false) {
PAGES.forEach(p => setStorageItem(`${p}_sidebarDisabled`, true));
}
// 2. Migrate unpinned (auto-hide) to per-page hide
PAGES.forEach(p => {
const wasPinned = getStorageItem(`${p}_sidebarPinned`, true);
const alreadyDisabled = getStorageItem(`${p}_sidebarDisabled`, false);
if (wasPinned === false && !alreadyDisabled) {
// Was auto-hide → user didn't want sidebar taking space
setStorageItem(`${p}_sidebarDisabled`, true);
}
// Clean up old keys
localStorage.removeItem(`${p}_sidebarPinned`);
});
setStorageItem('_sidebar_migration_done', true);
}
restoreSelectedFolder() {
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
if (activeFolder && typeof activeFolder === 'string') {
@@ -1936,11 +1710,6 @@ export class SidebarManager {
this.updateSidebarHeader();
this.updateBreadcrumbs(); // Always update breadcrumbs
}
// Removed hidden class toggle since breadcrumbs are always visible now
}
saveSidebarState() {
setStorageItem(`${this.pageType}_sidebarPinned`, this.isPinned);
}
saveExpandedState() {
@@ -1952,7 +1721,7 @@ export class SidebarManager {
}
async refresh() {
if (this.isDisabledBySetting || !this.isInitialized) {
if (!this.isInitialized) {
return;
}

View File

@@ -93,8 +93,7 @@ export class PageControls {
async initSidebarManager() {
try {
this.sidebarManager.setHostPageControls(this);
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
await this.sidebarManager.setSidebarEnabled(shouldShowSidebar);
await this.sidebarManager.initialize(this);
} catch (error) {
console.error('Failed to initialize SidebarManager:', error);
}
@@ -664,13 +663,6 @@ export class PageControls {
}
this.updateActionButtonStates();
if (this.sidebarManager) {
const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false;
this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
console.error('Failed to update sidebar visibility:', error);
});
}
}
suspendInteractiveModes() {

View File

@@ -355,9 +355,9 @@ function renderImportInterface(isEmpty) {
<button class="select-files-btn" id="selectExampleFilesBtn">
<i class="fas fa-folder-open"></i> Select Files
</button>
<p class="import-formats">Supported formats: jpg, png, gif, webp, mp4, webm</p>
<p class="import-formats">Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm</p>
</div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
<input type="file" id="exampleFilesInput" multiple accept="image/*,image/avif,image/jxl,video/mp4,video/webm" style="display: none;">
<div class="import-progress-container" style="display: none;">
<div class="import-progress">
<div class="progress-bar"></div>
@@ -473,7 +473,7 @@ export function initExampleImport(modelHash, container) {
*/
async function handleImportFiles(files, modelHash, importContainer) {
// Filter for supported file types
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.jxl'];
const supportedVideos = ['.mp4', '.webm'];
const supportedExtensions = [...supportedImages, ...supportedVideos];

View File

@@ -611,7 +611,9 @@ export class BulkManager {
const result = await apiClient.bulkDeleteModels(filePaths);
if (result.success) {
if (result?.cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
} else if (result.success) {
const currentConfig = this.getCurrentDisplayConfig();
showToast('toast.models.deletedSuccessfully', {
count: result.deleted_count,

View File

@@ -73,7 +73,7 @@ export class LoadingManager {
if (this.onCancelCallback) {
this.onCancelCallback();
this.cancelButton.disabled = true;
this.cancelButton.textContent = translate('common.status.loading', {}, 'Loading...');
this.cancelButton.textContent = translate('common.status.cancelling', {}, 'Cancelling...');
}
};

View File

@@ -15,7 +15,6 @@ import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
import { bannerService } from './BannerService.js';
import { sidebarManager } from '../components/SidebarManager.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
@@ -804,6 +803,16 @@ export class SettingsManager {
);
}
// Set card blur amount slider
const cardBlurAmountInput = document.getElementById('cardBlurAmount');
if (cardBlurAmountInput) {
cardBlurAmountInput.value = state.global.settings.card_blur_amount ?? 8;
}
const cardBlurAmountValue = document.getElementById('cardBlurAmountValue');
if (cardBlurAmountValue) {
cardBlurAmountValue.textContent = `${state.global.settings.card_blur_amount ?? 8}px`;
}
const usePortableCheckbox = document.getElementById('usePortableSettings');
if (usePortableCheckbox) {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
@@ -874,12 +883,6 @@ export class SettingsManager {
cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always';
}
const showFolderSidebarCheckbox = document.getElementById('showFolderSidebar');
if (showFolderSidebarCheckbox) {
const showSidebarSetting = state.global.settings.show_folder_sidebar;
showFolderSidebarCheckbox.checked = showSidebarSetting !== false;
}
// Set model card footer action
const modelCardFooterActionSelect = document.getElementById('modelCardFooterAction');
if (modelCardFooterActionSelect) {
@@ -2051,6 +2054,28 @@ export class SettingsManager {
}
}
async saveRangeSetting(elementId, displayId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
const value = parseInt(element.value, 10);
try {
await this.saveSetting(settingKey, value);
this.applyFrontendSettings();
// Update the displayed value next to the slider
const displayEl = document.getElementById(displayId);
if (displayEl) {
displayEl.textContent = `${value}px`;
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
updateExampleImagesOpenSettingsVisibility() {
const openMode = state.global.settings.example_images_open_mode || 'system';
const localRootSetting = document.getElementById('exampleImagesLocalRootSetting');
@@ -2887,6 +2912,10 @@ export class SettingsManager {
}
applyFrontendSettings() {
// Apply card blur amount to CSS custom property
const cardBlurAmount = state.global.settings.card_blur_amount ?? 8;
document.documentElement.style.setProperty('--card-blur-amount', `${cardBlurAmount}px`);
// Apply autoplay setting to existing videos in card previews
const autoplayOnHover = state.global.settings.autoplay_on_hover;
document.querySelectorAll('.card-preview video').forEach(video => {
@@ -2913,12 +2942,6 @@ export class SettingsManager {
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
console.error('Failed to apply sidebar visibility setting:', error);
});
}
}
}

View File

@@ -95,8 +95,7 @@ class RecipeManager {
async _initSidebar() {
try {
sidebarManager.setHostPageControls(this.pageControls);
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
await sidebarManager.setSidebarEnabled(shouldShowSidebar);
await sidebarManager.initialize(this.pageControls);
} catch (error) {
console.error('Failed to initialize recipe sidebar:', error);
}

View File

@@ -32,10 +32,10 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
auto_download_example_images: false,
blur_mature_content: true,
mature_blur_level: 'R',
card_blur_amount: 8,
autoplay_on_hover: false,
display_density: 'default',
card_info_display: 'always',
show_folder_sidebar: true,
model_name_display: 'model_name',
lora_syntax_format: 'legacy',
model_card_footer_action: 'example_images',

View File

@@ -998,7 +998,9 @@ function showNodeSelector(nodes, options = {}) {
nodeSelectorState.enableSendAll = options.enableSendAll !== false;
// 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 bgColor = node.bgcolor || DEFAULT_NODE_COLOR;
const graphLabel = node.graph_name ? ` (${node.graph_name})` : '';

View File

@@ -42,7 +42,12 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
onStart?.({ displayName, loadingMessage });
state.loadingManager?.showSimpleLoading?.(loadingMessage);
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
const abortController = new AbortController();
state.loadingManager?.showCancelButton?.(() => {
apiClient.cancelTask();
abortController.abort();
});
let status = 'success';
let records = [];
@@ -52,6 +57,7 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ force: false })
});
@@ -81,6 +87,11 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
await resetAndReload(false);
} catch (err) {
if (err?.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
status = 'cancelled';
return { status: 'cancelled', displayName, records: [], error: null };
}
status = 'error';
error = err instanceof Error ? err : new Error(String(err));
console.error('Error checking model updates:', error);
@@ -126,7 +137,12 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
);
state.loadingManager?.showSimpleLoading?.(loadingMessage);
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
const abortController = new AbortController();
state.loadingManager?.showCancelButton?.(() => {
apiClient.cancelTask();
abortController.abort();
});
let status = 'success';
let records = [];
@@ -136,6 +152,7 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({ folder_path: folderPath, force: false })
});
@@ -165,6 +182,11 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
await resetAndReload(false);
} catch (err) {
if (err?.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
status = 'cancelled';
return { status: 'cancelled', records: [], error: null };
}
status = 'error';
error = err instanceof Error ? err : new Error(String(err));
console.error('Error checking folder model updates:', error);

View File

@@ -1,6 +1,3 @@
<!-- Hover detection area -->
<div class="sidebar-hover-area" id="sidebarHoverArea"></div>
<!-- Folder Navigation Sidebar -->
<div class="folder-sidebar" id="folderSidebar">
<div class="sidebar-header" id="sidebarHeader">
@@ -15,8 +12,8 @@
<button class="sidebar-action-btn" id="sidebarCollapseAll" title="{{ t('sidebar.collapseAll') }}">
<i class="fas fa-compress-alt"></i>
</button>
<button class="sidebar-action-btn" id="sidebarPinToggle" title="{{ t('sidebar.unpinSidebar') }}">
<i class="fas fa-thumbtack"></i>
<button class="sidebar-action-btn" id="sidebarHideToggle" title="{{ t('sidebar.hideOnThisPage') }}">
<i class="fas fa-chevron-left"></i>
</button>
</div>
</div>

View File

@@ -448,6 +448,7 @@
</div>
</div>
</div>
</div>
<!-- Video Settings -->
@@ -479,24 +480,6 @@
<div class="settings-subsection-header">
<h4>{{ t('settings.sections.layoutSettings') }}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="showFolderSidebar">
{{ t('settings.layoutSettings.showFolderSidebar') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.showFolderSidebarHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="showFolderSidebar"
onchange="settingsManager.saveToggleSetting('showFolderSidebar', 'show_folder_sidebar')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
@@ -556,6 +539,23 @@
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardBlurAmount">
{{ t('settings.layoutSettings.cardBlurAmount') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.cardBlurAmountHelp') }}"></i>
</label>
</div>
<div class="setting-control range-control">
<input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1"
oninput="document.getElementById('cardBlurAmountValue').textContent = this.value + 'px'"
onchange="settingsManager.saveRangeSetting('cardBlurAmount', 'cardBlurAmountValue', 'card_blur_amount')">
<span id="cardBlurAmountValue" class="range-value">8px</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">

View File

@@ -2190,6 +2190,7 @@ describe('Interaction-level regression coverage', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: false }),
signal: expect.any(AbortSignal),
});
const updateResponse = await global.fetch.mock.results[1].value;

View File

@@ -20,7 +20,7 @@ const downloadManagerMock = {
const sidebarManagerMock = {
setHostPageControls: vi.fn(),
setSidebarEnabled: vi.fn(async () => {
initialize: vi.fn(async () => {
sidebarManagerMock.isInitialized = true;
}),
refresh: vi.fn(async () => {}),
@@ -75,9 +75,6 @@ beforeEach(() => {
performModelUpdateCheckMock.mockResolvedValue({ status: 'success', displayName: 'LoRA', records: [] });
sidebarManagerMock.isInitialized = false;
sidebarManagerMock.setSidebarEnabled.mockImplementation(async (enabled) => {
sidebarManagerMock.isInitialized = enabled;
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,

View File

@@ -72,12 +72,6 @@ vi.mock('../../../static/js/managers/BannerService.js', () => ({
},
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setSidebarEnabled: vi.fn().mockResolvedValue(),
},
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { state } from '../../../static/js/state/index.js';

View File

@@ -83,6 +83,15 @@ vi.mock('../../../static/js/api/recipeApi.js', () => ({
})),
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setHostPageControls: vi.fn(),
initialize: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
cleanup: vi.fn(),
},
}));
describe('RecipeManager', () => {
let RecipeManager;
let pageState;

View File

@@ -46,6 +46,7 @@ class StubRecipeScanner:
self.last_paginated_params: Dict[str, Any] | None = None
self.lora_lookup: Dict[str, List[Dict[str, Any]]] = {}
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
self.image_id_map_override: Dict[str, str] = {}
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
return None
@@ -56,7 +57,10 @@ class StubRecipeScanner:
)
async def get_cached_data(self, force_refresh: bool = False) -> SimpleNamespace: # noqa: ARG002 - flag unused by stub
return SimpleNamespace(raw_data=list(self.cached_raw))
return SimpleNamespace(
raw_data=list(self.cached_raw),
image_id_map=dict(getattr(self, "image_id_map_override", {})),
)
async def get_paginated_data(self, **params: Any) -> Dict[str, Any]:
self.last_paginated_params = params
@@ -999,3 +1003,95 @@ async def test_batch_import_cancel_missing_id(monkeypatch, tmp_path: Path) -> No
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
async def test_check_image_exists_uses_image_id_map(monkeypatch, tmp_path: Path) -> None:
"""check_image_exists must use precomputed image_id_map instead of scanning raw_data."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.image_id_map_override = {
"123": "recipe-alpha",
"789": "recipe-gamma",
}
response = await harness.client.get(
"/api/lm/recipes/check-image-exists",
params={"image_ids": "123,456,789"},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["results"]["123"] == {
"in_library": True,
"recipe_id": "recipe-alpha",
}
assert payload["results"]["456"] == {
"in_library": False,
"recipe_id": None,
}
assert payload["results"]["789"] == {
"in_library": True,
"recipe_id": "recipe-gamma",
}
async def test_check_image_exists_handles_empty_input(monkeypatch, tmp_path: Path) -> None:
"""Empty or non-numeric image_ids must return an empty results dict."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.get(
"/api/lm/recipes/check-image-exists",
params={"image_ids": ""},
)
payload = await response.json()
assert response.status == 200
assert payload["results"] == {}
async def test_import_from_url_detects_duplicate_via_image_id_map(
monkeypatch, tmp_path: Path,
) -> None:
"""import_from_url must return already_exists when image_id is in image_id_map."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.cached_raw = [
{"id": "existing-recipe", "title": "My Recipe"},
]
harness.scanner.image_id_map_override = {
"99999": "existing-recipe",
}
response = await harness.client.get(
"/api/lm/recipes/import-from-url",
params={"image_url": "https://civitai.com/images/99999"},
)
payload = await response.json()
assert response.status == 200
assert payload["already_exists"] is True
assert payload["recipe_id"] == "existing-recipe"
assert payload["name"] == "My Recipe"
async def test_import_from_url_proceeds_when_image_id_not_in_map(
monkeypatch, tmp_path: Path,
) -> None:
"""When image_id is absent from image_id_map, import_from_url must proceed to import."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.image_id_map_override = {
"111": "some-other-recipe",
}
harness.civitai.image_info["99999"] = {
"id": 99999,
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
"type": "image",
"meta": {"prompt": "test"},
}
response = await harness.client.get(
"/api/lm/recipes/import-from-url",
params={"image_url": "https://civitai.com/images/99999"},
)
# The import may succeed or fail depending on downstream stubs,
# but it must NOT return already_exists
payload = await response.json()
assert payload.get("already_exists") is not True

View File

@@ -293,7 +293,8 @@ async def test_fetch_and_update_model_respects_deleted_without_archive():
assert "metadata archive DB is not enabled" in error
helpers.default_provider_factory.assert_not_awaited()
helpers.metadata_manager.hydrate_model_data.assert_not_awaited()
update_cache.assert_not_awaited()
# Now update_cache_func IS called to persist the not-found flags to SQLite
update_cache.assert_awaited_once()
@pytest.mark.asyncio
@@ -441,7 +442,6 @@ async def test_fetch_and_update_model_returns_rate_limit_error(tmp_path):
assert ok is False
assert error is not None and "Rate limited" in error
assert "7" in error
helpers.metadata_manager.save_metadata.assert_not_awaited()
update_cache.assert_not_awaited()
helpers.provider_selector.assert_not_awaited()

View File

@@ -63,7 +63,8 @@ async def test_fallback_retries_same_provider_on_rate_limit(monkeypatch):
@pytest.mark.asyncio
async def test_fallback_respects_retry_limit(monkeypatch):
async def test_fallback_continues_to_next_provider_on_rate_limit(monkeypatch):
"""After exhausting retries on primary, fallback should continue to secondary."""
sleep_mock = AsyncMock()
monkeypatch.setattr(provider_module.asyncio, "sleep", sleep_mock)
monkeypatch.setattr(provider_module.random, "uniform", lambda *_: 0.0)
@@ -76,13 +77,13 @@ async def test_fallback_respects_retry_limit(monkeypatch):
rate_limit_retry_limit=2,
)
with pytest.raises(RateLimitError) as exc_info:
await fallback.get_model_by_hash("abc")
# After Change A: no longer raises; falls through to secondary
result, error = await fallback.get_model_by_hash("abc")
assert exc_info.value.provider == "primary"
assert primary.calls == 2
assert secondary.calls == 0
sleep_mock.assert_awaited_once()
assert error is None
assert result == {"id": "secondary"}
assert primary.calls == 2 # retry_limit exhausted on primary
assert secondary.calls == 1 # secondary IS called now
@pytest.mark.asyncio
@@ -117,3 +118,40 @@ async def test_rate_limit_retrying_provider_respects_limit(monkeypatch):
assert exc_info.value.provider == "inner"
assert inner.calls == 2
sleep_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_retry_helper_limits_retries_for_large_retry_after():
"""With retry_after >= 120s, _RateLimitRetryHelper should only attempt once (no retries)."""
calls = 0
async def failing():
nonlocal calls
calls += 1
raise RateLimitError("limited", retry_after=1500.0)
helper = provider_module._RateLimitRetryHelper(retry_limit=3)
with pytest.raises(RateLimitError):
await helper.run("test", failing)
assert calls == 1 # No retries for large retry_after
@pytest.mark.asyncio
async def test_retry_helper_retries_normally_for_small_retry_after(monkeypatch):
"""With retry_after < 120s, _RateLimitRetryHelper should retry normally (up to limit)."""
sleep_mock = AsyncMock()
monkeypatch.setattr(provider_module.asyncio, "sleep", sleep_mock)
calls = 0
async def succeeding():
nonlocal calls
calls += 1
if calls == 1:
raise RateLimitError("limited", retry_after=30.0)
return {"ok": True}, None
helper = provider_module._RateLimitRetryHelper(retry_limit=3)
result, _ = await helper.run("test", succeeding)
assert result == {"ok": True}
assert calls == 2 # Retried once (small retry_after)

View File

@@ -1015,3 +1015,85 @@ async def test_get_paginated_data_sorting(recipe_scanner):
# Test Date ASC: Gamma (5), Alpha (10), Beta (20)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc")
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
async def test_build_image_id_map_filters_correctly(recipe_scanner):
"""Only recipes with valid CivitAI source_path appear in image_id_map.
Recipes imported from local files or with empty/missing source_path
must be naturally excluded.
"""
scanner, _ = recipe_scanner
from py.services.recipe_cache import RecipeCache
scanner._cache = RecipeCache(
raw_data=[
{"id": "r1", "source_path": "https://civitai.com/images/12345"},
{"id": "r2", "source_path": "https://civitai.com/images/67890"},
{"id": "r3", "source_path": "/home/user/local_image.png"},
{"id": "r4", "source_path": ""},
{"id": "r5"},
],
sorted_by_name=[],
sorted_by_date=[],
)
result = scanner._build_image_id_map()
assert result == {
"12345": "r1",
"67890": "r2",
}
# r3 = local file path, r4 = empty string, r5 = no key → all excluded
for rid in ("r3", "r4", "r5"):
assert rid not in result.values()
async def test_add_recipe_updates_image_id_map(recipe_scanner):
"""Adding a recipe with a CivitAI URL must update image_id_map.
A recipe with a local file path must NOT produce an entry.
"""
scanner, _ = recipe_scanner
await scanner.add_recipe({
"id": "civitai-recipe",
"title": "CivitAI",
"source_path": "https://civitai.com/images/55555",
})
cache = await scanner.get_cached_data()
assert cache.image_id_map.get("55555") == "civitai-recipe"
await scanner.add_recipe({
"id": "local-recipe",
"title": "Local",
"source_path": "/path/to/local.png",
})
assert "local-recipe" not in cache.image_id_map.values()
async def test_remove_recipe_clears_image_id_map(recipe_scanner):
"""Removing a recipe that has a CivitAI image_id must clean up the map."""
scanner, _ = recipe_scanner
await scanner.add_recipe({
"id": "recipe-a",
"title": "A",
"source_path": "https://civitai.com/images/111",
})
await scanner.add_recipe({
"id": "recipe-b",
"title": "B",
"source_path": "https://civitai.com/images/222",
})
cache = await scanner.get_cached_data()
assert "111" in cache.image_id_map
assert cache.image_id_map["222"] == "recipe-b"
await scanner.remove_recipe("recipe-a")
assert "111" not in cache.image_id_map
assert cache.image_id_map["222"] == "recipe-b"

View File

@@ -465,3 +465,81 @@ class TestPersistentRecipeCache:
# Operations should complete
assert operation_counts["saves"] == 5
assert operation_counts["removes"] == 5
# -----------------------------------------------------------------------
# image_id_map persistence (Phase 1 improvement)
# -----------------------------------------------------------------------
def test_save_and_load_image_id_map_roundtrip(self, temp_db_path, sample_recipes):
"""Save image_id_map via save_cache() and verify it round-trips through load_cache()."""
cache = PersistentRecipeCache(db_path=temp_db_path)
image_id_map = {
"12345": "recipe-alpha",
"67890": "recipe-beta",
}
cache.save_cache(sample_recipes, image_id_map=image_id_map)
loaded = cache.load_cache()
assert loaded is not None
assert loaded.image_id_map == image_id_map
def test_load_without_image_id_map_returns_empty_dict(self, temp_db_path, sample_recipes):
"""Loading from a cache that has no image_id_map metadata must yield {}."""
cache = PersistentRecipeCache(db_path=temp_db_path)
# Save without image_id_map
cache.save_cache(sample_recipes)
loaded = cache.load_cache()
assert loaded is not None
assert loaded.image_id_map == {}
def test_save_cache_without_image_id_map_does_not_corrupt_existing(
self, temp_db_path, sample_recipes,
):
"""Overwriting cache without passing image_id_map must not leave stale data.
The previous image_id_map entry in cache_metadata should be replaced with {}.
"""
cache = PersistentRecipeCache(db_path=temp_db_path)
cache.save_cache(sample_recipes, image_id_map={"123": "old-recipe"})
# Overwrite without image_id_map
cache.save_cache(sample_recipes)
loaded = cache.load_cache()
assert loaded.image_id_map == {}
def test_image_id_map_survives_recipe_update(self, temp_db_path, sample_recipes):
"""Updating a single recipe must not drop the image_id_map metadata."""
cache = PersistentRecipeCache(db_path=temp_db_path)
cache.save_cache(sample_recipes, image_id_map={"123": "recipe-alpha"})
updated = dict(sample_recipes[0])
updated["title"] = "Updated"
cache.update_recipe(updated)
loaded = cache.load_cache()
assert loaded.image_id_map == {"123": "recipe-alpha"}
def test_save_image_id_map_persists_without_full_save(self, temp_db_path, sample_recipes):
"""save_image_id_map must update cache_metadata without rewriting all recipes."""
cache = PersistentRecipeCache(db_path=temp_db_path)
cache.save_cache(sample_recipes)
cache.save_image_id_map({"555": "new-recipe", "666": "another-recipe"})
loaded = cache.load_cache()
assert loaded.image_id_map == {"555": "new-recipe", "666": "another-recipe"}
def test_save_image_id_map_overwrites_previous(self, temp_db_path, sample_recipes):
"""Calling save_image_id_map twice must replace, not merge."""
cache = PersistentRecipeCache(db_path=temp_db_path)
cache.save_cache(sample_recipes, image_id_map={"111": "old"})
cache.save_image_id_map({"222": "new-only"})
loaded = cache.load_cache()
assert loaded.image_id_map == {"222": "new-only"}

View File

@@ -141,3 +141,150 @@ def test_update_image_metadata_preserves_png_workflow(tmp_path):
img.info["parameters"]
== 'prompt text\nRecipe metadata: {"title":"recipe"}'
)
# --- ISOBMFF / brotli extraction tests ---
import struct
import brotli
def _build_jxl_with_brob(payload_json: dict) -> bytes:
"""Build a minimal JXL container with a brob box containing brotli-compressed JSON."""
# ISOBMFF box 1: JXL signature box (size=12, type='JXL ', signature)
box1 = struct.pack(">I", 12) + b"JXL " + bytes([0x0d, 0x0a, 0x87, 0x0a])
# ISOBMFF box 2: ftyp (size=16, type='ftyp', major='jxl ', minor=0)
box2 = struct.pack(">I", 16) + b"ftyp" + b"jxl " + struct.pack(">I", 0)
# ISOBMFF box 3: brob — payload is b'comf' + brotli(json)
compressed = brotli.compress(json.dumps(payload_json).encode("utf-8"))
brob_payload = b"comf" + compressed
box3 = struct.pack(">I", 8 + len(brob_payload)) + b"brob" + brob_payload
return box1 + box2 + box3
def _build_avif_with_brob(payload_json: dict) -> bytes:
"""Build a minimal AVIF container with a brob box containing brotli-compressed JSON."""
compressed = brotli.compress(json.dumps(payload_json).encode("utf-8"))
brob_payload = b"comf" + compressed
ftyp_box = struct.pack(">I", 20) + b"ftyp" + b"avif" + struct.pack(">I", 0) + b"avif"
brob_box = struct.pack(">I", 8 + len(brob_payload)) + b"brob" + brob_payload
return ftyp_box + brob_box
class TestIsobmffBrotliExtraction:
"""Tests for ISOBMFF brotli metadata extraction in ExifUtils."""
def test_extract_jxl_brotli_happy_path(self, tmp_path):
"""JXL container with valid brob box extracts prompt and workflow."""
payload = {"prompt": "a cute cat", "workflow": {"nodes": [{"id": 1}]}}
data = _build_jxl_with_brob(payload)
path = tmp_path / "test.jxl"
path.write_bytes(data)
result = ExifUtils._load_structured_metadata(str(path))
assert result["prompt"] == "a cute cat"
assert result["workflow"] == '{"nodes": [{"id": 1}]}'
assert result["parameters"] is None
assert result["comment"] is None
def test_extract_avif_brotli_happy_path(self, tmp_path):
"""AVIF container with valid brob box extracts prompt and workflow."""
payload = {"prompt": "landscape", "workflow": {"nodes": []}}
data = _build_avif_with_brob(payload)
path = tmp_path / "test.avif"
path.write_bytes(data)
result = ExifUtils._load_structured_metadata(str(path))
assert result["prompt"] == "landscape"
assert result["workflow"] == '{"nodes": []}'
def test_extract_no_brob_box_returns_none(self, tmp_path):
"""JXL container without a brob box returns None from _extract_isobmff_brotli."""
# Only JXL signature + ftyp, no brob
box1 = struct.pack(">I", 12) + b"JXL " + bytes([0x0d, 0x0a, 0x87, 0x0a])
box2 = struct.pack(">I", 16) + b"ftyp" + b"jxl " + struct.pack(">I", 0)
path = tmp_path / "test.jxl"
path.write_bytes(box1 + box2)
# The low-level extraction should return None (no brob box)
result = ExifUtils._extract_isobmff_brotli(str(path))
assert result is None
def test_extract_corrupt_brob_returns_none(self, tmp_path):
"""Broken brob box payload gracefully returns None."""
box1 = struct.pack(">I", 12) + b"JXL " + bytes([0x0d, 0x0a, 0x87, 0x0a])
box2 = struct.pack(">I", 16) + b"ftyp" + b"jxl " + struct.pack(">I", 0)
# brob with garbage payload that doesn't start with b'comf'
garbage = b"\xff\xff\xff\xff" * 32
box3 = struct.pack(">I", 8 + len(garbage)) + b"brob" + garbage
path = tmp_path / "test.jxl"
path.write_bytes(box1 + box2 + box3)
result = ExifUtils._extract_isobmff_brotli(str(path))
assert result is None
def test_extract_non_isobmff_file_falls_through(self, tmp_path):
"""A regular PNG file is not processed as ISOBMFF and returns PIL metadata."""
png_info = PngImagePlugin.PngInfo()
png_info.add_text("prompt", "from png")
path = tmp_path / "test.png"
Image.new("RGB", (4, 4), color="red").save(path, pnginfo=png_info)
result = ExifUtils._load_structured_metadata(str(path))
assert result["prompt"] == "from png"
def test_extract_skip_on_update_and_optimize(self, tmp_path):
"""AVIF/JXL files are skipped for write operations (update/append/optimize)."""
path = tmp_path / "test.avif"
path.write_bytes(b"fake avif data")
# update_image_metadata should return the path unchanged
result = ExifUtils.update_image_metadata(str(path), "some metadata")
assert result == str(path)
# append_recipe_metadata should also skip
result = ExifUtils.append_recipe_metadata(str(path), {"title": "test"})
assert result == str(path)
# optimize_image should passthrough for AVIF/JXL paths
result_data, ext = ExifUtils.optimize_image(str(path))
assert ext == ".avif"
assert result_data == b"fake avif data"
def test_extract_prompt_as_dict(self, tmp_path):
"""prompt field as dict is JSON-serialized."""
payload = {"prompt": {"text": "hello", "negative": "bad"}}
data = _build_jxl_with_brob(payload)
path = tmp_path / "test.jxl"
path.write_bytes(data)
result = ExifUtils._load_structured_metadata(str(path))
assert json.loads(result["prompt"]) == {"text": "hello", "negative": "bad"}
def test_extract_workflow_as_list(self, tmp_path):
"""workflow field as list is JSON-serialized."""
payload = {"workflow": [{"id": 1}, {"id": 2}]}
data = _build_avif_with_brob(payload)
path = tmp_path / "test.avif"
path.write_bytes(data)
result = ExifUtils._load_structured_metadata(str(path))
assert json.loads(result["workflow"]) == [{"id": 1}, {"id": 2}]
def test_over_decompressed_size_limit(self, tmp_path, monkeypatch):
"""Decompressed data exceeding _BROTLI_MAX_DECOMPRESSED is rejected."""
# Monkey-patch the limit to a small value to avoid large test data
monkeypatch.setattr(ExifUtils, "_BROTLI_MAX_DECOMPRESSED", 100)
large_content = "x" * 200
payload = {"prompt": large_content}
data = _build_jxl_with_brob(payload)
path = tmp_path / "test.jxl"
path.write_bytes(data)
# Direct extraction should return None because decompressed size exceeds limit
result = ExifUtils._extract_isobmff_brotli(str(path))
assert result is None

View File

@@ -0,0 +1,135 @@
import { app } from "../../scripts/app.js";
import { chainCallback, getAllGraphNodes, getWidgetByName } from "./utils.js";
/**
* Format a date string using the given pattern (e.g. "yyyy-MM-dd").
* Supports: yyyy, yy, MM, M, dd, d, hh, h, mm, m, ss, s
*/
function formatDate(text, date) {
const pad = (n, len) => n.toString().padStart(len, "0");
// Order matters: longer patterns first to avoid partial substring matches.
// The original ComfyUI frontend uses the same ordered-alternation approach.
return text
.replace(/yyyy/g, () => date.getFullYear().toString())
.replace(/yy/g, () => pad(date.getFullYear() % 100, 2))
.replace(/MM/g, () => pad(date.getMonth() + 1, 2))
.replace(/M/g, () => (date.getMonth() + 1).toString())
.replace(/dd/g, () => pad(date.getDate(), 2))
.replace(/d/g, () => date.getDate().toString())
.replace(/hh/g, () => pad(date.getHours(), 2))
.replace(/h/g, () => date.getHours().toString())
.replace(/mm/g, () => pad(date.getMinutes(), 2))
.replace(/m/g, () => date.getMinutes().toString())
.replace(/ss/g, () => pad(date.getSeconds(), 2))
.replace(/s/g, () => date.getSeconds().toString());
}
/**
* Resolve %NodeTitle.WidgetName% placeholders in a string using the current graph.
*
* Patterns supported:
* %NodeTitle.WidgetName% widget value from a node (by title or "Node name for S&R")
* %date:format% current date/time formatted (e.g. %date:yyyy-MM-dd%)
* %width%, %height% left as-is, handled by the backend
*
* All other %text% patterns are passed through unchanged (they may be handled by
* the backend's format_filename, e.g. %seed%, %model%, %pprompt%).
*/
function applyTextReplacements(value) {
if (!value || typeof value !== "string" || !value.includes("%")) {
return value;
}
// Collect all nodes from the entire graph hierarchy (including subgraphs)
const allNodes = getAllGraphNodes(app.graph);
return value.replace(/%([^%]+)%/g, function (match, text) {
const split = text.split(".");
if (split.length !== 2) {
// Handle %date:format% patterns
if (split[0].startsWith("date:")) {
return formatDate(split[0].substring(5), new Date());
}
// %width% and %height% are left for the backend to handle
if (text !== "width" && text !== "height") {
console.warn(
"[Save Image (LoraManager)] Unknown placeholder: %" + text + "%"
);
}
return match;
}
// Try finding the node by its "Node name for S&R" property first
let nodes = allNodes
.filter((n) => n.node.properties?.["Node name for S&R"] === split[0])
.map((n) => n.node);
// Fall back to matching by node title
if (!nodes.length) {
nodes = allNodes
.filter((n) => n.node.title === split[0])
.map((n) => n.node);
}
if (!nodes.length) {
console.warn(
"[Save Image (LoraManager)] Node not found: " + split[0]
);
return match;
}
if (nodes.length > 1) {
console.warn(
"[Save Image (LoraManager)] Multiple nodes matched '" +
split[0] +
"', using first match"
);
}
const node = nodes[0];
const widget = node.widgets?.find((w) => w.name === split[1]);
if (!widget) {
console.warn(
"[Save Image (LoraManager)] Widget '" +
split[1] +
"' not found on node " +
split[0]
);
return match;
}
// Sanitize the value: replace characters invalid for filenames
// eslint-disable-next-line no-control-regex
return ((widget.value ?? "") + "").replaceAll(
/[/?<>\\:*|"\x00-\x1F\x7F]/g,
"_"
);
});
}
app.registerExtension({
name: "LoraManager.SaveImageExtraOutput",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "Save Image (LoraManager)") {
return;
}
chainCallback(nodeType.prototype, "onNodeCreated", function () {
// Find the filename_prefix widget
const widget = getWidgetByName(this, "filename_prefix");
if (!widget) {
console.warn(
"[Save Image (LoraManager)] filename_prefix widget not found"
);
return;
}
// Override serialization to resolve %NodeTitle.WidgetName% placeholders
widget.serializeValue = () => {
return applyTextReplacements(widget.value);
};
});
},
});