comfygit-core 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- comfygit_core/analyzers/custom_node_scanner.py +109 -0
- comfygit_core/analyzers/git_change_parser.py +156 -0
- comfygit_core/analyzers/model_scanner.py +318 -0
- comfygit_core/analyzers/node_classifier.py +58 -0
- comfygit_core/analyzers/node_git_analyzer.py +77 -0
- comfygit_core/analyzers/status_scanner.py +362 -0
- comfygit_core/analyzers/workflow_dependency_parser.py +143 -0
- comfygit_core/caching/__init__.py +16 -0
- comfygit_core/caching/api_cache.py +210 -0
- comfygit_core/caching/base.py +212 -0
- comfygit_core/caching/comfyui_cache.py +100 -0
- comfygit_core/caching/custom_node_cache.py +320 -0
- comfygit_core/caching/workflow_cache.py +797 -0
- comfygit_core/clients/__init__.py +4 -0
- comfygit_core/clients/civitai_client.py +412 -0
- comfygit_core/clients/github_client.py +349 -0
- comfygit_core/clients/registry_client.py +230 -0
- comfygit_core/configs/comfyui_builtin_nodes.py +1614 -0
- comfygit_core/configs/comfyui_models.py +62 -0
- comfygit_core/configs/model_config.py +151 -0
- comfygit_core/constants.py +82 -0
- comfygit_core/core/environment.py +1635 -0
- comfygit_core/core/workspace.py +898 -0
- comfygit_core/factories/environment_factory.py +419 -0
- comfygit_core/factories/uv_factory.py +61 -0
- comfygit_core/factories/workspace_factory.py +109 -0
- comfygit_core/infrastructure/sqlite_manager.py +156 -0
- comfygit_core/integrations/__init__.py +7 -0
- comfygit_core/integrations/uv_command.py +318 -0
- comfygit_core/logging/logging_config.py +15 -0
- comfygit_core/managers/environment_git_orchestrator.py +316 -0
- comfygit_core/managers/environment_model_manager.py +296 -0
- comfygit_core/managers/export_import_manager.py +116 -0
- comfygit_core/managers/git_manager.py +667 -0
- comfygit_core/managers/model_download_manager.py +252 -0
- comfygit_core/managers/model_symlink_manager.py +166 -0
- comfygit_core/managers/node_manager.py +1378 -0
- comfygit_core/managers/pyproject_manager.py +1321 -0
- comfygit_core/managers/user_content_symlink_manager.py +436 -0
- comfygit_core/managers/uv_project_manager.py +569 -0
- comfygit_core/managers/workflow_manager.py +1944 -0
- comfygit_core/models/civitai.py +432 -0
- comfygit_core/models/commit.py +18 -0
- comfygit_core/models/environment.py +293 -0
- comfygit_core/models/exceptions.py +378 -0
- comfygit_core/models/manifest.py +132 -0
- comfygit_core/models/node_mapping.py +201 -0
- comfygit_core/models/protocols.py +248 -0
- comfygit_core/models/registry.py +63 -0
- comfygit_core/models/shared.py +356 -0
- comfygit_core/models/sync.py +42 -0
- comfygit_core/models/system.py +204 -0
- comfygit_core/models/workflow.py +914 -0
- comfygit_core/models/workspace_config.py +71 -0
- comfygit_core/py.typed +0 -0
- comfygit_core/repositories/migrate_paths.py +49 -0
- comfygit_core/repositories/model_repository.py +958 -0
- comfygit_core/repositories/node_mappings_repository.py +246 -0
- comfygit_core/repositories/workflow_repository.py +57 -0
- comfygit_core/repositories/workspace_config_repository.py +121 -0
- comfygit_core/resolvers/global_node_resolver.py +459 -0
- comfygit_core/resolvers/model_resolver.py +250 -0
- comfygit_core/services/import_analyzer.py +218 -0
- comfygit_core/services/model_downloader.py +422 -0
- comfygit_core/services/node_lookup_service.py +251 -0
- comfygit_core/services/registry_data_manager.py +161 -0
- comfygit_core/strategies/__init__.py +4 -0
- comfygit_core/strategies/auto.py +72 -0
- comfygit_core/strategies/confirmation.py +69 -0
- comfygit_core/utils/comfyui_ops.py +125 -0
- comfygit_core/utils/common.py +164 -0
- comfygit_core/utils/conflict_parser.py +232 -0
- comfygit_core/utils/dependency_parser.py +231 -0
- comfygit_core/utils/download.py +216 -0
- comfygit_core/utils/environment_cleanup.py +111 -0
- comfygit_core/utils/filesystem.py +178 -0
- comfygit_core/utils/git.py +1184 -0
- comfygit_core/utils/input_signature.py +145 -0
- comfygit_core/utils/model_categories.py +52 -0
- comfygit_core/utils/pytorch.py +71 -0
- comfygit_core/utils/requirements.py +211 -0
- comfygit_core/utils/retry.py +242 -0
- comfygit_core/utils/symlink_utils.py +119 -0
- comfygit_core/utils/system_detector.py +258 -0
- comfygit_core/utils/uuid.py +28 -0
- comfygit_core/utils/uv_error_handler.py +158 -0
- comfygit_core/utils/version.py +73 -0
- comfygit_core/utils/workflow_hash.py +90 -0
- comfygit_core/validation/resolution_tester.py +297 -0
- comfygit_core-0.2.0.dist-info/METADATA +939 -0
- comfygit_core-0.2.0.dist-info/RECORD +93 -0
- comfygit_core-0.2.0.dist-info/WHEEL +4 -0
- comfygit_core-0.2.0.dist-info/licenses/LICENSE.txt +661 -0
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
"""ModelIndexManager - Model-specific database operations and schema management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from blake3 import blake3
|
|
8
|
+
|
|
9
|
+
from ..logging.logging_config import get_logger
|
|
10
|
+
from ..models.exceptions import ComfyDockError
|
|
11
|
+
from ..models.shared import ModelWithLocation
|
|
12
|
+
from ..infrastructure.sqlite_manager import SQLiteManager
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
# Database schema version
|
|
17
|
+
SCHEMA_VERSION = 9
|
|
18
|
+
|
|
19
|
+
# Models table: One entry per unique model file (by hash)
|
|
20
|
+
CREATE_MODELS_TABLE = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS models (
|
|
22
|
+
hash TEXT PRIMARY KEY,
|
|
23
|
+
file_size INTEGER NOT NULL,
|
|
24
|
+
blake3_hash TEXT,
|
|
25
|
+
sha256_hash TEXT,
|
|
26
|
+
first_seen INTEGER NOT NULL,
|
|
27
|
+
metadata TEXT DEFAULT '{}'
|
|
28
|
+
)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# Model locations: All instances of each model across all directories
|
|
32
|
+
CREATE_MODEL_LOCATIONS_TABLE = """
|
|
33
|
+
CREATE TABLE IF NOT EXISTS model_locations (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
model_hash TEXT NOT NULL,
|
|
36
|
+
base_directory TEXT NOT NULL,
|
|
37
|
+
relative_path TEXT NOT NULL,
|
|
38
|
+
filename TEXT NOT NULL,
|
|
39
|
+
mtime REAL NOT NULL,
|
|
40
|
+
last_seen INTEGER NOT NULL,
|
|
41
|
+
FOREIGN KEY (model_hash) REFERENCES models(hash) ON DELETE CASCADE,
|
|
42
|
+
UNIQUE(base_directory, relative_path)
|
|
43
|
+
)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
# Model sources: Track where models can be downloaded from
|
|
47
|
+
CREATE_MODEL_SOURCES_TABLE = """
|
|
48
|
+
CREATE TABLE IF NOT EXISTS model_sources (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
model_hash TEXT NOT NULL,
|
|
51
|
+
source_type TEXT NOT NULL,
|
|
52
|
+
source_url TEXT NOT NULL,
|
|
53
|
+
metadata TEXT DEFAULT '{}',
|
|
54
|
+
added_time INTEGER NOT NULL,
|
|
55
|
+
FOREIGN KEY (model_hash) REFERENCES models(hash) ON DELETE CASCADE,
|
|
56
|
+
UNIQUE(model_hash, source_url)
|
|
57
|
+
)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
CREATE_SCHEMA_INFO_TABLE = """
|
|
61
|
+
CREATE TABLE IF NOT EXISTS schema_info (
|
|
62
|
+
version INTEGER PRIMARY KEY
|
|
63
|
+
)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
# Indexes for efficient queries
|
|
67
|
+
CREATE_LOCATIONS_HASH_INDEX = """
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_locations_hash ON model_locations(model_hash)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
CREATE_LOCATIONS_PATH_INDEX = """
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_locations_path ON model_locations(relative_path)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
CREATE_MODELS_BLAKE3_INDEX = """
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_models_blake3 ON models(blake3_hash)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
CREATE_MODELS_SHA256_INDEX = """
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_models_sha256 ON models(sha256_hash)
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
CREATE_LOCATIONS_FILENAME_INDEX = """
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_locations_filename ON model_locations(filename)
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
CREATE_SOURCES_HASH_INDEX = """
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_sources_hash ON model_sources(model_hash)
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
CREATE_SOURCES_TYPE_INDEX = """
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_sources_type ON model_sources(source_type)
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ModelRepository:
|
|
97
|
+
"""Model-specific database operations and schema management."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, db_path: Path, current_directory: Path | None = None):
|
|
100
|
+
"""Initialize ModelIndexManager.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
db_path: Path to SQLite database file
|
|
104
|
+
current_directory: Current models directory for filtering queries
|
|
105
|
+
"""
|
|
106
|
+
self.db_path = db_path
|
|
107
|
+
self.sqlite = SQLiteManager(db_path)
|
|
108
|
+
self.current_directory = current_directory
|
|
109
|
+
self.ensure_schema()
|
|
110
|
+
|
|
111
|
+
def set_current_directory(self, directory: Path) -> None:
|
|
112
|
+
"""Set the current models directory for query filtering."""
|
|
113
|
+
self.current_directory = directory
|
|
114
|
+
|
|
115
|
+
def ensure_schema(self) -> None:
|
|
116
|
+
"""Create database schema if needed."""
|
|
117
|
+
self.sqlite.create_table(CREATE_MODELS_TABLE)
|
|
118
|
+
self.sqlite.create_table(CREATE_MODEL_LOCATIONS_TABLE)
|
|
119
|
+
self.sqlite.create_table(CREATE_MODEL_SOURCES_TABLE)
|
|
120
|
+
self.sqlite.create_table(CREATE_SCHEMA_INFO_TABLE)
|
|
121
|
+
self.sqlite.execute_query(CREATE_LOCATIONS_HASH_INDEX)
|
|
122
|
+
self.sqlite.execute_query(CREATE_LOCATIONS_PATH_INDEX)
|
|
123
|
+
self.sqlite.execute_query(CREATE_LOCATIONS_FILENAME_INDEX)
|
|
124
|
+
self.sqlite.execute_query(CREATE_SOURCES_HASH_INDEX)
|
|
125
|
+
self.sqlite.execute_query(CREATE_SOURCES_TYPE_INDEX)
|
|
126
|
+
self.sqlite.execute_query(CREATE_MODELS_BLAKE3_INDEX)
|
|
127
|
+
self.sqlite.execute_query(CREATE_MODELS_SHA256_INDEX)
|
|
128
|
+
|
|
129
|
+
# Check schema version
|
|
130
|
+
current_version = self.get_schema_version()
|
|
131
|
+
if current_version != SCHEMA_VERSION:
|
|
132
|
+
self.migrate_schema(current_version, SCHEMA_VERSION)
|
|
133
|
+
|
|
134
|
+
def get_schema_version(self) -> int:
|
|
135
|
+
"""Get current schema version from database.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Schema version number, 0 if not set
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
results = self.sqlite.execute_query("SELECT version FROM schema_info LIMIT 1")
|
|
142
|
+
if results:
|
|
143
|
+
return results[0]['version']
|
|
144
|
+
else:
|
|
145
|
+
# First time setup - insert current version
|
|
146
|
+
self.sqlite.execute_write(
|
|
147
|
+
"INSERT INTO schema_info (version) VALUES (?)",
|
|
148
|
+
(SCHEMA_VERSION,)
|
|
149
|
+
)
|
|
150
|
+
return SCHEMA_VERSION
|
|
151
|
+
except ComfyDockError:
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
def migrate_schema(self, from_version: int, to_version: int) -> None:
|
|
155
|
+
"""Migrate database schema between versions.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
from_version: Current schema version
|
|
159
|
+
to_version: Target schema version
|
|
160
|
+
"""
|
|
161
|
+
if from_version == to_version:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
logger.info(f"Dropping old schema v{from_version} and creating new v{to_version}")
|
|
165
|
+
|
|
166
|
+
# Drop everything and recreate
|
|
167
|
+
self.sqlite.execute_write("DROP TABLE IF EXISTS model_sources")
|
|
168
|
+
self.sqlite.execute_write("DROP TABLE IF EXISTS model_locations")
|
|
169
|
+
self.sqlite.execute_write("DROP TABLE IF EXISTS models")
|
|
170
|
+
self.sqlite.execute_write("DROP TABLE IF EXISTS schema_info")
|
|
171
|
+
|
|
172
|
+
# Recreate with new schema
|
|
173
|
+
self.sqlite.create_table(CREATE_MODELS_TABLE)
|
|
174
|
+
self.sqlite.create_table(CREATE_MODEL_LOCATIONS_TABLE)
|
|
175
|
+
self.sqlite.create_table(CREATE_MODEL_SOURCES_TABLE)
|
|
176
|
+
self.sqlite.create_table(CREATE_SCHEMA_INFO_TABLE)
|
|
177
|
+
self.sqlite.execute_query(CREATE_LOCATIONS_HASH_INDEX)
|
|
178
|
+
self.sqlite.execute_query(CREATE_LOCATIONS_PATH_INDEX)
|
|
179
|
+
self.sqlite.execute_query(CREATE_LOCATIONS_FILENAME_INDEX)
|
|
180
|
+
self.sqlite.execute_query(CREATE_SOURCES_HASH_INDEX)
|
|
181
|
+
self.sqlite.execute_query(CREATE_SOURCES_TYPE_INDEX)
|
|
182
|
+
self.sqlite.execute_query(CREATE_MODELS_BLAKE3_INDEX)
|
|
183
|
+
self.sqlite.execute_query(CREATE_MODELS_SHA256_INDEX)
|
|
184
|
+
|
|
185
|
+
# Set version
|
|
186
|
+
self.sqlite.execute_write(
|
|
187
|
+
"INSERT INTO schema_info (version) VALUES (?)",
|
|
188
|
+
(to_version,)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def ensure_model(self, hash: str, file_size: int, blake3_hash: str | None = None,
|
|
192
|
+
sha256_hash: str | None = None) -> None:
|
|
193
|
+
"""Ensure model exists in models table.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
hash: Model hash (short hash or blake3 if collision)
|
|
197
|
+
file_size: File size in bytes
|
|
198
|
+
blake3_hash: Full blake3 hash if available
|
|
199
|
+
sha256_hash: SHA256 hash if available
|
|
200
|
+
"""
|
|
201
|
+
query = """
|
|
202
|
+
INSERT OR IGNORE INTO models
|
|
203
|
+
(hash, file_size, blake3_hash, sha256_hash, first_seen, metadata)
|
|
204
|
+
VALUES (?, ?, ?, ?, ?, '{}')
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
self.sqlite.execute_write(
|
|
208
|
+
query,
|
|
209
|
+
(hash, file_size, blake3_hash, sha256_hash, int(datetime.now().timestamp()))
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
logger.debug(f"Ensured model in index: {hash[:8]}...")
|
|
213
|
+
|
|
214
|
+
def add_location(self, model_hash: str, base_directory: Path, relative_path: str,
|
|
215
|
+
filename: str, mtime: float) -> None:
|
|
216
|
+
"""Add or update a file location for a model.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
model_hash: Hash of the model this location belongs to
|
|
220
|
+
base_directory: Base directory path (e.g., /path/to/models)
|
|
221
|
+
relative_path: Path relative to base directory
|
|
222
|
+
filename: Just the filename part
|
|
223
|
+
mtime: File modification time
|
|
224
|
+
"""
|
|
225
|
+
query = """
|
|
226
|
+
INSERT OR REPLACE INTO model_locations
|
|
227
|
+
(model_hash, base_directory, relative_path, filename, mtime, last_seen)
|
|
228
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
base_dir_str = str(base_directory.resolve())
|
|
232
|
+
# Normalize to forward slashes for cross-platform consistency
|
|
233
|
+
normalized_path = relative_path.replace('\\', '/')
|
|
234
|
+
self.sqlite.execute_write(
|
|
235
|
+
query,
|
|
236
|
+
(model_hash, base_dir_str, normalized_path, filename, mtime, int(datetime.now().timestamp()))
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
logger.debug(f"Added location: {base_dir_str}/{relative_path} for model {model_hash[:8]}...")
|
|
240
|
+
|
|
241
|
+
def get_model(self, hash: str) -> ModelWithLocation | None:
|
|
242
|
+
"""Get model by hash.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
hash: Model hash to look up
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
ModelWithLocation or None if not found
|
|
249
|
+
"""
|
|
250
|
+
result = self.find_model_by_hash(hash)
|
|
251
|
+
return result[0] if result else None
|
|
252
|
+
|
|
253
|
+
def has_model(self, hash: str) -> bool:
|
|
254
|
+
"""Check if model exists by hash.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
hash: Model hash to check
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if model exists, False otherwise
|
|
261
|
+
"""
|
|
262
|
+
query = "SELECT 1 FROM models WHERE hash = ? LIMIT 1"
|
|
263
|
+
results = self.sqlite.execute_query(query, (hash,))
|
|
264
|
+
return len(results) > 0
|
|
265
|
+
|
|
266
|
+
def get_locations(self, model_hash: str) -> list[dict]:
|
|
267
|
+
"""Get all locations for a model.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
model_hash: Hash of model to get locations for
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
List of location dictionaries
|
|
274
|
+
"""
|
|
275
|
+
query = "SELECT * FROM model_locations WHERE model_hash = ? ORDER BY relative_path"
|
|
276
|
+
return self.sqlite.execute_query(query, (model_hash,))
|
|
277
|
+
|
|
278
|
+
def get_all_locations(self, base_directory: Path | None = None) -> list[dict]:
|
|
279
|
+
"""Get model locations, optionally filtered by base directory.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
base_directory: If provided, only return locations from this directory
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
List of location dictionaries
|
|
286
|
+
"""
|
|
287
|
+
if base_directory:
|
|
288
|
+
base_dir_str = str(base_directory.resolve())
|
|
289
|
+
query = "SELECT * FROM model_locations WHERE base_directory = ? ORDER BY relative_path"
|
|
290
|
+
return self.sqlite.execute_query(query, (base_dir_str,))
|
|
291
|
+
else:
|
|
292
|
+
query = "SELECT * FROM model_locations ORDER BY relative_path"
|
|
293
|
+
return self.sqlite.execute_query(query)
|
|
294
|
+
|
|
295
|
+
def remove_location(self, relative_path: str) -> bool:
|
|
296
|
+
"""Remove a specific location.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
relative_path: Path to remove
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
True if location was removed, False if not found
|
|
303
|
+
"""
|
|
304
|
+
query = "DELETE FROM model_locations WHERE relative_path = ?"
|
|
305
|
+
rows_affected = self.sqlite.execute_write(query, (relative_path,))
|
|
306
|
+
return rows_affected > 0
|
|
307
|
+
|
|
308
|
+
def clean_stale_locations(self, models_dir: Path) -> int:
|
|
309
|
+
"""Remove locations from this directory that no longer exist on disk.
|
|
310
|
+
|
|
311
|
+
Only checks locations belonging to the specified directory.
|
|
312
|
+
Preserves locations from other directories.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
models_dir: Base models directory path
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Number of stale locations removed
|
|
319
|
+
"""
|
|
320
|
+
base_dir_str = str(models_dir.resolve())
|
|
321
|
+
query = "SELECT id, relative_path FROM model_locations WHERE base_directory = ?"
|
|
322
|
+
results = self.sqlite.execute_query(query, (base_dir_str,))
|
|
323
|
+
|
|
324
|
+
removed_count = 0
|
|
325
|
+
for row in results:
|
|
326
|
+
file_path = models_dir / row['relative_path']
|
|
327
|
+
if not file_path.exists():
|
|
328
|
+
delete_query = "DELETE FROM model_locations WHERE id = ?"
|
|
329
|
+
self.sqlite.execute_write(delete_query, (row['id'],))
|
|
330
|
+
removed_count += 1
|
|
331
|
+
|
|
332
|
+
if removed_count > 0:
|
|
333
|
+
logger.info(f"Cleaned up {removed_count} stale model locations from {base_dir_str}")
|
|
334
|
+
|
|
335
|
+
return removed_count
|
|
336
|
+
|
|
337
|
+
def get_all_models(self, base_directory: Path | None = "USE_CURRENT") -> list[ModelWithLocation]:
|
|
338
|
+
"""Get all models with their locations, optionally filtered by directory.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
base_directory: Directory to filter by. Defaults to current_directory.
|
|
342
|
+
Pass None explicitly to get models from all directories.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
List of ModelWithLocation objects
|
|
346
|
+
"""
|
|
347
|
+
if base_directory == "USE_CURRENT":
|
|
348
|
+
base_directory = self.current_directory
|
|
349
|
+
|
|
350
|
+
if base_directory:
|
|
351
|
+
base_dir_str = str(base_directory.resolve())
|
|
352
|
+
query = """
|
|
353
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
354
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
355
|
+
FROM models m
|
|
356
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
357
|
+
WHERE l.base_directory = ?
|
|
358
|
+
ORDER BY l.relative_path
|
|
359
|
+
"""
|
|
360
|
+
results = self.sqlite.execute_query(query, (base_dir_str,))
|
|
361
|
+
else:
|
|
362
|
+
query = """
|
|
363
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
364
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
365
|
+
FROM models m
|
|
366
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
367
|
+
ORDER BY l.relative_path
|
|
368
|
+
"""
|
|
369
|
+
results = self.sqlite.execute_query(query)
|
|
370
|
+
models = []
|
|
371
|
+
|
|
372
|
+
for row in results:
|
|
373
|
+
metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
|
374
|
+
model = ModelWithLocation(
|
|
375
|
+
hash=row['hash'],
|
|
376
|
+
file_size=row['file_size'],
|
|
377
|
+
blake3_hash=row['blake3_hash'],
|
|
378
|
+
sha256_hash=row['sha256_hash'],
|
|
379
|
+
relative_path=row['relative_path'],
|
|
380
|
+
filename=row['filename'],
|
|
381
|
+
mtime=row['mtime'],
|
|
382
|
+
last_seen=row['last_seen'],
|
|
383
|
+
base_directory=row.get('base_directory'),
|
|
384
|
+
metadata=metadata
|
|
385
|
+
)
|
|
386
|
+
models.append(model)
|
|
387
|
+
|
|
388
|
+
return models
|
|
389
|
+
|
|
390
|
+
def find_model_by_hash(self, hash_query: str, base_directory: Path | None = "USE_CURRENT") -> list[ModelWithLocation]:
|
|
391
|
+
"""Find models by hash prefix, optionally filtered by directory.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
hash_query: Hash or hash prefix to search for
|
|
395
|
+
base_directory: Directory to filter by. Defaults to current_directory.
|
|
396
|
+
Pass None explicitly to search all directories.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
List of matching ModelWithLocation objects
|
|
400
|
+
"""
|
|
401
|
+
if base_directory == "USE_CURRENT":
|
|
402
|
+
base_directory = self.current_directory
|
|
403
|
+
|
|
404
|
+
# Support both exact match and prefix matching
|
|
405
|
+
if base_directory:
|
|
406
|
+
base_dir_str = str(base_directory.resolve())
|
|
407
|
+
query = """
|
|
408
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
409
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
410
|
+
FROM models m
|
|
411
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
412
|
+
WHERE (m.hash LIKE ? OR m.blake3_hash LIKE ? OR m.sha256_hash LIKE ?)
|
|
413
|
+
AND l.base_directory = ?
|
|
414
|
+
ORDER BY l.relative_path
|
|
415
|
+
"""
|
|
416
|
+
search_pattern = f"{hash_query}%"
|
|
417
|
+
results = self.sqlite.execute_query(query, (search_pattern, search_pattern, search_pattern, base_dir_str))
|
|
418
|
+
else:
|
|
419
|
+
query = """
|
|
420
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
421
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
422
|
+
FROM models m
|
|
423
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
424
|
+
WHERE m.hash LIKE ? OR m.blake3_hash LIKE ? OR m.sha256_hash LIKE ?
|
|
425
|
+
ORDER BY l.relative_path
|
|
426
|
+
"""
|
|
427
|
+
search_pattern = f"{hash_query}%"
|
|
428
|
+
results = self.sqlite.execute_query(query, (search_pattern, search_pattern, search_pattern))
|
|
429
|
+
|
|
430
|
+
models = []
|
|
431
|
+
for row in results:
|
|
432
|
+
metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
|
433
|
+
model = ModelWithLocation(
|
|
434
|
+
hash=row['hash'],
|
|
435
|
+
file_size=row['file_size'],
|
|
436
|
+
blake3_hash=row['blake3_hash'],
|
|
437
|
+
sha256_hash=row['sha256_hash'],
|
|
438
|
+
relative_path=row['relative_path'],
|
|
439
|
+
filename=row['filename'],
|
|
440
|
+
mtime=row['mtime'],
|
|
441
|
+
last_seen=row['last_seen'],
|
|
442
|
+
base_directory=row.get('base_directory'),
|
|
443
|
+
metadata=metadata
|
|
444
|
+
)
|
|
445
|
+
models.append(model)
|
|
446
|
+
|
|
447
|
+
return models
|
|
448
|
+
|
|
449
|
+
def find_by_filename(self, filename_query: str, base_directory: Path | None = "USE_CURRENT") -> list[ModelWithLocation]:
|
|
450
|
+
"""Find models by filename pattern.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
filename_query: Filename or pattern to search for
|
|
454
|
+
base_directory: Directory to filter by. Defaults to current_directory.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
List of matching ModelWithLocation objects
|
|
458
|
+
"""
|
|
459
|
+
if base_directory == "USE_CURRENT":
|
|
460
|
+
base_directory = self.current_directory
|
|
461
|
+
|
|
462
|
+
if base_directory:
|
|
463
|
+
base_dir_str = str(base_directory.resolve())
|
|
464
|
+
query = """
|
|
465
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
466
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
467
|
+
FROM models m
|
|
468
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
469
|
+
WHERE l.filename LIKE ? AND l.base_directory = ?
|
|
470
|
+
ORDER BY l.relative_path
|
|
471
|
+
"""
|
|
472
|
+
search_pattern = f"%{filename_query}%"
|
|
473
|
+
results = self.sqlite.execute_query(query, (search_pattern, base_dir_str))
|
|
474
|
+
else:
|
|
475
|
+
query = """
|
|
476
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
477
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
478
|
+
FROM models m
|
|
479
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
480
|
+
WHERE l.filename LIKE ?
|
|
481
|
+
ORDER BY l.relative_path
|
|
482
|
+
"""
|
|
483
|
+
search_pattern = f"%{filename_query}%"
|
|
484
|
+
results = self.sqlite.execute_query(query, (search_pattern,))
|
|
485
|
+
|
|
486
|
+
models = []
|
|
487
|
+
for row in results:
|
|
488
|
+
metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
|
489
|
+
model = ModelWithLocation(
|
|
490
|
+
hash=row['hash'],
|
|
491
|
+
file_size=row['file_size'],
|
|
492
|
+
blake3_hash=row['blake3_hash'],
|
|
493
|
+
sha256_hash=row['sha256_hash'],
|
|
494
|
+
relative_path=row['relative_path'],
|
|
495
|
+
filename=row['filename'],
|
|
496
|
+
mtime=row['mtime'],
|
|
497
|
+
last_seen=row['last_seen'],
|
|
498
|
+
base_directory=row.get('base_directory'),
|
|
499
|
+
metadata=metadata
|
|
500
|
+
)
|
|
501
|
+
models.append(model)
|
|
502
|
+
|
|
503
|
+
return models
|
|
504
|
+
|
|
505
|
+
def get_sources(self, model_hash: str) -> list[dict]:
|
|
506
|
+
"""Get all download sources for a model.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
model_hash: Hash of model to get sources for
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
List of source dictionaries with type, url, and metadata
|
|
513
|
+
"""
|
|
514
|
+
query = """
|
|
515
|
+
SELECT source_type, source_url, metadata, added_time
|
|
516
|
+
FROM model_sources
|
|
517
|
+
WHERE model_hash = ?
|
|
518
|
+
ORDER BY added_time DESC
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
results = self.sqlite.execute_query(query, (model_hash,))
|
|
522
|
+
sources = []
|
|
523
|
+
|
|
524
|
+
for row in results:
|
|
525
|
+
metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
|
526
|
+
source = {
|
|
527
|
+
'type': row['source_type'],
|
|
528
|
+
'url': row['source_url'],
|
|
529
|
+
'metadata': metadata,
|
|
530
|
+
'added_time': row['added_time']
|
|
531
|
+
}
|
|
532
|
+
sources.append(source)
|
|
533
|
+
|
|
534
|
+
return sources
|
|
535
|
+
|
|
536
|
+
def add_source(self, model_hash: str, source_type: str, source_url: str, metadata: dict | None = None) -> None:
|
|
537
|
+
"""Add a download source for a model.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
model_hash: Hash of the model
|
|
541
|
+
source_type: Type of source (civitai, huggingface, custom, etc.)
|
|
542
|
+
source_url: URL where model can be downloaded
|
|
543
|
+
metadata: Optional metadata about the source
|
|
544
|
+
"""
|
|
545
|
+
if metadata is None:
|
|
546
|
+
metadata = {}
|
|
547
|
+
|
|
548
|
+
query = """
|
|
549
|
+
INSERT OR REPLACE INTO model_sources
|
|
550
|
+
(model_hash, source_type, source_url, metadata, added_time)
|
|
551
|
+
VALUES (?, ?, ?, ?, ?)
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
self.sqlite.execute_write(
|
|
555
|
+
query,
|
|
556
|
+
(model_hash, source_type, source_url, json.dumps(metadata), int(datetime.now().timestamp()))
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
logger.debug(f"Added source for {model_hash[:8]}...: {source_type} - {source_url}")
|
|
560
|
+
|
|
561
|
+
def get_stats(self, base_directory: Path | None = "USE_CURRENT") -> dict[str, int]:
|
|
562
|
+
"""Get index statistics, optionally filtered by directory.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
base_directory: Directory to filter by. Defaults to current_directory.
|
|
566
|
+
Pass None explicitly to get stats from all directories.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
Dictionary with index statistics
|
|
570
|
+
"""
|
|
571
|
+
if base_directory == "USE_CURRENT":
|
|
572
|
+
base_directory = self.current_directory
|
|
573
|
+
|
|
574
|
+
if base_directory:
|
|
575
|
+
base_dir_str = str(base_directory.resolve())
|
|
576
|
+
models_query = """
|
|
577
|
+
SELECT COUNT(DISTINCT m.hash) as count
|
|
578
|
+
FROM models m
|
|
579
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
580
|
+
WHERE l.base_directory = ?
|
|
581
|
+
"""
|
|
582
|
+
locations_query = "SELECT COUNT(*) as count FROM model_locations WHERE base_directory = ?"
|
|
583
|
+
|
|
584
|
+
models_result = self.sqlite.execute_query(models_query, (base_dir_str,))
|
|
585
|
+
locations_result = self.sqlite.execute_query(locations_query, (base_dir_str,))
|
|
586
|
+
else:
|
|
587
|
+
models_query = """
|
|
588
|
+
SELECT COUNT(DISTINCT m.hash) as count
|
|
589
|
+
FROM models m
|
|
590
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
591
|
+
"""
|
|
592
|
+
locations_query = "SELECT COUNT(*) as count FROM model_locations"
|
|
593
|
+
|
|
594
|
+
models_result = self.sqlite.execute_query(models_query)
|
|
595
|
+
locations_result = self.sqlite.execute_query(locations_query)
|
|
596
|
+
|
|
597
|
+
sources_query = "SELECT COUNT(*) as count FROM model_sources"
|
|
598
|
+
sources_result = self.sqlite.execute_query(sources_query)
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
'total_models': models_result[0]['count'] if models_result else 0,
|
|
602
|
+
'total_locations': locations_result[0]['count'] if locations_result else 0,
|
|
603
|
+
'total_sources': sources_result[0]['count'] if sources_result else 0
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
def update_blake3(self, hash: str, blake3_hash: str) -> None:
|
|
607
|
+
"""Update full BLAKE3 hash for existing model.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
hash: Model hash (primary key)
|
|
611
|
+
blake3_hash: Computed full BLAKE3 hash
|
|
612
|
+
"""
|
|
613
|
+
query = "UPDATE models SET blake3_hash = ? WHERE hash = ?"
|
|
614
|
+
rows_affected = self.sqlite.execute_write(query, (blake3_hash, hash))
|
|
615
|
+
|
|
616
|
+
if rows_affected == 0:
|
|
617
|
+
raise ComfyDockError(f"Model with hash {hash} not found in index")
|
|
618
|
+
|
|
619
|
+
logger.debug(f"Updated BLAKE3 for {hash[:8]}...: {blake3_hash[:8]}...")
|
|
620
|
+
|
|
621
|
+
def update_sha256(self, hash: str, sha256_hash: str) -> None:
|
|
622
|
+
"""Update SHA256 hash for existing model.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
hash: Model hash (primary key)
|
|
626
|
+
sha256_hash: Computed SHA256 hash
|
|
627
|
+
"""
|
|
628
|
+
query = "UPDATE models SET sha256_hash = ? WHERE hash = ?"
|
|
629
|
+
rows_affected = self.sqlite.execute_write(query, (sha256_hash, hash))
|
|
630
|
+
|
|
631
|
+
if rows_affected == 0:
|
|
632
|
+
raise ComfyDockError(f"Model with hash {hash} not found in index")
|
|
633
|
+
|
|
634
|
+
logger.debug(f"Updated SHA256 for {hash[:8]}...: {sha256_hash[:8]}...")
|
|
635
|
+
|
|
636
|
+
def calculate_short_hash(self, file_path: Path) -> str:
|
|
637
|
+
"""Calculate fast short hash by sampling file chunks.
|
|
638
|
+
|
|
639
|
+
Samples 5MB each from start, middle, and end of file plus file size.
|
|
640
|
+
Provides excellent duplicate detection with ~200ms vs 30-60s for full hash.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
file_path: Path to model file
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
16-character hex-encoded hash string (64 bits)
|
|
647
|
+
|
|
648
|
+
Raises:
|
|
649
|
+
ComfyDockError: If hash calculation fails
|
|
650
|
+
"""
|
|
651
|
+
try:
|
|
652
|
+
if not file_path.exists() or not file_path.is_file():
|
|
653
|
+
raise ComfyDockError(f"File does not exist or is not a regular file: {file_path}")
|
|
654
|
+
|
|
655
|
+
file_size = file_path.stat().st_size
|
|
656
|
+
hasher = blake3()
|
|
657
|
+
|
|
658
|
+
# Include file size as discriminator
|
|
659
|
+
hasher.update(str(file_size).encode())
|
|
660
|
+
|
|
661
|
+
chunk_size = 5 * 1024 * 1024 # 5MB chunks
|
|
662
|
+
|
|
663
|
+
with open(file_path, 'rb') as f:
|
|
664
|
+
# Start chunk
|
|
665
|
+
hasher.update(f.read(chunk_size))
|
|
666
|
+
|
|
667
|
+
# Middle and end chunks for files > 30MB
|
|
668
|
+
if file_size > 30 * 1024 * 1024:
|
|
669
|
+
# Middle chunk
|
|
670
|
+
f.seek(file_size // 2 - chunk_size // 2)
|
|
671
|
+
hasher.update(f.read(chunk_size))
|
|
672
|
+
|
|
673
|
+
# End chunk
|
|
674
|
+
f.seek(-chunk_size, 2)
|
|
675
|
+
hasher.update(f.read(chunk_size))
|
|
676
|
+
|
|
677
|
+
return hasher.hexdigest()[:16]
|
|
678
|
+
|
|
679
|
+
except Exception as e:
|
|
680
|
+
raise ComfyDockError(f"Failed to calculate short hash for {file_path}: {e}")
|
|
681
|
+
|
|
682
|
+
def compute_blake3(self, file_path: Path, chunk_size: int = 8192 * 1024) -> str:
|
|
683
|
+
"""Calculate full Blake3 hash for model file.
|
|
684
|
+
|
|
685
|
+
Only used when short hash collision detected or explicit verification needed.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
file_path: Path to model file
|
|
689
|
+
chunk_size: Chunk size for streaming hash calculation
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
Hex-encoded hash string
|
|
693
|
+
|
|
694
|
+
Raises:
|
|
695
|
+
ComfyDockError: If hash calculation fails
|
|
696
|
+
"""
|
|
697
|
+
try:
|
|
698
|
+
hasher = blake3()
|
|
699
|
+
|
|
700
|
+
with open(file_path, 'rb') as f:
|
|
701
|
+
while chunk := f.read(chunk_size):
|
|
702
|
+
hasher.update(chunk)
|
|
703
|
+
|
|
704
|
+
return hasher.hexdigest()
|
|
705
|
+
|
|
706
|
+
except Exception as e:
|
|
707
|
+
raise ComfyDockError(f"Failed to calculate hash for {file_path}: {e}")
|
|
708
|
+
|
|
709
|
+
def compute_sha256(self, file_path: Path) -> str:
|
|
710
|
+
"""Compute SHA256 hash for external compatibility.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
file_path: Path to file
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
SHA256 hash string
|
|
717
|
+
"""
|
|
718
|
+
import hashlib
|
|
719
|
+
|
|
720
|
+
sha256_hash = hashlib.sha256()
|
|
721
|
+
with open(file_path, "rb") as f:
|
|
722
|
+
# Read file in chunks to handle large files
|
|
723
|
+
for byte_block in iter(lambda: f.read(8192), b""):
|
|
724
|
+
sha256_hash.update(byte_block)
|
|
725
|
+
|
|
726
|
+
return sha256_hash.hexdigest()
|
|
727
|
+
|
|
728
|
+
def get_by_category(self, category: str, base_directory: Path | None = "USE_CURRENT") -> list[ModelWithLocation]:
|
|
729
|
+
"""Get all models in a specific category, optionally filtered by directory.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
category: Category name (e.g., "checkpoints", "loras", "vae")
|
|
733
|
+
base_directory: Directory to filter by. Defaults to current_directory.
|
|
734
|
+
Pass None explicitly to search all directories.
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
List of ModelWithLocation objects in that category
|
|
738
|
+
"""
|
|
739
|
+
if base_directory == "USE_CURRENT":
|
|
740
|
+
base_directory = self.current_directory
|
|
741
|
+
|
|
742
|
+
if base_directory:
|
|
743
|
+
base_dir_str = str(base_directory.resolve())
|
|
744
|
+
query = """
|
|
745
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
746
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
747
|
+
FROM models m
|
|
748
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
749
|
+
WHERE l.relative_path LIKE ? AND l.base_directory = ?
|
|
750
|
+
ORDER BY l.filename
|
|
751
|
+
"""
|
|
752
|
+
search_pattern = f"{category}/%"
|
|
753
|
+
results = self.sqlite.execute_query(query, (search_pattern, base_dir_str))
|
|
754
|
+
else:
|
|
755
|
+
query = """
|
|
756
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
757
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
758
|
+
FROM models m
|
|
759
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
760
|
+
WHERE l.relative_path LIKE ?
|
|
761
|
+
ORDER BY l.filename
|
|
762
|
+
"""
|
|
763
|
+
search_pattern = f"{category}/%"
|
|
764
|
+
results = self.sqlite.execute_query(query, (search_pattern,))
|
|
765
|
+
|
|
766
|
+
models = []
|
|
767
|
+
for row in results:
|
|
768
|
+
metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
|
769
|
+
model = ModelWithLocation(
|
|
770
|
+
hash=row['hash'],
|
|
771
|
+
file_size=row['file_size'],
|
|
772
|
+
blake3_hash=row['blake3_hash'],
|
|
773
|
+
sha256_hash=row['sha256_hash'],
|
|
774
|
+
relative_path=row['relative_path'],
|
|
775
|
+
filename=row['filename'],
|
|
776
|
+
mtime=row['mtime'],
|
|
777
|
+
last_seen=row['last_seen'],
|
|
778
|
+
base_directory=row.get('base_directory'),
|
|
779
|
+
metadata=metadata
|
|
780
|
+
)
|
|
781
|
+
models.append(model)
|
|
782
|
+
|
|
783
|
+
return models
|
|
784
|
+
|
|
785
|
+
def find_by_exact_path(self, relative_path: str, base_directory: Path | None = "USE_CURRENT") -> ModelWithLocation | None:
|
|
786
|
+
"""Find model by exact relative path, optionally filtered by directory.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
relative_path: Exact relative path to match
|
|
790
|
+
base_directory: Directory to filter by. Defaults to current_directory.
|
|
791
|
+
Pass None explicitly to search all directories.
|
|
792
|
+
|
|
793
|
+
Returns:
|
|
794
|
+
ModelWithLocation or None if not found
|
|
795
|
+
"""
|
|
796
|
+
if base_directory == "USE_CURRENT":
|
|
797
|
+
base_directory = self.current_directory
|
|
798
|
+
|
|
799
|
+
# Normalize to forward slashes for cross-platform consistency
|
|
800
|
+
normalized_path = relative_path.replace('\\', '/')
|
|
801
|
+
|
|
802
|
+
if base_directory:
|
|
803
|
+
base_dir_str = str(base_directory.resolve())
|
|
804
|
+
query = """
|
|
805
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
806
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
807
|
+
FROM models m
|
|
808
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
809
|
+
WHERE l.relative_path = ? AND l.base_directory = ?
|
|
810
|
+
LIMIT 1
|
|
811
|
+
"""
|
|
812
|
+
results = self.sqlite.execute_query(query, (normalized_path, base_dir_str))
|
|
813
|
+
else:
|
|
814
|
+
query = """
|
|
815
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
816
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
817
|
+
FROM models m
|
|
818
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
819
|
+
WHERE l.relative_path = ?
|
|
820
|
+
LIMIT 1
|
|
821
|
+
"""
|
|
822
|
+
results = self.sqlite.execute_query(query, (normalized_path,))
|
|
823
|
+
|
|
824
|
+
if not results:
|
|
825
|
+
return None
|
|
826
|
+
|
|
827
|
+
row = results[0]
|
|
828
|
+
metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
|
829
|
+
|
|
830
|
+
return ModelWithLocation(
|
|
831
|
+
hash=row['hash'],
|
|
832
|
+
file_size=row['file_size'],
|
|
833
|
+
blake3_hash=row['blake3_hash'],
|
|
834
|
+
sha256_hash=row['sha256_hash'],
|
|
835
|
+
relative_path=row['relative_path'],
|
|
836
|
+
filename=row['filename'],
|
|
837
|
+
mtime=row['mtime'],
|
|
838
|
+
last_seen=row['last_seen'],
|
|
839
|
+
base_directory=row.get('base_directory'),
|
|
840
|
+
metadata=metadata
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def search(self, term: str, base_directory: Path | None = "USE_CURRENT") -> list[ModelWithLocation]:
|
|
844
|
+
"""Search for models by filename or path, optionally filtered by directory.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
term: Search term to match against filename or path
|
|
848
|
+
base_directory: Directory to filter by. Defaults to current_directory.
|
|
849
|
+
Pass None explicitly to search all directories.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
List of matching ModelWithLocation objects
|
|
853
|
+
"""
|
|
854
|
+
if base_directory == "USE_CURRENT":
|
|
855
|
+
base_directory = self.current_directory
|
|
856
|
+
|
|
857
|
+
if base_directory:
|
|
858
|
+
base_dir_str = str(base_directory.resolve())
|
|
859
|
+
query = """
|
|
860
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
861
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
862
|
+
FROM models m
|
|
863
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
864
|
+
WHERE (l.filename LIKE ? OR l.relative_path LIKE ?)
|
|
865
|
+
AND l.base_directory = ?
|
|
866
|
+
ORDER BY l.filename
|
|
867
|
+
"""
|
|
868
|
+
search_pattern = f"%{term}%"
|
|
869
|
+
results = self.sqlite.execute_query(query, (search_pattern, search_pattern, base_dir_str))
|
|
870
|
+
else:
|
|
871
|
+
query = """
|
|
872
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
873
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
874
|
+
FROM models m
|
|
875
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
876
|
+
WHERE l.filename LIKE ? OR l.relative_path LIKE ?
|
|
877
|
+
ORDER BY l.filename
|
|
878
|
+
"""
|
|
879
|
+
search_pattern = f"%{term}%"
|
|
880
|
+
results = self.sqlite.execute_query(query, (search_pattern, search_pattern))
|
|
881
|
+
|
|
882
|
+
models = []
|
|
883
|
+
for row in results:
|
|
884
|
+
metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
|
885
|
+
model = ModelWithLocation(
|
|
886
|
+
hash=row['hash'],
|
|
887
|
+
file_size=row['file_size'],
|
|
888
|
+
blake3_hash=row['blake3_hash'],
|
|
889
|
+
sha256_hash=row['sha256_hash'],
|
|
890
|
+
relative_path=row['relative_path'],
|
|
891
|
+
filename=row['filename'],
|
|
892
|
+
mtime=row['mtime'],
|
|
893
|
+
last_seen=row['last_seen'],
|
|
894
|
+
base_directory=row.get('base_directory'),
|
|
895
|
+
metadata=metadata
|
|
896
|
+
)
|
|
897
|
+
models.append(model)
|
|
898
|
+
|
|
899
|
+
return models
|
|
900
|
+
|
|
901
|
+
def clear_orphaned_models(self) -> int:
|
|
902
|
+
"""Remove models that have no file locations.
|
|
903
|
+
|
|
904
|
+
When switching directories, models from the old directory may no longer
|
|
905
|
+
have any valid locations. This method cleans up such orphaned records.
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
Number of orphaned models removed
|
|
909
|
+
"""
|
|
910
|
+
query = """
|
|
911
|
+
DELETE FROM models
|
|
912
|
+
WHERE hash NOT IN (SELECT DISTINCT model_hash FROM model_locations)
|
|
913
|
+
"""
|
|
914
|
+
rows_affected = self.sqlite.execute_write(query)
|
|
915
|
+
|
|
916
|
+
if rows_affected > 0:
|
|
917
|
+
logger.info(f"Removed {rows_affected} orphaned models with no locations")
|
|
918
|
+
|
|
919
|
+
return rows_affected
|
|
920
|
+
|
|
921
|
+
def find_by_source_url(self, url: str) -> ModelWithLocation | None:
|
|
922
|
+
"""Find model by exact source URL match.
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
url: Source URL to search for
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
ModelWithLocation if found, None otherwise
|
|
929
|
+
"""
|
|
930
|
+
query = """
|
|
931
|
+
SELECT m.hash, m.file_size, m.blake3_hash, m.sha256_hash, m.metadata,
|
|
932
|
+
l.base_directory, l.relative_path, l.filename, l.mtime, l.last_seen
|
|
933
|
+
FROM models m
|
|
934
|
+
JOIN model_locations l ON m.hash = l.model_hash
|
|
935
|
+
JOIN model_sources s ON m.hash = s.model_hash
|
|
936
|
+
WHERE s.source_url = ?
|
|
937
|
+
LIMIT 1
|
|
938
|
+
"""
|
|
939
|
+
|
|
940
|
+
results = self.sqlite.execute_query(query, (url,))
|
|
941
|
+
if not results:
|
|
942
|
+
return None
|
|
943
|
+
|
|
944
|
+
row = results[0]
|
|
945
|
+
metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
|
946
|
+
|
|
947
|
+
return ModelWithLocation(
|
|
948
|
+
hash=row['hash'],
|
|
949
|
+
file_size=row['file_size'],
|
|
950
|
+
blake3_hash=row['blake3_hash'],
|
|
951
|
+
sha256_hash=row['sha256_hash'],
|
|
952
|
+
relative_path=row['relative_path'],
|
|
953
|
+
filename=row['filename'],
|
|
954
|
+
mtime=row['mtime'],
|
|
955
|
+
last_seen=row['last_seen'],
|
|
956
|
+
base_directory=row.get('base_directory'),
|
|
957
|
+
metadata=metadata
|
|
958
|
+
)
|