highscoregames12018 commited on
Commit
ae99871
·
verified ·
1 Parent(s): e1bcb32

Add/update custom_nodes

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +3 -0
  2. custom_nodes/Civicomfy/.DS_Store +0 -0
  3. custom_nodes/Civicomfy/.gitattributes +2 -0
  4. custom_nodes/Civicomfy/.github/workflows/publish.yml +28 -0
  5. custom_nodes/Civicomfy/.gitignore +166 -0
  6. custom_nodes/Civicomfy/LICENSE +21 -0
  7. custom_nodes/Civicomfy/README.md +52 -0
  8. custom_nodes/Civicomfy/__init__.py +110 -0
  9. custom_nodes/Civicomfy/api/__init__.py +0 -0
  10. custom_nodes/Civicomfy/api/civitai.py +255 -0
  11. custom_nodes/Civicomfy/config.py +94 -0
  12. custom_nodes/Civicomfy/download_history.json +0 -0
  13. custom_nodes/Civicomfy/downloader/__init__.py +0 -0
  14. custom_nodes/Civicomfy/downloader/chunk_downloader.py +574 -0
  15. custom_nodes/Civicomfy/downloader/manager.py +929 -0
  16. custom_nodes/Civicomfy/pyproject.toml +14 -0
  17. custom_nodes/Civicomfy/server/__init__.py +5 -0
  18. custom_nodes/Civicomfy/server/routes/CancelDownload.py +44 -0
  19. custom_nodes/Civicomfy/server/routes/ClearHistory.py +32 -0
  20. custom_nodes/Civicomfy/server/routes/DownloadModel.py +470 -0
  21. custom_nodes/Civicomfy/server/routes/GetBaseModels.py +19 -0
  22. custom_nodes/Civicomfy/server/routes/GetModelDetails.py +192 -0
  23. custom_nodes/Civicomfy/server/routes/GetModelDirs.py +216 -0
  24. custom_nodes/Civicomfy/server/routes/GetModelTypes.py +31 -0
  25. custom_nodes/Civicomfy/server/routes/GetStatus.py +19 -0
  26. custom_nodes/Civicomfy/server/routes/OpenPath.py +58 -0
  27. custom_nodes/Civicomfy/server/routes/RetryDownload.py +39 -0
  28. custom_nodes/Civicomfy/server/routes/SearchModels.py +154 -0
  29. custom_nodes/Civicomfy/server/routes/__init__.py +21 -0
  30. custom_nodes/Civicomfy/server/utils.py +154 -0
  31. custom_nodes/Civicomfy/utils/__init__.py +0 -0
  32. custom_nodes/Civicomfy/utils/helpers.py +289 -0
  33. custom_nodes/Civicomfy/web/.DS_Store +0 -0
  34. custom_nodes/Civicomfy/web/images/placeholder.jpeg +3 -0
  35. custom_nodes/Civicomfy/web/js/.DS_Store +0 -0
  36. custom_nodes/Civicomfy/web/js/api/civitai.js +144 -0
  37. custom_nodes/Civicomfy/web/js/civitaiDownloader.css +731 -0
  38. custom_nodes/Civicomfy/web/js/civitaiDownloader.js +89 -0
  39. custom_nodes/Civicomfy/web/js/ui/UI.js +437 -0
  40. custom_nodes/Civicomfy/web/js/ui/feedback.js +41 -0
  41. custom_nodes/Civicomfy/web/js/ui/handlers/downloadHandler.js +110 -0
  42. custom_nodes/Civicomfy/web/js/ui/handlers/eventListeners.js +238 -0
  43. custom_nodes/Civicomfy/web/js/ui/handlers/searchHandler.js +39 -0
  44. custom_nodes/Civicomfy/web/js/ui/handlers/settingsHandler.js +104 -0
  45. custom_nodes/Civicomfy/web/js/ui/handlers/statusHandler.js +154 -0
  46. custom_nodes/Civicomfy/web/js/ui/previewRenderer.js +96 -0
  47. custom_nodes/Civicomfy/web/js/ui/searchRenderer.js +189 -0
  48. custom_nodes/Civicomfy/web/js/ui/statusRenderer.js +113 -0
  49. custom_nodes/Civicomfy/web/js/ui/templates.js +181 -0
  50. custom_nodes/Civicomfy/web/js/utils/cookies.js +24 -0
.gitattributes CHANGED
@@ -53,3 +53,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
53
  *.jpg filter=lfs diff=lfs merge=lfs -text
54
  *.jpeg filter=lfs diff=lfs merge=lfs -text
55
  *.webp filter=lfs diff=lfs merge=lfs -text
 
 
 
 
53
  *.jpg filter=lfs diff=lfs merge=lfs -text
54
  *.jpeg filter=lfs diff=lfs merge=lfs -text
55
  *.webp filter=lfs diff=lfs merge=lfs -text
56
+ custom_nodes/ComfyUI-KJNodes/fonts/FreeMono.ttf filter=lfs diff=lfs merge=lfs -text
57
+ custom_nodes/ComfyUI-KJNodes/fonts/FreeMonoBoldOblique.otf filter=lfs diff=lfs merge=lfs -text
58
+ custom_nodes/ComfyUI-KJNodes/fonts/TTNorms-Black.otf filter=lfs diff=lfs merge=lfs -text
custom_nodes/Civicomfy/.DS_Store ADDED
Binary file (6.15 kB). View file
 
custom_nodes/Civicomfy/.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
custom_nodes/Civicomfy/.github/workflows/publish.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish to Comfy registry
2
+ on:
3
+ workflow_dispatch:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+ paths:
9
+ - "pyproject.toml"
10
+
11
+ permissions:
12
+ issues: write
13
+
14
+ jobs:
15
+ publish-node:
16
+ name: Publish Custom Node to registry
17
+ runs-on: ubuntu-latest
18
+ if: ${{ github.repository_owner == 'MoonGoblinDev' }}
19
+ steps:
20
+ - name: Check out code
21
+ uses: actions/checkout@v4
22
+ with:
23
+ submodules: true
24
+ - name: Publish Custom Node
25
+ uses: Comfy-Org/publish-node-action@v1
26
+ with:
27
+ ## Add your own personal access token to your Github Repository secrets and reference it here.
28
+ personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
custom_nodes/Civicomfy/.gitignore ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/#use-with-ide
110
+ .pdm.toml
111
+
112
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+ __pypackages__/
114
+
115
+ # Celery stuff
116
+ celerybeat-schedule
117
+ celerybeat.pid
118
+
119
+ # SageMath parsed files
120
+ *.sage.py
121
+
122
+ # Environments
123
+ .env
124
+ .venv
125
+ env/
126
+ venv/
127
+ ENV/
128
+ env.bak/
129
+ venv.bak/
130
+
131
+ # Spyder project settings
132
+ .spyderproject
133
+ .spyproject
134
+
135
+ # Rope project settings
136
+ .ropeproject
137
+
138
+ # mkdocs documentation
139
+ /site
140
+
141
+ # mypy
142
+ .mypy_cache/
143
+ .dmypy.json
144
+ dmypy.json
145
+
146
+ # Pyre type checker
147
+ .pyre/
148
+
149
+ # pytype static type analyzer
150
+ .pytype/
151
+
152
+ # Cython debug symbols
153
+ cython_debug/
154
+
155
+ # PyCharm
156
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
159
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
+ #.idea/
161
+ download_history.json
162
+ .DS_Store
163
+ /Old Reference
164
+ CLAUDE.md
165
+ web/.DS_Store
166
+ /tests
custom_nodes/Civicomfy/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 MoonGoblin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
custom_nodes/Civicomfy/README.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Civicomfy - Civitai Model Downloader for ComfyUI
2
+
3
+ Civicomfy seamlessly integrates Civitai's vast model repository directly into ComfyUI, allowing you to search, download, and organize AI models without leaving your workflow.
4
+
5
+ ## Features
6
+
7
+ - **Integrated Model Search**: Search Civitai's extensive library directly from ComfyUI
8
+ - **One-Click Downloads**: Download models with associated metadata and thumbnails
9
+ - **Automatic Organization**: Models are automatically saved to their appropriate directories
10
+ - **Clean UI**: Clean, intuitive interface that complements ComfyUI's aesthetic
11
+
12
+ ## Installation
13
+
14
+ Git clone
15
+ ```bash
16
+ cd ComfyUI/custom_nodes
17
+ git clone https://github.com/MoonGoblinDev/Civicomfy.git
18
+ ```
19
+
20
+ Comfy-CLI
21
+ ```bash
22
+ comfy node registry-install civicomfy
23
+ ```
24
+
25
+ ComfyUI Manager
26
+
27
+ <img width="813" alt="Screenshot 2025-04-08 at 11 42 46" src="https://github.com/user-attachments/assets/5d4f5261-88f6-4aa0-9c66-d1811bb49e09" />
28
+
29
+ ## Usage
30
+
31
+ 1. Start ComfyUI with Civicomfy installed
32
+ 2. Access the Civicomfy panel from the Civicomfy menu button at the right top area.
33
+ 3. Search for models
34
+ 4. Click the download button on any model to save it to your local installation
35
+ 5. Models become immediately available in ComfyUI nodes
36
+
37
+ ## Configuration
38
+
39
+ - Enter your Civitai API Token in the setting
40
+
41
+ ## Screenshots
42
+ <img width="911" alt="Screenshot 2025-04-08 at 11 24 40" src="https://github.com/user-attachments/assets/b9be0c32-729d-490e-be61-2dc072cd9b15" />
43
+ <img width="911" alt="Screenshot 2025-04-08 at 11 23 17" src="https://github.com/user-attachments/assets/cb747c22-afd0-4baf-a9a2-39c70fb11e46" />
44
+ <img width="911" alt="Screenshot 2025-04-08 at 11 25 15" src="https://github.com/user-attachments/assets/02b6d841-a0fa-484c-91a4-4095a7554c3f" />
45
+ <img width="911" alt="Screenshot 2025-04-08 at 11 25 24" src="https://github.com/user-attachments/assets/20fcfcb5-3345-4a72-89fe-ee9c50626ebc" />
46
+
47
+
48
+
49
+
50
+ ## Contributing
51
+
52
+ Contributions are welcome! Please feel free to submit a Pull Request.
custom_nodes/Civicomfy/__init__.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: __init__.py
3
+ # ================================================
4
+
5
+ import os
6
+
7
+ # Define paths relative to this __init__.py file
8
+ EXTENSION_ROOT = os.path.dirname(os.path.realpath(__file__))
9
+ WEB_PATH = os.path.join(EXTENSION_ROOT, "web")
10
+ JS_PATH = os.path.join(WEB_PATH, "js")
11
+ # CSS_PATH definition removed as it was unused and pointed to a non-existent directory.
12
+ JS_FILENAME = "civitaiDownloader.js"
13
+ CSS_FILENAME = "civitaiDownloader.css"
14
+ JS_FILE_PATH = os.path.join(JS_PATH, JS_FILENAME)
15
+ CSS_FILE_PATH = os.path.join(JS_PATH, CSS_FILENAME)
16
+
17
+ # --- Import Core Components ---
18
+ # Import configurations and utility functions first
19
+ # Ensure config and helpers don't have side effects unsuitable for just checking files
20
+ try:
21
+ from .config import WEB_DIRECTORY as config_WEB_DIRECTORY
22
+ # Import downloader manager (creates the instance)
23
+ from .downloader import manager as download_manager
24
+ # Import server routes (registers the routes)
25
+ from .server import routes
26
+ imports_successful = True
27
+ print("[Civicomfy] Core modules imported successfully.")
28
+ except ImportError as e:
29
+ imports_successful = False
30
+ print("*"*80)
31
+ print(f"[Civicomfy] ERROR: Failed to import core modules: {e}")
32
+ print("Please ensure the file structure is correct and all required files exist.")
33
+ print("Extension will likely not function correctly.")
34
+ print("*"*80)
35
+ except Exception as e:
36
+ imports_successful = False
37
+ # Catch other potential init errors during import
38
+ import traceback
39
+ print("*"*80)
40
+ print(f"[Civicomfy] ERROR: An unexpected error occurred during module import:")
41
+ traceback.print_exc()
42
+ print("Extension will likely not function correctly.")
43
+ print("*"*80)
44
+
45
+ # --- Check for Frontend Files ---
46
+ frontend_files_ok = True
47
+ if not os.path.exists(CSS_FILE_PATH):
48
+ print("*"*80)
49
+ print(f"[Civicomfy] WARNING: Frontend CSS file not found!")
50
+ print(f" Expected at: {CSS_FILE_PATH}")
51
+ print(" The downloader UI may not display correctly.")
52
+ print(f" Please ensure '{CSS_FILENAME}' is placed in the '{os.path.basename(JS_PATH)}' directory inside 'web'.") # Corrected path hint
53
+ print("*"*80)
54
+ frontend_files_ok = False
55
+
56
+ if not os.path.exists(JS_FILE_PATH):
57
+ print("*"*80)
58
+ print(f"[Civicomfy] WARNING: Frontend JavaScript file not found!")
59
+ print(f" Expected at: {JS_FILE_PATH}")
60
+ print(" The downloader UI functionality will be missing.")
61
+ print(f" Please ensure '{JS_FILENAME}' is placed in the '{os.path.basename(JS_PATH)}' directory inside 'web'.") # Corrected path hint
62
+ print("*"*80)
63
+ frontend_files_ok = False
64
+
65
+ # --- ComfyUI Registration ---
66
+ if imports_successful:
67
+ # Standard ComfyUI extension variables
68
+ # No custom nodes defined in this extension
69
+ NODE_CLASS_MAPPINGS = {}
70
+ NODE_DISPLAY_NAME_MAPPINGS = {}
71
+
72
+ # Define the web directory for ComfyUI to serve
73
+ # The key is the path component in the URL: /extensions/Civicomfy/...
74
+ # The value is the directory path relative to this __init__.py file
75
+ WEB_DIRECTORY = "./web" # This tells ComfyUI to serve the ./web folder relative to this file
76
+
77
+ # --- Startup Messages ---
78
+ print("-" * 30)
79
+ print("--- Civicomfy Custom Extension Loaded ---")
80
+ print(f"- Serving frontend files from: {os.path.abspath(WEB_PATH)} (Relative: {WEB_DIRECTORY})")
81
+ # Download manager and routes are initialized/registered upon import
82
+ print(f"- Download Manager Initialized: {'Yes' if 'download_manager' in locals() else 'No! Import failed.'}")
83
+ print(f"- API Endpoints Registered: {'Yes' if 'routes' in locals() else 'No! Import failed.'}")
84
+ if frontend_files_ok:
85
+ print("- Frontend files found.")
86
+ else:
87
+ print("- WARNING: Frontend files missing (see warnings above). UI may not work.")
88
+ print("- Look for 'Civicomfy' button in the ComfyUI menu.")
89
+ print("-" * 30)
90
+
91
+ # Ensure default model-type directories exist at startup
92
+ try:
93
+ from .utils.helpers import get_model_dir
94
+ from .config import MODEL_TYPE_DIRS
95
+ created = []
96
+ for key in MODEL_TYPE_DIRS.keys():
97
+ path = get_model_dir(key)
98
+ created.append((key, path))
99
+ print("[Civicomfy] Verified model type directories:")
100
+ for k, p in created:
101
+ print(f" - {k}: {p}")
102
+ except Exception as e:
103
+ print(f"[Civicomfy] Warning: Failed ensuring model directories at startup: {e}")
104
+
105
+ else:
106
+ # If imports failed, don't register anything with ComfyUI
107
+ print("[Civicomfy] Initialization failed due to import errors. Extension inactive.")
108
+ NODE_CLASS_MAPPINGS = {}
109
+ NODE_DISPLAY_NAME_MAPPINGS = {}
110
+ WEB_DIRECTORY = None # Do not serve web directory if backend failed
custom_nodes/Civicomfy/api/__init__.py ADDED
File without changes
custom_nodes/Civicomfy/api/civitai.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: api/civitai.py
3
+ # ================================================
4
+ import requests
5
+ import json
6
+ from typing import List, Optional, Dict, Any, Union
7
+
8
+ class CivitaiAPI:
9
+ """Simple wrapper for interacting with the Civitai API v1."""
10
+ BASE_URL = "https://civitai.com/api/v1"
11
+
12
+ def __init__(self, api_key: Optional[str] = None):
13
+ self.api_key = api_key
14
+ self.base_headers = {'Content-Type': 'application/json'}
15
+ if api_key:
16
+ self.base_headers["Authorization"] = f"Bearer {api_key}"
17
+ print("[Civitai API] Using API Key.")
18
+ else:
19
+ print("[Civitai API] No API Key provided.")
20
+
21
+ def _get_request_headers(self, method: str, has_json_data: bool) -> Dict[str, str]:
22
+ """Returns headers for a specific request."""
23
+ headers = self.base_headers.copy()
24
+ # Don't send content-type for GET/HEAD if no json_data
25
+ if method.upper() in ["GET", "HEAD"] and not has_json_data:
26
+ headers.pop('Content-Type', None)
27
+ return headers
28
+
29
+ def _request(self, method: str, endpoint: str, params: Optional[Dict] = None,
30
+ json_data: Optional[Dict] = None, stream: bool = False,
31
+ allow_redirects: bool = True, timeout: int = 30) -> Union[Dict[str, Any], requests.Response, None]:
32
+ """Makes a request to the Civitai API and handles basic errors."""
33
+ url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
34
+ request_headers = self._get_request_headers(method, json_data is not None)
35
+
36
+ try:
37
+ response = requests.request(
38
+ method,
39
+ url,
40
+ headers=request_headers,
41
+ params=params,
42
+ json=json_data,
43
+ stream=stream,
44
+ allow_redirects=allow_redirects,
45
+ timeout=timeout
46
+ )
47
+ response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
48
+
49
+ if stream:
50
+ return response # Return the response object for streaming
51
+
52
+ # Handle No Content response (e.g., 204)
53
+ if response.status_code == 204 or not response.content:
54
+ return None
55
+
56
+ return response.json()
57
+
58
+ except requests.exceptions.HTTPError as http_err:
59
+ error_detail = None
60
+ status_code = http_err.response.status_code
61
+ try:
62
+ error_detail = http_err.response.json()
63
+ except json.JSONDecodeError:
64
+ error_detail = http_err.response.text[:200] # First 200 chars
65
+ print(f"Civitai API HTTP Error ({method} {url}): Status {status_code}, Response: {error_detail}")
66
+ # Return a structured error dictionary
67
+ return {"error": f"HTTP Error: {status_code}", "details": error_detail, "status_code": status_code}
68
+
69
+ except requests.exceptions.RequestException as req_err:
70
+ print(f"Civitai API Request Error ({method} {url}): {req_err}")
71
+ return {"error": str(req_err), "details": None, "status_code": None}
72
+
73
+ except json.JSONDecodeError as json_err:
74
+ print(f"Civitai API Error: Failed to decode JSON response from {url}: {json_err}")
75
+ # Include response text if possible and not streaming
76
+ response_text = response.text[:200] if not stream and hasattr(response, 'text') else "N/A"
77
+ return {"error": "Invalid JSON response", "details": response_text, "status_code": response.status_code if hasattr(response, 'status_code') else None}
78
+
79
+ def get_model_info(self, model_id: int) -> Optional[Dict[str, Any]]:
80
+ """Gets information about a model by its ID. (GET /models/{id})"""
81
+ endpoint = f"/models/{model_id}"
82
+ result = self._request("GET", endpoint)
83
+ # Check if the result is an error dictionary
84
+ if isinstance(result, dict) and "error" in result:
85
+ return result # Propagate error dict
86
+ return result # Return model info dict or None
87
+
88
+ def get_model_version_info(self, version_id: int) -> Optional[Dict[str, Any]]:
89
+ """Gets information about a specific model version by its ID. (GET /model-versions/{id})"""
90
+ endpoint = f"/model-versions/{version_id}"
91
+ result = self._request("GET", endpoint)
92
+ if isinstance(result, dict) and "error" in result:
93
+ return result
94
+ return result
95
+
96
+ def search_models(self, query: str, types: Optional[List[str]] = None,
97
+ sort: str = 'Highest Rated', period: str = 'AllTime',
98
+ limit: int = 20, page: int = 1,
99
+ nsfw: Optional[bool] = None) -> Optional[Dict[str, Any]]:
100
+ """Searches for models on Civitai. (GET /models)"""
101
+ endpoint = "/models"
102
+ params = {
103
+ "limit": max(1, min(100, limit)), # Ensure limit is reasonable
104
+ "page": max(1, page),
105
+ "query": query,
106
+ "sort": sort,
107
+ "period": period
108
+ }
109
+ if types:
110
+ # `requests` handles lists by appending multiple key=value pairs,
111
+ # which matches the expectation for 'array' type in the API doc.
112
+ params["types"] = types
113
+ if nsfw is not None:
114
+ params["nsfw"] = str(nsfw).lower() # API expects string "true" or "false"
115
+
116
+ result = self._request("GET", endpoint, params=params)
117
+ if isinstance(result, dict) and "error" in result:
118
+ return result
119
+ # Ensure structure is as expected before returning
120
+ if isinstance(result, dict) and "items" in result and "metadata" in result:
121
+ return result
122
+ else:
123
+ print(f"Warning: Unexpected search result format: {result}")
124
+ # Return a consistent empty structure on unexpected format
125
+ return {"items": [], "metadata": {"totalItems": 0, "currentPage": page, "pageSize": limit, "totalPages": 0}}
126
+
127
+ def search_models_meili(self, query: str, types: Optional[List[str]] = None,
128
+ base_models: Optional[List[str]] = None,
129
+ sort: str = 'metrics.downloadCount:desc', # Default to Most Downloaded
130
+ limit: int = 20, page: int = 1,
131
+ nsfw: Optional[bool] = None) -> Optional[Dict[str, Any]]:
132
+ """Searches models using the Civitai Meilisearch endpoint."""
133
+ meili_url = "https://search.civitai.com/multi-search"
134
+ headers = {'Content-Type': 'application/json'}
135
+ headers['Authorization'] = f'Bearer 8c46eb2508e21db1e9828a97968d91ab1ca1caa5f70a00e88a2ba1e286603b61' #Nothing harmful, everyone have the same meilisearch bearer token. I checked with 3 accounts
136
+
137
+ offset = max(0, (page - 1) * limit)
138
+
139
+ # Map simple sort terms to Meilisearch syntax
140
+ sort_mapping = {
141
+ "Relevancy": "id:desc",
142
+ "Most Downloaded": "metrics.downloadCount:desc",
143
+ "Highest Rated": "metrics.thumbsUpCount:desc",
144
+ "Most Liked": "metrics.favoriteCount:desc",
145
+ "Most Discussed": "metrics.commentCount:desc",
146
+ "Most Collected": "metrics.collectedCount:desc",
147
+ "Most Buzz": "metrics.tippedAmountCount:desc",
148
+ "Newest": "createdAt:desc",
149
+ }
150
+ meili_sort = [sort_mapping.get(sort, "metrics.downloadCount:desc")]
151
+
152
+
153
+ # --- Build Filters ---
154
+ # Meilisearch uses an array of filter groups. Filters within a group are OR'd, groups are AND'd.
155
+ filter_groups = []
156
+
157
+ # Type Filter Group (OR logic)
158
+ if types and isinstance(types, list) and len(types) > 0:
159
+ # Map internal type keys/display names to API type names if needed,
160
+ # but the provided example uses direct type names like "LORA". Let's assume frontend sends correct names.
161
+ # Ensure proper quoting for string values in the filter.
162
+ type_filters = [f'"type"="{t}"' for t in types]
163
+ filter_groups.append(type_filters)
164
+
165
+ # Base Model Filter Group (OR logic)
166
+ if base_models and isinstance(base_models, list) and len(base_models) > 0:
167
+ base_model_filters = [f'"version.baseModel"="{bm}"' for bm in base_models]
168
+ filter_groups.append(base_model_filters)
169
+
170
+ # NSFW Filter (applied as AND) - Meili typically uses boolean facets or numeric levels
171
+ # Let's filter by 'nsfwLevel' being acceptable (1=None, 2=Mild, 4=Mature) if NSFW is false or None.
172
+ # If NSFW is true, we don't add this filter (allow all levels).
173
+ # This might need adjustment based on exact Meili setup and desired behavior.
174
+ # An alternative is filtering on the 'nsfw' boolean field if it exists directly.
175
+ if nsfw is None or nsfw is False:
176
+ # Example: Allow levels 1, 2, 4 (adjust as needed)
177
+ # Meili syntax for multiple values: nsfwLevel IN [1, 2, 4]
178
+ filter_groups.append("nsfwLevel IN [1, 2, 4]") # Filter applied directly as AND
179
+ # Or maybe filter on the boolean `nsfw` field if it's indexed:
180
+ # filter_groups.append("nsfw = false")
181
+
182
+ # Availability Filter (Public)
183
+ filter_groups.append("availability = Public") # Filter applied directly as AND
184
+
185
+ # --- Construct Request Body ---
186
+ payload = {
187
+ "queries": [
188
+ {
189
+ "q": query if query else "", # Send empty string "" if no query
190
+ "indexUid": "models_v9",
191
+ "facets": [ # Keep facets requested by frontend if needed for analytics/refinement UI
192
+ "category.name",
193
+ "checkpointType",
194
+ "fileFormats",
195
+ "lastVersionAtUnix",
196
+ "tags.name",
197
+ "type",
198
+ "user.username",
199
+ "version.baseModel",
200
+ "nsfwLevel"
201
+ ],
202
+ "attributesToHighlight": [], # Keep empty if not using highlighting
203
+ "highlightPreTag": "__ais-highlight__",
204
+ "highlightPostTag": "__/ais-highlight__",
205
+ "limit": max(1, min(100, limit)), # Ensure reasonable limit
206
+ "offset": offset,
207
+ "filter": filter_groups
208
+ }
209
+ ]
210
+ }
211
+ if(sort != "Relevancy"):
212
+ payload["queries"][0]["sort"] = meili_sort
213
+
214
+
215
+ try:
216
+ # print(f"DEBUG: Meili Search Payload: {json.dumps(payload, indent=2)}") # Debugging payload
217
+ response = requests.post(meili_url, headers=headers, json=payload, timeout=25) # Use reasonable timeout
218
+ response.raise_for_status()
219
+
220
+ results_data = response.json()
221
+ # print(f"DEBUG: Meili Search Response: {json.dumps(results_data, indent=2)}") # Debugging response
222
+
223
+ # Basic validation of response structure
224
+ if not results_data or not isinstance(results_data.get('results'), list) or not results_data['results']:
225
+ print(f"Warning: Meili search returned unexpected structure or empty results list: {results_data}")
226
+ # Return empty structure consistent with expected format downstream
227
+ return {"hits": [], "limit": limit, "offset": offset, "estimatedTotalHits": 0}
228
+
229
+ # Return the content of the first result (assuming single query)
230
+ first_result = results_data['results'][0]
231
+ if isinstance(first_result, dict) and "hits" in first_result:
232
+ # Return the relevant part of the response
233
+ return first_result # Includes hits, processingTimeMs, limit, offset, estimatedTotalHits etc.
234
+ else:
235
+ print(f"Warning: Meili search first result structure invalid: {first_result}")
236
+ return {"hits": [], "limit": limit, "offset": offset, "estimatedTotalHits": 0}
237
+
238
+ except requests.exceptions.HTTPError as http_err:
239
+ error_detail = None
240
+ status_code = http_err.response.status_code
241
+ try:
242
+ error_detail = http_err.response.json()
243
+ except json.JSONDecodeError:
244
+ error_detail = http_err.response.text[:200]
245
+ print(f"Civitai Meili Search HTTP Error ({meili_url}): Status {status_code}, Response: {error_detail}")
246
+ return {"error": f"Meili HTTP Error: {status_code}", "details": error_detail, "status_code": status_code}
247
+
248
+ except requests.exceptions.RequestException as req_err:
249
+ print(f"Civitai Meili Search Request Error ({meili_url}): {req_err}")
250
+ return {"error": str(req_err), "details": None, "status_code": None}
251
+
252
+ except json.JSONDecodeError as json_err:
253
+ print(f"Civitai Meili Search Error: Failed to decode JSON response from {meili_url}: {json_err}")
254
+ response_text = response.text[:200] if hasattr(response, 'text') else "N/A"
255
+ return {"error": "Invalid JSON response from Meili", "details": response_text, "status_code": response.status_code if hasattr(response, 'status_code') else None}
custom_nodes/Civicomfy/config.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: config.py
3
+ # ================================================
4
+ import os
5
+ import folder_paths # Use ComfyUI's folder_paths
6
+
7
+ # --- Configuration ---
8
+ MAX_CONCURRENT_DOWNLOADS = 3
9
+ DEFAULT_CHUNK_SIZE = 1024 * 1024 # 1MB
10
+ DEFAULT_CONNECTIONS = 4
11
+ DOWNLOAD_HISTORY_LIMIT = 100
12
+ DOWNLOAD_TIMEOUT = 60 # Timeout for individual download chunks/requests (seconds)
13
+ HEAD_REQUEST_TIMEOUT = 25 # Timeout for initial HEAD request (seconds)
14
+ METADATA_DOWNLOAD_TIMEOUT = 20 # Timeout for downloading thumbnail (seconds)
15
+
16
+ # --- Paths ---
17
+ # The root directory of *this specific plugin/extension*
18
+ # Calculated based on the location of this config.py file
19
+ PLUGIN_ROOT = os.path.dirname(os.path.realpath(__file__))
20
+
21
+ # Construct web paths relative to the plugin's root directory
22
+ WEB_DIRECTORY = os.path.join(PLUGIN_ROOT, "web")
23
+ JAVASCRIPT_PATH = os.path.join(WEB_DIRECTORY, "js")
24
+ CSS_PATH = os.path.join(WEB_DIRECTORY, "css")
25
+ # Corrected path construction to avoid issues with leading slashes
26
+ PLACEHOLDER_IMAGE_PATH = os.path.join(WEB_DIRECTORY, "images", "placeholder.jpeg")
27
+
28
+ # Get ComfyUI directories using folder_paths
29
+ COMFYUI_ROOT_DIR = folder_paths.base_path
30
+ # MODELS_DIR removed; resolve per-type via folder_paths
31
+
32
+ # --- Model Types ---
33
+ # Maps the internal key (lowercase) to a tuple: (display_name, folder_paths_type)
34
+ # The folder_paths_type is used by ComfyUI's folder_paths.get_directory_by_type().
35
+ MODEL_TYPE_DIRS = {
36
+ "checkpoint": ("Checkpoint", "checkpoints"),
37
+ "diffusionmodels": ("Diffusion Models", "diffusers"),
38
+ "unet": ("Unet", "unet"),
39
+ "lora": ("Lora", "loras"),
40
+ "locon": ("LoCon", "loras"),
41
+ "lycoris": ("LyCORIS", "loras"),
42
+ "vae": ("VAE", "vae"),
43
+ "embedding": ("Embedding", "embeddings"),
44
+ "hypernetwork": ("Hypernetwork", "hypernetworks"),
45
+ "controlnet": ("ControlNet", "controlnet"),
46
+ "upscaler": ("Upscaler", "upscale_models"),
47
+ "motionmodule": ("Motion Module", "motion_models"),
48
+ "poses": ("Poses", "poses"),
49
+ "wildcards": ("Wildcards", "wildcards"),
50
+ # 'other' will save to a dedicated folder inside the Civicomfy extension directory
51
+ "other": ("Other", None)
52
+ }
53
+
54
+ # Civitai API specific type mapping (for search filters)
55
+ # Maps internal key (lowercase) to Civitai API 'types' parameter value
56
+ CIVITAI_API_TYPE_MAP = {
57
+ "checkpoint": "Checkpoint",
58
+ "lora": "LORA",
59
+ "locon": "LoCon",
60
+ "lycoris": "LORA", # Civitai might group LyCORIS under LORA search type
61
+ "vae": "VAE",
62
+ "embedding": "TextualInversion",
63
+ "hypernetwork": "Hypernetwork",
64
+ "controlnet": "Controlnet",
65
+ "motionmodule": "MotionModule",
66
+ "poses": "Poses",
67
+ "wildcards": "Wildcards",
68
+ "upscaler": "Upscaler",
69
+ "unet": "UNET",
70
+ "diffusionmodels": "Checkpoint", # No specific type, map to checkpoint
71
+ }
72
+
73
+ AVAILABLE_MEILI_BASE_MODELS = [
74
+ "AuraFlow", "CogVideoX", "Flux.1 D", "Flux.1 S", "Hunyuan 1", "Hunyuan Video",
75
+ "Illustrious", "Kolors", "LTXV", "Lumina", "Mochi", "NoobAI", "ODOR", "Other",
76
+ "PixArt E", "PixArt a", "Playground v2", "Pony", "SD 1.4", "SD 1.5",
77
+ "SD 1.5 Hyper", "SD 1.5 LCM", "SD 2.0", "SD 2.0 768", "SD 2.1", "SD 2.1 768",
78
+ "SD 2.1 Unclip", "SD 3", "SD 3.5", "SD 3.5 Large", "SD 3.5 Large Turbo",
79
+ "SD 3.5 Medium", "SDXL 0.9", "SDXL 1.0", "SDXL 1.0 LCM", "SDXL Distilled",
80
+ "SDXL Hyper", "SDXL Lightning", "SDXL Turbo", "SVD", "SVD XT", "Stable Cascade",
81
+ "Wan Video"
82
+ ]
83
+
84
+ # --- Filename Suffixes ---
85
+ METADATA_SUFFIX = ".cminfo.json"
86
+ PREVIEW_SUFFIX = ".preview.jpeg" # Keep as requested, even if source is png/webp
87
+
88
+ # --- Log Initial Paths for Verification ---
89
+ print("-" * 30)
90
+ print("[Civicomfy Config Initialized]")
91
+ print(f" - Plugin Root: {PLUGIN_ROOT}")
92
+ print(f" - Web Directory: {WEB_DIRECTORY}")
93
+ print(f" - ComfyUI Base Path: {COMFYUI_ROOT_DIR}")
94
+ print("-" * 30)
custom_nodes/Civicomfy/download_history.json ADDED
The diff for this file is too large to render. See raw diff
 
custom_nodes/Civicomfy/downloader/__init__.py ADDED
File without changes
custom_nodes/Civicomfy/downloader/chunk_downloader.py ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: downloader/chunk_downloader.py
3
+ # Multi-connection somehow still not working
4
+ # ================================================
5
+
6
+ import requests
7
+ import threading
8
+ import time
9
+ import shutil
10
+ from pathlib import Path
11
+ import os
12
+ from typing import Optional, Dict, Tuple, Union, TYPE_CHECKING
13
+
14
+ # Import manager type hint without circular dependency during type checking
15
+ if TYPE_CHECKING:
16
+ from .manager import DownloadManager
17
+
18
+ # Import config values
19
+ from ..config import DEFAULT_CHUNK_SIZE, DOWNLOAD_TIMEOUT, HEAD_REQUEST_TIMEOUT
20
+
21
+ class ChunkDownloader:
22
+ """Handles downloading files in chunks using multiple connections or fallback."""
23
+ # Constants
24
+ STATUS_UPDATE_INTERVAL = 0.5
25
+ HEAD_REQUEST_TIMEOUT = HEAD_REQUEST_TIMEOUT
26
+ DOWNLOAD_TIMEOUT = DOWNLOAD_TIMEOUT
27
+ MIN_SIZE_FOR_MULTI_MB = 100 # Minimum file size for multi-connection download
28
+
29
+ def __init__(self, url: str, output_path: str, num_connections: int = 4,
30
+ chunk_size: int = DEFAULT_CHUNK_SIZE, manager: 'DownloadManager' = None,
31
+ download_id: str = None, api_key: Optional[str] = None,
32
+ known_size: Optional[int] = None):
33
+ # URLs
34
+ self.initial_url = url
35
+ self.url = url
36
+
37
+ # Paths
38
+ self.output_path = Path(output_path)
39
+ self.temp_dir = self.output_path.parent / f".{self.output_path.name}.parts_{download_id or int(time.time())}"
40
+
41
+ # Download configuration
42
+ self.num_connections = max(1, num_connections)
43
+ self.chunk_size = chunk_size
44
+ self.manager = manager
45
+ self.download_id = download_id
46
+ self.api_key = api_key
47
+ self.known_size = known_size if known_size and known_size > 0 else None
48
+
49
+ # Download state
50
+ self.total_size = self.known_size or 0
51
+ self.downloaded = 0
52
+ self.connection_type = "N/A"
53
+ self.error = None
54
+
55
+ # Thread management
56
+ self.threads = []
57
+ self.lock = threading.Lock()
58
+ self.cancel_event = threading.Event()
59
+ self.part_files = []
60
+
61
+ # Performance tracking
62
+ self._start_time = 0
63
+ self._last_update_time = 0
64
+ self._last_downloaded_bytes = 0
65
+ self._speed = 0
66
+
67
+ def _get_request_headers(self, add_range: Optional[str] = None) -> Dict[str, str]:
68
+ """Constructs request headers with optional auth and range."""
69
+ headers = {}
70
+ if self.api_key:
71
+ headers["Authorization"] = f"Bearer {self.api_key}"
72
+ if add_range:
73
+ headers['Range'] = add_range
74
+ return headers
75
+
76
+ @property
77
+ def is_cancelled(self) -> bool:
78
+ """Check if download has been cancelled."""
79
+ return self.cancel_event.is_set()
80
+
81
+ def cancel(self):
82
+ """Signal the download to cancel."""
83
+ if not self.is_cancelled:
84
+ print(f"[Downloader {self.download_id or 'N/A'}] Cancellation requested by user.")
85
+ self.cancel_event.set()
86
+ self.error = "Download cancelled by user"
87
+ if self.manager and self.download_id:
88
+ self.manager._update_download_status(self.download_id, status="cancelled", error=self.error)
89
+
90
+ def _cleanup_temp(self, success: bool):
91
+ """Remove temporary directory and potentially the output file."""
92
+ # Clean up temp directory
93
+ if self.temp_dir.exists():
94
+ try:
95
+ shutil.rmtree(self.temp_dir)
96
+ except Exception as e:
97
+ print(f"[Downloader {self.download_id}] Warning: Could not remove temp directory {self.temp_dir}: {e}")
98
+
99
+ # Remove output file if download failed
100
+ if not success and self.output_path.exists():
101
+ try:
102
+ self.output_path.unlink()
103
+ print(f"[Downloader {self.download_id}] Removed incomplete/failed output file: {self.output_path}")
104
+ except Exception as e:
105
+ print(f"[Downloader {self.download_id}] Warning: Could not remove incomplete output file {self.output_path}: {e}")
106
+
107
+ def _get_range_support_and_url(self) -> Tuple[str, bool]:
108
+ """Check for range support and get final URL after redirects."""
109
+ final_url = self.initial_url
110
+ supports_ranges = False
111
+
112
+ try:
113
+ request_headers = self._get_request_headers()
114
+
115
+ print(f"[Downloader {self.download_id}] Checking range support/redirects for: {self.initial_url} (Timeout: {self.HEAD_REQUEST_TIMEOUT}s)")
116
+ response = requests.head(
117
+ self.initial_url,
118
+ allow_redirects=True,
119
+ timeout=self.HEAD_REQUEST_TIMEOUT,
120
+ headers=request_headers
121
+ )
122
+ response.raise_for_status()
123
+
124
+ # Update URL after redirects
125
+ final_url = response.url
126
+ self.url = final_url
127
+
128
+ # Check range support
129
+ accept_ranges = response.headers.get('accept-ranges', 'none').lower()
130
+ supports_ranges = accept_ranges == 'bytes'
131
+
132
+ # Get file size if not already known
133
+ if self.total_size <= 0:
134
+ head_size = int(response.headers.get('Content-Length', 0))
135
+ if head_size > 0:
136
+ self.total_size = head_size
137
+ print(f"[Downloader {self.download_id}] Got file size from HEAD: {self.total_size} bytes")
138
+
139
+ print(f"[Downloader {self.download_id}] HEAD Check OK - Final URL: {final_url}, Range Support: {supports_ranges}")
140
+ return final_url, supports_ranges
141
+
142
+ except requests.exceptions.Timeout:
143
+ print(f"[Downloader {self.download_id}] Warning: HEAD request timed out. Proceeding with Single connection.")
144
+ return self.initial_url, False
145
+
146
+ except requests.exceptions.RequestException as e:
147
+ http_error_details = ""
148
+ if hasattr(e, 'response') and e.response is not None:
149
+ status_code = e.response.status_code
150
+ http_error_details = f" (Status Code: {status_code})"
151
+ print(f"[Downloader {self.download_id}] Warning: HEAD request failed{http_error_details}. Proceeding with Single connection.")
152
+ return self.initial_url, False
153
+
154
+ except Exception as e:
155
+ print(f"[Downloader {self.download_id}] Warning: Unexpected error during HEAD request: {e}. Proceeding with Single connection.")
156
+ return self.initial_url, False
157
+
158
+ def _update_progress(self, chunk_len: int):
159
+ """Thread-safe update of download progress and speed calculation."""
160
+ with self.lock:
161
+ self.downloaded += chunk_len
162
+ current_time = time.monotonic()
163
+ time_diff = current_time - self._last_update_time
164
+
165
+ # Update speed and notify manager periodically
166
+ if time_diff >= self.STATUS_UPDATE_INTERVAL or self.downloaded == self.total_size:
167
+ progress = min((self.downloaded / self.total_size) * 100, 100.0) if self.total_size > 0 else 0
168
+
169
+ # Calculate speed
170
+ if time_diff > 0:
171
+ bytes_diff = self.downloaded - self._last_downloaded_bytes
172
+ self._speed = bytes_diff / time_diff
173
+
174
+ self._last_update_time = current_time
175
+ self._last_downloaded_bytes = self.downloaded
176
+
177
+ if self.manager and self.download_id:
178
+ self.manager._update_download_status(
179
+ self.download_id,
180
+ progress=progress,
181
+ speed=self._speed,
182
+ status="downloading"
183
+ )
184
+
185
+ def download_segment(self, segment_index: int, start_byte: int, end_byte: int):
186
+ """Downloads a specific segment of the file."""
187
+ part_file_path = self.temp_dir / f"part_{segment_index}"
188
+ request_headers = self._get_request_headers(add_range=f'bytes={start_byte}-{end_byte}')
189
+ retries = 3
190
+
191
+ for current_try in range(retries):
192
+ if self.is_cancelled:
193
+ print(f"[Downloader {self.download_id}] Segment {segment_index} cancelled before request (Try {current_try+1}).")
194
+ # Ensure error is set if not already
195
+ if not self.error: self.error = "Cancelled during segment download"
196
+ return
197
+
198
+ response = None
199
+ try:
200
+ response = requests.get(self.url, headers=request_headers, stream=True, timeout=self.DOWNLOAD_TIMEOUT)
201
+ response.raise_for_status()
202
+
203
+ bytes_written_this_segment = 0
204
+ with open(part_file_path, 'wb') as f:
205
+ for chunk in response.iter_content(self.chunk_size):
206
+ if self.is_cancelled:
207
+ print(f"[Downloader {self.download_id}] Segment {segment_index} cancelled mid-stream.")
208
+ # Ensure error is set if not already
209
+ if not self.error: self.error = "Cancelled during segment download"
210
+
211
+
212
+ if chunk:
213
+ bytes_written = f.write(chunk)
214
+ bytes_written_this_segment += bytes_written
215
+ self._update_progress(bytes_written)
216
+
217
+ # Verify segment size
218
+ expected_size = (end_byte - start_byte) + 1
219
+ if bytes_written_this_segment != expected_size:
220
+ if response:
221
+ response.close()
222
+ raise ValueError(f"Size mismatch. Expected {expected_size}, got {bytes_written_this_segment}")
223
+
224
+ return # Success
225
+
226
+
227
+ except (requests.exceptions.RequestException, ValueError) as e:
228
+ # Handle HTTP status codes
229
+ status_code = None
230
+ error_msg_detail = f"{e}"
231
+
232
+ if isinstance(e, requests.exceptions.RequestException) and hasattr(e, 'response') and e.response is not None:
233
+ status_code = e.response.status_code
234
+ if status_code == 401:
235
+ error_msg_detail += " (Unauthorized)"
236
+ elif status_code == 403:
237
+ error_msg_detail += " (Forbidden)"
238
+ elif status_code == 416:
239
+ error_msg_detail += " (Range Not Satisfiable)"
240
+ self.error = f"Segment {segment_index} failed: {error_msg_detail}"
241
+ self.cancel()
242
+ return
243
+
244
+ print(f"[Downloader {self.download_id}] Warning: Segment {segment_index} failed (Try {current_try+1}/{retries}): {error_msg_detail}")
245
+
246
+ if current_try >= retries - 1: # Last attempt failed
247
+ self.error = f"Segment {segment_index} failed after {retries} attempts: {error_msg_detail}"
248
+ self.cancel()
249
+ return
250
+
251
+ # Exponential backoff before retry
252
+ time.sleep(min(2 ** current_try, 10))
253
+
254
+ except Exception as e:
255
+ self.error = f"Segment {segment_index} critical error: {e}"
256
+ print(f"[Downloader {self.download_id}] Error: {self.error}")
257
+ self.cancel()
258
+ return
259
+
260
+ finally:
261
+ if response:
262
+ response.close()
263
+
264
+ def merge_parts(self) -> bool:
265
+ """Merges all downloaded part files into the final output file."""
266
+ print(f"[Downloader {self.download_id}] Merging {len(self.part_files)} parts for {self.output_path.name}...")
267
+
268
+ # Check if we have parts to merge
269
+ if not self.part_files:
270
+ if self.is_cancelled:
271
+ self.error = self.error or "Cancelled before any parts downloaded."
272
+ elif not self.error:
273
+ self.error = "No part files were created to merge."
274
+ print(f"[Downloader {self.download_id}] Error during merge: {self.error}")
275
+ return False
276
+
277
+ try:
278
+ # Sort part files numerically
279
+ sorted_part_files = sorted(self.part_files, key=lambda p: int(p.name.split('_')[-1]))
280
+
281
+ with open(self.output_path, 'wb') as outfile:
282
+ for part_file in sorted_part_files:
283
+ # Check if part exists
284
+ if not part_file.exists():
285
+ if self.is_cancelled:
286
+ self.error = self.error or "Cancelled during download, a part file is missing."
287
+ elif not self.error:
288
+ self.error = f"Merge failed, required part file is missing: {part_file}."
289
+ print(f"[Downloader {self.download_id}] Warning: Aborting merge. Missing part: {part_file.name}.")
290
+ return False
291
+
292
+ # Copy part data to output file
293
+ try:
294
+ with open(part_file, 'rb') as infile:
295
+ shutil.copyfileobj(infile, outfile, length=1024*1024*2) # 2MB buffer
296
+ except Exception as copy_e:
297
+ self.error = f"Error copying data from part {part_file.name} during merge: {copy_e}"
298
+ print(f"[Downloader {self.download_id}] Error: {self.error}")
299
+ return False
300
+
301
+ print(f"[Downloader {self.download_id}] Merging complete.")
302
+
303
+ # Verify final file size
304
+ final_size = self.output_path.stat().st_size
305
+ if self.total_size > 0 and abs(final_size - self.total_size) > 1:
306
+ self.error = f"Merged size ({final_size}) differs significantly from expected ({self.total_size}). File may be corrupt."
307
+ print(f"[Downloader {self.download_id}] Error: {self.error}")
308
+ return False
309
+ elif self.total_size > 0 and final_size != self.total_size:
310
+ print(f"[Downloader {self.download_id}] Warning: Final merged size ({final_size}) differs slightly from expected ({self.total_size}).")
311
+
312
+ return True
313
+
314
+ except Exception as e:
315
+ self.error = f"Failed to merge parts: {e}"
316
+ print(f"[Downloader {self.download_id}] Error: {self.error}")
317
+ return False
318
+
319
+ def fallback_download(self) -> bool:
320
+ """Fallback to standard single-connection download."""
321
+ self.connection_type = "Single"
322
+ if self.manager and self.download_id:
323
+ self.manager._update_download_status(self.download_id, connection_type=self.connection_type, status="downloading")
324
+
325
+ print(f"[Downloader {self.download_id}] Using standard single-connection download for {self.output_path.name}...")
326
+
327
+ self._start_time = self._start_time or time.monotonic()
328
+ self._last_update_time = self._start_time
329
+ self._last_downloaded_bytes = 0
330
+ self.downloaded = 0
331
+
332
+ response = None
333
+
334
+ try:
335
+ request_headers = self._get_request_headers()
336
+ response = requests.get(self.url, stream=True, timeout=self.DOWNLOAD_TIMEOUT,
337
+ allow_redirects=True, headers=request_headers)
338
+ response.raise_for_status()
339
+
340
+ # Update URL after potential redirects
341
+ final_get_url = response.url
342
+ if final_get_url != self.url:
343
+ print(f"[Downloader {self.download_id}] URL redirected during GET to: {final_get_url}")
344
+ self.url = final_get_url
345
+
346
+ # Get/confirm file size
347
+ if self.total_size <= 0:
348
+ get_size = int(response.headers.get('Content-Length', 0))
349
+ if get_size > 0:
350
+ self.total_size = get_size
351
+ print(f"[Downloader {self.download_id}] Obtained file size via fallback GET: {self.total_size}")
352
+ else:
353
+ print(f"[Downloader {self.download_id}] Warning: File size unknown. Progress may be inaccurate.")
354
+
355
+ # Ensure output directory exists
356
+ self.output_path.parent.mkdir(parents=True, exist_ok=True)
357
+
358
+ with open(self.output_path, 'wb') as f:
359
+ for chunk in response.iter_content(self.chunk_size):
360
+ if self.is_cancelled:
361
+ print(f"[Downloader {self.download_id}] Fallback download cancelled.")
362
+ return False
363
+ if chunk:
364
+ bytes_written = f.write(chunk)
365
+ self._update_progress(bytes_written)
366
+
367
+
368
+
369
+ # Verify download size if known
370
+ if self.total_size > 0 and self.downloaded != self.total_size and not self.error:
371
+ print(f"[Downloader {self.download_id}] Warning: Size mismatch. Expected {self.total_size}, got {self.downloaded}.")
372
+
373
+ print(f"[Downloader {self.download_id}] Fallback download completed.")
374
+ return not self.error
375
+
376
+ except requests.exceptions.RequestException as e:
377
+ error_msg_detail = f"{e}"
378
+ if hasattr(e, 'response') and e.response is not None:
379
+ status_code = e.response.status_code
380
+ if status_code == 401:
381
+ error_msg_detail += " (Unauthorized - Check API Key?)"
382
+ elif status_code == 403:
383
+ error_msg_detail += " (Forbidden - Permissions Issue?)"
384
+
385
+ if not self.error:
386
+ self.error = f"Fallback download failed: {error_msg_detail}"
387
+ print(f"[Downloader {self.download_id}] Error during fallback download: {self.error}")
388
+ return False
389
+
390
+ except Exception as e:
391
+ if not self.error:
392
+ self.error = f"Fallback download failed: {e}"
393
+ print(f"[Downloader {self.download_id}] Error during fallback download: {self.error}")
394
+ return False
395
+
396
+ finally:
397
+ if response:
398
+ response.close()
399
+
400
+ def download(self) -> bool:
401
+ """Main download method that chooses between multi-connection or fallback approach."""
402
+ self._start_time = time.monotonic()
403
+ self.downloaded = 0
404
+ self.error = None
405
+ self.threads = []
406
+ self.part_files = []
407
+ success = False
408
+
409
+ # Clean up any existing temp directory
410
+ if self.temp_dir.exists():
411
+ print(f"[Downloader {self.download_id}] Warning: Removing leftover temp directory: {self.temp_dir}")
412
+ self._cleanup_temp(success=False)
413
+
414
+ # Check range support and get final URL
415
+ final_url, supports_ranges = self._get_range_support_and_url()
416
+
417
+ # Decide on download strategy
418
+ use_multi_connection = False
419
+ if supports_ranges and self.num_connections > 1 and self.total_size > 0:
420
+ if self.total_size > self.MIN_SIZE_FOR_MULTI_MB * 1024 * 1024:
421
+ use_multi_connection = True
422
+ else:
423
+ print(f"[Downloader {self.download_id}] File size ({self.total_size / (1024*1024):.2f} MB) below threshold for multi-connection.")
424
+
425
+ expected_final_size = self.total_size
426
+
427
+ try:
428
+ if use_multi_connection:
429
+ # Multi-connection download approach
430
+ success = self._do_multi_connection_download()
431
+ else:
432
+ # Single connection fallback
433
+ reason = "Range requests not supported" if not supports_ranges else \
434
+ "Single connection requested" if self.num_connections <= 1 else \
435
+ "File size unknown or too small"
436
+ print(f"[Downloader {self.download_id}] ({reason}). Using fallback single-connection download.")
437
+ success = self.fallback_download()
438
+ expected_final_size = self.total_size
439
+
440
+ if not success and not self.error:
441
+ self.error = "Single connection download failed."
442
+
443
+ except KeyboardInterrupt:
444
+ print(f"[Downloader {self.download_id}] Interrupted! Signalling cancellation.")
445
+ self.cancel()
446
+ if not self.error:
447
+ self.error = "Download interrupted by user."
448
+ success = False
449
+
450
+ except Exception as e:
451
+ import traceback
452
+ print(f"--- Critical Error in Download {self.download_id} ('{self.output_path.name}') ---")
453
+ traceback.print_exc()
454
+ print("--- End Error ---")
455
+
456
+ if not self.error:
457
+ self.error = f"Unexpected download error: {str(e)}"
458
+
459
+ success = False
460
+ if not self.is_cancelled:
461
+ self.cancel()
462
+
463
+ finally:
464
+ # Cleanup and final status update
465
+ self._cleanup_temp(success=success and not self.is_cancelled and not self.error)
466
+
467
+ if self.manager and self.download_id:
468
+ final_status = "completed" if success else ("cancelled" if self.is_cancelled else "failed")
469
+ final_progress = 100.0 if success else ((self.downloaded / self.total_size * 100) if self.total_size > 0 else 0)
470
+
471
+ self.manager._update_download_status(
472
+ self.download_id,
473
+ status=final_status,
474
+ progress=min(100.0, final_progress),
475
+ speed=0,
476
+ error=self.error,
477
+ connection_type=self.connection_type
478
+ )
479
+
480
+ return success and not self.error and not self.is_cancelled
481
+
482
+ def _do_multi_connection_download(self) -> bool:
483
+ """Handle multi-connection download process."""
484
+ self.connection_type = f"Multi ({self.num_connections})"
485
+ if self.manager and self.download_id:
486
+ self.manager._update_download_status(self.download_id, connection_type=self.connection_type, status="downloading")
487
+
488
+ print(f"[Downloader {self.download_id}] Starting multi-connection download for {self.output_path.name} "
489
+ f"({self.total_size / (1024 * 1024):.2f} MB) using {self.num_connections} connections.")
490
+
491
+ # Create temp directory
492
+ try:
493
+ if self.temp_dir.exists():
494
+ shutil.rmtree(self.temp_dir)
495
+ self.temp_dir.mkdir(parents=True)
496
+ except Exception as e:
497
+ self.error = f"Failed to create temp directory: {e}"
498
+ print(f"[Downloader {self.download_id}] Error: {self.error}")
499
+ return False
500
+
501
+ # Calculate segments
502
+ segment_size = self.total_size // self.num_connections
503
+
504
+ # Handle small files with many connections
505
+ if segment_size == 0 and self.total_size > 0:
506
+ segment_size = self.total_size // min(self.num_connections, self.total_size) if self.total_size >= self.num_connections else self.total_size
507
+ if segment_size == 0:
508
+ segment_size = self.total_size
509
+ self.num_connections = 1
510
+ print(f"[Downloader {self.download_id}] Warning: Forcing single connection for very small file.")
511
+ return self.fallback_download()
512
+
513
+ # Create segments
514
+ segments = []
515
+ current_byte = 0
516
+ for i in range(self.num_connections):
517
+ if current_byte >= self.total_size:
518
+ break
519
+
520
+ start_byte = current_byte
521
+ end_byte = min(current_byte + segment_size - 1, self.total_size - 1)
522
+
523
+ # Ensure last segment goes to the end
524
+ if i == self.num_connections - 1:
525
+ end_byte = self.total_size - 1
526
+
527
+ # Ensure segment is valid
528
+ if start_byte <= end_byte < self.total_size:
529
+ segments.append((i, start_byte, end_byte))
530
+ self.part_files.append(self.temp_dir / f"part_{i}")
531
+ else:
532
+ print(f"[Downloader {self.download_id}] Warning: Skipping invalid segment {i}, start={start_byte}, end={end_byte}")
533
+
534
+ current_byte = end_byte + 1
535
+
536
+ if not segments:
537
+ self.error = f"No valid download segments calculated (Total Size: {self.total_size})."
538
+ print(f"[Downloader {self.download_id}] Error: {self.error}")
539
+ return False
540
+
541
+ # Start download threads
542
+ for index, start, end in segments:
543
+ if self.is_cancelled:
544
+ break
545
+ thread = threading.Thread(target=self.download_segment, args=(index, start, end), daemon=True)
546
+ self.threads.append(thread)
547
+ thread.start()
548
+
549
+ # Wait for threads to complete
550
+ active_threads = list(self.threads)
551
+ while active_threads and not self.is_cancelled:
552
+ joined_threads = []
553
+ for t in active_threads:
554
+ t.join(timeout=0.2)
555
+ if not t.is_alive():
556
+ joined_threads.append(t)
557
+
558
+ active_threads = [t for t in active_threads if t not in joined_threads]
559
+
560
+ # Handle download completion
561
+ if self.is_cancelled:
562
+ print(f"[Downloader {self.download_id}] Download stopped (cancelled).")
563
+ self.error = self.error or "Download cancelled."
564
+ return False
565
+ elif self.error:
566
+ print(f"[Downloader {self.download_id}] Download stopped (error): {self.error}")
567
+ return False
568
+ elif self.total_size > 0 and self.downloaded != self.total_size:
569
+ self.error = f"Multi-download size mismatch. Expected {self.total_size}, got {self.downloaded}."
570
+ print(f"[Downloader {self.download_id}] Error: {self.error}")
571
+ return False
572
+
573
+ # Merge parts
574
+ return self.merge_parts()
custom_nodes/Civicomfy/downloader/manager.py ADDED
@@ -0,0 +1,929 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: downloader/manager.py
3
+ # ================================================
4
+ import threading
5
+ import time
6
+ import datetime
7
+ import os
8
+ import json
9
+ import requests
10
+ import subprocess
11
+ import platform
12
+ import sys
13
+ from typing import List, Dict, Any, Optional, TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from .chunk_downloader import ChunkDownloader
17
+
18
+ from ..config import (
19
+ MAX_CONCURRENT_DOWNLOADS, DOWNLOAD_HISTORY_LIMIT, DEFAULT_CONNECTIONS,
20
+ METADATA_SUFFIX, PREVIEW_SUFFIX, METADATA_DOWNLOAD_TIMEOUT, PLUGIN_ROOT
21
+ )
22
+ try:
23
+ from folder_paths import get_directory_by_type, get_valid_path, base_path
24
+ COMFY_PATHS_AVAILABLE = True
25
+ except ImportError:
26
+ print("[Civicomfy Manager] Warning: ComfyUI folder_paths not available. Path validation/opening might be limited.")
27
+ COMFY_PATHS_AVAILABLE = False
28
+ base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
29
+
30
+ # --- Define History File Path ---
31
+ # Place it in the root of the extension directory
32
+ HISTORY_FILE_PATH = os.path.join(PLUGIN_ROOT, "download_history.json")
33
+
34
+ class DownloadManager:
35
+ """Manages a queue of downloads, running them concurrently and saving metadata."""
36
+
37
+ def __init__(self, max_concurrent: int = MAX_CONCURRENT_DOWNLOADS):
38
+ self.queue: List[Dict[str, Any]] = []
39
+ self.active_downloads: Dict[str, Dict[str, Any]] = {} # {download_id: download_info}
40
+ # History now stores more complete dictionaries for retry functionality
41
+ self.history: List[Dict[str, Any]] = []
42
+ self.lock: threading.Lock = threading.Lock()
43
+ self.max_concurrent: int = max(1, max_concurrent)
44
+ self.running: bool = True
45
+ self._load_history_from_file()
46
+ self._process_thread: threading.Thread = threading.Thread(target=self._process_queue, daemon=True)
47
+ print(f"Civitai Download Manager starting (Max Concurrent: {self.max_concurrent}).")
48
+ self._process_thread.start()
49
+
50
+ # --- add_to_queue remains largely the same, ensuring all necessary fields are initialized ---
51
+ def add_to_queue(self, download_info: Dict[str, Any]) -> str:
52
+ """Adds a download task to the queue."""
53
+ with self.lock:
54
+ # Generate a unique ID
55
+ timestamp = int(time.time() * 1000)
56
+ file_hint = os.path.basename(download_info.get('output_path', 'file'))[:10]
57
+ unique_num = sum(1 for item in self.queue if file_hint in item.get("id", "") or any(file_hint in h.get("id","") for h in self.history)) # Check history too
58
+ download_id = f"dl_{timestamp}_{unique_num}_{file_hint}"
59
+
60
+ # Set initial status and info
61
+ download_info["id"] = download_id
62
+ download_info["status"] = "queued"
63
+ download_info["added_time"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
64
+ download_info["progress"] = 0
65
+ download_info["speed"] = 0
66
+ download_info["error"] = None
67
+ download_info["start_time"] = None
68
+ download_info["end_time"] = None
69
+ download_info["connection_type"] = "N/A"
70
+
71
+ # --- Ensure all fields potentially needed for retry exist ---
72
+ # (Most were likely already filled by the calling route, but double-check)
73
+ required_for_retry = [
74
+ 'url', 'output_path', 'num_connections', 'api_key', 'known_size',
75
+ 'civitai_model_info', 'civitai_version_info', 'civitai_primary_file',
76
+ 'thumbnail', 'filename', 'model_url_or_id', 'model_version_id', 'model_type',
77
+ 'custom_filename', 'force_redownload' # Add force_redownload too
78
+ ]
79
+ for key in required_for_retry:
80
+ if key not in download_info:
81
+ # Add default or None if missing. More robust handling might be needed
82
+ # depending on how routes.py prepares the dict.
83
+ if key in ['civitai_model_info', 'civitai_version_info', 'civitai_primary_file']:
84
+ download_info[key] = {}
85
+ elif key == 'num_connections':
86
+ download_info[key] = DEFAULT_CONNECTIONS
87
+ elif key == 'force_redownload':
88
+ download_info[key] = False # Default for new downloads
89
+ else:
90
+ download_info[key] = None
91
+ print(f"[Manager Warning] Queued item '{download_id}' missing '{key}', added default.")
92
+
93
+ self.queue.append(download_info)
94
+ print(f"[Manager] Queued: {download_info.get('filename', 'N/A')} (ID: {download_id}, Size: {download_info.get('known_size', 'Unknown')})")
95
+ return download_id
96
+
97
+ # --- cancel_download remains the same ---
98
+ def cancel_download(self, download_id: str) -> bool:
99
+ """Requests cancellation of a queued or active download."""
100
+ # ... (no changes needed here) ...
101
+ print(f"[Manager] Received cancellation request for: {download_id}") # Moved print earlier
102
+ downloader_to_cancel: Optional['ChunkDownloader'] = None
103
+ found_in_active = False
104
+
105
+ with self.lock:
106
+ # 1. Check queue first (can be fully handled under lock)
107
+ for i, item in enumerate(self.queue):
108
+ if item["id"] == download_id:
109
+ cancelled_info = self.queue.pop(i)
110
+ cancelled_info["status"] = "cancelled"
111
+ cancelled_info["end_time"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
112
+ cancelled_info["error"] = "Cancelled from queue"
113
+ self._add_to_history(cancelled_info) # _add_to_history is safe under lock
114
+ print(f"[Manager] Cancelled queued download: {download_id}")
115
+ return True # Cancelled from queue, we are done
116
+
117
+ # 2. Check active downloads - Find the instance *under lock*
118
+ if download_id in self.active_downloads:
119
+ found_in_active = True
120
+ active_info = self.active_downloads[download_id]
121
+ downloader_to_cancel = active_info.get("downloader_instance")
122
+ current_status = active_info.get("status")
123
+
124
+ # If downloader instance doesn't exist yet (status 'starting')
125
+ # or if already terminal, handle it here under lock
126
+ if not downloader_to_cancel and current_status == "starting":
127
+ print(f"[Manager] Marking 'starting' download as cancelled: {download_id}")
128
+ # Mark as cancelled, it won't start or will be caught by wrapper
129
+ active_info["status"] = "cancelled"
130
+ if not active_info.get("end_time"):
131
+ active_info["end_time"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
132
+ active_info["error"] = "Cancelled before download thread fully started"
133
+ # Don't assign downloader_to_cancel, let process_queue clean it up
134
+ downloader_to_cancel = None # Explicitly clear
135
+ # Indicate we handled it (or attempted to)
136
+ return True # Exit, processed within lock
137
+ elif current_status in ["completed", "failed", "cancelled"]:
138
+ print(f"[Manager] Download {download_id} is already in terminal state '{current_status}'. Cannot cancel.")
139
+ # Indicate we don't need to proceed further outside the lock
140
+ downloader_to_cancel = None # Explicitly clear
141
+ return False # Already finished/cancelled
142
+
143
+ # If we found an instance and it's potentially running,
144
+ # store it to call cancel *after* releasing the lock.
145
+ print(f"[Manager] Found active downloader instance for {download_id}. Will signal outside lock.")
146
+
147
+ # Lock is released automatically here when exiting 'with'
148
+
149
+ # 3. Signal the downloader instance *outside* the lock
150
+ if downloader_to_cancel:
151
+ try:
152
+ # Check if already cancelled (to avoid duplicate logs/actions) - This check is thread-safe
153
+ if not downloader_to_cancel.is_cancelled:
154
+ print(f"[Manager] Calling downloader.cancel() for {download_id}")
155
+ downloader_to_cancel.cancel() # This can now call _update_download_status safely
156
+ print(f"[Manager] Signalled downloader.cancel() for {download_id}")
157
+ # Let the download thread's final status update handle moving to history
158
+ return True # Signal sent
159
+ else:
160
+ print(f"[Manager] Active download {download_id} was already cancelling.")
161
+ return True # Already in cancelling state is considered a success here
162
+ except Exception as e:
163
+ print(f"[Manager] Error calling downloader.cancel() for {download_id}: {e}")
164
+ # Update status to failed maybe? Or just log.
165
+ # Use _update_download_status directly here as we are outside the lock
166
+ self._update_download_status(download_id, status="failed", error=f"Error during cancel signaling: {e}")
167
+ return False # Failed to signal
168
+
169
+ # 4. Handle cases where it wasn't in queue and wasn't running/starting
170
+ if not found_in_active:
171
+ print(f"[Manager] Could not cancel - ID not found in queue or active: {download_id}")
172
+ return False # Not found
173
+
174
+ # It was found in active but was already terminal or couldn't be signalled
175
+ # Return value determined above
176
+ return False # Should have returned True earlier if successful
177
+
178
+ # --- get_status remains the same (still strips data for UI) ---
179
+ def get_status(self) -> Dict[str, List[Dict[str, Any]]]:
180
+ """Returns the current state of the queue, active downloads, and history.
181
+ Strips sensitive/large data for UI efficiency."""
182
+ with self.lock:
183
+ # Fields to exclude when sending status to UI
184
+ exclude_fields_for_ui = [
185
+ 'downloader_instance', 'civitai_model_info', 'civitai_version_info',
186
+ 'api_key', # Don't send API key to frontend status
187
+ # Large potentially redundant fields:
188
+ 'url', 'output_path', 'custom_filename', 'model_url_or_id',
189
+ # Keep 'thumbnail', 'filename', 'model_name', 'version_name' etc for display
190
+ ]
191
+
192
+ # Prepare active downloads list
193
+ active_list = [
194
+ {k: v for k, v in item_data.items() if k not in exclude_fields_for_ui}
195
+ for item_id, item_data in self.active_downloads.items()
196
+ ]
197
+
198
+ # Prepare history list similarly
199
+ history_list = [
200
+ {k: v for k, v in item_data.items() if k not in exclude_fields_for_ui}
201
+ for item_data in self.history[:DOWNLOAD_HISTORY_LIMIT]
202
+ ]
203
+
204
+ # Return copies
205
+ return {
206
+ "queue": [
207
+ {k:v for k,v in item.items() if k not in exclude_fields_for_ui}
208
+ for item in self.queue
209
+ ],
210
+ "active": active_list,
211
+ "history": history_list,
212
+ }
213
+
214
+ def _load_history_from_file(self):
215
+ """Loads download history from the JSON file."""
216
+ # No lock needed here as it's called during __init__ before the thread starts
217
+ if not os.path.exists(HISTORY_FILE_PATH):
218
+ print(f"[Manager] History file not found ({HISTORY_FILE_PATH}). Starting with empty history.")
219
+ self.history = []
220
+ return
221
+
222
+ try:
223
+ with open(HISTORY_FILE_PATH, 'r', encoding='utf-8') as f:
224
+ loaded_data = json.load(f)
225
+
226
+ if isinstance(loaded_data, list):
227
+ # Basic validation: Ensure items have IDs (optional but good)
228
+ validated_history = [item for item in loaded_data if isinstance(item, dict) and 'id' in item]
229
+ invalid_count = len(loaded_data) - len(validated_history)
230
+ if invalid_count > 0:
231
+ print(f"[Manager Warning] {invalid_count} items removed from loaded history due to missing 'id'.")
232
+
233
+ # Ensure history limit
234
+ self.history = validated_history[:DOWNLOAD_HISTORY_LIMIT]
235
+ print(f"[Manager] Successfully loaded {len(self.history)} items from {HISTORY_FILE_PATH}.")
236
+ else:
237
+ print(f"[Manager Warning] History file ({HISTORY_FILE_PATH}) contained invalid data (not a list). Starting fresh.")
238
+ self.history = []
239
+ # Optionally try to delete the corrupted file?
240
+ # try: os.remove(HISTORY_FILE_PATH) except Exception: pass
241
+
242
+ except json.JSONDecodeError as e:
243
+ print(f"[Manager Error] Failed to parse history file ({HISTORY_FILE_PATH}): {e}. Starting fresh.")
244
+ self.history = []
245
+ # Optionally try to delete the corrupted file?
246
+ except Exception as e:
247
+ print(f"[Manager Error] Failed to read history file ({HISTORY_FILE_PATH}): {e}. Starting fresh.")
248
+ self.history = []
249
+
250
+ # --- Save History Method ---
251
+ def _save_history_to_file(self):
252
+ """Saves the current in-memory history list to the JSON file."""
253
+ # Assumes self.lock is HELD when this is called
254
+ history_to_save = self.history[:DOWNLOAD_HISTORY_LIMIT] # Ensure limit before saving
255
+
256
+ try:
257
+ # Ensure directory exists (should already, but belt-and-suspenders)
258
+ os.makedirs(os.path.dirname(HISTORY_FILE_PATH), exist_ok=True)
259
+
260
+ # Write atomically (write to temp then rename) to reduce corruption risk
261
+ temp_file_path = HISTORY_FILE_PATH + ".tmp"
262
+ with open(temp_file_path, 'w', encoding='utf-8') as f:
263
+ json.dump(history_to_save, f, indent=2, ensure_ascii=False) # Pretty print
264
+
265
+ os.replace(temp_file_path, HISTORY_FILE_PATH) # Atomic rename/replace
266
+ # print(f"[Manager] Saved {len(history_to_save)} items to history file.") # Can be noisy
267
+
268
+ except Exception as e:
269
+ # Log error, but don't crash the manager
270
+ print(f"[Manager Error] Failed to save history file ({HISTORY_FILE_PATH}): {e}")
271
+ # Attempt to remove temp file if it exists
272
+ if os.path.exists(temp_file_path):
273
+ try: os.remove(temp_file_path)
274
+ except Exception: pass
275
+
276
+ # --- Updated _add_to_history Method ---
277
+ def _add_to_history(self, download_info: Dict[str, Any]):
278
+ """Adds a completed/failed/cancelled item to history (internal).
279
+ Stores most original parameters needed for potential retry.
280
+ NOW ALSO TRIGGERS SAVING HISTORY TO FILE."""
281
+ # --- (Keep existing logic to prepare info_copy) ---
282
+ info_copy = {
283
+ k: v for k, v in download_info.items()
284
+ if k not in ['downloader_instance']
285
+ }
286
+ if "end_time" not in info_copy or info_copy["end_time"] is None:
287
+ info_copy["end_time"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
288
+ if "status" not in info_copy:
289
+ info_copy["status"] = "unknown"
290
+ if info_copy["status"] != "completed" and not info_copy.get("error"):
291
+ info_copy["error"] = f"Finished with status '{info_copy['status']}' but no error recorded."
292
+
293
+ # --- Update in-memory history ---
294
+ self.history.insert(0, info_copy) # Prepend
295
+ # Trim in-memory list (save will also respect limit)
296
+ if len(self.history) > DOWNLOAD_HISTORY_LIMIT:
297
+ self.history = self.history[:DOWNLOAD_HISTORY_LIMIT]
298
+
299
+ # --- Trigger Save to File (still under lock) ---
300
+ self._save_history_to_file() # <--- Added call
301
+
302
+ # --- Updated clear_history Method ---
303
+ def clear_history(self) -> Dict[str, Any]:
304
+ """Clears the download history (in-memory and the JSON file)."""
305
+ cleared_count = 0
306
+ file_deleted = False
307
+ error_msg = None
308
+
309
+ try:
310
+ with self.lock:
311
+ cleared_count = len(self.history)
312
+ if cleared_count > 0:
313
+ print(f"[Manager] Clearing {cleared_count} items from in-memory history.")
314
+ self.history = [] # Clear memory list first
315
+
316
+ # Attempt to delete the history file
317
+ if os.path.exists(HISTORY_FILE_PATH):
318
+ try:
319
+ os.remove(HISTORY_FILE_PATH)
320
+ file_deleted = True
321
+ print(f"[Manager] Deleted history file: {HISTORY_FILE_PATH}")
322
+ except OSError as e:
323
+ error_msg = f"Failed to delete history file {HISTORY_FILE_PATH}: {e}"
324
+ print(f"[Manager Error] {error_msg}")
325
+ else:
326
+ print(f"[Manager] History file ({HISTORY_FILE_PATH}) did not exist, nothing to delete.")
327
+ file_deleted = True # Consider success if file wasn't there anyway
328
+
329
+ else:
330
+ print("[Manager] History clear request received, but history was already empty.")
331
+ # Should we still check/delete the file just in case? Yes.
332
+ if os.path.exists(HISTORY_FILE_PATH):
333
+ try:
334
+ os.remove(HISTORY_FILE_PATH)
335
+ file_deleted = True
336
+ print(f"[Manager] Deleted potentially orphaned history file: {HISTORY_FILE_PATH}")
337
+ except OSError as e:
338
+ error_msg = f"Failed to delete potentially orphaned history file {HISTORY_FILE_PATH}: {e}"
339
+ print(f"[Manager Error] {error_msg}")
340
+ else:
341
+ file_deleted = True # Success if clear requested and neither memory/file had anything
342
+
343
+ if error_msg:
344
+ return {"success": False, "error": f"History cleared from memory, but could not delete file: {error_msg}"}
345
+ elif cleared_count > 0:
346
+ return {"success": True, "message": f"Cleared {cleared_count} history items (memory and file)."}
347
+ else:
348
+ # If count was 0 but file deletion was attempted/succeeded
349
+ return {"success": True, "message": "History was already empty."}
350
+
351
+ except Exception as e:
352
+ print(f"[Manager] Critical error during clear_history: {e}")
353
+ import traceback
354
+ traceback.print_exc()
355
+ return {"success": False, "error": f"Failed to clear history due to unexpected error: {e}"}
356
+
357
+ # --- _process_queue remains the same ---
358
+ def _process_queue(self):
359
+ """Internal thread function to manage downloads."""
360
+ # ... (no changes needed here) ...
361
+ print("[Manager] Process queue thread started.")
362
+ while self.running:
363
+ processed_something = False
364
+ with self.lock:
365
+ # 1. Check for finished/failed/cancelled active downloads to move to history
366
+ finished_ids = [
367
+ dl_id for dl_id, info in self.active_downloads.items()
368
+ if info.get("status") in ["completed", "failed", "cancelled"] # Use .get() for safety
369
+ ]
370
+ for dl_id in finished_ids:
371
+ # Check if still in active_downloads before popping (might have been removed by another thread edge case?)
372
+ if dl_id in self.active_downloads:
373
+ finished_info = self.active_downloads.pop(dl_id)
374
+ self._add_to_history(finished_info) # Will now store more data
375
+ print(f"[Manager] Moved '{finished_info.get('filename', dl_id)}' to history (Status: {finished_info['status']})")
376
+ processed_something = True
377
+ else:
378
+ print(f"[Manager] Warning: Item {dl_id} intended for history was already removed from active list.")
379
+
380
+ # 2. Start new downloads if slots available and queue has items
381
+ while len(self.active_downloads) < self.max_concurrent and self.queue:
382
+ download_info = self.queue.pop(0)
383
+ download_id = download_info["id"]
384
+
385
+ # Double check if cancelled just before starting
386
+ if download_info["status"] == "cancelled":
387
+ # Ensure it has an end time before adding to history
388
+ if not download_info.get("end_time"):
389
+ download_info["end_time"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
390
+ self._add_to_history(download_info)
391
+ print(f"[Manager] Skipping cancelled item from queue: {download_id}")
392
+ processed_something = True
393
+ continue
394
+
395
+ # Update status to 'starting'
396
+ download_info["status"] = "starting"
397
+ download_info["start_time"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
398
+ download_info["downloader_instance"] = None # Placeholder
399
+
400
+ # Add to active downloads BEFORE starting thread
401
+ self.active_downloads[download_id] = download_info
402
+
403
+ # Start download in a separate thread
404
+ thread = threading.Thread(
405
+ target=self._download_file_wrapper,
406
+ args=(download_info,),
407
+ daemon=True # Ensure thread exits if main program exits
408
+ )
409
+ thread.start()
410
+ print(f"[Manager] Starting download thread for: {download_info.get('filename', 'N/A')} ({download_id})")
411
+ processed_something = True
412
+
413
+ # Sleep only if nothing was processed to avoid busy-waiting
414
+ if not processed_something:
415
+ time.sleep(0.5) # Small delay before checking again
416
+
417
+ print("[Manager] Process queue thread stopped.")
418
+
419
+ # --- _update_download_status remains the same ---
420
+ def _update_download_status(self, download_id: str, status: Optional[str] = None,
421
+ progress: Optional[float] = None, speed: Optional[float] = None,
422
+ error: Optional[str] = None, connection_type: Optional[str] = None):
423
+ """Safely updates the status fields of an active download (thread-safe)."""
424
+ # ... (no changes needed here) ...
425
+ with self.lock:
426
+ if download_id in self.active_downloads:
427
+ item = self.active_downloads[download_id]
428
+ updated = False # Track if any field was actually updated
429
+ # Only update if value is provided
430
+ if status is not None and item.get("status") != status:
431
+ item["status"] = status
432
+ updated = True
433
+ # If status becomes terminal, record end time
434
+ if status in ["completed", "failed", "cancelled"] and not item.get("end_time"):
435
+ item["end_time"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
436
+ if progress is not None:
437
+ # Clamp progress between 0 and 100
438
+ clamped_progress = max(0.0, min(100.0, progress))
439
+ # Only update if different enough to avoid excessive updates
440
+ if abs(item.get("progress", 0) - clamped_progress) > 0.01:
441
+ item["progress"] = clamped_progress
442
+ updated = True
443
+ if speed is not None:
444
+ clamped_speed = max(0.0, speed) # Speed cannot be negative
445
+ if item.get("speed") != clamped_speed: # Update if different
446
+ item["speed"] = clamped_speed
447
+ updated = True
448
+ if error is not None and item.get("error") != error:
449
+ # Update error only if it's new or different
450
+ item["error"] = str(error)[:500] # Limit length
451
+ updated = True
452
+ if connection_type is not None and connection_type != "N/A" and item.get("connection_type") != connection_type: # Only update if not N/A and different
453
+ item["connection_type"] = connection_type
454
+ updated = True
455
+
456
+ # --- _save_civitai_metadata remains the same ---
457
+ def _save_civitai_metadata(self, download_info: Dict[str, Any]):
458
+ """Saves the .cminfo.json file."""
459
+ # ... (no changes needed here) ...
460
+ output_path = download_info.get('output_path')
461
+ model_info = download_info.get('civitai_model_info', {})
462
+ version_info = download_info.get('civitai_version_info', {})
463
+ primary_file = download_info.get('civitai_primary_file', {})
464
+ download_id = download_info.get('id', 'unknown')
465
+
466
+ try:
467
+ file_meta = primary_file.get('metadata', {}) or {} # Ensure dict
468
+ creator_info = model_info.get('creator', {}) or {}
469
+ model_stats = model_info.get('stats', {}) or {}
470
+ version_stats = version_info.get('stats', {}) or {}
471
+
472
+ metadata = {
473
+ "ModelId": model_info.get('id', version_info.get('modelId')) , # Use .get() on version info too
474
+ "ModelName": model_info.get('name', version_info.get('model',{}).get('name')), # Nested .get()
475
+ "ModelDescription": model_info.get('description'),
476
+ "CreatorUsername": creator_info.get('username'),
477
+ "Nsfw": model_info.get('nsfw', version_info.get('model',{}).get('nsfw')),
478
+ "Poi": model_info.get('poi', version_info.get('model',{}).get('poi')),
479
+ "AllowNoCredit": model_info.get('allowNoCredit', True),
480
+ "AllowCommercialUse": str(model_info.get('allowCommercialUse', 'Unknown')), # Ensure string
481
+ "AllowDerivatives": model_info.get('allowDerivatives', True),
482
+ "AllowDifferentLicense": model_info.get('allowDifferentLicense', True),
483
+ "Tags": model_info.get('tags', []),
484
+ "ModelType": model_info.get('type'),
485
+ "VersionId": version_info.get('id'),
486
+ "VersionName": version_info.get('name'),
487
+ "VersionDescription": version_info.get('description'),
488
+ "BaseModel": version_info.get('baseModel'),
489
+ "BaseModelType": version_info.get('baseModelType'),
490
+ "EarlyAccessDeadline": version_info.get('earlyAccessDeadline'),
491
+ "VersionPublishedAt": version_info.get('publishedAt'),
492
+ "VersionUpdatedAt": version_info.get('updatedAt'),
493
+ "VersionStatus": version_info.get('status'),
494
+ "IsPrimaryFile": primary_file.get('primary', False),
495
+ "PrimaryFileId": primary_file.get('id'),
496
+ "PrimaryFileName": primary_file.get('name'),
497
+ "FileMetadata": {
498
+ "fp": file_meta.get('fp'),
499
+ "size": file_meta.get('size'),
500
+ "format": file_meta.get('format', 'Unknown')
501
+ },
502
+ "ImportedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
503
+ "Hashes": primary_file.get('hashes', {}),
504
+ "TrainedWords": version_info.get('trainedWords', []),
505
+ "Stats": {
506
+ "downloadCount": version_stats.get('downloadCount', model_stats.get('downloadCount', 0)),
507
+ "rating": version_stats.get('rating', model_stats.get('rating', 0)),
508
+ "ratingCount": version_stats.get('ratingCount', model_stats.get('ratingCount', 0)),
509
+ "favoriteCount": version_stats.get('favoriteCount', model_stats.get('favoriteCount', 0)), # Correct source needed? Check API docs
510
+ "commentCount": version_stats.get('commentCount', model_stats.get('commentCount', 0)), # Correct source needed? Check API docs
511
+ "thumbsUpCount": version_stats.get('thumbsUpCount', 0),
512
+ },
513
+ "DownloadUrlUsed": download_info.get('url'),
514
+ }
515
+
516
+ base, _ = os.path.splitext(output_path)
517
+ meta_filename = base + METADATA_SUFFIX
518
+ meta_path = os.path.join(os.path.dirname(output_path), meta_filename)
519
+
520
+ print(f"[Manager Meta {download_id}] Saving metadata to: {meta_path}")
521
+ with open(meta_path, 'w', encoding='utf-8') as f:
522
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
523
+ print(f"[Manager Meta {download_id}] Metadata saved successfully.")
524
+
525
+ except Exception as e:
526
+ import traceback
527
+ print(f"[Manager Meta {download_id}] Error saving metadata file {meta_path}: {e}")
528
+ # traceback.print_exc() # Uncomment for full trace
529
+
530
+ # --- _download_and_save_preview remains the same ---
531
+ def _download_and_save_preview(self, download_info: Dict[str, Any]):
532
+ """Downloads and saves the .preview.jpeg file."""
533
+ # ... (no changes needed here) ...
534
+ output_path = download_info.get('output_path')
535
+ thumbnail_url = download_info.get('thumbnail')
536
+ api_key = download_info.get('api_key')
537
+ download_id = download_info.get('id', 'unknown')
538
+
539
+ if not output_path:
540
+ print(f"[Manager Preview {download_id}] Skipping preview download: Missing output path.")
541
+ return
542
+ if not thumbnail_url:
543
+ print(f"[Manager Preview {download_id}] Skipping preview download: No thumbnail URL provided.")
544
+ # Optionally try to find one in version_info images again? Might be redundant.
545
+ version_info = download_info.get('civitai_version_info', {})
546
+ if version_info and isinstance(version_info.get('images'), list) and version_info['images']:
547
+ sorted_images = sorted([img for img in version_info['images'] if img and img.get("url")], key=lambda x: x.get('index', 0))
548
+ img_data = next((img for img in sorted_images if img.get("type") == "image" and "/width=" in img.get("url","")), None) # Prefer image type with width param
549
+ if not img_data: img_data = next((img for img in sorted_images if img.get("type") == "image"), None) # Fallback to any image type
550
+ if not img_data: img_data = next((img for img in sorted_images), None) # Fallback to any image at all
551
+ if img_data and img_data.get('url'):
552
+ thumbnail_url = img_data['url']
553
+ # Attempt to get a reasonable size (e.g. ~450px width)
554
+ if "/width=" in thumbnail_url:
555
+ thumbnail_url = thumbnail_url.split("/width=")[0] + "/width=450"
556
+ elif "/blob/" not in thumbnail_url: # Avoid adding params to blob URLs
557
+ separator = "&" if "?" in thumbnail_url else "?"
558
+ thumbnail_url += f"{separator}width=450"
559
+ print(f"[Manager Preview {download_id}] Found alternative thumbnail URL: {thumbnail_url}")
560
+ else:
561
+ print(f"[Manager Preview {download_id}] Still no thumbnail URL found in version info.")
562
+ return # Exit if still no URL
563
+ else:
564
+ return # Exit if no URL and no version info to search
565
+
566
+ base, _ = os.path.splitext(output_path)
567
+ preview_filename = base + PREVIEW_SUFFIX
568
+ preview_path = os.path.join(os.path.dirname(output_path), preview_filename)
569
+
570
+ print(f"[Manager Preview {download_id}] Downloading thumbnail from {thumbnail_url} to {preview_path}")
571
+ response = None
572
+ try:
573
+ headers = {}
574
+ if api_key: headers["Authorization"] = f"Bearer {api_key}"
575
+ response = requests.get(thumbnail_url, stream=True, headers=headers, timeout=METADATA_DOWNLOAD_TIMEOUT, allow_redirects=True)
576
+ response.raise_for_status()
577
+ content_type = response.headers.get('Content-Type', '').lower()
578
+ if not content_type.startswith('image/'):
579
+ print(f"[Manager Preview {download_id}] Warning: Thumbnail URL returned non-image content type '{content_type}'. Skipping save.")
580
+ return
581
+ with open(preview_path, 'wb') as f:
582
+ for chunk in response.iter_content(chunk_size=8192): f.write(chunk)
583
+ print(f"[Manager Preview {download_id}] Thumbnail downloaded successfully.")
584
+ except requests.exceptions.RequestException as e:
585
+ error_msg = f"Error downloading thumbnail {thumbnail_url}: {e}"
586
+ if hasattr(e, 'response') and e.response is not None: error_msg += f" (Status: {e.response.status_code})"
587
+ print(f"[Manager Preview {download_id}] {error_msg}")
588
+ except Exception as e: print(f"[Manager Preview {download_id}] Error saving thumbnail {preview_path}: {e}")
589
+ finally:
590
+ if response: response.close()
591
+
592
+ # --- _download_file_wrapper remains the same ---
593
+ def _download_file_wrapper(self, download_info: Dict[str, Any]):
594
+ """Wraps the download execution, handles status updates, exceptions, and metadata saving."""
595
+ # ... (no changes needed here) ...
596
+ download_id = download_info["id"]
597
+ filename = download_info.get('filename', download_id)
598
+ from .chunk_downloader import ChunkDownloader
599
+ downloader = None
600
+ success = False
601
+ final_status = "failed"
602
+ error_msg = None
603
+
604
+ try:
605
+ print(f"[Downloader Wrapper {download_id}] Preparing download for '{filename}'.")
606
+ downloader = ChunkDownloader(
607
+ url=download_info["url"],
608
+ output_path=download_info["output_path"],
609
+ num_connections=download_info.get("num_connections", DEFAULT_CONNECTIONS),
610
+ manager=self,
611
+ download_id=download_id,
612
+ api_key=download_info.get("api_key"),
613
+ known_size=download_info.get("known_size")
614
+ )
615
+
616
+ with self.lock:
617
+ if download_id not in self.active_downloads or self.active_downloads[download_id]["status"] == "cancelled":
618
+ print(f"[Downloader Wrapper {download_id}] Download was cancelled before instance could be fully linked/started.")
619
+ self._update_download_status(download_id, status="cancelled", error="Cancelled before start")
620
+ return
621
+
622
+ self.active_downloads[download_id]["downloader_instance"] = downloader
623
+
624
+ self._update_download_status(download_id, status="downloading")
625
+ print(f"[Downloader Wrapper {download_id}] Starting download process for '{filename}'.")
626
+ success = downloader.download() # Blocking call
627
+
628
+ error_msg = downloader.error
629
+
630
+ if success:
631
+ final_status = "completed"
632
+ print(f"[Downloader Wrapper {download_id}] Download completed successfully for '{filename}'.")
633
+ try:
634
+ self._save_civitai_metadata(download_info)
635
+ self._download_and_save_preview(download_info)
636
+ except Exception as meta_err:
637
+ print(f"[Downloader Wrapper {download_id}] Error during post-download metadata/preview saving: {meta_err}")
638
+
639
+ elif downloader.is_cancelled:
640
+ final_status = "cancelled"
641
+ error_msg = downloader.error or "Download cancelled"
642
+ print(f"[Downloader Wrapper {download_id}] Download cancelled for '{filename}'. Reason: {error_msg}")
643
+ else:
644
+ final_status = "failed"
645
+ error_msg = downloader.error or "Download failed with unknown error"
646
+ print(f"[Downloader Wrapper {download_id}] Download failed for '{filename}'. Error: {error_msg}")
647
+
648
+ except Exception as e:
649
+ import traceback
650
+ print(f"--- Critical Error in Download Wrapper {download_id} ('{filename}') ---")
651
+ traceback.print_exc()
652
+ print("--- End Error ---")
653
+ final_status = "failed"
654
+ error_msg = f"Unexpected wrapper error: {str(e)}"
655
+ if downloader and not downloader.is_cancelled:
656
+ try: downloader.cancel()
657
+ except: pass
658
+
659
+ finally:
660
+ final_progress_percent = 0
661
+ conn_type = download_info.get("connection_type", "N/A")
662
+
663
+ if downloader:
664
+ conn_type = downloader.connection_type
665
+ if downloader.total_size and downloader.total_size > 0:
666
+ final_progress_percent = (downloader.downloaded / downloader.total_size * 100)
667
+ if final_status == "completed": final_progress_percent = 100.0
668
+ final_progress_percent = min(100.0, max(0.0, final_progress_percent))
669
+
670
+ print(f"[Downloader Wrapper {download_id}] Finalizing status: {final_status}, Error: {error_msg}")
671
+ self._update_download_status(
672
+ download_id, status=final_status, progress=final_progress_percent,
673
+ speed=0, error=error_msg, connection_type=conn_type
674
+ )
675
+ if final_status == "completed":
676
+ print(f"[Manager] Download {download_id} completed ('{filename}'). Manual ComfyUI refresh may be needed for model list.")
677
+
678
+ # --- NEW: Retry Download Method ---
679
+ def retry_download(self, original_download_id: str) -> Dict[str, Any]:
680
+ """Finds a failed/cancelled download in history and re-queues it."""
681
+ with self.lock:
682
+ # Find the original download info in history
683
+ original_info = next((item for item in self.history if item.get("id") == original_download_id), None)
684
+
685
+ if not original_info:
686
+ return {"success": False, "error": f"Original download ID '{original_download_id}' not found in history."}
687
+
688
+ original_status = original_info.get("status")
689
+ if original_status not in ["failed", "cancelled"]:
690
+ return {"success": False, "error": f"Cannot retry download with status '{original_status}'. Only 'failed' or 'cancelled' are retryable."}
691
+
692
+ # --- Prepare the new download info dictionary ---
693
+ # Make a deep copy to avoid modifying the history item directly
694
+ try:
695
+ retry_info = json.loads(json.dumps(original_info))
696
+ print(retry_info)
697
+ except Exception as e:
698
+ return {"success": False, "error": f"Failed to copy original download data: {e}"}
699
+
700
+ # Remove fields specific to the *previous* attempt
701
+ retry_info.pop("id", None) # Will get a new ID
702
+ retry_info.pop("status", None)
703
+ retry_info.pop("progress", None)
704
+ retry_info.pop("speed", None)
705
+ retry_info.pop("error", None)
706
+ retry_info.pop("start_time", None)
707
+ retry_info.pop("end_time", None)
708
+ retry_info.pop("added_time", None)
709
+ retry_info.pop("connection_type", None)
710
+ retry_info.pop("downloader_instance", None)
711
+ # --- Crucially: Set force_redownload to True for retry ---
712
+ # This ensures it overwrites the potentially corrupted/partial file from the previous attempt.
713
+ retry_info["force_redownload"] = True
714
+
715
+ # --- Validate required fields for queuing (redundant check, but safe) ---
716
+ required_for_retry = [
717
+ 'url', 'output_path', 'num_connections', 'api_key', 'known_size',
718
+ 'civitai_model_info', 'civitai_version_info', 'civitai_primary_file',
719
+ 'thumbnail', 'filename', 'model_url_or_id', 'model_version_id', 'model_type',
720
+ 'custom_filename', 'force_redownload'
721
+ ]
722
+ missing_keys = [key for key in required_for_retry if key not in retry_info or retry_info[key] is None and key != 'api_key' and key != 'custom_filename'] # Allow api_key/custom_filename to be None
723
+ #if missing_keys:
724
+ # return {"success": False, "error": f"Cannot retry: Original download data is missing required fields: {', '.join(missing_keys)}"}
725
+
726
+ # --- Add the prepared info to the queue (outside the lock for add_to_queue's own lock) ---
727
+ # Note: add_to_queue acquires its own lock internally
728
+ try:
729
+ new_download_id = self.add_to_queue(retry_info)
730
+ if new_download_id: # Check if add_to_queue returned a valid ID (indicating success)
731
+ with self.lock:
732
+ original_len = len(self.history)
733
+ # Filter out the item matching the original ID
734
+ self.history = [item for item in self.history if item.get("id") != original_download_id]
735
+ items_removed = original_len - len(self.history)
736
+
737
+ if items_removed == 1:
738
+ print(f"[Manager] Successfully removed original download '{original_download_id}' from history.")
739
+ return {
740
+ "success": True,
741
+ "message": f"Retry initiated. New download queued. Original removed from history.",
742
+ "new_download_id": new_download_id
743
+ }
744
+
745
+ else:
746
+ # Should have been caught by the except block, but as a failsafe
747
+ print(f"[Manager] Retry queueing failed for '{original_download_id}' for an unknown reason (no ID returned).")
748
+ return {"success": False, "error": "Failed to queue retry (unknown internal error)."}
749
+
750
+ except Exception as e:
751
+ print(f"[Manager] Error requeuing download for retry (Original ID: {original_download_id}): {e}")
752
+ return {"success": False, "error": f"Failed to queue retry: {e}"}
753
+
754
+ # --- NEW: Open Containing Folder Method ---
755
+ def open_containing_folder(self, download_id: str) -> Dict[str, Any]:
756
+ """Opens the directory containing the specified completed download file."""
757
+ file_path = None
758
+ with self.lock:
759
+ # Check history first (most likely location for completed items)
760
+ item_info = next((item for item in self.history if item.get("id") == download_id), None)
761
+ # Fallback to active (less likely, but possible if called very quickly after completion)
762
+ if not item_info and download_id in self.active_downloads:
763
+ item_info = self.active_downloads.get(download_id)
764
+
765
+ if not item_info:
766
+ return {"success": False, "error": "Download ID not found."}
767
+
768
+ if item_info.get("status") != "completed":
769
+ return {"success": False, "error": f"Cannot open path for download with status '{item_info.get('status')}'. Must be 'completed'."}
770
+
771
+ file_path = item_info.get("output_path") # Get the full path to the file
772
+
773
+ # --- Perform file operations outside the lock ---
774
+ if not file_path:
775
+ return {"success": False, "error": "Output path not found for this download."}
776
+
777
+ # --- Security Check: Ensure the path is within expected ComfyUI directories ---
778
+ # (This is a basic check, might need refinement based on your setup)
779
+ is_safe_path = False
780
+ try:
781
+ # Get the absolute path
782
+ abs_file_path = os.path.abspath(file_path)
783
+ folder_path = os.path.dirname(abs_file_path)
784
+
785
+ # Option 1: Check against ComfyUI's known directories (preferred)
786
+ if COMFY_PATHS_AVAILABLE:
787
+ # Check if the folder is within any known model type directory
788
+ known_types = [
789
+ "checkpoints", "loras", "vae", "embeddings", "hypernetworks",
790
+ "controlnet", "upscale_models", "clip_vision", "gligen", "configs",
791
+ "unet", "diffusers", "motion_models", "poses", "wildcards"
792
+ ]
793
+ known_dirs = [os.path.abspath(get_directory_by_type(t)) for t in known_types if get_directory_by_type(t)]
794
+ # Also allow output and input directories
795
+ if get_directory_by_type("output"): known_dirs.append(os.path.abspath(get_directory_by_type("output")))
796
+ if get_directory_by_type("input"): known_dirs.append(os.path.abspath(get_directory_by_type("input")))
797
+ # Add the plugin's own 'other_models' directory as safe
798
+ known_dirs.append(os.path.abspath(os.path.join(PLUGIN_ROOT, "other_models")))
799
+ # Add plugin-managed custom roots as safe
800
+ try:
801
+ import json as _json
802
+ _roots_file = os.path.join(PLUGIN_ROOT, "custom_roots.json")
803
+ if os.path.exists(_roots_file):
804
+ with open(_roots_file, 'r', encoding='utf-8') as _f:
805
+ _data = _json.load(_f)
806
+ if isinstance(_data, dict):
807
+ for _lst in _data.values():
808
+ if isinstance(_lst, list):
809
+ for _p in _lst:
810
+ if isinstance(_p, str):
811
+ known_dirs.append(os.path.abspath(_p))
812
+ except Exception as _e:
813
+ print(f"[Manager OpenPath] Warning: Failed to load custom roots: {_e}")
814
+ # Include all first-level subdirectories under models_dir as safe
815
+ try:
816
+ models_dir = getattr(__import__('folder_paths'), 'folder_paths').models_dir
817
+ except Exception:
818
+ models_dir = None
819
+ try:
820
+ if models_dir and os.path.isdir(models_dir):
821
+ for _name in os.listdir(models_dir):
822
+ _p = os.path.join(models_dir, _name)
823
+ if os.path.isdir(_p):
824
+ known_dirs.append(os.path.abspath(_p))
825
+ except Exception as _e2:
826
+ print(f"[Manager OpenPath] Warning: Failed enumerating models_dir subfolders: {_e2}")
827
+
828
+ for known_dir in known_dirs:
829
+ if os.path.commonpath([known_dir, folder_path]) == known_dir:
830
+ is_safe_path = True
831
+ break
832
+ else:
833
+ # Option 2: Fallback - Check if path is within the ComfyUI base directory
834
+ comfy_base = os.path.abspath(base_path)
835
+ if os.path.commonpath([comfy_base, folder_path]) == comfy_base:
836
+ is_safe_path = True
837
+ #print(f"[Manager OpenPath Warning] ComfyUI paths unavailable. Using base path check for {folder_path}")
838
+
839
+ if not is_safe_path:
840
+ print(f"[Manager OpenPath Denied] Path '{folder_path}' is outside known safe ComfyUI directories.")
841
+ return {"success": False, "error": "Cannot open path: Directory is outside allowed locations."}
842
+
843
+ # --- Open the directory ---
844
+ if not os.path.exists(folder_path):
845
+ return {"success": False, "error": f"Directory does not exist: {folder_path}"}
846
+ if not os.path.isdir(folder_path):
847
+ return {"success": False, "error": f"Path is not a directory: {folder_path}"}
848
+
849
+ try:
850
+ system = platform.system()
851
+ print(f"[Manager OpenPath] Attempting to open folder '{folder_path}' on {system}...")
852
+ if system == "Windows":
853
+ # Use startfile for better handling of spaces etc.
854
+ os.startfile(folder_path)
855
+ elif system == "Darwin": # macOS
856
+ subprocess.check_call(["open", folder_path])
857
+ elif system == "Linux":
858
+ # Use xdg-open, handle potential errors if command not found
859
+ try:
860
+ subprocess.check_call(["xdg-open", folder_path])
861
+ except FileNotFoundError:
862
+ # Fallback for headless systems or if xdg-open isn't installed
863
+ print(f"[Manager OpenPath] 'xdg-open' not found. Cannot automatically open folder on this Linux system.")
864
+ return {"success": False, "error": "'xdg-open' command not found. Cannot open folder."}
865
+ else:
866
+ print(f"[Manager OpenPath] Unsupported operating system: {system}. Cannot open folder.")
867
+ return {"success": False, "error": f"Unsupported OS ({system}) for opening folder."}
868
+
869
+ print(f"[Manager OpenPath] Successfully requested folder opening for '{folder_path}'.")
870
+ return {"success": True, "message": f"Opened directory: {folder_path}"}
871
+
872
+ except Exception as e:
873
+ print(f"[Manager OpenPath] Failed to open directory '{folder_path}': {e}")
874
+ return {"success": False, "error": f"Failed to open directory: {e}"}
875
+
876
+ except Exception as path_err:
877
+ # Catch errors during path validation itself
878
+ print(f"[Manager OpenPath] Error during path validation/retrieval for {download_id}: {path_err}")
879
+ return {"success": False, "error": f"Error processing path: {path_err}"}
880
+
881
+ # --- Global Instance ---
882
+ manager = DownloadManager(max_concurrent=MAX_CONCURRENT_DOWNLOADS)
883
+
884
+ # --- Graceful Shutdown ---
885
+ # (shutdown_manager remains the same)
886
+ def shutdown_manager():
887
+ # ... (no changes) ...
888
+ print("[Manager] Shutdown requested.")
889
+ if manager:
890
+ manager.running = False
891
+ acquired_lock = False
892
+ try: acquired_lock = manager.lock.acquire(timeout=1.0)
893
+ except RuntimeError: pass # Lock might not be initialised if init failed
894
+
895
+ if acquired_lock:
896
+ try:
897
+ active_ids = list(manager.active_downloads.keys())
898
+ queue_ids = [item['id'] for item in manager.queue]
899
+ print(f"[Manager] Requesting cancellation for {len(active_ids)} active and {len(queue_ids)} queued downloads on shutdown...")
900
+ all_ids_to_cancel = active_ids + queue_ids
901
+ manager.lock.release() # Release lock BEFORE calling cancel_download
902
+
903
+ for dl_id in all_ids_to_cancel:
904
+ try:
905
+ # Reuse cancel_download which handles both active and queued safely
906
+ manager.cancel_download(dl_id)
907
+ except Exception as e:
908
+ print(f"Error cancelling {dl_id} during shutdown: {e}")
909
+ except Exception as e:
910
+ print(f"[Manager] Error accessing lists during shutdown (after lock acquired): {e}")
911
+ try: manager.lock.release() # Ensure release on error
912
+ except RuntimeError: pass
913
+ # Give threads/tasks a brief moment to react
914
+ time.sleep(0.5)
915
+ else:
916
+ print("[Manager] Warning: Could not acquire lock to cancel downloads during shutdown.")
917
+
918
+ # Attempt to join the manager's process thread (best effort)
919
+ try:
920
+ if manager._process_thread and manager._process_thread.is_alive():
921
+ manager._process_thread.join(timeout=2.0)
922
+ if manager._process_thread.is_alive():
923
+ print("[Manager] Warning: Process thread did not exit cleanly within timeout.")
924
+ except Exception as e:
925
+ print(f"[Manager] Error joining manager thread during shutdown: {e}")
926
+ print("[Manager] Shutdown complete.")
927
+
928
+ import atexit
929
+ atexit.register(shutdown_manager)
custom_nodes/Civicomfy/pyproject.toml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "civicomfy"
3
+ description = "Civicomfy seamlessly integrates Civitai's vast model repository directly into ComfyUI, allowing you to search, download, and organize AI models without leaving your workflow."
4
+ version = "1.0.9"
5
+ license = {file = "LICENSE"}
6
+
7
+ [project.urls]
8
+ Repository = "https://github.com/MoonGoblinDev/Civicomfy"
9
+ # Used by Comfy Registry https://comfyregistry.org
10
+
11
+ [tool.comfy]
12
+ PublisherId = "moongoblin"
13
+ DisplayName = "Civicomfy"
14
+ Icon = ""
custom_nodes/Civicomfy/server/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/__init__.py
3
+ # ================================================
4
+ # Import the routes package to ensure all route decorators are executed.
5
+ from . import routes
custom_nodes/Civicomfy/server/routes/CancelDownload.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/CancelDownload.py
3
+ # ================================================
4
+ import json
5
+ from aiohttp import web
6
+ import server # ComfyUI server instance
7
+ from ..utils import get_request_json
8
+ from ...downloader.manager import manager as download_manager
9
+
10
+ prompt_server = server.PromptServer.instance
11
+
12
+ @prompt_server.routes.post("/civitai/cancel")
13
+ async def route_cancel_download(request):
14
+ """API Endpoint to cancel a download."""
15
+ try:
16
+ data = await get_request_json(request)
17
+ download_id = data.get("download_id")
18
+ if not download_id:
19
+ print("not download id " + download_id)
20
+ raise web.HTTPBadRequest(reason="Missing 'download_id'")
21
+
22
+ success = download_manager.cancel_download(download_id)
23
+ if success:
24
+ return web.json_response({
25
+ "status": "cancelled", # Or "cancellation_requested" ?
26
+ "message": f"Cancellation requested for download ID: {download_id}.",
27
+ "download_id": download_id
28
+ })
29
+ else:
30
+ # Might be already completed/failed/cancelled and in history, or invalid ID
31
+ raise web.HTTPNotFound(reason=f"Download ID {download_id} not found in active queue or running downloads.")
32
+
33
+ except web.HTTPError as http_err:
34
+ # Consistent error handling
35
+ body_detail = ""
36
+ try:
37
+ body_detail = await http_err.text() if hasattr(http_err, 'text') else http_err.body.decode('utf-8', errors='ignore') if http_err.body else ""
38
+ if body_detail.startswith('{') and body_detail.endswith('}'): body_detail = json.loads(body_detail)
39
+ except Exception: pass
40
+ return web.json_response({"error": http_err.reason, "details": body_detail or "No details", "status_code": http_err.status}, status=http_err.status)
41
+
42
+ except Exception as e:
43
+ print(f"Error cancelling download: {e}")
44
+ return web.json_response({"error": "Internal Server Error", "details": f"Failed to cancel download: {str(e)}", "status_code": 500}, status=500)
custom_nodes/Civicomfy/server/routes/ClearHistory.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/ClearHistory.py
3
+ # ================================================
4
+ import asyncio
5
+ from aiohttp import web
6
+
7
+ import server # ComfyUI server instance
8
+ from ...downloader.manager import manager as download_manager
9
+
10
+ prompt_server = server.PromptServer.instance
11
+
12
+ @prompt_server.routes.post("/civitai/clear_history")
13
+ async def route_clear_history(request):
14
+ """API Endpoint to clear the download history."""
15
+ if not download_manager:
16
+ return web.json_response({"error": "Download Manager not initialized"}, status=500)
17
+
18
+ try:
19
+ # No request body needed for this action
20
+ print(f"[API Route /civitai/clear_history] Received clear history request.")
21
+
22
+ # Call manager method in thread
23
+ result = await asyncio.to_thread(download_manager.clear_history)
24
+
25
+ status_code = 200 if result.get("success") else 500 # Use 500 for internal clear error
26
+ return web.json_response(result, status=status_code)
27
+
28
+ except Exception as e:
29
+ import traceback
30
+ print(f"Error handling /civitai/clear_history request: {e}")
31
+ # traceback.print_exc() # Uncomment for detailed logs
32
+ return web.json_response({"error": "Internal Server Error", "details": f"An unexpected error occurred: {str(e)}"}, status=500)
custom_nodes/Civicomfy/server/routes/DownloadModel.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/DownloadModel.py
3
+ # ================================================
4
+ import os
5
+ import json
6
+ import traceback
7
+ import re
8
+ from aiohttp import web
9
+
10
+ import server # ComfyUI server instance
11
+ from ..utils import get_request_json
12
+ from ...downloader.manager import manager as download_manager
13
+ from ...api.civitai import CivitaiAPI
14
+ from ...utils.helpers import get_model_dir, parse_civitai_input, sanitize_filename, select_primary_file
15
+ from ...config import METADATA_SUFFIX, PREVIEW_SUFFIX
16
+
17
+ prompt_server = server.PromptServer.instance
18
+
19
+ @prompt_server.routes.post("/civitai/download")
20
+ async def route_download_model(request):
21
+ """API Endpoint to initiate a download."""
22
+ api_key = None # Define outside try block
23
+ model_info = None # Define here for broader scope
24
+ version_info = None
25
+ primary_file = None
26
+ target_model_id = None
27
+ target_version_id = None
28
+ try:
29
+ data = await get_request_json(request)
30
+
31
+ model_url_or_id = data.get("model_url_or_id")
32
+ # 'model_type' defines the target directory category (e.g., 'lora', 'checkpoint')
33
+ model_type_value = data.get("model_type", "checkpoint") # Use as-is; may be a literal folder name
34
+ req_version_id = data.get("model_version_id") # Optional explicit version ID
35
+ explicit_save_root = (data.get("save_root") or "").strip()
36
+ custom_filename_input = data.get("custom_filename", "").strip()
37
+ selected_subdir = (data.get("subdir") or "").strip()
38
+ # Optional file selection overrides
39
+ req_file_id = data.get("file_id")
40
+ req_file_name_contains = data.get("file_name_contains", "").strip()
41
+ num_connections = int(data.get("num_connections", 4))
42
+ force_redownload = bool(data.get("force_redownload", False))
43
+ api_key = data.get("api_key", "") # Get API key from frontend settings
44
+
45
+ if not model_url_or_id:
46
+ raise web.HTTPBadRequest(reason="Missing 'model_url_or_id'")
47
+
48
+ # --- Input Parsing and Info Fetching ---
49
+ print(f"[Server Download] Request: {model_url_or_id}, SaveType: {model_type_value}, Version: {req_version_id}")
50
+ # Instantiate API with the key from the request (frontend settings)
51
+ api = CivitaiAPI(api_key or None) # Pass None if empty string
52
+ parsed_model_id, parsed_version_id = parse_civitai_input(model_url_or_id)
53
+
54
+ # Determine the target version ID (request param > URL param)
55
+ target_version_id = None
56
+ if req_version_id and str(req_version_id).isdigit(): # Check if actually a number
57
+ try:
58
+ target_version_id = int(req_version_id)
59
+ except (ValueError, TypeError):
60
+ print(f"[Server Download] Warning: Invalid value provided for 'model_version_id': {req_version_id}. Ignoring.")
61
+ # Continue, will try to find latest if no version ID parsed from URL either
62
+ elif parsed_version_id:
63
+ target_version_id = parsed_version_id
64
+ # else: target_version_id remains None, we'll need model_id to find latest
65
+
66
+ target_model_id = parsed_model_id
67
+
68
+ # --- Get Model/Version Info from Civitai ---
69
+ # Store results in broader scope vars 'model_info' and 'version_info'
70
+ if target_version_id:
71
+ # Fetch version info directly using GET /model-versions/{id}
72
+ print(f"[Server Download] Fetching info for Version ID: {target_version_id}")
73
+ version_info_result = api.get_model_version_info(target_version_id)
74
+ if version_info_result and "error" not in version_info_result:
75
+ version_info = version_info_result # Assign to broader scope variable
76
+ # Infer model_id from version info if we didn't have it
77
+ if not target_model_id and version_info.get('modelId'):
78
+ target_model_id = version_info['modelId']
79
+ print(f"[Server Download] Inferred Model ID {target_model_id} from Version ID {target_version_id}")
80
+ # Fetch model info as well for completeness if we only had version ID initially
81
+ model_info_result = api.get_model_info(target_model_id)
82
+ if model_info_result and "error" not in model_info_result:
83
+ model_info = model_info_result
84
+ else:
85
+ print(f"[Server Download] Warning: Could not fetch model info ({target_model_id}) after inferring from version.")
86
+ model_info = {} # Use empty dict as placeholder
87
+ else:
88
+ model_info_result = api.get_model_info(target_model_id)
89
+ if model_info_result and "error" not in model_info_result:
90
+ model_info = model_info_result
91
+ else:
92
+ print(f"[Server Download] Warning: Could not fetch model info ({target_model_id}) after inferring from version.")
93
+ model_info = {} # Use empty dict as placeholder
94
+
95
+ else:
96
+ # Handle API error or not found for version ID
97
+ err_details = version_info_result.get('details') if isinstance(version_info_result, dict) else "Unknown API error"
98
+ status_code = version_info_result.get('status_code', 500) if isinstance(version_info_result, dict) else 500
99
+ raise web.HTTPNotFound(reason=f"Civitai API Error: Version {target_version_id} not found or API error. Details: {err_details}",
100
+ body=json.dumps({"error": f"Version {target_version_id} not found or API error", "details": err_details}))
101
+
102
+ elif target_model_id:
103
+ # Fetch model info (GET /models/{id}) to get the latest version
104
+ print(f"[Server Download] Fetching info for Model ID: {target_model_id} to find latest version.")
105
+ model_info_result = api.get_model_info(target_model_id)
106
+ if model_info_result and "error" not in model_info_result:
107
+ model_info = model_info_result # Assign to broader scope variable
108
+ versions = model_info.get("modelVersions")
109
+ if versions and isinstance(versions, list) and len(versions) > 0:
110
+ # Find the *best* default version (often marked as 'default' or just the first 'Published')
111
+ default_version_in_list = next((v for v in versions if v.get('status') == 'Published'), versions[0])
112
+ if not default_version_in_list: # Should not happen if versions exist, but safety check
113
+ raise web.HTTPNotFound(reason=f"Model {target_model_id} found, but has no published versions listed.")
114
+
115
+ partial_version_info = default_version_in_list # This is the partial version info dict from the list
116
+ target_version_id = partial_version_info.get('id')
117
+ if not target_version_id:
118
+ raise web.HTTPNotFound(reason=f"Model {target_model_id} found, but latest version has no ID.")
119
+
120
+ print(f"[Server Download] Using latest/default Version ID {target_version_id} for Model ID {target_model_id}")
121
+ # Need to re-fetch full version details as model info often lacks file download URLs or full metadata
122
+ print(f"[Server Download] Fetching full details for selected Version ID: {target_version_id}")
123
+ full_version_info_result = api.get_model_version_info(target_version_id)
124
+ if full_version_info_result and "error" not in full_version_info_result:
125
+ version_info = full_version_info_result # Overwrite with full details
126
+ else:
127
+ # Log error but proceed with partial data if possible (will likely fail later if files missing)
128
+ err_details = full_version_info_result.get('details') if isinstance(full_version_info_result, dict) else "Unknown error getting full version"
129
+ print(f"[Server Download] Warning: Could not fetch full details for version {target_version_id}. Details: {err_details}. Download might fail if file info is missing.")
130
+ version_info = partial_version_info # Use the partial info from the model list
131
+
132
+ else:
133
+ raise web.HTTPNotFound(reason=f"Model {target_model_id} found, but has no usable model versions listed.")
134
+ else:
135
+ # Handle API error or not found for model ID
136
+ err_details = model_info_result.get('details') if isinstance(model_info_result, dict) else "Unknown API error"
137
+ status_code = model_info_result.get('status_code', 500) if isinstance(model_info_result, dict) else 500
138
+ raise web.HTTPNotFound(reason=f"Civitai API Error: Model {target_model_id} not found or API error. Details: {err_details}",
139
+ body=json.dumps({"error": f"Model {target_model_id} not found or API error", "details": err_details}))
140
+
141
+ else:
142
+ # Neither model ID nor version ID could be determined
143
+ raise web.HTTPBadRequest(reason="Invalid input: Could not determine Model ID or Version ID from input.")
144
+
145
+ # --- Sanity Checks After Fetching ---
146
+ if not target_model_id:
147
+ # This case implies we started with only a version ID and failed to infer the model ID
148
+ raise web.HTTPInternalServerError(reason="Failed to determine the parent Model ID for the requested version.")
149
+ if not target_version_id or not version_info:
150
+ # This implies we started with a model ID but failed to find/fetch a valid version
151
+ raise web.HTTPInternalServerError(reason="Failed to resolve valid model version information.")
152
+ # Ensure model_info exists, even if empty (e.g., if started with only version_id and model fetch failed)
153
+ if model_info is None: model_info = {}
154
+
155
+ # --- Select File and Get Download URL ---
156
+ files = version_info.get("files", [])
157
+ if not isinstance(files, list):
158
+ print(f"[Server Download] Error: 'files' field is not a list in version info for {target_version_id}: {files}")
159
+ files = [] # Treat as empty if not a list
160
+
161
+ # Check for fallback downloadUrl at version level (less common now)
162
+ if not files and 'downloadUrl' in version_info and version_info['downloadUrl']:
163
+ print("[Server Download] Warning: No 'files' array found, attempting to use version-level 'downloadUrl'. File details will be incomplete.")
164
+ # Mock a file structure for consistency downstream
165
+ files = [{
166
+ "id": None, # No file ID available
167
+ "name": version_info.get('name', f"version_{target_version_id}_file"), # Use version name as fallback
168
+ "primary": True,
169
+ "type": "Model", # Default type
170
+ "sizeKB": version_info.get('fileSizeKB'), # Try to get size if available at version level
171
+ "downloadUrl": version_info['downloadUrl'],
172
+ "hashes": {}, # No hashes available
173
+ "metadata": {} # No metadata available
174
+ }]
175
+
176
+ if not files:
177
+ raise web.HTTPNotFound(reason=f"Version ID {target_version_id} ({version_info.get('name', 'N/A')}) has no files listed in API response.")
178
+
179
+ # If a specific file was requested by ID, honor it first
180
+ primary_file = None
181
+ if req_file_id is not None:
182
+ try:
183
+ # IDs in API are ints; accept stringified ints too
184
+ req_file_id_int = int(str(req_file_id).strip())
185
+ primary_file = next((f for f in files if isinstance(f, dict) and f.get("id") == req_file_id_int and f.get('downloadUrl')), None)
186
+ if not primary_file:
187
+ raise web.HTTPNotFound(reason=f"File with id {req_file_id_int} not found or not downloadable in version {target_version_id}.")
188
+ except ValueError:
189
+ raise web.HTTPBadRequest(reason=f"Invalid 'file_id' value: {req_file_id}")
190
+
191
+ # If not selected by ID, try selecting by partial name match (e.g., 'fp16', 'fp8')
192
+ if primary_file is None and req_file_name_contains:
193
+ needle = req_file_name_contains.lower()
194
+ def name_matches(f):
195
+ if not isinstance(f, dict):
196
+ return False
197
+ name = (f.get("name") or "").lower()
198
+ meta = (f.get("metadata") or {})
199
+ fmt = (meta.get("format") or "").lower()
200
+ size_tag = (meta.get("size") or "").lower()
201
+ return (needle in name) or (needle in fmt) or (needle in size_tag)
202
+ candidates = [f for f in files if f.get('downloadUrl') and name_matches(f)]
203
+ primary_file = candidates[0] if candidates else None
204
+
205
+ # If still not selected, fall back to heuristic helper
206
+ if primary_file is None:
207
+ primary_file = select_primary_file(files)
208
+
209
+ if not primary_file:
210
+ raise web.HTTPNotFound(reason=f"Could not find any file with a valid download URL for version {target_version_id}.")
211
+
212
+ # Ensure primary_file is a valid dictionary before accessing keys
213
+ if not isinstance(primary_file, dict) or not primary_file.get('downloadUrl'):
214
+ print(f"[Server Download] Error: Selected primary file data is invalid or missing URL: {primary_file}")
215
+ raise web.HTTPInternalServerError(reason=f"Selected file data is invalid for version {target_version_id}.")
216
+
217
+ print(f"[Server Download] Selected file: Name='{primary_file.get('name', 'N/A')}', Type='{primary_file.get('type', 'N/A')}', SizeKB={primary_file.get('sizeKB')}, Format={primary_file.get('metadata', {}).get('format')}, Size={primary_file.get('metadata', {}).get('size')}")
218
+
219
+ file_id = primary_file.get("id") # May not be present, but useful for logging/metadata
220
+
221
+ # *** Get the download URL directly from the file object ***
222
+ download_url = primary_file.get("downloadUrl")
223
+ print(f"[Server Download] Using Download URL: {download_url}")
224
+
225
+ # --- Determine Filename and Output Path ---
226
+ api_filename = primary_file.get("name", f"model_{target_model_id}_ver_{target_version_id}_file_{file_id or 'unknown'}")
227
+
228
+ # Defaults
229
+ final_filename = sanitize_filename(api_filename)
230
+ sub_path = ""
231
+
232
+ # Subdir: only use the selected existing subdir coming from UI
233
+ if selected_subdir:
234
+ norm_sub = os.path.normpath(selected_subdir.replace('\\', '/'))
235
+ parts = [p for p in norm_sub.split('/') if p and p not in ('.', '..')]
236
+ if parts:
237
+ sub_path = os.path.join(*[sanitize_filename(p) for p in parts])
238
+
239
+ # Filename: ignore any path separators in custom name; treat as base name only
240
+ if custom_filename_input:
241
+ safe_name = sanitize_filename(custom_filename_input)
242
+ base, ext = os.path.splitext(safe_name)
243
+ if not ext:
244
+ _, api_ext = os.path.splitext(api_filename)
245
+ ext = api_ext or ".safetensors"
246
+ final_filename = base + ext
247
+
248
+ # Get the target base directory for the selected model type, allow explicit root
249
+ if explicit_save_root:
250
+ # Validate the explicit root belongs to known roots for this type (ComfyUI or plugin)
251
+ try:
252
+ from ..routes.GetModelDirs import _get_all_roots_for_type
253
+ known_roots = _get_all_roots_for_type(model_type_value)
254
+ if os.path.abspath(explicit_save_root) in [os.path.abspath(p) for p in known_roots]:
255
+ base_output_dir = explicit_save_root
256
+ else:
257
+ print(f"[Server Download] Warning: Provided save_root not in known roots for type '{model_type_value}': {explicit_save_root}")
258
+ base_output_dir = get_model_dir(model_type_value)
259
+ except Exception as e:
260
+ print(f"[Server Download] Warning: Failed validating explicit save_root: {e}")
261
+ base_output_dir = get_model_dir(model_type_value)
262
+ else:
263
+ base_output_dir = get_model_dir(model_type_value)
264
+ output_dir = os.path.join(base_output_dir, sub_path) if sub_path else base_output_dir
265
+ # Ensure directory exists (including subdirectories)
266
+ try:
267
+ os.makedirs(output_dir, exist_ok=True)
268
+ print(f"[Server Download] Ensured output directory exists: {output_dir}")
269
+ except OSError as e:
270
+ raise web.HTTPInternalServerError(reason=f"Could not create subdirectory: {e}")
271
+ output_path = os.path.join(output_dir, final_filename)
272
+
273
+ # Construct corresponding metadata/preview paths
274
+ base_name, _ = os.path.splitext(final_filename)
275
+ meta_filename = base_name + METADATA_SUFFIX
276
+ preview_filename = base_name + PREVIEW_SUFFIX
277
+ meta_path = os.path.join(output_dir, meta_filename)
278
+ preview_path = os.path.join(output_dir, preview_filename)
279
+
280
+ # --- Check Existing File ---
281
+ # Get size from API response for comparison
282
+ api_size_kb = primary_file.get("sizeKB")
283
+ api_size_bytes = int(api_size_kb * 1024) if api_size_kb and isinstance(api_size_kb, (int, float)) else 0
284
+
285
+ file_exists = os.path.exists(output_path)
286
+ metadata_exists = os.path.exists(meta_path)
287
+ preview_exists = os.path.exists(preview_path)
288
+
289
+ if file_exists and not force_redownload:
290
+ local_size = os.path.getsize(output_path)
291
+ # Relax size check slightly (e.g., within 1KB) OR if API size is unknown (0)
292
+ size_matches = api_size_bytes > 0 and abs(api_size_bytes - local_size) <= 1024
293
+
294
+ if size_matches:
295
+ print(f"[Server Download] File already exists and size matches: {output_path}")
296
+ # If primary file exists and matches size, check if metadata/preview are missing and offer to fetch *only* those?
297
+ # For now, just report exists. Enhancement: could add a button/option to "Fetch Metadata/Preview Only".
298
+ if not metadata_exists:
299
+ print(f"[Server Download] Info: Main file exists, but metadata file {meta_filename} is missing.")
300
+ if not preview_exists:
301
+ print(f"[Server Download] Info: Main file exists, but preview file {preview_filename} is missing.")
302
+
303
+ return web.json_response({
304
+ "status": "exists",
305
+ "message": "File already exists with matching size.",
306
+ "path": output_path,
307
+ "filename": final_filename,
308
+ })
309
+ else:
310
+ # If sizes differ, report mismatch.
311
+ exist_reason = f"size differs (Local: {local_size} bytes, API: {api_size_bytes} bytes)" if api_size_bytes > 0 else "local file exists but API size was unknown"
312
+ print(f"[Server Download] File already exists but {exist_reason}. Path: {output_path}.")
313
+ # Return a distinct status code or message?
314
+ return web.json_response({
315
+ "status": "exists_size_mismatch",
316
+ "message": f"File already exists but {exist_reason}. Use 'Force Re-download' to overwrite.",
317
+ "path": output_path,
318
+ "filename": final_filename,
319
+ "local_size": local_size,
320
+ "api_size_kb": api_size_kb,
321
+ }, status=409) # Use 409 Conflict status
322
+
323
+ # If force_redownload is true, log it
324
+ if file_exists and force_redownload:
325
+ print(f"[Server Download] Force Re-download enabled. Will overwrite existing file: {output_path}")
326
+
327
+ # --- Prepare Download Info and Queue ---
328
+ model_name = model_info.get('name', version_info['model']['name'])
329
+ version_name = version_info.get('name', 'Unknown Version')
330
+
331
+ # Extract a suitable thumbnail URL (ensure it's done robustly) and nsfw level for it
332
+ thumbnail_url = None
333
+ thumbnail_nsfw_level = None
334
+ images = version_info.get("images")
335
+ if images and isinstance(images, list) and len(images) > 0:
336
+ # Ensure images are dictionaries with URLs
337
+ valid_images = [img for img in images if isinstance(img, dict) and img.get("url")]
338
+ # Sort by index if available, falling back to 0
339
+ sorted_images = sorted(valid_images, key=lambda x: x.get('index', 0))
340
+ # Try to find first image of type 'image' with explicit width (often better quality previews)
341
+ img_data = next((img for img in sorted_images if img.get("type") == "image" and "/width=" in img["url"]), None)
342
+ # Fallback 1: Any image of type 'image'
343
+ if not img_data: img_data = next((img for img in sorted_images if img.get("type") == "image"), None)
344
+ # Fallback 2: Any image at all
345
+ if not img_data: img_data = next((img for img in sorted_images), None)
346
+
347
+ if img_data and img_data.get("url"):
348
+ base_url = img_data["url"]
349
+ try:
350
+ lvl = img_data.get("nsfwLevel")
351
+ thumbnail_nsfw_level = int(lvl) if lvl is not None else None
352
+ except Exception:
353
+ thumbnail_nsfw_level = None
354
+ # Try to get a reasonably sized thumbnail version (e.g., width 256-450)
355
+ try:
356
+ # Basic heuristic: replace width param or append if not present/blob
357
+ if "/width=" in base_url:
358
+ thumbnail_url = re.sub(r"/width=\d+", "/width=256", base_url)
359
+ elif "/blob/" in base_url: # Handle blob URLs that might not have params
360
+ thumbnail_url = base_url
361
+ else:
362
+ separator = "&" if "?" in base_url else "?"
363
+ thumbnail_url = f"{base_url}{separator}width=256"
364
+ print(f"[Server Download] Generated thumbnail URL for UI: {thumbnail_url}")
365
+ except Exception as thumb_e:
366
+ print(f"[Server Download] Warning: Failed to generate specific thumbnail URL from {base_url}: {thumb_e}")
367
+ thumbnail_url = base_url # Fallback to original URL found
368
+ else:
369
+ print(f"[Server Download] Warning: No image with a valid URL found for thumbnail in version {target_version_id}")
370
+
371
+ # Ensure size is int or None
372
+ known_size_bytes = api_size_bytes if api_size_bytes > 0 else None
373
+
374
+ # --- Prepare full download_info dict ---
375
+ # Derive extra display attributes for UI/history
376
+ def _guess_precision_name(fobj):
377
+ try:
378
+ nm = (fobj.get('name') or '').lower()
379
+ meta = (fobj.get('metadata') or {})
380
+ for key in ('precision', 'dtype', 'fp'):
381
+ val = (meta.get(key) or '').lower()
382
+ if val:
383
+ return val
384
+ if 'fp8' in nm or 'int8' in nm or '8bit' in nm or '8-bit' in nm:
385
+ return 'fp8'
386
+ if 'fp16' in nm or 'bf16' in nm or '16bit' in nm or '16-bit' in nm:
387
+ return 'fp16'
388
+ if 'fp32' in nm or '32bit' in nm or '32-bit' in nm:
389
+ return 'fp32'
390
+ except Exception:
391
+ pass
392
+ return None
393
+
394
+ primary_meta = (primary_file.get('metadata') or {})
395
+ ui_file_precision = _guess_precision_name(primary_file)
396
+ ui_file_model_size = primary_meta.get('size') # e.g., Pruned/Full
397
+ ui_file_format = primary_meta.get('format')
398
+ # Pass all necessary info for download, metadata, and preview saving
399
+ download_info = {
400
+ # Core download params
401
+ "url": download_url,
402
+ "output_path": output_path,
403
+ "num_connections": num_connections,
404
+ "known_size": known_size_bytes,
405
+ "api_key": api_key or None, # Pass API key for download auth if needed
406
+ # Retry/context fields
407
+ "model_url_or_id": model_url_or_id,
408
+ "model_version_id": req_version_id,
409
+ "custom_filename": custom_filename_input,
410
+ "force_redownload": force_redownload,
411
+ # UI Display Info
412
+ "filename": final_filename,
413
+ "model_name": model_name,
414
+ "version_name": version_name,
415
+ "thumbnail": thumbnail_url, # URL for UI thumbnail preview
416
+ "thumbnail_nsfw_level": thumbnail_nsfw_level,
417
+ "model_type": model_type_value, # The category/directory key or literal folder used for saving
418
+ # Extra file attributes for UI lists
419
+ "file_precision": ui_file_precision,
420
+ "file_model_size": ui_file_model_size,
421
+ "file_format": ui_file_format,
422
+ # Metadata for .cminfo.json and preview saving
423
+ "civitai_model_id": target_model_id,
424
+ "civitai_version_id": target_version_id,
425
+ "civitai_file_id": file_id,
426
+ # ---> Pass the full API response dicts <---
427
+ "civitai_model_info": model_info,
428
+ "civitai_version_info": version_info,
429
+ "civitai_primary_file": primary_file, # Pass the selected file object
430
+ }
431
+
432
+ download_id = download_manager.add_to_queue(download_info)
433
+
434
+ # Return queued status and essential details for the UI
435
+ return web.json_response({
436
+ "status": "queued",
437
+ "message": f"Download added to queue: '{final_filename}'",
438
+ "download_id": download_id,
439
+ "details": {
440
+ "filename": final_filename,
441
+ "model_name": model_name,
442
+ "version_name": version_name,
443
+ "thumbnail": thumbnail_url,
444
+ "thumbnail_nsfw_level": thumbnail_nsfw_level,
445
+ "path": output_path, # The intended final path
446
+ "size_kb": api_size_kb if api_size_kb else None # Use KB for display consistency
447
+ }
448
+ })
449
+
450
+ except web.HTTPError as http_err:
451
+ # Re-raise known HTTP errors (like bad request, not found, conflict)
452
+ print(f"[Server Download] HTTP Error: {http_err.status} {http_err.reason}")
453
+ # Attempt to parse JSON body for details
454
+ body_detail = ""
455
+ try:
456
+ # Use await text() for aiohttp Response exceptions
457
+ body_detail = await http_err.text() if hasattr(http_err, 'text') else http_err.body.decode('utf-8', errors='ignore') if http_err.body else ""
458
+ # Try parsing as JSON if it looks like it
459
+ if body_detail.startswith('{') and body_detail.endswith('}'):
460
+ body_detail = json.loads(body_detail) # Return parsed dict
461
+ except Exception: pass # Ignore parsing errors, just use the raw text
462
+ # Return JSON response for frontend handling
463
+ return web.json_response({"error": http_err.reason, "details": body_detail or "No details", "status_code": http_err.status}, status=http_err.status)
464
+
465
+ except Exception as e:
466
+ print("--- Unhandled Error in /civitai/download ---")
467
+ traceback.print_exc()
468
+ print("--- End Error ---")
469
+ # Return a generic 500 Internal Server Error for unexpected issues
470
+ return web.json_response({"error": "Internal Server Error", "details": f"An unexpected error occurred: {str(e)}", "status_code": 500}, status=500)
custom_nodes/Civicomfy/server/routes/GetBaseModels.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/GetBaseModels.py
3
+ # ================================================
4
+ from aiohttp import web
5
+ import server # ComfyUI server instance
6
+ from ...config import AVAILABLE_MEILI_BASE_MODELS
7
+
8
+ prompt_server = server.PromptServer.instance
9
+
10
+ @prompt_server.routes.get("/civitai/base_models")
11
+ async def route_get_base_models(request):
12
+ """API Endpoint to get the known base model types for filtering."""
13
+ try:
14
+ # Return the hardcoded list for now
15
+ # In future, this *could* fetch dynamically if Civitai provides an endpoint
16
+ return web.json_response({"base_models": AVAILABLE_MEILI_BASE_MODELS})
17
+ except Exception as e:
18
+ print(f"Error getting base model types: {e}")
19
+ return web.json_response({"error": "Internal Server Error", "details": str(e), "status_code": 500}, status=500)
custom_nodes/Civicomfy/server/routes/GetModelDetails.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/GetModelDetails.py
3
+ # ================================================
4
+ import os
5
+ import json
6
+ import traceback
7
+ from aiohttp import web
8
+
9
+ import server # ComfyUI server instance
10
+ from ..utils import get_request_json, get_civitai_model_and_version_details
11
+ from ...api.civitai import CivitaiAPI
12
+ from ...config import PLACEHOLDER_IMAGE_PATH
13
+
14
+ prompt_server = server.PromptServer.instance
15
+
16
+ @prompt_server.routes.post("/civitai/get_model_details")
17
+ async def route_get_model_details(request):
18
+ """API Endpoint to fetch model/version details for preview."""
19
+ try:
20
+ data = await get_request_json(request)
21
+ model_url_or_id = data.get("model_url_or_id")
22
+ req_version_id = data.get("model_version_id") # Optional explicit version ID
23
+ api_key = data.get("api_key", "")
24
+
25
+ if not model_url_or_id:
26
+ raise web.HTTPBadRequest(reason="Missing 'model_url_or_id'")
27
+
28
+ # Instantiate API
29
+ api = CivitaiAPI(api_key or None)
30
+
31
+ # Use the helper to get details
32
+ details = await get_civitai_model_and_version_details(api, model_url_or_id, req_version_id)
33
+ model_info = details['model_info']
34
+ version_info = details['version_info']
35
+ primary_file = details['primary_file']
36
+ target_model_id = details['target_model_id']
37
+ target_version_id = details['target_version_id']
38
+
39
+ # --- Extract Data for Frontend Preview ---
40
+ model_name = model_info.get('name')
41
+ creator_username = model_info.get('creator', {}).get('username', 'Unknown Creator')
42
+ model_type = model_info.get('type', 'Unknown') # Checkpoint, LORA etc.
43
+
44
+ stats = model_info.get('stats', version_info.get('stats', {})) # Ensure stats is a dict
45
+ download_count = stats.get('downloadCount', 0)
46
+ likes_count = stats.get('thumbsUpCount', 0)
47
+ dislikes_count = stats.get('thumbsDownCount', 0)
48
+ buzz_count = stats.get('tippedAmountCount', 0)
49
+
50
+
51
+ # Get description from model_info (version description might be update notes)
52
+ # Handle potential None value for description
53
+ description_html = model_info.get('description')
54
+ if description_html is None:
55
+ description_html = "<p><em>No description provided.</em></p>"
56
+ else:
57
+ # Basic sanitization/check (could be more robust if needed)
58
+ if not isinstance(description_html, str):
59
+ description_html = "<p><em>Invalid description format.</em></p>"
60
+ elif not description_html.strip():
61
+ description_html = "<p><em>Description is empty.</em></p>"
62
+
63
+ version_description_html = version_info.get('description')
64
+ if version_description_html is None:
65
+ version_description_html = "<p><em>No description provided.</em></p>"
66
+ else:
67
+ # Basic sanitization/check (could be more robust if needed)
68
+ if not isinstance(version_description_html, str):
69
+ version_description_html = "<p><em>Invalid description format.</em></p>"
70
+ elif not version_description_html.strip():
71
+ version_description_html = "<p><em>Description is empty.</em></p>"
72
+
73
+ # File details
74
+ def _guess_precision(file_dict):
75
+ try:
76
+ name = (file_dict.get('name') or '').lower()
77
+ meta = (file_dict.get('metadata') or {})
78
+ for key in ('precision', 'dtype', 'fp'):
79
+ val = (meta.get(key) or '').lower()
80
+ if val:
81
+ return val
82
+ if 'fp8' in name or 'int8' in name or '8bit' in name or '8-bit' in name:
83
+ return 'fp8'
84
+ if 'fp16' in name or 'bf16' in name or '16bit' in name or '16-bit' in name:
85
+ return 'fp16'
86
+ if 'fp32' in name or '32bit' in name or '32-bit' in name:
87
+ return 'fp32'
88
+ except Exception:
89
+ pass
90
+ return 'N/A'
91
+
92
+ file_name = primary_file.get('name', 'N/A')
93
+ file_size_kb = primary_file.get('sizeKB', 0)
94
+ _meta = (primary_file.get('metadata') or {})
95
+ file_format = _meta.get('format', 'N/A') # e.g., SafeTensor, PickleTensor
96
+ file_model_size = _meta.get('size', 'N/A') # e.g., Pruned/Full
97
+ file_precision = _guess_precision(primary_file)
98
+
99
+ thumbnail_url = None
100
+ images = version_info.get("images") # Get the images list from the version info
101
+ nsfw_level = None
102
+
103
+ # Check if images list exists, is a list, has items, and the first item is valid with a URL
104
+ if images and isinstance(images, list) and len(images) > 0 and \
105
+ isinstance(images[0], dict) and images[0].get("url"):
106
+ # Use the URL of the very first image directly
107
+ first_image = images[0]
108
+ thumbnail_url = first_image["url"]
109
+ try:
110
+ lvl = first_image.get("nsfwLevel")
111
+ nsfw_level = int(lvl) if lvl is not None else None
112
+ except Exception:
113
+ nsfw_level = None
114
+ print(f"[Get Details Route] Using first image URL as thumbnail: {thumbnail_url}")
115
+ else:
116
+ print("[Get Details Route] No valid first image found in version info, falling back to placeholder.")
117
+ # Fallback placeholder logic (remains the same)
118
+ placeholder_filename = os.path.basename(PLACEHOLDER_IMAGE_PATH) if PLACEHOLDER_IMAGE_PATH else "placeholder.jpeg"
119
+ thumbnail_url = f"./{placeholder_filename}" # Relative path for JS to resolve
120
+
121
+
122
+ # Fallback placeholder if no thumbnail found
123
+ if not thumbnail_url:
124
+ placeholder_filename = os.path.basename(PLACEHOLDER_IMAGE_PATH) if PLACEHOLDER_IMAGE_PATH else "placeholder.jpeg"
125
+ thumbnail_url = f"./{placeholder_filename}" # Relative path for JS
126
+
127
+ # Build minimal files listing for selection in UI/clients
128
+ files_list = []
129
+ vfiles = version_info.get("files", []) or []
130
+ if isinstance(vfiles, list):
131
+ for f in vfiles:
132
+ if not isinstance(f, dict):
133
+ continue
134
+ _fmeta = (f.get("metadata") or {})
135
+ files_list.append({
136
+ "id": f.get("id"),
137
+ "name": f.get("name"),
138
+ "size_kb": f.get("sizeKB"),
139
+ "format": _fmeta.get("format"),
140
+ "model_size": _fmeta.get("size"),
141
+ "precision": _guess_precision(f),
142
+ "downloadable": bool(f.get("downloadUrl")),
143
+ })
144
+
145
+ # --- Return curated data ---
146
+ return web.json_response({
147
+ "success": True,
148
+ "model_id": target_model_id,
149
+ "version_id": target_version_id,
150
+ "model_name": model_name,
151
+ "version_name": version_info.get('name', 'Unknown Version'),
152
+ "creator_username": creator_username,
153
+ "model_type": model_type,
154
+ "description_html": description_html, # Send raw HTML (frontend should handle display safely)
155
+ "version_description_html": version_description_html,
156
+ "stats": {
157
+ "downloads": download_count,
158
+ "likes": likes_count,
159
+ "dislikes": dislikes_count,
160
+ "buzz": buzz_count,
161
+ },
162
+ "file_info": {
163
+ "name": file_name,
164
+ "size_kb": file_size_kb,
165
+ "format": file_format,
166
+ "model_size": file_model_size,
167
+ "precision": file_precision,
168
+ },
169
+ "files": files_list,
170
+ "thumbnail_url": thumbnail_url,
171
+ "nsfw_level": nsfw_level,
172
+ # Optionally include basic version info like baseModel
173
+ "base_model": version_info.get("baseModel", "N/A"),
174
+ # You could add tags here too if desired: model_info.get('tags', [])
175
+ })
176
+
177
+ except web.HTTPError as http_err:
178
+ # Consistent error handling (copied from route_download_model)
179
+ print(f"[Server GetDetails] HTTP Error: {http_err.status} {http_err.reason}")
180
+ body_detail = ""
181
+ try:
182
+ body_detail = await http_err.text() if hasattr(http_err, 'text') else http_err.body.decode('utf-8', errors='ignore') if http_err.body else ""
183
+ if body_detail.startswith('{') and body_detail.endswith('}'): body_detail = json.loads(body_detail)
184
+ except Exception: pass
185
+ return web.json_response({"success": False, "error": http_err.reason, "details": body_detail or "No details", "status_code": http_err.status}, status=http_err.status)
186
+
187
+ except Exception as e:
188
+ # Consistent error handling (copied from route_download_model)
189
+ print("--- Unhandled Error in /civitai/get_model_details ---")
190
+ traceback.print_exc()
191
+ print("--- End Error ---")
192
+ return web.json_response({"success": False, "error": "Internal Server Error", "details": f"An unexpected error occurred: {str(e)}", "status_code": 500}, status=500)
custom_nodes/Civicomfy/server/routes/GetModelDirs.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/GetModelDirs.py
3
+ # ================================================
4
+ import os
5
+ import json
6
+ from aiohttp import web
7
+
8
+ import server # ComfyUI server instance
9
+ from ...utils.helpers import get_model_dir, sanitize_filename
10
+ from ...config import PLUGIN_ROOT
11
+ import folder_paths
12
+
13
+ prompt_server = server.PromptServer.instance
14
+
15
+ CUSTOM_ROOTS_FILE = os.path.join(PLUGIN_ROOT, "custom_roots.json")
16
+
17
+ def _load_custom_roots():
18
+ try:
19
+ if os.path.exists(CUSTOM_ROOTS_FILE):
20
+ with open(CUSTOM_ROOTS_FILE, 'r', encoding='utf-8') as f:
21
+ data = json.load(f)
22
+ if isinstance(data, dict):
23
+ # Normalize values to lists of strings
24
+ return {k: [str(p) for p in (v or []) if isinstance(p, str)] for k, v in data.items()}
25
+ except Exception as e:
26
+ print(f"[Civicomfy] Warning: Failed to load custom roots: {e}")
27
+ return {}
28
+
29
+ def _save_custom_roots(data):
30
+ try:
31
+ with open(CUSTOM_ROOTS_FILE, 'w', encoding='utf-8') as f:
32
+ json.dump(data, f, indent=2)
33
+ return True
34
+ except Exception as e:
35
+ print(f"[Civicomfy] Error writing custom roots file: {e}")
36
+ return False
37
+
38
+ def _get_all_roots_for_type(model_type: str):
39
+ model_type = (model_type or '').lower().strip()
40
+ roots = []
41
+ try:
42
+ # Preferred: ask ComfyUI for all registered directories for this type
43
+ get_fp = getattr(folder_paths, 'get_folder_paths', None)
44
+ if callable(get_fp):
45
+ lst = get_fp(model_type)
46
+ if isinstance(lst, (list, tuple)):
47
+ roots.extend([os.path.abspath(p) for p in lst if isinstance(p, str)])
48
+ else:
49
+ d = folder_paths.get_directory_by_type(model_type)
50
+ if d:
51
+ roots.append(os.path.abspath(d))
52
+ except Exception:
53
+ # Fallback to our main model dir for type
54
+ d = get_model_dir(model_type)
55
+ if d:
56
+ roots.append(os.path.abspath(d))
57
+
58
+ custom = _load_custom_roots().get(model_type, [])
59
+ for p in custom:
60
+ ap = os.path.abspath(p)
61
+ if ap not in roots:
62
+ roots.append(ap)
63
+ # Include all immediate subdirectories inside the main ComfyUI models folder
64
+ try:
65
+ models_dir = getattr(folder_paths, 'models_dir', None)
66
+ if not models_dir:
67
+ base = getattr(folder_paths, 'base_path', os.getcwd())
68
+ models_dir = os.path.join(base, 'models')
69
+ if os.path.isdir(models_dir):
70
+ for name in os.listdir(models_dir):
71
+ p = os.path.join(models_dir, name)
72
+ if os.path.isdir(p):
73
+ ap = os.path.abspath(p)
74
+ if ap not in roots:
75
+ roots.append(ap)
76
+ except Exception as e:
77
+ print(f"[Civicomfy] Warning: Failed to enumerate models dir subfolders: {e}")
78
+ return roots
79
+
80
+ def _list_subdirs(root_dir: str, max_entries: int = 5000):
81
+ """Return a sorted list of relative subdirectory paths under root_dir, including nested."""
82
+ rel_dirs = set()
83
+ root_dir = os.path.abspath(root_dir)
84
+ count = 0
85
+ for current, dirs, _files in os.walk(root_dir):
86
+ # Avoid following symlinks to reduce risk
87
+ abs_current = os.path.abspath(current)
88
+ try:
89
+ rel = os.path.relpath(abs_current, root_dir)
90
+ except Exception:
91
+ continue
92
+ if rel == ".":
93
+ rel = "" # represent root as empty
94
+ rel_dirs.add(rel)
95
+ count += 1
96
+ if count >= max_entries:
97
+ break
98
+ return sorted(rel_dirs)
99
+
100
+ @prompt_server.routes.get("/civitai/model_dirs")
101
+ async def route_get_model_dirs(request):
102
+ """List the base directory (or provided root) and all subdirectories for a given model type."""
103
+ model_type = request.query.get("type", "checkpoint").lower().strip()
104
+ root = (request.query.get("root") or "").strip()
105
+ try:
106
+ base_dir = root if root else get_model_dir(model_type)
107
+ subdirs = _list_subdirs(base_dir)
108
+ return web.json_response({
109
+ "model_type": model_type,
110
+ "base_dir": base_dir,
111
+ "subdirs": subdirs, # relative paths, "" represents the base root
112
+ })
113
+ except Exception as e:
114
+ return web.json_response({"error": "Failed to list directories", "details": str(e)}, status=500)
115
+
116
+ @prompt_server.routes.post("/civitai/create_dir")
117
+ async def route_create_model_dir(request):
118
+ """Create a new subdirectory under a model type's base directory."""
119
+ try:
120
+ data = await request.json()
121
+ model_type = (data.get("model_type") or "checkpoint").lower().strip()
122
+ new_dir = (data.get("new_dir") or "").strip()
123
+ if not new_dir:
124
+ return web.json_response({"error": "Missing 'new_dir'"}, status=400)
125
+
126
+ # If client provided an explicit root, prefer it
127
+ base_dir = (data.get("root") or "").strip() or get_model_dir(model_type)
128
+
129
+ # Normalize and sanitize each part; disallow absolute and traversal
130
+ norm = os.path.normpath(new_dir.replace("\\", "/"))
131
+ parts = [p for p in norm.split("/") if p and p not in (".", "..")]
132
+ safe_parts = [sanitize_filename(p) for p in parts]
133
+ rel_path = os.path.join(*safe_parts) if safe_parts else ""
134
+ if not rel_path:
135
+ return web.json_response({"error": "Invalid folder name"}, status=400)
136
+
137
+ abs_path = os.path.abspath(os.path.join(base_dir, rel_path))
138
+ # Ensure it remains inside base_dir
139
+ if os.path.commonpath([abs_path, os.path.abspath(base_dir)]) != os.path.abspath(base_dir):
140
+ return web.json_response({"error": "Invalid path"}, status=400)
141
+
142
+ os.makedirs(abs_path, exist_ok=True)
143
+ return web.json_response({
144
+ "success": True,
145
+ "created": rel_path,
146
+ "abs_path": abs_path,
147
+ })
148
+ except Exception as e:
149
+ return web.json_response({"error": "Failed to create directory", "details": str(e)}, status=500)
150
+
151
+ @prompt_server.routes.post("/civitai/create_model_type")
152
+ async def route_create_model_type(request):
153
+ """Create a new first-level folder under the main models directory."""
154
+ try:
155
+ data = await request.json()
156
+ name = (data.get("name") or "").strip()
157
+ if not name:
158
+ return web.json_response({"error": "Missing 'name'"}, status=400)
159
+
160
+ # Sanitize folder name to a single path component
161
+ from ...utils.helpers import sanitize_filename
162
+ safe = sanitize_filename(name)
163
+ if not safe:
164
+ return web.json_response({"error": "Invalid folder name"}, status=400)
165
+
166
+ # Resolve models directory
167
+ models_dir = getattr(folder_paths, 'models_dir', None)
168
+ if not models_dir:
169
+ base = getattr(folder_paths, 'base_path', os.getcwd())
170
+ models_dir = os.path.join(base, 'models')
171
+
172
+ abs_path = os.path.abspath(os.path.join(models_dir, safe))
173
+ # Ensure it remains inside models_dir
174
+ if os.path.commonpath([abs_path, os.path.abspath(models_dir)]) != os.path.abspath(models_dir):
175
+ return web.json_response({"error": "Invalid path"}, status=400)
176
+
177
+ os.makedirs(abs_path, exist_ok=True)
178
+ return web.json_response({"success": True, "name": safe, "path": abs_path})
179
+ except Exception as e:
180
+ return web.json_response({"error": "Failed to create model type folder", "details": str(e)}, status=500)
181
+
182
+ @prompt_server.routes.get("/civitai/model_roots")
183
+ async def route_get_model_roots(request):
184
+ """Return all known root directories for a model type (ComfyUI + plugin custom roots)."""
185
+ model_type = request.query.get("type", "checkpoint").lower().strip()
186
+ roots = _get_all_roots_for_type(model_type)
187
+ return web.json_response({
188
+ "model_type": model_type,
189
+ "roots": roots,
190
+ })
191
+
192
+ @prompt_server.routes.post("/civitai/create_root")
193
+ async def route_create_model_root(request):
194
+ """Create a new root directory for a model type and register it in plugin config.
195
+ Note: ComfyUI may require restart to recognize this root globally; the plugin uses it immediately.
196
+ """
197
+ try:
198
+ data = await request.json()
199
+ model_type = (data.get("model_type") or "checkpoint").lower().strip()
200
+ abs_path = (data.get("path") or "").strip()
201
+ if not abs_path:
202
+ return web.json_response({"error": "Missing 'path'"}, status=400)
203
+ # Normalize to absolute path
204
+ abs_path = os.path.abspath(abs_path)
205
+ # Create directory if missing
206
+ os.makedirs(abs_path, exist_ok=True)
207
+ roots = _load_custom_roots()
208
+ current = roots.get(model_type, [])
209
+ if abs_path not in current:
210
+ current.append(abs_path)
211
+ roots[model_type] = current
212
+ if not _save_custom_roots(roots):
213
+ return web.json_response({"error": "Failed to persist custom root"}, status=500)
214
+ return web.json_response({"success": True, "path": abs_path})
215
+ except Exception as e:
216
+ return web.json_response({"error": "Failed to create root", "details": str(e)}, status=500)
custom_nodes/Civicomfy/server/routes/GetModelTypes.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/GetModelTypes.py
3
+ # ================================================
4
+ import os
5
+ from aiohttp import web
6
+ import server # ComfyUI server instance
7
+ import folder_paths
8
+
9
+ prompt_server = server.PromptServer.instance
10
+
11
+ @prompt_server.routes.get("/civitai/model_types")
12
+ async def route_get_model_types(request):
13
+ """API Endpoint to get the known model types and their mapping."""
14
+ try:
15
+ # Dynamically list all first-level folders under the main models directory
16
+ models_dir = getattr(folder_paths, 'models_dir', None)
17
+ if not models_dir:
18
+ base = getattr(folder_paths, 'base_path', os.getcwd())
19
+ models_dir = os.path.join(base, 'models')
20
+ if not os.path.isdir(models_dir):
21
+ return web.json_response({})
22
+
23
+ entries = {}
24
+ for name in sorted(os.listdir(models_dir)):
25
+ p = os.path.join(models_dir, name)
26
+ if os.path.isdir(p):
27
+ entries[name] = name
28
+ return web.json_response(entries)
29
+ except Exception as e:
30
+ print(f"Error getting model types: {e}")
31
+ return web.json_response({"error": "Internal Server Error", "details": str(e), "status_code": 500}, status=500)
custom_nodes/Civicomfy/server/routes/GetStatus.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/GetStatus.py
3
+ # ================================================
4
+ from aiohttp import web
5
+ import server # ComfyUI server instance
6
+ from ...downloader.manager import manager as download_manager
7
+
8
+ prompt_server = server.PromptServer.instance
9
+
10
+ @prompt_server.routes.get("/civitai/status")
11
+ async def route_get_status(request):
12
+ """API Endpoint to get the status of downloads."""
13
+ try:
14
+ status = download_manager.get_status()
15
+ return web.json_response(status)
16
+ except Exception as e:
17
+ print(f"Error getting download status: {e}")
18
+ # Format error response consistently
19
+ return web.json_response({"error": "Internal Server Error", "details": f"Failed to get status: {str(e)}", "status_code": 500}, status=500)
custom_nodes/Civicomfy/server/routes/OpenPath.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/OpenPath.py
3
+ # ================================================
4
+ import asyncio
5
+ import json
6
+ from aiohttp import web
7
+
8
+ import server # ComfyUI server instance
9
+ from ...downloader.manager import manager as download_manager
10
+
11
+ prompt_server = server.PromptServer.instance
12
+
13
+ @prompt_server.routes.post("/civitai/open_path")
14
+ async def route_open_path(request):
15
+ """API Endpoint to open the containing folder of a completed download."""
16
+ if not download_manager:
17
+ return web.json_response({"error": "Download Manager not initialized"}, status=500)
18
+
19
+ try:
20
+ data = await request.json()
21
+ download_id = data.get("download_id")
22
+
23
+ if not download_id:
24
+ return web.json_response({"error": "Missing 'download_id'", "details": "The request body must contain the 'download_id' of the completed item."}, status=400)
25
+
26
+ print(f"[API Route /civitai/open_path] Received open path request for ID: {download_id}")
27
+ # Call manager method in thread
28
+ result = await asyncio.to_thread(download_manager.open_containing_folder, download_id)
29
+
30
+ status_code = 200 if result.get("success") else 404 if "not found" in result.get("error", "").lower() else 400 # Use 400 for OS error, security etc
31
+ # Check for specific errors to return better codes
32
+ if not result.get("success"):
33
+ error_lower = result.get("error", "").lower()
34
+ if "directory does not exist" in error_lower or "id not found" in error_lower:
35
+ status_code = 404
36
+ elif "cannot open path" in error_lower or "unsupported os" in error_lower or "failed to open" in error_lower or "xdg-open" in error_lower:
37
+ status_code = 501 # Not Implemented / Failed on server side
38
+ elif "must be 'completed'" in error_lower:
39
+ status_code = 409 # Conflict - wrong state
40
+ else:
41
+ status_code = 400 # Bad Request / general failure
42
+
43
+ # Prevent sensitive path info leakage in error messages by default
44
+ if not result.get("success") and "error" in result and status_code != 200:
45
+ print(f"[API Route /civitai/open_path] Error for ID {download_id}: {result['error']}") # Log full error on server
46
+ # Optionally sanitize error sent to client
47
+ # if "Directory:" in result["error"] or "Path:" in result["error"]:
48
+ # result["error"] = "Server failed to open the specified directory."
49
+
50
+ return web.json_response(result, status=status_code)
51
+
52
+ except json.JSONDecodeError:
53
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
54
+ except Exception as e:
55
+ import traceback
56
+ print(f"Error handling /civitai/open_path request for ID '{data.get('download_id', 'N/A')}': {e}")
57
+ # traceback.print_exc()
58
+ return web.json_response({"error": "Internal Server Error", "details": f"An unexpected error occurred: {str(e)}"}, status=500)
custom_nodes/Civicomfy/server/routes/RetryDownload.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/RetryDownload.py
3
+ # ================================================
4
+ import asyncio
5
+ import json
6
+ from aiohttp import web
7
+
8
+ import server # ComfyUI server instance
9
+ from ...downloader.manager import manager as download_manager
10
+
11
+ prompt_server = server.PromptServer.instance
12
+
13
+ @prompt_server.routes.post("/civitai/retry")
14
+ async def route_retry_download(request):
15
+ """API Endpoint to retry a failed/cancelled download."""
16
+ if not download_manager:
17
+ return web.json_response({"error": "Download Manager not initialized"}, status=500)
18
+
19
+ try:
20
+ data = await request.json()
21
+ download_id = data.get("download_id")
22
+
23
+ if not download_id:
24
+ return web.json_response({"error": "Missing 'download_id'", "details": "The request body must contain the 'download_id' of the item to retry."}, status=400)
25
+
26
+ print(f"[API Route /civitai/retry] Received retry request for ID: {download_id}")
27
+ # Call manager method (which handles locking)
28
+ result = await asyncio.to_thread(download_manager.retry_download, download_id) # Run sync manager method in thread
29
+
30
+ status_code = 200 if result.get("success") else 404 if "not found" in result.get("error", "").lower() else 400
31
+ return web.json_response(result, status=status_code)
32
+
33
+ except json.JSONDecodeError:
34
+ return web.json_response({"error": "Invalid JSON body"}, status=400)
35
+ except Exception as e:
36
+ import traceback
37
+ print(f"Error handling /civitai/retry request for ID '{data.get('download_id', 'N/A')}': {e}")
38
+ # traceback.print_exc() # Uncomment for detailed logs
39
+ return web.json_response({"error": "Internal Server Error", "details": f"An unexpected error occurred: {str(e)}"}, status=500)
custom_nodes/Civicomfy/server/routes/SearchModels.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/SearchModels.py
3
+ # ================================================
4
+ import json
5
+ import math
6
+ import traceback
7
+ from aiohttp import web
8
+
9
+ import server # ComfyUI server instance
10
+ from ..utils import get_request_json
11
+ from ...api.civitai import CivitaiAPI
12
+ from ...config import CIVITAI_API_TYPE_MAP
13
+
14
+ prompt_server = server.PromptServer.instance
15
+
16
+ @prompt_server.routes.post("/civitai/search")
17
+ async def route_search_models(request):
18
+ """API Endpoint for searching models using Civitai's Meilisearch."""
19
+ api_key = None # Meili might not use the standard key
20
+ try:
21
+ data = await get_request_json(request)
22
+
23
+ query = data.get("query", "").strip()
24
+ model_type_keys = data.get("model_types", []) # e.g., ["lora", "checkpoint"] (frontend internal keys)
25
+ base_model_filters = data.get("base_models", []) # e.g., ["SD 1.5", "Pony"]
26
+ sort = data.get("sort", "Most Downloaded") # Frontend display value
27
+ # Make period optional or remove if not supported by Meili sort directly
28
+ # period = data.get("period", "AllTime")
29
+ limit = int(data.get("limit", 20))
30
+ page = int(data.get("page", 1))
31
+ api_key = data.get("api_key", "") # Keep for potential future use or different endpoints
32
+ nsfw = data.get("nsfw", None) # Expect Boolean or None
33
+
34
+ if not query and not model_type_keys and not base_model_filters:
35
+ raise web.HTTPBadRequest(reason="Search requires a query or at least one filter (type or base model).")
36
+
37
+ # Instantiate API - API key might not be needed for Meili public search
38
+ api = CivitaiAPI(api_key or None)
39
+
40
+ # --- Prepare Filters for Meili API call ---
41
+
42
+ # 1. Map internal type keys to Civitai API 'type' names (used in Meili filter)
43
+ # This assumes Meili filters on the uppercase names like "LORA", "Checkpoint"
44
+ api_types_filter = []
45
+ if isinstance(model_type_keys, list) and model_type_keys and "any" not in model_type_keys:
46
+ for key in model_type_keys:
47
+ # Map key.lower() for robustness - use the existing map from config
48
+ # CIVITAI_API_TYPE_MAP maps internal key -> Civitai API type name (e.g. 'lora' -> 'LORA')
49
+ api_type = CIVITAI_API_TYPE_MAP.get(key.lower())
50
+ # Ensure we handle cases where the map might return None or duplicate types
51
+ if api_type and api_type not in api_types_filter:
52
+ api_types_filter.append(api_type)
53
+
54
+ # 2. Base Model Filters (assume frontend sends exact names like "SD 1.5")
55
+ valid_base_models = []
56
+ if isinstance(base_model_filters, list) and base_model_filters:
57
+ # Optional: Validate against known list?
58
+ valid_base_models = [bm for bm in base_model_filters if isinstance(bm, str) and bm]
59
+ # Example validation (optional):
60
+ # valid_base_models = [bm for bm in base_model_filters if bm in AVAILABLE_MEILI_BASE_MODELS]
61
+ # if len(valid_base_models) != len(base_model_filters):
62
+ # print("Warning: Some provided base model filters were invalid.")
63
+
64
+ # --- Call the New API Method ---
65
+ print(f"[Server Search] Meili: query='{query if query else '<none>'}', types={api_types_filter or 'Any'}, baseModels={valid_base_models or 'Any'}, sort={sort}, nsfw={nsfw}, limit={limit}, page={page}")
66
+
67
+ # Call the new search method
68
+ meili_results = api.search_models_meili(
69
+ query=query or None, # Meili handles empty query if filters exist
70
+ types=api_types_filter or None,
71
+ base_models=valid_base_models or None,
72
+ sort=sort, # Pass the frontend value, mapping happens inside search_models_meili
73
+ limit=limit,
74
+ page=page,
75
+ nsfw=nsfw
76
+ )
77
+
78
+ # Handle API error response from CivitaiAPI helper
79
+ if meili_results and isinstance(meili_results, dict) and "error" in meili_results:
80
+ status_code = meili_results.get("status_code", 500) or 500
81
+ reason = f"Civitai API Meili Search Error: {meili_results.get('details', meili_results.get('error', 'Unknown error'))}"
82
+ raise web.HTTPException(reason=reason, status=status_code, body=json.dumps(meili_results))
83
+
84
+ # --- Process Meili Response for Frontend ---
85
+ if meili_results and isinstance(meili_results, dict) and "hits" in meili_results:
86
+ processed_items = []
87
+ image_base_url = "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7QA" # Base URL for images
88
+
89
+ for hit in meili_results.get("hits", []):
90
+ if not isinstance(hit, dict): continue # Skip invalid hits
91
+
92
+ thumbnail_url = None
93
+ # Get thumbnail from images array (prefer first image)
94
+ images = hit.get("images")
95
+ if images and isinstance(images, list) and len(images) > 0:
96
+ first_image = images[0]
97
+ # Ensure first image is a dict with a 'url' field
98
+ if isinstance(first_image, dict) and first_image.get("url"):
99
+ image_id = first_image["url"]
100
+ # Construct URL with a default width (e.g., 256 or 450)
101
+ thumbnail_url = f"{image_base_url}/{image_id}/width=256" # Adjust width as needed
102
+
103
+ # Extract latest version info (Meili response includes 'version' object for the primary version)
104
+ latest_version_info = hit.get("version", {}) or {} # Ensure it's a dict
105
+
106
+ # Prepare item structure for frontend (can pass raw hit + extras, or build a specific structure)
107
+ # Let's pass the raw `hit` and add the `thumbnailUrl` and potentially other processed fields.
108
+ hit['thumbnailUrl'] = thumbnail_url # Add processed thumbnail URL directly to the hit object
109
+
110
+ # Optional: Add more processed fields if needed, e.g., formatted stats
111
+ # hit['processedStats'] = { ... }
112
+
113
+ processed_items.append(hit)
114
+
115
+ # --- Calculate Pagination Info ---
116
+ total_hits = meili_results.get("estimatedTotalHits", 0)
117
+ current_page = page # Use the requested page number
118
+ total_pages = math.ceil(total_hits / limit) if limit > 0 else 0
119
+
120
+ # --- Return Structure for Frontend ---
121
+ response_data = {
122
+ "items": processed_items, # The array of processed hits
123
+ "metadata": {
124
+ "totalItems": total_hits,
125
+ "currentPage": current_page,
126
+ "pageSize": limit, # The limit used for the request
127
+ "totalPages": total_pages,
128
+ # Meili provides offset, limit, processingTimeMs which could also be passed if useful
129
+ "meiliProcessingTimeMs": meili_results.get("processingTimeMs"),
130
+ "meiliOffset": meili_results.get("offset"),
131
+ }
132
+ }
133
+ return web.json_response(response_data)
134
+ else:
135
+ # Handle unexpected format from API or empty results
136
+ print(f"[Server Search] Warning: Unexpected Meili search result format or empty hits: {meili_results}")
137
+ return web.json_response({"items": [], "metadata": {"totalItems": 0, "currentPage": page, "pageSize": limit, "totalPages": 0}}, status=500)
138
+
139
+ # --- Keep existing error handlers ---
140
+ except web.HTTPError as http_err:
141
+ # ... (keep existing HTTP error handling) ...
142
+ body_detail = ""
143
+ try:
144
+ body_detail = await http_err.text() if hasattr(http_err, 'text') else http_err.body.decode('utf-8', errors='ignore') if http_err.body else ""
145
+ if body_detail.startswith('{') and body_detail.endswith('}'): body_detail = json.loads(body_detail)
146
+ except Exception: pass
147
+ return web.json_response({"error": http_err.reason, "details": body_detail or "No details", "status_code": http_err.status}, status=http_err.status)
148
+
149
+ except Exception as e:
150
+ # ... (keep existing generic error handling) ...
151
+ print("--- Unhandled Error in /civitai/search ---")
152
+ traceback.print_exc()
153
+ print("--- End Error ---")
154
+ return web.json_response({"error": "Internal Server Error", "details": f"An unexpected search error occurred: {str(e)}", "status_code": 500}, status=500)
custom_nodes/Civicomfy/server/routes/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/routes/__init__.py
3
+ # ================================================
4
+ # This file imports all the individual route modules.
5
+ # When the `routes` package is imported by `server/__init__.py`,
6
+ # these imports will be executed, registering the routes with the
7
+ # ComfyUI server instance.
8
+
9
+ from . import CancelDownload
10
+ from . import ClearHistory
11
+ from . import DownloadModel
12
+ from . import GetBaseModels
13
+ from . import GetModelDetails
14
+ from . import GetModelTypes
15
+ from . import GetModelDirs
16
+ from . import GetStatus
17
+ from . import OpenPath
18
+ from . import RetryDownload
19
+ from . import SearchModels
20
+
21
+ print("[Civicomfy] All server route modules loaded.")
custom_nodes/Civicomfy/server/utils.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: server/utils.py
3
+ # ================================================
4
+ import json
5
+ from typing import Any, Dict, Optional
6
+ from aiohttp import web
7
+
8
+ # Import necessary components from our modules
9
+ from ..api.civitai import CivitaiAPI
10
+ from ..utils.helpers import parse_civitai_input, select_primary_file
11
+
12
+ async def get_request_json(request):
13
+ """Safely get JSON data from request."""
14
+ try:
15
+ return await request.json()
16
+ except Exception as e:
17
+ print(f"Error parsing request JSON: {e}")
18
+ raise web.HTTPBadRequest(reason=f"Invalid JSON format: {e}")
19
+
20
+ async def get_civitai_model_and_version_details(api: CivitaiAPI, model_url_or_id: str, req_version_id: Optional[int]) -> Dict[str, Any]:
21
+ """
22
+ Helper to fetch Civitai details.
23
+ Prioritizes fetching model info based on resolved Model ID.
24
+ Fetches specific version info if version ID is provided/resolved, otherwise latest.
25
+ Returns a dict with 'model_info', 'version_info', 'primary_file', and resolved IDs.
26
+ Raises HTTP exceptions on critical failures.
27
+ """
28
+ target_model_id = None
29
+ target_version_id = None
30
+ potential_version_id_from_input = None
31
+ model_info = {}
32
+ version_info_to_use = {} # The version (specific or latest) whose file we'll use
33
+ primary_file = None
34
+
35
+ # --- 1. Parse Input to get potential IDs ---
36
+ parsed_model_id, parsed_version_id = parse_civitai_input(model_url_or_id)
37
+
38
+ # Determine the initial target model ID (input URL/ID takes precedence)
39
+ target_model_id = parsed_model_id
40
+
41
+ # Determine the specific version requested (explicit param > URL param)
42
+ if req_version_id and str(req_version_id).isdigit():
43
+ try:
44
+ potential_version_id_from_input = int(req_version_id)
45
+ except (ValueError, TypeError):
46
+ print(f"[API Helper] Warning: Invalid req_version_id: {req_version_id}. Ignoring.")
47
+ elif parsed_version_id:
48
+ potential_version_id_from_input = parsed_version_id
49
+
50
+ # --- 2. Ensure we have a Model ID ---
51
+ # If we only got a version ID from the input (e.g., civitai.com/model-versions/456),
52
+ # we need to fetch that version *first* just to find the model ID.
53
+ if not target_model_id and potential_version_id_from_input:
54
+ print(f"[API Helper] Input requires fetching version {potential_version_id_from_input} first to find model ID.")
55
+ temp_version_info = api.get_model_version_info(potential_version_id_from_input)
56
+ if temp_version_info and "error" not in temp_version_info and temp_version_info.get('modelId'):
57
+ target_model_id = temp_version_info['modelId']
58
+ print(f"[API Helper] Found Model ID {target_model_id} from Version ID {potential_version_id_from_input}.")
59
+ # We might reuse temp_version_info later if this was the specifically requested version
60
+ else:
61
+ err = temp_version_info.get('details', 'Could not find model ID from version') if isinstance(temp_version_info, dict) else 'API error'
62
+ raise web.HTTPNotFound(reason=f"Could not determine Model ID from Version ID {potential_version_id_from_input}", body=json.dumps({"error": f"Version {potential_version_id_from_input} not found or missing modelId", "details": err}))
63
+
64
+ # If still no model ID after potential lookup, fail
65
+ if not target_model_id:
66
+ raise web.HTTPBadRequest(reason="Could not determine a valid Model ID from the input.")
67
+
68
+ # --- 3. Fetch Core Model Information (Always based on target_model_id) ---
69
+ print(f"[API Helper] Fetching core model info for Model ID: {target_model_id}")
70
+ model_info_result = api.get_model_info(target_model_id)
71
+ if not model_info_result or "error" in model_info_result:
72
+ err_details = model_info_result.get('details', 'Unknown API error') if isinstance(model_info_result, dict) else 'Unknown API error'
73
+ raise web.HTTPNotFound(reason=f"Model {target_model_id} not found or API error", body=json.dumps({"error": f"Model {target_model_id} not found or API error", "details": err_details}))
74
+ model_info = model_info_result # Store the successfully fetched model info
75
+
76
+ # --- 4. Determine and Fetch Version Info for File Details ---
77
+ if potential_version_id_from_input:
78
+ # User specified a version explicitly, fetch its details
79
+ print(f"[API Helper] Fetching specific version info for Version ID: {potential_version_id_from_input}")
80
+ target_version_id = potential_version_id_from_input # This is the version we need info for
81
+ # Check if we already fetched this during Model ID lookup
82
+ if 'temp_version_info' in locals() and temp_version_info.get('id') == target_version_id:
83
+ print("[API Helper] Reusing version info fetched earlier.")
84
+ version_info_to_use = temp_version_info
85
+ else:
86
+ version_info_result = api.get_model_version_info(target_version_id)
87
+ if not version_info_result or "error" in version_info_result:
88
+ err_details = version_info_result.get('details', 'Unknown API error') if isinstance(version_info_result, dict) else 'Unknown API error'
89
+ raise web.HTTPNotFound(reason=f"Specified Version {target_version_id} not found or API error", body=json.dumps({"error": f"Version {target_version_id} not found or API error", "details": err_details}))
90
+ version_info_to_use = version_info_result
91
+ else:
92
+ # No specific version requested, find latest/default from model_info
93
+ print(f"[API Helper] Finding latest/default version for Model ID: {target_model_id}")
94
+ versions = model_info.get("modelVersions")
95
+ if not versions or not isinstance(versions, list) or len(versions) == 0:
96
+ raise web.HTTPNotFound(reason=f"Model {target_model_id} has no listed model versions.")
97
+
98
+ # Find the 'best' default version (usually first published)
99
+ default_version_summary = next((v for v in versions if v.get('status') == 'Published'), versions[0])
100
+ target_version_id = default_version_summary.get('id')
101
+ if not target_version_id:
102
+ raise web.HTTPNotFound(reason=f"Model {target_model_id}'s latest version has no ID.")
103
+
104
+ print(f"[API Helper] Using latest/default Version ID: {target_version_id}. Fetching its full details.")
105
+ # Fetch full details for this latest version
106
+ version_info_result = api.get_model_version_info(target_version_id)
107
+ if not version_info_result or "error" in version_info_result:
108
+ # Log error, but maybe try to proceed with summary data if desperate? Risky.
109
+ err_details = version_info_result.get('details', 'Unknown error getting full version') if isinstance(version_info_result, dict) else 'Error'
110
+ print(f"[API Helper] Warning: Could not fetch full details for latest version {target_version_id}. Details: {err_details}. Falling back to summary.")
111
+ # Use summary data from model_info as fallback - file info might be missing!
112
+ version_info_to_use = default_version_summary
113
+ # Ensure minimal structure for file finding later
114
+ version_info_to_use['files'] = version_info_to_use.get('files', [])
115
+ version_info_to_use['images'] = version_info_to_use.get('images', [])
116
+ version_info_to_use['modelId'] = version_info_to_use.get('modelId', target_model_id) # Ensure modelId is present
117
+ version_info_to_use['model'] = version_info_to_use.get('model', {'name': model_info.get('name', 'Unknown')}) # Add fallback model name
118
+
119
+ else:
120
+ version_info_to_use = version_info_result
121
+
122
+ # --- 5. Find Primary File from the Determined Version (version_info_to_use) ---
123
+ print(f"[API Helper] Finding primary file for Version ID: {target_version_id}")
124
+ files = version_info_to_use.get("files", [])
125
+ if not isinstance(files, list): files = []
126
+
127
+ # Handle fallback downloadUrl at version level if 'files' is empty/missing
128
+ if not files and 'downloadUrl' in version_info_to_use and version_info_to_use['downloadUrl']:
129
+ print("[API Helper] Warning: No 'files' array found, using version-level 'downloadUrl'.")
130
+ files = [{
131
+ "id": None, "name": version_info_to_use.get('name', f"version_{target_version_id}_file"),
132
+ "primary": True, "type": "Model", "sizeKB": version_info_to_use.get('fileSizeKB'),
133
+ "downloadUrl": version_info_to_use['downloadUrl'], "hashes": {}, "metadata": {}
134
+ }]
135
+
136
+ if not files:
137
+ raise web.HTTPNotFound(reason=f"Version {target_version_id} (Name: {version_info_to_use.get('name', 'N/A')}) has no files listed.")
138
+
139
+ # Use the centralized helper to select the best file
140
+ primary_file = select_primary_file(files)
141
+
142
+ if not primary_file:
143
+ raise web.HTTPNotFound(reason=f"Could not find any usable file with a download URL for version {target_version_id}.")
144
+
145
+ print(f"[API Helper] Selected file: Name='{primary_file.get('name', 'N/A')}', SizeKB={primary_file.get('sizeKB')}")
146
+
147
+ # --- 6. Return Results ---
148
+ return {
149
+ "model_info": model_info, # Always the full model info
150
+ "version_info": version_info_to_use, # Info for the specific/latest version
151
+ "primary_file": primary_file, # The file from that version
152
+ "target_model_id": target_model_id, # Resolved model ID
153
+ "target_version_id": target_version_id, # Resolved version ID (specific or latest)
154
+ }
custom_nodes/Civicomfy/utils/__init__.py ADDED
File without changes
custom_nodes/Civicomfy/utils/helpers.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ================================================
2
+ # File: utils/helpers.py
3
+ # ================================================
4
+ import os
5
+ import urllib.parse
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Optional, List, Dict, Any
9
+
10
+ import folder_paths
11
+
12
+ # Import config values needed here
13
+ from ..config import PLUGIN_ROOT, MODEL_TYPE_DIRS
14
+
15
+ def get_model_dir(model_type: str) -> str:
16
+ """
17
+ Resolve the absolute directory path for a model type using ComfyUI's
18
+ folder_paths manager. Respects extra_model_paths.yaml and symlinks.
19
+ Ensures the directory exists.
20
+ """
21
+ model_type_raw = (model_type or "").strip()
22
+ model_type_key = model_type_raw.lower()
23
+
24
+ display_and_type = MODEL_TYPE_DIRS.get(model_type_key)
25
+ folder_paths_type = None
26
+ if display_and_type:
27
+ _, folder_paths_type = display_and_type
28
+
29
+ if display_and_type and folder_paths_type:
30
+ try:
31
+ # Preferred: ask ComfyUI for the configured directory for this type
32
+ full_path = folder_paths.get_directory_by_type(folder_paths_type)
33
+ # If API returned a falsy value, try alternative lookups
34
+ if not full_path:
35
+ raise RuntimeError(f"No directory registered for type '{folder_paths_type}'")
36
+ except Exception:
37
+ # Fallback: assume a subdirectory under models_dir
38
+ print(f"Warning: Could not resolve path for type '{folder_paths_type}' via folder_paths. Falling back to models_dir.")
39
+ # Try get_folder_paths first
40
+ full_path = None
41
+ try:
42
+ get_fp = getattr(folder_paths, 'get_folder_paths', None)
43
+ if callable(get_fp):
44
+ paths_list = get_fp(folder_paths_type)
45
+ if isinstance(paths_list, (list, tuple)) and paths_list:
46
+ full_path = paths_list[0]
47
+ except Exception:
48
+ pass
49
+ if not full_path:
50
+ models_dir = getattr(folder_paths, 'models_dir', None)
51
+ if not models_dir:
52
+ # Last resort: try to infer a models dir near base_path
53
+ base = getattr(folder_paths, 'base_path', os.getcwd())
54
+ models_dir = os.path.join(base, 'models')
55
+ full_path = os.path.join(models_dir, folder_paths_type)
56
+ else:
57
+ # Treat model_type as a literal folder under the main models directory
58
+ models_dir = getattr(folder_paths, 'models_dir', None)
59
+ if not models_dir:
60
+ base = getattr(folder_paths, 'base_path', os.getcwd())
61
+ models_dir = os.path.join(base, 'models')
62
+ # Use the raw (case-preserving) folder name
63
+ full_path = os.path.join(models_dir, model_type_raw)
64
+
65
+ # Ensure the directory exists
66
+ try:
67
+ # Ensure full_path is a string path
68
+ if not isinstance(full_path, (str, bytes, os.PathLike)):
69
+ raise TypeError(f"Resolved directory for '{model_type_key}' is invalid: {full_path!r}")
70
+ os.makedirs(full_path, exist_ok=True)
71
+ except Exception as e:
72
+ print(f"Error: Could not create directory '{full_path}': {e}")
73
+
74
+ return full_path
75
+
76
+ def parse_civitai_input(url_or_id: str) -> tuple[int | None, int | None]:
77
+ """
78
+ Parses Civitai URL or ID string.
79
+ Returns: (model_id, version_id) tuple. Both can be None.
80
+ Handles URLs like /models/123 and /models/123?modelVersionId=456
81
+ """
82
+ if not url_or_id:
83
+ return None, None
84
+
85
+ url_or_id = str(url_or_id).strip()
86
+ model_id: int | None = None
87
+ version_id: int | None = None
88
+ query_params = {}
89
+
90
+ # Check if it's just a number (could be model or version ID)
91
+ # Treat digits-only input as MODEL ID primarily, as users often copy just that.
92
+ # Version ID can be specified separately or via full URL query param.
93
+ if url_or_id.isdigit():
94
+ try:
95
+ # Assume it's a Model ID if just digits are provided.
96
+ model_id = int(url_or_id)
97
+ print(f"Parsed input '{url_or_id}' as Model ID.")
98
+ # Don't assume it's a version ID here. Let it be specified if needed.
99
+ return model_id, None
100
+ except (ValueError, TypeError):
101
+ print(f"Warning: Could not parse '{url_or_id}' as a numeric ID.")
102
+ return None, None
103
+
104
+ # If not just digits, try parsing as URL
105
+ try:
106
+ parsed_url = urllib.parse.urlparse(url_or_id)
107
+
108
+ # Basic check for URL structure and domain
109
+ if not parsed_url.scheme or not parsed_url.netloc:
110
+ # Maybe it's a path like /models/123 without the domain?
111
+ if url_or_id.startswith(("/models/", "/model-versions/")):
112
+ # Re-parse with a dummy scheme and domain
113
+ parsed_url = urllib.parse.urlparse("https://civitai.com" + url_or_id)
114
+ if not parsed_url.path: # If still fails, give up
115
+ print(f"Input '{url_or_id}' is not a recognizable Civitai path or URL.")
116
+ return None, None
117
+ else:
118
+ print(f"Input '{url_or_id}' is not a valid ID or Civitai URL/path.")
119
+ return None, None
120
+
121
+ # Check domain if it was present
122
+ if parsed_url.netloc and "civitai.com" not in parsed_url.netloc.lower():
123
+ print(f"Input URL '{url_or_id}' is not a Civitai URL.")
124
+ return None, None
125
+
126
+ # Extract path components and query parameters
127
+ path_parts = [p for p in parsed_url.path.split('/') if p] # Remove empty parts
128
+ query_params = urllib.parse.parse_qs(parsed_url.query)
129
+
130
+ # --- Logic ---
131
+ # 1. Check query params for modelVersionId FIRST (most explicit)
132
+ if 'modelVersionId' in query_params:
133
+ try:
134
+ version_id = int(query_params['modelVersionId'][0])
135
+ print(f"Found Version ID {version_id} in query parameters.")
136
+ except (ValueError, IndexError, TypeError):
137
+ print(f"Warning: Found modelVersionId in query but couldn't parse: {query_params.get('modelVersionId')}")
138
+ version_id = None # Reset if parsing failed
139
+
140
+ # 2. Check path for /models/ID
141
+ model_id_from_path = None
142
+ if "models" in path_parts:
143
+ try:
144
+ models_index = path_parts.index("models")
145
+ if models_index + 1 < len(path_parts):
146
+ # Take the part right after /models/ and check if it's digits
147
+ potential_id_str = path_parts[models_index + 1]
148
+ if potential_id_str.isdigit():
149
+ model_id_from_path = int(potential_id_str)
150
+ print(f"Found Model ID {model_id_from_path} in URL path.")
151
+ except (ValueError, IndexError, TypeError):
152
+ print(f"Warning: Found /models/ in path but couldn't parse ID from {path_parts}")
153
+
154
+ # 3. Check path for /model-versions/ID (less common, usually doesn't contain model ID)
155
+ version_id_from_path = None
156
+ if version_id is None and "model-versions" in path_parts: # Only check if not found in query
157
+ try:
158
+ versions_index = path_parts.index("model-versions")
159
+ if versions_index + 1 < len(path_parts):
160
+ potential_id_str = path_parts[versions_index + 1]
161
+ if potential_id_str.isdigit():
162
+ version_id_from_path = int(potential_id_str)
163
+ # Set version_id only if not already set by query param
164
+ if version_id is None:
165
+ version_id = version_id_from_path
166
+ print(f"Found Version ID {version_id} in URL path.")
167
+ except (ValueError, IndexError, TypeError):
168
+ print(f"Warning: Found /model-versions/ in path but couldn't parse ID from {path_parts}")
169
+
170
+ # 4. Assign final model ID (prefer path over digits-only assumption if URL was parsed)
171
+ if model_id_from_path is not None:
172
+ model_id = model_id_from_path
173
+ # If no model ID found yet and input looked like a URL, maybe it was ONLY a version URL?
174
+ elif model_id is None and version_id is not None:
175
+ print("Warning: Found Version ID but no Model ID in the URL. Model info might be incomplete.")
176
+ # Keep the initially parsed model_id if input was digits-only
177
+
178
+ except Exception as e:
179
+ print(f"Error parsing Civitai input '{url_or_id}': {e}")
180
+ return None, None
181
+
182
+ print(f"Parsed Civitai input: Model ID = {model_id}, Version ID = {version_id}")
183
+ # Return the determined IDs. It's the caller's responsibility to fetch model info if only version ID is present.
184
+ return model_id, version_id
185
+
186
+ # Updated sanitize_filename to be more restrictive
187
+ def sanitize_filename(filename: str, default_filename: str = "downloaded_model") -> str:
188
+ """
189
+ Stricter filename sanitization. Replaces invalid characters, trims whitespace,
190
+ handles reserved names (Windows), and ensures it's not empty.
191
+ Aims for better cross-OS compatibility.
192
+ """
193
+ if not filename:
194
+ return default_filename
195
+
196
+ # Decode if bytes
197
+ if isinstance(filename, bytes):
198
+ try:
199
+ filename = filename.decode('utf-8')
200
+ except UnicodeDecodeError:
201
+ # If decode fails, fall back to a safe default representation or hex
202
+ # For simplicity, just use default for now if decoding problematic bytes
203
+ return default_filename + "_decode_error"
204
+
205
+ # Remove characters invalid for Windows/Linux/MacOS filenames
206
+ # Invalid Chars: < > : " / \ | ? * and control characters (0-31)
207
+ # Also replace NULL character just in case.
208
+ sanitized = re.sub(r'[\x00-\x1f<>:"/\\|?*]', '_', filename)
209
+
210
+ # Replace sequences of multiple underscores or spaces introduced by replacement
211
+ sanitized = re.sub(r'[_ ]{2,}', '_', sanitized)
212
+
213
+ # Remove leading/trailing whitespace, dots, underscores
214
+ sanitized = sanitized.strip('. _')
215
+
216
+ # Windows Reserved Names (case-insensitive)
217
+ reserved_names = {'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'}
218
+ # Check base name without extension
219
+ base_name, ext = os.path.splitext(sanitized)
220
+ if base_name.upper() in reserved_names:
221
+ sanitized = f"_{base_name}_{ext}" # Prepend underscore
222
+
223
+ # Prevent names that are just '.' or '..' (though stripping dots should handle this)
224
+ if sanitized == '.' or sanitized == '..':
225
+ sanitized = default_filename + "_invalid_name"
226
+
227
+ # If sanitization results in an empty string (unlikely now), use default
228
+ if not sanitized:
229
+ sanitized = default_filename
230
+
231
+ # Optional: Limit overall length (e.g., 200 chars), considering path limits
232
+ # Be careful as some systems have path limits, not just filename limits
233
+ max_len = 200 # A reasonable limit for the filename itself
234
+ if len(sanitized) > max_len:
235
+ name_part, ext_part = os.path.splitext(sanitized)
236
+ # Truncate the name part, ensuring total length is within max_len
237
+ allowed_name_len = max_len - len(ext_part)
238
+ if allowed_name_len <= 0: # Handle case where extension itself is too long
239
+ sanitized = sanitized[:max_len] # Truncate forcefully
240
+ else:
241
+ sanitized = name_part[:allowed_name_len] + ext_part
242
+ print(f"Warning: Sanitized filename truncated to {max_len} characters.")
243
+
244
+ return sanitized
245
+
246
+ def select_primary_file(files: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
247
+ """
248
+ Selects the best file from a list of files based on a heuristic.
249
+ Prefers primary, then safetensors, then pruned, etc.
250
+ Returns the selected file dictionary or None.
251
+ """
252
+ if not files or not isinstance(files, list):
253
+ return None
254
+
255
+ # First, try to find a file explicitly marked as "primary" with a valid download URL
256
+ primary_marked_file = next((f for f in files if isinstance(f, dict) and f.get("primary") and f.get('downloadUrl')), None)
257
+ if primary_marked_file:
258
+ return primary_marked_file
259
+
260
+ # If no primary file is marked, sort all available files using a heuristic
261
+ def sort_key(file_obj):
262
+ if not isinstance(file_obj, dict): return 99
263
+ if not file_obj.get('downloadUrl'): return 98 # Deprioritize files without URL
264
+
265
+ name_lower = file_obj.get("name", "").lower()
266
+ meta = file_obj.get("metadata", {}) or {}
267
+ format_type = meta.get("format", "").lower()
268
+ size_type = meta.get("size", "").lower()
269
+
270
+ # Fallback to file extension if format metadata missing
271
+ is_safetensor = ".safetensors" in name_lower or format_type == "safetensor"
272
+ is_pickle = ".ckpt" in name_lower or ".pt" in name_lower or format_type == "pickletensor"
273
+ is_pruned = size_type == "pruned"
274
+
275
+ if is_safetensor and is_pruned: return 0
276
+ if is_safetensor: return 1
277
+ if is_pickle and is_pruned: return 2
278
+ if is_pickle: return 3
279
+ # Prioritize model files over others like VAEs if type is available
280
+ if file_obj.get("type") == "Model": return 4
281
+ if file_obj.get("type") == "Pruned Model": return 5
282
+ return 10 # Other types last
283
+
284
+ valid_files = [f for f in files if isinstance(f, dict) and f.get("downloadUrl")]
285
+ if not valid_files:
286
+ return None
287
+
288
+ sorted_files = sorted(valid_files, key=sort_key)
289
+ return sorted_files[0]
custom_nodes/Civicomfy/web/.DS_Store ADDED
Binary file (6.15 kB). View file
 
custom_nodes/Civicomfy/web/images/placeholder.jpeg ADDED

Git LFS Details

  • SHA256: 05fb67a451c768f86a7d393f4f8ebd86035f035944f6c46f68086c9b26a91313
  • Pointer size: 130 Bytes
  • Size of remote file: 23.1 kB
custom_nodes/Civicomfy/web/js/.DS_Store ADDED
Binary file (6.15 kB). View file
 
custom_nodes/Civicomfy/web/js/api/civitai.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // API client for Civicomfy UI
2
+ // Wraps ComfyUI's fetchApi with consistent error handling
3
+
4
+ import { api } from "../../../../scripts/api.js";
5
+
6
+ export class CivitaiDownloaderAPI {
7
+ static async _request(endpoint, options = {}) {
8
+ try {
9
+ const url = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
10
+ const response = await api.fetchApi(url, options);
11
+
12
+ if (!response.ok) {
13
+ let errorData;
14
+ const status = response.status;
15
+ const statusText = response.statusText;
16
+ try {
17
+ errorData = await response.json();
18
+ if (typeof errorData !== "object" || errorData === null) {
19
+ errorData = { detail: String(errorData) };
20
+ }
21
+ } catch (_) {
22
+ const detailText = await response.text().catch(() => `Status ${status} - Could not read error text`);
23
+ errorData = {
24
+ error: `HTTP error ${status}`,
25
+ details: String(detailText).substring(0, 500),
26
+ };
27
+ }
28
+ const err = new Error(errorData.error || errorData.reason || `HTTP Error: ${status} ${statusText}`);
29
+ err.details = errorData.details || errorData.detail || errorData.error || "No details provided.";
30
+ err.status = status;
31
+ throw err;
32
+ }
33
+
34
+ if (response.status === 204 || response.headers.get("Content-Length") === "0") {
35
+ return null;
36
+ }
37
+ return await response.json();
38
+ } catch (error) {
39
+ if (!error.details) error.details = error.message;
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ static async downloadModel(params) {
45
+ return await this._request("/civitai/download", {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify(params),
49
+ });
50
+ }
51
+
52
+ static async getModelDetails(params) {
53
+ return await this._request("/civitai/get_model_details", {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify(params),
57
+ });
58
+ }
59
+
60
+ static async getStatus() {
61
+ return await this._request("/civitai/status");
62
+ }
63
+
64
+ static async cancelDownload(downloadId) {
65
+ return await this._request("/civitai/cancel", {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ download_id: downloadId }),
69
+ });
70
+ }
71
+
72
+ static async searchModels(params) {
73
+ return await this._request("/civitai/search", {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify(params),
77
+ });
78
+ }
79
+
80
+ static async getBaseModels() {
81
+ return await this._request("/civitai/base_models");
82
+ }
83
+
84
+ static async getModelTypes() {
85
+ return await this._request("/civitai/model_types");
86
+ }
87
+
88
+ static async getModelDirs(modelType) {
89
+ const q = encodeURIComponent(modelType || 'checkpoint');
90
+ return await this._request(`/civitai/model_dirs?type=${q}`);
91
+ }
92
+
93
+ static async createModelDir(modelType, newDir) {
94
+ return await this._request("/civitai/create_dir", {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({ model_type: modelType, new_dir: newDir }),
98
+ });
99
+ }
100
+
101
+ static async createModelType(name) {
102
+ return await this._request("/civitai/create_model_type", {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ name }),
106
+ });
107
+ }
108
+
109
+ static async getModelRoots(modelType) {
110
+ const q = encodeURIComponent(modelType || 'checkpoint');
111
+ return await this._request(`/civitai/model_roots?type=${q}`);
112
+ }
113
+
114
+ static async createModelRoot(modelType, absPath) {
115
+ return await this._request("/civitai/create_root", {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({ model_type: modelType, path: absPath }),
119
+ });
120
+ }
121
+
122
+ static async retryDownload(downloadId) {
123
+ return await this._request("/civitai/retry", {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify({ download_id: downloadId }),
127
+ });
128
+ }
129
+
130
+ static async openPath(downloadId) {
131
+ return await this._request("/civitai/open_path", {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify({ download_id: downloadId }),
135
+ });
136
+ }
137
+
138
+ static async clearHistory() {
139
+ return await this._request("/civitai/clear_history", {
140
+ method: "POST",
141
+ headers: { "Content-Type": "application/json" },
142
+ });
143
+ }
144
+ }
custom_nodes/Civicomfy/web/js/civitaiDownloader.css ADDED
@@ -0,0 +1,731 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .civitai-downloader-modal {
2
+ position: fixed;
3
+ z-index: 1001; /* Above ComfyUI elements */
4
+ left: 0;
5
+ top: 0;
6
+ width: 100%;
7
+ height: 100%;
8
+ overflow: hidden; /* Prevent body scroll */
9
+ background-color: rgba(0, 0, 0, 0.6);
10
+ display: flex;
11
+ justify-content: center;
12
+ align-items: center;
13
+ opacity: 0;
14
+ visibility: hidden;
15
+ transition: opacity 0.3s ease, visibility 0s linear 0.3s;
16
+ }
17
+
18
+ .civitai-downloader-modal.open {
19
+ opacity: 1;
20
+ visibility: visible;
21
+ transition: opacity 0.3s ease, visibility 0s linear 0s;
22
+ }
23
+
24
+ .civitai-downloader-modal-content {
25
+ background-color: var(--comfy-menu-bg);
26
+ color: var(--comfy-text-color);
27
+ margin: auto;
28
+ padding: 0; /* Remove padding, handle internally */
29
+ border-radius: 8px;
30
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
31
+ width: 900px;
32
+ max-width: 95%;
33
+ height: 700px; /* Fixed height */
34
+ max-height: 90vh;
35
+ display: flex; /* Use flexbox for layout */
36
+ flex-direction: column; /* Stack header, tabs, content */
37
+ overflow: hidden; /* Prevent content overflow */
38
+ }
39
+
40
+ .civitai-downloader-header {
41
+ display: flex;
42
+ justify-content: space-between;
43
+ align-items: center;
44
+ padding: 15px 20px;
45
+ border-bottom: 1px solid var(--border-color, #444);
46
+ flex-shrink: 0; /* Prevent header from shrinking */
47
+ }
48
+
49
+ .civitai-downloader-header h2 {
50
+ margin: 0;
51
+ font-size: 1.3em;
52
+ }
53
+
54
+ .civitai-close-button {
55
+ background: none;
56
+ border: none;
57
+ color: var(--comfy-text-color);
58
+ font-size: 28px;
59
+ cursor: pointer;
60
+ padding: 0 5px;
61
+ line-height: 1;
62
+ }
63
+ .civitai-close-button:hover {
64
+ color: #aaa;
65
+ }
66
+
67
+ .civitai-downloader-body {
68
+ display: flex;
69
+ flex-direction: column;
70
+ flex-grow: 1; /* Allow body to take remaining space */
71
+ overflow: hidden; /* Manage internal scrolling */
72
+ }
73
+
74
+ .civitai-downloader-tabs {
75
+ display: flex;
76
+ border-bottom: 1px solid var(--border-color, #444);
77
+ padding: 0 15px;
78
+ flex-shrink: 0; /* Prevent tabs from shrinking */
79
+ }
80
+
81
+ .civitai-downloader-tab {
82
+ padding: 10px 18px;
83
+ cursor: pointer;
84
+ border: none;
85
+ background: none;
86
+ color: var(--comfy-text-color);
87
+ opacity: 0.7;
88
+ position: relative;
89
+ top: 1px; /* Align with bottom border */
90
+ margin-bottom: -1px; /* Overlap border */
91
+ }
92
+
93
+ .civitai-downloader-tab.active {
94
+ opacity: 1;
95
+ border-bottom: 3px solid var(--accent-color, #5c8aff);
96
+ font-weight: bold;
97
+ }
98
+ .civitai-downloader-tab:hover {
99
+ opacity: 1;
100
+ }
101
+
102
+ .civitai-downloader-tab-content {
103
+ display: none;
104
+ padding: 20px;
105
+ flex-grow: 1; /* Allow content to take space */
106
+ overflow-y: auto; /* Enable scrolling ONLY for the content area */
107
+ }
108
+
109
+ .civitai-downloader-tab-content.active {
110
+ display: block; /* Show active tab */
111
+ }
112
+
113
+ .civitai-form-group {
114
+ margin-bottom: 18px;
115
+ }
116
+ .civitai-form-group:last-child {
117
+ margin-bottom: 0;
118
+ }
119
+
120
+ .civitai-form-group label {
121
+ display: block;
122
+ margin-bottom: 6px;
123
+ font-weight: bold;
124
+ font-size: 0.95em;
125
+ }
126
+
127
+ .civitai-input,
128
+ .civitai-select {
129
+ width: 100%;
130
+ padding: 10px;
131
+ background-color: var(--comfy-input-bg);
132
+ color: var(--comfy-text-color);
133
+ border: 1px solid var(--border-color, #555);
134
+ border-radius: 4px;
135
+ box-sizing: border-box; /* Include padding/border in width */
136
+ }
137
+ .civitai-input:focus,
138
+ .civitai-select:focus {
139
+ outline: none;
140
+ border-color: var(--accent-color, #5c8aff);
141
+ box-shadow: 0 0 0 2px rgba(92, 138, 255, 0.3);
142
+ }
143
+
144
+ .civitai-form-row {
145
+ display: flex;
146
+ gap: 15px;
147
+ flex-wrap: wrap; /* Allow wrapping on smaller screens */
148
+ }
149
+ .civitai-form-row > .civitai-form-group {
150
+ flex: 1; /* Distribute space */
151
+ min-width: 180px; /* Minimum width before wrapping */
152
+ }
153
+ /* Adjust for checkbox/inline elements */
154
+ .civitai-form-group.inline {
155
+ display: flex;
156
+ align-items: center;
157
+ gap: 8px;
158
+ margin-bottom: 10px; /* Less margin for checkboxes */
159
+ }
160
+ .civitai-form-group.inline label {
161
+ margin-bottom: 0; /* Remove default bottom margin */
162
+ order: 1; /* Put label after checkbox */
163
+ }
164
+ .civitai-checkbox {
165
+ width: auto;
166
+ order: 0; /* Put checkbox before label */
167
+ accent-color: var(--accent-color, #5c8aff);
168
+ transform: scale(1.1);
169
+ }
170
+
171
+ .civitai-button {
172
+ background-color: var(--comfy-input-bg);
173
+ color: var(--comfy-text-color);
174
+ border: 1px solid var(--border-color, #555);
175
+ padding: 10px 18px;
176
+ border-radius: 4px;
177
+ cursor: pointer;
178
+ font-size: 1em;
179
+ transition: background-color 0.2s ease, border-color 0.2s ease;
180
+ }
181
+ .civitai-button:hover {
182
+ background-color: var(--comfy-input-bg-hover);
183
+ border-color: #777;
184
+ }
185
+ .civitai-button:disabled {
186
+ opacity: 0.5;
187
+ cursor: not-allowed;
188
+ }
189
+
190
+ .civitai-button.primary {
191
+ background-color: var(--accent-color, #5c8aff);
192
+ border-color: var(--accent-color, #5c8aff);
193
+ color: white;
194
+ }
195
+ .civitai-button.primary:hover {
196
+ background-color: #4a7ee0; /* Slightly darker blue */
197
+ border-color: #4a7ee0;
198
+ }
199
+ .civitai-button.primary:disabled {
200
+ background-color: var(--accent-color, #5c8aff); /* Keep color but lower opacity */
201
+ border-color: var(--accent-color, #5c8aff);
202
+ }
203
+
204
+ .civitai-button.danger {
205
+ background-color: #e64b4b;
206
+ border-color: #e64b4b;
207
+ color: white;
208
+ }
209
+ .civitai-button.danger:hover {
210
+ background-color: #d93a3a;
211
+ border-color: #d93a3a;
212
+ }
213
+ .civitai-button.small {
214
+ padding: 5px 10px;
215
+ font-size: 0.85em;
216
+ }
217
+
218
+ /* Status Tab Styling */
219
+ .civitai-status-section {
220
+ margin-bottom: 25px;
221
+ }
222
+ .civitai-status-section h3 {
223
+ margin-top: 0;
224
+ margin-bottom: 15px;
225
+ border-bottom: 1px solid var(--border-color, #444);
226
+ padding-bottom: 8px;
227
+ font-size: 1.1em;
228
+ }
229
+
230
+ .civitai-download-list {
231
+ display: flex;
232
+ flex-direction: column;
233
+ gap: 15px;
234
+ }
235
+
236
+ .civitai-download-item {
237
+ display: flex;
238
+ gap: 15px;
239
+ align-items: flex-start; /* Align items top */
240
+ padding: 15px;
241
+ border-radius: 6px;
242
+ background-color: var(--comfy-input-bg); /* Slightly different bg for items */
243
+ border: 1px solid var(--border-color, #555);
244
+ }
245
+
246
+ .civitai-download-thumbnail {
247
+ width: 80px;
248
+ height: 80px;
249
+ object-fit: cover;
250
+ border-radius: 4px;
251
+ background-color: #333; /* Placeholder color */
252
+ flex-shrink: 0; /* Don't shrink thumbnail */
253
+ margin-top: 3px; /* Align better with text */
254
+ }
255
+
256
+ .civitai-download-info {
257
+ flex-grow: 1; /* Allow info to take available space */
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 5px;
261
+ overflow: hidden; /* Prevent long text overflow */
262
+ }
263
+ .civitai-download-info strong {
264
+ font-size: 1.05em;
265
+ white-space: nowrap;
266
+ overflow: hidden;
267
+ text-overflow: ellipsis;
268
+ }
269
+ .civitai-download-info p {
270
+ margin: 0;
271
+ font-size: 0.9em;
272
+ color: #ccc;
273
+ white-space: nowrap;
274
+ overflow: hidden;
275
+ text-overflow: ellipsis;
276
+ }
277
+ .civitai-download-info .filename {
278
+ font-style: italic;
279
+ color: #bbb;
280
+ }
281
+ .civitai-download-info .error-message {
282
+ color: #ff6b6b;
283
+ font-size: 0.85em;
284
+ white-space: normal; /* Allow error message to wrap */
285
+ word-break: break-word;
286
+ }
287
+
288
+ .civitai-download-actions {
289
+ display: flex;
290
+ align-items: center;
291
+ gap: 8px;
292
+ margin-left: auto; /* Push actions to the right */
293
+ flex-shrink: 0; /* Don't shrink action buttons */
294
+ }
295
+
296
+ .civitai-progress-container {
297
+ margin-top: 8px;
298
+ width: 100%;
299
+ background-color: var(--comfy-menu-bg); /* Darker background for contrast */
300
+ border-radius: 4px;
301
+ overflow: hidden;
302
+ height: 20px;
303
+ border: 1px solid var(--border-color, #555);
304
+ }
305
+
306
+ .civitai-progress-bar {
307
+ height: 100%;
308
+ background-color: var(--accent-color, #5c8aff);
309
+ text-align: center;
310
+ color: white;
311
+ line-height: 20px; /* Center text vertically */
312
+ font-size: 12px;
313
+ font-weight: bold;
314
+ transition: width 0.3s ease-out;
315
+ min-width: 20px; /* Show something even for 0% */
316
+ width: 0%; /* Start at 0 */
317
+ white-space: nowrap;
318
+ overflow: hidden;
319
+ }
320
+ .civitai-progress-bar.completed {
321
+ background-color: #4caf50; /* Green for completed */
322
+ }
323
+ .civitai-progress-bar.failed {
324
+ background-color: #f44336; /* Red for failed */
325
+ }
326
+ .civitai-progress-bar.cancelled {
327
+ background-color: #9e9e9e; /* Grey for cancelled */
328
+ }
329
+
330
+ .civitai-speed-indicator {
331
+ font-size: 0.85em;
332
+ color: #ccc;
333
+ margin-top: 4px;
334
+ text-align: right;
335
+ }
336
+
337
+ /* Search Tab Styling */
338
+ .civitai-search-controls {
339
+ display: flex;
340
+ gap: 15px;
341
+ margin-bottom: 20px;
342
+ flex-wrap: wrap;
343
+ }
344
+ .civitai-search-controls .civitai-input {
345
+ flex-grow: 1; /* Let search input take more space */
346
+ }
347
+ .civitai-search-controls .civitai-select {
348
+ min-width: 150px; /* Min width for dropdowns */
349
+ }
350
+ .civitai-search-controls .civitai-button {
351
+ align-self: flex-end; /* Align button with bottom of inputs */
352
+ }
353
+
354
+ .civitai-search-results {
355
+ margin-top: 20px;
356
+ }
357
+ .civitai-search-item {
358
+ display: flex;
359
+ gap: 15px;
360
+ align-items: flex-start;
361
+ margin-bottom: 15px;
362
+ padding: 15px;
363
+ border-radius: 6px;
364
+ background-color: var(--comfy-input-bg);
365
+ border: 1px solid var(--border-color, #555);
366
+ transition: background-color 0.2s ease;
367
+ }
368
+
369
+ .civitai-thumbnail-container {
370
+ position: relative; /* Required for absolute positioning of the child badge */
371
+ display: block;
372
+ line-height: 0;
373
+ width: 120px;
374
+ height: 170px;
375
+ flex-shrink: 0;
376
+ }
377
+
378
+ .civitai-search-thumbnail {
379
+ width: 120px; /* Slightly larger for search */
380
+ height: 170px;
381
+ object-fit: cover;
382
+ border-radius: 4px;
383
+ background-color: #333;
384
+ flex-shrink: 0;
385
+ }
386
+
387
+ .civitai-search-meta-info {
388
+ font-size: 0.85em;
389
+ color: #aaa;
390
+ margin-bottom: 5px;
391
+ display: flex;
392
+ gap: 15px;
393
+ flex-wrap: wrap;
394
+ }
395
+
396
+ .civitai-search-meta-info span {
397
+ display: inline-flex;
398
+ align-items: center;
399
+ gap: 5px;
400
+ }
401
+
402
+ .civitai-search-meta-info i {
403
+ color: #ccc;
404
+ }
405
+ .civitai-type-badge {
406
+ position: absolute;
407
+ bottom: 5px; /* Adjust spacing from bottom */
408
+ left: 5px; /* Adjust spacing from left */
409
+ background-color: rgb(48, 94, 70); /* Black background with transparency */
410
+ color: white;
411
+ padding: 10px 8px; /* Adjust padding (top/bottom, left/right) */
412
+ border-radius: 10px; /* Adjust for desired roundness */
413
+ font-size: 0.75em; /* Adjust font size */
414
+ font-weight: bold;
415
+ z-index: 2; /* Ensure it's above the image */
416
+ white-space: nowrap; /* Prevent text wrapping */
417
+ }
418
+
419
+ .civitai-search-info {
420
+ flex-grow: 1;
421
+ display: flex;
422
+ flex-direction: column;
423
+ gap: 5px;
424
+ overflow: hidden;
425
+ }
426
+ .civitai-search-info h4 {
427
+ margin: 0;
428
+ font-size: 1.1em;
429
+ white-space: nowrap;
430
+ overflow: hidden;
431
+ text-overflow: ellipsis;
432
+ }
433
+ .civitai-search-info p {
434
+ margin: 0;
435
+ font-size: 0.9em;
436
+ color: #ccc;
437
+ }
438
+ .civitai-search-tags {
439
+ display: flex;
440
+ flex-wrap: wrap;
441
+ gap: 5px;
442
+ margin-top: 8px;
443
+ }
444
+ .civitai-search-tag {
445
+ background-color: rgba(255, 255, 255, 0.1);
446
+ color: #eee;
447
+ padding: 3px 8px;
448
+ border-radius: 10px;
449
+ font-size: 0.8em;
450
+ }
451
+ .civitai-search-stats span {
452
+ margin-right: 15px;
453
+ font-size: 0.85em;
454
+ color: #bbb;
455
+ }
456
+ .civitai-search-stats i { /* If using icons */
457
+ margin-right: 4px;
458
+ }
459
+
460
+ .civitai-search-actions {
461
+ display: flex;
462
+ flex-direction: column; /* Stack buttons vertically */
463
+ gap: 8px;
464
+ align-items: flex-end; /* Align buttons to the right */
465
+ margin-left: auto;
466
+ flex-shrink: 0;
467
+ }
468
+
469
+ .version-buttons-container {
470
+ display: flex;
471
+ flex-direction: column;
472
+ align-items: flex-end;
473
+ gap: 5px;
474
+ }
475
+
476
+ .all-versions-container {
477
+ display: flex;
478
+ flex-direction: column;
479
+ gap: 5px;
480
+ padding-bottom: 5px;
481
+ white-space: nowrap; /* Prevent wrapping */
482
+ max-width: 100%; /* Prevent excessive width */
483
+ align-items: flex-end;
484
+ }
485
+
486
+ .show-all-versions-button {
487
+ align-self: flex-end; /* Align button to left */
488
+ margin-top: auto; /* Push to the bottom */
489
+ }
490
+ .base-model-badge {
491
+ display: inline-block; /* Allows padding and background */
492
+ background-color: #3a3a3a; /* Or a specific purple hex code like #800080 */
493
+ color: white;
494
+ font-weight: bold;
495
+ padding: 2px 6px; /* Adjust padding for desired size (vertical horizontal) */
496
+ border-radius: 3px; /* Adjust for desired roundness */
497
+ margin-right: 4px; /* Optional: Adds some space between badge and hyphen */
498
+ line-height: 1; /* Optional: Can help contain the background better */
499
+ vertical-align: middle; /* Optional: Helps align the badge nicely with text */
500
+ }
501
+
502
+
503
+ /* Settings Tab Styling */
504
+ .civitai-settings-container {
505
+ display: grid;
506
+ grid-template-columns: 1fr; /* Single column */
507
+ gap: 20px;
508
+ }
509
+
510
+ @media (min-width: 768px) {
511
+ .civitai-settings-container {
512
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); /* Responsive columns */
513
+ }
514
+ }
515
+
516
+ .civitai-settings-section {
517
+ background-color: var(--comfy-input-bg);
518
+ padding: 15px;
519
+ border-radius: 6px;
520
+ border: 1px solid var(--border-color, #555);
521
+ }
522
+ .civitai-settings-section h4 {
523
+ margin-top: 0;
524
+ margin-bottom: 15px;
525
+ border-bottom: 1px solid var(--border-color, #444);
526
+ padding-bottom: 8px;
527
+ }
528
+
529
+ .civitai-toast {
530
+ position: fixed;
531
+ bottom: 20px;
532
+ left: 50%;
533
+ transform: translateX(-50%);
534
+ background-color: rgba(0, 0, 0, 0.8);
535
+ color: white;
536
+ padding: 12px 20px;
537
+ border-radius: 6px;
538
+ z-index: 1005; /* Above modal */
539
+ font-size: 0.95em;
540
+ opacity: 0;
541
+ transition: opacity 0.3s ease, bottom 0.3s ease;
542
+ pointer-events: none; /* Don't block clicks */
543
+ }
544
+
545
+ .civitai-toast.show {
546
+ opacity: 1;
547
+ bottom: 30px;
548
+ }
549
+
550
+ .civitai-toast.success {
551
+ background-color: rgba(76, 175, 80, 0.9); /* Green */
552
+ }
553
+
554
+ .civitai-toast.error {
555
+ background-color: rgba(244, 67, 54, 0.9); /* Red */
556
+ }
557
+
558
+ .civitai-toast.info {
559
+ background-color: rgba(33, 150, 243, 0.9); /* Blue */
560
+ }
561
+
562
+ /* Tooltip Styles */
563
+ [data-tooltip] {
564
+ position: relative;
565
+ cursor: help;
566
+ }
567
+ [data-tooltip]::after {
568
+ content: attr(data-tooltip);
569
+ position: absolute;
570
+ left: 50%;
571
+ transform: translateX(-50%);
572
+ bottom: 100%;
573
+ margin-bottom: 5px;
574
+ background-color: rgba(0, 0, 0, 0.85);
575
+ color: white;
576
+ padding: 5px 10px;
577
+ border-radius: 4px;
578
+ font-size: 0.85em;
579
+ white-space: nowrap;
580
+ opacity: 0;
581
+ visibility: hidden;
582
+ transition: opacity 0.2s ease, visibility 0.2s ease;
583
+ z-index: 1010; /* Ensure tooltip is on top */
584
+ }
585
+ [data-tooltip]:hover::after {
586
+ opacity: 1;
587
+ visibility: visible;
588
+ }
589
+
590
+ .civitai-download-preview-area {
591
+ /* Add specific margins/padding if needed */
592
+ min-height: 50px; /* Ensure it has some height for the loading spinner */
593
+ }
594
+
595
+ /* Style for the description box */
596
+ .model-description-content {
597
+ max-height: 200px; /* Limit height */
598
+ overflow-y: auto; /* Allow scrolling */
599
+ background-color: var(--comfy-input-bg); /* Match input background */
600
+ padding: 10px;
601
+ border-radius: 4px;
602
+ font-size: 0.9em; /* Slightly smaller text */
603
+ border: 1px solid var(--border-color, #555);
604
+ color: var(--comfy-text-color); /* Ensure text color matches */
605
+ line-height: 1.5; /* Improve readability */
606
+ }
607
+
608
+ /* Style links within the description */
609
+ .model-description-content a {
610
+ color: var(--accent-color, #5c8aff); /* Make links stand out */
611
+ text-decoration: underline;
612
+ }
613
+ .model-description-content a:hover {
614
+ color: #8bb0ff; /* Lighter color on hover */
615
+ }
616
+
617
+ /* Ensure reused search item styles look okay in this context */
618
+ .civitai-download-preview-area .civitai-search-item {
619
+ margin-bottom: 15px; /* Add space below the item */
620
+ background-color: var(--comfy-input-bg); /* Explicitly set background */
621
+ }
622
+
623
+ /* --- Confirmation Modal Styles --- */
624
+ .civitai-confirmation-modal {
625
+ display: none; /* Hidden by default */
626
+ position: fixed; /* Stay in place */
627
+ z-index: 1001; /* Ensure it's above the main modal */
628
+ left: 0;
629
+ top: 0;
630
+ width: 100%;
631
+ height: 100%;
632
+ overflow: auto; /* Enable scroll if needed */
633
+ background-color: rgba(0,0,0,0.6); /* Dim background */
634
+ /* Use flexbox for easy centering */
635
+ justify-content: center;
636
+ align-items: center;
637
+ }
638
+
639
+ .civitai-confirmation-modal-content {
640
+ background-color: var(--comfy-menu-bg, #282828);
641
+ margin: auto;
642
+ padding: 25px;
643
+ border: 1px solid var(--border-color, #444);
644
+ border-radius: 8px;
645
+ width: 80%;
646
+ max-width: 450px; /* Limit width */
647
+ box-shadow: 0 5px 15px rgba(0,0,0,0.3);
648
+ color: var(--input-text, #ddd);
649
+ }
650
+
651
+ .civitai-confirmation-modal-content h4 {
652
+ margin-top: 0;
653
+ margin-bottom: 15px;
654
+ color: var(--desc-text, #eee);
655
+ border-bottom: 1px solid var(--border-color, #444);
656
+ padding-bottom: 10px;
657
+ }
658
+
659
+ .civitai-confirmation-modal-content p {
660
+ margin-bottom: 25px;
661
+ line-height: 1.5;
662
+ font-size: 0.95em;
663
+ }
664
+
665
+ .civitai-confirmation-modal-actions {
666
+ display: flex;
667
+ justify-content: flex-end; /* Align buttons to the right */
668
+ gap: 10px; /* Space between buttons */
669
+ }
670
+
671
+ /* Style buttons within the confirmation modal */
672
+ .civitai-confirmation-modal-actions .civitai-button {
673
+ padding: 8px 15px; /* Adjust padding */
674
+ min-width: 80px; /* Ensure minimum width */
675
+ }
676
+
677
+ /* Ensure both img and video within the thumbnail container behave similarly */
678
+ .civitai-thumbnail-container img.civitai-search-thumbnail,
679
+ .civitai-thumbnail-container video.civitai-search-thumbnail,
680
+ .civitai-thumbnail-container img.civitai-download-thumbnail,
681
+ .civitai-thumbnail-container video.civitai-download-thumbnail {
682
+ display: block; /* Prevent extra space below */
683
+ width: 100%; /* Fill the container width */
684
+ height: 100%; /* Fill the container height */
685
+ object-fit: cover; /* Scale while maintaining aspect ratio, cropping if needed */
686
+ background-color: #333; /* Background for loading/error states */
687
+ }
688
+
689
+ /* You might already have rules for the container itself, ensure they define a size */
690
+ .civitai-thumbnail-container {
691
+ width: 100px; /* Example size */
692
+ height: 140px; /* Example size */
693
+ overflow: hidden; /* Hide parts of the video/image outside the cover area */
694
+ position: relative; /* For positioning badges etc. */
695
+ flex-shrink: 0; /* Prevent shrinking in flex layouts */
696
+ border-radius: 4px; /* Match styling */
697
+ }
698
+
699
+ /* Blur/Hide for R-rated thumbnails */
700
+ .civitai-thumbnail-container.blurred img.civitai-search-thumbnail,
701
+ .civitai-thumbnail-container.blurred video.civitai-search-thumbnail,
702
+ .civitai-thumbnail-container.blurred img.civitai-download-thumbnail,
703
+ .civitai-thumbnail-container.blurred video.civitai-download-thumbnail {
704
+ filter: blur(14px) brightness(0.6) saturate(0.6);
705
+ pointer-events: none; /* Let clicks hit the container */
706
+ }
707
+ .civitai-thumbnail-container .civitai-nsfw-overlay {
708
+ position: absolute;
709
+ inset: 0;
710
+ display: flex;
711
+ align-items: center;
712
+ justify-content: center;
713
+ color: #fff;
714
+ font-weight: 800;
715
+ font-size: 20px;
716
+ letter-spacing: 1px;
717
+ background: rgba(0,0,0,0.35);
718
+ z-index: 3;
719
+ user-select: none;
720
+ }
721
+ /* Make it obvious it’s clickable */
722
+ .civitai-thumbnail-container.blurred { cursor: pointer; }
723
+
724
+ /* Style for the type badge (likely exists) */
725
+ .civitai-type-badge {
726
+ /* ... existing styles ... */
727
+ position: absolute;
728
+ bottom: 5px;
729
+ right: 5px;
730
+ /* ... */
731
+ }
custom_nodes/Civicomfy/web/js/civitaiDownloader.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { app } from "../../../scripts/app.js";
2
+ import { addCssLink } from "./utils/dom.js";
3
+ import { CivitaiDownloaderUI } from "./ui/UI.js";
4
+
5
+ console.log("Loading Civicomfy UI...");
6
+
7
+ // --- Configuration ---
8
+ const EXTENSION_NAME = "Civicomfy";
9
+ const CSS_URL = `../civitaiDownloader.css`;
10
+ const PLACEHOLDER_IMAGE_URL = `/extensions/Civicomfy/images/placeholder.jpeg`;
11
+
12
+ // Add Menu Button to ComfyUI
13
+ function addMenuButton() {
14
+ const buttonGroup = document.querySelector(".comfyui-button-group");
15
+
16
+ if (!buttonGroup) {
17
+ console.warn(`[${EXTENSION_NAME}] ComfyUI button group not found. Retrying...`);
18
+ setTimeout(addMenuButton, 500);
19
+ return;
20
+ }
21
+
22
+ if (document.getElementById("civitai-downloader-button")) {
23
+ console.log(`[${EXTENSION_NAME}] Button already exists.`);
24
+ return;
25
+ }
26
+
27
+ const civitaiButton = document.createElement("button");
28
+ civitaiButton.textContent = "Civicomfy";
29
+ civitaiButton.id = "civitai-downloader-button";
30
+ civitaiButton.title = "Open Civicomfy";
31
+
32
+ civitaiButton.onclick = async () => {
33
+ if (!window.civitaiDownloaderUI) {
34
+ console.info(`[${EXTENSION_NAME}] Creating CivitaiDownloaderUI instance...`);
35
+ window.civitaiDownloaderUI = new CivitaiDownloaderUI();
36
+ document.body.appendChild(window.civitaiDownloaderUI.modal);
37
+
38
+ try {
39
+ await window.civitaiDownloaderUI.initializeUI();
40
+ console.info(`[${EXTENSION_NAME}] UI Initialization complete.`);
41
+ } catch (error) {
42
+ console.error(`[${EXTENSION_NAME}] Error during UI initialization:`, error);
43
+ window.civitaiDownloaderUI?.showToast("Error initializing UI components. Check console.", "error", 5000);
44
+ }
45
+ }
46
+
47
+ if (window.civitaiDownloaderUI) {
48
+ window.civitaiDownloaderUI.openModal();
49
+ } else {
50
+ console.error(`[${EXTENSION_NAME}] Cannot open modal: UI instance not available.`);
51
+ alert("Civicomfy failed to initialize. Please check the browser console for errors.");
52
+ }
53
+ };
54
+
55
+ buttonGroup.appendChild(civitaiButton);
56
+ console.log(`[${EXTENSION_NAME}] Civicomfy button added to .comfyui-button-group.`);
57
+
58
+ const menu = document.querySelector(".comfy-menu");
59
+ if (!buttonGroup.contains(civitaiButton) && menu && !menu.contains(civitaiButton)) {
60
+ console.warn(`[${EXTENSION_NAME}] Failed to append button to group, falling back to menu.`);
61
+ const settingsButton = menu.querySelector("#comfy-settings-button");
62
+ if (settingsButton) {
63
+ settingsButton.insertAdjacentElement("beforebegin", civitaiButton);
64
+ } else {
65
+ menu.appendChild(civitaiButton);
66
+ }
67
+ }
68
+ }
69
+
70
+ // --- Initialization ---
71
+ app.registerExtension({
72
+ name: "Civicomfy.CivitaiDownloader",
73
+ async setup(appInstance) {
74
+ console.log(`[${EXTENSION_NAME}] Setting up Civicomfy Extension...`);
75
+ addCssLink(CSS_URL);
76
+ addMenuButton();
77
+
78
+ // Optional: Pre-check placeholder image
79
+ fetch(PLACEHOLDER_IMAGE_URL)
80
+ .then(res => {
81
+ if (!res.ok) {
82
+ console.warn(`[${EXTENSION_NAME}] Placeholder image not found at ${PLACEHOLDER_IMAGE_URL}.`);
83
+ }
84
+ })
85
+ .catch(err => console.warn(`[${EXTENSION_NAME}] Error checking for placeholder image:`, err));
86
+
87
+ console.log(`[${EXTENSION_NAME}] Extension setup complete. UI will initialize on first click.`);
88
+ },
89
+ });
custom_nodes/Civicomfy/web/js/ui/UI.js ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Feedback } from "./feedback.js";
2
+ import { setupEventListeners } from "./handlers/eventListeners.js";
3
+ import { handleDownloadSubmit, fetchAndDisplayDownloadPreview, debounceFetchDownloadPreview } from "./handlers/downloadHandler.js";
4
+ import { handleSearchSubmit } from "./handlers/searchHandler.js";
5
+ import { handleSettingsSave, loadAndApplySettings, loadSettingsFromCookie, saveSettingsToCookie, applySettings, getDefaultSettings } from "./handlers/settingsHandler.js";
6
+ import { startStatusUpdates, stopStatusUpdates, updateStatus, handleCancelDownload, handleRetryDownload, handleOpenPath, handleClearHistory } from "./handlers/statusHandler.js";
7
+ import { renderSearchResults } from "./searchRenderer.js";
8
+ import { renderDownloadList } from "./statusRenderer.js";
9
+ import { renderDownloadPreview } from "./previewRenderer.js";
10
+ import { modalTemplate } from "./templates.js";
11
+ import { CivitaiDownloaderAPI } from "../api/civitai.js";
12
+
13
+ export class CivitaiDownloaderUI {
14
+ constructor() {
15
+ this.modal = null;
16
+ this.tabs = {};
17
+ this.tabContents = {};
18
+ this.activeTab = 'download';
19
+ this.modelTypes = {};
20
+ this.statusInterval = null;
21
+ this.statusData = { queue: [], active: [], history: [] };
22
+ this.baseModels = [];
23
+ this.searchPagination = { currentPage: 1, totalPages: 1, limit: 20 };
24
+ this.settings = this.getDefaultSettings();
25
+ this.toastTimeout = null;
26
+ this.modelPreviewDebounceTimeout = null;
27
+
28
+ this.updateStatus();
29
+ this.buildModalHTML();
30
+ this.cacheDOMElements();
31
+ this.setupEventListeners();
32
+ this.feedback = new Feedback(this.modal.querySelector('#civitai-toast'));
33
+ // Ensure icon stylesheet is loaded so buttons render icons immediately
34
+ this.ensureFontAwesome();
35
+ }
36
+
37
+ // --- Core UI Methods ---
38
+ buildModalHTML() {
39
+ this.modal = document.createElement('div');
40
+ this.modal.className = 'civitai-downloader-modal';
41
+ this.modal.id = 'civitai-downloader-modal';
42
+ this.modal.innerHTML = modalTemplate(this.settings);
43
+ }
44
+
45
+ cacheDOMElements() {
46
+ this.closeButton = this.modal.querySelector('#civitai-close-modal');
47
+ this.tabContainer = this.modal.querySelector('.civitai-downloader-tabs');
48
+
49
+ // Download Tab
50
+ this.downloadForm = this.modal.querySelector('#civitai-download-form');
51
+ this.downloadPreviewArea = this.modal.querySelector('#civitai-download-preview-area');
52
+ this.modelUrlInput = this.modal.querySelector('#civitai-model-url');
53
+ this.modelVersionIdInput = this.modal.querySelector('#civitai-model-version-id');
54
+ this.downloadModelTypeSelect = this.modal.querySelector('#civitai-model-type');
55
+ this.createModelTypeButton = this.modal.querySelector('#civitai-create-model-type');
56
+ this.customFilenameInput = this.modal.querySelector('#civitai-custom-filename');
57
+ this.subdirSelect = this.modal.querySelector('#civitai-subdir-select');
58
+ this.createSubdirButton = this.modal.querySelector('#civitai-create-subdir');
59
+ this.downloadConnectionsInput = this.modal.querySelector('#civitai-connections');
60
+ this.forceRedownloadCheckbox = this.modal.querySelector('#civitai-force-redownload');
61
+ this.downloadSubmitButton = this.modal.querySelector('#civitai-download-submit');
62
+
63
+ // Search Tab
64
+ this.searchForm = this.modal.querySelector('#civitai-search-form');
65
+ this.searchQueryInput = this.modal.querySelector('#civitai-search-query');
66
+ this.searchTypeSelect = this.modal.querySelector('#civitai-search-type');
67
+ this.searchBaseModelSelect = this.modal.querySelector('#civitai-search-base-model');
68
+ this.searchSortSelect = this.modal.querySelector('#civitai-search-sort');
69
+ this.searchPeriodSelect = this.modal.querySelector('#civitai-search-period');
70
+ this.searchSubmitButton = this.modal.querySelector('#civitai-search-submit');
71
+ this.searchResultsContainer = this.modal.querySelector('#civitai-search-results');
72
+ this.searchPaginationContainer = this.modal.querySelector('#civitai-search-pagination');
73
+
74
+ // Status Tab
75
+ this.statusContent = this.modal.querySelector('#civitai-status-content');
76
+ this.activeListContainer = this.modal.querySelector('#civitai-active-list');
77
+ this.queuedListContainer = this.modal.querySelector('#civitai-queued-list');
78
+ this.historyListContainer = this.modal.querySelector('#civitai-history-list');
79
+ this.statusIndicator = this.modal.querySelector('#civitai-status-indicator');
80
+ this.activeCountSpan = this.modal.querySelector('#civitai-active-count');
81
+ this.clearHistoryButton = this.modal.querySelector('#civitai-clear-history-button');
82
+ this.confirmClearModal = this.modal.querySelector('#civitai-confirm-clear-modal');
83
+ this.confirmClearYesButton = this.modal.querySelector('#civitai-confirm-clear-yes');
84
+ this.confirmClearNoButton = this.modal.querySelector('#civitai-confirm-clear-no');
85
+
86
+ // Settings Tab
87
+ this.settingsForm = this.modal.querySelector('#civitai-settings-form');
88
+ this.settingsApiKeyInput = this.modal.querySelector('#civitai-settings-api-key');
89
+ this.settingsConnectionsInput = this.modal.querySelector('#civitai-settings-connections');
90
+ this.settingsDefaultTypeSelect = this.modal.querySelector('#civitai-settings-default-type');
91
+ this.settingsAutoOpenCheckbox = this.modal.querySelector('#civitai-settings-auto-open-status');
92
+ this.settingsHideMatureCheckbox = this.modal.querySelector('#civitai-settings-hide-mature');
93
+ this.settingsNsfwThresholdInput = this.modal.querySelector('#civitai-settings-nsfw-threshold');
94
+ this.settingsSaveButton = this.modal.querySelector('#civitai-settings-save');
95
+
96
+ // Toast Notification
97
+ this.toastElement = this.modal.querySelector('#civitai-toast');
98
+
99
+ // Collect tabs and contents
100
+ this.tabs = {};
101
+ this.modal.querySelectorAll('.civitai-downloader-tab').forEach(tab => {
102
+ this.tabs[tab.dataset.tab] = tab;
103
+ });
104
+ this.tabContents = {};
105
+ this.modal.querySelectorAll('.civitai-downloader-tab-content').forEach(content => {
106
+ const tabName = content.id.replace('civitai-tab-', '');
107
+ if (tabName) this.tabContents[tabName] = content;
108
+ });
109
+ }
110
+
111
+ async initializeUI() {
112
+ console.info("[Civicomfy] Initializing UI components...");
113
+ await this.populateModelTypes();
114
+ await this.populateBaseModels();
115
+ this.loadAndApplySettings();
116
+ }
117
+
118
+ async populateModelTypes() {
119
+ console.log("[Civicomfy] Populating model types...");
120
+ try {
121
+ const types = await CivitaiDownloaderAPI.getModelTypes();
122
+ if (!types || typeof types !== 'object' || Object.keys(types).length === 0) {
123
+ throw new Error("Received invalid model types data format.");
124
+ }
125
+ this.modelTypes = types;
126
+ const sortedTypes = Object.entries(this.modelTypes).sort((a, b) => a[1].localeCompare(b[1]));
127
+
128
+ this.downloadModelTypeSelect.innerHTML = '';
129
+ this.searchTypeSelect.innerHTML = '<option value="any">Any Type</option>';
130
+ this.settingsDefaultTypeSelect.innerHTML = '';
131
+
132
+ sortedTypes.forEach(([key, displayName]) => {
133
+ const option = document.createElement('option');
134
+ option.value = key;
135
+ option.textContent = displayName;
136
+ this.downloadModelTypeSelect.appendChild(option.cloneNode(true));
137
+ this.settingsDefaultTypeSelect.appendChild(option.cloneNode(true));
138
+ this.searchTypeSelect.appendChild(option.cloneNode(true));
139
+ });
140
+ // After types are populated, load subdirs for the current selection
141
+ await this.loadAndPopulateSubdirs(this.downloadModelTypeSelect.value);
142
+ } catch (error) {
143
+ console.error("[Civicomfy] Failed to get or populate model types:", error);
144
+ this.showToast('Failed to load model types', 'error');
145
+ this.downloadModelTypeSelect.innerHTML = '<option value="checkpoint">Checkpoint (Default)</option>';
146
+ this.modelTypes = { "checkpoint": "Checkpoint (Default)" };
147
+ }
148
+ }
149
+
150
+ async loadAndPopulateSubdirs(modelType) {
151
+ try {
152
+ const res = await CivitaiDownloaderAPI.getModelDirs(modelType);
153
+ const select = this.subdirSelect;
154
+ if (!select) return;
155
+ const current = select.value;
156
+ select.innerHTML = '';
157
+ const optRoot = document.createElement('option');
158
+ optRoot.value = '';
159
+ optRoot.textContent = '(root)';
160
+ select.appendChild(optRoot);
161
+ if (res && Array.isArray(res.subdirs)) {
162
+ // res.subdirs contains '' for root; skip empty since we added (root)
163
+ res.subdirs.filter(p => p && typeof p === 'string').forEach(rel => {
164
+ const opt = document.createElement('option');
165
+ opt.value = rel;
166
+ opt.textContent = rel;
167
+ select.appendChild(opt);
168
+ });
169
+ }
170
+ // Restore selection if still present
171
+ if (Array.from(select.options).some(o => o.value === current)) {
172
+ select.value = current;
173
+ }
174
+ } catch (e) {
175
+ console.error('[Civicomfy] Failed to load subdirectories:', e);
176
+ if (this.subdirSelect) {
177
+ this.subdirSelect.innerHTML = '<option value="">(root)</option>';
178
+ }
179
+ }
180
+ }
181
+
182
+ // (loadAndPopulateRoots removed; dynamic types already reflect models/ subfolders)
183
+
184
+ async populateBaseModels() {
185
+ console.log("[Civicomfy] Populating base models...");
186
+ try {
187
+ const result = await CivitaiDownloaderAPI.getBaseModels();
188
+ if (!result || !Array.isArray(result.base_models)) {
189
+ throw new Error("Invalid base models data format received.");
190
+ }
191
+ this.baseModels = result.base_models.sort();
192
+ const existingOptions = Array.from(this.searchBaseModelSelect.options);
193
+ existingOptions.slice(1).forEach(opt => opt.remove());
194
+ this.baseModels.forEach(baseModelName => {
195
+ const option = document.createElement('option');
196
+ option.value = baseModelName;
197
+ option.textContent = baseModelName;
198
+ this.searchBaseModelSelect.appendChild(option);
199
+ });
200
+ } catch (error) {
201
+ console.error("[Civicomfy] Failed to get or populate base models:", error);
202
+ this.showToast('Failed to load base models list', 'error');
203
+ }
204
+ }
205
+
206
+ switchTab(tabId) {
207
+ if (this.activeTab === tabId || !this.tabs[tabId] || !this.tabContents[tabId]) return;
208
+
209
+ this.tabs[this.activeTab]?.classList.remove('active');
210
+ this.tabContents[this.activeTab]?.classList.remove('active');
211
+
212
+ this.tabs[tabId].classList.add('active');
213
+ this.tabContents[tabId].classList.add('active');
214
+ this.tabContents[tabId].scrollTop = 0;
215
+ this.activeTab = tabId;
216
+
217
+ if (tabId === 'status') this.updateStatus();
218
+ else if (tabId === 'settings') this.applySettings();
219
+ else if(tabId === 'download') {
220
+ this.downloadConnectionsInput.value = this.settings.numConnections;
221
+ if (Object.keys(this.modelTypes).length > 0) {
222
+ this.downloadModelTypeSelect.value = this.settings.defaultModelType;
223
+ }
224
+ }
225
+ }
226
+
227
+ // --- Modal Control ---
228
+ openModal() {
229
+ this.modal?.classList.add('open');
230
+ document.body.style.setProperty('overflow', 'hidden', 'important');
231
+ this.startStatusUpdates();
232
+ if (this.activeTab === 'status') this.updateStatus();
233
+ if (!this.settings.apiKey) this.switchTab('settings');
234
+ }
235
+
236
+ closeModal() {
237
+ this.modal?.classList.remove('open');
238
+ document.body.style.removeProperty('overflow');
239
+ this.stopStatusUpdates();
240
+ }
241
+
242
+ // --- Utility Methods ---
243
+ formatBytes(bytes, decimals = 2) {
244
+ if (bytes === null || bytes === undefined || isNaN(bytes)) return 'N/A';
245
+ if (bytes === 0) return '0 Bytes';
246
+ const k = 1024;
247
+ const dm = decimals < 0 ? 0 : decimals;
248
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
249
+ const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k));
250
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
251
+ }
252
+
253
+ formatSpeed(bytesPerSecond) {
254
+ if (!isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '';
255
+ return this.formatBytes(bytesPerSecond) + '/s';
256
+ }
257
+
258
+ formatDuration(isoStart, isoEnd) {
259
+ try {
260
+ const diffSeconds = Math.round((new Date(isoEnd) - new Date(isoStart)) / 1000);
261
+ if (isNaN(diffSeconds) || diffSeconds < 0) return 'N/A';
262
+ if (diffSeconds < 60) return `${diffSeconds}s`;
263
+ const diffMinutes = Math.floor(diffSeconds / 60);
264
+ const remainingSeconds = diffSeconds % 60;
265
+ return `${diffMinutes}m ${remainingSeconds}s`;
266
+ } catch (e) {
267
+ return 'N/A';
268
+ }
269
+ }
270
+
271
+ showToast(message, type = 'info', duration = 3000) {
272
+ this.feedback?.show(message, type, duration);
273
+ }
274
+
275
+ ensureFontAwesome() {
276
+ this.feedback?.ensureFontAwesome();
277
+ }
278
+
279
+ // --- Rendering (delegated to external renderers) ---
280
+ renderDownloadList = (items, container, emptyMessage) => renderDownloadList(this, items, container, emptyMessage);
281
+ renderSearchResults = (items) => renderSearchResults(this, items);
282
+ renderDownloadPreview = (data) => renderDownloadPreview(this, data);
283
+
284
+ // --- Auto-select model type based on Civitai model type ---
285
+ inferFolderFromCivitaiType(civitaiType) {
286
+ if (!civitaiType || typeof civitaiType !== 'string') return null;
287
+ const t = civitaiType.trim().toLowerCase();
288
+ const keys = Object.keys(this.modelTypes || {});
289
+ if (keys.length === 0) return null;
290
+
291
+ const exists = (k) => keys.includes(k);
292
+ const findBy = (pred) => keys.find(pred);
293
+
294
+ // Direct matches first
295
+ if (exists(t)) return t;
296
+ if (exists(`${t}s`)) return `${t}s`;
297
+
298
+ // Common mappings from Civitai types to ComfyUI folders
299
+ const candidates = [];
300
+ const addIfExists = (k) => { if (exists(k)) candidates.push(k); };
301
+
302
+ switch (t) {
303
+ case 'checkpoint':
304
+ addIfExists('checkpoints');
305
+ addIfExists('models');
306
+ break;
307
+ case 'lora': case 'locon': case 'lycoris':
308
+ addIfExists('loras');
309
+ break;
310
+ case 'vae':
311
+ addIfExists('vae');
312
+ break;
313
+ case 'textualinversion': case 'embedding': case 'embeddings':
314
+ addIfExists('embeddings');
315
+ break;
316
+ case 'hypernetwork':
317
+ addIfExists('hypernetworks');
318
+ break;
319
+ case 'controlnet':
320
+ addIfExists('controlnet');
321
+ break;
322
+ case 'unet': case 'unet2':
323
+ addIfExists('unet');
324
+ break;
325
+ case 'diffusers': case 'diffusionmodels': case 'diffusion_models': case 'diffusion':
326
+ addIfExists('diffusers');
327
+ addIfExists('diffusion_models');
328
+ break;
329
+ case 'upscaler': case 'upscalers':
330
+ addIfExists('upscale_models');
331
+ addIfExists('upscalers');
332
+ break;
333
+ case 'motionmodule':
334
+ addIfExists('motion_models');
335
+ break;
336
+ case 'poses':
337
+ addIfExists('poses');
338
+ break;
339
+ case 'wildcards':
340
+ addIfExists('wildcards');
341
+ break;
342
+ case 'onnx':
343
+ addIfExists('onnx');
344
+ break;
345
+ }
346
+ if (candidates.length > 0) return candidates[0];
347
+
348
+ // Relaxed match: name contains type
349
+ const contains = findBy(k => k.toLowerCase().includes(t));
350
+ if (contains) return contains;
351
+
352
+ return null;
353
+ }
354
+
355
+ async autoSelectModelTypeFromCivitai(civitaiType) {
356
+ try {
357
+ const folder = this.inferFolderFromCivitaiType(civitaiType);
358
+ if (!folder) return;
359
+ if (this.downloadModelTypeSelect && this.downloadModelTypeSelect.value !== folder) {
360
+ this.downloadModelTypeSelect.value = folder;
361
+ await this.loadAndPopulateSubdirs(folder);
362
+ // Reset subdir to root after auto-switch
363
+ if (this.subdirSelect) this.subdirSelect.value = '';
364
+ }
365
+ } catch (e) {
366
+ console.warn('[Civicomfy] Auto-select model type failed:', e);
367
+ }
368
+ }
369
+
370
+ renderSearchPagination(metadata) {
371
+ if (!this.searchPaginationContainer) return;
372
+ if (!metadata || metadata.totalPages <= 1) {
373
+ this.searchPaginationContainer.innerHTML = '';
374
+ this.searchPagination = { ...this.searchPagination, ...metadata };
375
+ return;
376
+ }
377
+
378
+ this.searchPagination = { ...this.searchPagination, ...metadata };
379
+ const { currentPage, totalPages, totalItems } = this.searchPagination;
380
+
381
+ const createButton = (text, page, isDisabled = false, isCurrent = false) => {
382
+ const button = document.createElement('button');
383
+ button.className = `civitai-button small civitai-page-button ${isCurrent ? 'primary active' : ''}`;
384
+ button.dataset.page = page;
385
+ button.disabled = isDisabled;
386
+ button.innerHTML = text;
387
+ button.type = 'button';
388
+ return button;
389
+ };
390
+
391
+ const fragment = document.createDocumentFragment();
392
+ fragment.appendChild(createButton('&laquo; Prev', currentPage - 1, currentPage === 1));
393
+
394
+ let startPage = Math.max(1, currentPage - 2);
395
+ let endPage = Math.min(totalPages, currentPage + 2);
396
+
397
+ if (startPage > 1) fragment.appendChild(createButton('1', 1));
398
+ if (startPage > 2) fragment.appendChild(document.createElement('span')).textContent = '...';
399
+
400
+ for (let i = startPage; i <= endPage; i++) {
401
+ fragment.appendChild(createButton(i, i, false, i === currentPage));
402
+ }
403
+
404
+ if (endPage < totalPages - 1) fragment.appendChild(document.createElement('span')).textContent = '...';
405
+ if (endPage < totalPages) fragment.appendChild(createButton(totalPages, totalPages));
406
+
407
+ fragment.appendChild(createButton('Next &raquo;', currentPage + 1, currentPage === totalPages));
408
+
409
+ const info = document.createElement('div');
410
+ info.className = 'civitai-pagination-info';
411
+ info.textContent = `Page ${currentPage} of ${totalPages} (${totalItems.toLocaleString()} models)`;
412
+ fragment.appendChild(info);
413
+
414
+ this.searchPaginationContainer.innerHTML = '';
415
+ this.searchPaginationContainer.appendChild(fragment);
416
+ }
417
+
418
+ // --- Event Handlers and State Management (delegated to handlers) ---
419
+ setupEventListeners = () => setupEventListeners(this);
420
+ getDefaultSettings = () => getDefaultSettings();
421
+ loadAndApplySettings = () => loadAndApplySettings(this);
422
+ loadSettingsFromCookie = () => loadSettingsFromCookie(this);
423
+ saveSettingsToCookie = () => saveSettingsToCookie(this);
424
+ applySettings = () => applySettings(this);
425
+ handleSettingsSave = () => handleSettingsSave(this);
426
+ handleDownloadSubmit = () => handleDownloadSubmit(this);
427
+ handleSearchSubmit = () => handleSearchSubmit(this);
428
+ fetchAndDisplayDownloadPreview = () => fetchAndDisplayDownloadPreview(this);
429
+ debounceFetchDownloadPreview = (delay) => debounceFetchDownloadPreview(this, delay);
430
+ startStatusUpdates = () => startStatusUpdates(this);
431
+ stopStatusUpdates = () => stopStatusUpdates(this);
432
+ updateStatus = () => updateStatus(this);
433
+ handleCancelDownload = (downloadId) => handleCancelDownload(this, downloadId);
434
+ handleRetryDownload = (downloadId, button) => handleRetryDownload(this, downloadId, button);
435
+ handleOpenPath = (downloadId, button) => handleOpenPath(this, downloadId, button);
436
+ handleClearHistory = () => handleClearHistory(this);
437
+ }
custom_nodes/Civicomfy/web/js/ui/feedback.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Centralized feedback utilities: toasts and icon CSS
2
+
3
+ export class Feedback {
4
+ constructor(toastElement) {
5
+ this.toastElement = toastElement || null;
6
+ this.toastTimeout = null;
7
+ }
8
+
9
+ ensureFontAwesome() {
10
+ if (!document.getElementById('civitai-fontawesome-link')) {
11
+ const faLink = document.createElement('link');
12
+ faLink.id = 'civitai-fontawesome-link';
13
+ faLink.rel = 'stylesheet';
14
+ faLink.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css';
15
+ faLink.integrity = 'sha512-1ycn6IcaQQ40/MKBW2W4Rhis/DbILU74C1vSrLJxCq57o941Ym01SwNsOMqvEBFlcgUa6xLiPY/NS5R+E6ztJQ==';
16
+ faLink.crossOrigin = 'anonymous';
17
+ faLink.referrerPolicy = 'no-referrer';
18
+ document.head.appendChild(faLink);
19
+ }
20
+ }
21
+
22
+ show(message, type = 'info', duration = 3000) {
23
+ if (!this.toastElement) return;
24
+ if (this.toastTimeout) {
25
+ clearTimeout(this.toastTimeout);
26
+ this.toastTimeout = null;
27
+ }
28
+ const valid = ['info', 'success', 'error', 'warning'];
29
+ const toastType = valid.includes(type) ? type : 'info';
30
+
31
+ this.toastElement.textContent = message;
32
+ this.toastElement.className = 'civitai-toast';
33
+ this.toastElement.classList.add(toastType);
34
+ requestAnimationFrame(() => this.toastElement.classList.add('show'));
35
+ this.toastTimeout = setTimeout(() => {
36
+ this.toastElement.classList.remove('show');
37
+ this.toastTimeout = null;
38
+ }, duration);
39
+ }
40
+ }
41
+
custom_nodes/Civicomfy/web/js/ui/handlers/downloadHandler.js ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CivitaiDownloaderAPI } from "../../api/civitai.js";
2
+
3
+ export function debounceFetchDownloadPreview(ui, delay = 500) {
4
+ clearTimeout(ui.modelPreviewDebounceTimeout);
5
+ ui.modelPreviewDebounceTimeout = setTimeout(() => {
6
+ fetchAndDisplayDownloadPreview(ui);
7
+ }, delay);
8
+ }
9
+
10
+ export async function fetchAndDisplayDownloadPreview(ui) {
11
+ const modelUrlOrId = ui.modelUrlInput.value.trim();
12
+ const versionId = ui.modelVersionIdInput.value.trim();
13
+
14
+ if (!modelUrlOrId) {
15
+ ui.downloadPreviewArea.innerHTML = '';
16
+ return;
17
+ }
18
+
19
+ ui.downloadPreviewArea.innerHTML = '<p><i class="fas fa-spinner fa-spin"></i> Loading model details...</p>';
20
+ ui.ensureFontAwesome();
21
+
22
+ const params = {
23
+ model_url_or_id: modelUrlOrId,
24
+ model_version_id: versionId ? parseInt(versionId, 10) : null,
25
+ api_key: ui.settings.apiKey
26
+ };
27
+
28
+ try {
29
+ const result = await CivitaiDownloaderAPI.getModelDetails(params);
30
+ if (result && result.success) {
31
+ ui.renderDownloadPreview(result);
32
+ // Auto-select model type save location based on Civitai model type
33
+ if (result.model_type) {
34
+ await ui.autoSelectModelTypeFromCivitai(result.model_type);
35
+ }
36
+ } else {
37
+ const message = `Failed to get details: ${result.details || result.error || 'Unknown backend error'}`;
38
+ ui.downloadPreviewArea.innerHTML = `<p style="color: var(--error-text, #ff6b6b);">${message}</p>`;
39
+ }
40
+ } catch (error) {
41
+ const message = `Error fetching details: ${error.details || error.message || 'Unknown error'}`;
42
+ console.error("Download Preview Fetch Error:", error);
43
+ ui.downloadPreviewArea.innerHTML = `<p style="color: var(--error-text, #ff6b6b);">${message}</p>`;
44
+ }
45
+ }
46
+
47
+ export async function handleDownloadSubmit(ui) {
48
+ if (!ui.settings.apiKey) {
49
+ ui.showToast("API key empty, please fill your API key in the settings", "error");
50
+ ui.switchTab("settings");
51
+ return;
52
+ }
53
+
54
+ ui.downloadSubmitButton.disabled = true;
55
+ ui.downloadSubmitButton.textContent = 'Starting...';
56
+
57
+ const modelUrlOrId = ui.modelUrlInput.value.trim();
58
+ if (!modelUrlOrId) {
59
+ ui.showToast("Model URL or ID cannot be empty.", "error");
60
+ ui.downloadSubmitButton.disabled = false;
61
+ ui.downloadSubmitButton.textContent = 'Start Download';
62
+ return;
63
+ }
64
+
65
+ // Subfolder comes from dropdown; filename is base name only
66
+ const selectedSubdir = ui.subdirSelect ? ui.subdirSelect.value.trim() : '';
67
+ const userFilename = ui.customFilenameInput.value.trim();
68
+
69
+ const params = {
70
+ model_url_or_id: modelUrlOrId,
71
+ model_type: ui.downloadModelTypeSelect.value,
72
+ model_version_id: ui.modelVersionIdInput.value ? parseInt(ui.modelVersionIdInput.value, 10) : null,
73
+ custom_filename: userFilename,
74
+ subdir: selectedSubdir,
75
+ num_connections: parseInt(ui.downloadConnectionsInput.value, 10),
76
+ force_redownload: ui.forceRedownloadCheckbox.checked,
77
+ api_key: ui.settings.apiKey
78
+ };
79
+
80
+ const fileSelectEl = ui.modal.querySelector('#civitai-file-select');
81
+ if (fileSelectEl && fileSelectEl.value) {
82
+ const fid = parseInt(fileSelectEl.value, 10);
83
+ if (!Number.isNaN(fid)) params.file_id = fid;
84
+ }
85
+
86
+ try {
87
+ const result = await CivitaiDownloaderAPI.downloadModel(params);
88
+
89
+ if (result.status === 'queued') {
90
+ ui.showToast(`Download queued: ${result.details?.filename || 'Model'}`, 'success');
91
+ if (ui.settings.autoOpenStatusTab) {
92
+ ui.switchTab('status');
93
+ } else {
94
+ ui.updateStatus();
95
+ }
96
+ } else if (result.status === 'exists' || result.status === 'exists_size_mismatch') {
97
+ ui.showToast(`${result.message}`, 'info', 4000);
98
+ } else {
99
+ console.warn("Unexpected success response from /civitai/download:", result);
100
+ ui.showToast(`Unexpected status: ${result.status} - ${result.message || ''}`, 'info');
101
+ }
102
+ } catch (error) {
103
+ const message = `Download failed: ${error.details || error.message || 'Unknown error'}`;
104
+ console.error("Download Submit Error:", error);
105
+ ui.showToast(message, 'error', 6000);
106
+ } finally {
107
+ ui.downloadSubmitButton.disabled = false;
108
+ ui.downloadSubmitButton.textContent = 'Start Download';
109
+ }
110
+ }
custom_nodes/Civicomfy/web/js/ui/handlers/eventListeners.js ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CivitaiDownloaderAPI } from "../../api/civitai.js";
2
+ export function setupEventListeners(ui) {
3
+ // Modal close
4
+ ui.closeButton.addEventListener('click', () => ui.closeModal());
5
+ ui.modal.addEventListener('click', (event) => {
6
+ if (event.target === ui.modal) ui.closeModal();
7
+ });
8
+
9
+ // Tab switching
10
+ ui.tabContainer.addEventListener('click', (event) => {
11
+ if (event.target.matches('.civitai-downloader-tab')) {
12
+ ui.switchTab(event.target.dataset.tab);
13
+ }
14
+ });
15
+
16
+ // --- FORMS ---
17
+ ui.downloadForm.addEventListener('submit', (event) => {
18
+ event.preventDefault();
19
+ ui.handleDownloadSubmit();
20
+ });
21
+
22
+ // Change of model type should refresh subdir list
23
+ ui.downloadModelTypeSelect.addEventListener('change', async () => {
24
+ await ui.loadAndPopulateSubdirs(ui.downloadModelTypeSelect.value);
25
+ });
26
+
27
+ // Create new model type folder (first-level under models/)
28
+ ui.createModelTypeButton.addEventListener('click', async () => {
29
+ const name = prompt('Enter new model type folder name (will be created under models/)');
30
+ if (!name) return;
31
+ try {
32
+ const res = await CivitaiDownloaderAPI.createModelType(name);
33
+ if (res && res.success) {
34
+ await ui.populateModelTypes();
35
+ ui.downloadModelTypeSelect.value = res.name;
36
+ await ui.loadAndPopulateSubdirs(res.name);
37
+ ui.showToast(`Created model type folder: ${res.name}`, 'success');
38
+ } else {
39
+ ui.showToast(res?.error || 'Failed to create model type folder', 'error');
40
+ }
41
+ } catch (e) {
42
+ ui.showToast(e.details || e.message || 'Error creating model type folder', 'error');
43
+ }
44
+ });
45
+
46
+ // Create new subfolder under current model type
47
+ ui.createSubdirButton.addEventListener('click', async () => {
48
+ const type = ui.downloadModelTypeSelect.value;
49
+ const name = prompt('Enter new subfolder name (you can include nested paths like A/B):');
50
+ if (!name) return;
51
+ try {
52
+ const res = await CivitaiDownloaderAPI.createModelDir(type, name);
53
+ if (res && res.success) {
54
+ await ui.loadAndPopulateSubdirs(type);
55
+ if (ui.subdirSelect) ui.subdirSelect.value = res.created || '';
56
+ ui.showToast(`Created folder: ${res.created}`, 'success');
57
+ } else {
58
+ ui.showToast(res?.error || 'Failed to create folder', 'error');
59
+ }
60
+ } catch (e) {
61
+ ui.showToast(e.details || e.message || 'Error creating folder', 'error');
62
+ }
63
+ });
64
+
65
+ ui.searchForm.addEventListener('submit', (event) => {
66
+ event.preventDefault();
67
+ if (!ui.searchQueryInput.value.trim() && ui.searchTypeSelect.value === 'any' && ui.searchBaseModelSelect.value === 'any') {
68
+ ui.showToast("Please enter a search query or select a filter.", "error");
69
+ if (ui.searchResultsContainer) ui.searchResultsContainer.innerHTML = '<p>Please enter a search query or select a filter.</p>';
70
+ if (ui.searchPaginationContainer) ui.searchPaginationContainer.innerHTML = '';
71
+ return;
72
+ }
73
+ ui.searchPagination.currentPage = 1;
74
+ ui.handleSearchSubmit();
75
+ });
76
+
77
+ ui.settingsForm.addEventListener('submit', (event) => {
78
+ event.preventDefault();
79
+ ui.handleSettingsSave();
80
+ });
81
+
82
+ // Download form inputs
83
+ ui.modelUrlInput.addEventListener('input', () => ui.debounceFetchDownloadPreview());
84
+ ui.modelUrlInput.addEventListener('paste', () => ui.debounceFetchDownloadPreview(0));
85
+ ui.modelVersionIdInput.addEventListener('blur', () => ui.fetchAndDisplayDownloadPreview());
86
+
87
+ // --- DYNAMIC CONTENT LISTENERS (Event Delegation) ---
88
+
89
+ // Status tab actions (Cancel/Retry/Open/Clear) and click-to-toggle blur on thumbs
90
+ ui.statusContent.addEventListener('click', (event) => {
91
+ const thumbContainer = event.target.closest('.civitai-thumbnail-container');
92
+ if (thumbContainer) {
93
+ const nsfwLevel = Number(thumbContainer.dataset.nsfwLevel ?? thumbContainer.getAttribute('data-nsfw-level'));
94
+ const threshold = Number(ui.settings?.nsfwBlurMinLevel ?? 4);
95
+ const enabled = ui.settings?.hideMatureInSearch === true;
96
+ if (enabled && Number.isFinite(nsfwLevel) && nsfwLevel >= threshold) {
97
+ if (thumbContainer.classList.contains('blurred')) {
98
+ thumbContainer.classList.remove('blurred');
99
+ const overlay = thumbContainer.querySelector('.civitai-nsfw-overlay');
100
+ if (overlay) overlay.remove();
101
+ } else {
102
+ thumbContainer.classList.add('blurred');
103
+ if (!thumbContainer.querySelector('.civitai-nsfw-overlay')) {
104
+ const ov = document.createElement('div');
105
+ ov.className = 'civitai-nsfw-overlay';
106
+ ov.title = 'R-rated: click to reveal';
107
+ ov.textContent = 'R';
108
+ thumbContainer.appendChild(ov);
109
+ }
110
+ }
111
+ return; // consume
112
+ }
113
+ }
114
+
115
+ const button = event.target.closest('button');
116
+ if (!button) return;
117
+
118
+ const downloadId = button.dataset.id;
119
+ if (downloadId) {
120
+ if (button.classList.contains('civitai-cancel-button')) ui.handleCancelDownload(downloadId);
121
+ else if (button.classList.contains('civitai-retry-button')) ui.handleRetryDownload(downloadId, button);
122
+ else if (button.classList.contains('civitai-openpath-button')) ui.handleOpenPath(downloadId, button);
123
+ } else if (button.id === 'civitai-clear-history-button') {
124
+ ui.confirmClearModal.style.display = 'flex';
125
+ }
126
+ });
127
+
128
+ // Download preview click-to-toggle blur
129
+ ui.downloadPreviewArea.addEventListener('click', (event) => {
130
+ const thumbContainer = event.target.closest('.civitai-thumbnail-container');
131
+ if (thumbContainer) {
132
+ const nsfwLevel = Number(thumbContainer.dataset.nsfwLevel ?? thumbContainer.getAttribute('data-nsfw-level'));
133
+ const threshold = Number(ui.settings?.nsfwBlurMinLevel ?? 4);
134
+ const enabled = ui.settings?.hideMatureInSearch === true;
135
+ if (enabled && Number.isFinite(nsfwLevel) && nsfwLevel >= threshold) {
136
+ if (thumbContainer.classList.contains('blurred')) {
137
+ thumbContainer.classList.remove('blurred');
138
+ const overlay = thumbContainer.querySelector('.civitai-nsfw-overlay');
139
+ if (overlay) overlay.remove();
140
+ } else {
141
+ thumbContainer.classList.add('blurred');
142
+ if (!thumbContainer.querySelector('.civitai-nsfw-overlay')) {
143
+ const ov = document.createElement('div');
144
+ ov.className = 'civitai-nsfw-overlay';
145
+ ov.title = 'R-rated: click to reveal';
146
+ ov.textContent = 'R';
147
+ thumbContainer.appendChild(ov);
148
+ }
149
+ }
150
+ }
151
+ }
152
+ });
153
+
154
+ // Search results actions, including click-to-toggle blur
155
+ ui.searchResultsContainer.addEventListener('click', (event) => {
156
+ const thumbContainer = event.target.closest('.civitai-thumbnail-container');
157
+ if (thumbContainer) {
158
+ const nsfwLevel = Number(thumbContainer.dataset.nsfwLevel ?? thumbContainer.getAttribute('data-nsfw-level'));
159
+ const threshold = Number(ui.settings?.nsfwBlurMinLevel ?? 4);
160
+ const enabled = ui.settings?.hideMatureInSearch === true;
161
+ if (enabled && Number.isFinite(nsfwLevel) && nsfwLevel >= threshold) {
162
+ if (thumbContainer.classList.contains('blurred')) {
163
+ thumbContainer.classList.remove('blurred');
164
+ const overlay = thumbContainer.querySelector('.civitai-nsfw-overlay');
165
+ if (overlay) overlay.remove();
166
+ } else {
167
+ thumbContainer.classList.add('blurred');
168
+ if (!thumbContainer.querySelector('.civitai-nsfw-overlay')) {
169
+ const ov = document.createElement('div');
170
+ ov.className = 'civitai-nsfw-overlay';
171
+ ov.title = 'R-rated: click to reveal';
172
+ ov.textContent = 'R';
173
+ thumbContainer.appendChild(ov);
174
+ }
175
+ }
176
+ return; // Don't trigger other actions on this click
177
+ }
178
+ }
179
+
180
+ const downloadButton = event.target.closest('.civitai-search-download-button');
181
+ if (downloadButton) {
182
+ event.preventDefault();
183
+ const { modelId, versionId, modelType } = downloadButton.dataset;
184
+ if (!modelId || !versionId) {
185
+ ui.showToast("Error: Missing data for download.", "error");
186
+ return;
187
+ }
188
+ const modelTypeInternalKey = Object.keys(ui.modelTypes).find(key => ui.modelTypes[key]?.toLowerCase() === modelType?.toLowerCase()) || ui.settings.defaultModelType;
189
+
190
+ ui.modelUrlInput.value = modelId;
191
+ ui.modelVersionIdInput.value = versionId;
192
+ ui.customFilenameInput.value = '';
193
+ ui.forceRedownloadCheckbox.checked = false;
194
+ ui.downloadModelTypeSelect.value = modelTypeInternalKey;
195
+
196
+ ui.switchTab('download');
197
+ ui.showToast(`Filled download form for Model ID ${modelId}.`, 'info', 4000);
198
+ ui.fetchAndDisplayDownloadPreview();
199
+ return;
200
+ }
201
+
202
+ const viewAllButton = event.target.closest('.show-all-versions-button');
203
+ if (viewAllButton) {
204
+ const modelId = viewAllButton.dataset.modelId;
205
+ const versionsContainer = ui.searchResultsContainer.querySelector(`#all-versions-${modelId}`);
206
+ if (versionsContainer) {
207
+ const currentlyVisible = versionsContainer.style.display !== 'none';
208
+ versionsContainer.style.display = currentlyVisible ? 'none' : 'flex';
209
+ viewAllButton.innerHTML = currentlyVisible
210
+ ? `All versions (${viewAllButton.dataset.totalVersions}) <i class="fas fa-chevron-down"></i>`
211
+ : `Show less <i class="fas fa-chevron-up"></i>`;
212
+ }
213
+ }
214
+ });
215
+
216
+ // Pagination
217
+ ui.searchPaginationContainer.addEventListener('click', (event) => {
218
+ const button = event.target.closest('.civitai-page-button');
219
+ if (button && !button.disabled) {
220
+ const page = parseInt(button.dataset.page, 10);
221
+ if (page && page !== ui.searchPagination.currentPage) {
222
+ ui.searchPagination.currentPage = page;
223
+ ui.handleSearchSubmit();
224
+ }
225
+ }
226
+ });
227
+
228
+ // Confirmation Modal
229
+ ui.confirmClearYesButton.addEventListener('click', () => ui.handleClearHistory());
230
+ ui.confirmClearNoButton.addEventListener('click', () => {
231
+ ui.confirmClearModal.style.display = 'none';
232
+ });
233
+ ui.confirmClearModal.addEventListener('click', (event) => {
234
+ if (event.target === ui.confirmClearModal) {
235
+ ui.confirmClearModal.style.display = 'none';
236
+ }
237
+ });
238
+ }
custom_nodes/Civicomfy/web/js/ui/handlers/searchHandler.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CivitaiDownloaderAPI } from "../../api/civitai.js";
2
+
3
+ export async function handleSearchSubmit(ui) {
4
+ ui.searchSubmitButton.disabled = true;
5
+ ui.searchSubmitButton.textContent = 'Searching...';
6
+ ui.searchResultsContainer.innerHTML = '<p><i class="fas fa-spinner fa-spin"></i> Searching...</p>';
7
+ ui.searchPaginationContainer.innerHTML = '';
8
+ ui.ensureFontAwesome();
9
+
10
+ const params = {
11
+ query: ui.searchQueryInput.value.trim(),
12
+ model_types: ui.searchTypeSelect.value === 'any' ? [] : [ui.searchTypeSelect.value],
13
+ base_models: ui.searchBaseModelSelect.value === 'any' ? [] : [ui.searchBaseModelSelect.value],
14
+ sort: ui.searchSortSelect.value,
15
+ limit: ui.searchPagination.limit,
16
+ page: ui.searchPagination.currentPage,
17
+ api_key: ui.settings.apiKey,
18
+ };
19
+
20
+ try {
21
+ const response = await CivitaiDownloaderAPI.searchModels(params);
22
+ if (!response || !response.metadata || !Array.isArray(response.items)) {
23
+ console.error("Invalid search response structure:", response);
24
+ throw new Error("Received invalid data from search API.");
25
+ }
26
+
27
+ ui.renderSearchResults(response.items);
28
+ ui.renderSearchPagination(response.metadata);
29
+
30
+ } catch (error) {
31
+ const message = `Search failed: ${error.details || error.message || 'Unknown error'}`;
32
+ console.error("Search Submit Error:", error);
33
+ ui.searchResultsContainer.innerHTML = `<p style="color: var(--error-text, #ff6b6b);">${message}</p>`;
34
+ ui.showToast(message, 'error');
35
+ } finally {
36
+ ui.searchSubmitButton.disabled = false;
37
+ ui.searchSubmitButton.textContent = 'Search';
38
+ }
39
+ }
custom_nodes/Civicomfy/web/js/ui/handlers/settingsHandler.js ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { setCookie, getCookie } from "../../utils/cookies.js";
2
+
3
+ const SETTINGS_COOKIE_NAME = 'civitaiDownloaderSettings';
4
+
5
+ export function getDefaultSettings() {
6
+ return {
7
+ apiKey: '',
8
+ numConnections: 1,
9
+ defaultModelType: 'checkpoint',
10
+ autoOpenStatusTab: true,
11
+ searchResultLimit: 20,
12
+ hideMatureInSearch: true,
13
+ nsfwBlurMinLevel: 4, // Blur thumbnails with nsfwLevel >= this value
14
+ };
15
+ }
16
+
17
+ export function loadAndApplySettings(ui) {
18
+ ui.settings = ui.loadSettingsFromCookie();
19
+ ui.applySettings();
20
+ }
21
+
22
+ export function loadSettingsFromCookie(ui) {
23
+ const defaults = ui.getDefaultSettings();
24
+ const cookieValue = getCookie(SETTINGS_COOKIE_NAME);
25
+
26
+ if (cookieValue) {
27
+ try {
28
+ const loadedSettings = JSON.parse(cookieValue);
29
+ return { ...defaults, ...loadedSettings };
30
+ } catch (e) {
31
+ console.error("Failed to parse settings cookie:", e);
32
+ return defaults;
33
+ }
34
+ }
35
+ return defaults;
36
+ }
37
+
38
+ export function saveSettingsToCookie(ui) {
39
+ try {
40
+ const settingsString = JSON.stringify(ui.settings);
41
+ setCookie(SETTINGS_COOKIE_NAME, settingsString, 365);
42
+ ui.showToast('Settings saved successfully!', 'success');
43
+ } catch (e) {
44
+ console.error("Failed to save settings to cookie:", e);
45
+ ui.showToast('Error saving settings', 'error');
46
+ }
47
+ }
48
+
49
+ export function applySettings(ui) {
50
+ if (ui.settingsApiKeyInput) {
51
+ ui.settingsApiKeyInput.value = ui.settings.apiKey || '';
52
+ }
53
+ if (ui.settingsConnectionsInput) {
54
+ ui.settingsConnectionsInput.value = Math.max(1, Math.min(16, ui.settings.numConnections || 1));
55
+ }
56
+ if (ui.settingsDefaultTypeSelect) {
57
+ ui.settingsDefaultTypeSelect.value = ui.settings.defaultModelType || 'checkpoint';
58
+ }
59
+ if (ui.settingsAutoOpenCheckbox) {
60
+ ui.settingsAutoOpenCheckbox.checked = ui.settings.autoOpenStatusTab === true;
61
+ }
62
+ if (ui.settingsHideMatureCheckbox) {
63
+ ui.settingsHideMatureCheckbox.checked = ui.settings.hideMatureInSearch === true;
64
+ }
65
+ if (ui.settingsNsfwThresholdInput) {
66
+ const val = Number(ui.settings.nsfwBlurMinLevel);
67
+ ui.settingsNsfwThresholdInput.value = Number.isFinite(val) ? val : 4;
68
+ }
69
+ if (ui.downloadConnectionsInput) {
70
+ ui.downloadConnectionsInput.value = Math.max(1, Math.min(16, ui.settings.numConnections || 1));
71
+ }
72
+ if (ui.downloadModelTypeSelect && Object.keys(ui.modelTypes).length > 0) {
73
+ ui.downloadModelTypeSelect.value = ui.settings.defaultModelType || 'checkpoint';
74
+ }
75
+ ui.searchPagination.limit = ui.settings.searchResultLimit || 20;
76
+ }
77
+
78
+ export function handleSettingsSave(ui) {
79
+ const apiKey = ui.settingsApiKeyInput.value.trim();
80
+ const numConnections = parseInt(ui.settingsConnectionsInput.value, 10);
81
+ const defaultModelType = ui.settingsDefaultTypeSelect.value;
82
+ const autoOpenStatusTab = ui.settingsAutoOpenCheckbox.checked;
83
+ const hideMatureInSearch = ui.settingsHideMatureCheckbox.checked;
84
+ const nsfwBlurMinLevel = Number(ui.settingsNsfwThresholdInput.value);
85
+
86
+ if (isNaN(numConnections) || numConnections < 1 || numConnections > 16) {
87
+ ui.showToast("Invalid Default Connections (must be 1-16).", "error");
88
+ return;
89
+ }
90
+ if (!ui.settingsDefaultTypeSelect.querySelector(`option[value="${defaultModelType}"]`)) {
91
+ ui.showToast("Invalid Default Model Type selected.", "error");
92
+ return;
93
+ }
94
+
95
+ ui.settings.apiKey = apiKey;
96
+ ui.settings.numConnections = numConnections;
97
+ ui.settings.defaultModelType = defaultModelType;
98
+ ui.settings.autoOpenStatusTab = autoOpenStatusTab;
99
+ ui.settings.hideMatureInSearch = hideMatureInSearch;
100
+ ui.settings.nsfwBlurMinLevel = (Number.isFinite(nsfwBlurMinLevel) && nsfwBlurMinLevel >= 0) ? Math.min(128, Math.round(nsfwBlurMinLevel)) : 4;
101
+
102
+ ui.saveSettingsToCookie();
103
+ ui.applySettings();
104
+ }
custom_nodes/Civicomfy/web/js/ui/handlers/statusHandler.js ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CivitaiDownloaderAPI } from "../../api/civitai.js";
2
+
3
+ export function startStatusUpdates(ui) {
4
+ if (!ui.statusInterval) {
5
+ console.log("[Civicomfy] Starting status updates (every 3s)...");
6
+ ui.updateStatus();
7
+ ui.statusInterval = setInterval(() => ui.updateStatus(), 3000);
8
+ }
9
+ }
10
+
11
+ export function stopStatusUpdates(ui) {
12
+ if (ui.statusInterval) {
13
+ clearInterval(ui.statusInterval);
14
+ ui.statusInterval = null;
15
+ console.log("[Civicomfy] Stopped status updates.");
16
+ }
17
+ }
18
+
19
+ export async function updateStatus(ui) {
20
+ if (!ui.modal || !ui.modal.classList.contains('open')) return;
21
+
22
+ try {
23
+ const newStatusData = await CivitaiDownloaderAPI.getStatus();
24
+ if (!newStatusData || !Array.isArray(newStatusData.active) || !Array.isArray(newStatusData.queue) || !Array.isArray(newStatusData.history)) {
25
+ throw new Error("Invalid status data structure received from server.");
26
+ }
27
+
28
+ const oldStateString = JSON.stringify(ui.statusData);
29
+ const newStateString = JSON.stringify(newStatusData);
30
+
31
+ // Cache new state if it differs
32
+ if (oldStateString !== newStateString) {
33
+ ui.statusData = newStatusData;
34
+ }
35
+
36
+ // Always keep counters in sync
37
+ const activeCount = ui.statusData.active.length + ui.statusData.queue.length;
38
+ ui.activeCountSpan.textContent = activeCount;
39
+ ui.statusIndicator.style.display = activeCount > 0 ? 'inline' : 'none';
40
+
41
+ // Always render when Status tab is active, even if data hasn't changed
42
+ if (ui.activeTab === 'status') {
43
+ ui.renderDownloadList(ui.statusData.active, ui.activeListContainer, 'No active downloads.');
44
+ ui.renderDownloadList(ui.statusData.queue, ui.queuedListContainer, 'Download queue is empty.');
45
+ ui.renderDownloadList(ui.statusData.history, ui.historyListContainer, 'No download history yet.');
46
+ }
47
+ } catch (error) {
48
+ console.error("[Civicomfy] Failed to update status:", error);
49
+ if (ui.activeTab === 'status') {
50
+ const errorHtml = `<p style="color: var(--error-text, #ff6b6b);">${error.details || error.message}</p>`;
51
+ if (ui.activeListContainer) ui.activeListContainer.innerHTML = errorHtml;
52
+ if (ui.queuedListContainer) ui.queuedListContainer.innerHTML = '';
53
+ if (ui.historyListContainer) ui.historyListContainer.innerHTML = '';
54
+ }
55
+ }
56
+ }
57
+
58
+ export async function handleCancelDownload(ui, downloadId) {
59
+ const button = ui.modal.querySelector(`.civitai-cancel-button[data-id="${downloadId}"]`);
60
+ if (button) {
61
+ button.disabled = true;
62
+ button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
63
+ button.title = "Cancelling...";
64
+ }
65
+ try {
66
+ const result = await CivitaiDownloaderAPI.cancelDownload(downloadId);
67
+ ui.showToast(result.message || `Cancellation requested for ${downloadId}`, 'info');
68
+ ui.updateStatus();
69
+ } catch (error) {
70
+ const message = `Cancel failed: ${error.details || error.message}`;
71
+ console.error("Cancel Download Error:", error);
72
+ ui.showToast(message, 'error');
73
+ if (button) {
74
+ button.disabled = false;
75
+ button.innerHTML = '<i class="fas fa-times"></i>';
76
+ button.title = "Cancel Download";
77
+ }
78
+ }
79
+ }
80
+
81
+ export async function handleRetryDownload(ui, downloadId, button) {
82
+ button.disabled = true;
83
+ button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
84
+ button.title = "Retrying...";
85
+ try {
86
+ const result = await CivitaiDownloaderAPI.retryDownload(downloadId);
87
+ if (result.success) {
88
+ ui.showToast(result.message || `Retry queued successfully!`, 'success');
89
+ if (ui.settings.autoOpenStatusTab) ui.switchTab('status');
90
+ else ui.updateStatus();
91
+ } else {
92
+ ui.showToast(`Retry failed: ${result.details || result.error}`, 'error', 5000);
93
+ button.disabled = false;
94
+ button.innerHTML = '<i class="fas fa-redo"></i>';
95
+ button.title = "Retry Download";
96
+ }
97
+ } catch (error) {
98
+ const message = `Retry failed: ${error.details || error.message}`;
99
+ console.error("Retry Download UI Error:", error);
100
+ ui.showToast(message, 'error', 5000);
101
+ button.disabled = false;
102
+ button.innerHTML = '<i class="fas fa-redo"></i>';
103
+ button.title = "Retry Download";
104
+ }
105
+ }
106
+
107
+ export async function handleOpenPath(ui, downloadId, button) {
108
+ const originalIcon = button.innerHTML;
109
+ button.disabled = true;
110
+ button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
111
+ button.title = "Opening...";
112
+ try {
113
+ const result = await CivitaiDownloaderAPI.openPath(downloadId);
114
+ if (result.success) {
115
+ ui.showToast(result.message || `Opened path successfully!`, 'success');
116
+ } else {
117
+ ui.showToast(`Open path failed: ${result.details || result.error}`, 'error', 5000);
118
+ }
119
+ } catch (error) {
120
+ const message = `Open path failed: ${error.details || error.message}`;
121
+ console.error("Open Path UI Error:", error);
122
+ ui.showToast(message, 'error', 5000);
123
+ } finally {
124
+ button.disabled = false;
125
+ button.innerHTML = originalIcon;
126
+ button.title = "Open Containing Folder";
127
+ }
128
+ }
129
+
130
+ export async function handleClearHistory(ui) {
131
+ ui.confirmClearYesButton.disabled = true;
132
+ ui.confirmClearNoButton.disabled = true;
133
+ ui.confirmClearYesButton.textContent = 'Clearing...';
134
+
135
+ try {
136
+ const result = await CivitaiDownloaderAPI.clearHistory();
137
+ if (result.success) {
138
+ ui.showToast(result.message || 'History cleared successfully!', 'success');
139
+ ui.statusData.history = [];
140
+ ui.renderDownloadList(ui.statusData.history, ui.historyListContainer, 'No download history yet.');
141
+ ui.confirmClearModal.style.display = 'none';
142
+ } else {
143
+ ui.showToast(`Clear history failed: ${result.details || result.error}`, 'error', 5000);
144
+ }
145
+ } catch (error) {
146
+ const message = `Clear history failed: ${error.details || error.message}`;
147
+ console.error("Clear History UI Error:", error);
148
+ ui.showToast(message, 'error', 5000);
149
+ } finally {
150
+ ui.confirmClearYesButton.disabled = false;
151
+ ui.confirmClearNoButton.disabled = false;
152
+ ui.confirmClearYesButton.textContent = 'Confirm Clear';
153
+ }
154
+ }
custom_nodes/Civicomfy/web/js/ui/previewRenderer.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Renders the download preview panel
2
+
3
+ const PLACEHOLDER_IMAGE_URL = `/extensions/Civicomfy/images/placeholder.jpeg`;
4
+
5
+ export function renderDownloadPreview(ui, data) {
6
+ if (!ui.downloadPreviewArea) return;
7
+ ui.ensureFontAwesome();
8
+
9
+ const modelId = data.model_id;
10
+ const modelName = data.model_name || 'Untitled Model';
11
+ const creator = data.creator_username || 'Unknown Creator';
12
+ const modelType = data.model_type || 'N/A';
13
+ const versionName = data.version_name || 'N/A';
14
+ const baseModel = data.base_model || 'N/A';
15
+ const stats = data.stats || {};
16
+ const descriptionHtml = data.description_html || '<p><em>No description.</em></p>';
17
+ const version_description_html = data.version_description_html || '<p><em>No description.</em></p>';
18
+ const fileInfo = data.file_info || {};
19
+ const files = Array.isArray(data.files) ? data.files : [];
20
+ const thumbnail = data.thumbnail_url || PLACEHOLDER_IMAGE_URL;
21
+ const nsfwLevel = Number(data.nsfw_level ?? 0);
22
+ const blurMinLevel = Number(ui.settings?.nsfwBlurMinLevel ?? 4);
23
+ const shouldBlur = ui.settings?.hideMatureInSearch === true && nsfwLevel >= blurMinLevel;
24
+ const civitaiLink = `https://civitai.com/models/${modelId}${data.version_id ? '?modelVersionId=' + data.version_id : ''}`;
25
+
26
+ const onErrorScript = `this.onerror=null; this.src='${PLACEHOLDER_IMAGE_URL}'; this.style.backgroundColor='#444';`;
27
+
28
+ const overlayHtml = shouldBlur ? `<div class="civitai-nsfw-overlay" title="R-rated: click to reveal">R</div>` : '';
29
+ const containerClasses = `civitai-thumbnail-container${shouldBlur ? ' blurred' : ''}`;
30
+
31
+ const previewHtml = `
32
+ <div class="civitai-search-item" style="background-color: var(--comfy-input-bg);">
33
+ <div class="${containerClasses}" data-nsfw-level="${Number.isFinite(nsfwLevel) ? nsfwLevel : ''}">
34
+ <img src="${thumbnail}" alt="${modelName} thumbnail" class="civitai-search-thumbnail" loading="lazy" onerror="${onErrorScript}">
35
+ ${overlayHtml}
36
+ <div class="civitai-type-badge">${modelType}</div>
37
+ </div>
38
+ <div class="civitai-search-info">
39
+ <h4>${modelName} <span style="font-weight: normal; font-size: 0.9em;">by ${creator}</span></h4>
40
+ <p style="font-weight: bold;">Version: ${versionName} <span class="base-model-badge" style="margin-left: 5px;">${baseModel}</span></p>
41
+ <div class="civitai-search-stats" title="Stats: Downloads / Rating (Count) / Likes">
42
+ <span title="Downloads"><i class="fas fa-download"></i> ${stats.downloads?.toLocaleString() || 0}</span>
43
+ <span title="Likes"><i class="fas fa-thumbs-up"></i> ${stats.likes?.toLocaleString(0) || 0}</span>
44
+ <span title="Dislikes"><i class="fas fa-thumbs-down"></i> ${stats.dislikes?.toLocaleString() || 0}</span>
45
+ <span title="Buzz"><i class="fas fa-bolt"></i> ${stats.buzz?.toLocaleString() || 0}</span>
46
+ </div>
47
+ <p style="font-weight: bold; margin-top: 10px;">Primary File:</p>
48
+ <p style="font-size: 0.9em; color: #ccc;">
49
+ Name: ${fileInfo.name || 'N/A'}<br>
50
+ Size: ${ui.formatBytes(fileInfo.size_kb * 1024) || 'N/A'} <br>
51
+ Format: ${fileInfo.format || 'N/A'}<br>
52
+ Precision: ${fileInfo.precision || 'N/A'}<br>
53
+ Model Size: ${fileInfo.model_size || 'N/A'}
54
+ </p>
55
+ ${files.length > 0 ? `
56
+ <div class=\"civitai-form-group\" style=\"margin-top: 10px;\">
57
+ <label for=\"civitai-file-select\">Choose File (optional)</label>
58
+ <select id=\"civitai-file-select\" class=\"civitai-select\">
59
+ <option value=\"\">Auto (primary/best)</option>
60
+ ${files.map(f => {
61
+ const id = f.id ?? '';
62
+ const name = (f.name || '').replace(/</g,'&lt;');
63
+ const fmt = f.format || 'N/A';
64
+ const prec = (f.precision || '').toUpperCase();
65
+ const msize = f.model_size || '';
66
+ const size = (typeof f.size_kb === 'number') ? ui.formatBytes(f.size_kb * 1024) : 'N/A';
67
+ const disabled = f.downloadable ? '' : 'disabled';
68
+ const title = f.downloadable ? '' : ' (not downloadable)';
69
+ const extras = [prec, msize].filter(Boolean).join(' • ');
70
+ return `<option value=\"${id}\" ${disabled}>#${id} • ${name} • ${fmt}${extras ? ' • ' + extras : ''} • ${size}${title}</option>`;
71
+ }).join('')}
72
+ </select>
73
+ <p style=\"font-size: 0.9em; color: #aaa; margin-top: 6px;\">Pick other variants when available.</p>
74
+ </div>
75
+ ` : ''}
76
+ <a href="${civitaiLink}" target="_blank" rel="noopener noreferrer" class="civitai-button small" title="Open on Civitai website" style="margin-top: 5px; display: inline-block;">
77
+ View on Civitai <i class="fas fa-external-link-alt"></i>
78
+ </a>
79
+ </div>
80
+ </div>
81
+ <div style="margin-top: 15px;">
82
+ <h5 style="margin-bottom: 5px;">Model Description:</h5>
83
+ <div class="model-description-content" style="max-height: 200px; overflow-y: auto; background-color: var(--comfy-input-bg); padding: 10px; border-radius: 4px; font-size: 0.9em; border: 1px solid var(--border-color, #555);">
84
+ ${descriptionHtml}
85
+ </div>
86
+ </div>
87
+ <div style="margin-top: 15px;">
88
+ <h5 style="margin-bottom: 5px;">Version Description:</h5>
89
+ <div class="model-description-content" style="max-height: 200px; overflow-y: auto; background-color: var(--comfy-input-bg); padding: 10px; border-radius: 4px; font-size: 0.9em; border: 1px solid var(--border-color, #555);">
90
+ ${version_description_html}
91
+ </div>
92
+ </div>
93
+ `;
94
+
95
+ ui.downloadPreviewArea.innerHTML = previewHtml;
96
+ }
custom_nodes/Civicomfy/web/js/ui/searchRenderer.js ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Rendering of search results list
2
+ // Usage: renderSearchResults(uiInstance, itemsArray)
3
+
4
+ const PLACEHOLDER_IMAGE_URL = `/extensions/Civicomfy/images/placeholder.jpeg`;
5
+
6
+ export function renderSearchResults(ui, items) {
7
+ ui.feedback?.ensureFontAwesome();
8
+
9
+ if (!items || items.length === 0) {
10
+ const queryUsed = ui.searchQueryInput && ui.searchQueryInput.value.trim();
11
+ const typeFilterUsed = ui.searchTypeSelect && ui.searchTypeSelect.value !== 'any';
12
+ const baseModelFilterUsed = ui.searchBaseModelSelect && ui.searchBaseModelSelect.value !== 'any';
13
+ const message = (queryUsed || typeFilterUsed || baseModelFilterUsed)
14
+ ? 'No models found matching your criteria.'
15
+ : 'Enter a query or select filters and click Search.';
16
+ ui.searchResultsContainer.innerHTML = `<p>${message}</p>`;
17
+ return;
18
+ }
19
+
20
+ const placeholder = PLACEHOLDER_IMAGE_URL;
21
+ const onErrorScript = `this.onerror=null; this.src='${placeholder}'; this.style.backgroundColor='#444';`;
22
+ const fragment = document.createDocumentFragment();
23
+
24
+ items.forEach(hit => {
25
+ const modelId = hit.id;
26
+ if (!modelId) return;
27
+
28
+ const creator = hit.user?.username || 'Unknown Creator';
29
+ const modelName = hit.name || 'Untitled Model';
30
+ const modelTypeApi = hit.type || 'other';
31
+ console.log('Model type for badge:', modelTypeApi);
32
+ const stats = hit.metrics || {};
33
+ const tags = hit.tags?.map(t => t.name) || [];
34
+
35
+ const thumbnailUrl = hit.thumbnailUrl || placeholder;
36
+ const firstImage = Array.isArray(hit.images) && hit.images.length > 0 ? hit.images[0] : null;
37
+ const thumbnailType = firstImage?.type;
38
+ const nsfwLevel = Number(firstImage?.nsfwLevel ?? hit.nsfwLevel ?? 0);
39
+ const blurMinLevel = Number(ui.settings?.nsfwBlurMinLevel ?? 4);
40
+ const shouldBlur = ui.settings?.hideMatureInSearch === true && nsfwLevel >= blurMinLevel;
41
+
42
+ const allVersions = hit.versions || [];
43
+ const primaryVersion = hit.version || (allVersions.length > 0 ? allVersions[0] : {});
44
+ const primaryVersionId = primaryVersion.id;
45
+ const primaryBaseModel = primaryVersion.baseModel || 'N/A';
46
+
47
+ const uniqueBaseModels = allVersions.length > 0
48
+ ? [...new Set(allVersions.map(v => v.baseModel).filter(Boolean))]
49
+ : (primaryBaseModel !== 'N/A' ? [primaryBaseModel] : []);
50
+ const baseModelsDisplay = uniqueBaseModels.length > 0 ? uniqueBaseModels.join(', ') : 'N/A';
51
+
52
+ const publishedAt = hit.publishedAt;
53
+ let lastUpdatedFormatted = 'N/A';
54
+ if (publishedAt) {
55
+ try {
56
+ const date = new Date(publishedAt);
57
+ lastUpdatedFormatted = date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
58
+ } catch (_) {}
59
+ }
60
+
61
+ const listItem = document.createElement('div');
62
+ listItem.className = 'civitai-search-item';
63
+ listItem.dataset.modelId = modelId;
64
+
65
+ const MAX_VISIBLE_VERSIONS = 3;
66
+ let visibleVersions = [];
67
+ if (primaryVersionId) {
68
+ visibleVersions.push({ id: primaryVersionId, name: primaryVersion.name || 'Primary Version', baseModel: primaryBaseModel });
69
+ }
70
+ allVersions.forEach(v => {
71
+ if (v.id !== primaryVersionId && visibleVersions.length < MAX_VISIBLE_VERSIONS) visibleVersions.push(v);
72
+ });
73
+
74
+ let versionButtonsHtml = visibleVersions.map(version => {
75
+ const versionId = version.id;
76
+ const versionName = version.name || 'Unknown Version';
77
+ const baseModel = version.baseModel || 'N/A';
78
+ return `
79
+ <button class="civitai-button primary small civitai-search-download-button"
80
+ data-model-id="${modelId}"
81
+ data-version-id="${versionId || ''}"
82
+ data-model-type="${modelTypeApi || ''}"
83
+ ${!versionId ? 'disabled title="Version ID missing, cannot pre-fill"' : 'title="Pre-fill Download Tab"'} >
84
+ <span class="base-model-badge">${baseModel}</span> ${versionName} <i class="fas fa-download"></i>
85
+ </button>
86
+ `;
87
+ }).join('');
88
+
89
+ const hasMoreVersions = allVersions.length > visibleVersions.length;
90
+ const totalVersionCount = allVersions.length;
91
+ const moreButtonHtml = hasMoreVersions ? `
92
+ <button class="civitai-button secondary small show-all-versions-button"
93
+ data-model-id="${modelId}"
94
+ data-total-versions="${totalVersionCount}"
95
+ title="Show all ${totalVersionCount} versions">
96
+ All versions (${totalVersionCount}) <i class="fas fa-chevron-down"></i>
97
+ </button>
98
+ ` : '';
99
+
100
+ let allVersionsHtml = '';
101
+ if (hasMoreVersions) {
102
+ const hiddenVersions = allVersions.filter(v => !visibleVersions.some(vis => vis.id === v.id));
103
+ allVersionsHtml = `
104
+ <div class="all-versions-container" id="all-versions-${modelId}" style="display: none;">
105
+ ${hiddenVersions.map(version => {
106
+ const versionId = version.id;
107
+ const versionName = version.name || 'Unknown Version';
108
+ const baseModel = version.baseModel || 'N/A';
109
+ return `
110
+ <button class="civitai-button primary small civitai-search-download-button"
111
+ data-model-id="${modelId}"
112
+ data-version-id="${versionId || ''}"
113
+ data-model-type="${modelTypeApi || ''}"
114
+ ${!versionId ? 'disabled title="Version ID missing, cannot pre-fill"' : 'title="Pre-fill Download Tab"'} >
115
+ <span class="base-model-badge">${baseModel}</span> ${versionName} <i class="fas fa-download"></i>
116
+ </button>
117
+ `;
118
+ }).join('')}
119
+ </div>
120
+ `;
121
+ }
122
+
123
+ let thumbnailHtml = '';
124
+ const videoTitle = `Video preview for ${modelName}`;
125
+ const imageAlt = `${modelName} thumbnail`;
126
+ if (thumbnailUrl && typeof thumbnailUrl === 'string' && thumbnailType === 'video') {
127
+ thumbnailHtml = `
128
+ <video class="civitai-search-thumbnail" src="${thumbnailUrl}" autoplay loop muted playsinline
129
+ title="${videoTitle}"
130
+ onerror="console.error('Failed to load video preview:', this.src)">
131
+ Your browser does not support the video tag.
132
+ </video>
133
+ `;
134
+ } else {
135
+ const effective = thumbnailUrl || placeholder;
136
+ thumbnailHtml = `
137
+ <img src="${effective}" alt="${imageAlt}" class="civitai-search-thumbnail" loading="lazy" onerror="${onErrorScript}">
138
+ `;
139
+ }
140
+
141
+ const overlayHtml = shouldBlur ? `<div class="civitai-nsfw-overlay" title="R-rated: click to reveal">R</div>` : '';
142
+ const containerClasses = `civitai-thumbnail-container${shouldBlur ? ' blurred' : ''}`;
143
+
144
+ listItem.innerHTML = `
145
+ <div class="${containerClasses}" data-nsfw-level="${nsfwLevel ?? ''}">
146
+ ${thumbnailHtml}
147
+ ${overlayHtml}
148
+ <div class="civitai-type-badge" data-type="${modelTypeApi.toLowerCase()}">${modelTypeApi}</div>
149
+ </div>
150
+ <div class="civitai-search-info">
151
+ <h4>${modelName}</h4>
152
+ <div class="civitai-search-meta-info">
153
+ <span title="Creator: ${creator}"><i class="fas fa-user"></i> ${creator}</span>
154
+ <span title="Base Models: ${baseModelsDisplay}"><i class="fas fa-layer-group"></i> ${baseModelsDisplay}</span>
155
+ <span title="Published: ${lastUpdatedFormatted}"><i class="fas fa-calendar-alt"></i> ${lastUpdatedFormatted}</span>
156
+ </div>
157
+ <div class="civitai-search-stats" title="Stats: Downloads / Rating (Count) / Likes">
158
+ <span title="Downloads"><i class="fas fa-download"></i> ${stats.downloadCount?.toLocaleString() || 0}</span>
159
+ <span title="Thumbs"><i class="fas fa-thumbs-up"></i> ${stats.thumbsUpCount?.toLocaleString() || 0}</span>
160
+ <span title="Collected"><i class="fas fa-archive"></i> ${stats.collectedCount?.toLocaleString() || 0}</span>
161
+ <span title="Buzz"><i class="fas fa-bolt"></i> ${stats.tippedAmountCount?.toLocaleString() || 0}</span>
162
+ </div>
163
+ ${tags.length > 0 ? `
164
+ <div class="civitai-search-tags" title="${tags.join(', ')}">
165
+ ${tags.slice(0, 5).map(tag => `<span class="civitai-search-tag">${tag}</span>`).join('')}
166
+ ${tags.length > 5 ? `<span class="civitai-search-tag">...</span>` : ''}
167
+ </div>
168
+ ` : ''}
169
+ </div>
170
+ <div class="civitai-search-actions">
171
+ <a href="https://civitai.com/models/${modelId}${primaryVersionId ? '?modelVersionId='+primaryVersionId : ''}"
172
+ target="_blank" rel="noopener noreferrer" class="civitai-button small"
173
+ title="Open on Civitai website">
174
+ View <i class="fas fa-external-link-alt"></i>
175
+ </a>
176
+ <div class="version-buttons-container">
177
+ ${versionButtonsHtml}
178
+ ${moreButtonHtml}
179
+ </div>
180
+ ${allVersionsHtml}
181
+ </div>
182
+ `;
183
+
184
+ fragment.appendChild(listItem);
185
+ });
186
+
187
+ ui.searchResultsContainer.innerHTML = '';
188
+ ui.searchResultsContainer.appendChild(fragment);
189
+ }
custom_nodes/Civicomfy/web/js/ui/statusRenderer.js ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Renders active/queued/history download lists
2
+
3
+ const PLACEHOLDER_IMAGE_URL = `/extensions/Civicomfy/images/placeholder.jpeg`;
4
+
5
+ export function renderDownloadList(ui, items, container, emptyMessage) {
6
+ if (!items || items.length === 0) {
7
+ container.innerHTML = `<p>${emptyMessage}</p>`;
8
+ return;
9
+ }
10
+
11
+ const fragment = document.createDocumentFragment();
12
+ items.forEach(item => {
13
+ const id = item.id || 'unknown-id';
14
+ const progress = item.progress !== undefined ? Math.max(0, Math.min(100, item.progress)) : 0;
15
+ const speed = item.speed !== undefined ? Math.max(0, item.speed) : 0;
16
+ const status = item.status || 'unknown';
17
+ const size = item.known_size !== undefined && item.known_size !== null ? item.known_size : (item.file_size || 0);
18
+ const downloadedBytes = size > 0 ? size * (progress / 100) : 0;
19
+ const errorMsg = item.error || null;
20
+ const modelName = item.model_name || item.model?.name || 'Unknown Model';
21
+ const versionName = item.version_name || 'Unknown Version';
22
+ const filename = item.filename || 'N/A';
23
+ const addedTime = item.added_time || null;
24
+ const startTime = item.start_time || null;
25
+ const endTime = item.end_time || null;
26
+ const thumbnail = item.thumbnail || PLACEHOLDER_IMAGE_URL;
27
+ const nsfwLevel = Number(item.thumbnail_nsfw_level ?? 0);
28
+ const blurMinLevel = Number(ui.settings?.nsfwBlurMinLevel ?? 4);
29
+ const shouldBlur = ui.settings?.hideMatureInSearch === true && nsfwLevel >= blurMinLevel;
30
+ const connectionType = item.connection_type || "N/A";
31
+
32
+ let progressBarClass = '';
33
+ let statusText = status.charAt(0).toUpperCase() + status.slice(1);
34
+ switch (status) {
35
+ case 'completed': progressBarClass = 'completed'; break;
36
+ case 'failed': progressBarClass = 'failed'; statusText = 'Failed'; break;
37
+ case 'cancelled': progressBarClass = 'cancelled'; statusText = 'Cancelled'; break;
38
+ case 'downloading': case 'queued': case 'starting': default: break;
39
+ }
40
+
41
+ const listItem = document.createElement('div');
42
+ listItem.className = 'civitai-download-item';
43
+ listItem.dataset.id = id;
44
+
45
+ const onErrorScript = `this.onerror=null; this.src='${PLACEHOLDER_IMAGE_URL}'; this.style.backgroundColor='#444';`;
46
+ const addedTooltip = addedTime ? `data-tooltip="Added: ${new Date(addedTime).toLocaleString()}"` : '';
47
+ const startedTooltip = startTime ? `data-tooltip="Started: ${new Date(startTime).toLocaleString()}"` : '';
48
+ const endedTooltip = endTime ? `data-tooltip="Ended: ${new Date(endTime).toLocaleString()}"` : '';
49
+ const durationTooltip = startTime && endTime ? `data-tooltip="Duration: ${ui.formatDuration(startTime, endTime)}"` : '';
50
+ const filenameTooltip = filename !== 'N/A' ? `title="Filename: ${filename}"` : '';
51
+ const errorTooltip = errorMsg ? `title="Error Details: ${String(errorMsg).substring(0, 200)}${String(errorMsg).length > 200 ? '...' : ''}"` : '';
52
+ const connectionInfoHtml = connectionType !== "N/A" ? `<span style="font-size: 0.85em; color: #aaa; margin-left: 10px;">(Conn: ${connectionType})</span>` : '';
53
+
54
+ const overlayHtml = shouldBlur ? `<div class=\"civitai-nsfw-overlay\" title=\"R-rated: click to reveal\">R</div>` : '';
55
+ const containerClasses = `civitai-thumbnail-container${shouldBlur ? ' blurred' : ''}`;
56
+
57
+ let innerHTML = `
58
+ <div class="${containerClasses}" data-nsfw-level="${Number.isFinite(nsfwLevel) ? nsfwLevel : ''}">
59
+ <img src="${thumbnail}" alt="thumbnail" class="civitai-download-thumbnail" loading="lazy" onerror="${onErrorScript}">
60
+ ${overlayHtml}
61
+ </div>
62
+ <div class="civitai-download-info">
63
+ <strong>${modelName}</strong>
64
+ <p>Ver: ${versionName}</p>
65
+ <p class="filename" ${filenameTooltip}>${filename}</p>
66
+ ${size > 0 ? `<p>Size: ${ui.formatBytes(size)}</p>` : ''}
67
+ ${item.file_format ? `<p>Format: ${item.file_format}</p>` : ''}
68
+ ${item.file_precision || item.file_model_size ? `<p>${item.file_precision ? 'Precision: ' + String(item.file_precision).toUpperCase() : ''}${item.file_precision && item.file_model_size ? ' • ' : ''}${item.file_model_size ? 'Model Size: ' + item.file_model_size : ''}</p>` : ''}
69
+ ${errorMsg ? `<p class="error-message" ${errorTooltip}><i class="fas fa-exclamation-triangle"></i> ${String(errorMsg).substring(0, 100)}${String(errorMsg).length > 100 ? '...' : ''}</p>` : ''}
70
+ `;
71
+
72
+ if (status === 'downloading' || status === 'starting' || status === 'completed') {
73
+ const statusLine = `<div ${durationTooltip} ${endedTooltip}>Status: ${statusText} ${connectionInfoHtml}</div>`;
74
+ innerHTML += `
75
+ <div class="civitai-progress-container" title="${statusText} - ${progress.toFixed(1)}%">
76
+ <div class="civitai-progress-bar ${progressBarClass}" style="width: ${progress}%;">
77
+ ${progress > 15 ? progress.toFixed(0)+'%' : ''}
78
+ </div>
79
+ </div>
80
+ `;
81
+ const speedText = (status === 'downloading' && speed > 0) ? ui.formatSpeed(speed) : '';
82
+ const progressText = (status === 'downloading' && size > 0) ? `(${ui.formatBytes(downloadedBytes)} / ${ui.formatBytes(size)})` : '';
83
+ const completedText = status === 'completed' ? '' : '';
84
+ const speedProgLine = `<div class="civitai-speed-indicator">${speedText} ${progressText} ${completedText}</div>`;
85
+ if (status === 'downloading') { innerHTML += speedProgLine; }
86
+ innerHTML += statusLine;
87
+ } else if (status === 'failed' || status === 'cancelled' || status === 'queued') {
88
+ innerHTML += `<div class="status-line-simple" ${durationTooltip} ${endedTooltip} ${addedTooltip}>Status: ${statusText} ${connectionInfoHtml}</div>`;
89
+ } else {
90
+ innerHTML += `<div class="status-line-simple">Status: ${statusText} ${connectionInfoHtml}</div>`;
91
+ }
92
+
93
+ innerHTML += `</div>`;
94
+ innerHTML += `<div class="civitai-download-actions">`;
95
+ if (status === 'queued' || status === 'downloading' || status === 'starting') {
96
+ innerHTML += `<button class="civitai-button danger small civitai-cancel-button" data-id="${id}" title="Cancel Download"><i class="fas fa-times"></i></button>`;
97
+ }
98
+ if (status === 'failed' || status === 'cancelled') {
99
+ innerHTML += `<button class="civitai-button small civitai-retry-button" data-id="${id}" title="Retry Download"><i class="fas fa-redo"></i></button>`;
100
+ }
101
+ if (status === 'completed') {
102
+ innerHTML += `<button class="civitai-button small civitai-openpath-button" data-id="${id}" title="Open Containing Folder"><i class="fas fa-folder-open"></i></button>`;
103
+ }
104
+ innerHTML += `</div>`;
105
+
106
+ listItem.innerHTML = innerHTML;
107
+ fragment.appendChild(listItem);
108
+ });
109
+
110
+ container.innerHTML = '';
111
+ container.appendChild(fragment);
112
+ ui.ensureFontAwesome();
113
+ }
custom_nodes/Civicomfy/web/js/ui/templates.js ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Modal template for Civicomfy UI
2
+ // Keep structure identical to the original inline HTML to minimize risk
3
+
4
+ export function modalTemplate(settings = {}) {
5
+ const numConnections = Number.isFinite(settings.numConnections) ? settings.numConnections : 1;
6
+ return `
7
+ <div class="civitai-downloader-modal-content">
8
+ <div class="civitai-downloader-header">
9
+ <h2>Civicomfy</h2>
10
+ <button class="civitai-close-button" id="civitai-close-modal">&times;</button>
11
+ </div>
12
+ <div class="civitai-downloader-body">
13
+ <div class="civitai-downloader-tabs">
14
+ <button class="civitai-downloader-tab active" data-tab="download">Download</button>
15
+ <button class="civitai-downloader-tab" data-tab="search">Search</button>
16
+ <button class="civitai-downloader-tab" data-tab="status">Status <span id="civitai-status-indicator" style="display: none;">(<span id="civitai-active-count">0</span>)</span></button>
17
+ <button class="civitai-downloader-tab" data-tab="settings">Settings</button>
18
+ </div>
19
+ <div id="civitai-tab-download" class="civitai-downloader-tab-content active">
20
+ <form id="civitai-download-form">
21
+ <div class="civitai-form-group">
22
+ <label for="civitai-model-url">Model URL or ID</label>
23
+ <input type="text" id="civitai-model-url" class="civitai-input" placeholder="e.g., https://civitai.com/models/12345 or 12345" required>
24
+ </div>
25
+ <p style="font-size: 0.9em; color: #ccc; margin-top: -10px; margin-bottom: 15px;">You can optionally specify a version ID using "?modelVersionId=xxxxx" in the URL or in the field below.</p>
26
+ <div class="civitai-form-row">
27
+ <div class="civitai-form-group">
28
+ <label for="civitai-model-type">Model Type (Save Location)</label>
29
+ <div style="display:flex; gap:6px; align-items:center;">
30
+ <select id="civitai-model-type" class="civitai-select" required></select>
31
+ <button type="button" id="civitai-create-model-type" class="civitai-button small" title="Create new model type folder"><i class="fas fa-folder-plus"></i></button>
32
+ </div>
33
+ </div>
34
+ <div class="civitai-form-group">
35
+ <label for="civitai-subdir-select">Save Subfolder</label>
36
+ <div style="display:flex; gap:6px; align-items:center;">
37
+ <select id="civitai-subdir-select" class="civitai-select">
38
+ <option value="">(root)</option>
39
+ </select>
40
+ <button type="button" id="civitai-create-subdir" class="civitai-button small" title="Create new subfolder"><i class="fas fa-folder-plus"></i></button>
41
+ </div>
42
+ </div>
43
+ <div class="civitai-form-group">
44
+ <label for="civitai-model-version-id">Version ID (Optional)</label>
45
+ <input type="number" id="civitai-model-version-id" class="civitai-input" placeholder="Overrides URL/Latest">
46
+ </div>
47
+ </div>
48
+ <div class="civitai-form-row">
49
+ <div class="civitai-form-group">
50
+ <label for="civitai-custom-filename">Custom Filename (Optional)</label>
51
+ <input type="text" id="civitai-custom-filename" class="civitai-input" placeholder="Leave blank to use original name">
52
+ </div>
53
+ <div class="civitai-form-group">
54
+ <label for="civitai-connections">Connections</label>
55
+ <input type="number" id="civitai-connections" class="civitai-input" value="${numConnections}" min="1" max="16" step="1" required disabled>
56
+ <p style="font-size: 0.9em; color: #ccc; margin-top: 7px; margin-bottom: 15px;">Disabled: Only single connection possible for now</p>
57
+ </div>
58
+ </div>
59
+ <div class="civitai-form-group inline">
60
+ <input type="checkbox" id="civitai-force-redownload" class="civitai-checkbox">
61
+ <label for="civitai-force-redownload">Force Re-download (if exists)</label>
62
+ </div>
63
+ <div id="civitai-download-preview-area" class="civitai-download-preview-area" style="margin-top: 25px; margin-bottom: 25px; padding-top: 15px; border-top: 1px solid var(--border-color, #444);">
64
+ <!-- Preview content will be injected here -->
65
+ </div>
66
+ <button type="submit" id="civitai-download-submit" class="civitai-button primary">Start Download</button>
67
+ </form>
68
+ </div>
69
+ <div id="civitai-tab-search" class="civitai-downloader-tab-content">
70
+ <form id="civitai-search-form">
71
+ <div class="civitai-search-controls">
72
+ <input type="text" id="civitai-search-query" class="civitai-input" placeholder="Search Civitai...">
73
+ <select id="civitai-search-type" class="civitai-select">
74
+ <option value="any">Any Type</option>
75
+ </select>
76
+ <select id="civitai-search-base-model" class="civitai-select">
77
+ <option value="any">Any Base Model</option>
78
+ </select>
79
+ <select id="civitai-search-sort" class="civitai-select">
80
+ <option value="Relevancy">Relevancy</option>
81
+ <option value="Highest Rated">Highest Rated</option>
82
+ <option value="Most Liked">Most Liked</option>
83
+ <option value="Most Discussed">Most Discussed</option>
84
+ <option value="Most Collected">Most Collected</option>
85
+ <option value="Most Buzz">Most Buzz</option>
86
+ <option value="Most Downloaded">Most Downloaded</option>
87
+ <option value="Newest">Newest</option>
88
+ </select>
89
+ </div>
90
+ <button type="submit" id="civitai-search-submit" class="civitai-button primary">Search</button>
91
+ </form>
92
+ <div id="civitai-search-results" class="civitai-search-results"></div>
93
+ <div id="civitai-search-pagination" style="text-align: center; margin-top: 20px;"></div>
94
+ </div>
95
+ <div id="civitai-tab-status" class="civitai-downloader-tab-content">
96
+ <div id="civitai-status-content">
97
+ <div class="civitai-status-section">
98
+ <h3>Active Downloads</h3>
99
+ <div id="civitai-active-list" class="civitai-download-list">
100
+ <p>No active downloads.</p>
101
+ </div>
102
+ </div>
103
+ <div class="civitai-status-section">
104
+ <h3>Queued Downloads</h3>
105
+ <div id="civitai-queued-list" class="civitai-download-list">
106
+ <p>Download queue is empty.</p>
107
+ </div>
108
+ </div>
109
+ <div class="civitai-status-section">
110
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
111
+ <h3>Download History (Recent)</h3>
112
+ <button id="civitai-clear-history-button" class="civitai-button danger small" title="Clear all history items">
113
+ <i class="fas fa-trash-alt"></i> Clear History
114
+ </button>
115
+ </div>
116
+ <div id="civitai-history-list" class="civitai-download-list">
117
+ <p>No download history yet.</p>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ <div id="civitai-tab-settings" class="civitai-downloader-tab-content">
123
+ <form id="civitai-settings-form">
124
+ <div class="civitai-settings-container">
125
+ <div class="civitai-settings-section">
126
+ <h4>API & Defaults</h4>
127
+ <div class="civitai-form-group">
128
+ <label for="civitai-settings-api-key">Civitai API Key (Optional)</label>
129
+ <input type="password" id="civitai-settings-api-key" class="civitai-input" placeholder="Enter API key for higher limits / authenticated access" autocomplete="new-password">
130
+ <p style="font-size: 0.85em; color: #bbb; margin-top: 5px;">Needed for some downloads/features. Find keys at civitai.com/user/account</p>
131
+ </div>
132
+ <div class="civitai-form-group">
133
+ <label for="civitai-settings-connections">Default Connections</label>
134
+ <input type="number" id="civitai-settings-connections" class="civitai-input" value="1" min="1" max="16" step="1" required disabled>
135
+ <p style="font-size: 0.85em; color: #bbb; margin-top: 5px;">Disabled. Only single connection possible for now</p>
136
+ </div>
137
+ <div class="civitai-form-group">
138
+ <label for="civitai-settings-default-type">Default Model Type (for saving)</label>
139
+ <select id="civitai-settings-default-type" class="civitai-select" required></select>
140
+ </div>
141
+ </div>
142
+ <div class="civitai-settings-section">
143
+ <h4>Interface & Search</h4>
144
+ <div class="civitai-form-group inline">
145
+ <input type="checkbox" id="civitai-settings-auto-open-status" class="civitai-checkbox">
146
+ <label for="civitai-settings-auto-open-status">Switch to Status tab after starting download</label>
147
+ </div>
148
+ <div class="civitai-form-group inline">
149
+ <input type="checkbox" id="civitai-settings-hide-mature" class="civitai-checkbox" ${settings.hideMatureInSearch ? 'checked' : ''}>
150
+ <label for="civitai-settings-hide-mature">Hide R-rated (Mature) images in search (click to reveal)</label>
151
+ </div>
152
+ <div class="civitai-form-group">
153
+ <label for="civitai-settings-nsfw-threshold">NSFW Blur Threshold (nsfwLevel)</label>
154
+ <input type="number" id="civitai-settings-nsfw-threshold" class="civitai-input" value="${Number.isFinite(settings.nsfwBlurMinLevel) ? settings.nsfwBlurMinLevel : 4}" min="0" max="128" step="1">
155
+ <p style="font-size: 0.85em; color: #bbb; margin-top: 5px;">
156
+ Blur thumbnails when an image's <code>nsfwLevel</code> is greater than or equal to this value.
157
+ Higher numbers indicate more explicit content. None (Safe/PG): 1, Mild (PG-13): 2, Mature (R): 4, Adult (X): 5, Extra Explicit (R): 8, Explicit (XXX): 16/32+
158
+ </p>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ <button type="submit" id="civitai-settings-save" class="civitai-button primary" style="margin-top: 20px;">Save Settings</button>
163
+ </form>
164
+ </div>
165
+ </div>
166
+ <!-- Toast Notification Area -->
167
+ <div id="civitai-toast" class="civitai-toast"></div>
168
+ <!-- Confirmation Modal -->
169
+ <div id="civitai-confirm-clear-modal" class="civitai-confirmation-modal">
170
+ <div class="civitai-confirmation-modal-content">
171
+ <h4>Confirm Clear History</h4>
172
+ <p>Are you sure you want to clear the download history? This action cannot be undone.</p>
173
+ <div class="civitai-confirmation-modal-actions">
174
+ <button id="civitai-confirm-clear-no" class="civitai-button secondary">Cancel</button>
175
+ <button id="civitai-confirm-clear-yes" class="civitai-button danger">Confirm Clear</button>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ `;
181
+ }
custom_nodes/Civicomfy/web/js/utils/cookies.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Lightweight cookie helpers for Civicomfy UI
2
+ // Exports: setCookie, getCookie
3
+
4
+ export function setCookie(name, value, days) {
5
+ let expires = "";
6
+ if (days) {
7
+ const date = new Date();
8
+ date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
9
+ expires = "; expires=" + date.toUTCString();
10
+ }
11
+ document.cookie = `${name}=${value || ""}${expires}; path=/; SameSite=Lax`;
12
+ }
13
+
14
+ export function getCookie(name) {
15
+ const nameEQ = name + "=";
16
+ const parts = document.cookie.split(";");
17
+ for (let i = 0; i < parts.length; i++) {
18
+ let c = parts[i];
19
+ while (c.charAt(0) === " ") c = c.substring(1);
20
+ if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length);
21
+ }
22
+ return null;
23
+ }
24
+