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.
Files changed (93) hide show
  1. comfygit_core/analyzers/custom_node_scanner.py +109 -0
  2. comfygit_core/analyzers/git_change_parser.py +156 -0
  3. comfygit_core/analyzers/model_scanner.py +318 -0
  4. comfygit_core/analyzers/node_classifier.py +58 -0
  5. comfygit_core/analyzers/node_git_analyzer.py +77 -0
  6. comfygit_core/analyzers/status_scanner.py +362 -0
  7. comfygit_core/analyzers/workflow_dependency_parser.py +143 -0
  8. comfygit_core/caching/__init__.py +16 -0
  9. comfygit_core/caching/api_cache.py +210 -0
  10. comfygit_core/caching/base.py +212 -0
  11. comfygit_core/caching/comfyui_cache.py +100 -0
  12. comfygit_core/caching/custom_node_cache.py +320 -0
  13. comfygit_core/caching/workflow_cache.py +797 -0
  14. comfygit_core/clients/__init__.py +4 -0
  15. comfygit_core/clients/civitai_client.py +412 -0
  16. comfygit_core/clients/github_client.py +349 -0
  17. comfygit_core/clients/registry_client.py +230 -0
  18. comfygit_core/configs/comfyui_builtin_nodes.py +1614 -0
  19. comfygit_core/configs/comfyui_models.py +62 -0
  20. comfygit_core/configs/model_config.py +151 -0
  21. comfygit_core/constants.py +82 -0
  22. comfygit_core/core/environment.py +1635 -0
  23. comfygit_core/core/workspace.py +898 -0
  24. comfygit_core/factories/environment_factory.py +419 -0
  25. comfygit_core/factories/uv_factory.py +61 -0
  26. comfygit_core/factories/workspace_factory.py +109 -0
  27. comfygit_core/infrastructure/sqlite_manager.py +156 -0
  28. comfygit_core/integrations/__init__.py +7 -0
  29. comfygit_core/integrations/uv_command.py +318 -0
  30. comfygit_core/logging/logging_config.py +15 -0
  31. comfygit_core/managers/environment_git_orchestrator.py +316 -0
  32. comfygit_core/managers/environment_model_manager.py +296 -0
  33. comfygit_core/managers/export_import_manager.py +116 -0
  34. comfygit_core/managers/git_manager.py +667 -0
  35. comfygit_core/managers/model_download_manager.py +252 -0
  36. comfygit_core/managers/model_symlink_manager.py +166 -0
  37. comfygit_core/managers/node_manager.py +1378 -0
  38. comfygit_core/managers/pyproject_manager.py +1321 -0
  39. comfygit_core/managers/user_content_symlink_manager.py +436 -0
  40. comfygit_core/managers/uv_project_manager.py +569 -0
  41. comfygit_core/managers/workflow_manager.py +1944 -0
  42. comfygit_core/models/civitai.py +432 -0
  43. comfygit_core/models/commit.py +18 -0
  44. comfygit_core/models/environment.py +293 -0
  45. comfygit_core/models/exceptions.py +378 -0
  46. comfygit_core/models/manifest.py +132 -0
  47. comfygit_core/models/node_mapping.py +201 -0
  48. comfygit_core/models/protocols.py +248 -0
  49. comfygit_core/models/registry.py +63 -0
  50. comfygit_core/models/shared.py +356 -0
  51. comfygit_core/models/sync.py +42 -0
  52. comfygit_core/models/system.py +204 -0
  53. comfygit_core/models/workflow.py +914 -0
  54. comfygit_core/models/workspace_config.py +71 -0
  55. comfygit_core/py.typed +0 -0
  56. comfygit_core/repositories/migrate_paths.py +49 -0
  57. comfygit_core/repositories/model_repository.py +958 -0
  58. comfygit_core/repositories/node_mappings_repository.py +246 -0
  59. comfygit_core/repositories/workflow_repository.py +57 -0
  60. comfygit_core/repositories/workspace_config_repository.py +121 -0
  61. comfygit_core/resolvers/global_node_resolver.py +459 -0
  62. comfygit_core/resolvers/model_resolver.py +250 -0
  63. comfygit_core/services/import_analyzer.py +218 -0
  64. comfygit_core/services/model_downloader.py +422 -0
  65. comfygit_core/services/node_lookup_service.py +251 -0
  66. comfygit_core/services/registry_data_manager.py +161 -0
  67. comfygit_core/strategies/__init__.py +4 -0
  68. comfygit_core/strategies/auto.py +72 -0
  69. comfygit_core/strategies/confirmation.py +69 -0
  70. comfygit_core/utils/comfyui_ops.py +125 -0
  71. comfygit_core/utils/common.py +164 -0
  72. comfygit_core/utils/conflict_parser.py +232 -0
  73. comfygit_core/utils/dependency_parser.py +231 -0
  74. comfygit_core/utils/download.py +216 -0
  75. comfygit_core/utils/environment_cleanup.py +111 -0
  76. comfygit_core/utils/filesystem.py +178 -0
  77. comfygit_core/utils/git.py +1184 -0
  78. comfygit_core/utils/input_signature.py +145 -0
  79. comfygit_core/utils/model_categories.py +52 -0
  80. comfygit_core/utils/pytorch.py +71 -0
  81. comfygit_core/utils/requirements.py +211 -0
  82. comfygit_core/utils/retry.py +242 -0
  83. comfygit_core/utils/symlink_utils.py +119 -0
  84. comfygit_core/utils/system_detector.py +258 -0
  85. comfygit_core/utils/uuid.py +28 -0
  86. comfygit_core/utils/uv_error_handler.py +158 -0
  87. comfygit_core/utils/version.py +73 -0
  88. comfygit_core/utils/workflow_hash.py +90 -0
  89. comfygit_core/validation/resolution_tester.py +297 -0
  90. comfygit_core-0.2.0.dist-info/METADATA +939 -0
  91. comfygit_core-0.2.0.dist-info/RECORD +93 -0
  92. comfygit_core-0.2.0.dist-info/WHEEL +4 -0
  93. 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
+ )